Files
zero/internal/tools/shell_runtime_test.go
euxaristiaandClaude Fable 5 81aad58d97 fix(tools): Block MSYS coreutils under Windows sandbox (#476)
* fix(tools): block MSYS coreutils under Windows sandbox

Git for Windows usr\bin coreutils (head, grep, cat, ...) are MSYS PE binaries that fail with ACCESS_DENIED and cygheap/signal-pipe errors when Zero runs them in the write-restricted Windows sandbox. Detect and block these commands before execution, surface MSYS runtime failures from stderr, steer agents toward native Zero tools or require_escalated, and exclude MSYS-prone names from known-safe command tails.

Refs #458

* fix(tools): anchor the last MSYS failure heuristic to MSYS markers

CodeRabbit review feedback: the win32-error-5 plus terminating check
had no MSYS-specific anchor, so unrelated access-denied failures could
be mislabeled as windows_msys_sandbox. Require an MSYS marker like the
neighboring checks do.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(tools): let escalation bypass the MSYS guard and make detection quote-aware

Addresses jatmn's review on PR #476:
- bash and exec_command called detectShellCommandIssue before resolving
  sandbox_permissions into a command engine, so a require_escalated retry of
  a blocked command was still hard-blocked by the same preflight check.
  Resolve the command engine first and only bypass the windows_msys_sandbox
  issue kind when that resolves to true unsandboxed execution; other issue
  kinds (real cmd.exe syntax problems) still block regardless of escalation.
- Unify all MSYS-prone command name checks (MsysProneCommandName, the
  preflight scan, and transitively command_prefix.go's known-safe guard)
  behind one windowsMsysProneNames set. This fixes expr (in the name set but
  missing from the old regex alternations, so never proactively flagged) and
  ls (hit the older windows_shell_syntax branch first, so it got different
  metadata than every other MSYS-prone name).
- Make the preflight scan quote-aware: split the command into cmd.exe-
  operator-separated segments respecting double-quote grouping, and only
  check the first word of each segment instead of scanning raw text anywhere.
  Fixes false positives on quoted text like commit messages or PR comment
  bodies that merely mention a coreutil name.

* test(tools): decouple MSYS-guard bypass tests from real command output

TestBashToolRequireEscalatedMsysGuard and
TestExecCommandRequireEscalatedBypassesMsysGuardAfterApproval asserted
shell_issue == "" to prove the require_escalated bypass worked. Once
bypassed, "cat somefile.txt" actually runs unsandboxed, and its real,
PATH-dependent output could trip the unrelated post-execution
detectShellOutputIssue heuristic, failing the assertion for reasons
unrelated to the preflight guard under test.

Assert on the exit_code "-1" sentinel (set only by shellIssueBlockResult)
instead, which directly reflects whether the preflight guard blocked the
command.

* fix(tools): anchor MSYS path detection to command position, fix prefix approval gap

Address jatmn's latest review on PR #476:

- shell_runtime.go: the explicit MSYS binary-path check (usr\bin\,
  mingw64\bin\, msys-2.0.dll, cygwin1.dll) ran against the raw command
  string, so a quoted mention (e.g. a commit message describing a
  usr\bin\head.exe failure) was still hard-blocked. It now runs against
  the first word of each operator-separated segment, same as the
  coreutil-name check. windowsCommandSegments also stopped treating ;
  as a command separator (cmd.exe does not split on ;, unlike bash) and
  now respects cmd.exe's ^ escape character, so escaped operators like
  echo ^| head are no longer misread as a real head invocation.

- command_prefix.go: proposedCommandPrefix could offer to approve a
  single segment (e.g. "ps aux" in "ps aux | head -5") without checking
  that every other segment was independently safe. Since approving a
  prefix escalates the whole command to bypass the sandbox, an
  MSYS-prone or otherwise unsafe segment could ride along uncovered.
  It now only proposes a segment as the prefix when every other
  segment in the command is known-safe on its own.

Added regression tests for quoted MSYS path mentions, caret-escaped
operators, semicolon text, and the prefix-approval gap.

Co-Authored-By: Claude Sonnet 5 <[email protected]>

* fix(tools): treat redirection as a command boundary, stop using command text as output evidence

Addresses jatmn's latest review on PR #476:
- firstCommandWord only split on whitespace, so cmd.exe redirection
  attached directly to a command name (head>out.txt, cat<in.txt,
  grep>matches.txt) was read as one word and never matched against the
  MSYS-prone name set. It now also stops at < and >.
- detectShellOutputIssue scanned command+output together, so a command
  whose own argument text merely quoted an MSYS failure string (e.g. a
  gh pr comment --body describing the bug) could be mislabeled after
  running even though the real stdout/stderr was unrelated. Removed the
  command parameter entirely from detectShellOutputIssue and both call
  sites (bash.go, exec_command.go), so the command line can never be
  used as post-execution evidence again.

* fix(tools): remove unused truncateHeadTail wrapper

CodeRabbit nitpick: truncateHeadTailWithTotal is called directly
everywhere, so this wrapper is dead code that trips golangci-lint's
unused check and fails CI.

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-06 08:03:32 +08:00

209 lines
8.9 KiB
Go

package tools
import (
"strings"
"testing"
)
func TestDetectShellCommandIssueFlagsMsysBinaryPaths(t *testing.T) {
for _, command := range []string{
`for /F %i in ('whoami') do echo %i | "C:\Program Files\Git\usr\bin\head.exe" -1`,
`C:\Git\usr\bin\grep.exe pattern file.txt`,
} {
issue := detectShellCommandIssue(command, "windows")
if issue == nil {
t.Fatalf("expected MSYS path issue for %q", command)
}
if issue.Kind != "windows_msys_sandbox" {
t.Fatalf("expected windows_msys_sandbox kind, got %q", issue.Kind)
}
if !strings.Contains(issue.Suggestion, "require_escalated") {
t.Fatalf("expected escalation guidance, got %#v", issue)
}
}
}
func TestDetectShellCommandIssueFlagsStandaloneCat(t *testing.T) {
issue := detectShellCommandIssue(`cat README.md`, "windows")
if issue == nil || issue.Kind != "windows_msys_sandbox" {
t.Fatalf("expected MSYS sandbox issue for cat, got %#v", issue)
}
}
func TestDetectShellOutputIssueFlagsMsysCreateFileMappingError(t *testing.T) {
output := `0 [main] head (3568) C:\Program Files\Git\usr\bin\head.exe: *** fatal error - CreateFileMapping S-1-5-21-3149109338-1484423945-518236903-1001.1, Win32 error 5. Terminating.`
issue := detectShellOutputIssue(output, "windows")
if issue == nil || issue.Kind != "windows_msys_sandbox" {
t.Fatalf("expected MSYS output issue, got %#v", issue)
}
if !strings.Contains(issue.Suggestion, "require_escalated") {
t.Fatalf("expected escalation guidance, got %#v", issue)
}
}
func TestDetectShellOutputIssueFlagsMsysSignalPipeError(t *testing.T) {
output := `0 [main] head (39684) cygheap_user::init: NtSetInformationToken (TokenDefaultDacl), 0xC0000022
648 [main] head (39684) C:\Program Files\Git\usr\bin\head.exe: *** fatal error - couldn't create signal pipe, Win32 error 5`
issue := detectShellOutputIssue(output, "windows")
if issue == nil || issue.Kind != "windows_msys_sandbox" {
t.Fatalf("expected MSYS output issue, got %#v", issue)
}
}
func TestDetectShellOutputIssueFlagsMsysTerminatingWithMsysMarker(t *testing.T) {
output := `1 [main] tail (4321) tail: *** MapViewOfFileEx failed, Win32 error 5. Terminating.`
issue := detectShellOutputIssue(output, "windows")
if issue == nil || issue.Kind != "windows_msys_sandbox" {
t.Fatalf("expected MSYS output issue, got %#v", issue)
}
}
func TestDetectShellOutputIssueIgnoresNonMsysWin32Error5(t *testing.T) {
output := `myapp.exe: unable to open service handle, Win32 error 5 (access denied). Terminating worker.`
issue := detectShellOutputIssue(output, "windows")
if issue != nil {
t.Fatalf("expected no issue for non-MSYS access-denied output, got %#v", issue)
}
}
func TestShellIssueBlockResultMsysCommand(t *testing.T) {
result := shellIssueBlockResult(*detectShellCommandIssue(`cat README.md`, "windows"))
if result.Status != StatusError {
t.Fatalf("status = %q, want error", result.Status)
}
for _, want := range []string{"[zero] shell issue:", "MSYS/Cygwin", "grep", "read_file", "require_escalated"} {
if !strings.Contains(result.Output, want) {
t.Fatalf("expected %q in blocked output, got %q", want, result.Output)
}
}
if result.Meta["shell_issue"] != "windows_msys_sandbox" {
t.Fatalf("meta shell_issue = %q", result.Meta["shell_issue"])
}
}
func TestMsysProneCommandName(t *testing.T) {
if !MsysProneCommandName("HEAD") || MsysProneCommandName("echo") {
t.Fatalf("unexpected MsysProneCommandName results")
}
}
// TestDetectShellCommandIssueFlagsExprAndLsConsistently guards against the
// preflight regex list drifting from the canonical windowsMsysProneNames set
// (both listed expr and ls as MSYS-prone, but the old regex alternations
// omitted expr entirely and let ls hit the older windows_shell_syntax branch
// first, so it never got MSYS-kind guidance).
func TestDetectShellCommandIssueFlagsExprAndLsConsistently(t *testing.T) {
for _, command := range []string{
`expr 1 + 1`,
`expr.exe 1 + 1`,
`ls -la`,
`ls`,
} {
issue := detectShellCommandIssue(command, "windows")
if issue == nil || issue.Kind != windowsMsysSandboxKind {
t.Fatalf("expected windows_msys_sandbox for %q, got %#v", command, issue)
}
}
}
// TestDetectShellCommandIssueIgnoresQuotedMsysMentions guards against
// treating an MSYS-prone name that only appears inside a quoted argument
// (e.g. a commit message, a PR comment body, or a doc string discussing the
// command) as an actual invocation. The preflight check must anchor on the
// first word of each command segment, not scan the raw text anywhere.
func TestDetectShellCommandIssueIgnoresQuotedMsysMentions(t *testing.T) {
for _, command := range []string{
`git commit -m "fix head.exe crash"`,
`gh pr comment --body "grep.exe fails under MSYS"`,
`echo "log | head is broken on windows"`,
`git commit -m "note: | head does not work here"`,
} {
if issue := detectShellCommandIssue(command, "windows"); issue != nil {
t.Fatalf("expected quoted MSYS mention to pass for %q, got %#v", command, issue)
}
}
// A real invocation alongside quoted text must still be caught.
if issue := detectShellCommandIssue(`echo "not a real command" && head file.txt`, "windows"); issue == nil || issue.Kind != windowsMsysSandboxKind {
t.Fatalf("expected real head invocation to still be flagged, got %#v", issue)
}
}
// TestDetectShellCommandIssueIgnoresQuotedMsysPathMentions guards the
// explicit MSYS-binary-path check the same way as the coreutil-name check:
// a full usr\bin\ path that only appears inside a quoted argument (e.g. a
// commit message describing the failure) must not be treated as an
// invocation, since the path check is now anchored to the first word of each
// command segment rather than scanned across the raw command text.
func TestDetectShellCommandIssueIgnoresQuotedMsysPathMentions(t *testing.T) {
for _, command := range []string{
`git commit -m "C:\Program Files\Git\usr\bin\head.exe fails"`,
`gh pr comment --body "C:\Git\usr\bin\grep.exe is blocked"`,
} {
if issue := detectShellCommandIssue(command, "windows"); issue != nil {
t.Fatalf("expected quoted MSYS path mention to pass for %q, got %#v", command, issue)
}
}
// A real invocation by full path must still be caught.
if issue := detectShellCommandIssue(`C:\Git\usr\bin\grep.exe pattern file.txt`, "windows"); issue == nil || issue.Kind != windowsMsysSandboxKind {
t.Fatalf("expected real MSYS path invocation to still be flagged, got %#v", issue)
}
}
// TestDetectShellCommandIssueRespectsCaretEscapedOperators guards against
// misreading cmd.exe's ^ escape character: `echo ^| head` prints the pipe and
// "head" as literal text (the caret escapes the pipe so it never splits into
// a separate `head` invocation), and `echo foo; head` is a single `echo`
// command with literal arguments since cmd.exe (unlike bash) does not treat
// ; as a statement separator.
func TestDetectShellCommandIssueRespectsCaretEscapedOperators(t *testing.T) {
for _, command := range []string{
`echo ^| head`,
`echo ^& head`,
`echo foo; head`,
} {
if issue := detectShellCommandIssue(command, "windows"); issue != nil {
t.Fatalf("expected no issue for %q, got %#v", command, issue)
}
}
// An unescaped pipe must still split into a real head invocation.
if issue := detectShellCommandIssue(`echo foo | head`, "windows"); issue == nil || issue.Kind != windowsMsysSandboxKind {
t.Fatalf("expected real head invocation to still be flagged, got %#v", issue)
}
}
// TestDetectShellCommandIssueFlagsRedirectionAttachedToCommand guards against
// firstCommandWord treating cmd.exe redirection operators as part of the
// command name. cmd.exe accepts redirection with no separating space
// (head>out.txt, cat<in.txt), so splitting only on whitespace would return
// "head>out.txt" as one word and miss the invoked command entirely.
func TestDetectShellCommandIssueFlagsRedirectionAttachedToCommand(t *testing.T) {
for _, command := range []string{
`some-command | head>out.txt`,
`cat<in.txt`,
`grep>matches.txt pattern`,
} {
issue := detectShellCommandIssue(command, "windows")
if issue == nil || issue.Kind != windowsMsysSandboxKind {
t.Fatalf("expected windows_msys_sandbox for %q, got %#v", command, issue)
}
}
}
// TestDetectShellOutputIssueSignatureOmitsCommandText documents, at the type
// level, that detectShellOutputIssue can no longer take the command line as
// evidence: it only accepts the real output. Harmless output must not be
// flagged, and output that genuinely carries the MSYS failure markers must
// still be flagged.
func TestDetectShellOutputIssueSignatureOmitsCommandText(t *testing.T) {
if issue := detectShellOutputIssue("hello from bash", "windows"); issue != nil {
t.Fatalf("expected no issue for harmless output, got %#v", issue)
}
output := `0 [main] head (3568) C:\Program Files\Git\usr\bin\head.exe: *** fatal error - CreateFileMapping ..., Win32 error 5. Terminating.`
if issue := detectShellOutputIssue(output, "windows"); issue == nil || issue.Kind != windowsMsysSandboxKind {
t.Fatalf("expected real MSYS output to still be flagged, got %#v", issue)
}
}