From 1d62b6e5761333de221a0d3aafea7cdf7a854ed8 Mon Sep 17 00:00:00 2001 From: dj-oyu <68707227+dj-oyu@users.noreply.github.com> Date: Sun, 22 Feb 2026 11:55:19 +0900 Subject: [PATCH] feat: allow read-only exec commands during interview phase LLMs need to run commands like find/ls/grep to explore project structure during the plan interview. Add isReadOnlyCommand to whitelist safe commands while blocking writes, path traversal, and absolute paths. Co-Authored-By: Claude Opus 4.6 --- pkg/agent/loop.go | 56 ++++++++++++++++++++++++++++++++++++++++++ pkg/agent/loop_test.go | 26 +++++++++++++++++++- 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 6a154bbaa..2632e9e6e 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -10,6 +10,7 @@ import ( "context" "encoding/json" "fmt" + "path/filepath" "regexp" "strconv" "strings" @@ -2576,6 +2577,61 @@ func isToolAllowedDuringInterview(toolName string, args map[string]interface{}) return strings.HasSuffix(path, "MEMORY.md") } + // exec: allow read-only commands + switch norm { + case "exec": + cmd, _ := args["command"].(string) + return isReadOnlyCommand(cmd) + } + + return false +} + +// isReadOnlyCommand returns true when cmd is a safe, read-only shell command +// that an LLM may run during the interview phase. +func isReadOnlyCommand(cmd string) bool { + cmd = strings.TrimSpace(cmd) + if cmd == "" { + return false + } + + // Reject write operators anywhere in the command + for _, op := range []string{">", ">>", "| tee "} { + if strings.Contains(cmd, op) { + return false + } + } + + // Reject path traversal (defense in depth; ExecTool.guardCommand also enforces workspace restriction) + if strings.Contains(cmd, "..") { + return false + } + // Block absolute paths in arguments (allow "cd /path && cmd" which is stripped later) + for _, field := range strings.Fields(cmd) { + if strings.HasPrefix(field, "/") && !strings.HasPrefix(cmd, "cd ") { + return false + } + } + + // Strip "cd /path &&" prefix (LLM habit) + if strings.HasPrefix(cmd, "cd ") { + if idx := strings.Index(cmd, "&&"); idx >= 0 { + cmd = strings.TrimSpace(cmd[idx+2:]) + } + } + + fields := strings.Fields(cmd) + if len(fields) == 0 { + return false + } + first := filepath.Base(fields[0]) + switch first { + case "find", "ls", "cat", "head", "tail", "grep", "rg", + "tree", "wc", "file", "which", "pwd", + "uname", "df", "du", "stat", "realpath", "dirname", + "basename", "date": + return true + } return false } diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go index df588df72..fe66b6d94 100644 --- a/pkg/agent/loop_test.go +++ b/pkg/agent/loop_test.go @@ -1401,8 +1401,32 @@ func TestIsToolAllowedDuringInterview_FuzzyNames(t *testing.T) { // Write to non-MEMORY.md — blocked {"edit_file", map[string]interface{}{"path": "/ws/main.go"}, false}, {"editfile", map[string]interface{}{"path": "/ws/main.go"}, false}, - // exec — always blocked + // exec — read-only commands allowed + {"exec", map[string]interface{}{"command": "find . -name '*.py'"}, true}, + {"exec", map[string]interface{}{"command": "ls -la"}, true}, + {"exec", map[string]interface{}{"command": "grep -r TODO ."}, true}, + {"exec", map[string]interface{}{"command": "cat README.md"}, true}, + // exec — cd prefix stripped + {"exec", map[string]interface{}{"command": "cd /home/user/project && find . -type f"}, true}, + {"exec", map[string]interface{}{"command": "cd /tmp && rm -rf *"}, false}, + // exec — write operators blocked + {"exec", map[string]interface{}{"command": "find . > output.txt"}, false}, + {"exec", map[string]interface{}{"command": "ls -la >> log.txt"}, false}, + {"exec", map[string]interface{}{"command": "cat foo | tee bar.txt"}, false}, + // exec — path traversal blocked + {"exec", map[string]interface{}{"command": "cat ../../etc/passwd"}, false}, + {"exec", map[string]interface{}{"command": "find ../../"}, false}, + {"exec", map[string]interface{}{"command": "ls ../secret"}, false}, + // exec — absolute paths blocked + {"exec", map[string]interface{}{"command": "cat /etc/passwd"}, false}, + {"exec", map[string]interface{}{"command": "find /etc -name '*.conf'"}, false}, + {"exec", map[string]interface{}{"command": "ls /root"}, false}, + // exec — write commands blocked + {"exec", map[string]interface{}{"command": "rm -rf /"}, false}, + {"exec", map[string]interface{}{"command": "mv a b"}, false}, + // exec — no args / empty command blocked {"exec", nil, false}, + {"exec", map[string]interface{}{"command": ""}, false}, {"Exec", nil, false}, } for _, tt := range tests {