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"
|
"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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue