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:
parent
fd2d5165a6
commit
5087554eec
2 changed files with 81 additions and 1 deletions
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue