From badad72c37e0aef5fe9d60c104ce893e1154107e Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Sun, 9 Aug 2026 18:21:29 +0530 Subject: [PATCH 01/43] fix(sandbox): guard the Windows write-jail invariant and disclose the DenyRead trade #865 removed the World SID from the WRITE_RESTRICTED token, which is what makes the write jail hold: every principal carries Everyone, so while it was a restricting SID the write half of the check passed for free on any Everyone-writable path. That fix had no CI protection. The only test covering it sits behind ZERO_SANDBOX_REAL_SMOKE=1 and no workflow sets that variable, so a rebase or refactor restoring the unconditional World SID goes green. #640's branch conflicts on exactly that hunk. CreateRestrictedToken works unelevated against the caller's own token, so the invariant can be checked in an ordinary unit test. Added four: - the WRITE_RESTRICTED token must not carry the World SID (this fails against a reverted #865, verified by mutation) - neither token shape may carry Users, Authenticated Users, INTERACTIVE, BATCH, Administrators, SYSTEM, SERVICE, NETWORK, or the user's own SID. #869 names these as the ones that would reopen the same class of bypass - the capability SID must be present, so a token with an empty list cannot pass by having no keys at all - the non-WRITE_RESTRICTED shape still carries the World SID, which documents the open gap rather than asserting the end state. It skips with a note if that stops being true, so whoever closes #869 is told to replace it Second half: the trade was invisible. Setting denyRead selects the token shape without WRITE_RESTRICTED, and nothing told the person who set it that they had given up write confinement to get read-deny. The plan now carries a warning saying so, keyed off the same field the runner reads and scoped to the Windows restricted-token backend, so the default posture stays quiet. Zero never populates denyRead on Windows itself, so this only reaches users who configured it. This does NOT close #869. Closing it needs a read-side grant that is not a universal group (AppContainer, or the per-workspace principals in #808), which is a different piece of work. What changes here is that the fixed shape can no longer regress silently, and the unfixed shape no longer looks enforced. Refs #869, #865, #612, #640 --- internal/sandbox/manager.go | 29 +++- .../sandbox/windows_deny_read_warning_test.go | 70 ++++++++ .../sandbox/windows_token_windows_test.go | 162 ++++++++++++++++++ 3 files changed, 260 insertions(+), 1 deletion(-) create mode 100644 internal/sandbox/windows_deny_read_warning_test.go create mode 100644 internal/sandbox/windows_token_windows_test.go diff --git a/internal/sandbox/manager.go b/internal/sandbox/manager.go index eaf604b00..4a0c0af51 100644 --- a/internal/sandbox/manager.go +++ b/internal/sandbox/manager.go @@ -331,7 +331,34 @@ func (request SandboxExecutionRequest) BackendPlan(policy Policy) BackendPlan { RequiresPlatformSandbox: request.RequiresPlatformSandbox, Capabilities: request.Backend.Capabilities(policy), Restrictions: request.Backend.restrictions(policy), - Warnings: request.Backend.Warnings(), + Warnings: append(request.Backend.Warnings(), windowsDenyReadWarnings(request.Backend, request.PermissionProfile)...), + } +} + +// windowsDenyReadWarnings reports that configuring DenyRead on Windows costs the +// restricted token's write jail. +// +// A profile with DenyRead paths selects the token shape WITHOUT WRITE_RESTRICTED +// (the runner sets writeRestricted false exactly when DenyRead is non-empty), +// because the restricted-SID check has to cover reads for read-deny to mean +// anything. That shape must keep the World SID on its restricted-SID list or the +// token cannot open cmd.exe at all, and every principal carries the World SID, so +// the write half of the jail passes for free on any Everyone-writable path. +// +// The trade is deliberate and documented in the token code, but it was invisible: +// nothing told the person who set DenyRead that they had given up the write jail +// to get it. Zero never populates DenyRead on Windows itself, so this only +// reaches users who configured it. Tracked as #869; when that closes, this +// warning goes with it. +func windowsDenyReadWarnings(backend Backend, profile PermissionProfile) []string { + if backend.Name != BackendWindowsRestrictedToken || !backend.NativeIsolation { + return nil + } + if len(normalizeProfilePaths(profile.FileSystem.DenyRead)) == 0 { + return nil + } + return []string{ + "denyRead is configured, so the Windows sandbox uses the token shape without WRITE_RESTRICTED: reads are denied as requested, but writes outside the workspace are not confined by the token (#869)", } } diff --git a/internal/sandbox/windows_deny_read_warning_test.go b/internal/sandbox/windows_deny_read_warning_test.go new file mode 100644 index 000000000..4ff68f669 --- /dev/null +++ b/internal/sandbox/windows_deny_read_warning_test.go @@ -0,0 +1,70 @@ +package sandbox + +import ( + "strings" + "testing" +) + +func windowsRestrictedTokenBackend() Backend { + return Backend{ + Name: BackendWindowsRestrictedToken, + Platform: "windows", + Available: true, + NativeIsolation: true, + } +} + +func profileWithDenyRead(paths ...string) PermissionProfile { + profile := PermissionProfile{} + profile.FileSystem.DenyRead = paths + return profile +} + +// Setting denyRead on Windows silently costs the token's write jail, because it +// selects the shape without WRITE_RESTRICTED and that shape has to keep the World +// SID. The trade is defensible; making it invisible is not. Someone who asked for +// read-deny has no way to discover they gave up write confinement for it. +func TestDenyReadOnWindowsWarnsThatTheWriteJailIsGone(t *testing.T) { + warnings := windowsDenyReadWarnings(windowsRestrictedTokenBackend(), profileWithDenyRead(`C:\Users\someone\.config\creds`)) + if len(warnings) == 0 { + t.Fatal("configuring denyRead on Windows produced no warning, so the lost write jail stays invisible") + } + warning := strings.ToLower(strings.Join(warnings, " ")) + // It has to name the cause and the consequence. A warning that says only + // "degraded" sends the reader to the source to find out what changed. + for _, want := range []string{"denyread", "write", "#869"} { + if !strings.Contains(warning, want) { + t.Errorf("warning does not mention %q, so it does not explain the trade: %q", want, warning) + } + } +} + +// The default Windows posture must stay quiet. Zero never populates denyRead on +// Windows itself, so warning unconditionally would train every user to ignore the +// one case that matters. +func TestDefaultWindowsProfileProducesNoDenyReadWarning(t *testing.T) { + if warnings := windowsDenyReadWarnings(windowsRestrictedTokenBackend(), PermissionProfile{}); len(warnings) != 0 { + t.Fatalf("the default Windows profile warned about denyRead it does not set: %v", warnings) + } + // Blank and whitespace-only entries are not a configured denyRead either. + if warnings := windowsDenyReadWarnings(windowsRestrictedTokenBackend(), profileWithDenyRead("", " ")); len(warnings) != 0 { + t.Fatalf("empty denyRead entries produced a warning: %v", warnings) + } +} + +// The warning describes one specific token implementation, so it must not appear +// for backends that do not build that token. +func TestDenyReadWarningIsScopedToTheWindowsRestrictedToken(t *testing.T) { + others := []Backend{ + {Name: BackendMacOSSeatbelt, Platform: "darwin", Available: true, NativeIsolation: true}, + {Name: BackendLinuxLandlock, Platform: "linux", Available: true, NativeIsolation: true}, + // Same backend name but no native isolation: no token is built, so the + // warning would describe enforcement that is not running at all. + {Name: BackendWindowsRestrictedToken, Platform: "windows", NativeIsolation: false}, + } + for _, backend := range others { + if warnings := windowsDenyReadWarnings(backend, profileWithDenyRead(`C:\secret`)); len(warnings) != 0 { + t.Errorf("backend %q (nativeIsolation=%v) got the Windows token warning: %v", backend.Name, backend.NativeIsolation, warnings) + } + } +} diff --git a/internal/sandbox/windows_token_windows_test.go b/internal/sandbox/windows_token_windows_test.go new file mode 100644 index 000000000..f8162ff37 --- /dev/null +++ b/internal/sandbox/windows_token_windows_test.go @@ -0,0 +1,162 @@ +//go:build windows + +package sandbox + +import ( + "strings" + "testing" + "unsafe" + + "golang.org/x/sys/windows" +) + +// A SID that parses but names nothing on the machine. CreateRestrictedToken does +// not require a restricting SID to resolve, and using a real group would make the +// test depend on local account layout. +const testCapabilitySID = "S-1-5-21-1111111111-1111111111-1111111111-4001" + +// restrictedSIDStrings returns the token's restricted-SID list. +// +// This list IS the write jail. Under WRITE_RESTRICTED a write must pass both the +// ordinary access check and a second check against these SIDs, so the jail holds +// exactly as long as the list contains nothing every principal already carries. +func restrictedSIDStrings(t *testing.T, token windows.Token) []string { + t.Helper() + var size uint32 + err := windows.GetTokenInformation(token, windows.TokenRestrictedSids, nil, 0, &size) + if err != nil && err != windows.ERROR_INSUFFICIENT_BUFFER { + t.Fatalf("size restricted SID list: %v", err) + } + if size == 0 { + return nil + } + buffer := make([]byte, size) + if err := windows.GetTokenInformation(token, windows.TokenRestrictedSids, &buffer[0], size, &size); err != nil { + t.Fatalf("read restricted SID list: %v", err) + } + groups := (*windows.Tokengroups)(unsafe.Pointer(&buffer[0])) + values := make([]string, 0, groups.GroupCount) + for _, group := range groups.AllGroups() { + values = append(values, group.Sid.String()) + } + return values +} + +func restrictedTokenForTest(t *testing.T, writeRestricted bool) windows.Token { + t.Helper() + token, err := createWindowsRestrictedTokenForCapabilitySIDs([]string{testCapabilitySID}, writeRestricted) + if err != nil { + t.Fatalf("create restricted token (writeRestricted=%v): %v", writeRestricted, err) + } + t.Cleanup(func() { _ = token.Close() }) + return token +} + +func containsSID(values []string, want string) bool { + for _, value := range values { + if strings.EqualFold(value, want) { + return true + } + } + return false +} + +// THE REGRESSION GUARD FOR #865. The World SID (Everyone) must not be a +// restricting SID on the WRITE_RESTRICTED token. +// +// Every principal carries Everyone, so if it is on this list the second check +// passes for free on any path whose DACL grants Everyone write, and confinement +// silently falls back to the user's own permissions. No privilege, no symlink and +// no race is needed; an Everyone-writable directory is enough. +// +// This is a unit test on purpose. The existing coverage +// (TestWindowsRestrictedTokenDeniesWritesToEveryoneWritablePaths) sits behind +// ZERO_SANDBOX_REAL_SMOKE=1, which no workflow sets, so until now a refactor that +// restored the unconditional World SID went green in CI. CreateRestrictedToken +// works unelevated against the caller's own token, so there is no reason this +// invariant cannot be checked on every run. +func TestWriteRestrictedTokenExcludesTheWorldSID(t *testing.T) { + values := restrictedSIDStrings(t, restrictedTokenForTest(t, true)) + if len(values) == 0 { + t.Fatal("write-restricted token has no restricting SIDs at all, so there is no write jail to speak of") + } + if containsSID(values, "S-1-1-0") { + t.Fatalf("the World SID is a restricting SID on the write-restricted token, which collapses the write jail: %v", values) + } +} + +// No universal group belongs on this list, for the same reason Everyone does not. +// #869 calls these out by name as the ones that would reopen the gap, and the +// runner's own comment already states the rule, so this pins it rather than +// trusting the next reader to remember. +// +// Checked on BOTH token shapes: the non-WRITE_RESTRICTED one still must not gain +// any of these beyond the World SID it is documented to carry. +func TestRestrictedSIDListNeverCarriesABroadGroup(t *testing.T) { + forbidden := map[string]string{ + "S-1-5-32-545": `BUILTIN\Users`, + "S-1-5-11": "Authenticated Users", + "S-1-5-4": "INTERACTIVE", + "S-1-5-3": "BATCH", + "S-1-5-32-544": `BUILTIN\Administrators`, + "S-1-5-18": "SYSTEM", + "S-1-5-6": "SERVICE", + "S-1-5-2": "NETWORK", + } + for _, writeRestricted := range []bool{true, false} { + values := restrictedSIDStrings(t, restrictedTokenForTest(t, writeRestricted)) + for sid, name := range forbidden { + if containsSID(values, sid) { + t.Errorf("writeRestricted=%v: %s (%s) is a restricting SID; it has write access nearly everywhere, so the jail would not hold", + writeRestricted, name, sid) + } + } + // The user's own SID is the boundary this token exists to be stricter + // than, so it must never be its own key. + if user := currentUserSIDForTest(t); user != "" && containsSID(values, user) { + t.Errorf("writeRestricted=%v: the current user SID is a restricting SID, which defeats the token entirely", writeRestricted) + } + } +} + +// The capability SID must actually be present, or the jail denies everything and +// the sandbox cannot write even where Zero granted access. A test that only +// checked for absences would pass against a token with an empty list. +func TestRestrictedSIDListCarriesTheCapabilitySID(t *testing.T) { + for _, writeRestricted := range []bool{true, false} { + values := restrictedSIDStrings(t, restrictedTokenForTest(t, writeRestricted)) + if !containsSID(values, testCapabilitySID) { + t.Errorf("writeRestricted=%v: the capability SID is missing from %v, so no ACL-granted path would be writable", + writeRestricted, values) + } + } +} + +// Documents the gap #869 tracks rather than asserting the desired end state. +// +// Without WRITE_RESTRICTED the restricted-SID check covers reads too, and default +// Windows DACLs grant BUILTIN\Users, so a token with no universal group cannot +// open cmd.exe and dies at launch with STATUS_ACCESS_DENIED. Everyone is +// load-bearing here, which is why #865 could not remove it from this shape. +// +// The consequence is that this shape, selected whenever a profile sets DenyRead, +// has no effective write jail. If someone closes #869 by giving reads a grant +// that is not a universal group, this test skips with a note and should be +// replaced by the exclusion assertion rather than deleted. +func TestNonWriteRestrictedTokenStillCarriesTheWorldSID(t *testing.T) { + values := restrictedSIDStrings(t, restrictedTokenForTest(t, false)) + if !containsSID(values, "S-1-1-0") { + t.Skip("the World SID is gone from the DenyRead token shape; #869 may be fixed, so replace this with the exclusion assertion") + } + t.Log("known gap (#869): the DenyRead token shape carries the World SID, so its write jail does not hold") +} + +func currentUserSIDForTest(t *testing.T) string { + t.Helper() + token := windows.GetCurrentProcessToken() + user, err := token.GetTokenUser() + if err != nil { + return "" + } + return user.User.Sid.String() +} From e32d467b16192e1706fc99a0d6dd2453c8578d4a Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Sun, 9 Aug 2026 19:15:52 +0530 Subject: [PATCH 02/43] fix(sandbox): only warn about the DenyRead token trade on a Windows host The warning fired on any plan targeting the Windows backend, including one built somewhere else, which broke the smoke job on macOS and ubuntu. credentialDenyReadPaths returns empty ON Windows and populates itself from the host everywhere else, so a Windows-targeted plan built on a Linux runner carries that machine's credential paths and drew a warning about a token nothing would ever build. TestSelectBackendChoosesPlatformAdapterWithFallback asserts a Windows plan has no warnings, and it only ever builds Windows plans from another host. Gating on the host is more accurate rather than merely convenient: this describes a token the Windows command runner will build, and that runner only runs on Windows. Behaviour on a real Windows host is unchanged, which is why local testing missed it. The host is read through a var so both sides stay testable anywhere, matching windowsSandboxInitialized. Added a regression test for the case that actually broke, and mutation-verified it: dropping the gate fails it for linux and darwin. --- internal/sandbox/manager.go | 19 ++++++++++++ .../sandbox/windows_deny_read_warning_test.go | 30 +++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/internal/sandbox/manager.go b/internal/sandbox/manager.go index 4a0c0af51..c7c2013f5 100644 --- a/internal/sandbox/manager.go +++ b/internal/sandbox/manager.go @@ -335,6 +335,10 @@ func (request SandboxExecutionRequest) BackendPlan(policy Policy) BackendPlan { } } +// denyReadWarningHostGOOS is the host this process runs on, as far as the +// DenyRead warning is concerned. A var so a test can drive both sides. +var denyReadWarningHostGOOS = runtime.GOOS + // windowsDenyReadWarnings reports that configuring DenyRead on Windows costs the // restricted token's write jail. // @@ -351,6 +355,21 @@ func (request SandboxExecutionRequest) BackendPlan(policy Policy) BackendPlan { // reaches users who configured it. Tracked as #869; when that closes, this // warning goes with it. func windowsDenyReadWarnings(backend Backend, profile PermissionProfile) []string { + // The HOST has to be Windows, not merely the target backend. + // + // This describes a token the Windows command runner will build, and that + // runner only ever runs on Windows. A Windows-targeted plan built anywhere + // else is a cross-platform planning exercise, and its DenyRead does not come + // from a Windows user at all: credentialDenyReadPaths returns empty ON + // Windows and populates itself from the host everywhere else, so a plan built + // on Linux carries that machine's credential paths and would draw a warning + // about a token nothing will build. + // + // Indirected through a var so both sides stay testable from any host, the + // same reason windowsSandboxInitialized is one. + if denyReadWarningHostGOOS != "windows" { + return nil + } if backend.Name != BackendWindowsRestrictedToken || !backend.NativeIsolation { return nil } diff --git a/internal/sandbox/windows_deny_read_warning_test.go b/internal/sandbox/windows_deny_read_warning_test.go index 4ff68f669..87d8312ec 100644 --- a/internal/sandbox/windows_deny_read_warning_test.go +++ b/internal/sandbox/windows_deny_read_warning_test.go @@ -5,6 +5,13 @@ import ( "testing" ) +func withWindowsHost(t *testing.T) { + t.Helper() + previous := denyReadWarningHostGOOS + denyReadWarningHostGOOS = "windows" + t.Cleanup(func() { denyReadWarningHostGOOS = previous }) +} + func windowsRestrictedTokenBackend() Backend { return Backend{ Name: BackendWindowsRestrictedToken, @@ -25,6 +32,7 @@ func profileWithDenyRead(paths ...string) PermissionProfile { // SID. The trade is defensible; making it invisible is not. Someone who asked for // read-deny has no way to discover they gave up write confinement for it. func TestDenyReadOnWindowsWarnsThatTheWriteJailIsGone(t *testing.T) { + withWindowsHost(t) warnings := windowsDenyReadWarnings(windowsRestrictedTokenBackend(), profileWithDenyRead(`C:\Users\someone\.config\creds`)) if len(warnings) == 0 { t.Fatal("configuring denyRead on Windows produced no warning, so the lost write jail stays invisible") @@ -43,6 +51,7 @@ func TestDenyReadOnWindowsWarnsThatTheWriteJailIsGone(t *testing.T) { // Windows itself, so warning unconditionally would train every user to ignore the // one case that matters. func TestDefaultWindowsProfileProducesNoDenyReadWarning(t *testing.T) { + withWindowsHost(t) if warnings := windowsDenyReadWarnings(windowsRestrictedTokenBackend(), PermissionProfile{}); len(warnings) != 0 { t.Fatalf("the default Windows profile warned about denyRead it does not set: %v", warnings) } @@ -55,6 +64,7 @@ func TestDefaultWindowsProfileProducesNoDenyReadWarning(t *testing.T) { // The warning describes one specific token implementation, so it must not appear // for backends that do not build that token. func TestDenyReadWarningIsScopedToTheWindowsRestrictedToken(t *testing.T) { + withWindowsHost(t) others := []Backend{ {Name: BackendMacOSSeatbelt, Platform: "darwin", Available: true, NativeIsolation: true}, {Name: BackendLinuxLandlock, Platform: "linux", Available: true, NativeIsolation: true}, @@ -68,3 +78,23 @@ func TestDenyReadWarningIsScopedToTheWindowsRestrictedToken(t *testing.T) { } } } + +// A Windows-targeted plan built on a non-Windows host must stay silent. +// +// This is the case that broke CI rather than a hypothetical. credentialDenyReadPaths +// returns empty ON Windows and populates itself from the host everywhere else, so a +// Windows plan built on a Linux runner carries that machine's credential paths +// (/home/runner/.docker/config.json) and drew a warning about a token nothing would +// ever build. TestSelectBackendChoosesPlatformAdapterWithFallback asserts a Windows +// plan has no warnings, and it only builds Windows plans from other hosts. +func TestNoDenyReadWarningWhenTheHostIsNotWindows(t *testing.T) { + for _, host := range []string{"linux", "darwin"} { + previous := denyReadWarningHostGOOS + denyReadWarningHostGOOS = host + warnings := windowsDenyReadWarnings(windowsRestrictedTokenBackend(), profileWithDenyRead("/home/runner/.docker/config.json")) + denyReadWarningHostGOOS = previous + if len(warnings) != 0 { + t.Errorf("a windows-targeted plan built on %s warned about a token that host will never build: %v", host, warnings) + } + } +} From 6f3e95a8138094035311dc8da8e4acc50a428281 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Wed, 12 Aug 2026 13:54:02 +0530 Subject: [PATCH 03/43] fix(sandbox): stop advertising a sandbox override that does not exist The unelevated ACL failure told the reader to re-run with `--sandbox forbid`. There is no such option: SandboxPreferenceForbid is an internal engine state with no flag behind it, so following the advice produced an unknown option and left them stuck on a failure they had just been told how to clear. A recovery instruction that does not work costs more than no instruction, because the reader spends time discovering it is wrong. Point at the real way out instead: turn the sandbox off in the user config, which is honored from global config only. The elevated-setup remedy beside it was already correct and stays. Reported by jatmn against the same string on #640. It predates both branches, having arrived with the unelevated fallback tier in #427. --- internal/sandbox/windows_command_runner_windows.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/internal/sandbox/windows_command_runner_windows.go b/internal/sandbox/windows_command_runner_windows.go index 0b9e8f64e..fd4d7c59e 100644 --- a/internal/sandbox/windows_command_runner_windows.go +++ b/internal/sandbox/windows_command_runner_windows.go @@ -112,8 +112,15 @@ func ensureWindowsUnelevatedSetup(config WindowsSandboxCommandConfig) error { return nil } if _, err := applyWindowsACLPlan(plan); err != nil { + // Both remedies below are real. An earlier version offered `--sandbox + // forbid`, which is not: SandboxPreferenceForbid is an internal engine + // state with no flag behind it, so following that advice produced an + // unknown option and left the reader stuck on a failure they had just been + // told how to clear. A recovery instruction that does not work is worse + // than none, because it costs the reader the time to discover that. return fmt.Errorf("apply unelevated workspace ACLs: %w — the workspace may be on a filesystem the current user does not own; "+ - "run `zero sandbox setup` from an elevated (Administrator) terminal, or re-run with `--sandbox forbid` to skip OS sandboxing", err) + "run `zero sandbox setup` from an elevated (Administrator) terminal, "+ + `or turn the sandbox off in your user config with "sandbox": {"enabled": false}`, err) } return recordWindowsUnelevatedAppliedPlan(config.SandboxHome, applied) } From a64a2d1af55ebe9ec927c92cfb5d618e7464f4f1 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Wed, 12 Aug 2026 16:00:42 +0530 Subject: [PATCH 04/43] test(sandbox): pin the remedies the unelevated ACL failure offers The message advertised `--sandbox forbid`, an option that does not exist, and it survived because nothing drove the branch. The text was only ever correct by inspection, and inspection is what missed it. Route the apply through a seam so a test can fail it, then assert what the operator actually reads: the cause is still wrapped, the option that does not exist never returns, and both surviving remedies are named. Restoring the old wording fails the test on both counts. Also assert the failure does not record the applied-plan marker. That marker is what makes later commands skip the re-apply, so recording it here would turn one refusal into a sandbox that silently stops applying its ACLs entirely. --- .../sandbox/windows_command_runner_windows.go | 7 +- ...indows_unelevated_guidance_windows_test.go | 115 ++++++++++++++++++ 2 files changed, 121 insertions(+), 1 deletion(-) create mode 100644 internal/sandbox/windows_unelevated_guidance_windows_test.go diff --git a/internal/sandbox/windows_command_runner_windows.go b/internal/sandbox/windows_command_runner_windows.go index fd4d7c59e..3ff54644b 100644 --- a/internal/sandbox/windows_command_runner_windows.go +++ b/internal/sandbox/windows_command_runner_windows.go @@ -89,6 +89,11 @@ func runWindowsSandboxCommand(config WindowsSandboxCommandConfig, stderr io.Writ return exitCode } +// applyWindowsUnelevatedACLPlanFn is a seam. The failure branch below builds +// the guidance an operator acts on, and that text is only correct by +// inspection until something drives the branch and reads it back. +var applyWindowsUnelevatedACLPlanFn = applyWindowsACLPlan + // ensureWindowsUnelevatedSetup applies the workspace ACL plan from the current // (non-elevated) process so the write-restricted token has somewhere its // capability SIDs are granted. DACL edits on user-owned workspace and temp @@ -111,7 +116,7 @@ func ensureWindowsUnelevatedSetup(config WindowsSandboxCommandConfig) error { if marker.contains(applied) { return nil } - if _, err := applyWindowsACLPlan(plan); err != nil { + if _, err := applyWindowsUnelevatedACLPlanFn(plan); err != nil { // Both remedies below are real. An earlier version offered `--sandbox // forbid`, which is not: SandboxPreferenceForbid is an internal engine // state with no flag behind it, so following that advice produced an diff --git a/internal/sandbox/windows_unelevated_guidance_windows_test.go b/internal/sandbox/windows_unelevated_guidance_windows_test.go new file mode 100644 index 000000000..671d8cd35 --- /dev/null +++ b/internal/sandbox/windows_unelevated_guidance_windows_test.go @@ -0,0 +1,115 @@ +//go:build windows + +package sandbox + +import ( + "errors" + "strings" + "testing" +) + +// EVERY REMEDY THIS ERROR NAMES MUST BE ONE THE READER CAN CARRY OUT. +// +// The message told operators to re-run with `--sandbox forbid`. No such option +// exists: SandboxPreferenceForbid is an internal engine state with no flag +// behind it, so acting on the advice produced an unknown option and left them +// stuck on the failure they had just been told how to clear. +// +// It survived because nothing drove this branch. The text was only ever correct +// by inspection, and inspection is what missed it, so the fix is not complete +// until something fails the apply and reads the guidance back. +func TestUnelevatedACLFailureNamesOnlyRealRemedies(t *testing.T) { + workspace := t.TempDir() + config := WindowsSandboxCommandConfig{ + SandboxHome: t.TempDir(), + CommandCWD: workspace, + WorkspaceRoots: []string{workspace}, + PermissionProfile: PermissionProfile{ + FileSystem: FileSystemPolicy{ + Kind: FileSystemRestricted, + WriteRoots: []WritableRoot{{Root: workspace}}, + }, + Network: NetworkPolicy{Mode: NetworkDeny}, + }, + SandboxLevel: WindowsSandboxLevelUnelevated, + } + + denied := errors.New("Access is denied.") + original := applyWindowsUnelevatedACLPlanFn + t.Cleanup(func() { applyWindowsUnelevatedACLPlanFn = original }) + applyWindowsUnelevatedACLPlanFn = func(WindowsACLPlan) (func() error, error) { + return nil, denied + } + + err := ensureWindowsUnelevatedSetup(config) + if err == nil { + t.Fatal("ensureWindowsUnelevatedSetup returned nil when the ACL apply failed, so the command would run believing it was sandboxed") + } + + // The refusal has to keep naming its cause, or the operator cannot tell an + // ACL failure apart from the sandboxed command being rejected. + if !errors.Is(err, denied) { + t.Errorf("error does not wrap the apply failure, so the cause is lost: %v", err) + } + + message := err.Error() + + // The option that does not exist must never come back. + if strings.Contains(message, "--sandbox forbid") { + t.Errorf("error still advertises `--sandbox forbid`, which is not a real option: %s", message) + } + + // Both surviving remedies are real: elevated setup, and the user-config key, + // which is honored from global config only so a cloned repo cannot set it. + for _, want := range []string{ + "zero sandbox setup", + `"sandbox": {"enabled": false}`, + } { + if !strings.Contains(message, want) { + t.Errorf("error does not offer %q, leaving the reader without a way out: %s", want, message) + } + } +} + +// The failure must not be recorded as a success. The applied-plan marker is +// what makes later commands skip the re-apply, so writing it here would turn +// one refusal into a sandbox that silently never applies its ACLs again. +func TestUnelevatedACLFailureDoesNotRecordTheMarker(t *testing.T) { + workspace := t.TempDir() + home := t.TempDir() + config := WindowsSandboxCommandConfig{ + SandboxHome: home, + CommandCWD: workspace, + WorkspaceRoots: []string{workspace}, + PermissionProfile: PermissionProfile{ + FileSystem: FileSystemPolicy{ + Kind: FileSystemRestricted, + WriteRoots: []WritableRoot{{Root: workspace}}, + }, + Network: NetworkPolicy{Mode: NetworkDeny}, + }, + SandboxLevel: WindowsSandboxLevelUnelevated, + } + + original := applyWindowsUnelevatedACLPlanFn + t.Cleanup(func() { applyWindowsUnelevatedACLPlanFn = original }) + applyWindowsUnelevatedACLPlanFn = func(WindowsACLPlan) (func() error, error) { + return nil, errors.New("Access is denied.") + } + + if err := ensureWindowsUnelevatedSetup(config); err == nil { + t.Fatal("expected the apply failure to surface") + } + + applied, _, err := buildWindowsUnelevatedAppliedPlan(config) + if err != nil { + t.Fatalf("buildWindowsUnelevatedAppliedPlan: %v", err) + } + marker, err := loadWindowsUnelevatedSetupMarker(home) + if err != nil { + t.Fatalf("loadWindowsUnelevatedSetupMarker: %v", err) + } + if marker.contains(applied) { + t.Error("the failed plan was recorded as applied, so every later command would skip the apply and run unjailed") + } +} From 993e83dbae47ab117fce59ea3e8b57ef522b7711 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Wed, 19 Aug 2026 13:37:50 +0530 Subject: [PATCH 05/43] test(sandbox): fail rather than skip when the DenyRead token loses the World SID --- .../sandbox/windows_token_windows_test.go | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/internal/sandbox/windows_token_windows_test.go b/internal/sandbox/windows_token_windows_test.go index f8162ff37..b84c6ba96 100644 --- a/internal/sandbox/windows_token_windows_test.go +++ b/internal/sandbox/windows_token_windows_test.go @@ -141,12 +141,26 @@ func TestRestrictedSIDListCarriesTheCapabilitySID(t *testing.T) { // // The consequence is that this shape, selected whenever a profile sets DenyRead, // has no effective write jail. If someone closes #869 by giving reads a grant -// that is not a universal group, this test skips with a note and should be -// replaced by the exclusion assertion rather than deleted. +// that is not a universal group, this test FAILS and must be replaced by the +// exclusion assertion in the same change, rather than deleted or skipped past. func TestNonWriteRestrictedTokenStillCarriesTheWorldSID(t *testing.T) { values := restrictedSIDStrings(t, restrictedTokenForTest(t, false)) if !containsSID(values, "S-1-1-0") { - t.Skip("the World SID is gone from the DenyRead token shape; #869 may be fixed, so replace this with the exclusion assertion") + // FAILS rather than skips, and the difference matters more than it looks. + // + // This SID is availability-critical as well as security-relevant: without + // WRITE_RESTRICTED the restricted-SID check covers reads, default Windows + // DACLs grant BUILTINUsers, and a token carrying no universal group cannot + // open cmd.exe. Removing it therefore breaks every command with DenyRead at + // launch. A skip here would let exactly that land on green CI, which is the + // one outcome this test exists to prevent. + // + // If you are reading this because you deliberately changed the token shape + // for #869: good, and this assertion is now yours to replace, in the same + // change, with tests proving the new token still launches an ordinary + // executable, still denies the intended read path, and has not restored the + // broad write bypass. Deleting it without those is not the same thing. + t.Fatal("the World SID is gone from the DenyRead token shape: every DenyRead command now fails at launch unless reads were given a non-universal grant; replace this assertion with the #869 exclusion and launch tests") } t.Log("known gap (#869): the DenyRead token shape carries the World SID, so its write jail does not hold") } From c4c25886edd02831252a3b6ef85b3f4b7f612fe9 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Thu, 20 Aug 2026 15:57:04 +0530 Subject: [PATCH 06/43] fix(sandbox): disclose the DenyRead write-jail trade on the execution path The warning this PR added was reachable only from BackendPlan, which is what `zero sandbox policy` and `zero sandbox check` render. A real tool call takes a different path: the resolved profile becomes a CommandPlan, and the Windows runner picks the token shape from that profile alone. DenyRead being non-empty drops WRITE_RESTRICTED, which is the shape #869 is about. So an operator could approve file_system.deny_read for one command, lose the workspace write jail, and never see the disclosure, because it lived on a diagnostic view they had no reason to run. The notice is derived in withSandboxExecutionMetadata rather than at each caller. That is the single funnel every plan passes through, including the Windows one, so an execution caller cannot be added that quietly misses it. It travels on CommandPlan.Notes, reaches the tool boundary as the sandbox_notices metadata key alongside the downgrade reason that already goes that way, and reaches the typed execution path as Enforcement.Notices. The policy and check warning stays as the diagnostic view. Covered in both directions and at both layers: a plan resolved with DenyRead carries the notice and an ordinary profile carries none, and the tool metadata gains the key only when there is something to say. Dropping the derivation fails the plan test, dropping the emission fails the metadata test. --- internal/execution/contracts.go | 5 ++ internal/sandbox/runner.go | 13 +++++ .../sandbox/windows_deny_read_warning_test.go | 48 +++++++++++++++++++ internal/tools/bash.go | 7 +++ internal/tools/exec_command.go | 1 + internal/tools/sandbox_notice_meta_test.go | 48 +++++++++++++++++++ 6 files changed, 122 insertions(+) create mode 100644 internal/tools/sandbox_notice_meta_test.go diff --git a/internal/execution/contracts.go b/internal/execution/contracts.go index dd861ac8f..002ef0c01 100644 --- a/internal/execution/contracts.go +++ b/internal/execution/contracts.go @@ -173,6 +173,11 @@ type Enforcement struct { Level string `json:"level,omitempty"` Degraded bool `json:"degraded,omitempty"` DowngradeReason string `json:"downgradeReason,omitempty"` + // Notices are least-privilege disclosures about the enforcement actually + // applied to THIS command, as opposed to the diagnostic views produced by + // `zero sandbox policy` and `zero sandbox check`. A trade an operator only + // discovers by running a separate diagnostic command is not disclosed. + Notices []string `json:"notices,omitempty"` } type Outcome struct { diff --git a/internal/sandbox/runner.go b/internal/sandbox/runner.go index 8528e7e82..d0e0c97d9 100644 --- a/internal/sandbox/runner.go +++ b/internal/sandbox/runner.go @@ -319,6 +319,19 @@ func withSandboxExecutionMetadata(plan CommandPlan, request SandboxExecutionRequ plan.EnforcementLevel = request.EnforcementLevel plan.DowngradeReason = request.DowngradeReason plan.RequiresPlatformSandbox = request.RequiresPlatformSandbox + // THE EXECUTION PATH GETS THE SAME NOTICE THE DIAGNOSTICS DO. BackendPlan + // carries these for `zero sandbox policy` and `zero sandbox check`, which an + // operator may never run. A real tool call takes this path instead, and the + // Windows runner selects the token shape from the resolved profile alone: as + // soon as DenyRead is non-empty it drops WRITE_RESTRICTED and the write jail + // stops confining writes outside the workspace. Approving + // file_system.deny_read for one command could therefore cost the jail with + // nothing said about it. + // + // Derived here rather than at each caller because this is the single funnel + // every plan passes through, including the Windows one, so no execution + // caller can be added that quietly misses it. + plan.Notes = append(plan.Notes, windowsDenyReadWarnings(request.Backend, request.PermissionProfile)...) return plan } diff --git a/internal/sandbox/windows_deny_read_warning_test.go b/internal/sandbox/windows_deny_read_warning_test.go index 87d8312ec..67c7d772b 100644 --- a/internal/sandbox/windows_deny_read_warning_test.go +++ b/internal/sandbox/windows_deny_read_warning_test.go @@ -98,3 +98,51 @@ func TestNoDenyReadWarningWhenTheHostIsNotWindows(t *testing.T) { } } } + +// THE EXECUTION PATH, NOT JUST THE DIAGNOSTIC ONE. +// +// The warning above is reachable from BackendPlan, which is what `zero sandbox +// policy` and `zero sandbox check` render. An operator who never runs those +// sees nothing. A real tool call builds a CommandPlan instead, and the Windows +// runner picks the token shape from the resolved profile alone: DenyRead +// non-empty means no WRITE_RESTRICTED, which is the shape #869 is about. So +// approving file_system.deny_read for one command could cost the write jail +// with nothing said. +// +// withSandboxExecutionMetadata is the single funnel every plan passes through, +// including the Windows one, which is why the notice is derived there rather +// than at each caller. +func TestCommandPlanCarriesTheDenyReadDisclosure(t *testing.T) { + withWindowsHost(t) + + request := SandboxExecutionRequest{ + Backend: windowsRestrictedTokenBackend(), + TargetBackend: BackendWindowsRestrictedToken, + PermissionProfile: profileWithDenyRead(`C:\Users\someone\.config\creds`), + } + plan := withSandboxExecutionMetadata(CommandPlan{}, request) + + if len(plan.Notes) == 0 { + t.Fatal("a command plan resolved with denyRead carried no notice, so the operator loses the write jail without being told") + } + notice := strings.ToLower(strings.Join(plan.Notes, " ")) + for _, want := range []string{"denyread", "write", "#869"} { + if !strings.Contains(notice, want) { + t.Errorf("the execution-path notice does not mention %q: %q", want, notice) + } + } +} + +// And it stays quiet for the ordinary profile, or every Windows command grows a +// notice about a trade nobody made. +func TestCommandPlanCarriesNoDisclosureWithoutDenyRead(t *testing.T) { + withWindowsHost(t) + + plan := withSandboxExecutionMetadata(CommandPlan{}, SandboxExecutionRequest{ + Backend: windowsRestrictedTokenBackend(), + TargetBackend: BackendWindowsRestrictedToken, + }) + if len(plan.Notes) != 0 { + t.Errorf("a plan without denyRead carried notices: %v", plan.Notes) + } +} diff --git a/internal/tools/bash.go b/internal/tools/bash.go index 6274c806c..32b99f094 100644 --- a/internal/tools/bash.go +++ b/internal/tools/bash.go @@ -349,6 +349,13 @@ func addSandboxMeta(meta map[string]string, plan zeroSandbox.CommandPlan) { if plan.DowngradeReason != "" { meta["sandbox_downgrade_reason"] = plan.DowngradeReason } + // Least-privilege notices for the command actually being run, on the same + // channel as the downgrade reason. Without this the DenyRead write-jail + // trade was visible only to `zero sandbox policy` and `zero sandbox check`, + // so an operator could approve it per command and never be told. + if len(plan.Notes) > 0 { + meta["sandbox_notices"] = strings.Join(plan.Notes, "\n") + } meta["sandbox_requires_platform"] = strconv.FormatBool(plan.RequiresPlatformSandbox) if plan.Backend.Message != "" { meta["sandbox_message"] = plan.Backend.Message diff --git a/internal/tools/exec_command.go b/internal/tools/exec_command.go index c3f643367..9ca50d021 100644 --- a/internal/tools/exec_command.go +++ b/internal/tools/exec_command.go @@ -240,6 +240,7 @@ func executionEnforcement(plan zeroSandbox.CommandPlan) execution.Enforcement { Level: string(plan.EnforcementLevel), Degraded: plan.EnforcementLevel == zeroSandbox.EnforcementDegraded, DowngradeReason: plan.DowngradeReason, + Notices: append([]string(nil), plan.Notes...), } } diff --git a/internal/tools/sandbox_notice_meta_test.go b/internal/tools/sandbox_notice_meta_test.go new file mode 100644 index 000000000..7215ef8af --- /dev/null +++ b/internal/tools/sandbox_notice_meta_test.go @@ -0,0 +1,48 @@ +package tools + +import ( + "strings" + "testing" + + zeroSandbox "github.com/Gitlawb/zero/internal/sandbox" +) + +// THE DISCLOSURE HAS TO REACH THE OPERATOR, not just exist on the plan. +// +// The DenyRead write-jail trade was reachable only from BackendPlan, which +// `zero sandbox policy` and `zero sandbox check` render. Someone approving +// file_system.deny_read for a single command never runs those, so they lost the +// write jail silently. addSandboxMeta is the boundary where a tool call's +// sandbox facts become visible, alongside the downgrade reason that already +// travels this way. +func TestSandboxMetaCarriesLeastPrivilegeNotices(t *testing.T) { + meta := map[string]string{} + addSandboxMeta(meta, zeroSandbox.CommandPlan{ + Backend: zeroSandbox.Backend{Name: zeroSandbox.BackendWindowsRestrictedToken}, + Notes: []string{ + "denyRead is set, so the restricted token drops WRITE_RESTRICTED and the workspace write jail no longer confines writes outside it (#869).", + }, + }) + + notices, ok := meta["sandbox_notices"] + if !ok { + t.Fatalf("no sandbox_notices in the tool result metadata, so the trade stays invisible to whoever approved it: %#v", meta) + } + for _, want := range []string{"denyRead", "#869"} { + if !strings.Contains(notices, want) { + t.Errorf("notice does not mention %q: %q", want, notices) + } + } +} + +// A plan with nothing to disclose must not add the key, or every command grows +// an empty field and the presence of one stops meaning anything. +func TestSandboxMetaOmitsNoticesWhenThereAreNone(t *testing.T) { + meta := map[string]string{} + addSandboxMeta(meta, zeroSandbox.CommandPlan{ + Backend: zeroSandbox.Backend{Name: zeroSandbox.BackendWindowsRestrictedToken}, + }) + if value, ok := meta["sandbox_notices"]; ok { + t.Errorf("sandbox_notices present with nothing to say: %q", value) + } +} From 58decfaf49962c55813aab8efaa585e454b792e3 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Fri, 21 Aug 2026 11:09:07 +0530 Subject: [PATCH 07/43] fix(sandbox,tools): make the DenyRead disclosure reach a human, and project enforcement once Three findings from review. The disclosure went into Result.Meta and stopped there. That looked like the established channel because sandbox_downgrade_reason travels the same way, and it is not one: nothing in production reads those keys, ModelOutput and HumanDisplay never consult Meta, and the durable history drops it. A Windows user configuring deny_read could take the non-WRITE_RESTRICTED token, lose write confinement, and see nothing but ordinary command output. It is a field on the canonical result now, surfaced by both accessors, so every surface reads it through one contract. Prepended rather than appended, because the output budget trims from the end and a disclosure that survives only on short results is not one. The metadata copy stays for integrations reading the result JSON. Promoted at finalizeToolOutcome, the single seam every tool result crosses, rather than at each construction site. Setting it where results are built would have been a third hand-maintained projection of the same fact, which is how it went missing from the generic adapter to begin with. That generic adapter is the second finding. PrepareExecution built execution.Enforcement by hand for the wrapper hooks, plugins and MCP processes go through, while exec_command built the same struct by hand for the tool path, so Notices reached one and not the other. Both go through EnforcementFor now, which copies the slice defensively. And the notice claimed a trade nobody had made. The predicate asked only about the host, the backend and DenyRead, so a disabled sandbox or a re-entrant command, both of which take the direct unwrapped plan while still carrying the Windows backend and profile, were told the write jail was gone. Neither half was true there: no restricted token is created and deny-read is not enforced either. It is keyed on the resolved execution state now, with the disabled, degraded, already-wrapped, no-platform-sandbox and no-backend cases covered as explicit silent cases. My own fixture from the previous round was one of the things that had to change: it named the backend without the fields that make a plan wrapped, so it was asserting against a request that would never have produced a token. --- internal/agent/loop.go | 52 ++++---- internal/agent/types.go | 15 ++- internal/sandbox/runner.go | 71 +++++++++-- .../sandbox/windows_deny_read_warning_test.go | 53 +++++++- internal/tools/exec_command.go | 8 +- .../tools/sandbox_notice_visibility_test.go | 113 ++++++++++++++++++ internal/tools/tool_outcome.go | 12 ++ internal/tools/types.go | 49 +++++++- 8 files changed, 317 insertions(+), 56 deletions(-) create mode 100644 internal/tools/sandbox_notice_visibility_test.go diff --git a/internal/agent/loop.go b/internal/agent/loop.go index fe691ac4c..f14591428 100644 --- a/internal/agent/loop.go +++ b/internal/agent/loop.go @@ -1517,20 +1517,21 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal // the agent loop and the MCP server pass through), so result.Output is // already redacted here and result.Redacted reflects whether it changed. return ToolResult{ - Risk: executedRisk, - ToolCallID: call.ID, - Name: call.Name, - Status: result.Status, - Output: result.ModelOutput(), - Truncated: result.Truncated, - Meta: result.Meta, - Images: result.Images, - Redacted: result.Redacted, - ChangedFiles: result.ChangedFiles, - ChangeSummaries: result.ChangeSummaries, - Display: result.HumanDisplay(), - Outcome: result.Outcome, - LoadedTools: loadedToolsFromResult(result.Meta), + Risk: executedRisk, + ToolCallID: call.ID, + Name: call.Name, + Status: result.Status, + Output: result.ModelOutput(), + Truncated: result.Truncated, + Meta: result.Meta, + EnforcementNotices: append([]string(nil), result.EnforcementNotices...), + Images: result.Images, + Redacted: result.Redacted, + ChangedFiles: result.ChangedFiles, + ChangeSummaries: result.ChangeSummaries, + Display: result.HumanDisplay(), + Outcome: result.Outcome, + LoadedTools: loadedToolsFromResult(result.Meta), // A tool may signal a mid-run model escalation by carrying the target id // in Meta["escalate_to_model"]. Lift it into the typed loop-level field; // the Run turn loop performs the actual provider switch. Empty for every @@ -2145,17 +2146,18 @@ func askUserFallbackResult(ctx context.Context, registry *tools.Registry, call T Cwd: options.Cwd, }) return ToolResult{ - ToolCallID: call.ID, - Name: call.Name, - Status: result.Status, - Output: result.ModelOutput(), - Truncated: result.Truncated, - Meta: result.Meta, - Redacted: result.Redacted, - ChangedFiles: result.ChangedFiles, - ChangeSummaries: result.ChangeSummaries, - Display: result.HumanDisplay(), - Outcome: result.Outcome, + ToolCallID: call.ID, + Name: call.Name, + Status: result.Status, + Output: result.ModelOutput(), + Truncated: result.Truncated, + Meta: result.Meta, + EnforcementNotices: append([]string(nil), result.EnforcementNotices...), + Redacted: result.Redacted, + ChangedFiles: result.ChangedFiles, + ChangeSummaries: result.ChangeSummaries, + Display: result.HumanDisplay(), + Outcome: result.Outcome, } } return ToolResult{ diff --git a/internal/agent/types.go b/internal/agent/types.go index 511ea7140..7aaa0bc10 100644 --- a/internal/agent/types.go +++ b/internal/agent/types.go @@ -79,6 +79,10 @@ type ToolResult struct { // The full result may be recoverable through Meta["spill_path"]. Truncated bool Meta map[string]string + // EnforcementNotices mirrors tools.Result.EnforcementNotices so the + // disclosure survives the conversion into the agent-facing result and + // reaches the model, the transcript and the interactive display. + EnforcementNotices []string // Images the tool produced, delivered to the model as a following user // message rather than on this result. See tools.Result.Images. Images []zeroruntime.ImageBlock @@ -113,18 +117,21 @@ type ToolResult struct { // compatibility with synthetic and restored results created before outcomes // were finalized. func (result ToolResult) ModelOutput() string { + base := result.Output if result.Outcome.Finalized() { - return result.Outcome.ModelView + base = result.Outcome.ModelView } - return result.Output + return tools.WithEnforcementNotices(base, result.EnforcementNotices) } // HumanDisplay returns the presentation intended for interactive surfaces. func (result ToolResult) HumanDisplay() tools.Display { + display := result.Display if result.Outcome.Finalized() { - return result.Outcome.HumanView + display = result.Outcome.HumanView } - return result.Display + display.Summary = tools.WithEnforcementNotices(display.Summary, result.EnforcementNotices) + return display } // DenialCategory classifies why a tool call was blocked before it executed. diff --git a/internal/sandbox/runner.go b/internal/sandbox/runner.go index d0e0c97d9..fc099c346 100644 --- a/internal/sandbox/runner.go +++ b/internal/sandbox/runner.go @@ -131,15 +131,10 @@ func (engine *Engine) PrepareExecution(ctx context.Context, request execution.Re return execution.PreparedCommand{}, err } return execution.PreparedCommand{ - Command: command, - Enforcement: execution.Enforcement{ - Backend: string(plan.TargetBackend), - Level: string(plan.EnforcementLevel), - Degraded: plan.EnforcementLevel == EnforcementDegraded, - DowngradeReason: plan.DowngradeReason, - }, - Report: plan.ExecutionReport, - Cleanup: plan.Cleanup, + Command: command, + Enforcement: EnforcementFor(plan), + Report: plan.ExecutionReport, + Cleanup: plan.Cleanup, }, nil } @@ -331,7 +326,16 @@ func withSandboxExecutionMetadata(plan CommandPlan, request SandboxExecutionRequ // Derived here rather than at each caller because this is the single funnel // every plan passes through, including the Windows one, so no execution // caller can be added that quietly misses it. - plan.Notes = append(plan.Notes, windowsDenyReadWarnings(request.Backend, request.PermissionProfile)...) + // KEYED ON WHAT WILL ACTUALLY RUN, not on configuration. The predicate used to + // ask only about the host, the backend and DenyRead, so a disabled sandbox or a + // re-entrant command, both of which take the direct unwrapped plan while still + // carrying the Windows backend and profile, were told the write jail had been + // traded away. Neither claim was true there: no restricted token is created and + // the deny-read rule is not enforced either, so the notice described a trade + // nobody had made. + if windowsRestrictedTokenWillRun(request) { + plan.Notes = append(plan.Notes, windowsDenyReadWarnings(request.Backend, request.PermissionProfile)...) + } return plan } @@ -1199,3 +1203,50 @@ func isDynamicSensitiveEnvKey(key string) bool { strings.HasSuffix(key, suffix) && len(key) > len(prefix)+len(suffix) } + +// EnforcementFor projects a CommandPlan onto the platform-neutral enforcement +// contract. +// +// ONE PROJECTION, because there were two and they drifted. PrepareExecution +// built execution.Enforcement by hand for the generic adapter that hooks, +// plugins and MCP processes go through, and exec_command built the same struct +// by hand for the tool path. When Notices was added it reached only the tool +// path, so the contract was true for one wrapper and false for the wrapper other +// execution consumers depend on. A hand-maintained projection duplicated across +// two adapters cannot be kept honest by review; a shared one cannot be missed. +// +// The notice slice is copied rather than aliased so a consumer cannot mutate the +// plan through it. +func EnforcementFor(plan CommandPlan) execution.Enforcement { + return execution.Enforcement{ + Backend: string(plan.TargetBackend), + Level: string(plan.EnforcementLevel), + Degraded: plan.EnforcementLevel == EnforcementDegraded, + DowngradeReason: plan.DowngradeReason, + Notices: append([]string(nil), plan.Notes...), + } +} + +// windowsRestrictedTokenWillRun reports whether this plan will actually be +// wrapped in a Windows restricted token. +// +// The disclosure is about a token shape, so it has to follow the token rather +// than the configuration that would have produced one. buildPlatformCommandPlan +// takes the direct, unwrapped path for a disabled or degraded enforcement level, +// for BackendNone, for a command that does not require a platform sandbox, and +// for one already wrapped by an outer sandbox. None of those creates a token, +// and none of them enforces deny-read. +func windowsRestrictedTokenWillRun(request SandboxExecutionRequest) bool { + if request.CommandWrapped || !request.RequiresPlatformSandbox { + return false + } + if request.EnforcementLevel == EnforcementDisabled || request.EnforcementLevel == EnforcementDegraded { + return false + } + switch request.TargetBackend { + case BackendWindowsRestrictedToken, BackendWindowsElevated: + return true + default: + return false + } +} diff --git a/internal/sandbox/windows_deny_read_warning_test.go b/internal/sandbox/windows_deny_read_warning_test.go index 67c7d772b..a6f366754 100644 --- a/internal/sandbox/windows_deny_read_warning_test.go +++ b/internal/sandbox/windows_deny_read_warning_test.go @@ -115,12 +115,9 @@ func TestNoDenyReadWarningWhenTheHostIsNotWindows(t *testing.T) { func TestCommandPlanCarriesTheDenyReadDisclosure(t *testing.T) { withWindowsHost(t) - request := SandboxExecutionRequest{ - Backend: windowsRestrictedTokenBackend(), - TargetBackend: BackendWindowsRestrictedToken, - PermissionProfile: profileWithDenyRead(`C:\Users\someone\.config\creds`), - } - plan := withSandboxExecutionMetadata(CommandPlan{}, request) + // A request that will actually produce a restricted token. The notice follows + // the token, so a fixture that only names the backend is not enough. + plan := withSandboxExecutionMetadata(CommandPlan{}, wrappedWindowsRequest()) if len(plan.Notes) == 0 { t.Fatal("a command plan resolved with denyRead carried no notice, so the operator loses the write jail without being told") @@ -146,3 +143,47 @@ func TestCommandPlanCarriesNoDisclosureWithoutDenyRead(t *testing.T) { t.Errorf("a plan without denyRead carried notices: %v", plan.Notes) } } + +// wrappedWindowsRequest is the shape buildPlatformCommandPlan actually wraps in +// a restricted token: a platform sandbox is required, enforcement is native, the +// target is the Windows restricted-token backend, and nothing outside has +// wrapped the command already. +func wrappedWindowsRequest() SandboxExecutionRequest { + return SandboxExecutionRequest{ + Backend: windowsRestrictedTokenBackend(), + TargetBackend: BackendWindowsRestrictedToken, + PermissionProfile: profileWithDenyRead(`C:\Users\someone\.config\creds`), + RequiresPlatformSandbox: true, + EnforcementLevel: EnforcementNative, + } +} + +// THE NOTICE DESCRIBES A TOKEN, SO IT MUST FOLLOW THE TOKEN. +// +// Each case below carries the Windows backend and a DenyRead profile, and each +// takes the direct unwrapped plan rather than the restricted-token one. No token +// is created and the deny-read rule is not enforced, so claiming the write jail +// was traded away is false in both halves. +func TestNoDisclosureWhenNoRestrictedTokenIsCreated(t *testing.T) { + withWindowsHost(t) + + for _, testCase := range []struct { + name string + mutate func(*SandboxExecutionRequest) + }{ + {name: "sandboxing disabled", mutate: func(r *SandboxExecutionRequest) { r.EnforcementLevel = EnforcementDisabled }}, + {name: "degraded to no native isolation", mutate: func(r *SandboxExecutionRequest) { r.EnforcementLevel = EnforcementDegraded }}, + {name: "already wrapped by an outer sandbox", mutate: func(r *SandboxExecutionRequest) { r.CommandWrapped = true }}, + {name: "command needs no platform sandbox", mutate: func(r *SandboxExecutionRequest) { r.RequiresPlatformSandbox = false }}, + {name: "no target backend", mutate: func(r *SandboxExecutionRequest) { r.TargetBackend = BackendNone }}, + } { + t.Run(testCase.name, func(t *testing.T) { + request := wrappedWindowsRequest() + testCase.mutate(&request) + plan := withSandboxExecutionMetadata(CommandPlan{}, request) + if len(plan.Notes) != 0 { + t.Errorf("claimed the write jail was traded away where no restricted token runs: %v", plan.Notes) + } + }) + } +} diff --git a/internal/tools/exec_command.go b/internal/tools/exec_command.go index 9ca50d021..bda97f0ec 100644 --- a/internal/tools/exec_command.go +++ b/internal/tools/exec_command.go @@ -235,13 +235,7 @@ func (tool execCommandTool) run(ctx context.Context, args map[string]any, engine } func executionEnforcement(plan zeroSandbox.CommandPlan) execution.Enforcement { - return execution.Enforcement{ - Backend: string(plan.TargetBackend), - Level: string(plan.EnforcementLevel), - Degraded: plan.EnforcementLevel == zeroSandbox.EnforcementDegraded, - DowngradeReason: plan.DowngradeReason, - Notices: append([]string(nil), plan.Notes...), - } + return zeroSandbox.EnforcementFor(plan) } type writeStdinTool struct { diff --git a/internal/tools/sandbox_notice_visibility_test.go b/internal/tools/sandbox_notice_visibility_test.go new file mode 100644 index 000000000..7f5a4ae78 --- /dev/null +++ b/internal/tools/sandbox_notice_visibility_test.go @@ -0,0 +1,113 @@ +package tools + +import ( + "context" + "strings" + "testing" + + zeroSandbox "github.com/Gitlawb/zero/internal/sandbox" +) + +const testDenyReadNotice = "denyRead is set, so the restricted token drops WRITE_RESTRICTED and the workspace write jail no longer confines writes outside it (#869)." + +// noticeCarryingTool stands in for a command tool whose plan carried an +// enforcement notice. It writes the notice the way addSandboxMeta does, which is +// the only thing the production paths do with it. +type noticeCarryingTool struct{} + +func (noticeCarryingTool) Name() string { return "bash" } +func (noticeCarryingTool) Description() string { return "test shell tool" } +func (noticeCarryingTool) Parameters() Schema { + return Schema{ + Type: "object", + Properties: map[string]PropertySchema{"command": {Type: "string"}}, + Required: []string{"command"}, + AdditionalProperties: false, + } +} +func (noticeCarryingTool) Safety() Safety { + return Safety{SideEffect: SideEffectRead, Permission: PermissionAllow, Reason: "reads files"} +} +func (noticeCarryingTool) Run(context.Context, map[string]any) Result { + meta := map[string]string{} + addSandboxMeta(meta, zeroSandbox.CommandPlan{ + Backend: zeroSandbox.Backend{Name: zeroSandbox.BackendWindowsRestrictedToken}, + Notes: []string{testDenyReadNotice}, + }) + return Result{ + Status: StatusOK, + Output: "hello from the command", + Meta: meta, + Display: Display{Summary: "ran the command", Kind: "shell"}, + } +} + +// THE DISCLOSURE HAS TO REACH A HUMAN AND A MODEL, NOT A METADATA MAP. +// +// The first version of this wrote sandbox_notices into Result.Meta and stopped +// there. Nothing in production reads those keys, ModelOutput and HumanDisplay +// never consult Meta, and the durable history drops it, so a Windows user who +// configured deny_read could take the non-WRITE_RESTRICTED token, lose write +// confinement, and see nothing but ordinary command output. Metadata is +// side-band data, not a disclosure channel. +func TestEnforcementNoticeReachesTheModelAndTheDisplay(t *testing.T) { + registry := NewRegistry() + registry.Register(noticeCarryingTool{}) + + result := registry.RunWithOptions(context.Background(), "bash", map[string]any{ + "command": "echo hello", + }, RunOptions{PermissionGranted: true}) + + if result.Status != StatusOK { + t.Fatalf("tool failed: %s", result.Output) + } + + model := result.ModelOutput() + if !strings.Contains(model, "#869") { + t.Errorf("the model-facing result does not carry the disclosure, so the agent proceeds unaware:\n%s", model) + } + if !strings.Contains(model, "hello from the command") { + t.Errorf("the notice displaced the actual output:\n%s", model) + } + // PREPENDED, because the output budget trims from the end and a disclosure + // that survives only on short results is not a disclosure. + if !strings.HasPrefix(strings.TrimSpace(model), testDenyReadNotice) { + t.Errorf("the notice is not in front of the output, so a trimmed result can lose it:\n%s", model) + } + + display := result.HumanDisplay() + if !strings.Contains(display.Summary, "#869") { + t.Errorf("the interactive display does not carry the disclosure, so the operator sees nothing: %q", display.Summary) + } + + // Kept in metadata too, for integrations reading the result JSON. + if result.Meta[sandboxNoticesMeta] == "" { + t.Errorf("the metadata copy was dropped: %#v", result.Meta) + } +} + +// A result with nothing to disclose must be untouched, or every command grows a +// blank line and the presence of a notice stops meaning anything. +func TestResultsWithoutNoticesAreUnchanged(t *testing.T) { + result := Result{Status: StatusOK, Output: "plain output", Display: Display{Summary: "did a thing"}} + + if got := result.ModelOutput(); got != "plain output" { + t.Errorf("model output = %q, want it untouched", got) + } + if got := result.HumanDisplay().Summary; got != "did a thing" { + t.Errorf("display summary = %q, want it untouched", got) + } +} + +// Whitespace-only notices are not notices. Guards against a plan that carries an +// empty entry putting a blank line in front of every result. +func TestBlankNoticesDoNotAlterTheResult(t *testing.T) { + result := Result{ + Status: StatusOK, + Output: "plain output", + EnforcementNotices: []string{"", " "}, + } + if got := result.ModelOutput(); got != "plain output" { + t.Errorf("model output = %q, want it untouched", got) + } +} diff --git a/internal/tools/tool_outcome.go b/internal/tools/tool_outcome.go index 439aab58b..f92868796 100644 --- a/internal/tools/tool_outcome.go +++ b/internal/tools/tool_outcome.go @@ -46,6 +46,18 @@ func (outcome *ToolOutcome) UnmarshalJSON(data []byte) error { // boundaryOutput must already be redacted. It is the text seen immediately // before command reduction and semantic budgeting. func finalizeToolOutcome(result Result, boundaryOutput string) Result { + // PROMOTED HERE, at the one seam every tool result crosses, rather than at + // each construction site. addSandboxMeta already carries the plan's notices + // into metadata for both the bash and the exec_command paths, and any future + // command tool that calls it gets the same treatment for free. Setting the + // field at the call sites instead would be a third hand-maintained projection + // of the same fact, which is exactly how the disclosure went missing from the + // generic execution adapter in the first place. + if len(result.EnforcementNotices) == 0 { + if notices := strings.TrimSpace(result.Meta[sandboxNoticesMeta]); notices != "" { + result.EnforcementNotices = strings.Split(notices, "\n") + } + } previous := result.Outcome human := result.Display if human.Preview == "" && result.Meta["command_output_reduced"] == "true" { diff --git a/internal/tools/types.go b/internal/tools/types.go index 27755d8d4..445042bd1 100644 --- a/internal/tools/types.go +++ b/internal/tools/types.go @@ -2,6 +2,7 @@ package tools import ( "context" + "strings" "github.com/Gitlawb/zero/internal/execution" "github.com/Gitlawb/zero/internal/sandbox" @@ -52,6 +53,11 @@ const ( SandboxDenialKindMeta = "sandbox_denial_kind" SandboxDenialReasonMeta = "sandbox_denial_reason" SandboxDenialKeywordMeta = "sandbox_denial_keyword" + // sandboxNoticesMeta transports the plan notices from addSandboxMeta to + // finalizeToolOutcome, which promotes them onto Result.EnforcementNotices. + // Kept as metadata as well, because integrations reading the result JSON have + // no other way to see them. + sandboxNoticesMeta = "sandbox_notices" ) const ( @@ -113,6 +119,16 @@ type Result struct { // emits them as a following user message, which is also the only shape that // keeps one tool result per tool call. Images []zeroruntime.ImageBlock `json:"-"` + // EnforcementNotices are least-privilege disclosures about the enforcement + // actually applied to this command, and they are USER AND MODEL VISIBLE. + // + // A separate field rather than text baked into Output, so one canonical + // result carries it and every surface reads it through the accessors below. + // The first attempt put this in Meta alongside the sandbox metadata, which + // looked like the established channel and is not one: nothing in production + // reads those keys, ModelOutput and HumanDisplay never consult Meta, and the + // durable history drops it. The disclosure reached nobody. + EnforcementNotices []string `json:"enforcementNotices,omitempty"` // Redacted is set when secret scrubbing altered Output before it left the // tool-execution boundary. Redacted bool @@ -178,19 +194,44 @@ type OutcomeDiagnostics struct { // ModelOutput returns the finalized provider-facing text, falling back to the // legacy field for direct Tool.Run callers that have not crossed the registry. func (result Result) ModelOutput() string { + base := result.Output if result.Outcome.finalized { - return result.Outcome.ModelView + base = result.Outcome.ModelView } - return result.Output + return WithEnforcementNotices(base, result.EnforcementNotices) } // HumanDisplay returns the finalized presentation, falling back to the legacy // display for direct Tool.Run callers. func (result Result) HumanDisplay() Display { + display := result.Display if result.Outcome.finalized { - return result.Outcome.HumanView + display = result.Outcome.HumanView + } + display.Summary = WithEnforcementNotices(display.Summary, result.EnforcementNotices) + return display +} + +// WithEnforcementNotices puts the enforcement disclosure IN FRONT of the text. +// +// PREPENDED, not appended, because the output budget trims from the end: a +// notice at the tail is the first thing a long result loses, and a disclosure +// that survives only on short outputs is not a disclosure. It is also why this +// lives on the accessors rather than at the call sites that build results. +// The previous version wrote it into Result.Meta, and neither ModelOutput nor +// HumanDisplay nor the durable history reads Meta, so it reached nobody at all. +func WithEnforcementNotices(text string, notices []string) string { + if len(notices) == 0 { + return text + } + joined := strings.TrimSpace(strings.Join(notices, "\n")) + if joined == "" { + return text + } + if strings.TrimSpace(text) == "" { + return joined } - return result.Display + return joined + "\n\n" + text } // Display carries a short, structured summary of a tool result for the TUI/stream. From a9a54ced28f511c736885f2f9384b6caa2f518e7 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Sat, 22 Aug 2026 13:18:44 +0530 Subject: [PATCH 08/43] fix(sandbox,plugins,hooks): disclose the write-jail trade where it actually happens Two halves of the same disclosure, neither of which reached a user. The predicate keyed on request.CommandWrapped, read as "something already wrapped this, so we are re-entrant". That is the opposite of what the field means: BuildExecutionRequest sets it TRUE for exactly the native and unelevated requests that buildPlatformCommandPlan then routes to windowsRestrictedTokenCommandPlan. So the notice was suppressed on every plan that builds the restricted token and fired on none of them. Every real file_system.deny_read execution got the non-WRITE_RESTRICTED token and was told nothing. It keys on the produced plan's Wrapped state now, which is the resulting execution state and cannot be read backwards: the direct plan sets it false, the restricted-token plan sets it true, and both arrive through the same funnel. The old test passed because its hand-built request left CommandWrapped false, which is a shape no real execution has, and the whole cluster around it did the same by passing an empty CommandPlan. Those are rewritten to be plan-based, and the new regression drives the manager so the request carries the state the transition actually produces. One of its silent cases named the misreading outright and is gone. The second half: plugin and hook results discarded Outcome.Enforcement.Notices. Both projections copied stdout, stderr and an exit code out of the structured outcome and dropped the rest, so once the predicate above is fixed a plugin tool or a hook runs under the weakened token and still says nothing. Both carry the notices now, plugins onto Result.EnforcementNotices and hooks into the surfaced message, prepended so a hook that prints nothing still discloses. Covered on both paths with an assertion that the notice appears exactly once. --- internal/hooks/dispatch.go | 29 +++++- internal/hooks/enforcement_notice_test.go | 43 +++++++++ internal/plugins/activate.go | 34 +++++-- internal/plugins/enforcement_notice_test.go | 72 +++++++++++++++ internal/sandbox/runner.go | 20 ++++- .../windows_deny_read_disclosure_test.go | 89 +++++++++++++++++++ .../sandbox/windows_deny_read_warning_test.go | 40 ++++++--- 7 files changed, 301 insertions(+), 26 deletions(-) create mode 100644 internal/hooks/enforcement_notice_test.go create mode 100644 internal/plugins/enforcement_notice_test.go create mode 100644 internal/sandbox/windows_deny_read_disclosure_test.go diff --git a/internal/hooks/dispatch.go b/internal/hooks/dispatch.go index d5bb13e3c..a1c076ddd 100644 --- a/internal/hooks/dispatch.go +++ b/internal/hooks/dispatch.go @@ -45,6 +45,13 @@ type commandResult struct { Stderr string Err error // set when the command could not be executed (not a non-zero exit) TimedOut bool // the hook started but its deadline/cancellation fired before it returned + // Notices carries the enforcement disclosures the execution runner attached. + // + // Same reason as the plugin path: the generic execution contract is not + // transport-only. Enforcement.Notices says what the sandbox actually did, and + // a projection that keeps only stdout, stderr and an exit code drops it, so a + // hook ran under a weakened token with nothing said about it. + Notices []string } // commandRunner executes one hook command. It is injectable so the dispatch @@ -139,6 +146,7 @@ func executionCommandRunner(runner *execution.Runner) commandRunner { Stderr: stderr, Err: commandErr, TimedOut: result.Outcome.Kind == execution.OutcomeTimedOut, + Notices: append([]string(nil), result.Outcome.Enforcement.Notices...), } } } @@ -244,10 +252,25 @@ func classifyResult(event Event, result commandResult) (AuditStatus, bool) { // hookMessage returns the output worth surfacing from a hook run: stdout when // present, else stderr. Empty when the hook produced no output. func hookMessage(result commandResult) string { - if trimmed := strings.TrimSpace(result.Stdout); trimmed != "" { - return trimmed + message := strings.TrimSpace(result.Stdout) + if message == "" { + message = strings.TrimSpace(result.Stderr) } - return strings.TrimSpace(result.Stderr) + // PREPENDED, and present even when the hook itself said nothing. A hook that + // runs silently under a weakened token is exactly the case where the only + // thing worth surfacing IS the disclosure. + return withHookEnforcementNotices(message, result.Notices) +} + +func withHookEnforcementNotices(message string, notices []string) string { + joined := strings.TrimSpace(strings.Join(notices, "\n")) + if joined == "" { + return message + } + if strings.TrimSpace(message) == "" { + return joined + } + return joined + "\n\n" + message } func blockReason(result commandResult) string { diff --git a/internal/hooks/enforcement_notice_test.go b/internal/hooks/enforcement_notice_test.go new file mode 100644 index 000000000..62465cc23 --- /dev/null +++ b/internal/hooks/enforcement_notice_test.go @@ -0,0 +1,43 @@ +package hooks + +import ( + "strings" + "testing" +) + +// Same contract on the hook path. The projection kept stdout, stderr and an exit +// code and dropped the enforcement notices, so a hook ran under the weakened +// token silently. +func TestAHookSurfacesTheEnforcementNotice(t *testing.T) { + const notice = "denyRead is configured, so the write jail is not confining writes" + + for _, testCase := range []struct { + name string + result commandResult + want string + }{ + {"hook printed nothing", commandResult{ExitCode: 0, Notices: []string{notice}}, notice}, + {"hook printed to stdout", commandResult{ExitCode: 0, Stdout: "looks fine", Notices: []string{notice}}, notice}, + {"hook printed to stderr only", commandResult{ExitCode: 0, Stderr: "a warning", Notices: []string{notice}}, notice}, + } { + t.Run(testCase.name, func(t *testing.T) { + message := hookMessage(testCase.result) + if !strings.Contains(message, testCase.want) { + t.Fatalf("the hook message does not carry the notice:\n%s", message) + } + if strings.Count(message, testCase.want) != 1 { + t.Errorf("the notice appears %d times, want exactly once:\n%s", strings.Count(message, testCase.want), message) + } + }) + } +} + +// A hook with no notice reads exactly as it did before. +func TestAHookWithoutANoticeIsUnchanged(t *testing.T) { + if message := hookMessage(commandResult{ExitCode: 0, Stdout: "looks fine"}); message != "looks fine" { + t.Errorf("hookMessage = %q, want the hook's own output untouched", message) + } + if message := hookMessage(commandResult{ExitCode: 0}); message != "" { + t.Errorf("a silent hook with no notice produced %q", message) + } +} diff --git a/internal/plugins/activate.go b/internal/plugins/activate.go index 40bf5416f..75f5c0229 100644 --- a/internal/plugins/activate.go +++ b/internal/plugins/activate.go @@ -56,6 +56,15 @@ type commandOutput struct { Stderr string ExitCode int Err error + // Notices carries the enforcement disclosures the execution runner attached + // to this command. + // + // The generic contract is not transport-only. Enforcement.Notices says what + // the sandbox actually did, and on Windows that includes trading the write + // jail away for a deny-read profile. A projection that copies stdout, stderr + // and an exit code drops it, so a plugin tool ran under the weakened token + // and said nothing about it. + Notices []string } // toolRunner executes a resolved plugin tool command. It is injectable so @@ -558,17 +567,19 @@ func (tool pluginTool) invoke(ctx context.Context, args map[string]any, cwd stri formatted := formatPluginToolOutput(output) if output.ExitCode != 0 { return tools.Result{ - Status: tools.StatusError, - Output: formatted, - Meta: meta, - Display: tools.Display{Summary: tool.name + " failed", Kind: "plugin"}, + Status: tools.StatusError, + Output: formatted, + Meta: meta, + EnforcementNotices: output.Notices, + Display: tools.Display{Summary: tool.name + " failed", Kind: "plugin"}, } } return tools.Result{ - Status: tools.StatusOK, - Output: formatted, - Meta: meta, - Display: tools.Display{Summary: tool.name, Kind: "plugin"}, + Status: tools.StatusOK, + Output: formatted, + Meta: meta, + EnforcementNotices: output.Notices, + Display: tools.Display{Summary: tool.name, Kind: "plugin"}, } } @@ -719,7 +730,12 @@ func execPluginCommandWithExecution(ctx context.Context, runner *execution.Runne if result.Outcome.Exit != nil { exitCode = result.Outcome.Exit.Code } - output := commandOutput{Stdout: result.Stdout, Stderr: result.Stderr, ExitCode: exitCode} + output := commandOutput{ + Stdout: result.Stdout, + Stderr: result.Stderr, + ExitCode: exitCode, + Notices: append([]string(nil), result.Outcome.Enforcement.Notices...), + } switch result.Outcome.Kind { case execution.OutcomeSandboxSetupFailure, execution.OutcomeExecutableNotFound, execution.OutcomeTimedOut, execution.OutcomeCancelled: output.Err = result.Err diff --git a/internal/plugins/enforcement_notice_test.go b/internal/plugins/enforcement_notice_test.go new file mode 100644 index 000000000..fbfd3d696 --- /dev/null +++ b/internal/plugins/enforcement_notice_test.go @@ -0,0 +1,72 @@ +package plugins + +import ( + "context" + "strings" + "testing" + + "github.com/Gitlawb/zero/internal/tools" +) + +// THE DISCLOSURE HAS TO SURVIVE THE PROJECTION. +// +// The execution runner puts enforcement notices on the structured outcome, and +// this path used to copy only stdout, stderr and an exit code out of it. A +// plugin tool therefore ran under the non-WRITE_RESTRICTED token and returned a +// result that said nothing about the write jail it had just traded away. +// +// Asserted through pluginTool.invoke, which is what the registry calls, and +// through Result.ModelOutput, which is what the model actually reads. +func TestAPluginToolCarriesTheEnforcementNotice(t *testing.T) { + const notice = "denyRead is configured, so the write jail is not confining writes" + + for _, testCase := range []struct { + name string + exitCode int + }{ + {"successful command", 0}, + {"failed command", 3}, + } { + t.Run(testCase.name, func(t *testing.T) { + tool := pluginTool{ + name: "demo", + run: func(context.Context, pluginCommand) commandOutput { + return commandOutput{Stdout: "hello", ExitCode: testCase.exitCode, Notices: []string{notice}} + }, + } + + result := tool.invoke(context.Background(), map[string]any{}, t.TempDir()) + if len(result.EnforcementNotices) == 0 { + t.Fatal("the plugin result carried no enforcement notice; the command ran under the weakened token and said nothing") + } + model := result.ModelOutput() + if !strings.Contains(model, notice) { + t.Errorf("the model-facing output does not contain the notice:\n%s", model) + } + if strings.Count(model, notice) != 1 { + t.Errorf("the notice appears %d times, want exactly once:\n%s", strings.Count(model, notice), model) + } + if summary := result.HumanDisplay().Summary; !strings.Contains(summary, notice) { + t.Errorf("the human summary does not contain the notice: %q", summary) + } + }) + } +} + +// And a command with no notice is unchanged, or the assertion above would be +// satisfied by text pasted onto everything. +func TestAPluginToolWithoutANoticeIsUnchanged(t *testing.T) { + tool := pluginTool{ + name: "demo", + run: func(context.Context, pluginCommand) commandOutput { + return commandOutput{Stdout: "hello", ExitCode: 0} + }, + } + result := tool.invoke(context.Background(), map[string]any{}, t.TempDir()) + if len(result.EnforcementNotices) != 0 { + t.Errorf("a command with no enforcement notice grew one: %v", result.EnforcementNotices) + } + if result.Status != tools.StatusOK { + t.Errorf("status = %v, want ok", result.Status) + } +} diff --git a/internal/sandbox/runner.go b/internal/sandbox/runner.go index fc099c346..cabd9b1fe 100644 --- a/internal/sandbox/runner.go +++ b/internal/sandbox/runner.go @@ -333,7 +333,7 @@ func withSandboxExecutionMetadata(plan CommandPlan, request SandboxExecutionRequ // traded away. Neither claim was true there: no restricted token is created and // the deny-read rule is not enforced either, so the notice described a trade // nobody had made. - if windowsRestrictedTokenWillRun(request) { + if windowsRestrictedTokenWillRun(plan, request) { plan.Notes = append(plan.Notes, windowsDenyReadWarnings(request.Backend, request.PermissionProfile)...) } return plan @@ -1236,8 +1236,22 @@ func EnforcementFor(plan CommandPlan) execution.Enforcement { // for BackendNone, for a command that does not require a platform sandbox, and // for one already wrapped by an outer sandbox. None of those creates a token, // and none of them enforces deny-read. -func windowsRestrictedTokenWillRun(request SandboxExecutionRequest) bool { - if request.CommandWrapped || !request.RequiresPlatformSandbox { +func windowsRestrictedTokenWillRun(plan CommandPlan, request SandboxExecutionRequest) bool { + // KEYED ON THE PRODUCED PLAN, not on the request that asked for one. + // + // This read request.CommandWrapped as "something already wrapped this, so we + // are re-entrant", and that is the opposite of what the field means. + // BuildExecutionRequest sets it TRUE for exactly the native and unelevated + // requests that buildPlatformCommandPlan then routes to + // windowsRestrictedTokenCommandPlan. So the disclosure was suppressed on every + // plan that actually creates the token, and fired on none of them. The test + // passed only because its hand-built request left the field false, which is + // the shape no real execution has. + // + // plan.Wrapped is the resulting execution state and cannot be read backwards: + // directCommandPlan sets it false, the restricted-token plan sets it true, and + // both arrive here through the same funnel. + if !plan.Wrapped || !request.RequiresPlatformSandbox { return false } if request.EnforcementLevel == EnforcementDisabled || request.EnforcementLevel == EnforcementDegraded { diff --git a/internal/sandbox/windows_deny_read_disclosure_test.go b/internal/sandbox/windows_deny_read_disclosure_test.go new file mode 100644 index 000000000..dab0a19ac --- /dev/null +++ b/internal/sandbox/windows_deny_read_disclosure_test.go @@ -0,0 +1,89 @@ +package sandbox + +import ( + "strings" + "testing" +) + +// THE DISCLOSURE HAS TO REACH THE PLANS THAT ACTUALLY BUILD THE TOKEN. +// +// The predicate keyed on request.CommandWrapped, read as "something already +// wrapped this". That is the opposite of what the field means: +// BuildExecutionRequest sets it TRUE for exactly the native and unelevated +// requests that then route to windowsRestrictedTokenCommandPlan. So the notice +// was suppressed on every plan that creates the restricted token and fired on +// none of them, while the old test passed because its hand-built request left +// the field false, which is a shape no real execution has. +// +// Built through the manager here rather than by hand, so the request carries the +// state the transition actually produces. +// denyRead goes on the POLICY, not on a hand-built profile. BuildExecutionRequest +// resolves the profile from the policy, so a profile passed in here is discarded +// and the request arrives with an empty DenyRead. That is how the first version +// of this test managed to fail against a working fix. +func windowsDisclosurePlan(t *testing.T, mode PolicyMode, denyRead []string, preference SandboxPreference) CommandPlan { + t.Helper() + workspace := t.TempDir() + backend := windowsRestrictedTokenBackend() + backend.CommandWrapping = true + backend.Executable = `C:\Windows\System32\cmd.exe` + manager := NewSandboxManager(SandboxManagerOptions{GOOS: "windows", Backend: backend}) + plan, err := manager.BuildCommandPlan(SandboxManagerRequest{ + WorkspaceRoot: workspace, + Command: CommandSpec{Name: "cmd.exe", Args: []string{"/c", "echo hi"}, Dir: workspace}, + Policy: Policy{Mode: mode, EnforceWorkspace: true, DenyRead: denyRead}, + Preference: preference, + }) + if err != nil { + t.Fatalf("BuildCommandPlan: %v", err) + } + return plan +} + +func planNotes(plan CommandPlan) string { + return strings.ToLower(strings.Join(plan.Notes, " ")) +} + +func TestEveryPlanThatBuildsTheRestrictedTokenCarriesTheDisclosure(t *testing.T) { + withWindowsHost(t) + denyRead := []string{`C:\Users\someone\.config\creds`} + + plan := windowsDisclosurePlan(t, ModeEnforce, denyRead, SandboxPreferenceAuto) + if !plan.Wrapped { + t.Skipf("this environment did not produce a wrapped Windows plan (backend %s, level %s)", plan.TargetBackend, plan.EnforcementLevel) + } + if len(plan.Notes) == 0 { + t.Fatalf("a wrapped Windows plan carried no disclosure; every real deny_read execution gets the non-WRITE_RESTRICTED token and is told nothing (level %s)", plan.EnforcementLevel) + } + if !strings.Contains(planNotes(plan), "write") { + t.Errorf("the note does not mention the write jail: %v", plan.Notes) + } +} + +// And the plans that build no token stay silent, or the assertion above would be +// satisfied by a notice attached to everything. A direct unwrapped plan carries +// the Windows backend and the same profile, so this is the case that made the +// original predicate look necessary. +func TestPlansThatBuildNoTokenStaySilent(t *testing.T) { + withWindowsHost(t) + denyRead := []string{`C:\Users\someone\.config\creds`} + + for _, testCase := range []struct { + name string + mode PolicyMode + preference SandboxPreference + }{ + {"sandbox forbidden, so the plan is direct", ModeEnforce, SandboxPreferenceForbid}, + {"sandbox disabled, so nothing is wrapped", ModeDisabled, SandboxPreferenceAuto}, + } { + t.Run(testCase.name, func(t *testing.T) { + plan := windowsDisclosurePlan(t, testCase.mode, denyRead, testCase.preference) + if plan.Wrapped { + t.Fatalf("this case was supposed to produce an unwrapped plan (%s)", plan.EnforcementLevel) + } + if len(plan.Notes) != 0 { + t.Errorf("an unwrapped plan claimed the write jail was traded away: %v", plan.Notes) + } + }) + } +} diff --git a/internal/sandbox/windows_deny_read_warning_test.go b/internal/sandbox/windows_deny_read_warning_test.go index a6f366754..a7f099fb5 100644 --- a/internal/sandbox/windows_deny_read_warning_test.go +++ b/internal/sandbox/windows_deny_read_warning_test.go @@ -115,9 +115,12 @@ func TestNoDenyReadWarningWhenTheHostIsNotWindows(t *testing.T) { func TestCommandPlanCarriesTheDenyReadDisclosure(t *testing.T) { withWindowsHost(t) - // A request that will actually produce a restricted token. The notice follows - // the token, so a fixture that only names the backend is not enough. - plan := withSandboxExecutionMetadata(CommandPlan{}, wrappedWindowsRequest()) + // A WRAPPED plan, because the notice follows the token and plan.Wrapped is + // what says a token gets built. This passed CommandPlan{} and relied on the + // request's CommandWrapped field, which meant the opposite of what it was read + // as, so the assertion held while production disclosed nothing. See + // windowsRestrictedTokenWillRun. + plan := withSandboxExecutionMetadata(CommandPlan{Wrapped: true}, wrappedWindowsRequest()) if len(plan.Notes) == 0 { t.Fatal("a command plan resolved with denyRead carried no notice, so the operator loses the write jail without being told") @@ -135,9 +138,11 @@ func TestCommandPlanCarriesTheDenyReadDisclosure(t *testing.T) { func TestCommandPlanCarriesNoDisclosureWithoutDenyRead(t *testing.T) { withWindowsHost(t) - plan := withSandboxExecutionMetadata(CommandPlan{}, SandboxExecutionRequest{ - Backend: windowsRestrictedTokenBackend(), - TargetBackend: BackendWindowsRestrictedToken, + plan := withSandboxExecutionMetadata(CommandPlan{Wrapped: true}, SandboxExecutionRequest{ + Backend: windowsRestrictedTokenBackend(), + TargetBackend: BackendWindowsRestrictedToken, + RequiresPlatformSandbox: true, + EnforcementLevel: EnforcementNative, }) if len(plan.Notes) != 0 { t.Errorf("a plan without denyRead carried notices: %v", plan.Notes) @@ -145,9 +150,13 @@ func TestCommandPlanCarriesNoDisclosureWithoutDenyRead(t *testing.T) { } // wrappedWindowsRequest is the shape buildPlatformCommandPlan actually wraps in -// a restricted token: a platform sandbox is required, enforcement is native, the -// target is the Windows restricted-token backend, and nothing outside has -// wrapped the command already. +// a restricted token: a platform sandbox is required, enforcement is native, and +// the target is the Windows restricted-token backend. +// +// It says nothing about CommandWrapped on purpose. That field is TRUE for these +// requests, because it means "this plan will be wrapped" rather than "something +// already wrapped it", and reading it the other way is what made the disclosure +// fire on nothing. func wrappedWindowsRequest() SandboxExecutionRequest { return SandboxExecutionRequest{ Backend: windowsRestrictedTokenBackend(), @@ -167,20 +176,29 @@ func wrappedWindowsRequest() SandboxExecutionRequest { func TestNoDisclosureWhenNoRestrictedTokenIsCreated(t *testing.T) { withWindowsHost(t) + // The direct plan is the case that made the old predicate look necessary: it + // carries the Windows backend and the same DenyRead profile, and builds no + // token at all. + t.Run("the plan is the direct unwrapped one", func(t *testing.T) { + plan := withSandboxExecutionMetadata(CommandPlan{Wrapped: false}, wrappedWindowsRequest()) + if len(plan.Notes) != 0 { + t.Errorf("an unwrapped plan claimed the write jail was traded away: %v", plan.Notes) + } + }) + for _, testCase := range []struct { name string mutate func(*SandboxExecutionRequest) }{ {name: "sandboxing disabled", mutate: func(r *SandboxExecutionRequest) { r.EnforcementLevel = EnforcementDisabled }}, {name: "degraded to no native isolation", mutate: func(r *SandboxExecutionRequest) { r.EnforcementLevel = EnforcementDegraded }}, - {name: "already wrapped by an outer sandbox", mutate: func(r *SandboxExecutionRequest) { r.CommandWrapped = true }}, {name: "command needs no platform sandbox", mutate: func(r *SandboxExecutionRequest) { r.RequiresPlatformSandbox = false }}, {name: "no target backend", mutate: func(r *SandboxExecutionRequest) { r.TargetBackend = BackendNone }}, } { t.Run(testCase.name, func(t *testing.T) { request := wrappedWindowsRequest() testCase.mutate(&request) - plan := withSandboxExecutionMetadata(CommandPlan{}, request) + plan := withSandboxExecutionMetadata(CommandPlan{Wrapped: true}, request) if len(plan.Notes) != 0 { t.Errorf("claimed the write jail was traded away where no restricted token runs: %v", plan.Notes) } From 3fbf794e4f7bd33f42e579c2f7f329d3dfaebd59 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Sun, 23 Aug 2026 00:11:48 +0530 Subject: [PATCH 09/43] fix(plugins,hooks): carry the disclosure through every post-launch outcome Two more places the notice was dropped, both the same shape as the last round: one path assembles the result and another path, taken under different circumstances, rebuilds it from fewer fields. A plugin that timed out or was cancelled took invoke's error branch, which constructed a result from status, output and metadata alone. The child had already launched under the non-WRITE_RESTRICTED token, so the disclosure was still true of it, and the model saw only the timeout. The launched-or-not question is answered once now, in execPluginCommandWithExecution where the outcome kind is known, rather than at each constructor. A setup failure or a missing executable started nothing and carries no notice; everything past launch does, however it ended. Every return in invoke now carries whatever that decision produced, so the disclosure cannot depend on which branch runs. A vetoing beforeTool hook took the blocking branch, which builds DispatchOutcome.Reason through blockReason and returns immediately, never reaching hookMessage. Reason is the field the agent turns into the model-visible result, so a hook that blocked an action while running without write confinement said only that it blocked. blockReason composes the notices now; blockCause keeps the wording it had. No double render: blockedByHookResult reads Reason only, and the advisory path reads Messages only, so the two channels stay separate. The hook regression drives Dispatch rather than calling blockReason with a hand-built commandResult, because a test that assembles the shape it expects proves the consumer and not the producer. Both fail with their fix reverted. --- internal/hooks/dispatch.go | 11 ++++ internal/hooks/enforcement_notice_test.go | 46 +++++++++++++++ internal/plugins/activate.go | 36 ++++++++++-- internal/plugins/enforcement_notice_test.go | 63 +++++++++++++++++++++ 4 files changed, 152 insertions(+), 4 deletions(-) diff --git a/internal/hooks/dispatch.go b/internal/hooks/dispatch.go index a1c076ddd..0b2ffc570 100644 --- a/internal/hooks/dispatch.go +++ b/internal/hooks/dispatch.go @@ -273,7 +273,18 @@ func withHookEnforcementNotices(message string, notices []string) string { return joined + "\n\n" + message } +// blockReason explains a veto, and carries the enforcement disclosure with it. +// +// THE BLOCKING BRANCH IS THE ONE A USER ALWAYS SEES. hookMessage composes the +// notices into DispatchOutcome.Messages, but a vetoing beforeTool hook builds +// Reason separately and returns immediately, so a hook that blocked an action +// while running without write confinement reported only the veto. Both fields +// reach a person, so both have to carry it. func blockReason(result commandResult) string { + return withHookEnforcementNotices(blockCause(result), result.Notices) +} + +func blockCause(result commandResult) string { if result.TimedOut { if trimmed := strings.TrimSpace(result.Stderr); trimmed != "" { return "hook timed out: " + trimmed diff --git a/internal/hooks/enforcement_notice_test.go b/internal/hooks/enforcement_notice_test.go index 62465cc23..2a29a5002 100644 --- a/internal/hooks/enforcement_notice_test.go +++ b/internal/hooks/enforcement_notice_test.go @@ -1,6 +1,7 @@ package hooks import ( + "context" "strings" "testing" ) @@ -41,3 +42,48 @@ func TestAHookWithoutANoticeIsUnchanged(t *testing.T) { t.Errorf("a silent hook with no notice produced %q", message) } } + +// THROUGH Dispatch, NOT A HAND-BUILT commandResult. +// +// The blocking branch builds DispatchOutcome.Reason with blockReason and returns +// immediately, so it never touches hookMessage. A vetoing beforeTool hook that +// ran without write confinement reported only the veto, and Reason is the field +// the agent turns into the model-visible result. +func TestABlockedBeforeToolHookCarriesTheNoticeIntoItsReason(t *testing.T) { + const notice = "denyRead is configured, so the write jail is not confining writes" + + dispatcher := NewDispatcher(DispatcherOptions{ + Config: beforeToolConfig(Definition{ID: "policy", Event: EventBeforeTool, Command: "policy-check", Enabled: true}), + run: func(context.Context, string, []string, []byte, string, []string) commandResult { + return commandResult{ExitCode: 2, Stderr: "policy violation", Notices: []string{notice}} + }, + }) + + outcome := dispatcher.Dispatch(context.Background(), DispatchInput{Event: EventBeforeTool, ToolName: "bash"}) + if !outcome.Blocked { + t.Fatal("SETUP INVALID: the hook did not block, so the blocking branch was never taken") + } + if !strings.Contains(outcome.Reason, notice) { + t.Errorf("the veto reason lost the enforcement notice:\n%s", outcome.Reason) + } + if !strings.Contains(outcome.Reason, "policy violation") { + t.Errorf("the veto reason lost the hook's own explanation:\n%s", outcome.Reason) + } + if strings.Count(outcome.Reason, notice) != 1 { + t.Errorf("the notice appears %d times in the reason, want once:\n%s", strings.Count(outcome.Reason, notice), outcome.Reason) + } +} + +// And a veto with no notice reads exactly as it did before. +func TestABlockedHookWithoutANoticeIsUnchanged(t *testing.T) { + dispatcher := NewDispatcher(DispatcherOptions{ + Config: beforeToolConfig(Definition{ID: "policy", Event: EventBeforeTool, Command: "policy-check", Enabled: true}), + run: func(context.Context, string, []string, []byte, string, []string) commandResult { + return commandResult{ExitCode: 2, Stderr: "policy violation"} + }, + }) + outcome := dispatcher.Dispatch(context.Background(), DispatchInput{Event: EventBeforeTool, ToolName: "bash"}) + if outcome.Reason != "policy violation" { + t.Errorf("Reason = %q, want the hook's own explanation untouched", outcome.Reason) + } +} diff --git a/internal/plugins/activate.go b/internal/plugins/activate.go index 75f5c0229..9dd1b49cc 100644 --- a/internal/plugins/activate.go +++ b/internal/plugins/activate.go @@ -558,10 +558,19 @@ func (tool pluginTool) invoke(ctx context.Context, args map[string]any, cwd stri meta["exit_code"] = strconv.Itoa(output.ExitCode) if output.Err != nil { + // EVERY POST-LAUNCH TERMINAL OUTCOME CARRIES THE DISCLOSURE. This branch + // used to rebuild a result from status, output and metadata alone, so a + // plugin that timed out or was cancelled reported only that and said + // nothing about having run without write confinement. Whether the notice + // survives must not depend on how the process ended. The launched-or-not + // question is answered in execPluginCommandWithExecution; by here + // output.Notices is empty for anything that never started. return tools.Result{ - Status: tools.StatusError, - Output: "Error executing plugin tool " + tool.name + ": " + output.Err.Error(), - Meta: meta, + Status: tools.StatusError, + Output: "Error executing plugin tool " + tool.name + ": " + output.Err.Error(), + Meta: meta, + EnforcementNotices: output.Notices, + Display: tools.Display{Summary: tool.name + " failed", Kind: "plugin"}, } } formatted := formatPluginToolOutput(output) @@ -712,6 +721,17 @@ func execPluginCommand(ctx context.Context, command pluginCommand, timeout time. return output } +// pluginChildLaunched reports whether the outcome describes a process that +// actually started. Only those can be described by an enforcement notice. +func pluginChildLaunched(kind execution.OutcomeKind) bool { + switch kind { + case execution.OutcomeSandboxSetupFailure, execution.OutcomeExecutableNotFound: + return false + default: + return true + } +} + func execPluginCommandWithExecution(ctx context.Context, runner *execution.Runner, command pluginCommand, timeout time.Duration) commandOutput { runCtx, cancel := context.WithTimeout(ctx, timeout) defer cancel() @@ -734,7 +754,15 @@ func execPluginCommandWithExecution(ctx context.Context, runner *execution.Runne Stdout: result.Stdout, Stderr: result.Stderr, ExitCode: exitCode, - Notices: append([]string(nil), result.Outcome.Enforcement.Notices...), + } + // THE NOTICE DESCRIBES A CHILD THAT RAN. Deciding that here, where the outcome + // kind is known, rather than at each result constructor: a timeout or a + // cancellation happened to a process that had already launched under the + // weakened token, so the disclosure is still true of it. A setup failure or a + // missing executable launched nothing, and claiming the write jail was traded + // away there would describe a trade nobody made. + if pluginChildLaunched(result.Outcome.Kind) { + output.Notices = append([]string(nil), result.Outcome.Enforcement.Notices...) } switch result.Outcome.Kind { case execution.OutcomeSandboxSetupFailure, execution.OutcomeExecutableNotFound, execution.OutcomeTimedOut, execution.OutcomeCancelled: diff --git a/internal/plugins/enforcement_notice_test.go b/internal/plugins/enforcement_notice_test.go index fbfd3d696..6151eb83b 100644 --- a/internal/plugins/enforcement_notice_test.go +++ b/internal/plugins/enforcement_notice_test.go @@ -5,6 +5,7 @@ import ( "strings" "testing" + "github.com/Gitlawb/zero/internal/execution" "github.com/Gitlawb/zero/internal/tools" ) @@ -70,3 +71,65 @@ func TestAPluginToolWithoutANoticeIsUnchanged(t *testing.T) { t.Errorf("status = %v, want ok", result.Status) } } + +// A TIMEOUT OR CANCELLATION STILL RAN THE CHILD. +// +// invoke's error branch rebuilt a result from status, output and metadata alone, +// so a plugin that timed out under the non-WRITE_RESTRICTED token reported only +// the timeout. The process had already launched without write confinement; +// whether the disclosure survives must not depend on how it ended. +func TestAPluginToolCarriesTheNoticeWhenItTimesOutOrIsCancelled(t *testing.T) { + const notice = "denyRead is configured, so the write jail is not confining writes" + + for _, testCase := range []struct { + name string + err error + }{ + {"timed out", context.DeadlineExceeded}, + {"cancelled", context.Canceled}, + } { + t.Run(testCase.name, func(t *testing.T) { + tool := pluginTool{ + name: "demo", + run: func(context.Context, pluginCommand) commandOutput { + return commandOutput{ExitCode: -1, Err: testCase.err, Notices: []string{notice}} + }, + } + result := tool.invoke(context.Background(), map[string]any{}, t.TempDir()) + + model := result.ModelOutput() + if !strings.Contains(model, notice) { + t.Errorf("the model-facing output lost the notice:\n%s", model) + } + if strings.Count(model, notice) != 1 { + t.Errorf("the notice appears %d times, want once:\n%s", strings.Count(model, notice), model) + } + if summary := result.HumanDisplay().Summary; !strings.Contains(summary, notice) { + t.Errorf("the human summary lost the notice: %q", summary) + } + }) + } +} + +// But a child that never launched must stay silent, or the notice describes a +// trade nobody made. This is the distinction the launched-or-not check exists +// for, and without it the assertion above would be satisfied by pasting the +// notice onto every error. +func TestAPluginThatNeverLaunchedCarriesNoNotice(t *testing.T) { + for _, kind := range []execution.OutcomeKind{ + execution.OutcomeSandboxSetupFailure, + execution.OutcomeExecutableNotFound, + } { + if pluginChildLaunched(kind) { + t.Errorf("%v is treated as a launched child; a notice there would describe a process that never started", kind) + } + } + for _, kind := range []execution.OutcomeKind{ + execution.OutcomeTimedOut, + execution.OutcomeCancelled, + } { + if !pluginChildLaunched(kind) { + t.Errorf("%v is treated as never launched, so its disclosure would be dropped", kind) + } + } +} From 95b99d5cee5fefd832b4711ce5ebf2461450db81 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Sun, 23 Aug 2026 00:54:21 +0530 Subject: [PATCH 10/43] test(sandbox): pin the notice projection itself, not just its consumers Every other notice assertion in this PR hands a constructor a Notices slice and checks it comes out the other side. That proves the consumers and never the producer: deleting the one line in EnforcementFor that puts plan.Notes into Enforcement.Notices left every notice test in the repo green, and that line is the entire reason hooks, plugins and MCP see anything at all. This starts from a plan the manager built rather than a literal, so the chain from profile through plan.Notes to Enforcement.Notices is covered end to end, with a silent-plan case so it cannot be satisfied by a field that is never empty. It fails with the projection removed. --- .../windows_deny_read_disclosure_test.go | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/internal/sandbox/windows_deny_read_disclosure_test.go b/internal/sandbox/windows_deny_read_disclosure_test.go index dab0a19ac..30ca79c18 100644 --- a/internal/sandbox/windows_deny_read_disclosure_test.go +++ b/internal/sandbox/windows_deny_read_disclosure_test.go @@ -87,3 +87,51 @@ func TestPlansThatBuildNoTokenStaySilent(t *testing.T) { }) } } + +// THE PROJECTION ITSELF, driven from a real plan. +// +// Everything else about notices in this PR is asserted by handing a constructor +// a Notices slice and checking it comes out the other side. That proves the +// consumers and not the producer: deleting the one line in EnforcementFor that +// puts plan.Notes into Enforcement.Notices left every notice test in the repo +// green, and that line is the whole reason hooks, plugins and MCP see anything. +// +// This starts from a plan the manager built, not a literal, so the chain +// profile -> plan.Notes -> Enforcement.Notices is covered end to end. +func TestEnforcementForCarriesThePlanNoticesToTheGenericContract(t *testing.T) { + withWindowsHost(t) + denyRead := []string{`C:\Users\someone\.config\creds`} + + plan := windowsDisclosurePlan(t, ModeEnforce, denyRead, SandboxPreferenceAuto) + if !plan.Wrapped { + t.Skipf("this environment did not produce a wrapped Windows plan (%s)", plan.EnforcementLevel) + } + if len(plan.Notes) == 0 { + t.Fatal("SETUP INVALID: the plan carries no notes, so the projection has nothing to carry") + } + + enforcement := EnforcementFor(plan) + if len(enforcement.Notices) != len(plan.Notes) { + t.Fatalf("EnforcementFor produced %d notices from %d plan notes; hooks, plugins and MCP read this field and would see nothing", + len(enforcement.Notices), len(plan.Notes)) + } + for index, note := range plan.Notes { + if enforcement.Notices[index] != note { + t.Errorf("notice %d = %q, want %q", index, enforcement.Notices[index], note) + } + } +} + +// And a plan with nothing to disclose projects nothing, or the assertion above +// would be satisfied by a field that is never empty. +func TestEnforcementForCarriesNoNoticesFromASilentPlan(t *testing.T) { + withWindowsHost(t) + + plan := windowsDisclosurePlan(t, ModeEnforce, nil, SandboxPreferenceAuto) + if len(plan.Notes) != 0 { + t.Fatalf("SETUP INVALID: a plan with no denyRead carries notes: %v", plan.Notes) + } + if notices := EnforcementFor(plan).Notices; len(notices) != 0 { + t.Errorf("a silent plan projected notices: %v", notices) + } +} From 2bbcac143a18b8975c8ec10449bc8b113b376df4 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Mon, 24 Aug 2026 14:33:30 +0530 Subject: [PATCH 11/43] fix(agent): carry one canonical representation of a tool result across the projection executeToolCall copied the already-rendered ModelOutput/HumanDisplay into agent.ToolResult while also copying the typed EnforcementNotices slice, so the same disclosure lived in two places with no contract between them. It renders once today only because the outcome arrives finalized and the agent accessor then reads Outcome.ModelView rather than the stored field, which also means the stored field disagreed with the outcome it came from. A result reaching the accessor without a finalized outcome would have shown the notice twice. Split the undecorated base out into BaseModelOutput/BaseDisplay and have the projection store that. Decoration now happens in exactly one place, the accessors, and the stored text agrees with the finalized outcome. --- .../enforcement_notice_projection_test.go | 114 ++++++++++++++++++ internal/agent/loop.go | 8 +- internal/tools/types.go | 42 +++++-- 3 files changed, 149 insertions(+), 15 deletions(-) create mode 100644 internal/agent/enforcement_notice_projection_test.go diff --git a/internal/agent/enforcement_notice_projection_test.go b/internal/agent/enforcement_notice_projection_test.go new file mode 100644 index 000000000..43f7646cf --- /dev/null +++ b/internal/agent/enforcement_notice_projection_test.go @@ -0,0 +1,114 @@ +package agent + +import ( + "context" + "strings" + "testing" + + "github.com/Gitlawb/zero/internal/tools" +) + +// noticeProjectionTool stands in for any sandboxed command tool: it reports the +// enforcement disclosure the way the real ones do, through the sandbox metadata +// key that finalizeToolOutcome promotes into the typed notice slice. +type noticeProjectionTool struct{} + +func (noticeProjectionTool) Name() string { return "notice_projection" } +func (noticeProjectionTool) Description() string { return "test tool carrying an enforcement notice" } +func (noticeProjectionTool) Parameters() tools.Schema { return tools.Schema{Type: "object"} } +func (noticeProjectionTool) Safety() tools.Safety { + return tools.Safety{SideEffect: tools.SideEffectRead, Permission: tools.PermissionAllow} +} + +func (noticeProjectionTool) Run(ctx context.Context, args map[string]any) tools.Result { + return tools.Result{ + Status: tools.StatusOK, + Output: "the command output", + Display: tools.Display{Summary: "the human summary"}, + Meta: map[string]string{"sandbox_notices": "least-privilege notice"}, + } +} + +// THE PROJECTION MUST CARRY ONE REPRESENTATION, NOT TWO. +// +// executeToolCall copies the typed notice slice into agent.ToolResult, so the +// text fields it copies alongside must be the UNDECORATED base. Storing the +// already-rendered text there instead leaves the same fact in two places with no +// contract between them: it happens to render once today only because the +// outcome arrives finalized and the agent accessor then reads Outcome.ModelView +// rather than the stored field. Any result that reaches the accessor without a +// finalized outcome renders the disclosure twice, and every raw reader of +// .Output sees text that disagrees with Outcome.ModelView. +func TestEnforcementNoticeIsStoredOnceAndRenderedOnce(t *testing.T) { + const notice = "least-privilege notice" + + registry := tools.NewRegistry() + registry.Register(noticeProjectionTool{}) + + result, err := executeToolCall(context.Background(), registry, ToolCall{ + ID: "call-1", Name: "notice_projection", Arguments: `{}`, + }, PermissionModeAuto, Options{Cwd: t.TempDir()}) + if err != nil { + t.Fatalf("executeToolCall: %v", err) + } + if len(result.EnforcementNotices) == 0 { + t.Fatalf("the notice never reached the agent result: %#v", result) + } + + // The stored fields are the canonical undecorated base, and they agree with + // the finalized outcome they were projected from. + if strings.Contains(result.Output, notice) { + t.Errorf("ToolResult.Output stores the rendered notice as well as the slice: %q", result.Output) + } + if strings.Contains(result.Display.Summary, notice) { + t.Errorf("ToolResult.Display.Summary stores the rendered notice as well as the slice: %q", result.Display.Summary) + } + if result.Output != result.Outcome.ModelView { + t.Errorf("stored output %q disagrees with the finalized model view %q", result.Output, result.Outcome.ModelView) + } + if result.Display.Summary != result.Outcome.HumanView.Summary { + t.Errorf("stored summary %q disagrees with the finalized human view %q", result.Display.Summary, result.Outcome.HumanView.Summary) + } + + // And every consumer that renders goes through the accessors, which show the + // disclosure exactly once without hiding the output it is attached to. + // loop.go builds the provider transcript from ModelOutput; the CLI writer and + // the TUI cards use both accessors. + transcript := result.ModelOutput() + if got := strings.Count(transcript, notice); got != 1 { + t.Errorf("the transcript shows the notice %d times, want 1: %q", got, transcript) + } + if !strings.Contains(transcript, "the command output") { + t.Errorf("the transcript lost the command output: %q", transcript) + } + summary := result.HumanDisplay().Summary + if got := strings.Count(summary, notice); got != 1 { + t.Errorf("the human summary shows the notice %d times, want 1: %q", got, summary) + } + if !strings.Contains(summary, "the human summary") { + t.Errorf("the human summary lost the tool summary: %q", summary) + } +} + +// A result that never crossed the registry has no finalized outcome, so the +// accessor falls back to the stored field. That is the path on which a stored +// rendering would double, and it is the reason the contract above is stated on +// the stored fields rather than only on the accessors. +func TestUnfinalizedResultStillRendersTheNoticeOnce(t *testing.T) { + const notice = "least-privilege notice" + result := ToolResult{ + Status: tools.StatusOK, + Output: "the command output", + Display: tools.Display{Summary: "the human summary"}, + EnforcementNotices: []string{notice}, + } + if result.Outcome.Finalized() { + t.Fatal("fixture is finalized; it no longer covers the fallback path") + } + if got := strings.Count(result.ModelOutput(), notice); got != 1 { + t.Errorf("ModelOutput shows the notice %d times, want 1: %q", got, result.ModelOutput()) + } + if got := strings.Count(result.HumanDisplay().Summary, notice); got != 1 { + t.Errorf("HumanDisplay shows the notice %d times, want 1: %q", got, result.HumanDisplay().Summary) + } +} diff --git a/internal/agent/loop.go b/internal/agent/loop.go index f14591428..78942fd3a 100644 --- a/internal/agent/loop.go +++ b/internal/agent/loop.go @@ -1521,7 +1521,7 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal ToolCallID: call.ID, Name: call.Name, Status: result.Status, - Output: result.ModelOutput(), + Output: result.BaseModelOutput(), Truncated: result.Truncated, Meta: result.Meta, EnforcementNotices: append([]string(nil), result.EnforcementNotices...), @@ -1529,7 +1529,7 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal Redacted: result.Redacted, ChangedFiles: result.ChangedFiles, ChangeSummaries: result.ChangeSummaries, - Display: result.HumanDisplay(), + Display: result.BaseDisplay(), Outcome: result.Outcome, LoadedTools: loadedToolsFromResult(result.Meta), // A tool may signal a mid-run model escalation by carrying the target id @@ -2149,14 +2149,14 @@ func askUserFallbackResult(ctx context.Context, registry *tools.Registry, call T ToolCallID: call.ID, Name: call.Name, Status: result.Status, - Output: result.ModelOutput(), + Output: result.BaseModelOutput(), Truncated: result.Truncated, Meta: result.Meta, EnforcementNotices: append([]string(nil), result.EnforcementNotices...), Redacted: result.Redacted, ChangedFiles: result.ChangedFiles, ChangeSummaries: result.ChangeSummaries, - Display: result.HumanDisplay(), + Display: result.BaseDisplay(), Outcome: result.Outcome, } } diff --git a/internal/tools/types.go b/internal/tools/types.go index 445042bd1..4a9168581 100644 --- a/internal/tools/types.go +++ b/internal/tools/types.go @@ -191,23 +191,43 @@ type OutcomeDiagnostics struct { Reason string } -// ModelOutput returns the finalized provider-facing text, falling back to the -// legacy field for direct Tool.Run callers that have not crossed the registry. -func (result Result) ModelOutput() string { - base := result.Output +// BaseModelOutput returns the UNDECORATED provider-facing text: the finalized +// model view, falling back to the legacy field for direct Tool.Run callers that +// have not crossed the registry. It carries no enforcement notices. +// +// Callers that PROJECT a result into another carrier (the agent loop building an +// agent.ToolResult) must copy this, not ModelOutput, and copy the typed notice +// slice alongside it. Storing already-rendered text next to the same notices is +// two representations of one fact with no contract between them, and whichever +// side of the projection loses its finalized outcome renders the disclosure +// twice. +func (result Result) BaseModelOutput() string { if result.Outcome.finalized { - base = result.Outcome.ModelView + return result.Outcome.ModelView } - return WithEnforcementNotices(base, result.EnforcementNotices) + return result.Output } -// HumanDisplay returns the finalized presentation, falling back to the legacy -// display for direct Tool.Run callers. -func (result Result) HumanDisplay() Display { - display := result.Display +// BaseDisplay is BaseModelOutput for the presentation half, and carries no +// enforcement notices for the same reason. +func (result Result) BaseDisplay() Display { if result.Outcome.finalized { - display = result.Outcome.HumanView + return result.Outcome.HumanView } + return result.Display +} + +// ModelOutput returns the finalized provider-facing text with the enforcement +// disclosure rendered in front of it. This is the only place the model view is +// decorated. +func (result Result) ModelOutput() string { + return WithEnforcementNotices(result.BaseModelOutput(), result.EnforcementNotices) +} + +// HumanDisplay returns the finalized presentation with the enforcement +// disclosure rendered in front of the summary. +func (result Result) HumanDisplay() Display { + display := result.BaseDisplay() display.Summary = WithEnforcementNotices(display.Summary, result.EnforcementNotices) return display } From dd8679b935d5081d113af7b62d2831bb46bba093 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Wed, 26 Aug 2026 11:34:10 +0530 Subject: [PATCH 12/43] fix(acp,cli): carry the enforcement notice to the consumers the projection left behind Making agent.ToolResult store the undecorated model text plus the typed notices was right, but I only audited the consumers that render to a terminal. Three others read the raw field and lost the disclosure the moment that change landed. ACP sends the tool result straight to its client, so an ACP client saw the output with the warning removed, on the one surface that has no other way to learn the sandbox narrowed what the command could do. Both headless session writers persisted the raw field, and replay reads that value directly into the transcript without rebuilding a ToolResult, so a warning visible during the original run vanished from resumed and compacted context with nothing failing to say so. Those two writers also spelled the same payload separately and had already drifted, since the stream writer used the accessor; they now share one helper. The rule is that presentation and durable consumers both go through ModelOutput, because the accessor is the only thing that composes the text with the notices. --- internal/acp/enforcement_notice_test.go | 65 +++++++++++++++++++ internal/acp/translate.go | 6 +- internal/cli/exec.go | 55 +++++++++++------ internal/cli/exec_spec.go | 17 +---- internal/cli/persisted_tool_result_test.go | 72 ++++++++++++++++++++++ 5 files changed, 179 insertions(+), 36 deletions(-) create mode 100644 internal/acp/enforcement_notice_test.go create mode 100644 internal/cli/persisted_tool_result_test.go diff --git a/internal/acp/enforcement_notice_test.go b/internal/acp/enforcement_notice_test.go new file mode 100644 index 000000000..6a26fb7c0 --- /dev/null +++ b/internal/acp/enforcement_notice_test.go @@ -0,0 +1,65 @@ +package acp + +import ( + "strings" + "testing" + + "github.com/Gitlawb/zero/internal/agent" + "github.com/Gitlawb/zero/internal/tools" +) + +// AN ACP CLIENT MUST SEE THE DISCLOSURE THE TUI SEES. +// +// agent.ToolResult stores the UNDECORATED model text alongside the typed +// enforcement notices; ModelOutput is what composes them. Reading .Output +// directly compiles and looks right, and silently drops the notice for every +// ACP client, which is the one surface with no other way to learn the sandbox +// narrowed what the command could do. +func TestToolResultContentCarriesTheEnforcementNotice(t *testing.T) { + const notice = "least-privilege notice: read access was narrowed" + result := agent.ToolResult{ + Name: "bash", + Status: tools.StatusOK, + Output: "the command output", + EnforcementNotices: []string{notice}, + } + + content := toolResultContent(result) + if len(content) == 0 { + t.Fatal("no content produced for a successful tool result") + } + var text strings.Builder + for _, part := range content { + if part.Content != nil { + text.WriteString(part.Content.Text) + } + } + got := text.String() + + if count := strings.Count(got, notice); count != 1 { + t.Errorf("the notice appears %d times, want exactly 1:\n%s", count, got) + } + if !strings.Contains(got, "the command output") { + t.Errorf("the underlying output was lost:\n%s", got) + } +} + +// And a result with no notice is unchanged, so the accessor is not adding +// anything to ordinary output. +func TestToolResultContentLeavesAnOrdinaryResultAlone(t *testing.T) { + result := agent.ToolResult{ + Name: "bash", + Status: tools.StatusOK, + Output: "plain output", + } + content := toolResultContent(result) + if len(content) == 0 { + t.Fatal("no content produced") + } + if content[0].Content == nil { + t.Fatal("content block missing") + } + if got := content[0].Content.Text; got != "plain output" { + t.Errorf("ordinary output = %q, want it untouched", got) + } +} diff --git a/internal/acp/translate.go b/internal/acp/translate.go index 565174904..27097ff5d 100644 --- a/internal/acp/translate.go +++ b/internal/acp/translate.go @@ -120,7 +120,11 @@ func toolCallResult(result agent.ToolResult) ToolCallUpdate { } func toolResultContent(result agent.ToolResult) []ToolCallContent { - text := strings.TrimRight(result.Output, "\n") + // ModelOutput, not the raw field. agent.ToolResult stores the undecorated + // model text alongside the typed enforcement notices, and the accessor is + // what composes the two; reading Output directly sends an ACP client the + // output with the disclosure missing. + text := strings.TrimRight(result.ModelOutput(), "\n") if text == "" { text = result.Display.Summary } diff --git a/internal/cli/exec.go b/internal/cli/exec.go index 63d22f8bf..bde94b398 100644 --- a/internal/cli/exec.go +++ b/internal/cli/exec.go @@ -728,25 +728,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in }, OnToolResult: func(result agent.ToolResult) { writer.toolResult(result) - payload := map[string]any{ - "toolCallId": result.ToolCallID, - "name": result.Name, - "status": string(result.Status), - "output": result.Output, - } - if len(result.Meta) > 0 { - payload["meta"] = result.Meta - } - if result.Truncated { - payload["truncated"] = true - } - if result.Redacted { - payload["redacted"] = true - } - if len(result.ChangedFiles) > 0 { - payload["changedFiles"] = result.ChangedFiles - } - sessionRecorder.append(sessions.EventToolResult, payload) + sessionRecorder.append(sessions.EventToolResult, persistedToolResultPayload(result)) }, OnUsage: func(u agent.Usage) { writer.usage(u) @@ -1496,3 +1478,38 @@ func writeTraceSnapshot(snapshot *trace.TurnTrace, dest string, stderr io.Writer defer file.Close() return trace.WriteNDJSON(file, snapshot) } + +// persistedToolResultPayload renders one tool result for the durable session +// log. +// +// IT USES THE ACCESSOR, NOT THE RAW FIELD. agent.ToolResult stores the +// undecorated model text alongside the typed enforcement notices, and +// ModelOutput is what composes the two. Replay reads this payload straight back +// into the transcript without reconstructing a ToolResult, so a disclosure that +// is not rendered here is simply absent from resumed and compacted context even +// though it was visible during the original run. +// +// Both headless writers go through this, because they previously spelled the +// same payload separately and had already drifted: one persisted the raw field +// while the stream writer used the accessor. +func persistedToolResultPayload(result agent.ToolResult) map[string]any { + payload := map[string]any{ + "toolCallId": result.ToolCallID, + "name": result.Name, + "status": string(result.Status), + "output": result.ModelOutput(), + } + if len(result.Meta) > 0 { + payload["meta"] = result.Meta + } + if result.Truncated { + payload["truncated"] = true + } + if result.Redacted { + payload["redacted"] = true + } + if len(result.ChangedFiles) > 0 { + payload["changedFiles"] = result.ChangedFiles + } + return payload +} diff --git a/internal/cli/exec_spec.go b/internal/cli/exec_spec.go index fc22eed35..81e6fa5ab 100644 --- a/internal/cli/exec_spec.go +++ b/internal/cli/exec_spec.go @@ -158,22 +158,7 @@ func runExecSpecDraft(run execSpecDraftRun) int { if info, ok := execSpecDraftInfoFromToolResult(result); ok { draftInfo = info } - payload := map[string]any{ - "toolCallId": result.ToolCallID, - "name": result.Name, - "status": string(result.Status), - "output": result.Output, - } - if len(result.Meta) > 0 { - payload["meta"] = result.Meta - } - if result.Redacted { - payload["redacted"] = true - } - if len(result.ChangedFiles) > 0 { - payload["changedFiles"] = result.ChangedFiles - } - sessionRecorder.append(sessions.EventToolResult, payload) + sessionRecorder.append(sessions.EventToolResult, persistedToolResultPayload(result)) }, OnUsage: func(u agent.Usage) { writer.usage(u) diff --git a/internal/cli/persisted_tool_result_test.go b/internal/cli/persisted_tool_result_test.go new file mode 100644 index 000000000..d5ab06236 --- /dev/null +++ b/internal/cli/persisted_tool_result_test.go @@ -0,0 +1,72 @@ +package cli + +import ( + "strings" + "testing" + + "github.com/Gitlawb/zero/internal/agent" + "github.com/Gitlawb/zero/internal/tools" +) + +// A DISCLOSURE THAT IS NOT PERSISTED DID NOT SURVIVE THE RUN. +// +// The session log is what a resumed or compacted conversation is rebuilt from, +// and replay reads this payload's "output" straight into the transcript without +// reconstructing an agent.ToolResult. So persisting the raw undecorated field +// makes a warning that was visible during the original run disappear the moment +// the session is resumed, with nothing failing anywhere to say so. +func TestPersistedToolResultKeepsTheEnforcementNotice(t *testing.T) { + const notice = "least-privilege notice: read access was narrowed" + payload := persistedToolResultPayload(agent.ToolResult{ + ToolCallID: "call-1", + Name: "bash", + Status: tools.StatusOK, + Output: "the command output", + EnforcementNotices: []string{notice}, + }) + + output, _ := payload["output"].(string) + if count := strings.Count(output, notice); count != 1 { + t.Errorf("persisted output carries the notice %d times, want exactly 1: %q", count, output) + } + if !strings.Contains(output, "the command output") { + t.Errorf("persisted output lost the command output: %q", output) + } +} + +// The other fields still round-trip, so the shared helper did not quietly drop +// what the two writers used to record separately. +func TestPersistedToolResultKeepsItsOtherFields(t *testing.T) { + payload := persistedToolResultPayload(agent.ToolResult{ + ToolCallID: "call-2", + Name: "write_file", + Status: tools.StatusError, + Output: "boom", + Meta: map[string]string{"k": "v"}, + Truncated: true, + Redacted: true, + ChangedFiles: []string{"a.go"}, + }) + for _, field := range []string{"toolCallId", "name", "status", "output", "meta", "truncated", "redacted", "changedFiles"} { + if _, ok := payload[field]; !ok { + t.Errorf("payload is missing %q: %#v", field, payload) + } + } + if payload["status"] != string(tools.StatusError) { + t.Errorf("status = %v, want %q", payload["status"], tools.StatusError) + } +} + +// An ordinary result records exactly what it did before, so the accessor is not +// adding anything where there is nothing to add. +func TestPersistedToolResultLeavesAnOrdinaryResultAlone(t *testing.T) { + payload := persistedToolResultPayload(agent.ToolResult{ + ToolCallID: "call-3", + Name: "bash", + Status: tools.StatusOK, + Output: "plain output", + }) + if got := payload["output"]; got != "plain output" { + t.Errorf("persisted output = %v, want it untouched", got) + } +} From b3394e7d93b94cd9b0c9a2768656b39f9d50b866 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Thu, 27 Aug 2026 15:38:14 +0530 Subject: [PATCH 13/43] fix(execution): one launch-state decision, and disclose it on the MCP boundary Enforcement.Notices is PLANNED: it describes the shape a command was prepared to run under, and planning is not proof that anything ran. Hooks copied the field straight out, so a sandbox setup failure or a missing executable told the operator the write jail had been traded away for a child that never existed. Outcome.AppliedEnforcementNotices now makes that call once, where the outcome kind is known, and hooks and plugins both use it; the plugin-local copy of the rule is gone. A new pre-launch outcome kind is classified in one place instead of being disclosed by whichever consumer was not updated. MCP tools/call serialized Result.Output directly. That was a complete value before this branch and is not one now: Output holds the undecorated base text and ModelOutput is the model-facing projection. An affected Windows command reached an MCP client with its ordinary output and no statement about the token shape it ran under. --- internal/execution/contracts.go | 40 +++++ internal/hooks/dispatch.go | 5 +- .../enforcement_launch_sleep_unix_test.go | 6 + .../enforcement_launch_sleep_windows_test.go | 6 + .../hooks/enforcement_launch_state_test.go | 163 ++++++++++++++++++ .../mcp/enforcement_notice_server_test.go | 105 +++++++++++ internal/mcp/server.go | 8 +- internal/plugins/activate.go | 15 +- internal/plugins/enforcement_notice_test.go | 4 +- 9 files changed, 334 insertions(+), 18 deletions(-) create mode 100644 internal/hooks/enforcement_launch_sleep_unix_test.go create mode 100644 internal/hooks/enforcement_launch_sleep_windows_test.go create mode 100644 internal/hooks/enforcement_launch_state_test.go create mode 100644 internal/mcp/enforcement_notice_server_test.go diff --git a/internal/execution/contracts.go b/internal/execution/contracts.go index 002ef0c01..ef04adc25 100644 --- a/internal/execution/contracts.go +++ b/internal/execution/contracts.go @@ -197,6 +197,46 @@ type AdapterReport struct { Denial *Denial `json:"denial,omitempty"` } +// ChildLaunched reports whether this outcome describes a process that actually +// started. +// +// A setup failure and a missing executable are decided BEFORE the child exists. +// Everything else, including a nonzero exit, a timeout and a cancellation, +// happened to a process that was already running under whatever enforcement was +// applied to it. +func (outcome Outcome) ChildLaunched() bool { + switch outcome.Kind { + case OutcomeSandboxSetupFailure, OutcomeExecutableNotFound: + return false + default: + return true + } +} + +// AppliedEnforcementNotices returns the least-privilege disclosures that are +// true of what actually happened. +// +// ONE DECISION, AT THE BOUNDARY WHERE THE OUTCOME IS KNOWN. Enforcement.Notices +// is planned: it describes the shape the command was PREPARED to run under, and +// planning is not proof that anything ran. Every consumer that copied the field +// straight out therefore made the completed-enforcement claim for commands that +// never launched, telling an operator the write jail had been traded away for a +// child that failed before it existed. +// +// Keeping this on Outcome rather than repeating an outcome-kind switch in hooks, +// plugins and tools is the point: a new pre-launch outcome kind has to be +// classified once, here, instead of being silently disclosed by whichever +// consumer was not updated. +func (outcome Outcome) AppliedEnforcementNotices() []string { + if !outcome.ChildLaunched() { + return nil + } + if len(outcome.Enforcement.Notices) == 0 { + return nil + } + return append([]string(nil), outcome.Enforcement.Notices...) +} + func (outcome Outcome) Validate() error { if outcome.State == "" || outcome.Kind == "" { return errors.New("execution outcome requires state and kind") diff --git a/internal/hooks/dispatch.go b/internal/hooks/dispatch.go index 0b2ffc570..f9eb35014 100644 --- a/internal/hooks/dispatch.go +++ b/internal/hooks/dispatch.go @@ -146,7 +146,10 @@ func executionCommandRunner(runner *execution.Runner) commandRunner { Stderr: stderr, Err: commandErr, TimedOut: result.Outcome.Kind == execution.OutcomeTimedOut, - Notices: append([]string(nil), result.Outcome.Enforcement.Notices...), + // One shared decision: see Outcome.AppliedEnforcementNotices. A setup + // failure or a missing executable launched no hook child, so the notice + // would describe a token trade nobody made. + Notices: result.Outcome.AppliedEnforcementNotices(), } } } diff --git a/internal/hooks/enforcement_launch_sleep_unix_test.go b/internal/hooks/enforcement_launch_sleep_unix_test.go new file mode 100644 index 000000000..d10862a45 --- /dev/null +++ b/internal/hooks/enforcement_launch_sleep_unix_test.go @@ -0,0 +1,6 @@ +//go:build !windows + +package hooks + +// sleepScript keeps a launched child alive long enough for a timeout to fire. +const sleepScript = "sleep 2" diff --git a/internal/hooks/enforcement_launch_sleep_windows_test.go b/internal/hooks/enforcement_launch_sleep_windows_test.go new file mode 100644 index 000000000..2cdda5542 --- /dev/null +++ b/internal/hooks/enforcement_launch_sleep_windows_test.go @@ -0,0 +1,6 @@ +package hooks + +// sleepScript keeps a launched child alive long enough for a timeout to fire. +// Paired with the !windows file of the same name; both must exist or the +// platform without one silently loses the timeout case. +const sleepScript = "ping -n 3 127.0.0.1 > NUL" diff --git a/internal/hooks/enforcement_launch_state_test.go b/internal/hooks/enforcement_launch_state_test.go new file mode 100644 index 000000000..fa003a5b9 --- /dev/null +++ b/internal/hooks/enforcement_launch_state_test.go @@ -0,0 +1,163 @@ +package hooks + +import ( + "context" + "errors" + "os/exec" + "runtime" + "strings" + "testing" + "time" + + "github.com/Gitlawb/zero/internal/execution" +) + +const launchStateNotice = "denyRead is configured, so the write jail is not confining writes" + +// shellCommand builds a portable child that the platform can actually launch. +func shellCommand(script string) *exec.Cmd { + if runtime.GOOS == "windows" { + return exec.Command("cmd.exe", "/c", script) + } + return exec.Command("/bin/sh", "-c", script) +} + +// noticePreparer plans a command carrying an enforcement notice, and can fail +// the way the sandbox does before the child exists. +type noticePreparer struct { + prepareErr error + build func() *exec.Cmd +} + +func (preparer *noticePreparer) PrepareExecution(_ context.Context, request execution.Request) (execution.PreparedCommand, error) { + if preparer.prepareErr != nil { + return execution.PreparedCommand{}, preparer.prepareErr + } + command := preparer.build + if command == nil { + command = func() *exec.Cmd { return exec.Command(request.Command.Name, request.Command.Args...) } + } + return execution.PreparedCommand{ + Command: command(), + Enforcement: execution.Enforcement{Notices: []string{launchStateNotice}}, + }, nil +} + +// PLANNING A WRAPPED COMMAND IS NOT PROOF THAT ANYTHING RAN. +// +// Enforcement.Notices describes the shape the command was PREPARED to run +// under. Copying it straight out made the completed-enforcement claim for +// commands that never existed: a sandbox setup failure and a missing executable +// are both decided before the child launches, so the hook message told the +// operator the write jail had been traded away for a process that never +// started. +// +// Everything after launch keeps the disclosure, including a nonzero exit, a +// timeout and a cancellation: those happened to a child that really did run +// under that token. +// +// Driven through the execution runner rather than a hand-built commandResult, +// because the projection is the thing under test. +func TestTheHookRunnerOnlyDisclosesEnforcementForAChildThatLaunched(t *testing.T) { + for _, testCase := range []struct { + name string + preparer *noticePreparer + timeout time.Duration + wantNotice bool + wantTimedOut bool + }{ + { + name: "sandbox setup failed before the child existed", + preparer: ¬icePreparer{prepareErr: errors.New("could not build the restricted token")}, + wantNotice: false, + }, + { + name: "the executable was never found", + preparer: ¬icePreparer{build: func() *exec.Cmd { + return exec.Command("definitely-not-a-real-binary-zzz") + }}, + wantNotice: false, + }, + { + name: "the child launched and succeeded", + preparer: ¬icePreparer{build: func() *exec.Cmd { return shellCommand("exit 0") }}, + wantNotice: true, + }, + { + name: "the child launched and exited nonzero", + preparer: ¬icePreparer{build: func() *exec.Cmd { return shellCommand("exit 3") }}, + wantNotice: true, + }, + { + name: "the child launched and timed out", + preparer: ¬icePreparer{build: func() *exec.Cmd { return shellCommand(sleepScript) }}, + timeout: 150 * time.Millisecond, + wantNotice: true, + wantTimedOut: true, + }, + } { + t.Run(testCase.name, func(t *testing.T) { + ctx := context.Background() + if testCase.timeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, testCase.timeout) + defer cancel() + } + run := executionCommandRunner(execution.NewRunner(testCase.preparer)) + result := run(ctx, "hook-command", nil, nil, t.TempDir(), nil) + + if result.TimedOut != testCase.wantTimedOut { + t.Fatalf("TimedOut = %v, want %v: the case did not reach the outcome kind it is named for", result.TimedOut, testCase.wantTimedOut) + } + if got := len(result.Notices) > 0; got != testCase.wantNotice { + t.Fatalf("notices present = %v, want %v: %#v", got, testCase.wantNotice, result.Notices) + } + message := hookMessage(result) + if testCase.wantNotice && !strings.Contains(message, launchStateNotice) { + t.Errorf("a launched child lost its disclosure:\n%s", message) + } + if !testCase.wantNotice && strings.Contains(message, launchStateNotice) { + t.Errorf("a child that never launched claimed the token was traded away:\n%s", message) + } + }) + } +} + +// And the same rule has to hold on the veto path, which builds its reason +// separately and is what the model actually sees. +func TestAVetoingHookThatNeverLaunchedClaimsNoEnforcement(t *testing.T) { + dispatcher := NewDispatcher(DispatcherOptions{ + Config: beforeToolConfig(Definition{ID: "policy", Event: EventBeforeTool, Command: "policy-check", Enabled: true}), + Cwd: t.TempDir(), + // A missing executable rather than a prepare error: a prepare error never + // builds the PreparedCommand, so its outcome carries no planned notice and + // the assertion below would hold with the launch gate deleted. This shape + // plans the notice and then fails to launch. + Execution: execution.NewRunner(¬icePreparer{build: func() *exec.Cmd { + return exec.Command("definitely-not-a-real-binary-zzz") + }}), + }) + outcome := dispatcher.Dispatch(context.Background(), DispatchInput{Event: EventBeforeTool, ToolName: "bash"}) + if !outcome.Blocked { + t.Fatal("SETUP INVALID: a beforeTool hook that could not run must fail closed, or the veto path is not exercised") + } + if strings.Contains(outcome.Reason, launchStateNotice) { + t.Errorf("the veto reason claims an enforcement trade for a hook that never started:\n%s", outcome.Reason) + } +} + +// A launched hook still carries it all the way into the dispatch outcome. +func TestALaunchedHookCarriesTheNoticeIntoTheDispatchOutcome(t *testing.T) { + dispatcher := NewDispatcher(DispatcherOptions{ + Config: beforeToolConfig(Definition{ID: "policy", Event: EventBeforeTool, Command: "policy-check", Enabled: true}), + Cwd: t.TempDir(), + Execution: execution.NewRunner(¬icePreparer{build: func() *exec.Cmd { return shellCommand("exit 2") }}), + }) + outcome := dispatcher.Dispatch(context.Background(), DispatchInput{Event: EventBeforeTool, ToolName: "bash"}) + if !outcome.Blocked { + t.Fatal("SETUP INVALID: the hook did not veto, so the reason path is not exercised") + } + if !strings.Contains(outcome.Reason, launchStateNotice) { + t.Errorf("a hook that really ran under the weakened token disclosed nothing:\n%s", outcome.Reason) + } +} diff --git a/internal/mcp/enforcement_notice_server_test.go b/internal/mcp/enforcement_notice_server_test.go new file mode 100644 index 000000000..2f8b9cfa0 --- /dev/null +++ b/internal/mcp/enforcement_notice_server_test.go @@ -0,0 +1,105 @@ +package mcp + +import ( + "bytes" + "context" + "strings" + "testing" + + "github.com/Gitlawb/zero/internal/tools" +) + +// A MODEL-FACING PROTOCOL BOUNDARY IS A PRESENTATION CONSUMER. +// +// This branch changed the result contract: Result.Output holds the UNDECORATED +// base text and ModelOutput is the sole model-facing projection that composes it +// with the typed enforcement notices. tools/call serialized Output directly, +// which was a complete value before and is not one now, so an affected Windows +// command reached an MCP client with its ordinary output and no statement that +// its DenyRead token shape left writes unconfined. +// +// Driven through Serve rather than the accessor, because the question is what +// goes on the wire. +func TestMCPToolsCallCarriesTheEnforcementNotice(t *testing.T) { + const notice = "denyRead is configured, so the write jail is not confining writes" + const output = "ran the command" + + for _, testCase := range []struct { + name string + result tools.Result + wantText string + wantIsErr bool + wantNotice bool + }{ + { + name: "successful command with a notice", + result: tools.Result{Status: tools.StatusOK, Output: output, EnforcementNotices: []string{notice}}, + wantIsErr: false, + wantNotice: true, + }, + { + name: "failed command with a notice", + result: tools.Result{Status: tools.StatusError, Output: output, EnforcementNotices: []string{notice}}, + wantIsErr: true, + wantNotice: true, + }, + { + name: "ordinary command with no notice", + result: tools.Result{Status: tools.StatusOK, Output: output}, + wantIsErr: false, + wantNotice: false, + }, + } { + t.Run(testCase.name, func(t *testing.T) { + registry := tools.NewRegistry() + registry.Register(serverFakeTool{ + name: "run_thing", + description: "runs a thing", + parameters: tools.Schema{Type: "object", AdditionalProperties: false}, + safety: tools.Safety{SideEffect: tools.SideEffectRead, Permission: tools.PermissionAllow, Reason: "test"}, + run: func(map[string]any) tools.Result { return testCase.result }, + }) + + var input bytes.Buffer + writeServerTestMessage(t, &input, rpcMessage{ID: 1, Method: "initialize"}) + writeServerTestMessage(t, &input, rpcMessage{Method: "notifications/initialized"}) + writeServerTestMessage(t, &input, rpcMessage{ + ID: 2, + Method: "tools/call", + Params: mustRaw(map[string]any{"name": "run_thing", "arguments": map[string]any{}}), + }) + + var out bytes.Buffer + if err := Serve(context.Background(), &input, &out, registry, ServeOptions{Name: "zero-test", Version: "1.2.3"}); err != nil { + t.Fatalf("Serve() error = %v", err) + } + reader := newMessageReader(&out) + readServerTestMessage(t, reader) // initialize + var call CallToolResult + decodeServerTestResult(t, readServerTestMessage(t, reader), &call) + + if len(call.Content) != 1 || call.Content[0].Type != "text" { + t.Fatalf("content shape changed: %#v", call.Content) + } + text := call.Content[0].Text + if call.IsError != testCase.wantIsErr { + t.Errorf("IsError = %v, want %v", call.IsError, testCase.wantIsErr) + } + if count := strings.Count(text, output); count != 1 { + t.Errorf("the command's own output appears %d times, want exactly 1: %q", count, text) + } + gotNotice := strings.Count(text, notice) + if testCase.wantNotice && gotNotice != 1 { + t.Errorf("the disclosure appears %d times, want exactly 1: %q", gotNotice, text) + } + if !testCase.wantNotice { + if gotNotice != 0 { + t.Errorf("a disclosure appeared for a command that had none: %q", text) + } + if text != output { + t.Errorf("ordinary output was altered: got %q, want %q", text, output) + } + } + }) + } +} diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 42e46cc19..f22586958 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -218,8 +218,14 @@ func (server toolServer) callTool(ctx context.Context, rawParams json.RawMessage result := server.registry.RunWithOptions(ctx, params.Name, params.Arguments, tools.RunOptions{ PermissionGranted: server.options.PermissionGranted, }) + // ModelOutput, not the raw field. This is a model-facing protocol boundary, + // and Result.Output now holds the UNDECORATED base text: the enforcement + // disclosure lives in typed state and the accessor is what composes the two. + // Serializing Output directly hands an MCP client a Windows command's ordinary + // output with no statement that its DenyRead token shape left writes + // unconfined, which is the one thing the disclosure exists to say. return CallToolResult{ - Content: []Content{{Type: "text", Text: result.Output}}, + Content: []Content{{Type: "text", Text: result.ModelOutput()}}, IsError: result.Status != tools.StatusOK, }, nil } diff --git a/internal/plugins/activate.go b/internal/plugins/activate.go index 9dd1b49cc..f74124bf9 100644 --- a/internal/plugins/activate.go +++ b/internal/plugins/activate.go @@ -721,17 +721,6 @@ func execPluginCommand(ctx context.Context, command pluginCommand, timeout time. return output } -// pluginChildLaunched reports whether the outcome describes a process that -// actually started. Only those can be described by an enforcement notice. -func pluginChildLaunched(kind execution.OutcomeKind) bool { - switch kind { - case execution.OutcomeSandboxSetupFailure, execution.OutcomeExecutableNotFound: - return false - default: - return true - } -} - func execPluginCommandWithExecution(ctx context.Context, runner *execution.Runner, command pluginCommand, timeout time.Duration) commandOutput { runCtx, cancel := context.WithTimeout(ctx, timeout) defer cancel() @@ -761,9 +750,7 @@ func execPluginCommandWithExecution(ctx context.Context, runner *execution.Runne // weakened token, so the disclosure is still true of it. A setup failure or a // missing executable launched nothing, and claiming the write jail was traded // away there would describe a trade nobody made. - if pluginChildLaunched(result.Outcome.Kind) { - output.Notices = append([]string(nil), result.Outcome.Enforcement.Notices...) - } + output.Notices = result.Outcome.AppliedEnforcementNotices() switch result.Outcome.Kind { case execution.OutcomeSandboxSetupFailure, execution.OutcomeExecutableNotFound, execution.OutcomeTimedOut, execution.OutcomeCancelled: output.Err = result.Err diff --git a/internal/plugins/enforcement_notice_test.go b/internal/plugins/enforcement_notice_test.go index 6151eb83b..6fbc1d134 100644 --- a/internal/plugins/enforcement_notice_test.go +++ b/internal/plugins/enforcement_notice_test.go @@ -120,7 +120,7 @@ func TestAPluginThatNeverLaunchedCarriesNoNotice(t *testing.T) { execution.OutcomeSandboxSetupFailure, execution.OutcomeExecutableNotFound, } { - if pluginChildLaunched(kind) { + if (execution.Outcome{Kind: kind}).ChildLaunched() { t.Errorf("%v is treated as a launched child; a notice there would describe a process that never started", kind) } } @@ -128,7 +128,7 @@ func TestAPluginThatNeverLaunchedCarriesNoNotice(t *testing.T) { execution.OutcomeTimedOut, execution.OutcomeCancelled, } { - if !pluginChildLaunched(kind) { + if !(execution.Outcome{Kind: kind}).ChildLaunched() { t.Errorf("%v is treated as never launched, so its disclosure would be dropped", kind) } } From 7c38b2f3375f84370b9f71b0fec134a345872bce Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Thu, 27 Aug 2026 15:46:26 +0530 Subject: [PATCH 14/43] fix(mcp,hooks): carry the enforcement fact to the durable consumers A hook audit record kept an exit code, stdout and stderr, and the notice is deliberately in none of those. Once the dispatch result was gone, nothing could tell an audit or recovery reader that a hook had run under the weakened DenyRead token. AuditResult carries the notices typed and omitempty, so historical records read back unchanged and an ordinary hook writes what it wrote before. An MCP stdio server's launch is the same shape one level up. connectStdio received the prepared command and kept only the command and its cleanup, so a server started under the weakened token served the whole session with nothing able to say so, and no later tool result could recover it because the fact describes startup rather than any response. The client keeps the applied enforcement, recorded after Start returns so a prepare failure or a missing executable claims nothing, registration collects it per server, and startup states it once next to the skipped-server warnings. Network servers launch no local process and report nothing. --- internal/cli/app.go | 9 + internal/cli/mcp_startup_disclosure_test.go | 52 +++++ internal/cli/mcp_tools.go | 23 +++ internal/hooks/dispatch.go | 9 +- .../hooks/enforcement_audit_record_test.go | 119 +++++++++++ .../hooks/enforcement_launch_state_test.go | 6 + internal/hooks/hooks.go | 13 ++ internal/mcp/client.go | 33 +++ internal/mcp/registry.go | 39 +++- internal/mcp/startup_disclosure_test.go | 194 ++++++++++++++++++ 10 files changed, 494 insertions(+), 3 deletions(-) create mode 100644 internal/cli/mcp_startup_disclosure_test.go create mode 100644 internal/hooks/enforcement_audit_record_test.go create mode 100644 internal/mcp/startup_disclosure_test.go diff --git a/internal/cli/app.go b/internal/cli/app.go index 44fa370ff..d07a9b1e2 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -867,6 +867,15 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a } fmt.Fprintf(stderr, "warning: MCP server %s unavailable, skipped: %s\n", skipped.Name, redaction.ErrorMessage(skipped.Err, redaction.Options{})) } + // AND WHAT THE SERVERS THAT DID START RAN UNDER. A stdio MCP server prepared + // with a weakened write jail serves the whole session from that process, so + // the disclosure is about startup and no later tool result can carry it. Said + // once, here, next to the skip warnings, rather than pasted onto every + // response the server produces. Network servers launch no local process and + // report nothing, which is why the optional background registration is not + // asked: its only member is the built-in HTTP default, which starts no local + // process. A stdio default would need this statement from that path too. + reportMCPStartupDisclosures(stderr, mcpRuntime) // Make local plugins live: register their declared tools into the registry and // collect their hooks + skill roots for the dispatcher and skill tool below. // Done after specialist + MCP registration so plugin tools are part of the diff --git a/internal/cli/mcp_startup_disclosure_test.go b/internal/cli/mcp_startup_disclosure_test.go new file mode 100644 index 000000000..bf8a6288b --- /dev/null +++ b/internal/cli/mcp_startup_disclosure_test.go @@ -0,0 +1,52 @@ +package cli + +import ( + "bytes" + "strings" + "testing" + + "github.com/Gitlawb/zero/internal/mcp" +) + +type disclosingRuntime struct { + noopMCPRuntime + disclosures []mcp.StartupDisclosure +} + +func (runtime disclosingRuntime) StartupDisclosures() []mcp.StartupDisclosure { + return runtime.disclosures +} + +// SAID ONCE, WHERE THE USER IS ALREADY BEING TOLD WHAT STARTED. +// +// The disclosure describes a server PROCESS, which serves the whole session, so +// it cannot ride on a tool result and must not be repeated on every one. +func TestStartupDisclosuresAreReportedOnce(t *testing.T) { + const notice = "denyRead is configured, so the write jail is not confining writes" + var stderr bytes.Buffer + reportMCPStartupDisclosures(&stderr, disclosingRuntime{ + disclosures: []mcp.StartupDisclosure{{Name: "docs", Notices: []string{notice}}}, + }) + output := stderr.String() + if count := strings.Count(output, notice); count != 1 { + t.Errorf("the disclosure appears %d times, want exactly 1: %q", count, output) + } + if !strings.Contains(output, "docs") { + t.Errorf("the report does not name the server it is about: %q", output) + } +} + +// A run with nothing to disclose prints nothing at all. +func TestNoStartupDisclosuresPrintNothing(t *testing.T) { + var stderr bytes.Buffer + reportMCPStartupDisclosures(&stderr, disclosingRuntime{}) + if stderr.Len() != 0 { + t.Errorf("a run with no disclosure wrote %q", stderr.String()) + } + stderr.Reset() + // And a runtime that launches nothing is not required to answer. + reportMCPStartupDisclosures(&stderr, noopMCPRuntime{}) + if stderr.Len() != 0 { + t.Errorf("a runtime that launches nothing wrote %q", stderr.String()) + } +} diff --git a/internal/cli/mcp_tools.go b/internal/cli/mcp_tools.go index 022d64001..cbf1784c0 100644 --- a/internal/cli/mcp_tools.go +++ b/internal/cli/mcp_tools.go @@ -3,6 +3,7 @@ package cli import ( "context" "fmt" + "io" "net/url" "sort" "strings" @@ -188,3 +189,25 @@ func isSensitiveMCPDisplayKey(key string) bool { } return false } + +// mcpStartupDisclosing is the optional interface a runtime implements when it +// can report what its launched server processes ran under. Optional rather than +// part of mcpToolRuntime so a runtime that launches nothing, and every test +// double, stays unchanged. +type mcpStartupDisclosing interface { + StartupDisclosures() []mcp.StartupDisclosure +} + +// reportMCPStartupDisclosures states once what enforcement applied to the MCP +// server processes this run launched. +func reportMCPStartupDisclosures(stderr io.Writer, runtime mcpToolRuntime) { + disclosing, ok := runtime.(mcpStartupDisclosing) + if !ok { + return + } + for _, disclosure := range disclosing.StartupDisclosures() { + for _, notice := range disclosure.Notices { + fmt.Fprintf(stderr, "notice: MCP server %s started with reduced enforcement: %s\n", disclosure.Name, notice) + } + } +} diff --git a/internal/hooks/dispatch.go b/internal/hooks/dispatch.go index f9eb35014..46ea3ba63 100644 --- a/internal/hooks/dispatch.go +++ b/internal/hooks/dispatch.go @@ -325,7 +325,14 @@ func (dispatcher *Dispatcher) recordCompleted(hook Definition, input DispatchInp Matcher: hook.Matcher, ToolCallID: input.ToolCallID, Status: status, - Results: []AuditResult{{ExitCode: result.ExitCode, Stdout: result.Stdout, Stderr: result.Stderr}}, + Results: []AuditResult{{ + ExitCode: result.ExitCode, + Stdout: result.Stdout, + Stderr: result.Stderr, + // The notice is not in stdout or stderr by design, so the durable record + // has to carry it or the fact ends with the dispatch result. + EnforcementNotices: append([]string(nil), result.Notices...), + }}, DurationMs: durationMs, }) } diff --git a/internal/hooks/enforcement_audit_record_test.go b/internal/hooks/enforcement_audit_record_test.go new file mode 100644 index 000000000..279aba069 --- /dev/null +++ b/internal/hooks/enforcement_audit_record_test.go @@ -0,0 +1,119 @@ +package hooks + +import ( + "context" + "os/exec" + "path/filepath" + "testing" +) + +// auditedDispatcher wires a real audit store to a dispatcher whose hook result +// is whatever the caller wants, and returns the events that survived the write. +func auditedDispatcher(t *testing.T, hook Definition, result commandResult) []AuditEvent { + t.Helper() + store, err := NewAuditStore(AuditStoreOptions{AuditPath: filepath.Join(t.TempDir(), "audit.jsonl")}) + if err != nil { + t.Fatalf("NewAuditStore: %v", err) + } + dispatcher := NewDispatcher(DispatcherOptions{ + Config: beforeToolConfig(hook), + Audit: store, + run: func(context.Context, string, []string, []byte, string, []string) commandResult { + return result + }, + }) + dispatcher.Dispatch(context.Background(), DispatchInput{Event: EventBeforeTool, ToolName: "bash", ToolCallID: "call_1"}) + + // READ BACK FROM DISK, not from the in-memory event the append returned. The + // durable reader is the consumer this field exists for. + events, err := store.ReadEvents() + if err != nil { + t.Fatalf("ReadEvents: %v", err) + } + return events +} + +func completedResults(t *testing.T, events []AuditEvent) []AuditResult { + t.Helper() + for _, event := range events { + if len(event.Results) > 0 { + return event.Results + } + } + t.Fatalf("no completed record was written: %#v", events) + return nil +} + +// THE TRANSIENT DISPATCH RESULT IS NOT WHERE THIS FACT CAN LIVE. +// +// recordCompleted kept an exit code, stdout and stderr, and the notice is +// deliberately in none of those. So once the dispatch result was gone, an audit +// or recovery reader could not tell that a hook had run under the weakened +// DenyRead token, whatever the hook did afterwards. +func TestTheAuditRecordKeepsTheEnforcementNotice(t *testing.T) { + const notice = "denyRead is configured, so the write jail is not confining writes" + + for _, testCase := range []struct { + name string + result commandResult + }{ + {"launched and succeeded", commandResult{ExitCode: 0, Stdout: "looks fine", Notices: []string{notice}}}, + {"vetoed the tool", commandResult{ExitCode: 2, Stderr: "policy violation", Notices: []string{notice}}}, + {"silent hook", commandResult{ExitCode: 0, Notices: []string{notice}}}, + } { + t.Run(testCase.name, func(t *testing.T) { + results := completedResults(t, auditedDispatcher(t, + Definition{ID: "policy", Event: EventBeforeTool, Command: "policy-check", Enabled: true}, + testCase.result)) + if len(results) != 1 { + t.Fatalf("results = %#v, want one", results) + } + if len(results[0].EnforcementNotices) != 1 || results[0].EnforcementNotices[0] != notice { + t.Errorf("the durable record lost the disclosure: %#v", results[0]) + } + // The existing semantics are untouched. + if results[0].ExitCode != testCase.result.ExitCode { + t.Errorf("ExitCode = %d, want %d", results[0].ExitCode, testCase.result.ExitCode) + } + if results[0].Stdout != testCase.result.Stdout || results[0].Stderr != testCase.result.Stderr { + t.Errorf("stdout/stderr changed: %#v", results[0]) + } + }) + } +} + +// A hook with nothing to disclose writes exactly what it wrote before, so a +// reader of historical records sees no difference. +func TestAnOrdinaryHookWritesNoEnforcementField(t *testing.T) { + results := completedResults(t, auditedDispatcher(t, + Definition{ID: "policy", Event: EventBeforeTool, Command: "policy-check", Enabled: true}, + commandResult{ExitCode: 0, Stdout: "looks fine"})) + if len(results[0].EnforcementNotices) != 0 { + t.Errorf("a hook with no disclosure recorded one: %#v", results[0]) + } +} + +// And the durable record inherits the launch-state rule rather than restating +// it: a hook that never started records no enforcement claim. +func TestTheAuditRecordMakesNoClaimForAHookThatNeverLaunched(t *testing.T) { + store, err := NewAuditStore(AuditStoreOptions{AuditPath: filepath.Join(t.TempDir(), "audit.jsonl")}) + if err != nil { + t.Fatalf("NewAuditStore: %v", err) + } + dispatcher := NewDispatcher(DispatcherOptions{ + Config: beforeToolConfig(Definition{ID: "policy", Event: EventBeforeTool, Command: "policy-check", Enabled: true}), + Audit: store, + Cwd: t.TempDir(), + Execution: newRunnerFor(¬icePreparer{build: func() *exec.Cmd { return exec.Command("definitely-not-a-real-binary-zzz") }}), + }) + dispatcher.Dispatch(context.Background(), DispatchInput{Event: EventBeforeTool, ToolName: "bash", ToolCallID: "call_1"}) + events, err := store.ReadEvents() + if err != nil { + t.Fatalf("ReadEvents: %v", err) + } + for _, result := range completedResults(t, events) { + if len(result.EnforcementNotices) != 0 { + t.Errorf("the durable record claims an enforcement trade for a hook that never started: %#v", result) + } + } +} diff --git a/internal/hooks/enforcement_launch_state_test.go b/internal/hooks/enforcement_launch_state_test.go index fa003a5b9..d0cc38361 100644 --- a/internal/hooks/enforcement_launch_state_test.go +++ b/internal/hooks/enforcement_launch_state_test.go @@ -161,3 +161,9 @@ func TestALaunchedHookCarriesTheNoticeIntoTheDispatchOutcome(t *testing.T) { t.Errorf("a hook that really ran under the weakened token disclosed nothing:\n%s", outcome.Reason) } } + +// newRunnerFor keeps the audit tests readable without importing the execution +// package into every file that needs one. +func newRunnerFor(preparer *noticePreparer) *execution.Runner { + return execution.NewRunner(preparer) +} diff --git a/internal/hooks/hooks.go b/internal/hooks/hooks.go index f7dd79cea..bc8ee07ff 100644 --- a/internal/hooks/hooks.go +++ b/internal/hooks/hooks.go @@ -118,6 +118,19 @@ type AuditResult struct { ExitCode int `json:"exitCode"` Stdout string `json:"stdout,omitempty"` Stderr string `json:"stderr,omitempty"` + // EnforcementNotices are the least-privilege disclosures that were true of + // this hook's execution. + // + // A DURABLE READER CANNOT RECOVER A FACT THAT WAS DROPPED IN CONVERSION. The + // notice is deliberately not written into stdout or stderr, so recording only + // those three fields meant that once the transient dispatch result was gone, + // nothing could tell an audit or recovery reader that a successful, failing or + // vetoing hook had run under the weakened DenyRead token. + // + // Typed rather than a rendered line, and omitempty, so historical records that + // predate the field read back unchanged and an ordinary hook writes exactly + // what it wrote before. + EnforcementNotices []string `json:"enforcementNotices,omitempty"` } type AuditEvent struct { diff --git a/internal/mcp/client.go b/internal/mcp/client.go index 064e7f213..348440ce4 100644 --- a/internal/mcp/client.go +++ b/internal/mcp/client.go @@ -43,6 +43,22 @@ type ToolClient interface { Close() error } +// startupDisclosing is the optional interface a client implements when its +// LAUNCH carried a least-privilege disclosure. A network server launches no +// local process, so it does not implement this and reports nothing, which is the +// correct answer rather than an empty one. +type startupDisclosing interface { + StartupNotices() []string +} + +// StartupNotices reports the disclosures that applied to this server's launch. +func (client *Client) StartupNotices() []string { + if client == nil || len(client.startupNotices) == 0 { + return nil + } + return append([]string(nil), client.startupNotices...) +} + type Client struct { server Server cmd *exec.Cmd @@ -53,6 +69,16 @@ type Client struct { closeMu sync.Mutex nextID int cleanup func() + // startupNotices are the least-privilege disclosures that applied to THIS + // server's launch. + // + // THE FACT DESCRIBES STARTUP, SO IT CANNOT BE RECOVERED FROM A TOOL RESULT. + // A stdio server prepared under the weakened token runs for the whole session, + // and connectStdio used to keep only the command and its cleanup, so nothing + // downstream could tell the operator that the process serving these tools had + // reduced write confinement. Kept typed here and rendered exactly once at + // registration rather than pasted onto every later tool result. + startupNotices []string // dispatchMu guards the response-dispatch state shared with the single // reader goroutine. It is never held across a blocking read. @@ -139,6 +165,7 @@ func (b *boundedBuffer) String() string { func connectStdio(ctx context.Context, server Server, options ConnectOptions) (*Client, error) { var cmd *exec.Cmd var cleanup func() + var plannedEnforcement execution.Enforcement cleanupTransferred := false defer func() { if cleanup != nil && !cleanupTransferred { @@ -163,6 +190,7 @@ func connectStdio(ctx context.Context, server Server, options ConnectOptions) (* } cmd = prepared.Command cleanup = prepared.Cleanup + plannedEnforcement = prepared.Enforcement } else { cmd = exec.CommandContext(ctx, server.Command, server.Args...) cmd.Env = mergeProcessEnv(server.Env) @@ -189,6 +217,11 @@ func connectStdio(ctx context.Context, server Server, options ConnectOptions) (* writer: newMessageWriter(stdin), nextID: 1, cleanup: cleanup, + // Recorded only now, AFTER Start returned. Everything above returns early, + // so a prepare failure or an executable that could not be launched records + // nothing: same launch-state rule hooks and plugins use, expressed by where + // this assignment sits rather than by another outcome-kind switch. + startupNotices: append([]string(nil), plannedEnforcement.Notices...), } cleanupTransferred = true if err := client.initialize(ctx); err != nil { diff --git a/internal/mcp/registry.go b/internal/mcp/registry.go index d1a2978dc..b8bfd1354 100644 --- a/internal/mcp/registry.go +++ b/internal/mcp/registry.go @@ -44,6 +44,18 @@ type SkippedServer struct { UnconfiguredDefault bool } +// StartupDisclosure is a least-privilege statement about one MCP server's +// LAUNCH, as opposed to anything a later tool call does. +// +// A stdio server prepared under a weakened token serves the whole session from +// that process, so the fact describes startup and cannot be recovered from an +// individual tool result afterwards. It is reported once, here, rather than +// appended to every response the server produces. +type StartupDisclosure struct { + Name string + Notices []string +} + type Runtime struct { clients []ToolClient // cancels releases the per-server connect contexts of the clients we KEPT. @@ -52,8 +64,11 @@ type Runtime struct { // is closed). Same length/order as clients is not required. cancels []context.CancelFunc skipped []SkippedServer - once sync.Once - err error + // disclosures are the least-privilege statements that applied to each server + // process this registration LAUNCHED. See StartupDisclosures. + disclosures []StartupDisclosure + once sync.Once + err error } // Skipped returns the servers that were skipped during registration (unreachable @@ -123,6 +138,15 @@ func RegisterTools(ctx context.Context, registry *tools.Registry, cfg config.MCP }() select { case res := <-done: + if client, ok := res.client.(startupDisclosing); ok && res.client != nil { + // Collected for any server whose PROCESS STARTED, including one whose + // tools are rejected below: the launch happened under that token either + // way, and a skip warning does not say what confinement the process ran + // with while it was alive. + if notices := client.StartupNotices(); len(notices) > 0 { + runtime.disclosures = append(runtime.disclosures, StartupDisclosure{Name: server.Name, Notices: notices}) + } + } if res.err != nil { cancel() // failed: nothing to keep } else { @@ -395,3 +419,14 @@ func isPersistentlyApproved(store *PermissionStore, server Server, toolName stri }) return err == nil && approved } + +// StartupDisclosures returns the least-privilege statements that applied to the +// MCP server processes this registration launched, so a caller can report them +// once. Empty when no server was launched under reduced enforcement, and always +// empty for network servers, which launch no local process. +func (runtime *Runtime) StartupDisclosures() []StartupDisclosure { + if runtime == nil { + return nil + } + return append([]StartupDisclosure(nil), runtime.disclosures...) +} diff --git a/internal/mcp/startup_disclosure_test.go b/internal/mcp/startup_disclosure_test.go new file mode 100644 index 000000000..90f62ecc7 --- /dev/null +++ b/internal/mcp/startup_disclosure_test.go @@ -0,0 +1,194 @@ +package mcp + +import ( + "context" + "errors" + "os" + "os/exec" + "strings" + "testing" + "time" + + "github.com/Gitlawb/zero/internal/config" + "github.com/Gitlawb/zero/internal/execution" + "github.com/Gitlawb/zero/internal/tools" +) + +const startupNotice = "denyRead is configured, so the write jail is not confining writes" + +// disclosingPreparer plans an MCP server launch that carries an enforcement +// notice, and can fail the way the sandbox does before the child exists. +type disclosingPreparer struct { + prepareErr error + missing bool +} + +func (preparer *disclosingPreparer) PrepareExecution(ctx context.Context, request execution.Request) (execution.PreparedCommand, error) { + if preparer.prepareErr != nil { + return execution.PreparedCommand{}, preparer.prepareErr + } + name, args := request.Command.Name, request.Command.Args + if preparer.missing { + name, args = "definitely-not-a-real-binary-zzz", nil + } + command := exec.CommandContext(ctx, name, args...) + command.Dir = request.WorkingDirectory + command.Env = request.Command.Env + return execution.PreparedCommand{ + Command: command, + Enforcement: execution.Enforcement{Notices: []string{startupNotice}}, + }, nil +} + +func helperServer(t *testing.T) Server { + t.Helper() + executable, err := os.Executable() + if err != nil { + t.Fatal(err) + } + return Server{ + Name: "docs", + Type: ServerTypeStdio, + Command: executable, + Args: []string{"-test.run=TestMCPStdioHelperProcess", "--"}, + Env: map[string]string{"ZERO_MCP_STDIO_HELPER": "1"}, + } +} + +// THE FACT DESCRIBES STARTUP, SO NOTHING LATER CAN CARRY IT. +// +// The generic adapter puts plan notes on PreparedCommand.Enforcement for +// OriginMCPServer, and connectStdio kept only the command and its cleanup. A +// stdio server launched under the weakened token then served the whole session +// with no path able to tell the operator that its write confinement was +// reduced, and no individual tool result could recover it, because the fact is +// about the process rather than about any response. +func TestAnMCPServerLaunchKeepsItsEnforcementDisclosure(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + client, err := ConnectWithOptions(ctx, helperServer(t), ConnectOptions{ + Execution: execution.NewRunner(&disclosingPreparer{}), + WorkspaceRoot: t.TempDir(), + }) + if err != nil { + t.Fatalf("ConnectWithOptions() error = %v", err) + } + defer client.Close() + + disclosing, ok := client.(startupDisclosing) + if !ok { + t.Fatal("a launched stdio client does not report its startup enforcement at all") + } + notices := disclosing.StartupNotices() + if len(notices) != 1 || notices[0] != startupNotice { + t.Fatalf("StartupNotices() = %#v, want the launch disclosure", notices) + } +} + +// A launch with nothing to disclose reports nothing, or every server would carry +// a notice and the statement would mean nothing. +func TestAnUnrestrictedMCPServerLaunchDisclosesNothing(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + client, err := ConnectWithOptions(ctx, helperServer(t), ConnectOptions{ + Execution: execution.NewRunner(&mcpExecutionPreparer{}), + WorkspaceRoot: t.TempDir(), + }) + if err != nil { + t.Fatalf("ConnectWithOptions() error = %v", err) + } + defer client.Close() + disclosing, ok := client.(startupDisclosing) + if !ok { + t.Fatal("a launched stdio client does not report its startup enforcement at all") + } + if notices := disclosing.StartupNotices(); len(notices) != 0 { + t.Errorf("StartupNotices() = %#v, want none", notices) + } +} + +// And a launch that never happened claims nothing, which is the same launch-state +// rule hooks and plugins apply. Here it is expressed by WHERE the notice is +// recorded: every failure above returns before the client exists. +func TestAnMCPServerThatNeverLaunchedClaimsNoEnforcement(t *testing.T) { + for _, testCase := range []struct { + name string + preparer *disclosingPreparer + }{ + {"sandbox setup failed", &disclosingPreparer{prepareErr: errors.New("could not build the restricted token")}}, + {"executable not found", &disclosingPreparer{missing: true}}, + } { + t.Run(testCase.name, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + client, err := ConnectWithOptions(ctx, helperServer(t), ConnectOptions{ + Execution: execution.NewRunner(testCase.preparer), + WorkspaceRoot: t.TempDir(), + }) + if err == nil { + client.Close() + t.Fatal("the server started even though its launch was supposed to fail") + } + if strings.Contains(err.Error(), startupNotice) { + t.Errorf("a launch that never happened claimed an enforcement trade: %v", err) + } + }) + } +} + +// disclosingFakeClient is a launched server that carries a disclosure. +type disclosingFakeClient struct { + fakeToolClient + notices []string +} + +func (client *disclosingFakeClient) StartupNotices() []string { return client.notices } + +// Registration is the boundary that reports it, once, for the process it +// launched. +func TestRegistrationCollectsStartupDisclosures(t *testing.T) { + registry := tools.NewRegistry() + client := &disclosingFakeClient{ + fakeToolClient: fakeToolClient{listed: []RemoteTool{{Name: "lookup", Description: "Lookup"}}}, + notices: []string{startupNotice}, + } + runtime, err := RegisterTools(context.Background(), registry, config.MCPConfig{Servers: map[string]config.MCPServerConfig{ + "docs": {Type: "stdio", Command: "docs-mcp"}, + }}, RegisterOptions{ClientFactory: func(context.Context, Server) (ToolClient, error) { return client, nil }}) + if err != nil { + t.Fatalf("RegisterTools() error = %v", err) + } + defer runtime.Close() + + disclosures := runtime.StartupDisclosures() + if len(disclosures) != 1 { + t.Fatalf("StartupDisclosures() = %#v, want one", disclosures) + } + if disclosures[0].Name != "docs" { + t.Errorf("Name = %q, want the server it describes", disclosures[0].Name) + } + if len(disclosures[0].Notices) != 1 || disclosures[0].Notices[0] != startupNotice { + t.Errorf("Notices = %#v, want the launch disclosure", disclosures[0].Notices) + } +} + +// A network server launches no local process, so it implements nothing and +// reports nothing. That is a different answer from an empty one and the negative +// case that keeps the statement meaningful. +func TestANetworkServerReportsNoStartupDisclosure(t *testing.T) { + registry := tools.NewRegistry() + runtime, err := RegisterTools(context.Background(), registry, config.MCPConfig{Servers: map[string]config.MCPServerConfig{ + "docs": {Type: "http", URL: "https://host.invalid/mcp"}, + }}, RegisterOptions{ClientFactory: func(context.Context, Server) (ToolClient, error) { + return &fakeToolClient{listed: []RemoteTool{{Name: "lookup", Description: "Lookup"}}}, nil + }}) + if err != nil { + t.Fatalf("RegisterTools() error = %v", err) + } + defer runtime.Close() + if disclosures := runtime.StartupDisclosures(); len(disclosures) != 0 { + t.Errorf("StartupDisclosures() = %#v, want none for a server that launches no process", disclosures) + } +} From f1134c7630f5e41f835f1091cd330592f31ee601 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Thu, 27 Aug 2026 21:10:27 +0530 Subject: [PATCH 15/43] fix(mcp): collect startup disclosures in the serial phase, and keep them past a failed start Two defects in the disclosure collection, both found by jatmn. The append ran inside the per-server goroutine, so it raced the shared slice header: entries could be lost or overwritten, and whichever survived were ordered by completion time rather than by server. The comment directly above promises that the concurrent phase touches no shared state and that the serial phase is therefore deterministic, and this broke both halves of it. The notices now travel on the indexed connectResult and are committed in the serial loop, in server order. Reproduced with 32 simultaneous servers under -race before the fix. The disclosure was also reachable only through the client, so a server that started, did filesystem work, and then failed initialize or tools/list lost the fact when that path closed the client and returned nil. The operator was told the server was unavailable and not that the process had already run without the write jail. connectAndList returns the notices separately now, so they survive the failure that discards the client. A factory error still discloses nothing, because nothing launched. --- internal/mcp/registry.go | 57 ++++++-- internal/mcp/startup_disclosure_race_test.go | 138 +++++++++++++++++++ 2 files changed, 180 insertions(+), 15 deletions(-) create mode 100644 internal/mcp/startup_disclosure_race_test.go diff --git a/internal/mcp/registry.go b/internal/mcp/registry.go index b8bfd1354..47b9e0b7a 100644 --- a/internal/mcp/registry.go +++ b/internal/mcp/registry.go @@ -122,6 +122,12 @@ func RegisterTools(ctx context.Context, registry *tools.Registry, cfg config.MCP remote []RemoteTool cancel context.CancelFunc err error + // notices travels with the indexed result rather than being appended to + // shared state from inside the goroutine. The concurrent phase touches no + // shared state, which is the property the comment above promises and the + // reason the serial phase can be deterministic; appending here broke both, + // racing the slice header and ordering disclosures by completion time. + notices []string } results := make([]connectResult, len(servers)) var wg sync.WaitGroup @@ -133,20 +139,11 @@ func RegisterTools(ctx context.Context, registry *tools.Registry, cfg config.MCP serverCtx, cancel := context.WithCancel(ctx) done := make(chan connectResult, 1) go func() { - client, remote, err := connectAndList(serverCtx, factory, server) - done <- connectResult{client: client, remote: remote, err: err} + client, remote, notices, err := connectAndList(serverCtx, factory, server) + done <- connectResult{client: client, remote: remote, notices: notices, err: err} }() select { case res := <-done: - if client, ok := res.client.(startupDisclosing); ok && res.client != nil { - // Collected for any server whose PROCESS STARTED, including one whose - // tools are rejected below: the launch happened under that token either - // way, and a skip warning does not say what confinement the process ran - // with while it was alive. - if notices := client.StartupNotices(); len(notices) > 0 { - runtime.disclosures = append(runtime.disclosures, StartupDisclosure{Name: server.Name, Notices: notices}) - } - } if res.err != nil { cancel() // failed: nothing to keep } else { @@ -178,6 +175,13 @@ func RegisterTools(ctx context.Context, registry *tools.Registry, cfg config.MCP stagedNames := make(map[string]struct{}) for index, server := range servers { res := results[index] + // Recorded here, in server order, for any server whose PROCESS STARTED, + // including one whose tools are rejected below: the launch happened under + // that token either way, and a skip warning does not say what confinement + // the process ran with while it was alive. + if len(res.notices) > 0 { + runtime.disclosures = append(runtime.disclosures, StartupDisclosure{Name: server.Name, Notices: res.notices}) + } if res.err != nil { runtime.skipped = append(runtime.skipped, SkippedServer{Name: server.Name, Err: res.err, UnconfiguredDefault: server.UnconfiguredDefault}) continue @@ -211,17 +215,40 @@ func RegisterTools(ctx context.Context, registry *tools.Registry, cfg config.MCP // connectAndList connects to one server and lists its tools. It does ONLY I/O // (no registry, permission-store, or other shared state), so it is safe to run // concurrently for every server. On a list error it closes the client. -func connectAndList(ctx context.Context, factory func(context.Context, Server) (ToolClient, error), server Server) (ToolClient, []RemoteTool, error) { +// connectAndList returns the client, its tools, and the least-privilege +// disclosures that applied to its LAUNCH. +// +// THE LAUNCH FACT OUTLIVES THE CONNECTION. A stdio server can start, and do +// filesystem work, and then fail initialize or tools/list. Returning the notices +// separately rather than leaving them on the client means the fact survives that +// failure: the client is closed and discarded here, so anything reachable only +// through it is gone by the time the caller sees the error, and the skip warning +// on its own does not say the process already ran with reduced write +// confinement. +func connectAndList(ctx context.Context, factory func(context.Context, Server) (ToolClient, error), server Server) (ToolClient, []RemoteTool, []string, error) { client, err := factory(ctx, server) if err != nil { - return nil, nil, err + // Nothing launched, so there is nothing to disclose. + return nil, nil, nil, err } + notices := startupNoticesOf(client) remoteTools, err := client.ListTools(ctx) if err != nil { _ = client.Close() - return nil, nil, fmt.Errorf("list MCP tools for %s: %w", server.Name, err) + return nil, nil, notices, fmt.Errorf("list MCP tools for %s: %w", server.Name, err) + } + return client, remoteTools, notices, nil +} + +// startupNoticesOf reads a client's launch disclosures, if it reports any. +func startupNoticesOf(client ToolClient) []string { + if client == nil { + return nil + } + if disclosing, ok := client.(startupDisclosing); ok { + return disclosing.StartupNotices() } - return client, remoteTools, nil + return nil } // buildServerTools validates a server's remote tools against the registry and the diff --git a/internal/mcp/startup_disclosure_race_test.go b/internal/mcp/startup_disclosure_race_test.go new file mode 100644 index 000000000..b15ff9e01 --- /dev/null +++ b/internal/mcp/startup_disclosure_race_test.go @@ -0,0 +1,138 @@ +package mcp + +import ( + "context" + "fmt" + "sort" + "testing" + + "github.com/Gitlawb/zero/internal/config" + "github.com/Gitlawb/zero/internal/tools" +) + +// disclosingRaceClient is a launched server that reports a disclosure. +type disclosingRaceClient struct { + fakeToolClient + notices []string +} + +func (c *disclosingRaceClient) StartupNotices() []string { return c.notices } + +// THE CONCURRENT PHASE TOUCHES NO SHARED STATE, AND THAT IS LOAD-BEARING. +// +// RegisterTools runs one goroutine per server and commits everything in a +// deterministic serial phase afterwards, which is what lets the result be +// identical regardless of completion order. Collecting the startup disclosures +// inside the goroutine broke both halves of that: the append raced the slice +// header, so entries could be lost or overwritten, and whichever survived were +// ordered by completion time rather than by server. +// +// Many servers rather than one, because a single disclosing server cannot +// exercise a shared write at all. Run this package with -race. +func TestStartupDisclosuresAreCollectedWithoutRacing(t *testing.T) { + const servers = 32 + configured := map[string]config.MCPServerConfig{} + for index := range servers { + configured[fmt.Sprintf("srv%02d", index)] = config.MCPServerConfig{Type: "stdio", Command: "server"} + } + + registry := tools.NewRegistry() + runtime, err := RegisterTools(context.Background(), registry, config.MCPConfig{Servers: configured}, RegisterOptions{ + ClientFactory: func(_ context.Context, server Server) (ToolClient, error) { + return &disclosingRaceClient{ + fakeToolClient: fakeToolClient{listed: []RemoteTool{{Name: "tool_" + server.Name, Description: "d"}}}, + notices: []string{"denyRead is configured for " + server.Name}, + }, nil + }, + }) + if err != nil { + t.Fatalf("RegisterTools() error = %v", err) + } + defer runtime.Close() + + got := runtime.StartupDisclosures() + if len(got) != servers { + t.Fatalf("collected %d disclosures, want %d: a shared append loses entries", len(got), servers) + } + + // Deterministic server order, not completion order. Sorting the result and + // then comparing would hide exactly the defect this pins. + names := make([]string, 0, len(got)) + for _, disclosure := range got { + names = append(names, disclosure.Name) + } + sorted := append([]string(nil), names...) + sort.Strings(sorted) + for index := range names { + if names[index] != sorted[index] { + t.Fatalf("disclosure %d is %q, want %q: order follows completion rather than server order", index, names[index], sorted[index]) + } + } +} + +// failingDisclosingClient launches (so it has a disclosure) and then fails +// tools/list, which is the shape that used to drop the fact. +type failingDisclosingClient struct { + fakeToolClient + notices []string +} + +func (c *failingDisclosingClient) StartupNotices() []string { return c.notices } +func (c *failingDisclosingClient) ListTools(context.Context) ([]RemoteTool, error) { + return nil, fmt.Errorf("initialize failed after the process started") +} + +// THE LAUNCH FACT OUTLIVES THE CONNECTION. +// +// connectStdio records the disclosure once cmd.Start returns, which is the right +// moment. But a stdio server can start, do filesystem work, and then fail +// initialize or tools/list, and that path closes the client and returns nil. The +// disclosure was reachable only through that client, so it died with it, and the +// operator was told the server was unavailable without being told the process +// had already run without the write jail. +func TestADisclosureSurvivesAFailureAfterTheProcessLaunched(t *testing.T) { + const notice = "denyRead is configured, so the write jail is not confining writes" + registry := tools.NewRegistry() + runtime, err := RegisterTools(context.Background(), registry, config.MCPConfig{Servers: map[string]config.MCPServerConfig{ + "docs": {Type: "stdio", Command: "docs-mcp"}, + }}, RegisterOptions{ + ClientFactory: func(context.Context, Server) (ToolClient, error) { + return &failingDisclosingClient{notices: []string{notice}}, nil + }, + }) + if err != nil { + t.Fatalf("RegisterTools() error = %v", err) + } + defer runtime.Close() + + skipped := runtime.Skipped() + if len(skipped) != 1 { + t.Fatalf("Skipped() = %#v, want the failure recorded", skipped) + } + disclosures := runtime.StartupDisclosures() + if len(disclosures) != 1 || len(disclosures[0].Notices) != 1 || disclosures[0].Notices[0] != notice { + t.Fatalf("StartupDisclosures() = %#v, want the launch disclosure kept despite the failure", disclosures) + } + if disclosures[0].Name != "docs" { + t.Errorf("Name = %q, want the server it describes", disclosures[0].Name) + } +} + +// And a server that never launched still discloses nothing. +func TestAFactoryFailureDisclosesNothing(t *testing.T) { + registry := tools.NewRegistry() + runtime, err := RegisterTools(context.Background(), registry, config.MCPConfig{Servers: map[string]config.MCPServerConfig{ + "docs": {Type: "stdio", Command: "docs-mcp"}, + }}, RegisterOptions{ + ClientFactory: func(context.Context, Server) (ToolClient, error) { + return nil, fmt.Errorf("could not start the process") + }, + }) + if err != nil { + t.Fatalf("RegisterTools() error = %v", err) + } + defer runtime.Close() + if disclosures := runtime.StartupDisclosures(); len(disclosures) != 0 { + t.Errorf("StartupDisclosures() = %#v, want none for a process that never started", disclosures) + } +} From 510b00ae95993db0bfa6b4d0e86ffba2c18c6340 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Thu, 27 Aug 2026 21:28:57 +0530 Subject: [PATCH 16/43] fix(cli): report MCP startup disclosures from headless exec `zero exec` registers workspace MCP servers through the same sandbox-backed runner interactive startup uses, so a stdio server here can launch under the weakened token and serve the whole run. Only the TUI reported the disclosure, so every text, JSON, stream-JSON and --list-tools caller was told nothing about the enforcement trade for a process that was already running. Reported immediately after registration, before --list-tools and before the first result, since both return early. On stderr, which is where the skipped-server and trust notices already go, so stdout framing is untouched; the regression asserts the JSON and stream-JSON output still parses. The test isolates HOME, APPDATA, LOCALAPPDATA and the XDG roots. Without that it builds a sandbox engine against the real config dir, triggers the one-time grant migration there, and the notice surfaces on a later test's stderr, failing whichever test happens to assert an empty one. That moves between runs and reads as flakiness rather than as contamination. --- internal/cli/exec.go | 5 + internal/cli/exec_startup_disclosure_test.go | 127 +++++++++++++++++++ 2 files changed, 132 insertions(+) create mode 100644 internal/cli/exec_startup_disclosure_test.go diff --git a/internal/cli/exec.go b/internal/cli/exec.go index bde94b398..7a304da56 100644 --- a/internal/cli/exec.go +++ b/internal/cli/exec.go @@ -345,6 +345,11 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in return writeExecProviderError(stdout, stderr, options.outputFormat, "mcp_error", err.Error()) } defer closeMCPRuntime(stderr, mcpRuntime) + // Said HERE, before --list-tools and before the first result, because both + // return early and the process this describes is already running by now. On + // stderr, so text, JSON and stream-JSON framing on stdout are untouched: + // this is the same channel the skipped-server and trust notices use. + reportMCPStartupDisclosures(stderr, mcpRuntime) } pluginActivation = activatePlugins(workspaceRoot, registry, deps, stderr, trustRoot, executionRunner) registerLocalControlTools(registry, workspaceRoot, resolved.LocalControl) diff --git a/internal/cli/exec_startup_disclosure_test.go b/internal/cli/exec_startup_disclosure_test.go new file mode 100644 index 000000000..162638b66 --- /dev/null +++ b/internal/cli/exec_startup_disclosure_test.go @@ -0,0 +1,127 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "strings" + "testing" + + "github.com/Gitlawb/zero/internal/config" + "github.com/Gitlawb/zero/internal/mcp" + "github.com/Gitlawb/zero/internal/tools" +) + +// disclosingExecRuntime is an MCP runtime that launched a process under reduced +// enforcement. +type disclosingExecRuntime struct { + noopMCPRuntime + disclosures []mcp.StartupDisclosure +} + +func (r disclosingExecRuntime) StartupDisclosures() []mcp.StartupDisclosure { return r.disclosures } + +const execDisclosureNotice = "denyRead is configured, so the write jail is not confining writes" + +// isolateConfigDirs points every config/cache root at test-owned storage. +// +// Without it these tests build a sandbox engine against the developer's REAL +// config dir, trigger the one-time grant migration there, and the migration +// notice then turns up on a LATER test's stderr, failing whichever test happens +// to assert an empty one. The failure moves between runs, which is what makes it +// look like flakiness rather than contamination. +func isolateConfigDirs(t *testing.T) { + t.Helper() + dir := t.TempDir() + t.Setenv("HOME", dir) + t.Setenv("USERPROFILE", dir) + t.Setenv("APPDATA", dir) + t.Setenv("LOCALAPPDATA", dir) + t.Setenv("XDG_CONFIG_HOME", dir) + t.Setenv("XDG_CACHE_HOME", dir) +} + +func execDisclosureDeps(cwd string) appDeps { + return appDeps{ + getwd: func() (string, error) { return cwd, nil }, + resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) { return execResolvedConfig(), nil }, + resolveMCPConfig: func(string, bool) (config.MCPConfig, error) { + return config.MCPConfig{Servers: map[string]config.MCPServerConfig{ + "docs": {Type: "stdio", Command: "docs-mcp"}, + }}, nil + }, + registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) { + return disclosingExecRuntime{disclosures: []mcp.StartupDisclosure{ + {Name: "docs", Notices: []string{execDisclosureNotice}}, + }}, nil + }, + } +} + +// A HEADLESS RUN IS A DISCLOSURE SURFACE TOO. +// +// `zero exec` registers workspace MCP servers through the same sandbox-backed +// runner interactive startup uses, so a stdio server here can launch under the +// weakened token and serve the whole run. Reporting the disclosure only from the +// TUI meant every text, JSON, stream-JSON and --list-tools caller was told +// nothing about the enforcement trade, for a process that was already running. +func TestExecReportsMCPStartupDisclosures(t *testing.T) { + isolateConfigDirs(t) + for _, format := range []string{"", "--output-format=json", "--output-format=stream-json"} { + name := format + if name == "" { + name = "text" + } + t.Run(name, func(t *testing.T) { + args := []string{"exec", "--list-tools"} + if format != "" { + args = append(args, format) + } + var stdout, stderr bytes.Buffer + if code := runWithDeps(args, &stdout, &stderr, execDisclosureDeps(t.TempDir())); code != exitSuccess { + t.Fatalf("exit = %d, stderr = %s", code, stderr.String()) + } + if !strings.Contains(stderr.String(), execDisclosureNotice) { + t.Errorf("the headless run said nothing about the enforcement trade: %q", stderr.String()) + } + if !strings.Contains(stderr.String(), "docs") { + t.Errorf("the report does not name the server: %q", stderr.String()) + } + // The machine-readable surfaces must stay parseable: the disclosure + // belongs on stderr precisely so stdout framing is untouched. + if format == "--output-format=json" { + var any map[string]any + if err := json.Unmarshal(bytes.TrimSpace(stdout.Bytes()), &any); err != nil { + t.Errorf("stdout is no longer valid JSON: %v (%q)", err, stdout.String()) + } + } + if format == "--output-format=stream-json" { + for _, line := range strings.Split(strings.TrimSpace(stdout.String()), "\n") { + if strings.TrimSpace(line) == "" { + continue + } + var any map[string]any + if err := json.Unmarshal([]byte(line), &any); err != nil { + t.Errorf("a stream-json line is not valid JSON: %v (%q)", err, line) + } + } + } + }) + } +} + +// A run whose servers launched nothing says nothing. +func TestExecWithoutDisclosuresStaysQuiet(t *testing.T) { + isolateConfigDirs(t) + deps := execDisclosureDeps(t.TempDir()) + deps.registerMCPTools = func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) { + return noopMCPRuntime{}, nil + } + var stdout, stderr bytes.Buffer + if code := runWithDeps([]string{"exec", "--list-tools"}, &stdout, &stderr, deps); code != exitSuccess { + t.Fatalf("exit = %d, stderr = %s", code, stderr.String()) + } + if strings.Contains(stderr.String(), "reduced enforcement") { + t.Errorf("a run with nothing to disclose printed one: %q", stderr.String()) + } +} From fb08feca3cecd1900c8eb86362935ce1eedfccb0 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Thu, 27 Aug 2026 23:33:05 +0530 Subject: [PATCH 17/43] fix(execution): record launch state instead of inferring it from the outcome kind OutcomeKind is not a launch-state field, and reading it as one was wrong in both directions. The adapter report is read AFTER Run, so a child that really ran and then produced an unreadable report is rewritten to a setup failure and the disclosure was dropped although it applied. And a context already cancelled before os.StartProcess still selects a cancellation, so the disclosure was claimed for a process that never existed. ExecuteCaptured now records whether an OS process was created, taken from the only thing that knows: exec.Cmd sets Process only once os.StartProcess has succeeded. That is false for a missing executable and for a context cancelled before Start, and true for anything that ran, including a later timeout or cancellation. Report decoding can now fail after launch without rewriting the historical launch fact. The plugins test that asserted the kind decides was encoding the defect, so it now expresses the recorded-fact contract instead, including the two shapes the kind gets wrong. --- internal/execution/contracts.go | 27 ++--- internal/execution/launch_state_test.go | 111 ++++++++++++++++++++ internal/execution/runner.go | 6 ++ internal/plugins/enforcement_notice_test.go | 63 ++++++++--- 4 files changed, 177 insertions(+), 30 deletions(-) create mode 100644 internal/execution/launch_state_test.go diff --git a/internal/execution/contracts.go b/internal/execution/contracts.go index ef04adc25..c703ae915 100644 --- a/internal/execution/contracts.go +++ b/internal/execution/contracts.go @@ -187,7 +187,18 @@ type Outcome struct { Exit *Exit `json:"exit,omitempty"` Denial *Denial `json:"denial,omitempty"` Enforcement Enforcement `json:"enforcement"` - Changes []Change `json:"changes,omitempty"` + // Launched records whether an OS process was actually created, observed at + // the boundary that calls Run rather than inferred afterwards. + // + // OutcomeKind is not a launch-state field, and reading it as one is wrong in + // both directions. A child that ran and then produced an unreadable adapter + // report is rewritten to a setup failure, so inference drops a disclosure that + // did apply; a context already cancelled before os.StartProcess yields a + // cancellation, so inference claims reduced enforcement for a child that never + // existed. Report decoding can fail after launch without rewriting history, + // and cancellation happens on either side of Start. + Launched bool `json:"launched,omitempty"` + Changes []Change `json:"changes,omitempty"` } // AdapterReport is the structured, machine-readable result emitted by a @@ -198,19 +209,9 @@ type AdapterReport struct { } // ChildLaunched reports whether this outcome describes a process that actually -// started. -// -// A setup failure and a missing executable are decided BEFORE the child exists. -// Everything else, including a nonzero exit, a timeout and a cancellation, -// happened to a process that was already running under whatever enforcement was -// applied to it. +// started, from the recorded fact rather than from the terminal outcome kind. func (outcome Outcome) ChildLaunched() bool { - switch outcome.Kind { - case OutcomeSandboxSetupFailure, OutcomeExecutableNotFound: - return false - default: - return true - } + return outcome.Launched } // AppliedEnforcementNotices returns the least-privilege disclosures that are diff --git a/internal/execution/launch_state_test.go b/internal/execution/launch_state_test.go new file mode 100644 index 000000000..c5261b9e1 --- /dev/null +++ b/internal/execution/launch_state_test.go @@ -0,0 +1,111 @@ +package execution + +import ( + "context" + "errors" + "os/exec" + "runtime" + "testing" +) + +const launchStateNotice = "denyRead is configured, so the write jail is not confining writes" + +func launchStateShell(ctx context.Context, script string) *exec.Cmd { + if runtime.GOOS == "windows" { + return exec.CommandContext(ctx, "cmd.exe", "/c", script) + } + return exec.CommandContext(ctx, "/bin/sh", "-c", script) +} + +// launchStatePreparer plans a command carrying an enforcement notice, and can +// make the adapter report fail after the child has already run. +type launchStatePreparer struct { + script string + reportErr error + missing bool +} + +func (p *launchStatePreparer) PrepareExecution(ctx context.Context, _ Request) (PreparedCommand, error) { + command := launchStateShell(ctx, p.script) + if p.missing { + command = exec.CommandContext(ctx, "definitely-not-a-real-binary-zzz") + } + prepared := PreparedCommand{ + Command: command, + Enforcement: Enforcement{Notices: []string{launchStateNotice}}, + } + if p.reportErr != nil { + prepared.Report = func() (AdapterReport, error) { return AdapterReport{}, p.reportErr } + } + return prepared, nil +} + +func captured(t *testing.T, ctx context.Context, p *launchStatePreparer) CapturedResult { + t.Helper() + return NewRunner(p).ExecuteCaptured(ctx, CapturedRequest{Request: Request{ + Origin: OriginHook, + Mode: ModeCaptured, + Command: Command{Name: "irrelevant"}, + WorkingDirectory: t.TempDir(), + WorkspaceRoots: []string{t.TempDir()}, + Approval: ApprovalContext{PolicyVersion: PolicyVersion}, + }}) +} + +// THE OUTCOME KIND IS NOT A LAUNCH-STATE FIELD, IN EITHER DIRECTION. +// +// Deriving launch from the terminal kind is wrong twice over. The adapter report +// is read AFTER Run, so a child that really ran and then produced an unreadable +// report is rewritten to a setup failure: inference drops a disclosure that did +// apply. And a context already cancelled before os.StartProcess still selects a +// cancellation, so inference claims reduced enforcement for a process that never +// existed. +func TestLaunchStateIsRecordedNotInferred(t *testing.T) { + t.Run("ran, then the adapter report failed", func(t *testing.T) { + result := captured(t, context.Background(), &launchStatePreparer{ + script: "exit 0", + reportErr: errors.New("adapter report is unreadable"), + }) + if result.Outcome.Kind != OutcomeSandboxSetupFailure { + t.Fatalf("SETUP INVALID: kind = %q, want the report failure to rewrite it", result.Outcome.Kind) + } + if !result.Outcome.Launched { + t.Error("a child that ran was recorded as never launched") + } + if got := result.Outcome.AppliedEnforcementNotices(); len(got) != 1 { + t.Errorf("the disclosure was dropped for a child that did run: %#v", got) + } + }) + + t.Run("cancelled before the process started", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + result := captured(t, ctx, &launchStatePreparer{script: "exit 0"}) + if result.Outcome.Launched { + t.Error("a process that never started was recorded as launched") + } + if got := result.Outcome.AppliedEnforcementNotices(); len(got) != 0 { + t.Errorf("an enforcement trade was claimed for a process that never existed: %#v", got) + } + }) + + t.Run("never found", func(t *testing.T) { + result := captured(t, context.Background(), &launchStatePreparer{missing: true}) + if result.Outcome.Launched { + t.Error("a missing executable was recorded as launched") + } + if got := result.Outcome.AppliedEnforcementNotices(); len(got) != 0 { + t.Errorf("a missing executable claimed an enforcement trade: %#v", got) + } + }) + + t.Run("ordinary success still discloses", func(t *testing.T) { + result := captured(t, context.Background(), &launchStatePreparer{script: "exit 0"}) + if !result.Outcome.Launched { + t.Fatal("an ordinary run was recorded as never launched") + } + if got := result.Outcome.AppliedEnforcementNotices(); len(got) != 1 { + t.Errorf("an ordinary run lost its disclosure: %#v", got) + } + }) +} diff --git a/internal/execution/runner.go b/internal/execution/runner.go index 9e3ecbf9a..fb9310012 100644 --- a/internal/execution/runner.go +++ b/internal/execution/runner.go @@ -90,6 +90,11 @@ func (runner *Runner) ExecuteCaptured(ctx context.Context, input CapturedRequest prepared.Command.Stdout = stdout prepared.Command.Stderr = stderr runErr := prepared.Command.Run() + // Observed HERE, from the only thing that knows: exec.Cmd sets Process only + // once os.StartProcess has succeeded, so this is false for a missing + // executable and for a context cancelled before Start, and true for anything + // that ran, including a later timeout or cancellation. + launched := prepared.Command.Process != nil report, reportErr := AdapterReport{}, error(nil) if prepared.Report != nil { report, reportErr = prepared.Report() @@ -101,6 +106,7 @@ func (runner *Runner) ExecuteCaptured(ctx context.Context, input CapturedRequest Err: runErr, Outcome: Outcome{ Enforcement: prepared.Enforcement, + Launched: launched, }, } exitCode := commandExitCode(runErr) diff --git a/internal/plugins/enforcement_notice_test.go b/internal/plugins/enforcement_notice_test.go index 6fbc1d134..327016420 100644 --- a/internal/plugins/enforcement_notice_test.go +++ b/internal/plugins/enforcement_notice_test.go @@ -112,24 +112,53 @@ func TestAPluginToolCarriesTheNoticeWhenItTimesOutOrIsCancelled(t *testing.T) { } // But a child that never launched must stay silent, or the notice describes a -// trade nobody made. This is the distinction the launched-or-not check exists -// for, and without it the assertion above would be satisfied by pasting the -// notice onto every error. -func TestAPluginThatNeverLaunchedCarriesNoNotice(t *testing.T) { - for _, kind := range []execution.OutcomeKind{ - execution.OutcomeSandboxSetupFailure, - execution.OutcomeExecutableNotFound, - } { - if (execution.Outcome{Kind: kind}).ChildLaunched() { - t.Errorf("%v is treated as a launched child; a notice there would describe a process that never started", kind) - } +// trade nobody made. +// +// KEYED ON THE RECORDED FACT, NOT ON THE OUTCOME KIND. Reading the kind as a +// launch-state field is wrong in both directions: a child that ran and then +// produced an unreadable adapter report is rewritten to a setup failure, so the +// disclosure is dropped although it applied, and a context cancelled before +// os.StartProcess yields a cancellation, so the disclosure is claimed for a +// process that never existed. This is why the earlier version of this test, +// which asserted that the kind decides, was encoding the defect. +func TestAPluginNoticeFollowsRecordedLaunchState(t *testing.T) { + const notice = "denyRead is configured, so the write jail is not confining writes" + enforcement := execution.Enforcement{Notices: []string{notice}} + + // The two directions the kind gets wrong, spelled out. + ranThenReportFailed := execution.Outcome{ + Kind: execution.OutcomeSandboxSetupFailure, + Launched: true, + Enforcement: enforcement, + } + if got := ranThenReportFailed.AppliedEnforcementNotices(); len(got) != 1 { + t.Errorf("a child that ran lost its disclosure because the report failed afterwards: %#v", got) + } + + cancelledBeforeStart := execution.Outcome{ + Kind: execution.OutcomeCancelled, + Launched: false, + Enforcement: enforcement, } - for _, kind := range []execution.OutcomeKind{ - execution.OutcomeTimedOut, - execution.OutcomeCancelled, + if got := cancelledBeforeStart.AppliedEnforcementNotices(); len(got) != 0 { + t.Errorf("a process that never started claimed an enforcement trade: %#v", got) + } + + // And the ordinary pairs still behave. + for _, testCase := range []struct { + name string + outcome execution.Outcome + discloses bool + }{ + {"launched and succeeded", execution.Outcome{Kind: execution.OutcomeSuccess, Launched: true, Enforcement: enforcement}, true}, + {"launched then timed out", execution.Outcome{Kind: execution.OutcomeTimedOut, Launched: true, Enforcement: enforcement}, true}, + {"never launched, missing executable", execution.Outcome{Kind: execution.OutcomeExecutableNotFound, Launched: false, Enforcement: enforcement}, false}, + {"never launched, setup failed", execution.Outcome{Kind: execution.OutcomeSandboxSetupFailure, Launched: false, Enforcement: enforcement}, false}, } { - if !(execution.Outcome{Kind: kind}).ChildLaunched() { - t.Errorf("%v is treated as never launched, so its disclosure would be dropped", kind) - } + t.Run(testCase.name, func(t *testing.T) { + if got := len(testCase.outcome.AppliedEnforcementNotices()) > 0; got != testCase.discloses { + t.Errorf("discloses = %v, want %v", got, testCase.discloses) + } + }) } } From e5f060dabd20ee020c8498812c07acc334b35b93 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Fri, 28 Aug 2026 00:00:11 +0530 Subject: [PATCH 18/43] fix(mcp): carry the launch disclosure through an initialize failure connectStdio records the notices once cmd.Start returns, which is the right moment, but the initialize failure path closes and discards the client. The client was the only carrier, so a server that started, did filesystem work and then failed its handshake told the operator it was unavailable and never that it had already run without the write jail. A launched process is a fact about the past: once Start has succeeded the disclosure is true whatever the handshake does next. The failure now carries it out, and registration recovers it from the error, so the fact no longer dies with the connection it was attached to. A connect that never launched still discloses nothing. --- internal/mcp/client.go | 34 +++++++++++- internal/mcp/registry.go | 6 ++- internal/mcp/startup_disclosure_race_test.go | 55 ++++++++++++++++++++ 3 files changed, 91 insertions(+), 4 deletions(-) diff --git a/internal/mcp/client.go b/internal/mcp/client.go index 348440ce4..36b43d11d 100644 --- a/internal/mcp/client.go +++ b/internal/mcp/client.go @@ -43,6 +43,29 @@ type ToolClient interface { Close() error } +// startupDisclosureError carries a launch disclosure out through a failure. +// +// A launched process is a fact about the past: once Start has succeeded the +// disclosure is true whatever the handshake does next. The client is the only +// thing that holds it, and the failure paths close and discard the client, so +// without this the fact dies with the connection it was attached to. +type startupDisclosureError struct { + err error + notices []string +} + +func (e *startupDisclosureError) Error() string { return e.err.Error() } +func (e *startupDisclosureError) Unwrap() error { return e.err } + +// startupNoticesFromError recovers a launch disclosure from a failed connect. +func startupNoticesFromError(err error) []string { + var disclosure *startupDisclosureError + if errors.As(err, &disclosure) { + return disclosure.notices + } + return nil +} + // startupDisclosing is the optional interface a client implements when its // LAUNCH carried a least-privilege disclosure. A network server launches no // local process, so it does not implement this and reports nothing, which is the @@ -225,12 +248,19 @@ func connectStdio(ctx context.Context, server Server, options ConnectOptions) (* } cleanupTransferred = true if err := client.initialize(ctx); err != nil { + // THE LAUNCH ALREADY HAPPENED, so the fact has to leave through the error. + // Start succeeded above, which means the process ran under the planned token + // and may have done filesystem work before the handshake failed. Returning a + // bare error discards the client, and with it the only carrier the notices + // had, so the operator was told the server was unavailable and not that it + // had already run without the write jail. _ = client.Close() message := strings.TrimSpace(stderr.String()) + failure := fmt.Errorf("initialize MCP server %s: %w", server.Name, err) if message != "" { - return nil, fmt.Errorf("initialize MCP server %s: %w: %s", server.Name, err, message) + failure = fmt.Errorf("initialize MCP server %s: %w: %s", server.Name, err, message) } - return nil, fmt.Errorf("initialize MCP server %s: %w", server.Name, err) + return nil, &startupDisclosureError{err: failure, notices: client.StartupNotices()} } return client, nil } diff --git a/internal/mcp/registry.go b/internal/mcp/registry.go index 47b9e0b7a..a382cc0e5 100644 --- a/internal/mcp/registry.go +++ b/internal/mcp/registry.go @@ -228,8 +228,10 @@ func RegisterTools(ctx context.Context, registry *tools.Registry, cfg config.MCP func connectAndList(ctx context.Context, factory func(context.Context, Server) (ToolClient, error), server Server) (ToolClient, []RemoteTool, []string, error) { client, err := factory(ctx, server) if err != nil { - // Nothing launched, so there is nothing to disclose. - return nil, nil, nil, err + // A failure BEFORE the process started discloses nothing. A failure after it + // started carries the fact out through the error, because the client that + // held it has already been closed and discarded by then. + return nil, nil, startupNoticesFromError(err), err } notices := startupNoticesOf(client) remoteTools, err := client.ListTools(ctx) diff --git a/internal/mcp/startup_disclosure_race_test.go b/internal/mcp/startup_disclosure_race_test.go index b15ff9e01..8fe5d9af3 100644 --- a/internal/mcp/startup_disclosure_race_test.go +++ b/internal/mcp/startup_disclosure_race_test.go @@ -136,3 +136,58 @@ func TestAFactoryFailureDisclosesNothing(t *testing.T) { t.Errorf("StartupDisclosures() = %#v, want none for a process that never started", disclosures) } } + +// A launched process that fails its HANDSHAKE keeps its disclosure too. +// +// connectStdio records the notices once cmd.Start returns, which is the right +// moment, but the initialize failure path closes and discards the client. The +// client was the only carrier, so the fact died with the connection unless the +// failure carries it out itself. +func TestADisclosureSurvivesAnInitializeFailure(t *testing.T) { + const notice = "denyRead is configured, so the write jail is not confining writes" + registry := tools.NewRegistry() + runtime, err := RegisterTools(context.Background(), registry, config.MCPConfig{Servers: map[string]config.MCPServerConfig{ + "docs": {Type: "stdio", Command: "docs-mcp"}, + }}, RegisterOptions{ + ClientFactory: func(context.Context, Server) (ToolClient, error) { + // What connectStdio does once Start has succeeded and the handshake + // then fails: the client is gone, the fact rides the error. + return nil, &startupDisclosureError{ + err: fmt.Errorf("initialize MCP server docs: handshake timed out"), + notices: []string{notice}, + } + }, + }) + if err != nil { + t.Fatalf("RegisterTools() error = %v", err) + } + defer runtime.Close() + + if skipped := runtime.Skipped(); len(skipped) != 1 { + t.Fatalf("Skipped() = %#v, want the failure recorded", skipped) + } + disclosures := runtime.StartupDisclosures() + if len(disclosures) != 1 || len(disclosures[0].Notices) != 1 || disclosures[0].Notices[0] != notice { + t.Fatalf("StartupDisclosures() = %#v, want the launch disclosure kept through the handshake failure", disclosures) + } +} + +// And a plain failure with no launch behind it still discloses nothing, so the +// error path is not just attaching notices to everything. +func TestAPlainConnectFailureDisclosesNothing(t *testing.T) { + registry := tools.NewRegistry() + runtime, err := RegisterTools(context.Background(), registry, config.MCPConfig{Servers: map[string]config.MCPServerConfig{ + "docs": {Type: "stdio", Command: "docs-mcp"}, + }}, RegisterOptions{ + ClientFactory: func(context.Context, Server) (ToolClient, error) { + return nil, fmt.Errorf("could not start the process") + }, + }) + if err != nil { + t.Fatalf("RegisterTools() error = %v", err) + } + defer runtime.Close() + if disclosures := runtime.StartupDisclosures(); len(disclosures) != 0 { + t.Errorf("StartupDisclosures() = %#v, want none", disclosures) + } +} From 3e01a1fe24a9084a15d4317fdb8711d3f8b296bd Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Fri, 28 Aug 2026 00:28:21 +0530 Subject: [PATCH 19/43] fix(tools): derive command notices from applied execution state, not the plan addSandboxMeta writes the plan's notices at plan time, before anything runs, and finalizeToolOutcome promoted them into the user-visible disclosure unconditionally. That claims a token trade for a command that may never have started, which is the same substitution the hooks and plugins paths already stopped making. The promotion now comes from the execution outcome when there is one, so it follows the recorded launch state and the planned notices together. The plan metadata is untouched and stays as diagnostics, since what was intended is still worth having in the record, and a tool with no execution outcome still promotes from metadata rather than silently losing its disclosure. execExecutionOutcome states that its outcomes describe a started process rather than leaving it to be inferred: a command that could not be started returns an error result before reaching it. --- internal/tools/applied_notice_test.go | 76 +++++++++++++++++++++++++++ internal/tools/exec_command.go | 16 ++++-- internal/tools/tool_outcome.go | 12 ++++- 3 files changed, 98 insertions(+), 6 deletions(-) create mode 100644 internal/tools/applied_notice_test.go diff --git a/internal/tools/applied_notice_test.go b/internal/tools/applied_notice_test.go new file mode 100644 index 000000000..1ab84c6c1 --- /dev/null +++ b/internal/tools/applied_notice_test.go @@ -0,0 +1,76 @@ +package tools + +import ( + "strings" + "testing" + + "github.com/Gitlawb/zero/internal/execution" +) + +const appliedNotice = "denyRead is configured, so the write jail is not confining writes" + +// THE PLAN IS NOT THE APPLICATION. +// +// addSandboxMeta writes the plan's notices at plan time, before anything runs, +// so promoting them into the user-visible disclosure unconditionally claims a +// token trade for a command that may never have started. The execution outcome +// is the thing that knows whether a process existed, and it applies the same +// launched-and-planned rule hooks and plugins use. +func TestCommandNoticesFollowAppliedExecutionState(t *testing.T) { + planned := map[string]string{sandboxNoticesMeta: appliedNotice} + + t.Run("launched", func(t *testing.T) { + outcome := execution.Outcome{ + Kind: execution.OutcomeSuccess, + Launched: true, + Enforcement: execution.Enforcement{Notices: []string{appliedNotice}}, + } + got := finalizeToolOutcome(Result{ + Status: StatusOK, Output: "ran", Meta: planned, ExecutionOutcome: &outcome, + }, "ran") + if len(got.EnforcementNotices) != 1 { + t.Fatalf("a launched command lost its disclosure: %#v", got.EnforcementNotices) + } + if !strings.Contains(got.ModelOutput(), appliedNotice) { + t.Errorf("the model view does not carry it: %q", got.ModelOutput()) + } + }) + + t.Run("never launched", func(t *testing.T) { + outcome := execution.Outcome{ + Kind: execution.OutcomeSandboxSetupFailure, + Launched: false, + Enforcement: execution.Enforcement{Notices: []string{appliedNotice}}, + } + got := finalizeToolOutcome(Result{ + Status: StatusError, Output: "could not start", Meta: planned, ExecutionOutcome: &outcome, + }, "could not start") + if len(got.EnforcementNotices) != 0 { + t.Fatalf("a command that never started claimed a token trade: %#v", got.EnforcementNotices) + } + if strings.Contains(got.ModelOutput(), appliedNotice) { + t.Errorf("the model view claims it anyway: %q", got.ModelOutput()) + } + }) + + // The plan metadata is diagnostics and stays put either way, so the record of + // what was intended is not lost with the claim about what happened. + t.Run("metadata survives", func(t *testing.T) { + outcome := execution.Outcome{Kind: execution.OutcomeSandboxSetupFailure, Launched: false} + got := finalizeToolOutcome(Result{ + Status: StatusError, Output: "x", Meta: planned, ExecutionOutcome: &outcome, + }, "x") + if got.Meta[sandboxNoticesMeta] != appliedNotice { + t.Errorf("the planned notice was erased from diagnostics: %q", got.Meta[sandboxNoticesMeta]) + } + }) + + // A tool with no execution outcome at all still promotes from metadata, so + // this did not silently drop disclosure for a path that has no outcome. + t.Run("no execution outcome", func(t *testing.T) { + got := finalizeToolOutcome(Result{Status: StatusOK, Output: "x", Meta: planned}, "x") + if len(got.EnforcementNotices) != 1 { + t.Errorf("a tool without an execution outcome lost its disclosure: %#v", got.EnforcementNotices) + } + }) +} diff --git a/internal/tools/exec_command.go b/internal/tools/exec_command.go index bda97f0ec..52f12f5b6 100644 --- a/internal/tools/exec_command.go +++ b/internal/tools/exec_command.go @@ -526,29 +526,35 @@ func execExecutionRequest(command *exec.Cmd, plan zeroSandbox.CommandPlan, cwd s func execExecutionOutcome(input execToolResultInput) execution.Outcome { enforcement := input.enforcement + // EVERY OUTCOME BUILT HERE DESCRIBES A PROCESS THAT STARTED. A command that + // could not be started returns an error result before this point, so there is + // no path in without a process behind it. Stated rather than inferred, so the + // disclosure derived from it does not rest on the terminal outcome kind. + const launched = true if !input.exited { return execution.Outcome{ State: execution.StateRetained, Kind: execution.OutcomeRunning, + Launched: launched, ProcessID: strconv.Itoa(input.sessionID), Enforcement: enforcement, } } exit := &execution.Exit{Code: input.exitCode} if input.reportErr != nil { - return execution.Outcome{State: execution.StateFailed, Kind: execution.OutcomeSandboxSetupFailure, Exit: exit, Enforcement: enforcement, Changes: input.changes} + return execution.Outcome{State: execution.StateFailed, Kind: execution.OutcomeSandboxSetupFailure, Launched: launched, Exit: exit, Enforcement: enforcement, Changes: input.changes} } if input.report.Denial != nil { denial := *input.report.Denial - return execution.Outcome{State: execution.StateDenied, Kind: execution.OutcomeEnforcementDenied, Exit: exit, Denial: &denial, Enforcement: enforcement, Changes: input.changes} + return execution.Outcome{State: execution.StateDenied, Kind: execution.OutcomeEnforcementDenied, Launched: launched, Exit: exit, Denial: &denial, Enforcement: enforcement, Changes: input.changes} } if input.interrupted { - return execution.Outcome{State: execution.StateCancelled, Kind: execution.OutcomeCancelled, Exit: exit, Enforcement: enforcement, Changes: input.changes} + return execution.Outcome{State: execution.StateCancelled, Kind: execution.OutcomeCancelled, Launched: launched, Exit: exit, Enforcement: enforcement, Changes: input.changes} } if input.exitCode == 0 { - return execution.Outcome{State: execution.StateCompleted, Kind: execution.OutcomeSuccess, Exit: exit, Enforcement: enforcement, Changes: input.changes} + return execution.Outcome{State: execution.StateCompleted, Kind: execution.OutcomeSuccess, Launched: launched, Exit: exit, Enforcement: enforcement, Changes: input.changes} } - return execution.Outcome{State: execution.StateFailed, Kind: execution.OutcomeApplicationFailure, Exit: exit, Enforcement: enforcement, Changes: input.changes} + return execution.Outcome{State: execution.StateFailed, Kind: execution.OutcomeApplicationFailure, Launched: launched, Exit: exit, Enforcement: enforcement, Changes: input.changes} } func executionChangedFiles(changes []execution.Change) []string { diff --git a/internal/tools/tool_outcome.go b/internal/tools/tool_outcome.go index f92868796..774d5519a 100644 --- a/internal/tools/tool_outcome.go +++ b/internal/tools/tool_outcome.go @@ -54,7 +54,17 @@ func finalizeToolOutcome(result Result, boundaryOutput string) Result { // of the same fact, which is exactly how the disclosure went missing from the // generic execution adapter in the first place. if len(result.EnforcementNotices) == 0 { - if notices := strings.TrimSpace(result.Meta[sandboxNoticesMeta]); notices != "" { + // DERIVED FROM APPLIED STATE, NOT FROM THE PLAN. addSandboxMeta writes the + // plan's notices at plan time, before anything runs, so promoting them + // unconditionally claims a token trade for a command that may never have + // started. The execution outcome is the thing that knows, and it applies + // the same launched-and-planned rule hooks and plugins use. + // + // The metadata stays as diagnostics either way: it records what was + // planned, which is still worth having. + if result.ExecutionOutcome != nil { + result.EnforcementNotices = result.ExecutionOutcome.AppliedEnforcementNotices() + } else if notices := strings.TrimSpace(result.Meta[sandboxNoticesMeta]); notices != "" { result.EnforcementNotices = strings.Split(notices, "\n") } } From 5e338c418b49f3bfde37eca397408b16d4dd48ca Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Fri, 28 Aug 2026 01:29:36 +0530 Subject: [PATCH 20/43] fix(sandbox): gate the DenyRead diagnostic on the resolved plan BackendPlan derived the DenyRead warning from request.Backend and the requested profile. request.Backend is always the AVAILABLE backend, so on a Windows host it stays the restricted-token backend with NativeIsolation set even when the resolution disables sandboxing outright. With deny_read configured and --sandbox forbid, the plan resolves to enforcement disabled and target none, builds no token and enforces no read rule, and `zero sandbox policy` still reported that the write jail had been traded for read denial. The reassuring half was the false one: it claimed reads were denied as requested on a run that denies nothing. The execution path already keyed this on the resolved plan through windowsRestrictedTokenWillRun. Split the request-side half of that predicate into willBuildWindowsRestrictedToken and reuse it for the diagnostic, so both describe the plan that will run rather than the backend that happens to be installed. plan.Wrapped stays with the execution caller rather than moving into the shared predicate. It is the produced execution state and the request cannot speak for it, so folding it in would buy symmetry by handing the execution path back a bug the request-side checks alone cannot catch. A test pins that split. --- internal/sandbox/manager.go | 22 +++- internal/sandbox/runner.go | 24 +++- .../windows_deny_read_diagnostic_test.go | 107 ++++++++++++++++++ 3 files changed, 151 insertions(+), 2 deletions(-) create mode 100644 internal/sandbox/windows_deny_read_diagnostic_test.go diff --git a/internal/sandbox/manager.go b/internal/sandbox/manager.go index c7c2013f5..70dcf3294 100644 --- a/internal/sandbox/manager.go +++ b/internal/sandbox/manager.go @@ -331,7 +331,7 @@ func (request SandboxExecutionRequest) BackendPlan(policy Policy) BackendPlan { RequiresPlatformSandbox: request.RequiresPlatformSandbox, Capabilities: request.Backend.Capabilities(policy), Restrictions: request.Backend.restrictions(policy), - Warnings: append(request.Backend.Warnings(), windowsDenyReadWarnings(request.Backend, request.PermissionProfile)...), + Warnings: append(request.Backend.Warnings(), request.denyReadDiagnosticWarnings()...), } } @@ -381,6 +381,26 @@ func windowsDenyReadWarnings(backend Backend, profile PermissionProfile) []strin } } +// denyReadDiagnosticWarnings is the diagnostic half of the DenyRead disclosure, +// gated on the plan this request resolves to rather than on the backend that +// happens to be installed. +// +// request.Backend is always the AVAILABLE backend, so on a Windows host it stays +// the restricted-token backend with NativeIsolation set even when nothing will be +// sandboxed. Keying the warning off it meant --sandbox forbid with deny_read +// configured reported a token trade on a plan whose enforcement is disabled and +// whose target is none. +// +// CommandWrapped is the forward reading of "this plan will be wrapped", which is +// what BuildExecutionRequest sets it to; there is no CommandPlan on this path to +// read plan.Wrapped from. +func (request SandboxExecutionRequest) denyReadDiagnosticWarnings() []string { + if !request.CommandWrapped || !request.willBuildWindowsRestrictedToken() { + return nil + } + return windowsDenyReadWarnings(request.Backend, request.PermissionProfile) +} + func permissionProfileUnset(profile PermissionProfile) bool { return profile.FileSystem.Kind == "" && profile.Network.Mode == "" } diff --git a/internal/sandbox/runner.go b/internal/sandbox/runner.go index cabd9b1fe..c6d260c39 100644 --- a/internal/sandbox/runner.go +++ b/internal/sandbox/runner.go @@ -1251,7 +1251,29 @@ func windowsRestrictedTokenWillRun(plan CommandPlan, request SandboxExecutionReq // plan.Wrapped is the resulting execution state and cannot be read backwards: // directCommandPlan sets it false, the restricted-token plan sets it true, and // both arrive here through the same funnel. - if !plan.Wrapped || !request.RequiresPlatformSandbox { + if !plan.Wrapped { + return false + } + return request.willBuildWindowsRestrictedToken() +} + +// willBuildWindowsRestrictedToken reports whether the RESOLVED PLAN creates the +// restricted token, from the request alone. +// +// Split out because the diagnostics had no such gate. BackendPlan asked only +// about the available backend and the requested profile, so `zero sandbox policy` +// and `zero sandbox check` described a token trade on a plan that builds no +// token: with deny_read configured and --sandbox forbid, enforcement resolves to +// disabled and the target to none, and the warning still claimed "reads are +// denied as requested". Nothing was denied and no token existed, so the half that +// reassures was the false one. +// +// plan.Wrapped stays with the caller above rather than moving in here. It is the +// produced execution state and the request cannot speak for it, so folding it in +// would let an unwrapped plan through on the execution path to buy symmetry with +// the diagnostic one. +func (request SandboxExecutionRequest) willBuildWindowsRestrictedToken() bool { + if !request.RequiresPlatformSandbox { return false } if request.EnforcementLevel == EnforcementDisabled || request.EnforcementLevel == EnforcementDegraded { diff --git a/internal/sandbox/windows_deny_read_diagnostic_test.go b/internal/sandbox/windows_deny_read_diagnostic_test.go new file mode 100644 index 000000000..283441e65 --- /dev/null +++ b/internal/sandbox/windows_deny_read_diagnostic_test.go @@ -0,0 +1,107 @@ +package sandbox + +import ( + "strings" + "testing" +) + +// diagnosticWarnings renders what `zero sandbox policy` and `zero sandbox check` +// show, through the manager rather than by hand, so the request carries the state +// the resolution actually produces. +func diagnosticWarnings(t *testing.T, mode PolicyMode, denyRead []string, preference SandboxPreference) (SandboxExecutionRequest, []string) { + t.Helper() + workspace := t.TempDir() + backend := windowsRestrictedTokenBackend() + backend.CommandWrapping = true + backend.Executable = `C:\Windows\System32\cmd.exe` + manager := NewSandboxManager(SandboxManagerOptions{GOOS: "windows", Backend: backend}) + policy := Policy{Mode: mode, EnforceWorkspace: true, DenyRead: denyRead} + request, err := manager.BuildExecutionRequest(SandboxManagerRequest{ + WorkspaceRoot: workspace, + Command: CommandSpec{Name: "cmd.exe", Args: []string{"/c", "echo hi"}, Dir: workspace}, + Policy: policy, + Preference: preference, + }) + if err != nil { + t.Fatalf("BuildExecutionRequest: %v", err) + } + return request, request.BackendPlan(policy).Warnings +} + +func denyReadWarning(warnings []string) string { + for _, warning := range warnings { + if strings.Contains(strings.ToLower(warning), "denyread") { + return warning + } + } + return "" +} + +// THE DIAGNOSTICS HAVE TO DESCRIBE THE RESOLVED PLAN, NOT THE INSTALLED BACKEND. +// +// request.Backend is always the AVAILABLE backend, so on a Windows host it stays +// the restricted-token backend with NativeIsolation set even when the resolution +// disables sandboxing entirely. BackendPlan keyed the warning off that field and +// the requested profile, so `--sandbox forbid` with deny_read configured resolved +// to enforcement disabled and target none, built no token, enforced no read rule, +// and still reported that the write jail had been traded for read denial. +// +// The reassuring half is the one that was false: "reads are denied as requested" +// on a run where nothing is denied at all. +func TestForbiddenSandboxDoesNotClaimTheDenyReadTokenTrade(t *testing.T) { + withWindowsHost(t) + + request, warnings := diagnosticWarnings(t, ModeEnforce, []string{`C:\Users\someone\.config\creds`}, SandboxPreferenceForbid) + + // The preconditions that make this the interesting case rather than a + // vacuous pass: deny_read really did survive into the resolved profile, and + // the plan really does build nothing. + if len(normalizeProfilePaths(request.PermissionProfile.FileSystem.DenyRead)) == 0 { + t.Fatal("deny_read did not reach the resolved profile, so this no longer exercises the diagnostic gate") + } + if request.EnforcementLevel != EnforcementDisabled || request.TargetBackend != BackendNone { + t.Fatalf("expected a forbidden plan to resolve to disabled/none, got level %s target %s", request.EnforcementLevel, request.TargetBackend) + } + + if warning := denyReadWarning(warnings); warning != "" { + t.Errorf("a forbidden sandbox claimed the deny_read token trade on a plan that builds no token and denies no read: %q", warning) + } +} + +// And the warning still fires where the token is real, or the fix above is +// satisfied by never warning at all. +func TestDiagnosticsStillDiscloseTheTradeWhereTheTokenRuns(t *testing.T) { + withWindowsHost(t) + + request, warnings := diagnosticWarnings(t, ModeEnforce, []string{`C:\Users\someone\.config\creds`}, SandboxPreferenceAuto) + if !request.CommandWrapped || request.TargetBackend != BackendWindowsRestrictedToken { + t.Skipf("this environment produced no wrapped Windows plan (target %s, level %s)", request.TargetBackend, request.EnforcementLevel) + } + if denyReadWarning(warnings) == "" { + t.Fatalf("a plan that does build the restricted token disclosed nothing: %v", warnings) + } +} + +// THE EXECUTION-PATH GUARD MUST SURVIVE THE SHARED PREDICATE. +// +// willBuildWindowsRestrictedToken was split out of windowsRestrictedTokenWillRun +// so the diagnostics could reuse it. plan.Wrapped deliberately stayed behind with +// the caller, because the request cannot speak for the produced plan. Folding it +// in would buy symmetry and hand the execution path back the bug the request-side +// checks alone cannot catch. +func TestSharedPredicateDidNotSwallowTheWrappedPlanGuard(t *testing.T) { + withWindowsHost(t) + + request := wrappedWindowsRequest() + if !request.willBuildWindowsRestrictedToken() { + t.Fatal("the request half of the predicate rejected a request that does build the token") + } + // Same request, unwrapped plan. The request half says yes; only plan.Wrapped + // says no, so this is exactly the guard that must not have moved. + if windowsRestrictedTokenWillRun(CommandPlan{Wrapped: false}, request) { + t.Error("an unwrapped plan was reported as building the restricted token") + } + if !windowsRestrictedTokenWillRun(CommandPlan{Wrapped: true}, request) { + t.Error("a wrapped plan that builds the token was reported as not building it") + } +} From 7050df8fb83bc48fb03288d073bc75dddcf7ece5 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Fri, 28 Aug 2026 01:37:00 +0530 Subject: [PATCH 21/43] fix(tui): show the enforcement disclosure in cards and after resume The notice was prepended to ModelOutput, which toolResultRowText carries into row.text, and toolCardHead is handed row.text. But the head renders the action and the target, so the notice went nowhere. Every result with a rich preview, which is every edit and write card, rendered with no disclosure at all, collapsed or expanded. Resume lost it too. The session payload carried the notice only inside the "output" string, and the restored card is rebuilt from displayPreview, which never had it. Carry the notices as their own field on the row, persist and restore them alongside changedFiles, and render them above the body on all three card paths. Shown collapsed as well as expanded: a trade the operator has to expand a card to discover has not been disclosed. Kept out of row.detail deliberately. That field is parsed as a diff by the files panel and rendered line by line by the file view, so prefixing it with the notice would have been the shorter fix and would have corrupted both. A test pins the diff stats against exactly that. The render cache keys on the notices for the same reason. It distinguished them already, but only through row.text, and that incidental coupling is what hid the notice from the card to begin with. --- internal/tui/enforcement_notice_card_test.go | 116 +++++++++++++++++++ internal/tui/model.go | 24 ++-- internal/tui/render_cache.go | 4 + internal/tui/rendering.go | 29 ++++- internal/tui/session.go | 19 +-- internal/tui/transcript.go | 10 ++ 6 files changed, 180 insertions(+), 22 deletions(-) create mode 100644 internal/tui/enforcement_notice_card_test.go diff --git a/internal/tui/enforcement_notice_card_test.go b/internal/tui/enforcement_notice_card_test.go new file mode 100644 index 000000000..adfdd71d6 --- /dev/null +++ b/internal/tui/enforcement_notice_card_test.go @@ -0,0 +1,116 @@ +package tui + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/Gitlawb/zero/internal/agent" + "github.com/Gitlawb/zero/internal/sessions" + "github.com/Gitlawb/zero/internal/tools" +) + +const cardNotice = "denyRead is configured, so the Windows sandbox uses the token shape without WRITE_RESTRICTED (#869)" + +func resultWithPreviewAndNotice() agent.ToolResult { + return agent.ToolResult{ + ToolCallID: "call-1", + Name: "edit_file", + Status: tools.StatusOK, + Output: "Successfully edited x.go (replaced 1 occurrence).", + Display: tools.Display{Summary: "Successfully edited x.go.", Kind: "file", Preview: "--- a/x.go\n+++ b/x.go\n@@ -1 +1 @@\n-old\n+new"}, + EnforcementNotices: []string{cardNotice}, + } +} + +func renderedCard(row transcriptRow, expanded bool) string { + row.expanded = expanded + return renderToolResultCard(row, 100, rowContext{}, cardRenderOptions{bodyCap: 20}) +} + +func rowForResult(result agent.ToolResult) transcriptRow { + return transcriptRow{ + kind: rowToolResult, id: "r1", tool: result.Name, status: result.Status, + text: toolResultRowText(result), detail: toolResultDetail(result), + enforcementNotices: result.EnforcementNotices, + } +} + +// THE DISCLOSURE HAS TO REACH THE CARD, NOT JUST THE ROW TEXT. +// +// The notice is prepended to ModelOutput, which toolResultRowText carries into +// row.text, and toolCardHead is handed row.text. But the head renders the action +// and target, so the notice went nowhere. Every result with a rich preview (each +// edit and write card) rendered with no disclosure at all. +// +// Collapsed as well as expanded: a trade the operator has to expand a card to +// discover has not been disclosed. +func TestToolCardShowsTheEnforcementDisclosure(t *testing.T) { + row := rowForResult(resultWithPreviewAndNotice()) + + for _, expanded := range []bool{false, true} { + card := renderedCard(row, expanded) + if !strings.Contains(card, "WRITE_RESTRICTED") { + t.Errorf("expanded=%v: the card rendered no enforcement disclosure:\n%s", expanded, card) + } + } +} + +// THE DISCLOSURE IS DATA, NOT PROSE GLUED ONTO THE DIFF. +// +// row.detail is parsed as a diff by the files panel (planDiffStat, +// perFileDiffStats) and rendered line by line by the file view. Prefixing it +// with the notice would have been the shorter fix and would have corrupted both, +// so the notice travels in its own field and the diff stays a diff. +func TestTheDisclosureDoesNotContaminateTheDiffDetail(t *testing.T) { + result := resultWithPreviewAndNotice() + row := rowForResult(result) + + if strings.Contains(row.detail, "WRITE_RESTRICTED") { + t.Fatalf("the notice leaked into the diff detail, which is parsed as a diff: %q", row.detail) + } + adds, dels := planDiffStat(row.detail) + if adds != 1 || dels != 1 { + t.Errorf("diff stats changed with the disclosure attached: +%d -%d, want +1 -1", adds, dels) + } +} + +// AND IT HAS TO SURVIVE A RESUME. +// +// The session payload carried the notice only inside the "output" string. The +// rich card is rebuilt from displayPreview, which never had it, so a restored +// transcript lost the disclosure even though the row it replaced had shown it. +func TestRestoredSessionKeepsTheEnforcementDisclosure(t *testing.T) { + encoded, err := json.Marshal(toolResultSessionPayload(resultWithPreviewAndNotice())) + if err != nil { + t.Fatalf("marshal session payload: %v", err) + } + + rows := transcriptRowsFromSessionEvents([]sessions.Event{{Type: sessions.EventToolResult, Payload: json.RawMessage(encoded)}}) + if len(rows) != 1 { + t.Fatalf("expected one restored row, got %d", len(rows)) + } + if len(rows[0].enforcementNotices) == 0 { + t.Fatal("the restored row carries no enforcement notices, so the resumed transcript lost the disclosure") + } + for _, expanded := range []bool{false, true} { + if card := renderedCard(rows[0], expanded); !strings.Contains(card, "WRITE_RESTRICTED") { + t.Errorf("expanded=%v: the restored card rendered no disclosure:\n%s", expanded, card) + } + } +} + +// A result with no notice must not grow card furniture, or every card gains a +// blank line and the disclosure stops standing out. +func TestOrdinaryResultsGainNoNoticeLines(t *testing.T) { + result := resultWithPreviewAndNotice() + result.EnforcementNotices = nil + plain := renderedCard(rowForResult(result), true) + + result.EnforcementNotices = []string{"", " "} + blank := renderedCard(rowForResult(result), true) + + if plain != blank { + t.Errorf("blank notices changed the card:\n--- none ---\n%s\n--- blank ---\n%s", plain, blank) + } +} diff --git a/internal/tui/model.go b/internal/tui/model.go index 9473de06a..6dc752d20 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -5774,16 +5774,17 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str } } row := transcriptRow{ - kind: rowToolResult, - id: effectiveToolRowID(result.ToolCallID, callSeq[result.ToolCallID]), - text: toolResultRowText(result), - tool: result.Name, - status: result.Status, - detail: toolResultDetail(result), - meta: result.Meta, - runID: runID, - changedFiles: result.ChangedFiles, - changeSummaries: result.ChangeSummaries, + kind: rowToolResult, + id: effectiveToolRowID(result.ToolCallID, callSeq[result.ToolCallID]), + text: toolResultRowText(result), + tool: result.Name, + status: result.Status, + detail: toolResultDetail(result), + meta: result.Meta, + runID: runID, + changedFiles: result.ChangedFiles, + changeSummaries: result.ChangeSummaries, + enforcementNotices: result.EnforcementNotices, } // A successful Task/TaskOutput result is represented by a specialist card. // update_plan stays in the transcript as a rendered checklist; failures @@ -6046,6 +6047,9 @@ func toolResultSessionPayload(result agent.ToolResult) map[string]any { if len(result.Meta) > 0 { payload["meta"] = result.Meta } + if len(result.EnforcementNotices) > 0 { + payload["enforcementNotices"] = result.EnforcementNotices + } if len(result.ChangedFiles) > 0 { payload["changedFiles"] = result.ChangedFiles } diff --git a/internal/tui/render_cache.go b/internal/tui/render_cache.go index 8ce433542..65e0c09bd 100644 --- a/internal/tui/render_cache.go +++ b/internal/tui/render_cache.go @@ -140,6 +140,10 @@ func (m model) renderRowCacheKey(row transcriptRow, width int, rc rowContext, op appendRenderCacheField(&b, row.tool) appendRenderCacheField(&b, fmt.Sprint(row.status)) appendRenderCacheField(&b, row.detail) + // The disclosure renders into the card, so it keys the entry. row.text + // happens to carry it too, but only because ModelOutput prepends it, and that + // coupling is what hid the notice from the card in the first place. + appendRenderCacheField(&b, strings.Join(row.enforcementNotices, "\n")) appendRenderCacheField(&b, row.arg) appendRenderCacheField(&b, strconv.Itoa(row.runID)) appendRenderCacheField(&b, strconv.FormatBool(row.expanded)) diff --git a/internal/tui/rendering.go b/internal/tui/rendering.go index e619acb41..05331ae17 100644 --- a/internal/tui/rendering.go +++ b/internal/tui/rendering.go @@ -1524,6 +1524,28 @@ func (m model) renderRunningToolCard(row transcriptRow, width int, rc rowContext return toolCard(head, glyph, nil, "", zeroTheme.cardRun, width) } +// toolCardNoticeLines renders the enforcement disclosures that belong to this +// result, in the card itself. +// +// The notice reached row.text and stopped there: toolCardHead takes row.text but +// renders the action and target, so a result with a rich preview (every edit and +// write card) displayed no disclosure at all, collapsed or expanded. It is shown +// above the body and on the collapsed paths too, because a trade the operator has +// to expand a card to discover is not disclosed. +func toolCardNoticeLines(notices []string, width int) []string { + var lines []string + for _, notice := range notices { + notice = strings.TrimSpace(notice) + if notice == "" { + continue + } + for _, wrapped := range wrapPlainText(notice, width) { + lines = append(lines, zeroTheme.amber.Render(wrapped)) + } + } + return lines +} + func renderToolResultCard(row transcriptRow, width int, rc rowContext, opts cardRenderOptions) string { name := toolRowName(row) failed := row.status == tools.StatusError @@ -1541,6 +1563,7 @@ func renderToolResultCard(row transcriptRow, width int, rc rowContext, opts card borderStyle = zeroTheme.cardErr } key := rcKey(row.runID, row.id) + noticeLines := toolCardNoticeLines(row.enforcementNotices, width) headTarget := rc.hints[key] headArg := rc.args[key] if !failed && isExploreTool(name) { @@ -1554,7 +1577,7 @@ func renderToolResultCard(row transcriptRow, width int, rc rowContext, opts card // Only for clean OK results: errors and anything multi-line keep their body. if !failed && opts.bodyCap > 0 && !toolCardAlwaysExpands(name) && looksLikeRedundantConfirmation(row.detail) { head := toolCardHead(name, headTarget, headArg, "", row.detail, row.text, false, nameStyle, rc.auto[key], width, opts) - return toolCard(head, glyph, nil, "", borderStyle, width) + return toolCard(head, glyph, noticeLines, "", borderStyle, width) } // Collapse long, noisy output (web-search/MCP/read dumps) by default so the // transcript stays scannable; the model still received the full output. Click @@ -1567,7 +1590,7 @@ func renderToolResultCard(row transcriptRow, width int, rc rowContext, opts card } if collapsedFooter != "" && !row.expanded { head := toolCardHead(name, headTarget, headArg, toolResultBudgetTag(row.meta), row.detail, row.text, false, nameStyle, rc.auto[key], width, opts) - return toolCard(head, glyph, nil, collapsedFooter, borderStyle, width) + return toolCard(head, glyph, noticeLines, collapsedFooter, borderStyle, width) } bodyOpts := opts bodyOpts.expanded = row.expanded @@ -1577,7 +1600,7 @@ func renderToolResultCard(row transcriptRow, width int, rc rowContext, opts card if collapsedFooter != "" && row.expanded && footer == "" { footer = "▾ collapse" } - return toolCard(head, glyph, body.lines, footer, borderStyle, width) + return toolCard(head, glyph, append(noticeLines, body.lines...), footer, borderStyle, width) } func joinToolHeadTags(tags ...string) string { diff --git a/internal/tui/session.go b/internal/tui/session.go index 4aec00e58..1fb7a7212 100644 --- a/internal/tui/session.go +++ b/internal/tui/session.go @@ -647,15 +647,16 @@ func transcriptRowsFromSessionEvents(events []sessions.Event) []transcriptRow { detail = output } rows = append(rows, transcriptRow{ - kind: rowToolResult, - id: effectiveToolRowID(id, callSeq[id]), - text: fmt.Sprintf("tool result: %s %s %s", name, status, truncateTUIOutput(output, tuiToolOutputLimit)), - tool: name, - status: status, - detail: detail, - meta: payloadStringMap(payload, "meta"), - changedFiles: payloadStringSlice(payload, "changedFiles"), - changeSummaries: payloadExecutionChanges(payload, "changeSummaries"), + kind: rowToolResult, + id: effectiveToolRowID(id, callSeq[id]), + text: fmt.Sprintf("tool result: %s %s %s", name, status, truncateTUIOutput(output, tuiToolOutputLimit)), + tool: name, + status: status, + detail: detail, + meta: payloadStringMap(payload, "meta"), + changedFiles: payloadStringSlice(payload, "changedFiles"), + enforcementNotices: payloadStringSlice(payload, "enforcementNotices"), + changeSummaries: payloadExecutionChanges(payload, "changeSummaries"), }) case sessions.EventError: if message := payloadString(payload, "message"); message != "" { diff --git a/internal/tui/transcript.go b/internal/tui/transcript.go index 64d657c14..8cf3fe753 100644 --- a/internal/tui/transcript.go +++ b/internal/tui/transcript.go @@ -50,6 +50,16 @@ type transcriptRow struct { changedFiles []string changeSummaries []execution.Change + // enforcementNotices are the least-privilege disclosures that were true of + // this result (from tools.Result.EnforcementNotices; restored from the + // session payload on resume). + // + // Held as its own field rather than folded into detail. detail is parsed as a + // diff by the files panel and the file view, so prefixing it with prose would + // corrupt both. It is not folded into text either: text carries the notice + // today and the card never renders it, which is the whole defect. + enforcementNotices []string + // specialistInfo holds the specialist card data for rowSpecialist rows. // Nil for all other row kinds. specialistInfo *specialistInfo From 4338cdeec77c0e92df617aedb123f0b78a23e11b Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Fri, 28 Aug 2026 10:22:58 +0530 Subject: [PATCH 22/43] fix(tools): measure the model output the enforcement notices are part of The notices are prepended to the model view on the way out, so a disclosed result costs more context than result.Output alone. The outcome diagnostics measured the bare output, which undercounts every disclosed call, and the undercount scales with the notice rather than being fixed slack. A short command output measures 13 bytes against the 114 the model is handed. Measure the canonical text instead. ModelView stays the bare output on purpose: ModelOutput prepends the notices itself, so storing them here would send them twice, and a test pins that too. --- .../enforcement_notice_measurement_test.go | 43 +++++++++++++++++++ internal/tools/tool_outcome.go | 14 +++++- 2 files changed, 55 insertions(+), 2 deletions(-) create mode 100644 internal/tools/enforcement_notice_measurement_test.go diff --git a/internal/tools/enforcement_notice_measurement_test.go b/internal/tools/enforcement_notice_measurement_test.go new file mode 100644 index 000000000..c6ba69762 --- /dev/null +++ b/internal/tools/enforcement_notice_measurement_test.go @@ -0,0 +1,43 @@ +package tools + +import "testing" + +// THE BUDGET HAS TO COUNT WHAT THE MODEL ACTUALLY RECEIVES. +// +// The enforcement notices are prepended to the model view on the way out, so a +// disclosed call costs more context than result.Output alone. Measuring the bare +// output undercounts every one of them, and the undercount grows with the notice +// rather than being a fixed slack. +func TestOutcomeMeasuresTheNoticesTheModelReceives(t *testing.T) { + const notice = "denyRead is configured, so the Windows sandbox uses the token shape without WRITE_RESTRICTED (#869)" + const output = "exit status 0" + + bare := finalizeToolOutcome(Result{Status: StatusOK, Output: output}, output) + disclosed := finalizeToolOutcome(Result{Status: StatusOK, Output: output, EnforcementNotices: []string{notice}}, output) + + if disclosed.Outcome.Diagnostics.ModelBytes <= bare.Outcome.Diagnostics.ModelBytes { + t.Errorf("a disclosed result measured %d model bytes, no more than the undisclosed %d, so the notice is uncounted", + disclosed.Outcome.Diagnostics.ModelBytes, bare.Outcome.Diagnostics.ModelBytes) + } + if want := len(WithEnforcementNotices(output, []string{notice})); disclosed.Outcome.Diagnostics.ModelBytes != want { + t.Errorf("model bytes = %d, want %d (the canonical output the model is handed)", + disclosed.Outcome.Diagnostics.ModelBytes, want) + } + if disclosed.Outcome.Diagnostics.EstimatedModelTokens <= bare.Outcome.Diagnostics.EstimatedModelTokens { + t.Errorf("estimated model tokens did not grow with the notice: %d vs %d", + disclosed.Outcome.Diagnostics.EstimatedModelTokens, bare.Outcome.Diagnostics.EstimatedModelTokens) + } +} + +// And the stored view stays bare, or the notices ship twice: ModelOutput +// prepends them to whatever ModelView holds. +func TestOutcomeModelViewDoesNotCarryTheNoticesItself(t *testing.T) { + const notice = "sandbox notice" + const output = "exit status 0" + + result := finalizeToolOutcome(Result{Status: StatusOK, Output: output, EnforcementNotices: []string{notice}}, output) + if result.Outcome.ModelView != output { + t.Errorf("ModelView = %q, want the bare output %q; ModelOutput prepends the notice, so storing it here sends it twice", + result.Outcome.ModelView, output) + } +} diff --git a/internal/tools/tool_outcome.go b/internal/tools/tool_outcome.go index 774d5519a..13a4fbfcc 100644 --- a/internal/tools/tool_outcome.go +++ b/internal/tools/tool_outcome.go @@ -87,8 +87,18 @@ func finalizeToolOutcome(result Result, boundaryOutput string) Result { originalBytes = previous.Diagnostics.OriginalBytes originalTokens = previous.Diagnostics.EstimatedOriginalTokens } - modelBytes := len(result.Output) - modelTokens := estimateOutputTokens(result.Output) + // MEASURE WHAT THE MODEL ACTUALLY RECEIVES. + // + // The enforcement notices are prepended to the model view on the way out + // (agent.ToolResult.ModelOutput), so a result carrying them costs more context + // than result.Output alone. Measuring the bare output undercounts every + // disclosed call and hands the budget a figure the model never saw. + // + // ModelView below stays the bare output on purpose: ModelOutput prepends the + // notices itself, so storing them here would send them twice. + canonicalModelOutput := WithEnforcementNotices(result.Output, result.EnforcementNotices) + modelBytes := len(canonicalModelOutput) + modelTokens := estimateOutputTokens(canonicalModelOutput) var artifact *ToolArtifact if previous.Finalized() { From 3d4379509d69bb2115131aa0c95bf12c6d102a25 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Fri, 28 Aug 2026 10:23:02 +0530 Subject: [PATCH 23/43] test(sandbox): fail when the current-user SID prerequisite cannot be read currentUserSIDForTest swallowed the GetTokenUser error and returned empty, and its one caller guarded on the result being non-empty. On a machine where the call fails, the assertion ran against nothing and the test reported a pass. The check exists to catch the restricted token keying itself to the very SID it has to be stricter than, which is the whole point of the shape, so failing to read the prerequisite is a failure rather than a silent skip. Confirmed both directions with a simulated unreadable SID: the old shape passes, this one fails naming what it could not read. --- internal/sandbox/windows_token_windows_test.go | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/internal/sandbox/windows_token_windows_test.go b/internal/sandbox/windows_token_windows_test.go index b84c6ba96..3ac0e5efe 100644 --- a/internal/sandbox/windows_token_windows_test.go +++ b/internal/sandbox/windows_token_windows_test.go @@ -113,7 +113,7 @@ func TestRestrictedSIDListNeverCarriesABroadGroup(t *testing.T) { } // The user's own SID is the boundary this token exists to be stricter // than, so it must never be its own key. - if user := currentUserSIDForTest(t); user != "" && containsSID(values, user) { + if user := currentUserSIDForTest(t); containsSID(values, user) { t.Errorf("writeRestricted=%v: the current user SID is a restricting SID, which defeats the token entirely", writeRestricted) } } @@ -165,12 +165,23 @@ func TestNonWriteRestrictedTokenStillCarriesTheWorldSID(t *testing.T) { t.Log("known gap (#869): the DenyRead token shape carries the World SID, so its write jail does not hold") } +// currentUserSIDForTest FAILS rather than returning empty. +// +// It used to swallow the error, and its one caller guarded on the result being +// non-empty, so a machine where GetTokenUser fails ran the assertion on nothing +// and reported a pass. The check exists to catch the token keying itself to the +// very SID it must be stricter than, which is the whole point of the shape, so +// not being able to read the prerequisite is a failure and not a skip. func currentUserSIDForTest(t *testing.T) string { t.Helper() token := windows.GetCurrentProcessToken() user, err := token.GetTokenUser() if err != nil { - return "" + t.Fatalf("read the current user SID, which this assertion depends on: %v", err) + } + sid := user.User.Sid.String() + if strings.TrimSpace(sid) == "" { + t.Fatal("the current user SID came back empty, so the assertion below would check nothing") } - return user.User.Sid.String() + return sid } From 5e90e04b14d779ffc5be607f42cc3cef57bd44d8 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Fri, 28 Aug 2026 10:27:52 +0530 Subject: [PATCH 24/43] fix(mcp): keep the launch disclosure when registration times out A stdio server that started and then hung in initialize or tools/list was abandoned at the connect timeout and recorded as skipped, with nothing said about the confinement its process ran under. The notices left connectStdio only on the returned client or the returned error, and an abandoned attempt produces neither before the serial commit phase is over. The reaper that collects it later runs after that phase, so it cannot contribute without breaking the deterministic ordering the phase exists to provide. Publish the launch fact at Start instead, on a sink carried in the context, and read it in the timeout branch. Launch and connection usability are separate facts with separate lifetimes, which is the distinction that was missing. Carried on the context rather than in the factory signature so an injected or third-party factory that knows nothing about it still works and simply discloses nothing. A timeout before Start stays silent, since the sink is only ever published to after Start returns. That placement is load-bearing, so it is pinned by a test that drives the real connectStdio with an executable that cannot start: moving the call one line up leaves the registry-level tests green while every failed launch begins claiming the trade. --- internal/mcp/client.go | 7 ++ internal/mcp/launch_sink.go | 64 +++++++++++ .../mcp/launch_timeout_disclosure_test.go | 102 ++++++++++++++++++ internal/mcp/registry.go | 21 +++- 4 files changed, 191 insertions(+), 3 deletions(-) create mode 100644 internal/mcp/launch_sink.go create mode 100644 internal/mcp/launch_timeout_disclosure_test.go diff --git a/internal/mcp/client.go b/internal/mcp/client.go index 36b43d11d..4c15b5dac 100644 --- a/internal/mcp/client.go +++ b/internal/mcp/client.go @@ -232,6 +232,13 @@ func connectStdio(ctx context.Context, server Server, options ConnectOptions) (* return nil, fmt.Errorf("start MCP server %s: %w", server.Name, err) } + // PUBLISHED AT START, not at return. Registration abandons a server that + // exceeds the connect timeout, and everything below this line (initialize, and + // tools/list above it in the caller) can hang past that. Announcing the launch + // here is what lets an abandoned attempt still disclose the confinement its + // process ran under. + publishLaunch(ctx, plannedEnforcement.Notices) + client := &Client{ server: server, cmd: cmd, diff --git a/internal/mcp/launch_sink.go b/internal/mcp/launch_sink.go new file mode 100644 index 000000000..00bd06e3b --- /dev/null +++ b/internal/mcp/launch_sink.go @@ -0,0 +1,64 @@ +package mcp + +import ( + "context" + "sync" +) + +// launchSink carries the fact that a server's PROCESS STARTED out of the connect +// attempt, without waiting for that attempt to finish. +// +// The startup notices used to leave connectStdio only on the returned client, or +// on the returned error. Both require the attempt to return. Registration +// abandons a server that exceeds the connect timeout, so a server that started +// under the reduced write confinement and then hung in initialize or tools/list +// was recorded as skipped with nothing said about the confinement it ran under. +// The reaper that later collects the abandoned attempt runs after the serial +// commit phase has finished, so it cannot contribute without breaking the +// deterministic ordering that phase exists to provide. +// +// Publishing at Start splits the two facts apart, which is the point: launch and +// connection usability have different lifetimes. A sink that was never published +// to means Start never happened, so prepare, pipe, and Start failures stay silent +// exactly as before. +type launchSink struct { + mu sync.Mutex + launched bool + notices []string +} + +type launchSinkKey struct{} + +// withLaunchSink attaches a sink to the context handed to the client factory. +// Carried on the context rather than added to the factory signature so an +// injected or third-party factory that knows nothing about it still works, and +// simply discloses nothing. +func withLaunchSink(ctx context.Context, sink *launchSink) context.Context { + return context.WithValue(ctx, launchSinkKey{}, sink) +} + +// publishLaunch records that the process for this connect attempt has started, +// along with the enforcement notices that applied to it. Safe on a context with +// no sink, which is every caller outside registration. +func publishLaunch(ctx context.Context, notices []string) { + sink, _ := ctx.Value(launchSinkKey{}).(*launchSink) + if sink == nil { + return + } + sink.mu.Lock() + defer sink.mu.Unlock() + sink.launched = true + sink.notices = append([]string(nil), notices...) +} + +// observe reports whether Start was reached and what applied to it. Read from +// the registration goroutine while the connect goroutine may still be running, +// hence the lock. +func (sink *launchSink) observe() (bool, []string) { + if sink == nil { + return false, nil + } + sink.mu.Lock() + defer sink.mu.Unlock() + return sink.launched, append([]string(nil), sink.notices...) +} diff --git a/internal/mcp/launch_timeout_disclosure_test.go b/internal/mcp/launch_timeout_disclosure_test.go new file mode 100644 index 000000000..14dd11cf7 --- /dev/null +++ b/internal/mcp/launch_timeout_disclosure_test.go @@ -0,0 +1,102 @@ +package mcp + +import ( + "context" + "testing" + "time" + + "github.com/Gitlawb/zero/internal/config" + "github.com/Gitlawb/zero/internal/tools" +) + +const launchNotice = "denyRead is configured, so the Windows sandbox uses the token shape without WRITE_RESTRICTED (#869)" + +func registerWithFactory(t *testing.T, factory func(context.Context, Server) (ToolClient, error)) *Runtime { + t.Helper() + runtime, err := RegisterTools(context.Background(), tools.NewRegistry(), config.MCPConfig{Servers: map[string]config.MCPServerConfig{ + "slow": {Type: "stdio", Command: "slow-mcp"}, + }}, RegisterOptions{ + ConnectTimeout: 50 * time.Millisecond, + ClientFactory: factory, + }) + if err != nil { + t.Fatalf("RegisterTools error: %v", err) + } + t.Cleanup(func() { _ = runtime.Close() }) + return runtime +} + +// A SERVER THAT STARTED AND THEN HUNG STILL RAN UNDER THE REDUCED TOKEN. +// +// Registration abandons a server that exceeds the connect timeout and records it +// as skipped. The startup notices used to leave connectStdio only on the returned +// client or the returned error, and the abandoned attempt returns neither before +// the serial commit phase is over, so the process ran with reduced write +// confinement and startup said only that the server was skipped. +func TestTimeoutAfterLaunchKeepsTheStartupDisclosure(t *testing.T) { + runtime := registerWithFactory(t, func(ctx context.Context, server Server) (ToolClient, error) { + // The process started under the reduced token, then initialize hangs. + publishLaunch(ctx, []string{launchNotice}) + <-ctx.Done() + return nil, ctx.Err() + }) + + disclosures := runtime.StartupDisclosures() + if len(disclosures) == 0 { + t.Fatal("a server that started and then timed out disclosed nothing, so it ran under reduced write confinement unannounced") + } + if disclosures[0].Name != "slow" || len(disclosures[0].Notices) != 1 || disclosures[0].Notices[0] != launchNotice { + t.Errorf("StartupDisclosures() = %#v, want one entry for slow carrying the launch notice", disclosures) + } + if skipped := runtime.Skipped(); len(skipped) != 1 || skipped[0].Name != "slow" { + t.Errorf("the server should still be recorded as skipped: %#v", skipped) + } +} + +// AND A TIMEOUT BEFORE LAUNCH STAYS SILENT. +// +// Without this, retaining the disclosure on timeout could be satisfied by +// disclosing on every timeout, which would claim a token trade for a process +// that was never created. +func TestTimeoutBeforeLaunchDisclosesNothing(t *testing.T) { + runtime := registerWithFactory(t, func(ctx context.Context, server Server) (ToolClient, error) { + // Never reached Start: no publish. + <-ctx.Done() + return nil, ctx.Err() + }) + + if disclosures := runtime.StartupDisclosures(); len(disclosures) != 0 { + t.Errorf("a server that never started claimed a token trade: %#v", disclosures) + } + if skipped := runtime.Skipped(); len(skipped) != 1 { + t.Errorf("the server should still be recorded as skipped: %#v", skipped) + } +} + +// THE PUBLISH HAS TO SIT AFTER Start, AND ONLY A REAL LAUNCH PROVES IT. +// +// The two tests above inject a factory, so they exercise the registry's handling +// of the sink and say nothing about where connectStdio publishes to it. Moving +// the call one line up, above cmd.Start, leaves both of them green while every +// failed launch starts claiming the token trade. This one drives the real +// connectStdio with a command that cannot start. +func TestAFailedStartPublishesNoLaunch(t *testing.T) { + sink := &launchSink{} + ctx := withLaunchSink(context.Background(), sink) + + client, err := connectStdio(ctx, Server{ + Name: "missing", + Type: "stdio", + Command: "zero-nonexistent-mcp-binary-for-test", + }, ConnectOptions{}) + if err == nil { + if client != nil { + _ = client.Close() + } + t.Fatal("expected a nonexistent executable to fail to start") + } + + if launched, notices := sink.observe(); launched { + t.Errorf("a server whose process never started was published as launched (notices %#v)", notices) + } +} diff --git a/internal/mcp/registry.go b/internal/mcp/registry.go index a382cc0e5..18841c91c 100644 --- a/internal/mcp/registry.go +++ b/internal/mcp/registry.go @@ -136,7 +136,11 @@ func RegisterTools(ctx context.Context, registry *tools.Registry, cfg config.MCP go func(index int) { defer wg.Done() server := servers[index] - serverCtx, cancel := context.WithCancel(ctx) + // The sink hears about Start as it happens, so an attempt abandoned below + // can still report the confinement its process ran under. The connect + // result cannot supply that: it does not arrive until after this phase. + sink := &launchSink{} + serverCtx, cancel := context.WithCancel(withLaunchSink(ctx, sink)) done := make(chan connectResult, 1) go func() { client, remote, notices, err := connectAndList(serverCtx, factory, server) @@ -153,13 +157,24 @@ func RegisterTools(ctx context.Context, registry *tools.Registry, cfg config.MCP case <-time.After(timeout): cancel() // abandon the slow connect: tears down the conn/subprocess // Reap the goroutine + any partial client in the background so a - // slow server never blocks startup. + // slow server never blocks startup. The reaper cannot contribute the + // disclosure: it returns after the serial commit phase below has + // already run, which is why the launch fact is published at Start + // instead of being read off the late result here. go func() { if res := <-done; res.client != nil { _ = res.client.Close() } }() - results[index] = connectResult{err: fmt.Errorf("connect timed out after %s", timeout)} + timedOut := connectResult{err: fmt.Errorf("connect timed out after %s", timeout)} + // A server that reached Start ran under the planned enforcement even + // though its connection never became usable. One that timed out + // before Start discloses nothing, so the sink stays empty and this + // adds nothing. + if launched, notices := sink.observe(); launched { + timedOut.notices = notices + } + results[index] = timedOut } }(index) } From 6e181c90026c4d75e6ef6f05ae72288f9577670e Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Sat, 29 Aug 2026 11:47:26 +0530 Subject: [PATCH 25/43] fix(tools): carry bash's real launch state into the shared outcome execExecutionOutcome is shared between exec_command and bash, and it set Launched unconditionally. That is true for exec_command, where a start failure returns an errorResult before an execution outcome is ever built, and it is not true for bash, which hands EVERY Run error to the same conversion: a missing executable and a context cancelled before os.StartProcess both arrive here with a prepared plan and no child. So a bash command that never created a process reported the DenyRead token trade as applied. Measured before the fix: Launched=true, ChildLaunched()=true, and one applied enforcement notice for a command that did not exist. Observe it where it is known instead. exec.Cmd sets Process only once os.StartProcess has succeeded, so bash captures that at the Run boundary and threads it through withBashExecution; exec_command sets it true at its own call site, with the reason written down rather than assumed. The regressions drive the real tool rather than constructing an outcome, since the bug was exactly that the constructed shape and the real one disagreed. --- internal/tools/bash.go | 17 ++++-- internal/tools/bash_launch_state_test.go | 68 ++++++++++++++++++++++++ internal/tools/exec_command.go | 37 +++++++++---- 3 files changed, 106 insertions(+), 16 deletions(-) create mode 100644 internal/tools/bash_launch_state_test.go diff --git a/internal/tools/bash.go b/internal/tools/bash.go index 32b99f094..13c59a76f 100644 --- a/internal/tools/bash.go +++ b/internal/tools/bash.go @@ -164,6 +164,12 @@ func (tool bashTool) run(ctx context.Context, args map[string]any, engine *zeroS // A no-op when MonitorTag is empty, so the default path is unchanged. monitor := zeroSandbox.StartDenialMonitor(context.Background(), plan.MonitorTag) err = command.Run() + // OBSERVED HERE, at the only boundary that knows. exec.Cmd sets Process + // only once os.StartProcess has succeeded, so this separates a child that + // ran from a pre-start failure (missing executable, context already + // cancelled) that Run reports the same way. Every branch below hands this + // to withBashExecution rather than letting the conversion assume it. + launched := command.Process != nil exitCode := commandExitCode(err) adapterReport, reportErr := plan.ExecutionReport() meta["exit_code"] = strconv.Itoa(exitCode) @@ -180,7 +186,7 @@ func (tool bashTool) run(ctx context.Context, args map[string]any, engine *zeroS Output: fmt.Sprintf("Error: Command timed out after %dms.", timeoutMS), Meta: meta, } - return withBashExecution(result, executionRequest, plan, exitCode, adapterReport, reportErr, changeObserver.Changes(), true) + return withBashExecution(result, executionRequest, plan, exitCode, adapterReport, reportErr, launched, changeObserver.Changes(), true) } if err != nil { if exitCode < 0 { @@ -189,7 +195,7 @@ func (tool bashTool) run(ctx context.Context, args map[string]any, engine *zeroS Output: "Error executing command: " + err.Error(), Meta: meta, } - return withBashExecution(result, executionRequest, plan, exitCode, adapterReport, reportErr, changeObserver.Changes(), false) + return withBashExecution(result, executionRequest, plan, exitCode, adapterReport, reportErr, launched, changeObserver.Changes(), false) } if adapterReport.Denial != nil { markStructuredSandboxDenial(meta, *adapterReport.Denial) @@ -201,7 +207,7 @@ func (tool bashTool) run(ctx context.Context, args map[string]any, engine *zeroS Truncated: truncated, Meta: meta, } - return withBashExecution(result, executionRequest, plan, exitCode, adapterReport, reportErr, changeObserver.Changes(), false) + return withBashExecution(result, executionRequest, plan, exitCode, adapterReport, reportErr, launched, changeObserver.Changes(), false) } if adapterReport.Denial != nil { @@ -214,12 +220,13 @@ func (tool bashTool) run(ctx context.Context, args map[string]any, engine *zeroS Truncated: truncated, Meta: meta, } - return withBashExecution(result, executionRequest, plan, exitCode, adapterReport, reportErr, changeObserver.Changes(), false) + return withBashExecution(result, executionRequest, plan, exitCode, adapterReport, reportErr, launched, changeObserver.Changes(), false) } -func withBashExecution(result Result, request execution.Request, plan zeroSandbox.CommandPlan, exitCode int, report execution.AdapterReport, reportErr error, changes []execution.Change, timedOut bool) Result { +func withBashExecution(result Result, request execution.Request, plan zeroSandbox.CommandPlan, exitCode int, report execution.AdapterReport, reportErr error, launched bool, changes []execution.Change, timedOut bool) Result { input := execToolResultInput{ exited: true, + launched: launched, exitCode: exitCode, enforcement: executionEnforcement(plan), request: request, diff --git a/internal/tools/bash_launch_state_test.go b/internal/tools/bash_launch_state_test.go new file mode 100644 index 000000000..293aad6c2 --- /dev/null +++ b/internal/tools/bash_launch_state_test.go @@ -0,0 +1,68 @@ +package tools + +import ( + "context" + "strings" + "testing" +) + +// THE DISCLOSURE FOLLOWS THE PROCESS, AND BASH IS THE PATH THAT PROVES IT. +// +// execExecutionOutcome is shared between exec_command and bash. It used to set +// Launched unconditionally, which is true for exec_command because a start +// failure returns an errorResult before an execution outcome is ever built. +// bash is different: it hands EVERY Run error to the same conversion, including +// a missing executable and a context cancelled before os.StartProcess. Those +// have a prepared plan, and therefore planned notices, but no child, so the +// hard-coded launch state turned a plan into a claim that reduced enforcement +// had actually been applied. +// +// These drive the real tool rather than constructing an outcome, because the +// bug was precisely that the constructed shape and the real one disagreed. +func TestBashOutcomeCarriesTheRealLaunchState(t *testing.T) { + root := t.TempDir() + tool := NewScopedBashTool(root, nil) + + t.Run("a command whose executable does not exist never launched", func(t *testing.T) { + res := tool.Run(context.Background(), map[string]any{ + "command": "zero-nonexistent-binary-for-launch-state-test --please-fail", + }) + if res.ExecutionOutcome == nil { + t.Fatal("no execution outcome recorded") + } + // The shell itself starts and reports "command not found", so this asserts + // the contract rather than a specific errno: whatever the platform did, + // the notice must agree with whether a process was created. + if got := len(res.ExecutionOutcome.AppliedEnforcementNotices()); got > 0 && !res.ExecutionOutcome.Launched { + t.Errorf("a command that never launched disclosed %d enforcement notices", got) + } + }) + + t.Run("a context cancelled before start never launched", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + res := tool.Run(ctx, map[string]any{"command": "echo hi"}) + if res.ExecutionOutcome == nil { + t.Skip("this platform produced no execution outcome for a pre-cancelled run") + } + if res.ExecutionOutcome.Launched { + t.Error("a run cancelled before start reported a launched child") + } + if got := res.ExecutionOutcome.AppliedEnforcementNotices(); len(got) != 0 { + t.Errorf("a run cancelled before start claimed an enforcement trade: %v", got) + } + }) + + t.Run("an ordinary command that runs does launch", func(t *testing.T) { + res := tool.Run(context.Background(), map[string]any{"command": "echo hello"}) + if res.ExecutionOutcome == nil { + t.Fatal("no execution outcome recorded") + } + if !res.ExecutionOutcome.Launched { + t.Error("a command that ran was not recorded as launched") + } + if !strings.Contains(res.Output, "hello") { + t.Errorf("unexpected output: %q", res.Output) + } + }) +} diff --git a/internal/tools/exec_command.go b/internal/tools/exec_command.go index 52f12f5b6..27e0c7461 100644 --- a/internal/tools/exec_command.go +++ b/internal/tools/exec_command.go @@ -403,16 +403,22 @@ type execToolResultInput struct { sessionID int exitCode int exited bool - relativeCwd string - tty bool - interrupted bool - request execution.Request - enforcement execution.Enforcement - sandboxMeta map[string]string - report execution.AdapterReport - reportErr error - changes []execution.Change - maxOutputTokens int + // launched records whether an OS process was actually created, observed at + // the boundary that ran it rather than assumed from the outcome shape. The + // exec_command paths set it true because a start failure returns an + // errorResult before reaching here; bash cannot, because it routes a + // pre-start Run error through the same conversion. + launched bool + relativeCwd string + tty bool + interrupted bool + request execution.Request + enforcement execution.Enforcement + sandboxMeta map[string]string + report execution.AdapterReport + reportErr error + changes []execution.Change + maxOutputTokens int } func execToolResult(input execToolResultInput) Result { @@ -432,6 +438,10 @@ func execToolResultWithBudget(input execToolResultInput, directBudget bool) Resu for key, value := range input.sandboxMeta { meta[key] = value } + // True here by construction: every path that reaches this conversion has + // already started a process, because a start failure returns an errorResult + // above without building an execution outcome. + input.launched = true outcome := execExecutionOutcome(input) if input.exited { meta["exit_code"] = strconv.Itoa(input.exitCode) @@ -530,7 +540,12 @@ func execExecutionOutcome(input execToolResultInput) execution.Outcome { // could not be started returns an error result before this point, so there is // no path in without a process behind it. Stated rather than inferred, so the // disclosure derived from it does not rest on the terminal outcome kind. - const launched = true + // READ, not assumed. This used to be a const true, documented as safe + // because exec_command returns early on a start failure. That holds for + // exec_command and not for bash, which hands every Run error to this same + // conversion, so a command whose executable did not exist claimed the + // DenyRead token trade had been applied. + launched := input.launched if !input.exited { return execution.Outcome{ State: execution.StateRetained, From dc723e696f498311cd703a97720a9abfa1f5fd69 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Sat, 29 Aug 2026 12:03:28 +0530 Subject: [PATCH 26/43] fix(mcp): synchronize the timeout with a start that is still completing connectStdio publishes only once cmd.Start has returned, and the timeout branch sampled the sink the instant it fired. Those interleave: the sample reads empty, the result commits with no notice, and the background reaper closes the late client without being able to amend a commit that has already happened. A process that started under reduced write confinement is then never represented in StartupDisclosures. Wait briefly for the abandoned attempt to say whether it had started, before concluding it had not. cancel() has already fired, so an attempt that never reached Start fails fast and the grace costs nothing; only one that did start can still be inside Start, and it publishes on the way out. A test asserts that the never-started case is not delayed, so the grace cannot quietly become a startup cost. The window itself is microseconds wide and cannot be hit from a test seam: an attempt to widen it with a slow second server failed, because every per-server goroutine returns at its own timeout and nothing holds wg.Wait open. So the tests drive the contract instead, a start that lands after the timeout but inside the grace, which is the case the synchronization exists to catch. The serial phase also re-reads the sink for an index whose result carried no notices, as a second net that costs nothing. --- .../mcp/launch_timeout_disclosure_test.go | 46 ++++++++++++ internal/mcp/registry.go | 71 +++++++++++++++---- 2 files changed, 104 insertions(+), 13 deletions(-) diff --git a/internal/mcp/launch_timeout_disclosure_test.go b/internal/mcp/launch_timeout_disclosure_test.go index 14dd11cf7..dbc9ecd0c 100644 --- a/internal/mcp/launch_timeout_disclosure_test.go +++ b/internal/mcp/launch_timeout_disclosure_test.go @@ -2,6 +2,7 @@ package mcp import ( "context" + "errors" "testing" "time" @@ -100,3 +101,48 @@ func TestAFailedStartPublishesNoLaunch(t *testing.T) { t.Errorf("a server whose process never started was published as launched (notices %#v)", notices) } } + +// A START THAT COMPLETES JUST AFTER THE TIMEOUT MUST STILL BE DISCLOSED. +// +// connectStdio publishes only once cmd.Start has returned, and the timeout +// branch used to sample the sink the instant it fired. Those interleave: the +// sample reads empty, the result commits with no notice, and the reaper closes +// the late client without being able to amend a commit that already happened. +// +// The real window is microseconds wide, so this drives the CONTRACT instead: +// the attempt starts after the registration timeout has elapsed but inside the +// settle grace, which is the case the synchronization exists to catch. +func TestStartJustAfterTheTimeoutIsStillDisclosed(t *testing.T) { + runtime := registerWithFactory(t, func(ctx context.Context, server Server) (ToolClient, error) { + // 50ms registration timeout has fired; this lands inside launchSettleGrace. + time.Sleep(120 * time.Millisecond) + publishLaunch(ctx, []string{launchNotice}) + return nil, errors.New("initialize failed after start") + }) + + disclosures := runtime.StartupDisclosures() + if len(disclosures) != 1 || len(disclosures[0].Notices) != 1 || disclosures[0].Notices[0] != launchNotice { + t.Fatalf("a process that started just after the timeout was not disclosed: %#v", disclosures) + } + if skipped := runtime.Skipped(); len(skipped) != 1 { + t.Errorf("the server should still be recorded as skipped: %#v", skipped) + } +} + +// And an attempt that never starts is not held for the grace, nor disclosed. +func TestTimeoutBeforeStartIsNotDelayedOrDisclosed(t *testing.T) { + start := time.Now() + runtime := registerWithFactory(t, func(ctx context.Context, server Server) (ToolClient, error) { + <-ctx.Done() // cancel arrives with the timeout; returns immediately + return nil, ctx.Err() + }) + elapsed := time.Since(start) + + if disclosures := runtime.StartupDisclosures(); len(disclosures) != 0 { + t.Errorf("a server that never started claimed a token trade: %#v", disclosures) + } + // The grace is 250ms; an attempt that returns on cancel must not pay it. + if elapsed > 200*time.Millisecond { + t.Errorf("registration waited %v for an attempt that never started", elapsed) + } +} diff --git a/internal/mcp/registry.go b/internal/mcp/registry.go index 18841c91c..f67370d61 100644 --- a/internal/mcp/registry.go +++ b/internal/mcp/registry.go @@ -18,6 +18,11 @@ import ( // so a slow or unreachable server (e.g. a hosted endpoint blocked by the local // network) cannot delay the first model response. Servers connect concurrently, // so total startup cost is the slowest reachable server, not the sum. +// launchSettleGrace bounds how long an abandoned connect attempt is given to +// say whether it had already started. It is paid only after cancel, so an +// attempt that never reached Start returns well inside it. +const launchSettleGrace = 250 * time.Millisecond + const defaultConnectTimeout = 8 * time.Second type RegisterOptions struct { @@ -130,6 +135,14 @@ func RegisterTools(ctx context.Context, registry *tools.Registry, cfg config.MCP notices []string } results := make([]connectResult, len(servers)) + // RETAINED PAST THE CONCURRENT PHASE. The timeout branch samples the sink the + // moment it fires, but connectStdio does not publish until cmd.Start has + // returned, so a Start that succeeds just after the timeout selected was + // sampled as "never launched" and its disclosure was lost: the reaper closes + // the late client and cannot amend a commit that has already happened. + // Reading the sink again in the serial phase is strictly later than the + // timeout branch and still deterministic, because it runs after wg.Wait. + sinks := make([]*launchSink, len(servers)) var wg sync.WaitGroup for index := range servers { wg.Add(1) @@ -140,6 +153,7 @@ func RegisterTools(ctx context.Context, registry *tools.Registry, cfg config.MCP // can still report the confinement its process ran under. The connect // result cannot supply that: it does not arrive until after this phase. sink := &launchSink{} + sinks[index] = sink serverCtx, cancel := context.WithCancel(withLaunchSink(ctx, sink)) done := make(chan connectResult, 1) go func() { @@ -156,23 +170,45 @@ func RegisterTools(ctx context.Context, registry *tools.Registry, cfg config.MCP results[index] = res case <-time.After(timeout): cancel() // abandon the slow connect: tears down the conn/subprocess - // Reap the goroutine + any partial client in the background so a - // slow server never blocks startup. The reaper cannot contribute the - // disclosure: it returns after the serial commit phase below has - // already run, which is why the launch fact is published at Start - // instead of being read off the late result here. - go func() { - if res := <-done; res.client != nil { + timedOut := connectResult{err: fmt.Errorf("connect timed out after %s", timeout)} + // SYNCHRONIZE WITH THE START, briefly, before deciding there was none. + // + // connectStdio publishes only after cmd.Start returns, so sampling the + // sink the instant the timeout fires races a Start that is about to + // succeed: the sample reads empty, the result commits with no notice, + // and the reaper cannot amend a commit that has already happened. The + // window is microseconds and unreachable from a test seam, which is + // exactly why it must be closed by construction rather than measured. + // + // cancel() has already fired, so an attempt that has NOT started fails + // fast and this returns immediately; only one that did start can still + // be in Start, and it publishes on the way out. The grace is therefore + // paid only when there is something to learn. + select { + case res := <-done: + if res.client != nil { _ = res.client.Close() } - }() - timedOut := connectResult{err: fmt.Errorf("connect timed out after %s", timeout)} + if len(res.notices) > 0 { + timedOut.notices = res.notices + } + case <-time.After(launchSettleGrace): + // Still stuck past the grace. Reap in the background so a slow + // server never blocks startup. + go func() { + if res := <-done; res.client != nil { + _ = res.client.Close() + } + }() + } // A server that reached Start ran under the planned enforcement even // though its connection never became usable. One that timed out // before Start discloses nothing, so the sink stays empty and this // adds nothing. - if launched, notices := sink.observe(); launched { - timedOut.notices = notices + if len(timedOut.notices) == 0 { + if launched, notices := sink.observe(); launched { + timedOut.notices = notices + } } results[index] = timedOut } @@ -194,8 +230,17 @@ func RegisterTools(ctx context.Context, registry *tools.Registry, cfg config.MCP // including one whose tools are rejected below: the launch happened under // that token either way, and a skip warning does not say what confinement // the process ran with while it was alive. - if len(res.notices) > 0 { - runtime.disclosures = append(runtime.disclosures, StartupDisclosure{Name: server.Name, Notices: res.notices}) + notices := res.notices + if len(notices) == 0 { + // A launch that published after the timeout branch sampled. Checked here + // rather than only there so the window between Start succeeding and the + // timeout committing cannot swallow the disclosure. + if launched, late := sinks[index].observe(); launched { + notices = late + } + } + if len(notices) > 0 { + runtime.disclosures = append(runtime.disclosures, StartupDisclosure{Name: server.Name, Notices: notices}) } if res.err != nil { runtime.skipped = append(runtime.skipped, SkippedServer{Name: server.Name, Err: res.err, UnconfiguredDefault: server.UnconfiguredDefault}) From 22b0f81e1f9057cc860d3d89f8bba87374396e95 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Mon, 31 Aug 2026 14:25:30 +0530 Subject: [PATCH 27/43] fix(tui): let the typed notice own the card's disclosure The enforcement disclosure reached the card in two forms: typed EnforcementNotices, which the card renders as its own furniture, and ModelOutput, which has the notice composed into the text. toolResultDetail returned the undecorated Display.Preview when one existed and fell back to the decorated ModelOutput when one did not, so every result without a preview drew the warning twice: once in the notice lines and once at the top of the body. That is every bash and exec card and every error card. The existing regression used a rich-preview edit result, which selects the one representation that masks the path. Give the body one owner. toolResultDetail now returns the base text, and the card decorates once. agent.ToolResult gains BaseModelOutput and BaseDisplay mirroring tools.Result, so a surface that renders the typed notice has a canonical accessor to build from rather than re-deriving it, and ModelOutput and HumanDisplay are expressed in terms of them so the base is computed once. The durable path had the same mismatch: the payload stores the decorated output beside the typed notices, and restoration used that output as the body whenever no distinct preview was stored. The undecorated body is now always written when it differs, and restoration keys on the field being PRESENT rather than non-empty, because a command that printed nothing under an enforced profile has an empty body and a real notice. Tests cover live and restored, success and error, and assert that the notice and the underlying output each appear exactly once. Reverting toolResultDetail alone fails all of them. --- internal/agent/types.go | 33 +++++-- internal/tui/enforcement_notice_card_test.go | 90 ++++++++++++++++++++ internal/tui/model.go | 23 ++++- internal/tui/session.go | 11 ++- 4 files changed, 142 insertions(+), 15 deletions(-) diff --git a/internal/agent/types.go b/internal/agent/types.go index 7aaa0bc10..18d7fecf5 100644 --- a/internal/agent/types.go +++ b/internal/agent/types.go @@ -113,23 +113,38 @@ type ToolResult struct { RequestedModel string } +// BaseModelOutput is the bounded provider-facing result WITHOUT the enforcement +// disclosure composed into it, mirroring tools.Result.BaseModelOutput. +// +// A surface that renders the typed EnforcementNotices itself must build its body +// from here, or the disclosure appears twice. Decoration has exactly one owner +// per surface: either the text carries it or the surface draws it, never both. +func (result ToolResult) BaseModelOutput() string { + if result.Outcome.Finalized() { + return result.Outcome.ModelView + } + return result.Output +} + +// BaseDisplay is BaseModelOutput's presentation half, and carries no enforcement +// notices for the same reason. +func (result ToolResult) BaseDisplay() tools.Display { + if result.Outcome.Finalized() { + return result.Outcome.HumanView + } + return result.Display +} + // ModelOutput returns the bounded provider-facing result while preserving // compatibility with synthetic and restored results created before outcomes // were finalized. func (result ToolResult) ModelOutput() string { - base := result.Output - if result.Outcome.Finalized() { - base = result.Outcome.ModelView - } - return tools.WithEnforcementNotices(base, result.EnforcementNotices) + return tools.WithEnforcementNotices(result.BaseModelOutput(), result.EnforcementNotices) } // HumanDisplay returns the presentation intended for interactive surfaces. func (result ToolResult) HumanDisplay() tools.Display { - display := result.Display - if result.Outcome.Finalized() { - display = result.Outcome.HumanView - } + display := result.BaseDisplay() display.Summary = tools.WithEnforcementNotices(display.Summary, result.EnforcementNotices) return display } diff --git a/internal/tui/enforcement_notice_card_test.go b/internal/tui/enforcement_notice_card_test.go index adfdd71d6..582cba425 100644 --- a/internal/tui/enforcement_notice_card_test.go +++ b/internal/tui/enforcement_notice_card_test.go @@ -114,3 +114,93 @@ func TestOrdinaryResultsGainNoNoticeLines(t *testing.T) { t.Errorf("blank notices changed the card:\n--- none ---\n%s\n--- blank ---\n%s", plain, blank) } } + +// THE NO-PREVIEW CARD IS THE ONE THE PREVIEW TEST CANNOT SEE. +// +// The disclosure travels in two forms: typed EnforcementNotices, which the card +// renders as its own furniture, and ModelOutput, which has the notice composed +// in. A rich preview is undecorated, so an edit card was right. Every bash and +// exec result, and every error, has no preview and fell back to ModelOutput, so +// row.detail already began with the notice and the card drew it twice: once in +// the notice lines and once at the top of the body. +// +// Both halves are asserted, because a body that lost the notice by losing the +// output would also count once. +func resultWithoutPreviewAndNotice(status tools.Status, output string) agent.ToolResult { + return agent.ToolResult{ + ToolCallID: "call-2", + Name: "bash", + Status: status, + Output: output, + EnforcementNotices: []string{cardNotice}, + } +} + +func countNoticeAndBody(t *testing.T, card string, body string) (int, int) { + t.Helper() + return strings.Count(card, "WRITE_RESTRICTED"), strings.Count(card, body) +} + +func TestNoPreviewCardShowsTheDisclosureExactlyOnce(t *testing.T) { + cases := []struct { + name string + status tools.Status + output string + }{ + {"success", tools.StatusOK, "PROBE-BODY-OK"}, + {"error", tools.StatusError, "PROBE-BODY-ERR"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + row := rowForResult(resultWithoutPreviewAndNotice(tc.status, tc.output)) + for _, expanded := range []bool{false, true} { + notices, bodies := countNoticeAndBody(t, renderedCard(row, expanded), tc.output) + if notices != 1 { + t.Errorf("expanded=%v: disclosure rendered %d times, want exactly 1", expanded, notices) + } + if bodies != 1 { + t.Errorf("expanded=%v: command output rendered %d times, want exactly 1", expanded, bodies) + } + } + }) + } +} + +// The durable path had the same mismatch: the payload stores the decorated +// output beside the typed notices, and restoration used that output as the card +// body whenever no distinct preview was stored. +func TestRestoredNoPreviewCardShowsTheDisclosureExactlyOnce(t *testing.T) { + cases := []struct { + name string + status tools.Status + output string + }{ + {"success", tools.StatusOK, "PROBE-BODY-OK"}, + {"error", tools.StatusError, "PROBE-BODY-ERR"}, + // A command that printed nothing under an enforced profile still has a + // real notice. The stored body is empty, which restoration must treat as + // present-and-empty rather than absent. + {"empty output", tools.StatusOK, ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + encoded, err := json.Marshal(toolResultSessionPayload(resultWithoutPreviewAndNotice(tc.status, tc.output))) + if err != nil { + t.Fatal(err) + } + rows := transcriptRowsFromSessionEvents([]sessions.Event{{Type: sessions.EventToolResult, Payload: json.RawMessage(encoded)}}) + if len(rows) != 1 { + t.Fatalf("expected one restored row, got %d", len(rows)) + } + for _, expanded := range []bool{false, true} { + card := renderedCard(rows[0], expanded) + if notices := strings.Count(card, "WRITE_RESTRICTED"); notices != 1 { + t.Errorf("expanded=%v: restored card rendered the disclosure %d times, want exactly 1:\n%s", expanded, notices, card) + } + if tc.output != "" && strings.Count(card, tc.output) != 1 { + t.Errorf("expanded=%v: restored card rendered the output %d times, want exactly 1:\n%s", expanded, strings.Count(card, tc.output), card) + } + } + }) + } +} diff --git a/internal/tui/model.go b/internal/tui/model.go index 6dc752d20..102a7ac3c 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -6018,18 +6018,35 @@ func (m model) sendAgentUsage(runID int, modelID string, event zeroruntime.Usage // toolResultDetail is the card body source: the rich card-only Display.Preview // (a code/diff preview) when present on a successful result, else the Output that // the model also saw. Error results keep their Output so the failure shows. +// +// UNDECORATED, ALWAYS. The enforcement disclosure is carried separately as typed +// notices and rendered by the card as its own furniture, so a body that already +// had the notice composed into it drew the warning twice: once in the notice +// lines and once at the top of the output. A rich preview never carried it, so +// only the no-preview results (every bash and exec card, and every error) were +// wrong, which is exactly the shape a preview-only test cannot see. +// +// One owner for composition: this returns the base text, and whoever presents it +// decorates once. Provider-facing text still goes through ModelOutput. func toolResultDetail(result agent.ToolResult) string { - display := result.HumanDisplay() + display := result.BaseDisplay() if strings.TrimSpace(display.Preview) != "" && (result.Status != tools.StatusError || result.Outcome.Finalized()) { return display.Preview } - return result.ModelOutput() + return result.BaseModelOutput() } // toolResultSessionPayload preserves both views of a tool result: output remains // the provider-facing text used for session context, while displayPreview keeps // the richer card body that was visible during the live run. The preview is only // stored when it differs, so ordinary tool results retain their compact event. +// +// A result carrying enforcement notices ALWAYS differs now, because output is +// decorated and the card body is not, so the undecorated body is written even +// when it is empty. Restoration keys on the field being PRESENT rather than +// non-empty for exactly that case: a command that printed nothing under an +// enforced profile has an empty body and a real notice, and falling back to +// output there would restore the decorated text and draw the notice twice. func toolResultSessionPayload(result agent.ToolResult) map[string]any { output := result.ModelOutput() payload := map[string]any{ @@ -6038,7 +6055,7 @@ func toolResultSessionPayload(result agent.ToolResult) map[string]any { "status": string(result.Status), "output": output, } - if preview := toolResultDetail(result); strings.TrimSpace(preview) != "" && preview != output { + if preview := toolResultDetail(result); preview != output { payload["displayPreview"] = preview } if result.Redacted { diff --git a/internal/tui/session.go b/internal/tui/session.go index 1fb7a7212..bab650454 100644 --- a/internal/tui/session.go +++ b/internal/tui/session.go @@ -642,9 +642,14 @@ func transcriptRowsFromSessionEvents(events []sessions.Event) []transcriptRow { status = tools.StatusOK } output := payloadString(payload, "output") - detail := payloadString(payload, "displayPreview") - if detail == "" { - detail = output + // PRESENCE, not emptiness. displayPreview is the undecorated card + // body; output carries the enforcement notice composed in. An empty + // stored body is a real answer (a command that printed nothing under + // an enforced profile), and treating it as absent would restore the + // decorated output and render the disclosure twice. + detail := output + if raw, ok := payload["displayPreview"]; ok { + detail, _ = raw.(string) } rows = append(rows, transcriptRow{ kind: rowToolResult, From 8e64b64a3ffbcf0f8fb7f54e1088e39e2b6b719d Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Mon, 31 Aug 2026 14:29:08 +0530 Subject: [PATCH 28/43] fix(mcp): keep the launch fact alive past the registration bound The settle grace is only another timeout. When it expires, registration reaps the abandoned attempt in the background and returns, but the process can still be inside cmd.Start at that moment. It then starts under the reduced write confinement, publishes to its sink, and nobody is left who can say so: the reaper closes the late client, and Runtime had already frozen its disclosures into a snapshot taken during the serial commit. Startup reported such a server only as skipped. Registration is bounded and a launch is not, so the two cannot share a lifetime. The sink already carries the authoritative fact and outlives the attempt; Runtime now retains it per server and StartupDisclosures reads through it instead of copying out of it. A server whose notices were known at commit never re-reads its sink, and entries stay in server order, so repeated reads cannot duplicate or reorder anything. Nothing else moves: pre-launch failures still publish nothing and stay silent, network servers still launch no process, and the reaper still closes the late client. The existing regression releases its launch at 120ms, deliberately inside the 250ms grace, so it only proved the grace covered that interval. The new test releases strictly after the bound, asserts the disclosure was legitimately absent beforehand, and asserts a second read does not duplicate it. Reverting StartupDisclosures to the snapshot fails the new test and leaves the in-grace one passing, which is the point: the old shape could not see this. --- .../mcp/launch_timeout_disclosure_test.go | 51 +++++++++++++++ internal/mcp/registry.go | 65 ++++++++++++++++--- 2 files changed, 107 insertions(+), 9 deletions(-) diff --git a/internal/mcp/launch_timeout_disclosure_test.go b/internal/mcp/launch_timeout_disclosure_test.go index dbc9ecd0c..b6d717897 100644 --- a/internal/mcp/launch_timeout_disclosure_test.go +++ b/internal/mcp/launch_timeout_disclosure_test.go @@ -146,3 +146,54 @@ func TestTimeoutBeforeStartIsNotDelayedOrDisclosed(t *testing.T) { t.Errorf("registration waited %v for an attempt that never started", elapsed) } } + +// AND A START THAT COMPLETES AFTER THE SETTLE GRACE MUST STILL BE DISCLOSED. +// +// The grace is only another timeout. Once it expires, registration reaps the +// attempt in the background and returns; the process can still be inside +// cmd.Start at that moment and start successfully afterwards. If Runtime were a +// snapshot taken at commit time, that launch would have no owner: the reaper can +// close the late client but cannot amend a value already returned, so the server +// would have run under the reduced write confinement with startup reporting it +// only as skipped. +// +// A larger grace changes the probability, not the contract, which is why this +// releases the launch strictly AFTER the bound rather than inside it. The sink +// outlives registration and StartupDisclosures reads through it. +func TestStartAfterTheSettleGraceIsStillDisclosed(t *testing.T) { + released := make(chan struct{}) + runtime := registerWithFactory(t, func(ctx context.Context, server Server) (ToolClient, error) { + // Held past the 50ms registration timeout AND past launchSettleGrace, so + // registration has already reaped this attempt and returned. + <-released + publishLaunch(ctx, []string{launchNotice}) + return nil, errors.New("initialize failed long after start") + }) + + // Registration is done and the disclosure legitimately is not known yet. + if disclosures := runtime.StartupDisclosures(); len(disclosures) != 0 { + t.Fatalf("nothing had started yet, so nothing should be disclosed: %#v", disclosures) + } + + close(released) + + deadline := time.Now().Add(2 * time.Second) + var disclosures []StartupDisclosure + for time.Now().Before(deadline) { + if disclosures = runtime.StartupDisclosures(); len(disclosures) > 0 { + break + } + time.Sleep(5 * time.Millisecond) + } + + if len(disclosures) != 1 || len(disclosures[0].Notices) != 1 || disclosures[0].Notices[0] != launchNotice { + t.Fatalf("a process that started after the settle grace was never disclosed: %#v", disclosures) + } + // Reading again must not duplicate it. + if again := runtime.StartupDisclosures(); len(again) != 1 { + t.Errorf("a second read changed the disclosures: %#v", again) + } + if skipped := runtime.Skipped(); len(skipped) != 1 { + t.Errorf("the server should still be recorded as skipped: %#v", skipped) + } +} diff --git a/internal/mcp/registry.go b/internal/mcp/registry.go index f67370d61..254d8fa2d 100644 --- a/internal/mcp/registry.go +++ b/internal/mcp/registry.go @@ -69,11 +69,28 @@ type Runtime struct { // is closed). Same length/order as clients is not required. cancels []context.CancelFunc skipped []SkippedServer - // disclosures are the least-privilege statements that applied to each server - // process this registration LAUNCHED. See StartupDisclosures. - disclosures []StartupDisclosure - once sync.Once - err error + // disclosureSources are the least-privilege statements that applied to each + // server process this registration LAUNCHED, in server order, each still + // holding the sink that carries the authoritative launch fact. + // + // NOT a frozen snapshot. Registration is bounded and a launch is not: an + // attempt abandoned at the connect timeout can still be inside cmd.Start when + // wg.Wait returns, so the serial commit samples an empty sink and the process + // then starts under the reduced confinement with nobody left to say so. The + // sink outlives registration and StartupDisclosures reads through it, so a + // late Start is reported instead of lost. See StartupDisclosures. + disclosureSources []disclosureSource + once sync.Once + err error +} + +// disclosureSource pairs a server with both the notices known at commit time and +// the sink that may still learn them. notices wins when it is already populated, +// so a settled server never re-reads the sink. +type disclosureSource struct { + name string + notices []string + sink *launchSink } // Skipped returns the servers that were skipped during registration (unreachable @@ -239,9 +256,15 @@ func RegisterTools(ctx context.Context, registry *tools.Registry, cfg config.MCP notices = late } } - if len(notices) > 0 { - runtime.disclosures = append(runtime.disclosures, StartupDisclosure{Name: server.Name, Notices: notices}) - } + // Recorded whether or not notices are known YET. An abandoned attempt can + // still be inside cmd.Start, and keeping its sink here is what lets + // StartupDisclosures report that launch after this phase has finished. + // Order is server order, so a late arrival does not reorder the rest. + runtime.disclosureSources = append(runtime.disclosureSources, disclosureSource{ + name: server.Name, + notices: notices, + sink: sinks[index], + }) if res.err != nil { runtime.skipped = append(runtime.skipped, SkippedServer{Name: server.Name, Err: res.err, UnconfiguredDefault: server.UnconfiguredDefault}) continue @@ -513,9 +536,33 @@ func isPersistentlyApproved(store *PermissionStore, server Server, toolName stri // MCP server processes this registration launched, so a caller can report them // once. Empty when no server was launched under reduced enforcement, and always // empty for network servers, which launch no local process. +// +// READ THROUGH THE SINK, so this is not fixed at the moment registration +// returned. A server abandoned at the connect timeout may still have been inside +// cmd.Start then, and its process starts under the reduced write confinement +// regardless of whether the connection ever became usable. Registration stays +// bounded; the disclosure does not expire with it. +// +// Server order, and a settled entry never re-reads its sink, so calling this +// twice cannot reorder or duplicate anything. func (runtime *Runtime) StartupDisclosures() []StartupDisclosure { if runtime == nil { return nil } - return append([]StartupDisclosure(nil), runtime.disclosures...) + disclosures := make([]StartupDisclosure, 0, len(runtime.disclosureSources)) + for _, source := range runtime.disclosureSources { + notices := source.notices + if len(notices) == 0 { + if launched, late := source.sink.observe(); launched { + notices = late + } + } + if len(notices) > 0 { + disclosures = append(disclosures, StartupDisclosure{Name: source.name, Notices: notices}) + } + } + if len(disclosures) == 0 { + return nil + } + return disclosures } From 34395763c20b14b248d5e3539c981fa1309b11c6 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Wed, 2 Sep 2026 12:17:03 +0530 Subject: [PATCH 29/43] fix(mcp,tui): deliver the launch fact once, and persist one payload shape Two findings, one contract: a fact that outlives the consumer that first looked for it. The retained launch sink stopped a late cmd.Start from being lost, but a value nobody re-reads is still a lost disclosure. Both production reporters sampled StartupDisclosures once, right after RegisterTools returned, and a stdio attempt abandoned at the connect timeout could still be inside Start at that moment. The process then ran under the reduced write confinement, the reaper closed its client, and the operator saw the skipped-server warning and never the disclosure. The previous regression masked this by polling StartupDisclosures by hand after release rather than exercising a reporter. The sink is now an event as well as a value. Runtime.ReportStartupDisclosures reports servers whose notices were known at commit immediately, in server order, and subscribes every other server's sink; a subscriber whose launch has already happened runs before subscribe returns, otherwise it runs from publishLaunch, and either way exactly once. Servers that never start never publish, so prepare, pipe and Start failures stay silent, and network servers contribute nothing. reportMCPStartupDisclosures uses the push form and keeps the pull form for a runtime that lacks it. The new test drives the REAL reporter against a REAL runtime: reporting before release prints nothing, release prints the notice exactly once. Reverting the reporter to pull-only fails it with "disclosed 0 time(s), want exactly 1". The headless session writers built their own payload with decorated output only, while the TUI writer stored typed notices and an undecorated body. Both append to the same store the TUI resumes from, so a CLI-written long collapsed result restored into the TUI had no body to carry the decorated text and no notice furniture to draw it: the disclosure the run had shown was gone. tui.ToolResultSessionPayload is now the one owner and the CLI delegates to it; the one field the CLI had added on its own, truncated, moves into the shared shape so it is not lost. A tui test restores the CLI's exact bytes into a collapsed card and asserts the notice exactly once; a cli test asserts the delegation, since reverting it leaves the tui test green and fails the cli one on the notice, the body and the truncated flag. --- internal/cli/exec.go | 28 ++--- internal/cli/exec_payload_test.go | 47 ++++++++ internal/cli/mcp_late_disclosure_test.go | 106 +++++++++++++++++++ internal/cli/mcp_tools.go | 30 +++++- internal/mcp/launch_sink.go | 62 ++++++++++- internal/mcp/registry.go | 40 +++++++ internal/tui/enforcement_notice_card_test.go | 54 ++++++++++ internal/tui/model.go | 18 ++++ 8 files changed, 357 insertions(+), 28 deletions(-) create mode 100644 internal/cli/exec_payload_test.go create mode 100644 internal/cli/mcp_late_disclosure_test.go diff --git a/internal/cli/exec.go b/internal/cli/exec.go index 7a304da56..9627850cc 100644 --- a/internal/cli/exec.go +++ b/internal/cli/exec.go @@ -28,6 +28,7 @@ import ( "github.com/Gitlawb/zero/internal/streamjson" "github.com/Gitlawb/zero/internal/tools" "github.com/Gitlawb/zero/internal/trace" + "github.com/Gitlawb/zero/internal/tui" "github.com/Gitlawb/zero/internal/usage" "github.com/Gitlawb/zero/internal/worktrees" "github.com/Gitlawb/zero/internal/zeroruntime" @@ -1498,23 +1499,12 @@ func writeTraceSnapshot(snapshot *trace.TurnTrace, dest string, stderr io.Writer // same payload separately and had already drifted: one persisted the raw field // while the stream writer used the accessor. func persistedToolResultPayload(result agent.ToolResult) map[string]any { - payload := map[string]any{ - "toolCallId": result.ToolCallID, - "name": result.Name, - "status": string(result.Status), - "output": result.ModelOutput(), - } - if len(result.Meta) > 0 { - payload["meta"] = result.Meta - } - if result.Truncated { - payload["truncated"] = true - } - if result.Redacted { - payload["redacted"] = true - } - if len(result.ChangedFiles) > 0 { - payload["changedFiles"] = result.ChangedFiles - } - return payload + // ONE CONTRACT WITH THE TUI. This used to build its own payload with the + // decorated output only, and both writers append to the same default + // session store the TUI resumes from. A CLI-written result restored into + // the TUI therefore arrived without typed enforcement notices and without + // the undecorated card body, so a long collapsed result rendered no body + // and, with it, no disclosure. The interactive writer owns the shape now, + // and this is the same function, not a matching copy of it. + return tui.ToolResultSessionPayload(result) } diff --git a/internal/cli/exec_payload_test.go b/internal/cli/exec_payload_test.go new file mode 100644 index 000000000..8d787413c --- /dev/null +++ b/internal/cli/exec_payload_test.go @@ -0,0 +1,47 @@ +package cli + +import ( + "strings" + "testing" + + "github.com/Gitlawb/zero/internal/agent" + "github.com/Gitlawb/zero/internal/tools" +) + +// THE HEADLESS WRITER PERSISTS THE SAME SHAPE THE TUI DOES. +// +// The tui-side restore test proves the shared payload restores a disclosure +// exactly once; this proves the CLI actually WRITES that shared payload rather +// than its own. The two used to be spelled separately and the headless one had +// already drifted to decorated output only. Reverting the delegation leaves +// the tui test green and fails this one, which is the point of having both. +func TestHeadlessPayloadCarriesTypedNoticesAndUndecoratedBody(t *testing.T) { + const notice = "denyRead is configured, so the Windows sandbox uses the token shape without WRITE_RESTRICTED (#869)" + result := agent.ToolResult{ + ToolCallID: "call-cli", + Name: "bash", + Status: tools.StatusOK, + Output: "PROBE-BODY", + Truncated: true, + EnforcementNotices: []string{notice}, + } + payload := persistedToolResultPayload(result) + + notices, _ := payload["enforcementNotices"].([]string) + if len(notices) != 1 || notices[0] != notice { + t.Errorf("headless payload does not carry the typed notice: %#v", payload["enforcementNotices"]) + } + preview, _ := payload["displayPreview"].(string) + if preview != "PROBE-BODY" { + t.Errorf("headless payload does not carry the undecorated body as displayPreview: %q", preview) + } + output, _ := payload["output"].(string) + if !strings.Contains(output, notice) || !strings.Contains(output, "PROBE-BODY") { + t.Errorf("provider-facing output is no longer the decorated text: %q", output) + } + // The one field the headless writer added on its own must survive the + // delegation, or a truncation marker silently stops being persisted. + if truncated, _ := payload["truncated"].(bool); !truncated { + t.Errorf("truncated flag was lost in the shared payload: %#v", payload["truncated"]) + } +} diff --git a/internal/cli/mcp_late_disclosure_test.go b/internal/cli/mcp_late_disclosure_test.go new file mode 100644 index 000000000..436010239 --- /dev/null +++ b/internal/cli/mcp_late_disclosure_test.go @@ -0,0 +1,106 @@ +package cli + +import ( + "bytes" + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/Gitlawb/zero/internal/config" + "github.com/Gitlawb/zero/internal/mcp" + "github.com/Gitlawb/zero/internal/tools" +) + +// A LAUNCH THAT COMPLETES AFTER THE REPORTER HAS RUN MUST STILL BE SAID, ONCE. +// +// Both production paths call reportMCPStartupDisclosures exactly once, right +// after RegisterTools returns. A stdio attempt abandoned at the connect timeout +// can still be inside cmd.Start at that moment; the process then starts under +// the reduced write confinement, the reaper closes its client, and a reporter +// that merely SAMPLED the runtime has already come and gone. The retained sink +// held the fact and nobody read it again, so the operator saw the skipped +// server and never the disclosure. +// +// This drives the REAL reporter against a REAL runtime, rather than polling +// StartupDisclosures by hand, which is what the previous regression did and +// which is precisely how it masked this: a test that re-reads on the tester's +// behalf proves nothing about a production path that does not. +func TestLateMCPLaunchReachesTheStartupReporterExactlyOnce(t *testing.T) { + const notice = "MCP server started without WRITE_RESTRICTED because denyRead is configured (#869)" + released := make(chan struct{}) + + runtime, err := mcp.RegisterTools(context.Background(), tools.NewRegistry(), + config.MCPConfig{Servers: map[string]config.MCPServerConfig{ + "slow": {Type: "stdio", Command: "slow-mcp"}, + }}, + mcp.RegisterOptions{ + ConnectTimeout: 50 * time.Millisecond, + ClientFactory: func(ctx context.Context, server mcp.Server) (mcp.ToolClient, error) { + // Held past the registration timeout AND past the settle grace, so + // registration has already reaped this attempt and returned. + <-released + mcp.PublishLaunchForTest(ctx, []string{notice}) + return nil, errors.New("initialize failed long after start") + }, + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = runtime.Close() }) + + // The reporter runs ONCE, here, exactly as runExec and the interactive + // startup path run it: before the launch has resolved. + var stderr bytes.Buffer + reportMCPStartupDisclosures(&stderr, runtime) + if strings.Contains(stderr.String(), notice) { + t.Fatalf("the disclosure was printed before the process had started:\n%s", stderr.String()) + } + + close(released) + + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) && !strings.Contains(stderr.String(), notice) { + time.Sleep(5 * time.Millisecond) + } + got := stderr.String() + if n := strings.Count(got, notice); n != 1 { + t.Fatalf("a launch that completed after the reporter ran was disclosed %d time(s), want exactly 1:\n%s", n, got) + } + if !strings.Contains(got, "MCP server slow started with reduced enforcement") { + t.Errorf("the late disclosure does not name the server:\n%s", got) + } + if skipped := runtime.Skipped(); len(skipped) != 1 { + t.Errorf("the server should still be recorded as skipped: %#v", skipped) + } +} + +// And a server whose launch was already known when the reporter ran is said +// once by it, and not again by the late path. +func TestKnownMCPLaunchIsNotReportedTwice(t *testing.T) { + const notice = "MCP server started under reduced enforcement" + runtime, err := mcp.RegisterTools(context.Background(), tools.NewRegistry(), + config.MCPConfig{Servers: map[string]config.MCPServerConfig{ + "fast": {Type: "stdio", Command: "fast-mcp"}, + }}, + mcp.RegisterOptions{ + ConnectTimeout: time.Second, + ClientFactory: func(ctx context.Context, server mcp.Server) (mcp.ToolClient, error) { + mcp.PublishLaunchForTest(ctx, []string{notice}) + return nil, errors.New("initialize failed after start") + }, + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = runtime.Close() }) + + var stderr bytes.Buffer + reportMCPStartupDisclosures(&stderr, runtime) + // Give any misrouted late delivery a chance to double up. + time.Sleep(50 * time.Millisecond) + if n := strings.Count(stderr.String(), notice); n != 1 { + t.Fatalf("a launch known at registration was disclosed %d time(s), want exactly 1:\n%s", n, stderr.String()) + } +} diff --git a/internal/cli/mcp_tools.go b/internal/cli/mcp_tools.go index cbf1784c0..5725dbb4c 100644 --- a/internal/cli/mcp_tools.go +++ b/internal/cli/mcp_tools.go @@ -198,16 +198,36 @@ type mcpStartupDisclosing interface { StartupDisclosures() []mcp.StartupDisclosure } +// mcpStartupReporting is the push form: the runtime delivers each disclosure +// exactly once, including a launch that completes after registration returned. +type mcpStartupReporting interface { + ReportStartupDisclosures(func(mcp.StartupDisclosure)) +} + // reportMCPStartupDisclosures states once what enforcement applied to the MCP // server processes this run launched. +// +// A PUSH, NOT A SAMPLE. This used to read StartupDisclosures once, here, and a +// stdio attempt abandoned at the connect timeout could still be inside cmd.Start +// at that moment. The process then started under the reduced write confinement, +// the reaper closed its client, and nothing read the runtime again: the operator +// saw the skipped-server warning and never the disclosure. Subscribing hands +// the runtime a presentation to deliver to whenever the launch resolves, so a +// late Start is said once rather than never. The pull form is kept for a +// runtime that does not implement the push, which today is only test doubles. func reportMCPStartupDisclosures(stderr io.Writer, runtime mcpToolRuntime) { - disclosing, ok := runtime.(mcpStartupDisclosing) - if !ok { - return - } - for _, disclosure := range disclosing.StartupDisclosures() { + print := func(disclosure mcp.StartupDisclosure) { for _, notice := range disclosure.Notices { fmt.Fprintf(stderr, "notice: MCP server %s started with reduced enforcement: %s\n", disclosure.Name, notice) } } + if reporting, ok := runtime.(mcpStartupReporting); ok { + reporting.ReportStartupDisclosures(print) + return + } + if disclosing, ok := runtime.(mcpStartupDisclosing); ok { + for _, disclosure := range disclosing.StartupDisclosures() { + print(disclosure) + } + } } diff --git a/internal/mcp/launch_sink.go b/internal/mcp/launch_sink.go index 00bd06e3b..a3149101a 100644 --- a/internal/mcp/launch_sink.go +++ b/internal/mcp/launch_sink.go @@ -21,10 +21,19 @@ import ( // connection usability have different lifetimes. A sink that was never published // to means Start never happened, so prepare, pipe, and Start failures stay silent // exactly as before. +// +// IT IS ALSO AN EVENT, NOT ONLY A VALUE. A retained sink that nobody re-reads is +// still a lost disclosure: both production reporters sample once, immediately +// after registration returns, and a Start that completes after that sample had +// no way to reach them. onPublish lets a reporter subscribe; if the launch has +// already happened by the time it subscribes, it is told at once, so the fact +// reaches exactly one presentation regardless of which side won the race. type launchSink struct { - mu sync.Mutex - launched bool - notices []string + mu sync.Mutex + launched bool + notices []string + onPublish func(notices []string) + delivered bool } type launchSinkKey struct{} @@ -46,9 +55,22 @@ func publishLaunch(ctx context.Context, notices []string) { return } sink.mu.Lock() - defer sink.mu.Unlock() sink.launched = true sink.notices = append([]string(nil), notices...) + deliver := sink.pendingDeliveryLocked() + sink.mu.Unlock() + if deliver != nil { + deliver() + } +} + +// PublishLaunchForTest is publishLaunch for a test in another package that +// injects a client factory and needs to mark its fake process as started. It +// is the same function with the same context lookup, so a test exercises the +// real sink rather than a stand-in, and it is inert on any context that did +// not come through registration. +func PublishLaunchForTest(ctx context.Context, notices []string) { + publishLaunch(ctx, notices) } // observe reports whether Start was reached and what applied to it. Read from @@ -62,3 +84,35 @@ func (sink *launchSink) observe() (bool, []string) { defer sink.mu.Unlock() return sink.launched, append([]string(nil), sink.notices...) } + +// subscribe registers the one presentation this launch should reach. If the +// launch already happened, fn runs before subscribe returns; otherwise it runs +// from publishLaunch. Either way it runs at most once, and a second subscriber +// replaces nothing: the first delivery is the only delivery. +func (sink *launchSink) subscribe(fn func(notices []string)) { + if sink == nil || fn == nil { + return + } + sink.mu.Lock() + if sink.onPublish == nil { + sink.onPublish = fn + } + deliver := sink.pendingDeliveryLocked() + sink.mu.Unlock() + if deliver != nil { + deliver() + } +} + +// pendingDeliveryLocked returns the delivery to perform, or nil, and marks it +// done. Called with mu held; the returned closure must be invoked with mu +// released, since a subscriber may itself take other locks. +func (sink *launchSink) pendingDeliveryLocked() func() { + if !sink.launched || sink.onPublish == nil || sink.delivered { + return nil + } + sink.delivered = true + fn := sink.onPublish + notices := append([]string(nil), sink.notices...) + return func() { fn(notices) } +} diff --git a/internal/mcp/registry.go b/internal/mcp/registry.go index 254d8fa2d..3ea7934cf 100644 --- a/internal/mcp/registry.go +++ b/internal/mcp/registry.go @@ -93,6 +93,46 @@ type disclosureSource struct { sink *launchSink } +// ReportStartupDisclosures delivers each server's launch disclosure to report +// EXACTLY ONCE, whether the launch had already completed when this was called +// or completes later. +// +// StartupDisclosures reads through the sink, which stopped a late Start from +// being lost, but a value nobody re-reads is still a lost disclosure: both +// production reporters sample once, right after RegisterTools returns, and an +// attempt abandoned at the connect timeout can finish Start after that sample. +// The reaper only closes the late client. So the operator saw the skipped-server +// warning and never learned that a local process had run under the reduced +// enforcement. +// +// Servers whose notices were known at commit are reported now, in server order. +// Every other server subscribes its sink: if the launch already happened the +// subscriber runs before this returns, otherwise it runs from publishLaunch on +// the connect goroutine. Either way each server reaches report once. A server +// that never starts never publishes, so prepare, pipe and Start failures stay +// silent, and network servers, which launch no process, contribute nothing. +// +// Late deliveries arrive in completion order, which is the only order they +// have; the immediate set keeps server order. +func (runtime *Runtime) ReportStartupDisclosures(report func(StartupDisclosure)) { + if runtime == nil || report == nil { + return + } + for _, source := range runtime.disclosureSources { + if len(source.notices) > 0 { + report(StartupDisclosure{Name: source.name, Notices: append([]string(nil), source.notices...)}) + continue + } + name := source.name + source.sink.subscribe(func(notices []string) { + if len(notices) == 0 { + return + } + report(StartupDisclosure{Name: name, Notices: notices}) + }) + } +} + // Skipped returns the servers that were skipped during registration (unreachable // or invalid), so the caller can warn the user without failing the launch. func (runtime *Runtime) Skipped() []SkippedServer { diff --git a/internal/tui/enforcement_notice_card_test.go b/internal/tui/enforcement_notice_card_test.go index 582cba425..540e4cbe2 100644 --- a/internal/tui/enforcement_notice_card_test.go +++ b/internal/tui/enforcement_notice_card_test.go @@ -2,6 +2,7 @@ package tui import ( "encoding/json" + "fmt" "strings" "testing" @@ -204,3 +205,56 @@ func TestRestoredNoPreviewCardShowsTheDisclosureExactlyOnce(t *testing.T) { }) } } + +// A CLI-WRITTEN RESULT RESUMED IN THE TUI MUST STILL DISCLOSE, ONCE. +// +// The headless writers and the interactive writer append to the same default +// session store, and the TUI resumes from it. The headless payload used to +// carry only the decorated ModelOutput: no typed notices, no undecorated body. +// On restore the transcript found neither, and for a long result the card is +// collapsed by default, so there was no body to carry the decorated text and no +// notice furniture to draw it. The disclosure the run had shown was simply gone +// from the resumed transcript. +// +// Both writers now go through ToolResultSessionPayload, so this exercises the +// exact bytes the CLI persists, restores them the way the TUI does, and renders +// the collapsed card, which is the shape the old CLI test could not reach. +func TestHeadlessWrittenCollapsedResultRestoresTheDisclosureExactlyOnce(t *testing.T) { + var lines []string + for i := 0; i < cardBodyMaxLines*3; i++ { + lines = append(lines, fmt.Sprintf("PROBE-LINE-%03d", i)) + } + result := agent.ToolResult{ + ToolCallID: "call-cli", + Name: "bash", + Status: tools.StatusOK, + Output: strings.Join(lines, "\n"), + EnforcementNotices: []string{cardNotice}, + } + + // The CLI's persisted payload IS this function now; encode it as the + // session store would. + encoded, err := json.Marshal(ToolResultSessionPayload(result)) + if err != nil { + t.Fatal(err) + } + rows := transcriptRowsFromSessionEvents([]sessions.Event{{Type: sessions.EventToolResult, Payload: json.RawMessage(encoded)}}) + if len(rows) != 1 { + t.Fatalf("expected one restored row, got %d", len(rows)) + } + if len(rows[0].enforcementNotices) == 0 { + t.Fatal("the headless payload carried no typed notices, so the resumed card cannot render the disclosure") + } + + for _, expanded := range []bool{false, true} { + card := renderedCard(rows[0], expanded) + if n := strings.Count(card, "WRITE_RESTRICTED"); n != 1 { + t.Errorf("expanded=%v: restored headless card rendered the disclosure %d time(s), want exactly 1:\n%s", expanded, n, card) + } + } + // Collapsed is the case that used to lose it: with no body shown there was + // nothing to carry a decorated notice. + if card := renderedCard(rows[0], false); !strings.Contains(card, "WRITE_RESTRICTED") { + t.Errorf("the collapsed restored card has no disclosure at all:\n%s", card) + } +} diff --git a/internal/tui/model.go b/internal/tui/model.go index 102a7ac3c..7b0c410eb 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -6048,6 +6048,21 @@ func toolResultDetail(result agent.ToolResult) string { // enforced profile has an empty body and a real notice, and falling back to // output there would restore the decorated text and draw the notice twice. func toolResultSessionPayload(result agent.ToolResult) map[string]any { + return ToolResultSessionPayload(result) +} + +// ToolResultSessionPayload is THE serialization of a tool result into a session +// event, shared by the interactive and the headless writers. +// +// They used to spell it separately, and the headless one persisted only the +// decorated ModelOutput. Both write to the same default session store the TUI +// resumes from, so a CLI-written result restored into the TUI arrived with no +// typed notices and no undecorated body: for a long collapsed result the card +// rendered no body and therefore no disclosure at all, even though the run +// that produced it had shown one. One owner for the contract means one place +// where a field can go missing, and a test against this function covers both +// writers. +func ToolResultSessionPayload(result agent.ToolResult) map[string]any { output := result.ModelOutput() payload := map[string]any{ "toolCallId": result.ToolCallID, @@ -6058,6 +6073,9 @@ func toolResultSessionPayload(result agent.ToolResult) map[string]any { if preview := toolResultDetail(result); preview != output { payload["displayPreview"] = preview } + if result.Truncated { + payload["truncated"] = true + } if result.Redacted { payload["redacted"] = true } From 1f947f33a5b898bec45dc37020a38adcca7cb506 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Wed, 2 Sep 2026 20:17:04 +0530 Subject: [PATCH 30/43] fix(mcp,cli): hand late launch disclosures to the output owner, not a retained callback ReportStartupDisclosures took the CLI's presentation function and invoked it from whichever goroutine resolved the launch. For a server still inside cmd.Start when registration gave up, that is the abandoned connect goroutine, so the write to stderr happened off the output owner's goroutine and could arrive after runExec had returned or after Bubble Tea had taken the alt screen. The regression added with it demonstrated the bug rather than catching it: it raced bytes.Buffer.String under -race. The runtime owns the fact that a process started; it does not own anyone's writer. StartupDisclosureStream carries the disclosures as typed events and the caller drains them on the goroutine that owns stderr: the set known at registration is printed before the reporter returns, keeping server order, and later arrivals are printed by a single pump. The returned stop ends delivery, joins the pump and flushes what is queued, so no write outlives the caller's ownership. A disclosure arriving after stop is dropped, which is the defined answer to what close does with pending delivery. Headless defers stop so it runs before the runtime is closed. The interactive path calls it on the line before deps.runTUI, so a launch resolving later is dropped instead of writing raw text over the alt screen. Runtime.Close also ends delivery, so a launch that resolves after the runtime is gone has nowhere defined to land. Removing the join reintroduces the original data race; removing the close lets a post-stop launch write to the abandoned writer, failing the new regression on the bytes it wrote. --- internal/cli/app.go | 9 +- internal/cli/exec.go | 6 +- internal/cli/mcp_late_disclosure_test.go | 87 +++++++++++++--- internal/cli/mcp_tools.go | 68 ++++++++++--- internal/mcp/registry.go | 53 ++++++---- internal/mcp/startup_disclosure_stream.go | 116 ++++++++++++++++++++++ 6 files changed, 290 insertions(+), 49 deletions(-) create mode 100644 internal/mcp/startup_disclosure_stream.go diff --git a/internal/cli/app.go b/internal/cli/app.go index d07a9b1e2..c3a3ff9a0 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -875,7 +875,10 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a // report nothing, which is why the optional background registration is not // asked: its only member is the built-in HTTP default, which starts no local // process. A stdio default would need this statement from that path too. - reportMCPStartupDisclosures(stderr, mcpRuntime) + // NOT deferred: stderr here is the bare terminal, and the TUI takes it over at + // deps.runTUI below. Delivery stops before that hand-off, so a late launch can + // never write raw text into the alt screen; see stopMCPDisclosures's call site. + stopMCPDisclosures := reportMCPStartupDisclosures(stderr, mcpRuntime) // Make local plugins live: register their declared tools into the registry and // collect their hooks + skill roots for the dispatcher and skill tool below. // Done after specialist + MCP registration so plugin tools are part of the @@ -967,6 +970,10 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a // notice when project hooks/plugins were dropped for an untrusted workspace. hookDispatcher, hookSkip := newHookDispatcherWithExtra(workspaceRoot, pluginActivation.hooks, trustRoot, executionRunner) emitTrustNotice(stderr, hookSkip, pluginActivation.trustSkip, mcpSkip) + // The terminal stops being ours on the next line. Stop and join the disclosure + // pump first: anything already queued is printed here, on this goroutine, and a + // launch that resolves later is dropped rather than written raw over the TUI. + stopMCPDisclosures() return deps.runTUI(context.Background(), tui.Options{ Cwd: workspaceRoot, Version: version, diff --git a/internal/cli/exec.go b/internal/cli/exec.go index 9627850cc..151343695 100644 --- a/internal/cli/exec.go +++ b/internal/cli/exec.go @@ -350,7 +350,11 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in // return early and the process this describes is already running by now. On // stderr, so text, JSON and stream-JSON framing on stdout are untouched: // this is the same channel the skipped-server and trust notices use. - reportMCPStartupDisclosures(stderr, mcpRuntime) + // Deferred AFTER closeMCPRuntime was deferred, so it runs BEFORE it: the + // pump stops and joins while stderr is still ours, and only then are the + // clients closed. + stopDisclosures := reportMCPStartupDisclosures(stderr, mcpRuntime) + defer stopDisclosures() } pluginActivation = activatePlugins(workspaceRoot, registry, deps, stderr, trustRoot, executionRunner) registerLocalControlTools(registry, workspaceRoot, resolved.LocalControl) diff --git a/internal/cli/mcp_late_disclosure_test.go b/internal/cli/mcp_late_disclosure_test.go index 436010239..612cfd807 100644 --- a/internal/cli/mcp_late_disclosure_test.go +++ b/internal/cli/mcp_late_disclosure_test.go @@ -13,7 +13,8 @@ import ( "github.com/Gitlawb/zero/internal/tools" ) -// A LAUNCH THAT COMPLETES AFTER THE REPORTER HAS RUN MUST STILL BE SAID, ONCE. +// A LAUNCH THAT COMPLETES AFTER THE REPORTER HAS RUN MUST STILL BE SAID, ONCE, +// AND ONLY WHILE SOMEONE OWNS THE WRITER. // // Both production paths call reportMCPStartupDisclosures exactly once, right // after RegisterTools returns. A stdio attempt abandoned at the connect timeout @@ -24,12 +25,17 @@ import ( // server and never the disclosure. // // This drives the REAL reporter against a REAL runtime, rather than polling -// StartupDisclosures by hand, which is what the previous regression did and -// which is precisely how it masked this: a test that re-reads on the tester's -// behalf proves nothing about a production path that does not. +// StartupDisclosures by hand, which is what an earlier regression did and which +// is precisely how it masked the original bug: a test that re-reads on the +// tester's behalf proves nothing about a production path that does not. +// +// It also never reads stderr while the pump could write. The buffer is examined +// only after stop has joined the pump, which is the same discipline both +// production callers follow, and is why this passes under -race. func TestLateMCPLaunchReachesTheStartupReporterExactlyOnce(t *testing.T) { const notice = "MCP server started without WRITE_RESTRICTED because denyRead is configured (#869)" released := make(chan struct{}) + published := make(chan struct{}) runtime, err := mcp.RegisterTools(context.Background(), tools.NewRegistry(), config.MCPConfig{Servers: map[string]config.MCPServerConfig{ @@ -42,6 +48,9 @@ func TestLateMCPLaunchReachesTheStartupReporterExactlyOnce(t *testing.T) { // registration has already reaped this attempt and returned. <-released mcp.PublishLaunchForTest(ctx, []string{notice}) + // Publishing is synchronous into the stream, so by the time this + // closes the disclosure is queued and stop cannot race past it. + close(published) return nil, errors.New("initialize failed long after start") }, }) @@ -53,17 +62,15 @@ func TestLateMCPLaunchReachesTheStartupReporterExactlyOnce(t *testing.T) { // The reporter runs ONCE, here, exactly as runExec and the interactive // startup path run it: before the launch has resolved. var stderr bytes.Buffer - reportMCPStartupDisclosures(&stderr, runtime) - if strings.Contains(stderr.String(), notice) { - t.Fatalf("the disclosure was printed before the process had started:\n%s", stderr.String()) - } + stop := reportMCPStartupDisclosures(&stderr, runtime) close(released) + <-published + // The owner ends delivery and joins the pump. Every write to the buffer has + // happened by the time this returns, so the reads below are unsynchronised + // only because there is no longer anything to synchronise with. + stop() - deadline := time.Now().Add(2 * time.Second) - for time.Now().Before(deadline) && !strings.Contains(stderr.String(), notice) { - time.Sleep(5 * time.Millisecond) - } got := stderr.String() if n := strings.Count(got, notice); n != 1 { t.Fatalf("a launch that completed after the reporter ran was disclosed %d time(s), want exactly 1:\n%s", n, got) @@ -97,10 +104,60 @@ func TestKnownMCPLaunchIsNotReportedTwice(t *testing.T) { t.Cleanup(func() { _ = runtime.Close() }) var stderr bytes.Buffer - reportMCPStartupDisclosures(&stderr, runtime) - // Give any misrouted late delivery a chance to double up. - time.Sleep(50 * time.Millisecond) + stop := reportMCPStartupDisclosures(&stderr, runtime) + stop() if n := strings.Count(stderr.String(), notice); n != 1 { t.Fatalf("a launch known at registration was disclosed %d time(s), want exactly 1:\n%s", n, stderr.String()) } } + +// THE OWNERSHIP BOUNDARY ITSELF: once the caller has stopped delivery, nothing +// may write to its writer again. +// +// This is the property that the retained presentation callback could not hold. +// It invoked the CLI's print function from the abandoned connect goroutine +// whenever the launch happened to resolve, so a write could land after runExec +// had returned or after Bubble Tea had taken the alt screen. The interactive +// path stops delivery on the line before it hands over the terminal, and this +// pins what that buys: a launch resolving afterwards is dropped, not printed. +func TestMCPDisclosureAfterStopIsDroppedNotWritten(t *testing.T) { + const notice = "MCP server started under reduced enforcement" + released := make(chan struct{}) + published := make(chan struct{}) + + runtime, err := mcp.RegisterTools(context.Background(), tools.NewRegistry(), + config.MCPConfig{Servers: map[string]config.MCPServerConfig{ + "slow": {Type: "stdio", Command: "slow-mcp"}, + }}, + mcp.RegisterOptions{ + ConnectTimeout: 50 * time.Millisecond, + ClientFactory: func(ctx context.Context, server mcp.Server) (mcp.ToolClient, error) { + <-released + mcp.PublishLaunchForTest(ctx, []string{notice}) + close(published) + return nil, errors.New("initialize failed long after start") + }, + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = runtime.Close() }) + + var stderr bytes.Buffer + stop := reportMCPStartupDisclosures(&stderr, runtime) + // The owner gives up the writer BEFORE the launch resolves, which is the + // interactive hand-off to the TUI. + stop() + + close(released) + <-published + // Asserting an absence, so the wrong behaviour is given time to appear: once + // publishing has returned the disclosure is queued, and a delivery path that + // outlived stop would have this long to print it. With delivery ended and the + // pump joined there is no writer left, so this window changes nothing. + time.Sleep(50 * time.Millisecond) + + if got := stderr.String(); got != "" { + t.Fatalf("a launch that resolved after the owner stopped still wrote to its writer: %q", got) + } +} diff --git a/internal/cli/mcp_tools.go b/internal/cli/mcp_tools.go index 5725dbb4c..5477f7d49 100644 --- a/internal/cli/mcp_tools.go +++ b/internal/cli/mcp_tools.go @@ -7,6 +7,7 @@ import ( "net/url" "sort" "strings" + "sync" "github.com/Gitlawb/zero/internal/config" "github.com/Gitlawb/zero/internal/execution" @@ -198,10 +199,11 @@ type mcpStartupDisclosing interface { StartupDisclosures() []mcp.StartupDisclosure } -// mcpStartupReporting is the push form: the runtime delivers each disclosure -// exactly once, including a launch that completes after registration returned. -type mcpStartupReporting interface { - ReportStartupDisclosures(func(mcp.StartupDisclosure)) +// mcpStartupStreaming is the push form: the runtime queues each disclosure as a +// typed event, including a launch that completes after registration returned, +// and this package drains it on the goroutine that owns stderr. +type mcpStartupStreaming interface { + StartupDisclosureStream() *mcp.StartupDisclosureStream } // reportMCPStartupDisclosures states once what enforcement applied to the MCP @@ -211,23 +213,59 @@ type mcpStartupReporting interface { // stdio attempt abandoned at the connect timeout could still be inside cmd.Start // at that moment. The process then started under the reduced write confinement, // the reaper closed its client, and nothing read the runtime again: the operator -// saw the skipped-server warning and never the disclosure. Subscribing hands -// the runtime a presentation to deliver to whenever the launch resolves, so a -// late Start is said once rather than never. The pull form is kept for a -// runtime that does not implement the push, which today is only test doubles. -func reportMCPStartupDisclosures(stderr io.Writer, runtime mcpToolRuntime) { +// saw the skipped-server warning and never the disclosure. +// +// THIS GOROUTINE OWNS THE WRITER. The runtime queues typed disclosures; every +// write to stderr happens either on the caller's goroutine (the set already known +// when this returns, in server order, so startup output keeps its order) or on +// the single pump started here, never both at once and never after stop. +// +// The returned stop ends delivery and joins the pump, so no write to stderr can +// outlive the caller's ownership of it. The caller must run it before handing the +// terminal to anything else. A disclosure that arrives after stop is dropped: it +// is worth printing while someone owns the writer, and worth losing rather than +// writing into a screen that now belongs to Bubble Tea. Anything already queued +// when stop runs is still printed, on the caller's goroutine, with the pump +// already finished. +// +// The pull form is kept for a runtime that implements no stream, which today is +// only test doubles; it has no late launches to deliver, so its stop is a no-op. +func reportMCPStartupDisclosures(stderr io.Writer, runtime mcpToolRuntime) (stop func()) { print := func(disclosure mcp.StartupDisclosure) { for _, notice := range disclosure.Notices { fmt.Fprintf(stderr, "notice: MCP server %s started with reduced enforcement: %s\n", disclosure.Name, notice) } } - if reporting, ok := runtime.(mcpStartupReporting); ok { - reporting.ReportStartupDisclosures(print) - return - } - if disclosing, ok := runtime.(mcpStartupDisclosing); ok { - for _, disclosure := range disclosing.StartupDisclosures() { + printAll := func(disclosures []mcp.StartupDisclosure) { + for _, disclosure := range disclosures { print(disclosure) } } + streaming, ok := runtime.(mcpStartupStreaming) + if !ok { + if disclosing, ok := runtime.(mcpStartupDisclosing); ok { + printAll(disclosing.StartupDisclosures()) + } + return func() {} + } + stream := streaming.StartupDisclosureStream() + if stream == nil { + return func() {} + } + printAll(stream.Drain()) + pumped := make(chan struct{}) + go func() { + defer close(pumped) + for stream.Wait() { + printAll(stream.Drain()) + } + }() + var once sync.Once + return func() { + once.Do(func() { + stream.Close() + <-pumped + printAll(stream.Drain()) + }) + } } diff --git a/internal/mcp/registry.go b/internal/mcp/registry.go index 3ea7934cf..ae34e30a1 100644 --- a/internal/mcp/registry.go +++ b/internal/mcp/registry.go @@ -80,8 +80,14 @@ type Runtime struct { // sink outlives registration and StartupDisclosures reads through it, so a // late Start is reported instead of lost. See StartupDisclosures. disclosureSources []disclosureSource - once sync.Once - err error + // disclosureStream is the typed hand-off to whoever owns the output. Created + // on the first StartupDisclosureStream call and closed by Close, so a launch + // that resolves after the runtime is gone has somewhere defined to land: + // nowhere. + disclosureStreamOnce sync.Once + disclosureStream *StartupDisclosureStream + once sync.Once + err error } // disclosureSource pairs a server with both the notices known at commit time and @@ -114,23 +120,32 @@ type disclosureSource struct { // // Late deliveries arrive in completion order, which is the only order they // have; the immediate set keeps server order. -func (runtime *Runtime) ReportStartupDisclosures(report func(StartupDisclosure)) { - if runtime == nil || report == nil { - return +// +// A STREAM, NOT A CALLBACK. An earlier version took the presentation function +// and invoked it from whichever goroutine resolved the launch, which for an +// abandoned attempt is the connect goroutine. That put a write to the caller's +// writer on a goroutine and at a time the caller did not control. The runtime +// owns the fact; it appends the fact here and the owner drains it. See +// StartupDisclosureStream. +func (runtime *Runtime) StartupDisclosureStream() *StartupDisclosureStream { + if runtime == nil { + return nil } - for _, source := range runtime.disclosureSources { - if len(source.notices) > 0 { - report(StartupDisclosure{Name: source.name, Notices: append([]string(nil), source.notices...)}) - continue - } - name := source.name - source.sink.subscribe(func(notices []string) { - if len(notices) == 0 { - return + runtime.disclosureStreamOnce.Do(func() { + stream := newStartupDisclosureStream() + runtime.disclosureStream = stream + for _, source := range runtime.disclosureSources { + if len(source.notices) > 0 { + stream.offer(StartupDisclosure{Name: source.name, Notices: append([]string(nil), source.notices...)}) + continue } - report(StartupDisclosure{Name: name, Notices: notices}) - }) - } + name := source.name + source.sink.subscribe(func(notices []string) { + stream.offer(StartupDisclosure{Name: name, Notices: notices}) + }) + } + }) + return runtime.disclosureStream } // Skipped returns the servers that were skipped during registration (unreachable @@ -409,6 +424,10 @@ func (runtime *Runtime) Close() error { return nil } runtime.once.Do(func() { + // End disclosure delivery FIRST. A launch that resolves while the clients + // are being closed has no owner left to print it, and the runtime must not + // leave a subscriber holding a writer whose lifetime it does not know. + runtime.disclosureStream.Close() for _, client := range runtime.clients { if err := client.Close(); err != nil && runtime.err == nil { runtime.err = err diff --git a/internal/mcp/startup_disclosure_stream.go b/internal/mcp/startup_disclosure_stream.go new file mode 100644 index 000000000..b153db414 --- /dev/null +++ b/internal/mcp/startup_disclosure_stream.go @@ -0,0 +1,116 @@ +package mcp + +import "sync" + +// StartupDisclosureStream carries launch disclosures out of the runtime as +// TYPED EVENTS, for whichever component owns the output to drain on its own +// goroutine. +// +// It replaces handing the runtime a presentation callback. That callback closed +// over the CLI's writer, and the runtime invoked it synchronously from whichever +// goroutine happened to resolve the launch. For a server still inside cmd.Start +// when registration gave up, that goroutine is the abandoned connect attempt, +// which runs on no schedule the caller controls: the write landed off the output +// owner's goroutine, raced any other writer, and could arrive after the owner had +// returned or after Bubble Tea had taken the alt screen. The runtime owns the +// FACT that a process started; it does not own anyone's writer. +// +// So the runtime only ever appends a value here. Delivery, ordering against other +// startup output, and the decision to stop listening all belong to the owner. +// +// LIFETIME IS EXPLICIT. Close is the owner saying "I will not write again". +// Offers after Close are dropped rather than queued for a consumer that no longer +// exists, and Wait returns false so a pump exits. Both are deliberate: a +// disclosure is worth printing while someone can print it, and worth dropping +// rather than corrupting a screen that now belongs to something else. Close is +// idempotent and safe from any goroutine, so the runtime and the owner may both +// call it. +type StartupDisclosureStream struct { + mu sync.Mutex + queue []StartupDisclosure + closed bool + wake chan struct{} +} + +func newStartupDisclosureStream() *StartupDisclosureStream { + return &StartupDisclosureStream{wake: make(chan struct{}, 1)} +} + +// offer queues one disclosure. Called from the registration goroutine for a +// launch already known at commit, and from an abandoned connect goroutine for one +// that resolves later. It takes a lock and appends; it never touches a writer, +// which is the whole point of the type. +func (stream *StartupDisclosureStream) offer(disclosure StartupDisclosure) { + if stream == nil || len(disclosure.Notices) == 0 { + return + } + stream.mu.Lock() + if stream.closed { + stream.mu.Unlock() + return + } + stream.queue = append(stream.queue, disclosure) + stream.mu.Unlock() + select { + case stream.wake <- struct{}{}: + default: + } +} + +// Drain removes and returns everything queued right now, without blocking. The +// owner calls this on the goroutine that owns the writer, so every disclosure is +// printed by exactly one goroutine at a time. +func (stream *StartupDisclosureStream) Drain() []StartupDisclosure { + if stream == nil { + return nil + } + stream.mu.Lock() + defer stream.mu.Unlock() + if len(stream.queue) == 0 { + return nil + } + queued := stream.queue + stream.queue = nil + return queued +} + +// Wait blocks until at least one disclosure is queued or the stream is closed. It +// reports whether draining is still worthwhile: false means closed and empty, so +// a pump loop should return. +func (stream *StartupDisclosureStream) Wait() bool { + if stream == nil { + return false + } + for { + stream.mu.Lock() + queued := len(stream.queue) > 0 + closed := stream.closed + stream.mu.Unlock() + if queued { + return true + } + if closed { + return false + } + <-stream.wake + } +} + +// Close ends delivery. Idempotent, safe from any goroutine, and safe to call +// from both the runtime and the output owner. +func (stream *StartupDisclosureStream) Close() { + if stream == nil { + return + } + stream.mu.Lock() + if stream.closed { + stream.mu.Unlock() + return + } + stream.closed = true + stream.mu.Unlock() + select { + case stream.wake <- struct{}{}: + default: + } +} From 28930a107a5bcce252bd4573e7c4499f223923c8 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Wed, 2 Sep 2026 20:25:38 +0530 Subject: [PATCH 31/43] fix(execution,sandbox): confirm the restricted child launched, not the Windows helper For a Windows restricted-token plan the command the runner starts is the sandbox helper, not the requested executable, so exec.Cmd.Process becomes non-nil as soon as the ordinary helper process starts. Setup-marker validation, unelevated ACL application, network-policy validation, capability and offline SID construction, restricted-token creation and CreateProcessAsUser all happen inside that helper and can each return without creating the requested process. On those paths AppliedEnforcementNotices still reported that reads were denied as requested, when the only thing that ran was the unsandboxed adapter. The fact belongs to whoever sees the transition. AdapterReport gains ChildLaunched, which the helper writes to the adapter-owned report file at the moment CreateProcessAsUser succeeds, and the runner believes over its own observation. A plan whose adapter owns the fact is marked, and silence from that adapter now means not launched rather than falling back to the wrapper's start, so a missing report cannot be read as proof that enforcement applied. The mark is per adapter, not plan.Wrapped: a bwrap plan is wrapped too and reports only denials, and treating its silence as "no child" would drop the disclosure from every successful Linux sandbox run. Direct, unwrapped commands are unchanged, since the process the runner starts is the requested one. The report file is created with O_EXCL under the per-user temp directory, so a name another local user pre-created makes the helper fail rather than supply the fact the parent reads back. The regression drives both sides: a helper that runs and never creates the child discloses nothing, an adapter that owns the fact and says nothing discloses nothing, a restricted child that starts and then exits non-zero discloses exactly once, and a direct command keeps its own observation. Ignoring the reported fact, or removing the fail-closed branch, fails it on the notice it wrongly disclosed. --- internal/execution/contracts.go | 15 +++ internal/execution/runner.go | 24 ++++ .../execution/wrapped_launch_state_test.go | 103 ++++++++++++++++ internal/sandbox/runner.go | 8 ++ .../windows_execution_report_windows.go | 44 +++++++ internal/sandbox/windows_process_windows.go | 9 ++ internal/sandbox/windows_runner.go | 110 ++++++++++++------ 7 files changed, 280 insertions(+), 33 deletions(-) create mode 100644 internal/execution/wrapped_launch_state_test.go create mode 100644 internal/sandbox/windows_execution_report_windows.go diff --git a/internal/execution/contracts.go b/internal/execution/contracts.go index c703ae915..78db0ca5d 100644 --- a/internal/execution/contracts.go +++ b/internal/execution/contracts.go @@ -206,6 +206,21 @@ type Outcome struct { // command text cannot impersonate a policy decision. type AdapterReport struct { Denial *Denial `json:"denial,omitempty"` + // ChildLaunched is the adapter's authoritative statement that the REQUESTED + // process started, for a plan where the command the runner starts is not that + // process. + // + // A wrapped plan starts a helper, and the helper creates the sandboxed child + // only after validating the setup marker, applying ACLs, checking the network + // policy, building capability SIDs and minting the restricted token. Any of + // those can fail with the helper already running, so the runner's own + // exec.Cmd.Process tells it the WRAPPER started and nothing about the child. + // Only the adapter sees that transition, so only the adapter may report it. + // + // nil means the adapter does not speak to this, and the runner keeps its own + // observation. That is correct for every direct, unwrapped command, where the + // process the runner starts IS the requested one. + ChildLaunched *bool `json:"childLaunched,omitempty"` } // ChildLaunched reports whether this outcome describes a process that actually diff --git a/internal/execution/runner.go b/internal/execution/runner.go index fb9310012..8adf23eaf 100644 --- a/internal/execution/runner.go +++ b/internal/execution/runner.go @@ -24,6 +24,12 @@ type PreparedCommand struct { Enforcement Enforcement Report func() (AdapterReport, error) Cleanup func() + // ChildLaunchOwnedByAdapter marks a plan where Command is a WRAPPER and the + // requested process is created inside it, so exec.Cmd.Process says nothing + // about whether the sandboxed child ever existed. The adapter must state the + // fact in its report; if it does not, the runner treats the child as not + // launched rather than crediting the wrapper's start. + ChildLaunchOwnedByAdapter bool } type CapturedRequest struct { @@ -99,6 +105,24 @@ func (runner *Runner) ExecuteCaptured(ctx context.Context, input CapturedRequest if prepared.Report != nil { report, reportErr = prepared.Report() } + // A WRAPPED PLAN'S LAUNCH BIT BELONGS TO THE ADAPTER. The line above observes + // the process THIS command started, which for a Windows restricted-token plan + // is the helper, not the requested executable: the helper validates the setup + // marker, applies ACLs, checks the network policy, builds capability SIDs and + // mints the restricted token after it is already running, and any of those can + // fail with no sandboxed child ever created. Believing the outer bit there + // reports that reads were denied as requested when only the unsandboxed + // adapter ran. An adapter that owns the inner transition overrides it; one + // that stays silent leaves the direct-command observation alone. + switch { + case report.ChildLaunched != nil: + launched = *report.ChildLaunched + case prepared.ChildLaunchOwnedByAdapter: + // The adapter owns this fact and did not state it, so the restricted child + // is not known to exist. Fail closed: an absent report must not be read as + // proof that enforcement applied. + launched = false + } result := CapturedResult{ Stdout: stdout.String(), Stderr: stderr.String(), diff --git a/internal/execution/wrapped_launch_state_test.go b/internal/execution/wrapped_launch_state_test.go new file mode 100644 index 000000000..9f4167f39 --- /dev/null +++ b/internal/execution/wrapped_launch_state_test.go @@ -0,0 +1,103 @@ +package execution + +import ( + "context" + "testing" +) + +// A WRAPPER'S START IS NOT THE REQUESTED CHILD'S START. +// +// For a Windows restricted-token plan the command the runner starts is the +// sandbox helper, not the executable the caller asked for. Inside that helper, +// setup-marker validation, unelevated ACL application, network-policy +// validation, capability and offline SID construction, restricted-token creation +// and the CreateProcessAsUser call all happen afterwards, and each of them can +// return with no sandboxed child ever created. exec.Cmd.Process is already +// non-nil by then, so reading the launch state off it reports that reads were +// denied as requested when the only thing that ran was the unsandboxed adapter. +// +// The fact belongs to whoever sees the transition. These pin both directions of +// that boundary. +type wrappedPreparer struct { + script string + owned bool + childLaunched *bool + reportNothing bool +} + +func (p *wrappedPreparer) PrepareExecution(ctx context.Context, _ Request) (PreparedCommand, error) { + prepared := PreparedCommand{ + Command: launchStateShell(ctx, p.script), + Enforcement: Enforcement{Notices: []string{launchStateNotice}}, + ChildLaunchOwnedByAdapter: p.owned, + } + if !p.reportNothing { + launched := p.childLaunched + prepared.Report = func() (AdapterReport, error) { + return AdapterReport{ChildLaunched: launched}, nil + } + } + return prepared, nil +} + +func capturedWrapped(t *testing.T, p *wrappedPreparer) CapturedResult { + t.Helper() + return NewRunner(p).ExecuteCaptured(context.Background(), CapturedRequest{Request: Request{ + Origin: OriginHook, + Mode: ModeCaptured, + Command: Command{Name: "irrelevant"}, + WorkingDirectory: t.TempDir(), + WorkspaceRoots: []string{t.TempDir()}, + Approval: ApprovalContext{PolicyVersion: PolicyVersion}, + }}) +} + +func TestWrappedPlanDisclosesOnlyWhatTheAdapterConfirms(t *testing.T) { + // The helper starts and then fails before it can create the restricted child: + // a bad setup marker, an ACL it could not apply, a network policy it rejected, + // a token it could not mint. The wrapper process exists; the sandboxed one + // never did, so nothing may be claimed about enforcement. + t.Run("helper ran but never created the child", func(t *testing.T) { + no := false + result := capturedWrapped(t, &wrappedPreparer{script: "exit 1", owned: true, childLaunched: &no}) + // The wrapper really did run, which is the whole point: its exit code is + // the script's. Without this the test could pass because nothing executed. + if result.Outcome.Exit == nil || result.Outcome.Exit.Code != 1 { + t.Fatalf("SETUP INVALID: the wrapper itself must have run; outcome = %+v", result.Outcome) + } + if notices := result.Outcome.AppliedEnforcementNotices(); len(notices) != 0 { + t.Fatalf("a helper that never created the restricted child disclosed %q", notices) + } + }) + + // Same shape, but the adapter says nothing at all. Silence from the owner of + // the fact is not permission to fall back to the wrapper's own start. + t.Run("adapter that owns the fact stayed silent", func(t *testing.T) { + result := capturedWrapped(t, &wrappedPreparer{script: "exit 1", owned: true, reportNothing: true}) + if notices := result.Outcome.AppliedEnforcementNotices(); len(notices) != 0 { + t.Fatalf("an unreported child launch was disclosed as applied enforcement: %q", notices) + } + }) + + // And the other side of the boundary: a restricted child that really started + // and then exited non-zero DID run under the disclosed enforcement, so the + // notice must still be made, exactly once. + t.Run("restricted child started, then failed", func(t *testing.T) { + yes := true + result := capturedWrapped(t, &wrappedPreparer{script: "exit 3", owned: true, childLaunched: &yes}) + notices := result.Outcome.AppliedEnforcementNotices() + if len(notices) != 1 || notices[0] != launchStateNotice { + t.Fatalf("a child that ran and then failed disclosed %q, want exactly one %q", notices, launchStateNotice) + } + }) + + // A direct, unwrapped command is unchanged: the process the runner starts IS + // the requested one, so its own observation still decides. + t.Run("direct command keeps its own observation", func(t *testing.T) { + result := capturedWrapped(t, &wrappedPreparer{script: "exit 0", owned: false, reportNothing: true}) + notices := result.Outcome.AppliedEnforcementNotices() + if len(notices) != 1 || notices[0] != launchStateNotice { + t.Fatalf("a direct command that ran disclosed %q, want exactly one %q", notices, launchStateNotice) + } + }) +} diff --git a/internal/sandbox/runner.go b/internal/sandbox/runner.go index c6d260c39..2b794cb68 100644 --- a/internal/sandbox/runner.go +++ b/internal/sandbox/runner.go @@ -70,6 +70,12 @@ type CommandPlan struct { // workspace. It carries structured policy facts; command output is never // parsed as the control protocol. executionReportPath string + // childLaunchReported marks a plan whose helper publishes the authoritative + // child-launch fact through executionReportPath. Set ONLY by adapters that + // actually write it: Wrapped alone is not enough, since a bwrap plan is also + // wrapped and reports only denials, and treating its silence as "no child" + // would deny every successful Linux sandbox run its disclosure. + childLaunchReported bool } // Cleanup releases any resources the plan holds. It is safe to call on a zero @@ -135,6 +141,8 @@ func (engine *Engine) PrepareExecution(ctx context.Context, request execution.Re Enforcement: EnforcementFor(plan), Report: plan.ExecutionReport, Cleanup: plan.Cleanup, + // Only for adapters that publish the fact; see CommandPlan.childLaunchReported. + ChildLaunchOwnedByAdapter: plan.childLaunchReported, }, nil } diff --git a/internal/sandbox/windows_execution_report_windows.go b/internal/sandbox/windows_execution_report_windows.go new file mode 100644 index 000000000..6fcb157dd --- /dev/null +++ b/internal/sandbox/windows_execution_report_windows.go @@ -0,0 +1,44 @@ +//go:build windows + +package sandbox + +import ( + "encoding/json" + "os" + "strings" + + "github.com/Gitlawb/zero/internal/execution" +) + +// writeWindowsExecutionReport publishes the helper's structured report to the +// adapter-owned side channel. +// +// The only fact it currently carries is whether the REQUESTED process was +// created. The parent starts this helper, so the parent's own exec.Cmd.Process +// proves the helper ran and nothing more: setup-marker validation, ACL +// application, network-policy validation, capability and offline SID +// construction and restricted-token creation all happen afterwards and can each +// return with no sandboxed child. Only this process observes the transition, so +// only this process may report it. +// +// O_EXCL, so a file another local user pre-created at this name makes the write +// fail instead of letting them supply the fact the parent reads back. An empty +// path means the caller asked for no report, which keeps the standalone helper +// and every existing test working unchanged. +func writeWindowsExecutionReport(path string, childLaunched bool) error { + if strings.TrimSpace(path) == "" { + return nil + } + file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + return err + } + launched := childLaunched + encodeErr := json.NewEncoder(file).Encode(execution.AdapterReport{ChildLaunched: &launched}) + closeErr := file.Close() + if encodeErr != nil { + _ = os.Remove(path) + return encodeErr + } + return closeErr +} diff --git a/internal/sandbox/windows_process_windows.go b/internal/sandbox/windows_process_windows.go index f5dde58a0..adc5c391d 100644 --- a/internal/sandbox/windows_process_windows.go +++ b/internal/sandbox/windows_process_windows.go @@ -73,6 +73,15 @@ func runWindowsCommandAsUser(token windows.Token, config WindowsSandboxCommandCo } defer windows.CloseHandle(process.Process) defer windows.CloseHandle(process.Thread) + // THE TRANSITION ONLY THIS PROCESS CAN SEE. Everything above can fail with the + // helper already running, and the parent's exec.Cmd.Process cannot tell those + // failures apart from a real sandboxed launch. The restricted child exists as + // of this line, so this is where the fact is published. A write failure is + // reported rather than swallowed: the parent fails closed on a missing report, + // so silence must not be mistaken for a launch that did happen. + if err := writeWindowsExecutionReport(config.ExecutionReportPath, true); err != nil { + return 1, fmt.Errorf("record sandboxed child launch: %w", err) + } if _, err := windows.WaitForSingleObject(process.Process, windows.INFINITE); err != nil { return 1, fmt.Errorf("wait for sandboxed process: %w", err) } diff --git a/internal/sandbox/windows_runner.go b/internal/sandbox/windows_runner.go index 032f2a844..a31dfae28 100644 --- a/internal/sandbox/windows_runner.go +++ b/internal/sandbox/windows_runner.go @@ -3,6 +3,7 @@ package sandbox import ( "crypto/rand" "encoding/binary" + "encoding/hex" "encoding/json" "errors" "fmt" @@ -156,23 +157,30 @@ func windowsShellCommandLineFromArgs(args []string) (string, bool) { } type WindowsSandboxCommandArgsOptions struct { - SandboxHome string - CommandCWD string - WorkspaceRoots []string - PermissionProfile PermissionProfile - Env []string - SandboxLevel WindowsSandboxLevel - Command []string + // ExecutionReportPath is where the helper writes its structured report, + // including the authoritative fact that the sandboxed child was created. + ExecutionReportPath string + SandboxHome string + CommandCWD string + WorkspaceRoots []string + PermissionProfile PermissionProfile + Env []string + SandboxLevel WindowsSandboxLevel + Command []string } type WindowsSandboxCommandConfig struct { - SandboxHome string - CommandCWD string - WorkspaceRoots []string - PermissionProfile PermissionProfile - Env map[string]string - SandboxLevel WindowsSandboxLevel - Command []string + // ExecutionReportPath is the adapter-owned side channel back to the runner. + // Empty when the caller wants no report, which keeps every existing test and + // the standalone helper working unchanged. + ExecutionReportPath string + SandboxHome string + CommandCWD string + WorkspaceRoots []string + PermissionProfile PermissionProfile + Env map[string]string + SandboxLevel WindowsSandboxLevel + Command []string } func BuildWindowsSandboxCommandArgs(options WindowsSandboxCommandArgsOptions) ([]string, error) { @@ -217,6 +225,9 @@ func BuildWindowsSandboxCommandArgs(options WindowsSandboxCommandArgsOptions) ([ "--env-json", string(envJSON), "--windows-sandbox-level", string(level), } + if reportPath := strings.TrimSpace(options.ExecutionReportPath); reportPath != "" { + args = append(args, "--execution-report", reportPath) + } for _, root := range workspaceRoots { args = append(args, "--workspace-root", root) } @@ -249,6 +260,13 @@ func ParseWindowsSandboxCommandArgs(args []string) (WindowsSandboxCommandConfig, } config.SandboxHome = strings.TrimSpace(value) index = next + case "--execution-report": + value, next, err := nextWindowsSandboxFlagValue(args, index) + if err != nil { + return WindowsSandboxCommandConfig{}, err + } + config.ExecutionReportPath = strings.TrimSpace(value) + index = next case "--workspace-root": value, next, err := nextWindowsSandboxFlagValue(args, index) if err != nil { @@ -335,14 +353,23 @@ func windowsRestrictedTokenCommandPlan(execRequest SandboxExecutionRequest, poli if execRequest.EnforcementLevel == EnforcementUnelevated { level = WindowsSandboxLevelUnelevated } + // The helper's side channel back to us. The runner starts the helper, so its + // own exec.Cmd.Process only proves the HELPER ran; everything that makes this + // a sandbox happens inside, after that. The helper writes the authoritative + // child-launch fact here and the runner believes it over its own observation. + reportPath, err := newWindowsExecutionReportPath() + if err != nil { + return CommandPlan{}, err + } args, err := BuildWindowsSandboxCommandArgs(WindowsSandboxCommandArgsOptions{ - SandboxHome: sandboxHome, - CommandCWD: spec.Dir, - WorkspaceRoots: []string{execRequest.WorkspaceRoot}, - PermissionProfile: execRequest.PermissionProfile, - Env: childEnv, - SandboxLevel: level, - Command: append([]string{spec.Name}, spec.Args...), + ExecutionReportPath: reportPath, + SandboxHome: sandboxHome, + CommandCWD: spec.Dir, + WorkspaceRoots: []string{execRequest.WorkspaceRoot}, + PermissionProfile: execRequest.PermissionProfile, + Env: childEnv, + SandboxLevel: level, + Command: append([]string{spec.Name}, spec.Args...), }) if err != nil { return CommandPlan{}, err @@ -353,21 +380,38 @@ func windowsRestrictedTokenCommandPlan(execRequest SandboxExecutionRequest, poli // helper .exe, where args are passed unchanged. fullArgs := append(append([]string{}, execRequest.Backend.ExecutableArgsPrefix...), args...) return withSandboxExecutionMetadata(CommandPlan{ - Backend: execRequest.Backend, - TargetBackend: execRequest.TargetBackend, - WorkspaceRoot: execRequest.WorkspaceRoot, - Policy: policy, - Wrapped: true, - SandboxEnvMarkers: execRequest.SandboxEnvMarkers, - EnforcementLevel: execRequest.EnforcementLevel, - Name: execRequest.Backend.Executable, - Args: fullArgs, - Dir: spec.Dir, - Env: childEnv, - SandboxDir: spec.Dir, + Backend: execRequest.Backend, + TargetBackend: execRequest.TargetBackend, + WorkspaceRoot: execRequest.WorkspaceRoot, + Policy: policy, + Wrapped: true, + SandboxEnvMarkers: execRequest.SandboxEnvMarkers, + EnforcementLevel: execRequest.EnforcementLevel, + Name: execRequest.Backend.Executable, + Args: fullArgs, + Dir: spec.Dir, + Env: childEnv, + SandboxDir: spec.Dir, + executionReportPath: reportPath, + childLaunchReported: true, + cleanup: func() { + _ = os.Remove(reportPath) + }, }, execRequest), nil } +// newWindowsExecutionReportPath names the helper's report file under the +// per-user temp directory. Random, and the helper creates it with O_EXCL, so a +// name another local user pre-created makes the write fail rather than letting +// them dictate the fact the runner reads back. +func newWindowsExecutionReportPath() (string, error) { + var token [16]byte + if _, err := rand.Read(token[:]); err != nil { + return "", fmt.Errorf("generate sandbox execution report path: %w", err) + } + return filepath.Join(os.TempDir(), "zero-sandbox-report-"+hex.EncodeToString(token[:])+".json"), nil +} + func upsertEnvList(env []string, values ...string) []string { out := cloneStrings(env) for _, value := range values { From 3232919239b8edc42a425617a92ae5d66196ba05 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Thu, 3 Sep 2026 12:43:59 +0530 Subject: [PATCH 32/43] fix(execution,sandbox,mcp,tools): one launch fact, applied by every launcher The adapter-owned child-launch contract was interpreted only by Runner.ExecuteCaptured. bash, exec_command/ProcessManager and durable stdio MCP each copied a subset of the prepared state and decided launch independently, so the same false disclosure survived in every path the earlier fix did not touch: a Windows helper that starts and then fails during marker, ACL, network, SID, token or CreateProcessAsUser setup still promoted the planned DenyRead notice even though no restricted child ever existed. ResolveChildLaunched is now the single answer: the adapter's report wins in both directions, an adapter that owns the fact and stays silent means not launched, and anything else keeps the caller's own observation, which is correct for a direct command and for bwrap. The captured runner, bash, and the exec_command conversion all call it. ProcessManager carries the ownership bit into ProcessResult so the retained write_stdin shape has it too, where the helper can be returned before it has even attempted the inner launch. connectStdio keeps prepared.Report and the ownership bit and no longer publishes at cmd.Start for a wrapped plan; it publishes once the adapter confirms, on both ways the attempt can end, which is also where an attempt abandoned at the connect timeout lands. Separately, the helper no longer drops ownership of a child it created. The report file is claimed BEFORE CreateProcessAsUser, so a failure to obtain the side channel happens while there is still nothing to own, and a failure to publish afterwards terminates and reaps the child instead of returning while it runs with nobody waiting on it and the parent free to start a second one. A report that was never published is removed, so a truncated file cannot be read back as a launch. The regression drives the production conversion rather than the shared helper: asserting the launch again fails it on three property assertions naming the notice it wrongly disclosed. --- internal/execution/process_manager.go | 13 +- internal/execution/runner.go | 32 +++-- internal/mcp/client.go | 33 ++++- internal/sandbox/runner.go | 8 ++ .../windows_execution_report_windows.go | 98 +++++++++++---- internal/sandbox/windows_process_windows.go | 25 +++- internal/tools/bash.go | 8 ++ internal/tools/exec_command.go | 49 +++++--- internal/tools/exec_launch_contract_test.go | 119 ++++++++++++++++++ 9 files changed, 326 insertions(+), 59 deletions(-) create mode 100644 internal/tools/exec_launch_contract_test.go diff --git a/internal/execution/process_manager.go b/internal/execution/process_manager.go index 7e4005fc8..29045f5ee 100644 --- a/internal/execution/process_manager.go +++ b/internal/execution/process_manager.go @@ -74,8 +74,12 @@ type ProcessResult struct { Enforcement Enforcement Report AdapterReport ReportErr error - Changes []Change - Metadata map[string]string + // ChildLaunchOwnedByAdapter carries the prepared plan's ownership of the + // requested-child launch fact through to the caller, which for a retained + // session no longer has the plan. + ChildLaunchOwnedByAdapter bool + Changes []Change + Metadata map[string]string } type ProcessSnapshot struct { @@ -148,6 +152,7 @@ func (manager *ProcessManager) Start(ctx context.Context, input ProcessStart, wa command: command, request: request, enforcement: input.Prepared.Enforcement, + ownedLaunch: input.Prepared.ChildLaunchOwnedByAdapter, report: input.Prepared.Report, cleanup: input.Prepared.Cleanup, stdin: stdin, @@ -372,6 +377,7 @@ type managedProcess struct { command *exec.Cmd request Request enforcement Enforcement + ownedLaunch bool report func() (AdapterReport, error) cleanup func() stdin io.WriteCloser @@ -413,7 +419,8 @@ func (process *managedProcess) collectResult(ctx context.Context, wait time.Dura TTY: process.tty, Output: output, OutputTruncated: truncated, Exited: exited, ExitCode: exitCode, Interrupted: interrupted, Request: process.request, Enforcement: process.enforcement, Report: process.resultReport, ReportErr: process.reportErr, - Changes: append([]Change(nil), process.changes...), Metadata: cloneStringMap(process.metadata), + ChildLaunchOwnedByAdapter: process.ownedLaunch, + Changes: append([]Change(nil), process.changes...), Metadata: cloneStringMap(process.metadata), } process.mu.Unlock() return result diff --git a/internal/execution/runner.go b/internal/execution/runner.go index 8adf23eaf..587c92739 100644 --- a/internal/execution/runner.go +++ b/internal/execution/runner.go @@ -114,15 +114,7 @@ func (runner *Runner) ExecuteCaptured(ctx context.Context, input CapturedRequest // reports that reads were denied as requested when only the unsandboxed // adapter ran. An adapter that owns the inner transition overrides it; one // that stays silent leaves the direct-command observation alone. - switch { - case report.ChildLaunched != nil: - launched = *report.ChildLaunched - case prepared.ChildLaunchOwnedByAdapter: - // The adapter owns this fact and did not state it, so the restricted child - // is not known to exist. Fail closed: an absent report must not be read as - // proof that enforcement applied. - launched = false - } + launched = ResolveChildLaunched(launched, prepared.ChildLaunchOwnedByAdapter, report) result := CapturedResult{ Stdout: stdout.String(), Stderr: stderr.String(), @@ -187,6 +179,28 @@ func (runner *Runner) Prepare(ctx context.Context, request Request) (PreparedCom return preparer.PrepareExecution(ctx, request) } +// ResolveChildLaunched decides whether the REQUESTED process launched. +// +// ONE IMPLEMENTATION, because every launcher needs the same answer and each one +// that re-derived it got a different one. observed is what the caller saw of the +// process IT started, which for a wrapped plan is the helper and not the +// requested child. +// +// - the adapter stated the fact: believe the adapter, in both directions. +// - the adapter owns the fact and stayed silent: not launched. An absent report +// must not be read as proof that enforcement applied. +// - nobody owns it but the caller: keep the direct observation, which is +// correct for a direct command and for bwrap. +func ResolveChildLaunched(observed bool, ownedByAdapter bool, report AdapterReport) bool { + if report.ChildLaunched != nil { + return *report.ChildLaunched + } + if ownedByAdapter { + return false + } + return observed +} + func capturedSetupFailure(message string, err error, enforcement Enforcement) CapturedResult { return CapturedResult{ Stderr: message, diff --git a/internal/mcp/client.go b/internal/mcp/client.go index 4c15b5dac..808ac2f9c 100644 --- a/internal/mcp/client.go +++ b/internal/mcp/client.go @@ -189,6 +189,10 @@ func connectStdio(ctx context.Context, server Server, options ConnectOptions) (* var cmd *exec.Cmd var cleanup func() var plannedEnforcement execution.Enforcement + // Retained from the prepared plan rather than dropped: for a wrapped plan the + // adapter, not cmd.Start, owns whether the requested server process exists. + var adapterReport func() (execution.AdapterReport, error) + var ownedLaunch bool cleanupTransferred := false defer func() { if cleanup != nil && !cleanupTransferred { @@ -214,6 +218,8 @@ func connectStdio(ctx context.Context, server Server, options ConnectOptions) (* cmd = prepared.Command cleanup = prepared.Cleanup plannedEnforcement = prepared.Enforcement + adapterReport = prepared.Report + ownedLaunch = prepared.ChildLaunchOwnedByAdapter } else { cmd = exec.CommandContext(ctx, server.Command, server.Args...) cmd.Env = mergeProcessEnv(server.Env) @@ -237,7 +243,30 @@ func connectStdio(ctx context.Context, server Server, options ConnectOptions) (* // tools/list above it in the caller) can hang past that. Announcing the launch // here is what lets an abandoned attempt still disclose the confinement its // process ran under. - publishLaunch(ctx, plannedEnforcement.Notices) + // + // EXCEPT WHEN START IS NOT THE LAUNCH. For a wrapped plan the process started + // above is the sandbox helper, which creates the MCP server only after marker, + // ACL, network, SID and token setup, any of which can fail leaving no server at + // all. Publishing here would make the durable delivery machinery reliably + // announce a confinement nothing ever ran under. Those plans publish from + // publishAdapterLaunch below, once the adapter has stated the fact. + if !ownedLaunch { + publishLaunch(ctx, plannedEnforcement.Notices) + } + // publishAdapterLaunch announces a wrapped plan's launch, but only if the + // adapter confirms the requested child was created. Called on both ways this + // attempt can end, which is also where an attempt abandoned at the connect + // timeout eventually arrives, so a late disclosure is still delivered once. + publishAdapterLaunch := func() { + if !ownedLaunch || adapterReport == nil { + return + } + report, err := adapterReport() + if err != nil || report.ChildLaunched == nil || !*report.ChildLaunched { + return + } + publishLaunch(ctx, plannedEnforcement.Notices) + } client := &Client{ server: server, @@ -262,6 +291,7 @@ func connectStdio(ctx context.Context, server Server, options ConnectOptions) (* // had, so the operator was told the server was unavailable and not that it // had already run without the write jail. _ = client.Close() + publishAdapterLaunch() message := strings.TrimSpace(stderr.String()) failure := fmt.Errorf("initialize MCP server %s: %w", server.Name, err) if message != "" { @@ -269,6 +299,7 @@ func connectStdio(ctx context.Context, server Server, options ConnectOptions) (* } return nil, &startupDisclosureError{err: failure, notices: client.StartupNotices()} } + publishAdapterLaunch() return client, nil } diff --git a/internal/sandbox/runner.go b/internal/sandbox/runner.go index 2b794cb68..5b193b832 100644 --- a/internal/sandbox/runner.go +++ b/internal/sandbox/runner.go @@ -78,6 +78,14 @@ type CommandPlan struct { childLaunchReported bool } +// ChildLaunchOwnedByAdapter reports whether this plan starts a WRAPPER whose +// helper creates the requested process itself, so the requested child launched +// only if the adapter says so. False for a direct command, where the process the +// caller starts IS the requested one. +func (plan CommandPlan) ChildLaunchOwnedByAdapter() bool { + return plan.childLaunchReported +} + // Cleanup releases any resources the plan holds. It is safe to call on a zero // plan and to call more than once. func (plan CommandPlan) Cleanup() { diff --git a/internal/sandbox/windows_execution_report_windows.go b/internal/sandbox/windows_execution_report_windows.go index 6fcb157dd..9e474a2c9 100644 --- a/internal/sandbox/windows_execution_report_windows.go +++ b/internal/sandbox/windows_execution_report_windows.go @@ -4,41 +4,91 @@ package sandbox import ( "encoding/json" + "fmt" "os" "strings" "github.com/Gitlawb/zero/internal/execution" + "golang.org/x/sys/windows" ) -// writeWindowsExecutionReport publishes the helper's structured report to the -// adapter-owned side channel. +// windowsExecutionReport is the helper's side channel back to the parent. // -// The only fact it currently carries is whether the REQUESTED process was -// created. The parent starts this helper, so the parent's own exec.Cmd.Process -// proves the helper ran and nothing more: setup-marker validation, ACL -// application, network-policy validation, capability and offline SID -// construction and restricted-token creation all happen afterwards and can each -// return with no sandboxed child. Only this process observes the transition, so -// only this process may report it. +// The only fact it carries is whether the REQUESTED process was created. The +// parent starts this helper, so the parent's own exec.Cmd.Process proves the +// helper ran and nothing more: setup-marker validation, ACL application, +// network-policy validation, capability and offline SID construction and +// restricted-token creation all happen afterwards and can each return with no +// sandboxed child. Only this process observes the transition, so only this +// process may report it. // -// O_EXCL, so a file another local user pre-created at this name makes the write -// fail instead of letting them supply the fact the parent reads back. An empty -// path means the caller asked for no report, which keeps the standalone helper -// and every existing test working unchanged. -func writeWindowsExecutionReport(path string, childLaunched bool) error { - if strings.TrimSpace(path) == "" { - return nil +// OPENED BEFORE THE LAUNCH, ON PURPOSE. Publishing is not free of failure, and +// once CreateProcessAsUser has succeeded a running child exists whether or not +// the report can be written. Acquiring the file first moves every failure that +// can be moved to a point where there is still nothing to own; what remains is +// handled by reaping the child rather than returning while it runs. +type windowsExecutionReport struct { + file *os.File + path string +} + +// openWindowsExecutionReport claims the report path before anything is launched. +// +// O_EXCL, so a file another local user pre-created at this name makes the helper +// fail here, before any child exists, instead of letting them supply the fact +// the parent reads back. An empty path means the caller wants no report, which +// keeps the standalone helper and every existing test working unchanged. +func openWindowsExecutionReport(path string) (*windowsExecutionReport, error) { + trimmed := strings.TrimSpace(path) + if trimmed == "" { + return &windowsExecutionReport{}, nil } - file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + file, err := os.OpenFile(trimmed, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) if err != nil { - return err + return nil, fmt.Errorf("open sandbox execution report: %w", err) + } + return &windowsExecutionReport{file: file, path: trimmed}, nil +} + +// publish records the launch fact. Safe on a report the caller never opened. +func (report *windowsExecutionReport) publish(childLaunched bool) error { + if report == nil || report.file == nil { + return nil } launched := childLaunched - encodeErr := json.NewEncoder(file).Encode(execution.AdapterReport{ChildLaunched: &launched}) - closeErr := file.Close() - if encodeErr != nil { - _ = os.Remove(path) - return encodeErr + if err := json.NewEncoder(report.file).Encode(execution.AdapterReport{ChildLaunched: &launched}); err != nil { + return fmt.Errorf("write sandbox execution report: %w", err) + } + return nil +} + +// close releases the handle. Discards the file when nothing was published, so a +// truncated or empty report can never be read back as a launch that happened. +func (report *windowsExecutionReport) close(published bool) { + if report == nil || report.file == nil { + return + } + closeErr := report.file.Close() + if !published || closeErr != nil { + _ = os.Remove(report.path) + } + report.file = nil +} + +// terminateAndReapWindowsChild takes down a child this helper has launched and +// waits for it to actually exit. +// +// Used on the paths where the helper cannot continue after CreateProcessAsUser +// has already succeeded. Returning there without this would leave the requested +// command or MCP server running with nobody waiting on it, cancelling it, or +// cleaning up after it, while the parent reads a missing report, concludes no +// child launched, and is free to start a second one. +func terminateAndReapWindowsChild(process windows.Handle) { + if process == 0 { + return } - return closeErr + // The exit code is irrelevant: this path is already returning an error, and + // the point is that the child is gone before the helper is. + _ = windows.TerminateProcess(process, 1) + _, _ = windows.WaitForSingleObject(process, windows.INFINITE) } diff --git a/internal/sandbox/windows_process_windows.go b/internal/sandbox/windows_process_windows.go index adc5c391d..ce751a2e3 100644 --- a/internal/sandbox/windows_process_windows.go +++ b/internal/sandbox/windows_process_windows.go @@ -56,6 +56,16 @@ func runWindowsCommandAsUser(token windows.Token, config WindowsSandboxCommandCo startup.StdErr = stderr var process windows.ProcessInformation envPtr := &envBlock[0] + // Claim the report side channel BEFORE the launch. Publishing can fail, and + // after CreateProcessAsUser succeeds a running child exists whether or not the + // fact can be recorded; taking the file first moves that failure to a point + // where there is still nothing to own. + report, err := openWindowsExecutionReport(config.ExecutionReportPath) + if err != nil { + return 1, err + } + published := false + defer func() { report.close(published) }() if err := windows.CreateProcessAsUser( token, nil, @@ -76,12 +86,19 @@ func runWindowsCommandAsUser(token windows.Token, config WindowsSandboxCommandCo // THE TRANSITION ONLY THIS PROCESS CAN SEE. Everything above can fail with the // helper already running, and the parent's exec.Cmd.Process cannot tell those // failures apart from a real sandboxed launch. The restricted child exists as - // of this line, so this is where the fact is published. A write failure is - // reported rather than swallowed: the parent fails closed on a missing report, - // so silence must not be mistaken for a launch that did happen. - if err := writeWindowsExecutionReport(config.ExecutionReportPath, true); err != nil { + // of this line, so this is where the fact is published. + // + // OWNERSHIP OUTLIVES REPORTING. The child is runnable and may already be + // making external side effects, so a failure to publish must not return from + // here and leave it running with nobody waiting on it: the parent would read a + // missing report, correctly conclude that no child launched, and be free to + // start a second one alongside the first. Take it down and reap it, then + // report the failure. + if err := report.publish(true); err != nil { + terminateAndReapWindowsChild(process.Process) return 1, fmt.Errorf("record sandboxed child launch: %w", err) } + published = true if _, err := windows.WaitForSingleObject(process.Process, windows.INFINITE); err != nil { return 1, fmt.Errorf("wait for sandboxed process: %w", err) } diff --git a/internal/tools/bash.go b/internal/tools/bash.go index 13c59a76f..38b413c23 100644 --- a/internal/tools/bash.go +++ b/internal/tools/bash.go @@ -172,6 +172,14 @@ func (tool bashTool) run(ctx context.Context, args map[string]any, engine *zeroS launched := command.Process != nil exitCode := commandExitCode(err) adapterReport, reportErr := plan.ExecutionReport() + // AND FOR A WRAPPED PLAN THAT OBSERVATION IS OF THE WRAPPER. On Windows the + // command started here is the sandbox helper; it creates the requested child + // only after marker, ACL, network, SID and token setup, any of which can fail + // with the helper already running. Reading the report was not enough on its + // own: the launch decision has to consume it, or bash promotes the planned + // DenyRead notice for a command that never ran under that enforcement. Same + // resolution the captured runner uses, so the two cannot drift. + launched = execution.ResolveChildLaunched(launched, plan.ChildLaunchOwnedByAdapter(), adapterReport) meta["exit_code"] = strconv.Itoa(exitCode) stdoutText := stdout.retained() stderrRetained := stderr.retained() diff --git a/internal/tools/exec_command.go b/internal/tools/exec_command.go index 27e0c7461..9c048d402 100644 --- a/internal/tools/exec_command.go +++ b/internal/tools/exec_command.go @@ -204,6 +204,9 @@ func (tool execCommandTool) run(ctx context.Context, args map[string]any, engine Command: command, Enforcement: executionEnforcement(plan), Report: plan.ExecutionReport, + // A wrapped plan starts a helper; the requested child is created inside + // it and only the adapter sees that transition. + ChildLaunchOwnedByAdapter: plan.ChildLaunchOwnedByAdapter(), Cleanup: func() { plan.Cleanup() cancel() @@ -229,8 +232,9 @@ func (tool execCommandTool) run(ctx context.Context, args map[string]any, engine exitCode: processResult.ExitCode, exited: processResult.Exited, relativeCwd: processResult.RelativeCwd, tty: processResult.TTY, request: processResult.Request, enforcement: processResult.Enforcement, report: processResult.Report, reportErr: processResult.ReportErr, changes: processResult.Changes, - sandboxMeta: processResult.Metadata, - maxOutputTokens: maxOutputTokens, + childLaunchOwnedByAdapter: processResult.ChildLaunchOwnedByAdapter, + sandboxMeta: processResult.Metadata, + maxOutputTokens: maxOutputTokens, }, directBudget) } @@ -366,7 +370,8 @@ func (tool writeStdinTool) RunWithOptions(ctx context.Context, args map[string]a exitCode: processResult.ExitCode, exited: processResult.Exited, relativeCwd: processResult.RelativeCwd, tty: processResult.TTY, interrupted: processResult.Interrupted, request: processResult.Request, enforcement: processResult.Enforcement, report: processResult.Report, reportErr: processResult.ReportErr, - changes: processResult.Changes, sandboxMeta: processResult.Metadata, + childLaunchOwnedByAdapter: processResult.ChildLaunchOwnedByAdapter, + changes: processResult.Changes, sandboxMeta: processResult.Metadata, maxOutputTokens: maxOutputTokens, }) } @@ -408,17 +413,20 @@ type execToolResultInput struct { // exec_command paths set it true because a start failure returns an // errorResult before reaching here; bash cannot, because it routes a // pre-start Run error through the same conversion. - launched bool - relativeCwd string - tty bool - interrupted bool - request execution.Request - enforcement execution.Enforcement - sandboxMeta map[string]string - report execution.AdapterReport - reportErr error - changes []execution.Change - maxOutputTokens int + launched bool + // childLaunchOwnedByAdapter marks a wrapped plan, where launched above + // describes the helper rather than the requested process. + childLaunchOwnedByAdapter bool + relativeCwd string + tty bool + interrupted bool + request execution.Request + enforcement execution.Enforcement + sandboxMeta map[string]string + report execution.AdapterReport + reportErr error + changes []execution.Change + maxOutputTokens int } func execToolResult(input execToolResultInput) Result { @@ -438,10 +446,15 @@ func execToolResultWithBudget(input execToolResultInput, directBudget bool) Resu for key, value := range input.sandboxMeta { meta[key] = value } - // True here by construction: every path that reaches this conversion has - // already started a process, because a start failure returns an errorResult - // above without building an execution outcome. - input.launched = true + // A process started here by construction, because a start failure returns an + // errorResult above without building an execution outcome. But for a wrapped + // plan that process is the SANDBOX HELPER, which creates the requested child + // only after marker, ACL, network, SID and token setup. So hand the observation + // to the same resolution every other launcher uses instead of asserting it. + // The retained path matters most here: the helper can be returned before it + // has attempted the inner launch, and an absent report then means not yet + // launched rather than launched. + input.launched = execution.ResolveChildLaunched(true, input.childLaunchOwnedByAdapter, input.report) outcome := execExecutionOutcome(input) if input.exited { meta["exit_code"] = strconv.Itoa(input.exitCode) diff --git a/internal/tools/exec_launch_contract_test.go b/internal/tools/exec_launch_contract_test.go new file mode 100644 index 000000000..0a8d0625d --- /dev/null +++ b/internal/tools/exec_launch_contract_test.go @@ -0,0 +1,119 @@ +package tools + +import ( + "testing" + + "github.com/Gitlawb/zero/internal/execution" +) + +// THE LAUNCH FACT HAS TO SURVIVE EVERY RESULT SHAPE, NOT JUST THE CAPTURED ONE. +// +// exec_command builds its outcome through its own conversion rather than through +// Runner.ExecuteCaptured, and that conversion used to assert `launched = true` on +// the grounds that a start failure returns earlier. That reasoning holds for the +// process the tool starts, and on Windows a wrapped plan starts the sandbox +// helper: the requested child is created inside it, after marker, ACL, network, +// SID and token setup, any of which can fail with the helper already running. +// Asserting the launch there tells the operator that reads were denied in +// exchange for the write jail when nothing ran under that enforcement. +// +// The retained shape is the one worth pinning hardest: exec_command can return a +// running session before the helper has even attempted the inner launch, so an +// absent report there means "not yet", not "yes". +func TestExecOutcomeTakesTheLaunchFactFromTheAdapter(t *testing.T) { + yes, no := true, false + + cases := []struct { + name string + owned bool + report execution.AdapterReport + exited bool + want bool + because string + }{ + { + name: "wrapped helper failed before creating the child", + owned: true, report: execution.AdapterReport{ChildLaunched: &no}, exited: true, + want: false, because: "only the unsandboxed helper ran", + }, + { + name: "wrapped plan, adapter said nothing", + owned: true, report: execution.AdapterReport{}, exited: true, + want: false, because: "an absent report is not proof that enforcement applied", + }, + { + name: "wrapped plan still running, nothing reported yet", + owned: true, report: execution.AdapterReport{}, exited: false, + want: false, because: "the helper can be returned before it attempts the inner launch", + }, + { + name: "wrapped plan, restricted child confirmed", + owned: true, report: execution.AdapterReport{ChildLaunched: &yes}, exited: true, + want: true, because: "the adapter saw the transition", + }, + { + name: "direct command keeps its own observation", + owned: false, report: execution.AdapterReport{}, exited: true, + want: true, because: "the process the tool started is the requested one", + }, + } + + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + input := execToolResultInput{ + commandText: "echo hi", + sessionID: 7, + exited: testCase.exited, + report: testCase.report, + childLaunchOwnedByAdapter: testCase.owned, + enforcement: execution.Enforcement{Notices: []string{"denyRead is configured, so the write jail is not confining writes"}}, + } + // Through the PRODUCTION conversion, which is what decides the launch + // state. Computing it here instead would pin the shared helper and prove + // nothing about whether exec_command consults it. + result := execToolResult(input) + outcome := result.ExecutionOutcome + if outcome == nil { + t.Fatalf("SETUP INVALID: the conversion produced no execution outcome") + } + if outcome.Launched != testCase.want { + t.Fatalf("Launched = %v, want %v: %s", outcome.Launched, testCase.want, testCase.because) + } + notices := outcome.AppliedEnforcementNotices() + if testCase.want && len(notices) != 1 { + t.Fatalf("a confirmed launch disclosed %q, want the planned notice exactly once", notices) + } + if !testCase.want && len(notices) != 0 { + t.Fatalf("no requested child ran, but the outcome disclosed %q", notices) + } + }) + } +} + +// And the shared resolution itself, since three launchers now depend on it +// answering the same way. +func TestResolveChildLaunchedIsOneAnswerForEveryLauncher(t *testing.T) { + yes, no := true, false + cases := []struct { + name string + observed bool + owned bool + report execution.AdapterReport + want bool + }{ + {"adapter confirms over a false observation", false, true, execution.AdapterReport{ChildLaunched: &yes}, true}, + {"adapter denies over a true observation", true, true, execution.AdapterReport{ChildLaunched: &no}, false}, + {"owned and silent fails closed", true, true, execution.AdapterReport{}, false}, + {"unowned keeps the observation, true", true, false, execution.AdapterReport{}, true}, + {"unowned keeps the observation, false", false, false, execution.AdapterReport{}, false}, + {"an adapter may speak even when unowned", false, false, execution.AdapterReport{ChildLaunched: &yes}, true}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + if got := execution.ResolveChildLaunched(testCase.observed, testCase.owned, testCase.report); got != testCase.want { + t.Fatalf("ResolveChildLaunched(%v, %v, %+v) = %v, want %v", + testCase.observed, testCase.owned, testCase.report, got, testCase.want) + } + }) + } +} From bec7571e17bc73af8791a6c5d5865f0d22aee756 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Thu, 3 Sep 2026 12:49:17 +0530 Subject: [PATCH 33/43] fix(cli): give the late MCP disclosure and startup output one owner of stderr Joining the pump at stop bounded writes to the pump's lifetime but said nothing about the overlap. Headless and interactive startup keep writing plugin, trust, peer, provider, trace and validation output to the same caller-supplied writer for the whole time the pump is live, and Run accepts an arbitrary io.Writer: a plain bytes.Buffer corrupts under concurrent use, and even a concurrency-safe terminal writer interleaves logical lines. A mutex private to the pump could not fix that, because the foreground writes do not go through it. reportMCPStartupDisclosures now returns a guarded view of the caller's writer alongside stop, and both startup paths adopt it for the rest of startup, so the pump and the foreground path take the same lock. Machine-readable stdout is untouched and the stop-before-TUI boundary is unchanged. The regression is deterministic rather than hopeful: a writer that parks inside Write holds the foreground message there while the late launch resolves and the pump tries to print, and it counts concurrent entries. Removing the lock from the guarded writer fails it on that count; the test also asserts stop drains the final notice exactly once and that the foreground message is not lost. --- internal/cli/app.go | 3 +- internal/cli/exec.go | 5 +- internal/cli/mcp_late_disclosure_test.go | 6 +- internal/cli/mcp_tools.go | 37 +++++- internal/cli/mcp_writer_ownership_test.go | 146 ++++++++++++++++++++++ 5 files changed, 186 insertions(+), 11 deletions(-) create mode 100644 internal/cli/mcp_writer_ownership_test.go diff --git a/internal/cli/app.go b/internal/cli/app.go index c3a3ff9a0..0fd7442fa 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -878,7 +878,8 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a // NOT deferred: stderr here is the bare terminal, and the TUI takes it over at // deps.runTUI below. Delivery stops before that hand-off, so a late launch can // never write raw text into the alt screen; see stopMCPDisclosures's call site. - stopMCPDisclosures := reportMCPStartupDisclosures(stderr, mcpRuntime) + guardedStderr, stopMCPDisclosures := reportMCPStartupDisclosures(stderr, mcpRuntime) + stderr = guardedStderr // Make local plugins live: register their declared tools into the registry and // collect their hooks + skill roots for the dispatcher and skill tool below. // Done after specialist + MCP registration so plugin tools are part of the diff --git a/internal/cli/exec.go b/internal/cli/exec.go index 151343695..2d7aaac82 100644 --- a/internal/cli/exec.go +++ b/internal/cli/exec.go @@ -353,7 +353,10 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in // Deferred AFTER closeMCPRuntime was deferred, so it runs BEFORE it: the // pump stops and joins while stderr is still ours, and only then are the // clients closed. - stopDisclosures := reportMCPStartupDisclosures(stderr, mcpRuntime) + // Adopt the guarded writer for the rest of startup: the pump is live from + // here until stop, and everything below writes to this same stderr. + guardedStderr, stopDisclosures := reportMCPStartupDisclosures(stderr, mcpRuntime) + stderr = guardedStderr defer stopDisclosures() } pluginActivation = activatePlugins(workspaceRoot, registry, deps, stderr, trustRoot, executionRunner) diff --git a/internal/cli/mcp_late_disclosure_test.go b/internal/cli/mcp_late_disclosure_test.go index 612cfd807..6c7e67911 100644 --- a/internal/cli/mcp_late_disclosure_test.go +++ b/internal/cli/mcp_late_disclosure_test.go @@ -62,7 +62,7 @@ func TestLateMCPLaunchReachesTheStartupReporterExactlyOnce(t *testing.T) { // The reporter runs ONCE, here, exactly as runExec and the interactive // startup path run it: before the launch has resolved. var stderr bytes.Buffer - stop := reportMCPStartupDisclosures(&stderr, runtime) + _, stop := reportMCPStartupDisclosures(&stderr, runtime) close(released) <-published @@ -104,7 +104,7 @@ func TestKnownMCPLaunchIsNotReportedTwice(t *testing.T) { t.Cleanup(func() { _ = runtime.Close() }) var stderr bytes.Buffer - stop := reportMCPStartupDisclosures(&stderr, runtime) + _, stop := reportMCPStartupDisclosures(&stderr, runtime) stop() if n := strings.Count(stderr.String(), notice); n != 1 { t.Fatalf("a launch known at registration was disclosed %d time(s), want exactly 1:\n%s", n, stderr.String()) @@ -144,7 +144,7 @@ func TestMCPDisclosureAfterStopIsDroppedNotWritten(t *testing.T) { t.Cleanup(func() { _ = runtime.Close() }) var stderr bytes.Buffer - stop := reportMCPStartupDisclosures(&stderr, runtime) + _, stop := reportMCPStartupDisclosures(&stderr, runtime) // The owner gives up the writer BEFORE the launch resolves, which is the // interactive hand-off to the TUI. stop() diff --git a/internal/cli/mcp_tools.go b/internal/cli/mcp_tools.go index 5477f7d49..4de3bb535 100644 --- a/internal/cli/mcp_tools.go +++ b/internal/cli/mcp_tools.go @@ -228,12 +228,23 @@ type mcpStartupStreaming interface { // when stop runs is still printed, on the caller's goroutine, with the pump // already finished. // +// ONE WRITER, ONE CALLER AT A TIME. Joining the pump stops writes after its +// lifetime but does nothing about the overlap: startup keeps emitting plugin, +// trust, peer, provider and validation output to the same writer while the pump +// is live. That is unsafe for an ordinary bytes.Buffer and interleaves lines even +// on a writer that tolerates concurrent calls. A mutex private to the pump would +// not help, because the other writes do not go through it. So the returned writer +// is a guarded view of the caller's, and the caller adopts it for the rest of +// startup; both sides then take the same lock. +// // The pull form is kept for a runtime that implements no stream, which today is -// only test doubles; it has no late launches to deliver, so its stop is a no-op. -func reportMCPStartupDisclosures(stderr io.Writer, runtime mcpToolRuntime) (stop func()) { +// only test doubles; it has no late launches to deliver, so its stop is a no-op +// and its writer is handed back unchanged. +func reportMCPStartupDisclosures(stderr io.Writer, runtime mcpToolRuntime) (guarded io.Writer, stop func()) { + serialized := &serializedWriter{writer: stderr} print := func(disclosure mcp.StartupDisclosure) { for _, notice := range disclosure.Notices { - fmt.Fprintf(stderr, "notice: MCP server %s started with reduced enforcement: %s\n", disclosure.Name, notice) + fmt.Fprintf(serialized, "notice: MCP server %s started with reduced enforcement: %s\n", disclosure.Name, notice) } } printAll := func(disclosures []mcp.StartupDisclosure) { @@ -246,11 +257,11 @@ func reportMCPStartupDisclosures(stderr io.Writer, runtime mcpToolRuntime) (stop if disclosing, ok := runtime.(mcpStartupDisclosing); ok { printAll(disclosing.StartupDisclosures()) } - return func() {} + return stderr, func() {} } stream := streaming.StartupDisclosureStream() if stream == nil { - return func() {} + return stderr, func() {} } printAll(stream.Drain()) pumped := make(chan struct{}) @@ -261,7 +272,7 @@ func reportMCPStartupDisclosures(stderr io.Writer, runtime mcpToolRuntime) (stop } }() var once sync.Once - return func() { + return serialized, func() { once.Do(func() { stream.Close() <-pumped @@ -269,3 +280,17 @@ func reportMCPStartupDisclosures(stderr io.Writer, runtime mcpToolRuntime) (stop }) } } + +// serializedWriter gives one underlying writer a single owner at a time, so the +// late-disclosure pump and the foreground startup path cannot be inside it +// together. +type serializedWriter struct { + mu sync.Mutex + writer io.Writer +} + +func (w *serializedWriter) Write(data []byte) (int, error) { + w.mu.Lock() + defer w.mu.Unlock() + return w.writer.Write(data) +} diff --git a/internal/cli/mcp_writer_ownership_test.go b/internal/cli/mcp_writer_ownership_test.go new file mode 100644 index 000000000..857bb124e --- /dev/null +++ b/internal/cli/mcp_writer_ownership_test.go @@ -0,0 +1,146 @@ +package cli + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "strings" + "sync" + "testing" + "time" + + "github.com/Gitlawb/zero/internal/config" + "github.com/Gitlawb/zero/internal/mcp" + "github.com/Gitlawb/zero/internal/tools" +) + +// blockingWriter makes the overlap deterministic instead of hoping to hit it. +// +// The first write parks inside Write until the test releases it, which is the +// window the pump and the foreground startup path really share: startup keeps +// emitting plugin, trust, peer and provider output to the same stderr while a +// late MCP disclosure can arrive at any moment. It also records whether it was +// ever entered twice at once, which is the property under test. +type blockingWriter struct { + mu sync.Mutex + inside int + overlaps int + buf bytes.Buffer + + block chan struct{} + blockOne sync.Once + entered chan struct{} +} + +func newBlockingWriter() *blockingWriter { + return &blockingWriter{block: make(chan struct{}), entered: make(chan struct{})} +} + +func (w *blockingWriter) Write(data []byte) (int, error) { + w.mu.Lock() + w.inside++ + if w.inside > 1 { + w.overlaps++ + } + w.mu.Unlock() + + // Only the first writer parks, and it announces that it is inside. + first := false + w.blockOne.Do(func() { + first = true + close(w.entered) + }) + if first { + <-w.block + } + + w.mu.Lock() + n, err := w.buf.Write(data) + w.inside-- + w.mu.Unlock() + return n, err +} + +func (w *blockingWriter) overlapCount() int { + w.mu.Lock() + defer w.mu.Unlock() + return w.overlaps +} + +func (w *blockingWriter) String() string { + w.mu.Lock() + defer w.mu.Unlock() + return w.buf.String() +} + +// ONE CALLER AT A TIME, FOR THE WHOLE OVERLAP. +// +// Joining the pump at stop bounds writes to the pump's lifetime but says nothing +// about what happens DURING it. The startup paths keep writing to the same +// io.Writer the whole time, and the caller may legitimately hand in a plain +// bytes.Buffer, which corrupts under concurrent use. A mutex private to the pump +// would not have helped, because the foreground writes do not go through it; the +// reporter therefore hands back a guarded view of the caller's writer and the +// caller adopts it, so both sides take the same lock. +func TestLateDisclosureAndForegroundStartupNeverShareTheWriter(t *testing.T) { + const notice = "MCP server started under reduced enforcement" + released := make(chan struct{}) + published := make(chan struct{}) + + runtime, err := mcp.RegisterTools(context.Background(), tools.NewRegistry(), + config.MCPConfig{Servers: map[string]config.MCPServerConfig{ + "slow": {Type: "stdio", Command: "slow-mcp"}, + }}, + mcp.RegisterOptions{ + ConnectTimeout: 50 * time.Millisecond, + ClientFactory: func(ctx context.Context, server mcp.Server) (mcp.ToolClient, error) { + <-released + mcp.PublishLaunchForTest(ctx, []string{notice}) + close(published) + return nil, errors.New("initialize failed long after start") + }, + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = runtime.Close() }) + + writer := newBlockingWriter() + guarded, stop := reportMCPStartupDisclosures(writer, runtime) + if guarded == io.Writer(writer) { + t.Fatal("SETUP INVALID: the reporter handed back the raw writer, so the caller cannot share its lock") + } + + // Foreground startup writes through the guarded writer and parks inside it, + // exactly as a slow terminal would. + foregroundDone := make(chan struct{}) + go func() { + defer close(foregroundDone) + fmt.Fprintln(guarded, "warning: MCP server other unavailable, skipped: dial tcp: refused") + }() + <-writer.entered + + // While the foreground write is parked, the late launch resolves and the pump + // tries to print. If the two did not share a lock, this would enter Write + // concurrently. + close(released) + <-published + time.Sleep(50 * time.Millisecond) + + close(writer.block) + <-foregroundDone + stop() + + if n := writer.overlapCount(); n != 0 { + t.Fatalf("the pump and foreground startup were inside the writer together %d time(s)", n) + } + got := writer.String() + if count := strings.Count(got, notice); count != 1 { + t.Fatalf("the late disclosure was written %d time(s), want exactly 1 after stop drained it:\n%s", count, got) + } + if !strings.Contains(got, "unavailable, skipped") { + t.Errorf("the foreground startup message was lost:\n%s", got) + } +} From cc85f2ee0635e84d904a0ec0c069f3d2000b598a Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Thu, 3 Sep 2026 12:53:26 +0530 Subject: [PATCH 34/43] fix(agent): deliver a successful beforeTool hook's output to the model executeToolCall inspected the beforeTool DispatchOutcome only when Blocked was true, so a hook that ran fine and produced output put it in the audit record and on no surface anyone could see. A beforeTool process that ran under the weakened DenyRead token said so to nobody; only vetoes and afterTool feedback reached the model. Its messages now ride out on the tool result, the same delivery afterTool feedback already uses, ahead of that feedback and without displacing it. Blank messages contribute nothing, so a run with no hook output stays silent rather than appending an empty header, and veto behaviour is untouched. The regression drives the real Run loop with a real hook process and asserts on what the provider received on the next turn; dropping the capture fails it on that assertion. A unit test on the joining helper passed with the capture removed, so it could not have caught this. sessionStart and sessionEnd still discard their outcomes. Routing those needs a delivery surface that does not exist in agent.Options today, and choosing one is a product decision rather than a mechanical fix, so it is raised on the PR instead of invented here. --- internal/agent/before_tool_delivery_test.go | 99 +++++++++++++++++++++ internal/agent/hook_wiring_test.go | 34 +++++++ internal/agent/loop.go | 30 ++++++- 3 files changed, 161 insertions(+), 2 deletions(-) create mode 100644 internal/agent/before_tool_delivery_test.go diff --git a/internal/agent/before_tool_delivery_test.go b/internal/agent/before_tool_delivery_test.go new file mode 100644 index 000000000..da2582f3d --- /dev/null +++ b/internal/agent/before_tool_delivery_test.go @@ -0,0 +1,99 @@ +package agent + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "runtime" + "testing" + + "github.com/Gitlawb/zero/internal/hooks" + "github.com/Gitlawb/zero/internal/tools" + zeroruntime "github.com/Gitlawb/zero/internal/zeroruntime" +) + +// A SUCCESSFUL beforeTool HOOK'S OUTPUT REACHES THE MODEL. +// +// executeToolCall used to read the beforeTool outcome only when Blocked was +// true, so a hook that ran fine and printed something — an enforcement notice +// saying it had run under the weakened DenyRead token, for instance — landed in +// the audit record and on no surface the model or the operator could see. Only +// vetoes and afterTool feedback got out. +// +// Driven through the real Run loop with a real hook process, and asserted on +// what the PROVIDER actually received on the next turn, because that is the +// delivery that matters. A unit test on the joining helper cannot see whether +// the loop captures the messages at all. +func TestSuccessfulBeforeToolHookOutputReachesTheModel(t *testing.T) { + goBinary, err := exec.LookPath("go") + if err != nil { + goRoot := runtime.GOROOT() + if goRoot == "" { + t.Skip("go binary unavailable for the hook command") + } + goBinary = filepath.Join(goRoot, "bin", "go") + if runtime.GOOS == "windows" { + goBinary += ".exe" + } + } + audit, err := hooks.NewAuditStore(hooks.AuditStoreOptions{AuditPath: filepath.Join(t.TempDir(), "audit.jsonl")}) + if err != nil { + t.Fatalf("NewAuditStore: %v", err) + } + // Exits 0 and prints to stdout: a hook that permits the call and still has + // something to say. + dispatcher := hooks.NewDispatcher(hooks.DispatcherOptions{ + Config: hooks.Config{ + Enabled: true, + Hooks: []hooks.Definition{ + {ID: "zero.before-tool", Event: hooks.EventBeforeTool, Matcher: "read_file", Command: goBinary, Args: []string{"version"}, Enabled: true}, + }, + }, + Audit: audit, + }) + + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "notes.txt"), []byte("hello"), 0o644); err != nil { + t.Fatalf("write notes.txt: %v", err) + } + registry := tools.NewRegistry() + registry.Register(tools.NewReadFileTool(root)) + provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{ + { + {Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "read_file"}, + {Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"path":"notes.txt"}`}, + {Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"}, + {Type: zeroruntime.StreamEventDone}, + }, + { + {Type: zeroruntime.StreamEventText, Content: "read it"}, + {Type: zeroruntime.StreamEventDone}, + }, + }} + + if _, err := Run(context.Background(), "read the notes", provider, Options{ + SessionID: "session-hook", + Cwd: root, + Registry: registry, + ProviderName: "test-provider", + Model: "test-model", + Hooks: dispatcher, + MaxTurns: 2, + }); err != nil { + t.Fatalf("Run: %v", err) + } + + // The tool itself ran, so the setup is the one under test rather than a + // blocked call that never reached the tool. + if !someRequestContains(provider.requests, "hello") { + t.Fatalf("SETUP INVALID: the tool result never reached the model, so nothing was delivered to check") + } + // go version prints "go version ..." on stdout; that is the hook's message. + if !someRequestContains(provider.requests, "go version") { + t.Fatal("a successful beforeTool hook produced output that never reached the model") + } + if !someRequestContains(provider.requests, "Hook output:") { + t.Error("the hook output was delivered without the header the model uses to recognise it") + } +} diff --git a/internal/agent/hook_wiring_test.go b/internal/agent/hook_wiring_test.go index cfbd93d36..06e66085d 100644 --- a/internal/agent/hook_wiring_test.go +++ b/internal/agent/hook_wiring_test.go @@ -93,3 +93,37 @@ func TestDispatchHelpersAreNoopWithoutDispatcher(t *testing.T) { t.Fatalf("a nil dispatcher must yield no feedback, got %q", feedback) } } + +// A SUCCESSFUL beforeTool HOOK'S OUTPUT MUST REACH THE MODEL, NOT ONLY THE AUDIT. +// +// executeToolCall used to read the beforeTool outcome only when Blocked was true, +// so a hook that ran fine and produced an enforcement notice — for instance that +// it ran under the weakened DenyRead token — put that notice in the audit record +// and nowhere anybody could see it. Only vetoes and afterTool feedback reached a +// surface. joinHookMessages is the delivery: beforeTool's messages ride out on +// the same tool result afterTool feedback already uses. +func TestJoinHookMessagesDeliversSuccessfulBeforeToolOutput(t *testing.T) { + const notice = "hook ran without WRITE_RESTRICTED because denyRead is configured" + + // A successful beforeTool hook alone still reaches the model. + if got := joinHookMessages([]string{notice}, ""); got != notice { + t.Fatalf("a successful beforeTool notice was dropped: %q", got) + } + // And it does not displace afterTool feedback; both arrive, in order. + got := joinHookMessages([]string{notice}, "gofmt reformatted main.go") + if !strings.Contains(got, notice) || !strings.Contains(got, "gofmt reformatted main.go") { + t.Fatalf("expected both the beforeTool notice and the afterTool feedback, got %q", got) + } + if strings.Index(got, notice) > strings.Index(got, "gofmt reformatted main.go") { + t.Fatalf("beforeTool output should precede afterTool feedback, got %q", got) + } + // Empty and whitespace-only messages contribute nothing, so a run with no hook + // output stays silent rather than appending an empty header. + if got := joinHookMessages([]string{"", " "}, ""); got != "" { + t.Fatalf("blank hook messages produced %q, want nothing", got) + } + // afterTool alone is unchanged, which is the behaviour that already worked. + if got := joinHookMessages(nil, "vet found an issue"); got != "vet found an issue" { + t.Fatalf("afterTool-only feedback changed shape: %q", got) + } +} diff --git a/internal/agent/loop.go b/internal/agent/loop.go index 78942fd3a..682639ab6 100644 --- a/internal/agent/loop.go +++ b/internal/agent/loop.go @@ -1414,10 +1414,20 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal } // beforeTool hooks may veto the call before it runs (a non-zero exit blocks). + // + // A SUCCESSFUL beforeTool HOOK STILL HAS SOMETHING TO SAY. Its Messages carry + // the enforcement notice for the process it ran, and reading the outcome only + // when Blocked left that notice in the audit record and nowhere the model or + // the operator could see it: a hook could run under the weakened DenyRead + // token and say so to nobody. Carried to the tool result below, which is the + // same surface afterTool feedback already uses. + var beforeToolMessages []string if toolFound { - if outcome, blocked := dispatchBeforeTool(ctx, options, call, args); blocked { + outcome, blocked := dispatchBeforeTool(ctx, options, call, args) + if blocked { return blockedByHookResult(call, outcome), nil } + beforeToolMessages = outcome.Messages } args = shellExecutionArgsForApproval(call.Name, args, decisionAction, options) @@ -1489,7 +1499,7 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal // afterTool hooks run once the tool has executed; their output (e.g. a // formatter or vet result) is surfaced back to the model on the result. if toolFound { - if feedback := dispatchAfterTool(ctx, options, call, args, result); feedback != "" { + if feedback := joinHookMessages(beforeToolMessages, dispatchAfterTool(ctx, options, call, args, result)); feedback != "" { var didRedact bool result.Output, didRedact = appendHookFeedback(result.Output, feedback) if didRedact { @@ -2012,6 +2022,22 @@ func blockedByHookResult(call ToolCall, outcome hooks.DispatchOutcome) ToolResul // scrubbed for secrets like every other string crossing the tool boundary. The // returned bool reports whether scrubbing changed the feedback, so the caller can // set ToolResult.Redacted to match the registry's redaction contract. +// joinHookMessages folds a successful beforeTool hook's output in with the +// afterTool feedback so both reach the model through the one delivery path, +// rather than beforeTool's being produced and then dropped. +func joinHookMessages(before []string, after string) string { + parts := make([]string, 0, len(before)+1) + for _, message := range before { + if strings.TrimSpace(message) != "" { + parts = append(parts, message) + } + } + if strings.TrimSpace(after) != "" { + parts = append(parts, after) + } + return strings.Join(parts, "\n\n") +} + func appendHookFeedback(output string, feedback string) (string, bool) { scrubbed := redaction.RedactString(feedback, redaction.Options{}) redacted := scrubbed != feedback From cd9cc8b4636b3b1c5f60ea8c097266abac8b4e90 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Thu, 3 Sep 2026 13:05:17 +0530 Subject: [PATCH 35/43] fix(agent): drop the deprecated runtime.GOROOT fallback from the hook regression Smoke (windows-latest) failed lint on cc85f2ee: SA1019, runtime.GOROOT has been deprecated since Go 1.24. The fallback was copied from an older test and was never needed here, since the test cannot run without a go binary anyway. Skipping when one is not on PATH is the honest answer. --- internal/agent/before_tool_delivery_test.go | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/internal/agent/before_tool_delivery_test.go b/internal/agent/before_tool_delivery_test.go index da2582f3d..4dc0c2de0 100644 --- a/internal/agent/before_tool_delivery_test.go +++ b/internal/agent/before_tool_delivery_test.go @@ -5,7 +5,6 @@ import ( "os" "os/exec" "path/filepath" - "runtime" "testing" "github.com/Gitlawb/zero/internal/hooks" @@ -26,16 +25,13 @@ import ( // delivery that matters. A unit test on the joining helper cannot see whether // the loop captures the messages at all. func TestSuccessfulBeforeToolHookOutputReachesTheModel(t *testing.T) { + // Any command that exits 0 and prints to stdout will do; the go binary is + // already required to run this test at all. runtime.GOROOT is deliberately not + // used as a fallback: it is deprecated, and a skip is the honest answer when + // there is no command to run. goBinary, err := exec.LookPath("go") if err != nil { - goRoot := runtime.GOROOT() - if goRoot == "" { - t.Skip("go binary unavailable for the hook command") - } - goBinary = filepath.Join(goRoot, "bin", "go") - if runtime.GOOS == "windows" { - goBinary += ".exe" - } + t.Skip("go binary unavailable for the hook command") } audit, err := hooks.NewAuditStore(hooks.AuditStoreOptions{AuditPath: filepath.Join(t.TempDir(), "audit.jsonl")}) if err != nil { From dc055478354564d2c30cc0cc7cd9f3e661b81c4e Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Fri, 4 Sep 2026 12:49:06 +0530 Subject: [PATCH 36/43] fix(agent,hooks): deliver only the beforeTool enforcement notice, on every exit Delivering DispatchOutcome.Messages for a successful beforeTool hook fixed the silent disclosure and overshot. hookMessage builds that slice by folding the enforcement notice together with the hook's ordinary stdout, or stderr when stdout is empty, because afterTool validators want both. So every successful hook's routine logging, large diagnostics, and whatever text the hook happened to process became a standing input channel into the next model request. main is silent for a successful hook. DispatchOutcome now carries Notices separately: only the disclosures, one entry per notice, accumulated across every hook that ran. Messages keeps its old meaning and its old consumer. The notices are also accumulated BEFORE the veto short-circuit, because they describe something that already happened. Dispatch stops at the first veto, so a hook that ran under the weakened token ahead of the vetoing one used to leave its disclosure in the audit record and nowhere else. Two exits after the hook runs now share one finalization helper with the normal tail: the veto result, and a denied, cancelled or ungrantable unsandboxed retry. The blocking hook's own notices are already inside its reason, so they are not repeated. The regression that expected `go version` stdout to reach the provider was locking in the wrong behaviour and is replaced. One hook run now emits both a notice and ordinary output, and the test asserts the notice arrives exactly once while the output stays silent, driven through Run and asserted on what the provider received. A second test covers the veto path. Falsified by delivering Messages again, by returning the veto result without the notices, and by dropping the accumulation ahead of the veto. --- internal/agent/before_tool_delivery_test.go | 187 ++++++++++++++++---- internal/agent/loop.go | 85 +++++++-- internal/hooks/dispatch.go | 23 +++ 3 files changed, 245 insertions(+), 50 deletions(-) diff --git a/internal/agent/before_tool_delivery_test.go b/internal/agent/before_tool_delivery_test.go index 4dc0c2de0..6eabdbe7d 100644 --- a/internal/agent/before_tool_delivery_test.go +++ b/internal/agent/before_tool_delivery_test.go @@ -5,50 +5,63 @@ import ( "os" "os/exec" "path/filepath" + "runtime" + "strings" "testing" + "github.com/Gitlawb/zero/internal/execution" "github.com/Gitlawb/zero/internal/hooks" "github.com/Gitlawb/zero/internal/tools" zeroruntime "github.com/Gitlawb/zero/internal/zeroruntime" ) -// A SUCCESSFUL beforeTool HOOK'S OUTPUT REACHES THE MODEL. -// -// executeToolCall used to read the beforeTool outcome only when Blocked was -// true, so a hook that ran fine and printed something — an enforcement notice -// saying it had run under the weakened DenyRead token, for instance — landed in -// the audit record and on no surface the model or the operator could see. Only -// vetoes and afterTool feedback got out. -// -// Driven through the real Run loop with a real hook process, and asserted on -// what the PROVIDER actually received on the next turn, because that is the -// delivery that matters. A unit test on the joining helper cannot see whether -// the loop captures the messages at all. -func TestSuccessfulBeforeToolHookOutputReachesTheModel(t *testing.T) { - // Any command that exits 0 and prints to stdout will do; the go binary is - // already required to run this test at all. runtime.GOROOT is deliberately not - // used as a fallback: it is deprecated, and a skip is the honest answer when - // there is no command to run. - goBinary, err := exec.LookPath("go") - if err != nil { - t.Skip("go binary unavailable for the hook command") +const beforeToolNotice = "denyRead is configured, so the write jail is not confining writes" + +// beforeToolChatter is what a hook prints for its own reasons. It must never +// reach the model: main is silent for a successful hook, and a hook that logs is +// not asking to be heard by anything but the operator's terminal. +const beforeToolChatter = "hook-ran-and-logged-this" + +// noticeHookPreparer plans the hook command with an enforcement notice attached, +// the way the sandbox does for a command it weakened. The prepared child prints +// ordinary output as well, so one run carries both kinds of text and the +// delivery decision has to tell them apart. +type noticeHookPreparer struct{} + +func (noticeHookPreparer) PrepareExecution(_ context.Context, _ execution.Request) (execution.PreparedCommand, error) { + var command *exec.Cmd + if runtime.GOOS == "windows" { + command = exec.Command("cmd.exe", "/c", "echo "+beforeToolChatter) + } else { + command = exec.Command("/bin/sh", "-c", "echo "+beforeToolChatter) } + return execution.PreparedCommand{ + Command: command, + Enforcement: execution.Enforcement{Notices: []string{beforeToolNotice}}, + }, nil +} + +func beforeToolDispatcher(t *testing.T, event hooks.Event, exitCode int) *hooks.Dispatcher { + t.Helper() audit, err := hooks.NewAuditStore(hooks.AuditStoreOptions{AuditPath: filepath.Join(t.TempDir(), "audit.jsonl")}) if err != nil { t.Fatalf("NewAuditStore: %v", err) } - // Exits 0 and prints to stdout: a hook that permits the call and still has - // something to say. - dispatcher := hooks.NewDispatcher(hooks.DispatcherOptions{ + return hooks.NewDispatcher(hooks.DispatcherOptions{ Config: hooks.Config{ Enabled: true, Hooks: []hooks.Definition{ - {ID: "zero.before-tool", Event: hooks.EventBeforeTool, Matcher: "read_file", Command: goBinary, Args: []string{"version"}, Enabled: true}, + {ID: "zero.before-tool", Event: event, Matcher: "read_file", Command: "hook", Enabled: true}, }, }, - Audit: audit, + Audit: audit, + Cwd: t.TempDir(), + Execution: execution.NewRunner(noticeHookPreparer{}), }) +} +func readFileRunOptions(t *testing.T, dispatcher *hooks.Dispatcher) (Options, *mockProvider, string) { + t.Helper() root := t.TempDir() if err := os.WriteFile(filepath.Join(root, "notes.txt"), []byte("hello"), 0o644); err != nil { t.Fatalf("write notes.txt: %v", err) @@ -67,8 +80,7 @@ func TestSuccessfulBeforeToolHookOutputReachesTheModel(t *testing.T) { {Type: zeroruntime.StreamEventDone}, }, }} - - if _, err := Run(context.Background(), "read the notes", provider, Options{ + return Options{ SessionID: "session-hook", Cwd: root, Registry: registry, @@ -76,20 +88,123 @@ func TestSuccessfulBeforeToolHookOutputReachesTheModel(t *testing.T) { Model: "test-model", Hooks: dispatcher, MaxTurns: 2, - }); err != nil { + }, provider, root +} + +// countRequestsContaining reports how many provider requests carry needle, so a +// notice delivered twice is distinguishable from one delivered once. +func countRequestsContaining(requests []zeroruntime.CompletionRequest, needle string) int { + total := 0 + for _, request := range requests { + for _, message := range request.Messages { + if strings.Contains(message.Content, needle) { + total++ + } + } + } + return total +} + +// THE NOTICE CROSSES TO THE MODEL. THE HOOK'S OWN OUTPUT DOES NOT. +// +// executeToolCall used to read the beforeTool outcome only when Blocked was +// true, so a hook that ran under the weakened DenyRead token said so to nobody. +// Delivering DispatchOutcome.Messages fixed that and overshot: hookMessage folds +// the notice together with the hook's ordinary stdout, so every successful +// hook's routine logging became a standing input channel into the next model +// request, which is not what main does. +// +// One hook run produces both kinds of text here, because the bug is exactly a +// failure to tell them apart. Asserted on what the PROVIDER received, since that +// is the boundary that matters; a unit test on the joining helper cannot see +// which slice the loop passes it. +func TestSuccessfulBeforeToolHookDeliversItsNoticeAndNotItsOutput(t *testing.T) { + options, provider, _ := readFileRunOptions(t, beforeToolDispatcher(t, hooks.EventBeforeTool, 0)) + if _, err := Run(context.Background(), "read the notes", provider, options); err != nil { t.Fatalf("Run: %v", err) } - // The tool itself ran, so the setup is the one under test rather than a - // blocked call that never reached the tool. + // The tool ran, so this is the successful-hook path rather than a blocked + // call that never reached the tool. if !someRequestContains(provider.requests, "hello") { - t.Fatalf("SETUP INVALID: the tool result never reached the model, so nothing was delivered to check") + t.Fatal("SETUP INVALID: the tool result never reached the model, so nothing was delivered to check") + } + // And the hook really did run and really did print, or the silence asserted + // below would be the silence of a hook that never executed. + if !someRequestContains(provider.requests, beforeToolNotice) { + t.Fatal("the enforcement notice never reached the model, so a hook could run under the weakened token and say so to nobody") } - // go version prints "go version ..." on stdout; that is the hook's message. - if !someRequestContains(provider.requests, "go version") { - t.Fatal("a successful beforeTool hook produced output that never reached the model") + if got := countRequestsContaining(provider.requests, beforeToolNotice); got != 1 { + t.Errorf("the notice reached the model %d times, want exactly once", got) + } + if someRequestContains(provider.requests, beforeToolChatter) { + t.Error("the hook's ordinary output reached the model; main is silent for a successful hook and routine logging must not become model input") + } +} + +// A VETO MUST NOT SWALLOW A NOTICE FROM A HOOK THAT ALREADY RAN. +// +// Dispatch runs hooks in order and returns at the first veto. The successful +// hook ahead of it may already have run under the weakened token, and that is a +// fact about something that happened. The veto result used to be built from the +// blocking hook's Reason alone, so the earlier disclosure existed only in the +// audit record. +func TestABlockedCallStillCarriesTheEarlierHooksNotice(t *testing.T) { + audit, err := hooks.NewAuditStore(hooks.AuditStoreOptions{AuditPath: filepath.Join(t.TempDir(), "audit.jsonl")}) + if err != nil { + t.Fatalf("NewAuditStore: %v", err) + } + dispatcher := hooks.NewDispatcher(hooks.DispatcherOptions{ + Config: hooks.Config{ + Enabled: true, + Hooks: []hooks.Definition{ + {ID: "zero.first", Event: hooks.EventBeforeTool, Matcher: "read_file", Command: "hook", Enabled: true}, + {ID: "zero.veto", Event: hooks.EventBeforeTool, Matcher: "read_file", Command: "veto", Enabled: true}, + }, + }, + Audit: audit, + Cwd: t.TempDir(), + Execution: execution.NewRunner(vetoSecondPreparer{}), + }) + options, provider, _ := readFileRunOptions(t, dispatcher) + if _, err := Run(context.Background(), "read the notes", provider, options); err != nil { + t.Fatalf("Run: %v", err) + } + + // SETUP: the second hook really did veto, or this is the ordinary path. + if !someRequestContains(provider.requests, "was blocked by hook") { + t.Fatal("SETUP INVALID: the call was not blocked, so the veto path is not under test") + } + if !someRequestContains(provider.requests, beforeToolNotice) { + t.Error("the veto result dropped the notice from the hook that had already run under the weakened token") + } + if got := countRequestsContaining(provider.requests, beforeToolNotice); got != 1 { + t.Errorf("the notice reached the model %d times, want exactly once", got) + } + if someRequestContains(provider.requests, beforeToolChatter) { + t.Error("the vetoed result carried the earlier hook's ordinary output") + } +} + +// vetoSecondPreparer runs the first hook successfully with a notice and makes +// the second one exit non-zero, which is a veto for a blocking event. +type vetoSecondPreparer struct{} + +func (vetoSecondPreparer) PrepareExecution(_ context.Context, request execution.Request) (execution.PreparedCommand, error) { + script := "echo " + beforeToolChatter + notices := []string{beforeToolNotice} + if request.Command.Name == "veto" { + script = "exit 2" + notices = nil } - if !someRequestContains(provider.requests, "Hook output:") { - t.Error("the hook output was delivered without the header the model uses to recognise it") + var command *exec.Cmd + if runtime.GOOS == "windows" { + command = exec.Command("cmd.exe", "/c", script) + } else { + command = exec.Command("/bin/sh", "-c", script) } + return execution.PreparedCommand{ + Command: command, + Enforcement: execution.Enforcement{Notices: notices}, + }, nil } diff --git a/internal/agent/loop.go b/internal/agent/loop.go index 682639ab6..53f5a1fbb 100644 --- a/internal/agent/loop.go +++ b/internal/agent/loop.go @@ -1415,19 +1415,27 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal // beforeTool hooks may veto the call before it runs (a non-zero exit blocks). // - // A SUCCESSFUL beforeTool HOOK STILL HAS SOMETHING TO SAY. Its Messages carry - // the enforcement notice for the process it ran, and reading the outcome only - // when Blocked left that notice in the audit record and nowhere the model or - // the operator could see it: a hook could run under the weakened DenyRead - // token and say so to nobody. Carried to the tool result below, which is the - // same surface afterTool feedback already uses. - var beforeToolMessages []string + // A SUCCESSFUL beforeTool HOOK STILL HAS SOMETHING TO SAY, BUT ONLY ITS NOTICE. + // + // Reading the outcome only when Blocked left the enforcement disclosure in the + // audit record and nowhere the model or the operator could see it: a hook could + // run under the weakened DenyRead token and say so to nobody. + // + // Notices, NOT Messages. Messages is presentation text that hookMessage builds + // by folding the notice together with the hook's ordinary stdout, so delivering + // it would put every successful hook's routine logging, large diagnostics, and + // whatever text a hook happened to process into the next model request. That is + // a behaviour change nobody asked for and a standing input channel. main is + // silent for successful hooks and stays silent here for everything except the + // disclosure. Carried to the tool result below, the same surface afterTool + // feedback already uses. + var beforeToolNotices []string if toolFound { outcome, blocked := dispatchBeforeTool(ctx, options, call, args) if blocked { return blockedByHookResult(call, outcome), nil } - beforeToolMessages = outcome.Messages + beforeToolNotices = outcome.Notices } args = shellExecutionArgsForApproval(call.Name, args, decisionAction, options) @@ -1473,7 +1481,10 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal }) if retryResult, directResult, retried, action, reason, prefix, abortErr := maybeRetryUnsandboxedAfterSandboxRestriction(ctx, registry, call, tool, args, result, permissionMode, options, progressCallback); retried || directResult != nil || abortErr != nil { if directResult != nil { - return *directResult, abortErr + // A denied, cancelled, or ungrantable retry still returns a result for a + // call whose beforeTool hook already ran. Without this the disclosure is + // produced and then dropped on the floor. + return withBeforeToolNotices(*directResult, beforeToolNotices), abortErr } result = retryResult permissionGranted = true @@ -1499,7 +1510,7 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal // afterTool hooks run once the tool has executed; their output (e.g. a // formatter or vet result) is surfaced back to the model on the result. if toolFound { - if feedback := joinHookMessages(beforeToolMessages, dispatchAfterTool(ctx, options, call, args, result)); feedback != "" { + if feedback := joinHookMessages(beforeToolNotices, dispatchAfterTool(ctx, options, call, args, result)); feedback != "" { var didRedact bool result.Output, didRedact = appendHookFeedback(result.Output, feedback) if didRedact { @@ -2008,7 +2019,7 @@ func blockedByHookResult(call ToolCall, outcome hooks.DispatchOutcome) ToolResul reason = "blocked by a beforeTool hook" } message := fmt.Sprintf("Error: %q was blocked by hook %q: %s", call.Name, outcome.BlockedBy, reason) - return ToolResult{ + result := ToolResult{ ToolCallID: call.ID, Name: call.Name, Status: tools.StatusError, @@ -2016,15 +2027,61 @@ func blockedByHookResult(call ToolCall, outcome hooks.DispatchOutcome) ToolResul Redacted: redacted, DenialReason: DenialHookBlocked, } + // Dispatch runs hooks in order and stops at the first veto, so an earlier hook + // may already have run under a weakened token before this one said no. Its + // notice describes something that happened and has to survive the veto. + // + // blockReason has already folded the BLOCKING hook's own notices into Reason, + // which is inside message above, so those are dropped here rather than said + // twice. + return withBeforeToolNotices(result, noticesBefore(outcome)) +} + +// noticesBefore returns the accumulated notices minus the blocking hook's own, +// which blockReason has already put in the veto message. +func noticesBefore(outcome hooks.DispatchOutcome) []string { + if !outcome.Blocked { + return outcome.Notices + } + kept := make([]string, 0, len(outcome.Notices)) + for _, notice := range outcome.Notices { + if strings.Contains(outcome.Reason, strings.TrimSpace(notice)) { + continue + } + kept = append(kept, notice) + } + return kept +} + +// withBeforeToolNotices is the single place a beforeTool enforcement notice +// reaches a tool result on a path that does NOT run afterTool. +// +// The normal tail joins the notices with the afterTool feedback and delivers +// both at once. Two other exits return a result for a call whose hook already +// ran: a later hook's veto, and a denied, cancelled, or ungrantable unsandboxed +// retry. Routing all three through one function is what keeps "the hook ran +// under this token" from depending on which exit the call happened to take. +func withBeforeToolNotices(result ToolResult, notices []string) ToolResult { + feedback := joinHookMessages(notices, "") + if strings.TrimSpace(feedback) == "" { + return result + } + output, didRedact := appendHookFeedback(result.Output, feedback) + result.Output = output + if didRedact { + result.Redacted = true + } + return result } // appendHookFeedback appends afterTool hook output to a tool result's output, // scrubbed for secrets like every other string crossing the tool boundary. The // returned bool reports whether scrubbing changed the feedback, so the caller can // set ToolResult.Redacted to match the registry's redaction contract. -// joinHookMessages folds a successful beforeTool hook's output in with the -// afterTool feedback so both reach the model through the one delivery path, -// rather than beforeTool's being produced and then dropped. +// joinHookMessages folds a successful beforeTool hook's enforcement notices in +// with the afterTool feedback so both reach the model through the one delivery +// path, rather than the notices being produced and then dropped. It takes the +// notices, never DispatchOutcome.Messages: see the capture site above. func joinHookMessages(before []string, after string) string { parts := make([]string, 0, len(before)+1) for _, message := range before { diff --git a/internal/hooks/dispatch.go b/internal/hooks/dispatch.go index 46ea3ba63..2f1c7a1d3 100644 --- a/internal/hooks/dispatch.go +++ b/internal/hooks/dispatch.go @@ -36,7 +36,23 @@ type DispatchOutcome struct { // Messages collects the output (stdout, else stderr) of each hook that // produced any, in run order. afterTool validators use this to feed results // (e.g. a formatter diff or vet warning) back to the model on the tool result. + // + // PRESENTATION TEXT, NOT A NOTICE CHANNEL. hookMessage composes the hook's + // ordinary stdout (or stderr) together with any enforcement notices, because + // afterTool wants both on one line. A caller that only wants to know what the + // sandbox did must read Notices instead: delivering this slice would put every + // successful hook's routine logging into the model's context. Messages []string + // Notices carries only the enforcement disclosures, one entry per notice, in + // run order across every hook that ran. + // + // Separate from Messages because they answer different questions and have + // different audiences. A notice says the hook ran under a weakened token, + // which the model and the operator both need; the hook's own output is for + // afterTool validators that asked to be heard. Accumulated BEFORE the veto + // short-circuit, so a notice from a hook that already ran survives a later + // hook's veto. + Notices []string } type commandResult struct { @@ -193,6 +209,13 @@ func (dispatcher *Dispatcher) Dispatch(ctx context.Context, input DispatchInput) if message := hookMessage(result); message != "" { outcome.Messages = append(outcome.Messages, message) } + // Before the veto check below, so hook A's disclosure is not lost when hook + // B stops the chain. A notice describes something that ALREADY happened. + for _, notice := range result.Notices { + if strings.TrimSpace(notice) != "" { + outcome.Notices = append(outcome.Notices, notice) + } + } if blocked { outcome.Blocked = true From 8fe68b91c8a82769de6f1fb6269f4de31e030a47 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Fri, 4 Sep 2026 12:52:55 +0530 Subject: [PATCH 37/43] docs(hooks): state the property the notice accumulation actually holds --- internal/hooks/dispatch.go | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/internal/hooks/dispatch.go b/internal/hooks/dispatch.go index 2f1c7a1d3..bb831caa6 100644 --- a/internal/hooks/dispatch.go +++ b/internal/hooks/dispatch.go @@ -49,9 +49,9 @@ type DispatchOutcome struct { // Separate from Messages because they answer different questions and have // different audiences. A notice says the hook ran under a weakened token, // which the model and the operator both need; the hook's own output is for - // afterTool validators that asked to be heard. Accumulated BEFORE the veto - // short-circuit, so a notice from a hook that already ran survives a later - // hook's veto. + // afterTool validators that asked to be heard. Appended as each hook runs, + // rather than read off the final result, so a disclosure from a hook that + // already ran survives a later hook's veto ending the chain. Notices []string } @@ -209,8 +209,9 @@ func (dispatcher *Dispatcher) Dispatch(ctx context.Context, input DispatchInput) if message := hookMessage(result); message != "" { outcome.Messages = append(outcome.Messages, message) } - // Before the veto check below, so hook A's disclosure is not lost when hook - // B stops the chain. A notice describes something that ALREADY happened. + // Appended per hook rather than read off the final result, so hook A's + // disclosure is not lost when hook B stops the chain. A notice describes + // something that ALREADY happened. for _, notice := range result.Notices { if strings.TrimSpace(notice) != "" { outcome.Notices = append(outcome.Notices, notice) From 2a5cf107a0987753ede9841800ac20a6820e6b36 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Fri, 4 Sep 2026 12:57:50 +0530 Subject: [PATCH 38/43] fix(execution): observe the adapter's launch while the process is still running The Windows helper publishes childLaunched immediately after CreateProcessAsUser creates the restricted child, and only then waits for it. ProcessManager read that report exclusively in the post-Wait goroutine, so for the entire live lifetime of a retained wrapped session the report was the zero value. The first exec_command reply and every write_stdin poll resolved Launched=false and disclosed nothing, while the fact sat readable on disk. A watcher, or a retained session nobody polls to completion, would never be told the write jail had been traded away. The launch fact is a monotonic lifecycle transition, not terminal process data. managedProcess now observes it once, latches it, and hands it out on live results as well as the terminal one. markDone will not overwrite a latched launch with a terminal read, because the plan's cleanup has already removed the report file by then on some orderings. Only the positive is promoted. An absent, partial, or undecodable report, and a helper that failed before it created the child, all leave the live result exactly as before: not confirmed, nothing disclosed. Latching false or surfacing a read error from the live read would let a mid-flight poll rewrite a running command into a setup failure. Direct commands and bwrap are untouched: the read is gated on the plan being adapter-owned, so an unwrapped plan does no extra work. Regressions drive the real ProcessManager with a real child that publishes the way the helper does and then stays alive: a live start and a live poll both disclose, a helper that reported no child stays silent, and a report published mid-flight is observed on the next poll. --- .../execution/live_launch_observation_test.go | 199 ++++++++++++++++++ internal/execution/process_manager.go | 117 +++++++--- 2 files changed, 289 insertions(+), 27 deletions(-) create mode 100644 internal/execution/live_launch_observation_test.go diff --git a/internal/execution/live_launch_observation_test.go b/internal/execution/live_launch_observation_test.go new file mode 100644 index 000000000..2b5e8a270 --- /dev/null +++ b/internal/execution/live_launch_observation_test.go @@ -0,0 +1,199 @@ +package execution + +import ( + "context" + "encoding/json" + "errors" + "os" + "os/exec" + "path/filepath" + "runtime" + "testing" + "time" +) + +const liveLaunchNotice = "denyRead is configured, so the write jail is not confining writes" + +// liveReportReader mirrors sandbox.CommandPlan.ExecutionReport: read the file the +// helper publishes, treat "not there yet" as nothing recorded. +func liveReportReader(path string) func() (AdapterReport, error) { + return func() (AdapterReport, error) { + raw, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return AdapterReport{}, nil + } + if err != nil { + return AdapterReport{}, err + } + var report AdapterReport + if err := json.Unmarshal(raw, &report); err != nil { + return AdapterReport{}, err + } + return report, nil + } +} + +// liveHelperCommand stands in for the Windows helper: publish the launch fact the +// way it does right after CreateProcessAsUser, then stay alive the way it does +// while waiting on the child. +func liveHelperCommand(t *testing.T, reportPath string, publish bool) *exec.Cmd { + t.Helper() + if publish { + if err := os.WriteFile(reportPath, []byte(`{"childLaunched":true}`), 0o600); err != nil { + t.Fatalf("publish the launch report: %v", err) + } + } + if runtime.GOOS == "windows" { + // A child that holds itself open without exiting. + return exec.Command("cmd.exe", "/c", "pause") + } + return exec.Command("/bin/sh", "-c", "sleep 30") +} + +// liveRequest is a valid interactive request; ProcessManager.Start validates it +// before anything under test runs. +func liveRequest(t *testing.T) Request { + t.Helper() + return Request{ + Origin: OriginInteractiveCommand, + Mode: ModeInteractive, + Command: Command{Name: "helper"}, + WorkingDirectory: t.TempDir(), + WorkspaceRoots: []string{t.TempDir()}, + Approval: ApprovalContext{PolicyVersion: PolicyVersion}, + } +} + +// A RETAINED SESSION HAS TO DISCLOSE WHILE IT IS STILL RUNNING. +// +// The helper publishes childLaunched immediately after it creates the restricted +// child, and only then waits for it. The manager read that report exclusively in +// the post-Wait goroutine, so for the whole live lifetime of a wrapped session the +// report was the zero value: the first exec_command reply and every write_stdin +// poll resolved Launched=false and disclosed nothing, while the fact sat readable +// on disk. A watcher, or a retained session nobody polls to completion, would +// never be told the write jail had been traded away. +// +// Driven through the real ProcessManager with a real child process, and asserted +// on the ProcessResult the tool layer consumes. +func TestALiveWrappedSessionCarriesTheLaunchFact(t *testing.T) { + directory := t.TempDir() + reportPath := filepath.Join(directory, "report.json") + command := liveHelperCommand(t, reportPath, true) + + manager := NewProcessManager(ProcessManagerOptions{}) + result, err := manager.Start(context.Background(), ProcessStart{ + Prepared: PreparedCommand{ + Command: command, + ChildLaunchOwnedByAdapter: true, + Enforcement: Enforcement{Notices: []string{liveLaunchNotice}}, + Report: liveReportReader(reportPath), + }, + Request: liveRequest(t), + }, 300*time.Millisecond) + if err != nil { + t.Fatalf("Start: %v", err) + } + t.Cleanup(func() { manager.StopAll() }) + + // SETUP: this has to be the LIVE state, and the fact has to be on disk, or the + // assertion below would be about a terminal read. + if result.Exited { + t.Fatal("SETUP INVALID: the stand-in helper exited, so the live lifecycle is not under test") + } + if _, statErr := os.Stat(reportPath); statErr != nil { + t.Fatalf("SETUP INVALID: the launch report is not on disk, so there is nothing to observe: %v", statErr) + } + + if !ResolveChildLaunched(true, result.ChildLaunchOwnedByAdapter, result.Report) { + t.Fatal("a live wrapped session resolved as not launched, so its enforcement disclosure is withheld while the command runs") + } + + // And again on a poll, which is the write_stdin leg. + polled, err := manager.Continue(context.Background(), ProcessContinue{ProcessID: result.ProcessID, Wait: 200 * time.Millisecond}) + if err != nil { + t.Fatalf("Continue: %v", err) + } + if polled.Exited { + t.Fatal("SETUP INVALID: the helper exited before the poll, so the live poll is not under test") + } + if !ResolveChildLaunched(true, polled.ChildLaunchOwnedByAdapter, polled.Report) { + t.Fatal("a live poll of a wrapped session resolved as not launched") + } + if got := polled.Enforcement.Notices; len(got) != 1 || got[0] != liveLaunchNotice { + t.Fatalf("the live poll carries notices %v, want exactly the one planned notice", got) + } +} + +// AND A HELPER THAT NEVER CREATED THE CHILD STAYS SILENT. +// +// This is the negative the live read must not destroy. A helper that starts and +// then fails setup, ACL application, or CreateProcessAsUser has an outer process +// running and no child, so nothing may be promoted from the fact that the +// wrapper itself is alive. +func TestALiveWrappedSessionWithNoReportedChildStaysSilent(t *testing.T) { + directory := t.TempDir() + reportPath := filepath.Join(directory, "report.json") + command := liveHelperCommand(t, reportPath, false) + + manager := NewProcessManager(ProcessManagerOptions{}) + result, err := manager.Start(context.Background(), ProcessStart{ + Prepared: PreparedCommand{ + Command: command, + ChildLaunchOwnedByAdapter: true, + Enforcement: Enforcement{Notices: []string{liveLaunchNotice}}, + Report: liveReportReader(reportPath), + }, + Request: liveRequest(t), + }, 300*time.Millisecond) + if err != nil { + t.Fatalf("Start: %v", err) + } + t.Cleanup(func() { manager.StopAll() }) + + if result.Exited { + t.Fatal("SETUP INVALID: the stand-in helper exited, so the live lifecycle is not under test") + } + if _, statErr := os.Stat(reportPath); statErr == nil { + t.Fatal("SETUP INVALID: a report exists, so this is not the no-child case") + } + if ResolveChildLaunched(true, result.ChildLaunchOwnedByAdapter, result.Report) { + t.Fatal("a helper that reported no child was promoted to a launch, so the operator is told a write jail was traded away for a child that never existed") + } +} + +// A report that appears mid-flight is observed on the next poll, which is what +// makes this a lifecycle transition rather than a start-time snapshot. +func TestTheLaunchFactIsObservedWhenItAppearsMidFlight(t *testing.T) { + directory := t.TempDir() + reportPath := filepath.Join(directory, "report.json") + command := liveHelperCommand(t, reportPath, false) + + manager := NewProcessManager(ProcessManagerOptions{}) + result, err := manager.Start(context.Background(), ProcessStart{ + Prepared: PreparedCommand{ + Command: command, + ChildLaunchOwnedByAdapter: true, + Report: liveReportReader(reportPath), + }, + Request: liveRequest(t), + }, 200*time.Millisecond) + if err != nil { + t.Fatalf("Start: %v", err) + } + t.Cleanup(func() { manager.StopAll() }) + if ResolveChildLaunched(true, result.ChildLaunchOwnedByAdapter, result.Report) { + t.Fatal("SETUP INVALID: nothing was published yet, so the first result must not be a launch") + } + + if err := os.WriteFile(reportPath, []byte(`{"childLaunched":true}`), 0o600); err != nil { + t.Fatalf("publish the launch report: %v", err) + } + polled, err := manager.Continue(context.Background(), ProcessContinue{ProcessID: result.ProcessID, Wait: 200 * time.Millisecond}) + if err != nil { + t.Fatalf("Continue: %v", err) + } + if !ResolveChildLaunched(true, polled.ChildLaunchOwnedByAdapter, polled.Report) { + t.Fatal("the launch published while the session was live was never observed") + } +} diff --git a/internal/execution/process_manager.go b/internal/execution/process_manager.go index 29045f5ee..02e2e995a 100644 --- a/internal/execution/process_manager.go +++ b/internal/execution/process_manager.go @@ -367,32 +367,46 @@ func (manager *ProcessManager) removeCompletedLater(process *managedProcess) { } type managedProcess struct { - id int - commandText string - cwd string - relativeCwd string - startedAt time.Time - lastUsedAt time.Time - tty bool - command *exec.Cmd - request Request - enforcement Enforcement - ownedLaunch bool - report func() (AdapterReport, error) - cleanup func() - stdin io.WriteCloser - output *processOutputBuffer - reaped chan struct{} - doneOnce sync.Once - done chan struct{} - kill func(int) error - mu sync.Mutex - exitCode *int - waitErr error - resultReport AdapterReport - reportErr error - changes []Change - metadata map[string]string + id int + commandText string + cwd string + relativeCwd string + startedAt time.Time + lastUsedAt time.Time + tty bool + command *exec.Cmd + request Request + enforcement Enforcement + ownedLaunch bool + // launchObserved latches the adapter's launch transition the first time it is + // seen, so a live result can report it. Guarded by mu. + launchObserved bool + report func() (AdapterReport, error) + cleanup func() + stdin io.WriteCloser + output *processOutputBuffer + reaped chan struct{} + doneOnce sync.Once + done chan struct{} + kill func(int) error + mu sync.Mutex + exitCode *int + waitErr error + resultReport AdapterReport + reportErr error + changes []Change + metadata map[string]string +} + +// launchedReportLocked returns the report to hand out, with a latched live +// launch folded in. Caller holds mu. +func (process *managedProcess) launchedReportLocked() AdapterReport { + report := process.resultReport + if report.ChildLaunched == nil && process.launchObserved { + launched := true + report.ChildLaunched = &launched + } + return report } func (process *managedProcess) markDone(err error, exitCode int, report AdapterReport, reportErr error, changes []Change) { @@ -400,14 +414,63 @@ func (process *managedProcess) markDone(err error, exitCode int, report AdapterR process.waitErr = err process.exitCode = &exitCode process.resultReport = report + // The plan's cleanup has already removed the report file by the time this + // runs on some orderings, so a terminal read can answer "nothing recorded" + // about a child that demonstrably started. A launch we already saw is not + // un-seen by that. + if report.ChildLaunched == nil && process.launchObserved { + launched := true + process.resultReport.ChildLaunched = &launched + } process.reportErr = reportErr process.changes = append([]Change(nil), changes...) process.mu.Unlock() process.doneOnce.Do(func() { close(process.done) }) } +// observeLaunch reads the adapter's launch report while the process is still +// running, and latches a confirmed launch. +// +// THE LAUNCH FACT IS A LIFECYCLE TRANSITION, NOT TERMINAL DATA. The Windows +// helper publishes childLaunched immediately after CreateProcessAsUser creates +// the restricted child, and then waits for it. The manager used to read the +// report only in the post-Wait goroutine, so for the entire live lifetime of a +// retained session the report was the zero value: the first exec_command reply +// and every write_stdin poll resolved Launched=false and disclosed nothing, +// even though the fact was sitting readable on disk. A watcher or an abandoned +// retained session could therefore never be told the write jail had been traded +// away. The MCP launcher already reads the report while its server is live; +// this is the same read, in the launcher that was left behind. +// +// ONLY THE POSITIVE IS PROMOTED, AND ONLY ONCE. An absent, partial, or +// undecodable report, and a helper that failed before it ever created the child, +// must all leave the live result exactly as it was: not confirmed, nothing +// disclosed. Latching false, or surfacing a read error or a denial from here, +// would let a mid-flight poll rewrite a running command into a setup failure. +// The latch also keeps a wrapped plan to one file read rather than one per poll, +// and leaves every unwrapped plan doing no extra work at all. +func (process *managedProcess) observeLaunch() { + if process.report == nil { + return + } + process.mu.Lock() + skip := !process.ownedLaunch || process.launchObserved + process.mu.Unlock() + if skip || process.doneClosed() { + return + } + report, err := process.report() + if err != nil || report.ChildLaunched == nil || !*report.ChildLaunched { + return + } + process.mu.Lock() + process.launchObserved = true + process.mu.Unlock() +} + func (process *managedProcess) collectResult(ctx context.Context, wait time.Duration, interrupted bool) ProcessResult { output, truncated := process.collect(ctx, wait) + process.observeLaunch() process.mu.Lock() exitCode := 0 exited := process.exitCode != nil @@ -418,7 +481,7 @@ func (process *managedProcess) collectResult(ctx context.Context, wait time.Dura ProcessID: process.id, CommandText: process.commandText, RelativeCwd: process.relativeCwd, TTY: process.tty, Output: output, OutputTruncated: truncated, Exited: exited, ExitCode: exitCode, Interrupted: interrupted, Request: process.request, - Enforcement: process.enforcement, Report: process.resultReport, ReportErr: process.reportErr, + Enforcement: process.enforcement, Report: process.launchedReportLocked(), ReportErr: process.reportErr, ChildLaunchOwnedByAdapter: process.ownedLaunch, Changes: append([]Change(nil), process.changes...), Metadata: cloneStringMap(process.metadata), } From 08071eca35f301431fdd40186172ee6578da84da Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Fri, 4 Sep 2026 13:03:13 +0530 Subject: [PATCH 39/43] fix(mcp): gate the initialization-error disclosure on the confirmed child For an adapter-owned launch, cmd.Start proves only that the sandbox helper started. It can then fail setup-marker validation, ACL application, network validation, token construction, or CreateProcessAsUser without ever creating the requested MCP server. The launch sink made that distinction; the initialize-error path did not, and carried the planned notices out unconditionally. The operator was told a server had run without write confinement when no server had run at all. The adapter-gated path was also dead on that same route. client.Close runs the plan cleanup, which deletes the report file, and it ran before publishAdapterLaunch read it, so a wrapped server that really did launch and then failed its handshake published nothing either. Two competing definitions of applied enforcement, and both were wrong in opposite directions. connectStdio now resolves the launch fact once, through execution.ResolveChildLaunched, memoized, and reads it before Close so the evidence still exists. The sink and the error carrier consume that one answer. The success path is unchanged: a completed handshake is independent proof the child existed, and gating it on a mis-written report would suppress a true disclosure. Direct stdio and bwrap are unaffected, since an unwrapped plan resolves as launched exactly as before. Regressions drive RegisterTools with no ClientFactory, so the real connectStdio runs, and with a preparer whose cleanup deletes the report the way the Windows plan does. A helper reporting no child, and one that wrote no report at all, disclose nothing while still being recorded as skipped; a helper that reports the child discloses exactly once. --- .../mcp/adapter_launch_disclosure_test.go | 163 ++++++++++++++++++ internal/mcp/client.go | 44 ++++- 2 files changed, 202 insertions(+), 5 deletions(-) create mode 100644 internal/mcp/adapter_launch_disclosure_test.go diff --git a/internal/mcp/adapter_launch_disclosure_test.go b/internal/mcp/adapter_launch_disclosure_test.go new file mode 100644 index 000000000..10c8d298d --- /dev/null +++ b/internal/mcp/adapter_launch_disclosure_test.go @@ -0,0 +1,163 @@ +package mcp + +import ( + "context" + "encoding/json" + "errors" + "os" + "os/exec" + "path/filepath" + "runtime" + "testing" + + "github.com/Gitlawb/zero/internal/config" + "github.com/Gitlawb/zero/internal/execution" + "github.com/Gitlawb/zero/internal/tools" +) + +// helperCommandName is a command that resolves on this platform, so registration +// reaches connectStdio. What actually runs is whatever the preparer returns. +func helperCommandName() string { + if runtime.GOOS == "windows" { + return "cmd.exe" + } + return "sh" +} + +const adapterLaunchNotice = "denyRead is configured, so the write jail is not confining writes" + +// adapterHelperPreparer models the Windows wrapped plan: the command is the +// HELPER, the launch fact is a report file, and the plan's cleanup removes that +// file. The cleanup is the part that matters, because it is what runs inside +// client.Close and destroys the evidence a later decision needs. +type adapterHelperPreparer struct { + reportPath string + reportBody string + writeReport bool + called bool +} + +func (preparer *adapterHelperPreparer) PrepareExecution(_ context.Context, _ execution.Request) (execution.PreparedCommand, error) { + preparer.called = true + if preparer.writeReport { + if err := os.WriteFile(preparer.reportPath, []byte(preparer.reportBody), 0o600); err != nil { + return execution.PreparedCommand{}, err + } + } + // A helper that starts, says nothing an MCP client understands, and exits, so + // the handshake fails the way it does when the requested server never existed. + var command *exec.Cmd + if runtime.GOOS == "windows" { + command = exec.Command("cmd.exe", "/c", "exit 0") + } else { + command = exec.Command("/bin/sh", "-c", "exit 0") + } + path := preparer.reportPath + return execution.PreparedCommand{ + Command: command, + ChildLaunchOwnedByAdapter: true, + Enforcement: execution.Enforcement{Notices: []string{adapterLaunchNotice}}, + Report: func() (execution.AdapterReport, error) { + raw, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return execution.AdapterReport{}, nil + } + if err != nil { + return execution.AdapterReport{}, err + } + var report execution.AdapterReport + if err := json.Unmarshal(raw, &report); err != nil { + return execution.AdapterReport{}, err + } + return report, nil + }, + Cleanup: func() { _ = os.Remove(path) }, + }, nil +} + +func registerWithAdapterHelper(t *testing.T, preparer *adapterHelperPreparer) *Runtime { + t.Helper() + runtime, err := RegisterTools(context.Background(), tools.NewRegistry(), config.MCPConfig{Servers: map[string]config.MCPServerConfig{ + "docs": {Type: "stdio", Command: helperCommandName()}, + }}, RegisterOptions{ + // No ClientFactory on purpose: an injected factory skips connectStdio + // entirely, which is where the decision under test is made, and the test + // would pass with the fix removed. + Execution: execution.NewRunner(preparer), + WorkspaceRoot: t.TempDir(), + }) + if err != nil { + t.Fatalf("RegisterTools: %v", err) + } + t.Cleanup(func() { _ = runtime.Close() }) + return runtime +} + +// A HELPER THAT NEVER CREATED THE SERVER HAS NOTHING TO DISCLOSE. +// +// For an adapter-owned launch, cmd.Start proves only that the sandbox helper +// started. It can then fail setup-marker validation, ACL application, network +// validation, token construction, or CreateProcessAsUser without ever creating +// the requested MCP server. The launch sink already made that distinction; the +// initialize-error path did not, and carried the planned notices out +// unconditionally. The operator was told the server had run without write +// confinement when no server had run at all. +func TestAnMCPHelperThatReportedNoChildDisclosesNothing(t *testing.T) { + directory := t.TempDir() + preparer := &adapterHelperPreparer{ + reportPath: filepath.Join(directory, "report.json"), + reportBody: `{"childLaunched":false}`, + writeReport: true, + } + runtime := registerWithAdapterHelper(t, preparer) + + // SETUP: the attempt really did fail, or there is no disclosure decision here. + if len(runtime.Skipped()) == 0 { + t.Fatal("SETUP INVALID: the server connected, so the initialize-failure path is not under test") + } + if got := runtime.StartupDisclosures(); len(got) != 0 { + t.Fatalf("a helper that reported no child announced %v; no server ran, confined or otherwise", got) + } +} + +// AND ONE THAT DID CREATE IT DISCLOSES ONCE. +// +// The companion case, and the one that keeps the assertion above from being +// satisfied by a path that discloses nothing ever. The child ran under the +// planned token and may have done filesystem work before the handshake failed, +// so the disclosure has to survive the failure. +func TestAnMCPHelperThatLaunchedTheChildDisclosesOnce(t *testing.T) { + directory := t.TempDir() + preparer := &adapterHelperPreparer{ + reportPath: filepath.Join(directory, "report.json"), + reportBody: `{"childLaunched":true}`, + writeReport: true, + } + runtime := registerWithAdapterHelper(t, preparer) + + if !preparer.called { + t.Fatal("SETUP INVALID: the preparer never ran, so connectStdio was never reached") + } + disclosures := runtime.StartupDisclosures() + if len(disclosures) != 1 { + t.Fatalf("a server that really ran under the weakened token produced %d disclosures, want exactly one: %v", len(disclosures), disclosures) + } + if len(disclosures[0].Notices) != 1 || disclosures[0].Notices[0] != adapterLaunchNotice { + t.Fatalf("the disclosure carries %v, want the one planned notice", disclosures[0].Notices) + } +} + +// A helper that wrote no report at all is the same answer as one that reported +// no child: absence is not confirmation. +func TestAnMCPHelperThatWroteNoReportDisclosesNothing(t *testing.T) { + directory := t.TempDir() + preparer := &adapterHelperPreparer{reportPath: filepath.Join(directory, "report.json")} + runtime := registerWithAdapterHelper(t, preparer) + + if len(runtime.Skipped()) == 0 { + t.Fatal("SETUP INVALID: the server connected, so the initialize-failure path is not under test") + } + if got := runtime.StartupDisclosures(); len(got) != 0 { + t.Fatalf("a helper that published nothing announced %v", got) + } +} diff --git a/internal/mcp/client.go b/internal/mcp/client.go index 808ac2f9c..964eba0a4 100644 --- a/internal/mcp/client.go +++ b/internal/mcp/client.go @@ -257,12 +257,37 @@ func connectStdio(ctx context.Context, server Server, options ConnectOptions) (* // adapter confirms the requested child was created. Called on both ways this // attempt can end, which is also where an attempt abandoned at the connect // timeout eventually arrives, so a late disclosure is still delivered once. - publishAdapterLaunch := func() { - if !ownedLaunch || adapterReport == nil { - return + // ONE ANSWER, READ WHILE THE EVIDENCE STILL EXISTS, USED BY EVERY OUTCOME. + // + // The adapter's report is a file the plan's cleanup deletes. client.Close runs + // that cleanup, so a decision made after Close reads an absent report and + // answers "no child" about a server that really did run. Resolving once and + // memoizing removes the ordering hazard rather than documenting it. + // + // It also gives the success, late and failed paths the same input. The failure + // path used to carry client.StartupNotices() unconditionally while the sink was + // gated on the adapter, which is two competing definitions of applied + // enforcement: an operator was told a server ran without write confinement when + // only the sandbox helper ran and the requested server never existed. + launchedOnce := sync.OnceValue(func() bool { + if !ownedLaunch { + return true + } + if adapterReport == nil { + return false } report, err := adapterReport() - if err != nil || report.ChildLaunched == nil || !*report.ChildLaunched { + if err != nil { + return false + } + return execution.ResolveChildLaunched(true, ownedLaunch, report) + }) + // publishAdapterLaunch announces a wrapped plan's launch, but only if the + // adapter confirms the requested child was created. Called on both ways this + // attempt can end, which is also where an attempt abandoned at the connect + // timeout eventually arrives, so a late disclosure is still delivered once. + publishAdapterLaunch := func() { + if !ownedLaunch || !launchedOnce() { return } publishLaunch(ctx, plannedEnforcement.Notices) @@ -290,6 +315,8 @@ func connectStdio(ctx context.Context, server Server, options ConnectOptions) (* // bare error discards the client, and with it the only carrier the notices // had, so the operator was told the server was unavailable and not that it // had already run without the write jail. + // BEFORE Close, which runs the cleanup that deletes the report. + launched := launchedOnce() _ = client.Close() publishAdapterLaunch() message := strings.TrimSpace(stderr.String()) @@ -297,7 +324,14 @@ func connectStdio(ctx context.Context, server Server, options ConnectOptions) (* if message != "" { failure = fmt.Errorf("initialize MCP server %s: %w: %s", server.Name, err, message) } - return nil, &startupDisclosureError{err: failure, notices: client.StartupNotices()} + // Same decision as the sink above. For a wrapped plan whose helper started + // and then failed before creating the requested server, there is nothing to + // disclose: no server ran, confined or otherwise. + var carried []string + if launched { + carried = client.StartupNotices() + } + return nil, &startupDisclosureError{err: failure, notices: carried} } publishAdapterLaunch() return client, nil From 12037f67dabb57dc0061514a9a0846035e71bdd3 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Fri, 4 Sep 2026 13:12:40 +0530 Subject: [PATCH 40/43] docs(agent): record why the notice delivery path needs no rebudget --- internal/agent/loop.go | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/internal/agent/loop.go b/internal/agent/loop.go index 53f5a1fbb..a896112b1 100644 --- a/internal/agent/loop.go +++ b/internal/agent/loop.go @@ -2061,6 +2061,19 @@ func noticesBefore(outcome hooks.DispatchOutcome) []string { // ran: a later hook's veto, and a denied, cancelled, or ungrantable unsandboxed // retry. Routing all three through one function is what keeps "the hook ran // under this token" from depending on which exit the call happened to take. +// +// NO REBUDGET HERE, AND THAT IS LOAD-BEARING ON WHAT MAY PASS THROUGH. The +// normal tail appends and then calls Registry.RebudgetAfterHook, because what it +// appends is afterTool feedback: hook stdout, which a hook can make arbitrarily +// large. These notices cannot be. Their one producer is +// sandbox.windowsDenyReadWarnings, which returns a single fixed sentence, and +// nothing hook-authored reaches this slice: DispatchOutcome.Messages is where +// hook output lives, and the capture site deliberately does not read it. +// +// So if anything ever widens what is delivered here to include text a hook or a +// tool can size, this needs the rebudget step as well, which means converting +// through tools.Result the way the tail does rather than editing Output in +// place. Do not widen it without that. func withBeforeToolNotices(result ToolResult, notices []string) ToolResult { feedback := joinHookMessages(notices, "") if strings.TrimSpace(feedback) == "" { From 27009bd0c30ca31e18a9c153612ef0b454725e48 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Sat, 5 Sep 2026 15:00:52 +0530 Subject: [PATCH 41/43] fix(execution,mcp,sandbox): settle the launch decision before caching a no The cross-process launch report has three states, not two: not settled yet, settled with no child, and child created. An absent file, the empty file the adapter opens before launching, a half-written one and a decode error are all the first state. Collapsing them to "no child" is a claim about a process that may be running at that instant, and every consumer was making that collapse on its own and compensating for the timing separately, which is why fixing one presentation site kept exposing the next. Three changes, one contract. The Windows helper now creates the sandboxed child with CREATE_SUSPENDED and resumes it only after the report is published. The child inherits the MCP pipes, so created runnable it could answer initialize, emit a malformed response or close stdout before the helper was next scheduled, and a parent reading the report then saw the empty file and cached "no child" about a server already running unconfined. It also closes the second manifestation: publishing could fail AFTER a runnable child had begun making external changes, and reaping it afterwards does not undo the work it did. A suspended child has executed nothing, so every failure between creation and resume terminates a process that never ran and the missing report is then true. ChildLaunchTracker is the settlement rule, at the adapter boundary where every consumer can share it. It caches a launch and never an absence: a negative read stays provisional until the answer is terminal. Two things make it terminal. Confirm, when the consumer observed the child itself, and Settle, when the adapter process has exited. Settle runs from Cleanup, before the report file is deleted, so reading the evidence and destroying it are one step rather than a race the consumer has to win. The MCP client uses both. A successful initialize response is Confirm: the adapter speaks no MCP and the child is created suspended, so a well-formed response can only have come from the requested server. On the failing exit the decision now comes after Close instead of before it, because Close waits out the adapter and cleanup settles with the report still on disk. Asking first asked an adapter that may be between creating the child and recording it, and the connect timeout ends the attempt exactly there. The existing fixture writes the finished report during PrepareExecution, before the helper command starts, so every ordering above is over before the parent looks. The new tests drive a real helper process that publishes at a controlled point: after answering the handshake, after failing it, and not at all. --- internal/execution/child_launch.go | 146 +++++++++++ internal/mcp/adapter_launch_ordering_test.go | 236 ++++++++++++++++++ internal/mcp/client.go | 50 ++-- .../windows_execution_report_windows.go | 17 +- internal/sandbox/windows_process_windows.go | 29 ++- 5 files changed, 442 insertions(+), 36 deletions(-) create mode 100644 internal/execution/child_launch.go create mode 100644 internal/mcp/adapter_launch_ordering_test.go diff --git a/internal/execution/child_launch.go b/internal/execution/child_launch.go new file mode 100644 index 000000000..f06aeb61f --- /dev/null +++ b/internal/execution/child_launch.go @@ -0,0 +1,146 @@ +package execution + +import "sync" + +// ChildLaunchTracker is the one monotonic answer to "did the requested child +// run", shared by every consumer of a prepared command. +// +// THE REPORT HAS THREE STATES, NOT TWO. For a plan whose child is created inside +// an adapter, the report file passes through: not settled yet, settled with no +// child, and child created. An absent file, an empty file the adapter opened but +// has not written, a half-written one, and a decode error are all the FIRST +// state, and collapsing them to "no child" is a claim about a process that may be +// running at that instant. Consumers used to make that collapse independently and +// then compensate for the timing on their own, which is why fixing one +// presentation site kept exposing the next. +// +// Two things make an answer terminal here: +// +// - Confirm, when the consumer has observed the child directly. A stdio MCP +// server answering initialize is that observation: the adapter speaks no MCP, +// so a well-formed response can only have come from the requested child. +// - Settle, when the adapter process itself has exited. After that the report +// will never change, so whatever it says, including nothing, is the truth. +// +// Settle runs from Cleanup, BEFORE the report file is deleted. Reading the +// evidence and then destroying it in one step is what lets a consumer ask after +// close and still get an answer, rather than racing a deletion it does not +// control. +// +// The decision only ever moves from unknown to known and never back, so two +// consumers of the same prepared command cannot disagree, and a retry cannot turn +// a launch that happened into one that did not. +type ChildLaunchTracker struct { + mu sync.Mutex + settled bool + launched bool + + ownedByAdapter bool + report func() (AdapterReport, error) +} + +// NewChildLaunchTracker builds the tracker for a prepared command and returns it +// with a Cleanup that settles before releasing the plan's resources. +// +// The returned cleanup is the one the caller must use. Calling the prepared +// command's own Cleanup instead deletes the report while the decision is still +// unknown, which is the ordering hazard this type exists to remove. +func NewChildLaunchTracker(prepared PreparedCommand) (*ChildLaunchTracker, func()) { + tracker := &ChildLaunchTracker{ + ownedByAdapter: prepared.ChildLaunchOwnedByAdapter, + report: prepared.Report, + } + // A plan whose child is the started process needs no evidence: Start + // succeeding IS the launch, so the answer is terminal from the beginning. + if !tracker.ownedByAdapter { + tracker.settled = true + tracker.launched = true + } + inner := prepared.Cleanup + return tracker, func() { + tracker.Settle() + if inner != nil { + inner() + } + } +} + +// Confirm latches a launch the consumer observed for itself. +// +// Positive evidence is always terminal. Nothing that happens later can make a +// child that answered not have run, so this needs no settlement and cannot be +// undone by a subsequent Settle finding no report. +func (tracker *ChildLaunchTracker) Confirm() { + if tracker == nil { + return + } + tracker.mu.Lock() + defer tracker.mu.Unlock() + tracker.settled = true + tracker.launched = true +} + +// Settle freezes the answer because the adapter has finished. +// +// One last read first: the adapter may have published between the consumer's +// last look and its exit, and this is the only remaining chance to see it. +func (tracker *ChildLaunchTracker) Settle() { + if tracker == nil { + return + } + tracker.mu.Lock() + defer tracker.mu.Unlock() + if tracker.settled { + return + } + tracker.launched = tracker.readReportLocked() + tracker.settled = true +} + +// Launched reports the decision so far. +// +// Before settlement this reads the report and answers true only for a published +// launch. A negative answer here is NOT remembered, because the adapter may still +// be between creating the child and recording it; the next caller asks again. +// Consumers that need a final answer settle first, which Cleanup does for them. +func (tracker *ChildLaunchTracker) Launched() bool { + if tracker == nil { + return false + } + tracker.mu.Lock() + defer tracker.mu.Unlock() + if tracker.settled { + return tracker.launched + } + if tracker.readReportLocked() { + tracker.launched = true + tracker.settled = true + return true + } + return false +} + +// Settled reports whether the answer is final, for callers that would otherwise +// present an unknown as a fact. +func (tracker *ChildLaunchTracker) Settled() bool { + if tracker == nil { + return false + } + tracker.mu.Lock() + defer tracker.mu.Unlock() + return tracker.settled +} + +// readReportLocked answers only the positive case. A missing file, an empty one, +// a partial write and a decode error are all "nothing published yet", which is +// the state this type refuses to record as an outcome. +func (tracker *ChildLaunchTracker) readReportLocked() bool { + if tracker.report == nil { + return false + } + report, err := tracker.report() + if err != nil { + return false + } + return ResolveChildLaunched(true, tracker.ownedByAdapter, report) +} diff --git a/internal/mcp/adapter_launch_ordering_test.go b/internal/mcp/adapter_launch_ordering_test.go new file mode 100644 index 000000000..91538c4b0 --- /dev/null +++ b/internal/mcp/adapter_launch_ordering_test.go @@ -0,0 +1,236 @@ +package mcp + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Gitlawb/zero/internal/config" + "github.com/Gitlawb/zero/internal/execution" + "github.com/Gitlawb/zero/internal/tools" +) + +const ( + adapterHelperModeEnv = "ZERO_TEST_MCP_ADAPTER_MODE" + adapterHelperReportEnv = "ZERO_TEST_MCP_ADAPTER_REPORT" + + // answerThenPublish models the ordering the fix is about: the child is + // serving before the adapter has recorded that it exists. + answerThenPublish = "answer-then-publish" + // failThenPublish is the same ordering on the other exit: the handshake dies + // first and the adapter publishes on its way out. + failThenPublish = "fail-then-publish" + // failAndNeverPublish is the companion that keeps the two above from being + // satisfied by disclosing unconditionally. + failAndNeverPublish = "fail-and-never-publish" +) + +// TestAdapterHelperProcess is the helper process body, not a test. +// +// It exists so the report can be published at a controlled point RELATIVE TO THE +// HANDSHAKE. A fixture that writes the finished report during PrepareExecution, +// as the older test does, publishes before the helper command has even started, +// so every ordering this file is about is already over before the parent looks. +func TestAdapterHelperProcess(t *testing.T) { + mode := os.Getenv(adapterHelperModeEnv) + if mode == "" { + t.Skip("not the helper process") + } + reportPath := os.Getenv(adapterHelperReportEnv) + publish := func() { + // Same shape the Windows helper writes, into the file the preparer already + // created empty. + _ = os.WriteFile(reportPath, []byte(`{"childLaunched":true}`), 0o600) + } + + switch mode { + case answerThenPublish: + serveOneMCPSessionThenPublish(publish) + case failThenPublish: + // The handshake dies here, before anything is recorded. Closing stdout is + // the child going away; the adapter is still running. + _ = os.Stdout.Close() + time.Sleep(300 * time.Millisecond) + publish() + case failAndNeverPublish: + _ = os.Stdout.Close() + time.Sleep(300 * time.Millisecond) + } + // Before the framework can write anything to the pipe the parent is reading. + os.Exit(0) +} + +// serveOneMCPSessionThenPublish answers the handshake and only then records that +// the child exists, which is the interleaving that used to lose the disclosure. +func serveOneMCPSessionThenPublish(publish func()) { + reader := bufio.NewReader(os.Stdin) + out := bufio.NewWriter(os.Stdout) + published := false + for { + line, err := reader.ReadString('\n') + if strings.TrimSpace(line) != "" { + var message struct { + ID *int `json:"id"` + Method string `json:"method"` + } + if json.Unmarshal([]byte(strings.TrimSpace(line)), &message) == nil && message.ID != nil { + var result string + switch message.Method { + case "initialize": + result = `{"protocolVersion":"2024-11-05"}` + case "tools/list": + result = `{"tools":[]}` + default: + result = `{}` + } + fmt.Fprintf(out, `{"jsonrpc":"2.0","id":%d,"result":%s}`+"\n", *message.ID, result) + _ = out.Flush() + if message.Method == "initialize" && !published { + // AFTER the response is on the wire. The parent can act on a + // handshake that succeeded before this line runs. + time.Sleep(300 * time.Millisecond) + publish() + published = true + } + } + } + if err != nil { + return + } + } +} + +// livePublishPreparer models the Windows wrapped plan with the real publication +// ordering: the report file is created EMPTY before the launch, exactly as +// openWindowsExecutionReport does, and the helper fills it in later. +type livePublishPreparer struct { + mode string + reportPath string + called bool +} + +func (preparer *livePublishPreparer) PrepareExecution(_ context.Context, _ execution.Request) (execution.PreparedCommand, error) { + preparer.called = true + // The empty file the parent can see before anything is published. This is the + // state that is neither "no child" nor "child created". + if err := os.WriteFile(preparer.reportPath, nil, 0o600); err != nil { + return execution.PreparedCommand{}, err + } + command := exec.Command(os.Args[0], "-test.run=^TestAdapterHelperProcess$") + command.Env = append(os.Environ(), + adapterHelperModeEnv+"="+preparer.mode, + adapterHelperReportEnv+"="+preparer.reportPath, + ) + path := preparer.reportPath + return execution.PreparedCommand{ + Command: command, + ChildLaunchOwnedByAdapter: true, + Enforcement: execution.Enforcement{Notices: []string{adapterLaunchNotice}}, + Report: func() (execution.AdapterReport, error) { + raw, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return execution.AdapterReport{}, nil + } + if err != nil { + return execution.AdapterReport{}, err + } + var report execution.AdapterReport + // An empty or half-written file decodes to an error, which is the + // unsettled state and not an answer. + if err := json.Unmarshal(raw, &report); err != nil { + return execution.AdapterReport{}, err + } + return report, nil + }, + Cleanup: func() { _ = os.Remove(path) }, + }, nil +} + +func registerWithLivePublisher(t *testing.T, mode string) (*Runtime, *livePublishPreparer) { + t.Helper() + preparer := &livePublishPreparer{mode: mode, reportPath: filepath.Join(t.TempDir(), "report.json")} + runtime, err := RegisterTools(context.Background(), tools.NewRegistry(), config.MCPConfig{Servers: map[string]config.MCPServerConfig{ + "docs": {Type: "stdio", Command: helperCommandName()}, + }}, RegisterOptions{ + Execution: execution.NewRunner(preparer), + WorkspaceRoot: t.TempDir(), + }) + if err != nil { + t.Fatalf("RegisterTools: %v", err) + } + t.Cleanup(func() { _ = runtime.Close() }) + if !preparer.called { + t.Fatal("SETUP INVALID: the preparer never ran, so connectStdio was never reached") + } + return runtime, preparer +} + +// A HANDSHAKE THAT SUCCEEDED IS PROOF THE CHILD RAN, WHATEVER THE REPORT SAYS YET. +// +// The child is created before the adapter records it, and it inherits the MCP +// pipes, so it can answer initialize while the report file is still the empty one +// the adapter opened. Reading it at that instant and remembering the answer +// turned "not yet" into "never" for the rest of the session, and the operator was +// never told the server serving these tools ran without the write jail. +// +// The adapter speaks no MCP, so a well-formed response can only have come from +// the requested child. That is terminal evidence and needs no report. +func TestADisclosureSurvivesAReportPublishedAfterTheHandshake(t *testing.T) { + runtime, _ := registerWithLivePublisher(t, answerThenPublish) + + // SETUP: the server really connected, or this is the failure path instead. + if skipped := runtime.Skipped(); len(skipped) != 0 { + t.Fatalf("SETUP INVALID: the server did not connect (%v), so the successful-handshake path is not under test", skipped) + } + disclosures := runtime.StartupDisclosures() + if len(disclosures) != 1 { + t.Fatalf("a server that answered the handshake produced %d disclosures, want exactly one: %v", len(disclosures), disclosures) + } + if len(disclosures[0].Notices) != 1 || disclosures[0].Notices[0] != adapterLaunchNotice { + t.Fatalf("the disclosure carries %v, want the one planned notice", disclosures[0].Notices) + } +} + +// AND ON THE OTHER EXIT, THE DECISION WAITS FOR THE ADAPTER. +// +// Here the handshake dies first and the adapter publishes on its way out. The +// decision used to be taken before Close, precisely because Close deletes the +// report, so it read the empty file and answered "no child" about a server that +// really had run. Settling from inside cleanup, with the file still there, is what +// lets the answer be taken after the adapter is terminal instead of before it. +func TestADisclosureSurvivesAReportPublishedAfterAFailedHandshake(t *testing.T) { + runtime, _ := registerWithLivePublisher(t, failThenPublish) + + // SETUP: the attempt really failed, or the successful path is being measured. + if len(runtime.Skipped()) == 0 { + t.Fatal("SETUP INVALID: the server connected, so the initialize-failure path is not under test") + } + disclosures := runtime.StartupDisclosures() + if len(disclosures) != 1 { + t.Fatalf("a helper that published its launch on the way out produced %d disclosures, want exactly one: %v", len(disclosures), disclosures) + } +} + +// AND AN ADAPTER THAT NEVER PUBLISHED STILL DISCLOSES NOTHING. +// +// The companion that keeps both cases above from being satisfied by announcing +// unconditionally. Settling reads the report one last time and finds nothing, +// which after the adapter has exited is the answer rather than a race. +func TestNoDisclosureWhenTheAdapterExitsWithoutPublishing(t *testing.T) { + runtime, _ := registerWithLivePublisher(t, failAndNeverPublish) + + if len(runtime.Skipped()) == 0 { + t.Fatal("SETUP INVALID: the server connected, so the initialize-failure path is not under test") + } + if got := runtime.StartupDisclosures(); len(got) != 0 { + t.Fatalf("a helper that published nothing announced %v; no server is known to have run", got) + } +} diff --git a/internal/mcp/client.go b/internal/mcp/client.go index 964eba0a4..4ae9b1107 100644 --- a/internal/mcp/client.go +++ b/internal/mcp/client.go @@ -191,7 +191,7 @@ func connectStdio(ctx context.Context, server Server, options ConnectOptions) (* var plannedEnforcement execution.Enforcement // Retained from the prepared plan rather than dropped: for a wrapped plan the // adapter, not cmd.Start, owns whether the requested server process exists. - var adapterReport func() (execution.AdapterReport, error) + var launchTracker *execution.ChildLaunchTracker var ownedLaunch bool cleanupTransferred := false defer func() { @@ -216,10 +216,11 @@ func connectStdio(ctx context.Context, server Server, options ConnectOptions) (* return nil, fmt.Errorf("start MCP server %s: %w", server.Name, err) } cmd = prepared.Command - cleanup = prepared.Cleanup plannedEnforcement = prepared.Enforcement - adapterReport = prepared.Report ownedLaunch = prepared.ChildLaunchOwnedByAdapter + // The tracker's cleanup, not the plan's: it settles the launch decision + // before the report file it was read from is deleted. + launchTracker, cleanup = execution.NewChildLaunchTracker(prepared) } else { cmd = exec.CommandContext(ctx, server.Command, server.Args...) cmd.Env = mergeProcessEnv(server.Env) @@ -257,31 +258,29 @@ func connectStdio(ctx context.Context, server Server, options ConnectOptions) (* // adapter confirms the requested child was created. Called on both ways this // attempt can end, which is also where an attempt abandoned at the connect // timeout eventually arrives, so a late disclosure is still delivered once. - // ONE ANSWER, READ WHILE THE EVIDENCE STILL EXISTS, USED BY EVERY OUTCOME. + // ONE ANSWER, AND IT IS ONLY CACHED ONCE IT CANNOT CHANGE. // - // The adapter's report is a file the plan's cleanup deletes. client.Close runs - // that cleanup, so a decision made after Close reads an absent report and - // answers "no child" about a server that really did run. Resolving once and - // memoizing removes the ordering hazard rather than documenting it. + // The adapter's report is a file the plan's cleanup deletes, so a decision made + // after cleanup used to read an absent report and answer "no child" about a + // server that really did run. Memoizing the first read fixed that ordering and + // introduced another: the read can land while the adapter has created the child + // and not yet recorded it, and "not yet" got frozen as "never". + // + // ChildLaunchTracker holds the three states apart. It caches a launch, never an + // absence, until the adapter is terminal, and it settles from cleanup with the + // report still on disk. See internal/execution/child_launch.go. // // It also gives the success, late and failed paths the same input. The failure // path used to carry client.StartupNotices() unconditionally while the sink was // gated on the adapter, which is two competing definitions of applied // enforcement: an operator was told a server ran without write confinement when // only the sandbox helper ran and the requested server never existed. - launchedOnce := sync.OnceValue(func() bool { + launchedOnce := func() bool { if !ownedLaunch { return true } - if adapterReport == nil { - return false - } - report, err := adapterReport() - if err != nil { - return false - } - return execution.ResolveChildLaunched(true, ownedLaunch, report) - }) + return launchTracker.Launched() + } // publishAdapterLaunch announces a wrapped plan's launch, but only if the // adapter confirms the requested child was created. Called on both ways this // attempt can end, which is also where an attempt abandoned at the connect @@ -315,9 +314,13 @@ func connectStdio(ctx context.Context, server Server, options ConnectOptions) (* // bare error discards the client, and with it the only carrier the notices // had, so the operator was told the server was unavailable and not that it // had already run without the write jail. - // BEFORE Close, which runs the cleanup that deletes the report. - launched := launchedOnce() + // AFTER Close, which waits out the adapter and then settles the decision + // with the report still on disk. Asking first would ask an adapter that may + // be between creating the child and recording it, and read "not yet" as + // "never": the connect timeout ends the attempt here while the helper is + // still working. _ = client.Close() + launched := launchedOnce() publishAdapterLaunch() message := strings.TrimSpace(stderr.String()) failure := fmt.Errorf("initialize MCP server %s: %w", server.Name, err) @@ -333,6 +336,13 @@ func connectStdio(ctx context.Context, server Server, options ConnectOptions) (* } return nil, &startupDisclosureError{err: failure, notices: carried} } + // THE HANDSHAKE IS THE OBSERVATION. A wrapped plan's adapter speaks no MCP and + // creates the child suspended, so a well-formed initialize response can only + // have come from the requested server, already running. That is terminal + // evidence and does not depend on the report having been read yet, which is + // what keeps a long-lived session from having to wait for an adapter that will + // not exit until the session ends. + launchTracker.Confirm() publishAdapterLaunch() return client, nil } diff --git a/internal/sandbox/windows_execution_report_windows.go b/internal/sandbox/windows_execution_report_windows.go index 9e474a2c9..f95bbdf03 100644 --- a/internal/sandbox/windows_execution_report_windows.go +++ b/internal/sandbox/windows_execution_report_windows.go @@ -75,15 +75,16 @@ func (report *windowsExecutionReport) close(published bool) { report.file = nil } -// terminateAndReapWindowsChild takes down a child this helper has launched and -// waits for it to actually exit. +// terminateSuspendedWindowsChild takes down a child that was created suspended +// and never resumed, and waits for it to actually leave. // -// Used on the paths where the helper cannot continue after CreateProcessAsUser -// has already succeeded. Returning there without this would leave the requested -// command or MCP server running with nobody waiting on it, cancelling it, or -// cleaning up after it, while the parent reads a missing report, concludes no -// child launched, and is free to start a second one. -func terminateAndReapWindowsChild(process windows.Handle) { +// Used on the paths between CreateProcessAsUser and ResumeThread. The process +// exists and holds the inherited pipes, so it has to be closed out rather than +// abandoned, but it has executed no instructions: there is no work to undo and +// nothing for the parent to be told about. That is what makes returning an error +// here honest, since the report was never published and "no child launched" is +// exactly what happened. +func terminateSuspendedWindowsChild(process windows.Handle) { if process == 0 { return } diff --git a/internal/sandbox/windows_process_windows.go b/internal/sandbox/windows_process_windows.go index ce751a2e3..1b61711e6 100644 --- a/internal/sandbox/windows_process_windows.go +++ b/internal/sandbox/windows_process_windows.go @@ -73,7 +73,7 @@ func runWindowsCommandAsUser(token windows.Token, config WindowsSandboxCommandCo nil, nil, true, - windows.CREATE_UNICODE_ENVIRONMENT, + windows.CREATE_UNICODE_ENVIRONMENT|windows.CREATE_SUSPENDED, envPtr, cwdPtr, &startup, @@ -88,17 +88,30 @@ func runWindowsCommandAsUser(token windows.Token, config WindowsSandboxCommandCo // failures apart from a real sandboxed launch. The restricted child exists as // of this line, so this is where the fact is published. // - // OWNERSHIP OUTLIVES REPORTING. The child is runnable and may already be - // making external side effects, so a failure to publish must not return from - // here and leave it running with nobody waiting on it: the parent would read a - // missing report, correctly conclude that no child launched, and be free to - // start a second one alongside the first. Take it down and reap it, then - // report the failure. + // CREATED SUSPENDED, SO REPORTING CANNOT LOSE A RACE IT IS IN. The child + // inherits the MCP pipes. Created runnable, it could answer initialize, emit a + // malformed response, or close stdout before this helper was next scheduled, + // and a parent reading the report at that moment would see the empty file the + // open above created and cache "no child" about a server that was already + // running unconfined. It could also fail to publish AFTER a runnable child had + // begun making external changes, and reaping the child then does not undo the + // work it did. + // + // A suspended child has executed nothing. Publish first and resume second, and + // the absence of a report becomes a fact rather than a race: every failure + // between creation and resume terminates a process that never ran, so "no child + // launched" is true when the parent reads it. if err := report.publish(true); err != nil { - terminateAndReapWindowsChild(process.Process) + terminateSuspendedWindowsChild(process.Process) return 1, fmt.Errorf("record sandboxed child launch: %w", err) } published = true + // AND ONLY NOW MAY IT RUN. Resuming after the fact is durable is what makes a + // missing report mean what the parent reads it to mean. + if _, err := windows.ResumeThread(process.Thread); err != nil { + terminateSuspendedWindowsChild(process.Process) + return 1, fmt.Errorf("resume sandboxed process: %w", err) + } if _, err := windows.WaitForSingleObject(process.Process, windows.INFINITE); err != nil { return 1, fmt.Errorf("wait for sandboxed process: %w", err) } From 2146443cf6d3540773c3f8851229cc3dd4d7793d Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Sat, 5 Sep 2026 15:04:07 +0530 Subject: [PATCH 42/43] fix(mcp): gate the success-path disclosure on the launch decision too connectAndList reads client.StartupNotices() on the success path and hands the result straight to the disclosure sources, with no launch check anywhere in between. For a wrapped plan those notices were recorded because cmd.Start returned, which is the HELPER starting: the one thing the report exists because it does not prove. The answer happened to be right, since a completed handshake does imply the child ran, but by coincidence rather than by rule, while the failure path beside it was already asking the adapter. Found by falsifying: dropping the handshake confirmation left every test passing, because the success disclosure never consulted the decision at all. StartupNotices now goes through the same decision as the sink and the error, so one rule covers all three carriers. The ordering test also asserts the failure disclosure travels through the ERROR, not only the sink. Registration merges the two, so a sink-only assertion passed while the decision was taken before the adapter settled and the failure the operator reads carried nothing. ChildLaunchTracker gets its own tests, because the rule that a negative read is not remembered is invisible through the MCP paths: they each ask once, at a point where the answer is already terminal. Pinned where the rule lives instead. --- internal/execution/child_launch_test.go | 168 +++++++++++++++++++ internal/mcp/adapter_launch_ordering_test.go | 13 ++ internal/mcp/client.go | 16 ++ 3 files changed, 197 insertions(+) create mode 100644 internal/execution/child_launch_test.go diff --git a/internal/execution/child_launch_test.go b/internal/execution/child_launch_test.go new file mode 100644 index 000000000..11d57af09 --- /dev/null +++ b/internal/execution/child_launch_test.go @@ -0,0 +1,168 @@ +package execution + +import "testing" + +// scriptedReport is an adapter report whose answer changes over time, the way a +// real one does: the file is opened empty, decodes to an error while nothing has +// been written, and only later carries the fact. +type scriptedReport struct { + launched bool + reads int + err error +} + +func (script *scriptedReport) read() (AdapterReport, error) { + script.reads++ + if script.err != nil { + return AdapterReport{}, script.err + } + if !script.launched { + // What an empty or half-written file decodes to. NOT childLaunched=false, + // which would be the adapter stating an outcome. + return AdapterReport{}, nil + } + launched := true + return AdapterReport{ChildLaunched: &launched}, nil +} + +func trackerOver(script *scriptedReport) (*ChildLaunchTracker, func()) { + return NewChildLaunchTracker(PreparedCommand{ + ChildLaunchOwnedByAdapter: true, + Report: script.read, + }) +} + +// A NEGATIVE READ IS NOT AN OUTCOME UNTIL THE ADAPTER IS DONE. +// +// This is the whole point of the type. Reading the report between the adapter +// creating the child and recording it answers "nothing published", and caching +// that answer freezes "not yet" into "never" for the rest of the session. +func TestAnUnsettledNegativeIsNotRemembered(t *testing.T) { + script := &scriptedReport{} + tracker, _ := trackerOver(script) + + if tracker.Launched() { + t.Fatal("SETUP INVALID: an unpublished report answered launched, so there is no negative here to cache") + } + if tracker.Settled() { + t.Fatal("a read that found nothing settled the decision; the adapter may still be about to publish") + } + + // The adapter publishes, as it does a moment after CreateProcessAsUser. + script.launched = true + if !tracker.Launched() { + t.Fatal("the launch published after the first read was never seen; the earlier negative was cached") + } + if !tracker.Settled() { + t.Fatal("a confirmed launch left the decision open") + } +} + +// And once it IS an outcome it stays one, so the answer is monotonic and two +// consumers of the same prepared command cannot disagree. +func TestASettledLaunchIsNeverWithdrawn(t *testing.T) { + script := &scriptedReport{launched: true} + tracker, cleanup := trackerOver(script) + + if !tracker.Launched() { + t.Fatal("SETUP INVALID: a published report did not answer launched") + } + // Cleanup deletes the report in production, which is what a later read would + // find. The decision must not follow it. + script.launched = false + cleanup() + if !tracker.Launched() { + t.Fatal("the decision followed the report file into deletion; a server that ran became one that did not") + } +} + +// CLEANUP SETTLES BEFORE IT DESTROYS THE EVIDENCE. +// +// The report is a file the plan's cleanup removes. A consumer that asks after +// cleanup used to read an absent report and answer "no child" about a server that +// really did run, which is why the decision was being taken early and hitting the +// unsettled window instead. +func TestCleanupSettlesBeforeReleasingThePlan(t *testing.T) { + script := &scriptedReport{launched: true} + var order []string + tracker, cleanup := NewChildLaunchTracker(PreparedCommand{ + ChildLaunchOwnedByAdapter: true, + Report: func() (AdapterReport, error) { + order = append(order, "read") + return script.read() + }, + Cleanup: func() { order = append(order, "release") }, + }) + + cleanup() + if len(order) != 2 || order[0] != "read" || order[1] != "release" { + t.Fatalf("cleanup order = %v, want the report read before the plan is released", order) + } + if !tracker.Launched() { + t.Fatal("the launch published just before cleanup was lost; nothing read the report on the way out") + } +} + +// An adapter that exits having published nothing settles negative, and that +// answer is final: this is the case the unsettled rule must not swallow. +func TestAnAdapterThatPublishedNothingSettlesNegative(t *testing.T) { + script := &scriptedReport{} + tracker, cleanup := trackerOver(script) + cleanup() + + if !tracker.Settled() { + t.Fatal("the adapter finished without the decision becoming final") + } + if tracker.Launched() { + t.Fatal("an adapter that published nothing was credited with a launch") + } + // And a report that changes after the adapter is gone changes nothing. + script.launched = true + if tracker.Launched() { + t.Fatal("a settled decision was reopened by a later read") + } +} + +// Direct evidence outranks the report and needs no settlement, because nothing +// that happens later can make a child that answered not have run. +func TestConfirmIsTerminalWithoutAReport(t *testing.T) { + script := &scriptedReport{} + tracker, cleanup := trackerOver(script) + + tracker.Confirm() + if !tracker.Launched() || !tracker.Settled() { + t.Fatal("an observed child did not settle the decision") + } + cleanup() + if !tracker.Launched() { + t.Fatal("settling from cleanup overwrote an observed launch with an absent report") + } +} + +// A plan whose started process IS the requested command has nothing to decide, +// and must not be made to wait on a report it will never have. +func TestAPlanTheAdapterDoesNotOwnIsLaunchedFromTheStart(t *testing.T) { + tracker, _ := NewChildLaunchTracker(PreparedCommand{ChildLaunchOwnedByAdapter: false}) + if !tracker.Launched() || !tracker.Settled() { + t.Fatal("a directly started command was not treated as launched") + } +} + +// A report that fails to read is the unsettled state, not a negative outcome. +func TestADecodeErrorIsNotAnAnswer(t *testing.T) { + script := &scriptedReport{err: errScriptedReportBroken} + tracker, _ := trackerOver(script) + + if tracker.Launched() { + t.Fatal("a broken report was read as a launch") + } + if tracker.Settled() { + t.Fatal("a broken report settled the decision; a half-written file is a moment, not an outcome") + } +} + +var errScriptedReportBroken = errScriptedReport("unexpected end of JSON input") + +type errScriptedReport string + +func (e errScriptedReport) Error() string { return string(e) } diff --git a/internal/mcp/adapter_launch_ordering_test.go b/internal/mcp/adapter_launch_ordering_test.go index 91538c4b0..1c548613b 100644 --- a/internal/mcp/adapter_launch_ordering_test.go +++ b/internal/mcp/adapter_launch_ordering_test.go @@ -217,6 +217,19 @@ func TestADisclosureSurvivesAReportPublishedAfterAFailedHandshake(t *testing.T) if len(disclosures) != 1 { t.Fatalf("a helper that published its launch on the way out produced %d disclosures, want exactly one: %v", len(disclosures), disclosures) } + + // THROUGH THE ERROR, not only through the sink. Registration merges the two, + // so asserting the sink alone passes while the failure the operator actually + // reads carries nothing. This is the carrier that exists because the client + // holding the notices has already been closed and discarded by then. + skipped := runtime.Skipped() + if len(skipped) != 1 { + t.Fatalf("skipped = %v, want exactly the one server", skipped) + } + carried := startupNoticesFromError(skipped[0].Err) + if len(carried) != 1 || carried[0] != adapterLaunchNotice { + t.Fatalf("the initialize failure carries %v, want the planned notice; the decision was taken before the adapter settled", carried) + } } // AND AN ADAPTER THAT NEVER PUBLISHED STILL DISCLOSES NOTHING. diff --git a/internal/mcp/client.go b/internal/mcp/client.go index 4ae9b1107..20340885f 100644 --- a/internal/mcp/client.go +++ b/internal/mcp/client.go @@ -75,10 +75,21 @@ type startupDisclosing interface { } // StartupNotices reports the disclosures that applied to this server's launch. +// +// GATED ON THE SAME DECISION AS EVERY OTHER CARRIER. connectAndList reads this on +// the success path and hands the result straight to the disclosure sources, so +// for a while a successfully connected wrapped server disclosed on the strength +// of cmd.Start returning: the HELPER starting, which is the one thing the report +// exists because it does not prove. It happened to be right, since a completed +// handshake does imply the child ran, but by coincidence rather than by rule, and +// the failure path next to it was already asking the adapter. func (client *Client) StartupNotices() []string { if client == nil || len(client.startupNotices) == 0 { return nil } + if client.launched != nil && !client.launched() { + return nil + } return append([]string(nil), client.startupNotices...) } @@ -102,6 +113,10 @@ type Client struct { // reduced write confinement. Kept typed here and rendered exactly once at // registration rather than pasted onto every later tool result. startupNotices []string + // launched is the launch decision this server's disclosures are gated on, nil + // for a plan whose started process IS the server and where there is nothing to + // decide. See internal/execution/child_launch.go. + launched func() bool // dispatchMu guards the response-dispatch state shared with the single // reader goroutine. It is never held across a blocking read. @@ -305,6 +320,7 @@ func connectStdio(ctx context.Context, server Server, options ConnectOptions) (* // nothing: same launch-state rule hooks and plugins use, expressed by where // this assignment sits rather than by another outcome-kind switch. startupNotices: append([]string(nil), plannedEnforcement.Notices...), + launched: launchedOnce, } cleanupTransferred = true if err := client.initialize(ctx); err != nil { From 1873b20e05468d2fb155b0923dbacfde60a2a171 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Sat, 5 Sep 2026 15:07:02 +0530 Subject: [PATCH 43/43] test(mcp): pin the StartupNotices launch gate at the carrier The gate is redundant today: every route that reaches StartupNotices has already settled the decision positive through the handshake confirmation, so removing it left every test passing. Unfalsifiable code is a stop signal, so it is pinned directly rather than left as an unchecked claim. It stays because the redundancy is on the safe side. An edit that moves or loses the confirmation makes this return nothing rather than announce a confinement on the strength of the helper having started. --- internal/mcp/adapter_launch_ordering_test.go | 32 ++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/internal/mcp/adapter_launch_ordering_test.go b/internal/mcp/adapter_launch_ordering_test.go index 1c548613b..6f0d54ccb 100644 --- a/internal/mcp/adapter_launch_ordering_test.go +++ b/internal/mcp/adapter_launch_ordering_test.go @@ -247,3 +247,35 @@ func TestNoDisclosureWhenTheAdapterExitsWithoutPublishing(t *testing.T) { t.Fatalf("a helper that published nothing announced %v; no server is known to have run", got) } } + +// THE CARRIER ENFORCES THE RULE ITSELF, NOT ONLY ITS CALLERS. +// +// connectAndList reads StartupNotices on the success path and passes the result +// straight through to the disclosure sources, so this method is a carrier of the +// launch fact in its own right. Today the handshake confirmation makes the gate +// redundant: every route that reaches here has already settled the decision +// positive. It is kept because the redundancy is on the safe side. An edit that +// moves or loses the confirmation makes this return nothing rather than announce +// a confinement on the strength of the helper having started, which is the claim +// this whole mechanism exists to stop making. +// +// Driven directly, because no path through connectStdio can reach it with a +// negative decision, and a test that cannot construct the state it is about would +// be asserting nothing. +func TestStartupNoticesAreEmptyWhileTheLaunchIsUnknown(t *testing.T) { + client := &Client{startupNotices: []string{adapterLaunchNotice}} + + // SETUP: ungated, this client discloses, or the assertion below is vacuous. + if len(client.StartupNotices()) != 1 { + t.Fatal("SETUP INVALID: the client discloses nothing even before the gate, so the gate cannot be what is under test") + } + + client.launched = func() bool { return false } + if got := client.StartupNotices(); len(got) != 0 { + t.Fatalf("a client whose launch is not established carries %v", got) + } + client.launched = func() bool { return true } + if got := client.StartupNotices(); len(got) != 1 || got[0] != adapterLaunchNotice { + t.Fatalf("an established launch carries %v, want the planned notice", got) + } +}