From 53cd653cf16edef82cffafa7a2cf4fb5a062f8df Mon Sep 17 00:00:00 2001 From: Badgerbees Date: Fri, 3 Apr 2026 04:24:06 +0700 Subject: [PATCH] tools: support node script preflight --- pkg/tools/shell.go | 59 ++++++++++++++++++++++++++++++++++++----- pkg/tools/shell_test.go | 39 +++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 7 deletions(-) diff --git a/pkg/tools/shell.go b/pkg/tools/shell.go index 7df330ecd..dab54dc36 100644 --- a/pkg/tools/shell.go +++ b/pkg/tools/shell.go @@ -113,6 +113,7 @@ var ( } scriptPreflightEnvVarPattern = regexp.MustCompile(`\$[A-Z_][A-Z0-9_]{1,}`) + scriptPreflightNodePattern = regexp.MustCompile(`(?m)^[ \t]*(?:NODE\s+["'][^"']+["']|(?:bash|sh)\s+-c\b|export\s+[A-Za-z_][A-Za-z0-9_]*=)`) envAssignmentPattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*=`) interpreterPipePattern = regexp.MustCompile( `(?i)(?:^|[|;&]\s*)(?:env\s+)?(?:python(?:\d+(?:\.\d+)?)?|node(?:js)?)\b`, @@ -1156,13 +1157,19 @@ func (t *ExecTool) validateScriptFileForShellBleed(command, cwd string) string { continue } - if !shouldScanScriptForShellBleed(absPath) { + kind := scriptPreflightKind(absPath) + if kind == "" { continue } - if first := scriptPreflightEnvVarPattern.Find(content); len(first) > 0 { + if first := firstScriptPreflightMatch(kind, content); first != "" { + reason := "exec preflight: detected likely shell variable injection" + if kind == "node" { + reason = "exec preflight: detected likely shell syntax" + } return fmt.Sprintf( - "Command blocked by safety guard (exec preflight: detected likely shell variable injection (%s))", + "Command blocked by safety guard (%s (%s))", + reason, first, ) } @@ -1191,7 +1198,7 @@ func extractScriptTargetFromCommand(command string) []string { } return []string{target} case isNodeInterpreter(interpreter): - target := findFirstPositionalScriptArg(argv[1:], []string{".js"}) + target := findFirstPositionalScriptArg(argv[1:], []string{".js", ".mjs", ".cjs"}) if target == "" { return nil } @@ -1287,6 +1294,24 @@ func stripEnvPrefix(argv []string) []string { idx++ continue } + if lower == "-i" || lower == "--ignore-environment" { + idx++ + continue + } + if lower == "-u" || strings.HasPrefix(lower, "-u") { + idx++ + if lower == "-u" && idx < len(argv) { + idx++ + } + continue + } + if lower == "--unset" || strings.HasPrefix(lower, "--unset=") { + idx++ + if lower == "--unset" && idx < len(argv) { + idx++ + } + continue + } if envAssignmentPattern.MatchString(token) && !strings.HasPrefix(token, "-") { idx++ continue @@ -1360,9 +1385,29 @@ func hasScriptSuffix(token string, suffixes []string) bool { return false } -func shouldScanScriptForShellBleed(path string) bool { - ext := strings.ToLower(filepath.Ext(path)) - return ext == ".py" || ext == ".pyw" +func scriptPreflightKind(path string) string { + switch strings.ToLower(filepath.Ext(path)) { + case ".py", ".pyw": + return "python" + case ".js", ".mjs", ".cjs": + return "node" + default: + return "" + } +} + +func firstScriptPreflightMatch(kind string, content []byte) string { + switch kind { + case "python": + if first := scriptPreflightEnvVarPattern.Find(content); len(first) > 0 { + return string(first) + } + case "node": + if first := scriptPreflightNodePattern.Find(content); len(first) > 0 { + return string(first) + } + } + return "" } func shouldFailClosedInterpreterPreflight(command string) bool { diff --git a/pkg/tools/shell_test.go b/pkg/tools/shell_test.go index 949646775..c6f1260c2 100644 --- a/pkg/tools/shell_test.go +++ b/pkg/tools/shell_test.go @@ -699,6 +699,27 @@ func TestShellTool_ScriptPreflight(t *testing.T) { content: "payload = $DM_JSON", want: "exec preflight: detected likely shell variable injection ($DM_JSON)", }, + { + name: "quoted node js script path validates shell bleed", + command: `node "bad.js"`, + fileName: "bad.js", + content: `NODE "$TMPDIR/hot.json"`, + want: "exec preflight: detected likely shell syntax (NODE \"$TMPDIR/hot.json\")", + }, + { + name: "quoted node mjs script path validates shell bleed", + command: `node "bad.mjs"`, + fileName: "bad.mjs", + content: `NODE "$TMPDIR/hot.json"`, + want: "exec preflight: detected likely shell syntax (NODE \"$TMPDIR/hot.json\")", + }, + { + name: "quoted node cjs script path validates shell bleed", + command: `node "bad.cjs"`, + fileName: "bad.cjs", + content: `NODE "$TMPDIR/hot.json"`, + want: "exec preflight: detected likely shell syntax (NODE \"$TMPDIR/hot.json\")", + }, { name: "piped interpreter fails closed", command: "cat bad.py | python", @@ -762,6 +783,24 @@ func TestShellTool_ScriptPreflight_AllowsDirectChainedInterpreter(t *testing.T) require.NotContains(t, result.ForLLM, "exec preflight:") } +func TestShellTool_ScriptPreflight_AllowsDirectChainedNodeInterpreter(t *testing.T) { + tmpDir := t.TempDir() + if err := os.WriteFile(filepath.Join(tmpDir, "good.js"), []byte("console.log('ok')"), 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": "node good.js && echo ok", + }) + require.NotContains(t, result.ForLLM, "exec preflight:") +} + // 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.