Compare commits

..
Author SHA1 Message Date
Vasanthdev2004 b46e943e68 Merge remote-tracking branch 'origin/main' into fix/windows-setup-marker-runtime-root 2026-10-25 19:42:10 +05:30
Vasanthdev2004 de4dfbe551 fix(sandbox): say when an ACL rollback target was never identified
A snapshot whose identity could not be read at apply time failed the
rollback match the same way a replaced target does, and the operator was
told the target had been replaced. Both still leave the object alone;
the message now says which one happened. Reported by CodeRabbit.
2026-10-17 18:06:09 +05:30
Vasanthdev2004 ae30755ce7 refactor(doctor): fingerprint the recorded runtime root that was just checked
The setup check read the marker's runtime root once to decide whether it
is current, then read the marker again to build the profile it
fingerprints. Reuse the first value, so a marker rewritten between the
two reads cannot put an unchecked root into the fingerprint. Reported by
CodeRabbit.
2026-10-17 18:06:09 +05:30
Vasanthdev2004 d6d0d0cc25 Merge remote-tracking branch 'origin/main' into fix/windows-setup-marker-runtime-root 2026-10-17 17:27:08 +05:30
Vasanthdev2004 0121fc4f3c 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:51:05 +05:30
Vasanthdev2004 158d5ee62f Merge remote-tracking branch 'origin/main' into fix/windows-setup-marker-runtime-root 2026-10-17 11:51:02 +05:30
Vasanthdev2004 4c945485d0 chore(sandbox): ignore the read-only closes explicitly, as lint asks 2026-09-26 09:11:28 +05:30
Vasanthdev2004 7f9ca0860a fix(sandbox): prepare the runtime tree's children through a handle on its root
The runtime root is a write root, so a sandboxed command can replace
cache, data, tmp or any of the listed grandchildren with a link to a host
directory. The fallback root is derived rather than minted, so the next
Zero process prepares that same tree unsandboxed, and it created and
chmodded every child by pathname after checking only the root and its
ancestors. It followed the planted link: a contained command could get
Zero to create npm inside a host directory and change that directory's
mode.

Everything below the root now goes through os.Root: each child is
created relative to the root's handle, which refuses any component that
leads out of it, a child that is a link at all (a junction on Windows) is
refused as an aliased component, and on Unix the mode is set on the
opened directory rather than on a name. The same path serves the
preferred cache root, which had the same pathname operations.

Reported by @jatmn.
2026-09-26 09:08:13 +05:30
Vasanthdev2004 34dea45d58 fix(sandbox): report a failed setup lock release instead of succeeding
The setup lock's release discarded both the unlock and the close error,
and elevated setup defers it after publishing the marker, so the operator
was told setup succeeded without anything knowing whether the lock had
been let go. The release now always runs both halves and returns whichever
failed, setup reports it and exits non-zero on top of the result it
already had, and the marker-only writer that holds the same lock returns
it too. The transaction order and the lock file name are unchanged.

Reported by @jatmn.
2026-09-26 09:02:51 +05:30
Vasanthdev2004 1e0120c1a6 fix(sandbox): replace the runtime stamp in one step
Commands and zero doctor read the runtime stamp without taking the setup
lock. The writer opened the existing stamp with FILE_OVERWRITE_IF, which
truncates at open time, and only then protected and wrote it, and rollback
deleted the stamp before recreating the previous one. A reader in either
interval saw an empty, partial or missing stamp on a setup that was still
valid, and a process that stopped there left it that way.

The new contents now go into a fresh file beside the stamp, created under
an unpredictable name with the protected DACL already in place, and that
file is renamed over the stamp through its own handle with POSIX
semantics. Rollback restores through the same publish instead of deleting
first. The validation read opens the stamp with FILE_SHARE_DELETE so a
publish can replace it while it is open; a reader without share-delete
gets a short bounded retry and then a failed setup with the previous stamp
whole and no replacement left behind.

Reported by @jatmn.
2026-09-26 08:59:52 +05:30
Vasanthdev2004 c84640ca60 test(sandbox): derive the fallback root from the canonical workspace, as selection does
The Windows runner's TEMP is an 8.3 short path, so the raw t.TempDir()
spelling digested differently from the canonical one the record check
computes, and the test stopped at its own setup guard. Selection hands
the fallback a canonical workspace, and the test now does the same.
2026-09-24 14:43:29 +05:30
Vasanthdev2004 ed355ca4c4 fix(sandbox): answer the open review threads on the runtime root
Resolve the stamp reader before the stamp is opened. FILE_OVERWRITE_IF
truncates at open time, so a reader that could not be established returned
an error with the previous stamp already emptied, or a new empty one left
behind. The pathname entry point now delegates to the handle writer instead
of carrying its own copy of the sequence.

Take the Windows user scope for the fallback runtime root from the token's
user SID rather than USERNAME. Setup and a later command derive that root in
two processes with independent environments, and a USERNAME that differed
between them made the command reject the root setup had recorded.

On Unix, report a created runtime directory that exists but cannot be
identified instead of treating every lookup failure as a clean undo, which
is the line the Windows build already draws.

Move runtimeTailNotOwned and windowsSameRuntimeRootPath beside their only,
Windows-only callers so they are no longer dead code on other platforms,
replace a deprecated filepath.HasPrefix in a Unix test, and bind the
junction test's planted flag to mklink's result so a failed mklink skips
instead of failing a correct acquisition.
2026-09-24 14:14:00 +05:30
Vasanthdev2004 87124fb463 test(sandbox): pin the ACE type rule on its own, beside the size rule
With the type rule removed the guard-page legs still passed, because their short tails are stopped by the size rule as well. An entry in another layout can be long enough to hold the SID its would-be count claims, and then only its type says it is not a plain ACE. This drives the admission function with a whole SID at the plain offset for eight ACE types and admits only the two plain ones.
2026-09-21 13:47:36 +05:30
Vasanthdev2004 a4a248c15e fix(sandbox): one resolver for selection and its diagnostic, and admit ACEs by type
Two findings from jatmn, both on code this branch added.

The diagnostic that answers "would a command still select the recorded
runtime root?" spelled out the same steps as selection and was missing one.
os.UserCacheDir can succeed and still return a value that canonicalizes to
nothing, and selection fails closed on that with "user cache directory is
unavailable" while the diagnostic went on to derive candidates from the
empty root. Doctor could then report the recorded root as current or as
stale on a machine where every command fails before it reads a marker. Both
callers now take their inputs from resolveSandboxRuntimeRootInputs, so an
input one refuses is refused by the other with the same error. A sandbox
test drives both entry points through the resolver seam and requires
identical errors; a Windows doctor test sets LOCALAPPDATA to "." and
requires runtime-root-unresolved with the resolver cause.

The ACL attestation cast every DACL entry to the plain ACE layout and
compared whatever sat at the SID offset before looking at the entry's type.
Only ACCESS_ALLOWED_ACE and ACCESS_DENIED_ACE keep a SID there; an object
ACE has a flags word and GUIDs, and EqualSid is undefined for a structure
that is not a SID. An entry is now admitted on its header, type first and
then a size that can hold the SID its own count claims, before any byte past
the header is read. Other layouts are skipped, so an allow that is not read
is not credited and the worst case is one idempotent re-apply. The decision
is split from the filesystem read so it can be tested against an ACL built
in memory.

That test places each ACL against a PAGE_NOACCESS page with an entry whose
tail spells the start of the trustee's SID, so a reader that believes the
count walks into the guard page. Each leg runs in a child process, because
an access violation cannot be recovered and would take the package's other
tests down with it.
2026-09-21 13:46:08 +05:30
Vasanthdev2004 1d75e35670 test(sandbox): a fixture that owns its inputs fails instead of skipping
The fallback tests and the shared runtime fixture still had skips for a missing
user cache directory or an underivable root. With the cache and temp both owned
by the test those cannot be explained by the machine, and a skip there is how a
test passes without reaching its assertion. They are setup failures now, and
the recorded-fallback test owns its cache directory too.
2026-09-18 17:09:05 +05:30
Vasanthdev2004 0a600a83d8 test(sandbox): a setup parked mid-transaction still excludes eviction
The setup lock orders setups against each other and says nothing to an
eviction, which takes the runtime lease exclusively. This runs the control
against the real transaction: parked between its stamp and its marker, the
root reports in use, and it is free again once setup returns.
2026-09-18 16:35:34 +05:30
Vasanthdev2004 5e574c729a fix(sandbox): scope the runtime attestation to its plan and order setup transactions
Five findings from review, at the boundaries between stages.

- The runtime stamp is named after the plan it attests. The runtime root is
  chosen per workspace and the plan is specific to a sandbox home, so two homes
  for one workspace shared one stamp file and each setup invalidated the other.
  Each plan now has its own attestation, and a failed setup can only reach the
  stamp of the plan it was applying. Eviction still takes them all.
- One setup at a time per sandbox home, through a lock file held from before the
  snapshot until the marker is published or every compensation has run. The
  shared runtime lease keeps eviction out but two setup helpers can both hold
  it, so the stamp and marker of different setups could interleave, and a failed
  setup could restore its snapshot over a later success.
- The native smoke test prepares its commands the way the planner does instead
  of sending the bare profile to the command builder, which the elevated runner
  refused before any probe ran. A portable test pins the agreement through the
  real builders and parsers, starting from a bare profile.
- Test fixtures redirect TMPDIR as well as TMP and TEMP and check containment
  before creating anything, so the fallback tests no longer write outside their
  own temp on Unix or skip past their assertion.
- Stamp compensation classifies absence with isWindowsNotFound, which knows the
  native statuses the no-follow open returns.

The elevated helper's transaction now runs in tests behind two seams (the
elevation check and the WFP install), so the regressions drive the real lock,
lease, snapshot, ACL apply with the riding stamp, marker and compensation, and
ask a later command prepared by the planner whether it validates.
2026-09-18 16:32:05 +05:30
Vasanthdev2004 6235e85b5a Merge remote-tracking branch 'origin/main' into fix/windows-setup-marker-runtime-root 2026-09-18 15:42:10 +05:30
Vasanthdev2004 b98cdde42c Merge remote-tracking branch 'origin/main' into HEAD 2026-09-13 19:25:44 +05:30
Vasanthdev2004 3eda0c1d3e fix(sandbox): reject a capability grant that stops at the immediate children
windowsPathCarriesGrant read only the two inherit bits, so an allow ACE with
OBJECT_INHERIT|CONTAINER_INHERIT|NO_PROPAGATE_INHERIT satisfied the flag test
and had its whole mask credited. NO_PROPAGATE lets the ACE be inherited exactly
once: the immediate children carry it and their children do not. The runtime
tree's real consumers sit past that edge - cache/npm, cache/go-build and
data/go-mod, which sandboxRuntimeEnvironment points npm_config_cache, GOCACHE
and GOMODCACHE at - so attestation reported the grant adequate while those
writes were refused, and the unelevated path skipped the repair that would have
fixed it.

The skip is gated on needInherit so a file entry, where inherit flags are
meaningless, is unaffected, and the apply path never sets NO_PROPAGATE for an
AllowWrite entry, so a grant Zero itself wrote cannot start looking
insufficient.

Reported by @jatmn.
2026-09-13 19:25:28 +05:30
Vasanthdev2004 72515e0c7f fix(sandbox): a lease is only a lease while its name still resolves to it
A command that opened the runtime lease while cleanup held it exclusively waited for the shared lock. Cleanup unlinked the lease name (marked it delete-pending on Windows) and compensated the tree before letting go, so the waiter woke on the old object, locked it, and got a lease that coordinated with nobody: the next cleanup created a fresh lease at the name, locked it unopposed, and reported the root unused while the command was running in it.

After either lock, both platforms now prove the locked object is still what the lease name resolves to under the retained parent: dev and inode against a no-follow fstatat on POSIX, volume serial and file index against a fresh no-follow open on Windows, where a name that is gone or delete-pending counts as moved on. A stale lock closes and the whole acquisition retries from tail creation, since cleanup has removed the leaf the contender was about to use; the exclusive side retries its open. Three stale objects in a row are reported. A stale lease is never undone by name, because the name is no longer that object. The created-directory ledger merges across attempts so a directory this run made on an earlier pass stays its own to compensate.

The regression drives the waiting-contender schedule through the production callers on both platforms, using the two seams as barriers and no sleeps: the contender must hold the current object, which the next cleanup proves by finding the root in use. Reported by @gnanam1990.
2026-09-12 11:58:51 +05:30
Vasanthdev2004 8bebff712c Merge origin/main into fix/windows-setup-marker-runtime-root
Brings in #1006 and the rest of main since the last rebase. Merged rather than rebased: fifty-three commits, and the marker-version comment conflicted on every replay.

One conflict: windows_setup.go keeps the marker schema at 6 with its history comments; main is at 5 with none.

The setup test #1006 added hands BuildWindowsSandboxSetupArgs straight to RunWindowsSandboxSetup as a string slice. On this branch the builder returns a WindowsSandboxSetupPlan so the creation ledger cannot be dropped, so the test now takes the plan, runs its Args, and registers its Rollback in cleanup, the same shape as the existing tests here.
2026-09-12 10:35:38 +05:30
Vasanthdev2004 d7f047d2f6 test(sandbox): drive the lease-creation failure through the real builder
The two carriers are each covered on their own now, but the interval where they
compose is the one the finding is actually about: the selection creates the lease
file, the step after it fails, so no lease object comes back and the builder takes
the early return that compensates only what the selection managed to report.

This drives BuildWindowsSandboxSetupArgs with that failure injected and asserts
nothing the invocation created is left behind, on both platforms. Falsified by
disabling the lease undo: the lease file survives, and with it every directory the
same invocation created, because it keeps their parent non-empty.

Both candidate roots are failed rather than only the preferred one, or the
selection relocates to the fallback and the build goes on to succeed.
2026-09-07 20:53:13 +05:30
Vasanthdev2004 3da7325349 fix(sandbox): publish creation ownership before the step that can fail
Acquisition learned it had created a directory or a lease file at the create
itself, then published that fact only after every later step had also succeeded.
Between the two it held a fact nothing else did.

On Windows, createWindowsChildDirectory returns a handle to a directory that now
exists, and a failing handleRuntimeIdentity closed it and returned before the
ledger entry two lines below. On Unix the same gap sits under mkdirat, both for
the ownership refusal and the identity read. The lease files have the parallel
version: O_EXCL and FILE_CREATED each say this call made the file, and a failing
inspection, wrapping or lock returns no lease object, which was the only carrier
for that fact.

The consequence is not just litter. Setup returns an error while leaving
invocation-owned state behind, and where an ancestor WAS recorded, that
ancestor's compensation then fails on a child it cannot account for, so the
whole partial tree stays.

Each of those failures now undoes its own creation before returning: through the
handle on Windows, relative to the parent descriptor on Unix, never by
re-resolving the name, since the name is exactly what this descent refuses to
trust twice. Only a create by this call is undone, so a lease that was already
there still belongs to whoever is holding it, and components that completed stay
on the ledger for the caller to compensate.

createWindowsChildDirectory and both rooted lease opens now ask for DELETE, which
is what lets the undo go through the handle rather than the name.

Reported by jatmn.
2026-09-07 20:48:25 +05:30
Vasanthdev2004 ec85bd5849 fix(sandbox): hold cleanup exclusivity across every mutation that needs it
Compensation took the exclusive cleanup lease to prove no command was holding
the runtime root, then gave that proof back before doing any of the work it was
proving anything about.

Two consequences. The lease was released before its own pathname was removed and
before the directory walk, so a command blocked on the shared lease was let in
mid-cleanup: it could take the old lease object, have the name unlinked out from
under it, and a later cleanup locking a fresh object at the same pathname would
then read the root as free while that command was still in it. The comment
justifying the early release said a file cannot be deleted on Windows while this
process holds it open, which is not true of a handle opened FILE_SHARE_DELETE,
and this one is: os.Remove succeeds and the name goes at once.

The second was worse and needed no race at all. An acquisition that came back
in-use appended "the lease and the tree it protects were left in place" to the
error list and then ran the directory walk anyway on the next statement. The
promise lived in the error string anyway. A contender holding the shared lease
had its root deleted and was told it had not been.

So the lease is now taken, held across the pathname removal and the whole walk,
and released at the end, and nothing is removed at all unless the acquisition
succeeded. Leaving a tree behind is recoverable by the next run; deleting a live
one is not. The lease pathname goes during the walk, immediately before the
directory that contains it: any earlier ends the exclusion everything after it
depends on, any later leaves that directory non-empty so it can never be
compensated.

Also removes the superseded pathname-based shared acquisition and both its
platform halves, which no longer had a caller. It opened the lease by full
pathname, so it followed a link planted at that name, and on Windows it opened
without FILE_SHARE_DELETE, which by itself stops cleanup from removing a lease
anyone is holding. A weaker door sitting beside the rooted one is how a later
change quietly takes it.

Reported by jatmn.
2026-09-07 20:41:18 +05:30
Vasanthdev2004 37d08e3833 test(sandbox): pin that a pre-existing lease file survives rollback
The untouched-tree case created the runtime root but not the lease beside it, so
nothing failed if compensation stopped asking whether the lease was ours. It is
the object two processes coordinate on, and removing one this invocation did not
create takes it out from under whoever did.
2026-09-05 15:41:56 +05:30
Vasanthdev2004 0504fa790c fix(sandbox,cli): carry the lease creation ledger into setup compensation
prepareSandboxRuntimeLeaseRecording reported the owned directories it created and
the two-result wrapper beside it dropped them. Every real selector and the setup
argument builder used the wrapper, so nothing in production ever saw that record,
and only tests called the recording form.

The consequence is a whole transaction with no undo. BuildWindowsSandboxSetupArgs
calls selectSandboxRuntimeRoot, which creates zero/runtime/v1 and the lease file
when they are not there, then releases the lease and serializes only the selected
pathname. The helper reacquires an already-existing tree so it records no
creation, and provisioning records only the leaf. Any failure before the marker
left the parents behind attested by nothing, and the sibling lease file kept v1
non-empty so even the leaf rollback could not finish.

The transaction now starts at the first mutating lease operation. The selector
returns the ledger, including on the error path where acquisition creates one
component and fails on the next, and the fallback branch appends rather than
replaces so a preferred attempt that created components before failing to lease
is not forgotten. BuildWindowsSandboxSetupArgs returns a plan rather than bare
args, so a caller cannot take the command line and leave the record behind, which
is exactly what happened last time. The CLI runs the undo when the helper does
not complete, on this side of the process boundary where the record lives.

The lease file is part of it. It is removed only when this acquisition created it,
which NtCreateFile and an O_EXCL open are what can say, and only after retaking it
exclusively. Taking it exclusively is not tidiness: it is the proof that no other
process is holding the tree about to be deleted. A holder means the lease and the
tree both stay and the caller is told, because reporting a clean undo while a tree
is still on disk sends the operator to retry against state they were told was
gone.

setupConsumerSID is a variable now. The interval between creating the tree and
returning the args is the whole reason the rollback exists, and the identity
lookup is the only step in it that can fail, so without that seam the compensation
path is unreachable from a test.

A command path takes no ledger on purpose and says so: a command is not a
transaction, publishes no marker, and what it creates is the tree it is about to
use.

Verified on Windows and on real Linux.
2026-09-05 15:41:28 +05:30
Vasanthdev2004 ec86c77abc fix(sandbox): create the POSIX runtime tail through a no-follow boundary
The fallback root moved from an atomically minted MkdirTemp parent to a
predictable /tmp/zero-u<uid>/runtime/v1/<digest>. The stable name is required,
because setup and every later process have to agree on one root without talking
to each other, but it also means another local account can name the first owned
component before this one does.

refuseAliasedRuntimeComponents answers about an ABSENT component by saying there
is nothing to alias, which is exactly the state a fresh fallback is in. After
that answer the code called os.MkdirAll on the parent and opened <root>.lease by
full pathname, and both follow a link planted in between. The guard was
authorizing writes whose destination it could not see, and the next check could
only report the problem after they had happened.

So the base is opened by name exactly once, and every component below it is
created or opened relative to a retained descriptor with O_NOFOLLOW, its
ownership and mode checked through fstat, and the lease opened relative to the
verified parent. A link at an owned component is an ELOOP or ENOTDIR from the
kernel rather than a redirection nobody noticed. The base itself keeps following
links: it belongs to the operator and is legitimately one on macOS, where /tmp
resolves through /private.

createRuntimeTailHandleRelative on this side was the same defect one layer up, an
os.Mkdir loop over full pathnames kept on the reasoning that the elevation
asymmetry the Windows descent closes does not apply here. True of the elevation,
false of the substitution. It goes through the same descent now, so there is one
implementation per platform rather than one protected platform.

Cleanup resolves the lease the same way for the same reason as on Windows: a link
at the lease name otherwise gives the shared holder and the exclusive holder two
different files, both locks succeed, and cleanup removes a root that is in use.

The two descent seams move to a file with no build tag. A seam defined beside one
of two implementations is how the other one quietly ends up untested.

Verified on real Linux, not only cross-compiled.
2026-09-05 15:28:34 +05:30
Vasanthdev2004 bd031f0ec1 test(sandbox): record what the unprivileged reparse case cannot show
Falsifying turned up that an unknown third-party tag is unresolvable, so the old
pathname cleanup fails on it too, with ERROR_CANT_ACCESS_FILE rather than by
classifying anything. The case still pins that both sites refuse, and the reason
check separates a refusal from an accident, but only the symbolic-link cases show
the half that matters most: a pathname open succeeding on the target. Those need
the privilege, so their result has to be read in CI rather than locally.
2026-09-05 15:22:41 +05:30
Vasanthdev2004 aaf681dec6 fix(sandbox): make both lease holders verify the same ordinary file
FILE_OPEN_REPARSE_POINT says do not follow, not refuse. It returns a handle to
the LINK, and FILE_NON_DIRECTORY_FILE beside it excludes directories rather than
non-directory reparse objects, so a file symbolic link at <digest>.lease was
opened, wrapped and locked as though it were the lease. The flag was being read
as a guarantee it does not make.

That mattered because cleanup did not use the rooted opener at all. It called
os.OpenFile on the full pathname with no no-follow flag, followed the same
planted link, and locked its TARGET. Setup or a running command then held a
shared lock on the link while cleanup held an exclusive lock on the target, both
calls succeeded, and cleanup was free to RemoveAll a runtime root somebody was
still using. This is not the ancestor-junction case already fixed: the
substituted object is the final lease entry itself.

No-follow opening and object classification are two requirements. The lease
handle is now asked what it is before it is locked, which is the check the
directory descent in openWindowsChildNoFollow already made, and cleanup resolves
the name the way acquisition does: the same base, the same owned components, the
same no-follow opens, the same refusal. Mutual exclusion is a property of the
object, so both sides have to arrive at it the same way.

Cleanup opens and never creates the tree. A runtime root that is not there has no
lease to take, and rebuilding it in order to lock it would be inventing the thing
cleanup is about to remove.

The regression covers a file symbolic link at the exact lease name for both call
sites and asserts the link target is never locked. That needs
SeCreateSymbolicLinkPrivilege, so it skips on an ordinary account; a third-party
reparse tag needs only write access, and pins the same classification everywhere
for both sites. Controls prove a shared lease still makes cleanup report inUse
and then succeed after release, and that two holders can still share one
legitimate lease.
2026-09-05 15:21:41 +05:30
Vasanthdev2004 b314301ff0 fix(sandbox): keep a recorded fallback authoritative across a TEMP change
Setup reaches the temp fallback when the preferred cache-derived root cannot be
leased, and it records that concrete root in the marker. Command selection then
recognised the record only by deriving today's fallback from this process's
TEMP and comparing the two. A later IDE, service or terminal running with a
different TEMP matched neither of today's candidates, so the record was
ignored, selection produced a tree setup never provisioned, and the runner
rejected the marker as out of date. Re-running setup from the original
environment repeats the original answer and does not make the other
environment converge.

That is the second selection the persisted-root design existed to remove: the
record was being treated partly as history and partly as something reproducible
from the next process's environment.

The record is now recognised by its own shape, which is the question actually
being asked. It must have the owned runtime shape, its first component must be
the fallback's, and its leaf must be this workspace's fallback digest. Nothing
about its base is re-derived. The cache-derived digest is deliberately not
accepted, so a moved user cache still reports stale, which is a different
failure with a different remedy. Both call sites take it, or doctor and the
command disagree again.

The refusals that made the old comparison worth having are unchanged: a record
provisioned for another workspace has a different digest and is still refused,
and a record with no owned shape is refused outright.
2026-09-04 14:47:38 +05:30
Vasanthdev2004 4315bec802 fix(sandbox): bind the stamp snapshot and the ACL apply to one object
snapshotWindowsSandboxRuntimeStamp reads the runtime root's identity and prior
stamp through one handle and closes it. applyWindowsACLPlanWithStamp then
resolved the same pathname again and mutated whatever answered. Neither stage
ever proved the two were the same object, so the transaction established "these
prior bytes belong to B" and wrote to A.

The root's owner can arrange that with ordinary directories and no privilege:
rename the real root aside, leave a plain directory at the predictable name for
the snapshot to read, and restore the original before the apply. Nothing is a
reparse point, so a no-follow open does not notice, and the setup lease is a
sibling of the root rather than the root entry itself.

The damage is not only a misplaced ACL. On a later failure, stamp compensation
compares what it finds against the snapshot's identity, refuses to restore, and
leaves this run's stamp on a directory whose published marker still describes
the previous successful setup, so a failed setup invalidates a good one.

The request now carries the identity the snapshot established, and the apply
verifies it before its first mutation rather than before the stamp, since the
ACL is the change that matters most. It fails closed: an identity that could not
be established refuses, because "we could not tell" is precisely the case the
guard exists for.

Two stamp tests built the request by hand without an identity and are updated to
build it the way setup does. The new regression substitutes the directory in the
one interval where a substitution can land, between the snapshot's close and the
apply's open, and asserts the substitute collects nothing and the prior stamp
survives byte for byte.
2026-09-04 14:44:22 +05:30
Vasanthdev2004 ea56072536 test(sandbox): reproduce the lease junction race, not a pre-planted junction
The first version of this test planted the junction before acquisition started,
which the old alias pre-check already refused, so it passed against the
implementation it was supposed to condemn. The defect is the check-then-use
window: os.MkdirAll and the pathname lease open both resolved the component
again after the check had passed.

A pre-create seam both implementations honour lets the junction land in exactly
that window.
2026-09-04 14:40:03 +05:30
Vasanthdev2004 00573812b2 fix(sandbox): acquire the setup lease through the handle boundary too
Provisioning already descends from the fixed cache or TEMP base through
retained no-follow handles, because a predictable owned component is exactly
what an ordinary same-account process can replace with a junction. Lease
acquisition runs first and did neither: it checked the components for aliases,
then called os.MkdirAll on the parent by pathname and opened "<root>.lease" by
pathname. Both follow.

A junction dropped on zero, runtime or v1 between the check and either call put
elevated setup's first writes inside the caller's chosen target. Restoring the
component afterwards left the later handle-relative provisioning operating on
the legitimate tree, so no post-check saw it, and the rollback could not
compensate writes it had no record of. The lease file made it worse by being a
sibling of the runtime root rather than one of its owned components, so the
alias check never inspected that name at all.

Lease acquisition now splits the owned tail, creates only the base by name,
descends the components above the leaf from retained handles, and creates the
lease file relative to the deepest of them with FILE_OPEN_IF,
FILE_NON_DIRECTORY_FILE and FILE_OPEN_REPARSE_POINT. It refuses rather than
falling back when the path is not a runtime root, and it reports the
directories it created so a failure can undo them. The leaf stays with
provisioning, which records it: two owners for one directory is what the
accounting is meant to avoid.

Non-Windows keeps its previous behaviour, alias check included.

The existing lease tests pre-created the whole tree before every call, so no
test exercised the creation path, which is how this shipped green. The new
regressions plant a real junction, assert nothing lands in the redirected
target, and pin that acquisition records what it created and only that.
2026-09-04 14:38:17 +05:30
Vasanthdev2004 bcbdd65c18 fix(sandbox,doctor): fix the boundary each carried fact crosses
Three instances of one shape: the right fact was established, but only after
crossing the point where it had to become authoritative.

Runtime creation. createRuntimeDirRecording found the deepest component that
already existed with os.Stat and opened THAT by name, so existence chose the
trust boundary. On the ordinary second-workspace shape zero\runtime\v1 is
already there and only the digest is missing, so the single by-name open was v1,
one of the predictable components the rooted traversal exists to protect. A local
owner could swap it for a junction between the pre-check and that open, let
elevated setup create the digest beneath the redirected target, and restore the
original before the post-check, leaving a privileged creation recorded under a
pathname that no longer named it. The split now comes from the same inventory
that built the root: only the cache or TEMP directory above the owned tail is
opened by name, every owned component below it is reached from the retained
parent handle, and a root without that shape is refused rather than walked by
name.

Setup handoff. Zero never elevates: the operator elevates the terminal and the
helper is launched with a plain exec.Command, so the consumer SID was resolved
in a process that was already elevated and described the installer. Same-account
UAC hides that; alternate administrator credentials do not, and the stamp would
get its allow ACE for a token that can never read it. That case cannot be
detected from inside an already-elevated process, so it is excluded rather than
mis-provisioned: the helper now refuses when the carried identity is not its own
token, before anything is provisioned, and the comments no longer claim an
elevation boundary that does not exist.

Doctor. WindowsSandboxRecordedRuntimeRootIsCurrent answers current, stale, or
unresolved, and the third fell through to the pinned branch: a valid old marker
made doctor report healthy while BuildCommandPlan hit the same resolution failure
and stopped every command from launching. Unresolved is now its own warning
carrying the resolver cause, and the pinned validation profile is built only once
currentness has been established.

The regressions drive the production entry points. The recording test asserts the
single by-name open is the fixed base, which is the property; asserting only that
a swap was caught was not discriminating, because the mutated code refused for an
unrelated reason. Two rollback tests were using a root missing the leading owned
component, a shape production never builds, and now use the real one.
2026-09-03 13:19:34 +05:30
Vasanthdev2004 fcedabf2ed fix(doctor): ask whether a command would still select the recorded root
Doctor pinned the marker's runtime root onto the profile so the stamp check
could run without taking a lease. A command does not pin blindly: it derives
the current cache and fallback candidates and honours the marker only when its
root is one of them. Run setup with the user cache at A, relocate the cache so
commands derive B, and the stamped A tree stays behind. Doctor reused A,
constructed its expected plan, and reported healthy immediately before every
real command rejected A as not a current candidate and failed on the
out-of-date marker.

The marker's root is history, needed to validate the stamp without mutating
anything. It is not proof the root is still selectable, and doctor conflated
the two. WindowsSandboxRecordedRuntimeRootIsCurrent derives the same
candidates a command derives, through the same resolver, and applies the same
equality by calling pinnedSandboxRuntimeRoot itself, so doctor asks the
command's own question rather than a copy of it. Only a root a command would
still select is used to validate the marker; otherwise doctor reports the
setup out of date with the runtime root it found and the remedy. It still
takes no lease and creates nothing.

The regression records a marker with the cache resolver at A, flips the
resolver to B, and asserts the recorded root is no longer current while its
value is unchanged. Making the helper trust the marker unconditionally fails
it on that assertion. An earlier draft of this test planted the marker
without setting profile.Runtime, so it failed on its own setup guard and its
falsification proved nothing; that is why it is worth saying the failure is
on the property.
2026-09-02 12:21:40 +05:30
Vasanthdev2004 427e7161ee fix(sandbox): create the owned runtime tail from retained handles
createRuntimeDirRecording guarded with Lstat before and after, but the
interval between them was still path-based: it found the deepest existing
ancestor with os.Stat and then created each missing component by opening its
parent BY NAME. Both follow a junction. A local user could replace the
predictable owned "zero" component with a junction after the pre-check, point
it at a directory of their choosing, let elevated setup create
runtime\v1\<hash> beneath that target, and restore the original before the
post-check. The post-check then saw an ordinary path, the plan applied
normally, and rollback reported the separately created target only as
identity-mismatched residue.

Pre and post pathname inspection was being used as authorization for an
elevated mutation. Now the deepest existing ancestor is the only thing opened
by name, and every missing component below it is addressed relative to its
parent's handle with FILE_OPEN_REPARSE_POINT: an existing component is opened
no-follow and refused if it is a link, and a missing one is created relative
to the parent handle and identified from the handle the create returned. No
name is resolved twice, so there is no interval for a swap to land in, and a
junction at any owned component is seen as the link it is. Redirected cache
and TEMP locations above the owned tail are still allowed; the restriction is
on the zero/runtime/v1/<hash> components Zero itself owns. Off Windows the
mkdir loop is unchanged.

A deterministic barrier between opening the base and touching the first owned
component lets a test plant a junction at exactly the vulnerable interval.
Driven with a real mklink /J: the descent refuses, records nothing, and the
junction target is untouched. The old by-name create against the same
junction returned nil and landed a directory under the target, which is the
hole verbatim. A control test creates the tail from handles and checks each
recorded identity against the directory the name resolves to afterwards.
2026-09-02 12:20:37 +05:30
Vasanthdev2004 9a289b9c07 fix(sandbox): prove the runtime create and stamp records before mutating
Two rollback records were still assembled from facts that were never
established, so compensation was correct about the wrong thing.

The created-directory record took its identity from a pathname resolved after
creation. Win32 CreateFile cannot create a directory whatever disposition it is
given, so the CREATE_NEW attempt fell through to os.Mkdir plus a separate
reopen on EVERY real creation, and the documented creation-handle contract
never once held. The runtime parent belongs to the ordinary user, who can
rename the new directory away and put an ordinary one at the predictable name
in between; the ledger then recorded the substitute, and a failed setup could
delete it while the directory this run actually created kept the ACL and stamp
under a name nothing was tracking.

NtCreateFile with FILE_CREATE and FILE_DIRECTORY_FILE does create a directory
and returns the handle, so identity is read from the object created and there
is no second resolution to race. A collision still surfaces as a *PathError
carrying os.ErrExist, because the caller asks os.IsExist, which does not unwrap
a %w chain: wrapping alone would have turned a benign lost race into a hard
setup failure. An unidentifiable create now errors instead of manufacturing an
ownership record.

The stamp record collapsed every failure into proven absence. An encoding
failure, a root that would not open, an unreadable identity, a denied child
open and a short read all arrived as existed=false, exactly like a real
not-found. The writer uses FILE_OVERWRITE_IF and can replace an existing stamp
where the read was denied, and compensation for "did not exist" deletes the
current stamp and returns with nothing to restore, so a setup attempt that
REPORTED FAILURE destroyed the attestation of the previous successful setup.

The snapshot now returns three states. Only ERROR_FILE_NOT_FOUND and
ERROR_PATH_NOT_FOUND produce absent; a complete read produces present; anything
else is unknown and returns an error. Setup refuses on unknown before the ACL
and stamp are applied, and restore refuses to compensate an unproven prior
state, so the two halves of the guard sit on both sides of the mutation. The
zero value is unknown deliberately.

Tests cover the three states separately, an unreadable prior stamp left
byte-for-byte intact with the writer never reached, and a create/reopen barrier
that counts pathname resolutions. One honest limit on that barrier: it counts
resolutions through runtimeIdentityAfterCreate, which the non-Windows path uses
and which is documented as the pathname reopen. It would not catch a future
hand-rolled one.
2026-09-02 11:56:28 +05:30
Vasanthdev2004 b3c0ac91cf fix(sandbox): assemble rollback records from one object, not one name
Compensation verifies and mutates through a single handle now, but the records
it trusts were still built from separate pathname opens.

snapshotWindowsSandboxRuntimeStamp took the identity through a handle, closed
it, and then let os.ReadFile resolve the name again. A rename and substitution
in that interval pairs one directory's identity with another's stamp bytes, and
a rollback that correctly proves it holds the first writes the second's contents
into it, corrupting an attestation that predates this run. Reading the stamp as
a child of the identified handle removes the second resolution.

The creation ledger had the same split: os.Mkdir created the directory and
runtimeDirIdentity reopened the name, so the ledger could record a substituted
object this run never made. Rollback would then prove it held that object and
delete it, while the real one kept this run's ACL and stamp under a name nothing
was tracking. The identity now comes from the creation itself.

The lease stops cleanup selecting the root; it does not stop the parent's owner
renaming it, which is what makes both windows reachable.

The race needs an elevated installer against an unelevated renamer and is not
reproducible here, so the tests pin the contract instead: both halves of a
snapshot come back together and agree with the object at the path, and a created
directory's identity describes what was created.
2026-09-02 11:56:28 +05:30
Vasanthdev2004 7fb33c381a fix(sandbox): carry the stamp reader across the elevation boundary
Resolving the reader from the runtime leaf's owner does not name the token that
consumes the stamp, because the elevated helper CREATES that leaf when it is
absent. The owner is then commonly BUILTIN\Administrators, and the repair-identity
collision fix correctly folds the reader into the Administrators grant rather
than emitting a duplicate ACE.

Both steps are right on their own and wrong together. Production crosses a token
boundary that the test did not: a later UAC-filtered administrator carries
Administrators deny-only, and a standard user given alternate administrator
credentials is not in the group at all. Neither can satisfy an allow ACE, so the
protected stamp holds no enabled grant for the token that has to validate it,
ValidateWindowsSandboxSetupMarker fails immediately after setup reported
success, every restricted command stops before launch, and doctor calls the
setup unusable.

Resolve the consumer in the operator's shell instead, before elevation, and
carry it in as --consumer-sid. That is the same rule the runtime root already
follows two lines away: selected where the answer is knowable, not derived where
it is not.

The owner fallback stays for the paths that are not setup, notably rollback
recreating a stamp it just removed, where the leaf already exists and belongs to
whoever owns the install. A test pins that the carried identity outranks the
leaf owner, since that precedence is the entire difference between the two
designs.
2026-09-02 11:56:27 +05:30
Vasanthdev2004 e7c23b3b92 fix(sandbox): treat only proven absence as a verified removal
The post-deletion probe distinguished only err == nil from err != nil, so every
Lstat failure took the success path. A second process holding a share-delete
handle leaves the entry delete-pending, and an access denial or sharing
violation from the probe then reported complete compensation for a directory
that is still there. A holder able to clear the disposition can make the
"removed" object visible again, after setup has already said the rollback
finished. Ordinary inspection failures were hidden the same way, and those
carry no evidence about the postcondition either.

Three outcomes rather than two: not-found proves absence and returns nil, a
successful stat means it is still present, and anything else is reported as
residue whose removal could not be verified.

The probe goes through a seam so a test can produce that third outcome, which
no real filesystem yields on demand. Reverting to the two-way check fails the
unverifiable case naming it as complete compensation.
2026-09-02 11:56:27 +05:30
Vasanthdev2004 c879333290 fix(sandbox): keep repair write when the reader is a repair identity
The resolved stamp reader can BE one of the repair identities. A runtime root
created by an elevated process is commonly owned by BUILTIN\Administrators
rather than by the invoking user, which is what CI runners do.

Naming the same SID twice, once read-only as the reader and once GENERIC_ALL as
repair, let the narrower entry win: Administrators came back with mask
0x00130089 and could no longer rewrite the stamp. Setup would succeed and leave
an attestation nothing could replace.

Skip the reader entry when a repair identity already covers it, and pin it with
a test that resolves the reader to Administrators directly, so the case is
exercised on an unelevated box too. My own regression caught this, but only on
CI, because here the directory is owned by the ordinary user.

That is also why the existing assertion had to change: no-write is right for an
ordinary owner and wrong for one that is itself repair.
2026-09-02 11:56:27 +05:30
Vasanthdev2004 fbd6b47c64 test(sandbox): fail the rollback fixture on identity, not on a stale path
TestRollbackContinuesAfterACompensationFails injected its failure with a
snapshot pointing at an unwritable stamp path. That stopped meaning anything
once compensation began deriving the stamp from the verified root handle rather
than the recorded path: the restore wrote a stamp INTO the directory the
rollback then tries to remove, so the removal failed for a reason this test is
not about.

It passed on an unelevated box and failed on all three CI runners, because
whether that recreate succeeds depends on the token the tests run under.

Inject a root that is no longer the directory this run stamped, which is a shape
production produces and which writes nothing on any platform.
2026-09-02 11:56:27 +05:30
Vasanthdev2004 8b63915ff1 fix(sandbox): mutate compensation targets through the verified handle
The identity check and the mutation used two different resolutions of the same
name: runtimeDirIdentity opened a handle, read the volume and file ID, closed
it, and then os.Remove or os.WriteFile resolved the pathname again. A rename
followed by a replacement in that interval makes the comparison true about one
object while the write or the delete lands on another. This is elevated
compensation, so a redirected pathname gives the mutation reach the replacer
does not have directly.

Open once, verify the identity on that handle, and perform the mutation through
it: the stamp relative to the directory handle, the created directory by
FileDispositionInfo on its own handle. No ancestor is re-resolved and there is
no interval to land in. A seam between the check and the mutation drives the
replacement in tests; with the old pathname resolution they fail, naming the
substitute.

Two things fell out of doing it properly.

The stamp restore now deletes and recreates through the ordinary writer rather
than overwriting. The stamp carries a protected DACL that withholds write, so
an in-place overwrite is denied under the token that wrote it, and only the
writer puts that DACL back on the replacement.

The reader ACE gains DELETE alongside read. Withholding write from the SANDBOX
is the real boundary and the capability SID has no ACE here at all. Withholding
it from the root owner is not one: they own the parent, so delete-then-create
forges a stamp exactly as well as an overwrite. What read-only actually cost
was rollback's ability to remove a stamp this run wrote.

A directory removal that reports success is verified rather than assumed, for
the reason recorded on the promote rename in #751.
2026-09-02 11:56:27 +05:30
Vasanthdev2004 da85c67244 fix(sandbox): refuse compensation it cannot bind to this run's object
Both identity capture sites discarded runtimeDirIdentity's success flag, so a
root that could not be opened was indistinguishable from one with no identity,
and both mutation sites read the empty string as "skip the check". Stamp
restoration then wrote to, or removed from, whatever answered to the pathname
afterwards, and directory rollback treated every reopened object as eligible
for removal. This is elevated compensation, so a pathname redirected after the
capture gives the mutation reach the replacer would not have directly.

Carry the flag and refuse: an identity that was never established is not
permission to mutate. The object is left alone and reported as residual state.

A root that is simply ABSENT stays quiet, because there is no object to confuse
and nothing of a previous run to put back; the created-directory rollback owns
that case. Without that distinction every fresh setup would report a
compensation failure, and a test pins it.

The test helper that builds created-directory records had the same discarded
flag, which is why the existing rollback tests passed against the fail-open
path. It now carries the identity through the way production does.

This does not close the check-then-mutate window itself: the identity is still
read on a handle that closes before the pathname operation. That half is
separate.
2026-09-02 11:56:27 +05:30
Vasanthdev2004 5737cb3c8f fix(sandbox): grant the stamp to the identity that validates it
The stamp's DACL named WinCreatorOwnerSid at GENERIC_ALL. SetSecurityInfo does
substitute that placeholder even in a NO_INHERITANCE ACE, so a concrete SID did
land in the ACE, measured again on this head: ACE[0] carries the user SID at
mask 0x1f01ff and the readback succeeds. The mechanism was never the problem.

The identity it named was. It is whoever ran setup, and setup runs elevated.
When elevation comes from a different administrator account than the one that
later runs the command or zero doctor, the reader matches no ACE, and a reader
named in no ACE gets Access is denied on os.ReadFile. A successful setup then
hands over an attestation the launch gate and doctor cannot open.

Resolve the reader from the runtime root the stamp is created in, through the
directory handle rather than a pathname, so the grant is bound to the install
rather than to the elevation. Give it read only: nothing outside setup and
repair should be able to rewrite an attestation about the tree, and the stamp
write still succeeds because the handle was opened GENERIC_WRITE before the
DACL was applied.

A reader that could not be resolved is refused rather than protected with a
DACL naming nobody.
2026-09-02 11:56:27 +05:30
Vasanthdev2004 4b2e10ad68 test(sandbox): give the lease tests the runtime root shape production uses
Owned depth is the fixed names plus the digest, so a root placed directly in
t.TempDir() put an owned component on /tmp. The Unix ownership guard then
correctly refused it, because /tmp belongs to root and the test runs as an
ordinary user. That is the guard working rather than an environment problem, and
it only appears off Windows: both ubuntu Smoke and Zero Review failed on it while
the Windows run was green.

The roots are built as <temp>/zero/runtime/v1/<leaf> now, so every component the
alias and ownership walk inspects was created by the test.
2026-09-02 11:56:27 +05:30
Vasanthdev2004 ddd236f045 fix(sandbox): hold the runtime lease through setup, and bind compensation to the object
The unelevated caller took a lease only to learn which root wins and released it
at once, so nothing owned the selected root while the elevated helper provisioned
the tree, applied the ACL and stamp, installed network state and wrote the
marker. A command for another workspace scanning the same runtime parent excludes
only its own current root, so it could take this root's cleanup lease and
RemoveAll it mid-transaction, leaving setup to publish success for a pathname
that was gone. The helper now holds a shared lease on the root it was handed,
from before provisioning until after the marker write, and fails before any
persistent state is written if it cannot take one.

Compensation resolved pathnames again after the apply handles had closed, so a
rename-aside plus an ordinary directory at the same name let it strip a stamp
from the substitute, write another object's bytes onto it, and remove it as
though this run had created it, while the original kept the grant and the stamp.
The stamp snapshot and every created-directory record now carry the identity of
the object they describe and refuse a replacement, reporting the original as
residual instead.

os.SameFile cannot express that on Windows. A Windows fileStat loads its volume
serial and file index lazily, BY PATHNAME, at comparison time, so an identity
captured before a replacement and compared after reports the substitute as the
same object and the original as different: exactly backwards, and silently.
Measured rather than reasoned about. runtimeDirIdentity reads the identity
through a handle at capture time instead, with the Unix build using device and
inode so both platforms follow one rule.
2026-09-02 11:56:26 +05:30
Vasanthdev2004 25075d0a09 fix(sandbox): one sandbox-home authority, and keep the tests out of the real cache
Runtime preparation resolved the sandbox home from the ambient environment while
Windows platform planning resolves ZERO_WINDOWS_SANDBOX_HOME out of the command's
own spec.Env and hands that one to the runner for marker validation. A command
that explicitly selects home B, while the parent still points at home A, pinned
A's recorded root into the profile; the runner then loaded B's marker, saw a
different root, and rejected the command as out of date even though setup for B
was valid. The two homes need no different derivation rules to disagree, only
different valid selections from the same preferred/fallback pair. Selection now
takes the home the command asked for, and an empty one still resolves the ambient
environment because that is the only authority a caller without command context
has. The refusal of a root belonging to another workspace is unchanged.

Separately, plan construction creates directories, which is easy to miss because
it reads like naming: windowsSandboxProfileWithProvisionedRuntime provisions the
root it selects, and the simulated-Windows tests run on every platform. Nothing
redirected the cache, so the package wrote into the developer's real one. The
machine this was found on had 64 runtime directories and 9115 orphaned lease
files accumulated under the real runtime root. Redirected once for the package
rather than per test, because the leak was in the default and any new test that
builds a Windows plan would inherit it.
2026-09-02 11:56:26 +05:30
Vasanthdev2004 9f34f4ab3e fix(sandbox): put the grant attestation on the launch gate, not in the marker
The grant check belongs beside the tier's other launch decision rather than
inside ValidateWindowsSandboxSetupMarker. That function compares what setup
intended with what this command wants, which is what its name asks and what
every consumer of it expects; folding a security-descriptor read into it made
`zero doctor` depend on real applied ACLs and report a freshly set-up machine
unhealthy.

Both tiers now attest in runWindowsSandboxCommand: the unelevated one reads the
descriptors and re-applies, the restricted-token one reads them and refuses,
because it cannot repeat an elevated provisioning. The refusal is covered
through the runner rather than only through the function, since a check that
reads correctly and is never called is the failure mode being avoided.
2026-09-02 11:56:26 +05:30
Vasanthdev2004 c965339908 fix(sandbox): attest the grant on both tiers, and roll back through one handle
Three things the end-to-end pass over the runtime-root lifecycle turned up.

The elevated tier attested its runtime root with a stamp file. That proves the
directory was not removed and recreated under the same pathname and that setup
provisioned it for this configuration, and it says nothing about whether the
capability ACE still carries the permissions windowsACLAccess created or still
reaches descendants. An ordinary file survives an ACL edit untouched, so an
icacls reset, an inheritance change on a parent, or a security product rewriting
the DACL leaves a valid stamp over a runtime root the restricted child cannot
write. This is the finding already closed on the unelevated tier, still open on
the tier that runs under the restricted token. It reads the descriptors now, and
refuses with the action that fixes it rather than re-applying, because this tier
cannot repeat an elevated provisioning.

The grant check counted an INHERIT_ONLY allow ACE towards the directory's own
grant. Such an ACE grants descendants and grants the directory nothing, so the
child is still refused FILE_ADD_FILE on the runtime root while every inherit flag
the check looks for is present: the propagation half of the grant taken as
evidence for the whole of it.

Rollback checked object identity through one open and then resolved the name
again for the restore. The two opens are a check-then-use, and the fact
established is not the fact the write depends on. One open now, with the
identity read from the handle that gets mutated.
2026-09-02 11:56:26 +05:30
Vasanthdev2004 fb96d065c0 fix(sandbox): attest the effective grant, not the presence of the SID
The attestation accepted any allow ACE naming the capability. That is weaker
than the contract the child depends on: windowsACLAccess grants
FILE_GENERIC_READ|WRITE|EXECUTE, and a directory entry carries
SUB_CONTAINERS_AND_OBJECTS_INHERIT so the grant reaches the TMP and cache
descendants the child actually writes.

So an ACE that still named the capability but had been reduced to a read-only
or metadata mask, or that no longer propagated, attested as healthy. Both
consumers skipped the idempotent re-apply and the restricted child reached a
marker-valid runtime root and got ACCESS_DENIED on its first write, which is
the silent unusable runtime the attestation was added to remove.

Attestation now unions the allow masks of the ACEs that actually propagate and
requires the plan's own mask, and a deny naming the capability over any
required bit fails closed rather than being ignored. The regression drives the
positive case plus four weakenings that all keep the SID: read+execute,
metadata only, full mask without inheritance, and containers-only inheritance.
Removing either half of the check fails the cases belonging to that half.
2026-09-02 11:56:26 +05:30
Vasanthdev2004 c73e47a025 fix(sandbox): recognise the user-scoped fallback as an owned runtime tail
Scoping the fallback's first component to the user broke the shape the owned
tail is matched by, because the matcher compares that position against the one
fixed name. The fallback therefore stopped being recognised on Unix, which
silently costs it both protections that depend on the shape: the rooted
no-follow traversal falls back to opening the tree by name, and the
owned-component guard no longer sees it. TestTheTempFallbackRootKeepsTheOwnedShape
exists for exactly that reason and caught it on CI.

The first position now accepts either spelling, the fixed one the cache root
uses or the user-scoped one the fallback uses, and every component below it
stays fixed so a tree Zero does not own is still refused.

The scoped spelling does not exist on Windows, so the accepting branch could
not be exercised there at all and a break in it would only ever surface on
another platform's CI. The matcher reads the fallback names through a seam so
the shape test can drive both spellings, including a different user's scope,
on any platform.
2026-09-02 11:56:26 +05:30
Vasanthdev2004 29795b49b1 fix(sandbox): make rollback prove it holds the object it changed
The forward apply and its stamp go through one handle, so they are provably
about one object. Compensation runs later, after a network or marker failure,
and resolves those names again. Opening no-follow refuses a reparse point but
accepts an ordinary directory moved into the name since the handle closed, so a
rename plus a substitute made rollback restore the pre-apply DACL onto the
substitute and report success, while the moved original kept this run's
capability ACE. Setup claimed a completed rollback with the modified object
still reachable elsewhere, having also mutated a directory the forward
operation never touched.

The snapshot now records the object identity read from the open handle, which
is the last moment the name and the object are known to be the same thing, and
the restore refuses to act unless the name still answers to it. On a mismatch
the substitute is left alone and the error says the original is unrestored,
rather than reporting success.

Materialized targets are deliberately not identity-checked. A materialized
target is one this run created, and its plan routinely denies Everyone read,
which is what a protected metadata carve-out is, so the attributes identity
needs cannot be read back even by the owner: requiring it turned rollback of
every materialized directory into "Access is denied". Establishing identity
there means holding the apply handle open to the last failure point, which is a
larger change than this one and is called out rather than papered over.
2026-09-02 11:56:26 +05:30
Vasanthdev2004 072d3fe260 fix(sandbox): attest the object before trusting the applied-plan marker
The unelevated marker records a plan hash and entry count, both derived from
pathnames and entries. That says what should be granted; it cannot establish
that whatever directory currently answers to the name has received it.

The runtime root makes the gap reachable rather than theoretical, because it is
deterministic and disposable. Cleanup removes the tree, the next command's
parent recreates the same pathname with ordinary inherited permissions, and the
hash is unchanged, so the fast path skipped the apply and the WRITE_RESTRICTED
child could not write TMP or its language and package caches. Nothing failed to
say why.

The fast path now reads the security descriptor of each allow entry and takes
it only when the grant is actually present. That costs one descriptor read per
entry on a path that is about to create a process, and it covers every reason a
grant can be missing rather than only recreation by the parent: manual
deletion, eviction between commands, a restored backup. Any failure reads as
"not applied", because re-applying an existing grant is idempotent while
skipping an absent one produces a sandbox that silently cannot write.
2026-09-02 11:56:25 +05:30
Vasanthdev2004 b45b38d247 fix(sandbox): put the user boundary above every private fallback component
The temp-derived runtime root scoped its LEAF to the user and left
zero/runtime/v1 fixed above it. Runtime preparation creates and
ownership-checks each of those at 0700, and on Unix os.TempDir() is shared
whenever TMPDIR is unset, so the first account to use the fallback created a
private directory every other account was then refused at: traversal fails on
the mode, and relaxing the mode fails the ownership guard instead. The
per-workspace digest never got the chance to separate them, and the fallback
became first-user-wins on a shared host. The MkdirTemp layout this replaced did
not have that problem, because each process sat under its own 0700 directory.

The scope moves to the shallowest component, so every ancestor the guards
create and validate is already inside one user's namespace. The workspace
digest stays the leaf, so setup and the command still derive the same root for
the same workspace.

Windows keeps the fixed names deliberately: its temp root already resolves
inside the user profile, and windowsSandboxRuntimeOwnedTail compares those
names to recognise a root built by an elevated setup running as another
account. The test pins both directions so neither platform's choice can drift
into the other.
2026-09-02 11:56:25 +05:30
Vasanthdev2004 bff135a60e test(sandbox): compare the DACL's entries, not the descriptor's SDDL
The rollback test compared the full SDDL of the security descriptor before and
after, which carries the owner, the group and the control flags as well as the
access. It passed here and failed on the Windows runner with the three access
entries byte-identical on both sides: SetSecurityInfo sets
SE_DACL_AUTO_INHERITED when it writes a DACL, and GetNamedSecurityInfo did not
report the owner and group the same way there.

None of that grants anyone anything, and the test is about whether a capability
grant survived a failed setup, so compare the access-control entries instead.
The failure message now names the entry that survived rather than printing two
SDDL strings to diff by eye.
2026-09-02 11:56:25 +05:30
Vasanthdev2004 e280f0ebdd fix(sandbox): make the ACL and its stamp one transaction, and fail setup without a runtime root
Three things, all on the elevated Windows setup path.

The capability ACL is committed by SetSecurityInfo before the ride-along stamp
is written, and the apply returns a rollback closure only on success, so a
stamp failure left the caller with nothing to compensate with. Setup reported
failure while leaving the grant on a pre-existing runtime root. The captured
descriptor is now restored through the same bound handle before the error goes
back, and a test hook makes that path reachable.

Runtime-root selection was best effort in BuildWindowsSandboxSetupArgs: on
failure setup carried on and wrote a marker recording no runtime root, having
already applied the ACLs. A later command selects a concrete root of its own,
finds no stamp on it and refuses, and re-running setup takes the same branch
again. That is the permanent brick the recorded-root contract exists to
prevent, reached by the one path allowed to skip it. Selection failure now
fails setup before any ACL or marker state is persisted.

The fallback-runtime test stubbed only the cache derivation, so the temp
derivation still read the process environment and the deterministic root landed
in the developer's real temp directory and stayed there. Both inputs are now
test owned, and the test asserts the redirect took rather than assuming it.
2026-09-02 11:56:25 +05:30
Vasanthdev2004 63322d059e fix(sandbox): stop the sandbox rewriting the attestation about itself
The runtime root grants the capability SID FILE_GENERIC_WRITE with
SUB_CONTAINERS_AND_OBJECTS_INHERIT, which is what lets a sandboxed command
write TMP, GOCACHE, GOMODCACHE and the package-manager caches beneath it. The
stamp is created inside that root, so it inherited the same grant. A restricted
command carrying the capability could open and overwrite the file attesting its
own setup after passing its own pre-launch validation. Its command would
continue, and every later elevated command and zero doctor would then reject
the altered plan hash until an Administrator re-ran setup: a sandboxed process
bricking the sandbox for everything after it.

Moving the attestation outside the tree is the other way to close it and is
worse. The stamp works precisely because it dies with the tree, which is how
eviction is detectable without reading an ACE. Keeping it inside with
inheritance switched off preserves that property.

The stamp now gets an explicit DACL naming only CREATOR OWNER, LocalSystem and
Administrators, applied with PROTECTED_DACL_SECURITY_INFORMATION. Protected
rather than merely explicit, because without that bit the inherited capability
ACE stays in the DACL beside whatever is set. Both writers do it, and both open
with WRITE_DAC now, which the regression caught: SetSecurityInfo needs it on the
handle and the first version failed with "Access is denied".

The regression grants a stand-in capability SID an inheritable write on a real
root, asserts an ordinary descendant DOES inherit it so the case is genuinely
exercised, then asserts the stamp neither grants that SID nor leaves its DACL
unprotected, and that setup can still read back what it wrote.
2026-09-02 11:56:25 +05:30
Vasanthdev2004 b1166032f3 fix(sandbox): stamp the runtime tree through the handle that carries the ACE
The stamp is supposed to prove that the directory a command later uses is the
one setup granted the capability ACE to, and writing it afterwards by pathname
cannot prove that however careful the second open is. The ACE went on through a
rooted handle, that handle closed, network setup ran, and only then did the
marker write re-open the name. A local process that can reach the user-owned
runtime tree removes the predictable root in that window and puts an ordinary
directory in its place. The re-open rejects a junction correctly, but an
ordinary replacement is not a reparse point and is not the ACL-bearing object
either, so it collected a valid-looking stamp. Marker validation then passed
over a directory with no capability ACE, and the next WRITE_RESTRICTED command
failed its cache and TMP writes while setup insisted it was current.

The stamp now rides along with the ACE. The marker is built before the apply so
the plan hash is known, the apply writes the stamp through the same handle it
just set the DACL on, and the marker write records the file and never names the
runtime tree again. The rollback snapshot moves ahead of the apply for the same
reason, so a failed setup restores what it found rather than its own artifact.

The regression drives the real apply and swaps the directory in the window
between the two, which is the only place the difference shows. A direct
handle-level test cannot see it: any name derived from a retained handle
resolves back to the same object, so a pathname write derived that way lands
correctly and the test passes either way. I had written that test first and it
survived the falsification, so it is gone rather than left looking like cover.

Also: the doctor stamp test was creating, removing and recreating a directory
under the developer's REAL user cache, because the runtime candidate is derived
from the process's actual cache directory. The workspace digest made a collision
with a live runtime tree unlikely rather than impossible, which is not the
standard for a test that calls RemoveAll. It redirects the cache to test-owned
storage now, and fails loudly if that ever stops working.
2026-09-02 11:56:25 +05:30
Vasanthdev2004 725967f85b fix(sandbox): stop building the runtime tree through a path other users can name
An audit of this branch turned up a regression this PR itself introduced, plus a
gap it left in doctor.

The temp fallback root used to come from os.MkdirTemp, which mints a random name
atomically at mode 0700 so no other local account can name it. Deriving it from
a digest of the workspace path bought stable cache reuse and gave the name away:
every component is computable, and on Linux os.TempDir() is the world-writable
/tmp whenever TMPDIR is unset. os.MkdirAll returns nil when Stat says the path
is already a directory and Stat follows links, so a link planted at an owned
component was accepted silently, the cache, data and tmp directories were built
inside whatever it pointed at, Chmod and Chtimes followed it too, and the root
was then handed to the backend as a write root (a read-write bind under bwrap)
with TMPDIR, GOCACHE, GOMODCACHE and the package-manager caches inside it. The
sandbox would have been granting the confined command write access to a
directory somebody else chose. Reproduced end to end before fixing.

The fallback digest now covers a per-user scope, and every component Zero owns
is checked for a link and for foreign ownership before anything is created
through it and again afterwards, on the shared path all three platforms take.
A link or a foreign owner is refused outright rather than relocated around,
because relocating leaves the link in place and moves to the next predictable
name; an ordinary file sitting where a component belongs is a broken machine
rather than a hostile one and still relocates, as before.

doctor built its profile with PermissionProfileFromPolicy, which never sets
Runtime, and validateWindowsSandboxRuntimeStamp returns nil early in that case.
So the one check that can tell an evicted runtime tree from a healthy one was
skipped and `zero doctor` reported a healthy sandbox on exactly the state the
stamp was added to detect. It reads the recorded root from the marker now, which
takes no lease and creates nothing.

One test in this branch was vacuous and is fixed here too: t.TempDir() names its
directory after the test, the subtest was called "link N levels above the leaf",
and strings.Contains(err.Error(), "link") was matching the path rather than the
reason, so all four subtests passed with the refusal deleted. Both that test and
the Windows traversal test now assert the sentinel and a distinctive phrase.
2026-09-02 11:56:25 +05:30
Vasanthdev2004 62a835c2e0 fix(sandbox): record the runtime root, open it by handle, roll back completely
Three problems in the Windows setup-to-command contract.

Setup selected a runtime root, released the lease and recorded a plan hash over
pathnames, and a command later ran the same selector again. Selection consults a
lease, and a lease is a fact about one moment: with the cache root briefly
unusable setup provisioned and recorded the temp fallback, and once it freed up
the command selected the cache root instead. Two selections, two roots, a marker
that could never validate, and re-running setup did not help because setup was
equally free to pick the other one. The concrete root is recorded in the marker
now and the command consumes it. A recorded root is only honoured when it is one
of the two this workspace derives, so a root belonging to another workspace is
ignored, and a recorded root that cannot be leased fails with a message naming
the situation instead of relocating into a tree nothing provisioned.

The reparse-point checks inspected pathnames, and everything after them reopened
the tree by name. FILE_FLAG_OPEN_REPARSE_POINT governs only the final component,
so a junction planted at an owned ancestor after the last check was followed and
the elevated capability ACL landed on a leaf inside the attacker's directory.
Junctions need no privilege. A second check narrows that window and cannot close
it, so the owned tail is now walked one component at a time through NtCreateFile
relative to the handle above it, refusing a reparse point at each step, and the
resulting handle is what the ACL apply and the stamp write use. The stamp used
MkdirAll and a pathname write, which was the same hole again after the ACL had
been applied. The base above the owned components is still followed: a
redirected LOCALAPPDATA is ordinary configuration, not an attack.

Rollback returned as soon as the ACL restore reported an error, so the runtime
rollback never ran and the failure most likely to leave a machine in a strange
state was the one that skipped half the cleanup. Every compensation runs now and
the errors are joined. The stamp is part of the rollback record too: it lands
inside the runtime root before the marker is renamed into place, so a late
failure used to leave a root that was non-empty and therefore unremovable by
design. A stamp that was already there is restored rather than deleted, so a
machine whose previous setup succeeded does not start reporting itself broken.
2026-09-02 11:56:24 +05:30
Vasanthdev2004 e3a65bca32 fix(sandbox): make the selected runtime root a setup-to-command contract, and refuse to provision through a link
The three P1s, and they were one defect: setup recorded what it INTENDED, a
fingerprint of a plan built from a root it merely derived, and never what it
actually provisioned.

A command derived the same cache root, failed to LEASE it, and silently
relocated to the temp fallback. Its plan then named a tree setup had never
provisioned, and the marker rejected it:

  windows sandbox setup is out of date: permission roots or deny lists changed
  (marker plan e0b1c3fec819, 2 entries; this command wants 8a75a38d0006, 2 entries)

blaming permissions for a runtime-root disagreement. Re-running setup could not
recover, because sandboxRuntimeRootFor rejects a candidate only for landing
inside the workspace, never for being unusable, so setup chose the same
unleasable root again. The only escapes were deleting the marker, which silently
drops WFP network enforcement, or turning the sandbox off, and the error
mentions neither. Setup and commands now select through one function, lease
attempt and fallback included, so a relocation is something they agree on.

The marker also could not tell whether the directory its pathnames resolve to
was still the one setup provisioned. cleanupSandboxRuntimeRoots evicts inactive
roots and the next run recreates the same pathname with ordinary inherited
permissions; the plan hash is over pathnames, so both marker checks reported
setup as current while the tree carried no capability ACE and a WRITE_RESTRICTED
token could not write TMP or GOCACHE. Setup now stamps the tree it provisioned,
alongside the marker and after the ACL has applied, and validation reads it. A
file inside the tree survives exactly as long as the tree does, so eviction is
detectable without needing to read an ACE.

Provisioning also created by pathname. Every component below the user cache root
is ours, a local user needs no privilege to create a junction, and planting one
at zero, runtime or v1 made the leaf land in their tree while
openWindowsACLTarget saw no reparse point ON THE LEAF and would have granted the
sandbox capability write access to a directory they control. Refused at every
component we own, before and after creation, and deliberately NOT above them:
a redirected LOCALAPPDATA is an ordinary configuration.

That last point caught a regression from the previous commit on this branch. Its
existence walk used os.Lstat, which reports a junction as not-a-directory, so a
redirected cache root was refused outright with "exists and is not a directory".
Existence follows links now; whether a link is acceptable is the separate
question above.

The marker schema is bumped, because a marker written by the previous version
has no stamp and requiring one without a bump would report every already-set-up
machine as broken rather than as out of date.

NOT VERIFIED HERE, said plainly: the elevated apply needs Administrator and this
machine is not. Everything above was exercised unelevated through the real
entry points, and the ACL write itself was not.
2026-09-02 11:56:24 +05:30
Vasanthdev2004 5fc455b726 fix(sandbox): roll back runtime roots a failed setup created, and keep the provisioning tests in owned storage
Two of the five findings from review. The three P1s are not addressed here and I
have said so on the PR rather than implying otherwise.

Setup materializes runtime roots before the network plan, the ACL apply, the
network apply and the marker write. Any of those can fail, and the rollback that
existed restored only ACL snapshots, so a run that reported failure still left
new persistent runtime directories behind. It could not have cleaned them up
even in principle, because provisioning returned nothing about what it had made.

Provisioning now records the components it actually created and hands back a
rollback, composed once at the top of the elevated path so no later failure can
forget it. It removes only what this run created, innermost first, and uses
os.Remove rather than os.RemoveAll: a directory that is not empty by then holds
something this run did not create, and removing it would turn a failed setup
into data loss. Refusing keeps the residue findable and reports it.

The provisioning test derived a real user-cache runtime path because it took a
bare t.TempDir() instead of the owned fixture the neighbouring tests use, so it
created and then RemoveAll'd a genuine ~/.cache/zero/runtime tree on every run
and failed outright on a read-only home. runtimeRootTestConfig now routes
through windowsRuntimeTestRoots, which redirects every derivation input before
any candidate is computed and refuses to run if a candidate escapes the owned
roots. Centralizing it there covers the other tests built on that config too.
2026-09-02 11:56:24 +05:30
Vasanthdev2004 1811e7b775 fix(sandbox): retry the final-path buffer whenever the size is not a success value 2026-09-02 11:56:24 +05:30
Vasanthdev2004 851bf7b054 test(sandbox): alias the cache by its real spelling, not a stacked case alias 2026-09-02 11:56:24 +05:30
Vasanthdev2004 3d5cdefbc9 fix(sandbox): decide runtime-root containment on physical paths, not spellings 2026-09-02 11:56:24 +05:30
Vasanthdev2004 b57d3e4abf test(sandbox): assert the case-folding contract only where it exists 2026-09-02 11:56:24 +05:30
Vasanthdev2004 d64cc67f9d fix(sandbox): name both plan hashes when the setup marker mismatches 2026-09-02 11:56:24 +05:30
Vasanthdev2004 1b4f880cb6 test(sandbox): decide the alias skip by filesystem identity, not by the function under test 2026-09-02 11:56:24 +05:30
Vasanthdev2004 6fcd5224ac test(sandbox): compare canonical spellings in the ownership guard
The guard added with the provisioning tests measured a canonicalized
candidate against a raw t.TempDir(), so it fired on every machine where
those two spellings differ and passed on the one where they do not. macOS
resolves /var to /private/var and the CI Windows runner's profile has an
8.3 name, so both failed; this box has neither and stayed green.

That is the same one-sided comparison this PR fixes in
fallbackSandboxRuntimeRoot, written into the guard meant to protect
against it. Both sides go through canonicalSandboxWorkspaceRoot now.
2026-09-02 11:56:23 +05:30
Vasanthdev2004 2a71cafdcf fix(sandbox): pin the Windows runtime root instead of deriving it twice
Review found the setup marker depended on the caller's transient TEMP.
Setup run from one terminal recorded a plan containing that shell's
temp-derived candidate; a later parent process started by an IDE or a
service with a different TEMP built a different path, and every command
failed the marker's equality check while the cache runtime sat there
untouched and healthy. That is the outage in #881 arriving by another
route.

The cause was two derivations of the same thing. sandboxRuntimeRootFor
decides the tree the process uses; windowsSandboxRuntimeCandidates
separately decided the tree the marker fingerprints, and folded in BOTH
candidates so that whichever the command picked was covered. That bought
agreement at the price of putting os.TempDir() into a machine-wide
fingerprint.

This removes the second derivation rather than trying to keep two in
step. windowsSandboxRuntimeRoots pins to profile.Runtime.Root when the
profile has one, because by then prepareSandboxRuntime has chosen,
created and leased that tree, so the plan names exactly what the command
writes to, including after a lease-failure relocation. Callers with no
runtime yet (elevated setup, doctor) derive through sandboxRuntimeRootFor
itself, so setup and command cannot drift.

The lease-failure relocation in prepareSandboxRuntime is deliberately
left alone. It is load-bearing off Windows, where there is no marker and
relocation is inherently consistent, and removing it would trade this bug
for a cross-platform one.

Scope, since the finding is broader than what this closes: the runtime
root no longer tracks TEMP, but the full plan hash still does, because
PermissionProfileFromPolicy grants os.TempDir() as a write root when the
policy allows temp. That predates this PR. The test says so in a comment
rather than asserting a stability the code does not yet have.

Both new tests fail with the change reverted.
2026-09-02 11:56:23 +05:30
Vasanthdev2004 4791a0a7d2 test(sandbox): keep the provisioning tests inside test-owned directories
The new provisioning tests derived their runtime candidates from the real
os.UserCacheDir() and then cleared one to prove provisioning recreates it,
so every run deleted a directory under the developer's actual cache and
the tests failed outright on a read-only home. The test has no ownership
claim on that path, so it must not derive one.

windowsRuntimeTestRoots now stubs sandboxUserCacheDir and TMP/TEMP to
t.TempDir() before deriving anything, and refuses to run at all if a
derived candidate falls outside those owned roots, so a later change to
the derivation cannot quietly reintroduce this.

Measured against the real cache directory: the previous version added an
entry on every run, this one adds none.
2026-09-02 11:56:23 +05:30
Vasanthdev2004 920e26e8ce fix(sandbox): create the Windows runtime roots the ACL plan grants
Review found that the runtime roots this PR folds into the permission
profile were granted but never created. applyWindowsACLPlan materializes
only DenyRead targets, so an AllowWrite target that does not exist fails
the whole run with "windows ACL target does not exist" -- turning a clean
`zero sandbox setup` into a different outage than the one being fixed.

ensureWindowsSandboxRuntimeCandidates already existed for exactly this
reason and its doc comment names the failure, but the split from the
parent PR carried the function across without its call site.

Provisioning now sits with whoever computes the candidates, because the
two have to happen in the same environment:

- buildWindowsSandboxSetupACLPlan provisions, then builds the elevated
  plan.
- windowsSandboxProfileWithProvisionedRuntime provisions, then returns
  the command profile, and BuildCommandPlan calls it in the PARENT. The
  unelevated tier applies its own plan inside the re-exec'd runner, where
  TEMP points into the runtime tree, so the runner can derive neither the
  paths nor the directories.

Also from review:

- doctor checked the marker against the bare profile, so `zero doctor`
  reported a correctly prepared machine as out of date. It now folds in
  the same roots setup and the command plan use.
- fallbackSandboxRuntimeRoot derived from a raw os.TempDir() while
  pathWithinRoot compares spellings, so an aliased TEMP read as outside
  the workspace. Canonicalized like the workspace and cache roots. This
  closes short-name and symlink aliases, not junctions, which
  EvalSymlinks returns unchanged; the comment says so rather than
  implying the case is shut.
- windowsSandboxRuntimeCandidates deriving from the first workspace root
  only is deliberate, not a defect: the marker compares plan hashes for
  equality and a command presents one root, so widening it would put
  entries in the marker no command reproduces. Documented and pinned by a
  test, since the suggested fix would have reintroduced the outage.

Tests drive production entry points rather than the helpers behind them.
The previous round shipped tests that called the helpers directly and
stayed green with the call sites deleted, which is how the missing
provisioning survived. Each of the four new assertions was verified to
fail with its fix reverted.
2026-09-02 11:56:23 +05:30
Vasanthdev2004 e68fb87467 fix(sandbox): agree on the runtime root across the Windows setup marker
Every exec_command on a Windows machine that had run `zero sandbox setup`
aborted with "windows sandbox setup is out of date: permission roots or
deny lists changed". File tools worked, only shell execution died, and
`zero doctor` reported the backend healthy throughout, so there was
nothing pointing at the cause. Reported in #881 with an accurate trace.

Setup fingerprinted the bare permission profile into the marker. Every
command arrived with the per-workspace runtime root already appended by
permissionProfileWithRuntime, so the plan hash the runner computed could
never match the one setup stored, and a marker written seconds earlier
was rejected forever.

Both sides now fold in the same runtime candidate set before the profile
is fingerprinted.

Three things this needs to get right, each of which broke it once.

BOTH candidates, not the one this process would pick.
sandboxRuntimeRootFor prefers the cache-derived root and falls back to
the temp-derived one when the cache sits inside the workspace, and that
choice is per process. Granting only one left a command that fell back
writing to a tree with no ACE on it.

The fallback has to be derived rather than minted. It used os.MkdirTemp
memoized in a process-global map, so the answer was private to whichever
process asked first: setup granted temp root A, the next command derived
root B, teardown cleaned a third. It is now a hash of the workspace and
creates nothing, so every process agrees without sharing state.

The runner cannot derive the candidates itself. It runs re-exec'd with
TEMP and TMP already pointed at the sandbox runtime temp, so os.TempDir()
there returns the redirected value and the temp-derived candidate comes
out rooted under the runtime tree. The profile is augmented in the parent
and passed down.

Split out of #808, which carries this fix among the Windows principal
work. That PR has open architectural questions and this does not, and
the bug is a user-visible outage on one platform, so it should not wait
on them.

On the tests, because one of them was not enough. The composition test
calls the helper directly and stays green even with the production call
site deleted, which is the same class of bug as the fix itself. The
added call-path test drives BuildCommandPlan and asserts the runtime
roots reach the runner's argv; reverting the runner call fails it with
the missing root named.
2026-09-02 11:56:23 +05:30
194 changed files with 14328 additions and 8400 deletions

No files matched your search

+20 -45
View File
@@ -962,25 +962,16 @@ 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: agent.ToolResultSessionPayload(result),
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...),
},
}
}
@@ -1153,14 +1144,13 @@ 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"`
EnforcementNotices []string `json:"enforcementNotices"`
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"`
}
if json.Unmarshal(raw, &payload) != nil {
return nil
@@ -1180,27 +1170,12 @@ 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: tools.WithoutEnforcementNotices(payload.Output, notices),
EnforcementNotices: notices,
ChangedFiles: append([]string(nil), payload.ChangedFiles...),
ToolCallID: id,
Name: payload.Name,
Status: status,
Output: payload.Output,
ChangedFiles: append([]string(nil), payload.ChangedFiles...),
})
return &upd
}
@@ -1,195 +0,0 @@
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
@@ -1,65 +0,0 @@
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)
}
}
+1 -5
View File
@@ -248,11 +248,7 @@ func toolCallResult(result agent.ToolResult) ToolCallUpdate {
func toolResultContent(result agent.ToolResult) []ToolCallContent {
content := make([]ToolCallContent, 0, 1+len(result.FileDiffs))
// 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")
text := strings.TrimRight(result.Output, "\n")
if text == "" {
text = result.Display.Summary
}
-160
View File
@@ -1,160 +0,0 @@
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
@@ -1,210 +0,0 @@
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
}
@@ -1,129 +0,0 @@
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")
}
}
@@ -1,114 +0,0 @@
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)
}
}
+2 -92
View File
@@ -84,102 +84,12 @@ 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, 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)
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)
}
}
+36 -191
View File
@@ -1467,31 +1467,10 @@ 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 {
outcome, blocked := dispatchBeforeTool(ctx, options, call, args)
if blocked {
if outcome, blocked := dispatchBeforeTool(ctx, options, call, args); blocked {
return blockedByHookResult(call, outcome), nil
}
beforeToolNotices = outcome.Notices
}
args = shellExecutionArgsForApproval(call.Name, args, decisionAction, options)
@@ -1537,10 +1516,7 @@ 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 {
// 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
return *directResult, abortErr
}
result = retryResult
permissionGranted = true
@@ -1566,15 +1542,7 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
// afterTool hooks run once the tool has executed; their output (e.g. a
// formatter or vet result) is surfaced back to the model on the result.
if toolFound {
// 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) != "" {
if feedback := dispatchAfterTool(ctx, options, call, args, result); feedback != "" {
var didRedact bool
result.Output, didRedact = appendHookFeedback(result.Output, feedback)
if didRedact {
@@ -1601,32 +1569,28 @@ 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.
//
// 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),
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),
// 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"],
}, beforeToolNotices, afterToolNotices), nil
}, nil
}
const sandboxNamespaceLimitedReason = "sandbox output is limited to the sandbox PID namespace; host/global state requires approval"
@@ -2010,9 +1974,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, []string) {
func dispatchAfterTool(ctx context.Context, options Options, call ToolCall, args map[string]any, result tools.Result) string {
if options.Hooks == nil || hooksSuppressed(options) {
return "", nil
return ""
}
outcome := options.Hooks.Dispatch(ctx, hooks.DispatchInput{
Event: hooks.EventAfterTool,
@@ -2028,13 +1992,7 @@ func dispatchAfterTool(ctx context.Context, options Options, call ToolCall, args
"changedFiles": result.ChangedFiles,
},
})
// 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
return strings.TrimSpace(strings.Join(outcome.Messages, "\n"))
}
// dispatchSessionStart runs configured sessionStart hooks once before the first
@@ -2098,7 +2056,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)
result := ToolResult{
return ToolResult{
ToolCallID: call.ID,
Name: call.Name,
Status: tools.StatusError,
@@ -2106,124 +2064,12 @@ 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
@@ -2358,19 +2204,18 @@ func askUserFallbackResult(ctx context.Context, registry *tools.Registry, call T
Cwd: options.Cwd,
})
return ToolResult{
ToolCallID: call.ID,
Name: call.Name,
Status: result.Status,
Output: result.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,
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,
}
}
return ToolResult{
-84
View File
@@ -1,84 +0,0 @@
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
}
+6 -28
View File
@@ -79,10 +79,6 @@ 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
@@ -114,42 +110,24 @@ type ToolResult struct {
RequestedModel string
}
// BaseModelOutput is the bounded provider-facing result WITHOUT the enforcement
// disclosure composed into it, mirroring tools.Result.BaseModelOutput.
//
// A surface that renders the typed EnforcementNotices itself must build its body
// from here, or the disclosure appears twice. Decoration has exactly one owner
// per surface: either the text carries it or the surface draws it, never both.
func (result ToolResult) BaseModelOutput() string {
// 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 {
if result.Outcome.Finalized() {
return result.Outcome.ModelView
}
return result.Output
}
// BaseDisplay is BaseModelOutput's presentation half, and carries no enforcement
// notices for the same reason.
func (result ToolResult) BaseDisplay() tools.Display {
// HumanDisplay returns the presentation intended for interactive surfaces.
func (result ToolResult) HumanDisplay() 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,19 +874,6 @@ 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
@@ -982,10 +969,6 @@ 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,
+19 -38
View File
@@ -365,18 +365,6 @@ 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)
@@ -734,7 +722,25 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
},
OnToolResult: func(result agent.ToolResult) {
writer.toolResult(result)
sessionRecorder.append(sessions.EventToolResult, persistedToolResultPayload(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)
},
OnUsage: func(u agent.Usage) {
writer.usage(u)
@@ -1498,28 +1504,3 @@ 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
@@ -1,47 +0,0 @@
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"])
}
}
+16 -1
View File
@@ -161,7 +161,22 @@ func runExecSpecDraft(run execSpecDraftRun) int {
if info, ok := execSpecDraftInfoFromToolResult(result); ok {
draftInfo = info
}
sessionRecorder.append(sessions.EventToolResult, persistedToolResultPayload(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.Redacted {
payload["redacted"] = true
}
if len(result.ChangedFiles) > 0 {
payload["changedFiles"] = result.ChangedFiles
}
sessionRecorder.append(sessions.EventToolResult, payload)
},
OnUsage: func(u agent.Usage) {
writer.usage(u)
@@ -1,127 +0,0 @@
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())
}
}
-163
View File
@@ -1,163 +0,0 @@
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)
}
}
@@ -1,52 +0,0 @@
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,11 +3,9 @@ package cli
import (
"context"
"fmt"
"io"
"net/url"
"sort"
"strings"
"sync"
"github.com/Twigpine/zero/internal/config"
"github.com/Twigpine/zero/internal/execution"
@@ -192,107 +190,3 @@ 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
@@ -1,146 +0,0 @@
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)
}
}
@@ -1,72 +0,0 @@
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)
}
}
+12 -3
View File
@@ -174,7 +174,7 @@ func runSandboxSetup(args []string, stdout io.Writer, stderr io.Writer, deps app
if !setupHelper.Available() {
return writeAppError(stderr, "Windows sandbox setup helper is not available", exitProvider)
}
setupArgs, err := zeroSandbox.BuildWindowsSandboxSetupArgs(zeroSandbox.WindowsSandboxSetupArgsOptions{
setupPlan, err := zeroSandbox.BuildWindowsSandboxSetupArgs(zeroSandbox.WindowsSandboxSetupArgsOptions{
CommandCWD: workspaceRoot,
WorkspaceRoots: []string{workspaceRoot},
PermissionProfile: profile,
@@ -182,9 +182,18 @@ func runSandboxSetup(args []string, stdout io.Writer, stderr io.Writer, deps app
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
setupArgs = append(append([]string{}, setupHelper.ArgsPrefix...), setupArgs...)
setupArgs := append(append([]string{}, setupHelper.ArgsPrefix...), setupPlan.Args...)
// BUILDING THE ARGS ALREADY WROTE TO DISK. Selecting the runtime root takes a
// lease, and taking a lease creates the runtime tree and the lease file when
// they are not there. A helper that never publishes a marker leaves that tree
// behind, attested by nothing, so the undo belongs on this side of the process
// boundary where the record of it lives.
if err := deps.runSandboxSetupHelper(setupHelper.Name, setupArgs, stdout, stderr); err != nil {
return writeAppError(stderr, "Windows sandbox setup failed: "+err.Error(), exitProvider)
message := "Windows sandbox setup failed: " + err.Error()
if undo := setupPlan.Rollback(); undo != nil {
message += "\nleftover sandbox runtime state could not be removed: " + undo.Error()
}
return writeAppError(stderr, message, exitProvider)
}
return writeSandboxSetupResult(stdout, options.json, sandboxSetupResult{
Platform: "windows",
+70 -4
View File
@@ -99,11 +99,77 @@ func windowsSandboxSetupCheck(goos string, backend sandbox.Backend, workspaceRoo
return &result
}
profile := sandbox.PermissionProfileFromPolicy(workspaceRoot, doctorSandboxPolicy(sandboxConfig), scope)
// A RECORDED ROOT IS HISTORY, NOT PROOF. Doctor pins the marker's runtime
// root so the stamp check can run without taking a lease, but a command does
// not pin blindly: it derives the current cache and fallback candidates and
// honours the marker only when its root is one of them. Run setup with the
// cache at A, relocate the cache so commands derive B, and the stamped A tree
// remains: pinning A here reported a healthy sandbox immediately before every
// real command rejected A and failed on the out-of-date marker.
//
// So ask the command's own question first, through the command's own
// function. Only a root a command would still select is used to validate
// the marker; anything else is reported as out of date, with the remedy.
//
// THREE STATES, NOT TWO. The helper answers current, stale, or "I could not
// tell", and the last one used to fall through to the pinned branch: a valid
// old marker then made doctor report healthy while selectSandboxRuntimeRoot,
// reached by BuildCommandPlan, propagated the same resolution error and stopped
// every command from launching. Historical state is not a substitute for a
// failed current selection, so an error is surfaced as its own warning with the
// cause, and the pinned validation profile is built only once currentness has
// actually been established.
recorded, current, rootErr := sandbox.WindowsSandboxRecordedRuntimeRootIsCurrent(sandboxHome, workspaceRoot)
if rootErr != nil {
result := check("sandbox.backend", "Sandbox backend", StatusWarn, fmt.Sprintf("Native sandbox backend %s is installed, but the runtime root a command would select cannot be resolved (%s), so the recorded setup cannot be trusted and commands are likely to fail to launch.", backend.Name, rootErr.Error()), map[string]any{
"backend": string(backend.Name),
"platform": goos,
"supportLevel": string(backend.SupportLevel()),
"setupStatus": "runtime-root-unresolved",
"error": rootErr.Error(),
"remedy": "run `zero sandbox setup` to prepare the Windows native sandbox for the current cache location",
})
return &result
}
if recorded != "" && !current {
result := check("sandbox.backend", "Sandbox backend", StatusWarn, fmt.Sprintf("Native sandbox backend %s is installed, but the runtime root setup recorded (%s) is not one a command would select now, so the setup is out of date.", backend.Name, recorded), map[string]any{
"backend": string(backend.Name),
"platform": goos,
"supportLevel": string(backend.SupportLevel()),
"setupStatus": "runtime-root-stale",
"runtimeRoot": recorded,
"remedy": "run `zero sandbox setup` to prepare the Windows native sandbox for the current cache location",
})
return &result
}
setupConfig := sandbox.WindowsSandboxSetupConfig{
SandboxHome: sandboxHome,
CommandCWD: workspaceRoot,
WorkspaceRoots: []string{workspaceRoot},
PermissionProfile: profile,
SandboxHome: sandboxHome,
CommandCWD: workspaceRoot,
WorkspaceRoots: []string{workspaceRoot},
// The same augmentation setup and the command plan apply, so doctor
// fingerprints what a real command fingerprints. Checking the bare profile
// made doctor call a correctly prepared machine "out of date", which is the
// mismatch this pairing exists to close. Safe to resolve in this process:
// doctor runs in the operator's shell, not behind the sandbox TEMP
// redirection that stops the runner deriving these for itself.
//
// The RECORDED runtime root goes on the profile as well, because that is
// what makes the stamp check run at all. Without it profile.Runtime is nil,
// validateWindowsSandboxRuntimeStamp returns nil early, and doctor reported
// a healthy sandbox on a machine whose runtime tree had been evicted and
// silently recreated without the capability ACE -- precisely the state the
// stamp exists to catch, and the state where every sandboxed command then
// fails with nothing explaining why.
//
// Read from the marker rather than selected, so doctor takes no lease and
// creates nothing, and read ONCE: this is the root whose currentness was
// checked above, not a second read that a rewritten marker could change. A
// marker from an older schema records no root, which leaves this exactly as
// it was.
PermissionProfile: sandbox.WindowsSandboxProfileWithRuntimeRoots(
sandbox.PermissionProfileWithRuntimeRoot(profile, recorded),
[]string{workspaceRoot},
),
}
if err := sandbox.ValidateWindowsSandboxSetupMarker(setupConfig); err != nil {
result := check("sandbox.backend", "Sandbox backend", StatusWarn, fmt.Sprintf("Native sandbox backend %s is installed, but Windows sandbox setup is missing or out of date: %v.", backend.Name, err), map[string]any{
@@ -0,0 +1,247 @@
package doctor
import (
"os"
"runtime"
"strings"
"testing"
"github.com/Twigpine/zero/internal/config"
"github.com/Twigpine/zero/internal/sandbox"
)
// DOCTOR HAS TO ASK THE ONE QUESTION THE MARKER CANNOT ANSWER.
//
// The marker hashes ACL-plan entries, which are pathnames. Whether the directory
// those pathnames resolve to is still the tree setup provisioned is a different
// question, and the runtime stamp is what answers it. validateWindowsSandboxRuntimeStamp
// returns nil early when profile.Runtime is nil, which is correct for the setup
// side and for unrestricted profiles, and was wrong here: doctor built its
// profile with PermissionProfileFromPolicy, which never sets Runtime, so the
// check was skipped and `zero doctor` reported a healthy sandbox on exactly the
// state the stamp was added to detect -- an evicted runtime tree, silently
// recreated with ordinary permissions and no capability ACE, where every
// sandboxed command then fails with nothing explaining why.
// doctorRuntimeCandidate returns a runtime root this workspace would really
// select, taken from the candidate set the Windows plan folds in.
//
// It has to be a REAL candidate. An arbitrary path is already in the ACL plan on
// one side and not the other, so the plan hashes diverge and validation fails
// before the stamp is ever consulted -- which would make this test fail without
// the fix for a reason that has nothing to do with the stamp.
// redirectUserCache points os.UserCacheDir at test-owned storage.
//
// WITHOUT THIS THE TEST WRITES INTO THE DEVELOPER'S REAL CACHE. The runtime
// candidate is derived from the process's actual user cache directory, so the
// test was creating, recursively removing, recreating and then cleanup-removing
// a directory under the real %LocalAppData%zerountime (or ~/.cache/zero on
// Unix). The workspace digest made a collision with a live runtime tree
// unlikely rather than impossible, and "unlikely" is not the standard for a
// test that calls RemoveAll.
//
// All three variables, because os.UserCacheDir reads a different one per
// platform: %LocalAppData% on Windows, $XDG_CACHE_HOME or $HOME on Unix, and
// $HOME on macOS.
func redirectUserCache(t *testing.T) {
t.Helper()
cache := t.TempDir()
t.Setenv("LOCALAPPDATA", cache)
t.Setenv("XDG_CACHE_HOME", cache)
t.Setenv("HOME", cache)
}
func doctorRuntimeCandidate(t *testing.T, workspace string) string {
t.Helper()
bare := doctorProfile(t, workspace)
augmented := sandbox.WindowsSandboxProfileWithRuntimeRoots(bare, []string{workspace})
existing := map[string]bool{}
for _, root := range bare.FileSystem.WriteRoots {
existing[root.Root] = true
}
for _, root := range augmented.FileSystem.WriteRoots {
if !existing[root.Root] {
return root.Root
}
}
t.Skip("no runtime candidate is derivable in this environment")
return ""
}
func doctorProfile(t *testing.T, workspace string) sandbox.PermissionProfile {
t.Helper()
scope, err := sandbox.NewScope(workspace, nil)
if err != nil {
t.Fatalf("NewScope: %v", err)
}
return sandbox.PermissionProfileFromPolicy(workspace, doctorSandboxPolicy(config.SandboxConfig{}), scope)
}
func writeDoctorSetupMarker(t *testing.T, home, workspace, runtimeRoot string) {
t.Helper()
profile := doctorProfile(t, workspace)
setup := sandbox.WindowsSandboxSetupConfig{
SandboxHome: home,
CommandCWD: workspace,
WorkspaceRoots: []string{workspace},
PermissionProfile: sandbox.WindowsSandboxProfileWithRuntimeRoots(
sandbox.PermissionProfileWithRuntimeRoot(profile, runtimeRoot),
[]string{workspace},
),
}
if _, err := sandbox.WriteWindowsSandboxSetupMarker(setup); err != nil {
t.Fatalf("WriteWindowsSandboxSetupMarker: %v", err)
}
}
func TestDoctorReportsAnEvictedRuntimeTree(t *testing.T) {
home := t.TempDir()
workspace := t.TempDir()
t.Setenv("ZERO_WINDOWS_SANDBOX_HOME", home)
redirectUserCache(t)
runtimeRoot := doctorRuntimeCandidate(t, workspace)
// Belt and braces: if the redirection above ever stops working, fail loudly
// rather than quietly operating on the developer's real cache.
if !strings.HasPrefix(runtimeRoot, os.TempDir()) && !strings.Contains(runtimeRoot, t.Name()) {
t.Fatalf("the runtime candidate %q is outside test-owned storage; this test creates and removes that path", runtimeRoot)
}
if err := os.MkdirAll(runtimeRoot, 0o700); err != nil {
t.Fatalf("create the runtime root: %v", err)
}
t.Cleanup(func() { _ = os.RemoveAll(runtimeRoot) })
writeDoctorSetupMarker(t, home, workspace, runtimeRoot)
backend := sandbox.Backend{Name: sandbox.BackendWindowsRestrictedToken}
// A healthy machine first, or the eviction assertion below would be satisfied
// by a check that warns unconditionally.
if result := windowsSandboxSetupCheck("windows", backend, workspace, config.SandboxConfig{}); result != nil {
t.Fatalf("a freshly set-up machine was reported unhealthy: %s", result.Message)
}
// cleanupSandboxRuntimeRoots evicts inactive roots on an age and count policy,
// and the next run recreates the pathname with inherited permissions. The plan
// hash never moves, so only the stamp can tell.
if err := os.RemoveAll(runtimeRoot); err != nil {
t.Fatalf("evict the runtime root: %v", err)
}
if err := os.MkdirAll(runtimeRoot, 0o700); err != nil {
t.Fatalf("recreate the pathname the way an ordinary run would: %v", err)
}
result := windowsSandboxSetupCheck("windows", backend, workspace, config.SandboxConfig{})
if result == nil {
t.Fatal("doctor reported a healthy sandbox while the provisioned runtime tree was gone; every sandboxed command on this machine would fail with nothing explaining why")
}
if !strings.Contains(strings.ToLower(result.Message), "setup") {
t.Errorf("the warning does not point at setup: %s", result.Message)
}
}
// UNKNOWN IS NOT CURRENT.
//
// WindowsSandboxRecordedRuntimeRootIsCurrent answers three ways: the recorded
// root is still one a command would select, it is not, or the inputs a command
// selects from cannot be resolved at all. The third used to fall through to the
// pinned branch, so a valid old marker and stamp made doctor report healthy
// while selectSandboxRuntimeRoot — reached by BuildCommandPlan — propagated the
// same resolution failure and stopped every command from launching. Doctor and
// the command disagreed about the same machine.
//
// Stale and unknown are different states, so this is deliberately separate from
// the cache-A-to-cache-B test rather than another assertion on the same boolean.
func TestDoctorWarnsWhenTheRuntimeRootCannotBeResolved(t *testing.T) {
home := t.TempDir()
workspace := t.TempDir()
t.Setenv("ZERO_WINDOWS_SANDBOX_HOME", home)
redirectUserCache(t)
runtimeRoot := doctorRuntimeCandidate(t, workspace)
if !strings.HasPrefix(runtimeRoot, os.TempDir()) && !strings.Contains(runtimeRoot, t.Name()) {
t.Fatalf("the runtime candidate %q is outside test-owned storage", runtimeRoot)
}
if err := os.MkdirAll(runtimeRoot, 0o700); err != nil {
t.Fatalf("create the runtime root: %v", err)
}
t.Cleanup(func() { _ = os.RemoveAll(runtimeRoot) })
writeDoctorSetupMarker(t, home, workspace, runtimeRoot)
backend := sandbox.Backend{Name: sandbox.BackendWindowsRestrictedToken}
// Healthy first, so the assertion below cannot be satisfied by a check that
// warns unconditionally.
if result := windowsSandboxSetupCheck("windows", backend, workspace, config.SandboxConfig{}); result != nil {
t.Fatalf("SETUP INVALID: a freshly set-up machine was reported unhealthy: %s", result.Message)
}
// Now take away the inputs the selection is derived from, leaving the marker
// and the provisioned tree exactly as they were. os.UserCacheDir fails with no
// location to read, which is the same error a command would hit.
t.Setenv("LOCALAPPDATA", "")
t.Setenv("XDG_CACHE_HOME", "")
t.Setenv("HOME", "")
result := windowsSandboxSetupCheck("windows", backend, workspace, config.SandboxConfig{})
if result == nil {
t.Fatal("doctor reported a healthy sandbox while the runtime root a command selects could not be resolved; every command would fail to launch with doctor saying nothing")
}
if status, _ := result.Details["setupStatus"].(string); status != "runtime-root-unresolved" {
t.Errorf("setupStatus = %q, want runtime-root-unresolved so the unknown state is not reported as staleness: %s", status, result.Message)
}
if _, ok := result.Details["error"]; !ok {
t.Errorf("the warning does not carry the resolver cause, so an operator cannot act on it: %+v", result.Details)
}
}
// A CACHE DIRECTORY THAT RESOLVES TO NOTHING IS UNRESOLVED TOO, not only one
// that fails to resolve. os.UserCacheDir succeeds on a value like "." and the
// selection a command makes refuses it after canonicalizing, with "user cache
// directory is unavailable". The diagnostic behind this check did not, so doctor
// went on to compare the recorded root against candidates derived from an empty
// cache root and reported staleness, which sends an operator to re-run setup on
// a machine where setup cannot select a root either.
//
// The sandbox package pins the two entry points to one error through the
// resolver seam on every platform. This is the consumer's half, and it is
// Windows-only because that is where the environment can produce the value:
// %LocalAppData% is returned as it is, while a relative XDG_CACHE_HOME is an
// error on Linux and a relative HOME still joins to a usable path on macOS.
// Reported by @jatmn.
func TestDoctorReportsAnEmptyCacheRootAsUnresolvedNotStale(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("only %LocalAppData% hands os.UserCacheDir a value that canonicalizes to nothing")
}
home := t.TempDir()
workspace := t.TempDir()
t.Setenv("ZERO_WINDOWS_SANDBOX_HOME", home)
redirectUserCache(t)
runtimeRoot := doctorRuntimeCandidate(t, workspace)
if !strings.HasPrefix(runtimeRoot, os.TempDir()) && !strings.Contains(runtimeRoot, t.Name()) {
t.Fatalf("the runtime candidate %q is outside test-owned storage", runtimeRoot)
}
if err := os.MkdirAll(runtimeRoot, 0o700); err != nil {
t.Fatalf("create the runtime root: %v", err)
}
t.Cleanup(func() { _ = os.RemoveAll(runtimeRoot) })
writeDoctorSetupMarker(t, home, workspace, runtimeRoot)
backend := sandbox.Backend{Name: sandbox.BackendWindowsRestrictedToken}
if result := windowsSandboxSetupCheck("windows", backend, workspace, config.SandboxConfig{}); result != nil {
t.Fatalf("SETUP INVALID: a freshly set-up machine was reported unhealthy: %s", result.Message)
}
for _, value := range []string{".", " . "} {
t.Setenv("LOCALAPPDATA", value)
result := windowsSandboxSetupCheck("windows", backend, workspace, config.SandboxConfig{})
if result == nil {
t.Fatalf("LOCALAPPDATA=%q: doctor reported a healthy sandbox while no command can select a runtime root", value)
}
if status, _ := result.Details["setupStatus"].(string); status != "runtime-root-unresolved" {
t.Errorf("LOCALAPPDATA=%q: setupStatus = %q, want runtime-root-unresolved: %s", value, status, result.Message)
}
if cause, _ := result.Details["error"].(string); !strings.Contains(cause, "user cache directory is unavailable") {
t.Errorf("LOCALAPPDATA=%q: the warning does not carry the resolver cause a command would report: %+v", value, result.Details)
}
}
}
-146
View File
@@ -1,146 +0,0 @@
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
@@ -1,168 +0,0 @@
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) }
+1 -62
View File
@@ -173,11 +173,6 @@ 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 {
@@ -187,18 +182,7 @@ type Outcome struct {
Exit *Exit `json:"exit,omitempty"`
Denial *Denial `json:"denial,omitempty"`
Enforcement Enforcement `json:"enforcement"`
// 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"`
Changes []Change `json:"changes,omitempty"`
}
// AdapterReport is the structured, machine-readable result emitted by a
@@ -206,51 +190,6 @@ 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
@@ -1,111 +0,0 @@
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)
}
})
}
@@ -1,199 +0,0 @@
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")
}
}
+29 -112
View File
@@ -79,12 +79,8 @@ type ProcessResult struct {
Enforcement Enforcement
Report AdapterReport
ReportErr error
// 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
Changes []Change
Metadata map[string]string
}
type ProcessSnapshot struct {
@@ -166,7 +162,6 @@ 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,
@@ -428,46 +423,31 @@ func (manager *ProcessManager) removeCompletedLater(process *managedProcess) {
}
type managedProcess struct {
id int
commandText string
cwd string
relativeCwd string
startedAt time.Time
lastUsedAt time.Time
tty bool
command *exec.Cmd
request Request
enforcement Enforcement
ownedLaunch bool
// 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
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
}
func (process *managedProcess) markDone(err error, exitCode int, report AdapterReport, reportErr error, changes []Change) {
@@ -475,76 +455,14 @@ 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
@@ -555,9 +473,8 @@ 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.launchedReportLocked(), ReportErr: process.reportErr,
ChildLaunchOwnedByAdapter: process.ownedLaunch,
Changes: append([]Change(nil), process.changes...), Metadata: cloneStringMap(process.metadata),
Enforcement: process.enforcement, Report: process.resultReport, ReportErr: process.reportErr,
Changes: append([]Change(nil), process.changes...), Metadata: cloneStringMap(process.metadata),
}
process.mu.Unlock()
return result
-128
View File
@@ -1,128 +0,0 @@
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,12 +24,6 @@ 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 {
@@ -96,25 +90,10 @@ 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(),
@@ -122,7 +101,6 @@ func (runner *Runner) ExecuteCaptured(ctx context.Context, input CapturedRequest
Err: runErr,
Outcome: Outcome{
Enforcement: prepared.Enforcement,
Launched: launched,
},
}
exitCode := commandExitCode(runErr)
@@ -179,28 +157,6 @@ 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,
@@ -1,103 +0,0 @@
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)
}
})
}
+6 -74
View File
@@ -36,25 +36,7 @@ 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 {
@@ -63,13 +45,6 @@ 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
@@ -164,10 +139,6 @@ 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(),
}
}
}
@@ -211,19 +182,11 @@ 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 = blockCause(result)
outcome.Reason = blockReason(result)
// Stop on the first veto: the action is already denied.
return outcome
}
@@ -280,38 +243,14 @@ 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 {
message := strings.TrimSpace(result.Stdout)
if message == "" {
message = strings.TrimSpace(result.Stderr)
if trimmed := strings.TrimSpace(result.Stdout); trimmed != "" {
return trimmed
}
return message
return strings.TrimSpace(result.Stderr)
}
// 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 {
func blockReason(result commandResult) string {
if result.TimedOut {
if trimmed := strings.TrimSpace(result.Stderr); trimmed != "" {
return "hook timed out: " + trimmed
@@ -349,14 +288,7 @@ 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,
// 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...),
}},
Results: []AuditResult{{ExitCode: result.ExitCode, Stdout: result.Stdout, Stderr: result.Stderr}},
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 := blockCause(timedOut); !strings.Contains(reason, "timed out") {
t.Fatalf("blockCause = %q, want a timeout message", reason)
if reason := blockReason(timedOut); !strings.Contains(reason, "timed out") {
t.Fatalf("blockReason = %q, want a timeout message", reason)
}
}
@@ -1,119 +0,0 @@
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)
}
}
}
@@ -1,6 +0,0 @@
//go:build !windows
package hooks
// sleepScript keeps a launched child alive long enough for a timeout to fire.
const sleepScript = "sleep 2"
@@ -1,6 +0,0 @@
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"
@@ -1,184 +0,0 @@
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
@@ -1,116 +0,0 @@
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,19 +118,6 @@ 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 {
@@ -1,163 +0,0 @@
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)
}
}
@@ -1,281 +0,0 @@
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)
}
}
+3 -160
View File
@@ -54,56 +54,6 @@ 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
@@ -114,20 +64,6 @@ 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
@@ -233,11 +169,6 @@ 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 {
@@ -261,11 +192,7 @@ func connectStdio(ctx context.Context, server Server, options ConnectOptions) (*
return nil, fmt.Errorf("start MCP server %s: %w", server.Name, err)
}
cmd = prepared.Command
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)
cleanup = prepared.Cleanup
} else {
cmd = exec.CommandContext(ctx, server.Command, server.Args...)
cmd.Env = mergeProcessEnv(server.Env)
@@ -284,55 +211,6 @@ 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,
@@ -341,51 +219,16 @@ 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 != "" {
failure = fmt.Errorf("initialize MCP server %s: %w: %s", server.Name, err, message)
return nil, fmt.Errorf("initialize MCP server %s: %w: %s", server.Name, err, message)
}
// 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}
return nil, fmt.Errorf("initialize MCP server %s: %w", server.Name, err)
}
// 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
}
@@ -1,105 +0,0 @@
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
@@ -1,118 +0,0 @@
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) }
}
@@ -1,199 +0,0 @@
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)
}
}
+15 -260
View File
@@ -18,11 +18,6 @@ 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 {
@@ -52,18 +47,6 @@ 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.
@@ -72,83 +55,8 @@ type Runtime struct {
// is closed). Same length/order as clients is not required.
cancels []context.CancelFunc
skipped []SkippedServer
// 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
once sync.Once
err error
}
// Skipped returns the servers that were skipped during registration (unreachable
@@ -202,38 +110,19 @@ 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]
// 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))
serverCtx, cancel := context.WithCancel(ctx)
done := make(chan connectResult, 1)
go func() {
client, remote, notices, err := connectAndList(serverCtx, factory, server)
done <- connectResult{client: client, remote: remote, notices: notices, err: err}
client, remote, err := connectAndList(serverCtx, factory, server)
done <- connectResult{client: client, remote: remote, err: err}
}()
select {
case res := <-done:
@@ -245,47 +134,14 @@ 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
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 {
// Reap the goroutine + any partial client in the background so a
// slow server never blocks startup.
go func() {
if res := <-done; res.client != nil {
_ = res.client.Close()
}
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
}()
results[index] = connectResult{err: fmt.Errorf("connect timed out after %s", timeout)}
}
}(index)
}
@@ -301,28 +157,6 @@ 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
@@ -356,42 +190,17 @@ 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.
// 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) {
func connectAndList(ctx context.Context, factory func(context.Context, Server) (ToolClient, error), server Server) (ToolClient, []RemoteTool, error) {
client, err := factory(ctx, server)
if err != nil {
// 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
return nil, nil, err
}
notices := startupNoticesOf(client)
remoteTools, err := client.ListTools(ctx)
if err != nil {
_ = client.Close()
return nil, nil, notices, fmt.Errorf("list MCP tools for %s: %w", server.Name, err)
return nil, nil, fmt.Errorf("list MCP tools for %s: %w", server.Name, err)
}
return client, remoteTools, notices, nil
}
// startupNoticesOf reads a client's launch disclosures, if it reports any.
func startupNoticesOf(client ToolClient) []string {
if client == nil {
return nil
}
if disclosing, ok := client.(startupDisclosing); ok {
return disclosing.StartupNotices()
}
return nil
return client, remoteTools, nil
}
// buildServerTools validates a server's remote tools against the registry and the
@@ -427,25 +236,6 @@ 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
@@ -644,41 +434,6 @@ 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 {
@@ -1,127 +0,0 @@
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")
}
}
}
+1 -7
View File
@@ -218,14 +218,8 @@ 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.ModelOutput()}},
Content: []Content{{Type: "text", Text: result.Output}},
IsError: result.Status != tools.StatusOK,
}, nil
}
@@ -1,193 +0,0 @@
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
@@ -1,116 +0,0 @@
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
@@ -1,200 +0,0 @@
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)
}
}
+12 -43
View File
@@ -56,15 +56,6 @@ type commandOutput struct {
Stderr string
ExitCode int
Err error
// Notices carries the enforcement disclosures the execution runner attached
// to this command.
//
// The generic contract is not transport-only. Enforcement.Notices says what
// the sandbox actually did, and on Windows that includes trading the write
// jail away for a deny-read profile. A projection that copies stdout, stderr
// and an exit code drops it, so a plugin tool ran under the weakened token
// and said nothing about it.
Notices []string
}
// toolRunner executes a resolved plugin tool command. It is injectable so
@@ -558,37 +549,26 @@ 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,
EnforcementNotices: output.Notices,
Display: tools.Display{Summary: tool.name + " failed", Kind: "plugin"},
Status: tools.StatusError,
Output: "Error executing plugin tool " + tool.name + ": " + output.Err.Error(),
Meta: meta,
}
}
formatted := formatPluginToolOutput(output)
if output.ExitCode != 0 {
return tools.Result{
Status: tools.StatusError,
Output: formatted,
Meta: meta,
EnforcementNotices: output.Notices,
Display: tools.Display{Summary: tool.name + " failed", Kind: "plugin"},
Status: tools.StatusError,
Output: formatted,
Meta: meta,
Display: tools.Display{Summary: tool.name + " failed", Kind: "plugin"},
}
}
return tools.Result{
Status: tools.StatusOK,
Output: formatted,
Meta: meta,
EnforcementNotices: output.Notices,
Display: tools.Display{Summary: tool.name, Kind: "plugin"},
Status: tools.StatusOK,
Output: formatted,
Meta: meta,
Display: tools.Display{Summary: tool.name, Kind: "plugin"},
}
}
@@ -739,18 +719,7 @@ 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,
}
// 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()
output := commandOutput{Stdout: result.Stdout, Stderr: result.Stderr, ExitCode: exitCode}
switch result.Outcome.Kind {
case execution.OutcomeSandboxSetupFailure, execution.OutcomeExecutableNotFound, execution.OutcomeTimedOut, execution.OutcomeCancelled:
output.Err = result.Err
-164
View File
@@ -1,164 +0,0 @@
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)
}
})
}
}
@@ -1,37 +0,0 @@
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)
}
}
+35
View File
@@ -0,0 +1,35 @@
package sandbox
import (
"os"
"testing"
)
// TestMain points the sandbox runtime's user-cache root at test-owned storage
// for the whole package.
//
// PLAN CONSTRUCTION CREATES DIRECTORIES, which is easy to miss because it reads
// like naming. windowsSandboxProfileWithProvisionedRuntime provisions the root
// it selects, and the simulated-Windows tests run on every platform, so a test
// that supplies an explicit child environment but leaves the cache alone writes
// into the developer's real one. Not hypothetical: the machine this was found on
// had accumulated thousands of entries under the real runtime root from exactly
// these runs.
//
// Done once for the package rather than per test, because the leak is in the
// DEFAULT: any new test that builds a Windows plan is affected unless its author
// remembers, and remembering is what failed here. A test that needs a specific
// cache root still overrides sandboxUserCacheDir itself.
func TestMain(m *testing.M) {
root, err := os.MkdirTemp("", "zero-sandbox-testcache-")
if err != nil {
// Fail loudly rather than silently falling back to the real cache.
panic("sandbox tests: create the test cache root: " + err.Error())
}
sandboxUserCacheDir = func() (string, error) { return root, nil }
code := m.Run()
_ = os.RemoveAll(root)
os.Exit(code)
}
+21 -44
View File
@@ -70,20 +70,6 @@ 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
@@ -145,12 +131,15 @@ func (engine *Engine) PrepareExecution(ctx context.Context, request execution.Re
return execution.PreparedCommand{}, err
}
return execution.PreparedCommand{
Command: command,
Enforcement: EnforcementFor(plan),
Report: plan.ExecutionReport,
Cleanup: plan.Cleanup,
// Only for adapters that publish the fact; see CommandPlan.childLaunchReported.
ChildLaunchOwnedByAdapter: plan.childLaunchReported,
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,
}, nil
}
@@ -200,7 +189,18 @@ func (engine *Engine) BuildCommandPlan(spec CommandSpec) (CommandPlan, error) {
}
var runtimeCleanup func()
if preference != SandboxPreferenceForbid && policy.Mode != ModeDisabled {
runtimeState, cleanup, runtimeErr := prepareSandboxRuntime(workspaceRoot)
// The home THIS command asked for. Windows planning resolves
// ZERO_WINDOWS_SANDBOX_HOME out of spec.Env and hands it to the runner for
// marker validation, so selection has to read the same environment or the
// two disagree about which marker describes the tree. See
// pinnedSandboxRuntimeRoot.
commandSandboxHome := ""
if spec.Env != nil {
if resolved, err := ResolveWindowsSandboxHome(envListToMap(spec.Env)); err == nil {
commandSandboxHome = resolved
}
}
runtimeState, cleanup, runtimeErr := prepareSandboxRuntime(workspaceRoot, commandSandboxHome)
if runtimeErr != nil {
return CommandPlan{}, runtimeErr
}
@@ -1207,26 +1207,3 @@ 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...),
}
}
@@ -393,10 +393,11 @@ func realSmokeExecutable(t *testing.T, envKey string, fallbackName string) strin
func runWindowsRealSmokeSetup(t *testing.T, setupExe string, options WindowsSandboxSetupArgsOptions) {
t.Helper()
args, err := BuildWindowsSandboxSetupArgs(options)
setupPlan, err := BuildWindowsSandboxSetupArgs(options)
if err != nil {
t.Fatalf("BuildWindowsSandboxSetupArgs: %v", err)
}
args := setupPlan.Args
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, setupExe, args...)
@@ -406,8 +407,30 @@ func runWindowsRealSmokeSetup(t *testing.T, setupExe string, options WindowsSand
}
}
// prepareWindowsRealSmokeCommand gives an elevated-tier smoke command the profile
// the planner would have given it. The elevated runner validates the command
// against the setup marker, and setup fingerprinted a profile with the selected
// runtime folded in, so a command built from the bare profile can only be
// refused. The unelevated tier applies its own plan and has no marker to agree
// with, so it is left exactly as the test wrote it.
func prepareWindowsRealSmokeCommand(t *testing.T, base WindowsSandboxCommandArgsOptions) (WindowsSandboxCommandArgsOptions, func()) {
t.Helper()
if base.SandboxLevel != WindowsSandboxLevelRestrictedToken || base.PermissionProfile.Runtime != nil {
return base, func() {}
}
workspace := firstNonEmpty(base.WorkspaceRoots...)
prepared, release, err := plannerPreparedWindowsProfile(workspace, base.SandboxHome, base.PermissionProfile)
if err != nil {
t.Fatalf("prepare the smoke command the way the planner does: %v", err)
}
base.PermissionProfile = prepared
return base, release
}
func runWindowsRealSmokeCommand(t *testing.T, runnerExe string, base WindowsSandboxCommandArgsOptions, command []string, wantCode int) {
t.Helper()
base, release := prepareWindowsRealSmokeCommand(t, base)
defer release()
base.Command = command
args, err := BuildWindowsSandboxCommandArgs(base)
if err != nil {
@@ -433,6 +456,8 @@ func runWindowsRealSmokeCommand(t *testing.T, runnerExe string, base WindowsSand
// explicit unsupported-mode rejections rather than sandboxed command failures).
func runWindowsRealSmokeCommandExpectError(t *testing.T, runnerExe string, base WindowsSandboxCommandArgsOptions, command []string, wantSubstr ...string) {
t.Helper()
base, release := prepareWindowsRealSmokeCommand(t, base)
defer release()
base.Command = command
args, err := BuildWindowsSandboxCommandArgs(base)
if err != nil {
@@ -0,0 +1,88 @@
package sandbox
import (
"os"
"path/filepath"
"testing"
)
// THE TWO HALVES OF A ROLLBACK RECORD MUST DESCRIBE ONE OBJECT.
//
// The snapshot used to read the identity through a handle, close it, and then
// re-resolve the pathname to read the stamp. A rename between those pairs one
// directory's identity with another's bytes, and a rollback that correctly
// proves it holds the first then writes the second's contents into it.
//
// The window itself needs an elevated installer racing an unelevated renamer and
// is not reproducible here, so this pins the contract the binding provides: both
// facts come back together, and they agree with the object actually at the path.
func TestStampSnapshotPairsIdentityWithItsOwnBytes(t *testing.T) {
root := filepath.Join(t.TempDir(), "root")
if err := os.MkdirAll(root, 0o700); err != nil {
t.Fatal(err)
}
const contents = "the stamp that belongs to this directory"
if err := os.WriteFile(windowsSandboxRuntimeStampPath(root, testStampPlanHash), []byte(contents), 0o600); err != nil {
t.Fatal(err)
}
identity, identified, prior, state, err := snapshotRuntimeStampBound(root, windowsSandboxRuntimeStampName(testStampPlanHash))
if err != nil {
t.Fatalf("snapshot: %v", err)
}
existed := state == runtimeStampPresent
if !identified {
t.Fatal("the snapshot established no identity for a directory that exists")
}
if !existed || string(prior) != contents {
t.Fatalf("prior stamp = %q existed=%v, want %q", string(prior), existed, contents)
}
if direct, ok := runtimeDirIdentity(root); !ok || direct != identity {
t.Errorf("snapshot identity %q does not describe the directory at the path (%q)", identity, direct)
}
}
// An absent stamp still establishes the identity, because that came from the
// directory handle and not from the stamp read.
func TestStampSnapshotIdentifiesARootWithNoStamp(t *testing.T) {
root := filepath.Join(t.TempDir(), "root")
if err := os.MkdirAll(root, 0o700); err != nil {
t.Fatal(err)
}
identity, identified, _, state, err := snapshotRuntimeStampBound(root, windowsSandboxRuntimeStampName(testStampPlanHash))
if err != nil {
t.Fatalf("snapshot: %v", err)
}
existed := state == runtimeStampPresent
if !identified || identity == "" {
t.Error("a root with no stamp established no identity")
}
if existed {
t.Error("reported a prior stamp that does not exist")
}
}
// A created directory's identity must come from the creation, so the ledger
// cannot record an object this run did not make.
func TestCreatedDirectoryIdentityDescribesWhatWasCreated(t *testing.T) {
path := filepath.Join(t.TempDir(), "created")
identity, identified, err := createRuntimeDirIdentified(path)
if err != nil {
t.Fatalf("create: %v", err)
}
if !identified || identity == "" {
t.Fatal("creation established no identity")
}
if info, statErr := os.Stat(path); statErr != nil || !info.IsDir() {
t.Fatalf("the directory was not created: %v", statErr)
}
if direct, ok := runtimeDirIdentity(path); !ok || direct != identity {
t.Errorf("creation identity %q does not describe the directory now at the path (%q)", identity, direct)
}
// Creating over something that exists is the caller's already-handled signal.
if _, _, again := createRuntimeDirIdentified(path); !os.IsExist(again) {
t.Errorf("creating over an existing directory returned %v, want an IsExist error", again)
}
}
@@ -0,0 +1,97 @@
//go:build windows
package sandbox
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
// ABSENCE IS CLASSIFIED IN THE PRODUCER'S DOMAIN.
//
// The stamp is opened through NtCreateFile, so a stamp that is not there comes
// back as STATUS_OBJECT_NAME_NOT_FOUND. Compensation compared it with the Win32
// codes only. An NTStatus is a different type and errors.Is does not convert it,
// so removing a stamp that never existed was reported as a rollback failure.
//
// Native, against real directories: the question is what the API returns at this
// boundary, and a synthetic error value would only test the comparison.
func runtimeRootForCompensation(t *testing.T) (string, string) {
t.Helper()
root := filepath.Join(t.TempDir(), "zero", "runtime", "v1", "abcd")
if err := os.MkdirAll(root, 0o700); err != nil {
t.Fatalf("create the runtime root: %v", err)
}
identity, ok := runtimeDirIdentity(root)
if !ok {
t.Fatal("identify the runtime root")
}
return root, identity
}
// A runtime directory that exists and holds no stamp: removal has nothing to do
// and says so by succeeding.
func TestRemovingAStampThatIsNotThereSucceeds(t *testing.T) {
root, identity := runtimeRootForCompensation(t)
name := windowsSandboxRuntimeStampName(testStampPlanHash)
if err := compensateRuntimeStampBound(root, identity, name, nil, false); err != nil {
t.Fatalf("removing a stamp that was never written reported a failure: %v", err)
}
if _, err := os.Stat(root); err != nil {
t.Errorf("the runtime directory itself was disturbed: %v", err)
}
}
// A fresh setup that fails BEFORE its stamp is written, composed the way the
// setup transaction composes it: a real snapshot of the untouched tree, and the
// compensation run with the original failure as its cause. The cause has to come
// back alone. A second error about the stamp sends the operator looking for
// residue that does not exist.
func TestAFreshSetupFailureBeforeTheStampReportsOnlyItsCause(t *testing.T) {
root, _ := runtimeRootForCompensation(t)
snapshot, err := snapshotWindowsSandboxRuntimeStamp(root, testStampPlanHash)
if err != nil {
t.Fatalf("snapshot: %v", err)
}
if snapshot.priorState != runtimeStampAbsent || !snapshot.rootIdentified {
t.Fatalf("SETUP INVALID: the snapshot did not record an identified root with no stamp: %+v", snapshot)
}
cause := errors.New("windows ACL target does not exist: an earlier entry in the plan")
reported := runWindowsSandboxSetupCompensations(cause, nil, windowsRuntimeRootRollback{stamp: snapshot})
if reported == nil || !errors.Is(reported, cause) {
t.Fatalf("the original failure is no longer visible: %v", reported)
}
if text := reported.Error(); strings.Contains(text, "rollback failed") || strings.Contains(text, "setup stamp") {
t.Errorf("compensation added a failure of its own for a stamp that never existed:\n%s", text)
}
}
// AND ONLY ABSENCE. Something at the stamp's name that cannot be opened as the
// stamp is not "already removed", and treating every failed open as success
// would report a clean undo over residue nobody looked at. A directory at that
// name is a failure the real API produces on demand.
func TestAnUninspectableStampIsStillReported(t *testing.T) {
root, identity := runtimeRootForCompensation(t)
name := windowsSandboxRuntimeStampName(testStampPlanHash)
if err := os.Mkdir(filepath.Join(root, name), 0o700); err != nil {
t.Fatalf("put a directory where the stamp would be: %v", err)
}
err := compensateRuntimeStampBound(root, identity, name, nil, false)
if err == nil {
t.Fatal("compensation reported success over something it could not open as the stamp")
}
if isWindowsNotFound(err) {
t.Errorf("the failure classifies as not-found, so it would be swallowed: %v", err)
}
if _, statErr := os.Stat(filepath.Join(root, name)); statErr != nil {
t.Errorf("the object it could not inspect was removed anyway: %v", statErr)
}
}
@@ -0,0 +1,112 @@
package sandbox
import (
"os"
"path/filepath"
"strings"
"testing"
)
// COMPENSATION MUST PROVE IT HOLDS THE OBJECT IT CHANGED.
//
// The forward apply and its stamp go through one handle, so they are provably
// about one object. Compensation runs later, after those handles have closed,
// and used to resolve the pathname again. Rename the original aside, put an
// ordinary directory at the name, and a pathname-only undo strips a stamp from
// the substitute, or writes bytes snapshotted from another object onto it, and
// then removes it as though this run had created it, while the moved original
// keeps this run's grant and stamp.
func TestStampCompensationRefusesAReplacementDirectory(t *testing.T) {
parent := t.TempDir()
root := filepath.Join(parent, "runtime-root")
if err := os.MkdirAll(root, 0o700); err != nil {
t.Fatal(err)
}
stampPath := windowsSandboxRuntimeStampPath(root, testStampPlanHash)
if err := os.WriteFile(stampPath, []byte("previous-plan-hash"), 0o600); err != nil {
t.Fatal(err)
}
snapshot, err := snapshotWindowsSandboxRuntimeStamp(root, testStampPlanHash)
if err != nil {
t.Fatalf("snapshot: %v", err)
}
if snapshot.priorState != runtimeStampPresent {
t.Fatal("SETUP INVALID: the snapshot did not record the pre-existing stamp")
}
// The original is moved aside and an ordinary directory takes the name.
moved := filepath.Join(parent, "moved-aside")
if err := os.Rename(root, moved); err != nil {
t.Skipf("cannot rename the runtime root on this filesystem: %v", err)
}
if err := os.MkdirAll(root, 0o700); err != nil {
t.Fatal(err)
}
substituteStamp := filepath.Join(root, filepath.Base(stampPath))
if err := os.WriteFile(substituteStamp, []byte("not-ours"), 0o600); err != nil {
t.Fatal(err)
}
err = snapshot.restore()
if err == nil {
t.Fatal("compensation mutated a directory it never touched and reported success")
}
for _, want := range []string{"no longer the directory", "replacement"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("the error does not say what was left behind (%q): %v", want, err)
}
}
if body, readErr := os.ReadFile(substituteStamp); readErr != nil || string(body) != "not-ours" {
t.Errorf("the substitute's stamp was overwritten: %q %v", string(body), readErr)
}
if body, readErr := os.ReadFile(filepath.Join(moved, filepath.Base(stampPath))); readErr != nil || string(body) != "previous-plan-hash" {
t.Errorf("the original lost its stamp: %q %v", string(body), readErr)
}
}
// And the created-directory ledger applies the same rule, or the stamp check
// just moves the damage one line down.
func TestCreatedDirectoryCompensationRefusesAReplacement(t *testing.T) {
parent := t.TempDir()
root := filepath.Join(parent, "created-root")
if err := os.MkdirAll(root, 0o700); err != nil {
t.Fatal(err)
}
rollback := windowsRuntimeRootRollback{created: createdRuntimeDirsForTest(root)}
moved := filepath.Join(parent, "moved")
if err := os.Rename(root, moved); err != nil {
t.Skipf("cannot rename on this filesystem: %v", err)
}
if err := os.MkdirAll(root, 0o700); err != nil {
t.Fatal(err)
}
err := rollback.run()
if err == nil {
t.Fatal("a substitute directory was removed as though this run had created it")
}
if !strings.Contains(err.Error(), "no longer the directory this run created") {
t.Errorf("the error does not name the replacement: %v", err)
}
if _, statErr := os.Stat(root); statErr != nil {
t.Errorf("the substitute was removed: %v", statErr)
}
}
// The ordinary case still cleans up, or the guard would be refusing everything.
func TestCompensationStillRemovesWhatThisRunCreated(t *testing.T) {
parent := t.TempDir()
root := filepath.Join(parent, "mine")
if err := os.MkdirAll(root, 0o700); err != nil {
t.Fatal(err)
}
rollback := windowsRuntimeRootRollback{created: createdRuntimeDirsForTest(root)}
if err := rollback.run(); err != nil {
t.Fatalf("compensation refused a directory it really did create: %v", err)
}
if _, err := os.Stat(root); !os.IsNotExist(err) {
t.Errorf("the directory this run created survived compensation: %v", err)
}
}
@@ -0,0 +1,67 @@
//go:build !windows
package sandbox
import (
"errors"
"fmt"
"os"
"path/filepath"
)
// runtimeCompensationSwapSeam exists so the shared compensation code compiles
// everywhere. Only the Windows build closes a check-then-mutate window, because
// only there does setup run elevated against a tree an unelevated process can
// rename.
var runtimeCompensationSwapSeam func()
func compensateRuntimeStampBound(root string, identity string, name string, prior []byte, existed bool) error {
current, ok := runtimeDirIdentity(root)
if !ok {
return fmt.Errorf("identify the sandbox runtime root %s for stamp compensation", root)
}
if current != identity {
return fmt.Errorf("sandbox runtime root %s is no longer the directory this setup stamped; "+
"leaving the replacement untouched, and the original still carries this run's stamp", root)
}
if runtimeCompensationSwapSeam != nil {
runtimeCompensationSwapSeam()
}
path := filepath.Join(root, name)
if !existed {
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove sandbox runtime setup stamp written by this run: %w", err)
}
return nil
}
if err := os.WriteFile(path, prior, 0o600); err != nil {
return fmt.Errorf("restore the previous sandbox runtime setup stamp: %w", err)
}
return nil
}
func removeCreatedRuntimeDirBound(path string, identity string) error {
current, ok := runtimeDirIdentity(path)
if !ok {
// ONLY ABSENCE MEANS THERE IS NOTHING TO UNDO. Any lookup failure used to
// return here, so a directory that exists but could not be identified was
// reported as cleanly removed while it stayed on disk. The Windows build
// already draws this line, and the rollback it serves promises to report
// what it could not remove. Reported by CodeRabbit.
if _, err := os.Lstat(path); errors.Is(err, os.ErrNotExist) {
return nil
}
return fmt.Errorf("sandbox runtime root %s was created by this run but could not be identified for removal; leaving it in place", path)
}
if current != identity {
return fmt.Errorf("sandbox runtime root %s is no longer the directory this run created; "+
"leaving the replacement in place", path)
}
if runtimeCompensationSwapSeam != nil {
runtimeCompensationSwapSeam()
}
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove sandbox runtime root %s created by this run: %w", path, err)
}
return nil
}
@@ -0,0 +1,74 @@
//go:build !windows
package sandbox
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
// ONLY ABSENCE IS NOTHING TO UNDO.
//
// removeCreatedRuntimeDirBound returned success on any failure to identify the
// directory, so one that was still there, but could not be looked at, was
// reported as removed. Here the parent loses search permission, which makes the
// directory exist and be unidentifiable at the same time.
func TestRemovingAnUnidentifiableCreatedDirectoryIsReported(t *testing.T) {
if os.Geteuid() == 0 {
t.Skip("root bypasses directory search permission, so the lookup cannot be made to fail")
}
parent := filepath.Join(t.TempDir(), "zero")
created := filepath.Join(parent, "runtime")
if err := os.MkdirAll(created, 0o700); err != nil {
t.Fatalf("create the directory: %v", err)
}
identity, ok := runtimeDirIdentity(created)
if !ok {
t.Fatal("identify the created directory")
}
if err := os.Chmod(parent, 0o600); err != nil {
t.Fatalf("withdraw search permission: %v", err)
}
t.Cleanup(func() { _ = os.Chmod(parent, 0o700) })
if _, err := os.Lstat(created); err == nil || errors.Is(err, os.ErrNotExist) {
t.Fatalf("SETUP INVALID: the directory should exist and be unidentifiable, Lstat said %v", err)
}
err := removeCreatedRuntimeDirBound(created, identity)
if err == nil {
t.Fatal("a created directory that could not be identified was reported as removed")
}
if !strings.Contains(err.Error(), "could not be identified") {
t.Errorf("the error does not say why the directory was left: %v", err)
}
// And it is still there, which is why the error is owed.
if err := os.Chmod(parent, 0o700); err != nil {
t.Fatalf("restore search permission: %v", err)
}
if _, err := os.Lstat(created); err != nil {
t.Fatalf("the directory was gone after all, so the error was not owed: %v", err)
}
}
// Absence stays a clean undo, or the test above would be satisfied by a function
// that reports every lookup failure.
func TestRemovingACreatedDirectoryThatIsAlreadyGoneSucceeds(t *testing.T) {
created := filepath.Join(t.TempDir(), "runtime")
if err := os.Mkdir(created, 0o700); err != nil {
t.Fatalf("create the directory: %v", err)
}
identity, ok := runtimeDirIdentity(created)
if !ok {
t.Fatal("identify the created directory")
}
if err := os.Remove(created); err != nil {
t.Fatalf("remove the directory: %v", err)
}
if err := removeCreatedRuntimeDirBound(created, identity); err != nil {
t.Fatalf("a created directory that is already gone was reported as a failed undo: %v", err)
}
}
@@ -0,0 +1,100 @@
//go:build windows
package sandbox
import (
"os"
"path/filepath"
"testing"
)
// swapDuringCompensation installs a seam that runs once, between the identity
// check and the mutation, and replaces root with a fresh directory carrying a
// file of its own.
func swapDuringCompensation(t *testing.T, root string, aside string) *string {
t.Helper()
fired := false
previous := runtimeCompensationSwapSeam
t.Cleanup(func() { runtimeCompensationSwapSeam = previous })
runtimeCompensationSwapSeam = func() {
if fired {
return
}
fired = true
if err := os.Rename(root, aside); err != nil {
t.Logf("could not rename the original aside: %v", err)
return
}
if err := os.MkdirAll(root, 0o700); err != nil {
t.Fatalf("create the substitute: %v", err)
}
if err := os.WriteFile(filepath.Join(root, "substitute.txt"), []byte("belongs to whoever put it here"), 0o600); err != nil {
t.Fatalf("seed the substitute: %v", err)
}
}
return &aside
}
// A REPLACEMENT AFTER THE CHECK MUST NOT REACH THE SUBSTITUTE.
//
// Compensation used to read the identity through a handle, close it, and then
// resolve the pathname again for the write or the delete. A rename plus a
// replacement in that interval made the comparison true about one object while
// the mutation landed on another, under elevation. Holding the handle across
// both means the mutation follows the object that was verified, and whatever now
// answers to the name is untouched.
func TestStampCompensationDoesNotReachASubstitute(t *testing.T) {
parent := t.TempDir()
root := filepath.Join(parent, "zero", "runtime", "v1", "abcd")
if err := os.MkdirAll(root, 0o700); err != nil {
t.Fatalf("create the runtime root: %v", err)
}
if err := writeWindowsSandboxRuntimeStamp(root, "planhash"); err != nil {
t.Fatalf("write the stamp: %v", err)
}
identity, ok := runtimeDirIdentity(root)
if !ok {
t.Fatal("identify the runtime root")
}
aside := filepath.Join(parent, "renamed-aside")
swapDuringCompensation(t, root, aside)
// Removal of a stamp this run wrote, i.e. the fresh-setup rollback.
_ = compensateRuntimeStampBound(root, identity, windowsSandboxRuntimeStampName(testStampPlanHash), nil, false)
// Whatever the outcome, the substitute is not this run's business.
substitute := filepath.Join(root, "substitute.txt")
if _, err := os.Stat(substitute); err != nil {
t.Errorf("compensation removed a file from the substitute directory: %v", err)
}
if _, err := os.Stat(windowsSandboxRuntimeStampPath(root, testStampPlanHash)); err == nil {
t.Error("compensation created a stamp inside the substitute directory")
}
// And the original, which is the object that was verified, is the one that
// lost the stamp this run wrote.
if _, err := os.Stat(windowsSandboxRuntimeStampPath(aside, testStampPlanHash)); err == nil {
t.Error("the stamp this run wrote is still on the original object, so compensation followed the name instead")
}
}
// The same for the delete, where following the name would remove a directory
// this run never created.
func TestDirectoryCompensationDoesNotRemoveASubstitute(t *testing.T) {
parent := t.TempDir()
created := filepath.Join(parent, "created")
if err := os.MkdirAll(created, 0o700); err != nil {
t.Fatalf("create the directory: %v", err)
}
identity, ok := runtimeDirIdentity(created)
if !ok {
t.Fatal("identify the created directory")
}
aside := filepath.Join(parent, "renamed-aside")
swapDuringCompensation(t, created, aside)
_ = removeCreatedRuntimeDirBound(created, identity)
if _, err := os.Stat(filepath.Join(created, "substitute.txt")); err != nil {
t.Errorf("compensation removed the substitute directory's contents: %v", err)
}
}
@@ -0,0 +1,78 @@
//go:build windows
package sandbox
import (
"errors"
"io/fs"
"os"
"path/filepath"
"strings"
"testing"
)
// ONLY NOT-FOUND IS EVIDENCE OF REMOVAL.
//
// The verification probe used to read any Lstat error as absence, so a sharing
// violation, an access denial, or an entry left delete-pending by another
// process that holds a share-delete handle all reported complete compensation
// for a directory that is still there. A holder able to clear the disposition
// could then make the "removed" object visible again, and setup would already
// have said the rollback finished.
//
// The three outcomes are distinguished with a probe seam, because a real
// filesystem will not produce the third on demand.
func TestDeletionVerificationDistinguishesAllThreeOutcomes(t *testing.T) {
previous := runtimeCompensationStat
t.Cleanup(func() { runtimeCompensationStat = previous })
newDir := func() (string, string) {
t.Helper()
dir := filepath.Join(t.TempDir(), "created")
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatal(err)
}
identity, ok := runtimeDirIdentity(dir)
if !ok {
t.Fatal("identify the created directory")
}
return dir, identity
}
t.Run("absence is proven and reported as success", func(t *testing.T) {
dir, identity := newDir()
runtimeCompensationStat = func(string) (fs.FileInfo, error) {
return nil, &os.PathError{Op: "lstat", Path: dir, Err: os.ErrNotExist}
}
if err := removeCreatedRuntimeDirBound(dir, identity); err != nil {
t.Errorf("a proven-absent directory was reported as a failure: %v", err)
}
})
t.Run("still present is reported", func(t *testing.T) {
dir, identity := newDir()
runtimeCompensationStat = func(path string) (fs.FileInfo, error) { return os.Stat(".") }
err := removeCreatedRuntimeDirBound(dir, identity)
if err == nil {
t.Fatal("a directory still present after deletion was reported as removed")
}
if !strings.Contains(err.Error(), "still present") {
t.Errorf("unexpected error: %v", err)
}
})
t.Run("an unverifiable probe is residue, not success", func(t *testing.T) {
dir, identity := newDir()
// What a share-delete holder produces: neither absence nor presence.
runtimeCompensationStat = func(path string) (fs.FileInfo, error) {
return nil, &os.PathError{Op: "lstat", Path: path, Err: errors.New("Access is denied.")}
}
err := removeCreatedRuntimeDirBound(dir, identity)
if err == nil {
t.Fatal("an unverifiable removal was reported as complete compensation")
}
if !strings.Contains(err.Error(), "could not be verified") {
t.Errorf("the error does not say the removal is unproven: %v", err)
}
})
}
@@ -0,0 +1,195 @@
//go:build windows
package sandbox
import (
"errors"
"fmt"
"os"
"unsafe"
"golang.org/x/sys/windows"
)
// THE HANDLE IS THE OBJECT. A pathname is not.
//
// Compensation used to read the directory identity through a handle, close it,
// and then resolve the pathname again for the mutation. A rename followed by a
// replacement between those two steps makes the comparison true about one
// object while the write or delete lands on another, and this runs elevated, so
// a redirected pathname gives the mutation reach the replacer does not have
// directly.
//
// Opening once and keeping the handle open across BOTH the identity check and
// the mutation removes the interval. The child operations are relative to that
// handle, so no ancestor is re-resolved either.
// runtimeCompensationSwapSeam runs between the identity check and the mutation.
// Nil in production; a test installs one to replace the directory in exactly the
// window this design closes.
var runtimeCompensationSwapSeam func()
// runtimeCompensationStat is the post-deletion existence probe. A var so a test
// can produce the third outcome, an inspection that neither proves absence nor
// presence, which no real filesystem produces on demand.
var runtimeCompensationStat = os.Lstat
type fileDispositionInfo struct {
DeleteFile byte
}
func handleRuntimeIdentity(handle windows.Handle) (string, error) {
var info windows.ByHandleFileInformation
if err := windows.GetFileInformationByHandle(handle, &info); err != nil {
return "", err
}
return fmt.Sprintf("%d:%d:%d", info.VolumeSerialNumber, info.FileIndexHigh, info.FileIndexLow), nil
}
// openVerifiedRuntimeDirectory opens path without following a link at the final
// component and returns it only if it is still the object identity names.
func openVerifiedRuntimeDirectory(path string, identity string, access uint32, what string) (windows.Handle, error) {
utf16Path, err := windows.UTF16PtrFromString(path)
if err != nil {
return 0, err
}
handle, err := windows.CreateFile(
utf16Path,
access|windows.FILE_READ_ATTRIBUTES,
windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE,
nil,
windows.OPEN_EXISTING,
windows.FILE_FLAG_BACKUP_SEMANTICS|windows.FILE_FLAG_OPEN_REPARSE_POINT,
0,
)
if err != nil {
return 0, err
}
current, err := handleRuntimeIdentity(handle)
if err != nil {
_ = windows.CloseHandle(handle)
return 0, fmt.Errorf("identify the sandbox runtime directory %s for compensation: %w", path, err)
}
if current != identity {
_ = windows.CloseHandle(handle)
return 0, fmt.Errorf("sandbox runtime root %s is no longer the directory this run %s; "+
"leaving the replacement in place, and the original still carries this run's changes", path, what)
}
return handle, nil
}
// markForDeletion queues the object the handle names for removal. It never
// resolves a pathname, so it can only reach what this process already holds.
func markForDeletion(handle windows.Handle) error {
info := fileDispositionInfo{DeleteFile: 1}
return windows.SetFileInformationByHandle(
handle,
windows.FileDispositionInfo,
(*byte)(unsafe.Pointer(&info)),
uint32(unsafe.Sizeof(info)),
)
}
// compensateRuntimeStampBound restores or removes the stamp through a handle on
// the directory whose identity still matches.
//
// RESTORED WITHOUT A GAP. This used to delete the stamp and then recreate the
// previous one, so a command validating in between, or a process stopping
// there, found no stamp at all where the previous setup had left a valid one.
// The ordinary writer replaces the stamp in one step and puts the protected
// DACL back, which a raw write would not, so a restore is just a publish of the
// previous bytes. Only the branch that had no stamp before deletes, because
// absent is the state it restores. Reported by @jatmn.
func compensateRuntimeStampBound(root string, identity string, name string, prior []byte, existed bool) error {
directory, err := openVerifiedRuntimeDirectory(root, identity,
windows.FILE_TRAVERSE|windowsFileAddFile|windows.READ_CONTROL|windows.SYNCHRONIZE, "stamped")
if err != nil {
return err
}
defer windows.CloseHandle(directory)
if runtimeCompensationSwapSeam != nil {
runtimeCompensationSwapSeam()
}
if !existed {
return deleteRuntimeStampChild(directory, name)
}
// Through the ordinary writer so it is protected again, and so the reader ACE
// is resolved the same way a fresh setup resolves it.
if err := writeWindowsRuntimeStampToDirectoryHandle(directory, name, string(prior)); err != nil {
return fmt.Errorf("restore the previous sandbox runtime setup stamp: %w", err)
}
return nil
}
// deleteRuntimeStampChild removes the stamp relative to an already verified
// directory handle. A stamp that is not there is the desired end state; anything
// else is reported rather than swallowed.
func deleteRuntimeStampChild(directory windows.Handle, name string) error {
stamp, err := openWindowsChildNoFollow(directory, name,
windows.DELETE|windows.FILE_READ_ATTRIBUTES, windows.FILE_NON_DIRECTORY_FILE)
if err != nil {
// ABSENCE IS CLASSIFIED BY THE PRODUCER'S DOMAIN. openWindowsChildNoFollow
// goes through NtCreateFile, so a stamp that is not there arrives as
// STATUS_OBJECT_NAME_NOT_FOUND. This compared against the Win32 codes only,
// and an NTStatus is a different type that errors.Is does not convert, so a
// fresh setup that failed before writing any stamp reported a second,
// spurious rollback failure for removing a file that never existed. The
// snapshot path already answers this question for both domains, and using
// the same answer keeps snapshot and compensation agreeing about what
// absence means. Reported by @jatmn.
if isWindowsNotFound(err) {
return nil
}
return fmt.Errorf("remove sandbox runtime setup stamp written by this run: %w", err)
}
if err := markForDeletion(stamp); err != nil {
_ = windows.CloseHandle(stamp)
return fmt.Errorf("remove sandbox runtime setup stamp written by this run: %w", err)
}
// The entry goes when the last handle closes.
_ = windows.CloseHandle(stamp)
return nil
}
// removeCreatedRuntimeDirBound removes a directory this run created, through a
// handle on the object identity names.
func removeCreatedRuntimeDirBound(path string, identity string) error {
handle, err := openVerifiedRuntimeDirectory(path, identity, windows.DELETE, "created")
if err != nil {
if isWindowsNotFound(err) {
return nil
}
return err
}
if runtimeCompensationSwapSeam != nil {
runtimeCompensationSwapSeam()
}
if err := markForDeletion(handle); err != nil {
_ = windows.CloseHandle(handle)
return fmt.Errorf("remove sandbox runtime root %s created by this run: %w", path, err)
}
// The entry goes when the last handle closes, so releasing ours is what makes
// the removal observable.
_ = windows.CloseHandle(handle)
// REPORTED SUCCESS IS NOT PROOF. A handle-bound operation accepting the call
// has been seen not to take effect (PR #751, the promote rename), so the
// outcome is checked rather than assumed.
//
// THREE OUTCOMES, NOT TWO. This used to read any Lstat error as absence, so a
// sharing violation, an access denial, or a delete-pending entry held open by
// another process all reported complete compensation for a directory that is
// still there. A holder that clears the disposition can then make the
// "removed" object visible again. Only not-found is evidence of removal;
// everything else is at best unproven and has to be reported as residue.
_, statErr := runtimeCompensationStat(path)
switch {
case errors.Is(statErr, os.ErrNotExist):
return nil
case statErr == nil:
return fmt.Errorf("remove sandbox runtime root %s created by this run: it is still present after the deletion was accepted", path)
default:
return fmt.Errorf("remove sandbox runtime root %s created by this run: its removal could not be verified, so it may still be present: %w", path, statErr)
}
}
+15
View File
@@ -0,0 +1,15 @@
package sandbox
// runtimeIdentityAfterCreate resolves a just-created runtime directory's
// identity BY PATHNAME, which is a second resolution of the same name and
// therefore a window: the directory created can be renamed away and an ordinary
// one substituted before this runs, and the ledger then records the substitute
// for a directory the run never made.
//
// It is the honest implementation off Windows, where the elevated-installer
// versus unelevated-renamer split this closes does not apply. On Windows the
// creation returns the handle and identity is read from that, so nothing there
// may call this: a Windows test asserts the count is zero, which is what makes
// "the creation establishes the identity" a checked property rather than a
// comment.
var runtimeIdentityAfterCreate = runtimeDirIdentity
+15
View File
@@ -0,0 +1,15 @@
//go:build !windows
package sandbox
import "os"
// createRuntimeDirIdentified keeps the pathname form off Windows, where the
// elevated-installer-versus-unelevated-renamer split this closes does not apply.
func createRuntimeDirIdentified(path string) (string, bool, error) {
if err := os.Mkdir(path, 0o700); err != nil {
return "", false, err
}
identity, ok := runtimeIdentityAfterCreate(path)
return identity, ok, nil
}
+131
View File
@@ -0,0 +1,131 @@
//go:build windows
package sandbox
import (
"errors"
"fmt"
"os"
"path/filepath"
"unsafe"
"golang.org/x/sys/windows"
)
// createRuntimeDirIdentified creates path and returns the identity of the
// directory it created, read from the handle the creation itself returned.
//
// THE CREATION MUST BE THE THING THAT ESTABLISHES IDENTITY. os.Mkdir followed by
// runtimeDirIdentity(path) is two resolutions of one name: Mkdir creates A, and
// the reopen can land on a B substituted in between, so the rollback ledger
// records B's identity for a directory this run never made. Compensation then
// correctly proves it holds B and deletes it, while A keeps this run's ACL and
// stamp under a name nothing is tracking. The runtime parent belongs to the
// ordinary user, so that substitution needs no privilege.
//
// Win32 CreateFile cannot create a directory at all, whatever disposition it is
// given, so an earlier attempt to do this with CREATE_NEW fell through to the
// Mkdir-plus-reopen path on EVERY real creation and the documented handle
// contract never once held. NtCreateFile with FILE_CREATE and
// FILE_DIRECTORY_FILE does create one, and returns the handle to it.
//
// If the atomic create cannot be completed, this returns an error rather than
// manufacturing an ownership record from a reopen: an unidentified create must
// stop setup before privileged state is applied, not enter the ledger as though
// it were proven.
func createRuntimeDirIdentified(path string) (string, bool, error) {
clean := filepath.Clean(path)
parentPath, leaf := filepath.Split(clean)
leaf = filepath.Clean(leaf)
parentPath = filepath.Clean(parentPath)
if leaf == "" || leaf == "." || parentPath == clean {
return "", false, fmt.Errorf("sandbox runtime path %s has no component to create", path)
}
// The parent is either a directory that already existed when the missing
// components were computed, or one this same loop created a moment ago.
parent, err := openWindowsDirectoryByName(parentPath)
if err != nil {
return "", false, fmt.Errorf("open sandbox runtime parent %s: %w", parentPath, err)
}
defer windows.CloseHandle(parent)
handle, err := createWindowsChildDirectory(parent, leaf)
if err != nil {
// A collision is the same signal os.Mkdir gives for a component another
// process won the race to create, and the caller already treats that as
// "not ours" (windows_setup.go). Returned as a *PathError so BOTH
// os.IsExist, which the caller uses and which does not unwrap %w, and
// errors.Is recognize it; wrapping with %w alone would have turned a
// benign race into a hard setup failure.
if errors.Is(err, windows.STATUS_OBJECT_NAME_COLLISION) {
return "", false, &os.PathError{Op: "mkdir", Path: clean, Err: os.ErrExist}
}
return "", false, fmt.Errorf("create sandbox runtime directory %s: %w", clean, err)
}
defer windows.CloseHandle(handle)
identity, idErr := handleRuntimeIdentity(handle)
if idErr != nil {
return "", false, fmt.Errorf("identify the sandbox runtime directory created at %s: %w", clean, idErr)
}
return identity, true, nil
}
// removeWindowsObjectByHandle deletes the object this handle is open on.
//
// THROUGH THE HANDLE, NOT THE NAME. Every caller is on a failure path partway
// through a creation, and the one object it may remove is the one it just made
// and is still holding. Re-resolving the name to delete it is the substitution
// hole this whole descent exists to close, and here it would be a pathname
// authoritative delete of somebody else's directory.
//
// The handle needs DELETE access, which the creating opens above ask for.
func removeWindowsObjectByHandle(handle windows.Handle) error {
var iosb windows.IO_STATUS_BLOCK
deleteIt := byte(1)
return windows.NtSetInformationFile(handle, &iosb, &deleteIt, 1, windows.FileDispositionInformation)
}
// createWindowsChildDirectory creates exactly one directory component beneath
// parent and returns the handle to the object it created.
//
// Relative to a handle, so no ancestor is re-resolved and there is no interval
// for a swap to land in. FILE_CREATE fails rather than opening anything that is
// already there, so the returned handle cannot describe a pre-existing object,
// and FILE_OPEN_REPARSE_POINT keeps the failure honest if one is.
func createWindowsChildDirectory(parent windows.Handle, name string) (windows.Handle, error) {
objectName, err := windows.NewNTUnicodeString(name)
if err != nil {
return 0, fmt.Errorf("encode sandbox runtime component %s: %w", name, err)
}
attributes := windows.OBJECT_ATTRIBUTES{
RootDirectory: parent,
ObjectName: objectName,
Attributes: windows.OBJ_CASE_INSENSITIVE,
}
attributes.Length = uint32(unsafe.Sizeof(attributes))
var handle windows.Handle
var iosb windows.IO_STATUS_BLOCK
// DELETE because the caller has to be able to undo this creation through the
// handle it just got, without going back through the name. See
// removeWindowsObjectByHandle.
err = windows.NtCreateFile(
&handle,
windows.DELETE|windows.FILE_READ_ATTRIBUTES|windows.FILE_TRAVERSE|windows.SYNCHRONIZE,
&attributes,
&iosb,
nil,
windows.FILE_ATTRIBUTE_DIRECTORY,
windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE,
windows.FILE_CREATE,
windows.FILE_DIRECTORY_FILE|windows.FILE_SYNCHRONOUS_IO_NONALERT|windows.FILE_OPEN_REPARSE_POINT,
0,
0,
)
if err != nil {
return 0, err
}
return handle, nil
}
@@ -0,0 +1,158 @@
package sandbox
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
// CREATION IS THE OWNERSHIP BOUNDARY, NOT THE STEP AFTER IT.
//
// Acquisition learns it created a directory or a lease file at the create
// itself, but used to publish that fact only once every later step had also
// succeeded: the identity read for a directory, and the inspection, wrapping and
// locking for a lease. A failure in between returned an error while leaving the
// object on disk with nothing above holding a record of it. Setup then reported
// failure and left invocation-owned state behind, and where an ancestor HAD been
// recorded, that ancestor's compensation failed afterwards on a child it could
// not account for.
//
// Both platforms are driven through acquireRuntimeLeaseForPlatform, the call
// production makes, rather than through either descent directly.
var errInjectedRuntimeFailure = errors.New("injected failure after the create")
// failRuntimeCreationAt makes the step after the creation of the named object
// fail, which is the only way to reach the paths under test.
func failRuntimeCreationAt(t *testing.T, match string) {
t.Helper()
runtimeCreationFailure = func(created string) error {
if filepath.Base(created) == match {
return errInjectedRuntimeFailure
}
return nil
}
t.Cleanup(func() { runtimeCreationFailure = nil })
}
// runtimeRootForAcquisition returns a root whose owned tail does not exist yet,
// so acquisition has to create it.
func runtimeRootForAcquisition(t *testing.T) string {
t.Helper()
base := t.TempDir()
if err := os.MkdirAll(filepath.Join(base, "zero", "runtime"), 0o700); err != nil {
t.Fatal(err)
}
return filepath.Join(base, "zero", "runtime", "v1", "digest")
}
// A directory whose identity could not be read is removed, not abandoned.
func TestAcquisitionUndoesADirectoryItCreatedButCouldNotIdentify(t *testing.T) {
root := runtimeRootForAcquisition(t)
version := filepath.Dir(root)
failRuntimeCreationAt(t, filepath.Base(version))
lease, created, err := acquireRuntimeLeaseForPlatform(root)
if err == nil {
lease.release()
t.Fatal("SETUP INVALID: acquisition succeeded, so the injected failure never fired")
}
if !errors.Is(err, errInjectedRuntimeFailure) {
t.Fatalf("acquisition failed for some other reason: %v", err)
}
if len(created) != 0 {
t.Errorf("acquisition returned a ledger for a failed create: %v", created)
}
if _, statErr := os.Stat(version); !os.IsNotExist(statErr) {
t.Errorf("%s was created by this run and left behind after the failure, with nothing recording it (stat: %v)", version, statErr)
}
// And what was already there is untouched.
if _, statErr := os.Stat(filepath.Dir(version)); statErr != nil {
t.Errorf("a directory that existed before the run was removed: %v", statErr)
}
}
// So is a lease file this run created but never got to lock.
func TestAcquisitionUndoesALeaseFileItCreatedButCouldNotLock(t *testing.T) {
root := runtimeRootForAcquisition(t)
leasePath := sandboxRuntimeLeasePath(root)
failRuntimeCreationAt(t, filepath.Base(leasePath))
lease, _, err := acquireRuntimeLeaseForPlatform(root)
if err == nil {
lease.release()
t.Fatal("SETUP INVALID: acquisition succeeded, so the injected failure never fired")
}
if !errors.Is(err, errInjectedRuntimeFailure) {
t.Fatalf("acquisition failed for some other reason: %v", err)
}
if _, statErr := os.Stat(leasePath); !os.IsNotExist(statErr) {
t.Errorf("%s was created by this run and left behind after the failure, so its directory can never be compensated (stat: %v)", leasePath, statErr)
}
}
// A LEASE FILE THAT WAS ALREADY THERE IS SOMEBODY ELSE'S.
//
// The undo is keyed on this call having created the object, so an acquisition
// that merely opened an existing lease must not remove it on its way out.
func TestAcquisitionLeavesALeaseFileItDidNotCreate(t *testing.T) {
root := runtimeRootForAcquisition(t)
if err := os.MkdirAll(filepath.Dir(root), 0o700); err != nil {
t.Fatal(err)
}
leasePath := sandboxRuntimeLeasePath(root)
if err := os.WriteFile(leasePath, nil, 0o600); err != nil {
t.Fatal(err)
}
failRuntimeCreationAt(t, filepath.Base(leasePath))
lease, _, err := acquireRuntimeLeaseForPlatform(root)
if err == nil {
lease.release()
t.Fatal("SETUP INVALID: acquisition succeeded, so the injected failure never fired")
}
if _, statErr := os.Stat(leasePath); statErr != nil {
t.Errorf("acquisition removed a lease file it did not create: %v", statErr)
}
}
// AND THE LEDGER STILL COVERS WHAT SURVIVES A LATER FAILURE.
//
// Undoing the component that failed must not undo the ones before it: those are
// recorded, and compensation is what removes them.
func TestAcquisitionKeepsTheLedgerForComponentsItFinished(t *testing.T) {
base := t.TempDir()
root := filepath.Join(base, "zero", "runtime", "v1", "digest")
failRuntimeCreationAt(t, "v1")
lease, created, err := acquireRuntimeLeaseForPlatform(root)
if err == nil {
lease.release()
t.Fatal("SETUP INVALID: acquisition succeeded, so the injected failure never fired")
}
var recorded []string
for _, entry := range created {
recorded = append(recorded, filepath.Base(entry.path))
}
// "zero" and "runtime" were created and identified before the injected
// failure, so they belong to the caller to compensate.
if len(recorded) != 2 || recorded[0] != "zero" || recorded[1] != "runtime" {
t.Errorf("the completed components are not on the ledger the caller compensates from: %v", recorded)
}
if _, statErr := os.Stat(filepath.Join(base, "zero", "runtime")); statErr != nil {
t.Errorf("a component that was created and recorded was removed anyway: %v", statErr)
}
if failed := filepath.Join(base, "zero", "runtime", "v1"); !isMissing(failed) {
t.Errorf("%s failed after being created and was left behind", failed)
}
if !strings.Contains(err.Error(), "v1") {
t.Errorf("the error does not name the component that failed: %v", err)
}
}
func isMissing(path string) bool {
_, err := os.Stat(path)
return os.IsNotExist(err)
}
+24
View File
@@ -0,0 +1,24 @@
//go:build !windows
package sandbox
import "golang.org/x/sys/unix"
// createRuntimeTailHandleRelative creates the owned tail through the same
// no-follow boundary the lease uses.
//
// This was an os.Mkdir loop over full pathnames, on the reasoning that the
// elevated-installer-versus-unelevated-renamer split the Windows descent closes
// does not apply here. That is true of the elevation asymmetry and false of the
// substitution: the fallback root is a predictable name under a temp directory
// every local account can write to, so another user can put a link at an owned
// component and have this loop create the rest of the tree inside it. Same
// descent, same refusal, one implementation per platform rather than one
// protected platform.
func createRuntimeTailHandleRelative(base string, tail []string) ([]windowsCreatedRuntimeDir, error) {
created, parent, err := createRuntimeTailRetainingFD(base, tail)
if parent >= 0 {
_ = unix.Close(parent)
}
return created, err
}
+40
View File
@@ -0,0 +1,40 @@
package sandbox
// The two seams the rooted descent exposes, shared by both platform
// implementations so a test asserts the same property on either.
//
// They live apart from the Windows descent they started in because the POSIX
// fallback now takes the same shape, and a seam defined next to one of two
// implementations is how the other one quietly ends up untested.
// runtimeDescentBarrier, when set, runs after the base directory has been opened
// and before the first owned component is touched. It exists so a test can swap
// an owned component for a link at exactly the point the old pathname walk was
// vulnerable, and prove the redirected target is never created or granted. Nil in
// production.
var runtimeDescentBarrier func()
// runtimeCreationFailure, when set, is consulted immediately after this run
// creates a runtime object and before the step that would publish it: a
// directory component's identity read, or the lease file's inspection, wrapping
// and locking. Returning an error stands in for that step failing.
//
// It is the only way to reach those paths, and they are the ones where the
// transaction knows it created something that nothing above it knows about yet.
// The argument is the created component's path, or the lease file's name. Nil in
// production.
var runtimeCreationFailure func(string) error
// runtimeCleanupExclusivityBarrier, when set, runs during compensation while the
// exclusive cleanup lease is held: after it has been taken and before the first
// thing is removed. It exists so a test can put a contender into that window and
// prove the exclusion still covers it, which is precisely where the old shape had
// already handed the lease back. Nil in production.
var runtimeCleanupExclusivityBarrier func()
// runtimeBaseOpenedByName, when set, receives the ONE path this descent opens by
// name. The whole security property is which path that is: the fixed cache or
// temp directory above the owned tail, never a predictable component Zero owns. A
// test can assert it directly instead of inferring it from whether a swap
// happened to be caught, which is not discriminating. Nil in production.
var runtimeBaseOpenedByName func(string)
+171
View File
@@ -0,0 +1,171 @@
//go:build !windows
package sandbox
import (
"errors"
"fmt"
"path/filepath"
"strconv"
"golang.org/x/sys/unix"
)
// createRuntimeTailRetainingFD creates the owned tail of the runtime root
// beneath base, one component at a time, from retained descriptors.
//
// THE NAME IS NOT AUTHORIZATION, ON THIS SIDE EITHER.
//
// The fallback root moved from an atomically minted os.MkdirTemp parent to a
// predictable /tmp/zero-u<uid>/runtime/v1/<digest>. The stable name is required,
// because setup and every later process have to agree on one root without
// talking to each other, but it also means another local account can name the
// first owned component before this one does. refuseAliasedRuntimeComponents
// answers about a component that is ABSENT by saying there is nothing to alias,
// and the code then called os.MkdirAll on the parent and opened <root>.lease by
// full pathname. Both follow a link planted after that answer, so the guard was
// authorizing writes it could not see the destination of.
//
// So the base is opened by name exactly once, and every component below it is
// created or opened relative to a retained descriptor with O_NOFOLLOW. A link at
// an owned component is then an ELOOP or ENOTDIR from the kernel rather than a
// redirection nobody noticed, and no owned name is ever resolved twice.
//
// The base itself is opened WITHOUT O_NOFOLLOW. It belongs to the operator and is
// legitimately a link on macOS, where /tmp resolves through /private. Only the
// tail Zero creates is held to the stricter rule.
func createRuntimeTailRetainingFD(base string, tail []string) ([]windowsCreatedRuntimeDir, int, error) {
if runtimeBaseOpenedByName != nil {
runtimeBaseOpenedByName(base)
}
parent, err := unix.Open(base, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC, 0)
if err != nil {
return nil, -1, fmt.Errorf("open sandbox runtime base %s: %w", base, err)
}
if runtimeDescentBarrier != nil {
runtimeDescentBarrier()
}
var created []windowsCreatedRuntimeDir
for _, path := range tail {
name := filepath.Base(path)
child, madeIt, openErr := openOrCreateRuntimeChildNoFollow(parent, name)
if openErr != nil {
_ = unix.Close(parent)
return created, -1, fmt.Errorf("open sandbox runtime component %s: %w", path, openErr)
}
// CREATED, AND ABOUT TO BE FORGOTTEN. Both checks below run before the
// ledger entry is appended, so failing either used to return a directory
// this run had made with nothing above holding a record of it: setup
// reported failure and left it behind, and a recorded ancestor's
// compensation then failed on a child it could not explain.
//
// Undone relative to the parent descriptor rather than published to the
// ledger, because on the identity path the identity the ledger needs is the
// thing that just failed, and the descriptor is the only authority here
// that is not a name.
undoCreation := func(err error) error {
if !madeIt {
return err
}
if undoErr := unix.Unlinkat(parent, name, unix.AT_REMOVEDIR); undoErr != nil {
return fmt.Errorf("%w; and the directory this run created there could not be removed: %w", err, undoErr)
}
return err
}
if err := refuseForeignRuntimeDirectory(child, path); err != nil {
err = undoCreation(err)
_ = unix.Close(child)
_ = unix.Close(parent)
return created, -1, err
}
if madeIt {
identity, idErr := runtimeDirectoryIdentity(child)
if idErr == nil && runtimeCreationFailure != nil {
idErr = runtimeCreationFailure(path)
}
if idErr != nil {
err := undoCreation(fmt.Errorf("identify the sandbox runtime directory created at %s: %w", path, idErr))
_ = unix.Close(child)
_ = unix.Close(parent)
return created, -1, err
}
created = append(created, windowsCreatedRuntimeDir{path: path, identity: identity, identified: true})
}
_ = unix.Close(parent)
parent = child
}
return created, parent, nil
}
// openOrCreateRuntimeChildNoFollow opens name under parent, creating it if it is
// not there, and never follows a link at that name.
//
// madeIt distinguishes the directory this process created from one that was
// already there, because only the former belongs on the rollback ledger.
func openOrCreateRuntimeChildNoFollow(parent int, name string) (fd int, madeIt bool, err error) {
const flags = unix.O_RDONLY | unix.O_DIRECTORY | unix.O_NOFOLLOW | unix.O_CLOEXEC
child, openErr := unix.Openat(parent, name, flags, 0)
if openErr == nil {
return child, false, nil
}
if !errors.Is(openErr, unix.ENOENT) {
// ELOOP or ENOTDIR here IS the finding: a link sits where a directory of
// ours should be. Returned as-is rather than translated, because the two
// platforms spell it differently and the caller only needs it to fail.
return -1, false, openErr
}
if mkErr := unix.Mkdirat(parent, name, 0o700); mkErr != nil {
if !errors.Is(mkErr, unix.EEXIST) {
return -1, false, mkErr
}
// Somebody created it between the open and the create. That is an ordinary
// race with another Zero process, and the reopen below is still no-follow,
// so a link that arrived in the same window is refused rather than used.
child, openErr = unix.Openat(parent, name, flags, 0)
if openErr != nil {
return -1, false, openErr
}
return child, false, nil
}
child, openErr = unix.Openat(parent, name, flags, 0)
if openErr != nil {
return -1, false, openErr
}
return child, true, nil
}
// refuseForeignRuntimeDirectory proves the directory reached belongs to this
// user and is not group- or world-writable.
//
// Asked of the DESCRIPTOR, so it describes the object the descent is holding
// rather than whatever the name resolves to now. Ownership is the part that
// matters on a shared temp root: a component another account created first is
// theirs, and continuing into it would put the sandbox runtime inside a tree they
// control even though nothing about it is a link.
func refuseForeignRuntimeDirectory(fd int, path string) error {
var stat unix.Stat_t
if err := unix.Fstat(fd, &stat); err != nil {
return fmt.Errorf("inspect sandbox runtime component %s: %w", path, err)
}
if uid := unix.Getuid(); uid >= 0 && stat.Uid != uint32(uid) {
return fmt.Errorf("refusing to use the sandbox runtime component %s: it is owned by uid %d, not %d, so another account chose what the sandbox writes into",
path, stat.Uid, uid)
}
if stat.Mode&(unix.S_IWGRP|unix.S_IWOTH) != 0 {
return fmt.Errorf("refusing to use the sandbox runtime component %s: mode %#o lets another account replace what is inside it",
path, stat.Mode&0o7777)
}
return nil
}
// runtimeDirectoryIdentity names the object behind a descriptor, so rollback can
// tell the directory it created from one that replaced it under the same name.
func runtimeDirectoryIdentity(fd int) (string, error) {
var stat unix.Stat_t
if err := unix.Fstat(fd, &stat); err != nil {
return "", err
}
return strconv.FormatUint(uint64(stat.Dev), 10) + ":" + strconv.FormatUint(uint64(stat.Ino), 10), nil
}
+124
View File
@@ -0,0 +1,124 @@
//go:build windows
package sandbox
import (
"errors"
"fmt"
"path/filepath"
"golang.org/x/sys/windows"
)
// createRuntimeTailHandleRelative creates the owned tail of the runtime root
// beneath base, one component at a time, from retained handles.
//
// THE NAME IS NOT AUTHORIZATION. The previous shape found the deepest existing
// ancestor with os.Stat and then created each missing component by opening its
// parent BY NAME. Both follow a junction, so a local user could replace the
// predictable owned "zero" component with a junction after the pre-check,
// point it anywhere, let elevated setup create runtime\v1\<hash> beneath that
// target, and put the original back before the post-check. The post-check then
// saw an ordinary path, the plan applied normally, and rollback reported the
// separately created target only as identity-mismatched residue.
//
// So the base is opened by name exactly once, and everything below it is
// addressed relative to a handle with FILE_OPEN_REPARSE_POINT: an existing
// component is opened no-follow and refused if it is a link, and a missing one
// is created relative to its parent's handle and identified from the handle the
// create returned. No component name is resolved twice, so there is no interval
// for a swap to land in, and a junction placed at any owned component is seen
// as the link it is rather than followed.
//
// Redirected cache and TEMP locations ABOVE the owned tail are still allowed:
// they are part of base, which is the operator's business. The restriction is
// on the zero/runtime/v1/<hash> components Zero itself owns.
func createRuntimeTailHandleRelative(base string, tail []string) ([]windowsCreatedRuntimeDir, error) {
created, parent, err := createRuntimeTailRetainingHandle(base, tail)
if parent != 0 {
_ = windows.CloseHandle(parent)
}
return created, err
}
// createRuntimeTailRetainingHandle is the same descent, but it hands the caller
// the retained handle to the deepest component instead of closing it.
//
// Lease acquisition needs that handle: the lease file is a sibling of the
// runtime root, so creating it by pathname would re-resolve the owned components
// the descent just verified, which is the whole interval this design removes.
// The caller owns the returned handle and must close it, including when an error
// is returned alongside a non-zero handle.
func createRuntimeTailRetainingHandle(base string, tail []string) ([]windowsCreatedRuntimeDir, windows.Handle, error) {
if runtimeBaseOpenedByName != nil {
runtimeBaseOpenedByName(base)
}
parent, err := openWindowsDirectoryByName(base)
if err != nil {
return nil, 0, fmt.Errorf("open sandbox runtime base %s: %w", base, err)
}
if runtimeDescentBarrier != nil {
runtimeDescentBarrier()
}
var created []windowsCreatedRuntimeDir
for _, path := range tail {
name := filepath.Base(path)
// Exists already: another process won the race, or it was there before
// setup began. Opened no-follow, so a junction here is refused rather than
// descended, and it is not ours to record.
existing, openErr := openWindowsChildNoFollow(parent, name,
windows.FILE_READ_ATTRIBUTES|windows.FILE_TRAVERSE, windows.FILE_DIRECTORY_FILE)
if openErr == nil {
_ = windows.CloseHandle(parent)
parent = existing
continue
}
if !isWindowsNotFound(openErr) {
return created, parent, fmt.Errorf("inspect sandbox runtime component %s: %w", path, openErr)
}
handle, createErr := createWindowsChildDirectory(parent, name)
if createErr != nil {
if errors.Is(createErr, windows.STATUS_OBJECT_NAME_COLLISION) {
// Created between the open and the create by someone else. Same
// answer as the os.IsExist branch the caller already had: not ours,
// but not a failure either. Reopen no-follow so the descent
// continues through a verified object rather than a name.
existing, reopenErr := openWindowsChildNoFollow(parent, name,
windows.FILE_READ_ATTRIBUTES|windows.FILE_TRAVERSE, windows.FILE_DIRECTORY_FILE)
if reopenErr != nil {
return created, parent, fmt.Errorf("inspect sandbox runtime component %s after a concurrent create: %w", path, reopenErr)
}
_ = windows.CloseHandle(parent)
parent = existing
continue
}
return created, parent, fmt.Errorf("create sandbox runtime root %s: %w", path, createErr)
}
identity, idErr := handleRuntimeIdentity(handle)
if idErr == nil && runtimeCreationFailure != nil {
idErr = runtimeCreationFailure(path)
}
if idErr != nil {
// CREATED, AND ABOUT TO BE FORGOTTEN. The ledger entry is appended on
// the next line, so returning here left a directory this run had made
// with nothing above holding a record of it: setup reports failure and
// leaves it behind, and if an ancestor WAS recorded, that ancestor's
// compensation then fails on a directory it cannot explain.
//
// Undone through the handle rather than published to the ledger,
// because the identity the ledger needs is the thing that just failed.
err := fmt.Errorf("identify the sandbox runtime directory created at %s: %w", path, idErr)
if undoErr := removeWindowsObjectByHandle(handle); undoErr != nil {
err = fmt.Errorf("%w; and the directory this run created there could not be removed: %w", err, undoErr)
}
_ = windows.CloseHandle(handle)
return created, parent, err
}
created = append(created, windowsCreatedRuntimeDir{path: path, identity: identity, identified: true})
_ = windows.CloseHandle(parent)
parent = handle
}
return created, parent, nil
}
@@ -0,0 +1,93 @@
//go:build windows
package sandbox
import (
"os"
"os/exec"
"path/filepath"
"testing"
)
// THE SWAP THE OLD WALK COULD NOT SEE.
//
// createRuntimeDirRecording used to find the deepest existing ancestor with
// os.Stat and then create each missing component by opening its parent BY
// NAME. Both follow a junction. So between the pre-check and the create, a
// local user could replace the predictable owned "zero" component with a
// junction into a directory of their choosing, and elevated setup would create
// runtime\v1\<hash> beneath that target, apply its ACL plan there, and then
// find an ordinary path at the post-check once the original was put back.
//
// This plants the junction at the deterministic barrier between opening the
// base and touching the first owned component, which is that exact interval,
// and proves two things: nothing is created beneath the redirected target, and
// the descent refuses rather than continuing through the link. A junction
// needs no privilege on Windows, which is why it is the shape used here rather
// than a symlink that would skip on an unelevated box.
func TestRuntimeDescentRefusesAJunctionSwappedIntoAnOwnedComponent(t *testing.T) {
base := t.TempDir()
target := t.TempDir()
zero := filepath.Join(base, "zero")
tail := []string{zero, filepath.Join(zero, "runtime")}
previous := runtimeDescentBarrier
runtimeDescentBarrier = func() {
out, err := exec.Command("cmd", "/c", "mklink", "/J", zero, target).CombinedOutput()
if err != nil {
t.Fatalf("mklink /J: %v\n%s", err, out)
}
}
t.Cleanup(func() { runtimeDescentBarrier = previous })
created, err := createRuntimeTailHandleRelative(base, tail)
if err == nil {
t.Fatalf("the descent continued through a junction and reported success: created=%v", created)
}
if len(created) != 0 {
t.Errorf("a junction-swapped component still produced ownership records: %v", created)
}
// The whole point: the redirected target must be untouched.
entries, readErr := os.ReadDir(target)
if readErr != nil {
t.Fatal(readErr)
}
if len(entries) != 0 {
names := make([]string, 0, len(entries))
for _, entry := range entries {
names = append(names, entry.Name())
}
t.Errorf("setup created %v beneath the junction target, which is somebody else's directory", names)
}
}
// And the honest control: with no swap, the same descent creates the tail,
// records an identity for each component from the creation handle, and the
// result matches what the name resolves to afterwards.
func TestRuntimeDescentCreatesTheOwnedTailFromHandles(t *testing.T) {
base := t.TempDir()
zero := filepath.Join(base, "zero")
tail := []string{zero, filepath.Join(zero, "runtime"), filepath.Join(zero, "runtime", "v1")}
created, err := createRuntimeTailHandleRelative(base, tail)
if err != nil {
t.Fatalf("descent: %v", err)
}
if len(created) != len(tail) {
t.Fatalf("created %d components, want %d: %v", len(created), len(tail), created)
}
for i, record := range created {
if record.path != tail[i] {
t.Errorf("record %d path = %q, want %q", i, record.path, tail[i])
}
if !record.identified {
t.Errorf("record %d for %s carries no identity", i, record.path)
continue
}
if now, ok := runtimeDirIdentity(record.path); !ok || now != record.identity {
t.Errorf("record %d identity %q does not describe the directory at %s (%q)", i, record.identity, record.path, now)
}
}
}
@@ -0,0 +1,27 @@
//go:build !windows
package sandbox
import (
"fmt"
"os"
"syscall"
)
// runtimeDirIdentity identifies the directory currently at path.
//
// device plus inode, read with Lstat so a link substituted at the final
// component is identified as the link rather than followed. The Windows build
// cannot use os.SameFile for this and explains why; here the same eager capture
// keeps both platforms on one rule.
func runtimeDirIdentity(path string) (string, bool) {
info, err := os.Lstat(path)
if err != nil {
return "", false
}
stat, ok := info.Sys().(*syscall.Stat_t)
if !ok {
return "", false
}
return fmt.Sprintf("%d:%d", stat.Dev, stat.Ino), true
}
@@ -0,0 +1,45 @@
//go:build windows
package sandbox
import (
"fmt"
"golang.org/x/sys/windows"
)
// runtimeDirIdentity identifies the directory currently at path, EAGERLY.
//
// os.SameFile cannot be used for this on Windows. A Windows fileStat loads its
// volume serial and file index lazily, by PATHNAME, at comparison time, so an
// identity captured before a replacement and compared afterwards reports the
// substitute as the same object and the original as a different one: exactly
// backwards, and silently. Measured, not reasoned about.
//
// Reading the identity through a handle at capture time removes the lazy step.
// Opened with FILE_FLAG_OPEN_REPARSE_POINT so a link substituted at the final
// component is identified as the link it is rather than followed.
func runtimeDirIdentity(path string) (string, bool) {
utf16Path, err := windows.UTF16PtrFromString(path)
if err != nil {
return "", false
}
handle, err := windows.CreateFile(
utf16Path,
windows.FILE_READ_ATTRIBUTES,
windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE,
nil,
windows.OPEN_EXISTING,
windows.FILE_FLAG_BACKUP_SEMANTICS|windows.FILE_FLAG_OPEN_REPARSE_POINT,
0,
)
if err != nil {
return "", false
}
defer windows.CloseHandle(handle)
var info windows.ByHandleFileInformation
if err := windows.GetFileInformationByHandle(handle, &info); err != nil {
return "", false
}
return fmt.Sprintf("%d:%d:%d", info.VolumeSerialNumber, info.FileIndexHigh, info.FileIndexLow), true
}
@@ -0,0 +1,44 @@
//go:build windows
package sandbox
import "testing"
// THE COMMAND HAS TO RECOGNISE THE ROOT SETUP RECORDED, WHATEVER ITS
// ENVIRONMENT SAYS.
//
// Setup derives the fallback root in an elevated terminal and records it; a
// command re-derives it in an ordinary one to decide whether the record belongs
// to this workspace. The user scope in that derivation used to come from
// USERNAME, so a value that differed between the two terminals made the command
// reject the record and select a root with no capability ACE on it.
func TestRecordedFallbackRootSurvivesADifferentUsernameVariable(t *testing.T) {
// Canonical, as selection hands it over. The record check canonicalizes on
// its own, so a raw t.TempDir() spelling digests differently wherever TEMP is
// an 8.3 short path, as it is on the CI runners.
workspace := canonicalSandboxWorkspaceRoot(t.TempDir())
tempHome := t.TempDir()
t.Setenv("TMP", tempHome)
t.Setenv("TEMP", tempHome)
t.Setenv("USERNAME", "setup-terminal")
recorded, err := fallbackSandboxRuntimeRoot(workspace)
if err != nil {
t.Fatalf("derive the fallback root as setup: %v", err)
}
if !ownedFallbackRuntimeRecord(workspace, recorded) {
t.Fatalf("SETUP INVALID: the fallback root is not recognised even in the environment that derived it: %s", recorded)
}
t.Setenv("USERNAME", "command-terminal")
if !ownedFallbackRuntimeRecord(workspace, recorded) {
t.Fatalf("once USERNAME changed, the command no longer recognises the fallback root setup recorded: %s", recorded)
}
derived, err := fallbackSandboxRuntimeRoot(workspace)
if err != nil {
t.Fatalf("derive the fallback root as the command: %v", err)
}
if !sameWindowsRuntimeRootPath(derived, recorded) {
t.Fatalf("setup and the command derived different fallback roots for one workspace:\n setup: %s\n command: %s", recorded, derived)
}
}
@@ -0,0 +1,106 @@
package sandbox
import (
"path/filepath"
"runtime"
"strings"
"testing"
)
// EVERY OWNERSHIP-CHECKED ANCESTOR MUST ALREADY BE INSIDE ONE USER'S NAMESPACE.
//
// The temp-derived fallback lives under a directory that is shared on Unix
// whenever TMPDIR is unset. Runtime preparation creates and ownership-checks
// each component of the tail at 0700, so a fixed first component meant the
// first account to use the fallback created a private directory every other
// account was then refused at: traversal fails on the mode, and relaxing the
// mode fails the ownership guard instead. The per-workspace digest is the leaf,
// so it never got the chance to separate them.
//
// The invariant is therefore about the SHALLOWEST checked component, not the
// leaf: it has to carry the user scope, or the guards below it are guarding a
// namespace two users share.
func TestFallbackRuntimeRootScopesItsShallowestOwnedComponent(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("os.TempDir() resolves inside the user profile on Windows, so the shared-temp collision cannot arise")
}
workspace := t.TempDir()
tempHome := t.TempDir()
t.Setenv("TMPDIR", tempHome)
root, err := fallbackSandboxRuntimeRoot(workspace)
if err != nil {
t.Fatalf("fallbackSandboxRuntimeRoot: %v", err)
}
components := ownedRuntimeComponents(root)
if len(components) == 0 {
t.Fatal("no ownership-checked components derived for the fallback root")
}
// ownedRuntimeComponents walks upward, so the last entry is the shallowest
// directory the guards create and validate.
shallowest := filepath.Base(components[len(components)-1])
scope := sandboxRuntimeUserScope()
if !strings.Contains(shallowest, scope) {
t.Errorf("the shallowest ownership-checked component is %q and does not carry the user scope %q; "+
"two accounts on a shared temp would contend for it", shallowest, scope)
}
}
// The workspace still decides the leaf, so setup and the command derive the same
// root for the same workspace. Scoping the top must not have moved that.
func TestFallbackRuntimeRootStaysStableForOneWorkspace(t *testing.T) {
workspace := t.TempDir()
tempHome := t.TempDir()
t.Setenv("TMPDIR", tempHome)
t.Setenv("TMP", tempHome)
t.Setenv("TEMP", tempHome)
first, err := fallbackSandboxRuntimeRoot(workspace)
if err != nil {
t.Fatal(err)
}
second, err := fallbackSandboxRuntimeRoot(workspace)
if err != nil {
t.Fatal(err)
}
if first != second {
t.Errorf("the fallback root is not stable for one workspace:\n %s\n %s", first, second)
}
other, err := fallbackSandboxRuntimeRoot(t.TempDir())
if err != nil {
t.Fatal(err)
}
if other == first {
t.Error("two different workspaces derived the same fallback root")
}
if filepath.Dir(other) != filepath.Dir(first) {
t.Errorf("two workspaces for one user should differ only in the leaf:\n %s\n %s", first, other)
}
}
// And the Windows side is a deliberate choice, not an omission. Its temp root
// is already inside the user profile, and the names have to stay fixed so
// windowsSandboxRuntimeOwnedTail can still recognise a root that an elevated
// setup running as a different account built.
func TestFallbackOwnedNamesAreUnscopedOnWindowsOnly(t *testing.T) {
names := sandboxRuntimeFallbackOwnedNames()
if len(names) != len(windowsSandboxRuntimeOwnedNames) {
t.Fatalf("fallback names = %v, want the same depth as %v", names, windowsSandboxRuntimeOwnedNames)
}
// The components below the first are shared by both platforms, so a change
// to them would break the tail matcher on Windows.
for index := 1; index < len(names); index++ {
if names[index] != windowsSandboxRuntimeOwnedNames[index] {
t.Errorf("component %d = %q, want %q", index, names[index], windowsSandboxRuntimeOwnedNames[index])
}
}
scoped := names[0] != windowsSandboxRuntimeOwnedNames[0]
if runtime.GOOS == "windows" && scoped {
t.Errorf("the Windows fallback scoped its first component to %q; the tail matcher compares fixed names", names[0])
}
if runtime.GOOS != "windows" && !scoped {
t.Errorf("the first component is %q on %s, where the temp root can be shared between accounts",
names[0], runtime.GOOS)
}
}
@@ -0,0 +1,74 @@
package sandbox
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
// writeRecordedRoot puts a setup marker naming root under sandboxHome.
func writeRecordedRoot(t *testing.T, sandboxHome, root string) {
t.Helper()
path := WindowsSandboxSetupMarkerPath(sandboxHome)
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatal(err)
}
blob, err := json.Marshal(WindowsSandboxSetupMarker{SchemaVersion: windowsSandboxSetupMarkerSchemaVersion, RuntimeRoot: root})
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, blob, 0o600); err != nil {
t.Fatal(err)
}
}
// ONE COMMAND, ONE SANDBOX HOME.
//
// Runtime preparation happens before Windows platform planning, and it used to
// resolve the sandbox home from the AMBIENT environment while the planner
// resolves ZERO_WINDOWS_SANDBOX_HOME out of the command's own spec.Env and hands
// that one to the runner for marker validation. So a command that explicitly
// selects home B, while the parent process still points at home A, pinned A's
// recorded root into the profile; the runner then loaded B's marker, saw a
// different root, and rejected the command as out of date even though setup for
// B was valid. The two homes need no different derivation rules to disagree,
// only different valid selections from the same preferred/fallback pair.
func TestThePinnedRootComesFromTheCommandsOwnSandboxHome(t *testing.T) {
homeA := t.TempDir()
homeB := t.TempDir()
preferred := filepath.Join(t.TempDir(), "preferred")
fallback := filepath.Join(t.TempDir(), "fallback")
writeRecordedRoot(t, homeA, preferred)
writeRecordedRoot(t, homeB, fallback)
if got := pinnedSandboxRuntimeRoot(t.TempDir(), preferred, fallback, homeB); got != fallback {
t.Errorf("pinned %q for a command that selected home B, want %q: the ambient home decided instead of the command's", got, fallback)
}
if got := pinnedSandboxRuntimeRoot(t.TempDir(), preferred, fallback, homeA); got != preferred {
t.Errorf("pinned %q for home A, want %q", got, preferred)
}
}
// A recorded root that this workspace could not select is still refused, which
// is the protection that stops one workspace's tree being pinned into another.
func TestARecordedRootFromAnotherWorkspaceIsStillRefused(t *testing.T) {
home := t.TempDir()
writeRecordedRoot(t, home, filepath.Join(t.TempDir(), "someone-elses-tree"))
if got := pinnedSandboxRuntimeRoot(t.TempDir(), filepath.Join(t.TempDir(), "preferred"), filepath.Join(t.TempDir(), "fallback"), home); got != "" {
t.Errorf("pinned %q, want none: it matches neither candidate this workspace derives", got)
}
}
// No command context means the ambient environment is the only authority there
// is, so an empty home must still resolve rather than refusing outright.
func TestAnEmptyCommandHomeFallsBackToTheAmbientEnvironment(t *testing.T) {
home := t.TempDir()
t.Setenv("ZERO_WINDOWS_SANDBOX_HOME", home)
preferred := filepath.Join(t.TempDir(), "preferred")
writeRecordedRoot(t, home, preferred)
if got := pinnedSandboxRuntimeRoot(t.TempDir(), preferred, filepath.Join(t.TempDir(), "fallback"), ""); got != preferred {
t.Errorf("pinned %q with no command home, want the ambient one to decide (%q)", got, preferred)
}
}
+64 -7
View File
@@ -1,31 +1,88 @@
package sandbox
import (
"fmt"
"errors"
"sync"
)
const sandboxRuntimeLeaseSuffix = ".lease"
// errRuntimeLeaseReplaced says a lock was taken on a lease object that the lease
// name no longer resolves to: cleanup unlinked or replaced it while this call was
// waiting. Both acquisitions retry from their open on it, and surface it only
// once the retries are spent.
var errRuntimeLeaseReplaced = errors.New("the sandbox runtime lease was removed or replaced while it was being taken")
// runtimeLeaseAcquireAttempts bounds those retries. Each stale lock means one
// cleanup ran to completion in the window, and cleanups do not chain without
// limit; a third stale object in a row is reported rather than chased.
const runtimeLeaseAcquireAttempts = 3
// sandboxRuntimeLease is one holder's grip on a runtime root.
//
// createdFile says this acquisition is the one that brought the lease file into
// existence. Compensation needs it: rollback refuses a non-empty directory, and
// the lease sits beside the leaf inside a directory a failed setup has to be able
// to remove, so the file has to go with it. It may only go if it was ours, which
// is a fact only the create knows.
type sandboxRuntimeLease struct {
handle runtimeLeaseHandle
once sync.Once
// root is the runtime root this lease is for, kept so compensation can name
// the lease file without deriving it again.
root string
// createdFile says THIS acquisition brought the lease file into existence.
createdFile bool
}
// createdLeaseFile reports whether compensation may remove the lease file.
//
// Only the acquisition that created it may, and only while nothing else holds
// it. A lease that was already there belongs to whoever made it, and removing it
// would take the coordination object out from under a live command.
func (lease *sandboxRuntimeLease) createdLeaseFile() (string, bool) {
if lease == nil || !lease.createdFile {
return "", false
}
return sandboxRuntimeLeasePath(lease.root), true
}
func sandboxRuntimeLeasePath(root string) string {
return root + sandboxRuntimeLeaseSuffix
}
func acquireSandboxRuntimeLease(root string) (*sandboxRuntimeLease, error) {
handle, err := acquireSharedRuntimeLease(sandboxRuntimeLeasePath(root))
if err != nil {
return nil, fmt.Errorf("acquire sandbox runtime lease: %w", err)
// The pathname-based shared acquisition that used to live here is gone with its
// two platform halves. It opened the lease by full pathname, so it followed a
// link planted at that name and, on Windows, opened without FILE_SHARE_DELETE,
// which alone stops cleanup from ever removing the lease it is holding. Every
// caller now goes through acquireRuntimeLeaseForPlatform, which descends from a
// retained parent handle no-follow. Leaving the weaker door defined next to the
// stronger one is how a later change quietly takes it.
// appendCreatedRuntimeDirs merges one acquisition attempt's ledger into the
// running one. A path seen again was re-created after cleanup removed the
// earlier object, so the later identity supersedes; rollback verifies identity
// before it removes anything, and a stale one would make it refuse the directory
// this run actually made.
func appendCreatedRuntimeDirs(existing, made []windowsCreatedRuntimeDir) []windowsCreatedRuntimeDir {
for _, record := range made {
replaced := false
for index := range existing {
if existing[index].path == record.path {
existing[index] = record
replaced = true
break
}
}
if !replaced {
existing = append(existing, record)
}
}
return &sandboxRuntimeLease{handle: handle}, nil
return existing
}
func tryAcquireSandboxRuntimeCleanupLease(root string) (*sandboxRuntimeLease, bool, error) {
handle, inUse, err := tryAcquireExclusiveRuntimeLease(sandboxRuntimeLeasePath(root))
handle, inUse, err := tryAcquireExclusiveRuntimeLease(root)
if err != nil || inUse {
return nil, inUse, err
}
+15
View File
@@ -0,0 +1,15 @@
package sandbox
// runtimeLeasePreCreateBarrier, when set, runs after lease acquisition has
// decided what it is going to do and before it creates anything.
//
// It exists so a test can reproduce the CHECK-THEN-USE RACE rather than a
// junction that was already there when acquisition started. The two are not the
// same finding: a junction present from the outset was already refused by the
// alias pre-check, while one planted in this window defeated it, because
// os.MkdirAll and the lease open both resolved the component again and followed
// it. A test that only plants the junction up front passes against the defective
// implementation and proves nothing.
//
// Nil in production.
var runtimeLeasePreCreateBarrier func()
@@ -0,0 +1,150 @@
//go:build windows
package sandbox
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
// leaseRootUnder builds a production-shaped runtime root beneath a test-owned
// cache directory, so the owned tail is real rather than assumed.
func leaseRootUnder(t *testing.T, cacheRoot string) string {
t.Helper()
workspace := t.TempDir()
root, ok := deterministicSandboxRuntimeRoot(canonicalSandboxWorkspaceRoot(workspace), canonicalSandboxWorkspaceRoot(cacheRoot))
if !ok {
t.Fatalf("SETUP INVALID: no deterministic runtime root under %s", cacheRoot)
}
return root
}
// THE LEASE IS THE FIRST WRITE SETUP MAKES, SO IT IS THE ONE THAT MATTERS MOST.
//
// prepareSandboxRuntimeLease checked the owned components for aliases and then
// created the parent with os.MkdirAll and opened "<root>.lease" by pathname.
// Both follow. An ordinary same-account process could replace "zero", "runtime"
// or "v1" with a junction after the check and before either call, and elevated
// setup would then build the tree and the lease file inside somebody else's
// target. Putting the component back afterwards left the later handle-relative
// provisioning working on the legitimate tree, so no post-check ever saw it, and
// the rollback could not compensate what it had no record of.
//
// A junction needs no privilege on Windows, so the caller's half of this runs on
// an ordinary unelevated box.
func TestRuntimeLeaseRefusesAJunctionPlantedAfterTheCheck(t *testing.T) {
cacheRoot := t.TempDir()
target := t.TempDir()
root := leaseRootUnder(t, cacheRoot)
owned := filepath.Join(canonicalSandboxWorkspaceRoot(cacheRoot), "zero")
if !strings.HasPrefix(strings.ToLower(root), strings.ToLower(owned)) {
t.Fatalf("SETUP INVALID: %s does not sit under the owned component %s", root, owned)
}
// THE JUNCTION ARRIVES IN THE WINDOW, WHICH IS THE WHOLE POINT.
//
// One that is already there when acquisition starts was refused by the old
// alias pre-check too, so a test that plants it up front passes against the
// defective implementation and proves nothing. This one lands after the
// decision and before the first create, which is exactly where os.MkdirAll and
// the pathname lease open used to follow it.
previous := runtimeLeasePreCreateBarrier
t.Cleanup(func() { runtimeLeasePreCreateBarrier = previous })
attempted, planted := false, false
runtimeLeasePreCreateBarrier = func() {
if attempted {
return
}
attempted = true
if out, err := exec.Command("cmd", "/c", "mklink", "/J", owned, target).CombinedOutput(); err != nil {
t.Logf("mklink /J unavailable: %v: %s", err, out)
return
}
planted = true
}
lease, _, err := prepareSandboxRuntimeLeaseRecording(root)
if lease != nil {
lease.release()
}
if !attempted {
t.Skip("the pre-create barrier never ran, so the race was not reproduced")
}
// Bound to mklink's result, not to the attempt. When it fails, acquisition
// goes on to create an ordinary directory at the same name, the check below
// finds it, and a correct acquisition would be failed as a junction bypass.
if !planted {
t.Skip("mklink /J could not create the junction here, so the race was not reproduced")
}
if _, statErr := os.Lstat(owned); statErr != nil {
t.Skipf("the junction could not be created here: %v", statErr)
}
entries, readErr := os.ReadDir(target)
if readErr != nil {
t.Fatal(readErr)
}
if len(entries) != 0 {
names := make([]string, 0, len(entries))
for _, entry := range entries {
names = append(names, entry.Name())
}
t.Fatalf("lease acquisition wrote %v beneath a junction planted after its own check; err=%v", names, err)
}
if err == nil {
t.Fatal("lease acquisition reported success through a junction planted after its check")
}
}
// And an ordinary cache tree still gets its lease, or the refusal above would be
// satisfied by an acquirer that refuses everything.
func TestRuntimeLeaseStillAcquiresOnAnOrdinaryTree(t *testing.T) {
cacheRoot := t.TempDir()
root := leaseRootUnder(t, cacheRoot)
lease, created, err := prepareSandboxRuntimeLeaseRecording(root)
if err != nil {
t.Fatalf("an ordinary cache tree was refused: %v", err)
}
defer lease.release()
if _, statErr := os.Stat(sandboxRuntimeLeasePath(root)); statErr != nil {
t.Fatalf("the lease file was not created: %v", statErr)
}
// The components above the leaf are recorded, so a later failure can undo
// them. The leaf itself belongs to provisioning and must NOT appear here.
for _, entry := range created {
if strings.EqualFold(filepath.Clean(entry.path), filepath.Clean(root)) {
t.Fatalf("lease acquisition claimed the runtime leaf %s, which provisioning owns", root)
}
}
if len(created) == 0 {
t.Fatal("SETUP INVALID: nothing was recorded as created, so the accounting assertion above is vacuous")
}
}
// A second acquisition over an existing tree records nothing: it created nothing,
// so it owns nothing, and rollback must not remove a tree it found.
func TestRuntimeLeaseRecordsOnlyWhatItCreated(t *testing.T) {
cacheRoot := t.TempDir()
root := leaseRootUnder(t, cacheRoot)
first, _, err := prepareSandboxRuntimeLeaseRecording(root)
if err != nil {
t.Fatalf("first acquisition: %v", err)
}
first.release()
second, created, err := prepareSandboxRuntimeLeaseRecording(root)
if err != nil {
t.Fatalf("second acquisition: %v", err)
}
defer second.release()
if len(created) != 0 {
t.Fatalf("the second acquisition claimed %d directories it did not create; rollback would delete a tree it found", len(created))
}
}
@@ -0,0 +1,216 @@
//go:build !windows
package sandbox
import (
"errors"
"os"
"path/filepath"
"testing"
"golang.org/x/sys/unix"
)
// unixLeaseRootUnder builds a production-shaped runtime root beneath a
// test-owned base, so the owned tail is real rather than assumed.
func unixLeaseRootUnder(t *testing.T, base string) string {
t.Helper()
workspace := t.TempDir()
root, ok := deterministicSandboxRuntimeRoot(canonicalSandboxWorkspaceRoot(workspace), canonicalSandboxWorkspaceRoot(base))
if !ok {
t.Fatalf("SETUP INVALID: no deterministic runtime root under %s", base)
}
return root
}
// countEntriesUnder reports how many entries exist anywhere beneath dir, so a
// test can say "nothing was written here" rather than checking one name.
func countEntriesUnder(t *testing.T, dir string) []string {
t.Helper()
var found []string
err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if path == dir {
return nil
}
found = append(found, path)
return nil
})
if err != nil {
t.Fatalf("walk %s: %v", dir, err)
}
return found
}
// A PRE-CHECK CANNOT AUTHORIZE A PATHNAME WRITE THAT COMES AFTER IT.
//
// The fallback root moved from an atomically minted MkdirTemp parent to a
// predictable /tmp/zero-u<uid>/runtime/v1/<digest>. The stable name is required,
// because setup and every later process have to agree on one root without talking
// to each other, but it also means another local account can name the first owned
// component first. refuseAliasedRuntimeComponents answers about an ABSENT
// component by saying there is nothing to alias, which is the state a fresh
// fallback is in, and os.MkdirAll plus the pathname lease open then followed a
// link planted after that answer.
//
// The link arrives after the last validation and before the first create, which
// is the only window that matters: one planted earlier was refused by the old
// pre-check too, so a test that plants it up front passes against the defect and
// proves nothing.
func TestRuntimeLeaseRefusesALinkPlantedAfterTheCheck(t *testing.T) {
base := t.TempDir()
target := t.TempDir()
root := unixLeaseRootUnder(t, base)
owned := filepath.Join(canonicalSandboxWorkspaceRoot(base), "zero")
if !pathWithinRoot(owned, root) {
t.Fatalf("SETUP INVALID: %s does not sit under the owned component %s", root, owned)
}
previous := runtimeDescentBarrier
t.Cleanup(func() { runtimeDescentBarrier = previous })
planted := false
runtimeDescentBarrier = func() {
if planted {
return
}
planted = true
if err := os.Symlink(target, owned); err != nil {
t.Errorf("plant the link: %v", err)
}
}
lease, _, err := prepareSandboxRuntimeLeaseRecording(root)
if lease != nil {
lease.release()
}
// SETUP: the swap really happened in the window, or nothing was under test.
if !planted {
t.Fatal("SETUP INVALID: the descent never reached the barrier, so no link was planted")
}
if err == nil {
t.Fatal("lease acquisition succeeded through a link planted at the first owned component")
}
// ELOOP on Linux, and either that or ENOTDIR elsewhere. Asserted as a class
// rather than one kernel's spelling.
if !errors.Is(err, unix.ELOOP) && !errors.Is(err, unix.ENOTDIR) {
t.Errorf("refused for the wrong reason, want a no-follow refusal: %v", err)
}
// THE POINT IS WHERE THE WRITES DID NOT GO. An error alone would also be
// produced by a later guard noticing the link after the tree was built inside
// somebody else's directory.
if leaked := countEntriesUnder(t, target); len(leaked) != 0 {
t.Fatalf("the redirected target received %v; the refusal happened after the writes rather than instead of them", leaked)
}
}
// CONTROL: an ordinary tree still gets its lease, and the one path opened by name
// is the operator's base rather than a component Zero owns.
//
// Refusing everything would satisfy the test above. Asserting the base directly
// is what makes this discriminating: inferring it from whether a swap was caught
// says nothing about which name was trusted.
func TestRuntimeLeaseOpensOnlyTheBaseByName(t *testing.T) {
base := t.TempDir()
root := unixLeaseRootUnder(t, base)
previousBase := runtimeBaseOpenedByName
t.Cleanup(func() { runtimeBaseOpenedByName = previousBase })
var byName []string
runtimeBaseOpenedByName = func(path string) { byName = append(byName, path) }
lease, created, err := prepareSandboxRuntimeLeaseRecording(root)
if err != nil {
t.Fatalf("lease acquisition failed on an ordinary tree: %v", err)
}
lease.release()
if len(byName) != 1 {
t.Fatalf("opened %v by name, want exactly the base", byName)
}
if got := byName[0]; got != canonicalSandboxWorkspaceRoot(base) && got != base {
t.Fatalf("opened %q by name, want the base %q", got, base)
}
if len(created) == 0 {
t.Fatal("the descent created the owned components but recorded none of them, so rollback has nothing to undo")
}
if _, err := os.Lstat(sandboxRuntimeLeasePath(root)); err != nil {
t.Fatalf("no lease file was created on an ordinary tree: %v", err)
}
}
// And a link at the LEASE NAME itself is refused by both holders, so the shared
// and exclusive sides cannot end up locking different objects.
func TestRuntimeLeaseRefusesALinkAtTheLeaseName(t *testing.T) {
base := t.TempDir()
root := unixLeaseRootUnder(t, base)
lease, _, err := prepareSandboxRuntimeLeaseRecording(root)
if err != nil {
t.Fatalf("SETUP: cannot seed a lease: %v", err)
}
lease.release()
leasePath := sandboxRuntimeLeasePath(root)
if err := os.Remove(leasePath); err != nil {
t.Fatalf("SETUP INVALID: cannot clear the seeded lease: %v", err)
}
target := filepath.Join(t.TempDir(), "target.lease")
if err := os.WriteFile(target, nil, 0o600); err != nil {
t.Fatal(err)
}
if err := os.Symlink(target, leasePath); err != nil {
t.Fatalf("SETUP: cannot plant the link: %v", err)
}
shared, _, sharedErr := prepareSandboxRuntimeLeaseRecording(root)
if shared != nil {
shared.release()
}
if sharedErr == nil {
t.Error("shared acquisition accepted a link at the lease name")
}
cleanup, inUse, cleanupErr := tryAcquireSandboxRuntimeCleanupLease(root)
if cleanup != nil {
cleanup.release()
}
if cleanupErr == nil {
t.Errorf("cleanup accepted a link at the lease name (inUse=%t); it would treat a lock on %s as proof the root is free to delete", inUse, target)
}
}
// CONTROL: the two sides still coordinate on an ordinary lease.
func TestUnixSharedLeaseMakesCleanupReportInUse(t *testing.T) {
base := t.TempDir()
root := unixLeaseRootUnder(t, base)
held, _, err := prepareSandboxRuntimeLeaseRecording(root)
if err != nil {
t.Fatalf("cannot acquire a runtime lease: %v", err)
}
lease, inUse, err := tryAcquireSandboxRuntimeCleanupLease(root)
if lease != nil {
lease.release()
}
if err != nil {
t.Fatalf("cleanup failed against a legitimately held lease: %v", err)
}
if !inUse {
t.Fatal("cleanup reported the runtime root free while a shared lease was held; it would delete a tree a live command is using")
}
held.release()
lease, inUse, err = tryAcquireSandboxRuntimeCleanupLease(root)
if err != nil {
t.Fatalf("cleanup failed after the shared lease was released: %v", err)
}
if inUse {
t.Fatal("cleanup still reported the root in use after the only holder released it, so reclamation never happens")
}
lease.release()
}
@@ -0,0 +1,93 @@
package sandbox
import (
"os"
"path/filepath"
"testing"
)
// runtimeRootUnderTest builds a root with the shape production uses, so the
// components refuseAliasedRuntimeComponents walks stay inside test-owned
// storage.
//
// Owned depth is the fixed names plus the digest, so a root placed directly in
// t.TempDir() puts an owned component on /tmp, which the Unix ownership guard
// correctly refuses because /tmp belongs to root. That is the guard working, not
// a test environment problem, and it only shows up off Windows.
func runtimeRootUnderTest(t *testing.T, leaf string) string {
t.Helper()
root := filepath.Join(t.TempDir(), "zero", "runtime", "v1", leaf)
if err := os.MkdirAll(root, 0o700); err != nil {
t.Fatal(err)
}
return root
}
// A LEASE IS OWNERSHIP FOR A TRANSACTION, NOT A SELECTION PROBE.
//
// Setup took a lease only to learn which root won and released it at once, so
// nothing owned the selected root while the elevated helper provisioned the
// tree, applied the ACL and stamp, installed network state and wrote the marker.
// A command for another workspace scans the same runtime parent and excludes
// only its own current root, so it can take this root's cleanup lease and
// RemoveAll it mid-transaction; setup then publishes success for a pathname that
// is gone.
//
// This pins the mechanism the fix relies on: a held lease is what makes the
// cleanup's exclusive acquire fail.
func TestAHeldRuntimeLeaseStopsConcurrentCleanup(t *testing.T) {
root := runtimeRootUnderTest(t, "deadbeefdeadbeef")
lease, err := prepareSandboxRuntimeLease(root)
if err != nil {
t.Fatalf("acquire the runtime lease: %v", err)
}
removeSandboxRuntimeRootIfUnused(root)
if _, err := os.Stat(root); err != nil {
t.Fatalf("cleanup removed a root that setup was holding: %v", err)
}
lease.release()
// And once nothing holds it, cleanup does its job: without this half the test
// would pass against a cleanup that never removes anything.
removeSandboxRuntimeRootIfUnused(root)
if _, err := os.Stat(root); !os.IsNotExist(err) {
t.Fatalf("an unheld root survived cleanup: %v", err)
}
}
// The lease is on the root the caller names, so holding one root does not
// protect a sibling: setup reserving its own selection must not stop the
// cleanup doing its work elsewhere.
func TestAHeldLeaseDoesNotProtectASiblingRoot(t *testing.T) {
held := runtimeRootUnderTest(t, "aaaaaaaaaaaaaaaa")
sibling := filepath.Join(filepath.Dir(held), "bbbbbbbbbbbbbbbb")
if err := os.MkdirAll(sibling, 0o700); err != nil {
t.Fatal(err)
}
lease, err := prepareSandboxRuntimeLease(held)
if err != nil {
t.Fatal(err)
}
defer lease.release()
removeSandboxRuntimeRootIfUnused(sibling)
if _, err := os.Stat(sibling); !os.IsNotExist(err) {
t.Errorf("holding one root blocked cleanup of an unrelated sibling: %v", err)
}
if _, err := os.Stat(held); err != nil {
t.Errorf("the held root was removed: %v", err)
}
}
// createdRuntimeDirsForTest builds identity-bound rollback records for paths a
// test made itself, so a test can express "these are the directories this run
// created" without restating the identity capture.
func createdRuntimeDirsForTest(paths ...string) []windowsCreatedRuntimeDir {
records := make([]windowsCreatedRuntimeDir, 0, len(paths))
for _, path := range paths {
identity, identified := runtimeDirIdentity(path)
records = append(records, windowsCreatedRuntimeDir{path: path, identity: identity, identified: identified})
}
return records
}
@@ -0,0 +1,21 @@
//go:build !windows
package sandbox
// acquireRuntimeLeaseForPlatform creates and locks the lease through the same
// rooted no-follow boundary the Windows side uses.
//
// A PRE-CHECK IS NOT A BOUNDARY. This used to call refuseAliasedRuntimeComponents
// and then os.MkdirAll plus a pathname lease open. The guard answers about an
// ABSENT component by saying there is nothing to alias, which is exactly the
// state a fresh fallback root is in, and both calls that followed resolve the
// name again. A link planted in between was therefore created through and
// written into, and the next check could only report it after the writes.
//
// The alias guard is gone from this path rather than kept alongside: retaining it
// would suggest the two together are the protection, when the descent is the
// protection and the guard is the thing that could not be one. Other callers
// still use it where a pathname really is all there is.
func acquireRuntimeLeaseForPlatform(root string) (*sandboxRuntimeLease, []windowsCreatedRuntimeDir, error) {
return acquireRuntimeLeaseRootedUnix(root)
}
@@ -0,0 +1,10 @@
//go:build windows
package sandbox
// acquireRuntimeLeaseForPlatform routes Windows through the rooted, no-follow
// acquisition. See acquireRuntimeLeaseRooted for why the pathname walk it
// replaces was unsafe.
func acquireRuntimeLeaseForPlatform(root string) (*sandboxRuntimeLease, []windowsCreatedRuntimeDir, error) {
return acquireRuntimeLeaseRooted(root)
}
@@ -0,0 +1,299 @@
//go:build windows
package sandbox
import (
"os"
"path/filepath"
"strings"
"testing"
"unsafe"
"golang.org/x/sys/windows"
)
// seedRuntimeLeaseTree creates the owned tail and the lease the way a first run
// does, then removes just the lease file so a test can put something else at that
// name. The tree has to exist, or cleanup's descent has nothing to open and the
// test would pass for the wrong reason.
func seedRuntimeLeaseTree(t *testing.T, root string) string {
t.Helper()
lease, _, err := prepareSandboxRuntimeLeaseRecording(root)
if err != nil {
t.Skipf("cannot seed a runtime lease here: %v", err)
}
lease.release()
leasePath := sandboxRuntimeLeasePath(root)
if err := os.Remove(leasePath); err != nil {
t.Fatalf("SETUP INVALID: cannot clear the seeded lease: %v", err)
}
return leasePath
}
// plantFileLink puts a FILE symbolic link at leasePath pointing at an ordinary
// file, and returns the target.
//
// A file link, not a junction: a junction is a directory and FILE_NON_DIRECTORY_FILE
// already refuses it, so a junction here would pass against the defect and prove
// nothing. Creating one needs SeCreateSymbolicLinkPrivilege, which an ordinary
// unelevated account does not hold, so this skips there and runs on CI.
func plantFileLink(t *testing.T, leasePath string) string {
t.Helper()
target := filepath.Join(t.TempDir(), "target.lease")
if err := os.WriteFile(target, nil, 0o600); err != nil {
t.Fatal(err)
}
if err := os.Symlink(target, leasePath); err != nil {
t.Skipf("cannot create a file symbolic link here, which needs SeCreateSymbolicLinkPrivilege: %v", err)
}
// SETUP: it really is a reparse point, or nothing below is about links.
info, err := os.Lstat(leasePath)
if err != nil || info.Mode()&os.ModeSymlink == 0 {
t.Fatalf("SETUP INVALID: %s is not a link (%v, %v)", leasePath, info, err)
}
return target
}
// assertTargetUnlocked proves nobody holds a lock on the link's target, which is
// the object the defect made the two sides fight over.
func assertTargetUnlocked(t *testing.T, target string) {
t.Helper()
file, err := os.OpenFile(target, os.O_RDWR, 0o600)
if err != nil {
t.Fatalf("open the link target: %v", err)
}
defer func() { _ = file.Close() }()
var overlapped windows.Overlapped
flags := uint32(windows.LOCKFILE_EXCLUSIVE_LOCK | windows.LOCKFILE_FAIL_IMMEDIATELY)
if err := windows.LockFileEx(windows.Handle(file.Fd()), flags, 0, 1, 0, &overlapped); err != nil {
t.Fatalf("the link target is locked, so the refusal happened after the lease had already been taken on it: %v", err)
}
_ = windows.UnlockFileEx(windows.Handle(file.Fd()), 0, 1, 0, &overlapped)
}
// FILE_OPEN_REPARSE_POINT SAYS DO NOT FOLLOW, NOT REFUSE.
//
// The rooted opener passed that flag and FILE_NON_DIRECTORY_FILE and treated the
// pair as a guarantee. It is not: the flag returns a handle to the LINK, and
// FILE_NON_DIRECTORY_FILE excludes directories rather than non-directory reparse
// objects. A file symbolic link at <digest>.lease was opened, wrapped and locked
// as though it were the lease.
//
// That matters because cleanup opened the same name by pathname with no
// no-follow flag at all, so it locked the link's TARGET. Two holders, two
// objects, both calls succeeding, and cleanup free to RemoveAll a runtime root a
// live command was using.
func TestSharedLeaseRefusesAFileLinkAtTheLeaseName(t *testing.T) {
cacheRoot := t.TempDir()
root := leaseRootUnder(t, cacheRoot)
leasePath := seedRuntimeLeaseTree(t, root)
target := plantFileLink(t, leasePath)
lease, _, err := prepareSandboxRuntimeLeaseRecording(root)
if lease != nil {
lease.release()
}
if err == nil {
t.Fatal("shared acquisition accepted a file link at the lease name, so it is holding a lock on an object cleanup does not see")
}
if !strings.Contains(err.Error(), "reparse point") {
t.Errorf("refused for the wrong reason: %v", err)
}
assertTargetUnlocked(t, target)
}
// And cleanup refuses the same object, so it cannot decide the root is free by
// locking something else.
func TestCleanupLeaseRefusesAFileLinkAtTheLeaseName(t *testing.T) {
cacheRoot := t.TempDir()
root := leaseRootUnder(t, cacheRoot)
leasePath := seedRuntimeLeaseTree(t, root)
target := plantFileLink(t, leasePath)
lease, inUse, err := tryAcquireSandboxRuntimeCleanupLease(root)
if lease != nil {
lease.release()
}
if err == nil {
t.Fatalf("cleanup accepted a file link at the lease name (inUse=%t); it would treat a lock on %s as proof the runtime root is free to delete", inUse, target)
}
if !strings.Contains(err.Error(), "reparse point") {
t.Errorf("refused for the wrong reason: %v", err)
}
assertTargetUnlocked(t, target)
}
// CONTROL: THE TWO SIDES REALLY DO COORDINATE ON AN ORDINARY LEASE.
//
// Without this, refusing everything would satisfy the two tests above. This one
// runs everywhere, including on an unelevated box with no symbolic-link
// privilege, so the mutual-exclusion contract stays pinned even where the link
// cases skip.
func TestASharedLeaseMakesCleanupReportInUse(t *testing.T) {
cacheRoot := t.TempDir()
root := leaseRootUnder(t, cacheRoot)
held, _, err := prepareSandboxRuntimeLeaseRecording(root)
if err != nil {
t.Skipf("cannot acquire a runtime lease here: %v", err)
}
lease, inUse, err := tryAcquireSandboxRuntimeCleanupLease(root)
if lease != nil {
lease.release()
}
if err != nil {
t.Fatalf("cleanup failed against a legitimately held lease: %v", err)
}
if !inUse {
t.Fatal("cleanup reported the runtime root free while a shared lease was held; it would delete a tree a live command is using")
}
held.release()
lease, inUse, err = tryAcquireSandboxRuntimeCleanupLease(root)
if err != nil {
t.Fatalf("cleanup failed after the shared lease was released: %v", err)
}
if inUse {
t.Fatal("cleanup still reported the runtime root in use after the only holder released it, so reclamation never happens")
}
if lease == nil {
t.Fatal("cleanup reported the root free but handed back no lease")
}
lease.release()
}
// CONTROL: a legitimate lease is still SHARED between processes.
//
// The classification must not turn the shared lease into an exclusive one, or
// two concurrent commands on one workspace would stop working.
func TestAnOrdinaryLeaseIsStillSharedByTwoHolders(t *testing.T) {
cacheRoot := t.TempDir()
root := leaseRootUnder(t, cacheRoot)
first, _, err := prepareSandboxRuntimeLeaseRecording(root)
if err != nil {
t.Skipf("cannot acquire a runtime lease here: %v", err)
}
defer first.release()
second, _, err := prepareSandboxRuntimeLeaseRecording(root)
if err != nil {
t.Fatalf("a second holder was refused an existing ordinary lease: %v", err)
}
second.release()
}
// reparseGUIDDataBuffer is the non-Microsoft form of REPARSE_GUID_DATA_BUFFER.
//
// Setting a tag with the Microsoft bit clear needs only write access to the file,
// unlike a symbolic link, so this runs on an ordinary unelevated account and the
// classification stays pinned where the link cases skip.
type reparseGUIDDataBuffer struct {
ReparseTag uint32
ReparseDataLength uint16
Reserved uint16
ReparseGUID windows.GUID
Data [16]byte
}
// plantGenericReparsePoint turns an ordinary file at path into a reparse object.
func plantGenericReparsePoint(t *testing.T, path string) {
t.Helper()
if err := os.WriteFile(path, nil, 0o600); err != nil {
t.Fatal(err)
}
name, err := windows.UTF16PtrFromString(path)
if err != nil {
t.Fatal(err)
}
handle, err := windows.CreateFile(name,
windows.GENERIC_WRITE|windows.FILE_WRITE_ATTRIBUTES, 0, nil,
windows.OPEN_EXISTING,
windows.FILE_FLAG_OPEN_REPARSE_POINT|windows.FILE_FLAG_BACKUP_SEMANTICS, 0)
if err != nil {
t.Skipf("cannot open %s to set a reparse tag: %v", path, err)
}
defer func() { _ = windows.CloseHandle(handle) }()
buffer := reparseGUIDDataBuffer{
// Bit 31 clear, so this is a third-party tag and needs no privilege.
ReparseTag: 0x00000042,
ReparseDataLength: 16,
ReparseGUID: windows.GUID{Data1: 0x5ee0e5f1, Data2: 0x1a11, Data3: 0x4d3b, Data4: [8]byte{0x9a, 0x77, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06}},
}
size := uint32(unsafe.Sizeof(buffer))
var returned uint32
if err := windows.DeviceIoControl(handle, windowsFSCTLSetReparsePoint,
(*byte)(unsafe.Pointer(&buffer)), size, nil, 0, &returned, nil); err != nil {
t.Skipf("cannot set a generic reparse tag here: %v", err)
}
}
const windowsFSCTLSetReparsePoint = 0x000900A4
// ANY REPARSE OBJECT AT THE LEASE NAME IS REFUSED, NOT ONLY A SYMBOLIC LINK.
//
// The requirement is that the name denotes an ordinary file, because that is what
// makes the shared holder and cleanup lock the same thing. Keying on the link
// shape instead would leave every other reparse tag accepted, and it would leave
// this untested on any machine without the symbolic-link privilege.
//
// WHAT THIS CASE CANNOT SHOW, so nobody reads a local pass as more than it is:
// an unknown third-party tag is unresolvable, so the OLD pathname cleanup fails
// on it too, with ERROR_CANT_ACCESS_FILE rather than by classifying anything. It
// therefore pins that both sites refuse, and the reason check below is what
// separates a refusal from an accident. Only the symbolic-link cases above show
// the half that matters most, a pathname open SUCCEEDING on the target, and those
// need the privilege. Read their result in CI, not here.
func TestLeaseRefusesAnyReparseObjectAtTheLeaseName(t *testing.T) {
for name, acquire := range map[string]func(string) error{
"shared": func(root string) error {
lease, _, err := prepareSandboxRuntimeLeaseRecording(root)
if lease != nil {
lease.release()
}
return err
},
"cleanup": func(root string) error {
lease, _, err := tryAcquireSandboxRuntimeCleanupLease(root)
if lease != nil {
lease.release()
}
return err
},
} {
t.Run(name, func(t *testing.T) {
cacheRoot := t.TempDir()
root := leaseRootUnder(t, cacheRoot)
leasePath := seedRuntimeLeaseTree(t, root)
plantGenericReparsePoint(t, leasePath)
// SETUP: the attribute really is set, or this asserts nothing.
info, err := os.Lstat(leasePath)
if err != nil || info.Mode()&os.ModeIrregular == 0 && info.Mode()&os.ModeSymlink == 0 {
attrs, statErr := windowsFileAttributes(leasePath)
if statErr != nil || attrs&windows.FILE_ATTRIBUTE_REPARSE_POINT == 0 {
t.Fatalf("SETUP INVALID: %s is not a reparse object (attrs=%#x err=%v)", leasePath, attrs, statErr)
}
}
err = acquire(root)
if err == nil {
t.Fatalf("%s acquisition accepted a reparse object at the lease name", name)
}
if !strings.Contains(err.Error(), "reparse point") {
t.Errorf("refused for the wrong reason: %v", err)
}
})
}
}
func windowsFileAttributes(path string) (uint32, error) {
name, err := windows.UTF16PtrFromString(path)
if err != nil {
return 0, err
}
return windows.GetFileAttributes(name)
}
@@ -0,0 +1,282 @@
//go:build !windows
package sandbox
import (
"errors"
"fmt"
"os"
"path/filepath"
"golang.org/x/sys/unix"
)
// acquireRuntimeLeaseRootedUnix creates and locks the lease for root without
// resolving an owned component by name.
//
// The lease is the FIRST thing setup writes, so it is the write a redirection has
// to be caught before, not after. It is a sibling of the runtime root rather than
// one of its owned components, which is why the alias guard never looked at it at
// all: it walks the components, and the lease is not one of them.
//
// Only the components above the leaf are created here. The leaf belongs to
// provisioning, which records it for rollback, and creating it here would mean
// two owners for one directory.
func acquireRuntimeLeaseRootedUnix(root string) (*sandboxRuntimeLease, []windowsCreatedRuntimeDir, error) {
base, components, owned := windowsSandboxRuntimeOwnedTail(root)
if !owned || len(components) == 0 {
// Fail rather than fall back to the pathname walk: the walk is the defect.
return nil, nil, fmt.Errorf("acquire sandbox runtime lease for %s: %w", root, errRuntimeTailNotOwned)
}
if runtimeLeasePreCreateBarrier != nil {
runtimeLeasePreCreateBarrier()
}
// The base is the operator's, and may legitimately be a redirected or linked
// temp location, so it is created and opened by name exactly as before.
// Everything below it is Zero's and is addressed by descriptor.
if err := os.MkdirAll(base, 0o700); err != nil {
return nil, nil, fmt.Errorf("create sandbox runtime base: %w", err)
}
// The lease sits beside the leaf, so the deepest directory needed here is the
// leaf's parent.
tail := make([]string, 0, len(components)-1)
current := base
for _, component := range components[:len(components)-1] {
current = filepath.Join(current, component)
tail = append(tail, current)
}
// THE WHOLE ACQUISITION RETRIES, NOT JUST THE LOCK. A contender that waited
// on the lease while cleanup held it exclusively can wake on an object cleanup
// has since unlinked, and by then cleanup has compensated the tail this run
// created as well. Re-taking the lock alone would lock a fresh file beside a
// leaf that is gone, so a stale lock sends the caller back through tail
// creation. The ledger accumulates across attempts: a directory this run made
// on an earlier pass and cleanup did not remove is still this run's to
// compensate.
var created []windowsCreatedRuntimeDir
for attempt := 1; ; attempt++ {
made, parent, err := createRuntimeTailRetainingFD(base, tail)
created = appendCreatedRuntimeDirs(created, made)
if err != nil {
return nil, created, err
}
handle, madeLease, err := acquireSharedRuntimeLeaseAtFD(parent, filepath.Base(sandboxRuntimeLeasePath(root)))
_ = unix.Close(parent)
if err == nil {
return &sandboxRuntimeLease{handle: handle, root: root, createdFile: madeLease}, created, nil
}
if errors.Is(err, errRuntimeLeaseReplaced) && attempt < runtimeLeaseAcquireAttempts {
continue
}
return nil, created, fmt.Errorf("acquire sandbox runtime lease: %w", err)
}
}
// acquireSharedRuntimeLeaseAtFD opens the lease relative to a verified parent and
// takes the shared lock on it.
func acquireSharedRuntimeLeaseAtFD(parent int, name string) (runtimeLeaseHandle, bool, error) {
file, created, err := openRuntimeLeaseAtFD(parent, name)
if err != nil {
return runtimeLeaseHandle{}, false, err
}
if err := unix.Flock(int(file.Fd()), unix.LOCK_SH); err != nil {
// The lock is the last step, and until now the created fact went out only
// alongside a lease object. Failing here returned no object, so this
// invocation's own lease file stayed on disk with nothing recording it,
// and its directory could never be compensated afterwards.
err = undoCreatedRuntimeLeaseAtFD(created, parent, name, err)
_ = file.Close()
return runtimeLeaseHandle{}, false, err
}
// THE LOCK IS ON AN INODE, THE COORDINATION IS ON A NAME. Flock blocks while
// cleanup holds the exclusive lock, and cleanup unlinks the lease name before
// it lets go. A waiter then acquires the old, unlinked inode and returns a
// lease that coordinates with nobody: the next cleanup creates a fresh file at
// the name, locks it unopposed, and reports the root unused while this holder
// is still running in it. So the lock is only a lease once the locked object
// is still what the name resolves to. Reported by @gnanam1990.
current, err := runtimeLeaseIsCurrentAtFD(parent, name, int(file.Fd()))
if err != nil {
_ = file.Close()
return runtimeLeaseHandle{}, false, err
}
if !current {
// Not undone by name, whatever created says: the name is no longer this
// object's, so an unlink there would take somebody else's lease.
_ = file.Close()
return runtimeLeaseHandle{}, false, errRuntimeLeaseReplaced
}
return runtimeLeaseHandle{file: file}, created, nil
}
// runtimeLeaseIsCurrentAtFD reports whether the locked lease is still the object
// the lease name resolves to under parent.
//
// Both answers come from the descriptor side: the locked file's own identity and
// the directory entry read relative to the retained parent, no-follow. A missing
// entry, a different inode at the name, or a locked inode with no links left all
// mean the coordination moved on while this call was waiting.
func runtimeLeaseIsCurrentAtFD(parent int, name string, fd int) (bool, error) {
var locked, entry unix.Stat_t
if err := unix.Fstat(fd, &locked); err != nil {
return false, fmt.Errorf("inspect the locked sandbox runtime lease %s: %w", name, err)
}
if locked.Nlink == 0 {
return false, nil
}
if err := unix.Fstatat(parent, name, &entry, unix.AT_SYMLINK_NOFOLLOW); err != nil {
if errors.Is(err, unix.ENOENT) {
return false, nil
}
return false, fmt.Errorf("inspect the sandbox runtime lease entry %s: %w", name, err)
}
return locked.Dev == entry.Dev && locked.Ino == entry.Ino, nil
}
// undoCreatedRuntimeLeaseAtFD removes a lease file this call created, when a
// later step means no lease object will be returned to carry that fact.
//
// Relative to the verified parent descriptor, and only when this call is the one
// that created the file: a lease that was already there belongs to whoever is
// holding it.
func undoCreatedRuntimeLeaseAtFD(created bool, parent int, name string, cause error) error {
if !created {
return cause
}
if undoErr := unix.Unlinkat(parent, name, 0); undoErr != nil {
return fmt.Errorf("%w; and the lease file this run created could not be removed: %w", cause, undoErr)
}
return cause
}
// openRuntimeLeaseAtFD opens or creates the lease under parent and proves it is
// an ordinary file.
//
// O_NOFOLLOW so a symlink planted at the lease name is an ELOOP rather than an
// open of its target, and a regular-file check besides, because O_NOFOLLOW says
// nothing about a fifo or a device a local user can also create there. Both
// holders have to end up on the same object or the lock protects nothing.
func openRuntimeLeaseAtFD(parent int, name string) (*os.File, bool, error) {
const flags = unix.O_RDWR | unix.O_NOFOLLOW | unix.O_CLOEXEC
// O_EXCL FIRST, so the create is what reports the create. A Stat beforehand
// answers about a moment that has passed by the time the open runs, and
// compensation would then delete a lease another process had just made.
created := true
fd, err := unix.Openat(parent, name, flags|unix.O_CREAT|unix.O_EXCL, 0o600)
if errors.Is(err, unix.EEXIST) {
created = false
fd, err = unix.Openat(parent, name, flags, 0)
}
if err != nil {
if errors.Is(err, unix.ELOOP) {
return nil, false, fmt.Errorf("refusing to use the sandbox runtime lease at %s: it is a link, so the holders would lock different objects: %w", name, err)
}
return nil, false, err
}
// Every failure from here on has already created the file when created is
// true, and returns no lease object to carry that fact, so each one undoes
// its own creation rather than leaving an unrecorded artifact behind.
if runtimeCreationFailure != nil {
if injected := runtimeCreationFailure(name); injected != nil {
injected = undoCreatedRuntimeLeaseAtFD(created, parent, name, injected)
_ = unix.Close(fd)
return nil, false, injected
}
}
var stat unix.Stat_t
if err := unix.Fstat(fd, &stat); err != nil {
err = undoCreatedRuntimeLeaseAtFD(created, parent, name, fmt.Errorf("inspect the sandbox runtime lease %s: %w", name, err))
_ = unix.Close(fd)
return nil, false, err
}
if stat.Mode&unix.S_IFMT != unix.S_IFREG {
err := undoCreatedRuntimeLeaseAtFD(created, parent, name, fmt.Errorf("refusing to use the sandbox runtime lease at %s: it is not an ordinary file (mode %#o)", name, stat.Mode&unix.S_IFMT))
_ = unix.Close(fd)
return nil, false, err
}
if uid := unix.Getuid(); uid >= 0 && stat.Uid != uint32(uid) {
err := undoCreatedRuntimeLeaseAtFD(created, parent, name, fmt.Errorf("refusing to use the sandbox runtime lease at %s: it is owned by uid %d, not %d", name, stat.Uid, uid))
_ = unix.Close(fd)
return nil, false, err
}
file := os.NewFile(uintptr(fd), name)
if file == nil {
err := undoCreatedRuntimeLeaseAtFD(created, parent, name, fmt.Errorf("wrap the sandbox runtime lease handle for %s", name))
_ = unix.Close(fd)
return nil, false, err
}
return file, created, nil
}
// tryAcquireExclusiveRuntimeLeaseRootedUnix is cleanup's acquisition, resolved
// the way acquisition resolves it.
//
// It opens and never creates the tree: a runtime root that is not there has no
// lease to take, and cleanup rebuilding it in order to lock it would be inventing
// the thing it is about to remove.
func tryAcquireExclusiveRuntimeLeaseRootedUnix(root string) (runtimeLeaseHandle, bool, error) {
base, components, owned := windowsSandboxRuntimeOwnedTail(root)
if !owned || len(components) == 0 {
return runtimeLeaseHandle{}, false, fmt.Errorf("open the sandbox runtime lease parent for %s: %w", root, errRuntimeTailNotOwned)
}
parent, err := unix.Open(base, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC, 0)
if err != nil {
return runtimeLeaseHandle{}, false, fmt.Errorf("open sandbox runtime base %s: %w", base, err)
}
path := base
for _, name := range components[:len(components)-1] {
path = filepath.Join(path, name)
child, openErr := unix.Openat(parent, name, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0)
if openErr != nil {
_ = unix.Close(parent)
return runtimeLeaseHandle{}, false, fmt.Errorf("open sandbox runtime component %s: %w", path, openErr)
}
if err := refuseForeignRuntimeDirectory(child, path); err != nil {
_ = unix.Close(child)
_ = unix.Close(parent)
return runtimeLeaseHandle{}, false, err
}
_ = unix.Close(parent)
parent = child
}
defer func() { _ = unix.Close(parent) }()
name := filepath.Base(sandboxRuntimeLeasePath(root))
for attempt := 1; ; attempt++ {
file, created, err := openRuntimeLeaseAtFD(parent, name)
if err != nil {
return runtimeLeaseHandle{}, false, err
}
if err := unix.Flock(int(file.Fd()), unix.LOCK_EX|unix.LOCK_NB); err != nil {
if errors.Is(err, unix.EWOULDBLOCK) || errors.Is(err, unix.EAGAIN) {
// Somebody holds it, so it is not this call's to remove, whatever the
// open reported. A file this call created cannot reach here anyway.
_ = file.Close()
return runtimeLeaseHandle{}, true, nil
}
err = undoCreatedRuntimeLeaseAtFD(created, parent, name, err)
_ = file.Close()
return runtimeLeaseHandle{}, false, err
}
// The exclusive side has the same window in miniature: between its open
// and its lock another cleanup can unlink and replace the name, and an
// exclusive lock on the old inode would then remove a tree a holder of the
// new one is entering. Same check, same answer: only the current object
// counts, and a stale one is reopened rather than reasoned about.
current, err := runtimeLeaseIsCurrentAtFD(parent, name, int(file.Fd()))
if err != nil {
_ = file.Close()
return runtimeLeaseHandle{}, false, err
}
if !current {
_ = file.Close()
if attempt < runtimeLeaseAcquireAttempts {
continue
}
return runtimeLeaseHandle{}, false, errRuntimeLeaseReplaced
}
return runtimeLeaseHandle{file: file}, false, nil
}
}
@@ -0,0 +1,408 @@
//go:build windows
package sandbox
import (
"errors"
"fmt"
"os"
"path/filepath"
"unsafe"
"golang.org/x/sys/windows"
)
// acquireRuntimeLeaseRooted creates and locks the lease file for root without
// ever resolving an owned component by name.
//
// THE LEASE IS ACQUIRED BEFORE ANYTHING ELSE, SO IT CANNOT BE THE WEAK LINK.
//
// Provisioning already descends from the fixed cache or TEMP base through
// retained no-follow handles, because a predictable owned component is exactly
// what an ordinary same-account process can replace with a junction. Lease
// acquisition ran first and did neither: it checked the components for aliases,
// then called os.MkdirAll on the parent by pathname and opened
// "<root>.lease" by pathname, both of which follow. A junction dropped on
// "zero", "runtime" or "v1" between the check and either call put elevated
// setup's first writes inside somebody else's tree, and restoring the component
// afterwards left the later handle-relative provisioning working on the
// legitimate tree so no post-check ever saw it.
//
// The lease file is a SIBLING of the runtime root rather than one of its owned
// components, so refuseAliasedRuntimeComponents never inspected it at all. Here
// it is created relative to the retained handle for the directory that contains
// it, which is the deepest owned component, so its name is resolved exactly once
// and relative to a verified object.
//
// Only the components above the leaf are created. The leaf itself belongs to
// provisioning, which records it for rollback; creating it here would mean two
// owners for one directory.
func acquireRuntimeLeaseRooted(root string) (*sandboxRuntimeLease, []windowsCreatedRuntimeDir, error) {
base, components, owned := windowsSandboxRuntimeOwnedTail(root)
if !owned {
// Fail rather than fall back to the pathname walk: the walk is the defect.
return nil, nil, fmt.Errorf("acquire sandbox runtime lease for %s: %w", root, errRuntimeTailNotOwned)
}
if len(components) == 0 {
return nil, nil, fmt.Errorf("acquire sandbox runtime lease for %s: %w", root, errRuntimeTailNotOwned)
}
// The base is the operator's, and may legitimately be a redirected cache or
// TEMP location, so it is created and opened by name exactly as provisioning
// does. Everything below it is Zero's and is addressed by handle.
if runtimeLeasePreCreateBarrier != nil {
runtimeLeasePreCreateBarrier()
}
if err := os.MkdirAll(base, 0o700); err != nil {
return nil, nil, fmt.Errorf("create sandbox runtime base: %w", err)
}
// The lease sits beside the leaf, so the deepest directory needed here is the
// leaf's parent.
parents := components[:len(components)-1]
tail := make([]string, 0, len(parents))
current := base
for _, component := range parents {
current = filepath.Join(current, component)
tail = append(tail, current)
}
// THE WHOLE ACQUISITION RETRIES, NOT JUST THE LOCK. A contender that waited
// on the lease while cleanup held it exclusively can wake on an object cleanup
// has since marked for deletion, and by then cleanup has compensated the tail
// this run created as well. Re-taking the lock alone would lock a fresh file
// beside a leaf that is gone, so a stale lock sends the caller back through
// tail creation. The ledger accumulates across attempts: a directory this run
// made on an earlier pass and cleanup did not remove is still this run's to
// compensate.
var created []windowsCreatedRuntimeDir
for attempt := 1; ; attempt++ {
made, parent, err := createRuntimeTailRetainingHandle(base, tail)
created = appendCreatedRuntimeDirs(created, made)
if err != nil {
if parent != 0 {
_ = windows.CloseHandle(parent)
}
return nil, created, err
}
handle, madeLease, err := acquireSharedRuntimeLeaseAt(parent, filepath.Base(sandboxRuntimeLeasePath(root)))
_ = windows.CloseHandle(parent)
if err == nil {
return &sandboxRuntimeLease{handle: handle, root: root, createdFile: madeLease}, created, nil
}
if errors.Is(err, errRuntimeLeaseReplaced) && attempt < runtimeLeaseAcquireAttempts {
continue
}
return nil, created, fmt.Errorf("acquire sandbox runtime lease: %w", err)
}
}
// acquireSharedRuntimeLeaseAt is acquireSharedRuntimeLease with the file named
// relative to a directory handle instead of by full path.
func acquireSharedRuntimeLeaseAt(parent windows.Handle, name string) (runtimeLeaseHandle, bool, error) {
objectName, err := windows.NewNTUnicodeString(name)
if err != nil {
return runtimeLeaseHandle{}, false, fmt.Errorf("encode sandbox runtime lease name %s: %w", name, err)
}
attributes := windows.OBJECT_ATTRIBUTES{
RootDirectory: parent,
ObjectName: objectName,
Attributes: windows.OBJ_CASE_INSENSITIVE,
}
attributes.Length = uint32(unsafe.Sizeof(attributes))
var handle windows.Handle
var iosb windows.IO_STATUS_BLOCK
// FILE_OPEN_IF because the lease is shared: whoever gets there first creates
// it. FILE_NON_DIRECTORY_FILE so a directory under that name is refused.
//
// FILE_OPEN_REPARSE_POINT IS NOT A REFUSAL. It says do not follow, so the call
// returns a handle to the LINK, and FILE_NON_DIRECTORY_FILE excludes
// directories rather than non-directory reparse objects. A file symbolic link
// planted at <digest>.lease was therefore opened and locked as if it were the
// lease. No-follow and classification are two requirements; the flag is only
// the first, and the handle is asked for the second below.
err = windows.NtCreateFile(
&handle,
windows.DELETE|windows.GENERIC_READ|windows.GENERIC_WRITE|windows.SYNCHRONIZE,
&attributes,
&iosb,
nil,
windows.FILE_ATTRIBUTE_NORMAL,
windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE,
windows.FILE_OPEN_IF,
windows.FILE_NON_DIRECTORY_FILE|windows.FILE_SYNCHRONOUS_IO_NONALERT|windows.FILE_OPEN_REPARSE_POINT,
0,
0,
)
if err != nil {
return runtimeLeaseHandle{}, false, err
}
// FILE_CREATED here, rather than a Stat before the call, because only the
// create itself can distinguish the file it made from one that arrived a
// moment earlier.
created := iosb.Information == windowsFileCreatedDisposition
// And every failure below has already created the file when that is true,
// while returning no lease object to carry the fact, so each undoes its own
// creation instead of leaving an unrecorded artifact in a directory
// compensation then cannot empty.
if runtimeCreationFailure != nil {
if injected := runtimeCreationFailure(name); injected != nil {
injected = undoCreatedRuntimeLease(created, handle, injected)
_ = windows.CloseHandle(handle)
return runtimeLeaseHandle{}, false, injected
}
}
if err := refuseReparseRuntimeLeaseHandle(handle, name); err != nil {
err = undoCreatedRuntimeLease(created, handle, err)
_ = windows.CloseHandle(handle)
return runtimeLeaseHandle{}, false, err
}
file := os.NewFile(uintptr(handle), name)
if file == nil {
err := undoCreatedRuntimeLease(created, handle, fmt.Errorf("wrap the sandbox runtime lease handle for %s", name))
_ = windows.CloseHandle(handle)
return runtimeLeaseHandle{}, false, err
}
lease := runtimeLeaseHandle{file: file}
if err := windows.LockFileEx(windows.Handle(file.Fd()), 0, 0, 1, 0, &lease.overlapped); err != nil {
err = undoCreatedRuntimeLease(created, windows.Handle(file.Fd()), err)
_ = file.Close()
return runtimeLeaseHandle{}, false, err
}
// THE LOCK IS ON AN OBJECT, THE COORDINATION IS ON A NAME. LockFileEx waits
// while cleanup holds the exclusive lock, and cleanup marks the lease for
// deletion before it lets go; this handle was opened with FILE_SHARE_DELETE
// precisely so cleanup could. A waiter then locks a delete-pending object and
// returns a lease that coordinates with nobody: once this handle closes the
// name is free, the next cleanup creates a fresh file there, locks it
// unopposed, and reports the root unused while this holder is still running
// in it. So the lock is only a lease once the locked object is still what the
// name resolves to. Reported by @gnanam1990.
current, err := runtimeLeaseIsCurrentAt(parent, name, windows.Handle(file.Fd()))
if err != nil {
_ = file.Close()
return runtimeLeaseHandle{}, false, err
}
if !current {
// Not undone, whatever created says: the object is already going, and the
// name now belongs to whoever comes next. Closing this handle is what lets
// the name go so the retry can take a current one.
_ = file.Close()
return runtimeLeaseHandle{}, false, errRuntimeLeaseReplaced
}
return lease, created, nil
}
// runtimeLeaseIsCurrentAt reports whether the locked lease is still the object
// the lease name resolves to under parent.
//
// Both answers come from handles: the locked file's own identity, and a fresh
// no-follow open of the name relative to the retained parent, asked for nothing
// but attributes. A name that is gone or delete-pending, or that resolves to a
// different volume and file index, means the coordination moved on while this
// call was waiting.
func runtimeLeaseIsCurrentAt(parent windows.Handle, name string, locked windows.Handle) (bool, error) {
var lockedInfo windows.ByHandleFileInformation
if err := windows.GetFileInformationByHandle(locked, &lockedInfo); err != nil {
return false, fmt.Errorf("inspect the locked sandbox runtime lease %s: %w", name, err)
}
objectName, err := windows.NewNTUnicodeString(name)
if err != nil {
return false, fmt.Errorf("encode sandbox runtime lease name %s: %w", name, err)
}
attributes := windows.OBJECT_ATTRIBUTES{
RootDirectory: parent,
ObjectName: objectName,
Attributes: windows.OBJ_CASE_INSENSITIVE,
}
attributes.Length = uint32(unsafe.Sizeof(attributes))
var probe windows.Handle
var iosb windows.IO_STATUS_BLOCK
err = windows.NtCreateFile(
&probe,
windows.FILE_READ_ATTRIBUTES|windows.SYNCHRONIZE,
&attributes,
&iosb,
nil,
0,
windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE,
windows.FILE_OPEN,
windows.FILE_NON_DIRECTORY_FILE|windows.FILE_SYNCHRONOUS_IO_NONALERT|windows.FILE_OPEN_REPARSE_POINT,
0,
0,
)
if err != nil {
if errors.Is(err, windows.STATUS_OBJECT_NAME_NOT_FOUND) || errors.Is(err, windows.STATUS_DELETE_PENDING) {
return false, nil
}
return false, fmt.Errorf("inspect the sandbox runtime lease entry %s: %w", name, err)
}
defer func() { _ = windows.CloseHandle(probe) }()
var entryInfo windows.ByHandleFileInformation
if err := windows.GetFileInformationByHandle(probe, &entryInfo); err != nil {
return false, fmt.Errorf("inspect the sandbox runtime lease entry %s: %w", name, err)
}
return lockedInfo.VolumeSerialNumber == entryInfo.VolumeSerialNumber &&
lockedInfo.FileIndexHigh == entryInfo.FileIndexHigh &&
lockedInfo.FileIndexLow == entryInfo.FileIndexLow, nil
}
// undoCreatedRuntimeLease removes a lease file this call created, when a later
// step means no lease object will be returned to carry that fact.
//
// Through the handle, and only when this call is the one that created the file:
// a lease that was already there belongs to whoever is holding it.
func undoCreatedRuntimeLease(created bool, handle windows.Handle, cause error) error {
if !created {
return cause
}
if undoErr := removeWindowsObjectByHandle(handle); undoErr != nil {
return fmt.Errorf("%w; and the lease file this run created could not be removed: %w", cause, undoErr)
}
return cause
}
// refuseReparseRuntimeLeaseHandle proves the opened lease is an ordinary file.
//
// Asked of the HANDLE, not the name, so there is no second resolution for a
// substitution to land in. This is the check the directory descent already makes
// in openWindowsChildNoFollow; the lease carried the no-follow flag and not the
// classification that has to go with it.
func refuseReparseRuntimeLeaseHandle(handle windows.Handle, name string) error {
var info windows.ByHandleFileInformation
if err := windows.GetFileInformationByHandle(handle, &info); err != nil {
return fmt.Errorf("inspect the sandbox runtime lease %s: %w", name, err)
}
if info.FileAttributes&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 {
return fmt.Errorf("refusing to use the sandbox runtime lease at %s: a reparse point here means the shared and exclusive holders would lock different objects", name)
}
return nil
}
// openRuntimeLeaseParentRooted opens, and never creates, the directory holding
// the lease for root.
//
// Cleanup's side of the contract. It descends the same owned components from the
// same base with the same no-follow opens acquisition uses, so both sides resolve
// the lease name relative to a verified directory rather than by pathname.
//
// It creates nothing. A runtime tree that is not there has no lease to take, and
// cleanup rebuilding the tree in order to lock it would be inventing the thing it
// is about to remove.
func openRuntimeLeaseParentRooted(root string) (windows.Handle, error) {
base, components, owned := windowsSandboxRuntimeOwnedTail(root)
if !owned || len(components) == 0 {
return 0, fmt.Errorf("open the sandbox runtime lease parent for %s: %w", root, errRuntimeTailNotOwned)
}
parent, err := openWindowsDirectoryByName(base)
if err != nil {
return 0, fmt.Errorf("open sandbox runtime base %s: %w", base, err)
}
// The lease is a SIBLING of the leaf, so the deepest directory needed here is
// the leaf's parent, exactly as in acquisition.
for _, name := range components[:len(components)-1] {
child, openErr := openWindowsChildNoFollow(parent, name,
windows.FILE_READ_ATTRIBUTES|windows.FILE_TRAVERSE, windows.FILE_DIRECTORY_FILE)
if openErr != nil {
_ = windows.CloseHandle(parent)
return 0, openErr
}
_ = windows.CloseHandle(parent)
parent = child
}
return parent, nil
}
// tryAcquireExclusiveRuntimeLeaseRooted is cleanup's acquisition, resolved the
// way acquisition resolves it.
func tryAcquireExclusiveRuntimeLeaseRooted(root string) (runtimeLeaseHandle, bool, error) {
parent, err := openRuntimeLeaseParentRooted(root)
if err != nil {
return runtimeLeaseHandle{}, false, err
}
defer func() { _ = windows.CloseHandle(parent) }()
name := filepath.Base(sandboxRuntimeLeasePath(root))
objectName, err := windows.NewNTUnicodeString(name)
if err != nil {
return runtimeLeaseHandle{}, false, fmt.Errorf("encode sandbox runtime lease name %s: %w", name, err)
}
attributes := windows.OBJECT_ATTRIBUTES{
RootDirectory: parent,
ObjectName: objectName,
Attributes: windows.OBJ_CASE_INSENSITIVE,
}
attributes.Length = uint32(unsafe.Sizeof(attributes))
for attempt := 1; ; attempt++ {
var handle windows.Handle
var iosb windows.IO_STATUS_BLOCK
// FILE_OPEN_IF matches what cleanup did by pathname with O_CREATE: a
// runtime root whose lease file is gone is held by nobody, and creating the
// empty lease is how that is expressed.
err = windows.NtCreateFile(
&handle,
windows.DELETE|windows.GENERIC_READ|windows.GENERIC_WRITE|windows.SYNCHRONIZE,
&attributes,
&iosb,
nil,
windows.FILE_ATTRIBUTE_NORMAL,
windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE,
windows.FILE_OPEN_IF,
windows.FILE_NON_DIRECTORY_FILE|windows.FILE_SYNCHRONOUS_IO_NONALERT|windows.FILE_OPEN_REPARSE_POINT,
0,
0,
)
if err != nil {
return runtimeLeaseHandle{}, false, err
}
created := iosb.Information == windowsFileCreatedDisposition
if err := refuseReparseRuntimeLeaseHandle(handle, name); err != nil {
err = undoCreatedRuntimeLease(created, handle, err)
_ = windows.CloseHandle(handle)
return runtimeLeaseHandle{}, false, err
}
file := os.NewFile(uintptr(handle), name)
if file == nil {
err := undoCreatedRuntimeLease(created, handle, fmt.Errorf("wrap the sandbox runtime lease handle for %s", name))
_ = windows.CloseHandle(handle)
return runtimeLeaseHandle{}, false, err
}
lease := runtimeLeaseHandle{file: file}
flags := uint32(windows.LOCKFILE_EXCLUSIVE_LOCK | windows.LOCKFILE_FAIL_IMMEDIATELY)
if err := windows.LockFileEx(windows.Handle(file.Fd()), flags, 0, 1, 0, &lease.overlapped); err != nil {
if errors.Is(err, windows.ERROR_LOCK_VIOLATION) {
// Somebody holds it, so it is not this call's to remove, whatever the
// open reported. A file this call created cannot reach here anyway.
_ = file.Close()
return runtimeLeaseHandle{}, true, nil
}
err = undoCreatedRuntimeLease(created, windows.Handle(file.Fd()), err)
_ = file.Close()
return runtimeLeaseHandle{}, false, err
}
// The exclusive side has the same window in miniature: between its open
// and its lock another cleanup can mark the object for deletion, and an
// exclusive lock on that object would then remove a tree a holder of the
// replacement is entering. Same check, same answer: only the current
// object counts, and a stale one is reopened rather than reasoned about.
current, err := runtimeLeaseIsCurrentAt(parent, name, windows.Handle(file.Fd()))
if err != nil {
_ = file.Close()
return runtimeLeaseHandle{}, false, err
}
if !current {
_ = file.Close()
if attempt < runtimeLeaseAcquireAttempts {
continue
}
return runtimeLeaseHandle{}, false, errRuntimeLeaseReplaced
}
return lease, false, nil
}
}
// windowsFileCreatedDisposition is the IO_STATUS_BLOCK Information value that
// says NtCreateFile made the file rather than opening one that was there.
// x/sys/windows does not export it.
const windowsFileCreatedDisposition = 2
+9 -26
View File
@@ -3,7 +3,6 @@
package sandbox
import (
"errors"
"os"
"golang.org/x/sys/unix"
@@ -13,31 +12,15 @@ type runtimeLeaseHandle struct {
file *os.File
}
func acquireSharedRuntimeLease(path string) (runtimeLeaseHandle, error) {
file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
if err != nil {
return runtimeLeaseHandle{}, err
}
if err := unix.Flock(int(file.Fd()), unix.LOCK_SH); err != nil {
_ = file.Close()
return runtimeLeaseHandle{}, err
}
return runtimeLeaseHandle{file: file}, nil
}
func tryAcquireExclusiveRuntimeLease(path string) (runtimeLeaseHandle, bool, error) {
file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
if err != nil {
return runtimeLeaseHandle{}, false, err
}
if err := unix.Flock(int(file.Fd()), unix.LOCK_EX|unix.LOCK_NB); err != nil {
_ = file.Close()
if errors.Is(err, unix.EWOULDBLOCK) || errors.Is(err, unix.EAGAIN) {
return runtimeLeaseHandle{}, true, nil
}
return runtimeLeaseHandle{}, false, err
}
return runtimeLeaseHandle{file: file}, false, nil
// BOTH SIDES HAVE TO MEAN THE SAME OBJECT.
//
// Cleanup opened the lease by full pathname while acquisition opened it relative
// to a verified parent, so a symlink at <digest>.lease gave the two of them
// different files: a live command held a shared lock on one, cleanup took an
// exclusive lock on the other, both succeeded, and cleanup went on to remove a
// runtime root that was still in use.
func tryAcquireExclusiveRuntimeLease(root string) (runtimeLeaseHandle, bool, error) {
return tryAcquireExclusiveRuntimeLeaseRootedUnix(root)
}
func (lease runtimeLeaseHandle) release() {
@@ -0,0 +1,95 @@
package sandbox
import (
"os"
"testing"
)
// A CONTENDER THAT WAITED OUT A CLEANUP HOLDS THE CURRENT LEASE, NOT THE OLD ONE.
//
// The schedule the exclusivity test does not reach. A command opens the lease
// while cleanup holds it exclusively and waits for the shared lock. Cleanup
// removes the lease name and the tree before it lets go. The waiter then wakes on
// the old object: unlinked on POSIX, delete-pending on Windows. Locking it
// succeeds, and the acquisition used to return that as a lease. Nothing else
// could ever see it: the next cleanup created a fresh lease at the name, locked
// it unopposed, and reported the root unused while the command was running in
// it. The two locks each succeeded and coordinated nothing.
//
// Both seams are used as barriers, not as failures. The cleanup barrier is
// where the contender is started, so its open lands while cleanup owns the lease;
// the creation seam is installed there too, rather than up front, because
// cleanup's own open consults it on POSIX and would otherwise signal before the
// contender exists. Reported by @gnanam1990.
func TestAContenderThatWaitedOutACleanupHoldsTheCurrentLease(t *testing.T) {
root, _, leasePath := runtimeTreeAwaitingCompensation(t)
// Only the leaf is this run's, the same shape as the exclusivity test: the
// contender's retained parent handle must survive compensation, which is a
// different race from the one under test.
created := createdRuntimeDirsForTest(root)
opened := make(chan struct{})
entered := make(chan struct{})
var contender *sandboxRuntimeLease
var contenderErr error
runtimeCleanupExclusivityBarrier = func() {
runtimeCreationFailure = func(string) error {
select {
case <-opened:
default:
close(opened)
}
return nil
}
go func() {
defer close(entered)
contender, _, contenderErr = acquireRuntimeLeaseForPlatform(root)
}()
// The contender now holds a handle on the lease cleanup is about to
// remove, and is waiting for the lock cleanup holds.
<-opened
}
t.Cleanup(func() {
runtimeCleanupExclusivityBarrier = nil
runtimeCreationFailure = nil
})
if err := undoWindowsSetupRuntimeCreation(root, created, leasePath, true); err != nil {
t.Fatalf("compensation failed: %v", err)
}
<-entered
runtimeCreationFailure = nil
if contenderErr != nil {
t.Fatalf("the contender could not take the lease after cleanup finished: %v", contenderErr)
}
if contender == nil {
t.Fatal("SETUP INVALID: the contender returned neither a lease nor an error")
}
defer contender.release()
// The proof is the next cleanup: if the contender holds the current object,
// cleanup finds the root in use. If it holds the object cleanup already
// removed, cleanup locks a fresh one and calls the root free.
second, inUse, err := tryAcquireSandboxRuntimeCleanupLease(root)
if err != nil {
t.Fatalf("a second cleanup could not take the lease at all, so the contender is holding an object the name no longer resolves to: %v", err)
}
if !inUse {
second.release()
t.Fatal("a second cleanup found the root unused while the contender holds a lease on it: the contender locked the object cleanup had already removed")
}
if _, statErr := os.Stat(leasePath); statErr != nil {
t.Errorf("the contender's lease is not at the lease name: %v", statErr)
}
// CONTROL: once the contender lets go, the root really is free.
contender.release()
third, inUse, err := tryAcquireSandboxRuntimeCleanupLease(root)
if err != nil {
t.Fatalf("cleanup after the contender released: %v", err)
}
if inUse {
t.Fatal("the root is still reported in use after the contender released")
}
third.release()
}
+13 -29
View File
@@ -3,7 +3,6 @@
package sandbox
import (
"errors"
"os"
"golang.org/x/sys/windows"
@@ -14,34 +13,19 @@ type runtimeLeaseHandle struct {
overlapped windows.Overlapped
}
func acquireSharedRuntimeLease(path string) (runtimeLeaseHandle, error) {
file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
if err != nil {
return runtimeLeaseHandle{}, err
}
handle := runtimeLeaseHandle{file: file}
if err := windows.LockFileEx(windows.Handle(file.Fd()), 0, 0, 1, 0, &handle.overlapped); err != nil {
_ = file.Close()
return runtimeLeaseHandle{}, err
}
return handle, nil
}
func tryAcquireExclusiveRuntimeLease(path string) (runtimeLeaseHandle, bool, error) {
file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
if err != nil {
return runtimeLeaseHandle{}, false, err
}
handle := runtimeLeaseHandle{file: file}
flags := uint32(windows.LOCKFILE_EXCLUSIVE_LOCK | windows.LOCKFILE_FAIL_IMMEDIATELY)
if err := windows.LockFileEx(windows.Handle(file.Fd()), flags, 0, 1, 0, &handle.overlapped); err != nil {
_ = file.Close()
if errors.Is(err, windows.ERROR_LOCK_VIOLATION) {
return runtimeLeaseHandle{}, true, nil
}
return runtimeLeaseHandle{}, false, err
}
return handle, false, nil
// BOTH SIDES HAVE TO MEAN THE SAME OBJECT.
//
// This opened the lease by full pathname with os.OpenFile and no no-follow flag,
// while shared acquisition opened it relative to a retained parent handle. A file
// symbolic link at <digest>.lease therefore handed the two sides different
// objects: setup or a running command held a shared lock on the link, cleanup
// took an exclusive lock on its target, both calls succeeded, and cleanup went on
// to RemoveAll a root somebody was still using.
//
// Mutual exclusion is a property of the OBJECT, so cleanup resolves the name the
// way acquisition does and refuses the same reparse objects.
func tryAcquireExclusiveRuntimeLease(root string) (runtimeLeaseHandle, bool, error) {
return tryAcquireExclusiveRuntimeLeaseRooted(root)
}
func (lease runtimeLeaseHandle) release() {
@@ -0,0 +1,49 @@
package sandbox
import (
"path/filepath"
"testing"
)
// THE SHAPE HAS TO HOLD FOR BOTH SPELLINGS OF THE FIRST COMPONENT.
//
// The cache-derived root uses the fixed name; the temp-derived fallback scopes
// that component to the user wherever the temp root is shared. A tail that
// stops matching loses the rooted no-follow traversal AND the owned-component
// guard, and neither failure is visible where it happens.
//
// The scoped spelling does not exist on Windows, so without the seam this
// branch could only ever be exercised by another platform's CI.
func TestOwnedTailAcceptsBothFirstComponentSpellings(t *testing.T) {
previous := fallbackOwnedNamesForMatch
t.Cleanup(func() { fallbackOwnedNamesForMatch = previous })
fallbackOwnedNamesForMatch = func() []string {
return []string{windowsSandboxRuntimeOwnedNames[0] + "-u1001", "runtime", "v1"}
}
base := filepath.Join("C:", "shared")
for _, testCase := range []struct {
name string
first string
want bool
}{
{"fixed cache spelling", windowsSandboxRuntimeOwnedNames[0], true},
{"user-scoped fallback spelling", windowsSandboxRuntimeOwnedNames[0] + "-u1001", true},
{"a different user's scope", windowsSandboxRuntimeOwnedNames[0] + "-u2002", false},
{"an unrelated directory", "notzero", false},
} {
t.Run(testCase.name, func(t *testing.T) {
root := filepath.Join(base, testCase.first, "runtime", "v1", "abcdef0123456789")
if _, _, ok := windowsSandboxRuntimeOwnedTail(root); ok != testCase.want {
t.Errorf("owned tail for %s = %v, want %v", root, ok, testCase.want)
}
})
}
// The components below the first stay fixed for both spellings, or the
// traversal would accept a tree Zero does not own.
wrong := filepath.Join(base, windowsSandboxRuntimeOwnedNames[0]+"-u1001", "elsewhere", "v1", "abcdef0123456789")
if _, _, ok := windowsSandboxRuntimeOwnedTail(wrong); ok {
t.Errorf("owned tail accepted %s, whose middle component is not one Zero owns", wrong)
}
}
+15
View File
@@ -0,0 +1,15 @@
//go:build !windows
package sandbox
// physicalSandboxPath resolves path to the spelling the filesystem itself uses.
//
// Off Windows that is what canonicalSandboxWorkspaceRoot already does:
// filepath.EvalSymlinks follows every symlink, and there is no junction to
// follow. Two aliases remain unresolved and are handled by the identity walk in
// runtimeRootWithinWorkspace instead: a differing case on a case-insensitive
// volume, which EvalSymlinks preserves, and a bind mount, which no userspace
// path API resolves because the kernel deliberately presents it as a real path.
func physicalSandboxPath(path string) string {
return canonicalSandboxWorkspaceRoot(path)
}
@@ -0,0 +1,116 @@
//go:build windows
package sandbox
import (
"path/filepath"
"strings"
"golang.org/x/sys/windows"
)
const (
// GetFinalPathNameByHandle flags. golang.org/x/sys/windows does not export
// these; both are zero, and naming them keeps the call site readable.
fileNameNormalized = 0x0
volumeNameDOS = 0x0
)
// physicalSandboxPath resolves path to the spelling the filesystem itself uses.
//
// canonicalSandboxWorkspaceRoot cannot do this on Windows. filepath.EvalSymlinks
// returns a directory JUNCTION unchanged, so a TEMP that reaches into the
// workspace through one still measures as outside it, and that is how a runtime
// tree ends up inside the very workspace the sandbox exists to confine.
// GetFinalPathNameByHandle answers with the target's real path, junctions and
// mount points followed and casing as stored on disk.
//
// This deliberately opens WITHOUT FILE_FLAG_OPEN_REPARSE_POINT, the opposite of
// openWindowsACLTarget. That helper must refuse to follow a reparse point,
// because following one is the path-swap it guards against. Here the whole
// question is where the reparse point leads, and the answer is only ever used to
// decide that a runtime root is contained, never that it is safe.
func physicalSandboxPath(path string) string {
cleaned := canonicalSandboxWorkspaceRoot(path)
if cleaned == "" || cleaned == "." {
return cleaned
}
// The runtime root does not exist yet at derivation time, which is the point.
// Resolve the deepest ancestor that does exist and re-append the rest, the
// same shape canonicalSandboxWorkspaceRoot uses for EvalSymlinks.
remainder := ""
current := cleaned
for {
if resolved, ok := finalWindowsPathName(current); ok {
if remainder == "" {
return resolved
}
return filepath.Join(resolved, remainder)
}
parent := filepath.Dir(current)
if parent == current {
// Nothing along the path could be opened. The cleaned form is the best
// answer available, and the caller's spelling comparison already ran.
return cleaned
}
remainder = filepath.Join(filepath.Base(current), remainder)
current = parent
}
}
func finalWindowsPathName(path string) (string, bool) {
utf16Path, err := windows.UTF16PtrFromString(path)
if err != nil {
return "", false
}
// Zero desired access is enough: GetFinalPathNameByHandle reads metadata, so
// this cannot be refused for lack of read rights on the directory contents.
handle, err := windows.CreateFile(
utf16Path,
0,
windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE,
nil,
windows.OPEN_EXISTING,
windows.FILE_FLAG_BACKUP_SEMANTICS,
0,
)
if err != nil {
return "", false
}
defer func() { _ = windows.CloseHandle(handle) }()
buffer := make([]uint16, windows.MAX_PATH)
for attempt := 0; attempt < 2; attempt++ {
n, err := windows.GetFinalPathNameByHandle(handle, &buffer[0], uint32(len(buffer)), fileNameNormalized|volumeNameDOS)
if err != nil {
return "", false
}
if int(n) >= len(buffer) {
// TWO DIFFERENT CONVENTIONS, and the boundary is where they meet. On
// success the return value EXCLUDES the terminating null; on an
// insufficient buffer it INCLUDES it. So n == len(buffer) cannot be
// read as a complete path: a success that large would not have fitted
// its own terminator, which means the only reading left is a required
// size. Retrying on >= costs one extra call in a case that may not be
// reachable and removes the need to be right about which it was.
buffer = make([]uint16, int(n)+1)
continue
}
if n == 0 {
return "", false
}
return trimWindowsExtendedPrefix(windows.UTF16ToString(buffer[:n])), true
}
return "", false
}
// trimWindowsExtendedPrefix converts the \\?\ form GetFinalPathNameByHandle
// returns into an ordinary path, so it compares against paths the rest of this
// package builds with filepath.Join. \\?\UNC\server\share becomes
// \\server\share; anything else loses the \\?\ and keeps its drive letter.
func trimWindowsExtendedPrefix(path string) string {
if rest, ok := strings.CutPrefix(path, `\\?\UNC\`); ok {
return `\\` + rest
}
return strings.TrimPrefix(path, `\\?\`)
}
@@ -0,0 +1,208 @@
//go:build windows
package sandbox
import (
"os"
"path/filepath"
"testing"
)
// THE CREATION ITSELF HAS TO ESTABLISH THE IDENTITY.
//
// Win32 CreateFile cannot create a directory whatever disposition it is given,
// so the CREATE_NEW attempt fell through to os.Mkdir plus a separate reopen on
// EVERY real creation and the documented creation-handle contract never once
// held. The runtime parent belongs to the ordinary user, who can rename the new
// directory A away and drop an ordinary directory B at the predictable name in
// between, and the ledger then records B for a directory this run never made.
//
// Driving the interleaving is not the point, and a barrier there would only
// prove the window was still measurable. The property is that no reopen exists
// to be raced: whatever the name resolves to afterwards, the recorded identity
// is the object the create returned.
func TestCreatedRuntimeDirIdentityComesFromTheCreation(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "created")
// THE DETERMINISTIC BARRIER. The old shape resolved the pathname a second
// time after os.Mkdir, and that reopen is the whole window. Counting the
// resolutions turns "the creation establishes the identity" into something
// checked: on Windows the create returns the handle, so this must be zero.
resolutions := 0
restore := runtimeIdentityAfterCreate
runtimeIdentityAfterCreate = func(p string) (string, bool) {
resolutions++
return restore(p)
}
t.Cleanup(func() { runtimeIdentityAfterCreate = restore })
identity, identified, err := createRuntimeDirIdentified(path)
if err != nil || !identified {
t.Fatalf("create: identity=%q identified=%v err=%v", identity, identified, err)
}
if resolutions != 0 {
t.Errorf("the creation resolved the pathname again %d time(s); identity must come from the creation handle", resolutions)
}
info, statErr := os.Stat(path)
if statErr != nil || !info.IsDir() {
t.Fatalf("no directory was created: err=%v", statErr)
}
// Substitute the whole directory the way the parent's owner could, then ask
// what the name says now. The record must still describe what was created.
aside := filepath.Join(root, "moved-aside")
if err := os.Rename(path, aside); err != nil {
t.Skipf("cannot rename the runtime directory on this filesystem: %v", err)
}
if err := os.Mkdir(path, 0o700); err != nil {
t.Fatal(err)
}
substitute, ok := runtimeDirIdentity(path)
if !ok {
t.Fatal("the substitute could not be identified, so the comparison proves nothing")
}
if identity == substitute {
t.Fatal("SETUP INVALID: the substitute has the same identity as the created directory")
}
moved, ok := runtimeDirIdentity(aside)
if !ok {
t.Fatal("the created directory could not be identified after the rename")
}
if identity != moved {
t.Errorf("the recorded identity %q describes neither the created directory (%q) nor anything this run owns", identity, moved)
}
}
// A component another process created first must stay "not ours", and it must
// keep saying so through os.IsExist, which is what the caller asks and which
// does not unwrap a %w chain.
func TestCreatedRuntimeDirRefusesAnExistingName(t *testing.T) {
path := filepath.Join(t.TempDir(), "created")
if _, _, err := createRuntimeDirIdentified(path); err != nil {
t.Fatalf("first create: %v", err)
}
identity, identified, err := createRuntimeDirIdentified(path)
if !os.IsExist(err) {
t.Errorf("creating over an existing directory returned %v, want an IsExist error", err)
}
if identified || identity != "" {
t.Errorf("a refused create still produced an ownership record: identity=%q identified=%v", identity, identified)
}
}
// THE THREE STATES ARE DIFFERENT FACTS.
//
// "Read it and there was nothing" and "could not read it" both used to arrive as
// existed=false. The stamp writer uses FILE_OVERWRITE_IF and can replace an
// existing stamp even where the read was denied, so that lie let a setup which
// then FAILED delete an attestation it had no record of, leaving the previous
// run's marker pointing at a runtime root it can no longer prove.
func TestRuntimeStampSnapshotSeparatesAbsentPresentAndUnknown(t *testing.T) {
t.Run("absent", func(t *testing.T) {
root := t.TempDir()
_, _, prior, state, err := snapshotRuntimeStampBound(root, windowsSandboxRuntimeStampName(testStampPlanHash))
if err != nil {
t.Fatalf("a readable root with no stamp is not an error: %v", err)
}
if state != runtimeStampAbsent {
t.Errorf("state = %v, want absent", state)
}
if prior != nil {
t.Errorf("absent produced prior bytes %q", prior)
}
})
t.Run("present", func(t *testing.T) {
root := t.TempDir()
want := []byte("prior-attestation")
if err := os.WriteFile(filepath.Join(root, windowsSandboxRuntimeStampName(testStampPlanHash)), want, 0o600); err != nil {
t.Fatal(err)
}
_, _, prior, state, err := snapshotRuntimeStampBound(root, windowsSandboxRuntimeStampName(testStampPlanHash))
if err != nil {
t.Fatalf("snapshot: %v", err)
}
if state != runtimeStampPresent {
t.Errorf("state = %v, want present", state)
}
if string(prior) != string(want) {
t.Errorf("prior = %q, want %q", prior, want)
}
})
// A stamp NAME that cannot be read as a file. The child open is refused for a
// reason that is emphatically not "not found", which is the whole distinction.
t.Run("unknown", func(t *testing.T) {
root := t.TempDir()
if err := os.Mkdir(filepath.Join(root, windowsSandboxRuntimeStampName(testStampPlanHash)), 0o700); err != nil {
t.Fatal(err)
}
_, _, prior, state, err := snapshotRuntimeStampBound(root, windowsSandboxRuntimeStampName(testStampPlanHash))
if err == nil {
t.Fatal("an unreadable stamp was reported as a successful snapshot")
}
if state != runtimeStampUnknown {
t.Errorf("state = %v, want unknown", state)
}
if prior != nil {
t.Errorf("unknown produced prior bytes %q", prior)
}
})
}
// AND UNKNOWN MUST NOT AUTHORIZE COMPENSATION.
//
// The forward mutation is refused before it begins, so this asserts the second
// half: a record that somehow reached compensation with an unproven prior state
// leaves the current stamp alone and says so, rather than deleting it and
// returning with nothing to put back.
func TestUnknownPriorStampIsNeverCompensated(t *testing.T) {
root := t.TempDir()
stampPath := filepath.Join(root, windowsSandboxRuntimeStampName(testStampPlanHash))
current := []byte("the-attestation-of-the-previous-successful-setup")
if err := os.WriteFile(stampPath, current, 0o600); err != nil {
t.Fatal(err)
}
identity, identified := runtimeDirIdentity(root)
if !identified {
t.Fatal("SETUP INVALID: the runtime root could not be identified")
}
snapshot := windowsSandboxStampSnapshot{
path: stampPath,
priorState: runtimeStampUnknown,
root: root,
rootIdentity: identity,
rootIdentified: true,
}
err := snapshot.restore()
if err == nil {
t.Fatal("compensation acted on a prior state it never established, and reported success")
}
after, readErr := os.ReadFile(stampPath)
if readErr != nil {
t.Fatalf("the existing stamp was destroyed by a rollback that had nothing to restore: %v", readErr)
}
if string(after) != string(current) {
t.Errorf("the existing stamp was rewritten: got %q, want %q", after, current)
}
}
// And setup refuses BEFORE the ACL and stamp are applied, which is the half that
// keeps the writer from running at all.
func TestSetupRefusesAnUnreadablePriorStamp(t *testing.T) {
root := t.TempDir()
if err := os.Mkdir(filepath.Join(root, windowsSandboxRuntimeStampName(testStampPlanHash)), 0o700); err != nil {
t.Fatal(err)
}
snapshot, err := snapshotWindowsSandboxRuntimeStamp(root, testStampPlanHash)
if err == nil {
t.Fatal("setup accepted a snapshot that could not read the prior stamp")
}
if snapshot.priorState != runtimeStampUnknown {
t.Errorf("the refused snapshot carried state %v, want unknown", snapshot.priorState)
}
}
@@ -0,0 +1,134 @@
package sandbox
import (
"os"
"path/filepath"
"testing"
)
// A RECORDED ROOT IS HISTORY. IT MUST NOT BE RE-DERIVED FROM TODAY'S TEMP.
//
// Setup reaches the temp fallback when the preferred cache-derived root cannot
// be leased, and it records that concrete root in the marker. Command selection
// then recognised the record only by deriving today's fallback from this
// process's TEMP and comparing. A later IDE, service or terminal running with a
// different TEMP therefore matched neither of today's candidates: the record was
// ignored, selection produced a tree setup never provisioned, and the runner
// rejected the marker as out of date. Re-running setup from the original
// environment repeats the original answer and does not make the other
// environment converge.
//
// The record is now recognised by its own shape instead, which is the question
// that was actually being asked: does this belong to this workspace and home.
//
// Not gated on Windows. The marker only exists where setup wrote one, so this is
// Windows-only in practice, but keeping it platform-neutral means the
// setup-to-command contract runs on every CI runner rather than only one.
func TestARecordedFallbackSurvivesATempChange(t *testing.T) {
home := t.TempDir()
workspace := canonicalSandboxWorkspaceRoot(t.TempDir())
// The cache is an input of the preferred candidate, so it is owned as well.
// Left alone it made the result depend on the machine, and gave this test two
// more ways to skip instead of asserting.
cacheRoot := t.TempDir()
originalCacheDir := sandboxUserCacheDir
sandboxUserCacheDir = func() (string, error) { return cacheRoot, nil }
t.Cleanup(func() { sandboxUserCacheDir = originalCacheDir })
// EVERY temp variable, in both phases. With only TMP and TEMP set this
// changed nothing on Unix: the fallback derived from the ambient TMPDIR, the
// MkdirAll below created a workspace-digest tree outside anything this test
// owns, the second derivation came back identical, and the test skipped
// having asserted nothing and left the directory behind.
tempA := t.TempDir()
redirectSandboxTestTemp(t, tempA)
recorded, err := fallbackSandboxRuntimeRoot(workspace)
if err != nil {
t.Fatalf("SETUP INVALID: no fallback runtime root under a temp this test owns: %v", err)
}
// Containment BEFORE the tree exists, so cleanup belongs to tempA and happens
// even when an assertion below fails.
requireWithinTestOwned(t, recorded, tempA)
if err := os.MkdirAll(recorded, 0o700); err != nil {
t.Fatal(err)
}
writeRecordedRoot(t, home, recorded)
// SETUP: with the original TEMP the record is honoured, so a failure after the
// flip is about the flip and not about the record being unusable.
preferred := preferredRuntimeRootFor(t, workspace)
if got := pinnedSandboxRuntimeRoot(workspace, preferred, recorded, home); got != recorded {
t.Fatalf("SETUP INVALID: the record is not honoured even before the temp change: got %q, want %q", got, recorded)
}
// The same command, run later from an environment with a different TEMP.
tempB := t.TempDir()
redirectSandboxTestTemp(t, tempB)
derivedNow, err := fallbackSandboxRuntimeRoot(workspace)
if err != nil {
t.Fatalf("SETUP INVALID: no fallback runtime root under the second temp: %v", err)
}
requireWithinTestOwned(t, derivedNow, tempB)
// A FAILURE, NOT A SKIP. The redirect owns every input the derivation reads,
// so an unmoved fallback means the premise of this test is broken, and a skip
// here is how it passed on Unix without ever reaching the assertion below.
if sameWindowsRuntimeRootPath(derivedNow, recorded) {
t.Fatalf("SETUP INVALID: the temp change did not move the derived fallback (%s), so the assertion below would prove nothing", derivedNow)
}
if got := pinnedSandboxRuntimeRoot(workspace, preferred, derivedNow, home); got != recorded {
t.Fatalf("selection abandoned the root setup provisioned after only TEMP changed: got %q, want the recorded %q", got, recorded)
}
}
// preferredRuntimeRootFor is the cache-derived candidate, which must stay a
// candidate: this change is about the fallback only.
func preferredRuntimeRootFor(t *testing.T, workspace string) string {
t.Helper()
cacheRoot, err := sandboxUserCacheDir()
if err != nil {
t.Fatalf("SETUP INVALID: no user cache directory: %v", err)
}
root, err := sandboxRuntimeRootFor(workspace, canonicalSandboxWorkspaceRoot(cacheRoot))
if err != nil {
t.Fatalf("SETUP INVALID: no preferred runtime root under a cache this test owns: %v", err)
}
return root
}
// AND THE PROTECTION THAT MADE THE OLD COMPARISON WORTH HAVING STAYS.
//
// Recognising the record by shape must not turn into recognising anybody's
// record. A fallback root belonging to a DIFFERENT workspace has the same owned
// shape and a different digest, so it is still refused.
func TestARecordedFallbackForAnotherWorkspaceIsStillRefused(t *testing.T) {
home := t.TempDir()
mine := canonicalSandboxWorkspaceRoot(t.TempDir())
theirs := canonicalSandboxWorkspaceRoot(t.TempDir())
temp := t.TempDir()
redirectSandboxTestTemp(t, temp)
theirRoot, err := fallbackSandboxRuntimeRoot(theirs)
if err != nil {
t.Fatalf("SETUP INVALID: no fallback runtime root under a temp this test owns: %v", err)
}
requireWithinTestOwned(t, theirRoot, temp)
writeRecordedRoot(t, home, theirRoot)
if got := pinnedSandboxRuntimeRoot(mine, filepath.Join(t.TempDir(), "preferred"), filepath.Join(t.TempDir(), "fallback"), home); got != "" {
t.Fatalf("pinned %q, which was provisioned for another workspace", got)
}
}
// A record with no owned runtime shape at all is refused too, so the shape test
// is doing work rather than waving anything through.
func TestARecordedRootWithoutTheOwnedShapeIsRefused(t *testing.T) {
home := t.TempDir()
workspace := canonicalSandboxWorkspaceRoot(t.TempDir())
writeRecordedRoot(t, home, filepath.Join(t.TempDir(), "not-a-runtime-root"))
if got := pinnedSandboxRuntimeRoot(workspace, filepath.Join(t.TempDir(), "preferred"), filepath.Join(t.TempDir(), "fallback"), home); got != "" {
t.Fatalf("pinned %q, which has none of the owned runtime shape", got)
}
}
Loaded 100 of 194 files, more files were not shown because too many files have changed in this diff. Show more