mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-12 19:13:28 -10:00
* fix(tools): fix cmd.exe /S/C corrupting commands with embedded quotes Any shell command whose own text contains embedded double quotes - python -c "print(15 / 3)", git commit -m "message", node -e "..." - was silently corrupted on Windows before reaching the target program. Reported live: a model ran python -c "print(15 / 3)" and got back a SyntaxError showing the code Python received as literally "print(15, truncated right before the /. Root cause: bash.go passes the full command text as ONE argv element to cmd.exe /d /s /c, and Go's os/exec escapes it correctly for a standard CommandLineToArgvW-style parser (wrapping it in quotes, backslash-escaping the embedded ones). But cmd.exe's own /C remainder parsing does not follow those rules once /S is present: per its documented behavior, it strips the FIRST and LAST literal quote character in the whole remainder - not a matched pair - before handing the (now corrupted) text along to be re-tokenized. The escaping was correct for one layer of parsing; the command line actually goes through two. Fix: on Windows, write commandText to a temp .cmd script file and point /c at the file's path instead of the raw command text. This sidesteps cmd.exe's /C-remainder heuristic entirely, since the remainder is then just a plain path with no quotes to mis-strip. @echo off is prepended since cmd.exe treats a /c target ending in .cmd as a batch file and echoes each line it runs by default, which would otherwise pollute captured stdout (caught by the existing bash tool test suite before this commit, not left in). buildBashCommand now returns a cleanup func (in addition to plan.Cleanup) for the temp file. Both callers updated: bash.go's one-shot run defers it immediately; exec_command.go's persistent session composes it with startExecProcess's own cleanup and runs it after the session's process actually exits, not before. This fix lives upstream of the sandbox wrapping, in how the Name/Args spec is constructed before either the direct or sandboxed code path consumes it, so both benefit without separate sandbox-specific changes - cmd.exe's own command-line parsing does not depend on what token launched it. New TestBashToolPreservesEmbeddedQuotesOnWindows pins the exact failure shape via a self-dispatch test helper (a quoted argument containing a space and a slash, matching the original bug report's truncation point), with no dependency on python being installed. Full internal/tools suite green, excluding 3 failures pre-existing and identical on unmodified main in my environment (unrelated path-assumption issues under a deeply nested working directory). Co-Authored-By: Claude Fable 5 <[email protected]> * fix(tools): pass the shell command line to cmd.exe raw instead of via a temp script Review on the original fix found real problems with running commandText from a temporary .cmd file: it silently changed cmd.exe command-line syntax into batch-file syntax (for example, for %i loops need %%i only inside an actual .bat/.cmd file), /s was still corrupting the script path itself if it contained spaces, and the script leaked on sandbox planning errors. The actual root cause was narrower than the temp-file approach assumed. cmd.exe's own /C remainder parsing is not CommandLineToArgvW-compatible: it strips the first and last literal quote from the remainder whenever it starts with a quote, without undoing backslash-escaping. Passing commandText as a normal exec.Cmd argument makes Go wrap it in exactly that shape (quoted because it has spaces, inner quotes escaped as \") since it contains embedded quotes, which is what the stripping then corrupts. Fixes it at the source instead: commandText now reaches cmd.exe completely unescaped (via SysProcAttr.CmdLine for the unwrapped path, and the same treatment inside the sandboxed runner's own process creation for the wrapped path), exactly as if it had been typed at an interactive prompt. No outer quotes means the stripping heuristic never triggers for a command like `python -c "print(15 / 3)"`, and a command that legitimately starts with a quoted executable path is handled by cmd.exe's own careful quote-preserving special case for that shape. Also drops /s, which forced the more aggressive of cmd.exe's two stripping behaviors. No temp file, so nothing to leak on error, and no batch-file reinterpretation of the command text. internal/sandbox/windows_runner.go now owns the shared shell-argv shape (WindowsShellArgs/WindowsShellCommandLine) so internal/tools/bash.go and the sandboxed runner's windowsCommandLine build the exact same raw command line instead of duplicating the flag literals in two places. * chore: retrigger CI The Windows smoke job failed on TestLoadOrCreateSecretConcurrentConverges in internal/oauth (a lock-file access-denied flake), unrelated to this change. internal/tools and internal/sandbox, the packages this PR touches, both passed. Retriggering to confirm. --------- Co-authored-by: Claude Fable 5 <[email protected]>
638 lines
22 KiB
Go
638 lines
22 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/Gitlawb/zero/internal/sandbox"
|
|
)
|
|
|
|
func TestMain(m *testing.M) {
|
|
if len(os.Args) >= 3 && os.Args[1] == "--zero-bash-helper" {
|
|
if os.Args[2] == "echo-arg" {
|
|
// Echoes back exactly the one argument it received, to verify a
|
|
// quoted, space-and-slash-containing argument (the same shape as
|
|
// `python -c "print(15 / 3)"`) survives the shell invocation
|
|
// intact instead of being truncated/corrupted.
|
|
if len(os.Args) < 4 {
|
|
fmt.Fprintln(os.Stderr, "echo-arg requires exactly one argument")
|
|
os.Exit(1)
|
|
}
|
|
fmt.Println(os.Args[3])
|
|
return
|
|
}
|
|
runBashToolHelper(os.Args[2])
|
|
return
|
|
}
|
|
|
|
os.Exit(m.Run())
|
|
}
|
|
|
|
func runBashToolHelper(command string) {
|
|
switch command {
|
|
case "success":
|
|
fmt.Println("hello from bash")
|
|
case "stderr":
|
|
fmt.Fprintln(os.Stderr, "warning from bash")
|
|
case "fail":
|
|
fmt.Println("before failure")
|
|
fmt.Fprintln(os.Stderr, "failure details")
|
|
os.Exit(7)
|
|
case "pwd":
|
|
cwd, err := os.Getwd()
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, err)
|
|
os.Exit(1)
|
|
}
|
|
fmt.Println(cwd)
|
|
case "sleep":
|
|
time.Sleep(250 * time.Millisecond)
|
|
fmt.Println("woke up")
|
|
case "long-sleep":
|
|
time.Sleep(5 * time.Second)
|
|
fmt.Println("long sleep finished")
|
|
case "http-server":
|
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, err)
|
|
os.Exit(1)
|
|
}
|
|
fmt.Println("listening", listener.Addr().String())
|
|
server := &http.Server{
|
|
Handler: http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
|
_, _ = response.Write([]byte("zero-server-ok"))
|
|
}),
|
|
}
|
|
if err := server.Serve(listener); err != nil && err != http.ErrServerClosed {
|
|
fmt.Fprintln(os.Stderr, err)
|
|
os.Exit(1)
|
|
}
|
|
default:
|
|
fmt.Fprintln(os.Stderr, "unknown helper command")
|
|
os.Exit(2)
|
|
}
|
|
}
|
|
|
|
func TestCoreToolsExposeShellTools(t *testing.T) {
|
|
toolset := CoreTools(t.TempDir())
|
|
byName := make(map[string]Tool, len(toolset))
|
|
for _, tool := range toolset {
|
|
byName[tool.Name()] = tool
|
|
}
|
|
|
|
for _, name := range []string{"exec_command", "write_stdin", "bash"} {
|
|
tool, ok := byName[name]
|
|
if !ok {
|
|
t.Fatalf("expected core tools to include %s", name)
|
|
}
|
|
if tool.Safety().SideEffect != SideEffectShell {
|
|
t.Fatalf("%s side effect = %s, want shell", name, tool.Safety().SideEffect)
|
|
}
|
|
wantPermission := PermissionPrompt
|
|
if tool.Safety().Permission != wantPermission {
|
|
t.Fatalf("%s permission = %s, want %s", name, tool.Safety().Permission, wantPermission)
|
|
}
|
|
if name == "write_stdin" && !tool.Safety().AdvertiseInAuto {
|
|
t.Fatalf("write_stdin should stay visible in auto mode for polling and interrupts")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBashToolDescribesHostShellSyntax(t *testing.T) {
|
|
tool := NewBashTool(t.TempDir())
|
|
schema := tool.Parameters()
|
|
descriptionParts := []string{tool.Description()}
|
|
for _, property := range schema.Properties {
|
|
descriptionParts = append(descriptionParts, property.Description)
|
|
}
|
|
description := strings.ToLower(strings.Join(descriptionParts, " "))
|
|
if !strings.Contains(description, "sandbox_permissions") ||
|
|
!strings.Contains(description, "require_escalated") ||
|
|
!strings.Contains(description, "host/global process") ||
|
|
!strings.Contains(description, "sandbox namespaces") {
|
|
t.Fatalf("expected sandbox escalation guidance in bash description, got %q", description)
|
|
}
|
|
|
|
if runtime.GOOS == "windows" {
|
|
if !strings.Contains(description, "cmd.exe") || !strings.Contains(description, "cwd") {
|
|
t.Fatalf("expected Windows cmd.exe and cwd guidance in bash description, got %q", description)
|
|
}
|
|
return
|
|
}
|
|
if !strings.Contains(description, "/bin/sh") {
|
|
t.Fatalf("expected /bin/sh guidance in bash description, got %q", description)
|
|
}
|
|
}
|
|
|
|
func TestDetectShellCommandIssueFlagsWindowsBashisms(t *testing.T) {
|
|
issue := detectShellCommandIssue(`cd /d/tmp/zero-pr-158 && ls -la`, "windows")
|
|
if issue == nil {
|
|
t.Fatal("expected Windows bash-style cd command to be flagged")
|
|
}
|
|
for _, want := range []string{"Windows cmd.exe", "cwd", "list_directory"} {
|
|
if !strings.Contains(issue.Message+" "+issue.Suggestion, want) {
|
|
t.Fatalf("expected issue to mention %q, got %#v", want, issue)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDetectShellCommandIssueAllowsWindowsCDSwitch(t *testing.T) {
|
|
issue := detectShellCommandIssue(`cd /d D:\tmp\zero-pr-158 && dir`, "windows")
|
|
if issue != nil {
|
|
t.Fatalf("expected valid Windows cd /d switch to pass, got %#v", issue)
|
|
}
|
|
}
|
|
|
|
func TestDetectShellCommandIssueRequiresActualLSCommand(t *testing.T) {
|
|
for _, command := range []string{
|
|
`echo false ls -la`,
|
|
`echo list -items`,
|
|
`powershell -NoProfile -Command "Write-Output ls -la"`,
|
|
} {
|
|
if issue := detectShellCommandIssue(command, "windows"); issue != nil {
|
|
t.Fatalf("expected incidental ls text to pass for %q, got %#v", command, issue)
|
|
}
|
|
}
|
|
|
|
for _, command := range []string{
|
|
`ls -la`,
|
|
`cd C:\tmp && ls -la`,
|
|
`cd C:\tmp && ls`,
|
|
} {
|
|
if issue := detectShellCommandIssue(command, "windows"); issue == nil {
|
|
t.Fatalf("expected actual ls command to be flagged for %q", command)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDetectShellCommandIssueFlagsPipedPosixUtilities(t *testing.T) {
|
|
for _, command := range []string{
|
|
`git log --oneline -- pinokio_compat.py | head`,
|
|
`git blame -L 40,46 pinokio_compat.py --format=%author | grep summary`,
|
|
`cat file.txt | wc -l`,
|
|
`some_command | tail -n 20`,
|
|
} {
|
|
if issue := detectShellCommandIssue(command, "windows"); issue == nil {
|
|
t.Fatalf("expected POSIX utility pipeline to be flagged for %q", command)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDetectShellCommandIssueAllowsUnrelatedCommands(t *testing.T) {
|
|
for _, command := range []string{
|
|
`git log --oneline`,
|
|
`dir /b`,
|
|
`findstr /r "summary" file.txt`,
|
|
`echo header text`,
|
|
`tail-log.sh`,
|
|
`grep-cli --version`,
|
|
`sed.exe --help`,
|
|
} {
|
|
if issue := detectShellCommandIssue(command, "windows"); issue != nil {
|
|
t.Fatalf("expected unrelated command to pass for %q, got %#v", command, issue)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDetectShellOutputIssueAddsWindowsSyntaxHint(t *testing.T) {
|
|
issue := detectShellOutputIssue(`cd /d/tmp/zero-pr-158 && ls -la`, "The syntax of the command is incorrect.", "windows")
|
|
if issue == nil {
|
|
t.Fatal("expected Windows syntax error to get shell guidance")
|
|
}
|
|
rendered := appendShellIssueHint("stderr:\nThe syntax of the command is incorrect.\nexit_code: 1", *issue)
|
|
for _, want := range []string{"[zero] shell issue:", "Windows cmd.exe", "Suggestion:"} {
|
|
if !strings.Contains(rendered, want) {
|
|
t.Fatalf("expected rendered hint to contain %q, got %q", want, rendered)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRegistryBlocksBashWithoutGrant(t *testing.T) {
|
|
registry := NewRegistry()
|
|
registry.Register(NewBashTool(t.TempDir()))
|
|
|
|
result := registry.Run(context.Background(), "bash", map[string]any{
|
|
"command": helperCommand("success"),
|
|
})
|
|
|
|
if result.Status != StatusError {
|
|
t.Fatalf("expected error status, got %s", result.Status)
|
|
}
|
|
if !strings.Contains(result.Output, "Permission required for bash") {
|
|
t.Fatalf("expected permission error, got %q", result.Output)
|
|
}
|
|
}
|
|
|
|
func TestBashToolRunsCommandInWorkspace(t *testing.T) {
|
|
root := t.TempDir()
|
|
|
|
result := NewBashTool(root).Run(context.Background(), map[string]any{
|
|
"command": helperCommand("success"),
|
|
})
|
|
|
|
if result.Status != StatusOK {
|
|
t.Fatalf("expected ok status, got %s: %s", result.Status, result.Output)
|
|
}
|
|
if !strings.Contains(result.Output, "stdout:\nhello from bash") {
|
|
t.Fatalf("expected stdout in output, got %q", result.Output)
|
|
}
|
|
if result.Meta["exit_code"] != "0" {
|
|
t.Fatalf("expected exit_code metadata 0, got %q", result.Meta["exit_code"])
|
|
}
|
|
if result.Meta["cwd"] != "." {
|
|
t.Fatalf("expected cwd metadata ., got %q", result.Meta["cwd"])
|
|
}
|
|
}
|
|
|
|
func TestBashToolUsesRequestedCwd(t *testing.T) {
|
|
root := t.TempDir()
|
|
nested := filepath.Join(root, "nested")
|
|
if err := os.MkdirAll(nested, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
result := NewBashTool(root).Run(context.Background(), map[string]any{
|
|
"command": helperCommand("pwd"),
|
|
"cwd": "nested",
|
|
})
|
|
|
|
if result.Status != StatusOK {
|
|
t.Fatalf("expected ok status, got %s: %s", result.Status, result.Output)
|
|
}
|
|
normalizedOutput := filepath.ToSlash(strings.TrimSpace(result.Output))
|
|
if !strings.Contains(normalizedOutput, "stdout:\n") || !strings.HasSuffix(normalizedOutput, "/nested") {
|
|
t.Fatalf("expected command to run in nested cwd, got %q", result.Output)
|
|
}
|
|
if result.Meta["cwd"] != "nested" {
|
|
t.Fatalf("expected cwd metadata nested, got %q", result.Meta["cwd"])
|
|
}
|
|
}
|
|
|
|
func TestBashToolRejectsCwdOutsideWorkspace(t *testing.T) {
|
|
outside := t.TempDir()
|
|
|
|
result := NewBashTool(t.TempDir()).Run(context.Background(), map[string]any{
|
|
"command": helperCommand("success"),
|
|
"cwd": outside,
|
|
})
|
|
|
|
if result.Status != StatusError {
|
|
t.Fatalf("expected error status, got %s", result.Status)
|
|
}
|
|
if !strings.Contains(result.Output, "must stay inside the workspace") {
|
|
t.Fatalf("expected workspace error, got %q", result.Output)
|
|
}
|
|
}
|
|
|
|
func TestBashToolReturnsNonzeroExitAsError(t *testing.T) {
|
|
result := NewBashTool(t.TempDir()).Run(context.Background(), map[string]any{
|
|
"command": helperCommand("fail"),
|
|
})
|
|
|
|
if result.Status != StatusError {
|
|
t.Fatalf("expected error status, got %s", result.Status)
|
|
}
|
|
for _, want := range []string{"stdout:\nbefore failure", "stderr:\nfailure details", "exit_code: 7"} {
|
|
if !strings.Contains(result.Output, want) {
|
|
t.Fatalf("expected output to contain %q, got %q", want, result.Output)
|
|
}
|
|
}
|
|
if result.Meta["exit_code"] != "7" {
|
|
t.Fatalf("expected exit_code metadata 7, got %q", result.Meta["exit_code"])
|
|
}
|
|
}
|
|
|
|
func TestBashToolTimesOut(t *testing.T) {
|
|
result := NewBashTool(t.TempDir()).Run(context.Background(), map[string]any{
|
|
"command": helperCommand("sleep"),
|
|
"timeout_ms": 20,
|
|
})
|
|
|
|
if result.Status != StatusError {
|
|
t.Fatalf("expected error status, got %s", result.Status)
|
|
}
|
|
if !strings.Contains(result.Output, "timed out after 20ms") {
|
|
t.Fatalf("expected timeout error, got %q", result.Output)
|
|
}
|
|
if result.Meta["timeout_ms"] != "20" {
|
|
t.Fatalf("expected timeout_ms metadata 20, got %q", result.Meta["timeout_ms"])
|
|
}
|
|
}
|
|
|
|
func TestBashToolTimeoutKillsBackgroundChildren(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("process-group kill is POSIX-only")
|
|
}
|
|
// Shorten the post-kill I/O drain so this regression doesn't bake several
|
|
// seconds into every run; the child sleep below is correspondingly short.
|
|
defer func(prev time.Duration) { bashWaitDelay = prev }(bashWaitDelay)
|
|
bashWaitDelay = 200 * time.Millisecond
|
|
|
|
root := t.TempDir()
|
|
sentinel := filepath.Join(root, "leaked")
|
|
// A backgrounded child sleeps past the timeout, then drops a sentinel. `wait`
|
|
// keeps the foreground shell alive so the deadline fires while the child is
|
|
// still running. Without process-group kill the child is orphaned: it
|
|
// survives, eventually writes the sentinel, and (because it inherited the
|
|
// stdout pipe) blocks Run() until it exits.
|
|
const childSleep = time.Second
|
|
command := fmt.Sprintf("(sleep 1; touch %s) & wait", shellQuote(sentinel))
|
|
|
|
start := time.Now()
|
|
result := NewBashTool(root).Run(context.Background(), map[string]any{
|
|
"command": command,
|
|
"timeout_ms": 300,
|
|
})
|
|
elapsed := time.Since(start)
|
|
|
|
if result.Status != StatusError {
|
|
t.Fatalf("expected timeout error status, got %s: %q", result.Status, result.Output)
|
|
}
|
|
if elapsed > time.Second {
|
|
t.Fatalf("Run blocked %s past the 300ms timeout; background child held the pipes", elapsed)
|
|
}
|
|
|
|
// Give the child more than its sleep to fire if it survived the timeout.
|
|
time.Sleep(childSleep + 500*time.Millisecond)
|
|
if _, err := os.Stat(sentinel); err == nil {
|
|
t.Fatalf("background child survived the timeout and wrote %s", sentinel)
|
|
} else if !os.IsNotExist(err) {
|
|
t.Fatalf("unexpected error stat-ing sentinel: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRegistryRunsWithDegradedUnavailableNativeSandbox(t *testing.T) {
|
|
root := t.TempDir()
|
|
registry := NewRegistry()
|
|
registry.Register(NewBashTool(root))
|
|
engine := sandbox.NewEngine(sandbox.EngineOptions{
|
|
WorkspaceRoot: root,
|
|
Policy: sandbox.DefaultPolicy(),
|
|
Backend: sandbox.Backend{Name: sandbox.BackendUnavailable, Message: "native sandbox unavailable"},
|
|
})
|
|
|
|
result := registry.RunWithOptions(context.Background(), "bash", map[string]any{
|
|
"command": helperCommand("success"),
|
|
}, RunOptions{
|
|
PermissionGranted: true,
|
|
Sandbox: engine,
|
|
PermissionMode: string(sandbox.PermissionUnsafe),
|
|
Autonomy: "medium",
|
|
})
|
|
|
|
if result.Status != StatusOK || !strings.Contains(result.Output, "hello from bash") {
|
|
t.Fatalf("expected degraded command to run, got %s: %s", result.Status, result.Output)
|
|
}
|
|
if result.Meta["sandbox_wrapped"] != "false" || result.Meta["sandbox_enforcement_level"] != string(sandbox.EnforcementDegraded) {
|
|
t.Fatalf("sandbox metadata = %#v, want degraded direct plan", result.Meta)
|
|
}
|
|
}
|
|
|
|
func TestBashToolRunsWithDegradedUnavailableNativeSandbox(t *testing.T) {
|
|
root := t.TempDir()
|
|
policy := sandbox.DefaultPolicy()
|
|
engine := sandbox.NewEngine(sandbox.EngineOptions{
|
|
WorkspaceRoot: root,
|
|
Policy: policy,
|
|
Backend: sandbox.Backend{Name: sandbox.BackendUnavailable, Message: "native sandbox unavailable"},
|
|
})
|
|
|
|
result := NewBashTool(root).(interface {
|
|
RunWithSandbox(context.Context, map[string]any, *sandbox.Engine) Result
|
|
}).RunWithSandbox(context.Background(), map[string]any{
|
|
"command": helperCommand("success"),
|
|
}, engine)
|
|
|
|
if result.Status != StatusOK || !strings.Contains(result.Output, "hello from bash") {
|
|
t.Fatalf("expected degraded command to run, got %s: %q", result.Status, result.Output)
|
|
}
|
|
if result.Meta["sandbox_wrapped"] != "false" || result.Meta["sandbox_enforcement_level"] != string(sandbox.EnforcementDegraded) {
|
|
t.Fatalf("sandbox metadata = %#v, want degraded direct plan", result.Meta)
|
|
}
|
|
}
|
|
|
|
func TestBashToolBuildsWrappedSandboxExecCommand(t *testing.T) {
|
|
root := t.TempDir()
|
|
resolvedRoot, err := filepath.EvalSymlinks(root)
|
|
if err != nil {
|
|
t.Fatalf("EvalSymlinks(%q): %v", root, err)
|
|
}
|
|
engine := sandbox.NewEngine(sandbox.EngineOptions{
|
|
WorkspaceRoot: root,
|
|
Policy: sandbox.DefaultPolicy(),
|
|
Backend: sandbox.Backend{
|
|
Name: sandbox.BackendMacOSSeatbelt,
|
|
Available: true,
|
|
Executable: "/usr/bin/sandbox-exec",
|
|
},
|
|
})
|
|
|
|
command, plan, err := buildBashCommand(context.Background(), "pwd", root, engine)
|
|
if err != nil {
|
|
t.Fatalf("buildBashCommand: %v", err)
|
|
}
|
|
if command.Path != "/usr/bin/sandbox-exec" || !plan.Wrapped {
|
|
t.Fatalf("command path = %q plan = %#v, want wrapped sandbox-exec", command.Path, plan)
|
|
}
|
|
if len(command.Args) < 5 || command.Args[1] != "-p" || !strings.Contains(command.Args[2], "(deny network*)") {
|
|
t.Fatalf("sandbox-exec args = %#v, want inline profile", command.Args)
|
|
}
|
|
if command.Dir != resolvedRoot || plan.SandboxDir != resolvedRoot {
|
|
t.Fatalf("dirs = command %q plan %q, want root", command.Dir, plan.SandboxDir)
|
|
}
|
|
}
|
|
|
|
func TestBashToolRunsWithHostSandboxBackendWhenAvailable(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("Windows sandbox adapter is owned by the Windows integration slice")
|
|
}
|
|
backend := sandbox.SelectBackend(sandbox.BackendOptions{})
|
|
if !backend.Available || backend.Name == sandbox.BackendUnavailable {
|
|
t.Skipf("host sandbox backend unavailable: %s", backend.Message)
|
|
}
|
|
root := t.TempDir()
|
|
engine := sandbox.NewEngine(sandbox.EngineOptions{
|
|
WorkspaceRoot: root,
|
|
Policy: sandbox.DefaultPolicy(),
|
|
Backend: backend,
|
|
})
|
|
|
|
result := NewBashTool(root).(interface {
|
|
RunWithSandbox(context.Context, map[string]any, *sandbox.Engine) Result
|
|
}).RunWithSandbox(context.Background(), map[string]any{
|
|
"command": "printf sandbox-ok",
|
|
}, engine)
|
|
|
|
if result.Status != StatusOK {
|
|
t.Fatalf("expected host sandbox command to run, got %s: %s; meta=%#v", result.Status, result.Output, result.Meta)
|
|
}
|
|
if !strings.Contains(result.Output, "sandbox-ok") {
|
|
t.Fatalf("expected sandbox command output, got %q", result.Output)
|
|
}
|
|
if result.Meta["sandbox_backend"] != string(backend.Name) || result.Meta["sandbox_wrapped"] != "true" {
|
|
t.Fatalf("sandbox metadata = %#v, want wrapped host backend %s", result.Meta, backend.Name)
|
|
}
|
|
}
|
|
|
|
func TestBashToolBlocksInteractiveCommandBeforeExecution(t *testing.T) {
|
|
root := t.TempDir()
|
|
|
|
result := NewBashTool(root).Run(context.Background(), map[string]any{
|
|
"command": "vim main.go",
|
|
})
|
|
|
|
if result.Status != StatusError {
|
|
t.Fatalf("expected error status for interactive command, got %s: %s", result.Status, result.Output)
|
|
}
|
|
if !strings.Contains(result.Output, "interactive") {
|
|
t.Fatalf("expected interactive guard message, got %q", result.Output)
|
|
}
|
|
if !strings.Contains(result.Output, "edit_file") && !strings.Contains(result.Output, "sed") {
|
|
t.Fatalf("expected actionable suggestion, got %q", result.Output)
|
|
}
|
|
// The command must NOT have run: exit_code metadata should mark a pre-exec block.
|
|
if result.Meta["exit_code"] != "-1" {
|
|
t.Fatalf("expected exit_code -1 (not executed), got %q", result.Meta["exit_code"])
|
|
}
|
|
if result.Meta["safety_block"] != "interactive_command" {
|
|
t.Fatalf("expected safety_block metadata, got %#v", result.Meta)
|
|
}
|
|
if result.Display.Kind != "shell" || !strings.Contains(result.Display.Summary, "Blocked") {
|
|
t.Fatalf("expected blocked display annotation, got %#v", result.Display)
|
|
}
|
|
}
|
|
|
|
func TestBashToolBlocksInteractiveCommandThroughSandbox(t *testing.T) {
|
|
root := t.TempDir()
|
|
engine := sandbox.NewEngine(sandbox.EngineOptions{
|
|
WorkspaceRoot: root,
|
|
Policy: sandbox.DefaultPolicy(),
|
|
Backend: sandbox.Backend{Name: sandbox.BackendUnavailable, Message: "native sandbox unavailable"},
|
|
})
|
|
|
|
result := NewBashTool(root).(interface {
|
|
RunWithSandbox(context.Context, map[string]any, *sandbox.Engine) Result
|
|
}).RunWithSandbox(context.Background(), map[string]any{
|
|
"command": "less /etc/hosts",
|
|
}, engine)
|
|
|
|
if result.Status != StatusError {
|
|
t.Fatalf("expected error status, got %s: %s", result.Status, result.Output)
|
|
}
|
|
if !strings.Contains(result.Output, "interactive") || !strings.Contains(result.Output, "cat") {
|
|
t.Fatalf("expected pager guard message with cat suggestion, got %q", result.Output)
|
|
}
|
|
}
|
|
|
|
func TestBashToolAllowsNonInteractiveCommand(t *testing.T) {
|
|
root := t.TempDir()
|
|
|
|
result := NewBashTool(root).Run(context.Background(), map[string]any{
|
|
"command": helperCommand("success"),
|
|
})
|
|
|
|
if result.Status != StatusOK {
|
|
t.Fatalf("expected ok status for non-interactive command, got %s: %s", result.Status, result.Output)
|
|
}
|
|
if result.Meta["safety_block"] != "" {
|
|
t.Fatalf("did not expect a safety block, got %#v", result.Meta)
|
|
}
|
|
}
|
|
|
|
// TestBashToolPreservesEmbeddedQuotesOnWindows pins a real, previously-broken
|
|
// case: passing commandText as a normal exec.Cmd argument makes Go wrap it in
|
|
// an outer pair of quotes (it contains spaces) with its own quotes escaped as
|
|
// \", and cmd.exe's /C remainder parsing strips the first and last literal
|
|
// quote character in that remainder without undoing the backslash-escaping -
|
|
// corrupting a command whose own text contains embedded double quotes,
|
|
// exactly the shape of `python -c "print(15 / 3)"`, `git commit -m
|
|
// "message"`, `node -e "..."`. Before the fix, the helper below received a
|
|
// truncated, mis-quoted argument (starting with a stray literal `"`) instead
|
|
// of the text between the quotes.
|
|
func TestBashToolPreservesEmbeddedQuotesOnWindows(t *testing.T) {
|
|
if runtime.GOOS != "windows" {
|
|
t.Skip("cmd.exe /S/C quote-remainder parsing is Windows-specific")
|
|
}
|
|
root := t.TempDir()
|
|
executable := shellQuote(os.Args[0])
|
|
// Space and a slash: the two characters whose position in the original
|
|
// bug report's SyntaxError ("print(15, truncated right before the /")
|
|
// showed exactly where the corruption happened.
|
|
commandText := executable + ` --zero-bash-helper echo-arg "hello / world"`
|
|
|
|
result := NewBashTool(root).Run(context.Background(), map[string]any{
|
|
"command": commandText,
|
|
})
|
|
|
|
if result.Status != StatusOK {
|
|
t.Fatalf("expected ok status, got %s: %s", result.Status, result.Output)
|
|
}
|
|
if !strings.Contains(result.Output, "hello / world") {
|
|
t.Fatalf("expected the embedded-quote argument to survive intact, got %q", result.Output)
|
|
}
|
|
}
|
|
|
|
// TestBashToolRunsCommandLineForLoopSyntax pins cmd.exe command-line syntax,
|
|
// not batch-file syntax: a `for %i in (...) do ...` loop with a single
|
|
// percent sign is valid typed directly at a cmd.exe prompt (or via
|
|
// `cmd /C "..."`), but requires %%i inside an actual .bat/.cmd FILE, because
|
|
// batch files perform an extra pass of percent-substitution before FOR ever
|
|
// runs, consuming the single percent so the loop variable never binds. An
|
|
// earlier version of this fix ran commandText from a temporary .cmd file
|
|
// instead of passing it straight through to cmd.exe's /C remainder, which
|
|
// silently broke single-percent syntax like this.
|
|
func TestBashToolRunsCommandLineForLoopSyntax(t *testing.T) {
|
|
if runtime.GOOS != "windows" {
|
|
t.Skip("cmd.exe command-line vs batch-file parsing is Windows-specific")
|
|
}
|
|
root := t.TempDir()
|
|
|
|
result := NewBashTool(root).Run(context.Background(), map[string]any{
|
|
"command": "for %i in (1 2 3) do echo %i",
|
|
})
|
|
|
|
if result.Status != StatusOK {
|
|
t.Fatalf("expected ok status, got %s: %s", result.Status, result.Output)
|
|
}
|
|
for _, want := range []string{"1", "2", "3"} {
|
|
if !strings.Contains(result.Output, want) {
|
|
t.Fatalf("expected the for-loop to expand %%i to %q, got %q", want, result.Output)
|
|
}
|
|
}
|
|
}
|
|
|
|
func helperCommand(name string) string {
|
|
executable := shellQuote(os.Args[0])
|
|
return executable + " --zero-bash-helper " + name
|
|
}
|
|
|
|
func shellQuote(value string) string {
|
|
if runtime.GOOS == "windows" {
|
|
return value
|
|
}
|
|
return "'" + strings.ReplaceAll(value, "'", `'\''`) + "'"
|
|
}
|
|
|
|
func TestAppendSandboxBlocks(t *testing.T) {
|
|
if got := appendSandboxBlocks("err output", nil); got != "err output" {
|
|
t.Fatalf("no blocks must leave stderr unchanged, got %q", got)
|
|
}
|
|
got := appendSandboxBlocks("err output", []string{"deny file-write-create /etc/x"})
|
|
for _, want := range []string{"err output", "<sandbox_blocks>", "deny file-write-create /etc/x", "</sandbox_blocks>"} {
|
|
if !strings.Contains(got, want) {
|
|
t.Fatalf("annotated stderr missing %q:\n%s", want, got)
|
|
}
|
|
}
|
|
if block := appendSandboxBlocks("", []string{"deny x"}); strings.HasPrefix(block, "\n") {
|
|
t.Fatalf("empty stderr must not yield a leading newline: %q", block)
|
|
}
|
|
}
|