Compare commits

...
Author SHA1 Message Date
Vasanthdev2004 1dfe275e50 Merge remote-tracking branch 'origin/main' into fix/windows-restricted-sid-invariant 2026-10-25 19:37:43 +05:30
Vasanth T 5d080ef46b test(tui): pin the cwd in tests that assert text is absent from the view (#1170)
* test(tui): pin the cwd in tests that assert text is absent from the view

Nine tests built their model with an empty Options.Cwd, so newModel took
the package directory and the title bar showed the developer's real
branch and checkout path. Each then asserted that some text was absent
from the whole view, and failed wherever that branch or path happened to
contain it: TestSuggestionOverlayRenders on a branch with /model in its
name, TestSuggestionOverlayCapsRowsWithoutMoreText on a branch or path
containing "more" or "compact", the provider wizard tests on "offline",
"chatgpt" or "gpt-4.1", and so on.

They now pin Options.Cwd to isolatedCwd, a path that does not exist, so
no branch is read and the path is the same on every machine. A guard
test checks that isolatedCwd stays outside any git checkout.

* test(tui): make isolatedCwd fully qualified and absent by construction

The fixed /workspace/zero path could exist on some machine, a dev
container for one, and newModel would then read whatever is there. On
Windows it was also only drive-relative: \workspace\zero is not a fully
qualified path.

isolatedCwd is now a child of the home TestMain isolates that nothing
creates, so it is fully qualified, absent and outside any checkout on
every platform, and the title bar shows ~/not-a-checkout everywhere. A
t.TempDir path would have put the user name and the test name into the
view. The guard test checks all three properties.

* test(tui): clear the branch in view-test models instead of trusting the cwd

isolatedCwd is a child of the isolated home, which lives under the
temporary directory. With TMPDIR inside a checkout, gitBranch walks up
from that absent path to the checkout and puts its branch in the title
bar, so the guard failed on an ordinary branch and a colliding branch
broke the nine view tests again.

newViewTestModel now builds those models: it sets the isolated cwd and
clears the branch after construction, since no path can promise there
is no checkout above it. The guard keeps only what a path can promise
(fully qualified and absent), and a new test puts the home inside a
checkout on feat/model-picker and checks that neither that branch nor
the real checkout path reaches the view.

* test(tui): assert no checkout path reaches the view in the enclosing-checkout test

The test checked the branch strings and that the isolated cwd shows,
but a view could show both the isolated cwd and a real checkout path
without failing it. It now also asserts that neither the enclosing
checkout nor the directory the tests run from appears in the view.
2026-10-22 06:52:51 -07:00
Vasanth T 1d233c0a9b test(modelregistry): clear ZERO_DISABLE_MODELS_FETCH in the models.dev tests (#1169)
The variable turns off both the models.dev fetch and the cached overlay,
and four tests in modelsdev_test.go inherited it from the shell. With it
set, TestRefreshModelsDevCacheRejectsBadBodyWithoutClobbering and
TestDefaultModelEntriesAppliesFreshCache fail, and
TestCachedModelsDevProvidersIgnoresStaleCache and
TestCachedModelsDevProvidersRequiresOptIn pass without reaching the
check they are named for: the nil they expect comes from the variable.

Clear it in each of them, as the two sibling refresh tests already do.
2026-10-21 07:33:36 -07:00
Vasanth T de1443b2ed fix(deps): refresh the lockfile to hono 4.13.13 (#1171)
GHSA-5r4p-p66f-jhc7 (moderate): hono's serveStatic decodes the request
path a second time, so middleware on static paths can be bypassed.
Versions 4.12.4 to 4.13.10 are affected and main pins 4.13.9 through
tuistory, which release packaging installs as a local-control helper.

npm update takes hono to 4.13.13, inside every range that asks for it.
package.json is unchanged and nothing else in the lock moves.
2026-10-21 07:12:22 -07:00
Felix-Ayush 84c1b15540 fix(oauth): reuse the crash-safe secret store and sync encrypted publications (#1165)
* fix(oauth): use the crash-safe secret store and sync token publications

The encrypted-file OAuth backend carried its own AES-GCM secret implementation that had drifted from internal/securefile: a crash during first login left <secret>.lock behind and every later Save timed out instead of reclaiming it, and the secret and token blobs were published without fsync.\n\nUse securefile.Crypter with an opt-in publication directory so the secret still publishes through the sandbox-denied .secret.publish directory, add file and directory barriers to the encrypted token blob, and extend securefile with the publication directory plus destination-directory sync.\n\nMove the concurrent-creation and wrong-sized-secret coverage to securefile, add a stale-lock regression through the OAuth store, and test publication-dir use, sync ordering, sync-failure preservation, and retry after a failed secret sync. The stale-lock test fails on the previous code.

* test(oauth): cover post-rename directory-sync failures

The existing failure tests inject a file sync error before the rename. Add coverage for a destination-directory barrier failure after the token or secret has been atomically replaced: the write must report the error and the newly published bytes must remain readable. Also cover the corresponding securefile secret path, including its expected size.

* fix(securefile): never clobber a newer lock when restoring a reclaimed one

reclaimStaleLock could rename a lock that had just been replaced, then restore the quarantined copy with os.Rename, replacing a third holder's new lock. Both holders could then publish different secrets for the same file. Restore with O_CREATE|O_EXCL and discard the quarantined copy when the destination is already held.

Extend the directory-sync failure test to prove a later caller actually loads the published secret, not just its size. Add restore tests for the free and already-held cases.
2026-10-21 18:41:38 +08:00
Vasanth T 6393a90ed6 fix: bump Go to 1.26.10 for GO-2026-6809 (#1168)
govulncheck now reports GO-2026-6809 (crypto/x509 distrusts compromised
intermediate certificates) against Go 1.26.9, reachable through
daemon.WriteFrame, so Security & Code Health fails on main and on every
open PR. It is fixed in 1.26.10.

CI and release builds read the toolchain from go.mod. The stated
source-build minimum in README.md, README_ZH.md and docs/INSTALL.md
moves with it.
2026-10-21 16:37:49 +08:00
Vasanth T cd3e0b0a9f test(cli): isolate the exec image tests from the models.dev refresh (#1161)
runExec starts a background models.dev download unless
ZERO_DISABLE_MODELS_FETCH is set. TestRunExecImageAttachmentHistory moved
the user directories into a TempDir by hand but left the fetch on, so a
5.7 MB download landed in the TempDir while its cleanup ran, and the
macOS runner failed with "unlinkat ... directory not empty". The two
older image tests had no isolation and fetched into the real user cache.

Use isolateCLIUserState in all three, which also points the cache path
into the fixture and turns the fetch off.
2026-10-18 10:08:09 +08:00
Felix-Ayush aa9ba8412e fix(tools): grep stops where a file's line breaks stop (#1160)
* fix(tools): grep stops where a file's line breaks stop

scanGrepFile treated EOF after a trailing line break - and an empty file -
as the START of another line, and handed the matcher an empty line one
number past the real content. Every pattern that matches an empty string
then reported a match on a line no reader sees:

  a.txt holds "x\n"; grep pattern "^$"
    -> a.txt:2:           (grep -c '' GNU-style reports no such line)
  same file, pattern ".*", count mode
    -> "2 matches found"  (one real line, one phantom)
  an empty file, pattern "^$"
    -> a.txt:1:           (the file has no lines at all)

read_file and the file tracker's countLines already state the rule the
scan violated: "the trailing newline does not open a line", and an empty
file has no lines. The scan now stops there too, so anchored and
empty-matching patterns can only match the lines the content carries.

Real matches, numbering, count-per-hit accounting for non-empty patterns,
file mode, glob and symlink handling are unchanged.

* test(tools): make the grep line-number parity test catch the phantom

The parity test matched only "needle", a pattern that cannot match the
empty line the old EOF branch invented, so its absence-of-line-4
assertion passed on the code being fixed. The pattern now also matches an
empty line, and the expected output is asserted exactly: on the pre-fix
scanner the same run reported

    grep = "a.txt:2: needle two\na.txt:4: ",
        want exactly the line read_file numbers 2

so the phantom is visible right beside the real line, and the test is
about the numbering claim it states. Also gofmt-aligns the case table
that made the CI formatting check fail.
2026-10-18 10:07:34 +08:00
Felix-Ayush a97db8832f fix(redaction): redact credentials whose trigger a greedy span swallowed (#1158)
Two credentials glued end to end share one recovering property: the second
one's trigger prefix is where redaction can re-anchor. For fixed-length
bodies that anchor is exact (AKIA{16} stops at the next key), which is the
case the anchored end-chaining always covered. For unbounded bodies it
isn't: github's [A-Za-z0-9]{36,} body eats the literal "ghp" of the token
glued behind it, so the first match ends PAST the second trigger, the scan
that re-anchors at the span end finds nothing there, and the second
credential leaves the output as +_bbbb... / .eyJzdWIi... plaintext:

  RedactString("ghp_aaa..36a..ghp_bbb..36b..")
    -> "[REDACTED]_bbbb......"   (second token's body printed in full)
  RedactString(jwtA+jwtB)
    -> "[REDACTED].eyJzdWIiOiI5ODc2...SA" (second token's payload+sig)

The same holds across shapes (a github key directly feeding "sk-ant-..."
through its body) and for three tokens in one run.

redactAdjacent now follows the run a match starts: at each boundary it
re-binds the match at the EARLIEST trigger occurrence inside the span that
stills validates as its shape (the head before the trigger must match the
anchored pattern exactly, and the run chained from the trigger must reach
past the span, so a mid-body lookalike and a mid-span cut that recovers
nothing leave the span alone), and continues under whichever shape matched
the swallowed trigger. Candidate examination is capped at
maxTriggerOccurrences per span so body spam of trigger lookalikes costs
what the span costs, and the first validating cut wins.

End-chaining for fixed-length bodies, single-credential shapes, custom
replacement templates, and the split path's announced no-op-for-contiguous
text guarantee are all unchanged; the regression tests pin the glued pairs
that previously leaked plus the AWS chaining and lone-token cases.
2026-10-18 10:07:04 +08:00
Vasanthdev2004 3ad039e937 docs(mcp): drop the duplicated publishAdapterLaunch paragraph
The paragraph describing publishAdapterLaunch appeared twice: once above
launchedOnce, where it described the wrong closure, and once above
publishAdapterLaunch itself. Keep the second.
2026-10-17 18:03:42 +05:30
Vasanthdev2004 3123b8d36e Merge remote-tracking branch 'origin/main' into fix/windows-restricted-sid-invariant 2026-10-17 17:27:06 +05:30
Vasanthdev2004 a1d4ac3b9a chore: follow the Twigpine module path
main moved the module to github.com/Twigpine/zero in #1105. Rename this
branch's imports to match, including the two in internal/workspaceindex
that main itself still carries until #1153 lands.
2026-10-17 11:27:59 +05:30
Vasanthdev2004 0899b1132e Merge remote-tracking branch 'origin/main' into fix/windows-restricted-sid-invariant 2026-10-17 11:27:58 +05:30
Vasanthdev2004 433653b873 Merge remote-tracking branch 'origin/main' into fix/windows-restricted-sid-invariant 2026-10-14 15:42:52 +05:30
Vasanthdev2004 e09c43e20a Merge remote-tracking branch 'origin/main' into fix/windows-restricted-sid-invariant
# Conflicts:
#	internal/cli/app.go
#	internal/mcp/registry.go
2026-10-12 11:29:26 +05:30
Vasanthdev2004 066a3a2ff0 test(mcp): bound the stream Wait in the Close tests
An open, empty stream blocks Wait forever, which is the defect these tests
pin, so against the old Close they hung until the test binary timed out
instead of failing. Wait now runs behind a bound that reports the stream
open and closes it so the waiting goroutine returns.
2026-09-24 14:56:07 +05:30
Vasanthdev2004 b868a92290 fix(hooks,mcp): one owner for a vetoing hook's notice, and close the disclosure stream through its Once
A beforeTool hook that vetoed while carrying an enforcement notice put the
notice on two channels: Dispatch appended it to DispatchOutcome.Notices and
blockReason folded it into Reason as well. The one consumer made up for it
by dropping every notice whose text appeared in the reason, which also
dropped an earlier hook's disclosure whenever the blocking hook's output
quoted it. Reason now carries the hook's words only, and the veto result
takes every notice from the typed slice, the same owner hookMessage already
defers to.

Runtime.Close read the disclosure stream field directly. That raced a first
StartupDisclosureStream call, and a Close that came before any call closed
nothing, so a later caller got a fresh, open stream subscribed to launches
from a runtime that was already gone. Close now publishes through the same
Once, as an empty stream when nobody had asked for one, and closes it.

Tests: the launch-state and veto tests assert the typed slice and that the
reason carries no copy; the veto result is checked for the blocker's own
notice and for an earlier notice the reason quotes; a command that launched
and failed keeps its disclosure; the never-launched MCP check reads the
carried notices instead of the error text, which never included them.
2026-09-24 14:31:26 +05:30
Vasanthdev2004 daf9b8c65d fix(acp): persist tool results through the shared session contract
The ACP agent spelled its own tool-result payload and wrote result.Output.
After the output/notice split that field is the undecorated text, and the
payload had no notices field, so the enforcement disclosure was dropped at
the moment of persisting. The live tool_call_update for the same result
showed it, and session/load then replayed the sandboxed command as if
nothing had constrained it. Fixing the live translation could not reach
this: the text was already gone on disk.

The payload builder moves from internal/tui to internal/agent, beside
ToolResult, because it now has three writers and only one of them is a
terminal. The TUI keeps its unexported helpers as thin delegates, exec calls
the agent function directly, and ACP persists through the same function. A
TUI resume of an ACP-written session therefore gets the typed notices and the
undecorated card body like any other.

The ACP reader restores the disclosure as what it was, typed notices beside
undecorated text, so toolCallResult composes it exactly once. The stored
output already has the notices composed in, so tools gains the inverse of
WithEnforcementNotices next to it; text that was never decorated passes
through unchanged and still gets its notice. displayPreview is deliberately
not read there: it is a card body, and the live ACP update sends the model
text.

The regression runs a real turn through the production OnToolResult
callback, loads the session in a fresh agent, and requires the replayed
update to equal the live one with the notice exactly once, including for a
command that printed nothing. Reported by jatmn.
2026-09-21 13:27:39 +05:30
Vasanthdev2004 c022e6afe7 test(sandbox): pin the ownership order at the runner's earliest refusal
The previous version used an unsupported sandbox level, which is refused in the
level switch, so moving the join down to just above that switch still counted
one call and the test passed. A DenyRead profile on a restricted-token tier is
refused before the switch, so a join below any check now reads as not having
happened.
2026-09-19 11:55:56 +05:30
Vasanthdev2004 c1b4e27749 fix(sandbox): hold the child kill job for the process lifetime, and test it from outside
Two problems with the ownership this branch added, both mine.

The handle was closed in a defer. This process is a MEMBER of the job it
creates, so closing the last handle to it is the kill: the defer fired during
runWindowsSandboxCommand's own return and terminated the helper before its exit
code could be handed back. Every sandboxed command reported 0 however it really
ended, refusals included. Measured three ways in one process: no job exits 42,
join and hold exits 42, join and close exits 0. The handle is now held for the
process lifetime, which is what the guarantee was built on in the first place:
process exit is what releases it.

The regression tested it from inside the process that owns it. It called
joinWindowsChildKillJob in the test binary and then closed the handle, so it
terminated the test binary. `go test` printed "ok" and exited 0 with the
assertions never reached, and the 51 tests that had not run yet were silently
dropped: 315 of them completed where skipping this one test gave 366. Everything
is driven out of process now, through the test binary re-executed as a stand-in
for the command helper, parked in the create-to-resume window with a real
suspended child on the real production join.

On top of that the coverage review asked for:

- cancellation and deadline expiry both driven through execution.Runner's
  captured path over the sandbox Engine, the wiring hooks and plugins use, with
  the call required to return bounded AND the child required to be gone
- the control without the job, which reproduces the orphan and the blocked
  caller, so the two above cannot pass for an unrelated reason
- the exit code of the production runner, and that it takes ownership before it
  refuses a command
- cleanup that owns the child independently of every assertion

Reported by @jatmn.
2026-09-19 11:52:30 +05:30
Vasanthdev2004 937631a4ea Merge remote-tracking branch 'origin/main' into fix/windows-restricted-sid-invariant 2026-09-19 11:39:40 +05:30
Vasanthdev2004 9d8059cd49 Merge origin/main into fix/windows-restricted-sid-invariant
Keeps the typed enforcement notices and the Base* accessors this branch
introduced, and carries main's FileDiffs from #999 alongside them in both
ToolResult constructions; the ACP translation keeps composing the disclosure
through ModelOutput while building main's content slice.
2026-09-15 10:29:08 +05:30
Vasanthdev2004 7dfdbcb686 Merge remote-tracking branch 'origin/main' into HEAD 2026-09-13 20:01:17 +05:30
Vasanthdev2004 f1ff15eb94 fix(sandbox): give the suspended child an owner that outlives a killed helper
Creating the child suspended is what makes the launch report trustworthy: the
fact is published before the child can execute, so an absent report means
nothing ran. It also creates a state the helper's ordinary unwind does not
cover. Between CreateProcessAsUser and ResumeThread the child exists, holds the
inherited stdin/stdout/stderr handles, and can run nothing.

Hook and plugin commands reach the helper through Engine.CommandContext, which
uses the default exec.CommandContext cancellation, so a timeout or a cancel
kills the helper outright. It never reaches terminateSuspendedWindowsChild, its
defers, or the resume. Windows does not terminate a child because its parent
died, so the suspended process survives holding the pipe write ends and a
parent still reading them waits on a process that will never write and never
exit.

The helper now joins a job object with JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
before any child can exist, so every child created afterwards inherits
membership at creation and there is no window where one is alive and unowned.
Terminating the helper closes the last handle and the kernel does the killing,
which is the only mechanism that survives the helper being killed rather than
unwound. A failure to create the job is not fatal: it costs the guarantee, not
the sandbox.

Deliberately not fixed with exec.Cmd.WaitDelay in the parent. That releases the
caller while the orphan keeps running and keeps the pipes, trading a visible
hang for an invisible leak; it is a reasonable backstop, not the fix.

The regression drives the real kernel: join the job, create a suspended child,
close the last handle the way a terminated helper would, and require the child
to be gone. With a plain job instead, the child outlives it.

Reported by @jatmn.
2026-09-13 20:01:10 +05:30
Vasanthdev2004 eb3bbb2f12 fix(sandbox): drop the Windows denyRead trade notice now that denyRead is refused
Since #1006 a Windows profile with denyRead is rejected before setup or token creation on both restricted-token tiers, so there is no run left for the notice to describe. Keeping it meant a plan could carry a note saying reads are denied and writes unconfined while the runner refused to start that plan at all: two statements about one fact, disagreeing.

Removed: windowsDenyReadWarnings and its host indirection, denyReadDiagnosticWarnings and the append into the execution request warnings, the plan-notes append in the command-plan funnel, and the two predicates that only served it (windowsRestrictedTokenWillRun, willBuildWindowsRestrictedToken). The three test files that pinned the producer go with it.

CommandPlan.Notes now has no producer in the tree. The channel from plan notes into Enforcement.Notices stays, since it is what hooks, plugins and MCP read, and the end-to-end pin that reached it through the real producer is replaced by a fixture-plan test so deleting that one line still fails somewhere. The comment in loop.go that justified skipping the rebudget on the strength of that one producer now says so.

Closes the last part of #869, which #1006 settled by refusal.
2026-09-12 10:35:51 +05:30
Vasanthdev2004 9e09d0a7e6 Merge remote-tracking branch 'origin/main' into fix/windows-restricted-sid-invariant 2026-09-12 10:15:56 +05:30
Vasanthdev2004 f984082c82 test(sandbox): pin the retraction, and the fallback when it cannot be written
The unwind test asserted the report was removed on a resume failure, which
is the contract this branch just replaced: absence is what a normal cleanup
leaves too, so it does not revoke a launch a live reader already saw. It
now asserts an explicit false, with a companion for the case where the
retraction write itself fails and the file is discarded after all.
2026-09-09 11:25:07 +05:30
Vasanthdev2004 33f62e7f94 fix(sandbox,execution): let a failed resume revoke the launch it published
The report is published before ResumeThread, so the fact is readable while
the child has executed nothing. A live poll landing in that window latched
it, and the latch outlived the file: the terminal read finds the report
cleaned away and restores what was observed, so a child that never became
runnable was reported as launched and its write-jail trade disclosed as if
it had been made.

Deleting the report on that path cannot fix it, because deletion is also
what a normal cleanup does and the restore exists for exactly that. The
helper now retracts with an explicit false, which is the one answer that
outranks a latch, and the manager repeats the observation while the command
runs instead of caching it. Silence still changes nothing, so a genuine
launch still survives its own cleanup.
2026-09-09 11:25:07 +05:30
Vasanthdev2004 8535e4a6b1 fix(sandbox): unwind the launch report when the suspended child cannot resume
The report is published before ResumeThread so the inherited-pipe race is
closed, and that ordering stays. But a failure between the publish and the
resume reaped a process that had executed nothing while leaving a report on disk
saying a child launched. AppliedEnforcementNotices gates on that report and
ResolveChildLaunched treats it as authoritative, so the operator would have been
told a write-jail trade applied to a child that never became runnable.

The comment above the publish already claimed the stronger invariant, that every
failure between creation and resume leaves "no child launched" true for the
parent. The code held it only for failures before the write.

publishThenResume now owns the sequence: the published flag it returns is true
only when the child actually resumed, so a resume failure hands the deferred
close a false and the report is removed. The docstring on the terminate helper
now distinguishes the pre-publish path, where nothing was written, from the
post-publish pre-resume path, where the record has to be taken back.

Reported by jatmn.
2026-09-09 11:25:07 +05:30
Vasanthdev2004 6bce7d3883 fix(agent,hooks): give afterTool the same typed-notice contract as beforeTool
beforeTool was moved onto the typed EnforcementNotices slice and afterTool was
left folding its notices into the prose feedback, so the same fact had two
writers on the normal tool tail: the typed slice, which every surface composes
through ModelOutput and HumanDisplay, and the hook feedback block appended to the
body. Both carry the identical fixed deny_read string, so a hook running under
the same token shape as the tool it follows made the model see the disclosure
twice, and a bash or exec card show it in the amber furniture and again in the
body. Neither half existed on the merge base; this branch introduced both.

hookMessage now returns the hook's own stdout or stderr and nothing else.
dispatchAfterTool returns its notices alongside that output, and the loop merges
them into the result through the same finalization beforeTool uses, with the same
order and dedupe. A veto's Reason still carries its notice inline, because that
field is prose that reaches a person on its own.

Ordinary afterTool validator output is untouched: a formatter diff or vet warning
still arrives as feedback in the body, which is what an afterTool hook asked for.
A silent hook that exists only to disclose a token trade now reaches the typed
slice instead of becoming model input.

The hooks tests that asserted the disclosure through hookMessage now assert it on
Notices and additionally that it does NOT ride along in Messages, so the property
they protected is unchanged and its carrier moved with it.

Reported by jatmn.
2026-09-09 11:25:07 +05:30
Vasanthdev2004 3b7c741480 fix(agent): keep a beforeTool enforcement notice typed through the result
A successful beforeTool hook's disclosure was folded into result.Output as hook
prose. That reached the provider, which reads the output, and reached nothing
else.

Every interactive surface builds its enforcement furniture from the typed
EnforcementNotices slice, and for an edit or a write the card renders
Display.Preview instead of Output. So on exactly the results where something was
written, the operator saw the diff and no disclosure at all, collapsed and
expanded, and the session payload persisted that same omission for the restored
card. The model was told the token had been weakened and the person was not.

The notice now merges into the typed slice at one finalization point that the
normal path, the hook veto and the retry denial all return through, so no return
path can reopen the loss. Ordering is the hook's disclosures ahead of the tool's
own, exact repeats dropped, so a surface rendering the slice shows each one once.
Nothing is written into Output as well: decoration has one owner per surface, or
the disclosure appears twice. ModelOutput, HumanDisplay, the card renderer and
session serialization keep the ownership they already had.

Hook notices are third-party text on a path that bypasses the registry's
redaction boundary, so the merge scrubs them the way appendHookFeedback did while
they travelled as prose, and reports it so Redacted stays accurate.

The joiner that folded notices in with afterTool feedback is gone with its last
caller. afterTool output was never enforcement data and still arrives as prose.

Reported by jatmn.
2026-09-09 11:25:07 +05:30
Vasanthdev2004 b11a8a6231 test(sandbox): fail the deny_read disclosure tests when the producer disappears
Three positive-path tests skipped when the manager came back with an unwrapped
plan. Nothing about those fixtures is an environment prerequisite: they pin GOOS,
an available native backend, command wrapping and an explicit executable, so an
unwrapped result is not a machine that cannot run the test, it is the producer
whose disclosure they exist to check having gone away. CI would have reported all
of the deny_read disclosure coverage passing after the real route disappeared,
and the hand-built predicate tests would have stayed green with it because they
start from CommandPlan{Wrapped: true}.

They now fail through one shared assertion that names the wrapping state, the
target backend and the enforcement level.

The fixtures were also still reading the real machine in one place.
windowsSandboxInitialized stats the per-host setup marker and BuildCommandPlan
consults it, so a box where `zero sandbox setup` has run resolved these requests
at native enforcement while every CI runner got unelevated. The plan came out
wrapped either way, which is why nothing failed, but the level being asserted
against was whichever machine ran it. Both fixtures now pin it.

Reported by jatmn.
2026-09-09 11:25:07 +05:30
Vasanthdev2004 105e955fa6 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.
2026-09-09 11:25:07 +05:30
Vasanthdev2004 67de11f0ad 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.
2026-09-09 11:25:07 +05:30
Vasanthdev2004 2f8dc76666 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.
2026-09-09 11:25:06 +05:30
Vasanthdev2004 886567fe19 docs(agent): record why the notice delivery path needs no rebudget 2026-09-09 11:25:06 +05:30
Vasanthdev2004 b16971f5c3 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.
2026-09-09 11:25:06 +05:30
Vasanthdev2004 03ee13e480 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.
2026-09-09 11:25:06 +05:30
Vasanthdev2004 b69ff6733d docs(hooks): state the property the notice accumulation actually holds 2026-09-09 11:25:06 +05:30
Vasanthdev2004 4ccdab8f08 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.
2026-09-09 11:25:06 +05:30
Vasanthdev2004 600bbc4c44 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.
2026-09-09 11:25:06 +05:30
Vasanthdev2004 b8d344d48c 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.
2026-09-09 11:25:06 +05:30
Vasanthdev2004 2176c98271 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.
2026-09-09 11:25:06 +05:30
Vasanthdev2004 0ae48e400d 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.
2026-09-09 11:25:05 +05:30
Vasanthdev2004 47d6ab7015 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.
2026-09-09 11:25:05 +05:30
Vasanthdev2004 591874f1f8 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.
2026-09-09 11:25:05 +05:30
Vasanthdev2004 b619016f08 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.
2026-09-09 11:25:05 +05:30
Vasanthdev2004 60a1452eeb 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.
2026-09-09 11:25:05 +05:30
Vasanthdev2004 98fdb3e8e5 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.
2026-09-09 11:25:05 +05:30
Vasanthdev2004 fc06cc6aaa 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.
2026-09-09 11:25:04 +05:30
Vasanthdev2004 b44d56f2b2 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.
2026-09-09 11:25:04 +05:30
Vasanthdev2004 6bf5a86b7f 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.
2026-09-09 11:25:04 +05:30
Vasanthdev2004 e2ac98b906 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.
2026-09-09 11:25:04 +05:30
Vasanthdev2004 b60359e2d6 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.
2026-09-09 11:25:04 +05:30
Vasanthdev2004 f889b7e3b3 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.
2026-09-09 11:25:04 +05:30
Vasanthdev2004 e0e8db306e 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.
2026-09-09 11:25:04 +05:30
Vasanthdev2004 f62ae776c9 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.
2026-09-09 11:25:03 +05:30
Vasanthdev2004 427df5fdb9 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.
2026-09-09 11:25:03 +05:30
Vasanthdev2004 22c9194bce 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.
2026-09-09 11:25:03 +05:30
Vasanthdev2004 85526b4978 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.
2026-09-09 11:25:03 +05:30
Vasanthdev2004 1ee7aa762a 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.
2026-09-09 11:25:03 +05:30
Vasanthdev2004 4f8aa57314 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.
2026-09-09 11:25:03 +05:30
Vasanthdev2004 76e5e35a1c 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.
2026-09-09 11:25:03 +05:30
Vasanthdev2004 cd76c67050 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.
2026-09-09 11:25:03 +05:30
Vasanthdev2004 081aabdbc4 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.
2026-09-09 11:25:03 +05:30
Vasanthdev2004 0b646ad7f9 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.
2026-09-09 11:25:02 +05:30
Vasanthdev2004 4e2231e5de 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.
2026-09-09 11:25:02 +05:30
Vasanthdev2004 f7814819cb 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.
2026-09-09 11:25:02 +05:30
Vasanthdev2004 5c64cef7f6 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.
2026-09-09 11:25:02 +05:30
Vasanthdev2004 55621d2176 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.
2026-09-09 11:25:02 +05:30
Vasanthdev2004 915e1df103 test(sandbox): fail rather than skip when the DenyRead token loses the World SID 2026-09-09 11:25:02 +05:30
Vasanthdev2004 3bfd898bfc 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.
2026-09-09 11:25:02 +05:30
Vasanthdev2004 c4905d296b 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.
2026-09-09 11:25:02 +05:30
Vasanthdev2004 93f9505b72 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.
2026-09-09 11:25:01 +05:30
Vasanthdev2004 6a4eff21a2 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
2026-09-09 11:25:01 +05:30
107 changed files with 9307 additions and 697 deletions

No files matched your search

+2 -2
View File
@@ -12,7 +12,7 @@
<p align="center">
<a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-MIT-blue"></a>
<img alt="Go 1.26.9+" src="https://img.shields.io/badge/Go-1.26.9+-00ADD8?logo=go&logoColor=white">
<img alt="Go 1.26.10+" src="https://img.shields.io/badge/Go-1.26.10+-00ADD8?logo=go&logoColor=white">
<img alt="25+ providers" src="https://img.shields.io/badge/providers-25+-34E2EA">
<a href="https://discord.gg/CaQDS6wdFn"><img alt="Discord" src="https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white"></a>
<br>
@@ -158,7 +158,7 @@ irm https://raw.githubusercontent.com/Twigpine/zero/main/scripts/install.ps1 | i
### From source
Source builds require Go 1.26.9+.
Source builds require Go 1.26.10+.
```bash
git clone https://github.com/Twigpine/zero.git
+2 -2
View File
@@ -12,7 +12,7 @@
<p align="center">
<a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-MIT-blue"></a>
<img alt="Go 1.26.9+" src="https://img.shields.io/badge/Go-1.26.9+-00ADD8?logo=go&logoColor=white">
<img alt="Go 1.26.10+" src="https://img.shields.io/badge/Go-1.26.10+-00ADD8?logo=go&logoColor=white">
<img alt="25+ providers" src="https://img.shields.io/badge/providers-25+-34E2EA">
<a href="https://discord.gg/CaQDS6wdFn"><img alt="Discord" src="https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white"></a>
<br>
@@ -136,7 +136,7 @@ irm https://raw.githubusercontent.com/Twigpine/zero/main/scripts/install.ps1 | i
### 从源码构建
源码构建需要 Go 1.26.9+。
源码构建需要 Go 1.26.10+。
```bash
git clone https://github.com/Twigpine/zero.git
+2 -2
View File
@@ -190,7 +190,7 @@ Every release so far declares the module path `github.com/Gitlawb/zero`, so
fails with "module declares its path as" until a release is tagged with the new
module path.
This builds from source, so it needs Go 1.26.9+ and it does not go through the
This builds from source, so it needs Go 1.26.10+ and it does not go through the
release archives. Two consequences worth knowing before you pick it:
- On Linux, Bubblewrap is still required for native sandboxing. Zero resolves
@@ -218,7 +218,7 @@ Build a local binary:
go build -o zero ./cmd/zero
```
Source builds require Go 1.26.9+.
Source builds require Go 1.26.10+.
### Sandbox Helpers For Source Builds
+1 -1
View File
@@ -1,6 +1,6 @@
module github.com/Twigpine/zero
go 1.26.9
go 1.26.10
require (
charm.land/bubbles/v2 v2.1.1
+45 -20
View File
@@ -962,16 +962,25 @@ func toolCallEvent(call agent.ToolCall) sessions.AppendEventInput {
}
}
// toolResultEvent persists a tool result through the ONE session contract every
// writer shares, agent.ToolResultSessionPayload.
//
// This used to spell its own payload and wrote result.Output. After the
// output/notice split that field is the UNDECORATED text, and nothing else in
// the payload carried the enforcement notices, so the disclosure was dropped at
// the moment of persisting. The live tool_call_update for the same result had
// shown it (toolResultContent reads ModelOutput), and then session/load
// replayed the call with the sandbox's narrowing gone. Fixing only the live
// translation could not reach this: the text was already lost on disk.
//
// The session store is shared with the TUI and headless exec, so the same
// payload is also what lets a TUI resume of an ACP-written session draw the
// notice as card furniture, from the typed field and the undecorated body.
// Reported by @jatmn.
func toolResultEvent(result agent.ToolResult) sessions.AppendEventInput {
return sessions.AppendEventInput{
Type: sessions.EventToolResult,
Payload: map[string]any{
"toolCallId": result.ToolCallID,
"name": result.Name,
"status": result.Status,
"output": result.Output,
"changedFiles": append([]string(nil), result.ChangedFiles...),
},
Type: sessions.EventToolResult,
Payload: agent.ToolResultSessionPayload(result),
}
}
@@ -1144,13 +1153,14 @@ func replayToolUpdate(event sessions.Event) *ToolCallUpdate {
return nil
}
var payload struct {
Name string `json:"name"`
ToolCallID string `json:"toolCallId"`
ID string `json:"id"`
Arguments string `json:"arguments"`
Status string `json:"status"`
Output string `json:"output"`
ChangedFiles []string `json:"changedFiles"`
Name string `json:"name"`
ToolCallID string `json:"toolCallId"`
ID string `json:"id"`
Arguments string `json:"arguments"`
Status string `json:"status"`
Output string `json:"output"`
ChangedFiles []string `json:"changedFiles"`
EnforcementNotices []string `json:"enforcementNotices"`
}
if json.Unmarshal(raw, &payload) != nil {
return nil
@@ -1170,12 +1180,27 @@ func replayToolUpdate(event sessions.Event) *ToolCallUpdate {
if status == "" {
status = tools.StatusOK
}
// THE DISCLOSURE IS RESTORED AS WHAT IT WAS: typed notices beside undecorated
// text, so toolCallResult composes it the way it did live and the client
// sees it exactly once. The stored output already has the notices composed
// in (that is the shared contract, because output is the provider-facing
// text), so handing it to ModelOutput together with the typed field would
// draw the disclosure twice, and dropping the typed field would make this
// reader depend on every writer having decorated the text. Taking the
// notices back off the front covers both: a record whose output was never
// decorated comes through unchanged and still gets its notice.
//
// displayPreview is deliberately not read. It is the card body for a
// terminal, and the ACP wire has no card: the live update sends the model
// text, so a replay that sent the preview instead would not match it.
notices := append([]string(nil), payload.EnforcementNotices...)
upd := toolCallResult(agent.ToolResult{
ToolCallID: id,
Name: payload.Name,
Status: status,
Output: payload.Output,
ChangedFiles: append([]string(nil), payload.ChangedFiles...),
ToolCallID: id,
Name: payload.Name,
Status: status,
Output: tools.WithoutEnforcementNotices(payload.Output, notices),
EnforcementNotices: notices,
ChangedFiles: append([]string(nil), payload.ChangedFiles...),
})
return &upd
}
@@ -0,0 +1,195 @@
package acp
import (
"context"
"encoding/json"
"reflect"
"strings"
"testing"
"time"
"github.com/Twigpine/zero/internal/agent"
"github.com/Twigpine/zero/internal/sessions"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
)
const persistedNotice = "least-privilege notice: read access was narrowed"
func mustRawPayload(t *testing.T, payload map[string]any) json.RawMessage {
t.Helper()
encoded, err := json.Marshal(payload)
if err != nil {
t.Fatal(err)
}
return encoded
}
func toolUpdateText(update ToolCallUpdate) string {
var text strings.Builder
for _, part := range update.Content {
if part.Content != nil {
text.WriteString(part.Content.Text)
}
}
return text.String()
}
// nextToolResultUpdate returns the next tool_call_update, skipping the
// tool_call that announces it.
func nextToolResultUpdate(t *testing.T, ctx context.Context, updates <-chan ToolCallUpdate, stage string) ToolCallUpdate {
t.Helper()
for {
select {
case update := <-updates:
if update.SessionUpdate == UpdateToolCallUpdate {
return update
}
case <-ctx.Done():
t.Fatalf("%s: the tool result update never arrived", stage)
}
}
}
// A DISCLOSURE SHOWN LIVE HAS TO SURVIVE BEING WRITTEN DOWN.
//
// enforcement_notice_test.go pins the live translation: toolResultContent reads
// ModelOutput, so a connected ACP client sees the sandbox's narrowing. That was
// the only half fixed. The turn also persists each result, and the persisted
// payload was spelled by hand in this package with result.Output, which after
// the output/notice split is the undecorated text, and with no notices field at
// all. The disclosure was gone from disk, so session/load replayed a sandboxed
// command as if nothing had constrained it.
//
// This runs a real turn, so the result reaches the store through the same
// OnToolResult callback production uses, then loads the session in a fresh
// agent and reads the replayed update. It fails if only translate.go is fixed,
// which is the state it was written against. Reported by @jatmn.
func TestACPReloadedToolResultCarriesTheEnforcementNoticeOnce(t *testing.T) {
for _, tc := range []struct {
name string
output string
}{
{name: "a command with output", output: "the command output"},
// An enforced command that printed nothing still has a disclosure, and
// an empty body is where a reader that falls back to the decorated text
// draws it twice.
{name: "a command that printed nothing", output: ""},
} {
t.Run(tc.name, func(t *testing.T) {
deps := testDeps(t)
deps.RunAgent = func(_ context.Context, _ string, _ zeroruntime.Provider, opts agent.Options) (agent.Result, error) {
call := agent.ToolCall{ID: "call-sandboxed", Name: "bash", Arguments: `{"command":"ls"}`}
opts.OnToolCall(call)
opts.OnToolResult(agent.ToolResult{
ToolCallID: call.ID,
Name: call.Name,
Status: tools.StatusOK,
Output: tc.output,
EnforcementNotices: []string{persistedNotice},
})
return agent.Result{FinalAnswer: "done"}, nil
}
workspace := t.TempDir()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
live := newHarness(t, deps)
var created NewSessionResult
if err := live.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: workspace, McpServers: []McpServer{}}, &created); err != nil {
t.Fatalf("session/new: %v", err)
}
if err := live.client.Call(ctx, MethodSessionPrompt, PromptParams{SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock("list the files")}}, &PromptResult{}); err != nil {
t.Fatalf("session/prompt: %v", err)
}
liveText := toolUpdateText(nextToolResultUpdate(t, ctx, live.tools, "live"))
if count := strings.Count(liveText, persistedNotice); count != 1 {
t.Fatalf("premise: the live update carries the notice %d times, want 1:\n%s", count, liveText)
}
live.stop()
loader := newHarness(t, deps)
defer loader.stop()
if err := loader.client.Call(ctx, MethodSessionLoad, LoadSessionParams{SessionID: created.SessionID, Cwd: workspace, McpServers: []McpServer{}}, &LoadSessionResult{}); err != nil {
t.Fatalf("session/load: %v", err)
}
replayed := toolUpdateText(nextToolResultUpdate(t, ctx, loader.tools, "replay"))
if count := strings.Count(replayed, persistedNotice); count != 1 {
t.Errorf("the replayed result carries the notice %d times, want exactly 1:\n%s", count, replayed)
}
if replayed != liveText {
t.Errorf("the replayed result does not match what the client saw live\nlive: %q\nreplayed: %q", liveText, replayed)
}
})
}
}
// THE ACP WRITER IS THE SHARED CONTRACT, NOT A COPY OF IT. The session store is
// the one the TUI and headless exec also write to and resume from, so a payload
// spelled here by hand is a third representation waiting to drift. It already
// had: it was the only writer with no notices and no undecorated body.
func TestToolResultEventIsTheSharedSessionPayload(t *testing.T) {
result := agent.ToolResult{
ToolCallID: "call-1",
Name: "bash",
Status: tools.StatusOK,
Output: "the command output",
EnforcementNotices: []string{persistedNotice},
ChangedFiles: []string{"a.go"},
}
event := toolResultEvent(result)
if event.Type != sessions.EventToolResult {
t.Fatalf("event type = %q", event.Type)
}
if want := agent.ToolResultSessionPayload(result); !reflect.DeepEqual(event.Payload, want) {
t.Fatalf("the ACP payload is not the shared one\n got: %#v\nwant: %#v", event.Payload, want)
}
payload, ok := event.Payload.(map[string]any)
if !ok {
t.Fatalf("payload is %T", event.Payload)
}
if notices, _ := payload["enforcementNotices"].([]string); len(notices) != 1 || notices[0] != persistedNotice {
t.Errorf("typed notices were not persisted: %#v", payload["enforcementNotices"])
}
if body, present := payload["displayPreview"]; !present || body != "the command output" {
t.Errorf("the undecorated body was not persisted: %#v (present=%v)", body, present)
}
}
// A record can reach the reader in either shape, and both have to come out as
// one disclosure: output with the notices already composed in, which is what
// every current writer stores, and output left undecorated beside the typed
// field, which a reader must not answer by dropping the notice.
func TestReplayedToolResultComposesTheNoticeOnceForEitherStoredShape(t *testing.T) {
for _, tc := range []struct {
name string
output string
want string
}{
{name: "output stored decorated", output: persistedNotice + "\n\nthe command output", want: persistedNotice + "\n\nthe command output"},
{name: "output stored undecorated", output: "the command output", want: persistedNotice + "\n\nthe command output"},
{name: "decorated and otherwise empty", output: persistedNotice, want: persistedNotice},
{name: "undecorated and empty", output: "", want: persistedNotice},
} {
t.Run(tc.name, func(t *testing.T) {
update := replayToolUpdate(sessions.Event{Type: sessions.EventToolResult, Payload: mustRawPayload(t, map[string]any{
"toolCallId": "call-1", "name": "bash", "status": "ok",
"output": tc.output, "enforcementNotices": []string{persistedNotice},
})})
if update == nil {
t.Fatal("the stored result was not replayed")
}
if got := toolUpdateText(*update); got != tc.want {
t.Errorf("replayed text = %q, want %q", got, tc.want)
}
})
}
// And a stored result with no notices is replayed as it was written.
plain := replayToolUpdate(sessions.Event{Type: sessions.EventToolResult, Payload: mustRawPayload(t, map[string]any{
"toolCallId": "call-2", "name": "bash", "status": "ok", "output": "plain output",
})})
if plain == nil || toolUpdateText(*plain) != "plain output" {
t.Errorf("an ordinary stored result changed on replay: %+v", plain)
}
}
+65
View File
@@ -0,0 +1,65 @@
package acp
import (
"strings"
"testing"
"github.com/Twigpine/zero/internal/agent"
"github.com/Twigpine/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)
}
}
+5 -1
View File
@@ -248,7 +248,11 @@ func toolCallResult(result agent.ToolResult) ToolCallUpdate {
func toolResultContent(result agent.ToolResult) []ToolCallContent {
content := make([]ToolCallContent, 0, 1+len(result.FileDiffs))
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
}
+160
View File
@@ -0,0 +1,160 @@
package agent
import (
"context"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/Twigpine/zero/internal/execution"
"github.com/Twigpine/zero/internal/hooks"
"github.com/Twigpine/zero/internal/tools"
)
// The tool's own disclosure and the afterTool hook's are the SAME STRING, which
// is the real case: both come from the fixed Windows deny_read warning, so a hook
// running under the same token shape as the tool it follows reports exactly what
// the tool reported.
const sharedEnforcementNotice = "least-privilege notice"
const afterToolChatter = "vet-found-nothing"
// sharedNoticeHookPreparer runs a hook that prints ordinary output and carries
// the same enforcement notice the tool carries.
type sharedNoticeHookPreparer struct{}
func (sharedNoticeHookPreparer) PrepareExecution(_ context.Context, _ execution.Request) (execution.PreparedCommand, error) {
var command *exec.Cmd
if runtime.GOOS == "windows" {
command = exec.Command("cmd.exe", "/c", "echo "+afterToolChatter)
} else {
command = exec.Command("/bin/sh", "-c", "echo "+afterToolChatter)
}
return execution.PreparedCommand{
Command: command,
Enforcement: execution.Enforcement{Notices: []string{sharedEnforcementNotice}},
}, nil
}
func afterToolNoticeDispatcher(t *testing.T) *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)
}
return hooks.NewDispatcher(hooks.DispatcherOptions{
Config: hooks.Config{
Enabled: true,
Hooks: []hooks.Definition{
{ID: "zero.after-tool", Event: hooks.EventAfterTool, Matcher: "notice_projection", Command: "vet", Enabled: true},
},
},
Audit: audit,
Cwd: t.TempDir(),
Execution: execution.NewRunner(sharedNoticeHookPreparer{}),
})
}
// ONE DISCLOSURE, ONE DELIVERY, FROM EITHER HOOK PHASE.
//
// beforeTool was moved onto the typed EnforcementNotices slice and afterTool was
// left folding its notices into the prose feedback. Both halves then wrote the
// same fact: the typed slice, which every surface composes through ModelOutput
// and HumanDisplay, and the "Hook output:" block appended to the body. Since the
// two carry the identical fixed string, the model saw the disclosure twice and a
// bash or exec card showed it in the amber furniture and again in the body.
//
// Half a symmetry is its own defect, and this is the composition that catches it:
// a tool that reports a notice, followed by an afterTool hook that reports the
// same one.
func TestAnAfterToolNoticeIsDeliveredOnce(t *testing.T) {
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(), Hooks: afterToolNoticeDispatcher(t)})
if err != nil {
t.Fatalf("executeToolCall: %v", err)
}
// SETUP: the hook really ran, or the silence below is the silence of a hook
// that never executed.
if !strings.Contains(result.ModelOutput(), afterToolChatter) {
t.Fatalf("SETUP INVALID: the afterTool hook's own output never arrived, so nothing here is under test:\n%s", result.ModelOutput())
}
if got := strings.Count(result.ModelOutput(), sharedEnforcementNotice); got != 1 {
t.Errorf("the disclosure reached the model %d times, want once:\n%s", got, result.ModelOutput())
}
if got := strings.Count(strings.Join(result.EnforcementNotices, "\n"), sharedEnforcementNotice); got != 1 {
t.Errorf("the typed slice carries the disclosure %d times, want once: %v", got, result.EnforcementNotices)
}
// The body must not carry it at all: the surfaces draw it from the slice, so a
// copy in the body is what renders it twice.
if strings.Contains(result.BaseModelOutput(), sharedEnforcementNotice) {
t.Errorf("the disclosure is in the result body as well as the typed slice:\n%s", result.BaseModelOutput())
}
if strings.Count(result.HumanDisplay().Summary, sharedEnforcementNotice) != 1 {
t.Errorf("the card summary shows the disclosure %d times, want once:\n%s",
strings.Count(result.HumanDisplay().Summary, sharedEnforcementNotice), result.HumanDisplay().Summary)
}
}
// And an afterTool hook that discloses something the tool did not still gets
// through, on the typed channel rather than as prose.
func TestAnAfterToolNoticeReachesTheTypedSlice(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(quietTool{})
result, err := executeToolCall(context.Background(), registry, ToolCall{
ID: "call-1", Name: "quiet_tool", Arguments: `{}`,
}, PermissionModeAuto, Options{Cwd: t.TempDir(), Hooks: quietToolAfterHookDispatcher(t)})
if err != nil {
t.Fatalf("executeToolCall: %v", err)
}
if !strings.Contains(result.ModelOutput(), afterToolChatter) {
t.Fatalf("SETUP INVALID: the afterTool hook never ran:\n%s", result.ModelOutput())
}
if len(result.EnforcementNotices) != 1 || result.EnforcementNotices[0] != sharedEnforcementNotice {
t.Errorf("an afterTool hook's disclosure did not reach the typed slice, so no card renders it: %v", result.EnforcementNotices)
}
if strings.Contains(result.BaseModelOutput(), sharedEnforcementNotice) {
t.Errorf("the disclosure travelled as prose in the body instead:\n%s", result.BaseModelOutput())
}
}
// quietTool carries no enforcement notice of its own.
type quietTool struct{}
func (quietTool) Name() string { return "quiet_tool" }
func (quietTool) Description() string { return "test tool with no enforcement notice" }
func (quietTool) Parameters() tools.Schema { return tools.Schema{Type: "object"} }
func (quietTool) Safety() tools.Safety {
return tools.Safety{SideEffect: tools.SideEffectRead, Permission: tools.PermissionAllow}
}
func (quietTool) Run(ctx context.Context, args map[string]any) tools.Result {
return tools.Result{Status: tools.StatusOK, Output: "the command output"}
}
func quietToolAfterHookDispatcher(t *testing.T) *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)
}
return hooks.NewDispatcher(hooks.DispatcherOptions{
Config: hooks.Config{
Enabled: true,
Hooks: []hooks.Definition{
{ID: "zero.after-tool", Event: hooks.EventAfterTool, Matcher: "quiet_tool", Command: "vet", Enabled: true},
},
},
Audit: audit,
Cwd: t.TempDir(),
Execution: execution.NewRunner(sharedNoticeHookPreparer{}),
})
}
+210
View File
@@ -0,0 +1,210 @@
package agent
import (
"context"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/Twigpine/zero/internal/execution"
"github.com/Twigpine/zero/internal/hooks"
"github.com/Twigpine/zero/internal/tools"
zeroruntime "github.com/Twigpine/zero/internal/zeroruntime"
)
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)
}
return hooks.NewDispatcher(hooks.DispatcherOptions{
Config: hooks.Config{
Enabled: true,
Hooks: []hooks.Definition{
{ID: "zero.before-tool", Event: event, Matcher: "read_file", Command: "hook", Enabled: true},
},
},
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)
}
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},
},
}}
return Options{
SessionID: "session-hook",
Cwd: root,
Registry: registry,
ProviderName: "test-provider",
Model: "test-model",
Hooks: dispatcher,
MaxTurns: 2,
}, 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 ran, so this is the successful-hook path rather than a blocked
// call that never reached the tool.
if !someRequestContains(provider.requests, "hello") {
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")
}
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
}
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
}
@@ -0,0 +1,129 @@
package agent
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"github.com/Twigpine/zero/internal/execution"
"github.com/Twigpine/zero/internal/hooks"
"github.com/Twigpine/zero/internal/tools"
zeroruntime "github.com/Twigpine/zero/internal/zeroruntime"
)
// editMatchingDispatcher is beforeToolDispatcher for the edit tool, so the same
// notice-and-chatter hook runs ahead of a call whose result carries a diff.
func editMatchingDispatcher(t *testing.T) *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)
}
return hooks.NewDispatcher(hooks.DispatcherOptions{
Config: hooks.Config{
Enabled: true,
Hooks: []hooks.Definition{
{ID: "zero.before-tool", Event: hooks.EventBeforeTool, Matcher: "edit_file", Command: "hook", Enabled: true},
},
},
Audit: audit,
Cwd: t.TempDir(),
Execution: execution.NewRunner(noticeHookPreparer{}),
})
}
// editFileRunOptions is readFileRunOptions for a tool whose result carries a
// rich preview, which is the case the disclosure went missing on.
func editFileRunOptions(t *testing.T, dispatcher *hooks.Dispatcher, onResult func(ToolResult)) (Options, *mockProvider) {
t.Helper()
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "x.go"), []byte("package main\n\nconst answer = 41\n"), 0o644); err != nil {
t.Fatalf("write x.go: %v", err)
}
registry := tools.NewRegistry()
registry.Register(tools.NewScopedEditFileTool(root, nil))
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "edit_file"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"path":"x.go","old_string":"41","new_string":"42"}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventText, Content: "edited it"},
{Type: zeroruntime.StreamEventDone},
},
}}
return Options{
SessionID: "session-hook-edit",
Cwd: root,
Registry: registry,
ProviderName: "test-provider",
Model: "test-model",
Hooks: dispatcher,
OnToolResult: onResult,
PermissionMode: PermissionModeUnsafe,
MaxTurns: 2,
}, provider
}
// THE NOTICE HAS TO SURVIVE A RESULT WHOSE BODY IS A DIFF.
//
// This is the boundary the component suites left between them. The delivery test
// above proves a beforeTool notice reaches the provider with read_file, whose
// body is its output. The card tests prove a card renders a notice when the
// result already carries one typed. Neither composed the two, and in between them
// the notice was being folded into result.Output as hook prose.
//
// For an edit or a write that is exactly where it disappears: the card builds its
// body from Display.Preview rather than Output, and draws enforcement furniture
// only from the typed slice, so the operator saw the diff and no disclosure at
// all, live and restored, while the model saw the disclosure. A notice about a
// weakened token was hidden on precisely the results where something was written.
func TestABeforeToolNoticeSurvivesARichPreviewResult(t *testing.T) {
dispatcher := editMatchingDispatcher(t)
var results []ToolResult
options, provider := editFileRunOptions(t, dispatcher, func(result ToolResult) {
results = append(results, result)
})
if _, err := Run(context.Background(), "bump the answer", provider, options); err != nil {
t.Fatalf("Run: %v", err)
}
if len(results) != 1 {
t.Fatalf("SETUP INVALID: %d tool results, want the one edit", len(results))
}
result := results[0]
if result.Status != tools.StatusOK {
t.Fatalf("SETUP INVALID: the edit failed, so this is not the successful rich-preview path: %s", result.Output)
}
preview := result.BaseDisplay().Preview
if !strings.Contains(preview, "42") {
t.Fatalf("SETUP INVALID: the result carries no diff preview, which is the whole case under test: %q", preview)
}
// TYPED, which is what every interactive surface reads.
if len(result.EnforcementNotices) != 1 || result.EnforcementNotices[0] != beforeToolNotice {
t.Errorf("the hook's disclosure did not reach the typed field, so the card and the restored card show the diff and no disclosure: %v", result.EnforcementNotices)
}
// AND NOT IN THE BODY AS WELL. Decoration has one owner per surface; carrying
// it in both places renders it twice wherever the surface draws the slice.
if strings.Contains(result.BaseModelOutput(), beforeToolNotice) {
t.Errorf("the disclosure is in the result body as well as the typed field:\n%s", result.BaseModelOutput())
}
// The diff itself stays a parseable diff.
if strings.Contains(preview, beforeToolNotice) {
t.Errorf("the disclosure was glued onto the diff:\n%s", preview)
}
// And the model still sees it, exactly once, with none of the hook's chatter.
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")
}
}
@@ -0,0 +1,114 @@
package agent
import (
"context"
"strings"
"testing"
"github.com/Twigpine/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)
}
}
+92 -2
View File
@@ -84,12 +84,102 @@ func TestBlockedByHookResultFallsBackWhenReasonEmpty(t *testing.T) {
}
}
// THE BLOCKING HOOK'S OWN NOTICE IS A NOTICE, NOT PART OF THE VETO TEXT.
//
// It used to arrive folded into Reason, so it reached the model inside the error
// message and never reached EnforcementNotices, which is what every surface
// renders as the disclosure and what the session record keeps apart from the
// body.
func TestBlockedByHookResultCarriesTheBlockersNoticeOnTheTypedSlice(t *testing.T) {
const notice = "denyRead is configured, so the write jail is not confining writes"
out := blockedByHookResult(
ToolCall{ID: "c4", Name: "bash"},
hooks.DispatchOutcome{Blocked: true, BlockedBy: "policy", Reason: "policy violation", Notices: []string{notice}},
)
if len(out.EnforcementNotices) != 1 || out.EnforcementNotices[0] != notice {
t.Fatalf("EnforcementNotices = %#v, want the blocking hook's notice", out.EnforcementNotices)
}
if strings.Contains(out.Output, notice) {
t.Errorf("the notice is also inside the veto message, so it is said twice:\n%s", out.Output)
}
if got := strings.Count(out.ModelOutput(), notice); got != 1 {
t.Errorf("the model sees the notice %d times, want once:\n%s", got, out.ModelOutput())
}
}
// AN EARLIER HOOK'S NOTICE SURVIVES A REASON THAT QUOTES IT.
//
// The veto path used to drop every notice whose text appeared anywhere in the
// blocking hook's reason, on the theory that the reason already carried it. A
// blocking hook that printed the same sentence, for example by relaying another
// tool's stderr, therefore removed an earlier hook's disclosure from the typed
// slice.
func TestBlockedByHookResultKeepsAnEarlierNoticeTheReasonQuotes(t *testing.T) {
const notice = "denyRead is configured, so the write jail is not confining writes"
out := blockedByHookResult(
ToolCall{ID: "c5", Name: "bash"},
hooks.DispatchOutcome{
Blocked: true,
BlockedBy: "veto",
Reason: "refusing: the last run reported \"" + notice + "\"",
Notices: []string{notice},
},
)
if len(out.EnforcementNotices) != 1 || out.EnforcementNotices[0] != notice {
t.Fatalf("the earlier hook's notice was dropped because the veto quoted it: %#v", out.EnforcementNotices)
}
}
func TestDispatchHelpersAreNoopWithoutDispatcher(t *testing.T) {
options := Options{} // Hooks is nil
if _, blocked := dispatchBeforeTool(context.Background(), options, ToolCall{Name: "bash"}, nil); blocked {
t.Fatal("a nil dispatcher must never block a tool")
}
if feedback := dispatchAfterTool(context.Background(), options, ToolCall{Name: "bash"}, nil, tools.Result{}); feedback != "" {
t.Fatalf("a nil dispatcher must yield no feedback, got %q", feedback)
if feedback, notices := dispatchAfterTool(context.Background(), options, ToolCall{Name: "bash"}, nil, tools.Result{}); feedback != "" || notices != nil {
t.Fatalf("a nil dispatcher must yield no feedback and no notices, got %q and %v", feedback, notices)
}
}
// A SUCCESSFUL beforeTool HOOK'S NOTICE MUST STAY TYPED.
//
// 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. The first fix delivered it as prose appended
// to the result output, which reached the model and no interactive surface,
// because those build their enforcement furniture from the typed slice.
//
// So the delivery is the typed field, and this pins the merge: the hook's notice
// first, the tool's own after it, each exactly once, blanks contributing nothing.
func TestBeforeToolNoticesMergeIntoTheTypedField(t *testing.T) {
const notice = "hook ran without WRITE_RESTRICTED because denyRead is configured"
const toolOwned = "the sandbox dropped the network capability for this call"
result := withAppliedHookNotices(ToolResult{Output: "ok"}, []string{notice}, nil)
if len(result.EnforcementNotices) != 1 || result.EnforcementNotices[0] != notice {
t.Fatalf("a successful beforeTool notice did not reach the typed field: %v", result.EnforcementNotices)
}
// AND NOT THE OUTPUT AS WELL, or every surface that renders the slice shows
// the disclosure twice.
if strings.Contains(result.Output, notice) {
t.Errorf("the notice was written into the output as well as the typed field: %q", result.Output)
}
// Both arrive, hook first, when the tool carries its own.
result = withAppliedHookNotices(ToolResult{EnforcementNotices: []string{toolOwned}}, []string{notice}, nil)
if len(result.EnforcementNotices) != 2 || result.EnforcementNotices[0] != notice || result.EnforcementNotices[1] != toolOwned {
t.Fatalf("the hook and tool notices did not merge in order: %v", result.EnforcementNotices)
}
// The same disclosure from both sides is carried once.
result = withAppliedHookNotices(ToolResult{EnforcementNotices: []string{notice}}, []string{notice}, nil)
if len(result.EnforcementNotices) != 1 {
t.Errorf("one disclosure reported by both the hook and the tool was carried %d times: %v", len(result.EnforcementNotices), result.EnforcementNotices)
}
// Blank notices contribute nothing, so a run with no hook output stays silent.
result = withAppliedHookNotices(ToolResult{}, []string{"", " "}, nil)
if len(result.EnforcementNotices) != 0 {
t.Errorf("blank hook notices produced %v, want nothing", result.EnforcementNotices)
}
}
+191 -36
View File
@@ -1467,10 +1467,31 @@ 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, 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
// Filled after the tool runs, and merged with beforeTool's at the one
// finalization below so both phases land on the same contract.
var afterToolNotices []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
}
beforeToolNotices = outcome.Notices
}
args = shellExecutionArgsForApproval(call.Name, args, decisionAction, options)
@@ -1516,7 +1537,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 withAppliedHookNotices(*directResult, beforeToolNotices, nil), abortErr
}
result = retryResult
permissionGranted = true
@@ -1542,7 +1566,15 @@ 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 != "" {
// NEITHER PHASE'S NOTICES ARE FOLDED IN HERE. They are typed enforcement
// data on both sides now, and appending them as hook prose was what kept
// beforeTool's out of every surface that renders the typed slice, and what
// made afterTool's arrive twice once that slice was composed. Only the
// hook's own output, which is what an afterTool validator asked to say,
// still travels this way.
feedback, notices := dispatchAfterTool(ctx, options, call, args, result)
afterToolNotices = notices
if strings.TrimSpace(feedback) != "" {
var didRedact bool
result.Output, didRedact = appendHookFeedback(result.Output, feedback)
if didRedact {
@@ -1569,28 +1601,32 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
// Secret scrubbing happens at the registry boundary (the single point both
// 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,
FileDiffs: result.FileDiffs,
ChangeSummaries: result.ChangeSummaries,
Display: result.HumanDisplay(),
Outcome: result.Outcome,
LoadedTools: loadedToolsFromResult(result.Meta),
//
// Wrapped in the same finalization every other return from a hooked call
// uses, so the normal path cannot drift away from the veto and retry paths.
return withAppliedHookNotices(ToolResult{
Risk: executedRisk,
ToolCallID: call.ID,
Name: call.Name,
Status: result.Status,
Output: result.BaseModelOutput(),
Truncated: result.Truncated,
Meta: result.Meta,
EnforcementNotices: append([]string(nil), result.EnforcementNotices...),
Images: result.Images,
Redacted: result.Redacted,
ChangedFiles: result.ChangedFiles,
FileDiffs: result.FileDiffs,
ChangeSummaries: result.ChangeSummaries,
Display: result.BaseDisplay(),
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
// ordinary tool result.
RequestedModel: result.Meta["escalate_to_model"],
}, nil
}, beforeToolNotices, afterToolNotices), nil
}
const sandboxNamespaceLimitedReason = "sandbox output is limited to the sandbox PID namespace; host/global state requires approval"
@@ -1974,9 +2010,9 @@ func dispatchBeforeTool(ctx context.Context, options Options, call ToolCall, arg
// dispatchAfterTool runs configured afterTool hooks once a tool has executed and
// returns any advisory output (e.g. a formatter or vet result) to surface back
// to the model. afterTool hooks never block. A nil dispatcher is a no-op.
func dispatchAfterTool(ctx context.Context, options Options, call ToolCall, args map[string]any, result tools.Result) string {
func dispatchAfterTool(ctx context.Context, options Options, call ToolCall, args map[string]any, result tools.Result) (string, []string) {
if options.Hooks == nil || hooksSuppressed(options) {
return ""
return "", nil
}
outcome := options.Hooks.Dispatch(ctx, hooks.DispatchInput{
Event: hooks.EventAfterTool,
@@ -1992,7 +2028,13 @@ func dispatchAfterTool(ctx context.Context, options Options, call ToolCall, args
"changedFiles": result.ChangedFiles,
},
})
return strings.TrimSpace(strings.Join(outcome.Messages, "\n"))
// Both halves, because they have different destinations. The hook's own output
// is prose that belongs in the result body where an afterTool validator's
// findings have always gone; its enforcement notices are typed data that the
// caller merges into the result the same way beforeTool's are. Returning only
// the joined messages was what left afterTool on the old channel while
// beforeTool moved, and a notice reported by both then appeared twice.
return strings.TrimSpace(strings.Join(outcome.Messages, "\n")), outcome.Notices
}
// dispatchSessionStart runs configured sessionStart hooks once before the first
@@ -2056,7 +2098,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,
@@ -2064,12 +2106,124 @@ 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, and so
// does the blocking hook's own.
//
// ALL OF THEM, FROM THE TYPED SLICE. Reason carries the hook's words only, so
// nothing here is said twice and nothing has to be filtered out. The filter
// this replaced matched notices against the reason text, and dropped an
// earlier hook's disclosure whenever the blocking hook's output quoted it.
return withAppliedHookNotices(result, outcome.Notices, nil)
}
// withAppliedHookNotices is the single place a hook 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.
//
// 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: they are CommandPlan.Notes, which only this
// package's own fixed sentences may fill (none does today; #1006 refuses the
// denyRead trade the last one described), 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.
// withAppliedHookNotices carries a hook's disclosures onto the result as TYPED
// enforcement notices, and is the single finalization point every return path
// from a hooked call goes through.
//
// BOTH PHASES, because half a symmetry is its own defect. beforeTool was moved
// onto the typed slice while afterTool was left folding its notices into the
// prose feedback, and since the two carry the identical fixed string, a
// disclosure reported by both then reached the model twice: once from the typed
// prepend every surface composes, once inside the hook feedback in the body. A
// bash or exec card showed it in the furniture and again in the body.
//
// It used to fold them into result.Output as prose. That reached the provider,
// which reads the output, and reached nothing else. Every interactive surface
// builds its enforcement furniture from the typed slice, and for an edit or a
// write the card shows Display.Preview instead of Output, so the disclosure was
// absent from the live card in both its collapsed and expanded states and from
// the session payload that restores them. A notice about a weakened token was
// therefore shown to the model and hidden from the operator, on exactly the
// results where something was written.
//
// Typed here, the canonical accessors compose it per surface, the same way a
// tool's own notices already work. Nothing is written into Output as well:
// decoration has one owner per surface or the disclosure appears twice.
// Hook notices lead, in run order, with the tool's own after them: dedupe makes
// the ordering moot for the duplicate case that motivated this, and for distinct
// disclosures it keeps the shape the tool-owned notices already had.
func withAppliedHookNotices(result ToolResult, before []string, after []string) ToolResult {
hookNotices := before
if len(after) > 0 {
hookNotices = append(append([]string(nil), before...), after...)
}
merged, didRedact := mergeEnforcementNotices(hookNotices, result.EnforcementNotices)
result.EnforcementNotices = merged
if didRedact {
result.Redacted = true
}
return result
}
// mergeEnforcementNotices puts the hook disclosures ahead of the
// tool's own and drops exact repeats, so a surface rendering the slice shows
// each disclosure exactly once.
//
// Hook notices are third-party text arriving on an intercepted path that bypasses
// the registry's redaction boundary, so they are scrubbed here the way
// appendHookFeedback scrubbed them while they travelled as prose. The bool
// reports whether scrubbing changed anything, so Redacted keeps matching the
// registry's contract.
func mergeEnforcementNotices(before []string, own []string) ([]string, bool) {
if len(before) == 0 && len(own) == 0 {
return nil, false
}
merged := make([]string, 0, len(before)+len(own))
seen := make(map[string]struct{}, len(before)+len(own))
redacted := false
for index, notice := range append(append([]string(nil), before...), own...) {
if index < len(before) {
scrubbed := redaction.RedactString(notice, redaction.Options{})
redacted = redacted || scrubbed != notice
notice = scrubbed
}
if strings.TrimSpace(notice) == "" {
continue
}
if _, already := seen[notice]; already {
continue
}
seen[notice] = struct{}{}
merged = append(merged, notice)
}
if len(merged) == 0 {
return nil, redacted
}
return merged, redacted
}
// 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.
// The joiner that folded beforeTool notices in with the afterTool feedback is
// gone. Sending a typed enforcement notice out as hook prose was the defect, not
// the delivery: see withAppliedHookNotices. afterTool feedback still arrives here
// on its own, which is all this path was ever meant to carry.
func appendHookFeedback(output string, feedback string) (string, bool) {
scrubbed := redaction.RedactString(feedback, redaction.Options{})
redacted := scrubbed != feedback
@@ -2204,18 +2358,19 @@ 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,
FileDiffs: result.FileDiffs,
ChangeSummaries: result.ChangeSummaries,
Display: result.HumanDisplay(),
Outcome: result.Outcome,
ToolCallID: call.ID,
Name: call.Name,
Status: result.Status,
Output: result.BaseModelOutput(),
Truncated: result.Truncated,
Meta: result.Meta,
EnforcementNotices: append([]string(nil), result.EnforcementNotices...),
Redacted: result.Redacted,
ChangedFiles: result.ChangedFiles,
FileDiffs: result.FileDiffs,
ChangeSummaries: result.ChangeSummaries,
Display: result.BaseDisplay(),
Outcome: result.Outcome,
}
}
return ToolResult{
+84
View File
@@ -0,0 +1,84 @@
package agent
import (
"strings"
"github.com/Twigpine/zero/internal/tools"
)
// CardBody is the card body source: the rich card-only Display.Preview (a
// code/diff preview) when present on a successful result, else the text 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 (result ToolResult) CardBody() string {
display := result.BaseDisplay()
if strings.TrimSpace(display.Preview) != "" && (result.Status != tools.StatusError || result.Outcome.Finalized()) {
return display.Preview
}
return result.BaseModelOutput()
}
// ToolResultSessionPayload is THE serialization of a tool result into a session
// event, shared by every writer: the TUI, headless exec, and the ACP agent.
//
// It lives here, beside ToolResult, because it has three callers and only one of
// them is a terminal. The writers used to spell the payload separately, and each
// time one of them was left behind the same thing went missing on reload. The
// headless writer persisted only the decorated ModelOutput, so a CLI-written
// result restored into the TUI arrived with no typed notices and no undecorated
// body. The ACP writer persisted the raw Output field, which after the
// output/notice split is the UNDECORATED text, so an ACP client that loaded a
// session saw every sandboxed result with its disclosure gone, although the
// live update for the same call had shown it. All of them write to the same
// session store, so a session written by one surface is restored by another.
// One owner for the contract means one place where a field can go missing, and a
// test against this function covers every writer.
//
// output is the provider-facing text with the disclosure composed in.
// displayPreview is the undecorated card body, written whenever it differs from
// output. A result carrying enforcement notices ALWAYS differs, because output
// is decorated and the card body is not, so the undecorated body is written
// even when it is empty. Readers key 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 ToolResult) map[string]any {
output := result.ModelOutput()
payload := map[string]any{
"toolCallId": result.ToolCallID,
"name": result.Name,
"status": string(result.Status),
"output": output,
}
if preview := result.CardBody(); preview != output {
payload["displayPreview"] = preview
}
if result.Truncated {
payload["truncated"] = true
}
if result.Redacted {
payload["redacted"] = true
}
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
}
if len(result.ChangeSummaries) > 0 {
payload["changeSummaries"] = result.ChangeSummaries
}
return payload
}
+28 -6
View File
@@ -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
@@ -110,24 +114,42 @@ type ToolResult struct {
RequestedModel string
}
// 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 {
// 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
}
// HumanDisplay returns the presentation intended for interactive surfaces.
func (result ToolResult) HumanDisplay() tools.Display {
// 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 {
return tools.WithEnforcementNotices(result.BaseModelOutput(), result.EnforcementNotices)
}
// HumanDisplay returns the presentation intended for interactive surfaces.
func (result ToolResult) HumanDisplay() tools.Display {
display := result.BaseDisplay()
display.Summary = tools.WithEnforcementNotices(display.Summary, result.EnforcementNotices)
return display
}
// DenialCategory classifies why a tool call was blocked before it executed.
type DenialCategory string
+17
View File
@@ -874,6 +874,19 @@ 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.
// 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.
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
@@ -969,6 +982,10 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
// describes the sandbox they actually get.
startupNotices := []string{sandboxEngine.DegradedNotice()}
modelRPM := zeroruntime.NewModelRPMLimiter(resolved.ModelRPM)
// 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{
ModelRPM: modelRPM,
Cwd: workspaceRoot,
+38 -19
View File
@@ -365,6 +365,18 @@ 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.
// 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.
// 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)
registerLocalControlTools(registry, workspaceRoot, resolved.LocalControl)
@@ -722,25 +734,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)
@@ -1504,3 +1498,28 @@ 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 {
// 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. internal/agent owns the shape now, beside
// ToolResult, and every writer calls the same function rather than keeping
// a matching copy of it.
return agent.ToolResultSessionPayload(result)
}
+47
View File
@@ -0,0 +1,47 @@
package cli
import (
"strings"
"testing"
"github.com/Twigpine/zero/internal/agent"
"github.com/Twigpine/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"])
}
}
+1 -16
View File
@@ -161,22 +161,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)
@@ -0,0 +1,127 @@
package cli
import (
"bytes"
"context"
"encoding/json"
"strings"
"testing"
"github.com/Twigpine/zero/internal/config"
"github.com/Twigpine/zero/internal/mcp"
"github.com/Twigpine/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())
}
}
+6 -4
View File
@@ -54,10 +54,10 @@ func TestRunExecImageAttachmentHistory(t *testing.T) {
{name: "stream and file images", model: "gpt-4.1", fileImage: true},
} {
t.Run(tc.name, func(t *testing.T) {
root := t.TempDir()
for _, name := range []string{"HOME", "USERPROFILE", "APPDATA", "LOCALAPPDATA", "XDG_CONFIG_HOME", "XDG_CACHE_HOME", "XDG_DATA_HOME", "XDG_STATE_HOME"} {
t.Setenv(name, root)
}
// The shared isolation also turns off the models.dev refresh that
// runExec starts in the background. Without it, the refresh downloads
// the catalog into this HOME and races the TempDir cleanup.
isolateCLIUserState(t)
cwd := t.TempDir()
png := []byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0x0d, 0x49, 0x48, 0x44, 0x52}
encoded := base64.StdEncoding.EncodeToString(png)
@@ -148,6 +148,7 @@ func TestRunExecImageAttachmentHistory(t *testing.T) {
// user turn). Before the fix, stream-json images were parsed and validated but
// silently dropped — never threaded into the agent run.
func TestRunExecStreamJSONImageReachesAgent(t *testing.T) {
isolateCLIUserState(t)
cwd := t.TempDir()
// A minimal PNG, base64-encoded for the stream-json image payload.
@@ -217,6 +218,7 @@ func TestRunExecStreamJSONImageReachesAgent(t *testing.T) {
// the fix, ResolvePrompt's "must include at least one prompt or user message
// event" error was returned before images were considered, rejecting the turn.
func TestRunExecStreamJSONImageOnlyMessageProceeds(t *testing.T) {
isolateCLIUserState(t)
cwd := t.TempDir()
png := []byte{
+163
View File
@@ -0,0 +1,163 @@
package cli
import (
"bytes"
"context"
"errors"
"strings"
"testing"
"time"
"github.com/Twigpine/zero/internal/config"
"github.com/Twigpine/zero/internal/mcp"
"github.com/Twigpine/zero/internal/tools"
)
// 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
// 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 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{
"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})
// 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")
},
})
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
_, 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()
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
_, 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)
}
}
@@ -0,0 +1,52 @@
package cli
import (
"bytes"
"strings"
"testing"
"github.com/Twigpine/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())
}
}
+106
View File
@@ -3,9 +3,11 @@ package cli
import (
"context"
"fmt"
"io"
"net/url"
"sort"
"strings"
"sync"
"github.com/Twigpine/zero/internal/config"
"github.com/Twigpine/zero/internal/execution"
@@ -190,3 +192,107 @@ 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
}
// 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
// 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.
//
// 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.
//
// 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
// 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(serialized, "notice: MCP server %s started with reduced enforcement: %s\n", disclosure.Name, notice)
}
}
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 stderr, func() {}
}
stream := streaming.StartupDisclosureStream()
if stream == nil {
return stderr, func() {}
}
printAll(stream.Drain())
pumped := make(chan struct{})
go func() {
defer close(pumped)
for stream.Wait() {
printAll(stream.Drain())
}
}()
var once sync.Once
return serialized, func() {
once.Do(func() {
stream.Close()
<-pumped
printAll(stream.Drain())
})
}
}
// 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)
}
+146
View File
@@ -0,0 +1,146 @@
package cli
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"strings"
"sync"
"testing"
"time"
"github.com/Twigpine/zero/internal/config"
"github.com/Twigpine/zero/internal/mcp"
"github.com/Twigpine/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)
}
}
@@ -0,0 +1,72 @@
package cli
import (
"strings"
"testing"
"github.com/Twigpine/zero/internal/agent"
"github.com/Twigpine/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)
}
}
+146
View File
@@ -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)
}
+168
View File
@@ -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) }
+62 -1
View File
@@ -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 {
@@ -182,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
@@ -190,6 +206,51 @@ 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
// started, from the recorded fact rather than from the terminal outcome kind.
func (outcome Outcome) ChildLaunched() bool {
return outcome.Launched
}
// 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 {
+111
View File
@@ -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)
}
})
}
@@ -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")
}
}
+112 -29
View File
@@ -79,8 +79,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 {
@@ -162,6 +166,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,
@@ -423,31 +428,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
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) {
@@ -455,14 +475,76 @@ 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.
//
// SILENCE IS NOT A NEGATIVE, BUT AN EXPLICIT NEGATIVE IS. 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. Reading absence as 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, and absence is the normal state once the plan's cleanup has removed
// the file.
//
// A report that SAYS false is different, and it revokes. The Windows helper
// publishes the launch before it resumes the suspended child, so there is a
// window where the fact is readable and the child has still executed nothing; if
// the resume then fails, the helper retracts the record with an explicit false.
// Without this, a poll that landed inside that window would hold a launch that
// never happened, and hold it through completion, since the final read finds the
// file cleaned away and restores what was latched.
//
// Which is why the observation is repeated while the command runs rather than
// latched once. It costs one small read per poll on a wrapped plan, and every
// unwrapped plan still does no extra work at all; a fact that can be withdrawn
// is not one to cache.
func (process *managedProcess) observeLaunch() {
if process.report == nil {
return
}
process.mu.Lock()
skip := !process.ownedLaunch
process.mu.Unlock()
if skip || process.doneClosed() {
return
}
report, err := process.report()
if err != nil || report.ChildLaunched == nil {
return
}
process.mu.Lock()
process.launchObserved = *report.ChildLaunched
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
@@ -473,8 +555,9 @@ 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,
Changes: append([]Change(nil), process.changes...), Metadata: cloneStringMap(process.metadata),
Enforcement: process.enforcement, Report: process.launchedReportLocked(), ReportErr: process.reportErr,
ChildLaunchOwnedByAdapter: process.ownedLaunch,
Changes: append([]Change(nil), process.changes...), Metadata: cloneStringMap(process.metadata),
}
process.mu.Unlock()
return result
+128
View File
@@ -0,0 +1,128 @@
package execution
import (
"context"
"os"
"path/filepath"
"testing"
"time"
)
// A LAUNCH THE HELPER TAKES BACK MUST NOT SURVIVE IN EITHER RESULT.
//
// The Windows helper publishes childLaunched before it resumes the suspended
// child, so the fact is readable while the child has still executed nothing. A
// poll landing in that window latched it, and the latch outlived the file: the
// terminal read finds the report cleaned away and restores what was observed, so
// a child that never became runnable was reported as launched, and the write-jail
// trade it never made was disclosed as if it had.
//
// Deleting the report on that path cannot fix it, because deletion is also what a
// normal cleanup does. The helper retracts with an explicit false instead, and
// this is the interleaving that pins it: publish, live read, retract, poll, then
// finish and read terminally.
func TestARetractedLaunchIsNotCommittedLiveOrTerminally(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: the publication window. The live read has to have seen the positive,
// or the revocation below would have nothing to revoke.
if result.Exited {
t.Fatal("SETUP INVALID: the stand-in helper exited, so the live lifecycle is not under test")
}
if !ResolveChildLaunched(true, result.ChildLaunchOwnedByAdapter, result.Report) {
t.Fatal("SETUP INVALID: the published launch was not observed live, so this test cannot show it being taken back")
}
// The resume fails: the helper retracts the record it published.
if err := os.WriteFile(reportPath, []byte(`{"childLaunched":false}`), 0o600); err != nil {
t.Fatal(err)
}
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.Error("a live poll still reports a launch the helper retracted, so the disclosure stands for a child that never ran")
}
// Finish, with the plan's cleanup removing the report the way it always does.
// The terminal read then finds absence, which is exactly the case the restore
// exists for, and it must not resurrect the retracted launch.
if !manager.Stop(result.ProcessID) {
t.Fatal("Stop: process not found")
}
_ = os.Remove(reportPath)
final, err := manager.Continue(context.Background(), ProcessContinue{ProcessID: result.ProcessID, Wait: time.Second})
if err != nil {
t.Fatalf("Continue after stop: %v", err)
}
if !final.Exited {
t.Fatal("SETUP INVALID: the helper never exited, so there is no terminal result under test")
}
if ResolveChildLaunched(true, final.ChildLaunchOwnedByAdapter, final.Report) {
t.Error("the final result reports a launch the helper retracted")
}
}
// AND A GENUINE LAUNCH STILL SURVIVES ITS OWN CLEANUP. The revocation must key
// on an explicit denial and nothing else: a report removed by the plan's normal
// cleanup is silence, and silence does not take back what was seen.
func TestAConfirmedLaunchSurvivesTheReportCleanup(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() })
if !ResolveChildLaunched(true, result.ChildLaunchOwnedByAdapter, result.Report) {
t.Fatal("SETUP INVALID: the published launch was not observed live")
}
if !manager.Stop(result.ProcessID) {
t.Fatal("Stop: process not found")
}
_ = os.Remove(reportPath)
final, err := manager.Continue(context.Background(), ProcessContinue{ProcessID: result.ProcessID, Wait: time.Second})
if err != nil {
t.Fatalf("Continue after stop: %v", err)
}
if !final.Exited {
t.Fatal("SETUP INVALID: the helper never exited")
}
if !ResolveChildLaunched(true, final.ChildLaunchOwnedByAdapter, final.Report) {
t.Error("a confirmed launch was lost when the plan's cleanup removed its report")
}
}
+44
View File
@@ -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 {
@@ -90,10 +96,25 @@ 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()
}
// 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.
launched = ResolveChildLaunched(launched, prepared.ChildLaunchOwnedByAdapter, report)
result := CapturedResult{
Stdout: stdout.String(),
Stderr: stderr.String(),
@@ -101,6 +122,7 @@ func (runner *Runner) ExecuteCaptured(ctx context.Context, input CapturedRequest
Err: runErr,
Outcome: Outcome{
Enforcement: prepared.Enforcement,
Launched: launched,
},
}
exitCode := commandExitCode(runErr)
@@ -157,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,
@@ -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)
}
})
}
+74 -6
View File
@@ -36,7 +36,25 @@ 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, and it no longer carries notices at
// all. hookMessage used to fold them in so afterTool got both on one line;
// that made the disclosure arrive twice once the typed slice was composed by
// every surface. Enforcement disclosures are on Notices, for both hook phases.
// A caller that only wants to know what the sandbox did must read that:
// 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. 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
}
type commandResult struct {
@@ -45,6 +63,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 +164,10 @@ func executionCommandRunner(runner *execution.Runner) commandRunner {
Stderr: stderr,
Err: commandErr,
TimedOut: result.Outcome.Kind == execution.OutcomeTimedOut,
// 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(),
}
}
}
@@ -182,11 +211,19 @@ func (dispatcher *Dispatcher) Dispatch(ctx context.Context, input DispatchInput)
if message := hookMessage(result); message != "" {
outcome.Messages = append(outcome.Messages, message)
}
// 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)
}
}
if blocked {
outcome.Blocked = true
outcome.BlockedBy = hook.ID
outcome.Reason = blockReason(result)
outcome.Reason = blockCause(result)
// Stop on the first veto: the action is already denied.
return outcome
}
@@ -243,14 +280,38 @@ 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.
// STDOUT OR STDERR, AND NOTHING ELSE. The enforcement notices used to be
// prepended here as well, so that afterTool got the disclosure and its own output
// on one line. That was the only delivery available when this was written, and it
// is not any more: the notices travel typed on DispatchOutcome.Notices, and the
// agent loop merges them into ToolResult.EnforcementNotices for beforeTool and
// afterTool alike.
//
// Keeping the fold as well made the same disclosure arrive twice, once from the
// typed slice that every surface composes and once inside the hook feedback block
// in the body. The two writers carry the identical fixed string, so the model saw
// it doubled and a bash or exec card showed it in both the furniture and the body.
// One owner per fact, and for a notice that owner is the typed slice.
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)
return message
}
func blockReason(result commandResult) string {
// blockCause explains a veto in the hook's own words, and says nothing about
// enforcement.
//
// THE SAME RULE AS hookMessage. The blocking hook's notices used to be folded in
// here as well, from before the typed slice existed, and Dispatch had already
// appended them to DispatchOutcome.Notices, so a hook that vetoed while running
// without write confinement put one disclosure on two channels. The one consumer
// made up for it by dropping every notice whose text appeared anywhere in the
// reason, which also dropped an EARLIER hook's disclosure whenever the blocking
// hook's output happened to quote it. The notice now travels on the typed slice
// only, whichever hook in the chain produced it. Reported by CodeRabbit.
func blockCause(result commandResult) string {
if result.TimedOut {
if trimmed := strings.TrimSpace(result.Stderr); trimmed != "" {
return "hook timed out: " + trimmed
@@ -288,7 +349,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,
})
}
+2 -2
View File
@@ -244,8 +244,8 @@ func TestClassifyResultTimedOutFailsClosedForBeforeTool(t *testing.T) {
if status, blocked := classifyResult(EventAfterTool, timedOut); blocked || status != AuditError {
t.Fatalf("afterTool timeout classify = (%q, %v), want (error, false)", status, blocked)
}
if reason := blockReason(timedOut); !strings.Contains(reason, "timed out") {
t.Fatalf("blockReason = %q, want a timeout message", reason)
if reason := blockCause(timedOut); !strings.Contains(reason, "timed out") {
t.Fatalf("blockCause = %q, want a timeout message", reason)
}
}
@@ -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(&noticePreparer{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)
}
}
}
@@ -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"
@@ -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"
@@ -0,0 +1,184 @@
package hooks
import (
"context"
"errors"
"os/exec"
"runtime"
"strings"
"testing"
"time"
"github.com/Twigpine/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: &noticePreparer{prepareErr: errors.New("could not build the restricted token")},
wantNotice: false,
},
{
name: "the executable was never found",
preparer: &noticePreparer{build: func() *exec.Cmd {
return exec.Command("definitely-not-a-real-binary-zzz")
}},
wantNotice: false,
},
{
name: "the child launched and succeeded",
preparer: &noticePreparer{build: func() *exec.Cmd { return shellCommand("exit 0") }},
wantNotice: true,
},
{
name: "the child launched and exited nonzero",
preparer: &noticePreparer{build: func() *exec.Cmd { return shellCommand("exit 3") }},
wantNotice: true,
},
{
name: "the child launched and timed out",
preparer: &noticePreparer{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)
}
// Asserted on Notices, which is where the disclosure travels. It used
// to be folded into the hook message as well, and the agent loop now
// merges Notices into the typed enforcement slice for both hook
// phases, so carrying it in both places delivered it twice.
notices := strings.Join(result.Notices, "\n")
if testCase.wantNotice && !strings.Contains(notices, launchStateNotice) {
t.Errorf("a launched child lost its disclosure:\n%s", notices)
}
if !testCase.wantNotice && strings.Contains(notices, launchStateNotice) {
t.Errorf("a child that never launched claimed the token was traded away:\n%s", notices)
}
if message := hookMessage(result); strings.Contains(message, launchStateNotice) {
t.Errorf("the disclosure also rode along in the hook message, so it reaches the model twice:\n%s", message)
}
})
}
}
// And the same rule has to hold on the veto path, which returns early and is what
// the model actually sees. Both channels are checked: the notice travels on
// Notices, and Reason must not grow a copy of it either.
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(&noticePreparer{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 len(outcome.Notices) != 0 {
t.Errorf("the dispatch outcome claims an enforcement trade for a hook that never started: %#v", outcome.Notices)
}
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, on the
// typed slice and only there.
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(&noticePreparer{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 got := strings.Count(strings.Join(outcome.Notices, "\n"), launchStateNotice); got != 1 {
t.Errorf("a hook that really ran under the weakened token disclosed it %d times on Notices, want once: %#v", got, outcome.Notices)
}
if strings.Contains(outcome.Reason, launchStateNotice) {
t.Errorf("the veto reason carries a second copy of the notice:\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)
}
+116
View File
@@ -0,0 +1,116 @@
package hooks
import (
"context"
"strings"
"testing"
)
// A HOOK THAT RAN UNDER THE WEAKENED TOKEN STILL SAYS SO, ON THE TYPED CHANNEL.
//
// The projection once kept stdout, stderr and an exit code and dropped the
// notices, so such a hook ran silently. The first fix put them into the message
// alongside the hook's own output, which delivered them but made Messages two
// things at once. Now that the agent loop merges Notices into the typed
// EnforcementNotices for beforeTool and afterTool alike, folding them into the
// message as well delivered the same disclosure twice.
//
// So the property is unchanged and its carrier moved: whatever the hook printed,
// the notice reaches Notices exactly once and never rides along in Messages.
func TestAHookSurfacesTheEnforcementNoticeOnTheTypedChannel(t *testing.T) {
const notice = "denyRead is configured, so the write jail is not confining writes"
for _, testCase := range []struct {
name string
result commandResult
}{
{"hook printed nothing", commandResult{ExitCode: 0, Notices: []string{notice}}},
{"hook printed to stdout", commandResult{ExitCode: 0, Stdout: "looks fine", Notices: []string{notice}}},
{"hook printed to stderr only", commandResult{ExitCode: 0, Stderr: "a warning", Notices: []string{notice}}},
} {
t.Run(testCase.name, func(t *testing.T) {
result := testCase.result
dispatcher := NewDispatcher(DispatcherOptions{
Config: beforeToolConfig(Definition{ID: "vet", Event: EventAfterTool, Command: "vet", Enabled: true}),
run: func(context.Context, string, []string, []byte, string, []string) commandResult {
return result
},
})
outcome := dispatcher.Dispatch(context.Background(), DispatchInput{Event: EventAfterTool, ToolName: "bash"})
if got := strings.Count(strings.Join(outcome.Notices, "\n"), notice); got != 1 {
t.Fatalf("the notice reached Notices %d times, want once: %v", got, outcome.Notices)
}
// AND NOT IN THE PROSE AS WELL, which is what made it arrive twice.
if joined := strings.Join(outcome.Messages, "\n"); strings.Contains(joined, notice) {
t.Errorf("the notice also rode along in Messages, so every surface that composes the typed slice shows it twice:\n%s", joined)
}
})
}
}
// A hook's own output is untouched, with a notice or without one.
func TestAHookMessageCarriesOnlyTheHooksOwnOutput(t *testing.T) {
const notice = "denyRead is configured, so the write jail is not confining writes"
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, Stdout: "looks fine", Notices: []string{notice}}); message != "looks fine" {
t.Errorf("hookMessage = %q, want the notice left to the typed channel", message)
}
if message := hookMessage(commandResult{ExitCode: 0}); message != "" {
t.Errorf("a silent hook with no notice produced %q", message)
}
if message := hookMessage(commandResult{ExitCode: 0, Notices: []string{notice}}); message != "" {
t.Errorf("a silent hook produced %q, want nothing: its disclosure travels typed", message)
}
}
// THROUGH Dispatch, NOT A HAND-BUILT commandResult.
//
// The blocking branch returns immediately, so it never touches hookMessage, and a
// vetoing beforeTool hook that ran without write confinement once reported only
// the veto. The disclosure still has to come out of that branch, and it has to
// come out ONCE: the blocking hook's notices used to be folded into Reason as well
// as appended to Notices, so the same fact travelled on two channels and the
// consumer had to guess which copy to drop. Reason is the hook's words, and the
// notice travels on the typed slice like every other hook's.
func TestABlockedBeforeToolHookCarriesItsNoticeOnTheTypedSliceOnly(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 len(outcome.Notices) != 1 || outcome.Notices[0] != notice {
t.Errorf("the veto lost the enforcement notice from the typed slice: %#v", outcome.Notices)
}
if outcome.Reason != "policy violation" {
t.Errorf("Reason = %q, want the hook's own explanation and nothing else", outcome.Reason)
}
// Across BOTH channels, because a copy in each is the defect this pins.
if got := strings.Count(outcome.Reason, notice) + strings.Count(strings.Join(outcome.Notices, "\n"), notice); got != 1 {
t.Errorf("the notice appears %d times across Reason and Notices, want once", got)
}
}
// 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)
}
}
+13
View File
@@ -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 {
@@ -0,0 +1,163 @@
package mcp
import (
"context"
"encoding/json"
"errors"
"os"
"os/exec"
"path/filepath"
"runtime"
"testing"
"github.com/Twigpine/zero/internal/config"
"github.com/Twigpine/zero/internal/execution"
"github.com/Twigpine/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)
}
}
@@ -0,0 +1,281 @@
package mcp
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"github.com/Twigpine/zero/internal/config"
"github.com/Twigpine/zero/internal/execution"
"github.com/Twigpine/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)
}
// 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.
//
// 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)
}
}
// 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)
}
}
+160 -3
View File
@@ -54,6 +54,56 @@ 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
// correct answer rather than an empty one.
type startupDisclosing interface {
StartupNotices() []string
}
// 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...)
}
type Client struct {
server Server
cmd *exec.Cmd
@@ -64,6 +114,20 @@ type Client struct {
idMu 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
// 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
writeMu sync.Mutex
writeQueue chan writeOp
@@ -169,6 +233,11 @@ 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
// 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 launchTracker *execution.ChildLaunchTracker
var ownedLaunch bool
cleanupTransferred := false
defer func() {
if cleanup != nil && !cleanupTransferred {
@@ -192,7 +261,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
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)
@@ -211,6 +284,55 @@ 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.
//
// 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)
}
// ONE ANSWER, AND IT IS ONLY CACHED ONCE IT CANNOT CHANGE.
//
// 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 := func() bool {
if !ownedLaunch {
return true
}
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
// timeout eventually arrives, so a late disclosure is still delivered once.
publishAdapterLaunch := func() {
if !ownedLaunch || !launchedOnce() {
return
}
publishLaunch(ctx, plannedEnforcement.Notices)
}
client := &Client{
server: server,
cmd: cmd,
@@ -219,16 +341,51 @@ 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...),
launched: launchedOnce,
}
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.
// 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)
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)
// 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}
}
// 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
}
@@ -0,0 +1,105 @@
package mcp
import (
"bytes"
"context"
"strings"
"testing"
"github.com/Twigpine/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)
}
}
})
}
}
+118
View File
@@ -0,0 +1,118 @@
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.
//
// 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
onPublish func(notices []string)
delivered bool
}
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()
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
// 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...)
}
// 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) }
}
@@ -0,0 +1,199 @@
package mcp
import (
"context"
"errors"
"testing"
"time"
"github.com/Twigpine/zero/internal/config"
"github.com/Twigpine/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)
}
}
// 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)
}
}
// 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)
}
}
+260 -15
View File
@@ -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 {
@@ -47,6 +52,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.
@@ -55,8 +72,83 @@ type Runtime struct {
// is closed). Same length/order as clients is not required.
cancels []context.CancelFunc
skipped []SkippedServer
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
// 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
// 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
}
// 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.
//
// 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
}
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
}
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
@@ -110,19 +202,38 @@ 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))
// 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)
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{}
sinks[index] = sink
serverCtx, cancel := context.WithCancel(withLaunchSink(ctx, sink))
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:
@@ -134,14 +245,47 @@ 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.
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()
}
}()
results[index] = 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 len(timedOut.notices) == 0 {
if launched, notices := sink.observe(); launched {
timedOut.notices = notices
}
}
results[index] = timedOut
}
}(index)
}
@@ -157,6 +301,28 @@ 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.
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
}
}
// 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
@@ -190,17 +356,42 @@ 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
// 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)
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, nil
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 nil
}
// buildServerTools validates a server's remote tools against the registry and the
@@ -236,6 +427,25 @@ 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.
//
// THROUGH THE ONCE, NOT THE FIELD. Reading disclosureStream directly raced
// a first StartupDisclosureStream call on another goroutine, and a Close
// that came before any call found nil and closed nothing, so a later call
// built a fresh, open stream and subscribed it to launches from a runtime
// that was already gone. Reported by CodeRabbit.
//
// If nobody asked for the stream before this, nobody is draining it, so it
// is published EMPTY rather than built the usual way: no sink is
// subscribed and nothing is queued for an owner that does not exist. Every
// later caller then gets this stream, already closed. The read after Do is
// ordered by the Once whichever call got there first.
runtime.disclosureStreamOnce.Do(func() {
runtime.disclosureStream = newStartupDisclosureStream()
})
runtime.disclosureStream.Close()
for _, client := range runtime.clients {
if err := client.Close(); err != nil && runtime.err == nil {
runtime.err = err
@@ -434,6 +644,41 @@ 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.
//
// 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
}
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
}
// appendServerNote joins a [zero] note to output, choosing "returned" or
// "also returned" based on whether anything precedes it.
func appendServerNote(output, subject, tail string) string {
@@ -0,0 +1,127 @@
package mcp
import (
"context"
"sync"
"testing"
"time"
"github.com/Twigpine/zero/internal/config"
"github.com/Twigpine/zero/internal/tools"
)
// streamStillOpen asks Wait whether the stream is worth draining, without
// letting it block the suite. An open, empty stream blocks Wait forever, which
// is the very defect these tests pin, so an unbounded call hangs until the test
// binary times out instead of failing. Past the bound the stream is reported
// open and closed here, so the waiting goroutine returns.
func streamStillOpen(t *testing.T, stream *StartupDisclosureStream) bool {
t.Helper()
result := make(chan bool, 1)
go func() { result <- stream.Wait() }()
select {
case open := <-result:
return open
case <-time.After(5 * time.Second):
stream.Close()
<-result
return true
}
}
// disclosingRuntime registers one server whose launch carried a startup notice,
// so its runtime has something the stream could deliver.
func disclosingRuntime(t *testing.T) *Runtime {
t.Helper()
client := &disclosingFakeClient{
fakeToolClient: fakeToolClient{listed: []RemoteTool{{Name: "lookup", Description: "Lookup"}}},
notices: []string{startupNotice},
}
runtime, err := RegisterTools(context.Background(), tools.NewRegistry(), 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)
}
if disclosures := runtime.StartupDisclosures(); len(disclosures) != 1 {
t.Fatalf("SETUP INVALID: the runtime has %d disclosures, so an empty stream would prove nothing", len(disclosures))
}
return runtime
}
// A CLOSED RUNTIME HANDS OUT A CLOSED STREAM.
//
// Close read the stream field directly. Before anyone had asked for the stream
// that field was nil, so Close closed nothing, and the next caller built a fresh,
// open stream, queued this runtime's disclosures into it and subscribed it to
// launches from a runtime that was already gone. A pump started on it then
// waited forever.
func TestAStreamRequestedAfterCloseIsAlreadyClosed(t *testing.T) {
runtime := disclosingRuntime(t)
if err := runtime.Close(); err != nil {
t.Fatalf("Close() error = %v", err)
}
stream := runtime.StartupDisclosureStream()
if stream == nil {
t.Fatal("StartupDisclosureStream() returned nil for a runtime")
}
if queued := stream.Drain(); len(queued) != 0 {
t.Errorf("a stream requested after Close delivered %#v from a runtime that is gone", queued)
}
if streamStillOpen(t, stream) {
t.Error("the stream is still open after Close, so a pump started on it never returns")
}
}
// And the stream an owner already holds is the one Close ends, with what was
// queued before Close still drainable.
func TestCloseEndsTheStreamAnOwnerAlreadyHolds(t *testing.T) {
runtime := disclosingRuntime(t)
stream := runtime.StartupDisclosureStream()
if err := runtime.Close(); err != nil {
t.Fatalf("Close() error = %v", err)
}
if again := runtime.StartupDisclosureStream(); again != stream {
t.Error("Close replaced the stream the owner holds, so the owner's pump is never told to stop")
}
if queued := stream.Drain(); len(queued) != 1 {
t.Errorf("the disclosure queued before Close was lost: %#v", queued)
}
if streamStillOpen(t, stream) {
t.Error("Close did not end the stream the owner holds")
}
}
// NO RACE BETWEEN CLOSE AND THE FIRST REQUEST FOR THE STREAM.
//
// Close read the field while a first StartupDisclosureStream call could be
// writing it inside the Once on another goroutine. Only meaningful under -race,
// which the race job runs.
func TestCloseAndTheFirstStreamRequestDoNotRace(t *testing.T) {
for attempt := 0; attempt < 20; attempt++ {
runtime := disclosingRuntime(t)
start := make(chan struct{})
var group sync.WaitGroup
group.Add(2)
go func() {
defer group.Done()
<-start
_ = runtime.StartupDisclosureStream()
}()
go func() {
defer group.Done()
<-start
_ = runtime.Close()
}()
close(start)
group.Wait()
// Whichever call won the Once, the stream is closed now. A disclosure
// queued before Close stays drainable, so drain before asking.
stream := runtime.StartupDisclosureStream()
stream.Drain()
if streamStillOpen(t, stream) {
t.Fatal("the stream survived Close")
}
}
}
+7 -1
View File
@@ -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
}
@@ -0,0 +1,193 @@
package mcp
import (
"context"
"fmt"
"sort"
"testing"
"github.com/Twigpine/zero/internal/config"
"github.com/Twigpine/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)
}
}
// 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)
}
}
+116
View File
@@ -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:
}
}
+200
View File
@@ -0,0 +1,200 @@
package mcp
import (
"context"
"errors"
"os"
"os/exec"
"strings"
"testing"
"time"
"github.com/Twigpine/zero/internal/config"
"github.com/Twigpine/zero/internal/execution"
"github.com/Twigpine/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")
}
// THE CARRIER, NOT THE TEXT. A connect failure hands its notices on
// startupDisclosureError, whose Error() prints only the cause, so a
// check of the message could never see a notice that was carried.
if carried := startupNoticesFromError(err); len(carried) != 0 {
t.Errorf("a launch that never happened claimed an enforcement trade: %#v", carried)
}
if strings.Contains(err.Error(), startupNotice) {
t.Errorf("a launch that never happened claimed an enforcement trade in its error: %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)
}
}
+8
View File
@@ -149,6 +149,7 @@ func TestRefreshModelsDevCacheRejectsBadBodyWithoutClobbering(t *testing.T) {
}
t.Setenv("ZERO_MODELS_CACHE_PATH", cachePath)
t.Setenv("ZERO_MODELS_URL", server.URL)
t.Setenv("ZERO_DISABLE_MODELS_FETCH", "")
if err := RefreshModelsDevCache(t.Context()); err == nil {
t.Fatal("bad body must return an error")
@@ -211,6 +212,9 @@ func TestCachedModelsDevProvidersIgnoresStaleCache(t *testing.T) {
t.Fatal(err)
}
t.Setenv("ZERO_MODELS_CACHE_PATH", cachePath)
// The variable turns the overlay off too, which would pass this test
// without the staleness check ever running.
t.Setenv("ZERO_DISABLE_MODELS_FETCH", "")
resetModelsDevCacheForTest()
t.Cleanup(resetModelsDevCacheForTest)
EnableModelsDevOverlay()
@@ -226,6 +230,9 @@ func TestCachedModelsDevProvidersRequiresOptIn(t *testing.T) {
t.Fatal(err)
}
t.Setenv("ZERO_MODELS_CACHE_PATH", cachePath)
// As above: with the variable set, a nil result says nothing about the
// opt-in.
t.Setenv("ZERO_DISABLE_MODELS_FETCH", "")
resetModelsDevCacheForTest()
t.Cleanup(resetModelsDevCacheForTest)
@@ -242,6 +249,7 @@ func TestDefaultModelEntriesAppliesFreshCache(t *testing.T) {
t.Fatal(err)
}
t.Setenv("ZERO_MODELS_CACHE_PATH", cachePath)
t.Setenv("ZERO_DISABLE_MODELS_FETCH", "")
resetModelsDevCacheForTest()
t.Cleanup(resetModelsDevCacheForTest)
EnableModelsDevOverlay()
-191
View File
@@ -1,191 +0,0 @@
package oauth
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"time"
)
const (
// secretBytes is the AES-256 key length kept in the per-user secret file.
secretBytes = 32
secretRetryAttempts = 500
secretRetryDelay = 2 * time.Millisecond
)
// aesGCMCrypter encrypts the token file at rest with AES-256-GCM under a
// per-user random secret persisted (0600) beside the token file. The on-disk
// blob is nonce || ciphertext; GCM provides confidentiality AND tamper
// detection, so a corrupted/forged file fails closed on open. This is the
// opt-in "encrypted-file" storage backend; the default backend writes the
// 0600 plaintext JSON unchanged.
type aesGCMCrypter struct {
secretPath string
}
func newAESGCMCrypter(secretPath string) *aesGCMCrypter {
return &aesGCMCrypter{secretPath: secretPath}
}
// aead loads (or, when create is set, generates) the secret and returns the GCM
// AEAD. open passes create=false so a missing secret is a hard error rather than
// silently minting a new key that could never decrypt the existing file.
func (c *aesGCMCrypter) aead(create bool) (cipher.AEAD, error) {
secret, err := loadOrCreateSecret(c.secretPath, create)
if err != nil {
return nil, err
}
block, err := aes.NewCipher(secret)
if err != nil {
return nil, fmt.Errorf("oauth: build cipher: %w", err)
}
return cipher.NewGCM(block)
}
// seal encrypts plaintext, prefixing a fresh random nonce. It creates the secret
// on first use.
func (c *aesGCMCrypter) seal(plaintext []byte) ([]byte, error) {
gcm, err := c.aead(true)
if err != nil {
return nil, err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return nil, fmt.Errorf("oauth: generate nonce: %w", err)
}
return gcm.Seal(nonce, nonce, plaintext, nil), nil
}
// open decrypts a nonce||ciphertext blob, failing closed on a missing secret,
// a short blob, or a failed authentication tag (tampering / wrong key).
func (c *aesGCMCrypter) open(blob []byte) ([]byte, error) {
gcm, err := c.aead(false)
if err != nil {
return nil, err
}
if len(blob) < gcm.NonceSize() {
return nil, errors.New("oauth: encrypted token file is too short")
}
nonce, ciphertext := blob[:gcm.NonceSize()], blob[gcm.NonceSize():]
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return nil, fmt.Errorf("oauth: decrypt token file (wrong secret or tampered): %w", err)
}
return plaintext, nil
}
// loadOrCreateSecret reads the 32-byte secret at path. When create is set and
// the file is absent, it generates a random secret and creates the file
// atomically (0600). A wrong-sized existing secret fails closed (corruption).
func loadOrCreateSecret(path string, create bool) ([]byte, error) {
if data, err := readSecretFileRetry(path); err == nil {
return data, nil
} else if !errors.Is(err, os.ErrNotExist) {
return nil, err
}
if !create {
return nil, fmt.Errorf("oauth: token secret %s is missing; cannot decrypt the token file", path)
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return nil, err
}
return createSecretFile(path)
}
func createSecretFile(path string) ([]byte, error) {
lockPath := path + ".lock"
var lastErr error
for attempt := 0; attempt < secretRetryAttempts; attempt++ {
lock, err := os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err == nil {
_ = lock.Close()
defer os.Remove(lockPath)
if data, rerr := readSecretFileRetry(path); rerr == nil {
return data, nil
} else if !errors.Is(rerr, os.ErrNotExist) {
return nil, rerr
}
return writeNewSecretFile(path)
}
// On Windows a concurrent holder's os.Remove leaves the lock file in a
// "delete pending" state, so an O_EXCL create races it with
// ERROR_ACCESS_DENIED (os.ErrPermission) rather than ErrExist. Treat that
// as contention and retry too — mirroring acquireFileLock in lock.go —
// otherwise concurrent secret creation spuriously fails on Windows.
if !errors.Is(err, os.ErrExist) && !errors.Is(err, os.ErrPermission) {
return nil, fmt.Errorf("oauth: create token secret lock: %w", err)
}
// Remember the lock-creation error itself rather than a subsequent
// "secret file doesn't exist yet" read error -- otherwise a real
// contention/ACL problem is masked by the expected-while-waiting
// ErrNotExist once the retries are exhausted.
lastErr = err
if data, rerr := readSecretFileRetry(path); rerr == nil {
return data, nil
}
time.Sleep(secretRetryDelay)
}
return nil, fmt.Errorf("oauth: timed out waiting for token secret %s: %w", path, lastErr)
}
func writeNewSecretFile(path string) ([]byte, error) {
secret := make([]byte, secretBytes)
if _, err := io.ReadFull(rand.Reader, secret); err != nil {
return nil, fmt.Errorf("oauth: generate token secret: %w", err)
}
// The encryption key is published exactly like the token blob: through the
// protected per-store directory, under a random name a sandboxed process
// cannot guess, wait for, or rename away.
tmp, tmpPath, err := createPublicationFile(path)
if err != nil {
return nil, fmt.Errorf("oauth: create token secret temp file: %w", err)
}
defer os.Remove(tmpPath)
if _, werr := tmp.Write(secret); werr != nil {
_ = tmp.Close()
return nil, fmt.Errorf("oauth: write token secret: %w", werr)
}
if cerr := tmp.Close(); cerr != nil {
return nil, fmt.Errorf("oauth: write token secret: %w", cerr)
}
if err := os.Rename(tmpPath, path); err != nil {
return nil, fmt.Errorf("oauth: publish token secret: %w", err)
}
return secret, nil
}
func readSecretFileRetry(path string) ([]byte, error) {
var lastErr error
for attempt := 0; attempt < secretRetryAttempts; attempt++ {
data, err := readSecretFile(path)
if !isTransientSecretAccessError(err) {
return data, err
}
lastErr = err
time.Sleep(secretRetryDelay)
}
return nil, fmt.Errorf("oauth: timed out waiting for token secret %s: %w", path, lastErr)
}
// readSecretFile reads and validates the secret at path, returning a wrapped
// os.ErrNotExist when it is absent so callers can branch on creation.
func readSecretFile(path string) ([]byte, error) {
data, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, err
}
return nil, fmt.Errorf("oauth: read token secret: %w", err)
}
if len(data) != secretBytes {
return nil, fmt.Errorf("oauth: token secret at %s has unexpected size %d", path, len(data))
}
return data, nil
}
+34 -91
View File
@@ -1,103 +1,13 @@
package oauth
import (
"bytes"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"testing"
"time"
)
func TestLoadOrCreateSecretConcurrentConverges(t *testing.T) {
path := filepath.Join(t.TempDir(), "tok.json.secret")
const n = 16
secrets := make([][]byte, n)
errs := make([]error, n)
var wg sync.WaitGroup
for i := 0; i < n; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
secrets[i], errs[i] = loadOrCreateSecret(path, true)
}(i)
}
wg.Wait()
// Exactly one creator wins; every racer must converge on the same on-disk
// secret rather than reading a half-published file or orphaning its own.
for i := 0; i < n; i++ {
if errs[i] != nil {
t.Fatalf("goroutine %d: %v", i, errs[i])
}
if !bytes.Equal(secrets[i], secrets[0]) {
t.Fatalf("goroutine %d got a divergent secret; concurrent create did not converge", i)
}
}
}
func TestAESGCMCrypterRoundTripAndTamper(t *testing.T) {
secretPath := filepath.Join(t.TempDir(), "tok.json.secret")
c := newAESGCMCrypter(secretPath)
plaintext := []byte(`{"schemaVersion":1,"tokens":{}}`)
blob, err := c.seal(plaintext)
if err != nil {
t.Fatalf("seal: %v", err)
}
if bytes.Contains(blob, plaintext) {
t.Fatal("sealed blob must not contain the plaintext")
}
got, err := c.open(blob)
if err != nil {
t.Fatalf("open: %v", err)
}
if !bytes.Equal(got, plaintext) {
t.Fatalf("round-trip mismatch: %q", got)
}
// Tamper: flipping any byte breaks the GCM tag.
tampered := append([]byte(nil), blob...)
tampered[len(tampered)-1] ^= 0xff
if _, err := c.open(tampered); err == nil {
t.Fatal("open must reject a tampered blob (GCM auth)")
}
}
func TestLoadOrCreateSecret(t *testing.T) {
path := filepath.Join(t.TempDir(), "tok.json.secret")
// Missing + create=false => fail closed (can't decrypt without the secret).
if _, err := loadOrCreateSecret(path, false); err == nil {
t.Fatal("missing secret with create=false must error")
}
secret, err := loadOrCreateSecret(path, true)
if err != nil {
t.Fatalf("create: %v", err)
}
if len(secret) != secretBytes {
t.Fatalf("secret length = %d, want %d", len(secret), secretBytes)
}
// Stable across reads.
again, err := loadOrCreateSecret(path, false)
if err != nil || !bytes.Equal(again, secret) {
t.Fatalf("secret not stable: %v", err)
}
if runtime.GOOS != "windows" {
info, err := os.Stat(path)
if err != nil {
t.Fatalf("stat secret: %v", err)
}
if perm := info.Mode().Perm(); perm != 0o600 {
t.Fatalf("secret file mode = %o, want 600", perm)
}
}
// Wrong-sized secret fails closed.
if err := os.WriteFile(path, []byte("short"), 0o600); err != nil {
t.Fatalf("corrupt secret: %v", err)
}
if _, err := loadOrCreateSecret(path, true); err == nil {
t.Fatal("wrong-sized secret must error")
}
}
func newEncryptedStore(t *testing.T) (*Store, string) {
t.Helper()
path := filepath.Join(t.TempDir(), "oauth-tokens.json")
@@ -202,3 +112,36 @@ func TestEncryptedStoreMissingSecretFailsClosed(t *testing.T) {
t.Fatal("missing secret must fail closed, not return empty")
}
}
// TestEncryptedStoreReclaimsStaleSecretLock reproduces a crash while the
// encrypted-file backend was creating its AES key. The key-creation lock is an
// O_EXCL ownership marker, not a kernel lock, so a process killed between
// creating and removing it leaves a file that every later Save waits on and
// then rejects forever. A stale marker must be reclaimed instead.
func TestEncryptedStoreReclaimsStaleSecretLock(t *testing.T) {
s, path := newEncryptedStore(t)
lockPath := path + ".secret.lock"
if err := os.WriteFile(lockPath, []byte("crashed-holder"), 0o600); err != nil {
t.Fatalf("write stale secret lock: %v", err)
}
stale := time.Now().Add(-2 * time.Minute)
if err := os.Chtimes(lockPath, stale, stale); err != nil {
t.Fatalf("backdate stale secret lock: %v", err)
}
if err := s.Save(ProviderKey("demo"), Token{AccessToken: "after-crash"}); err != nil {
t.Fatalf("Save must reclaim a stale secret lock, got: %v", err)
}
if _, err := os.Stat(lockPath); err == nil {
t.Fatal("stale secret lock was not cleaned up")
}
s2, err := NewStore(StoreOptions{FilePath: path, Encrypted: true})
if err != nil {
t.Fatalf("NewStore: %v", err)
}
token, ok, err := s2.Load(ProviderKey("demo"))
if err != nil || !ok || token.AccessToken != "after-crash" {
t.Fatalf("Load after stale-lock recovery = %+v ok=%v err=%v", token, ok, err)
}
}
+98
View File
@@ -0,0 +1,98 @@
package oauth
import (
"bytes"
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
func TestFileBlobWriteSyncsFileAndDirectory(t *testing.T) {
path := filepath.Join(t.TempDir(), "oauth-tokens.json")
blob := fileBlob{path: path}
origFile, origDir := syncOAuthFileFn, syncOAuthDirFn
t.Cleanup(func() {
syncOAuthFileFn, syncOAuthDirFn = origFile, origDir
})
fileSynced, dirSynced := false, false
syncOAuthFileFn = func(file *os.File) error {
fileSynced = true
return origFile(file)
}
syncOAuthDirFn = func(dir string) error {
dirSynced = true
return origDir(dir)
}
if err := blob.write([]byte("payload")); err != nil {
t.Fatalf("write: %v", err)
}
if !fileSynced {
t.Fatal("token blob was not synced before rename")
}
if !dirSynced {
t.Fatal("token store directory was not synced after rename")
}
data, ok, err := blob.read()
if err != nil || !ok || !bytes.Equal(data, []byte("payload")) {
t.Fatalf("read = %q ok=%v err=%v", data, ok, err)
}
}
// TestFileBlobWriteDirectorySyncFailureLeavesPublishedBlob pins the post-rename
// path: the directory barrier runs after the atomic replace, so a barrier
// failure reports an error but must not hide the already-published token.
func TestFileBlobWriteDirectorySyncFailureLeavesPublishedBlob(t *testing.T) {
path := filepath.Join(t.TempDir(), "oauth-tokens.json")
blob := fileBlob{path: path}
if err := blob.write([]byte("old")); err != nil {
t.Fatalf("seed write: %v", err)
}
origDir := syncOAuthDirFn
t.Cleanup(func() { syncOAuthDirFn = origDir })
syncErr := errors.New("directory sync failed")
syncOAuthDirFn = func(string) error { return syncErr }
err := blob.write([]byte("new"))
if !errors.Is(err, syncErr) {
t.Fatalf("write error = %v, want it to wrap %v", err, syncErr)
}
data, ok, err := blob.read()
if err != nil || !ok || !bytes.Equal(data, []byte("new")) {
t.Fatalf("published blob was not retained: data=%q ok=%v err=%v", data, ok, err)
}
}
func TestFileBlobWriteSyncFailurePreservesPrevious(t *testing.T) {
path := filepath.Join(t.TempDir(), "oauth-tokens.json")
blob := fileBlob{path: path}
if err := blob.write([]byte("old")); err != nil {
t.Fatalf("seed write: %v", err)
}
origFile := syncOAuthFileFn
t.Cleanup(func() { syncOAuthFileFn = origFile })
syncErr := errors.New("sync failed")
syncOAuthFileFn = func(*os.File) error { return syncErr }
err := blob.write([]byte("new"))
if !errors.Is(err, syncErr) {
t.Fatalf("write error = %v, want it to wrap %v", err, syncErr)
}
data, ok, err := blob.read()
if err != nil || !ok || !bytes.Equal(data, []byte("old")) {
t.Fatalf("previous blob was not preserved: data=%q ok=%v err=%v", data, ok, err)
}
entries, err := os.ReadDir(PublicationDir(path))
if err != nil {
t.Fatalf("ReadDir publication dir: %v", err)
}
for _, entry := range entries {
if strings.HasPrefix(entry.Name(), "publish-") {
t.Fatalf("failed write left publication temp %s", entry.Name())
}
}
}
-7
View File
@@ -1,7 +0,0 @@
//go:build !windows
package oauth
func isTransientSecretAccessError(error) bool {
return false
}
-14
View File
@@ -1,14 +0,0 @@
//go:build windows
package oauth
import (
"errors"
"golang.org/x/sys/windows"
)
func isTransientSecretAccessError(err error) bool {
return errors.Is(err, windows.ERROR_SHARING_VIOLATION) ||
errors.Is(err, windows.ERROR_LOCK_VIOLATION)
}
+53 -9
View File
@@ -18,7 +18,9 @@ import (
"sync"
"time"
"github.com/Twigpine/zero/internal/fsutil"
"github.com/Twigpine/zero/internal/keyring"
"github.com/Twigpine/zero/internal/securefile"
)
const (
@@ -155,7 +157,7 @@ const (
// is AES-256-GCM ciphertext at rest.
type Store struct {
blob blobStore
crypter *aesGCMCrypter // nil => plaintext blob
crypter *securefile.Crypter // nil => plaintext blob
now func() time.Time
mu sync.Mutex
}
@@ -244,8 +246,15 @@ func NewStore(options StoreOptions) (*Store, error) {
return nil, err
}
// The file blob holds AES-256-GCM ciphertext; the per-user secret lives in
// a sibling ".secret" file (see encrypt.go).
return &Store{blob: fileBlob{path: path}, crypter: newAESGCMCrypter(path + ".secret"), now: now}, nil
// a sibling ".secret" file. The secret publishes through the sandbox-denied
// ".secret.publish" directory so a same-user sandboxed process cannot race
// the plaintext key name.
secretPath := path + ".secret"
return &Store{
blob: fileBlob{path: path},
crypter: securefile.NewCrypterWithPublicationDir(secretPath, PublicationDir(secretPath)),
now: now,
}, nil
case "keyring":
kr := options.Keyring
if kr == nil {
@@ -405,7 +414,7 @@ func (s *Store) readState() (storeFile, error) {
}
if s.crypter != nil {
// Encrypted backend: the blob is AES-256-GCM ciphertext, not JSON.
data, err = s.crypter.open(data)
data, err = s.crypter.Open(data)
if err != nil {
return storeFile{}, fmt.Errorf("oauth: decrypt token store at %s: %w", s.blob.location(), err)
}
@@ -437,9 +446,9 @@ func (s *Store) writeState(state storeFile) error {
// writes opaque ciphertext instead.
payload := append(data, '\n')
if s.crypter != nil {
payload, err = s.crypter.seal(data)
payload, err = s.crypter.Seal(data)
if err != nil {
return err
return fmt.Errorf("oauth: encrypt token store: %w", err)
}
}
return s.blob.write(payload)
@@ -511,6 +520,31 @@ func (b fileBlob) read() ([]byte, bool, error) {
return data, true, nil
}
// Sync seams allow tests to inject persistence failures at each barrier.
var (
syncOAuthFileFn = (*os.File).Sync
syncOAuthDirFn = syncOAuthDir
)
// syncOAuthDir fsyncs the directory that now holds the published token store.
// Windows cannot sync directory handles, so the rename is best-effort durable
// there. A directory the user can write but not read cannot be opened for
// syncing either; that is also best effort, because the write it follows
// already succeeded. Every other failure is reported.
func syncOAuthDir(dir string) error {
if runtime.GOOS == "windows" {
return nil
}
d, err := os.Open(dir)
if err != nil {
if errors.Is(err, os.ErrPermission) {
return nil
}
return err
}
return errors.Join(d.Sync(), d.Close())
}
func (b fileBlob) write(data []byte) error {
if err := os.MkdirAll(filepath.Dir(b.path), 0o700); err != nil {
return err
@@ -524,10 +558,20 @@ func (b fileBlob) write(data []byte) error {
_ = temp.Close()
return err
}
if err := temp.Close(); err != nil {
return err
if err := syncOAuthFileFn(temp); err != nil {
_ = temp.Close()
return fmt.Errorf("oauth: sync token store: %w", err)
}
return os.Rename(tempPath, b.path)
if err := temp.Close(); err != nil {
return fmt.Errorf("oauth: write token store: %w", err)
}
if err := fsutil.RenameWithRetry(tempPath, b.path, nil); err != nil {
return fmt.Errorf("oauth: publish token store: %w", err)
}
if err := syncOAuthDirFn(filepath.Dir(b.path)); err != nil {
return fmt.Errorf("oauth: sync token store directory: %w", err)
}
return nil
}
// PublicationDirSuffix names the per-store directory a token store publishes new
+43 -12
View File
@@ -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
@@ -549,26 +558,37 @@ 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)
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 +739,18 @@ 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,
}
// 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.
output.Notices = result.Outcome.AppliedEnforcementNotices()
switch result.Outcome.Kind {
case execution.OutcomeSandboxSetupFailure, execution.OutcomeExecutableNotFound, execution.OutcomeTimedOut, execution.OutcomeCancelled:
output.Err = result.Err
+164
View File
@@ -0,0 +1,164 @@
package plugins
import (
"context"
"strings"
"testing"
"github.com/Twigpine/zero/internal/execution"
"github.com/Twigpine/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)
}
}
// 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.
//
// 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,
}
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},
} {
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)
}
})
}
}
+241 -31
View File
@@ -112,6 +112,42 @@ var textSecretPrefixes = [][]string{
{"eyJ"},
}
type secretTrigger struct {
literal string
// shape indexes the anchoredSecretPatterns entry the trigger belongs to,
// so a cut can chain the bytes after it against the shape the trigger
// actually starts.
shape int
}
// secretTriggers flattens textSecretPrefixes into one sorted list for the
// swallowed-trigger search below. Sorted so cuts are discovered earliest-first
// regardless of which pattern list they came from.
var secretTriggers = func() []secretTrigger {
pairs := []secretTrigger{}
for shape, prefixes := range textSecretPrefixes {
for _, prefix := range prefixes {
pairs = append(pairs, secretTrigger{literal: prefix, shape: shape})
}
}
sort.Slice(pairs, func(i, j int) bool { return pairs[i].literal < pairs[j].literal })
return pairs
}()
// longestTrigger bounds how far past a swallowed-scored span the trigger
// inspection may need to look: an occurrence whose last bytes run past the
// span end (a greedy body stops at a byte outside its class, and several
// triggers end in one).
var longestTrigger = func() int {
longest := 0
for _, trigger := range secretTriggers {
if len(trigger.literal) > longest {
longest = len(trigger.literal)
}
}
return longest
}()
// anchoredSecretPatterns mirrors textSecretPatterns with the leading \b
// replaced by \A, so each shape can be tested at an exact byte offset. A
// credential that starts exactly where a redacted span ends (two AWS keys
@@ -129,52 +165,226 @@ var anchoredSecretPatterns = func() []*regexp.Regexp {
return out
}()
// redactAdjacent redacts every match of pattern, plus any match of anchored
// that begins exactly where a redacted span ends. This catches credentials
// glued end to end (AKIA...AKIA...) without weakening the leading-boundary
// rule elsewhere: a mid-word occurrence that does not abut a redacted span
// still does not match. Chained runs (three or more glued credentials) are
// followed to the end.
// A credential's body can run into the trigger prefix of the NEXT one when
// both are glued end to end. For fixed-length bodies this cannot happen — the
// `{16}` stops the body exactly where the next AKIA...AKIA... key begins,
// which is the case the anchored end-chaining always covered. An unbounded
// body ({36,} on a github key, {20,} on an API key) swallows whichever trigger
// bytes sit inside its class — "ghp" after a github key is all lowercase
// letters — so the first match ends PAST the next credential's trigger, and a
// scan that re-anchors at the span end finds nothing: the second credential
// is never redacted.
//
// redactedSpan is one region of a redaction: the slice s[start:end] and the
// bytes to write in its place. Chained runs can link shapes together, so a
// span says which shape produced it rather than assuming the caller's pattern
// covered it.
type redactedSpan struct {
start int
end int
// text replaces s[start:end]. Every entry in textSecretPatterns captures
// nothing, so this is the replacement itself unless a custom replacement
// template references a group; anchoring the expansion to the shape that
// matched keeps that working for cross-shape chains.
text string
}
// redactAdjacent redacts every match of pattern, plus every credential glued
// into the runs those matches start. Runs follow three loss-of-boundary
// mechanisms: end-chaining (a credential glued exactly at the previous span's
// end, the AKIA...AKIA... case), all-shape chaining (a glued credential of a
// different shape), and the swallowed-trigger cut (one credential's greedy
// body runs into the next one's trigger prefix, hiding it from any scan that
// re-anchors at a span end). A run ends at the first byte boundary where no
// shape matches; a mid-word lookalike never matches and never starts one.
func redactAdjacent(s string, pattern, anchored *regexp.Regexp, replacement string) string {
spans := pattern.FindAllStringIndex(s, -1)
// Bound the outer loop to the original match count. The inner loop
// appends chained spans to the same slice; re-reading len(spans) would
// re-chain every appended span, growing the span count (and the
// FindStringIndex calls) exponentially on long glued runs.
n := len(spans)
for i := 0; i < n; i++ {
end := spans[i][1]
for end < len(s) {
loc := anchored.FindStringIndex(s[end:])
if loc == nil || loc[0] != 0 || loc[1] <= 0 {
break
}
newEnd := end + loc[1]
spans = append(spans, []int{end, newEnd})
end = newEnd
}
plain := pattern.FindAllStringIndex(s, -1)
if len(plain) == 0 {
return s
}
// Bound the outer loop to the original match count. Every span collects
// its own run; re-reading len(plain) after appends would re-run every
// appended credential and grow the span count (with the anchored scans
// each drags) exponentially on long glued runs.
n := len(plain)
spans := make([]redactedSpan, 0, n+4)
for i := 0; i < n; i++ {
spans = appendCredentialRun(s, plain[i][0], plain[i][1], replacement, spans)
}
return writeRedactedSpans(s, spans)
}
func writeRedactedSpans(s string, spans []redactedSpan) string {
if len(spans) == 0 {
return s
}
sort.Slice(spans, func(i, j int) bool { return spans[i][0] < spans[j][0] })
sort.Slice(spans, func(i, j int) bool { return spans[i].start < spans[j].start })
var out strings.Builder
pos := 0
for _, sp := range spans {
if sp[0] < pos {
if sp.start < pos {
continue // overlapping span, already covered
}
out.WriteString(s[pos:sp[0]])
// s[sp[0]:sp[1]] is a match (anchored spans match at \b when taken
// alone), so ReplaceAllString expands $ references exactly as the
// plain loop below would.
out.WriteString(pattern.ReplaceAllString(s[sp[0]:sp[1]], replacement))
pos = sp[1]
out.WriteString(s[pos:sp.start])
out.WriteString(sp.text)
pos = sp.end
}
out.WriteString(s[pos:])
return out.String()
}
// appendCredentialRun follows the path that begins exactly at start, whose
// first plain match ended at end. Every step finds the shape that matches at
// the current boundary, tries to re-bind that match at a swallowed trigger,
// and otherwise consumes the whole match; the run ends where no shape does.
func appendCredentialRun(s string, start, end int, replacement string, spans []redactedSpan) []redactedSpan {
cur := start
for cur < len(s) {
shape, matchEnd := firstShapeAt(s, cur)
if shape < 0 || matchEnd == cur {
break
}
if cutStart, cut := earliestSwallowedCut(s, cur, matchEnd, shape); cut {
// The bytes before the trigger are the credential that matched;
// from the trigger on the run continues under the next shape.
spans = append(spans, redactedSpan{start: cur, end: cutStart, text: shapeReplacement(shape, s[cur:cutStart], replacement)})
cur = cutStart
continue
}
spans = append(spans, redactedSpan{start: cur, end: matchEnd, text: shapeReplacement(shape, s[cur:matchEnd], replacement)})
cur = matchEnd
}
return spans
}
func shapeReplacement(shape int, slice, replacement string) string {
return anchoredSecretPatterns[shape].ReplaceAllString(slice, replacement)
}
// firstShapeAt returns the first anchored shape that matches exactly at off,
// with the end of its match. Shapes are tried in declaration order, so the
// strict JWT form wins over the loose one; both redact the same bytes, so the
// tie-break only decides which regex expanded the replacement.
func firstShapeAt(s string, off int) (shape int, end int) {
for shape, anchored := range anchoredSecretPatterns {
loc := anchored.FindStringIndex(s[off:])
if loc != nil && loc[0] == 0 && loc[1] > 0 {
return shape, off + loc[1]
}
}
return -1, off
}
// earliestSwallowedCut searches the matched span [start, end) of shape for a
// trigger literal that begins a valid credential right there: the head (the
// bytes before it) still matches the shape as a whole, and the run chained
// from the trigger reaches past the span. The earliest such trigger is the
// cut a boundary at that byte would have produced.
//
// The head requirement and the past-the-end requirement keep the cut honest:
// a trigger spelled mid-body that does not begin a credential leaves the span
// alone, and a cut that stayed inside the original span recovers no bytes —
// it would only split one redaction in two.
//
// The candidate set is the trigger occurrences inside the span, bounded by
// the span plus one trigger length; every occurrence of every trigger in that
// window is a candidate, earliest-first.
func earliestSwallowedCut(s string, start, end, shape int) (int, bool) {
if end-start <= 1 {
return 0, false
}
lookahead := end + longestTrigger
if lookahead > len(s) {
lookahead = len(s)
}
window := s[start+1 : lookahead]
// A trigger occurrence whose bytes begin inside the span may extend past
// its end: the greedy body stops at one byte outside its class, and
// several triggers end in exactly such a byte ("ghp_"'s underscore,
// "sk-ant-" plus a hyphen).
anchored := anchoredSecretPatterns[shape]
examined := 0
for _, q := range triggerOccurrences(window, start+1, end) {
if examined >= maxTriggerOccurrences {
// Occurrences are earliest-first, so the ones past the cap are the
// farthest into the span; a cut among them is missed and the
// greedy span stands, as it did before the cut existed.
break
}
examined++
// The bytes before the trigger must still be a complete match of this
// shape: a trigger spelling inside a body whose head does not
// validate is prose-shaped, not a swallowed credential.
loc := anchored.FindStringIndex(s[start:q])
if loc == nil || loc[0] != 0 || loc[1] != q-start {
continue
}
if runReach(s, q) > end {
return q, true
}
}
return 0, false
}
// maxTriggerOccurrences bounds how many trigger occurrences one span is
// examined for, after sorting. Real glued credentials announce their trigger
// within reach of the span's own start, so a distant lookalike run does not
// get walked to its end; the split path makes the same trade ("at most two
// passes and no more"). A cut found past the cap is missed and the greedy
// span stands, as it did before the cut existed.
const maxTriggerOccurrences = 64
// triggerOccurrences returns every occurrence of every trigger literal that
// begins inside [windowStart, windowEnd), where window is the slice rooted at
// windowStart that the caller bounded so occurrences could reach one trigger
// length past its end. Earliest first, duplicates collapsed.
func triggerOccurrences(window string, windowStart, windowEnd int) []int {
occurrences := make([]int, 0, 4)
for _, trigger := range secretTriggers {
at := 0
for {
found := strings.Index(window[at:], trigger.literal)
if found < 0 {
break
}
q := windowStart + at + found
if q >= windowEnd {
break // later occurrences for this trigger start even later
}
occurrences = append(occurrences, q)
at += found + 1
}
}
sort.Ints(occurrences)
// In-place deduplicate: duplicates come only from triggers shared between
// shapes ("eyJ"), and zero is never an occurrence — the scan starts past it.
unique := occurrences[:0]
for i, q := range occurrences {
if i > 0 && q == occurrences[i-1] {
continue
}
unique = append(unique, q)
}
return unique
}
// runReach reports how far the credential run beginning exactly at off
// extends: each boundary picks the first shape that matches there, until one
// does not. The run is followed, not redacted; this is the shared-size check
// that keeps a cut honest.
func runReach(s string, off int) int {
cur := off
for cur < len(s) {
_, matchEnd := firstShapeAt(s, cur)
if matchEnd == cur {
break
}
cur = matchEnd
}
return cur
}
var (
privateKeyPattern = regexp.MustCompile(`(?s)-----BEGIN [A-Z ]*PRIVATE KEY-----.*?-----END [A-Z ]*PRIVATE KEY-----`)
jsonStringPattern = regexp.MustCompile(`("([^"\\]*(?:\\.[^"\\]*)*)"\s*:\s*)"([^"\\]*(?:\\.[^"\\]*)*)"`)
+171
View File
@@ -0,0 +1,171 @@
package redaction
import (
"strings"
"testing"
)
// A GREEDY BODY SWALLOWS THE NEXT CREDENTIAL'S TRIGGER, SO THE END-CHAIN
// NEVER SEES IT.
//
// The anchored chain re-binds a redacted span at its END; it can only catch a
// credential that begins exactly where the previous one stops. That is true
// for fixed-length bodies (AKIA{16} stops at the next key) and false for
// unbounded ones: github's body class [A-Za-z0-9] happily eats the literal
// "ghp" of the token glued behind it, so the first match ends past the second
// trigger and the plain chain has nothing left to match. Every unbounded shape
// below leaked its second credential before the swallowed-trigger cut.
func TestRedactStringRedactsCredentialsSwallowedByAGluedRun(t *testing.T) {
// Assembled so no secret-shaped literal appears in source.
// gitleaks:allow -- synthetic redaction fixtures below
firstGitHub := "ghp_" + strings.Repeat("a", 36)
secondGitHub := "ghp_" + strings.Repeat("b", 36)
t.Run("two github tokens glued end to end", func(t *testing.T) {
got := RedactString(firstGitHub+secondGitHub, Options{})
if got != RedactedSecret+RedactedSecret {
t.Fatalf("second token survived a glued github pair: got %q", got)
}
})
t.Run("two JWTs glued end to end", func(t *testing.T) {
got := RedactString(jwtToken+otherJWT, Options{})
if got != RedactedSecret+RedactedSecret {
t.Fatalf("second token survived a glued JWT pair: got %q", got)
}
})
t.Run("third github token in the same run", func(t *testing.T) {
thirdGitHub := "ghp_" + strings.Repeat("c", 36)
got := RedactString(firstGitHub+secondGitHub+thirdGitHub, Options{})
if got != strings.Repeat(RedactedSecret, 3) {
t.Fatalf("glued github run left residue between markers: got %q", got)
}
})
}
// THE SWALLOWED TRIGGER CAN BELONG TO A DIFFERENT SHAPE. The chained run from
// a cut must try every shape at each boundary, not only the one that matched
// the swallowed span.
//
// Two outcomes are both fully redacted and the tests accept either. A github
// body stops at the hyphen an anthropic trigger carries, so the cut recovers
// the boundary and each key becomes its own marker. An anthropic body class
// spans [A-Za-z0-9_-], wide enough to cover the next trigger and the
// credential behind it whole, so the single match already consumed them and
// the requirement is only that nothing of either key survives.
func TestRedactStringRedactsACrossShapedCredentialGluedOntoARun(t *testing.T) {
// gitleaks:allow -- synthetic redaction fixtures below
github := "ghp_" + strings.Repeat("a", 36)
anthropic := "sk-ant-" + strings.Repeat("b", 20)
secondAnthropic := "sk-ant-" + strings.Repeat("d", 20)
// keptOut fails when any trigger or credential-shaped residue survives the
// redaction, the one property a glued run can never relax.
keptOut := func(t *testing.T, got string, pieces ...string) {
t.Helper()
for _, mark := range []string{"sk-ant-", "ghp_"} {
if strings.Contains(got, mark) {
t.Fatalf("a trigger survived a glued run: %q in %q", mark, got)
}
}
for _, piece := range pieces {
if strings.Contains(got, piece) {
t.Fatalf("a credential survived a glued run: parts of %q… in %q", piece[:8], got)
}
}
}
t.Run("anthropic glued behind github", func(t *testing.T) {
value := github + anthropic
got := RedactString(value, Options{})
if got != RedactedSecret+RedactedSecret {
t.Fatalf("anthropic key survived behind a github key: got %q", got)
}
keptOut(t, got, github, anthropic)
})
t.Run("anthropic glued behind anthropic, then github", func(t *testing.T) {
value := anthropic + secondAnthropic + github
got := RedactString(value, Options{})
if got == value {
t.Fatal("SETUP INVALID: the glued run is not redacted at all")
}
keptOut(t, got, anthropic, secondAnthropic, github)
})
t.Run("github glued behind anthropic", func(t *testing.T) {
value := anthropic + github
got := RedactString(value, Options{})
if got == value {
t.Fatal("SETUP INVALID: the glued run is not redacted at all")
}
keptOut(t, got, anthropic, github)
})
}
// THE CUT MUST NOT WEAKEN WHAT ALREADY WORKED.
//
// A trigger spelled inside a matched span that starts no credential there
// leaves the span whole: the bytes before it do not re-validate as their
// shape, and the run chained from it stays inside the span. The end-chaining
// for fixed-length bodies and every single-credential behavior the suite
// already pins must be untouched.
func TestRedactStringSwallowedTriggerCutKeepsExistingBehavior(t *testing.T) {
// gitleaks:allow -- synthetic redaction fixtures below
github := "ghp_" + strings.Repeat("a", 36)
t.Run("two fixed-length AWS keys still chain at the end", func(t *testing.T) {
got := RedactString("AKIAIOSFODNN7EXAMPLE"+"ASIAIOSFODNN7EXAMPLE", Options{})
if got != RedactedSecret+RedactedSecret {
t.Fatalf("glued AWS keys regressed: got %q", got)
}
})
t.Run("an interior trigger spelling starts no credential of its own", func(t *testing.T) {
// A JWT payload can contain "eyJ" away from a segment start. The strict
// form needs the segment to open with it and the loose form needs the
// three-segment skeleton, so a payload fragment is prose to both and the
// token stays one marker.
token := "eyJhbGciOiJIUzI1NiJ9." + "aaaaeyJaaaaaa" + ".QWxpY2VCb2JDYXJvbA"
if got := RedactString(token, Options{}); got != RedactedSecret {
t.Fatalf("a JWT with an interior trigger spelling changed shape: got %q", got)
}
})
t.Run("a single token is one marker, not two", func(t *testing.T) {
got := RedactString(github, Options{})
if got != RedactedSecret {
t.Fatalf("a lone token changed shape: got %q", got)
}
})
}
// A SPAM OF TRIGGER SPELLINGS INSIDE ONE BODY MUST COST WHAT THE SPAN COSTS.
//
// Every trigger occurrence inside a matched span is a cut candidate, but only
// the first maxTriggerOccurrences are examined, so the scan stays linear in
// the span: a body stuffed with lookalikes must not walk the span once per
// candidate. Measured as allocation against the same bar the split path's
// linearity test uses, the one an index-per-byte or a walk-per-credential
// explosion offends.
func TestRedactStringTriggerLookalikeSpamStaysBounded(t *testing.T) {
// No digit appears anywhere in the fixture, so the openai shape's filter
// rejects it as kebab-case prose and the anthropic shape still matches it
// whole: every "sk-ant-" inside the span is a cut candidate, and none
// validates because the run chained from any of them reaches only to the
// span's own end.
// gitleaks:allow -- synthetic redaction fixture below
value := "sk-ant-" + strings.Repeat("sk-ant-", (64<<10)/len("sk-ant-"))
if got := RedactString(value, Options{}); got != RedactedSecret {
t.Fatalf("SETUP INVALID: the lookalike span was not redacted whole: got %q", got)
}
// Sixty-four candidates, each re-checked against the span, cost a small
// constant multiple of the span. A walk over every occurrence quadruples
// the cost when the span doubles, and the split path measured that walk
// at seventeen gigabytes on a one-megabyte chain.
limit := int64(200 * len(value))
if used := redactAllocBytes(value); used > limit {
t.Fatalf("redacting a %d byte lookalike span allocated %d bytes, over the %d byte limit", len(value), used, limit)
}
}
@@ -0,0 +1,37 @@
package sandbox
import "testing"
// THE CHANNEL IS PINNED WITHOUT A PRODUCER.
//
// Nothing in the tree fills CommandPlan.Notes today: the Windows denyRead trade
// notice was the last producer, and since #1006 refuses denyRead outright there
// is no trade to describe. The line in EnforcementFor that carries plan notes
// into Enforcement.Notices is still what hooks, plugins and MCP read, and the
// end-to-end test that used to reach it through the real producer went with the
// producer. So it is driven from a fixture plan instead: deleting that one line
// fails here, and nowhere else.
func TestEnforcementForCarriesThePlanNoticesToTheGenericContract(t *testing.T) {
plan := CommandPlan{Notes: []string{
"first fixed sentence from the sandbox",
"second fixed sentence from the sandbox",
}}
notices := EnforcementFor(plan).Notices
if len(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(notices), len(plan.Notes))
}
for index, note := range plan.Notes {
if notices[index] != note {
t.Errorf("notice %d = %q, want the plan note %q in the same position", index, notices[index], note)
}
}
}
// And a plan with nothing to say produces no notices, so a consumer cannot be
// handed an empty string it would then render as a card.
func TestEnforcementForCarriesNoNoticesFromASilentPlan(t *testing.T) {
if notices := EnforcementFor(CommandPlan{}).Notices; len(notices) != 0 {
t.Fatalf("EnforcementFor produced notices from a plan with none: %q", notices)
}
}
+43 -9
View File
@@ -70,6 +70,20 @@ 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
}
// 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
@@ -131,15 +145,12 @@ 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,
// Only for adapters that publish the fact; see CommandPlan.childLaunchReported.
ChildLaunchOwnedByAdapter: plan.childLaunchReported,
}, nil
}
@@ -1196,3 +1207,26 @@ 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...),
}
}
@@ -0,0 +1,71 @@
//go:build windows
package sandbox
import (
"unsafe"
"golang.org/x/sys/windows"
)
// THE SUSPENDED CHILD NEEDS AN OWNER THAT OUTLIVES A KILLED HELPER.
//
// The helper publishes the launch fact and only then resumes, which is what
// makes "no report" mean "nothing ran" rather than a race (see
// publishThenResume). Creating the child suspended is what buys that, and it
// also creates a state nothing else in the helper covers: between
// CreateProcessAsUser and ResumeThread the child exists, holds the inherited
// stdin/stdout/stderr handles, and cannot execute a single instruction.
//
// Hook and plugin commands reach this through Engine.CommandContext, which uses
// the default exec.CommandContext cancellation: on timeout or cancel the helper
// is killed outright. It cannot run its deferred cleanup, cannot reach
// terminateSuspendedWindowsChild, and cannot resume. Windows does not terminate
// a child because its parent died, so the suspended process survives holding the
// pipe write ends, and a parent still reading them waits on a process that will
// never write and never exit.
//
// A job object with kill-on-close is the only mechanism that survives the
// helper being terminated rather than unwound: the kernel enforces it. The job
// is created and the helper joins it BEFORE any child exists, so every child
// created afterwards inherits membership at creation and there is no window in
// which a child is alive and unowned. TerminateProcess on the helper closes the
// last handle, and the kernel kills the job.
//
// Deliberately not solved with exec.Cmd.WaitDelay in the parent: that releases
// the caller while the orphan keeps running and keeps the pipes, which trades a
// visible hang for an invisible leak. It is a reasonable backstop, not the fix.
//
// Reported by @jatmn.
// windowsJoinChildKillJob is the seam the command runner calls. Production value
// only; a test substitutes it to prove the runner takes ownership on EVERY path,
// including the ones that refuse the command a few lines later.
var windowsJoinChildKillJob = joinWindowsChildKillJob
func joinWindowsChildKillJob() (windows.Handle, error) {
job, err := windows.CreateJobObject(nil, nil)
if err != nil {
return 0, err
}
limits := windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION{
BasicLimitInformation: windows.JOBOBJECT_BASIC_LIMIT_INFORMATION{
LimitFlags: windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
},
}
if _, err := windows.SetInformationJobObject(
job,
windows.JobObjectExtendedLimitInformation,
uintptr(unsafe.Pointer(&limits)),
uint32(unsafe.Sizeof(limits)),
); err != nil {
_ = windows.CloseHandle(job)
return 0, err
}
// CurrentProcess() is a pseudo-handle, which AssignProcessToJobObject
// accepts; the membership it creates is what descendants inherit.
if err := windows.AssignProcessToJobObject(job, windows.CurrentProcess()); err != nil {
_ = windows.CloseHandle(job)
return 0, err
}
return job, nil
}
@@ -0,0 +1,427 @@
//go:build windows
package sandbox
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"golang.org/x/sys/windows"
"github.com/Twigpine/zero/internal/execution"
)
// THE OWNERSHIP HAS TO BE TESTED FROM OUTSIDE THE PROCESS THAT OWNS IT.
//
// joinWindowsChildKillJob puts the CALLING process into a job with
// kill-on-close, which is the whole point: when the caller is terminated its
// handle closes and the kernel takes the children down with it. A test that
// calls it in-process and then closes the handle therefore terminates the test
// binary. That is exactly what the first version of this file did: `go test`
// printed "ok", exited 0, and the assertions after the close were never
// reached. Worse, the package's remaining tests never ran either, because the
// binary was gone, and nothing anywhere said so.
//
// So everything below drives a SEPARATE process: the test binary re-executed as
// the helper below. It calls the production joinWindowsChildKillJob, creates a
// suspended child the way the sandbox helper does, and parks. The test is then
// free to kill it and ask what happened, which is the question.
const (
suspendedHelperModeEnv = "ZERO_TEST_SUSPENDED_CHILD_MODE"
suspendedHelperPIDFileEnv = "ZERO_TEST_SUSPENDED_CHILD_PIDFILE"
// suspendedHelperModeJob is the production shape: own the children before
// creating any. suspendedHelperModeNoJob is the same helper WITHOUT that
// ownership, which is what this code did before the fix.
suspendedHelperModeJob = "job"
suspendedHelperModeNoJob = "nojob"
// suspendedHelperModeRunner runs the production command runner itself and
// exits with what it returned.
suspendedHelperModeRunner = "runner"
)
// TestWindowsSuspendedChildHelperProcess is not a test. It is the stand-in for
// the sandbox command helper, re-executed from the test binary, parked in the
// one window this contract is about: after CreateProcessAsUser has returned a
// suspended child and before ResumeThread.
//
// It stands in for the helper rather than being it, because the real one mints a
// restricted token against an elevated setup marker. What it does NOT stand in
// for is the ownership: it calls the production joinWindowsChildKillJob, in the
// production order, before any child can exist.
//
// The child inherits this process's stdout, which is the pipe the captured
// runner is reading. That is what makes a surviving child a hang and not just a
// leak: the reader waits for an EOF that a process which can never execute will
// never produce.
func TestWindowsSuspendedChildHelperProcess(t *testing.T) {
mode := os.Getenv(suspendedHelperModeEnv)
if mode == "" {
return
}
fail := func(code int, format string, args ...any) {
fmt.Fprintf(os.Stderr, "suspended-child helper: "+format+"\n", args...)
os.Exit(code)
}
if mode == suspendedHelperModeJob {
// BEFORE ANY CHILD EXISTS, exactly where runWindowsSandboxCommand does
// it, so there is no interval in which one is alive and unowned. The
// handle is deliberately never closed: it is released when this process
// is terminated, which is the event the guarantee is about.
if _, err := joinWindowsChildKillJob(); err != nil {
fail(3, "join the kill job: %v", err)
}
}
if mode == suspendedHelperModeRunner {
// The production entry point, refused at its own earliest return so the
// exit code below is the one runWindowsSandboxCommand chose and nothing
// else has run. An unsupported level is the cheapest such refusal.
os.Exit(runWindowsSandboxCommand(WindowsSandboxCommandConfig{SandboxLevel: "not-a-level"}, os.Stderr))
}
child := exec.Command(comspec(), "/c", "ver")
child.Stdout = os.Stdout
child.Stderr = os.Stderr
child.SysProcAttr = &windows.SysProcAttr{CreationFlags: windows.CREATE_SUSPENDED}
if err := child.Start(); err != nil {
fail(4, "start the suspended child: %v", err)
}
if err := publishHelperChildPID(os.Getenv(suspendedHelperPIDFileEnv), child.Process.Pid); err != nil {
_ = child.Process.Kill()
fail(5, "publish the child pid: %v", err)
}
// Parked. Production reaches ResumeThread a few instructions later; this
// process never does, so it holds the window open for as long as the test
// needs. Nothing below ever runs: the test kills this process.
time.Sleep(10 * time.Minute)
fail(6, "the test never terminated this helper")
}
func comspec() string {
if shell := strings.TrimSpace(os.Getenv("COMSPEC")); shell != "" {
return shell
}
return "cmd.exe"
}
// publishHelperChildPID writes the pid through a rename, so the test never
// reads a half-written file and never mistakes "not created yet" for "created".
func publishHelperChildPID(path string, pid int) error {
if strings.TrimSpace(path) == "" {
return fmt.Errorf("no pid file was requested")
}
temp := path + ".tmp"
if err := os.WriteFile(temp, []byte(strconv.Itoa(pid)), 0o600); err != nil {
return err
}
return os.Rename(temp, path)
}
// suspendedChildRun is one captured run of the helper, parked with its child
// created.
type suspendedChildRun struct {
childPID uint32
done chan struct{}
result execution.CapturedResult
cancel context.CancelFunc
}
// startParkedSuspendedChildRun runs the helper through the REAL captured
// execution path, the one hook and plugin commands use: execution.Runner over
// the sandbox Engine, whose CommandContext builds an exec.CommandContext. It
// returns once the helper has created its child, which is the barrier.
//
// The plan is deliberately a direct command. The wrapper is not what this is
// about: the hang lives in the captured runner, which sets Stdout and Stderr to
// buffers, so Wait cannot finish until both pipes reach EOF, and sets no
// WaitDelay.
func startParkedSuspendedChildRun(t *testing.T, ctx context.Context, cancel context.CancelFunc, mode string) *suspendedChildRun {
t.Helper()
workspace := t.TempDir()
pidFile := filepath.Join(t.TempDir(), "child.pid")
engine := NewEngine(EngineOptions{
WorkspaceRoot: workspace,
Policy: DefaultPolicy(),
Backend: Backend{Name: BackendUnavailable, Message: "test backend unavailable"},
})
runner := execution.NewRunner(engine)
run := &suspendedChildRun{done: make(chan struct{}), cancel: cancel}
go func() {
defer close(run.done)
run.result = runner.ExecuteCaptured(ctx, execution.CapturedRequest{Request: execution.Request{
Origin: execution.OriginHook,
Mode: execution.ModeCaptured,
Command: execution.Command{
Name: os.Args[0],
Args: []string{"-test.run=TestWindowsSuspendedChildHelperProcess"},
Env: append(os.Environ(),
suspendedHelperModeEnv+"="+mode,
suspendedHelperPIDFileEnv+"="+pidFile,
),
},
WorkingDirectory: workspace,
WorkspaceRoots: []string{workspace},
Approval: execution.ApprovalContext{PolicyVersion: execution.PolicyVersion},
}})
}()
run.childPID = awaitHelperChildPID(t, run, pidFile)
// INDEPENDENT OF EVERY ASSERTION BELOW. A failure after this point must not
// leave a suspended process on the machine, and a suspended process cannot
// exit by itself.
t.Cleanup(func() {
cancel()
terminateTestProcess(run.childPID)
select {
case <-run.done:
case <-time.After(30 * time.Second):
t.Errorf("the captured call never returned, even after the child was killed")
}
})
if alive, err := windowsProcessAlive(run.childPID); err != nil || !alive {
t.Fatalf("SETUP INVALID: the helper's child is not alive at the barrier: alive=%v err=%v", alive, err)
}
return run
}
// awaitHelperChildPID waits for the helper to say it has created its child. It
// fails rather than timing out silently, and it gives up early if the helper
// died, so a broken helper reads as a broken helper.
func awaitHelperChildPID(t *testing.T, run *suspendedChildRun, pidFile string) uint32 {
t.Helper()
deadline := time.Now().Add(90 * time.Second)
for {
if data, err := os.ReadFile(pidFile); err == nil {
pid, convErr := strconv.Atoi(strings.TrimSpace(string(data)))
if convErr == nil && pid > 0 {
return uint32(pid)
}
}
select {
case <-run.done:
t.Fatalf("SETUP INVALID: the helper exited before creating its child: %#v\nstderr: %s", run.result.Outcome, run.result.Stderr)
default:
}
if time.Now().After(deadline) {
t.Fatal("SETUP INVALID: the helper never reported a child pid")
}
time.Sleep(20 * time.Millisecond)
}
}
func terminateTestProcess(pid uint32) {
handle, err := windows.OpenProcess(windows.PROCESS_TERMINATE, false, pid)
if err != nil {
return
}
defer func() { _ = windows.CloseHandle(handle) }()
_ = windows.TerminateProcess(handle, 1)
}
func awaitProcessGone(t *testing.T, pid uint32, within time.Duration) bool {
t.Helper()
deadline := time.Now().Add(within)
for {
alive, err := windowsProcessAlive(pid)
if err != nil {
t.Fatalf("query the child: %v", err)
}
if !alive {
return true
}
if time.Now().After(deadline) {
return false
}
time.Sleep(20 * time.Millisecond)
}
}
// CANCELLATION IN THE CREATE-TO-RESUME WINDOW MUST NOT ORPHAN THE CHILD, AND
// MUST NOT HANG THE CALLER.
//
// Hook and plugin commands reach the sandbox through Engine.CommandContext,
// which uses the default exec.CommandContext cancellation: on a timeout or a
// cancel the helper is killed outright, so it never reaches its own cleanup,
// never reaches terminateSuspendedWindowsChild, and never resumes. Windows does
// not terminate a child because its parent died. The suspended child then
// survives holding the inherited pipe write ends, and ExecuteCaptured, which is
// copying from those pipes with no WaitDelay, waits for an EOF that a process
// which cannot execute will never send.
//
// Both halves are asserted, because either one alone is the wrong fix: a pipe
// deadline would release the caller and keep the orphan, and killing the child
// without the caller returning would still hang the hook.
func TestCancellingACapturedRunTakesDownTheSuspendedChild(t *testing.T) {
for _, tc := range []struct {
name string
deadline bool
}{
{"parent cancellation", false},
{"deadline expiry", true},
} {
t.Run(tc.name, func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
if tc.deadline {
ctx, cancel = context.WithTimeout(context.Background(), 3*time.Second)
}
run := startParkedSuspendedChildRun(t, ctx, cancel, suspendedHelperModeJob)
if !tc.deadline {
cancel()
}
select {
case <-run.done:
case <-time.After(60 * time.Second):
t.Fatal("the captured call did not return after cancellation; the hook or plugin that made it is blocked on a pipe the orphaned child still holds")
}
if !awaitProcessGone(t, run.childPID, 30*time.Second) {
t.Fatal("the suspended child outlived the cancelled helper, so it is an orphan holding the inherited pipes")
}
if state := run.result.Outcome.State; state != execution.StateCancelled && state != execution.StateFailed {
t.Errorf("outcome state = %q, want the run reported as cancelled or failed", state)
}
})
}
}
// THE CONTROL, and the reason the job is the fix rather than a nicety.
//
// The same helper WITHOUT the ownership step is what this code did before: the
// child survives its terminated parent, and the captured call stays blocked on
// the pipes it holds. Asserting that shape keeps the test above from passing for
// some unrelated reason, such as the child exiting on its own.
//
// It cleans up after itself by killing the orphan, which is also what releases
// the blocked call.
func TestWithoutTheKillJobACancelledHelperLeavesAnOrphan(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
run := startParkedSuspendedChildRun(t, ctx, cancel, suspendedHelperModeNoJob)
cancel()
if awaitProcessGone(t, run.childPID, 5*time.Second) {
t.Fatal("SETUP INVALID: the child died without the kill job, so this control proves nothing about the job")
}
select {
case <-run.done:
t.Error("SETUP INVALID: the captured call returned while the orphan still held the pipes, so the hang this pins is not reproduced here")
case <-time.After(3 * time.Second):
}
// The cleanup registered by startParkedSuspendedChildRun kills the orphan,
// which closes the pipes and lets the captured call return.
}
// A SUCCESSFUL RUN IS STILL A SUCCESSFUL RUN. The job is joined on every command,
// so an ordinary one has to come back with its output and its exit code intact.
func TestJoiningTheKillJobLeavesAnOrdinaryRunAlone(t *testing.T) {
workspace := t.TempDir()
engine := NewEngine(EngineOptions{
WorkspaceRoot: workspace,
Policy: DefaultPolicy(),
Backend: Backend{Name: BackendUnavailable, Message: "test backend unavailable"},
})
runner := execution.NewRunner(engine)
result := runner.ExecuteCaptured(context.Background(), execution.CapturedRequest{Request: execution.Request{
Origin: execution.OriginHook,
Mode: execution.ModeCaptured,
Command: execution.Command{Name: comspec(), Args: []string{"/c", "echo", "still-here"}},
WorkingDirectory: workspace,
WorkspaceRoots: []string{workspace},
Approval: execution.ApprovalContext{PolicyVersion: execution.PolicyVersion},
}})
if result.Outcome.Kind != execution.OutcomeSuccess {
t.Fatalf("outcome = %#v, stderr=%q", result.Outcome, result.Stderr)
}
if !strings.Contains(result.Stdout, "still-here") {
t.Errorf("stdout = %q, want the command's output", result.Stdout)
}
}
// windowsProcessAlive reports whether pid still exists. A suspended process is
// alive, so WaitForSingleObject returning WAIT_TIMEOUT is the positive answer.
func windowsProcessAlive(pid uint32) (bool, error) {
handle, err := windows.OpenProcess(windows.SYNCHRONIZE|windows.PROCESS_QUERY_LIMITED_INFORMATION, false, pid)
if err != nil {
// The process is gone, which is what the caller is asking about.
return false, nil
}
defer func() { _ = windows.CloseHandle(handle) }()
state, err := windows.WaitForSingleObject(handle, 0)
if err != nil {
return false, err
}
return state == uint32(windows.WAIT_TIMEOUT), nil
}
// TAKING OWNERSHIP MUST NOT COST THE EXIT CODE.
//
// The helper is a MEMBER of the job it creates, so closing the last handle to
// that job terminates the helper itself. The first version of this fix closed
// it in a defer, which fired during runWindowsSandboxCommand's own return: the
// process was killed before its exit code could be handed back, and every
// sandboxed command reported 0 no matter how it really ended, including the
// refusals a few lines below the join.
//
// This runs the production entry point in a separate process and asks for the
// code it returned. The refusal it drives returns 1, so a 0 here is the bug
// coming back rather than a command that happened to succeed.
func TestOwningTheChildrenKeepsTheCommandRunnersExitCode(t *testing.T) {
command := exec.Command(os.Args[0], "-test.run=TestWindowsSuspendedChildHelperProcess")
command.Env = append(os.Environ(), suspendedHelperModeEnv+"="+suspendedHelperModeRunner)
output, err := command.CombinedOutput()
code := 0
if exitErr, ok := err.(*exec.ExitError); ok {
code = exitErr.ExitCode()
} else if err != nil {
t.Fatalf("run the command runner: %v\n%s", err, output)
}
if code != 1 {
t.Fatalf("the command runner exited %d, want the 1 it returned; the job handle is being closed on the way out and killing this process before its exit code survives\n%s", code, output)
}
if !strings.Contains(string(output), "unsupported Windows sandbox level") {
t.Errorf("the runner did not reach its refusal, so the exit code above is not the one under test:\n%s", output)
}
}
// AND OWNERSHIP IS TAKEN ON EVERY PATH, including the ones that refuse the
// command before any child could exist. The point of joining first is that
// there is no interval in which a child is alive and unowned, and a join that
// moved below one of these returns would leave exactly that interval on the
// paths that do go on to launch.
func TestTheCommandRunnerTakesOwnershipBeforeItRefuses(t *testing.T) {
joined := 0
previous := windowsJoinChildKillJob
t.Cleanup(func() { windowsJoinChildKillJob = previous })
windowsJoinChildKillJob = func() (windows.Handle, error) {
joined++
return 0, fmt.Errorf("declined for the test")
}
// The EARLIEST refusal in the function, so a join that slipped below any of
// the checks would be measured as not having happened. A DenyRead profile on
// a restricted-token tier is refused before the level switch, before setup
// validation, and long before a token or a child exists.
var stderr strings.Builder
config := WindowsSandboxCommandConfig{
SandboxLevel: WindowsSandboxLevelRestrictedToken,
PermissionProfile: PermissionProfile{FileSystem: FileSystemPolicy{DenyRead: []string{`C:\secrets`}}},
}
if code := runWindowsSandboxCommand(config, &stderr); code != 1 {
t.Fatalf("exit code = %d, want 1 (%s)", code, stderr.String())
}
if !strings.Contains(stderr.String(), "DenyRead is not supported") {
t.Fatalf("SETUP INVALID: the runner did not stop at its earliest refusal, so a later join would still count: %s", stderr.String())
}
if joined != 1 {
t.Fatalf("the runner took ownership %d times before its earliest refusal, want exactly once", joined)
}
}
@@ -8,6 +8,20 @@ import (
)
func runWindowsSandboxCommand(config WindowsSandboxCommandConfig, stderr io.Writer) int {
// OWN THE CHILDREN BEFORE ANY CHILD CAN EXIST, so one created later inherits
// job membership at creation and a forcibly terminated helper cannot leave a
// suspended one behind. A failure here is not fatal: it costs the
// kill-on-close guarantee, not the sandbox, and the ordinary unwind paths
// still run.
//
// THE HANDLE IS NEVER CLOSED, and that is the contract rather than a leak.
// Closing it IS the kill: this process is a member of the job, so releasing
// the last handle terminates it where it stands. A deferred close therefore
// killed this helper during its own return, before the exit code below could
// be handed back, and every sandboxed command reported 0 however it really
// ended. Process exit releases the handle, which is exactly the event the
// guarantee is built on.
_, _ = windowsJoinChildKillJob()
// Fully restricted DenyRead tokens cannot load ordinary Users-granted
// system binaries without SID broadening; broadening is permanently off
// because it admits write grants outside WriteRoots. Reject on both the
@@ -98,6 +112,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
@@ -120,9 +139,16 @@ 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
// 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)
}
@@ -0,0 +1,114 @@
//go:build windows
package sandbox
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"testing"
"github.com/Twigpine/zero/internal/execution"
)
// A REPORT THAT SAYS A CHILD LAUNCHED HAS TO MEAN THE CHILD COULD RUN.
//
// The report is published before ResumeThread so the inherited-pipe race is
// closed, and that ordering is right. But a failure between the publish and the
// resume reaps a process that executed nothing, and the report was left on disk
// saying true. AppliedEnforcementNotices gates on that report, so the operator
// would have been told a write-jail trade applied to a child that never became
// runnable. The record has to be unwound on that path, not only on the ones
// before it was written.
// AND IT HAS TO SAY SO, RATHER THAN SAY NOTHING. Removing the file leaves the
// parent reading absence, which is also what a normal cleanup leaves, so a
// reader that saw the publication during the window keeps its positive through
// completion. The retraction states the negative instead.
func TestResumeFailureRetractsTheLaunchReport(t *testing.T) {
path := filepath.Join(t.TempDir(), "report.json")
report, err := openWindowsExecutionReport(path)
if err != nil {
t.Fatal(err)
}
keep, err := publishThenResume(report, func() error { return errors.New("STATUS_ACCESS_DENIED") })
if err == nil {
t.Fatal("SETUP INVALID: the injected resume failure was not reported")
}
if !keep {
t.Fatal("publishThenResume discarded the report after its resume failed, so the parent reads absence and a live reader keeps the launch it already saw")
}
report.close(keep)
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("the retracted report is not readable: %v", err)
}
var decoded execution.AdapterReport
if err := json.Unmarshal(data, &decoded); err != nil {
t.Fatalf("decode report %q: %v", data, err)
}
if decoded.ChildLaunched == nil {
t.Fatalf("report = %s, want an explicit childLaunched false; silence does not revoke a launch a live reader already latched", data)
}
if *decoded.ChildLaunched {
t.Fatalf("report = %s, want childLaunched false for a child that executed no instruction", data)
}
}
// A retraction that cannot be written falls back to discarding the file, which
// is where this path was before: absence is weaker than an explicit false, and
// still better than a report left saying true.
func TestAnUnwritableRetractionDiscardsTheReport(t *testing.T) {
path := filepath.Join(t.TempDir(), "report.json")
report, err := openWindowsExecutionReport(path)
if err != nil {
t.Fatal(err)
}
keep, err := publishThenResume(report, func() error {
// Close the handle underneath the retraction, so its write fails the
// way a broken report file would.
_ = report.file.Close()
return errors.New("STATUS_ACCESS_DENIED")
})
if err == nil {
t.Fatal("SETUP INVALID: the injected resume failure was not reported")
}
if keep {
t.Fatal("publishThenResume kept a report it could not retract, so the file still says a child launched")
}
report.close(keep)
if _, statErr := os.Stat(path); !os.IsNotExist(statErr) {
t.Fatalf("a report that could not be retracted survived (stat: %v); the parent would read a child as launched that executed no instruction", statErr)
}
}
// And the ordinary path still publishes exactly what the parent relies on.
func TestSuccessfulResumeKeepsTheLaunchReport(t *testing.T) {
path := filepath.Join(t.TempDir(), "report.json")
report, err := openWindowsExecutionReport(path)
if err != nil {
t.Fatal(err)
}
keep, err := publishThenResume(report, func() error { return nil })
if err != nil || !keep {
t.Fatalf("publishThenResume = (%v, %v), want (true, nil)", keep, err)
}
report.close(keep)
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("the launch report is gone after a successful resume: %v", err)
}
var decoded execution.AdapterReport
if err := json.Unmarshal(data, &decoded); err != nil {
t.Fatalf("decode report: %v", err)
}
if decoded.ChildLaunched == nil || !*decoded.ChildLaunched {
t.Fatalf("report = %s, want childLaunched true", data)
}
}
@@ -0,0 +1,166 @@
//go:build windows
package sandbox
import (
"encoding/json"
"fmt"
"io"
"os"
"strings"
"github.com/Twigpine/zero/internal/execution"
"golang.org/x/sys/windows"
)
// windowsExecutionReport is the helper's side channel back to the parent.
//
// 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.
//
// 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(trimmed, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
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
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. keep is false when nothing worth reading was
// written, and the file is discarded then, so a truncated or empty report can
// never be read back as a launch that happened. A retracted report is worth
// reading: an explicit false is the only answer that outranks a launch a live
// reader has already seen.
func (report *windowsExecutionReport) close(keep bool) {
if report == nil || report.file == nil {
return
}
closeErr := report.file.Close()
if !keep || closeErr != nil {
_ = os.Remove(report.path)
}
report.file = nil
}
// retract replaces a published launch with an explicit denial of it.
//
// REMOVING THE FILE IS NOT ENOUGH, BECAUSE ABSENCE ALREADY MEANS SOMETHING
// ELSE. The parent reads this report while the command is still running and
// latches a launch it sees, precisely because the file is expected to be gone
// by the time the command finishes: a normal cleanup removes it, and the
// manager restores the launch it observed rather than reading that absence as a
// child that never started. So a reader that looked during the window between
// publish and resume keeps its positive no matter what is deleted afterwards.
// An explicit false is the one answer that outranks it.
func (report *windowsExecutionReport) retract() error {
if report == nil || report.file == nil {
return nil
}
if err := report.file.Truncate(0); err != nil {
return fmt.Errorf("retract sandbox execution report: %w", err)
}
if _, err := report.file.Seek(0, io.SeekStart); err != nil {
return fmt.Errorf("retract sandbox execution report: %w", err)
}
launched := false
if err := json.NewEncoder(report.file).Encode(execution.AdapterReport{ChildLaunched: &launched}); err != nil {
return fmt.Errorf("retract sandbox execution report: %w", err)
}
return nil
}
// publishThenResume records the launch and only then lets the child run, and
// unwinds the record if the child cannot run after all.
//
// The report says one thing to the parent: a sandboxed child could execute, and
// enforcement was in force for it. That is what AppliedEnforcementNotices gates
// on, and what ResolveChildLaunched treats as authoritative. It is deliberately
// NOT "CreateProcessAsUser returned a handle": a suspended process that is reaped
// before ResumeThread has executed no instruction and applied nothing, and a
// report saying otherwise would disclose a write-jail trade nobody made.
//
// Publish before resume stays, because it closes the inherited-pipe race the
// caller documents. What this adds is the other half: a resume failure retracts
// the record, so the report stops saying true about a child that never ran.
//
// RETRACTED, NOT DELETED. The publication is readable for as long as the window
// between it and the resume lasts, and the parent reads it live: a poll landing
// in that window latches a launch, and that latch outlives the file, because a
// normal cleanup deletes the report too and the parent must not read that
// deletion as a child that never started. Deleting on this path would therefore
// leave the latched positive standing in both the live and the final result. An
// explicit false is the one answer that revokes it, so the returned flag keeps
// the file when the retraction was written. If it could not be written the file
// is discarded after all, which is no worse than before.
func publishThenResume(report *windowsExecutionReport, resume func() error) (keep bool, err error) {
if err := report.publish(true); err != nil {
return false, fmt.Errorf("record sandboxed child launch: %w", err)
}
if resumeErr := resume(); resumeErr != nil {
if retractErr := report.retract(); retractErr != nil {
return false, fmt.Errorf("resume sandboxed process: %w", resumeErr)
}
return true, fmt.Errorf("resume sandboxed process: %w", resumeErr)
}
return true, nil
}
// terminateSuspendedWindowsChild takes down a child that was created suspended
// and never resumed, and waits for it to actually leave.
//
// 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.
//
// Two of those paths differ in what the report holds. Before publish, nothing was
// written and "no child launched" is simply what happened. After publish but
// before resume, a report saying true is on disk about a child that never ran;
// publishThenResume returns false there so the caller's deferred close removes
// it. Either way the parent reads absence, and returning an error here is honest.
func terminateSuspendedWindowsChild(process windows.Handle) {
if process == 0 {
return
}
// 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)
}
+44 -1
View File
@@ -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,
@@ -63,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,
@@ -73,6 +83,39 @@ 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.
//
// 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.
//
// AND THE RECORD HAS TO MEAN THE SAME THING ON THE WAY BACK OUT. The report
// is published before the resume, so a failure between the two leaves a report
// saying a child launched about a process that is being reaped without ever
// having run. publishThenResume unwinds the record on that path, which is
// what keeps "no child launched" true for the parent on every failure before
// the child could execute, not just the ones before the write.
published, err = publishThenResume(report, func() error {
_, err := windows.ResumeThread(process.Thread)
return err
})
if err != nil {
terminateSuspendedWindowsChild(process.Process)
return 1, err
}
if _, err := windows.WaitForSingleObject(process.Process, windows.INFINITE); err != nil {
return 1, fmt.Errorf("wait for sandboxed process: %w", err)
}
+77 -33
View File
@@ -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 {
@@ -341,14 +359,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
@@ -359,21 +386,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 {
@@ -0,0 +1,187 @@
//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); 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 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") {
// 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")
}
// 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 {
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 sid
}
@@ -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")
}
}
+94 -13
View File
@@ -15,6 +15,7 @@ import (
"io"
"os"
"path/filepath"
"runtime"
"sync/atomic"
"time"
)
@@ -30,13 +31,35 @@ const (
var (
openSecretLockFile = os.OpenFile
syncSecretFileFn = (*os.File).Sync
syncSecretDirFn = syncSecretDir
secureLockSeq atomic.Uint64
)
// syncSecretDir fsyncs the directory that now holds the published secret.
// Windows cannot sync directory handles, so the rename is best-effort durable
// there. A directory the user can write but not read cannot be opened for
// syncing either; that is also best effort, because the write it follows
// already succeeded. Every other failure is reported.
func syncSecretDir(dir string) error {
if runtime.GOOS == "windows" {
return nil
}
d, err := os.Open(dir)
if err != nil {
if errors.Is(err, os.ErrPermission) {
return nil
}
return err
}
return errors.Join(d.Sync(), d.Close())
}
// Crypter encrypts a blob at rest with AES-256-GCM under a per-user random secret
// persisted (0600) at secretPath.
type Crypter struct {
secretPath string
secretPath string
publicationDir string
}
// NewCrypter returns a Crypter whose key lives at secretPath (created on first
@@ -45,11 +68,20 @@ func NewCrypter(secretPath string) *Crypter {
return &Crypter{secretPath: secretPath}
}
// NewCrypterWithPublicationDir is NewCrypter with secret temp files published
// through publicationDir instead of beside the secret. Callers that protect a
// sandboxed process from racing the plaintext key use this to put the temp file
// in a directory their sandbox profile denies. The directory must be on the
// same filesystem as secretPath; the final rename remains atomic.
func NewCrypterWithPublicationDir(secretPath, publicationDir string) *Crypter {
return &Crypter{secretPath: secretPath, publicationDir: publicationDir}
}
// aead loads (or, when create is set, generates) the secret and returns the GCM
// AEAD. Open passes create=false so a missing secret is a hard error rather than
// silently minting a new key that could never decrypt the existing file.
func (c *Crypter) aead(create bool) (cipher.AEAD, error) {
secret, err := loadOrCreateSecret(c.secretPath, create)
secret, err := loadOrCreateSecret(c.secretPath, c.publicationDir, create)
if err != nil {
return nil, err
}
@@ -95,7 +127,7 @@ func (c *Crypter) Open(blob []byte) ([]byte, error) {
// loadOrCreateSecret reads the 32-byte secret at path. When create is set and the
// file is absent, it generates a random secret and creates the file atomically
// (0600). A wrong-sized existing secret fails closed (corruption).
func loadOrCreateSecret(path string, create bool) ([]byte, error) {
func loadOrCreateSecret(path, publicationDir string, create bool) ([]byte, error) {
if data, err := readSecretFileRetry(path); err == nil {
return data, nil
} else if !errors.Is(err, os.ErrNotExist) {
@@ -107,10 +139,10 @@ func loadOrCreateSecret(path string, create bool) ([]byte, error) {
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return nil, err
}
return createSecretFile(path)
return createSecretFile(path, publicationDir)
}
func createSecretFile(path string) ([]byte, error) {
func createSecretFile(path, publicationDir string) ([]byte, error) {
lockPath := path + ".lock"
token := fmt.Sprintf("%d-%d-%d", os.Getpid(), time.Now().UnixNano(), secureLockSeq.Add(1))
var lastErr error
@@ -136,13 +168,13 @@ func createSecretFile(path string) ([]byte, error) {
} else if !errors.Is(rerr, os.ErrNotExist) {
return nil, rerr
}
return writeNewSecretFile(path)
return writeNewSecretFile(path, publicationDir)
}
// On Windows a concurrent holder's os.Remove leaves the lock file in a
// "delete pending" state, so an O_EXCL create races it with
// ERROR_ACCESS_DENIED (os.ErrPermission) rather than ErrExist. Treat that
// as contention and retry too -- mirroring oauth's createSecretFile --
// otherwise concurrent secret creation spuriously fails on Windows.
// as contention and retry, otherwise concurrent secret creation
// spuriously fails on Windows.
if !errors.Is(err, os.ErrExist) && !errors.Is(err, os.ErrPermission) {
return nil, fmt.Errorf("securefile: create secret lock: %w", err)
}
@@ -173,20 +205,63 @@ func reclaimStaleLock(lockPath, token string, staleAfter time.Duration) bool {
return false
}
if info, err := os.Stat(reclaimed); err == nil && time.Since(info.ModTime()) <= staleAfter {
_ = os.Rename(reclaimed, lockPath)
// The lock was replaced between our stat and rename, so put the fresh
// lock back without clobbering a third holder that acquired lockPath in
// the meantime.
_ = restoreReclaimedLock(reclaimed, lockPath)
return false
}
_ = os.Remove(reclaimed)
return true
}
func writeNewSecretFile(path string) ([]byte, error) {
// restoreReclaimedLock restores a quarantined lock only if lockPath is still
// free. The destination is created exclusively; a newer holder is never
// replaced. The quarantined copy is discarded either way.
func restoreReclaimedLock(reclaimed, lockPath string) error {
data, err := os.ReadFile(reclaimed)
if err != nil {
_ = os.Remove(reclaimed)
return err
}
lock, err := openSecretLockFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err != nil {
_ = os.Remove(reclaimed)
return err
}
if _, err := lock.Write(data); err != nil {
_ = lock.Close()
_ = os.Remove(lockPath)
_ = os.Remove(reclaimed)
return err
}
if err := lock.Close(); err != nil {
_ = os.Remove(lockPath)
_ = os.Remove(reclaimed)
return err
}
_ = os.Remove(reclaimed)
return nil
}
func writeNewSecretFile(path, publicationDir string) ([]byte, error) {
secret := make([]byte, secretBytes)
if _, err := io.ReadFull(rand.Reader, secret); err != nil {
return nil, fmt.Errorf("securefile: generate secret: %w", err)
}
dir := filepath.Dir(path)
tmp, err := os.CreateTemp(dir, filepath.Base(path)+".*.tmp")
dir := publicationDir
prefix := filepath.Base(path) + ".*.tmp"
if dir == "" {
dir = filepath.Dir(path)
} else {
// Keep the prefix oauth's store reset uses to clean crash residue from a
// publication directory.
prefix = "publish-*"
}
if err := os.MkdirAll(dir, 0o700); err != nil {
return nil, fmt.Errorf("securefile: create secret publication dir: %w", err)
}
tmp, err := os.CreateTemp(dir, prefix)
if err != nil {
return nil, fmt.Errorf("securefile: create secret temp file: %w", err)
}
@@ -200,7 +275,7 @@ func writeNewSecretFile(path string) ([]byte, error) {
_ = tmp.Close()
return nil, fmt.Errorf("securefile: write secret: %w", werr)
}
if serr := tmp.Sync(); serr != nil {
if serr := syncSecretFileFn(tmp); serr != nil {
_ = tmp.Close()
return nil, fmt.Errorf("securefile: sync secret: %w", serr)
}
@@ -210,6 +285,12 @@ func writeNewSecretFile(path string) ([]byte, error) {
if err := os.Rename(tmpPath, path); err != nil {
return nil, fmt.Errorf("securefile: publish secret: %w", err)
}
// The key is now visible at its final name; sync the destination directory
// so the rename itself survives a crash. The source publication directory
// only held an unlinked temp entry and does not need a barrier.
if err := syncSecretDirFn(filepath.Dir(path)); err != nil {
return nil, fmt.Errorf("securefile: sync secret directory: %w", err)
}
return secret, nil
}
+200 -2
View File
@@ -1,9 +1,12 @@
package securefile
import (
"bytes"
"errors"
"os"
"path/filepath"
"runtime"
"sync"
"testing"
"time"
)
@@ -23,7 +26,7 @@ func TestCreateSecretFileRetriesOnPermissionContention(t *testing.T) {
openSecretLockFile = origOpen
})
secret, err := createSecretFile(secretPath)
secret, err := createSecretFile(secretPath, "")
if err != nil {
t.Fatalf("createSecretFile should retry on permission contention: %v", err)
}
@@ -125,7 +128,7 @@ func TestCreateSecretFileReclaimsStaleLock(t *testing.T) {
}
// Attempt to create the secret file; it should detect the stale lock, reclaim it, and succeed
secret, err := createSecretFile(secretPath)
secret, err := createSecretFile(secretPath, "")
if err != nil {
t.Fatalf("createSecretFile failed to reclaim stale lock: %v", err)
}
@@ -138,3 +141,198 @@ func TestCreateSecretFileReclaimsStaleLock(t *testing.T) {
t.Fatal("expected lock file to be cleaned up after successful acquisition")
}
}
func TestSecretCreationConcurrentConverges(t *testing.T) {
secretPath := filepath.Join(t.TempDir(), "k.secret")
const n = 16
blobs := make([][]byte, n)
errs := make([]error, n)
var wg sync.WaitGroup
for i := 0; i < n; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
blobs[i], errs[i] = NewCrypter(secretPath).Seal([]byte("payload"))
}(i)
}
wg.Wait()
c := NewCrypter(secretPath)
for i := 0; i < n; i++ {
if errs[i] != nil {
t.Fatalf("goroutine %d: %v", i, errs[i])
}
got, err := c.Open(blobs[i])
if err != nil {
t.Fatalf("goroutine %d blob does not decrypt under the converged secret: %v", i, err)
}
if !bytes.Equal(got, []byte("payload")) {
t.Fatalf("goroutine %d plaintext = %q", i, got)
}
}
}
func TestWrongSizedSecretFailsClosed(t *testing.T) {
secretPath := filepath.Join(t.TempDir(), "k.secret")
if err := os.WriteFile(secretPath, []byte("short"), 0o600); err != nil {
t.Fatal(err)
}
if _, err := NewCrypter(secretPath).Seal([]byte("payload")); err == nil {
t.Fatal("wrong-sized secret must error, not mint a new key")
}
}
func TestNewCrypterWithPublicationDirPublishesThroughIt(t *testing.T) {
dir := t.TempDir()
secretPath := filepath.Join(dir, "tokens.json.secret")
pubDir := secretPath + ".publish"
c := NewCrypterWithPublicationDir(secretPath, pubDir)
if _, err := c.Seal([]byte("payload")); err != nil {
t.Fatalf("Seal: %v", err)
}
if info, err := os.Stat(secretPath); err != nil || info.Size() != secretBytes {
t.Fatalf("secret = %v err=%v", info, err)
}
entries, err := os.ReadDir(pubDir)
if err != nil {
t.Fatalf("publication dir: %v", err)
}
if len(entries) != 0 {
t.Fatalf("publication dir left temp files: %v", entries)
}
if entries, err := os.ReadDir(dir); err != nil {
t.Fatalf("parent dir: %v", err)
} else {
for _, entry := range entries {
if entry.Name() != "tokens.json.secret" && entry.Name() != filepath.Base(pubDir) {
t.Fatalf("unexpected parent-dir entry %q; secret must publish through the protected dir", entry.Name())
}
}
}
}
func TestSecretCreationSyncsFileAndDirectory(t *testing.T) {
secretPath := filepath.Join(t.TempDir(), "k.secret")
origFile, origDir := syncSecretFileFn, syncSecretDirFn
t.Cleanup(func() {
syncSecretFileFn, syncSecretDirFn = origFile, origDir
})
fileSynced, dirSynced := false, false
syncSecretFileFn = func(file *os.File) error {
fileSynced = true
return origFile(file)
}
syncSecretDirFn = func(dir string) error {
dirSynced = true
return origDir(dir)
}
if _, err := createSecretFile(secretPath, ""); err != nil {
t.Fatalf("createSecretFile: %v", err)
}
if !fileSynced {
t.Fatal("secret temp file was not synced before rename")
}
if !dirSynced {
t.Fatal("secret destination directory was not synced after rename")
}
}
// TestSecretDirectorySyncFailureLeavesPublishedSecret pins the post-rename
// path: a destination-directory barrier failure reports an error but the
// renamed secret is already there and a later caller can load it.
func TestSecretDirectorySyncFailureLeavesPublishedSecret(t *testing.T) {
secretPath := filepath.Join(t.TempDir(), "k.secret")
origDir := syncSecretDirFn
t.Cleanup(func() { syncSecretDirFn = origDir })
syncErr := errors.New("directory sync failed")
syncSecretDirFn = func(string) error { return syncErr }
if _, err := createSecretFile(secretPath, ""); !errors.Is(err, syncErr) {
t.Fatalf("createSecretFile error = %v, want %v", err, syncErr)
}
published, err := os.ReadFile(secretPath)
if err != nil {
t.Fatalf("read published secret: %v", err)
}
if len(published) != secretBytes {
t.Fatalf("published secret size = %d, want %d", len(published), secretBytes)
}
loaded, err := createSecretFile(secretPath, "")
if err != nil {
t.Fatalf("later caller could not load the published secret: %v", err)
}
if !bytes.Equal(loaded, published) {
t.Fatal("later caller did not reuse the published secret")
}
}
// TestRestoreReclaimedLockDoesNotReplaceNewerLock covers the reclaim race: a
// lock that looked stale can be replaced before the reclaimer renames it. If a
// third holder then creates lockPath, restoring the quarantined lock must not
// replace that newer lock.
func TestRestoreReclaimedLockDoesNotReplaceNewerLock(t *testing.T) {
dir := t.TempDir()
lockPath := filepath.Join(dir, "k.secret.lock")
reclaimed := lockPath + ".stale.crashed"
if err := os.WriteFile(reclaimed, []byte("quarantined-token"), 0o600); err != nil {
t.Fatalf("write quarantined lock: %v", err)
}
if err := os.WriteFile(lockPath, []byte("new-holder-token"), 0o600); err != nil {
t.Fatalf("write newer lock: %v", err)
}
if err := restoreReclaimedLock(reclaimed, lockPath); !errors.Is(err, os.ErrExist) {
t.Fatalf("restoreReclaimedLock error = %v, want %v", err, os.ErrExist)
}
data, err := os.ReadFile(lockPath)
if err != nil {
t.Fatalf("read newer lock: %v", err)
}
if string(data) != "new-holder-token" {
t.Fatalf("newer lock was replaced: %q", data)
}
if _, err := os.Stat(reclaimed); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("quarantined lock was not discarded: %v", err)
}
}
func TestRestoreReclaimedLockRestoresWhenDestinationIsFree(t *testing.T) {
dir := t.TempDir()
lockPath := filepath.Join(dir, "k.secret.lock")
reclaimed := lockPath + ".stale.crashed"
if err := os.WriteFile(reclaimed, []byte("fresh-token"), 0o600); err != nil {
t.Fatalf("write quarantined lock: %v", err)
}
if err := restoreReclaimedLock(reclaimed, lockPath); err != nil {
t.Fatalf("restoreReclaimedLock: %v", err)
}
data, err := os.ReadFile(lockPath)
if err != nil {
t.Fatalf("read restored lock: %v", err)
}
if string(data) != "fresh-token" {
t.Fatalf("restored lock = %q", data)
}
if _, err := os.Stat(reclaimed); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("quarantined lock was not discarded: %v", err)
}
}
func TestSecretSyncFailureLeavesNoSecretAndRetrySucceeds(t *testing.T) {
secretPath := filepath.Join(t.TempDir(), "k.secret")
origFile := syncSecretFileFn
t.Cleanup(func() { syncSecretFileFn = origFile })
syncErr := errors.New("sync failed")
syncSecretFileFn = func(*os.File) error { return syncErr }
if _, err := createSecretFile(secretPath, ""); !errors.Is(err, syncErr) {
t.Fatalf("createSecretFile error = %v, want %v", err, syncErr)
}
if _, err := os.Stat(secretPath); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("failed publish left a secret: %v", err)
}
syncSecretFileFn = origFile
if _, err := createSecretFile(secretPath, ""); err != nil {
t.Fatalf("retry after sync failure: %v", err)
}
}
+107
View File
@@ -0,0 +1,107 @@
package tools
import (
"strings"
"testing"
"github.com/Twigpine/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())
}
})
// A command that ran and then FAILED still ran under the token it was given,
// and a failure is when the reader most needs to know that. Only success was
// pinned, so a rule keyed on the result status rather than on the launch would
// have passed everything above.
t.Run("launched and failed", func(t *testing.T) {
outcome := execution.Outcome{
Kind: execution.OutcomeApplicationFailure,
Launched: true,
Enforcement: execution.Enforcement{Notices: []string{appliedNotice}},
}
got := finalizeToolOutcome(Result{
Status: StatusError, Output: "exit status 1: permission denied", Meta: planned, ExecutionOutcome: &outcome,
Display: Display{Summary: "command failed", Kind: "shell"},
}, "exit status 1: permission denied")
if len(got.EnforcementNotices) != 1 {
t.Fatalf("a command that ran and failed lost its disclosure: %#v", got.EnforcementNotices)
}
model := got.ModelOutput()
for _, want := range []string{appliedNotice, "permission denied"} {
if !strings.Contains(model, want) {
t.Errorf("the model view of a failed command is missing %q:\n%s", want, model)
}
}
summary := got.HumanDisplay().Summary
for _, want := range []string{appliedNotice, "command failed"} {
if !strings.Contains(summary, want) {
t.Errorf("the display of a failed command is missing %q: %q", want, summary)
}
}
})
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)
}
})
}
+27 -5
View File
@@ -164,8 +164,22 @@ 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()
// 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()
@@ -180,7 +194,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 +203,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 +215,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 +228,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,
@@ -349,6 +364,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
+68
View File
@@ -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)
}
})
}
@@ -0,0 +1,38 @@
package tools
import "testing"
// WithoutEnforcementNotices exists for readers of stored results, which hold
// the text with the disclosure composed in AND the same notices as a typed
// field. The property that matters is that composing after it always yields
// exactly what composing once would have: whether the stored text had been
// decorated or not, the reader ends up with one disclosure.
func TestWithoutEnforcementNoticesUndoesExactlyOneComposition(t *testing.T) {
notices := []string{"least-privilege notice: read access was narrowed", "network access was denied"}
for _, base := range []string{"the command output", "", " ", "two\n\nparagraphs of output", "least-privilege notice: quoted in the output itself"} {
once := WithEnforcementNotices(base, notices)
// Stored decorated, the shape every writer produces.
if got := WithEnforcementNotices(WithoutEnforcementNotices(once, notices), notices); got != once {
t.Errorf("decorated %q: recomposed to %q, want %q", base, got, once)
}
// Stored undecorated: nothing to take off, and still one disclosure.
if got := WithEnforcementNotices(WithoutEnforcementNotices(base, notices), notices); got != once {
t.Errorf("undecorated %q: recomposed to %q, want %q", base, got, once)
}
}
// No notices, or only blank ones, means there was never a composition.
for _, none := range [][]string{nil, {}, {"", " "}} {
if got := WithoutEnforcementNotices("plain output", none); got != "plain output" {
t.Errorf("notices %q changed undecorated text to %q", none, got)
}
}
// Only a leading copy is a composition. The same words further down are the
// command's own output and stay where they are.
text := "first line\n\n" + notices[0] + "\n" + notices[1] + "\n\ntail"
if got := WithoutEnforcementNotices(text, notices); got != text {
t.Errorf("a notice quoted inside the output was removed: %q", got)
}
}
@@ -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)
}
}
+53 -24
View File
@@ -221,6 +221,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()
@@ -246,18 +249,14 @@ 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)
}
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,
}
return zeroSandbox.EnforcementFor(plan)
}
type writeStdinTool struct {
@@ -388,7 +387,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,
})
}
@@ -425,16 +425,25 @@ 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
// 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 {
@@ -454,6 +463,15 @@ func execToolResultWithBudget(input execToolResultInput, directBudget bool) Resu
for key, value := range input.sandboxMeta {
meta[key] = value
}
// 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)
@@ -548,29 +566,40 @@ 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.
// 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,
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 {
+119
View File
@@ -0,0 +1,119 @@
package tools
import (
"testing"
"github.com/Twigpine/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)
}
})
}
}
+7 -7
View File
@@ -387,24 +387,24 @@ func scanGrepFile(ctx context.Context, resolvedRoot string, absolutePaths bool,
reader := bufio.NewReader(handle)
lineNumber := 1
sawLine := false
lastEnded := false
for {
if err := ctx.Err(); err != nil {
return err
}
raw, ended, err := readRawLine(reader)
// At EOF the scan stops where the line breaks stopped. A trailing
// line break is the boundary that CLOSED the line it ends — the rule
// the file tracker's countLines states for everything a reader
// reports, and an empty file has no lines at all. Emitting at EOF
// anyway handed every pattern that matches an empty string (anchors
// among them: ^$, \b, x*) a phantom match on a line that does not
// exist, numbered one past the real content.
if err == io.EOF {
if !sawLine || lastEnded {
return emitGrepLine(matcher, fileLabel, lineNumber, nil, emit)
}
return nil
}
if err != nil {
return nil
}
sawLine = true
lastEnded = ended
line := trimTrailingCarriageReturns(trimLineBreak(raw, ended))
if err := emitGrepLine(matcher, fileLabel, lineNumber, line, emit); err != nil {
return err
+82
View File
@@ -0,0 +1,82 @@
package tools
import (
"context"
"path/filepath"
"strings"
"testing"
)
// A LINE BREAK ENDS THE LINE IT TRAILS; IT DOES NOT OPEN THE ONE AFTER IT.
//
// The scan used to hand the matcher an empty line past the file's real content
// at EOF, so every pattern that matches an empty string — the anchored ones ^$
// and \b among them — reported a match on a line no reader sees, numbered one
// past the real content, and count mode charged that phantom to its total.
// read_file and the file tracker's countLines both report "x\n" as one line
// and an empty file as none; the grep scan now stops with them.
func TestGrepStopsAtTheLineBreaksTheFileHas(t *testing.T) {
for name, testCase := range map[string]struct {
content string
pattern string
want string
}{
"a trailing newline closes the line": {"x\n", `^$`, "content:No matches found."},
"an unterminated last line stands alone": {"x", `^$`, "content:No matches found."},
"an empty file has no lines": {"", `^$`, "content:No matches found."},
"the middle empty line is the only empty one": {"x\n\nz\n", `^$`, "content:a.txt:2: "},
"dot star does not run past the break": {"x\n", ".*", "content:a.txt:1: x"},
"count stops at the trailing break": {"x\n", `^$`, "count:0 matches found"},
"count charges only real lines": {"x\n\nz\n", `^$`, "count:1 matches found"},
"count takes the trailing empty lines": {"z\n\n\n", `^$`, "count:2 matches found"},
"an empty file counts nothing": {"", `^$`, "count:0 matches found"},
} {
t.Run(name, func(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "a.txt"), testCase.content)
mode, want, found := strings.Cut(testCase.want, ":")
if !found {
t.Fatalf("SETUP INVALID: %s carries no mode", testCase.want)
}
result := NewScopedGrepTool(root, nil).Run(context.Background(), map[string]any{
"pattern": testCase.pattern,
"output_mode": mode,
})
if result.Status != StatusOK {
t.Fatalf("grep status = %s: %s", result.Status, result.Output)
}
if result.Output != want {
t.Fatalf("grep = %q, want %q", result.Output, want)
}
})
}
}
// THE LINE NUMBERS THE SCAN REPORTS ARE THE LINE NUMBERS read_file USES, and
// neither of them runs past the breaks the content carries. The pattern also
// matches an empty line, so the line the scan used to invent at EOF would
// appear right beside the real one: numbering a real line wrong in the same
// file would fail the read side first, and the phantom would fail as a match
// the content does not carry.
func TestGrepLineNumbersAgreeWithReadFile(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "a.txt"), "one\nneedle two\nthree\n")
read := NewScopedReadFileTool(root, nil).Run(context.Background(), map[string]any{
"path": "a.txt",
})
if read.Status != StatusOK || !strings.Contains(read.Output, "2→needle two") {
t.Fatalf("SETUP INVALID: read_file does not number the needle line 2: %s", read.Output)
}
result := NewScopedGrepTool(root, nil).Run(context.Background(), map[string]any{
"pattern": "needle|^$",
})
if result.Status != StatusOK {
t.Fatalf("grep status = %s: %s", result.Status, result.Output)
}
if result.Output != "a.txt:2: needle two" {
t.Fatalf("grep = %q, want exactly the line read_file numbers 2; an extra empty match is a line the content does not have, so the scan opened one past the trailing break", result.Output)
}
}
@@ -0,0 +1,48 @@
package tools
import (
"strings"
"testing"
zeroSandbox "github.com/Twigpine/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)
}
}
@@ -0,0 +1,113 @@
package tools
import (
"context"
"strings"
"testing"
zeroSandbox "github.com/Twigpine/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)
}
}
+34 -2
View File
@@ -46,6 +46,28 @@ 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 {
// 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")
}
}
previous := result.Outcome
human := result.Display
if human.Preview == "" && result.Meta["command_output_reduced"] == "true" {
@@ -65,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() {
+95 -6
View File
@@ -2,6 +2,7 @@ package tools
import (
"context"
"strings"
"github.com/Twigpine/zero/internal/execution"
"github.com/Twigpine/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
@@ -179,24 +195,97 @@ 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 {
// 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 {
return result.Outcome.ModelView
}
return result.Output
}
// HumanDisplay returns the finalized presentation, falling back to the legacy
// display for direct Tool.Run callers.
func (result Result) HumanDisplay() 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 {
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
}
// 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 joined + "\n\n" + text
}
// WithoutEnforcementNotices is the inverse, for a reader of STORED text.
//
// A persisted tool result carries its output with the disclosure already
// composed in, and the same notices again as a typed field. A reader that
// rebuilds a result from both has to take the composed copy back off, or the
// accessors above compose it a second time and the disclosure is drawn twice.
// It lives beside WithEnforcementNotices so the two agree on the format by
// construction rather than by a second spelling of it somewhere else.
//
// Text that was never decorated is returned unchanged, so a record whose writer
// stored the undecorated output still round-trips to exactly one disclosure.
func WithoutEnforcementNotices(text string, notices []string) string {
if len(notices) == 0 {
return text
}
joined := strings.TrimSpace(strings.Join(notices, "\n"))
if joined == "" {
return text
}
if rest, ok := strings.CutPrefix(text, joined+"\n\n"); ok {
return rest
}
if strings.TrimSpace(text) == joined {
return ""
}
return text
}
// Display carries a short, structured summary of a tool result for the TUI/stream.
type Display struct {
Summary string
+1 -1
View File
@@ -23,7 +23,7 @@ func testAskUserRequest() agent.AskUserRequest {
func newAskUserModel(t *testing.T, request agent.AskUserRequest, answers *[][]string) model {
t.Helper()
m := newModel(context.Background(), Options{})
m := newViewTestModel(t, Options{})
m.pending = true
m.activeRunID = 7
m.width = 96
+2 -2
View File
@@ -306,7 +306,7 @@ func TestSuggestionsSuppressedDuringSpecReview(t *testing.T) {
}
func TestSuggestionOverlayRenders(t *testing.T) {
m := newModel(context.Background(), Options{})
m := newViewTestModel(t, Options{})
m.width, m.height = 96, 30
m = typeRunes(t, m, "/mo")
@@ -387,7 +387,7 @@ func TestOverlayViewportLinesPreservesTextOutsidePanel(t *testing.T) {
}
func TestSuggestionOverlayCapsRowsWithoutMoreText(t *testing.T) {
m := newModel(context.Background(), Options{})
m := newViewTestModel(t, Options{})
m.width, m.height = 96, 30
m = typeRunes(t, m, "/")
+1 -1
View File
@@ -263,7 +263,7 @@ func TestMCPManagerNavigationOpensAddWizard(t *testing.T) {
}
func TestMCPManagerSearchFiltersMarketplace(t *testing.T) {
m := newModel(context.Background(), Options{})
m := newViewTestModel(t, Options{})
m.width = 120
m.height = 36
m.input.SetValue("/mcp")
@@ -0,0 +1,260 @@
package tui
import (
"encoding/json"
"fmt"
"strings"
"testing"
"github.com/Twigpine/zero/internal/agent"
"github.com/Twigpine/zero/internal/sessions"
"github.com/Twigpine/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)
}
}
// 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)
}
}
})
}
}
// 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.
//
// Every writer now goes through agent.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(agent.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)
}
}
+88
View File
@@ -4,12 +4,100 @@ import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"github.com/Twigpine/zero/internal/config"
"github.com/Twigpine/zero/internal/testutil"
)
// newViewTestModel builds the model for a test that asserts on the whole view.
// With an empty Options.Cwd, newModel uses this package's directory, so the
// title bar shows the real branch and checkout path, and a check that some text
// is absent fails wherever either one contains it: a branch named
// feat/model-picker breaks a "/model" check, a checkout under ~/more breaks a
// "more" check.
//
// The cwd is isolatedCwd, so the path in the title bar is the same on every
// machine. The branch is cleared after construction, because no path can
// promise there is none: gitBranch walks up from the cwd even though it does
// not exist, so a temporary directory inside a checkout would still put that
// checkout's branch in the title bar.
func newViewTestModel(t *testing.T, options Options) model {
t.Helper()
options.Cwd = isolatedCwd(t)
m := newModel(context.Background(), options)
m.gitBranch = ""
return m
}
// isolatedCwd is a child of the home TestMain isolates that nothing creates:
// fully qualified on every platform and holding nothing newModel could read.
// The title bar shows it as ~/not-a-checkout. A t.TempDir path would carry the
// user name and the test name into the view instead.
func isolatedCwd(t *testing.T) string {
t.Helper()
home, err := os.UserHomeDir()
if err != nil {
t.Fatal(err)
}
return filepath.Join(home, "not-a-checkout")
}
// Guards isolatedCwd: the view tests rely on a fully qualified path that does
// not exist. Whether a checkout encloses it depends on where the temporary
// directory is, which is why newViewTestModel clears the branch instead.
func TestIsolatedCwdIsAbsentAndFullyQualified(t *testing.T) {
cwd := isolatedCwd(t)
if !filepath.IsAbs(cwd) {
t.Fatalf("isolatedCwd = %q, want a fully qualified path", cwd)
}
if _, err := os.Lstat(cwd); !os.IsNotExist(err) {
t.Fatalf("isolatedCwd %q must not exist, got %v", cwd, err)
}
}
// A temporary directory inside a checkout puts the home, and so isolatedCwd,
// under that checkout. Its branch must still stay out of the view, or a branch
// named feat/model-picker fails the "/model" check again.
func TestViewTestModelKeepsAnEnclosingCheckoutsBranchOutOfTheView(t *testing.T) {
checkout := t.TempDir()
if err := os.MkdirAll(filepath.Join(checkout, ".git"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(checkout, ".git", "HEAD"), []byte("ref: refs/heads/feat/model-picker\n"), 0o644); err != nil {
t.Fatal(err)
}
home := filepath.Join(checkout, "home")
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
if got := gitBranch(isolatedCwd(t)); got != "feat/model-picker" {
t.Fatalf("fixture: gitBranch(isolatedCwd) = %q, want the enclosing checkout's feat/model-picker", got)
}
m := newViewTestModel(t, Options{})
m.width, m.height = 96, 30
m = typeRunes(t, m, "/mo")
plain := plainRender(t, m.View())
if strings.Contains(plain, "feat/model-picker") || strings.Contains(plain, "/model") {
t.Fatalf("the enclosing checkout's branch reached the view:\n%s", plain)
}
// The path is the other half: the title bar shows the isolated cwd, not the
// checkout the tests happen to run from or the one enclosing the home.
if !strings.Contains(plain, "not-a-checkout") {
t.Fatalf("the title bar does not show the isolated cwd:\n%s", plain)
}
wd, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
for _, path := range []string{checkout, wd} {
if strings.Contains(plain, path) {
t.Fatalf("the view shows the checkout path %s:\n%s", path, plain)
}
}
}
// Guards TestMain: the per-user directories every newModel reads (and the ones
// tests could write to) must resolve inside the isolated root on every OS.
func TestUserDirsAreIsolatedFromTheDeveloperHome(t *testing.T) {
+18 -43
View File
@@ -6002,16 +6002,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
@@ -6253,45 +6254,19 @@ func (m model) sendAgentUsage(runID int, modelID string, event zeroruntime.Usage
m.runtimeMessageSink(agentUsageMsg{runID: runID, modelID: modelID, usage: event})
}
// 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.
// toolResultDetail is the card body source. The rule lives on the result itself
// (agent.ToolResult.CardBody) because the session payload is built from it too,
// and that payload now has a writer outside this package.
func toolResultDetail(result agent.ToolResult) string {
display := result.HumanDisplay()
if strings.TrimSpace(display.Preview) != "" && (result.Status != tools.StatusError || result.Outcome.Finalized()) {
return display.Preview
}
return result.ModelOutput()
return result.CardBody()
}
// 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.
// the richer card body that was visible during the live run. The contract and
// its presence rules are documented on agent.ToolResultSessionPayload.
func toolResultSessionPayload(result agent.ToolResult) map[string]any {
output := result.ModelOutput()
payload := map[string]any{
"toolCallId": result.ToolCallID,
"name": result.Name,
"status": string(result.Status),
"output": output,
}
if preview := toolResultDetail(result); strings.TrimSpace(preview) != "" && preview != output {
payload["displayPreview"] = preview
}
if result.Redacted {
payload["redacted"] = true
}
if len(result.Meta) > 0 {
payload["meta"] = result.Meta
}
if len(result.ChangedFiles) > 0 {
payload["changedFiles"] = result.ChangedFiles
}
if len(result.ChangeSummaries) > 0 {
payload["changeSummaries"] = result.ChangeSummaries
}
return payload
return agent.ToolResultSessionPayload(result)
}
func toolResultRowText(result agent.ToolResult) string {
+1 -1
View File
@@ -357,7 +357,7 @@ func TestInitialRenderShowsLimeChatSurface(t *testing.T) {
}
func TestEmptyStateCollapsesAfterFirstPrompt(t *testing.T) {
m := newModel(context.Background(), Options{})
m := newViewTestModel(t, Options{})
m.width = 100
m.height = 30
m.input.SetValue("inspect the repo")
Loaded 100 of 107 files, more files were not shown because too many files have changed in this diff. Show more