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
85 changes: 83 additions & 2 deletions docs/reference/file-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -359,9 +359,15 @@ coding-context -p issue_number=123 -p issue_title="Bug" normal-task
# Output will contain: Issue: 123 and Title: Bug
```

### Parameter Substitution
### Content Expansion

Use `${parameter_name}` syntax for dynamic values.
Task and command content supports three types of dynamic expansion, processed in a single pass to prevent injection attacks.

#### Parameter Expansion

Use `${parameter_name}` syntax to substitute parameter values from `-p` flags.

**Syntax:** `${parameter_name}`

**Example:**
```markdown
Expand All @@ -384,6 +390,81 @@ coding-context \
/fix-bug
```

**Behavior:** If a parameter is not found, the placeholder remains unchanged (e.g., `${missing}` stays as `${missing}`) and a warning is logged.

#### Command Expansion

Use `` !`command` `` syntax to execute shell commands and include their output.

**Syntax:** `` !`command` ``

**Example:**
```markdown
---
task_name: system-info
---
# System Information

Current date: !`date +%Y-%m-%d`
Current user: !`whoami`
Git branch: !`git rev-parse --abbrev-ref HEAD`
```

**Output:**
```
Current date: 2025-12-11
Current user: alex
Git branch: main
```

**Behavior:**
- Command output is included as-is (including any trailing newlines)
- If the command fails, the original syntax remains unchanged (e.g., `` !`false` `` stays as `` !`false` ``) and a warning is logged
- Commands are executed using `sh -c`

**Security Note:** Only use with trusted task files, as commands are executed with your user permissions.

#### Path Expansion

Use `@path` syntax to include the contents of a file.

**Syntax:** `@path` (delimited by whitespace; use `\ ` to escape spaces in filenames)

**Example:**
```markdown
---
task_name: include-config
---
# Current Configuration

@config.yaml

# API Documentation

@docs/api.md
```

**With spaces in filenames:**
```markdown
Content from file: @my\ file\ with\ spaces.txt
```

**Behavior:**
- File content is included verbatim
- If the file is not found, the original syntax remains unchanged (e.g., `@missing.txt` stays as `@missing.txt`) and a warning is logged
- Path can be absolute or relative to the current directory

#### Security: Single-Pass Expansion

All three expansion types are processed in a **single pass, rune-by-rune** to prevent injection attacks:

- Expanded content is **never re-processed** for further expansions
- Command output containing `${param}` will not be expanded
- File content containing `` !`command` `` will not be executed
- Parameter values containing `@path` will not be read as files

This prevents command injection where expanded content could trigger further, unintended expansions.

### File Location

Task files must be in one of these directories:
Expand Down
112 changes: 112 additions & 0 deletions integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,118 @@ Please work on ${component} and fix ${issue}.
}
}

func TestExpanderIntegration(t *testing.T) {
tmpDir := t.TempDir()
tasksDir := filepath.Join(tmpDir, ".agents", "tasks")

if err := os.MkdirAll(tasksDir, 0o755); err != nil {
t.Fatalf("failed to create tasks dir: %v", err)
}

// Create a test file for path expansion
dataFile := filepath.Join(tmpDir, "data.txt")
if err := os.WriteFile(dataFile, []byte("file content"), 0o644); err != nil {
t.Fatalf("failed to write data file: %v", err)
}

// Create a task file with all three expansion types
taskFile := filepath.Join(tasksDir, "test-expander.md")
taskContent := fmt.Sprintf(`---
task_name: test-expander
---
# Test Expander

Parameter: ${component}
Command: !`+"`echo hello`"+`
Path: @%s
Combined: ${component} !`+"`echo world`"+`
`, dataFile)
if err := os.WriteFile(taskFile, []byte(taskContent), 0o644); err != nil {
t.Fatalf("failed to write task file: %v", err)
}

// Run the program with parameters
output := runTool(t, "-C", tmpDir, "-p", "component=auth", "test-expander")

// Check parameter expansion
if !strings.Contains(output, "Parameter: auth") {
t.Errorf("parameter expansion failed. Output:\n%s", output)
}

// Check command expansion
if !strings.Contains(output, "Command: hello") {
t.Errorf("command expansion failed. Output:\n%s", output)
}

// Check path expansion
if !strings.Contains(output, "Path: file content") {
t.Errorf("path expansion failed. Output:\n%s", output)
}

// Check combined expansion
if !strings.Contains(output, "Combined: auth world") {
t.Errorf("combined expansion failed. Output:\n%s", output)
}
}
Comment thread
alexec marked this conversation as resolved.

func TestExpanderSecurityIntegration(t *testing.T) {
tmpDir := t.TempDir()
tasksDir := filepath.Join(tmpDir, ".agents", "tasks")

if err := os.MkdirAll(tasksDir, 0o755); err != nil {
t.Fatalf("failed to create tasks dir: %v", err)
}

// Create a file that contains expansion syntax (should not be re-expanded)
dataFile := filepath.Join(tmpDir, "injection.txt")
if err := os.WriteFile(dataFile, []byte("${injected} and !`echo hacked`"), 0o644); err != nil {
t.Fatalf("failed to write data file: %v", err)
}

// Create a task file that tests security (no re-expansion)
taskFile := filepath.Join(tasksDir, "test-security.md")
taskContent := fmt.Sprintf(`---
task_name: test-security
---
# Test Security

File content: @%s
Param with command: ${evil}
Command with param: !`+"`echo '${secret}'`"+`
`, dataFile)
if err := os.WriteFile(taskFile, []byte(taskContent), 0o644); err != nil {
t.Fatalf("failed to write task file: %v", err)
}

// Run the program with parameters that contain expansion syntax
output := runTool(t, "-C", tmpDir, "-p", "evil=!`echo INJECTED`", "-p", "secret=TOPSECRET", "test-security")

// Check that file content with expansion syntax is NOT re-expanded
if !strings.Contains(output, "File content: ${injected} and !`echo hacked`") {
t.Errorf("file content was re-expanded (security issue). Output:\n%s", output)
}

// Check that parameter value with command syntax is NOT executed
if !strings.Contains(output, "Param with command: !`echo INJECTED`") {
t.Errorf("parameter with command syntax was executed (security issue). Output:\n%s", output)
}

// Check that command output with parameter syntax is NOT re-expanded
if !strings.Contains(output, "Command with param: ${secret}") {
t.Errorf("command output was re-expanded (security issue). Output:\n%s", output)
}

// Verify that sensitive data is NOT in output (unless it's part of literal text)
if strings.Contains(output, "TOPSECRET") {
t.Errorf("parameter was re-expanded from command output (security issue). Output:\n%s", output)
}
// Check that the literal command syntax is preserved (not executed)
// The word "hacked" appears in the literal text, so we check for the full context
if !strings.Contains(output, "!`echo hacked`") {
t.Errorf("file content was re-expanded (security issue). Output:\n%s", output)
}
}

func TestMdcFileSupport(t *testing.T) {
dirs := setupTestDirs(t)

Expand Down
28 changes: 16 additions & 12 deletions pkg/codingcontext/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -270,19 +270,23 @@ func (cc *Context) findCommand(commandName string, params map[string]string) (st
return *content, nil
}

// expandParams substitutes parameter placeholders in the given content.
// expandParams performs all types of content expansion:
// - Parameter expansion: ${param_name}
// - Command expansion: !`command`
// - Path expansion: @path
// If params is provided, it is merged with cc.params (with params taking precedence).
Comment thread
alexec marked this conversation as resolved.
func (cc *Context) expandParams(content string, params map[string]string) string {
return os.Expand(content, func(key string) string {
if val, ok := params[key]; ok {
return val
}
// If not in params map, check cc.params
if val, ok := cc.params[key]; ok {
return val
}
// Return original placeholder if not found
return fmt.Sprintf("${%s}", key)
})
// Merge params with cc.params
mergedParams := make(map[string]string)
for k, v := range cc.params {
mergedParams[k] = v
}
for k, v := range params {
mergedParams[k] = v
}

// Use the expand function to handle all expansion types
return expand(content, mergedParams, cc.logger)
}

// shouldExpandParams returns true if parameter expansion should occur based on the expandParams field.
Expand Down
Loading