diff --git a/internal/tools/credentialed_exec.go b/internal/tools/credentialed_exec.go index 2ab9a52a..7fb78657 100644 --- a/internal/tools/credentialed_exec.go +++ b/internal/tools/credentialed_exec.go @@ -112,7 +112,11 @@ func (t *ExecTool) executeCredentialed(ctx context.Context, cred *store.SecureCL // Step 2: Resolve binary to absolute path and verify against config absPath, err := resolveAndMatchBinary(binary, cred.BinaryPath) if err != nil { - return credentialedPathError(binary, err) + r := credentialedPathError(binary, err) + if t.sandboxMgr != nil && sandboxKey != "" { + r.ForLLM += sandboxHint + } + return r } // Step 3: Per-binary deny check (deny_args) @@ -199,7 +203,8 @@ func (t *ExecTool) executeCredentialedSandbox(ctx context.Context, absPath strin output += "STDERR:\n" + result.Stderr } if result.ExitCode != 0 { - return credentialedExecFailError(absPath, args, result.ExitCode, ScrubCredentials(output)) + scrubbed := ScrubCredentials(output) + return credentialedExecFailError(absPath, args, result.ExitCode, scrubbed+MaybeSandboxHint(result.ExitCode, scrubbed)) } if output == "" { output = "(command completed with no output)" diff --git a/internal/tools/sandbox_hints.go b/internal/tools/sandbox_hints.go new file mode 100644 index 00000000..51b81256 --- /dev/null +++ b/internal/tools/sandbox_hints.go @@ -0,0 +1,32 @@ +package tools + +import "strings" + +// sandboxHint is appended to tool error output when a command fails inside +// a Docker sandbox container due to a missing binary/tool (exit code 127). +const sandboxHint = "\n\n[SANDBOX] This command ran inside a Docker sandbox container. " + + "The required tool/binary is not installed in the sandbox image. " + + "Tell the user this failed due to sandbox environment limitations — " + + "they can install the binary in the sandbox image or disable sandbox mode for this agent." + +// isBinaryNotFound returns true if the error output and exit code indicate +// a missing binary. Exit code 127 is the POSIX convention for "command not found". +func isBinaryNotFound(exitCode int, output string) bool { + if exitCode == 127 { + return true + } + // Fallback: match stderr patterns from non-standard shells. + lower := strings.ToLower(output) + return strings.Contains(lower, "not found") && + (strings.Contains(lower, "command") || strings.Contains(lower, "sh:")) +} + +// MaybeSandboxHint returns the sandbox hint suffix if the error looks like +// a missing binary. Returns empty string otherwise. +// Only call this from sandbox execution paths — never from host execution. +func MaybeSandboxHint(exitCode int, output string) string { + if isBinaryNotFound(exitCode, output) { + return sandboxHint + } + return "" +} diff --git a/internal/tools/sandbox_hints_test.go b/internal/tools/sandbox_hints_test.go new file mode 100644 index 00000000..711fb0aa --- /dev/null +++ b/internal/tools/sandbox_hints_test.go @@ -0,0 +1,63 @@ +package tools + +import ( + "strings" + "testing" +) + +func TestIsBinaryNotFound(t *testing.T) { + tests := []struct { + name string + exitCode int + output string + want bool + }{ + {"exit 127 empty output", 127, "", true}, + {"exit 127 with sh not found", 127, "sh: git: not found", true}, + {"exit 127 with bash error", 127, "bash: python3: command not found", true}, + {"exit 1 permission denied", 1, "permission denied", false}, + {"exit 0 success", 0, "ok", false}, + {"exit 1 sh not found pattern", 1, "sh: node: not found", true}, + {"exit 1 command not found pattern", 1, "bash: command not found: curl", true}, + {"exit 1 unrelated not found", 1, "file not found: config.yaml", false}, + {"exit 2 no such file", 2, "No such file or directory", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := isBinaryNotFound(tt.exitCode, tt.output) + if got != tt.want { + t.Errorf("isBinaryNotFound(%d, %q) = %v, want %v", tt.exitCode, tt.output, got, tt.want) + } + }) + } +} + +func TestMaybeSandboxHint(t *testing.T) { + tests := []struct { + name string + exitCode int + output string + wantHint bool + }{ + {"binary not found returns hint", 127, "sh: git: not found", true}, + {"success returns empty", 0, "ok", false}, + {"non-binary error returns empty", 1, "segfault", false}, + {"sh pattern returns hint", 1, "sh: python3: not found", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := MaybeSandboxHint(tt.exitCode, tt.output) + if tt.wantHint { + if !strings.Contains(got, "[SANDBOX]") { + t.Errorf("MaybeSandboxHint(%d, %q) = %q, want hint containing [SANDBOX]", tt.exitCode, tt.output, got) + } + } else { + if got != "" { + t.Errorf("MaybeSandboxHint(%d, %q) = %q, want empty", tt.exitCode, tt.output, got) + } + } + }) + } +} diff --git a/internal/tools/shell.go b/internal/tools/shell.go index b61eb757..f31dad44 100644 --- a/internal/tools/shell.go +++ b/internal/tools/shell.go @@ -322,6 +322,7 @@ func (t *ExecTool) executeInSandbox(ctx context.Context, command, cwd, sandboxKe if output == "" { output = fmt.Sprintf("command exited with code %d", result.ExitCode) } + output += MaybeSandboxHint(result.ExitCode, output) return ErrorResult(output) } if output == "" {