Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 | | | ✓ | | | | |
Expand Down
12 changes: 12 additions & 0 deletions examples/instructions_from_file.hcl
Original file line number Diff line number Diff line change
@@ -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"
}
7 changes: 7 additions & 0 deletions examples/instructions_from_file.md
Original file line number Diff line number Diff line change
@@ -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
36 changes: 28 additions & 8 deletions pkg/config/hcl/hcl.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -19,13 +22,15 @@ package hcl
import (
"fmt"
"math/big"
"path/filepath"
"strings"

"github.com/goccy/go-yaml"
"github.com/hashicorp/hcl/v2"
"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
Expand Down Expand Up @@ -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())
}
Expand Down Expand Up @@ -207,20 +212,20 @@ 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
}
}

for _, block := range body.Blocks {
diags = append(diags, mergeBlock(out, block)...)
diags = append(diags, mergeBlock(out, block, evalCtx)...)
}

return out, diags
Expand All @@ -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
}
Expand Down Expand Up @@ -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 {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MEDIUM] file() silently resolves paths relative to CWD when filename is empty or has no directory component

Two cases:

  1. ToMap(src, "")baseDir("") returns "" → the baseDir != "" guard fails → os.ReadFile resolves relative paths against the process working directory.
  2. ToMap(src, "agent.hcl")filepath.Dir("agent.hcl") returns "."filepath.Join(".", path) still resolves against CWD.

In both cases, file("secret.txt") reads from wherever the binary was invoked, not from a stable, expected location. Consider requiring filename to be an absolute path, or documenting the CWD-relative behaviour explicitly.

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 {
Expand Down
49 changes: 49 additions & 0 deletions pkg/config/hcl/hcl_file.go
Original file line number Diff line number Diff line change
@@ -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
}
101 changes: 101 additions & 0 deletions pkg/config/hcl/hcl_file_test.go
Original file line number Diff line number Diff line change
@@ -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"))
}
Loading