Skip to content

Commit 8bd9742

Browse files
authored
fix(tools): classify silent wrapped Windows command failures as sandbox denials (#659)
* fix(tools): classify silent wrapped Windows command failures as sandbox denials A Windows restricted-token sandbox failure is often completely silent: when the token cannot open the target executable or its DLLs (or an MSYS runtime dies during init), the command exits nonzero having written zero bytes to stdout and stderr. The existing detection is keyword-based, so these failures were never marked as sandbox denials, the agent loop never offered the unsandboxed-retry prompt, and the model was left staring at 'Command completed with no output' until it gave up. Treat a wrapped command on the Windows restricted-token or elevated backend that exits nonzero with completely empty output as a likely sandbox denial. A command that legitimately fails silently (findstr with no match, for example) now costs one extra approval prompt; the inverse misclassification silently strands the session, so the trade goes to prompting. * test(tools): cover the elevated Windows backend in the silent-denial test The heuristic treats windows-elevated identically to windows-restricted-token, but only the latter was exercised. Parameterize the silent-failure test over both backends.
1 parent 91ea6de commit 8bd9742

2 files changed

Lines changed: 77 additions & 0 deletions

File tree

‎internal/tools/sandbox_denial.go‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,9 +66,36 @@ func sandboxDenialKind(plan zeroSandbox.CommandPlan, exitCode int, sections ...s
6666
if plan.TargetBackend == zeroSandbox.BackendLinuxBwrap && exitCode == 128+31 {
6767
return SandboxDenialKindSandbox, "seccomp"
6868
}
69+
// Windows restricted-token failures are often SILENT: a spawn the token
70+
// cannot satisfy (executable or DLL unreadable under the restricted-SID
71+
// check, MSYS runtime init death, loader status 0xC0000135/0xC0000142)
72+
// produces a nonzero exit with no output at all, so none of the keyword
73+
// heuristics above can ever fire. Treat "wrapped Windows command failed
74+
// without writing a single byte" as a likely sandbox denial so the caller
75+
// offers the unsandboxed-retry prompt instead of surfacing a bare
76+
// "command completed with no output". A command that legitimately fails
77+
// silently (findstr with no match, for one) costs one extra approval
78+
// prompt; the inverse misclassification silently strands the whole
79+
// session, so the trade goes to prompting.
80+
if windowsWrappedBackend(plan.TargetBackend) && sectionsEmpty(sections...) {
81+
return SandboxDenialKindSandbox, "no output from sandboxed command"
82+
}
6983
return "", ""
7084
}
7185

86+
func windowsWrappedBackend(backend zeroSandbox.BackendName) bool {
87+
return backend == zeroSandbox.BackendWindowsRestrictedToken || backend == zeroSandbox.BackendWindowsElevated
88+
}
89+
90+
func sectionsEmpty(sections ...string) bool {
91+
for _, section := range sections {
92+
if strings.TrimSpace(section) != "" {
93+
return false
94+
}
95+
}
96+
return true
97+
}
98+
7299
func networkDeniedBySandbox(plan zeroSandbox.CommandPlan) bool {
73100
return plan.PermissionProfile.Network.Mode == zeroSandbox.NetworkDeny || plan.Policy.Network == zeroSandbox.NetworkDeny
74101
}

‎internal/tools/sandbox_denial_test.go‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,53 @@ func TestLikelySandboxDeniedIgnoresUnsandboxedFailure(t *testing.T) {
4242
t.Fatal("unsandboxed command output must not be classified as a sandbox denial")
4343
}
4444
}
45+
46+
func TestLikelySandboxDeniedDetectsSilentWindowsWrappedFailure(t *testing.T) {
47+
for _, backend := range []zeroSandbox.BackendName{
48+
zeroSandbox.BackendWindowsRestrictedToken,
49+
zeroSandbox.BackendWindowsElevated,
50+
} {
51+
plan := zeroSandbox.CommandPlan{
52+
Wrapped: true,
53+
TargetBackend: backend,
54+
}
55+
if !likelySandboxDenied(plan, 1, "", " \n") {
56+
t.Fatalf("wrapped Windows command failing with empty output must be classified as sandbox denied (backend %s)", backend)
57+
}
58+
meta := map[string]string{}
59+
markLikelySandboxDenial(meta, plan, 1, "")
60+
if meta[SandboxLikelyDeniedMeta] != "true" || meta[SandboxDenialKindMeta] != SandboxDenialKindSandbox {
61+
t.Fatalf("silent windows denial meta = %#v (backend %s)", meta, backend)
62+
}
63+
}
64+
}
65+
66+
func TestLikelySandboxDeniedIgnoresSilentWindowsWrappedSuccess(t *testing.T) {
67+
plan := zeroSandbox.CommandPlan{
68+
Wrapped: true,
69+
TargetBackend: zeroSandbox.BackendWindowsRestrictedToken,
70+
}
71+
if likelySandboxDenied(plan, 0, "") {
72+
t.Fatal("wrapped Windows command exiting 0 with empty output must not be classified as sandbox denied")
73+
}
74+
}
75+
76+
func TestLikelySandboxDeniedIgnoresSilentFailureOnOtherBackends(t *testing.T) {
77+
plan := zeroSandbox.CommandPlan{
78+
Wrapped: true,
79+
TargetBackend: zeroSandbox.BackendLinuxBwrap,
80+
}
81+
if likelySandboxDenied(plan, 1, "") {
82+
t.Fatal("silent nonzero exit on non-Windows backends must not be classified as sandbox denied")
83+
}
84+
}
85+
86+
func TestLikelySandboxDeniedIgnoresSilentWindowsFailureWithOutput(t *testing.T) {
87+
plan := zeroSandbox.CommandPlan{
88+
Wrapped: true,
89+
TargetBackend: zeroSandbox.BackendWindowsRestrictedToken,
90+
}
91+
if likelySandboxDenied(plan, 1, "fatal: not a git repository") {
92+
t.Fatal("a wrapped Windows command that produced unrelated output must not be classified as sandbox denied")
93+
}
94+
}

0 commit comments

Comments
 (0)