From 512361ad516bd3aa3e5dba85b17dfdb196ea4b51 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 10 Dec 2025 22:02:09 +0000 Subject: [PATCH 01/12] Initial plan From c2580631c6d9caada5582377baf46e16c62c4cbb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 10 Dec 2025 22:08:57 +0000 Subject: [PATCH 02/12] Implement expander with parameter, command, and path expansion Co-authored-by: alexec <1142830+alexec@users.noreply.github.com> --- integration_test.go | 54 ++++++ pkg/codingcontext/context.go | 29 +-- pkg/codingcontext/expander.go | 142 ++++++++++++++ pkg/codingcontext/expander_test.go | 301 +++++++++++++++++++++++++++++ 4 files changed, 514 insertions(+), 12 deletions(-) create mode 100644 pkg/codingcontext/expander.go create mode 100644 pkg/codingcontext/expander_test.go diff --git a/integration_test.go b/integration_test.go index 91e4288a..28311c6e 100644 --- a/integration_test.go +++ b/integration_test.go @@ -292,6 +292,60 @@ 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) + } +} + func TestMdcFileSupport(t *testing.T) { dirs := setupTestDirs(t) diff --git a/pkg/codingcontext/context.go b/pkg/codingcontext/context.go index e4360b2c..8e7e49c2 100644 --- a/pkg/codingcontext/context.go +++ b/pkg/codingcontext/context.go @@ -270,19 +270,24 @@ 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). 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(Params) + for k, v := range cc.params { + mergedParams[k] = v + } + for k, v := range params { + mergedParams[k] = v + } + + // Use the new Expander to handle all expansion types + expander := NewExpander(mergedParams, cc.logger) + return expander.Expand(content) } // shouldExpandParams returns true if parameter expansion should occur based on the expandParams field. diff --git a/pkg/codingcontext/expander.go b/pkg/codingcontext/expander.go new file mode 100644 index 00000000..dfe7f927 --- /dev/null +++ b/pkg/codingcontext/expander.go @@ -0,0 +1,142 @@ +package codingcontext + +import ( + "fmt" + "log/slog" + "os" + "os/exec" + "regexp" + "strings" +) + +// Expander handles content expansion for parameters, commands, and file paths +type Expander struct { + params Params + logger *slog.Logger +} + +// NewExpander creates a new Expander with the given parameters and logger +func NewExpander(params Params, logger *slog.Logger) *Expander { + if logger == nil { + logger = slog.New(slog.NewTextHandler(os.Stderr, nil)) + } + return &Expander{ + params: params, + logger: logger, + } +} + +// Expand performs all types of expansion on the content: +// 1. Parameter expansion: ${param_name} +// 2. Command expansion: !`command` +// 3. Path expansion: @path +func (e *Expander) Expand(content string) string { + // First expand commands, then paths, then parameters + // This order allows commands and paths to generate parameter references + content = e.expandCommands(content) + content = e.expandPaths(content) + content = e.expandParameters(content) + return content +} + +// expandParameters handles ${param_name} expansion +func (e *Expander) expandParameters(content string) string { + return os.Expand(content, func(key string) string { + if val, ok := e.params[key]; ok { + return val + } + // Return original placeholder if not found and log warning + e.logger.Warn("parameter not found", "param", key) + return fmt.Sprintf("${%s}", key) + }) +} + +// expandCommands handles !`command` expansion +func (e *Expander) expandCommands(content string) string { + // Match !`...` where ... is the command + // The backtick content can span multiple lines + re := regexp.MustCompile("!`([^`]*)`") + + result := re.ReplaceAllStringFunc(content, func(match string) string { + // Extract command from !`command` + command := match[2 : len(match)-1] // Remove !` and ` + + // Execute the command using sh -c + cmd := exec.Command("sh", "-c", command) + output, err := cmd.CombinedOutput() + if err != nil { + e.logger.Warn("command expansion failed", "command", command, "error", err) + // Return the original match if command fails + return match + } + + // Return the command output, trimming trailing newline if present + return strings.TrimSuffix(string(output), "\n") + }) + + return result +} + +// expandPaths handles @path expansion +func (e *Expander) expandPaths(content string) string { + // Match @path where path is delimited by whitespace + // Paths can have escaped spaces (\ ) + var result strings.Builder + i := 0 + + for i < len(content) { + if content[i] == '@' && (i == 0 || isWhitespace(content[i-1])) { + // Found potential path expansion at start or after whitespace + pathStart := i + 1 + pathEnd := pathStart + + // Scan for the end of the path (whitespace or end of string) + // Handle escaped spaces + for pathEnd < len(content) { + if content[pathEnd] == '\\' && pathEnd+1 < len(content) && content[pathEnd+1] == ' ' { + // Escaped space, skip both characters + pathEnd += 2 + } else if isWhitespace(content[pathEnd]) { + // Unescaped whitespace marks end of path + break + } else { + pathEnd++ + } + } + + if pathEnd > pathStart { + // Extract and unescape the path + path := unescapePath(content[pathStart:pathEnd]) + + // Read the file + fileContent, err := os.ReadFile(path) + if err != nil { + e.logger.Warn("path expansion failed", "path", path, "error", err) + // Return the original @path if file doesn't exist + result.WriteString(content[i:pathEnd]) + } else { + // Expand to file content + result.Write(fileContent) + } + + i = pathEnd + continue + } + } + + result.WriteByte(content[i]) + i++ + } + + return result.String() +} + +// isWhitespace checks if a byte is whitespace (space, tab, newline, carriage return) +func isWhitespace(b byte) bool { + return b == ' ' || b == '\t' || b == '\n' || b == '\r' +} + +// unescapePath removes escape sequences from a path (specifically \) +func unescapePath(path string) string { + return strings.ReplaceAll(path, "\\ ", " ") +} diff --git a/pkg/codingcontext/expander_test.go b/pkg/codingcontext/expander_test.go new file mode 100644 index 00000000..7d9b5089 --- /dev/null +++ b/pkg/codingcontext/expander_test.go @@ -0,0 +1,301 @@ +package codingcontext + +import ( + "log/slog" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestExpandParameters(t *testing.T) { + tests := []struct { + name string + params Params + content string + expected string + }{ + { + name: "single parameter expansion", + params: Params{"name": "Alice"}, + content: "Hello, ${name}!", + expected: "Hello, Alice!", + }, + { + name: "multiple parameter expansions", + params: Params{"first": "John", "last": "Doe"}, + content: "${first} ${last}", + expected: "John Doe", + }, + { + name: "parameter not found - returns unchanged with warning", + params: Params{}, + content: "Value: ${missing}", + expected: "Value: ${missing}", + }, + { + name: "mixed found and not found parameters", + params: Params{"found": "yes"}, + content: "${found} and ${notfound}", + expected: "yes and ${notfound}", + }, + { + name: "no parameters to expand", + params: Params{"key": "value"}, + content: "Plain text without parameters", + expected: "Plain text without parameters", + }, + { + name: "parameter with special characters", + params: Params{"path": "/tmp/file.txt"}, + content: "File: ${path}", + expected: "File: /tmp/file.txt", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + expander := NewExpander(tt.params, slog.New(slog.NewTextHandler(os.Stderr, nil))) + result := expander.expandParameters(tt.content) + if result != tt.expected { + t.Errorf("expandParameters() = %q, want %q", result, tt.expected) + } + }) + } +} + +func TestExpandCommands(t *testing.T) { + tests := []struct { + name string + content string + expected string + contains string // Use contains for commands with variable output + }{ + { + name: "simple echo command", + content: "Output: !`echo hello`", + expected: "Output: hello", + }, + { + name: "command with multiple words", + content: "!`echo hello world`", + expected: "hello world", + }, + { + name: "multiple commands in content", + content: "!`echo foo` and !`echo bar`", + expected: "foo and bar", + }, + { + name: "command that fails - returns unchanged", + content: "!`false` failed", + expected: "!`false` failed", + }, + { + name: "command with pipes", + content: "!`echo test | tr a-z A-Z`", + expected: "TEST", + }, + { + name: "no commands to expand", + content: "Plain text without commands", + expected: "Plain text without commands", + }, + { + name: "command with newline in output", + content: "!`printf 'line1\\nline2'`", + expected: "line1\nline2", + }, + { + name: "command output trimmed trailing newline", + content: "!`echo -n hello` world", + expected: "hello world", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + expander := NewExpander(Params{}, slog.New(slog.NewTextHandler(os.Stderr, nil))) + result := expander.expandCommands(tt.content) + if tt.contains != "" { + if !strings.Contains(result, tt.contains) { + t.Errorf("expandCommands() = %q, should contain %q", result, tt.contains) + } + } else { + if result != tt.expected { + t.Errorf("expandCommands() = %q, want %q", result, tt.expected) + } + } + }) + } +} + +func TestExpandPaths(t *testing.T) { + // Create a temporary directory for test files + tmpDir := t.TempDir() + + // Create test files + testFile1 := filepath.Join(tmpDir, "test1.txt") + if err := os.WriteFile(testFile1, []byte("content1"), 0644); err != nil { + t.Fatalf("failed to create test file: %v", err) + } + + testFile2 := filepath.Join(tmpDir, "test2.txt") + if err := os.WriteFile(testFile2, []byte("content2"), 0644); err != nil { + t.Fatalf("failed to create test file: %v", err) + } + + testFileWithSpace := filepath.Join(tmpDir, "test file.txt") + if err := os.WriteFile(testFileWithSpace, []byte("spaced content"), 0644); err != nil { + t.Fatalf("failed to create test file with space: %v", err) + } + + tests := []struct { + name string + content string + expected string + }{ + { + name: "single file expansion", + content: "File content: @" + testFile1, + expected: "File content: content1", + }, + { + name: "multiple file expansions", + content: "First: @" + testFile1 + " Second: @" + testFile2, + expected: "First: content1 Second: content2", + }, + { + name: "file not found - returns unchanged", + content: "Missing: @/nonexistent/file.txt", + expected: "Missing: @/nonexistent/file.txt", + }, + { + name: "file path with escaped space", + content: "Content: @" + strings.ReplaceAll(testFileWithSpace, " ", "\\ "), + expected: "Content: spaced content", + }, + { + name: "no paths to expand", + content: "Plain text without @ paths", + expected: "Plain text without @ paths", + }, + { + name: "@ not at start or after whitespace is not expanded", + content: "email@example.com", + expected: "email@example.com", + }, + { + name: "@ after newline", + content: "line1\n@" + testFile1, + expected: "line1\ncontent1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + expander := NewExpander(Params{}, slog.New(slog.NewTextHandler(os.Stderr, nil))) + result := expander.expandPaths(tt.content) + if result != tt.expected { + t.Errorf("expandPaths() = %q, want %q", result, tt.expected) + } + }) + } +} + +func TestExpand(t *testing.T) { + // Create a temporary directory for test files + tmpDir := t.TempDir() + + testFile := filepath.Join(tmpDir, "data.txt") + if err := os.WriteFile(testFile, []byte("file-${param}"), 0644); err != nil { + t.Fatalf("failed to create test file: %v", err) + } + + tests := []struct { + name string + params Params + content string + expected string + }{ + { + name: "combined expansions - command, path, parameter", + params: Params{"name": "World"}, + content: "!`echo Hello` ${name} from @" + testFile, + expected: "Hello World from file-${param}", + }, + { + name: "parameter expansion in file content", + params: Params{"param": "replaced"}, + content: "@" + testFile, + expected: "file-replaced", + }, + { + name: "command generates parameter reference", + params: Params{"dynamic": "value"}, + content: "!`echo '${dynamic}'`", + expected: "value", + }, + { + name: "all expansion types together", + params: Params{"x": "X", "y": "Y"}, + content: "${x} !`echo middle` ${y}", + expected: "X middle Y", + }, + { + name: "no expansions needed", + params: Params{}, + content: "plain text", + expected: "plain text", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + expander := NewExpander(tt.params, slog.New(slog.NewTextHandler(os.Stderr, nil))) + result := expander.Expand(tt.content) + if result != tt.expected { + t.Errorf("Expand() = %q, want %q", result, tt.expected) + } + }) + } +} + +func TestNewExpander(t *testing.T) { + tests := []struct { + name string + params Params + logger *slog.Logger + }{ + { + name: "with params and logger", + params: Params{"key": "value"}, + logger: slog.New(slog.NewTextHandler(os.Stderr, nil)), + }, + { + name: "with nil logger - should create default", + params: Params{}, + logger: nil, + }, + { + name: "with empty params", + params: Params{}, + logger: slog.New(slog.NewTextHandler(os.Stderr, nil)), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + expander := NewExpander(tt.params, tt.logger) + if expander == nil { + t.Error("NewExpander() returned nil") + } + if expander.params == nil { + t.Error("expander.params is nil") + } + if expander.logger == nil { + t.Error("expander.logger is nil") + } + }) + } +} From 674c33e7b232fc2cdacb03fca5ee0ed706a73d3d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 10 Dec 2025 22:14:51 +0000 Subject: [PATCH 03/12] Add security documentation and validation for expander Co-authored-by: alexec <1142830+alexec@users.noreply.github.com> --- pkg/codingcontext/expander.go | 39 ++++++++++++++++++++++ pkg/codingcontext/expander_test.go | 53 ++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+) diff --git a/pkg/codingcontext/expander.go b/pkg/codingcontext/expander.go index dfe7f927..41e199cc 100644 --- a/pkg/codingcontext/expander.go +++ b/pkg/codingcontext/expander.go @@ -5,6 +5,7 @@ import ( "log/slog" "os" "os/exec" + "path/filepath" "regexp" "strings" ) @@ -52,9 +53,14 @@ func (e *Expander) expandParameters(content string) string { } // expandCommands handles !`command` expansion +// SECURITY NOTE: This executes arbitrary shell commands from markdown content. +// Only use this tool with trusted markdown files as it can execute any command +// with the permissions of the user running the tool. +// Limitation: Escaped backticks within commands are not supported. func (e *Expander) expandCommands(content string) string { // Match !`...` where ... is the command // The backtick content can span multiple lines + // Note: This does not handle escaped backticks within the command re := regexp.MustCompile("!`([^`]*)`") result := re.ReplaceAllStringFunc(content, func(match string) string { @@ -78,6 +84,9 @@ func (e *Expander) expandCommands(content string) string { } // expandPaths handles @path expansion +// SECURITY NOTE: This reads arbitrary file paths specified in markdown content. +// The tool validates paths to prevent directory traversal but still grants +// read access to any file the user can read. Only use with trusted markdown files. func (e *Expander) expandPaths(content string) string { // Match @path where path is delimited by whitespace // Paths can have escaped spaces (\ ) @@ -108,6 +117,15 @@ func (e *Expander) expandPaths(content string) string { // Extract and unescape the path path := unescapePath(content[pathStart:pathEnd]) + // Validate the path to prevent directory traversal attacks + if err := validatePath(path); err != nil { + e.logger.Warn("path validation failed", "path", path, "error", err) + // Return the original @path if validation fails + result.WriteString(content[i:pathEnd]) + i = pathEnd + continue + } + // Read the file fileContent, err := os.ReadFile(path) if err != nil { @@ -140,3 +158,24 @@ func isWhitespace(b byte) bool { func unescapePath(path string) string { return strings.ReplaceAll(path, "\\ ", " ") } + +// validatePath validates a file path for basic safety checks. +// Note: This tool is designed to work with user-created markdown files in their +// workspace and grants read access to files the user can read. The primary +// defense is that users should only use trusted markdown files. +func validatePath(path string) error { + // Check for null bytes which are never valid in file paths + if strings.Contains(path, "\x00") { + return fmt.Errorf("path contains null byte") + } + + // Clean the path to normalize it (this is primarily for consistency) + _ = filepath.Clean(path) + + // We intentionally allow paths with .. components as they may be + // legitimate references to files in parent directories within the + // user's workspace. The security model is that users should only + // use trusted markdown files, similar to running trusted shell scripts. + + return nil +} diff --git a/pkg/codingcontext/expander_test.go b/pkg/codingcontext/expander_test.go index 7d9b5089..10a13181 100644 --- a/pkg/codingcontext/expander_test.go +++ b/pkg/codingcontext/expander_test.go @@ -299,3 +299,56 @@ func TestNewExpander(t *testing.T) { }) } } + +func TestValidatePath(t *testing.T) { + tests := []struct { + name string + path string + wantErr bool + }{ + { + name: "simple filename", + path: "file.txt", + wantErr: false, + }, + { + name: "absolute path", + path: "/tmp/file.txt", + wantErr: false, + }, + { + name: "relative path with subdirectory", + path: "subdir/file.txt", + wantErr: false, + }, + { + name: "path with null byte - rejected", + path: "file\x00.txt", + wantErr: true, + }, + { + name: "path with directory traversal - allowed (legitimate use case)", + path: "../../../etc/passwd", + wantErr: false, + }, + { + name: "path with .. - allowed", + path: "dir/../file.txt", + wantErr: false, + }, + { + name: "simple path", + path: "file.txt", + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validatePath(tt.path) + if (err != nil) != tt.wantErr { + t.Errorf("validatePath() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} From 7fa72ccc3173be52543758f95960e2f78d285b7e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 10 Dec 2025 22:17:00 +0000 Subject: [PATCH 04/12] Fix code review issues: remove duplicate test and unused import Co-authored-by: alexec <1142830+alexec@users.noreply.github.com> --- pkg/codingcontext/expander.go | 4 ---- pkg/codingcontext/expander_test.go | 5 ----- 2 files changed, 9 deletions(-) diff --git a/pkg/codingcontext/expander.go b/pkg/codingcontext/expander.go index 41e199cc..b04ff30c 100644 --- a/pkg/codingcontext/expander.go +++ b/pkg/codingcontext/expander.go @@ -5,7 +5,6 @@ import ( "log/slog" "os" "os/exec" - "path/filepath" "regexp" "strings" ) @@ -169,9 +168,6 @@ func validatePath(path string) error { return fmt.Errorf("path contains null byte") } - // Clean the path to normalize it (this is primarily for consistency) - _ = filepath.Clean(path) - // We intentionally allow paths with .. components as they may be // legitimate references to files in parent directories within the // user's workspace. The security model is that users should only diff --git a/pkg/codingcontext/expander_test.go b/pkg/codingcontext/expander_test.go index 10a13181..8cec75d2 100644 --- a/pkg/codingcontext/expander_test.go +++ b/pkg/codingcontext/expander_test.go @@ -336,11 +336,6 @@ func TestValidatePath(t *testing.T) { path: "dir/../file.txt", wantErr: false, }, - { - name: "simple path", - path: "file.txt", - wantErr: false, - }, } for _, tt := range tests { From 3f3f3f39d361481c24820715fb30c93099e2155d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 10 Dec 2025 22:49:05 +0000 Subject: [PATCH 05/12] Fix command injection vulnerability - process rune-by-rune Process expansions in a single pass, rune-by-rune, to prevent command injection where expanded content (from commands or files) contains further expansion sequences that would be re-expanded. Security issue: Previously, expansions were done in multiple passes (commands -> paths -> parameters), allowing: - Command output containing ${param} to be expanded - File content containing ${param} or !`cmd` to be expanded - Parameters containing !`cmd` or @path to be expanded Fix: Process all three expansion types in a single pass by scanning rune-by-rune, which ensures expanded content is never re-processed. Tests updated to verify security fix and ensure no re-expansion occurs. Co-authored-by: alexec <1142830+alexec@users.noreply.github.com> --- pkg/codingcontext/expander.go | 134 ++++++++++++++--------------- pkg/codingcontext/expander_test.go | 71 ++++++++++++--- 2 files changed, 123 insertions(+), 82 deletions(-) diff --git a/pkg/codingcontext/expander.go b/pkg/codingcontext/expander.go index b04ff30c..f123c51a 100644 --- a/pkg/codingcontext/expander.go +++ b/pkg/codingcontext/expander.go @@ -5,7 +5,6 @@ import ( "log/slog" "os" "os/exec" - "regexp" "strings" ) @@ -26,85 +25,77 @@ func NewExpander(params Params, logger *slog.Logger) *Expander { } } -// Expand performs all types of expansion on the content: +// Expand performs all types of expansion on the content in a single pass: // 1. Parameter expansion: ${param_name} // 2. Command expansion: !`command` // 3. Path expansion: @path +// SECURITY: Processes rune-by-rune to prevent injection attacks where expanded +// content contains further expansion sequences (e.g., command output with ${param}). func (e *Expander) Expand(content string) string { - // First expand commands, then paths, then parameters - // This order allows commands and paths to generate parameter references - content = e.expandCommands(content) - content = e.expandPaths(content) - content = e.expandParameters(content) - return content -} + var result strings.Builder + runes := []rune(content) + i := 0 -// expandParameters handles ${param_name} expansion -func (e *Expander) expandParameters(content string) string { - return os.Expand(content, func(key string) string { - if val, ok := e.params[key]; ok { - return val + for i < len(runes) { + // Check for parameter expansion: ${...} + if i+2 < len(runes) && runes[i] == '$' && runes[i+1] == '{' { + // Find the closing } + end := i + 2 + for end < len(runes) && runes[end] != '}' { + end++ + } + if end < len(runes) { + // Extract parameter name + paramName := string(runes[i+2 : end]) + if val, ok := e.params[paramName]; ok { + result.WriteString(val) + } else { + e.logger.Warn("parameter not found", "param", paramName) + result.WriteString("${" + paramName + "}") + } + i = end + 1 + continue + } } - // Return original placeholder if not found and log warning - e.logger.Warn("parameter not found", "param", key) - return fmt.Sprintf("${%s}", key) - }) -} -// expandCommands handles !`command` expansion -// SECURITY NOTE: This executes arbitrary shell commands from markdown content. -// Only use this tool with trusted markdown files as it can execute any command -// with the permissions of the user running the tool. -// Limitation: Escaped backticks within commands are not supported. -func (e *Expander) expandCommands(content string) string { - // Match !`...` where ... is the command - // The backtick content can span multiple lines - // Note: This does not handle escaped backticks within the command - re := regexp.MustCompile("!`([^`]*)`") - - result := re.ReplaceAllStringFunc(content, func(match string) string { - // Extract command from !`command` - command := match[2 : len(match)-1] // Remove !` and ` - - // Execute the command using sh -c - cmd := exec.Command("sh", "-c", command) - output, err := cmd.CombinedOutput() - if err != nil { - e.logger.Warn("command expansion failed", "command", command, "error", err) - // Return the original match if command fails - return match + // Check for command expansion: !`...` + if i+2 < len(runes) && runes[i] == '!' && runes[i+1] == '`' { + // Find the closing ` + end := i + 2 + for end < len(runes) && runes[end] != '`' { + end++ + } + if end < len(runes) { + // Extract command + command := string(runes[i+2 : end]) + cmd := exec.Command("sh", "-c", command) + output, err := cmd.CombinedOutput() + if err != nil { + e.logger.Warn("command expansion failed", "command", command, "error", err) + // Return the original !`command` if command fails + result.WriteString("!`" + command + "`") + } else { + // Write command output (trimming trailing newline) + result.WriteString(strings.TrimSuffix(string(output), "\n")) + } + i = end + 1 + continue + } } - // Return the command output, trimming trailing newline if present - return strings.TrimSuffix(string(output), "\n") - }) - - return result -} - -// expandPaths handles @path expansion -// SECURITY NOTE: This reads arbitrary file paths specified in markdown content. -// The tool validates paths to prevent directory traversal but still grants -// read access to any file the user can read. Only use with trusted markdown files. -func (e *Expander) expandPaths(content string) string { - // Match @path where path is delimited by whitespace - // Paths can have escaped spaces (\ ) - var result strings.Builder - i := 0 - - for i < len(content) { - if content[i] == '@' && (i == 0 || isWhitespace(content[i-1])) { + // Check for path expansion: @path + if runes[i] == '@' && (i == 0 || isWhitespaceRune(runes[i-1])) { // Found potential path expansion at start or after whitespace pathStart := i + 1 pathEnd := pathStart // Scan for the end of the path (whitespace or end of string) // Handle escaped spaces - for pathEnd < len(content) { - if content[pathEnd] == '\\' && pathEnd+1 < len(content) && content[pathEnd+1] == ' ' { + for pathEnd < len(runes) { + if runes[pathEnd] == '\\' && pathEnd+1 < len(runes) && runes[pathEnd+1] == ' ' { // Escaped space, skip both characters pathEnd += 2 - } else if isWhitespace(content[pathEnd]) { + } else if isWhitespaceRune(runes[pathEnd]) { // Unescaped whitespace marks end of path break } else { @@ -114,13 +105,13 @@ func (e *Expander) expandPaths(content string) string { if pathEnd > pathStart { // Extract and unescape the path - path := unescapePath(content[pathStart:pathEnd]) + path := unescapePath(string(runes[pathStart:pathEnd])) - // Validate the path to prevent directory traversal attacks + // Validate the path if err := validatePath(path); err != nil { e.logger.Warn("path validation failed", "path", path, "error", err) // Return the original @path if validation fails - result.WriteString(content[i:pathEnd]) + result.WriteString(string(runes[i:pathEnd])) i = pathEnd continue } @@ -130,7 +121,7 @@ func (e *Expander) expandPaths(content string) string { if err != nil { e.logger.Warn("path expansion failed", "path", path, "error", err) // Return the original @path if file doesn't exist - result.WriteString(content[i:pathEnd]) + result.WriteString(string(runes[i:pathEnd])) } else { // Expand to file content result.Write(fileContent) @@ -141,16 +132,17 @@ func (e *Expander) expandPaths(content string) string { } } - result.WriteByte(content[i]) + // No expansion found, write the current rune + result.WriteRune(runes[i]) i++ } return result.String() } -// isWhitespace checks if a byte is whitespace (space, tab, newline, carriage return) -func isWhitespace(b byte) bool { - return b == ' ' || b == '\t' || b == '\n' || b == '\r' +// isWhitespaceRune checks if a rune is whitespace (space, tab, newline, carriage return) +func isWhitespaceRune(r rune) bool { + return r == ' ' || r == '\t' || r == '\n' || r == '\r' } // unescapePath removes escape sequences from a path (specifically \) diff --git a/pkg/codingcontext/expander_test.go b/pkg/codingcontext/expander_test.go index 8cec75d2..77701303 100644 --- a/pkg/codingcontext/expander_test.go +++ b/pkg/codingcontext/expander_test.go @@ -56,9 +56,9 @@ func TestExpandParameters(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { expander := NewExpander(tt.params, slog.New(slog.NewTextHandler(os.Stderr, nil))) - result := expander.expandParameters(tt.content) + result := expander.Expand(tt.content) if result != tt.expected { - t.Errorf("expandParameters() = %q, want %q", result, tt.expected) + t.Errorf("Expand() = %q, want %q", result, tt.expected) } }) } @@ -116,14 +116,14 @@ func TestExpandCommands(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { expander := NewExpander(Params{}, slog.New(slog.NewTextHandler(os.Stderr, nil))) - result := expander.expandCommands(tt.content) + result := expander.Expand(tt.content) if tt.contains != "" { if !strings.Contains(result, tt.contains) { - t.Errorf("expandCommands() = %q, should contain %q", result, tt.contains) + t.Errorf("Expand() = %q, should contain %q", result, tt.contains) } } else { if result != tt.expected { - t.Errorf("expandCommands() = %q, want %q", result, tt.expected) + t.Errorf("Expand() = %q, want %q", result, tt.expected) } } }) @@ -195,9 +195,9 @@ func TestExpandPaths(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { expander := NewExpander(Params{}, slog.New(slog.NewTextHandler(os.Stderr, nil))) - result := expander.expandPaths(tt.content) + result := expander.Expand(tt.content) if result != tt.expected { - t.Errorf("expandPaths() = %q, want %q", result, tt.expected) + t.Errorf("Expand() = %q, want %q", result, tt.expected) } }) } @@ -225,16 +225,16 @@ func TestExpand(t *testing.T) { expected: "Hello World from file-${param}", }, { - name: "parameter expansion in file content", + name: "file content NOT re-expanded (security fix)", params: Params{"param": "replaced"}, content: "@" + testFile, - expected: "file-replaced", + expected: "file-${param}", // Changed: file content is not re-expanded }, { - name: "command generates parameter reference", + name: "command output NOT re-expanded (security fix)", params: Params{"dynamic": "value"}, content: "!`echo '${dynamic}'`", - expected: "value", + expected: "${dynamic}", // Changed: command output is not re-expanded }, { name: "all expansion types together", @@ -347,3 +347,52 @@ func TestValidatePath(t *testing.T) { }) } } + +func TestExpandSecurityNoReExpansion(t *testing.T) { + tests := []struct { + name string + params Params + content string + expected string + desc string + }{ + { + name: "parameter value with command syntax not expanded", + params: Params{"evil": "!`echo INJECTED`"}, + content: "Value: ${evil}", + expected: "Value: !`echo INJECTED`", + desc: "Parameter containing command syntax should not be executed", + }, + { + name: "parameter value with path syntax not expanded", + params: Params{"path": "@/etc/passwd"}, + content: "Path: ${path}", + expected: "Path: @/etc/passwd", + desc: "Parameter containing path syntax should not be read", + }, + { + name: "command output with parameter syntax not expanded", + params: Params{"secret": "SECRET"}, + content: "!`echo '${secret}'`", + expected: "${secret}", + desc: "Command output containing parameter syntax should not be expanded", + }, + { + name: "command output with path syntax not expanded", + params: Params{}, + content: "!`echo '@/etc/passwd'`", + expected: "@/etc/passwd", + desc: "Command output containing path syntax should not be read", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + expander := NewExpander(tt.params, slog.New(slog.NewTextHandler(os.Stderr, nil))) + result := expander.Expand(tt.content) + if result != tt.expected { + t.Errorf("Security test failed: %s\nExpand() = %q, want %q", tt.desc, result, tt.expected) + } + }) + } +} From 8d435ad23e35453e24668885b0ecd41169ad0875 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 11 Dec 2025 02:01:59 +0000 Subject: [PATCH 06/12] Make Expander private and use map type directly - Change Expander to expander (private struct) - Change params field from Params type to map[string]string directly - Update all references to use lowercase expander Co-authored-by: alexec <1142830+alexec@users.noreply.github.com> --- pkg/codingcontext/expander.go | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/pkg/codingcontext/expander.go b/pkg/codingcontext/expander.go index f123c51a..4379fb09 100644 --- a/pkg/codingcontext/expander.go +++ b/pkg/codingcontext/expander.go @@ -8,18 +8,18 @@ import ( "strings" ) -// Expander handles content expansion for parameters, commands, and file paths -type Expander struct { - params Params +// expander handles content expansion for parameters, commands, and file paths +type expander struct { + params map[string]string logger *slog.Logger } -// NewExpander creates a new Expander with the given parameters and logger -func NewExpander(params Params, logger *slog.Logger) *Expander { +// NewExpander creates a new expander with the given parameters and logger +func NewExpander(params Params, logger *slog.Logger) *expander { if logger == nil { logger = slog.New(slog.NewTextHandler(os.Stderr, nil)) } - return &Expander{ + return &expander{ params: params, logger: logger, } @@ -31,7 +31,7 @@ func NewExpander(params Params, logger *slog.Logger) *Expander { // 3. Path expansion: @path // SECURITY: Processes rune-by-rune to prevent injection attacks where expanded // content contains further expansion sequences (e.g., command output with ${param}). -func (e *Expander) Expand(content string) string { +func (e *expander) Expand(content string) string { var result strings.Builder runes := []rune(content) i := 0 From 8b33e9106d385691b06ec7517c1d0f0e97e68e2c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 11 Dec 2025 02:10:15 +0000 Subject: [PATCH 07/12] Use rune slices instead of string concatenation for preserved text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace string concatenation with rune slice notation for better efficiency and to preserve exact original text when parameters are not found or commands fail. Changes: - Line 54: "${" + paramName + "}" → string(runes[i : end+1]) - Line 76: "!`" + command + "`" → string(runes[i : end+1]) Co-authored-by: alexec <1142830+alexec@users.noreply.github.com> --- pkg/codingcontext/expander.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/codingcontext/expander.go b/pkg/codingcontext/expander.go index 4379fb09..5aa5fabd 100644 --- a/pkg/codingcontext/expander.go +++ b/pkg/codingcontext/expander.go @@ -51,7 +51,7 @@ func (e *expander) Expand(content string) string { result.WriteString(val) } else { e.logger.Warn("parameter not found", "param", paramName) - result.WriteString("${" + paramName + "}") + result.WriteString(string(runes[i : end+1])) } i = end + 1 continue @@ -73,7 +73,7 @@ func (e *expander) Expand(content string) string { if err != nil { e.logger.Warn("command expansion failed", "command", command, "error", err) // Return the original !`command` if command fails - result.WriteString("!`" + command + "`") + result.WriteString(string(runes[i : end+1])) } else { // Write command output (trimming trailing newline) result.WriteString(strings.TrimSuffix(string(output), "\n")) From 205a1b81b75a5ff65b3bd8e2013d3bee2624effa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 11 Dec 2025 03:20:50 +0000 Subject: [PATCH 08/12] Remove expander struct, make expand function private with params Refactored per @alexec feedback: - Removed expander struct - Removed NewExpander function - Made expand function private (lowercase) - Pass params and logger as function parameters - Updated all tests to use new function signature - Simplified TestNewExpander to TestExpandWithNilLogger This results in a simpler, more functional API with no unnecessary struct wrapper. Co-authored-by: alexec <1142830+alexec@users.noreply.github.com> --- pkg/codingcontext/context.go | 7 ++- pkg/codingcontext/expander.go | 34 +++++--------- pkg/codingcontext/expander_test.go | 71 +++++++++--------------------- 3 files changed, 33 insertions(+), 79 deletions(-) diff --git a/pkg/codingcontext/context.go b/pkg/codingcontext/context.go index 8e7e49c2..f4159976 100644 --- a/pkg/codingcontext/context.go +++ b/pkg/codingcontext/context.go @@ -277,7 +277,7 @@ func (cc *Context) findCommand(commandName string, params map[string]string) (st // If params is provided, it is merged with cc.params (with params taking precedence). func (cc *Context) expandParams(content string, params map[string]string) string { // Merge params with cc.params - mergedParams := make(Params) + mergedParams := make(map[string]string) for k, v := range cc.params { mergedParams[k] = v } @@ -285,9 +285,8 @@ func (cc *Context) expandParams(content string, params map[string]string) string mergedParams[k] = v } - // Use the new Expander to handle all expansion types - expander := NewExpander(mergedParams, cc.logger) - return expander.Expand(content) + // 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. diff --git a/pkg/codingcontext/expander.go b/pkg/codingcontext/expander.go index 5aa5fabd..5ab85968 100644 --- a/pkg/codingcontext/expander.go +++ b/pkg/codingcontext/expander.go @@ -8,30 +8,16 @@ import ( "strings" ) -// expander handles content expansion for parameters, commands, and file paths -type expander struct { - params map[string]string - logger *slog.Logger -} - -// NewExpander creates a new expander with the given parameters and logger -func NewExpander(params Params, logger *slog.Logger) *expander { - if logger == nil { - logger = slog.New(slog.NewTextHandler(os.Stderr, nil)) - } - return &expander{ - params: params, - logger: logger, - } -} - -// Expand performs all types of expansion on the content in a single pass: +// expand performs all types of expansion on the content in a single pass: // 1. Parameter expansion: ${param_name} // 2. Command expansion: !`command` // 3. Path expansion: @path // SECURITY: Processes rune-by-rune to prevent injection attacks where expanded // content contains further expansion sequences (e.g., command output with ${param}). -func (e *expander) Expand(content string) string { +func expand(content string, params map[string]string, logger *slog.Logger) string { + if logger == nil { + logger = slog.New(slog.NewTextHandler(os.Stderr, nil)) + } var result strings.Builder runes := []rune(content) i := 0 @@ -47,10 +33,10 @@ func (e *expander) Expand(content string) string { if end < len(runes) { // Extract parameter name paramName := string(runes[i+2 : end]) - if val, ok := e.params[paramName]; ok { + if val, ok := params[paramName]; ok { result.WriteString(val) } else { - e.logger.Warn("parameter not found", "param", paramName) + logger.Warn("parameter not found", "param", paramName) result.WriteString(string(runes[i : end+1])) } i = end + 1 @@ -71,7 +57,7 @@ func (e *expander) Expand(content string) string { cmd := exec.Command("sh", "-c", command) output, err := cmd.CombinedOutput() if err != nil { - e.logger.Warn("command expansion failed", "command", command, "error", err) + logger.Warn("command expansion failed", "command", command, "error", err) // Return the original !`command` if command fails result.WriteString(string(runes[i : end+1])) } else { @@ -109,7 +95,7 @@ func (e *expander) Expand(content string) string { // Validate the path if err := validatePath(path); err != nil { - e.logger.Warn("path validation failed", "path", path, "error", err) + logger.Warn("path validation failed", "path", path, "error", err) // Return the original @path if validation fails result.WriteString(string(runes[i:pathEnd])) i = pathEnd @@ -119,7 +105,7 @@ func (e *expander) Expand(content string) string { // Read the file fileContent, err := os.ReadFile(path) if err != nil { - e.logger.Warn("path expansion failed", "path", path, "error", err) + logger.Warn("path expansion failed", "path", path, "error", err) // Return the original @path if file doesn't exist result.WriteString(string(runes[i:pathEnd])) } else { diff --git a/pkg/codingcontext/expander_test.go b/pkg/codingcontext/expander_test.go index 77701303..f892c0cf 100644 --- a/pkg/codingcontext/expander_test.go +++ b/pkg/codingcontext/expander_test.go @@ -55,10 +55,9 @@ func TestExpandParameters(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - expander := NewExpander(tt.params, slog.New(slog.NewTextHandler(os.Stderr, nil))) - result := expander.Expand(tt.content) + result := expand(tt.content, tt.params, slog.New(slog.NewTextHandler(os.Stderr, nil))) if result != tt.expected { - t.Errorf("Expand() = %q, want %q", result, tt.expected) + t.Errorf("expand() = %q, want %q", result, tt.expected) } }) } @@ -115,15 +114,14 @@ func TestExpandCommands(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - expander := NewExpander(Params{}, slog.New(slog.NewTextHandler(os.Stderr, nil))) - result := expander.Expand(tt.content) + result := expand(tt.content, Params{}, slog.New(slog.NewTextHandler(os.Stderr, nil))) if tt.contains != "" { if !strings.Contains(result, tt.contains) { - t.Errorf("Expand() = %q, should contain %q", result, tt.contains) + t.Errorf("expand() = %q, should contain %q", result, tt.contains) } } else { if result != tt.expected { - t.Errorf("Expand() = %q, want %q", result, tt.expected) + t.Errorf("expand() = %q, want %q", result, tt.expected) } } }) @@ -194,10 +192,9 @@ func TestExpandPaths(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - expander := NewExpander(Params{}, slog.New(slog.NewTextHandler(os.Stderr, nil))) - result := expander.Expand(tt.content) + result := expand(tt.content, Params{}, slog.New(slog.NewTextHandler(os.Stderr, nil))) if result != tt.expected { - t.Errorf("Expand() = %q, want %q", result, tt.expected) + t.Errorf("expand() = %q, want %q", result, tt.expected) } }) } @@ -252,51 +249,24 @@ func TestExpand(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - expander := NewExpander(tt.params, slog.New(slog.NewTextHandler(os.Stderr, nil))) - result := expander.Expand(tt.content) + result := expand(tt.content, tt.params, slog.New(slog.NewTextHandler(os.Stderr, nil))) if result != tt.expected { - t.Errorf("Expand() = %q, want %q", result, tt.expected) + t.Errorf("expand() = %q, want %q", result, tt.expected) } }) } } -func TestNewExpander(t *testing.T) { - tests := []struct { - name string - params Params - logger *slog.Logger - }{ - { - name: "with params and logger", - params: Params{"key": "value"}, - logger: slog.New(slog.NewTextHandler(os.Stderr, nil)), - }, - { - name: "with nil logger - should create default", - params: Params{}, - logger: nil, - }, - { - name: "with empty params", - params: Params{}, - logger: slog.New(slog.NewTextHandler(os.Stderr, nil)), - }, - } +func TestExpandWithNilLogger(t *testing.T) { + // Test that expand handles nil logger without panicking + content := "Hello ${name}!" + params := Params{"name": "World"} - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - expander := NewExpander(tt.params, tt.logger) - if expander == nil { - t.Error("NewExpander() returned nil") - } - if expander.params == nil { - t.Error("expander.params is nil") - } - if expander.logger == nil { - t.Error("expander.logger is nil") - } - }) + // Should not panic with nil logger + result := expand(content, params, nil) + expected := "Hello World!" + if result != expected { + t.Errorf("expand() with nil logger = %q, want %q", result, expected) } } @@ -388,10 +358,9 @@ func TestExpandSecurityNoReExpansion(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - expander := NewExpander(tt.params, slog.New(slog.NewTextHandler(os.Stderr, nil))) - result := expander.Expand(tt.content) + result := expand(tt.content, tt.params, slog.New(slog.NewTextHandler(os.Stderr, nil))) if result != tt.expected { - t.Errorf("Security test failed: %s\nExpand() = %q, want %q", tt.desc, result, tt.expected) + t.Errorf("Security test failed: %s\nexpand() = %q, want %q", tt.desc, result, tt.expected) } }) } From b3637ac120a4908d1072eff5ae0b5166d7e58a14 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 11 Dec 2025 03:43:20 +0000 Subject: [PATCH 09/12] Remove nil logger check, preserve command output, update docs Code changes per @alexec feedback: - Removed nil logger check in expand function (line 18) - Preserve command output as-is, no longer trim trailing newlines (line 65) - Updated all test expectations to account for newlines in command output - Changed TestExpandWithNilLogger to TestExpandBasic Documentation updates: - Expanded "Parameter Substitution" section to "Content Expansion" - Added documentation for all three expansion types: - Parameter expansion (${param}) - Command expansion (!`cmd`) - Path expansion (@path) - Documented security model (single-pass, no re-expansion) - Added examples and usage notes for each expansion type Co-authored-by: alexec <1142830+alexec@users.noreply.github.com> --- docs/reference/file-formats.md | 85 +++++++++++++++++++++++++++++- pkg/codingcontext/expander.go | 7 +-- pkg/codingcontext/expander_test.go | 30 +++++------ 3 files changed, 100 insertions(+), 22 deletions(-) diff --git a/docs/reference/file-formats.md b/docs/reference/file-formats.md index 4b6c4528..22277e5f 100644 --- a/docs/reference/file-formats.md +++ b/docs/reference/file-formats.md @@ -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 @@ -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: diff --git a/pkg/codingcontext/expander.go b/pkg/codingcontext/expander.go index 5ab85968..d9f41b37 100644 --- a/pkg/codingcontext/expander.go +++ b/pkg/codingcontext/expander.go @@ -15,9 +15,6 @@ import ( // SECURITY: Processes rune-by-rune to prevent injection attacks where expanded // content contains further expansion sequences (e.g., command output with ${param}). func expand(content string, params map[string]string, logger *slog.Logger) string { - if logger == nil { - logger = slog.New(slog.NewTextHandler(os.Stderr, nil)) - } var result strings.Builder runes := []rune(content) i := 0 @@ -61,8 +58,8 @@ func expand(content string, params map[string]string, logger *slog.Logger) strin // Return the original !`command` if command fails result.WriteString(string(runes[i : end+1])) } else { - // Write command output (trimming trailing newline) - result.WriteString(strings.TrimSuffix(string(output), "\n")) + // Write command output + result.WriteString(string(output)) } i = end + 1 continue diff --git a/pkg/codingcontext/expander_test.go b/pkg/codingcontext/expander_test.go index f892c0cf..fc1d7211 100644 --- a/pkg/codingcontext/expander_test.go +++ b/pkg/codingcontext/expander_test.go @@ -73,17 +73,17 @@ func TestExpandCommands(t *testing.T) { { name: "simple echo command", content: "Output: !`echo hello`", - expected: "Output: hello", + expected: "Output: hello\n", }, { name: "command with multiple words", content: "!`echo hello world`", - expected: "hello world", + expected: "hello world\n", }, { name: "multiple commands in content", content: "!`echo foo` and !`echo bar`", - expected: "foo and bar", + expected: "foo\n and bar\n", }, { name: "command that fails - returns unchanged", @@ -93,7 +93,7 @@ func TestExpandCommands(t *testing.T) { { name: "command with pipes", content: "!`echo test | tr a-z A-Z`", - expected: "TEST", + expected: "TEST\n", }, { name: "no commands to expand", @@ -106,7 +106,7 @@ func TestExpandCommands(t *testing.T) { expected: "line1\nline2", }, { - name: "command output trimmed trailing newline", + name: "command output not trimmed", content: "!`echo -n hello` world", expected: "hello world", }, @@ -219,7 +219,7 @@ func TestExpand(t *testing.T) { name: "combined expansions - command, path, parameter", params: Params{"name": "World"}, content: "!`echo Hello` ${name} from @" + testFile, - expected: "Hello World from file-${param}", + expected: "Hello\n World from file-${param}", }, { name: "file content NOT re-expanded (security fix)", @@ -231,13 +231,13 @@ func TestExpand(t *testing.T) { name: "command output NOT re-expanded (security fix)", params: Params{"dynamic": "value"}, content: "!`echo '${dynamic}'`", - expected: "${dynamic}", // Changed: command output is not re-expanded + expected: "${dynamic}\n", // Changed: command output is not re-expanded }, { name: "all expansion types together", params: Params{"x": "X", "y": "Y"}, content: "${x} !`echo middle` ${y}", - expected: "X middle Y", + expected: "X middle\n Y", }, { name: "no expansions needed", @@ -257,16 +257,16 @@ func TestExpand(t *testing.T) { } } -func TestExpandWithNilLogger(t *testing.T) { - // Test that expand handles nil logger without panicking +func TestExpandBasic(t *testing.T) { + // Test basic expansion functionality content := "Hello ${name}!" params := Params{"name": "World"} + logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) - // Should not panic with nil logger - result := expand(content, params, nil) + result := expand(content, params, logger) expected := "Hello World!" if result != expected { - t.Errorf("expand() with nil logger = %q, want %q", result, expected) + t.Errorf("expand() = %q, want %q", result, expected) } } @@ -344,14 +344,14 @@ func TestExpandSecurityNoReExpansion(t *testing.T) { name: "command output with parameter syntax not expanded", params: Params{"secret": "SECRET"}, content: "!`echo '${secret}'`", - expected: "${secret}", + expected: "${secret}\n", desc: "Command output containing parameter syntax should not be expanded", }, { name: "command output with path syntax not expanded", params: Params{}, content: "!`echo '@/etc/passwd'`", - expected: "@/etc/passwd", + expected: "@/etc/passwd\n", desc: "Command output containing path syntax should not be read", }, } From c9a772576e881026757546d67ecd3050cb6a226d Mon Sep 17 00:00:00 2001 From: Alex Collins Date: Wed, 10 Dec 2025 19:47:33 -0800 Subject: [PATCH 10/12] Update expander.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- pkg/codingcontext/expander.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/codingcontext/expander.go b/pkg/codingcontext/expander.go index d9f41b37..78eec34f 100644 --- a/pkg/codingcontext/expander.go +++ b/pkg/codingcontext/expander.go @@ -16,6 +16,7 @@ import ( // content contains further expansion sequences (e.g., command output with ${param}). func expand(content string, params map[string]string, logger *slog.Logger) string { var result strings.Builder + result.Grow(len(content)) runes := []rune(content) i := 0 From e49fee244af5b38535bfd7ceef0bd2fc40dd2958 Mon Sep 17 00:00:00 2001 From: Alex Collins Date: Thu, 11 Dec 2025 08:11:51 -0800 Subject: [PATCH 11/12] Update pkg/codingcontext/expander_test.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- pkg/codingcontext/expander_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/codingcontext/expander_test.go b/pkg/codingcontext/expander_test.go index fc1d7211..dbe9ba19 100644 --- a/pkg/codingcontext/expander_test.go +++ b/pkg/codingcontext/expander_test.go @@ -200,7 +200,7 @@ func TestExpandPaths(t *testing.T) { } } -func TestExpand(t *testing.T) { +func TestExpandCombined(t *testing.T) { // Create a temporary directory for test files tmpDir := t.TempDir() From e637274d88d2e23def270386305434f04e36a557 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 11 Dec 2025 16:19:04 +0000 Subject: [PATCH 12/12] Output command stderr even on failure, add edge case tests Changes based on code review feedback: - Command expansion now outputs stderr even when command fails - Added edge case tests for parameter expansion: - Unclosed parameter syntax - Empty parameter name - Parameter at end of string - Nested braces - Added edge case tests for command expansion: - Unclosed backtick - Empty command - Command at end of string - Command with error output - Added edge case tests for path expansion: - Path at end without trailing whitespace - Lone @ with no path - Multiple consecutive @ symbols - Path with backslash not escaping space - Added security integration test verifying no re-expansion of: - File content containing expansion syntax - Parameter values containing command syntax - Command output containing parameter syntax Co-authored-by: alexec <1142830+alexec@users.noreply.github.com> --- integration_test.go | 58 +++++++++++++++++++++++++ pkg/codingcontext/expander.go | 7 +-- pkg/codingcontext/expander_test.go | 68 +++++++++++++++++++++++++++++- 3 files changed, 126 insertions(+), 7 deletions(-) diff --git a/integration_test.go b/integration_test.go index 28311c6e..abeafd7f 100644 --- a/integration_test.go +++ b/integration_test.go @@ -346,6 +346,64 @@ Combined: ${component} !`+"`echo world`"+` } } +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) diff --git a/pkg/codingcontext/expander.go b/pkg/codingcontext/expander.go index 78eec34f..e696e161 100644 --- a/pkg/codingcontext/expander.go +++ b/pkg/codingcontext/expander.go @@ -56,12 +56,9 @@ func expand(content string, params map[string]string, logger *slog.Logger) strin output, err := cmd.CombinedOutput() if err != nil { logger.Warn("command expansion failed", "command", command, "error", err) - // Return the original !`command` if command fails - result.WriteString(string(runes[i : end+1])) - } else { - // Write command output - result.WriteString(string(output)) } + // Write command output (even if command failed, output may contain error info) + result.WriteString(string(output)) i = end + 1 continue } diff --git a/pkg/codingcontext/expander_test.go b/pkg/codingcontext/expander_test.go index dbe9ba19..663e00e5 100644 --- a/pkg/codingcontext/expander_test.go +++ b/pkg/codingcontext/expander_test.go @@ -51,6 +51,30 @@ func TestExpandParameters(t *testing.T) { content: "File: ${path}", expected: "File: /tmp/file.txt", }, + { + name: "unclosed parameter - treated as literal", + params: Params{"name": "value"}, + content: "Text ${name and more", + expected: "Text ${name and more", + }, + { + name: "empty parameter name - expands to empty", + params: Params{"": "value"}, + content: "Text ${} more", + expected: "Text value more", + }, + { + name: "parameter at end of string", + params: Params{"end": "final"}, + content: "Start ${end}", + expected: "Start final", + }, + { + name: "nested braces - outer takes precedence", + params: Params{"outer": "value"}, + content: "${outer{inner}}", + expected: "${outer{inner}}", + }, } for _, tt := range tests { @@ -86,9 +110,9 @@ func TestExpandCommands(t *testing.T) { expected: "foo\n and bar\n", }, { - name: "command that fails - returns unchanged", + name: "command that fails - returns output (empty for false)", content: "!`false` failed", - expected: "!`false` failed", + expected: " failed", }, { name: "command with pipes", @@ -110,6 +134,26 @@ func TestExpandCommands(t *testing.T) { content: "!`echo -n hello` world", expected: "hello world", }, + { + name: "unclosed backtick - treated as literal", + content: "Text !`echo test more", + expected: "Text !`echo test more", + }, + { + name: "empty command", + content: "Text !`` more", + expected: "Text more", + }, + { + name: "command at end of string", + content: "Start !`echo end`", + expected: "Start end\n", + }, + { + name: "command with error output", + content: "Error: !`cat /nonexistent/file 2>&1`", + contains: "No such file or directory", + }, } for _, tt := range tests { @@ -188,6 +232,26 @@ func TestExpandPaths(t *testing.T) { content: "line1\n@" + testFile1, expected: "line1\ncontent1", }, + { + name: "path at end without trailing whitespace", + content: "End: @" + testFile1, + expected: "End: content1", + }, + { + name: "lone @ with no path", + content: "Text @ more", + expected: "Text @ more", + }, + { + name: "multiple consecutive @ symbols", + content: "Text @@ more", + expected: "Text @@ more", + }, + { + name: "path with backslash not escaping space - whole path not found", + content: "Path: @" + testFile1 + "\\notspace", + expected: "Path: @" + testFile1 + "\\notspace", + }, } for _, tt := range tests {