diff --git a/internal/tools/sandbox_denial.go b/internal/tools/sandbox_denial.go index 72ddc58d..2431ea5d 100644 --- a/internal/tools/sandbox_denial.go +++ b/internal/tools/sandbox_denial.go @@ -66,9 +66,36 @@ func sandboxDenialKind(plan zeroSandbox.CommandPlan, exitCode int, sections ...s if plan.TargetBackend == zeroSandbox.BackendLinuxBwrap && exitCode == 128+31 { return SandboxDenialKindSandbox, "seccomp" } + // Windows restricted-token failures are often SILENT: a spawn the token + // cannot satisfy (executable or DLL unreadable under the restricted-SID + // check, MSYS runtime init death, loader status 0xC0000135/0xC0000142) + // produces a nonzero exit with no output at all, so none of the keyword + // heuristics above can ever fire. Treat "wrapped Windows command failed + // without writing a single byte" as a likely sandbox denial so the caller + // offers the unsandboxed-retry prompt instead of surfacing a bare + // "command completed with no output". A command that legitimately fails + // silently (findstr with no match, for one) costs one extra approval + // prompt; the inverse misclassification silently strands the whole + // session, so the trade goes to prompting. + if windowsWrappedBackend(plan.TargetBackend) && sectionsEmpty(sections...) { + return SandboxDenialKindSandbox, "no output from sandboxed command" + } return "", "" } +func windowsWrappedBackend(backend zeroSandbox.BackendName) bool { + return backend == zeroSandbox.BackendWindowsRestrictedToken || backend == zeroSandbox.BackendWindowsElevated +} + +func sectionsEmpty(sections ...string) bool { + for _, section := range sections { + if strings.TrimSpace(section) != "" { + return false + } + } + return true +} + func networkDeniedBySandbox(plan zeroSandbox.CommandPlan) bool { return plan.PermissionProfile.Network.Mode == zeroSandbox.NetworkDeny || plan.Policy.Network == zeroSandbox.NetworkDeny } diff --git a/internal/tools/sandbox_denial_test.go b/internal/tools/sandbox_denial_test.go index e71bd8a1..f9dbbbdd 100644 --- a/internal/tools/sandbox_denial_test.go +++ b/internal/tools/sandbox_denial_test.go @@ -42,3 +42,53 @@ func TestLikelySandboxDeniedIgnoresUnsandboxedFailure(t *testing.T) { t.Fatal("unsandboxed command output must not be classified as a sandbox denial") } } + +func TestLikelySandboxDeniedDetectsSilentWindowsWrappedFailure(t *testing.T) { + for _, backend := range []zeroSandbox.BackendName{ + zeroSandbox.BackendWindowsRestrictedToken, + zeroSandbox.BackendWindowsElevated, + } { + plan := zeroSandbox.CommandPlan{ + Wrapped: true, + TargetBackend: backend, + } + if !likelySandboxDenied(plan, 1, "", " \n") { + t.Fatalf("wrapped Windows command failing with empty output must be classified as sandbox denied (backend %s)", backend) + } + meta := map[string]string{} + markLikelySandboxDenial(meta, plan, 1, "") + if meta[SandboxLikelyDeniedMeta] != "true" || meta[SandboxDenialKindMeta] != SandboxDenialKindSandbox { + t.Fatalf("silent windows denial meta = %#v (backend %s)", meta, backend) + } + } +} + +func TestLikelySandboxDeniedIgnoresSilentWindowsWrappedSuccess(t *testing.T) { + plan := zeroSandbox.CommandPlan{ + Wrapped: true, + TargetBackend: zeroSandbox.BackendWindowsRestrictedToken, + } + if likelySandboxDenied(plan, 0, "") { + t.Fatal("wrapped Windows command exiting 0 with empty output must not be classified as sandbox denied") + } +} + +func TestLikelySandboxDeniedIgnoresSilentFailureOnOtherBackends(t *testing.T) { + plan := zeroSandbox.CommandPlan{ + Wrapped: true, + TargetBackend: zeroSandbox.BackendLinuxBwrap, + } + if likelySandboxDenied(plan, 1, "") { + t.Fatal("silent nonzero exit on non-Windows backends must not be classified as sandbox denied") + } +} + +func TestLikelySandboxDeniedIgnoresSilentWindowsFailureWithOutput(t *testing.T) { + plan := zeroSandbox.CommandPlan{ + Wrapped: true, + TargetBackend: zeroSandbox.BackendWindowsRestrictedToken, + } + if likelySandboxDenied(plan, 1, "fatal: not a git repository") { + t.Fatal("a wrapped Windows command that produced unrelated output must not be classified as sandbox denied") + } +}