tools: support node script preflight

This commit is contained in:
Badgerbees 2026-04-03 04:24:06 +07:00
parent b18095a8c9
commit 53cd653cf1
2 changed files with 91 additions and 7 deletions

View file

@ -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 {

View file

@ -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.