tools: add exec script preflight
This commit is contained in:
parent
7eba27c3c4
commit
09d146cb92
2 changed files with 301 additions and 0 deletions
|
|
@ -111,6 +111,11 @@ var (
|
||||||
"/dev/stdout": true,
|
"/dev/stdout": true,
|
||||||
"/dev/stderr": 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) {
|
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)
|
return ErrorResult(guardError)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if preflightError := t.validateScriptFileForShellBleed(command, cwd); preflightError != "" {
|
||||||
|
return ErrorResult(preflightError)
|
||||||
|
}
|
||||||
|
|
||||||
// Re-resolve symlinks immediately before execution to shrink the TOCTOU window
|
// Re-resolve symlinks immediately before execution to shrink the TOCTOU window
|
||||||
// between validation and cmd.Dir assignment.
|
// between validation and cmd.Dir assignment.
|
||||||
if t.restrictToWorkspace && t.workingDir != "" && cwd != t.workingDir {
|
if t.restrictToWorkspace && t.workingDir != "" && cwd != t.workingDir {
|
||||||
|
|
@ -1115,6 +1124,238 @@ func (t *ExecTool) guardCommand(command, cwd string) string {
|
||||||
return ""
|
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 <file>.py` or `node <file>.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) {
|
func (t *ExecTool) SetTimeout(timeout time.Duration) {
|
||||||
t.timeout = timeout
|
t.timeout = timeout
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
// 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