diff --git a/examples/README.md b/examples/README.md index e41273ec4..0f5be1618 100644 --- a/examples/README.md +++ b/examples/README.md @@ -15,6 +15,7 @@ Some of these agents use [built-in tools](../docs/index.html#configuration/tools | [contradict.yaml](contradict.yaml) | Contrarian viewpoint provider | | | | | | | | | [silvia.yaml](silvia.yaml) | Sylvia Plath-inspired poetic AI | | | | | | | | | [script_shell.yaml](script_shell.yaml) | Agent with custom shell commands | | ✓ | | | | | | +| [instructions_from_file.hcl](instructions_from_file.hcl) | HCL agent that loads instructions from a file | | | | | | | | | [mem.yaml](mem.yaml) | Humorous AI with persistent memory | ✓ | | | | ✓ | | | | [diag.yaml](diag.yaml) | Log analysis and diagnostics | ✓ | ✓ | | ✓ | | | | | [todo.yaml](todo.yaml) | Task manager example | | | ✓ | | | | | diff --git a/examples/instructions_from_file.hcl b/examples/instructions_from_file.hcl new file mode 100644 index 000000000..4cc051eef --- /dev/null +++ b/examples/instructions_from_file.hcl @@ -0,0 +1,12 @@ +#!/usr/bin/env docker agent run + +agent "root" { + description = "Agent that loads its system prompt from a separate file" + model = "auto" + + # The file() helper reads a text file and injects its contents here. + # Relative paths are resolved from this HCL file's directory. + instruction = file("instructions_from_file.md") + + welcome_message = "Hi! My instructions were loaded from instructions_from_file.md" +} diff --git a/examples/instructions_from_file.md b/examples/instructions_from_file.md new file mode 100644 index 000000000..c096e3810 --- /dev/null +++ b/examples/instructions_from_file.md @@ -0,0 +1,7 @@ +You are a helpful assistant. + +When you answer: +- be concise +- prefer bullet points when listing steps +- ask for clarification only if necessary +- mention when you are making an assumption diff --git a/pkg/config/hcl/hcl.go b/pkg/config/hcl/hcl.go index 41d4209c4..87025ecd3 100644 --- a/pkg/config/hcl/hcl.go +++ b/pkg/config/hcl/hcl.go @@ -11,6 +11,9 @@ // - Multi-line strings should use heredocs. Because HCL templates expand // `${...}` interpolation, any literal `${...}` (such as // `${shell({cmd: "..."})}`) must be escaped as `$${...}`. +// - The custom `file("path")` function reads a UTF-8 text file and returns +// its contents as a string. Relative paths are resolved from the HCL +// config file's directory. // // The converter does not validate the resulting document against the // configuration schema; that is left to the existing YAML/JSON loader. @@ -19,6 +22,7 @@ package hcl import ( "fmt" "math/big" + "path/filepath" "strings" "github.com/goccy/go-yaml" @@ -26,6 +30,7 @@ import ( "github.com/hashicorp/hcl/v2/hclparse" "github.com/hashicorp/hcl/v2/hclsyntax" "github.com/zclconf/go-cty/cty" + "github.com/zclconf/go-cty/cty/function" ) // LooksLikeHCL reports whether the given bytes look like an HCL document @@ -88,7 +93,7 @@ func ToMap(data []byte, filename string) (map[string]any, error) { if !ok { return nil, fmt.Errorf("HCL file %s is not native syntax", filename) } - out, diags := convertBody(body) + out, diags := convertBody(body, newEvalContext(baseDir(filename))) if diags.HasErrors() { return nil, fmt.Errorf("converting HCL %s: %s", filename, diags.Error()) } @@ -207,12 +212,12 @@ func LabelKeyedMapOutKeys() map[string]bool { // // HCL's parser already rejects duplicate attribute names within a body, so // we don't guard against them here. -func convertBody(body *hclsyntax.Body) (map[string]any, hcl.Diagnostics) { +func convertBody(body *hclsyntax.Body, evalCtx *hcl.EvalContext) (map[string]any, hcl.Diagnostics) { var diags hcl.Diagnostics out := map[string]any{} for name, attr := range body.Attributes { - val, attrDiags := convertExpr(attr.Expr) + val, attrDiags := convertExpr(attr.Expr, evalCtx) diags = append(diags, attrDiags...) if !attrDiags.HasErrors() { out[name] = val @@ -220,7 +225,7 @@ func convertBody(body *hclsyntax.Body) (map[string]any, hcl.Diagnostics) { } for _, block := range body.Blocks { - diags = append(diags, mergeBlock(out, block)...) + diags = append(diags, mergeBlock(out, block, evalCtx)...) } return out, diags @@ -230,14 +235,14 @@ func convertBody(body *hclsyntax.Body) (map[string]any, hcl.Diagnostics) { // according to the block's rule. It validates label count and detects // per-rule duplicates (e.g. two singleton blocks of the same name, or two // labeled blocks with the same label). -func mergeBlock(out map[string]any, block *hclsyntax.Block) hcl.Diagnostics { +func mergeBlock(out map[string]any, block *hclsyntax.Block, evalCtx *hcl.EvalContext) hcl.Diagnostics { rule := lookupRule(block.Type, len(block.Labels)) if d := checkLabels(block, rule.expectedLabels()); d != nil { return d } - body, diags := convertBody(block.Body) + body, diags := convertBody(block.Body, evalCtx) if diags.HasErrors() { return diags } @@ -297,14 +302,29 @@ func errf(subj *hcl.Range, summary, format string, args ...any) hcl.Diagnostics }} } -func convertExpr(expr hclsyntax.Expression) (any, hcl.Diagnostics) { - val, diags := expr.Value(&hcl.EvalContext{}) +func convertExpr(expr hclsyntax.Expression, evalCtx *hcl.EvalContext) (any, hcl.Diagnostics) { + val, diags := expr.Value(evalCtx) if diags.HasErrors() { return nil, diags } return ctyToGo(val), nil } +func baseDir(filename string) string { + if filename == "" { + return "" + } + return filepath.Dir(filename) +} + +func newEvalContext(baseDir string) *hcl.EvalContext { + return &hcl.EvalContext{ + Functions: map[string]function.Function{ + "file": fileFunction(baseDir), + }, + } +} + // ctyToGo recursively converts a cty.Value into the Go primitives used by // the YAML marshaller (string, int64, float64, bool, []any, map[string]any). func ctyToGo(val cty.Value) any { diff --git a/pkg/config/hcl/hcl_file.go b/pkg/config/hcl/hcl_file.go new file mode 100644 index 000000000..5f902b5c6 --- /dev/null +++ b/pkg/config/hcl/hcl_file.go @@ -0,0 +1,49 @@ +package hcl + +import ( + "errors" + "fmt" + "os" + "path/filepath" + + "github.com/zclconf/go-cty/cty" + "github.com/zclconf/go-cty/cty/function" +) + +func fileFunction(baseDir string) function.Function { + return function.New(&function.Spec{ + Params: []function.Parameter{{ + Name: "path", + Type: cty.String, + }}, + Type: function.StaticReturnType(cty.String), + Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { + path := args[0].AsString() + + data, readPath, err := readFileForHCL(path, baseDir) + if err != nil { + return cty.NilVal, fmt.Errorf("reading file %q: %w", readPath, err) + } + return cty.StringVal(string(data)), nil + }, + }) +} + +func readFileForHCL(path, baseDir string) ([]byte, string, error) { + if baseDir == "" { + data, err := os.ReadFile(path) + return data, path, err + } + if !filepath.IsLocal(path) { + return nil, path, errors.New("path must be a local relative path inside the config directory") + } + + root, err := os.OpenRoot(baseDir) + if err != nil { + return nil, baseDir, fmt.Errorf("opening config directory %q: %w", baseDir, err) + } + defer root.Close() + + data, err := root.ReadFile(filepath.ToSlash(path)) + return data, filepath.Join(baseDir, path), err +} diff --git a/pkg/config/hcl/hcl_file_test.go b/pkg/config/hcl/hcl_file_test.go new file mode 100644 index 000000000..0e75d2843 --- /dev/null +++ b/pkg/config/hcl/hcl_file_test.go @@ -0,0 +1,101 @@ +package hcl + +import ( + "os" + "path/filepath" + "testing" + + "github.com/goccy/go-yaml" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestToYAML_FileFunction(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + instructionsPath := filepath.Join(dir, "instructions.txt") + require.NoError(t, os.WriteFile(instructionsPath, []byte("Line 1\nLine 2\n"), 0o644)) + + src := []byte(` +agent "root" { + instruction = file("instructions.txt") + model = "auto" +} +`) + + m, err := ToMap(src, filepath.Join(dir, "agent.hcl")) + require.NoError(t, err) + + items := m["agents"].(yaml.MapSlice) + root := items[0].Value.(map[string]any) + assert.Equal(t, "Line 1\nLine 2\n", root["instruction"]) +} + +func TestToYAML_FileFunctionMissingFile(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + src := []byte(` +agent "root" { + instruction = file("missing.txt") + model = "auto" +} +`) + + _, err := ToMap(src, filepath.Join(dir, "agent.hcl")) + require.Error(t, err) + assert.Contains(t, err.Error(), "reading file") + assert.Contains(t, err.Error(), "missing.txt") +} + +func TestToYAML_FileFunctionRejectsTraversal(t *testing.T) { + t.Parallel() + + parent := t.TempDir() + dir := filepath.Join(parent, "config") + require.NoError(t, os.Mkdir(dir, 0o755)) + secret := filepath.Join(parent, "secret.txt") + require.NoError(t, os.WriteFile(secret, []byte("nope"), 0o644)) + + src := []byte(` +agent "root" { + instruction = file("../secret.txt") + model = "auto" +} +`) + + _, err := ToMap(src, filepath.Join(dir, "agent.hcl")) + require.Error(t, err) + assert.Contains(t, err.Error(), "reading file") + assert.Contains(t, err.Error(), "../secret.txt") + assert.Contains(t, err.Error(), "local relative path") +} + +func TestToYAML_FileFunctionRejectsSymlinkEscape(t *testing.T) { + t.Parallel() + + parent := t.TempDir() + dir := filepath.Join(parent, "config") + require.NoError(t, os.Mkdir(dir, 0o755)) + + outside := filepath.Join(parent, "outside.txt") + require.NoError(t, os.WriteFile(outside, []byte("secret"), 0o644)) + + link := filepath.Join(dir, "instructions.txt") + if err := os.Symlink("../outside.txt", link); err != nil { + t.Skipf("symlink not supported: %v", err) + } + + src := []byte(` +agent "root" { + instruction = file("instructions.txt") + model = "auto" +} +`) + + _, err := ToMap(src, filepath.Join(dir, "agent.hcl")) + require.Error(t, err) + assert.Contains(t, err.Error(), "reading file") + assert.Contains(t, err.Error(), filepath.Join(dir, "instructions.txt")) +}