diff --git a/pkg/tools/shell.go b/pkg/tools/shell.go index d2971f3f8..ff5109dca 100644 --- a/pkg/tools/shell.go +++ b/pkg/tools/shell.go @@ -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 .py` or `node .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 } diff --git a/pkg/tools/shell_test.go b/pkg/tools/shell_test.go index a8de2f4c9..20959f9f2 100644 --- a/pkg/tools/shell_test.go +++ b/pkg/tools/shell_test.go @@ -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.