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 <noreply@anthropic.com>
This commit is contained in:
dj-oyu 2026-02-22 11:55:19 +09:00
parent fd2d5165a6
commit 5087554eec
2 changed files with 81 additions and 1 deletions

View file

@ -10,6 +10,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"path/filepath"
"regexp" "regexp"
"strconv" "strconv"
"strings" "strings"
@ -2576,6 +2577,61 @@ func isToolAllowedDuringInterview(toolName string, args map[string]interface{})
return strings.HasSuffix(path, "MEMORY.md") 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 return false
} }

View file

@ -1401,8 +1401,32 @@ func TestIsToolAllowedDuringInterview_FuzzyNames(t *testing.T) {
// Write to non-MEMORY.md — blocked // Write to non-MEMORY.md — blocked
{"edit_file", map[string]interface{}{"path": "/ws/main.go"}, false}, {"edit_file", map[string]interface{}{"path": "/ws/main.go"}, false},
{"editfile", 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", nil, false},
{"exec", map[string]interface{}{"command": ""}, false},
{"Exec", nil, false}, {"Exec", nil, false},
} }
for _, tt := range tests { for _, tt := range tests {