Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions internal/sandbox/windows_command_runner_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,24 @@ func runWindowsSandboxCommand(config WindowsSandboxCommandConfig, stderr io.Writ
// mitigation is to run TLS in a broker process, not the sandboxed one) and
// has no clean in-token fix. Workarounds: the degraded path (no restricted
// token) or the in-process web_fetch tool.
//
// KNOWN LIMITATION: MSYS2/Cygwin binaries (Git for Windows bash.exe,
// sh.exe, and the usr\bin coreutils) cannot initialize under this token at
// all, whether invoked directly or spawned internally by an otherwise
// native command (git hooks, git/gh credential helpers). The MSYS runtime
// secures its signal pipe and shared-memory sections with explicit DACLs
// granting only the user, Administrators, and SYSTEM (msys2-runtime
// sigproc.cc sigproc_init -> sec_user_nih -> __sec_user), and a
// WRITE_RESTRICTED write check must ALSO match one of the token's
// restricted SIDs (logon SID, Everyone, capability SIDs). None of the
// granted SIDs can be added to the restricted list without collapsing the
// write jail (each has write access nearly everywhere), so MSYS startup
// dies with "couldn't create signal pipe" or "CreateFileMapping <SID>.1",
// Win32 error 5, and exit status 0xC0000142. The System32 WSL bash
// launcher fails equivalently (the restricted token cannot connect to the
// WSL service: Bash/Service/CreateInstance/E_ACCESSDENIED). Like Schannel,
// this has no in-token fix; preflight blocking and output hints live in
// internal/tools/shell_runtime.go.
tokenSIDs := windowsRuntimeTokenSIDs(capabilitySIDs, offlineSID, config.PermissionProfile.Network.Mode)
token, err := createWindowsRestrictedTokenForCapabilitySIDs(tokenSIDs)
if err != nil {
Expand Down
36 changes: 28 additions & 8 deletions internal/tools/shell_runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,21 +20,29 @@ type shellIssue struct {

const windowsMsysSandboxKind = "windows_msys_sandbox"

const windowsMsysSandboxSuggestion = "MSYS/Cygwin coreutils from Git for Windows cannot run under Zero's write-restricted Windows sandbox. Prefer Zero native tools (grep, read_file with offset/limit, list_directory, glob), cmd.exe findstr, or PowerShell Select-Object -First/-Last. If host-level execution is truly required, rerun with sandbox_permissions: \"require_escalated\" and a narrow justification."
const windowsMsysSandboxSuggestion = "MSYS/Cygwin coreutils and shells (bash, sh) from Git for Windows cannot run under Zero's write-restricted Windows sandbox, and the WSL bash launcher cannot reach the WSL service from it either. This also hits native commands that spawn Git Bash internally: git hooks and git/gh credential helpers can fail this way even though git and gh themselves run fine. Prefer Zero native tools (grep, read_file with offset/limit, list_directory, glob), cmd.exe findstr, or PowerShell Select-Object -First/-Last. If host-level execution is truly required, rerun with sandbox_permissions: \"require_escalated\" and a narrow justification."

// windowsMsysProneNames is the single source of truth for POSIX coreutil names
// that commonly resolve to a Git-for-Windows MSYS/Cygwin binary rather than a
// cmd.exe-native command, and so fail under the write-restricted Windows
// sandbox (#458). Every Windows MSYS-detection path (the preflight command
// scan below, the exported MsysProneCommandName, and the known-safe-segment
// guard in internal/agent/command_prefix.go) derives from this one set, so
// they cannot drift out of sync with each other.
// windowsMsysProneNames is the single source of truth for POSIX coreutil and
// shell names that commonly resolve to a Git-for-Windows MSYS/Cygwin binary
// rather than a cmd.exe-native command, and so fail under the write-restricted
// Windows sandbox (#458). Every Windows MSYS-detection path (the preflight
// command scan below, the exported MsysProneCommandName, and the
// known-safe-segment guard in internal/agent/command_prefix.go) derives from
// this one set, so they cannot drift out of sync with each other.
var windowsMsysProneNames = map[string]bool{
"cat": true, "cut": true, "expr": true, "grep": true, "head": true,
"id": true, "ls": true, "nl": true, "paste": true, "rev": true,
"seq": true, "stat": true, "tail": true, "tr": true, "uname": true,
"uniq": true, "wc": true, "which": true, "awk": true, "sed": true,
"xargs": true,
// Shells. Git-for-Windows bash.exe/sh.exe are MSYS binaries and die during
// MSYS runtime init under the restricted token ("couldn't create signal
// pipe" or "CreateFileMapping <SID>.1", both Win32 error 5), and the
// System32 WSL bash launcher fails equivalently at a different layer (the
// restricted token cannot connect to the WSL service:
// Bash/Service/CreateInstance/E_ACCESSDENIED), so every executable a bare
// `bash` can resolve to fails under the sandbox.
"bash": true, "sh": true,
}

var (
Expand Down Expand Up @@ -255,6 +263,9 @@ func detectShellOutputIssue(output string, goos string) *shellIssue {
if msysRuntimeFailedInOutput(lower) {
return windowsMsysSandboxIssue("An MSYS/Cygwin runtime failed under Zero's Windows sandbox (ACCESS_DENIED during MSYS startup).")
}
if wslServiceDeniedInOutput(lower) {
return windowsMsysSandboxIssue("WSL bash could not connect to the WSL service under Zero's Windows sandbox (Bash/Service/CreateInstance/E_ACCESSDENIED).")
}
if strings.Contains(lower, "the syntax of the command is incorrect") ||
strings.Contains(lower, "is not recognized as an internal or external command") {
return &shellIssue{
Expand Down Expand Up @@ -292,6 +303,15 @@ func msysRuntimeFailedInOutput(lower string) bool {
strings.Contains(lower, "[main]")
}

// wslServiceDeniedInOutput matches the WSL bash launcher's failure to open its
// service connection under the restricted token. The launcher writes UTF-16LE
// to its (piped, non-console) stderr, which the byte-based capture renders as
// ASCII interleaved with NUL bytes, so the NULs are stripped before matching.
func wslServiceDeniedInOutput(lower string) bool {
compact := strings.ReplaceAll(lower, "\x00", "")
return strings.Contains(compact, "bash/service/") && strings.Contains(compact, "e_accessdenied")
}

func appendShellIssueHint(output string, issue shellIssue) string {
output = strings.TrimRight(output, "\r\n")
hint := "[zero] shell issue: " + issue.Message
Expand Down
51 changes: 50 additions & 1 deletion internal/tools/shell_runtime_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,36 @@ func TestDetectShellCommandIssueFlagsStandaloneCat(t *testing.T) {
}
}

// TestDetectShellCommandIssueFlagsShells covers bash/sh invocations: every
// executable a bare `bash` resolves to on Windows fails under the restricted
// token (Git-for-Windows MSYS bash dies during runtime init; the System32 WSL
// launcher is denied its WSL service connection), so both names are blocked
// upfront like the MSYS coreutils.
func TestDetectShellCommandIssueFlagsShells(t *testing.T) {
for _, command := range []string{
`bash -c "make test"`,
`bash.exe -lc ls`,
`sh -c "echo hi"`,
`sh.exe -c "echo hi"`,
`git status && bash -c "echo hi"`,
} {
issue := detectShellCommandIssue(command, "windows")
if issue == nil || issue.Kind != "windows_msys_sandbox" {
t.Fatalf("expected windows_msys_sandbox for %q, got %#v", command, issue)
}
}

// Shell names inside quoted argument text are not invocations.
for _, command := range []string{
`git commit -m "bash fails under the sandbox"`,
`gh pr comment --body "run sh -c manually"`,
} {
if issue := detectShellCommandIssue(command, "windows"); issue != nil {
t.Fatalf("expected quoted shell mention to pass for %q, got %#v", command, 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")
Expand Down Expand Up @@ -58,6 +88,25 @@ func TestDetectShellOutputIssueFlagsMsysTerminatingWithMsysMarker(t *testing.T)
}
}

// TestDetectShellOutputIssueFlagsWslServiceDenied pins detection of the WSL
// bash launcher's failure under the restricted token. The launcher writes
// UTF-16LE to a piped stderr, so the captured text carries a NUL byte after
// every ASCII character; the fixture reproduces that shape.
func TestDetectShellOutputIssueFlagsWslServiceDenied(t *testing.T) {
var utf16ish strings.Builder
for _, r := range "Access is denied.\r\nError code: Bash/Service/CreateInstance/E_ACCESSDENIED\r\n" {
utf16ish.WriteRune(r)
utf16ish.WriteByte(0)
}
issue := detectShellOutputIssue(utf16ish.String(), "windows")
if issue == nil || issue.Kind != "windows_msys_sandbox" {
t.Fatalf("expected WSL service-denied output issue, got %#v", issue)
}
if !strings.Contains(issue.Message, "WSL") {
t.Fatalf("expected WSL-specific message, 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")
Expand All @@ -82,7 +131,7 @@ func TestShellIssueBlockResultMsysCommand(t *testing.T) {
}

func TestMsysProneCommandName(t *testing.T) {
if !MsysProneCommandName("HEAD") || MsysProneCommandName("echo") {
if !MsysProneCommandName("HEAD") || !MsysProneCommandName("bash") || MsysProneCommandName("echo") {
t.Fatalf("unexpected MsysProneCommandName results")
}
}
Expand Down
2 changes: 1 addition & 1 deletion internal/tui/picker_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -768,7 +768,7 @@ func TestSwitchProviderModelRecordsRecentHistory(t *testing.T) {
},
})

next, status, _ := m.switchProviderModel("ollama", "kimi-k2.7-code:cloud")
next, status, _, _ := m.switchProviderModel("ollama", "kimi-k2.7-code:cloud")
wantStatus := "Model\nSwitched to ollama · kimi-k2.7-code:cloud"
if status != wantStatus {
t.Fatalf("switchProviderModel() status = %q, want %q (a mismatch here means the switch itself failed, not the recentModels assertion below)", status, wantStatus)
Expand Down
Loading