tools: support node script preflight
This commit is contained in:
parent
b18095a8c9
commit
53cd653cf1
2 changed files with 91 additions and 7 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue