tools: add exec script preflight

This commit is contained in:
Badgerbees 2026-04-03 03:13:06 +07:00
parent 7eba27c3c4
commit 09d146cb92
2 changed files with 301 additions and 0 deletions

View file

@ -111,6 +111,11 @@ var (
"/dev/stdout": true,
"/dev/stderr": true,
}
scriptPreflightEnvVarPattern = regexp.MustCompile(`\$[A-Z_][A-Z0-9_]{1,}`)
interpreterPipePattern = regexp.MustCompile(`(?i)(?:^|[|;&]\s*)(?:env\s+)?(?:python(?:\d+(?:\.\d+)?)?|node(?:js)?)\b`)
interpreterShellWrapperPattern = regexp.MustCompile(`(?i)(?:^|\s)(?:env\s+)?(?:bash|sh|zsh|dash)\b[^\n]*\s-c\s+["']?\s*(?:env\s+)?(?:python(?:\d+(?:\.\d+)?)?|node(?:js)?)\b`)
interpreterProcessSubstPattern = regexp.MustCompile(`(?i)(?:^|\s)(?:env\s+)?(?:python(?:\d+(?:\.\d+)?)?|node(?:js)?)\b[^\n]*<\(`)
)
func NewExecTool(workingDir string, restrict bool, allowPaths ...[]*regexp.Regexp) (*ExecTool, error) {
@ -321,6 +326,10 @@ func (t *ExecTool) executeRun(ctx context.Context, args map[string]any) *ToolRes
return ErrorResult(guardError)
}
if preflightError := t.validateScriptFileForShellBleed(command, cwd); preflightError != "" {
return ErrorResult(preflightError)
}
// Re-resolve symlinks immediately before execution to shrink the TOCTOU window
// between validation and cmd.Dir assignment.
if t.restrictToWorkspace && t.workingDir != "" && cwd != t.workingDir {
@ -1115,6 +1124,238 @@ func (t *ExecTool) guardCommand(command, cwd string) string {
return ""
}
func (t *ExecTool) validateScriptFileForShellBleed(command, cwd string) string {
if shouldFailClosedInterpreterPreflight(command) {
return "Command blocked by safety guard (exec preflight: complex interpreter invocation detected; refusing to run without script preflight validation. Use a direct `python <file>.py` or `node <file>.js` command.)"
}
targets := extractScriptTargetFromCommand(command)
if len(targets) == 0 {
return ""
}
for _, relOrAbsPath := range targets {
if relOrAbsPath == "" {
continue
}
absPath := relOrAbsPath
if !filepath.IsAbs(absPath) {
absPath = filepath.Join(cwd, relOrAbsPath)
}
content, err := os.ReadFile(absPath)
if err != nil || len(content) > 512*1024 {
continue
}
if first := scriptPreflightEnvVarPattern.Find(content); len(first) > 0 {
return fmt.Sprintf("Command blocked by safety guard (exec preflight: detected likely shell variable injection (%s))", first)
}
}
return ""
}
func extractScriptTargetFromCommand(command string) []string {
argv := splitShellArgs(command)
if len(argv) == 0 {
return nil
}
argv = stripEnvPrefix(argv)
if len(argv) == 0 {
return nil
}
interpreter := strings.ToLower(filepath.Base(argv[0]))
switch {
case isPythonInterpreter(interpreter):
target := findLastPositionalScriptArg(argv[1:], []string{".py"})
if target == "" {
return nil
}
return []string{target}
case isNodeInterpreter(interpreter):
target := findLastPositionalScriptArg(argv[1:], []string{".js"})
if target == "" {
return nil
}
return []string{target}
default:
return nil
}
}
func splitShellArgs(raw string) []string {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil
}
tokens := make([]string, 0, 8)
var buf strings.Builder
inSingle := false
inDouble := false
escaped := false
pushToken := func() {
if buf.Len() == 0 {
return
}
tokens = append(tokens, buf.String())
buf.Reset()
}
for i := 0; i < len(raw); i++ {
ch := raw[i]
if escaped {
buf.WriteByte(ch)
escaped = false
continue
}
if inSingle {
if ch == '\'' {
inSingle = false
continue
}
buf.WriteByte(ch)
continue
}
if inDouble {
switch ch {
case '\\':
if i+1 < len(raw) {
i++
buf.WriteByte(raw[i])
}
case '"':
inDouble = false
default:
buf.WriteByte(ch)
}
continue
}
switch ch {
case '\\':
if i+1 < len(raw) {
next := raw[i+1]
if next == ' ' || next == '\\' || next == '"' || next == '\'' || next == '$' || next == '`' {
escaped = true
continue
}
}
buf.WriteByte(ch)
case '\'':
inSingle = true
case '"':
inDouble = true
case ' ', '\t', '\n', '\r':
pushToken()
default:
buf.WriteByte(ch)
}
}
if escaped || inSingle || inDouble {
return nil
}
pushToken()
return tokens
}
func stripEnvPrefix(argv []string) []string {
idx := 0
for idx < len(argv) {
token := argv[idx]
lower := strings.ToLower(token)
if lower == "env" {
idx++
continue
}
if strings.Contains(token, "=") && !strings.HasPrefix(token, "-") && !strings.ContainsAny(token, "/\\") {
idx++
continue
}
break
}
if idx >= len(argv) {
return nil
}
return argv[idx:]
}
func isPythonInterpreter(token string) bool {
return token == "python" || token == "python2" || token == "python3" || strings.HasPrefix(token, "python")
}
func isNodeInterpreter(token string) bool {
return token == "node" || token == "nodejs"
}
func findLastPositionalScriptArg(tokens []string, suffixes []string) string {
if len(tokens) == 0 {
return ""
}
for i := 0; i < len(tokens); i++ {
token := tokens[i]
if token == "--" {
if i+1 < len(tokens) && hasScriptSuffix(tokens[i+1], suffixes) {
return tokens[i+1]
}
continue
}
if token == "-c" || token == "-m" || token == "-e" || token == "-p" || token == "--eval" || token == "--print" {
return ""
}
if token == "-W" || token == "-X" || token == "-Q" || token == "--check-hash-based-pycs" || token == "-r" || token == "--require" || token == "--import" {
i++
continue
}
if strings.HasPrefix(token, "-W") || strings.HasPrefix(token, "-X") || strings.HasPrefix(token, "-Q") || strings.HasPrefix(token, "-r") || strings.HasPrefix(token, "-e") || strings.HasPrefix(token, "-p") || strings.HasPrefix(token, "-c") {
continue
}
if strings.HasPrefix(token, "--require=") || strings.HasPrefix(token, "--import=") || strings.HasPrefix(token, "--check-hash-based-pycs=") || strings.HasPrefix(token, "--eval=") || strings.HasPrefix(token, "--print=") {
continue
}
if hasScriptSuffix(token, suffixes) {
return token
}
}
return ""
}
func hasScriptSuffix(token string, suffixes []string) bool {
lower := strings.ToLower(token)
for _, suffix := range suffixes {
if strings.HasSuffix(lower, suffix) {
return true
}
}
return false
}
func shouldFailClosedInterpreterPreflight(command string) bool {
trimmed := strings.TrimSpace(command)
if trimmed == "" {
return false
}
if interpreterProcessSubstPattern.MatchString(trimmed) {
return true
}
if interpreterShellWrapperPattern.MatchString(trimmed) {
return true
}
if interpreterPipePattern.MatchString(trimmed) && strings.ContainsAny(trimmed, "|;&") {
return true
}
return false
}
func (t *ExecTool) SetTimeout(timeout time.Duration) {
t.timeout = timeout
}

View file

@ -677,6 +677,66 @@ func TestShellTool_FileURISandboxing(t *testing.T) {
}
}
func TestShellTool_ScriptPreflight(t *testing.T) {
tests := []struct {
name string
command string
fileName string
content string
want string
}{
{
name: "quoted script path validates content",
command: `node "bad.js"`,
fileName: "bad.js",
content: "const value = $DM_JSON;",
want: "exec preflight: detected likely shell variable injection ($DM_JSON)",
},
{
name: "piped interpreter fails closed",
command: "cat bad.py | python",
fileName: "bad.py",
content: "payload = $DM_JSON",
want: "exec preflight: complex interpreter invocation detected",
},
{
name: "shell wrapped interpreter fails closed",
command: `bash -c "python bad.py"`,
fileName: "bad.py",
content: "payload = $DM_JSON",
want: "exec preflight: complex interpreter invocation detected",
},
{
name: "process substitution fails closed",
command: "python <(cat bad.py)",
fileName: "bad.py",
content: "payload = $DM_JSON",
want: "exec preflight: complex interpreter invocation detected",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tmpDir := t.TempDir()
if err := os.WriteFile(filepath.Join(tmpDir, tt.fileName), []byte(tt.content), 0o644); err != nil {
t.Fatalf("failed to write test script: %v", err)
}
tool, err := NewExecTool(tmpDir, false)
if err != nil {
t.Fatalf("unable to configure exec tool: %s", err)
}
result := tool.Execute(context.Background(), map[string]any{
"action": "run",
"command": tt.command,
})
require.True(t, result.IsError, "expected script preflight to block %q", tt.command)
require.Contains(t, result.ForLLM, tt.want)
})
}
}
// TestShellTool_URLBypassPrevented verifies that a command cannot bypass the workspace
// sandbox by smuggling a real path after a URL that contains the same //path substring.
// e.g. "echo https://etc/passwd && cat //etc/passwd" must still be blocked.