diff --git a/cel/prompt.go b/cel/prompt.go index 1529680f..ca25e7ad 100644 --- a/cel/prompt.go +++ b/cel/prompt.go @@ -1,226 +1,233 @@ -// Copyright 2025 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// https://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package cel - -import ( - _ "embed" - "sort" - "strings" - "text/template" - - "github.com/google/cel-go/common" - "github.com/google/cel-go/common/operators" - "github.com/google/cel-go/common/overloads" - "github.com/google/cel-go/common/types" -) - -//go:embed templates/authoring.tmpl -var authoringPrompt string - -// splitImpl splits a string into a list of strings. -// -// Normalizes extracted comments (trim common prefix whitespace and extra trailing newlines). -func splitImpl(str string) []string { - str = strings.TrimRight(str, " \n\t\r") - out := strings.Split(str, "\n") - if len(out) == 0 { - return nil - } - negative := strings.TrimLeft(out[0], " \t") - lenNegative := len(negative) - lenOut := len(out[0]) - if lenNegative == lenOut { - return out - } - prefix := out[0][:lenOut-lenNegative] - trimmed := make([]string, len(out)) - for i, line := range out { - if line == "" { - trimmed[i] = "" - continue - } - if !strings.HasPrefix(line, prefix) { - return out - } - trimmed[i] = strings.TrimPrefix(line, prefix) - } - - return trimmed -} - -// AuthoringPrompt creates a prompt template from a CEL environment for the purpose of AI-assisted authoring. -func AuthoringPrompt(env *Env) (*Prompt, error) { - funcMap := template.FuncMap{ - "split": splitImpl, - "newlineToSpace": func(str string) string { return strings.ReplaceAll(str, "\n", " ") }, - } - tmpl := template.New("cel").Funcs(funcMap) - tmpl, err := tmpl.Parse(authoringPrompt) - if err != nil { - return nil, err - } - return &Prompt{ - Persona: defaultPersona, - FormatRules: defaultFormatRules, - GeneralUsage: defaultGeneralUsage, - tmpl: tmpl, - env: env, - }, nil -} - -// AuthoringPromptWithFieldPaths creates a prompt template from a CEL environment for the purpose of AI-assisted authoring. -// Includes documentation for all of the reachable field paths in the environment. -func AuthoringPromptWithFieldPaths(env *Env) (*Prompt, error) { - p, err := AuthoringPrompt(env) - if err != nil { - return nil, err - } - p.fieldPaths = true - return p, nil -} - -// Prompt represents the core components of an LLM prompt based on a CEL environment. -// -// All fields of the prompt may be overwritten / modified with support for rendering the -// prompt to a human-readable string. -type Prompt struct { - // Persona indicates something about the kind of user making the request - Persona string - - // FormatRules indicate how the LLM should generate its output - FormatRules string - - // GeneralUsage specifies additional context on how CEL should be used. - GeneralUsage string - - // tmpl is the text template base-configuration for rendering text. - tmpl *template.Template - - // fieldPaths is a flag to enable including reachable field paths in the prompt. - fieldPaths bool - - // env reference used to collect variables, functions, and macros available to the prompt. - env *Env -} - -type promptVariable struct { - *common.Doc - FieldPaths []*common.Doc -} - -type promptInst struct { - *Prompt - - Variables []*promptVariable - Macros []*common.Doc - Functions []*common.Doc - UserPrompt string -} - -// Render renders the user prompt with the associated context from the prompt template -// for use with LLM generators. -func (p *Prompt) Render(userPrompt string) string { - var buffer strings.Builder - vars := make([]*promptVariable, len(p.env.Variables())) - for i, v := range p.env.Variables() { - vars[i] = &promptVariable{Doc: v.Documentation()} - if p.fieldPaths && v.Type().Kind() == types.StructKind { - var fieldPaths []*common.Doc - - paths := fieldPathsForType(p.env.CELTypeProvider(), v.Name(), v.Type()) - if len(paths) < 2 { - paths = nil - } else { - // First path is the variable which is already documented. - paths = paths[1:] - } - for _, path := range paths { - fieldPaths = append(fieldPaths, path.Documentation()) - } - - sort.SliceStable(fieldPaths, func(i, j int) bool { - return fieldPaths[i].Name < fieldPaths[j].Name - }) - vars[i].FieldPaths = fieldPaths - } - } - sort.SliceStable(vars, func(i, j int) bool { - return vars[i].Name < vars[j].Name - }) - macs := make([]*common.Doc, len(p.env.Macros())) - for i, m := range p.env.Macros() { - macs[i] = m.(common.Documentor).Documentation() - } - funcs := make([]*common.Doc, 0, len(p.env.Functions())) - for _, f := range p.env.Functions() { - if _, hidden := hiddenFunctions[f.Name()]; hidden { - continue - } - funcs = append(funcs, f.Documentation()) - } - sort.SliceStable(funcs, func(i, j int) bool { - return funcs[i].Name < funcs[j].Name - }) - inst := &promptInst{ - Prompt: p, - Variables: vars, - Macros: macs, - Functions: funcs, - UserPrompt: userPrompt} - p.tmpl.Execute(&buffer, inst) - return buffer.String() -} - -const ( - defaultPersona = `You are a software engineer with expertise in networking and application security -authoring boolean Common Expression Language (CEL) expressions to ensure firewall, -networking, authentication, and data access is only permitted when all conditions -are satisfied.` - - defaultFormatRules = `Output your response as a CEL expression. - -Write the expression with the comment on the first line and the expression on the -subsequent lines. Format the expression using 80-character line limits commonly -found in C++ or Java code.` - - defaultGeneralUsage = `CEL supports Protocol Buffer and JSON types, as well as simple types and aggregate types. - -Simple types include bool, bytes, double, int, string, and uint: - -* double literals must always include a decimal point: 1.0, 3.5, -2.2 -* uint literals must be positive values suffixed with a 'u': 42u -* byte literals are strings prefixed with a 'b': b'1235' -* string literals can use either single quotes or double quotes: 'hello', "world" -* string literals can also be treated as raw strings that do not require any - escaping within the string by using the 'R' prefix: R"""quote: "hi" """ - -Aggregate types include list and map: - -* list literals consist of zero or more values between brackets: "['a', 'b', 'c']" -* map literal consist of colon-separated key-value pairs within braces: "{'key1': 1, 'key2': 2}" -* Only int, uint, string, and bool types are valid map keys. -* Maps containing HTTP headers must always use lower-cased string keys. - -Comments start with two-forward slashes followed by text and a newline.` -) - -var ( - hiddenFunctions = map[string]bool{ - overloads.DeprecatedIn: true, - operators.OldIn: true, - operators.OldNotStrictlyFalse: true, - operators.NotStrictlyFalse: true, - } -) +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cel + +import ( + _ "embed" + "sort" + "strings" + "text/template" + + "github.com/google/cel-go/common" + "github.com/google/cel-go/common/operators" + "github.com/google/cel-go/common/overloads" + "github.com/google/cel-go/common/types" +) + +//go:embed templates/authoring.tmpl +var authoringPrompt string + +// splitImpl splits a string into a list of strings. +// +// Normalizes extracted comments (trim common prefix whitespace and extra trailing newlines). +func splitImpl(str string) []string { + str = strings.TrimRight(str, " \n\t\r") + out := strings.Split(str, "\n") + if len(out) == 0 { + return nil + } + negative := strings.TrimLeft(out[0], " \t") + lenNegative := len(negative) + lenOut := len(out[0]) + if lenNegative == lenOut { + return out + } + prefix := out[0][:lenOut-lenNegative] + trimmed := make([]string, len(out)) + for i, line := range out { + if line == "" { + trimmed[i] = "" + continue + } + if !strings.HasPrefix(line, prefix) { + return out + } + trimmed[i] = strings.TrimPrefix(line, prefix) + } + + return trimmed +} + +// AuthoringPrompt creates a prompt template from a CEL environment for the purpose of AI-assisted authoring. +func AuthoringPrompt(env *Env) (*Prompt, error) { + funcMap := template.FuncMap{ + "split": splitImpl, + "newlineToSpace": func(str string) string { return strings.ReplaceAll(str, "\n", " ") }, + } + tmpl := template.New("cel").Funcs(funcMap) + tmpl, err := tmpl.Parse(authoringPrompt) + if err != nil { + return nil, err + } + return &Prompt{ + Persona: defaultPersona, + FormatRules: defaultFormatRules, + GeneralUsage: defaultGeneralUsage, + tmpl: tmpl, + env: env, + }, nil +} + +// AuthoringPromptWithFieldPaths creates a prompt template from a CEL environment for the purpose of AI-assisted authoring. +// Includes documentation for all of the reachable field paths in the environment. +func AuthoringPromptWithFieldPaths(env *Env) (*Prompt, error) { + p, err := AuthoringPrompt(env) + if err != nil { + return nil, err + } + p.fieldPaths = true + return p, nil +} + +// Prompt represents the core components of an LLM prompt based on a CEL environment. +// +// All fields of the prompt may be overwritten / modified with support for rendering the +// prompt to a human-readable string. +type Prompt struct { + // Persona indicates something about the kind of user making the request + Persona string + + // FormatRules indicate how the LLM should generate its output + FormatRules string + + // GeneralUsage specifies additional context on how CEL should be used. + GeneralUsage string + + // tmpl is the text template base-configuration for rendering text. + tmpl *template.Template + + // fieldPaths is a flag to include reachable field paths in the prompt. + fieldPaths bool + + // env reference used to collect variables, functions, and macros available to the prompt. + env *Env +} + +type promptVariable struct { + *common.Doc + FieldPaths []*common.Doc +} + +type promptInst struct { + *Prompt + + Variables []*promptVariable + Macros []*common.Doc + Functions []*common.Doc + UserPrompt string +} + +// Render renders the user prompt with the associated context from the prompt template +// for use with LLM generators. +// +// User-supplied input is passed as template data via the UserPrompt field, which +// Go's text/template renders as a literal string value. Template action delimiters +// such as {{.Persona}} in the user prompt are never evaluated as template directives +// because text/template only executes directives present in the template definition +// itself, not in data values interpolated at render time. +func (p *Prompt) Render(userPrompt string) string { + var buffer strings.Builder + vars := make([]*promptVariable, len(p.env.Variables())) + for i, v := range p.env.Variables() { + vars[i] = &promptVariable{Doc: v.Documentation()} + if p.fieldPaths && v.Type().Kind() == types.StructKind { + var fieldPaths []*common.Doc + + paths := fieldPathsForType(p.env.CELTypeProvider(), v.Name(), v.Type()) + if len(paths) < 2 { + paths = nil + } else { + // First path is the variable which is already documented. + paths = paths[1:] + } + for _, path := range paths { + fieldPaths = append(fieldPaths, path.Documentation()) + } + + sort.SliceStable(fieldPaths, func(i, j int) bool { + return fieldPaths[i].Name < fieldPaths[j].Name + }) + vars[i].FieldPaths = fieldPaths + } + } + sort.SliceStable(vars, func(i, j int) bool { + return vars[i].Name < vars[j].Name + }) + macs := make([]*common.Doc, len(p.env.Macros())) + for i, m := range p.env.Macros() { + macs[i] = m.(common.Documentor).Documentation() + } + funcs := make([]*common.Doc, 0, len(p.env.Functions())) + for _, f := range p.env.Functions() { + if _, hidden := hiddenFunctions[f.Name()]; hidden { + continue + } + funcs = append(funcs, f.Documentation()) + } + sort.SliceStable(funcs, func(i, j int) bool { + return funcs[i].Name < funcs[j].Name + }) + inst := &promptInst{ + Prompt: p, + Variables: vars, + Macros: macs, + Functions: funcs, + UserPrompt: userPrompt, + } + p.tmpl.Execute(&buffer, inst) + return buffer.String() +} + +const ( + defaultPersona = `You are a software engineer with expertise in networking and application security +authoring boolean Common Expression Language (CEL) expressions to ensure firewall, +networking, authentication, and data access is only permitted when all conditions +are satisfied.` + + defaultFormatRules = `Output your response as a CEL expression. + +Write the expression with the comment on the first line and the expression on the +subsequent lines. Format the expression using 80-character line limits commonly +found in C++ or Java code.` + + defaultGeneralUsage = `CEL supports Protocol Buffer and JSON types, as well as simple types and aggregate types. + +Simple types include bool, bytes, double, int, string, and uint: + +* double literals must always include a decimal point: 1.0, 3.5, -2.2 +* uint literals must be positive values suffixed with a 'u': 42u +* byte literals are strings prefixed with a 'b': b'1235' +* string literals can use either single quotes or double quotes: 'hello', "world" +* string literals can also be treated as raw strings that do not require any + escaping within the string by using the 'R' prefix: R"""quote: "hi" """ + +Aggregate types include list and map: + +* list literals consist of zero or more values between brackets: "['a', 'b', 'c']" +* map literal consist of colon-separated key-value pairs within braces: "{'key1': 1, 'key2': 2}" +* Only int, uint, string, and bool types are valid map keys. +* Maps containing HTTP headers must always use lower-cased string keys. + +Comments start with two-forward slashes followed by text and a newline.` +) + +var ( + hiddenFunctions = map[string]bool{ + overloads.DeprecatedIn: true, + operators.OldIn: true, + operators.OldNotStrictlyFalse: true, + operators.NotStrictlyFalse: true, + } +) \ No newline at end of file diff --git a/cel/prompt_test.go b/cel/prompt_test.go index 34607d0b..1e9290ea 100644 --- a/cel/prompt_test.go +++ b/cel/prompt_test.go @@ -1,142 +1,211 @@ -// Copyright 2025 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// https://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package cel - -import ( - _ "embed" - "sync" - "testing" - - "github.com/google/cel-go/common" - "github.com/google/cel-go/common/env" - "github.com/google/go-cmp/cmp" - - "google.golang.org/protobuf/proto" - dpb "google.golang.org/protobuf/types/descriptorpb" -) - -//go:embed testdata/basic.prompt.txt -var wantBasicPrompt string - -//go:embed testdata/macros.prompt.txt -var wantMacrosPrompt string - -//go:embed testdata/standard_env.prompt.txt -var wantStandardEnvPrompt string - -//go:embed testdata/field_paths.prompt.txt -var wantFieldPathsPrompt string - -//go:embed testdata/test_fds_with_source_info-transitive-descriptor-set-source-info.proto.bin -var testFdsWithSourceInfo []byte - -var onceFds sync.Once -var fds *dpb.FileDescriptorSet - -func testFds(t *testing.T) *dpb.FileDescriptorSet { - onceFds.Do(func() { - fds = &dpb.FileDescriptorSet{} - err := proto.Unmarshal(testFdsWithSourceInfo, fds) - if err != nil { - t.Fatalf("failed to unmarshal testFdsWithSourceInfo: %v", err) - } - }) - return fds -} - -func TestPromptTemplate(t *testing.T) { - tests := []struct { - name string - envOpts []EnvOption - out string - }{ - { - name: "basic", - out: wantBasicPrompt, - }, - { - name: "macros", - envOpts: []EnvOption{Macros(StandardMacros...)}, - out: wantMacrosPrompt, - }, - { - name: "standard_env", - envOpts: []EnvOption{StdLib(StdLibSubset(env.NewLibrarySubset().SetDisableMacros(true)))}, - out: wantStandardEnvPrompt, - }, - } - - for _, tst := range tests { - tc := tst - t.Run(tc.name, func(t *testing.T) { - envOpts := append([]EnvOption{TypeDescs(testFds(t))}, tc.envOpts...) - - env, err := NewCustomEnv( - envOpts..., - ) - if err != nil { - t.Fatalf("cel.NewCustomEnv() failed: %v", err) - } - prompt, err := AuthoringPrompt(env) - if err != nil { - t.Fatalf("cel.AuthoringPrompt() failed: %v", err) - } - out := prompt.Render("") - if diff := cmp.Diff(tc.out, out); diff != "" { - t.Errorf("got %s, diff (-want +got): %s", out, diff) - } - }) - } -} - -func TestPromptTemplateFieldPaths(t *testing.T) { - tests := []struct { - name string - envOpts []EnvOption - out string - }{ - { - name: "standard_env", - envOpts: []EnvOption{ - VariableWithDoc("team", ObjectType("cel.testdata.Team"), - common.MultilineDescription("A team of gifted youngsters")), - StdLib(StdLibSubset(env.NewLibrarySubset().SetDisableMacros(true))), - }, - out: wantFieldPathsPrompt, - }, - } - - for _, tst := range tests { - tc := tst - t.Run(tc.name, func(t *testing.T) { - envOpts := append([]EnvOption{TypeDescs(testFds(t))}, tc.envOpts...) - - env, err := NewCustomEnv( - envOpts..., - ) - if err != nil { - t.Fatalf("cel.NewCustomEnv() failed: %v", err) - } - prompt, err := AuthoringPromptWithFieldPaths(env) - if err != nil { - t.Fatalf("cel.AuthoringPromptWithFieldPaths() failed: %v", err) - } - out := prompt.Render("") - if diff := cmp.Diff(tc.out, out); diff != "" { - t.Errorf("got %s, diff (-want +got): %s", out, diff) - } - }) - } -} +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cel + +import ( + _ "embed" + "strings" + "sync" + "testing" + + "github.com/google/cel-go/common" + "github.com/google/cel-go/common/env" + "github.com/google/go-cmp/cmp" + + "google.golang.org/protobuf/proto" + dpb "google.golang.org/protobuf/types/descriptorpb" +) + +//go:embed testdata/basic.prompt.txt +var wantBasicPrompt string + +//go:embed testdata/macros.prompt.txt +var wantMacrosPrompt string + +//go:embed testdata/standard_env.prompt.txt +var wantStandardEnvPrompt string + +//go:embed testdata/field_paths.prompt.txt +var wantFieldPathsPrompt string + +//go:embed testdata/test_fds_with_source_info-transitive-descriptor-set-source-info.proto.bin +var testFdsWithSourceInfo []byte + +var onceFds sync.Once +var fds *dpb.FileDescriptorSet + +func testFds(t *testing.T) *dpb.FileDescriptorSet { + onceFds.Do(func() { + fds = &dpb.FileDescriptorSet{} + err := proto.Unmarshal(testFdsWithSourceInfo, fds) + if err != nil { + t.Fatalf("failed to unmarshal testFdsWithSourceInfo: %v", err) + } + }) + return fds +} + +func TestPromptTemplate(t *testing.T) { + tests := []struct { + name string + envOpts []EnvOption + out string + }{ + { + name: "basic", + out: wantBasicPrompt, + }, + { + name: "macros", + envOpts: []EnvOption{Macros(StandardMacros...)}, + out: wantMacrosPrompt, + }, + { + name: "standard_env", + envOpts: []EnvOption{StdLib(StdLibSubset(env.NewLibrarySubset().SetDisableMacros(true)))}, + out: wantStandardEnvPrompt, + }, + } + + for _, tst := range tests { + tc := tst + t.Run(tc.name, func(t *testing.T) { + envOpts := append([]EnvOption{TypeDescs(testFds(t))}, tc.envOpts...) + + env, err := NewCustomEnv( + envOpts..., + ) + if err != nil { + t.Fatalf("cel.NewCustomEnv() failed: %v", err) + } + prompt, err := AuthoringPrompt(env) + if err != nil { + t.Fatalf("cel.AuthoringPrompt() failed: %v", err) + } + out := prompt.Render("") + if diff := cmp.Diff(tc.out, out); diff != "" { + t.Errorf("got %s, diff (-want +got): %s", out, diff) + } + }) + } +} + +func TestPromptTemplateFieldPaths(t *testing.T) { + tests := []struct { + name string + envOpts []EnvOption + out string + }{ + { + name: "standard_env", + envOpts: []EnvOption{ + VariableWithDoc("team", ObjectType("cel.testdata.Team"), + common.MultilineDescription("A team of gifted youngsters")), + StdLib(StdLibSubset(env.NewLibrarySubset().SetDisableMacros(true))), + }, + out: wantFieldPathsPrompt, + }, + } + + for _, tst := range tests { + tc := tst + t.Run(tc.name, func(t *testing.T) { + envOpts := append([]EnvOption{TypeDescs(testFds(t))}, tc.envOpts...) + + env, err := NewCustomEnv( + envOpts..., + ) + if err != nil { + t.Fatalf("cel.NewCustomEnv() failed: %v", err) + } + prompt, err := AuthoringPromptWithFieldPaths(env) + if err != nil { + t.Fatalf("cel.AuthoringPromptWithFieldPaths() failed: %v", err) + } + out := prompt.Render("") + if diff := cmp.Diff(tc.out, out); diff != "" { + t.Errorf("got %s, diff (-want +got): %s", out, diff) + } + }) + } +} + +// TestRenderSanitizesTemplateDirectives verifies that user-supplied input containing +// Go text/template action delimiters is rendered as literal text and never executed +// as template directives. +// +// Go's text/template renders struct field values (such as .UserPrompt) as literal +// strings — it does not re-parse or re-evaluate them as template syntax. This means +// a caller passing "{{.Persona}}" as the user prompt will see that exact string in +// the output, not the expanded persona content. +func TestRenderSanitizesTemplateDirectives(t *testing.T) { + env, err := NewEnv() + if err != nil { + t.Fatalf("cel.NewEnv() failed: %v", err) + } + prompt, err := AuthoringPrompt(env) + if err != nil { + t.Fatalf("cel.AuthoringPrompt() failed: %v", err) + } + + tests := []struct { + name string + input string + wantLiteral string + wantAbsent string + }{ + { + name: "template_field_not_executed", + input: "{{.Persona}} should be literal", + wantLiteral: "{{.Persona}} should be literal", + }, + { + name: "closing_delimiters_escaped", + input: "hello }} world", + wantLiteral: "hello }} world", + }, + { + name: "format_rules_field_not_executed", + input: "{{.FormatRules}} injected", + wantLiteral: "{{.FormatRules}} injected", + }, + { + name: "persona_not_duplicated_via_injection", + input: "{{.Persona}}", + wantLiteral: "{{.Persona}}", + // Persona text appears once legitimately at the top of the rendered output. + // A second occurrence would mean the injected directive executed and dumped + // the persona content again into the user prompt section. + wantAbsent: "software engineer\nauthoring boolean", + }, + } + + for _, tst := range tests { + tc := tst + t.Run(tc.name, func(t *testing.T) { + out := prompt.Render(tc.input) + if !strings.Contains(out, tc.wantLiteral) { + t.Errorf("Render(%q):\n want literal substring: %q\n got output:\n%s", + tc.input, tc.wantLiteral, out) + } + if tc.wantAbsent != "" { + if strings.Count(out, tc.wantAbsent) > 1 { + t.Errorf("Render(%q): template injection detected — %q appears multiple times in output:\n%s", + tc.input, tc.wantAbsent, out) + } + } + }) + } +} \ No newline at end of file