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,}`)
|
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_]*=`)
|
envAssignmentPattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*=`)
|
||||||
interpreterPipePattern = regexp.MustCompile(
|
interpreterPipePattern = regexp.MustCompile(
|
||||||
`(?i)(?:^|[|;&]\s*)(?:env\s+)?(?:python(?:\d+(?:\.\d+)?)?|node(?:js)?)\b`,
|
`(?i)(?:^|[|;&]\s*)(?:env\s+)?(?:python(?:\d+(?:\.\d+)?)?|node(?:js)?)\b`,
|
||||||
|
|
@ -1156,13 +1157,19 @@ func (t *ExecTool) validateScriptFileForShellBleed(command, cwd string) string {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if !shouldScanScriptForShellBleed(absPath) {
|
kind := scriptPreflightKind(absPath)
|
||||||
|
if kind == "" {
|
||||||
continue
|
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(
|
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,
|
first,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -1191,7 +1198,7 @@ func extractScriptTargetFromCommand(command string) []string {
|
||||||
}
|
}
|
||||||
return []string{target}
|
return []string{target}
|
||||||
case isNodeInterpreter(interpreter):
|
case isNodeInterpreter(interpreter):
|
||||||
target := findFirstPositionalScriptArg(argv[1:], []string{".js"})
|
target := findFirstPositionalScriptArg(argv[1:], []string{".js", ".mjs", ".cjs"})
|
||||||
if target == "" {
|
if target == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -1287,6 +1294,24 @@ func stripEnvPrefix(argv []string) []string {
|
||||||
idx++
|
idx++
|
||||||
continue
|
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, "-") {
|
if envAssignmentPattern.MatchString(token) && !strings.HasPrefix(token, "-") {
|
||||||
idx++
|
idx++
|
||||||
continue
|
continue
|
||||||
|
|
@ -1360,9 +1385,29 @@ func hasScriptSuffix(token string, suffixes []string) bool {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func shouldScanScriptForShellBleed(path string) bool {
|
func scriptPreflightKind(path string) string {
|
||||||
ext := strings.ToLower(filepath.Ext(path))
|
switch strings.ToLower(filepath.Ext(path)) {
|
||||||
return ext == ".py" || ext == ".pyw"
|
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 {
|
func shouldFailClosedInterpreterPreflight(command string) bool {
|
||||||
|
|
|
||||||
|
|
@ -699,6 +699,27 @@ func TestShellTool_ScriptPreflight(t *testing.T) {
|
||||||
content: "payload = $DM_JSON",
|
content: "payload = $DM_JSON",
|
||||||
want: "exec preflight: detected likely shell variable injection ($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",
|
name: "piped interpreter fails closed",
|
||||||
command: "cat bad.py | python",
|
command: "cat bad.py | python",
|
||||||
|
|
@ -762,6 +783,24 @@ func TestShellTool_ScriptPreflight_AllowsDirectChainedInterpreter(t *testing.T)
|
||||||
require.NotContains(t, result.ForLLM, "exec preflight:")
|
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
|
// 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.
|
// 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.
|
// e.g. "echo https://etc/passwd && cat //etc/passwd" must still be blocked.
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue