Compare commits

..
Author SHA1 Message Date
Vasanthdev2004 58803032cb Merge remote-tracking branch 'origin/main' into split/permission-mode-and-classifier 2026-10-25 19:32:59 +05:30
Vasanthdev2004 65ded9dc05 Merge remote-tracking branch 'origin/main' into split/permission-mode-and-classifier 2026-10-17 17:27:05 +05:30
Vasanthdev2004 786d4fc23e refactor(cli): use PermissionModeFullAuto instead of its deprecated alias
This branch marks PermissionModeUnsafe deprecated, so staticcheck reports
SA1019 at every use outside the agent package, including three in cli and
one in a test that came in from main. The alias is the same value, so
nothing behaves differently.
2026-10-17 16:40:16 +05:30
Vasanthdev2004 ad3c77cc44 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:30:59 +05:30
Vasanthdev2004 08a8b9f466 Merge remote-tracking branch 'origin/main' into split/permission-mode-and-classifier
# Conflicts:
#	internal/tui/run.go
2026-10-17 11:30:58 +05:30
Vasanthdev2004 6fce00c070 Merge remote-tracking branch 'origin/main' into split/permission-mode-and-classifier
# Conflicts:
#	internal/acp/agent_test.go
2026-10-12 12:38:29 +05:30
Vasanthdev2004 ccc041c855 Merge remote-tracking branch 'origin/main' into split/permission-mode-and-classifier
Conflicts in internal/acp/agent_test.go and
internal/cli/user_state_isolation_test.go were additions on both sides; both
are kept. Both sides also added the charmbracelet/x/ansi import to
internal/tui/view.go, which merged without a conflict marker but did not
compile; main's import, in the third-party group, is the one kept.
2026-10-12 11:33:17 +05:30
Vasanthdev2004 8608699881 fix(tui): end a full-auto offer on cross-session peer traffic
The offer made by shift+tab is valid only until the next user-visible input
transition, whatever delivers it. Paste, blur, mouse, the clipboard and
dictation all honoured that; peer delivery did not. A peer message raises an
approval prompt or lands in the inbox, a held message is released after a
mode sync, a status line or a peer error is appended to the transcript, an
approval expires and its prompt goes away. Each changes what the user is
looking at, so shift+tab, then peer traffic, then an unrelated ctrl+g still
entered full-auto with the confirmation several events behind them.

The offer is cleared for the whole peer message family at the dispatch
boundary in updateModel, not in the three handlers the report named. Those
were the ones that reproduced; the decision, expiry and error deliveries are
the same kind of transition and were armed the same way, and a rule written
per handler is a rule the next handler forgets.

isPeerDelivery is a closed list, and a test reads the package source for
every peer*Msg type and fails by name if one is not classified and not
driven. The regression sends one production message of each of the seven
types through Update, requires the offer gone and ctrl+g to leave the mode
alone, and then requires a fresh shift+tab to offer and confirm again, so it
cannot pass on a mode cycle that has become unreachable. The message that
needs approval also has to have raised its prompt. Reported by jatmn.
2026-09-21 13:37:01 +05:30
Vasanthdev2004 9f427ba172 Merge remote-tracking branch 'origin/main' into split/permission-mode-and-classifier 2026-09-21 13:31:50 +05:30
Vasanthdev2004 7be892028c fix(tui): only offer full-auto where the footer can show the confirmation key
The footer reserves columns for a docked pet before it renders the status line,
so at 24 and 30 columns the offer came out truncated with no ctrl+g in sight
while ctrl+g still confirmed. The armed flag now means what it says only when
the footer, at its effective width after the reservation, shows the key: the
decision is made on the same truncation the footer applies, an offer that
cannot be shown is not raised, and a live offer is withdrawn when the terminal
shrinks under it. Pinned through the complete View at 24 and 30 columns with a
pet, with 30 pet-free and 96 with a pet as controls.
2026-09-15 10:51:52 +05:30
Vasanthdev2004 6f228bfa00 test(cli): isolate user state in the full-auto parity helper and pin it
execListToolsFor supplied a temporary cwd and a mocked provider config, but
runWithDeps filled the rest with production dependencies, so --list-tools
refreshed the developer's models cache, started their MCP servers and could
touch their config before returning. The helper applies isolateCLIUserState
first, and the helper-isolation table now covers it.
2026-09-15 10:51:52 +05:30
Vasanthdev2004 89eab02ee2 fix(tui): deliver peer callbacks to the event loop through an ordered forwarder
Service.UpdateIdentity invokes the held-release handler synchronously for every
message that becomes deliverable, and the shell calls UpdateIdentity from inside
Update on the shift+tab and ctrl+g paths that change the permission class. The
handler called program.Send, which waits on the unbuffered message channel that
only the loop reads, and the loop was inside that Update: confirming full-auto
with a bypass-mode peer's message held froze the shell. Peer callbacks now
append to a FIFO drained by one goroutine, so the caller returns, order among
peer events is kept, and a late callback after exit returns on the cancelled
program context. The wiring is a function with a fake-able service so the
regression drives a real Bubble Tea program through the production shape.
2026-09-15 10:51:52 +05:30
Vasanthdev2004 c9d8ac91a0 Merge origin/main into split/permission-mode-and-classifier
Keeps both sides of the two model.go hunks: a mouse event and a paste still
answer a pending full-auto offer with not now, and then the terminal-attach
overlay from #1055 takes the event as main intends.
2026-09-15 10:26:35 +05:30
Vasanthdev2004 3b52175bd8 fix(tui): keep the full-auto offer visible before ctrl+g can accept it
The offer renders through modeLabel into the left chip, and several statuses
replaced that chip wholesale: an active recording, a transcription, and a model
download. Arming while the mic was live therefore showed REC while ctrl+g still
committed full-auto, so the user was asked to confirm something the footer never
put in front of them, and permission prompts went away.

offerOwnsStatusChip makes a live offer outrank the statuses that merely report
what is happening, in both statusLine branches, since the tiny and non-tiny
paths reach the chip differently. The two confirmations that ask their own
question still win, because those are what the user just pressed a key for.

The confirm key consults the same rule through offerConfirmable, which takes the
armed flag as an argument because the handler captures and clears m.unsafeArmed
before its switch runs. Sharing one expression is the point: an offer that
cannot be displayed is not one that can be accepted, so a status added later
cannot reopen the gap by taking the chip.

The regression asserts the implication through the real renderer and the real
handler, matrixed over the contending states and three widths: if the mode
became full-auto, the frame shown before the key contained the offer. It also
asserts the converse, so a fix that merely made confirmation unreachable would
fail it.

Reported by @jatmn.
2026-09-13 19:57:00 +05:30
Vasanthdev2004 f87a0afa10 Merge remote-tracking branch 'origin/main' into split/permission-mode-and-classifier
# Conflicts:
#	internal/acp/agent_test.go
2026-09-12 20:34:36 +05:30
Vasanthdev2004 32c3527794 fix(sandbox): keep framework builds under the network gate
Splitting LocalServer out of the network classification also dropped localServerPrograms from commandUsesNetwork, so next build, vite build, nuxt generate and astro check came back Network=false. Those CLIs run the repository config, plugins and application code during a build as surely as during a serve, and on the platforms where the approval gate is the network boundary an ordinary shell grant would have run them without the separate network decision main applies. The test that pinned Network=false for builds is replaced.

Network stays conservative for the whole framework inventory; LocalServer alone carries the serve/build distinction. The framework classifier now walks the positions a subcommand can occupy instead of reading the first operand, so a global option before the subcommand (vite --config vite.prod.ts build) no longer makes a build count as a server.

Regressions: build-like and option-bearing forms are Network=true and LocalServer=false at the analyzer, serving forms are both, and Engine.Evaluate still returns the network prompt for a framework build after a bash allow grant. Reported by @gnanam1990.
2026-09-12 11:47:34 +05:30
KRATOS 677b3f0c4f fix(sandbox): a flagged invocation classifies like its unflagged self at any arity
Raised by CodeRabbit. Skipping flags is not enough, because it does not skip the
words they CONSUME: `npm --prefix ./web install` resolved to "./web", the
option's VALUE, and stopped being recognised at all — so a command genuinely
fetching from the network lost the approval its plain spelling gets.

The first fix offered a fixed window of two positions. That covers exactly ONE
value-taking option, and a second walks straight past it. Measured before this
change:

  npm --prefix ./web install                            -> network (correct)
  npm --prefix ./web --loglevel warn install            -> NOT network
  npm --registry https://r.test --prefix ./web install  -> NOT network
  pnpm -C ./web --filter web add left-pad               -> NOT network

All four genuinely fetch.

ADJACENCY DECIDES NOW, because it is the actual grammar: an option can only
consume the word immediately after it. Every leading operand sitting right after
a flag might be that flag's value, so each opens another position; the first
operand NOT preceded by a flag cannot be anyone's value, and the window closes
there. The arity is counted rather than assumed, and no option is ever named —
which was the point of not enumerating them, since that is four tools each free
to add one.

The run has to be CONTIGUOUS, not a count of flags anywhere in the line.
Counting every flag lets TRAILING options widen the window back over words they
have nothing to do with, and three cases flip to false positives when it does:

  npm run --grep foo --tag dev            -> network AND localServer
  npm run test --reporter x --filter dev  -> network
  npm run build --a x --b start           -> network

The false positives the old cap protected against are unaffected, and for a
stronger reason than the cap gave: `npm run build --workspace dev` and `npm run
test -- --grep start` put an unflagged operand FIRST, so the window is one
position wide however many flags follow. The cap held only because two happened
to be short enough.

Tested as a PROPERTY rather than a list: each flagged form is paired against its
unflagged baseline, so a change that shifts both still fails. Eight pairs, the
"="-joined forms and repeated flags included. Two mutations, each caught —
restoring the fixed window mis-classifies five, dropping the contiguity
requirement widens three.

Also from a verification pass:

The doc comment claimed the cap at two was justified purely by argument-position
false positives and never said a second value-taking option defeated it. It now
describes what ships.

`npm --prefix dev install` reads a directory named "dev" as a script name. Pinned
rather than fixed: the ambiguity is the point of the window, Network is already
correct because the install really does fetch, and LocalServer only implies
Network — so the union is conservative in the safe direction.

An earlier note justified leaving internal/tui/session.go unnormalised by saying
the value feeds transcript rendering and the render cache. It feeds only the
cache fingerprint — grepping every reader of PermissionMode in that package
returns render_cache.go and nothing else — so the tradeoff described did not
exist. It is normalised now, and two spellings of one mode no longer evict the
cache for nothing.

The ACP tests asserted on ConfigOptions[1], a magic index that would move onto a
different option if the advertised order changed. They select by ID.

Rebased onto ad34dc8d. Pre-existing here and on main:
TestRunDoctorFormatsRedactedProviderDiagnostics and
TestRunDoctorConnectivityProbesProvider exit 3 in this environment.

Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
2026-09-12 10:11:23 +05:30
Vasanthdev2004 b75e7cb2b7 fix(tui,sandbox): cancel the offer on a streaming partial, and derive the network fallback from the analyzer
Two findings, both follow-ons to fixes I made last round.

A streaming dictation partial did not cancel the full-auto offer. The final
transcript did, and my note there said starting dictation is a keypress that
already clears the arm, which does not cover this: the offer can be armed AFTER
dictation is running, the next partial rewrites the composer, and active
dictation is not a blocking modal, so an ordinary ctrl+g still confirmed with
the user several actions past the confirmation. Cleared at the dispatch boundary
rather than inside handleDictationPartial, so a future partial-delivery path
inherits the rule. The regression drives a genuinely live session and fails if
the partial never reaches the composer, so it cannot pass on a session-mismatch
early return.

The unparseable network fallback is meant to be a superset of everything the AST
path flags, and it was maintained as a separate inventory, so it drifted. The
analyzer accepts "py" as a Python launcher and the regex listed only python,
python2 and python3:

  py -m http.server 8000                                     parsed=true  network=true
  if "%OS%"=="Windows_NT" (py -m http.server 8000) else (..)  parsed=false network=false
  if "%OS%"=="Windows_NT" (python -m http.server ...) else .. parsed=false network=true

On Windows that approval IS the egress control. Rather than adding the alias and
leaving two lists to drift again, the launchers are one named inventory the
analyzer uses at both call sites, and the fallback test iterates it, so a
launcher added there fails immediately if the regex does not cover it.

Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
2026-09-12 10:11:23 +05:30
Vasanthdev2004 1cc95f39f3 fix(sandbox,tui,cli): close the unparseable network gap and the deferred-input arm
Five findings from review.

The unparseable fallback lost the serving forms on the reasoning that they bind
rather than fetch. The AST path sets Network for every recognized LocalServer
anyway, deliberately, because nothing consumes LocalServer yet and `npm run dev`
is matched by script name where the repository decides what dev and predev do.
So dropping them did not align the two paths, it split them: the POSIX parser
rejects Windows shell syntax the invoked shell accepts, and

  if "%OS%"=="Windows_NT" (npm run dev) else (npm start)

came back parsed=false network=false while every parseable spelling got network.
That is the approval gate disappearing on the platform where it is the only
egress control. Restored as a superset, with a regression that asserts both
spellings of the same intent classify alike and fails loudly if the parser ever
learns the syntax.

tea.PasteMsg cancels the full-auto offer, but a right-click paste does not
arrive that way: it starts a clipboard read, and shift+tab can reach the offer
while that read is in flight. The delayed delivery inserted text and left the
arm alive, so an ordinary ctrl+g afterwards turned permission prompts off with
the user several actions past the confirmation. Cleared before the branches, on
the image delivery too, and on transcribed dictation for the same reason.

Specialist registration read the raw CLI fields while execution read the
resolved mode, so `--permission-mode full-auto` composed a registry without Task
and swarm tooling while `--auto high` and `--full-auto` composed one with it. The
mode is resolved before the registry now and the predicate consumes it, so a new
alias cannot create another unequal capability set.

The full-auto warning matched the typed --permission-mode value against three
spellings while the resolver accepts four, so `--permission-mode full_auto` was
told --auto high was responsible. It echoes what was typed instead of matching a
list that has already drifted once.

Help and validation text still led with the deprecated spelling: --permission-mode
listed "unsafe" as its value, --auto high was described as enabling "unsafe
tools", and the root-flag errors named --skip-permissions-unsafe. All now present
full-auto as the mode and the primary flag; the deprecated alias stays where its
compatibility is the point.

Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
2026-09-12 10:11:23 +05:30
Vasanthdev2004 714ce649bc fix(tui): cancel the full-auto offer when the terminal loses focus
Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
2026-09-12 10:11:23 +05:30
Vasanthdev2004 54613e4ff9 fix(sandbox,cli,tui): keep serving commands gated, and accept the canonical mode
All five of jatmn's findings.

The two P1s were the same mistake in two places: the LocalServer classification
was treated as a REPLACEMENT for Network when nothing consumes it. No policy or
runner code reads LocalServer, so classifying a serving command as local-only
granted it no host listener; it only removed the network approval it used to
get, and the command then ran under the default deny profile. On Linux that is a
network namespace and on macOS (deny network*), so `python -m http.server` and
`vite` started with no prompt and could not serve a preview to the operator's
browser.

The package-manager half is the sharper one. `npm run dev` is matched by SCRIPT
NAME, and the repository decides what `dev` and `predev` actually do; either can
curl before anything binds a port. On Windows the approval gate IS the network
protection, so inferring no-egress from a name lets that egress run unprompted.

LocalServer is now additive: the classification stays, because a scoped
host-listener path will want it, and the approval path stays until that path
exists. One line covers both findings, since both flow through the same
classification.

The PR's own tests asserted the premise being corrected, so they were rewritten
rather than flipped: building still counts as no egress, serving now asserts it
keeps BOTH the LocalServer classification and the network approval, and the
table rows moved with it.

The three P2s:

resolveExecPermissionMode accepted only the deprecated `unsafe`, so
`--permission-mode full-auto` failed usage validation before
NormalizePermissionMode was ever reached, while the warning code already had an
unreachable full-auto branch. It now accepts the canonical spelling and keeps
unsafe as the alias, and the usage text names full-auto.

newModel copied the raw mode through while the TUI tests only for
PermissionModeFullAuto, so an entry point handed the accepted legacy value
rejected ! shell escapes, published a prompting peer identity and chose low
self-correction, even though agent.Run normalized the same run to full-auto. One
value, two behaviours, decided by which layer looked at it. It is normalized at
the TUI boundary now.

Both completion inventories listed only --skip-permissions-unsafe, so no
generated shell script could suggest the documented --full-auto. Both now carry
the canonical flag alongside the deprecated alias.

Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
2026-09-12 10:11:22 +05:30
Vasanthdev2004 30459e167b test(tui): move the plan-mode guard onto advancePermissionMode
The rebase renamed nextPermissionMode to advancePermissionMode, and main's
plan-mode test still called the old name, so internal/tui did not build.

Also asserts the second return value. Plan must never carry a full-auto OFFER
either, or two presses from Plan would reach the mode that turns permission
prompts off entirely, starting from the one mode that promises no mutation.

Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
2026-09-12 10:10:57 +05:30
Vasanthdev2004 874db9ff34 fix: make the full-auto rename reach the values that travel as data
The rename kept a Go alias for PermissionModeUnsafe, which covers callers that
name the constant. It does not cover a mode that moves as a string, and several
do, so those paths went on comparing against "unsafe" and stopped matching.

Three places were affected:

- swarm permissionRank did not rank "full-auto", so it scored 0, the strictest
  tier. A full-auto parent clamped its members harder than an ask parent.
- specialist specialistAutonomy did not match it either, so a full-auto parent's
  specialists dropped to read-only "low" autonomy.
- the agent loop compares Options.PermissionMode directly, so a caller passing
  the old spelling got prompts instead of full-auto.

All three fail safe, which is why nothing broke loudly. Added
agent.NormalizePermissionMode and applied it at the single point the loop reads
the mode off Options, and taught the swarm and specialist constants both
spellings. Their existing tables only listed "unsafe", which is exactly how the
rename passed review.

Separately, permission_mode_arm_test.go was never compiled. Go read the trailing
"_arm" as a GOARCH constraint and excluded the file from every amd64 build, so
the five tests covering the shift+tab full-auto offer had never run, locally or
in CI. Two panicked once they did. The file is renamed, the model construction
fixed, and a repository-wide test now fails on any _test.go whose name ends in a
GOOS/GOARCH token without an explicit build tag.

With those tests actually running, the offer gate had a real hole: unsafeArmed
was cleared only in the key handler, so a paste or a mouse click left the offer
live and a later ctrl+g committed full-auto with nobody having accepted it.
Paste and deliberate mouse actions now cancel it. Passive motion does not, since
terminals stream motion while tracking is on and cancelling on a twitch would
make the confirm key unreachable.

Also: commandRunsLocalServer matched on program name alone, so "next build" and
"vite build" claimed to bind a port while compiling. Nothing reads the flag yet,
which is why it could be wrong quietly. Fixed to require a serving subcommand,
falling back to bare-invocation behaviour when firstSubcommand lands on an
option value rather than a subcommand.

Remaining wording fixes name full-auto rather than unsafe in the retry reasons,
the exec warning, and the shell-escape notice.

Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
2026-09-12 10:10:57 +05:30
Vasanthdev2004 f608752821 fix: keep the old permission-mode constant names as deprecated aliases
The rename broke CI, and it broke it in the merge rather than on the branch:
this branch is ten commits behind main, CI builds the merge, and newer main
code still references PermissionModeUnsafe and PermissionUnsafe. Deleting a
constant that other work is actively using turns an ordinary merge into a
compile failure for whoever merges second.

Both old names are restored as deprecated aliases of the full-auto constants.
Same value, so behaviour is identical, and code on either side of the rename
compiles.

My local check missed this because `go test ./internal/...` returned
"ok (cached)" for internal/agent, and a cached result cannot catch a compile
error. Verified this time with -count=1 and against an actual trial merge with
origin/main rather than the branch alone.

Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
2026-09-12 10:10:57 +05:30
Vasanthdev2004 dbdd281d59 refactor: rename the unsafe permission mode to full-auto
"unsafe" was a judgement rather than a description, and it sat oddly beside
auto and ask. full-auto says what the mode does and reads as the obvious third
step: ask, auto, full-auto.

Not "unrestricted", which was the other candidate and is factually wrong. This
mode turns off permission PROMPTS. The OS sandbox stays on, the write jail
still holds, and a label implying nothing restrains the agent would be wrong in
the dangerous direction.

The on-disk value changes to "full-auto" and "unsafe" is accepted permanently
as an alias rather than migrated. That value lives in user configs and in
scripts, and a session silently falling back to auto because its saved mode no
longer parsed would be a confusing downgrade rather than a visible error.

--full-auto is the new flag; --skip-permissions-unsafe keeps working and is
documented as the deprecated spelling. The exec warning names "the full-auto
flag" generically, because either spelling reaches it and the bool does not
record which was typed: claiming --full-auto when the caller passed the old one
would be a small lie in a warning, which is the wrong place for one.

The warning also says "prompt-gated tools run without approval" rather than
anything broader, for the same reason the mode is not called unrestricted.

What deliberately does NOT change is the colour. The name can be calm; the
indicator should not be, so full-auto keeps the alarm style in the status bar
and the ctrl+g confirm gate is untouched.

The Go "unsafe" package is imported in fifteen files here, so the rename was
done by identifier token only and the imports were verified intact afterwards
rather than assumed.

Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
2026-09-12 10:10:57 +05:30
Vasanthdev2004 eda9f3051c fix(sandbox): stop classifying local dev servers as network egress
`python -m http.server` was denied with "network access requires approval".
Serving files out of the workspace on a local port is not reaching out, and
treating it as egress made the single most common thing an agent is asked to
do while building something stop for an approval that protected nobody.

The analyzer already had a localServerPrograms map sitting beside
networkPrograms, but commandUsesNetwork returned true for it anyway, so the
distinction had been drawn and never acted on. It is acted on now:
AnalysisResult carries LocalServer separately, so the information is preserved
rather than dropped, and a caller that does care about inbound can still see
it.

Covers the same set consistently. `next dev` and `npm run dev` are the same
intent, so classifying one as binding and the other as fetching would have left
users hitting exactly the same wall through the other door. Both are
LocalServer now, across npm, pnpm, yarn and bun, as a direct subcommand and
behind `run`.

What still counts as network is unchanged: install, add, ci, create, publish,
login, exec, and every fetching program. `npx http-server` in particular stays
network, because npx downloads the package when it is missing; the test caught
that when a first pass flipped it, which is why the bind-versus-fetch pairs are
now pinned side by side in the table.

The unparseable fallback regex is updated to agree. Left alone it would have
flagged an obfuscated dev server for network while the same command written
plainly did not, which is the kind of inconsistency that is very hard to
explain to whoever hits it.

Being honest about the edge: some of these touch the network incidentally, and
`npm run dev` may install first. The claim is narrow, that BINDING is not
EGRESS, not that dev tooling is inert. Anything that actually fetches still
matches through its own program or subcommand.

Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
2026-09-12 10:10:22 +05:30
Vasanthdev2004 1bcb2bfadd feat(tui): put unsafe mode in the shift+tab cycle behind a confirm key
Unsafe was reachable only via --skip-permissions-unsafe at launch, which is
bad discoverability: the mode exists, works, and nothing in the TUI tells you
so. It is now the third position in the shift+tab cycle, alongside auto and
ask.

What it is NOT is reachable by repeating a navigation key. Unsafe turns
permission prompts off entirely, so shift+tab only ever OFFERS it; committing
takes ctrl+g while that offer is live. Press shift+tab again instead and the
offer is declined and the cycle continues to auto, so every mode stays
reachable with shift+tab alone.

The offer lives on the model across keypresses, and that is the part worth
being careful about: a stale flag would turn a later innocent ctrl+g into a
silent drop into unsafe. So it is cleared unconditionally at the top of the key
handler and re-armed only by the shift+tab branch. Forgetting a path therefore
cancels the offer rather than leaving it live, which is the harmless direction
to be wrong in. Tests drive the real handler for eight different cancelling
keys and assert the consequence, not just the flag.

ctrl+g is guarded in the case expression rather than the body, so without a
live offer the key is not consumed at all and falls through to whatever would
normally handle it. That is what stops it being a standalone shortcut into
unsafe, and there is a test that presses it repeatedly from every other mode.

Leaving unsafe stays one press and is never gated. Getting stricter should not
need confirming.

Returning to auto from ask now costs two presses, since it passes through the
offer. That is inherent to a third position in a cycle rather than a
regression, and TestShiftTabCyclesPermissionMode was updated to walk the whole
loop rather than assume two positions.

An earlier draft of this had the second shift+tab commit unsafe, which silently
removed ask -> auto from the cycle entirely. TestPermissionModeCycleNeverReaches
UnsafeOnItsOwn walks the full loop and asserts every mode stays reachable, so
that cannot come back unnoticed.

Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
2026-09-12 10:10:22 +05:30
166 changed files with 3040 additions and 14269 deletions

No files matched your search

+13 -4
View File
@@ -700,7 +700,13 @@ func (a *Agent) handleSetMode(_ context.Context, params json.RawMessage) (any, e
}
sess.turnMu.Lock()
defer sess.turnMu.Unlock()
mode := agent.PermissionMode(p.ModeID)
// Normalized before the switch, for the same reason the TUI normalizes at its
// own boundary: the mode arrives as data, so the accepted legacy "unsafe"
// spelling reaches this switch unrewritten and falls through to default. The
// client that asks for the disallowed mode by its documented old name is then
// told it does not exist, which reads as "try another spelling" rather than
// "this door is closed over ACP".
mode := agent.NormalizePermissionMode(agent.PermissionMode(p.ModeID))
switch mode {
case agent.PermissionModeAuto, agent.PermissionModeAsk, agent.PermissionModePlan:
if err := a.updateMode(sess, mode); err != nil {
@@ -708,7 +714,7 @@ func (a *Agent) handleSetMode(_ context.Context, params json.RawMessage) (any, e
}
(&notifier{conn: a.conn, sessionID: sess.id}).currentMode(string(mode))
return SetSessionModeResult{}, nil
case agent.PermissionModeUnsafe:
case agent.PermissionModeFullAuto:
// Unsafe = run every tool with no prompt. The TUI gates this behind an
// explicit --skip-permissions-unsafe operator flag; an editor client must
// not be able to grant itself unconfined, no-prompt access over the wire.
@@ -738,14 +744,17 @@ func (a *Agent) handleSetConfigOption(_ context.Context, params json.RawMessage)
// set_mode and set_config_option) serialize mode flips consistently.
sess.turnMu.Lock()
defer sess.turnMu.Unlock()
mode := agent.PermissionMode(p.Value)
// Normalized like handleSetMode above: this is the second advertised mode
// door, and an alias that only one of them rewrites is a difference
// between two paths that are meant to be the same contract.
mode := agent.NormalizePermissionMode(agent.PermissionMode(p.Value))
switch mode {
case agent.PermissionModeAuto, agent.PermissionModeAsk, agent.PermissionModePlan:
if err := a.updateMode(sess, mode); err != nil {
return nil, err
}
(&notifier{conn: a.conn, sessionID: sess.id}).currentMode(string(mode))
case agent.PermissionModeUnsafe:
case agent.PermissionModeFullAuto:
return nil, RPCError(codeInvalidParams, "mode not permitted over ACP: "+p.Value)
default:
return nil, RPCError(codeInvalidParams, "unknown mode: "+p.Value)
+84 -4
View File
@@ -205,8 +205,8 @@ func TestACPEndToEndPrompt(t *testing.T) {
if len(newRes.ConfigOptions) != 2 || newRes.ConfigOptions[0].ID != configIDModel || newRes.ConfigOptions[0].CurrentValue != "fake-model" {
t.Fatalf("model config option = %+v, want fake-model fallback", newRes.ConfigOptions)
}
if newRes.ConfigOptions[1].ID != configIDMode || newRes.ConfigOptions[1].CurrentValue != string(agent.PermissionModeAuto) {
t.Fatalf("mode config option = %+v", newRes.ConfigOptions[1])
if mode := configOptionByID(t, newRes.ConfigOptions, configIDMode); mode.CurrentValue != string(agent.PermissionModeAuto) {
t.Fatalf("mode config option = %+v", mode)
}
// session/prompt
@@ -455,7 +455,7 @@ func TestACPModelConfigOptionsCatalogSelectionAndLoad(t *testing.T) {
if len(loaded.ConfigOptions) != 2 || loaded.ConfigOptions[0].CurrentValue != "gpt-5.4-mini" {
t.Fatalf("load model option = %+v", loaded.ConfigOptions)
}
if loaded.ConfigOptions[1].CurrentValue != string(agent.PermissionModeAuto) {
if mode := configOptionByID(t, loaded.ConfigOptions, configIDMode); mode.CurrentValue != string(agent.PermissionModeAuto) {
t.Fatalf("load mode option = %+v", loaded.ConfigOptions[1])
}
}
@@ -654,7 +654,7 @@ func TestACPSetModeUpdatesSession(t *testing.T) {
t.Fatalf("config mode options missing plan: %#v", planConfigured.ConfigOptions[1].Options)
}
// Unsafe must be rejected over ACP — a client can't self-grant no-prompt host access.
if err := h.client.Call(ctx, MethodSessionSetMode, SetSessionModeParams{SessionID: newRes.SessionID, ModeID: string(agent.PermissionModeUnsafe)}, &SetSessionModeResult{}); err == nil {
if err := h.client.Call(ctx, MethodSessionSetMode, SetSessionModeParams{SessionID: newRes.SessionID, ModeID: string(agent.PermissionModeFullAuto)}, &SetSessionModeResult{}); err == nil {
t.Fatal("expected Unsafe mode to be rejected over ACP")
}
// An unknown mode must be rejected.
@@ -663,6 +663,70 @@ func TestACPSetModeUpdatesSession(t *testing.T) {
}
}
// The legacy "unsafe" spelling has to reach the full-auto arm of both mode
// doors, not fall through to "unknown mode".
//
// It is an accepted alias everywhere else in the tree, so a client that sends it
// is naming the mode ACP deliberately refuses. Unnormalized, both handlers
// answered "unknown mode: unsafe" — the mode was still refused, so nothing
// escalated, but the client was told the wrong thing about why: that the mode
// does not exist, rather than that this transport will not grant it. That
// invites retrying under another spelling instead of stopping.
//
// Both doors are checked because they are two entry points onto one contract,
// and the previous round of this bug was exactly one layer normalizing while
// another did not.
func TestACPRejectsLegacyUnsafeAliasAsDisallowedNotUnknown(t *testing.T) {
h := newHarness(t, testDeps(t))
defer h.stop()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var newRes NewSessionResult
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: t.TempDir(), McpServers: []McpServer{}}, &newRes); err != nil {
t.Fatalf("session/new: %v", err)
}
assertDisallowed := func(door string, err error) {
t.Helper()
if err == nil {
t.Fatalf("%s: legacy unsafe alias was accepted over ACP", door)
}
if !strings.Contains(err.Error(), "mode not permitted over ACP") {
t.Errorf("%s: error = %q, want the disallowed-mode message", door, err)
}
if strings.Contains(err.Error(), "unknown mode") {
t.Errorf("%s: error = %q, want the alias resolved instead of reported as unknown", door, err)
}
}
// Spelled literally on purpose: this is the value that travels over the wire,
// and the Go alias for it is the canonical string, not the legacy one.
const legacyModeID = "unsafe"
assertDisallowed("set_mode", h.client.Call(ctx, MethodSessionSetMode,
SetSessionModeParams{SessionID: newRes.SessionID, ModeID: legacyModeID}, &SetSessionModeResult{}))
assertDisallowed("set_config_option", h.client.Call(ctx, MethodSessionSetConfigOption,
SetSessionConfigOptionParams{SessionID: newRes.SessionID, ConfigID: configIDMode, Value: legacyModeID}, &SetSessionConfigOptionResult{}))
// Refusing must leave the session where it was, not in a half-applied state.
var configured SetSessionConfigOptionResult
if err := h.client.Call(ctx, MethodSessionSetConfigOption, SetSessionConfigOptionParams{
SessionID: newRes.SessionID, ConfigID: configIDMode, Value: string(agent.PermissionModeAsk),
}, &configured); err != nil {
t.Fatalf("set_config_option ask: %v", err)
}
if got := configured.ConfigOptions[1].CurrentValue; got != string(agent.PermissionModeAsk) {
t.Fatalf("mode after the refusals = %q, want ask", got)
}
// A genuinely unknown mode must still say so, or the assertions above would
// hold for a handler that answered "not permitted" to everything.
err := h.client.Call(ctx, MethodSessionSetMode, SetSessionModeParams{SessionID: newRes.SessionID, ModeID: "bogus"}, &SetSessionModeResult{})
if err == nil || !strings.Contains(err.Error(), "unknown mode") {
t.Fatalf("set_mode bogus = %v, want an unknown-mode error", err)
}
}
// TestACPPlanModeWiresPermissionModeIntoAgentOptions confirms selecting "plan"
// over ACP actually reaches agent.Options.PermissionMode for the next turn —
// the same gap this test's TUI counterpart covers for /plan on.
@@ -2546,6 +2610,22 @@ func payloadString(payload any, key string) string {
return value
}
// configOptionByID finds an advertised option by its identity rather than by
// position. Asserting on ConfigOptions[1] made every one of these tests depend
// on the ORDER the options are advertised in: reordering them would move the
// assertion onto a different option, or panic, instead of failing with a
// message about the option it means.
func configOptionByID(t *testing.T, options []SessionConfigOption, id string) SessionConfigOption {
t.Helper()
for _, option := range options {
if option.ID == id {
return option
}
}
t.Fatalf("no config option %q was advertised; got %+v", id, options)
return SessionConfigOption{}
}
// Load is best effort about a history it cannot read, but a session that zero
// sessions prune holds is refused by load and resume alike, and not published:
// this process could not hold it open while prune may be removing it.
+4 -4
View File
@@ -365,7 +365,7 @@ func TestRunProactiveCompactionTriggers(t *testing.T) {
result, err := Run(context.Background(), strings.Repeat("y", 8000), provider, Options{
Registry: registry,
PermissionMode: PermissionModeUnsafe,
PermissionMode: PermissionModeFullAuto,
ContextWindow: 1000, // ~250 token 80% threshold; easily exceeded
CompactionPreserveLast: 2,
})
@@ -393,7 +393,7 @@ func TestRunNoCompactionWhenContextWindowZero(t *testing.T) {
_, err := Run(context.Background(), strings.Repeat("y", 8000), provider, Options{
Registry: registry,
PermissionMode: PermissionModeUnsafe,
PermissionMode: PermissionModeFullAuto,
ContextWindow: 0, // disabled
})
if err != nil {
@@ -534,7 +534,7 @@ func TestRunReactiveCompactionRecovers(t *testing.T) {
// only the reactive path can save the run.
result, err := Run(context.Background(), strings.Repeat("z", 6000), provider, Options{
Registry: registry,
PermissionMode: PermissionModeUnsafe,
PermissionMode: PermissionModeFullAuto,
ContextWindow: 10_000_000,
CompactionPreserveLast: 2,
Trace: recorder,
@@ -620,7 +620,7 @@ func TestRunReactiveRetryDoesNotDoubleEmitText(t *testing.T) {
var deltas []string
result, err := Run(context.Background(), strings.Repeat("z", 6000), provider, Options{
Registry: registry,
PermissionMode: PermissionModeUnsafe,
PermissionMode: PermissionModeFullAuto,
ContextWindow: 10_000_000,
CompactionPreserveLast: 2,
OnText: func(delta string) { deltas = append(deltas, delta) },
+10 -6
View File
@@ -188,7 +188,11 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
registry = tools.NewRegistry()
}
permissionMode := options.PermissionMode
// Normalized once, here, because this is the single place the run's mode is
// read off Options; every comparison downstream takes it as a parameter from
// this local. Normalizing at the individual comparison sites instead would
// leave the next one added to be found by whoever it breaks.
permissionMode := NormalizePermissionMode(options.PermissionMode)
if permissionMode == "" {
permissionMode = PermissionModeAuto
}
@@ -1282,7 +1286,7 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
return executeRequestPermissions(ctx, call, args, permissionMode, options)
}
permissionGranted := permissionMode == PermissionModeUnsafe
permissionGranted := permissionMode == PermissionModeFullAuto
if toolFound && effectivePermission(tool, args) == tools.PermissionAllow {
permissionGranted = true
}
@@ -1604,10 +1608,10 @@ func maybeRetryUnsandboxedAfterSandboxRestriction(ctx context.Context, registry
}
requestEvent := sandboxRestrictionRetryEvent(call, tool, args, permissionMode, options, result)
request := permissionRequestFromEvent(requestEvent, args, options)
if permissionMode == PermissionModeUnsafe {
if permissionMode == PermissionModeFullAuto {
retryArgs := unsandboxedRetryArgs(args)
retry := runToolForUnsandboxedRetry(ctx, registry, call.Name, call.ID, retryArgs, permissionMode, options, progressCallback)
return retry, nil, true, PermissionDecisionAllow, "unsafe permission mode permits unsandboxed retry", nil, nil
return retry, nil, true, PermissionDecisionAllow, "full-auto permission mode permits unsandboxed retry", nil, nil
}
decision, err := requestPermission(ctx, request, options)
if err != nil {
@@ -1658,9 +1662,9 @@ func maybeRetryWithNetworkAfterSandboxDenial(ctx context.Context, registry *tool
}
requestEvent := sandboxDeniedNetworkRetryEvent(call, tool, args, permissionMode, options, result)
request := permissionRequestFromEvent(requestEvent, args, options)
if permissionMode == PermissionModeUnsafe {
if permissionMode == PermissionModeFullAuto {
retry := runToolForNetworkRetry(ctx, registry, call.Name, call.ID, args, permissionMode, options, progressCallback)
return retry, nil, true, PermissionDecisionAllow, "unsafe permission mode permits sandbox network retry", nil
return retry, nil, true, PermissionDecisionAllow, "full-auto permission mode permits sandbox network retry", nil
}
decision, err := requestPermission(ctx, request, options)
if err != nil {
+3 -3
View File
@@ -2737,7 +2737,7 @@ func TestRunGrantsPromptToolInUnsafeMode(t *testing.T) {
result, err := Run(context.Background(), "write notes", provider, Options{
Registry: registry,
PermissionMode: PermissionModeUnsafe,
PermissionMode: PermissionModeFullAuto,
OnPermission: func(event PermissionEvent) {
permissionEvents = append(permissionEvents, event)
},
@@ -2763,7 +2763,7 @@ func TestRunGrantsPromptToolInUnsafeMode(t *testing.T) {
if event.Action != PermissionActionAllow || !event.PermissionGranted {
t.Fatalf("expected unsafe approval permission event, got %#v", event)
}
if event.ToolName != "write_file" || event.PermissionMode != PermissionModeUnsafe {
if event.ToolName != "write_file" || event.PermissionMode != PermissionModeFullAuto {
t.Fatalf("unexpected unsafe approval metadata: %#v", event)
}
}
@@ -2968,7 +2968,7 @@ func TestRunAppliesSandboxEvenInUnsafeMode(t *testing.T) {
result, err := Run(context.Background(), "write outside", provider, Options{
Registry: registry,
PermissionMode: PermissionModeUnsafe,
PermissionMode: PermissionModeFullAuto,
Autonomy: "high",
Sandbox: sandbox.NewEngine(sandbox.EngineOptions{
WorkspaceRoot: root,
+40 -4
View File
@@ -2,6 +2,7 @@ package agent
import (
"context"
"strings"
"github.com/Twigpine/zero/internal/execution"
"github.com/Twigpine/zero/internal/hooks"
@@ -22,10 +23,24 @@ type PermissionAction string
type PermissionDecisionAction string
const (
PermissionModeAuto PermissionMode = "auto"
PermissionModeAsk PermissionMode = "ask"
PermissionModeUnsafe PermissionMode = "unsafe"
PermissionModeSpecDraft PermissionMode = "spec-draft"
PermissionModeAuto PermissionMode = "auto"
PermissionModeAsk PermissionMode = "ask"
PermissionModeFullAuto PermissionMode = "full-auto"
// PermissionModeUnsafe is the former name of PermissionModeFullAuto.
//
// Kept as an alias rather than deleted because this constant is referenced
// across packages and by code that lands independently of this branch, so
// removing it turns an ordinary merge into a compile failure for whoever
// merges second. It is the same value, so behaviour is identical either way.
//
// Deprecated: use PermissionModeFullAuto.
PermissionModeUnsafe = PermissionModeFullAuto
// legacyFullAutoPermissionMode is the raw string full-auto used to be. The
// Go alias above keeps SOURCE compatible, but a value that arrives as data
// rather than as an identifier is unaffected by it, so it still needs
// mapping. See NormalizePermissionMode.
legacyFullAutoPermissionMode PermissionMode = "unsafe"
PermissionModeSpecDraft PermissionMode = "spec-draft"
// PermissionModePlan is an interactive, read-only planning mode. It applies
// to the CURRENT session (unlike spec-draft, which drafts in a separate
// session): the agent may inspect the workspace and shape the plan with
@@ -518,3 +533,24 @@ func (result Result) TruncationNotice() string {
return "Response ended early (" + result.FinishReason + ") and may be incomplete."
}
}
// NormalizePermissionMode maps a permission mode that arrived as data onto its
// canonical value.
//
// full-auto was renamed from "unsafe", and a Go alias only covers callers that
// name the constant. A value that travels as a string does not go through the
// alias: it comes off a command line, out of a swarm member spec, or across a
// protocol, and after the rename "unsafe" stops matching the comparisons the
// loop makes against PermissionModeFullAuto. The mode then reads as unrecognized
// and the run silently behaves as though full-auto was never requested.
//
// Only the legacy spelling is rewritten. Unknown values are returned unchanged
// rather than folded to a default, because this package has modes the sandbox
// layer does not know about (spec-draft, member-auto) and quietly rewriting one
// of those would be a worse bug than the one being fixed.
func NormalizePermissionMode(mode PermissionMode) PermissionMode {
if PermissionMode(strings.ToLower(strings.TrimSpace(string(mode)))) == legacyFullAutoPermissionMode {
return PermissionModeFullAuto
}
return mode
}
+26 -14
View File
@@ -332,7 +332,7 @@ func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
// until it opts in here.
forwardsRootFlags := false
switch args[0] {
case "--skip-permissions-unsafe", "-p", "--prompt", "exec":
case "--skip-permissions-unsafe", "--full-auto", "-p", "--prompt", "exec":
forwardsRootFlags = true
}
if len(addDirs) > 0 && !forwardsRootFlags {
@@ -343,7 +343,7 @@ func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
}
switch args[0] {
case "--skip-permissions-unsafe":
case "--skip-permissions-unsafe", "--full-auto":
// Launch the interactive TUI directly in unsafe mode. Without this, the
// flag fell through to the unknown-command path, so a user could never
// reach unsafe mode in the shell — and the "!" shell escape (which is
@@ -371,7 +371,7 @@ func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
// so check for it across all of rest before rejecting stray args.
for _, arg := range rest {
if arg == "--add-dir" || strings.HasPrefix(arg, "--add-dir=") {
return writeAppError(stderr, "--add-dir must come before any other arguments (it may precede or follow --skip-permissions-unsafe)", 1)
return writeAppError(stderr, "--add-dir must come before any other arguments (it may precede or follow --full-auto)", 1)
}
}
// This path launches the interactive TUI, which takes no positional prompt or
@@ -380,10 +380,10 @@ func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
// hang in the TUI with the prompt discarded. (AUDIT-L3)
for _, arg := range rest {
if strings.TrimSpace(arg) != "" {
return writeAppError(stderr, "--skip-permissions-unsafe launches the interactive TUI and takes no prompt or subcommand; for a one-shot unsafe run use `zero exec --skip-permissions-unsafe -p \"...\"`", 1)
return writeAppError(stderr, "--full-auto launches the interactive TUI and takes no prompt or subcommand; for a one-shot full-auto run use `zero exec --full-auto -p \"...\"`", 1)
}
}
return runInteractiveTUI(stderr, deps, agent.PermissionModeUnsafe, append(append([]string{}, addDirs...), moreDirs...), skipTheme, allowEscalation || skipAllowEscalation)
return runInteractiveTUI(stderr, deps, agent.PermissionModeFullAuto, append(append([]string{}, addDirs...), moreDirs...), skipTheme, allowEscalation || skipAllowEscalation)
case "-h", "--help", "help":
if err := writeHelp(stdout); err != nil {
return 1
@@ -886,9 +886,10 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
// only PermissionAllow tools, so prompt-gated tools (write_file/edit_file/bash/
// apply_patch) would never be offered to the model — the TUI could neither edit
// files nor run shell. Ask advertises them and routes each through the existing
// OnPermissionRequest flow; shift+tab lets the user switch modes live. An
// explicit --skip-permissions-unsafe launch overrides this to unsafe (the only
// way to reach unsafe, since shift+tab deliberately cycles auto↔ask only).
// OnPermissionRequest flow; shift+tab lets the user switch modes live. A
// --full-auto launch overrides this to full-auto. Shift+tab cycles auto↔ask
// and never lands on full-auto by itself: it only OFFERS it, and the offer
// has to be accepted with ctrl+g before the mode changes.
//
// Resolve the effective mode BEFORE the deferral gate below so the registration
// count uses the SAME permission mode the agent loop's partition will use; an
@@ -898,7 +899,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
}
sessionStore := deps.newSessionStore()
peerClass := peermsg.PermissionPrompting
if permissionMode == agent.PermissionModeUnsafe {
if permissionMode == agent.PermissionModeFullAuto {
peerClass = peermsg.PermissionBypass
}
peerService, peerErr := peermsg.New(peermsg.Options{
@@ -1282,7 +1283,7 @@ func specialistSummaries(paths specialist.Paths) []agent.SpecialistInfo {
return summaries
}
func shouldRegisterExecSpecialistTools(options execOptions) bool {
func shouldRegisterExecSpecialistTools(options execOptions, resolved agent.PermissionMode) bool {
if options.useSpec {
return false
}
@@ -1294,6 +1295,14 @@ func shouldRegisterExecSpecialistTools(options execOptions) bool {
// ones, so a non-trivial exec can auto-delegate exploration safely (a headless
// write spawn still gets denied at the prompt). Default "low" stays clean — no
// specialist tooling or swarm runtime for trivial/CI one-shots.
// The RESOLVED mode decides, not the raw flags. Every spelling that reaches
// full-auto (`--full-auto`, `--skip-permissions-unsafe`, `--auto high`,
// `--permission-mode full-auto|full_auto|unsafe|high`) resolves to the same
// value, so a new alias cannot create an unequal capability set the way
// `--permission-mode full-auto` did.
if resolved == agent.PermissionModeFullAuto {
return true
}
autonomy := strings.ToLower(strings.TrimSpace(options.autonomy))
return options.skipPermissionsUnsafe || autonomy == "high" || autonomy == "medium"
}
@@ -1393,7 +1402,8 @@ Flags:
-p, --prompt Run a one-shot prompt
--add-dir <path> Allow writes in an extra directory (repeatable)
--allow-escalation Let the agent escalate to a stronger model mid-run via escalate_model
--skip-permissions-unsafe Launch the interactive shell in unsafe mode (enables the ! shell escape)
--full-auto Launch the interactive shell in full-auto mode (enables the ! shell escape)
--skip-permissions-unsafe Deprecated spelling of --full-auto
`)
return err
}
@@ -1627,13 +1637,14 @@ Flags:
Override draft reasoning effort when --use-spec is set
--plan Read-only planning mode: write and shell tools are hidden
--permission-mode <mode> Set permission mode directly (plan, spec-draft, auto, member,
ask, unsafe). Outranks --auto; prefer --plan / --auto for
ask, full-auto). Outranks --auto; prefer --plan / --auto for
interactive use. Used by specialist/swarm child processes.
--max-turns <number> Override the maximum agent loop turns
--exec-profile <name> Apply an execution profile (balanced, fast, thorough): loop
posture only (turn budget, effort, self-correction, escalation);
composes with --mode (which picks the model) and explicit flags win
--auto <low|medium|high> Set exec autonomy; high enables unsafe tools
--auto <low|medium|high> Set exec autonomy; high is full-auto (prompt-gated tools run
without approval)
--enabled-tools <tools> Only expose these comma or space separated tools
--disabled-tools <tools> Hide these comma or space separated tools
--list-tools List model-visible tools and exit
@@ -1656,7 +1667,8 @@ Flags:
--depth <number> Set specialist nesting depth metadata
--session-title <text> Set the created session title
--init-session-id <id> Create a new exec session with this id
--skip-permissions-unsafe Allow prompt-gated tools without approval
--full-auto Allow prompt-gated tools without approval
--skip-permissions-unsafe Deprecated spelling of --full-auto
--allow-escalation Let the agent escalate to a stronger model mid-run via escalate_model
--self-correct Run the post-edit verify-and-correct loop (auto-fix needs --auto medium or high)
--notify <off|bell|notify|both>
+6 -6
View File
@@ -995,11 +995,11 @@ func TestRunSkipPermissionsUnsafeLaunchesTUIInUnsafeMode(t *testing.T) {
if !launched {
t.Fatal("expected the interactive TUI to launch, but runTUI was never called")
}
if launchedOptions.PermissionMode != agent.PermissionModeUnsafe {
t.Fatalf("PermissionMode = %q, want %q", launchedOptions.PermissionMode, agent.PermissionModeUnsafe)
if launchedOptions.PermissionMode != agent.PermissionModeFullAuto {
t.Fatalf("PermissionMode = %q, want %q", launchedOptions.PermissionMode, agent.PermissionModeFullAuto)
}
if launchedOptions.AgentOptions.PermissionMode != agent.PermissionModeUnsafe {
t.Fatalf("AgentOptions.PermissionMode = %q, want %q", launchedOptions.AgentOptions.PermissionMode, agent.PermissionModeUnsafe)
if launchedOptions.AgentOptions.PermissionMode != agent.PermissionModeFullAuto {
t.Fatalf("AgentOptions.PermissionMode = %q, want %q", launchedOptions.AgentOptions.PermissionMode, agent.PermissionModeFullAuto)
}
}
@@ -1110,8 +1110,8 @@ func TestRunSkipPermissionsUnsafeMergesAddDirGrants(t *testing.T) {
if exitCode != 0 {
t.Fatalf("expected exit code 0, got %d: %s", exitCode, stderr.String())
}
if launchedOptions.PermissionMode != agent.PermissionModeUnsafe {
t.Fatalf("PermissionMode = %q, want %q", launchedOptions.PermissionMode, agent.PermissionModeUnsafe)
if launchedOptions.PermissionMode != agent.PermissionModeFullAuto {
t.Fatalf("PermissionMode = %q, want %q", launchedOptions.PermissionMode, agent.PermissionModeFullAuto)
}
if launchedOptions.AgentOptions.Sandbox == nil {
t.Fatal("AgentOptions.Sandbox = nil, want sandbox engine")
+2 -2
View File
@@ -20,7 +20,7 @@ type completionContext struct {
}
var completionRoot = completionNode{
flags: []string{"-h", "--help", "-v", "--version", "-p", "--prompt", "--add-dir", "--allow-escalation", "--theme", "--skip-permissions-unsafe"},
flags: []string{"-h", "--help", "-v", "--version", "-p", "--prompt", "--add-dir", "--allow-escalation", "--theme", "--full-auto", "--skip-permissions-unsafe"},
children: []completionNode{
{names: []string{"exec"}, flags: []string{
"-h", "--help", "-f", "--file", "--image", "--add-dir", "--mode", "-m", "--model",
@@ -30,7 +30,7 @@ var completionRoot = completionNode{
"--reasoning-effort", "-C", "--cwd", "-w", "--worktree", "--worktree-dir", "-i",
"--input-format", "-o", "--output-format", "--prompt", "--resume", "--fork",
"--calling-session-id", "--calling-tool-use-id", "--tag", "--depth", "--session-title",
"--init-session-id", "--skip-permissions-unsafe", "--allow-escalation", "--self-correct",
"--init-session-id", "--full-auto", "--skip-permissions-unsafe", "--allow-escalation", "--self-correct",
"--notify", "--no-notify", "--no-completion-gate",
}},
{
+5 -2
View File
@@ -153,8 +153,11 @@ func TestCompletionTreeCoversAliasesNestingAndCommonFlags(t *testing.T) {
byPath[context.path] = context.candidates
}
assertCandidates(t, byPath[""], "sessions", "session", "plugins", "plugin", "worktrees", "worktree", "--add-dir", "--theme", "-p", "--prompt")
assertCandidates(t, byPath["exec"], "--model", "--cwd", "--worktree", "--output-format", "--resume", "--skip-permissions-unsafe")
// --full-auto is the canonical spelling and --skip-permissions-unsafe its
// compatibility alias. BOTH are asserted: testing only the surviving alias is
// what lets a later cleanup quietly stop suggesting the documented flag.
assertCandidates(t, byPath[""], "sessions", "session", "plugins", "plugin", "worktrees", "worktree", "--add-dir", "--theme", "-p", "--prompt", "--full-auto", "--skip-permissions-unsafe")
assertCandidates(t, byPath["exec"], "--model", "--cwd", "--worktree", "--output-format", "--resume", "--full-auto", "--skip-permissions-unsafe")
assertCandidates(t, byPath["worktrees"], "prepare", "release")
assertCandidates(t, byPath["worktree"], "prepare", "release")
assertCandidates(t, byPath["daemon"], "start", "stop", "status", "run", "attach")
+62 -25
View File
@@ -254,8 +254,28 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
if options.allowEscalation {
registry.Register(tools.NewEscalateModelTool())
}
// RESOLVED BEFORE THE REGISTRY IS COMPOSED, because registration used to read
// the raw CLI fields while execution read the resolved mode, and the two
// disagreed. `--permission-mode full-auto` resolves to the same effective
// mode as `--auto high` and `--full-auto`, but only the latter two were
// visible to the eligibility check, so that spelling silently shipped without
// Task and swarm tooling: the explicitly selected full-auto agent could not
// delegate, and `--list-tools` showed a different set for the same mode.
//
// Safe to hoist: resolveExecPermissionMode only parses the options it was
// given, so --list-tools stays offline.
permissionMode, err := resolveExecPermissionMode(options)
if err != nil {
return writeExecFormatUsageError(stdout, stderr, options.outputFormat, err.Error())
}
if options.useSpec {
permissionMode = agent.PermissionModeSpecDraft
}
if options.plan {
permissionMode = agent.PermissionModePlan
}
var specialistRuntime *agentToolRuntime
if shouldRegisterExecSpecialistTools(options) {
if shouldRegisterExecSpecialistTools(options, permissionMode) {
// Specialist tools register before the full config resolve below (so
// --list-tools stays offline). swarm.maxTeamSize is not affected by
// overrides, so an empty-overrides resolve yields the same value; a resolve
@@ -271,16 +291,6 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
}
defer closeSpecialistRuntime(stderr, specialistRuntime)
}
permissionMode, err := resolveExecPermissionMode(options)
if err != nil {
return writeExecFormatUsageError(stdout, stderr, options.outputFormat, err.Error())
}
if options.useSpec {
permissionMode = agent.PermissionModeSpecDraft
}
if options.plan {
permissionMode = agent.PermissionModePlan
}
var mcpRuntime mcpToolRuntime
var mcpSkip trustSkip
// Make local plugins live for this run: register their declared tools into the
@@ -594,20 +604,21 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
if writer.err != nil {
return exitCrash
}
// Surface the unsafe-permissions warning whenever the run resolves to unsafe
// mode, covering BOTH --skip-permissions-unsafe and --auto high (which also
// resolves to PermissionModeUnsafe). Previously only the explicit flag path
// warned, so --auto high silently ran without notice.
if permissionMode == agent.PermissionModeUnsafe {
reason := "--auto high"
switch {
case options.skipPermissionsUnsafe:
reason = "--skip-permissions-unsafe"
case strings.EqualFold(strings.TrimSpace(options.permissionMode), "unsafe"),
strings.EqualFold(strings.TrimSpace(options.permissionMode), "high"):
reason = "--permission-mode unsafe"
}
writer.warning(fmt.Sprintf("Unsafe permissions are active for this run because %s was passed.", reason))
// Surface the warning whenever the run resolves to full-auto, covering BOTH
// the explicit flag and --auto high (which also resolves to
// PermissionModeFullAuto). Previously only the explicit flag path warned, so
// --auto high silently ran without notice.
//
// The wording names permission prompts specifically. Full-auto skips
// approvals; it does not remove the OS sandbox, and a warning that implied
// otherwise would misdescribe what is actually happening.
if permissionMode == agent.PermissionModeFullAuto {
// One bool backs both spellings, so which one was typed is not recoverable
// here. Naming both is the only accurate option: reporting "--full-auto"
// to someone who passed --skip-permissions-unsafe sends them looking for a
// flag they did not use, and a warning is the wrong place to be wrong
// about what the user did.
writer.warning(fmt.Sprintf("Full-auto is active for this run because %s was passed: prompt-gated tools run without approval.", fullAutoWarningSource(options)))
if writer.err != nil {
return exitCrash
}
@@ -1504,3 +1515,29 @@ func writeTraceSnapshot(snapshot *trace.TurnTrace, dest string, stderr io.Writer
defer file.Close()
return trace.WriteNDJSON(file, snapshot)
}
// fullAutoWarningSource names the flag that put this run into full-auto, for the
// warning that explains why prompt-gated tools will run without approval.
//
// ECHOED, NOT MATCHED. This used to test the typed --permission-mode value
// against a list of three spellings while resolveExecPermissionMode accepted
// four, so `--permission-mode full_auto` fell through and the run was told
// full-auto was active because `--auto high` was passed, which it was not.
// Keeping alias acceptance and user-facing attribution in two separate tables
// guarantees they drift again, and a warning explaining why approvals are being
// skipped is the worst place to be wrong about what the user did.
//
// The order matches resolveExecPermissionMode, which consults --permission-mode
// before --auto and the boolean flags.
func fullAutoWarningSource(options execOptions) string {
if mode := strings.TrimSpace(options.permissionMode); mode != "" {
return "--permission-mode " + mode
}
if options.skipPermissionsUnsafe {
// One bool backs both spellings, so naming both is the only accurate
// option: pointing someone at a flag they did not type sends them looking
// for it.
return "--full-auto (or --skip-permissions-unsafe)"
}
return "--auto high"
}
@@ -0,0 +1,59 @@
package cli
import (
"bytes"
"strings"
"testing"
"github.com/Twigpine/zero/internal/config"
)
func execListToolsFor(t *testing.T, args ...string) string {
t.Helper()
// A temporary cwd isolates the workspace, not the user: runWithDeps fills
// the dependencies left out with production ones, and --list-tools returns
// only after startup has read user config, started configured MCP servers
// and begun the models.dev refresh. Every user root goes to a fixture first.
isolateCLIUserState(t)
cwd := t.TempDir()
var stdout, stderr bytes.Buffer
exitCode := runWithDeps(append([]string{"exec"}, append(args, "--list-tools")...), &stdout, &stderr, appDeps{
getwd: func() (string, error) { return cwd, nil },
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) { return execResolvedConfig(), nil },
})
if exitCode != exitSuccess {
t.Fatalf("exit %d for %v: %s", exitCode, args, stderr.String())
}
return stdout.String()
}
// EVERY SPELLING OF FULL-AUTO GETS THE SAME TOOLS.
//
// Registration read the raw CLI fields while execution read the resolved mode,
// so `--permission-mode full-auto` composed a registry without Task and swarm
// tooling while `--auto high` and `--full-auto`, which resolve to exactly the
// same effective mode, composed one with it. The explicitly selected full-auto
// agent could not delegate, and --list-tools reported a different capability set
// for the same run.
func TestFullAutoEntryPointsListTheSameSpecialistTools(t *testing.T) {
baseline := execListToolsFor(t, "--auto", "high")
if !strings.Contains(baseline, "swarm_spawn") {
t.Fatalf("SETUP INVALID: --auto high did not list swarm_spawn, so this test cannot detect the gap:\n%s", baseline)
}
for _, args := range [][]string{
{"--full-auto"},
{"--permission-mode", "full-auto"},
{"--permission-mode", "full_auto"},
{"--permission-mode", "unsafe"},
{"--permission-mode", "high"},
{"--skip-permissions-unsafe"},
} {
t.Run(strings.Join(args, " "), func(t *testing.T) {
listing := execListToolsFor(t, args...)
if !strings.Contains(listing, "swarm_spawn") {
t.Errorf("%v resolves to full-auto but did not list swarm_spawn, so this entry point cannot delegate:\n%s", args, listing)
}
})
}
}
@@ -0,0 +1,35 @@
package cli
import "testing"
// The warning explains why approvals are being skipped, so misattributing it
// sends someone looking for a flag they never typed. Attribution used to be a
// partial list of --permission-mode spellings, and full_auto was missing from
// it, so that run was told --auto high was responsible.
func TestFullAutoWarningNamesTheFlagThatWasActuallyPassed(t *testing.T) {
for _, testCase := range []struct {
name string
options execOptions
want string
}{
{name: "canonical mode", options: execOptions{permissionMode: "full-auto"}, want: "--permission-mode full-auto"},
{name: "underscore alias", options: execOptions{permissionMode: "full_auto"}, want: "--permission-mode full_auto"},
{name: "legacy unsafe alias", options: execOptions{permissionMode: "unsafe"}, want: "--permission-mode unsafe"},
{name: "legacy high alias", options: execOptions{permissionMode: "high"}, want: "--permission-mode high"},
{name: "boolean flag", options: execOptions{skipPermissionsUnsafe: true}, want: "--full-auto (or --skip-permissions-unsafe)"},
{name: "autonomy", options: execOptions{autonomy: "high"}, want: "--auto high"},
{
// resolveExecPermissionMode reads --permission-mode first, so the
// attribution has to as well.
name: "explicit mode wins over the boolean flag",
options: execOptions{permissionMode: "full-auto", skipPermissionsUnsafe: true},
want: "--permission-mode full-auto",
},
} {
t.Run(testCase.name, func(t *testing.T) {
if got := fullAutoWarningSource(testCase.options); got != testCase.want {
t.Errorf("fullAutoWarningSource = %q, want %q", got, testCase.want)
}
})
}
}
+1 -1
View File
@@ -33,7 +33,7 @@ func parseExecArgs(args []string) (execOptions, bool, error) {
return options, false, err
}
options.permissionMode = value
case arg == "--skip-permissions-unsafe":
case arg == "--skip-permissions-unsafe", arg == "--full-auto":
options.skipPermissionsUnsafe = true
case arg == "--list-tools":
options.listTools = true
+8 -8
View File
@@ -514,7 +514,7 @@ func TestResolveExecPermissionModeMember(t *testing.T) {
{"low", agent.PermissionModeAuto},
{"medium", agent.PermissionModeAuto},
{"member", agent.PermissionModeMemberAuto}, // headless members: write + sandboxed shell
{"high", agent.PermissionModeUnsafe},
{"high", agent.PermissionModeFullAuto},
}
for _, c := range cases {
got, err := resolveExecPermissionMode(execOptions{autonomy: c.autonomy})
@@ -785,8 +785,8 @@ func TestRunExecUnsafeTextModeWarns(t *testing.T) {
if !strings.Contains(stdout, "hello") {
t.Fatalf("expected prompt in stdout, got %q", stdout)
}
if got := stderr; !strings.Contains(got, "WARNING") || !strings.Contains(got, "--skip-permissions-unsafe") {
t.Fatalf("expected unsafe warning, got %q", got)
if got := stderr; !strings.Contains(got, "WARNING") || !strings.Contains(got, "full-auto") {
t.Fatalf("expected full-auto warning, got %q", got)
}
}
@@ -935,8 +935,8 @@ func TestRunExecJSONUnsafeOutputsWarningEvent(t *testing.T) {
if !slices.Contains(eventTypes, "warning") {
t.Fatalf("expected JSON warning event in %v; output %q", eventTypes, stdout)
}
if got := events[0]["permission_mode"]; got != "unsafe" {
t.Fatalf("expected run_start permission_mode unsafe, got %v", got)
if got := events[0]["permission_mode"]; got != "full-auto" {
t.Fatalf("expected run_start permission_mode full-auto, got %v", got)
}
}
@@ -1194,7 +1194,7 @@ func TestRunExecReasoningEffortNoticeUsesEffectiveModel(t *testing.T) {
}
// TestRunExecAutoHighEmitsUnsafeWarning asserts that --auto high (which resolves
// to PermissionModeUnsafe) surfaces the same unsafe warning as
// to PermissionModeFullAuto) surfaces the same unsafe warning as
// --skip-permissions-unsafe.
func TestRunExecAutoHighEmitsUnsafeWarning(t *testing.T) {
exitCode, stdout, stderr := runExecWithEcho(t, []string{
@@ -1212,8 +1212,8 @@ func TestRunExecAutoHighEmitsUnsafeWarning(t *testing.T) {
if !slices.Contains(jsonEventTypes(events), "warning") {
t.Fatalf("expected JSON warning event for --auto high, got %v; output %q", jsonEventTypes(events), stdout)
}
if got := events[0]["permission_mode"]; got != "unsafe" {
t.Fatalf("expected run_start permission_mode unsafe, got %v", got)
if got := events[0]["permission_mode"]; got != "full-auto" {
t.Fatalf("expected run_start permission_mode full-auto, got %v", got)
}
}
+9 -5
View File
@@ -85,10 +85,14 @@ func resolveExecPermissionMode(options execOptions) (agent.PermissionMode, error
return agent.PermissionModeMemberAuto, nil
case "ask":
return agent.PermissionModeAsk, nil
case "unsafe", "high":
return agent.PermissionModeUnsafe, nil
// full-auto is the canonical spelling this PR establishes; unsafe stays as
// the deprecated alias so existing invocations keep working. Without the
// canonical value here, `--permission-mode full-auto` failed usage
// validation before NormalizePermissionMode was ever reached.
case "full-auto", "full_auto", "unsafe", "high":
return agent.PermissionModeFullAuto, nil
default:
return "", execUsageError{fmt.Sprintf("Invalid permission mode %q. Expected plan, spec-draft, auto, member, ask, or unsafe.", options.permissionMode)}
return "", execUsageError{fmt.Sprintf("Invalid permission mode %q. Expected plan, spec-draft, auto, member, ask, or full-auto.", options.permissionMode)}
}
}
// Validate --auto first, regardless of --skip-permissions-unsafe, so an
@@ -105,12 +109,12 @@ func resolveExecPermissionMode(options execOptions) (agent.PermissionMode, error
// swarm launcher sets this; it is not part of the public low|medium|high set.
mode = agent.PermissionModeMemberAuto
case "high":
mode = agent.PermissionModeUnsafe
mode = agent.PermissionModeFullAuto
default:
return "", execUsageError{fmt.Sprintf("Invalid autonomy level %q. Expected low, medium, or high.", options.autonomy)}
}
if options.skipPermissionsUnsafe {
return agent.PermissionModeUnsafe, nil
return agent.PermissionModeFullAuto, nil
}
return mode, nil
}
+1 -1
View File
@@ -55,7 +55,7 @@ func TestRootFlagsComposeInAnyOrder(t *testing.T) {
wantMode := agent.PermissionModeAsk
for _, arg := range args {
if arg == "--skip-permissions-unsafe" {
wantMode = agent.PermissionModeUnsafe
wantMode = agent.PermissionModeFullAuto
}
}
if options.PermissionMode != wantMode {
+3 -12
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)
}
setupPlan, err := zeroSandbox.BuildWindowsSandboxSetupArgs(zeroSandbox.WindowsSandboxSetupArgsOptions{
setupArgs, err := zeroSandbox.BuildWindowsSandboxSetupArgs(zeroSandbox.WindowsSandboxSetupArgsOptions{
CommandCWD: workspaceRoot,
WorkspaceRoots: []string{workspaceRoot},
PermissionProfile: profile,
@@ -182,18 +182,9 @@ 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...), 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.
setupArgs = append(append([]string{}, setupHelper.ArgsPrefix...), setupArgs...)
if err := deps.runSandboxSetupHelper(setupHelper.Name, setupArgs, stdout, stderr); err != nil {
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 writeAppError(stderr, "Windows sandbox setup failed: "+err.Error(), exitProvider)
}
return writeSandboxSetupResult(stdout, options.json, sandboxSetupResult{
Platform: "windows",
+1 -1
View File
@@ -103,7 +103,7 @@ func TestTrustGateBlocksToolHookThroughAgentRun(t *testing.T) {
if _, err := agent.Run(context.Background(), "go", toolThenTextProvider{toolName: "marker_tool"}, agent.Options{
Registry: reg,
Hooks: disp,
PermissionMode: agent.PermissionModeUnsafe,
PermissionMode: agent.PermissionModeFullAuto,
MaxTurns: 3,
}); err != nil {
t.Fatalf("agent.Run: %v", err)
@@ -62,6 +62,7 @@ func TestCLILaunchHelpersLeaveUserStateAlone(t *testing.T) {
{"execAdvertisesEscalateModel", func(t *testing.T) {
_ = execAdvertisesEscalateModel(t, []string{"--allow-escalation", "exec", "say hi"})
}},
{"execListToolsFor", func(t *testing.T) { _ = execListToolsFor(t, "--auto", "high") }},
{"runExecWithSandbox", func(t *testing.T) {
_, _, _ = runExecWithSandbox(t, []string{"exec", "hello"}, unavailableTestSandbox, config.SandboxConfig{})
}},
+4 -70
View File
@@ -99,77 +99,11 @@ 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},
// 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},
),
SandboxHome: sandboxHome,
CommandCWD: workspaceRoot,
WorkspaceRoots: []string{workspaceRoot},
PermissionProfile: profile,
}
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{
@@ -1,247 +0,0 @@
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)
}
}
}
+268 -22
View File
@@ -15,6 +15,15 @@ type AnalysisResult struct {
Interactive bool
Destructive bool
Network bool
// LocalServer is set when a command BINDS a local port rather than reaching
// out: `python -m http.server`, `vite`, `next dev` and friends.
//
// Kept distinct from Network instead of folded into it. Listening and
// fetching are different acts with different consequences, and treating a
// dev server as egress made ordinary local work prompt for network approval
// it never needed. The information is preserved rather than dropped, so a
// caller that does care about inbound can still see it.
LocalServer bool
// TooComplex is set when the script cannot be parsed (obfuscated or invalid),
// so a caller can treat it as higher-risk instead of trusting a clean result.
TooComplex bool
@@ -45,6 +54,20 @@ var networkPrograms = map[string]bool{
"invoke-restmethod": true,
}
// pythonLauncherPrograms is every spelling of the Python launcher the analyzer
// accepts. It is a named list rather than a repeated switch case because the
// unparseable fallback in risk.go has to be a superset of it, and the two were
// maintained separately: "py" was accepted here and missing there, so a Windows
// batch spelling the POSIX parser rejects lost the network gate that its
// parseable equivalent receives. TestUnparseableFallbackCoversEveryPythonLauncher
// now derives from this, so a new alias cannot weaken enforcement silently.
var pythonLauncherPrograms = map[string]bool{
"python": true,
"python2": true,
"python3": true,
"py": true,
}
var localServerPrograms = map[string]bool{
"http-server": true,
"serve": true,
@@ -54,6 +77,17 @@ var localServerPrograms = map[string]bool{
"astro": true,
}
// packageManagerServingScripts are the script names that start a dev server
// across npm, pnpm, yarn and bun. Authoritative: risk.go's unparseable fallback
// has to stay a superset of what this flags, and the superset test drives its
// cases off this map so a name added here cannot silently weaken the fallback.
var packageManagerServingScripts = map[string]bool{
"start": true,
"serve": true,
"dev": true,
"preview": true,
}
// AnalyzeCommand parses script and reports interactive/destructive/network usage
// from the shell AST. A script that cannot be parsed yields TooComplex (with no
// other flags set) so the caller can decide how to treat an unanalyzable command.
@@ -183,6 +217,29 @@ func analyzeInto(script string, result *AnalysisResult, seen map[string]bool, de
if commandUsesNetwork(prog, rest) {
result.Network = true
}
if commandRunsLocalServer(prog, rest) {
result.LocalServer = true
// LocalServer is ADDITIVE, not a replacement for Network, and that is
// the whole of this line.
//
// Nothing consumes LocalServer yet: no policy or runner code reads it,
// so classifying a serving command as local-only did not grant it a
// scoped host listener, it only removed the network approval it used to
// get. The command then ran under the default deny profile, which on
// Linux is a network namespace and on macOS is (deny network*), so
// `python -m http.server` and `vite` started without a prompt and could
// not serve a preview to the operator's browser.
//
// It is also unsound for the package managers. `npm run dev` is matched
// by SCRIPT NAME, and the repository decides what `dev` and `predev`
// actually do; either can curl before anything binds a port. On Windows
// the approval gate IS the network protection, so inferring "no egress"
// from a name there lets that egress run unprompted.
//
// The classification is kept, because a scoped host-listener path will
// want it, and the approval path is kept until that path exists.
result.Network = true
}
if destructivePrograms[prog] ||
(prog == "rm" && hasRecursiveForce(rest)) ||
(powerShellRemoveItemPrograms[prog] && hasPowerShellRecursiveForce(rest)) ||
@@ -198,12 +255,23 @@ func commandUsesNetwork(prog string, args []*syntax.Word) bool {
return true
}
words := literalWordTexts(args)
// A framework CLI stays conservative for Network whatever its subcommand.
// Binding a port is not egress, but next, vite, nuxt and astro execute the
// repository's config, plugins and application code during a build as
// surely as during a serve, and nothing in the subcommand name proves that
// code makes no requests. On Windows the approval gate IS the network
// boundary, so an ordinary shell grant must not run `next build` without
// the separate network decision. LocalServer alone carries the serve/build
// distinction (commandRunsLocalServer); `python -m http.server` is decided
// by pythonModuleUsesNetwork below, not by this inventory. Reported by
// @gnanam1990.
if localServerPrograms[prog] {
return true
}
switch prog {
case "python", "python2", "python3", "py":
if pythonLauncherPrograms[prog] {
return pythonModuleUsesNetwork(words)
}
switch prog {
case "npm":
return packageManagerUsesNetwork(words, map[string]string{
"run": "run",
@@ -246,23 +314,29 @@ func packageManagerUsesNetwork(words []string, aliases map[string]string) bool {
if packageManagerOffline(words) {
return false
}
first := firstSubcommand(words, aliases)
switch first {
case "install", "add", "ci", "create", "dlx", "publish", "unpublish",
"login", "logout", "adduser", "whoami", "ping", "audit", "outdated",
"update", "upgrade", "search", "view", "info", "show", "dist-tag",
"deprecate", "owner", "org", "team", "token", "profile", "access":
return true
case "start", "serve", "dev", "preview":
return true
case "run":
second := secondSubcommand(words)
return second == "start" || second == "serve" || second == "dev" || second == "preview"
case "exec":
// Package-manager exec commands may resolve and download a missing
// package before launching it. An explicit offline flag keeps this path
// inside the isolated namespace; otherwise request egress up front.
return true
operands, eatable := commandOperands(words)
for index := 0; index < subcommandPositions(operands, eatable); index++ {
subcommand := operands[index]
if alias, ok := aliases[subcommand]; ok {
subcommand = alias
}
switch subcommand {
case "install", "add", "ci", "create", "dlx", "publish", "unpublish",
"login", "logout", "adduser", "whoami", "ping", "audit", "outdated",
"update", "upgrade", "search", "view", "info", "show", "dist-tag",
"deprecate", "owner", "org", "team", "token", "profile", "access":
return true
// start / serve / dev / preview are handled by packageManagerRunsLocalServer
// instead. They start a dev server, which binds rather than fetches, and
// `npm run dev` is the single most common command an agent is asked to run
// while building something. Classifying it as egress made every one of them
// stop for a network approval that protected nothing.
case "exec":
// Package-manager exec commands may resolve and download a missing
// package before launching it. An explicit offline flag keeps this path
// inside the isolated namespace; otherwise request egress up front.
return true
}
}
return false
}
@@ -303,11 +377,183 @@ func pythonModuleUsesNetwork(words []string) bool {
if words[index] != "-m" || index+1 >= len(words) {
continue
}
module := words[index+1]
if module == "http.server" {
// http.server is handled by pythonModuleRunsLocalServer instead: it
// listens, it does not fetch. pip install genuinely reaches out.
if words[index+1] == "pip" && firstSubcommand(words[index+2:], nil) == "install" {
return true
}
if module == "pip" && firstSubcommand(words[index+2:], nil) == "install" {
}
return false
}
// commandRunsLocalServer reports a command that binds a local port.
//
// Separate from commandUsesNetwork on purpose. A dev server is the single most
// common thing an agent is asked to start while building something, and making
// it indistinguishable from `curl` meant every one of them stopped for a
// network approval that protected nobody.
//
// Honest about the edges: some of these do touch the network incidentally, and
// `npm run dev` may install first. What is claimed here is narrow, that BINDING
// is not EGRESS, not that dev tooling is inert. Anything that actually fetches
// still matches commandUsesNetwork through its own program or subcommand.
func commandRunsLocalServer(prog string, args []*syntax.Word) bool {
words := literalWordTexts(args)
if localServerPrograms[prog] {
return frameworkSubcommandRunsLocalServer(prog, words)
}
if pythonLauncherPrograms[prog] {
return pythonModuleRunsLocalServer(words)
}
switch prog {
case "npm", "pnpm", "yarn", "bun":
return packageManagerRunsLocalServer(words)
}
return false
}
// frameworkSubcommandRunsLocalServer decides whether a framework CLI is being
// asked to SERVE or merely to compile.
//
// The program name alone is not enough. `next build`, `vite build`, `nuxt
// generate` and `astro check` bind nothing, and classifying them as servers
// makes the flag mean "some dev tool ran", which is not what a reader of it
// would assume.
//
// Dedicated servers stay unconditional: running http-server or serve IS the
// server. vite is also a server when bare, since bare `vite` starts the dev
// server, whereas the multi-command frameworks print help when bare and so need
// an explicit serving subcommand.
func frameworkSubcommandRunsLocalServer(prog string, words []string) bool {
switch prog {
case "http-server", "serve":
return true
}
// The subcommand is not simply the first operand: a global option before it
// consumes the next word (`vite --config vite.prod.ts build`), and reading
// that value as the subcommand made the build fall through to the bare-vite
// rule and count as a server. Walk the positions a subcommand can occupy,
// the same way the package managers do; see subcommandPositions.
operands, eatable := commandOperands(words)
for index := 0; index < subcommandPositions(operands, eatable); index++ {
switch operands[index] {
case "dev", "start", "serve", "preview":
return true
case "build", "optimize", "generate", "check", "lint", "export":
return false
}
}
// No recognized subcommand at any position it could occupy: a bare
// invocation, or only option values. Fall back to what the program does when
// run bare: vite starts its dev server, while the multi-command frameworks
// print help.
return prog == "vite"
}
// packageManagerRunsLocalServer covers `npm run dev` and its siblings across the
// package managers, both as a direct subcommand and behind `run`, and wherever
// global options push the subcommand off its usual position. See
// subcommandPositions for why the subcommand is not simply the first operand.
func packageManagerRunsLocalServer(words []string) bool {
operands, eatable := commandOperands(words)
for index := 0; index < subcommandPositions(operands, eatable); index++ {
if packageManagerServingScripts[operands[index]] {
return true
}
if operands[index] == "run" && index+1 < len(operands) && packageManagerServingScripts[operands[index+1]] {
return true
}
}
return false
}
// commandOperands returns a command's non-flag words in order, and whether a
// flag came before the first of them. The program name is expected to be
// stripped already.
func commandOperands(words []string) (operands []string, eatable int) {
// eatable counts the LEADING RUN of operands that each sit immediately after
// a flag, and so might be that flag's value rather than the subcommand.
// Adjacency is the whole rule: an option can only consume the word next to
// it, and the first operand that is NOT preceded by a flag cannot be anyone's
// value, so the run ends there and the subcommand is at or before it.
previousWasFlag := false
counting := true
for _, word := range words {
if word == "" {
continue
}
if strings.HasPrefix(word, "-") {
previousWasFlag = true
continue
}
if isNumericToken(word) {
previousWasFlag = false
continue
}
if counting {
if previousWasFlag {
eatable++
} else {
counting = false
}
}
operands = append(operands, word)
previousWasFlag = false
}
return operands, eatable
}
// subcommandPositions reports how many leading operands could be the subcommand.
//
// Skipping flags is not enough, because it does not skip the words they CONSUME.
// `npm --prefix ./web run dev` then resolves to "./web" — the option's VALUE —
// and the invocation stops being recognized at all: the dev server loses the
// network approval its unflagged spelling gets, and `npm --prefix ./web install`
// loses it while genuinely fetching. That is not a contrived spelling; a global
// option before the subcommand is documented usage for every one of these tools
// (`npm --prefix`, `pnpm -C`, `yarn --cwd`, `bun --cwd`).
//
// Which options take a value is deliberately not enumerated. That is four tools
// each free to add one, and a list like that leaks silently the moment they do.
// ADJACENCY IS USED INSTEAD, because it is the actual grammar: an option can only
// consume the word immediately after it. Every leading operand that sits right
// after a flag might be that flag's value, so each one opens another position;
// the first operand NOT preceded by a flag cannot be anyone's value, and the
// window closes there.
//
// A FIXED WINDOW OF TWO WAS THE BUG. It covered exactly one value-taking option
// and no more, so a second one walked straight past it — measured before this
// change:
//
// npm --prefix ./web install -> network (correct)
// npm --prefix ./web --loglevel warn install -> NOT network
// npm --registry https://r.test --prefix ./web install -> NOT network
// pnpm -C ./web --filter web add left-pad -> NOT network
//
// all four genuinely fetching. Counting the flags rather than fixing the number
// closes the class at any arity without naming a single option.
//
// The false positives the old cap was protecting against are unaffected, because
// they turn on adjacency too: `npm run build --workspace dev` and `npm run test
// -- --grep start` both put an unflagged operand FIRST, so the window is one
// position and "dev" and "start" are never reached. That is a stronger guarantee
// than the cap gave — it held only because two happened to be short enough.
func subcommandPositions(operands []string, eatable int) int {
if len(operands) == 0 {
return 0
}
if positions := eatable + 1; positions < len(operands) {
return positions
}
return len(operands)
}
func pythonModuleRunsLocalServer(words []string) bool {
for index := 0; index < len(words); index++ {
if words[index] != "-m" || index+1 >= len(words) {
continue
}
if words[index+1] == "http.server" {
return true
}
}
@@ -0,0 +1,151 @@
package sandbox
import "testing"
// LocalServer exists to say one narrow thing: this command BINDS a local port,
// so it is not egress and should not be treated as network access. That claim
// is only worth anything if it tracks what the command actually does.
//
// Matching on the program name alone made every invocation of a framework CLI a
// "server", so `next build` and `vite build` claimed to bind a port while
// compiling to disk. Nothing reads the flag today, which is exactly why it could
// be wrong quietly; the first reader would have inherited the bug.
func TestLocalServerDistinguishesServingFromBuilding(t *testing.T) {
serving := []string{
"next dev", "next start", "nuxt dev", "astro dev", "astro preview",
"vite", "vite serve", "vite preview",
// Options only, no subcommand. firstSubcommand skips the flag but not the
// value it consumes, so this used to resolve to "127.0.0.1" and read as a
// build.
"vite --host 127.0.0.1", "vite --port 5173",
// Dedicated servers, whose entire purpose is to bind.
"http-server", "http-server ./public", "serve", "serve dist",
// Via a package manager, the pre-existing path.
"npm run dev", "pnpm start", "yarn preview",
}
for _, command := range serving {
if !AnalyzeCommand(command).LocalServer {
t.Errorf("%q does bind a local port but was not classified as a local server", command)
}
}
building := []string{
"next build", "next lint", "next",
"vite build", "vite optimize",
"nuxt generate", "nuxt build",
"astro check", "astro build",
"npm run build",
}
for _, command := range building {
if AnalyzeCommand(command).LocalServer {
t.Errorf("%q compiles rather than serving but was classified as a local server", command)
}
}
}
// BUILDING IS NOT SERVING, BUT IT IS STILL NETWORK.
//
// The serve/build distinction lives in LocalServer alone. Network stays
// conservative for the whole framework inventory, because next, vite, nuxt and
// astro run the repository config, plugins and application code during a build
// as surely as during a serve, and the subcommand name cannot prove that code
// makes no requests. An earlier head of this branch let build-like spellings
// drop to Network=false, and on Windows the approval gate IS the network
// boundary, so an ordinary shell grant would have run them without the separate
// network decision main applies. Reported by gnanam1990.
func TestFrameworkBuildsStayNetworkWithoutBeingServers(t *testing.T) {
for _, command := range []string{
"next build", "next lint", "next",
"vite build", "vite optimize",
"nuxt generate", "nuxt build",
"astro check", "astro build",
// Option-bearing forms: a more precise action parse must not remove the gate.
"next build --profile", "vite build --mode production",
"vite --config vite.prod.ts build", "nuxt generate --dotenv .env.prod",
"astro check --watch",
} {
analysis := AnalyzeCommand(command)
if !analysis.Network {
t.Errorf("%q lost its network classification; a framework build runs repository code and cannot be proven inert", command)
}
if analysis.LocalServer {
t.Errorf("%q compiles rather than serving but was classified as a local server", command)
}
}
for _, command := range []string{"next dev", "vite", "nuxt dev", "astro dev", "astro preview"} {
analysis := AnalyzeCommand(command)
if !analysis.Network || !analysis.LocalServer {
t.Errorf("%q = Network %v LocalServer %v, want both: serving binds AND may egress", command, analysis.Network, analysis.LocalServer)
}
}
}
// SERVING KEEPS ITS NETWORK APPROVAL until something consumes LocalServer.
//
// The classification alone grants nothing: no policy or runner code reads
// LocalServer, so treating a serving command as local-only only removed the
// approval it used to get, and the command then ran under the default deny
// profile. On Linux that is a network namespace and on macOS it is
// (deny network*), so the server started unprompted and could not serve a
// preview to the operator's browser.
//
// The package-manager cases are the sharper half. "npm run dev" is matched by
// SCRIPT NAME, and the repository decides what "dev" and "predev" actually do;
// either can curl before anything binds a port. On Windows the approval gate IS
// the network protection, so inferring no-egress from a name lets that through
// unprompted.
func TestServingStillRequiresNetworkApproval(t *testing.T) {
for _, command := range []string{"next dev", "vite", "http-server", "npm run dev", "pnpm dev", "python -m http.server"} {
analysis := AnalyzeCommand(command)
if !analysis.LocalServer {
t.Errorf("%q was not recognised as a local server", command)
}
if !analysis.Network {
t.Errorf("%q lost its network approval, so it runs under the deny profile and cannot serve a preview", command)
}
}
}
// A GLOBAL OPTION BEFORE THE SUBCOMMAND MUST NOT HIDE IT.
//
// The subcommand scan skips flags but not the word they CONSUME, so
// `npm --prefix ./web run dev` resolved to "./web" and the dev server stopped
// being recognised at all — it lost the network approval its unflagged spelling
// gets, and on Windows that approval IS the egress control. A global option
// ahead of the subcommand is documented usage for every one of these tools
// (`npm --prefix`, `pnpm -C`, `yarn --cwd`, `bun --cwd`), not a contrived form.
//
// Driven off packageManagerServingScripts rather than a written-out list, so a
// script name added there is covered in the flagged spelling too instead of only
// in the one someone remembered to type.
func TestPackageManagerOptionValuesDoNotHideTheServingSubcommand(t *testing.T) {
if len(packageManagerServingScripts) == 0 {
t.Fatal("SETUP INVALID: no serving scripts to check")
}
for _, manager := range []struct{ prog, option string }{
{"npm", "--prefix"},
{"pnpm", "-C"},
{"yarn", "--cwd"},
{"bun", "--cwd"},
} {
for script := range packageManagerServingScripts {
for _, command := range []string{
manager.prog + " " + manager.option + " ./web " + script,
manager.prog + " " + manager.option + " ./web run " + script,
} {
t.Run(command, func(t *testing.T) {
analysis := AnalyzeCommand(command)
if analysis.TooComplex {
t.Fatalf("SETUP INVALID: %q was expected to parse", command)
}
if !analysis.LocalServer {
t.Errorf("%q binds a local port, but the option's value was read as its subcommand", command)
}
if !analysis.Network {
t.Errorf("%q lost the network approval its unflagged spelling receives", command)
}
})
}
}
}
}
+143 -9
View File
@@ -9,6 +9,7 @@ func TestAnalyzeCommand(t *testing.T) {
interactive bool
destructive bool
network bool
localServer bool
tooComplex bool
}{
{name: "editor", script: "vim foo.txt", interactive: true},
@@ -80,7 +81,7 @@ func TestAnalyzeCommand(t *testing.T) {
{name: "Windows drive relative path curl exe", script: `'C:curl.exe' https://example.com`, network: true},
{name: "Windows npm cmd", script: "npm.cmd install", network: true},
{name: "wget piped to shell", script: "wget -qO- https://x.test | sh", network: true},
{name: "python http server", script: "python3 -m http.server 8000", network: true},
{name: "python http server", script: "python3 -m http.server 8000", network: true, localServer: true},
{name: "python pip install", script: "python3 -m pip install requests", network: true},
{name: "npm install", script: "npm install", network: true},
{name: "npm ci", script: "npm ci", network: true},
@@ -89,12 +90,12 @@ func TestAnalyzeCommand(t *testing.T) {
{name: "npm metadata search", script: "npm search typescript", network: true},
{name: "npm offline install", script: "npm install --offline", network: false},
{name: "npm version is offline", script: "npm --version", network: false},
{name: "npm start", script: "npm start", network: true},
{name: "npm run dev", script: "npm run dev", network: true},
{name: "npm start", script: "npm start", network: true, localServer: true},
{name: "npm run dev", script: "npm run dev", network: true, localServer: true},
{name: "npx http server", script: "npx http-server public -p 8080 -a 127.0.0.1", network: true},
{name: "direct http server", script: "http-server public -p 8080 -a 127.0.0.1", network: true},
{name: "direct vite", script: "vite --host 127.0.0.1", network: true},
{name: "next dev", script: "next dev", network: true},
{name: "direct http server", script: "http-server public -p 8080 -a 127.0.0.1", network: true, localServer: true},
{name: "direct vite", script: "vite --host 127.0.0.1", network: true, localServer: true},
{name: "next dev", script: "next dev", network: true, localServer: true},
{name: "git clone", script: "git clone https://example.com/repo.git", network: true},
{name: "git fetch", script: "git fetch origin", network: true},
{name: "git status is offline", script: "git status", network: false},
@@ -103,15 +104,42 @@ func TestAnalyzeCommand(t *testing.T) {
{name: "process pattern is not network", script: `pkill -f "python3 -m http.server 8000"`, network: false},
{name: "process listing is not special-cased", script: "ps aux", network: false},
// Binding a port is not reaching out. These four are the shape that made
// ordinary local work stop for a network approval it never needed, and the
// fetching siblings beside them are what must keep asking.
{name: "pnpm dev binds", script: "pnpm dev", network: true, localServer: true},
{name: "yarn serve binds", script: "yarn serve", network: true, localServer: true},
{name: "bun run preview binds", script: "bun run preview", network: true, localServer: true},
{name: "pnpm install still fetches", script: "pnpm install", network: true},
{name: "yarn add still fetches", script: "yarn add left-pad", network: true},
{name: "npm publish still fetches", script: "npm publish", network: true},
{name: "python pip install still fetches", script: "python3 -m pip install requests", network: true},
// A global option BEFORE the subcommand is documented usage for all four
// package managers, and the subcommand scan skipped the flag but not the
// word it consumed — so the option's VALUE was read as the subcommand and
// both the dev server and the genuine fetch fell out of their categories.
{name: "npm prefix then run dev", script: "npm --prefix ./web run dev", network: true, localServer: true},
{name: "pnpm dir then run dev", script: "pnpm -C ./web run dev", network: true, localServer: true},
{name: "yarn cwd then dev", script: "yarn --cwd ./web dev", network: true, localServer: true},
{name: "npm prefix then install still fetches", script: "npm --prefix ./web install", network: true},
{name: "npm registry then install still fetches", script: "npm --registry https://registry.test install", network: true},
{name: "pnpm filter then add still fetches", script: "pnpm --filter web add left-pad", network: true},
// The candidate window stops at two operands. Past that, "dev" and "start"
// are arguments to something else and reading them as the subcommand would
// stop an ordinary build for a network approval.
{name: "npm run build with a workspace named dev", script: "npm run build --workspace dev", network: false},
{name: "npm run test forwarding a start flag", script: "npm run test -- --grep start", network: false},
{name: "unparseable", script: `'unterminated quote`, tooComplex: true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := AnalyzeCommand(tc.script)
if got.Interactive != tc.interactive || got.Destructive != tc.destructive ||
got.Network != tc.network || got.TooComplex != tc.tooComplex {
t.Fatalf("AnalyzeCommand(%q) = %#v, want interactive=%v destructive=%v network=%v tooComplex=%v",
tc.script, got, tc.interactive, tc.destructive, tc.network, tc.tooComplex)
got.Network != tc.network || got.LocalServer != tc.localServer || got.TooComplex != tc.tooComplex {
t.Fatalf("AnalyzeCommand(%q) = %#v, want interactive=%v destructive=%v network=%v localServer=%v tooComplex=%v",
tc.script, got, tc.interactive, tc.destructive, tc.network, tc.localServer, tc.tooComplex)
}
})
}
@@ -122,3 +150,109 @@ func TestAnalyzeCommandEmptyIsClean(t *testing.T) {
t.Fatalf("empty script should be clean, got %#v", got)
}
}
// A FLAGGED INVOCATION CLASSIFIES LIKE ITS UNFLAGGED SELF, AT ANY ARITY.
//
// Skipping flags is not enough, because it does not skip the words they CONSUME:
// `npm --prefix ./web install` resolved to "./web", the option's VALUE, and the
// invocation stopped being recognised at all — so a command genuinely fetching
// from the network lost the approval its plain spelling gets.
//
// The first fix offered a fixed window of two positions, which covered exactly
// ONE value-taking option. A second one walked straight past it. All four of
// these were measured wrong before the window was replaced:
//
// npm --prefix ./web --loglevel warn install
// npm --registry https://r.test --prefix ./web install
// pnpm -C ./web --filter web add left-pad
// yarn --cwd ./web --verbose install
//
// Adjacency decides now — an option can only eat the word beside it — so the
// arity is counted rather than assumed, and no option is ever named. Pairing
// each flagged form against its unflagged baseline is what makes that a property
// rather than a list of examples: a change that shifts BOTH still fails.
func TestAFlaggedInvocationClassifiesLikeItsUnflaggedSelf(t *testing.T) {
for _, pair := range []struct{ bare, flagged string }{
{"npm install", "npm --prefix ./web --loglevel warn install"},
{"npm install", "npm --registry https://r.test --prefix ./web install"},
{"npm install", "npm --prefix=./web --loglevel=warn install"},
{"npm install", "npm -w a -w b --loglevel warn install"},
{"npm run dev", "npm --prefix ./web --loglevel warn run dev"},
{"npm run deploy", "npm --prefix ./web --loglevel warn run deploy"},
{"pnpm add left-pad", "pnpm -C ./web --filter web add left-pad"},
{"yarn install", "yarn --cwd ./web --verbose install"},
} {
bare, flagged := AnalyzeCommand(pair.bare), AnalyzeCommand(pair.flagged)
if bare.Network != flagged.Network {
t.Errorf("%q needs network=%v but %q reports %v: an option value was read as the subcommand",
pair.bare, bare.Network, pair.flagged, flagged.Network)
}
if bare.LocalServer != flagged.LocalServer {
t.Errorf("%q localServer=%v but %q reports %v", pair.bare, bare.LocalServer, pair.flagged, flagged.LocalServer)
}
}
}
// AND THE WINDOW STILL CLOSES ON AN UNFLAGGED OPERAND. Widening it by arity must
// not reach words that belong to something else: "dev" and "start" here are
// arguments to a script, not subcommands. They are safe for a structural reason
// rather than a lucky one — the FIRST operand is unflagged, so it cannot be any
// option's value and the window is one position wide however many flags follow.
func TestAnUnflaggedFirstOperandClosesTheSubcommandWindow(t *testing.T) {
for _, command := range []string{
"npm run build --workspace dev",
"npm run test -- --grep start",
"npm run build --filter start",
} {
if got := AnalyzeCommand(command); got.LocalServer {
t.Errorf("%q read an argument as a dev server", command)
}
}
// THE RUN HAS TO BE CONTIGUOUS, not merely a count of flags anywhere in the
// line. Counting every flag would let TRAILING options widen the window back
// over words they have nothing to do with, and these three flip to false
// positives the moment it does — measured:
//
// npm run --grep foo --tag dev -> network AND localServer
// npm run test --reporter x --filter dev -> network
// npm run build --a x --b start -> network
//
// In each, the first operand is unflagged, so nothing after it can be an
// option's value however many options follow.
for _, command := range []string{
"npm run --grep foo --tag dev",
"npm run test --reporter x --filter dev",
"npm run build --a x --b start",
} {
if got := AnalyzeCommand(command); got.Network || got.LocalServer {
t.Errorf("%q was widened past its unflagged first operand: network=%v localServer=%v",
command, got.Network, got.LocalServer)
}
}
}
// A DIRECTORY NAMED dev OR start IS READ AS A SCRIPT NAME, and that is pinned
// rather than fixed. The ambiguity is the point of the window: with `npm --prefix
// dev install` the analyzer genuinely cannot tell whether "dev" is the option's
// value or the subcommand, so it offers both readings and takes the union.
//
// It costs nothing here. Network is already true and correct — the install
// really does fetch — and LocalServer only implies Network, so the union is
// conservative in the safe direction. Removing the ambiguity would mean naming
// which options take values, which is the enumeration this design exists to
// avoid.
func TestADirectoryNamedLikeAScriptIsReadConservatively(t *testing.T) {
for _, command := range []string{
"npm --prefix dev install",
"npm --prefix start install",
} {
got := AnalyzeCommand(command)
if !got.Network {
t.Errorf("%q is an install and must still need network", command)
}
if !got.LocalServer {
t.Errorf("%q no longer takes the conservative reading; if that is deliberate, update this test", command)
}
}
}
+5 -5
View File
@@ -396,13 +396,13 @@ func (engine *Engine) Evaluate(ctx context.Context, request Request) Decision {
}
netMode := engine.effectiveNetworkMode(policy)
if netMode == NetworkDeny && HasRiskCategory(risk, "network") && !engine.toolNetworkExempt(request) {
if request.SideEffect == SideEffectShell && request.PermissionMode != PermissionUnsafe {
if request.SideEffect == SideEffectShell && request.PermissionMode != PermissionFullAuto {
return Decision{Action: ActionPrompt, Risk: risk, Reason: ReasonNetworkBlocked}
}
return deny(request, risk, BlockNetwork, "", ReasonNetworkBlocked, false)
}
if HasRiskCategory(risk, "destructive") {
if request.SideEffect == SideEffectShell && !request.PermissionGranted && request.PermissionMode != PermissionUnsafe {
if request.SideEffect == SideEffectShell && !request.PermissionGranted && request.PermissionMode != PermissionFullAuto {
return Decision{Action: ActionPrompt, Risk: risk, Reason: "destructive shell command requires approval"}
}
if request.SideEffect == SideEffectShell && !request.PermissionGranted {
@@ -410,7 +410,7 @@ func (engine *Engine) Evaluate(ctx context.Context, request Request) Decision {
}
}
if request.SideEffect == SideEffectShell && requestRequiresEscalatedSandbox(request) {
if !request.PermissionGranted && request.PermissionMode != PermissionUnsafe {
if !request.PermissionGranted && request.PermissionMode != PermissionFullAuto {
return Decision{Action: ActionPrompt, Risk: risk, Reason: ReasonEscalatedSandboxRequired}
}
// Unsafe mode may auto-allow ordinary shell prompts, but it must not
@@ -453,7 +453,7 @@ func (engine *Engine) Evaluate(ctx context.Context, request Request) Decision {
if request.SideEffect == SideEffectShell && engine.shellSandboxActive(policy) {
return Decision{Action: ActionAllow, Risk: risk, Reason: "auto-allowed: sandbox is active for this shell command", AutoAllowed: true}
}
if request.PermissionGranted || request.PermissionMode == PermissionUnsafe {
if request.PermissionGranted || request.PermissionMode == PermissionFullAuto {
return Decision{Action: ActionAllow, Risk: risk, Reason: permissionReason(request)}
}
return Decision{Action: ActionPrompt, Risk: risk, Reason: permissionReason(request)}
@@ -534,7 +534,7 @@ func promptablePathBlock(request Request, block *pathBlock) bool {
if block == nil || block.Code != BlockOutsideWorkspace {
return false
}
if request.PermissionMode == PermissionUnsafe {
if request.PermissionMode == PermissionFullAuto {
return false
}
if request.ToolName == "apply_patch" {
+46 -9
View File
@@ -176,7 +176,7 @@ func TestEngineEvaluatesReadPromptAndPersistentDecisions(t *testing.T) {
ToolName: "write_file",
SideEffect: SideEffectWrite,
Permission: PermissionPrompt,
PermissionMode: PermissionUnsafe,
PermissionMode: PermissionFullAuto,
Args: map[string]any{"path": "notes.txt"},
})
if write.Action != ActionDeny || !write.GrantMatched || write.Block == nil || write.Block.Code != BlockPersistentDeny {
@@ -225,7 +225,7 @@ func TestEngineGrantScopesToFileAndDirectory(t *testing.T) {
t.Fatalf("engine.Grant dir deny: %v", err)
}
denied := writeReq(filepath.Join("secrets", "creds.txt"))
denied.PermissionMode = PermissionUnsafe
denied.PermissionMode = PermissionFullAuto
if d := engine.Evaluate(context.Background(), denied); d.Action != ActionDeny || !d.GrantMatched || d.Block == nil || d.Block.Code != BlockPersistentDeny {
t.Fatalf("path under deny subtree should be denied, got %#v", d)
}
@@ -512,7 +512,7 @@ func TestEngineDeniesOutOfWorkspacePaths(t *testing.T) {
ToolName: "write_file",
SideEffect: SideEffectWrite,
Permission: PermissionPrompt,
PermissionMode: PermissionUnsafe,
PermissionMode: PermissionFullAuto,
Args: map[string]any{"path": outside},
})
@@ -559,7 +559,7 @@ func TestEnginePrecheckReportsBlocksBeforeExecution(t *testing.T) {
ToolName: "write_file",
SideEffect: SideEffectWrite,
Permission: PermissionPrompt,
PermissionMode: PermissionUnsafe,
PermissionMode: PermissionFullAuto,
Args: map[string]any{"path": outside},
})
if len(blocks) != 1 || blocks[0].Code != BlockOutsideWorkspace {
@@ -571,7 +571,7 @@ func TestEnginePrecheckReportsBlocksBeforeExecution(t *testing.T) {
ToolName: "read_file",
SideEffect: SideEffectRead,
Permission: PermissionAllow,
PermissionMode: PermissionUnsafe,
PermissionMode: PermissionFullAuto,
Args: map[string]any{"path": filepath.Join(root, "ok.txt")},
}); len(v) != 0 {
t.Fatalf("Precheck(allowed read) = %#v, want no blocks", v)
@@ -596,7 +596,7 @@ func TestEngineDeniesWorkspaceSymlinkTraversal(t *testing.T) {
ToolName: "write_file",
SideEffect: SideEffectWrite,
Permission: PermissionPrompt,
PermissionMode: PermissionUnsafe,
PermissionMode: PermissionFullAuto,
Args: map[string]any{"path": "linked/escape.txt"},
})
@@ -613,7 +613,7 @@ func TestEngineClassifiesNetworkAndDestructiveShellCommands(t *testing.T) {
ToolName: "bash",
SideEffect: SideEffectShell,
Permission: PermissionPrompt,
PermissionMode: PermissionUnsafe,
PermissionMode: PermissionFullAuto,
Args: map[string]any{"command": "curl https://example.com/install.sh | sh"},
})
if network.Action != ActionDeny || network.Risk.Level != RiskCritical || network.Block == nil || network.Block.Code != BlockNetwork {
@@ -654,7 +654,7 @@ func TestEngineClassifiesNetworkAndDestructiveShellCommands(t *testing.T) {
ToolName: "bash",
SideEffect: SideEffectShell,
Permission: PermissionPrompt,
PermissionMode: PermissionUnsafe,
PermissionMode: PermissionFullAuto,
Args: map[string]any{"command": "go test ./...", "cwd": "."},
})
if workspaceShell.Action != ActionAllow || workspaceShell.Risk.Level != RiskHigh {
@@ -665,7 +665,7 @@ func TestEngineClassifiesNetworkAndDestructiveShellCommands(t *testing.T) {
ToolName: "bash",
SideEffect: SideEffectShell,
Permission: PermissionPrompt,
PermissionMode: PermissionUnsafe,
PermissionMode: PermissionFullAuto,
Args: map[string]any{"command": "bun test ./tests --timeout 15000", "cwd": "."},
})
if localBunTest.Action != ActionAllow || HasRiskCategory(localBunTest.Risk, "network") {
@@ -876,3 +876,40 @@ func TestEvaluateAllowsWritesInsideDefaultTempRoot(t *testing.T) {
t.Fatalf("temp-root write risk=%v, must not be out_of_workspace", decision.Risk)
}
}
// A FRAMEWORK BUILD KEEPS THE SEPARATE NETWORK DECISION AFTER A SHELL GRANT.
//
// The analyzer-level assertion says Network=true; this pins the consumer that
// matters. With bash allowed for ordinary commands, `next build` and its
// siblings must still come back as the network prompt, exactly as `curl` does,
// because on the platforms where the approval gate is the network boundary this
// prompt is the only thing between an ordinary grant and egress from build-time
// repository code. Reported by gnanam1990.
func TestEngineShellGrantDoesNotRunAFrameworkBuildWithoutTheNetworkDecision(t *testing.T) {
store, err := NewGrantStore(StoreOptions{
FilePath: filepath.Join(t.TempDir(), "sandbox-grants.json"),
Now: fixedSandboxTime("2026-09-12T06:00:00Z"),
})
if err != nil {
t.Fatalf("NewGrantStore returned error: %v", err)
}
if _, err := store.Grant(GrantInput{ToolName: "bash", Decision: GrantAllow, Reason: "regular commands"}); err != nil {
t.Fatalf("Grant bash allow returned error: %v", err)
}
engine := NewEngine(EngineOptions{WorkspaceRoot: t.TempDir(), Policy: DefaultPolicy(), Store: store})
for _, command := range []string{"next build", "vite build --mode production", "nuxt generate", "astro check"} {
decision := engine.Evaluate(context.Background(), Request{
ToolName: "bash",
SideEffect: SideEffectShell,
Permission: PermissionPrompt,
PermissionMode: PermissionModeAsk,
Args: map[string]any{"command": command},
})
if decision.Action != ActionPrompt || decision.Reason != ReasonNetworkBlocked {
t.Errorf("%q with a bash allow grant = %#v, want the network prompt", command, decision)
}
if decision.GrantMatched {
t.Errorf("%q: the network prompt must not report the bash allow grant as matched: %#v", command, decision)
}
}
}
-35
View File
@@ -1,35 +0,0 @@
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)
}
+10 -2
View File
@@ -5,13 +5,21 @@ import (
"strings"
)
// legacyFullAutoPermissionMode is what full-auto used to be called on disk.
//
// Accepted permanently rather than migrated: this value lives in user configs
// and in scripts, and a session that silently fell back to auto because its
// saved mode no longer parsed would be a confusing downgrade rather than a
// visible error.
const legacyFullAutoPermissionMode PermissionMode = "unsafe"
func NormalizePermissionMode(value PermissionMode) PermissionMode {
normalized := PermissionMode(strings.ToLower(strings.TrimSpace(string(value))))
switch normalized {
case PermissionModeAsk:
return PermissionModeAsk
case PermissionUnsafe:
return PermissionUnsafe
case PermissionFullAuto, legacyFullAutoPermissionMode:
return PermissionFullAuto
default:
return PermissionModeAuto
}
+34 -1
View File
@@ -9,6 +9,18 @@ import (
"strings"
)
// leadingOptionsPattern spans the global options a package manager accepts
// before its subcommand, including the word an option consumes as its value:
// `npm --prefix ./web run dev` and `pnpm -C ./web run dev` are documented usage,
// and without this the fallback only ever saw the unflagged spelling.
//
// The value is optional on purpose, so both readings of `--flag word` are
// covered. That over-matches — `npm --prefix run` matches too — which is the
// direction this fallback is already documented to favour: it runs only after
// the command was declared too complex to parse, where catching an obvious
// network program matters more than proving exact shell syntax.
const leadingOptionsPattern = `(\s+-{1,2}\S+(\s+\S+)?)*`
var (
// destructiveCommandPattern matches the highest-risk shell forms:
// - rm -rf (with combined/reordered r/f flags) targeting /, $HOME (bare,
@@ -33,7 +45,28 @@ var (
// unparseableNetworkPattern is used only after the shell parser fails. At
// that point the command is already marked too complex, so this intentionally
// favors catching obvious network programs over proving exact shell syntax.
unparseableNetworkPattern = regexp.MustCompile(`(?i)\b(curl|wget|fetch|aria2c|ssh|scp|sftp|rsync|nc|ncat|netcat|telnet|ftp|npx|http-server|vite|next|nuxt|astro)\b|\b(npm|pnpm|yarn|bun|pip|pip2|pip3)\s+(install|add|publish|login|start|serve|dev|preview|run\s+(start|serve|dev|preview)|exec|x|dlx)\b|\bgo\s+get\b|\bgit\s+clone\b|\bpython(2|3)?\s+-m\s+(http\.server|pip\s+install)\b|\bgh\s+(api|repo\s+clone|release\s+download)\b`)
//
// IT MUST BE A SUPERSET OF WHAT THE AST PATH FLAGS FOR NETWORK, not a mirror
// of the analyzer's category names. The serving forms were dropped from here
// on the reasoning that they bind rather than fetch, but the analyzer sets
// Network for every recognized LocalServer anyway, deliberately and for two
// reasons it documents: nothing consumes LocalServer yet, so the approval IS
// the protection, and `npm run dev` is matched by SCRIPT NAME, where the
// repository decides what `dev` and `predev` actually do and either can reach
// out before a port is bound.
//
// So dropping them here did not align the two paths, it split them. The POSIX
// parser rejects Windows shell syntax the invoked shell accepts, and the
// analyzer names that as a fallback case, so:
//
// if "%OS%"=="Windows_NT" (npm run dev) else (npm start)
// -> parsed=false network=false categories=[shell unparseable_command]
//
// while every parseable spelling of the same command gets network. That is
// the approval gate disappearing on exactly the platform where it is the only
// egress control. Restored until a runner actually provides a scoped listener
// capability and the AST path stops setting Network for these itself.
unparseableNetworkPattern = regexp.MustCompile(`(?i)\b(curl|wget|fetch|aria2c|ssh|scp|sftp|rsync|nc|ncat|netcat|telnet|ftp|npx)\b|\b(npm|pnpm|yarn|bun|pip|pip2|pip3)` + leadingOptionsPattern + `\s+(install|add|publish|login|exec|x|dlx)\b|\b(npm|pnpm|yarn|bun)` + leadingOptionsPattern + `\s+(run|dev|serve|start|preview)\b|\b(http-server|serve|vite|next|nuxt|astro)\b|\b(python(2|3)?|py)\s+-m\s+http\.server\b|\bgo\s+get\b|\bgit\s+clone\b|\b(python(2|3)?|py)\s+-m\s+pip\s+install\b|\bgh\s+(api|repo\s+clone|release\s+download)\b`)
// destructiveExtraPatterns hold high-severity patterns that the legacy
// destructiveCommandPattern does not already cover. Folded in from the
// blueprint safe_bash.go without duplicating existing matches.
+1 -12
View File
@@ -189,18 +189,7 @@ func (engine *Engine) BuildCommandPlan(spec CommandSpec) (CommandPlan, error) {
}
var runtimeCleanup func()
if preference != SandboxPreferenceForbid && policy.Mode != ModeDisabled {
// 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)
runtimeState, cleanup, runtimeErr := prepareSandboxRuntime(workspaceRoot)
if runtimeErr != nil {
return CommandPlan{}, runtimeErr
}
@@ -393,11 +393,10 @@ func realSmokeExecutable(t *testing.T, envKey string, fallbackName string) strin
func runWindowsRealSmokeSetup(t *testing.T, setupExe string, options WindowsSandboxSetupArgsOptions) {
t.Helper()
setupPlan, err := BuildWindowsSandboxSetupArgs(options)
args, 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...)
@@ -407,30 +406,8 @@ 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 {
@@ -456,8 +433,6 @@ 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 {
@@ -1,88 +0,0 @@
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)
}
}
@@ -1,97 +0,0 @@
//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)
}
}
@@ -1,112 +0,0 @@
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)
}
}
@@ -1,67 +0,0 @@
//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
}
@@ -1,74 +0,0 @@
//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)
}
}
@@ -1,100 +0,0 @@
//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)
}
}
@@ -1,78 +0,0 @@
//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)
}
})
}
@@ -1,195 +0,0 @@
//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
@@ -1,15 +0,0 @@
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
@@ -1,15 +0,0 @@
//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
@@ -1,131 +0,0 @@
//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
}
@@ -1,158 +0,0 @@
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
@@ -1,24 +0,0 @@
//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
@@ -1,40 +0,0 @@
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
@@ -1,171 +0,0 @@
//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
@@ -1,124 +0,0 @@
//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
}
@@ -1,93 +0,0 @@
//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)
}
}
}
@@ -1,27 +0,0 @@
//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
}
@@ -1,45 +0,0 @@
//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
}
@@ -1,44 +0,0 @@
//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)
}
}
@@ -1,106 +0,0 @@
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)
}
}
@@ -1,74 +0,0 @@
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)
}
}
+7 -64
View File
@@ -1,88 +1,31 @@
package sandbox
import (
"errors"
"fmt"
"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
}
// 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)
}
func acquireSandboxRuntimeLease(root string) (*sandboxRuntimeLease, error) {
handle, err := acquireSharedRuntimeLease(sandboxRuntimeLeasePath(root))
if err != nil {
return nil, fmt.Errorf("acquire sandbox runtime lease: %w", err)
}
return existing
return &sandboxRuntimeLease{handle: handle}, nil
}
func tryAcquireSandboxRuntimeCleanupLease(root string) (*sandboxRuntimeLease, bool, error) {
handle, inUse, err := tryAcquireExclusiveRuntimeLease(root)
handle, inUse, err := tryAcquireExclusiveRuntimeLease(sandboxRuntimeLeasePath(root))
if err != nil || inUse {
return nil, inUse, err
}
-15
View File
@@ -1,15 +0,0 @@
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()
@@ -1,150 +0,0 @@
//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))
}
}
@@ -1,216 +0,0 @@
//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()
}
@@ -1,93 +0,0 @@
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
}
@@ -1,21 +0,0 @@
//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)
}
@@ -1,10 +0,0 @@
//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)
}
@@ -1,299 +0,0 @@
//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)
}
@@ -1,282 +0,0 @@
//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
}
}
@@ -1,408 +0,0 @@
//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
+26 -9
View File
@@ -3,6 +3,7 @@
package sandbox
import (
"errors"
"os"
"golang.org/x/sys/unix"
@@ -12,15 +13,31 @@ type runtimeLeaseHandle struct {
file *os.File
}
// 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 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
}
func (lease runtimeLeaseHandle) release() {
@@ -1,95 +0,0 @@
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()
}
+29 -13
View File
@@ -3,6 +3,7 @@
package sandbox
import (
"errors"
"os"
"golang.org/x/sys/windows"
@@ -13,19 +14,34 @@ type runtimeLeaseHandle struct {
overlapped windows.Overlapped
}
// 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 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
}
func (lease runtimeLeaseHandle) release() {
@@ -1,49 +0,0 @@
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
@@ -1,15 +0,0 @@
//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)
}
@@ -1,116 +0,0 @@
//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, `\\?\`)
}
@@ -1,208 +0,0 @@
//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)
}
}
@@ -1,134 +0,0 @@
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)
}
}
@@ -1,115 +0,0 @@
//go:build windows
package sandbox
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
// EXISTENCE MUST NOT CHOOSE THE TRUST BOUNDARY.
//
// createRuntimeDirRecording used to os.Stat its way to the deepest component
// that already existed and open THAT by name. On the ordinary second-workspace
// shape <base>\zero\runtime\v1 is already there and only the digest is missing,
// so the single by-name open was v1 — one of the predictable, user-owned
// components the rooted traversal exists to protect, not the cache directory
// above the tail.
//
// The race that opens: the pre-check accepts an ordinary v1, os.Stat selects it,
// the local owner replaces it with a junction, the by-name open follows it, and
// elevated setup creates the digest beneath the redirected target. Putting the
// original back makes the post-check see an ordinary pathname again, so the
// creation record names the redirected object under the original path and
// compensation leaves a privileged creation as residue.
//
// This drives the PRODUCTION entry point rather than the descent helper, because
// the helper is handed a base that has already been chosen — which is precisely
// the decision under test. A junction needs no privilege on Windows, so this
// runs unelevated.
func TestRuntimeRecordingOpensTheFixedBaseNotTheDeepestExistingComponent(t *testing.T) {
cacheBase := t.TempDir()
target := t.TempDir()
// The shape a second workspace finds: everything above the digest already
// exists, so the old walk would have picked v1 as its by-name base.
v1 := filepath.Join(cacheBase, "zero", "runtime", "v1")
if err := os.MkdirAll(v1, 0o755); err != nil {
t.Fatal(err)
}
root := filepath.Join(v1, "deadbeefdeadbeef")
// Sanity: the helper agrees the fixed base is the cache directory, not v1.
base, components, owned := windowsSandboxRuntimeOwnedTail(root)
if !owned || base != cacheBase {
t.Fatalf("SETUP INVALID: owned tail resolved base=%q owned=%v, want %q", base, owned, cacheBase)
}
if len(components) == 0 {
t.Fatal("SETUP INVALID: the owned tail has no components")
}
// THE PROPERTY, OBSERVED DIRECTLY. Whether a particular swap is caught depends
// on where the barrier sits; which path is opened by name does not, and that is
// the finding. Recorded here so the assertion cannot pass for an unrelated
// reason.
var openedByName []string
previousOpen := runtimeBaseOpenedByName
runtimeBaseOpenedByName = func(path string) { openedByName = append(openedByName, path) }
t.Cleanup(func() { runtimeBaseOpenedByName = previousOpen })
swapped := false
previous := runtimeDescentBarrier
runtimeDescentBarrier = func() {
// Fires after the base has been opened and before the first owned
// component is touched: exactly the interval the old walk was vulnerable
// in. Replace v1 with a junction into a directory the test watches.
if err := os.Remove(v1); err != nil {
t.Fatalf("SETUP INVALID: could not clear v1 to plant the junction: %v", err)
}
out, err := exec.Command("cmd", "/c", "mklink", "/J", v1, target).CombinedOutput()
if err != nil {
t.Fatalf("SETUP INVALID: mklink /J: %v\n%s", err, out)
}
swapped = true
}
t.Cleanup(func() { runtimeDescentBarrier = previous })
created, err := createRuntimeDirRecording(root)
if !swapped {
t.Fatal("SETUP INVALID: the barrier never ran, so no swap was attempted")
}
// Exactly one by-name open, and it is the cache directory above the tail.
// Under the old walk this was v1, a component the local user controls.
if len(openedByName) != 1 {
t.Fatalf("the descent opened %d paths by name, want exactly 1: %v", len(openedByName), openedByName)
}
if !strings.EqualFold(openedByName[0], cacheBase) {
t.Fatalf("opened %q by name, want the fixed base %q: existence must not choose the trust boundary", openedByName[0], cacheBase)
}
if err == nil {
t.Fatalf("the recording walked through a junction at an owned component and reported success: created=%v", created)
}
// The redirected target must be untouched: no digest created beneath it.
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("elevated setup created %v beneath the redirected target", names)
}
// And nothing may be recorded as ours under a pathname that now names
// somebody else's object, since that is what compensation acts on.
for _, record := range created {
if strings.HasPrefix(strings.ToLower(record.path), strings.ToLower(v1)) {
t.Errorf("a redirected creation was recorded as ours: %+v", record)
}
}
}
-180
View File
@@ -1,180 +0,0 @@
package sandbox
import (
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
)
// aliasTo returns a second path that IS target, spelled in a way
// canonicalSandboxWorkspaceRoot does not fold, or "" when this platform offers
// none.
//
// A plain symlink is no good: EvalSymlinks folds it, so the spelling comparison
// already wins and nothing downstream is exercised. The two aliases that survive
// canonicalization are a Windows directory junction, which needs no privilege,
// and an upper-cased spelling on a case-insensitive volume, which is the macOS
// default.
func aliasTo(t *testing.T, target string) string {
t.Helper()
if runtime.GOOS == "windows" {
link := filepath.Join(t.TempDir(), "alias")
if out, err := exec.Command("cmd", "/c", "mklink", "/J", link, target).CombinedOutput(); err != nil {
t.Logf("mklink /J unavailable: %v %s", err, out)
return ""
}
// A junction that did not actually land on the target proves nothing.
targetInfo, err := os.Stat(target)
if err != nil {
t.Fatalf("stat target: %v", err)
}
linkInfo, err := os.Stat(link)
if err != nil || !os.SameFile(targetInfo, linkInfo) {
t.Fatalf("mklink reported success but %s is not %s", link, target)
}
return link
}
upper := strings.ToUpper(target)
if upper == target {
return ""
}
targetInfo, err := os.Stat(target)
if err != nil {
t.Fatalf("stat target: %v", err)
}
upperInfo, err := os.Stat(upper)
if err != nil || !os.SameFile(targetInfo, upperInfo) {
// Case-sensitive volume: the upper-cased name is a different directory, or
// none at all. Correctly not an alias.
return ""
}
return upper
}
// TestRuntimeRootRefusesAWorkspaceReachedByAnAlias is the regression for the gap
// the macOS Smoke run exposed and the junction gap found alongside it.
// canonicalSandboxWorkspaceRoot folds the aliases EvalSymlinks folds and no
// others, so a runtime root that reaches the workspace under a junction, or under
// a different casing on a case-insensitive volume, measured as OUTSIDE the
// workspace and the runtime tree was allowed to live inside the tree the sandbox
// exists to confine.
//
// Both alias shapes are covered on purpose. An alias whose target IS the
// workspace root is caught by the identity walk; an alias into a SUBDIRECTORY is
// not, because the walk climbs a spelling and a junction has no spelling chain
// back into its target's parent. Only the physical-path resolution catches that
// one, and a test that exercised the root shape alone reported green while it was
// broken.
func TestRuntimeRootRefusesAWorkspaceReachedByAnAlias(t *testing.T) {
for _, shape := range []struct {
name string
suffix []string
}{
{name: "alias to the workspace root"},
{name: "alias into a workspace subdirectory", suffix: []string{"build", "tmp"}},
} {
t.Run(shape.name, func(t *testing.T) {
workspaceRoot := canonicalSandboxWorkspaceRoot(filepath.Join(t.TempDir(), "workspace"))
target := filepath.Join(append([]string{workspaceRoot}, shape.suffix...)...)
if err := os.MkdirAll(target, 0o700); err != nil {
t.Fatalf("create target: %v", err)
}
alias := aliasTo(t, target)
if alias == "" {
t.Skip("no alias spelling is constructible here")
}
// Precondition, asserted rather than assumed: the plain spelling
// comparison has to MISS. If canonicalization folds this alias the old
// code already handled it and the test proves nothing.
probe := filepath.Join(canonicalSandboxWorkspaceRoot(alias), "zero", "runtime", "v1", "0123456789abcdef")
if pathWithinRoot(workspaceRoot, probe) {
t.Skipf("canonicalization already folds %s into %s", alias, workspaceRoot)
}
t.Setenv("TMP", alias)
t.Setenv("TEMP", alias)
t.Setenv("TMPDIR", alias)
root, err := fallbackSandboxRuntimeRoot(workspaceRoot)
if err == nil {
t.Fatalf("fallback returned runtime root %s for a TEMP that reaches %s through %s; the sandbox would keep its own cache inside the tree it is confining", root, target, alias)
}
if !strings.Contains(err.Error(), "inside workspace") {
t.Fatalf("fallback refused for the wrong reason: %v", err)
}
})
}
}
// TestDeterministicRuntimeRootRejectsAnAliasedCache covers the other call site.
// Reverting only deterministicSandboxRuntimeRoot left the whole package green
// before this existed, so the cache-derived root had no alias coverage at all.
func TestDeterministicRuntimeRootRejectsAnAliasedCache(t *testing.T) {
workspaceRoot := canonicalSandboxWorkspaceRoot(filepath.Join(t.TempDir(), "workspace"))
inner := filepath.Join(workspaceRoot, "cachehome")
if err := os.MkdirAll(inner, 0o700); err != nil {
t.Fatalf("create target: %v", err)
}
// The derived root is <cache>/zero/runtime/v1/<hash>, so aliasing <cache>/zero
// is what puts the whole tree inside the workspace.
cacheRoot := filepath.Join(t.TempDir(), "cache")
if err := os.MkdirAll(cacheRoot, 0o700); err != nil {
t.Fatalf("create cache: %v", err)
}
// A junction on Windows, a symlink elsewhere. Both point at inner by its REAL
// spelling on purpose. Stacking a case alias on top would build the one shape
// the doc on runtimeRootWithinWorkspace says stays open off Windows, an alias
// into a workspace SUBDIRECTORY that no spelling chain reaches, and the test
// would then be asserting a guarantee macOS does not make.
link := filepath.Join(cacheRoot, "zero")
if runtime.GOOS == "windows" {
if out, err := exec.Command("cmd", "/c", "mklink", "/J", link, inner).CombinedOutput(); err != nil {
t.Skipf("mklink /J unavailable: %v %s", err, out)
}
} else if err := os.Symlink(inner, link); err != nil {
t.Skipf("symlink unavailable: %v", err)
}
innerInfo, err := os.Stat(inner)
if err != nil {
t.Fatalf("stat target: %v", err)
}
linkInfo, err := os.Stat(link)
if err != nil || !os.SameFile(innerInfo, linkInfo) {
t.Fatalf("%s is not an alias of %s", link, inner)
}
root, usableOutside := deterministicSandboxRuntimeRoot(workspaceRoot, cacheRoot)
if usableOutside {
t.Fatalf("cache-derived runtime root %s reported as outside %s while it resolves inside it", root, workspaceRoot)
}
}
// TestRuntimeRootWithinWorkspaceKeepsAGenuinelyOutsideRootUsable is the other
// half. The three checks only ever ADD containment answers, so the one thing that
// could go wrong is reporting containment for a root that is merely adjacent.
func TestRuntimeRootWithinWorkspaceKeepsAGenuinelyOutsideRootUsable(t *testing.T) {
parent := t.TempDir()
workspaceRoot := canonicalSandboxWorkspaceRoot(filepath.Join(parent, "workspace"))
sibling := filepath.Join(parent, "workspace-runtime", "zero", "runtime", "v1", "0123456789abcdef")
if err := os.MkdirAll(workspaceRoot, 0o700); err != nil {
t.Fatalf("create workspace: %v", err)
}
if err := os.MkdirAll(sibling, 0o700); err != nil {
t.Fatalf("create sibling: %v", err)
}
if runtimeRootWithinWorkspace(workspaceRoot, sibling) {
t.Fatalf("%s reported as inside %s; a sibling sharing a name prefix is not contained", sibling, workspaceRoot)
}
if _, usableOutside := deterministicSandboxRuntimeRoot(workspaceRoot, filepath.Join(parent, "cache")); !usableOutside {
t.Fatalf("a cache root outside the workspace was reported unusable")
}
}
-100
View File
@@ -1,100 +0,0 @@
package sandbox
import (
"errors"
"fmt"
"os"
"path/filepath"
)
// errRuntimeComponentAliased marks a refusal that must NOT be treated as a
// reason to relocate.
//
// selectSandboxRuntimeRoot falls back to the temp root when the preferred one
// cannot be leased, which is right for an unusable directory and wrong for a
// hostile one: relocating would leave the attacker's link in place, say nothing,
// and simply move to the next predictable name for them to take as well. A
// machine in this state needs an operator, not a retry.
var errRuntimeComponentAliased = errors.New("sandbox runtime component is aliased")
// THE RUNTIME TREE IS CREATED IN A DIRECTORY OTHER PEOPLE CAN WRITE TO.
//
// The fallback root used to come from os.MkdirTemp, which mints a random name
// atomically at mode 0700, so no other local user could name the directory, let
// alone pre-create it. Deriving it from a digest of the workspace path bought
// stable cache reuse across runs and gave that name away: every component is
// computable by anyone who can guess the workspace path, and on Linux
// os.TempDir() is the shared, world-writable /tmp whenever TMPDIR is unset.
//
// What follows from a name another user can create is not subtle.
// os.MkdirAll returns nil when Stat says the path is already a directory, and
// Stat FOLLOWS LINKS, so a link planted at the leaf is silently accepted; the
// cache, data and tmp directories are then created inside whatever it points at,
// os.Chmod and os.Chtimes follow it too, and the root is handed to the platform
// backend as a WRITE ROOT (a read-write bind under bwrap) with TMPDIR, GOCACHE,
// GOMODCACHE and the package-manager caches all pointed inside it. The sandbox
// would be granting the confined command write access to a directory an
// attacker chose.
//
// Two things close it, and both are needed. The path carries a per-user
// component so ordinary users are not sharing one tree, and every component Zero
// owns is verified to be a real directory belonging to this user before anything
// is created through it. The name alone is not enough: /tmp is world-writable,
// so another user can create the per-user directory FIRST and wait.
//
// This is the same rule refuseReparsedRuntimeAncestors applies during elevated
// Windows setup. That guard was never on this path, which is the shared one
// every platform takes for every command.
func refuseAliasedRuntimeComponents(root string) error {
for _, component := range ownedRuntimeComponents(root) {
info, err := os.Lstat(component)
if err != nil {
if os.IsNotExist(err) {
// Not there yet, so there is nothing to alias. The caller re-checks
// after creation, because this alone is a check-then-use.
continue
}
return fmt.Errorf("inspect sandbox runtime component %s: %w", component, err)
}
// ModeIrregular as well as ModeSymlink: a Windows junction is reported as
// irregular, needs no privilege to create, and a guard written against
// symlinks alone is inert against it.
if info.Mode()&(os.ModeSymlink|os.ModeIrregular) != 0 {
return fmt.Errorf("%w: refusing to use the sandbox runtime through a link at %s: "+
"a link here redirects the directory the sandbox is granted write access to", errRuntimeComponentAliased, component)
}
if !info.IsDir() {
// NOT tagged as aliased, deliberately. An ordinary file sitting where a
// runtime component belongs is a broken machine rather than a hostile
// one, and relocating to the other candidate is the sane recovery that
// was already there. Only a link or a directory belonging to somebody
// else says an attacker chose this path, and those are the two the
// caller must refuse outright rather than route around.
return fmt.Errorf("sandbox runtime component %s exists and is not a directory", component)
}
if err := refuseForeignRuntimeComponent(component, info); err != nil {
return err
}
}
return nil
}
// ownedRuntimeComponents lists the trailing components Zero creates, deepest
// first. Anything above them belongs to the user or the machine.
func ownedRuntimeComponents(root string) []string {
cleaned := filepath.Clean(root)
if cleaned == "" || cleaned == "." {
return nil
}
components := make([]string, 0, windowsSandboxRuntimeOwnedDepth)
current := cleaned
for range windowsSandboxRuntimeOwnedDepth {
components = append(components, current)
parent := filepath.Dir(current)
if parent == current {
break
}
current = parent
}
return components
}
@@ -1,16 +0,0 @@
package sandbox
import (
"crypto/sha256"
"encoding/hex"
"testing"
)
// digestFor recomputes the fallback leaf name the way fallbackSandboxRuntimeRoot
// does, so the test moves with the implementation rather than pinning a literal.
func digestFor(workspaceRoot string, scope string) string {
digest := sha256.Sum256([]byte(canonicalSandboxWorkspaceRoot(workspaceRoot) + "\x00" + scope))
return hex.EncodeToString(digest[:8])
}
var _ = testing.Verbose
@@ -1,16 +0,0 @@
//go:build !windows
package sandbox
import (
"os"
"testing"
)
// A POSIX symlink is the reachable alias off Windows.
func linkRuntimeComponent(t *testing.T, link, target string) {
t.Helper()
if err := os.Symlink(target, link); err != nil {
t.Skipf("cannot create a symlink in this environment: %v", err)
}
}
@@ -1,19 +0,0 @@
//go:build windows
package sandbox
import (
"os/exec"
"testing"
)
// A JUNCTION, not a symlink: it needs no privilege, which is what makes it the
// alias an ordinary local user can actually plant, and os.Lstat reports it as
// ModeIrregular rather than ModeSymlink.
func linkRuntimeComponent(t *testing.T, link, target string) {
t.Helper()
output, err := exec.Command("cmd", "/c", "mklink", "/J", link, target).CombinedOutput()
if err != nil {
t.Skipf("cannot create a junction in this environment: %v (%s)", err, output)
}
}
-208
View File
@@ -1,208 +0,0 @@
package sandbox
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
// A LINK AT AN OWNED COMPONENT MUST NOT BE BUILT THROUGH.
//
// This is the shared path every platform takes for every command, and until now
// it had no guard at all: the only link refusal in this package was wired into
// elevated Windows setup. 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 is accepted silently. The cache, data and tmp directories are then
// created inside whatever it points at, Chmod and Chtimes follow it too, and the
// root is handed to the backend as a WRITE ROOT with TMPDIR, GOCACHE and the
// package-manager caches pointed inside it. The sandbox would be granting the
// confined command write access to a directory somebody else chose.
func TestTheRuntimeGuardRefusesALinkAtEveryOwnedComponent(t *testing.T) {
for depth := range windowsSandboxRuntimeOwnedDepth {
t.Run("replaced "+string(rune('0'+depth))+" levels above the leaf", func(t *testing.T) {
base := t.TempDir()
root := filepath.Join(append([]string{base}, append(append([]string{}, windowsSandboxRuntimeOwnedNames...), "abcdef0123456789")...)...)
if err := os.MkdirAll(root, 0o700); err != nil {
t.Fatalf("create the runtime tree: %v", err)
}
swapped := root
for range depth {
swapped = filepath.Dir(swapped)
}
tail, err := filepath.Rel(swapped, root)
if err != nil {
t.Fatalf("relate the swapped component to the root: %v", err)
}
if err := os.RemoveAll(swapped); err != nil {
t.Fatalf("clear the component to replace: %v", err)
}
target := filepath.Join(t.TempDir(), "somewhere-else")
if err := os.MkdirAll(target, 0o700); err != nil {
t.Fatalf("create the link target: %v", err)
}
linkRuntimeComponent(t, swapped, target)
if tail != "." {
if err := os.MkdirAll(filepath.Join(target, tail), 0o700); err != nil {
t.Fatalf("recreate the components below the link: %v", err)
}
}
err = refuseAliasedRuntimeComponents(root)
if err == nil {
t.Fatalf("a link at an owned component was accepted; the runtime tree would have been built inside %s and granted to the sandbox as a write root", target)
}
// ASSERTED ON THE SENTINEL, not on a word in the message.
//
// This read strings.Contains(err.Error(), "link") and was vacuous:
// t.TempDir() names its directory after the test, the subtest was called
// "link N levels above the leaf", and every component path therefore
// contained "link". Deleting the reparse refusal left a different error
// ("exists and is not a directory") whose PATH still satisfied the
// assertion, so all four subtests passed with the fix removed.
//
// The sentinel is also the real contract: selectSandboxRuntimeRoot
// branches on errors.Is to decide refuse-versus-relocate.
if !errors.Is(err, errRuntimeComponentAliased) {
t.Errorf("a link was not reported as a hostile alias, so selection would relocate around it instead of refusing: %v", err)
}
if !strings.Contains(err.Error(), "redirects the directory") {
t.Errorf("the refusal does not name the reason: %v", err)
}
})
}
}
// An ordinary tree passes, or the guard above would be satisfied by one that
// refuses everything.
func TestTheRuntimeGuardAcceptsAnOrdinaryTree(t *testing.T) {
base := t.TempDir()
root := filepath.Join(append([]string{base}, append(append([]string{}, windowsSandboxRuntimeOwnedNames...), "abcdef0123456789")...)...)
if err := os.MkdirAll(root, 0o700); err != nil {
t.Fatalf("create the runtime tree: %v", err)
}
if err := refuseAliasedRuntimeComponents(root); err != nil {
t.Fatalf("an ordinary runtime tree was refused: %v", err)
}
}
// A tree that does not exist yet is fine: there is nothing to alias, and this is
// the ordinary first-run case.
func TestTheRuntimeGuardAcceptsATreeThatDoesNotExistYet(t *testing.T) {
base := t.TempDir()
root := filepath.Join(append([]string{base}, append(append([]string{}, windowsSandboxRuntimeOwnedNames...), "abcdef0123456789")...)...)
if err := refuseAliasedRuntimeComponents(root); err != nil {
t.Fatalf("an absent runtime tree was refused: %v", err)
}
}
// A FILE where an owned component belongs is refused too, rather than producing
// a confusing failure deeper in.
func TestTheRuntimeGuardRefusesAFileWhereADirectoryBelongs(t *testing.T) {
base := t.TempDir()
root := filepath.Join(append([]string{base}, append(append([]string{}, windowsSandboxRuntimeOwnedNames...), "abcdef0123456789")...)...)
if err := os.MkdirAll(filepath.Dir(root), 0o700); err != nil {
t.Fatalf("create the runtime parents: %v", err)
}
if err := os.WriteFile(root, []byte("not a directory"), 0o600); err != nil {
t.Fatalf("seed the file: %v", err)
}
if err := refuseAliasedRuntimeComponents(root); err == nil {
t.Fatal("a file standing where the runtime root belongs was accepted")
}
}
// THE SHARED-TEMP FALLBACK IS SCOPED TO THIS USER.
//
// It replaced os.MkdirTemp, which minted a random 0700 directory atomically, so
// no other local user could name it. A digest of the workspace path alone is the
// same string for every account on the host, and on Linux os.TempDir() is the
// world-writable /tmp whenever TMPDIR is unset: two users on the same path would
// name one directory and the first one there would own it.
func TestTheTempFallbackRootIsScopedToTheUser(t *testing.T) {
workspace := t.TempDir()
root, err := fallbackSandboxRuntimeRoot(canonicalSandboxWorkspaceRoot(workspace))
if err != nil {
t.Fatalf("fallbackSandboxRuntimeRoot: %v", err)
}
scope := sandboxRuntimeUserScope()
if strings.TrimSpace(scope) == "" {
t.Fatal("the user scope is empty, so the digest is the same for every account")
}
// The leaf must move when the scope does. Recomputed the way the function
// does, rather than asserting on a hardcoded digest.
same := digestFor(workspace, scope)
other := digestFor(workspace, scope+"-someone-else")
if same == other {
t.Fatal("the user scope does not reach the digest")
}
if !strings.HasSuffix(filepath.Clean(root), same) {
t.Errorf("the fallback root %s does not end in the user-scoped digest %s", root, same)
}
}
// And the fallback still has the shape the owned-component guard and the Windows
// rooted traversal both recognize. A path that stops matching silently loses
// BOTH protections, which is the expensive direction.
func TestTheTempFallbackRootKeepsTheOwnedShape(t *testing.T) {
workspace := t.TempDir()
root, err := fallbackSandboxRuntimeRoot(canonicalSandboxWorkspaceRoot(workspace))
if err != nil {
t.Fatalf("fallbackSandboxRuntimeRoot: %v", err)
}
if _, _, ok := windowsSandboxRuntimeOwnedTail(root); !ok {
t.Fatalf("the fallback root %s is not recognized as an owned runtime tail, so the rooted traversal falls back to opening it by name", root)
}
components := ownedRuntimeComponents(root)
if len(components) != windowsSandboxRuntimeOwnedDepth {
t.Fatalf("the guard walks %d components of %s, want %d", len(components), root, windowsSandboxRuntimeOwnedDepth)
}
}
// THROUGH prepareSandboxRuntime, not the helper.
//
// The helper being correct proves nothing on its own: the guard has to be on the
// path runner.go actually calls, before the MkdirAll that would build the tree
// through the link and before the root is handed back as a write root.
func TestPreparingTheRuntimeRefusesALinkedRoot(t *testing.T) {
workspace := t.TempDir()
cacheRoot := t.TempDir()
original := sandboxUserCacheDir
sandboxUserCacheDir = func() (string, error) { return cacheRoot, nil }
t.Cleanup(func() { sandboxUserCacheDir = original })
canonical := canonicalSandboxWorkspaceRoot(workspace)
root, err := sandboxRuntimeRootFor(canonical, canonicalSandboxWorkspaceRoot(cacheRoot))
if err != nil {
t.Skipf("no cache-derived runtime root in this environment: %v", err)
}
// Somebody else got to the predictable name first and pointed it elsewhere.
if err := os.MkdirAll(filepath.Dir(root), 0o700); err != nil {
t.Fatalf("create the runtime parents: %v", err)
}
target := filepath.Join(t.TempDir(), "somewhere-else")
if err := os.MkdirAll(target, 0o700); err != nil {
t.Fatalf("create the link target: %v", err)
}
linkRuntimeComponent(t, root, target)
runtimeState, cleanup, err := prepareSandboxRuntime(canonical, "")
if cleanup != nil {
cleanup()
}
if err == nil {
t.Fatalf("the runtime was prepared at %s through a link; %s would have been bound read-write into the sandbox with TMPDIR and the build caches inside it", runtimeState.Root, target)
}
if !errors.Is(err, errRuntimeComponentAliased) {
t.Errorf("the linked root was not reported as a hostile alias: %v", err)
}
for _, name := range []string{"cache", "data", "tmp"} {
if _, statErr := os.Stat(filepath.Join(target, name)); statErr == nil {
t.Errorf("the runtime tree was created inside the link target at %s", filepath.Join(target, name))
}
}
}
@@ -1,67 +0,0 @@
//go:build !windows
package sandbox
import (
"fmt"
"os"
"syscall"
)
// refuseForeignRuntimeComponent rejects a component owned by somebody else.
//
// The link check above stops the redirection; this stops the quieter half. /tmp
// is world-writable and sticky, so another local user can create the components
// Zero owns BEFORE Zero ever runs. A directory they own but Zero writes into is
// a place they can read the sandbox's caches out of, and the sticky bit does not
// help because they own it. os.MkdirAll accepts it silently, since the path
// already exists as a directory.
//
// Ownership rather than mode, because a 0777 directory belonging to this user is
// the user's own business while a 0700 directory belonging to another user is
// not something Zero should adopt.
func refuseForeignRuntimeComponent(component string, info os.FileInfo) error {
stat, ok := info.Sys().(*syscall.Stat_t)
if !ok {
// No ownership information on this filesystem. The link check already ran.
return nil
}
if uid := os.Getuid(); uid >= 0 && int(stat.Uid) != uid {
return fmt.Errorf("%w: refusing to use the sandbox runtime directory %s: it belongs to uid %d, not to this user (uid %d)",
errRuntimeComponentAliased, component, stat.Uid, uid)
}
return nil
}
// sandboxRuntimeUserScope isolates the derived runtime tree per user.
//
// Two users on one host derive the same digest for the same workspace path, so
// without this they name one directory in shared temp and the first one there
// owns it. A uid is not a secret and is not meant to be: it removes the
// collision, and refuseAliasedRuntimeComponents handles the case where somebody
// got there first.
func sandboxRuntimeUserScope() string {
return fmt.Sprintf("u%d", os.Getuid())
}
// sandboxRuntimeFallbackOwnedNames are the components the temp-derived runtime
// root is built from.
//
// THE USER BOUNDARY COMES FIRST, ABOVE EVERY PRIVATE COMPONENT. On Unix
// os.TempDir() is a SHARED directory whenever TMPDIR is unset, and runtime
// preparation creates and ownership-checks each of these components at 0700. A
// fixed first component therefore meant the first account to use the fallback
// created a private directory that 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, and the fallback became first-user-wins on a shared host.
//
// Scoping the FIRST component keeps every ownership-checked ancestor inside a
// namespace that already belongs to one user, which is the property the guards
// below assume. The workspace digest stays the leaf so setup and the command
// still derive the same path for the same workspace.
func sandboxRuntimeFallbackOwnedNames() []string {
names := append([]string(nil), windowsSandboxRuntimeOwnedNames...)
names[0] = names[0] + "-" + sandboxRuntimeUserScope()
return names
}
@@ -1,51 +0,0 @@
//go:build windows
package sandbox
import (
"os"
"strings"
)
// refuseForeignRuntimeComponent has no ownership check on Windows.
//
// The derived root lives under the per-user cache directory or the per-session
// TEMP, both of which are already user-private, and the elevated setup path
// applies its own capability ACL. The link refusal in the shared guard is the
// part that matters here.
func refuseForeignRuntimeComponent(string, os.FileInfo) error {
return nil
}
// sandboxRuntimeUserScope names the account the tree belongs to.
//
// Windows TEMP is already per-user, so this is belt and braces rather than the
// load-bearing separation it is on Unix. Kept so the derived path has the same
// shape on every platform and one code path builds it.
//
// FROM THE TOKEN, NOT THE ENVIRONMENT. Setup derives the fallback root in the
// elevated terminal and records it; a command later re-derives it in an
// ordinary one to decide whether that record belongs to this workspace. Those
// are two processes with independent environments, so a USERNAME that differed
// between them made the command reject the root setup had provisioned and
// select one that carries no capability ACE. The token's user SID is the same
// in both, because setup refuses to run as any account other than the one that
// will use Zero. The Unix build reads the uid for the same reason. Reported by
// CodeRabbit.
func sandboxRuntimeUserScope() string {
sid, err := currentProcessSID()
if err != nil {
return "u"
}
return "u" + strings.ToLower(sid)
}
// sandboxRuntimeFallbackOwnedNames are the components the temp-derived runtime
// root is built from. Unscoped on Windows: os.TempDir() already resolves inside
// the user's own profile, so the shared-temp collision the Unix build guards
// against cannot arise, and the fixed names keep windowsSandboxRuntimeOwnedTail
// able to recognise a root built by an elevated setup running as another
// account.
func sandboxRuntimeFallbackOwnedNames() []string {
return windowsSandboxRuntimeOwnedNames
}
@@ -1,80 +0,0 @@
package sandbox
import (
"testing"
)
// A RECORDED ROOT IS HISTORY, NOT PROOF THAT A COMMAND WOULD STILL SELECT IT.
//
// Doctor pins the marker's runtime root so it can check the stamp 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 cache at A, relocate the cache so commands derive B,
// and the stamped A tree stays behind. Pinning A reported a healthy machine
// immediately before every real command rejected A and failed on the marker.
//
// This records a marker with the cache resolver pointed at A, flips the
// resolver to B, and asks the same question a command asks. The seam is the
// production resolver, so the derivation under test is the command's own.
func TestRecordedRuntimeRootIsNotCurrentOnceTheCacheMoves(t *testing.T) {
cacheA := t.TempDir()
cacheB := t.TempDir()
workspace := t.TempDir()
previous := sandboxUserCacheDir
t.Cleanup(func() { sandboxUserCacheDir = previous })
sandboxUserCacheDir = func() (string, error) { return cacheA, nil }
config := WindowsSandboxSetupConfig{
SandboxHome: t.TempDir(),
CommandCWD: workspace,
WorkspaceRoots: []string{workspace},
PermissionProfile: PermissionProfile{
FileSystem: FileSystemPolicy{
Kind: FileSystemRestricted,
WriteRoots: []WritableRoot{{Root: workspace}},
},
Network: NetworkPolicy{Mode: NetworkDeny},
},
}
// The marker records profile.Runtime.Root, which setup sets from the root
// it SELECTS. Select it here the way a command under cache A would, and
// put it on the profile the way doctor does, so the recorded root is the
// genuine A-derived one rather than a value this test invented.
rootA, err := sandboxRuntimeRootFor(canonicalSandboxWorkspaceRoot(workspace), canonicalSandboxWorkspaceRoot(cacheA))
if err != nil {
t.Fatalf("derive the runtime root under cache A: %v", err)
}
config.PermissionProfile = PermissionProfileWithRuntimeRoot(
WindowsSandboxProfileWithRuntimeRoots(config.PermissionProfile, config.WorkspaceRoots),
rootA,
)
if _, err := WriteWindowsSandboxSetupMarker(config); err != nil {
t.Fatalf("WriteWindowsSandboxSetupMarker: %v", err)
}
recorded, current, err := WindowsSandboxRecordedRuntimeRootIsCurrent(config.SandboxHome, workspace)
if err != nil {
t.Fatalf("with the cache still at A: %v", err)
}
if recorded == "" {
t.Fatal("SETUP INVALID: the marker recorded no runtime root, so the comparison proves nothing")
}
if !current {
t.Fatalf("the root setup just recorded (%s) is reported stale while the cache has not moved", recorded)
}
// The cache moves. Commands now derive under B; the stamped tree is under A.
sandboxUserCacheDir = func() (string, error) { return cacheB, nil }
recordedAfter, currentAfter, err := WindowsSandboxRecordedRuntimeRootIsCurrent(config.SandboxHome, workspace)
if err != nil {
t.Fatalf("with the cache at B: %v", err)
}
if recordedAfter != recorded {
t.Errorf("the recorded root changed on read: %q then %q", recorded, recordedAfter)
}
if currentAfter {
t.Fatalf("the marker's root %s is under cache A, commands derive under B, and it was still reported current", recorded)
}
}
@@ -1,108 +0,0 @@
package sandbox
import (
"errors"
"strings"
"testing"
)
// THE DIAGNOSTIC REFUSES WHAT SELECTION REFUSES, WITH THE SAME WORDS.
//
// os.UserCacheDir can succeed and still hand back a value that canonicalizes to
// nothing: an empty string, ".", or only whitespace. Selection fails closed on
// that with "user cache directory is unavailable". The diagnostic that answers
// "would a command still select the recorded root?" spelled the same steps out
// separately and did not have that check, so it derived candidates from the
// empty root and returned an answer, current or stale, on a machine where every
// command fails before it reads a marker. Doctor then reported staleness, or
// nothing at all, instead of runtime-root-unresolved.
//
// Both now get their inputs from one resolver. This drives the two production
// entry points with the same stubbed cache value and requires the same error
// from each. The seam is the production resolver's own. Reported by @jatmn.
func TestTheDiagnosticRefusesTheCacheRootSelectionRefuses(t *testing.T) {
workspace := t.TempDir()
usableCache := t.TempDir()
previous := sandboxUserCacheDir
t.Cleanup(func() { sandboxUserCacheDir = previous })
sandboxUserCacheDir = func() (string, error) { return usableCache, nil }
config := WindowsSandboxSetupConfig{
SandboxHome: t.TempDir(),
CommandCWD: workspace,
WorkspaceRoots: []string{workspace},
PermissionProfile: PermissionProfile{
FileSystem: FileSystemPolicy{
Kind: FileSystemRestricted,
WriteRoots: []WritableRoot{{Root: workspace}},
},
Network: NetworkPolicy{Mode: NetworkDeny},
},
}
root, err := sandboxRuntimeRootFor(canonicalSandboxWorkspaceRoot(workspace), canonicalSandboxWorkspaceRoot(usableCache))
if err != nil {
t.Fatalf("derive the runtime root: %v", err)
}
config.PermissionProfile = PermissionProfileWithRuntimeRoot(
WindowsSandboxProfileWithRuntimeRoots(config.PermissionProfile, config.WorkspaceRoots),
root,
)
if _, err := WriteWindowsSandboxSetupMarker(config); err != nil {
t.Fatalf("WriteWindowsSandboxSetupMarker: %v", err)
}
// Healthy first: with a usable cache the diagnostic answers, and says
// current. Without this the legs below could pass on a marker that was
// never readable.
if recorded, current, err := WindowsSandboxRecordedRuntimeRootIsCurrent(config.SandboxHome, workspace); err != nil || recorded == "" || !current {
t.Fatalf("SETUP INVALID: with a usable cache the diagnostic gave (%q, %v, %v)", recorded, current, err)
}
for _, testCase := range []struct {
name string
cache string
}{
{name: "empty", cache: ""},
{name: "dot", cache: "."},
{name: "whitespace", cache: " "},
{name: "dot with whitespace", cache: " . "},
} {
t.Run(testCase.name, func(t *testing.T) {
sandboxUserCacheDir = func() (string, error) { return testCase.cache, nil }
_, lease, _, selectErr := selectSandboxRuntimeRoot(workspace, true, config.SandboxHome)
if lease != nil {
lease.release()
}
if selectErr == nil {
t.Fatalf("SETUP INVALID: selection accepted the cache root %q, so there is no refusal to match", testCase.cache)
}
recorded, current, diagnosticErr := WindowsSandboxRecordedRuntimeRootIsCurrent(config.SandboxHome, workspace)
if diagnosticErr == nil {
t.Fatalf("the diagnostic answered current=%v for a cache root %q that selection refuses with %q; doctor would report that instead of runtime-root-unresolved", current, testCase.cache, selectErr)
}
if diagnosticErr.Error() != selectErr.Error() {
t.Errorf("the diagnostic failed with %q and selection with %q; an operator is told two different things about one cause", diagnosticErr, selectErr)
}
if !strings.Contains(diagnosticErr.Error(), "user cache directory is unavailable") {
t.Errorf("the cause does not name the cache directory: %q", diagnosticErr)
}
if current {
t.Errorf("an unresolvable selection was still reported current")
}
if recorded == "" {
t.Errorf("the recorded root was dropped from the answer, so doctor cannot say which marker it was looking at")
}
})
}
// And a resolver error is still reported as itself, by both.
boom := errors.New("no cache location is defined")
sandboxUserCacheDir = func() (string, error) { return "", boom }
_, _, _, selectErr := selectSandboxRuntimeRoot(workspace, true, config.SandboxHome)
_, _, diagnosticErr := WindowsSandboxRecordedRuntimeRootIsCurrent(config.SandboxHome, workspace)
if !errors.Is(selectErr, boom) || !errors.Is(diagnosticErr, boom) || selectErr.Error() != diagnosticErr.Error() {
t.Errorf("a resolver error reads differently: selection %q, diagnostic %q", selectErr, diagnosticErr)
}
}
@@ -1,32 +0,0 @@
//go:build !windows
package sandbox
import (
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
)
// snapshotRuntimeStampBound keeps the pathname form off Windows. The split it
// closes there is specific to an elevated installer racing an unelevated
// renamer; the same eager identity capture still applies.
//
// The three-state result is NOT Windows-specific, though: "read it and there was
// nothing" and "could not read it" are different facts on every platform, and
// only the first may authorize a compensating delete. A permission or I/O error
// here stops setup rather than being recorded as proven absence.
func snapshotRuntimeStampBound(root string, name string) (identity string, identified bool, prior []byte, state runtimeStampState, err error) {
identity, identified = runtimeDirIdentity(root)
path := filepath.Join(root, name)
data, readErr := os.ReadFile(path)
if readErr != nil {
if errors.Is(readErr, fs.ErrNotExist) {
return identity, identified, nil, runtimeStampAbsent, nil
}
return identity, identified, nil, runtimeStampUnknown, fmt.Errorf("read the sandbox runtime stamp at %s: %w", path, readErr)
}
return identity, identified, data, runtimeStampPresent, nil
}
@@ -1,98 +0,0 @@
//go:build windows
package sandbox
import (
"errors"
"fmt"
"io"
"os"
"golang.org/x/sys/windows"
)
// snapshotRuntimeStampBound reads the runtime root's identity and its existing
// stamp through ONE handle.
//
// The two used to be taken separately: runtimeDirIdentity opened the root, read
// its volume and file ID, and closed the handle, and then os.ReadFile resolved
// the pathname again. A rename and substitution in that interval pairs A's
// identity with B's stamp bytes, and a rollback that correctly proves it holds A
// then writes B's bytes into it, corrupting an attestation that predates this
// run. The lease stops cleanup selecting the root; it does not stop the parent's
// owner renaming it.
//
// Reading the child relative to the identified handle removes the second
// resolution, so both facts describe the same object by construction.
//
// EVERY FAILURE IS AN ERROR, NOT AN ABSENCE. This used to collapse an encoding
// failure, a directory that would not open, an identity that could not be read,
// a denied child open and a short read all into the same "there was no stamp"
// answer that a genuine ERROR_FILE_NOT_FOUND produces. The stamp writer uses
// FILE_OVERWRITE_IF and can replace an existing stamp even where the read was
// denied, so that lie let a FAILED setup delete an attestation it had no record
// of, leaving the previous run's marker pointing at an unusable runtime root.
// Only a positive not-found produces runtimeStampAbsent.
func snapshotRuntimeStampBound(root string, name string) (identity string, identified bool, prior []byte, state runtimeStampState, err error) {
utf16Root, err := windows.UTF16PtrFromString(root)
if err != nil {
return "", false, nil, runtimeStampUnknown, fmt.Errorf("encode sandbox runtime root %s: %w", root, err)
}
directory, err := windows.CreateFile(
utf16Root,
windows.FILE_TRAVERSE|windows.FILE_READ_ATTRIBUTES|windows.SYNCHRONIZE,
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 {
// A root that is simply not there yet is the ordinary first-run case: the
// created-directory rollback owns it and there is no prior stamp to lose.
if isWindowsNotFound(err) {
return "", false, nil, runtimeStampAbsent, nil
}
return "", false, nil, runtimeStampUnknown, fmt.Errorf("open sandbox runtime root %s: %w", root, err)
}
defer windows.CloseHandle(directory)
identity, idErr := handleRuntimeIdentity(directory)
if idErr != nil {
return "", false, nil, runtimeStampUnknown, fmt.Errorf("identify sandbox runtime root %s: %w", root, idErr)
}
stamp, err := openWindowsChildNoFollow(directory, name,
windows.GENERIC_READ|windows.FILE_READ_ATTRIBUTES, windows.FILE_NON_DIRECTORY_FILE)
if err != nil {
if isWindowsNotFound(err) {
// Proven absent. The identity still stands: it came from the handle
// above, not from this.
return identity, true, nil, runtimeStampAbsent, nil
}
return identity, true, nil, runtimeStampUnknown, fmt.Errorf("open the sandbox runtime stamp in %s: %w", root, err)
}
file := os.NewFile(uintptr(stamp), name)
defer file.Close()
data, readErr := io.ReadAll(file)
if readErr != nil {
return identity, true, nil, runtimeStampUnknown, fmt.Errorf("read the sandbox runtime stamp in %s: %w", root, readErr)
}
return identity, true, data, runtimeStampPresent, nil
}
// isWindowsNotFound reports the two statuses that mean the object genuinely is
// not there, as opposed to the many that mean it could not be looked at.
//
// openWindowsChildNoFollow wraps its NTSTATUS, and CreateFile returns the Win32
// errno, so both spellings are checked rather than assuming one layer.
func isWindowsNotFound(err error) bool {
if err == nil {
return false
}
return errors.Is(err, os.ErrNotExist) ||
errors.Is(err, windows.ERROR_FILE_NOT_FOUND) ||
errors.Is(err, windows.ERROR_PATH_NOT_FOUND) ||
errors.Is(err, windows.STATUS_OBJECT_NAME_NOT_FOUND) ||
errors.Is(err, windows.STATUS_OBJECT_PATH_NOT_FOUND)
}
+66 -475
View File
@@ -21,6 +21,11 @@ const (
sandboxRuntimeMaxRoots = 64
)
var fallbackSandboxRuntimes = struct {
sync.Mutex
roots map[string]string
}{roots: make(map[string]string)}
type SandboxRuntime struct {
Root string `json:"root,omitempty"`
Cache string `json:"cache,omitempty"`
@@ -28,106 +33,37 @@ type SandboxRuntime struct {
Temp string `json:"temp,omitempty"`
}
// sandboxRuntimeRootFor derives the per-workspace runtime root. It is separated
// from prepareSandboxRuntime because the elevated Windows setup path needs the
// same answer WITHOUT taking a lease or creating anything: a sandbox principal
// is a separate account with no inherited rights under the user cache, so setup
// has to grant it write access to this tree before any command runs.
//
// Both callers must agree exactly. If they ever drift, setup grants the ACE on
// one directory while commands write to another, and the failure is a bare
// ACCESS_DENIED from npm or go build with nothing pointing at the sandbox.
func sandboxRuntimeRootFor(workspaceRoot string, cacheRoot string) (string, error) {
if root, ok := deterministicSandboxRuntimeRoot(workspaceRoot, cacheRoot); ok {
return root, nil
func prepareSandboxRuntime(workspaceRoot string) (SandboxRuntime, func(), error) {
workspaceRoot = filepath.Clean(strings.TrimSpace(workspaceRoot))
if workspaceRoot == "" || workspaceRoot == "." {
return SandboxRuntime{}, nil, errors.New("sandbox runtime requires a workspace root")
}
cacheRoot, err := sandboxUserCacheDir()
if err != nil {
return SandboxRuntime{}, nil, fmt.Errorf("resolve user cache directory: %w", err)
}
cacheRoot = filepath.Clean(strings.TrimSpace(cacheRoot))
if cacheRoot == "" || cacheRoot == "." {
return SandboxRuntime{}, nil, errors.New("user cache directory is unavailable")
}
return fallbackSandboxRuntimeRoot(workspaceRoot)
}
// deterministicSandboxRuntimeRoot returns the cache-derived runtime root and
// whether it is usable, meaning it lands outside the workspace.
//
// Neither this nor the fallback creates anything now, so a caller that only
// needs to NAME the tree can safely go through sandboxRuntimeRootFor and get the
// answer commands will actually use. This remains separate for callers that need
// to distinguish the cache-derived root from the temp-derived one.
func deterministicSandboxRuntimeRoot(workspaceRoot string, cacheRoot string) (string, bool) {
digest := sha256.Sum256([]byte(workspaceRoot))
// The same inventory the rooted traversal recognizes a runtime root by. See
// windowsSandboxRuntimeOwnedNames: two spellings of this list is how a real
// runtime root stops being recognized as owned, and that failure opens by
// name instead of by handle.
root := filepath.Join(append(append([]string{cacheRoot}, windowsSandboxRuntimeOwnedNames...), hex.EncodeToString(digest[:8]))...)
return root, !runtimeRootWithinWorkspace(workspaceRoot, root)
}
// runtimeRootWithinWorkspace reports whether root lands inside workspaceRoot.
//
// pathWithinRoot compares SPELLINGS, and canonicalSandboxWorkspaceRoot folds only
// the aliases filepath.EvalSymlinks folds. Two get through it. Case: filepath.Rel
// folds case on Windows via sameWord but not elsewhere, so on a case-insensitive
// macOS volume /var/folders/x and /VAR/FOLDERS/X are one directory that every
// string comparison here keeps apart. Junctions: EvalSymlinks returns a Windows
// directory junction unchanged, so a TEMP that reaches the workspace through one
// measures as outside it.
//
// Three checks, each of which can only ADD a containment answer. That asymmetry
// is the safety argument: the failure that matters is the runtime tree living
// inside the workspace, so a missed alias is the expensive direction and an extra
// relocation is the cheap one.
//
// 1. the spellings as given;
// 2. the spellings resolved to physical paths, which on Windows follows
// junctions at any depth via GetFinalPathNameByHandle;
// 3. filesystem identity across root's existing ancestors, which catches a case
// alias on a case-insensitive volume where step 2 has no API to call.
//
// Step 3 only sees an alias whose target IS the workspace root, because it walks
// a SPELLING upward and a junction has no spelling chain back into its target's
// parent. That shape is covered by step 2 on Windows. It remains open off Windows
// for a bind mount, which the kernel presents as a real path with no way to ask
// where it came from; closing that needs mountinfo parsing, not a path API.
func runtimeRootWithinWorkspace(workspaceRoot string, root string) bool {
root := filepath.Join(cacheRoot, "zero", "runtime", "v1", hex.EncodeToString(digest[:8]))
if pathWithinRoot(workspaceRoot, root) {
return true
}
if physicalWorkspace := physicalSandboxPath(workspaceRoot); physicalWorkspace != "" {
if pathWithinRoot(physicalWorkspace, physicalSandboxPath(root)) {
return true
root, err = fallbackSandboxRuntimeRoot(workspaceRoot)
if err != nil {
return SandboxRuntime{}, nil, err
}
}
workspaceInfo, err := os.Stat(workspaceRoot)
lease, err := prepareSandboxRuntimeLease(root)
if err != nil {
// An unresolvable workspace leaves nothing to compare against. The
// spelling checks above already returned their answer.
return false
}
// root itself usually does not exist yet, which is the point: start at the
// deepest component and walk up, so the first directory that does exist gets
// compared and every ancestor above it after that.
current := filepath.Clean(root)
for {
if info, err := os.Stat(current); err == nil && os.SameFile(workspaceInfo, info) {
return true
root, err = fallbackSandboxRuntimeRoot(workspaceRoot)
if err != nil {
return SandboxRuntime{}, nil, err
}
parent := filepath.Dir(current)
if parent == current {
return false
lease, err = prepareSandboxRuntimeLease(root)
if err != nil {
return SandboxRuntime{}, nil, err
}
current = parent
}
}
func prepareSandboxRuntime(workspaceRoot string, sandboxHome string) (SandboxRuntime, func(), error) {
// One selection function, shared with setup. See selectSandboxRuntimeRoot.
//
// The ledger is deliberately unused here. A COMMAND is not a transaction: it
// does not publish a marker and has nothing to roll back to, and a component
// created on this path is the runtime tree the command is about to use. Only
// setup owns an undo.
root, lease, _, err := selectSandboxRuntimeRoot(workspaceRoot, true, sandboxHome)
if err != nil {
return SandboxRuntime{}, nil, err
}
prepared := false
defer func() {
@@ -141,29 +77,30 @@ func prepareSandboxRuntime(workspaceRoot string, sandboxHome string) (SandboxRun
Data: filepath.Join(root, "data"),
Temp: filepath.Join(root, "tmp"),
}
// BEFORE ANYTHING IS CREATED. 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 is silently accepted and the whole tree is built inside
// whatever it points at, and the root then becomes a write root the backend
// binds read-write with TMPDIR and the build caches pointed inside it.
if err := refuseAliasedRuntimeComponents(runtimeState.Root); err != nil {
return SandboxRuntime{}, nil, err
directories := []string{
runtimeState.Root,
runtimeState.Cache,
runtimeState.Data,
runtimeState.Temp,
filepath.Join(runtimeState.Cache, "npm"),
filepath.Join(runtimeState.Cache, "yarn"),
filepath.Join(runtimeState.Cache, "corepack"),
filepath.Join(runtimeState.Cache, "pip"),
filepath.Join(runtimeState.Cache, "go-build"),
filepath.Join(runtimeState.Data, "go-mod"),
filepath.Join(runtimeState.Data, "cargo"),
}
if err := os.MkdirAll(runtimeState.Root, 0o700); err != nil {
return SandboxRuntime{}, nil, fmt.Errorf("create sandbox runtime directory %s: %w", runtimeState.Root, err)
for _, directory := range directories {
if err := os.MkdirAll(directory, 0o700); err != nil {
return SandboxRuntime{}, nil, fmt.Errorf("create sandbox runtime directory %s: %w", directory, err)
}
if err := os.Chmod(directory, 0o700); err != nil {
return SandboxRuntime{}, nil, fmt.Errorf("secure sandbox runtime directory %s: %w", directory, err)
}
}
// AND AGAIN AFTER, because the check above is a check-then-use on its own: a
// component swapped during creation would still redirect the tree. Pairing the
// two narrows the window to the creation itself.
if err := refuseAliasedRuntimeComponents(runtimeState.Root); err != nil {
return SandboxRuntime{}, nil, err
}
// Everything below the root goes through a handle on it. The children are
// the one part of the tree a sandboxed command can replace; see
// prepareSandboxRuntimeTree.
now := sandboxRuntimeNow()
if err := prepareSandboxRuntimeTree(runtimeState.Root, now); err != nil {
return SandboxRuntime{}, nil, err
if err := os.Chtimes(runtimeState.Root, now, now); err != nil {
return SandboxRuntime{}, nil, fmt.Errorf("touch sandbox runtime root: %w", err)
}
cleanupSandboxRuntimeRoots(filepath.Dir(runtimeState.Root), runtimeState.Root, now)
prepared = true
@@ -171,19 +108,10 @@ func prepareSandboxRuntime(workspaceRoot string, sandboxHome string) (SandboxRun
}
func prepareSandboxRuntimeLease(root string) (*sandboxRuntimeLease, error) {
lease, _, err := prepareSandboxRuntimeLeaseRecording(root)
return lease, err
}
// prepareSandboxRuntimeLeaseRecording also reports the owned directories it
// created, so a caller that can roll back knows what it owns.
//
// Nothing recorded here is created by the leaf's owner. Provisioning creates and
// records the leaf itself; these are the components above it, which used to be
// produced by an os.MkdirAll that nobody accounted for. Setup could therefore
// fail after the lease and leave a tree behind with no record that it made it.
func prepareSandboxRuntimeLeaseRecording(root string) (*sandboxRuntimeLease, []windowsCreatedRuntimeDir, error) {
return acquireRuntimeLeaseForPlatform(root)
if err := os.MkdirAll(filepath.Dir(root), 0o700); err != nil {
return nil, fmt.Errorf("create sandbox runtime parent: %w", err)
}
return acquireSandboxRuntimeLease(root)
}
// cleanupSandboxRuntimeRoots applies a conservative age/count policy. Cleanup
@@ -246,60 +174,22 @@ func combineSandboxCleanups(cleanups ...func()) func() {
}
}
// fallbackSandboxRuntimeRoot returns the runtime root for a workspace whose
// cache-derived root would land inside itself.
//
// DERIVED, not minted, and that is the whole of the fix. It used to call
// os.MkdirTemp and remember the answer in a process-global map, which made the
// result private to whichever process asked first. Elevated Windows setup
// granted the sandbox principal write access to the directory IT created, then
// every later __windows-command-runner process created a DIFFERENT one and
// pointed TMP, GOCACHE, npm and the rest at it. Those directories are created by
// the calling user and carry no ACE for the principal, so ordinary cache and
// temp writes failed with a bare ACCESS_DENIED and nothing naming the sandbox.
//
// sandboxRuntimeRootFor already documents that both callers must agree exactly.
// Hashing the workspace, the same way the cache-derived root does, is what makes
// that true for this branch too: every process reaches the same path without
// having to share any state.
//
// It creates nothing, so deterministicSandboxRuntimeRoot's promise about naming
// a tree without materializing it now holds for the fallback as well.
func fallbackSandboxRuntimeRoot(workspaceRoot string) (string, error) {
// Canonicalized for the same reason prepareSandboxRuntime canonicalizes the
// workspace and cache roots: pathWithinRoot compares SPELLINGS, so a raw
// os.TempDir() measured against a canonical workspace root compares two
// different names for one directory and the containment check misses. Both
// callers resolve this in the operator's environment, so the derived path is
// identical on the setup and command sides and the plan hashes still agree.
//
// This closes the 8.3 short-name and symlink spellings, not every alias: a
// Windows directory JUNCTION comes back from EvalSymlinks unchanged, and case
// survives it on a case-insensitive macOS volume. The containment check below
// therefore does not rely on canonicalization alone; runtimeRootWithinWorkspace
// falls through to filesystem identity for exactly those aliases.
tempRoot := canonicalSandboxWorkspaceRoot(os.TempDir())
if tempRoot == "" || tempRoot == "." {
return "", errors.New("temp directory is unavailable")
fallbackSandboxRuntimes.Lock()
defer fallbackSandboxRuntimes.Unlock()
if root := fallbackSandboxRuntimes.roots[workspaceRoot]; root != "" {
return root, nil
}
// SCOPED TO THIS USER, unlike the cache-derived root, because this one lives
// in shared temp. On Linux os.TempDir() is the world-writable /tmp whenever
// TMPDIR is unset, and a digest of the workspace path alone is the same string
// for every user on the host: two accounts working on the same path would name
// one directory and the first one there would own it. The uid is not a secret
// and is not doing secrecy work; it removes the collision, and
// refuseAliasedRuntimeComponents handles somebody having got there first.
digest := sha256.Sum256([]byte(workspaceRoot + "\x00" + sandboxRuntimeUserScope()))
root := filepath.Join(append(append([]string{tempRoot}, sandboxRuntimeFallbackOwnedNames()...), hex.EncodeToString(digest[:8]))...)
if runtimeRootWithinWorkspace(workspaceRoot, root) {
// Both candidates land inside the workspace, so there is nowhere left to
// put a runtime tree the workspace's own policy does not govern. Refused
// rather than pointed somewhere arbitrary: a runtime root inside the
// workspace makes the sandbox's own cache writes indistinguishable from
// the work it is meant to be confining.
return "", fmt.Errorf("sandbox runtime root %q would fall inside workspace %q; "+
"open the workspace somewhere other than the cache or temp directory", root, workspaceRoot)
parent, err := os.MkdirTemp("", "zero-runtime-")
if err != nil {
return "", fmt.Errorf("create fallback sandbox runtime: %w", err)
}
root := filepath.Join(parent, "runtime")
if pathWithinRoot(workspaceRoot, root) {
_ = os.RemoveAll(parent)
return "", fmt.Errorf("fallback sandbox runtime root %q must be outside workspace %q", root, workspaceRoot)
}
fallbackSandboxRuntimes.roots[workspaceRoot] = root
return root, nil
}
@@ -336,302 +226,3 @@ func permissionProfileWithRuntime(profile PermissionProfile, runtimeState Sandbo
profile.FileSystem.WriteRoots = append(profile.FileSystem.WriteRoots, WritableRoot{Root: runtimeState.Root})
return profile
}
// canonicalSandboxWorkspaceRoot normalizes a workspace root the way
// Engine.resolveCommandDir already does — clean, absolutize, then resolve
// symlinks — so every derivation keyed to a workspace agrees on the string.
//
// The runtime root is a hash of this, and the elevated Windows setup grants the
// principal that tree while commands derive it again. Cleaning alone was not
// enough for the two to agree, and it does not take a symlink for them to
// differ: a path opened in different casing, or through an 8.3 short name (what
// a Windows CI runner's TEMP looks like), resolves to a different spelling.
// Setup then granted one tree and every command used another, so the grant that
// makes npm/go/pip caches writable landed where nothing reads and surfaced as a
// bare ACCESS_DENIED.
//
// Resolution failing is not an error: an unresolvable root still needs a stable
// key, and falling back to the cleaned absolute path is what the command path
// does too.
func canonicalSandboxWorkspaceRoot(root string) string {
cleaned := filepath.Clean(strings.TrimSpace(root))
if cleaned == "" || cleaned == "." {
return ""
}
if !filepath.IsAbs(cleaned) {
if absolute, err := filepath.Abs(cleaned); err == nil {
cleaned = absolute
}
}
// EvalSymlinks fails outright when the LEAF does not exist, which is the
// normal case for a cache or runtime root that has not been created yet. A
// plain call therefore resolved an existing workspace while leaving a
// not-yet-created cache root unresolved, and the two were compared against
// each other — the containment check that decides whether the runtime tree
// must move out of the workspace then ran on /private/var/... versus
// /var/..., missed, and left the tree inside the workspace.
//
// Resolve the longest existing ancestor and re-append the rest, so a path
// normalizes the same way whether or not its final segments exist yet.
remainder := ""
current := cleaned
for {
if resolved, err := filepath.EvalSymlinks(current); err == nil {
if remainder == "" {
return resolved
}
return filepath.Join(resolved, remainder)
}
parent := filepath.Dir(current)
if parent == current {
// Nothing along the path resolved; the cleaned absolute form is the
// best stable key available.
return cleaned
}
remainder = filepath.Join(filepath.Base(current), remainder)
current = parent
}
}
// selectSandboxRuntimeRoot picks the runtime root a command will actually use,
// and holds a lease on it while the caller decides what to do with it.
//
// SETUP AND THE COMMAND HAVE TO SELECT THE SAME WAY, or they disagree about
// which tree exists. Setup used to derive the cache-based root and fingerprint a
// plan naming it, while a command derived the same root, failed to lease it, and
// silently relocated to the temp fallback. The command's plan then named the
// fallback and the marker rejected it:
//
// windows sandbox setup is out of date: permission roots or deny lists changed
//
// which blames permissions for a runtime-root disagreement, and re-running setup
// could not recover because setup deterministically chose the same unleasable
// root again. That is a permanent brick, not a retry.
//
// Extracted from prepareSandboxRuntime so both sides run this one function. The
// caller must release the returned lease.
// pinnedSandboxRuntimeRoot returns the root a previous setup recorded, when
// that root is one this workspace could actually select.
//
// The candidate check is what keeps this honest. One sandbox home serves
// whichever workspace ran setup last, so a recorded root can belong to a
// different workspace entirely; pinning to that would point this command's
// runtime at another workspace's tree. A recorded root is only honoured when it
// matches one of the two roots THIS workspace derives, which is also the only
// pair the selections could ever have disagreed about.
// sandboxHome is the home THIS command asked for, not the one the parent
// process happens to be pointed at.
//
// TWO ENVIRONMENT AUTHORITIES IS ONE TOO MANY. Runtime preparation ran before
// Windows platform planning and resolved the home from the ambient environment,
// while the planner resolves ZERO_WINDOWS_SANDBOX_HOME out of the command's own
// spec.Env and hands THAT to the runner for marker validation. A request that
// selects home B while the parent still points at home A pinned A's recorded
// root into the profile, and the runner then loaded B's marker and rejected the
// command as out of date even though setup for B was perfectly valid. The two
// homes do not need different derivation rules to disagree, only different valid
// selections from the same preferred/fallback pair.
//
// Empty means no command context, so the ambient environment is the only
// authority there is and resolving it here is correct.
func pinnedSandboxRuntimeRoot(workspaceRoot, preferred, fallback, sandboxHome string) string {
// No GOOS gate. The marker only exists where setup wrote one, so this is
// already Windows-only in practice, and keeping the code path platform-neutral
// means the setup-to-command contract is exercised on every CI runner instead
// of only the Windows one.
home := strings.TrimSpace(sandboxHome)
if home == "" {
resolved, err := ResolveWindowsSandboxHome(nil)
if err != nil {
return ""
}
home = resolved
}
recorded := WindowsSandboxRecordedRuntimeRoot(home)
if recorded == "" {
return ""
}
for _, candidate := range []string{preferred, fallback} {
if candidate != "" && sameWindowsRuntimeRootPath(recorded, candidate) {
return candidate
}
}
// A RECORD IS HISTORY, NOT SOMETHING TO RE-DERIVE.
//
// The candidates above are both computed from THIS process's environment, and
// the fallback one is computed from its TEMP. Setup can legitimately land on
// the fallback when the preferred cache root cannot be leased, and it records
// that concrete root. If a later IDE, service or terminal runs with a
// different TEMP, the recorded root matches neither of today's candidates, the
// record is ignored, and selection picks a tree setup never provisioned. The
// runner then rejects the marker as out of date, and re-running setup from the
// original environment does not make the other environment converge.
//
// So the recorded root is recognised by its OWN shape instead: it must be a
// runtime root Zero owns, its first component must be the fallback's, and its
// leaf must be this workspace's fallback digest. That answers "does this
// record belong to this workspace and home" without asking "what would I
// choose from scratch right now", which is the question that reintroduced the
// second selection.
//
// The cache-derived digest is deliberately NOT accepted here. A moved user
// cache stays stale, which is a different failure with a different remedy.
if ownedFallbackRuntimeRecord(workspaceRoot, recorded) {
return recorded
}
return ""
}
// ownedFallbackRuntimeRecord reports whether recorded is a fallback runtime root
// this workspace would own, judged on the record's own shape.
func ownedFallbackRuntimeRecord(workspaceRoot, recorded string) bool {
workspaceRoot = canonicalSandboxWorkspaceRoot(workspaceRoot)
if workspaceRoot == "" || strings.TrimSpace(recorded) == "" {
return false
}
base, components, owned := windowsSandboxRuntimeOwnedTail(recorded)
if !owned || base == "" || len(components) == 0 {
return false
}
names := sandboxRuntimeFallbackOwnedNames()
if len(names) == 0 || !strings.EqualFold(components[0], names[0]) {
return false
}
digest := sha256.Sum256([]byte(workspaceRoot + "\x00" + sandboxRuntimeUserScope()))
return strings.EqualFold(components[len(components)-1], hex.EncodeToString(digest[:8]))
}
// resolveSandboxRuntimeRootInputs is the part of runtime-root selection that
// names things and touches nothing: the canonical workspace, and the root a
// command prefers for it. It takes no lease and creates no directory.
//
// ONE RESOLVER, BECAUSE THERE WERE TWO AND THEY DISAGREED. Selection and the
// diagnostic that asks "would a command still select the recorded root?" each
// spelled these steps out, and the diagnostic's copy was missing one: a user
// cache directory that resolves to nothing. os.UserCacheDir can succeed and
// still hand back a value that canonicalizes to "" or ".", and selection fails
// closed on that with "user cache directory is unavailable" while the
// diagnostic carried on and derived candidates from the empty root. Doctor
// could then report the recorded root as current, or as merely stale, on a
// machine where every command fails before it ever looks at a marker. That is
// the false-healthy signal this whole change exists to remove, reached through
// a different edge of the same resolver.
//
// The diagnostic's doc comment already said it asked the command's question
// through the command's function. Now it does: both callers get their inputs
// here, so an input one of them refuses is refused by the other, with the same
// error. Reported by @jatmn.
func resolveSandboxRuntimeRootInputs(workspaceRoot string) (workspace string, preferred string, err error) {
workspace = canonicalSandboxWorkspaceRoot(workspaceRoot)
if workspace == "" || workspace == "." {
return "", "", errors.New("sandbox runtime requires a workspace root")
}
cacheRoot, err := sandboxUserCacheDir()
if err != nil {
return "", "", fmt.Errorf("resolve user cache directory: %w", err)
}
cacheRoot = canonicalSandboxWorkspaceRoot(cacheRoot)
if cacheRoot == "" || cacheRoot == "." {
return "", "", errors.New("user cache directory is unavailable")
}
preferred, err = sandboxRuntimeRootFor(workspace, cacheRoot)
if err != nil {
return "", "", err
}
return workspace, preferred, nil
}
// WindowsSandboxRecordedRuntimeRootIsCurrent answers the question a diagnostic
// has to ask before trusting the marker: would a command run NOW still select
// the runtime root that setup recorded?
//
// The marker's root is a historical fact, needed to check the stamp without
// mutating anything. It is not proof that the root is still selectable. Setup
// can run with the user cache at A, the cache can then move so commands derive
// B, and the stamped A tree stays behind. A diagnostic that pins A reports a
// healthy machine while every real command rejects A as not a current
// candidate and fails on the out-of-date marker.
//
// This derives the same candidates a command derives, through the same
// resolver, and applies the same equality, by calling the same function the
// command path calls. It takes no lease and creates nothing.
func WindowsSandboxRecordedRuntimeRootIsCurrent(sandboxHome, workspaceRoot string) (recorded string, current bool, err error) {
recorded = WindowsSandboxRecordedRuntimeRoot(sandboxHome)
if recorded == "" {
return "", false, nil
}
workspaceRoot, preferred, err := resolveSandboxRuntimeRootInputs(workspaceRoot)
if err != nil {
return recorded, false, err
}
fallback, _ := fallbackSandboxRuntimeRoot(workspaceRoot)
return recorded, pinnedSandboxRuntimeRoot(workspaceRoot, preferred, fallback, sandboxHome) != "", nil
}
// selectSandboxRuntimeRoot picks the runtime root and returns the lease on it
// together with the directories acquiring that lease had to create.
//
// honorRecorded is true on the command side and false during setup: setup is
// making the choice, so it must not consult a record it is about to overwrite,
// or a single unlucky relocation to the temp fallback would pin every future
// setup to temp.
//
// THE LEDGER IS PART OF THE ANSWER, NOT A DETAIL OF IT. Acquiring the lease
// creates zero/runtime/v1 when they are not there, and this was the two-result
// wrapper that dropped that fact on the floor. Setup then provisioned the leaf,
// recorded only the leaf for rollback, and any failure before the marker left
// the parents behind with nothing that knew it had made them. An error carries
// the partial ledger out for the same reason: acquisition can create one
// component and fail on the next.
func selectSandboxRuntimeRoot(workspaceRoot string, honorRecorded bool, sandboxHome string) (string, *sandboxRuntimeLease, []windowsCreatedRuntimeDir, error) {
workspaceRoot, root, err := resolveSandboxRuntimeRootInputs(workspaceRoot)
if err != nil {
return "", nil, nil, err
}
// CONSUME SETUP'S CHOICE, do not re-make it. Everything below is a fresh
// selection whose answer depends on whether a lease can be taken right now,
// and a command reaching a different answer than setup did is the outage this
// contract exists to prevent: the tree the command names was never
// provisioned, so its ACL plan hash cannot match and no amount of re-running
// setup fixes it.
if honorRecorded {
fallbackRoot, _ := fallbackSandboxRuntimeRoot(workspaceRoot)
if pinned := pinnedSandboxRuntimeRoot(workspaceRoot, root, fallbackRoot, sandboxHome); pinned != "" {
lease, pinnedCreated, leaseErr := prepareSandboxRuntimeLeaseRecording(pinned)
if leaseErr != nil {
// NOT relocated. Relocating is what produced the permanent brick:
// the other root has no capability ACL, so the command would be
// rejected anyway, with a message about permissions. Failing here
// says the true thing and points at the action that fixes it.
return "", nil, pinnedCreated, fmt.Errorf("sandbox runtime root %s was provisioned by setup but cannot be used now (%w); "+
"re-run `zero sandbox setup` from an elevated (Administrator) terminal", pinned, leaseErr)
}
return pinned, lease, pinnedCreated, nil
}
}
lease, created, err := prepareSandboxRuntimeLeaseRecording(root)
if err == nil {
return root, lease, created, nil
}
// AN ALIASED COMPONENT IS NOT A REASON TO RELOCATE. Falling back here would
// leave the link in place, report nothing, and move to the next predictable
// name, which the same attacker can take as well. Relocating is for a root
// that is merely unusable.
if errors.Is(err, errRuntimeComponentAliased) {
return "", nil, created, err
}
// The preferred root could not be leased. Relocating is right, and it is what
// commands already did; the defect was that setup never learned about it.
root, err = fallbackSandboxRuntimeRoot(workspaceRoot)
if err != nil {
return "", nil, created, err
}
// APPENDED, not replaced. The preferred attempt may have created components
// before it failed to lease, and those are this invocation's too.
fallbackLease, fallbackCreated, err := prepareSandboxRuntimeLeaseRecording(root)
created = append(created, fallbackCreated...)
if err != nil {
return "", nil, created, err
}
return root, fallbackLease, created, nil
}
+5 -24
View File
@@ -16,7 +16,7 @@ func TestPrepareSandboxRuntimeStaysOutsideWorkspace(t *testing.T) {
sandboxUserCacheDir = func() (string, error) { return cacheRoot, nil }
t.Cleanup(func() { sandboxUserCacheDir = original })
runtimeState, release, err := prepareSandboxRuntime(workspace, "")
runtimeState, release, err := prepareSandboxRuntime(workspace)
if err != nil {
t.Fatalf("prepareSandboxRuntime: %v", err)
}
@@ -59,7 +59,7 @@ func TestPrepareSandboxRuntimeCleansExpiredSibling(t *testing.T) {
if err := os.Chtimes(expired, old, old); err != nil {
t.Fatal(err)
}
_, release, err := prepareSandboxRuntime(workspace, "")
_, release, err := prepareSandboxRuntime(workspace)
if err != nil {
t.Fatalf("prepareSandboxRuntime: %v", err)
}
@@ -71,34 +71,15 @@ func TestPrepareSandboxRuntimeCleansExpiredSibling(t *testing.T) {
func TestPrepareSandboxRuntimeFallsBackWhenUserCacheIsInsideWorkspace(t *testing.T) {
workspace := t.TempDir()
// BOTH derivation inputs belong to the test, not just the cache one. The
// fallback root is derived from os.TempDir(), which reads TMPDIR on Unix and
// TMP/TEMP on Windows, and its name is deterministic, so stubbing only the
// cache left this test creating a persistent tree in the developer real temp
// directory that later runs then found already present.
tempHome := t.TempDir()
t.Setenv("TMPDIR", tempHome)
t.Setenv("TMP", tempHome)
t.Setenv("TEMP", tempHome)
original := sandboxUserCacheDir
sandboxUserCacheDir = func() (string, error) { return filepath.Join(workspace, ".cache"), nil }
t.Cleanup(func() { sandboxUserCacheDir = original })
runtimeState, release, err := prepareSandboxRuntime(workspace, "")
runtimeState, release, err := prepareSandboxRuntime(workspace)
if err != nil {
t.Fatalf("prepareSandboxRuntime: %v", err)
}
defer func() {
release()
// Only the root this test created.
_ = os.RemoveAll(runtimeState.Root)
}()
// And the redirect actually took, or the cleanup above removes one tree while
// the real one is still left behind somewhere else.
if !pathWithinRoot(canonicalSandboxWorkspaceRoot(tempHome), runtimeState.Root) {
t.Fatalf("fallback runtime root %q is outside the test-owned temp directory %q", runtimeState.Root, tempHome)
}
defer release()
if pathWithinRoot(workspace, runtimeState.Root) {
t.Fatalf("fallback runtime root %q must stay outside workspace %q", runtimeState.Root, workspace)
}
@@ -120,7 +101,7 @@ func TestCleanupSandboxRuntimeSkipsActiveLease(t *testing.T) {
sandboxRuntimeNow = originalNow
})
runtimeState, release, err := prepareSandboxRuntime(workspace, "")
runtimeState, release, err := prepareSandboxRuntime(workspace)
if err != nil {
t.Fatalf("prepareSandboxRuntime: %v", err)
}
-105
View File
@@ -1,105 +0,0 @@
package sandbox
import (
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"runtime"
"time"
)
// runtimeTreeChildren are the directories prepared inside every runtime root,
// relative to it, each listed after its parent.
var runtimeTreeChildren = []string{
"cache",
"data",
"tmp",
filepath.Join("cache", "npm"),
filepath.Join("cache", "yarn"),
filepath.Join("cache", "corepack"),
filepath.Join("cache", "pip"),
filepath.Join("cache", "go-build"),
filepath.Join("data", "go-mod"),
filepath.Join("data", "cargo"),
}
// prepareSandboxRuntimeTree creates and secures the runtime root's own
// directories through a handle on the root, and refuses any that is a link.
//
// THE CHILDREN ARE THE SANDBOX'S TO CHANGE. The root is a write root: every
// backend grants or binds it read-write so a sandboxed command can use its TMP
// and caches, and that same grant lets the command remove cache, data or tmp
// and leave a link to a host directory in its place. The tree outlives the
// command, and the fallback root is derived rather than minted, so the NEXT Zero
// process prepares the same tree, unsandboxed. It used to MkdirAll and Chmod each
// child by pathname after checking only the root and its ancestors, which
// followed the planted link: a contained command could get Zero to create npm
// inside a host directory and change that directory's mode. Reported by @jatmn.
//
// So nothing below the root is reached by pathname. os.Root resolves every
// component relative to the root's handle and refuses one that leads out of it,
// which covers the parent of every grandchild too; a child that is a link at
// all is refused outright, because nothing but tampering puts one there; and
// the mode is set on the opened directory rather than on a name that could be
// swapped between the check and the change.
func prepareSandboxRuntimeTree(root string, now time.Time) error {
tree, err := os.OpenRoot(root)
if err != nil {
return fmt.Errorf("open sandbox runtime root %s: %w", root, err)
}
defer func() { _ = tree.Close() }()
if err := secureRuntimeTreeDirectory(tree, ".", root); err != nil {
return err
}
for _, child := range runtimeTreeChildren {
path := filepath.Join(root, child)
if err := tree.Mkdir(child, 0o700); err != nil && !errors.Is(err, fs.ErrExist) {
return fmt.Errorf("create sandbox runtime directory %s: %w", path, err)
}
info, err := tree.Lstat(child)
if err != nil {
return fmt.Errorf("inspect sandbox runtime directory %s: %w", path, err)
}
// ModeIrregular as well as ModeSymlink: a Windows junction reports as
// irregular, and needs no privilege to create.
if info.Mode()&(os.ModeSymlink|os.ModeIrregular) != 0 {
return fmt.Errorf("%w: refusing to prepare the sandbox runtime through a link at %s, which a command run inside "+
"the sandbox can leave there to redirect Zero's own writes; remove %s and Zero will recreate it",
errRuntimeComponentAliased, path, root)
}
if !info.IsDir() {
return fmt.Errorf("sandbox runtime directory %s exists and is not a directory; remove %s and Zero will recreate it", path, root)
}
if err := secureRuntimeTreeDirectory(tree, child, path); err != nil {
return err
}
}
if err := tree.Chtimes(".", now, now); err != nil {
return fmt.Errorf("touch sandbox runtime root: %w", err)
}
return nil
}
// secureRuntimeTreeDirectory sets 0700 on the directory it opened, not on a
// name, so a swap after the check cannot move the change onto something else.
//
// Not on Windows, where a directory's access is its ACL, which setup manages,
// and chmod only toggles a read-only attribute Windows does not honour on
// directories. Setting it there would also need write-attribute access this
// handle does not have, for no effect.
func secureRuntimeTreeDirectory(tree *os.Root, name string, path string) error {
if runtime.GOOS == "windows" {
return nil
}
directory, err := tree.Open(name)
if err != nil {
return fmt.Errorf("open sandbox runtime directory %s: %w", path, err)
}
defer func() { _ = directory.Close() }()
if err := directory.Chmod(0o700); err != nil {
return fmt.Errorf("secure sandbox runtime directory %s: %w", path, err)
}
return nil
}
-146
View File
@@ -1,146 +0,0 @@
package sandbox
import (
"errors"
"os"
"os/exec"
"path/filepath"
"runtime"
"testing"
)
// A LINK LEFT IN THE TREE BY ONE COMMAND IS NOT FOLLOWED BY THE NEXT ZERO.
//
// The runtime root is a write root, so a sandboxed command can replace any
// directory under it with a link to a host directory it cannot write. The
// fallback root is derived rather than minted, so the next Zero process,
// unsandboxed, prepares that same tree. It created and chmodded every child by
// pathname and followed the link: jatmn's probe got outside/npm created and the
// outside directory's mode changed through cache. Reported by @jatmn.
//
// Every child is covered, grandchildren included, because each is its own
// pathname and closing one leaves the others open.
// plantRuntimeLink replaces nothing; it makes link point at target, the way a
// sandboxed command would: a symlink off Windows, and a junction on Windows,
// where a symlink needs a privilege and a junction does not.
func plantRuntimeLink(t *testing.T, link, target string) {
t.Helper()
if runtime.GOOS == "windows" {
if out, err := exec.Command("cmd", "/c", "mklink", "/J", link, target).CombinedOutput(); err != nil {
t.Fatalf("SETUP INVALID: plant a junction at %s: %v %s", link, err, out)
}
} else if err := os.Symlink(target, link); err != nil {
t.Fatalf("SETUP INVALID: plant a symlink at %s: %v", link, err)
}
linkInfo, err := os.Stat(link)
if err != nil {
t.Fatalf("SETUP INVALID: the planted link does not resolve: %v", err)
}
targetInfo, err := os.Stat(target)
if err != nil || !os.SameFile(linkInfo, targetInfo) {
t.Fatalf("SETUP INVALID: %s does not lead to %s, so the prepare below would prove nothing", link, target)
}
}
// fallbackRuntimeForTest makes the next prepareSandboxRuntime choose the temp
// fallback, inside a temp directory this test owns.
func fallbackRuntimeForTest(t *testing.T) string {
t.Helper()
workspace := t.TempDir()
redirectSandboxTestTemp(t, t.TempDir())
original := sandboxUserCacheDir
// A cache inside the workspace puts the preferred root there, which is what
// sends selection to the fallback.
sandboxUserCacheDir = func() (string, error) { return filepath.Join(workspace, ".cache"), nil }
t.Cleanup(func() { sandboxUserCacheDir = original })
return workspace
}
func TestPreparingTheRuntimeDoesNotFollowAChildLinkLeftByACommand(t *testing.T) {
for _, child := range runtimeTreeChildren {
t.Run(filepath.ToSlash(child), func(t *testing.T) {
workspace := fallbackRuntimeForTest(t)
first, release, err := prepareSandboxRuntime(workspace, "")
if err != nil {
t.Fatalf("SETUP INVALID: the first prepare failed: %v", err)
}
release()
requireWithinTestOwned(t, first.Root, os.TempDir())
if pathWithinRoot(canonicalSandboxWorkspaceRoot(workspace), canonicalSandboxWorkspaceRoot(first.Root)) {
t.Fatalf("SETUP INVALID: %s is the preferred root inside the workspace, not the fallback", first.Root)
}
// A host directory the command cannot write inside the sandbox, with a
// mode a chmod through the link would change.
outside := t.TempDir()
if runtime.GOOS != "windows" {
if err := os.Chmod(outside, 0o755); err != nil {
t.Fatal(err)
}
}
planted := filepath.Join(first.Root, child)
if err := os.RemoveAll(planted); err != nil {
t.Fatalf("SETUP INVALID: remove %s: %v", planted, err)
}
plantRuntimeLink(t, planted, outside)
second, release, err := prepareSandboxRuntime(workspace, "")
if err == nil {
release()
t.Fatalf("the next prepare accepted a link at %s (root %s)", child, second.Root)
}
if !errors.Is(err, errRuntimeComponentAliased) {
t.Fatalf("refused for a reason other than the link, so this does not pin the guard: %v", err)
}
entries, readErr := os.ReadDir(outside)
if readErr != nil {
t.Fatalf("read the outside directory: %v", readErr)
}
if len(entries) != 0 {
t.Errorf("the prepare created %d entries inside the directory the link pointed at, starting with %s", len(entries), entries[0].Name())
}
if runtime.GOOS != "windows" {
info, statErr := os.Stat(outside)
if statErr != nil {
t.Fatal(statErr)
}
if info.Mode().Perm() != 0o755 {
t.Errorf("the prepare changed the mode of the directory the link pointed at to %04o", info.Mode().Perm())
}
}
})
}
}
// The ordinary case still works through the handle: a second prepare of an
// intact tree succeeds, and off Windows it puts a loosened child back to 0700.
func TestPreparingAnIntactRuntimeTwiceStillSecuresItsChildren(t *testing.T) {
workspace := fallbackRuntimeForTest(t)
first, release, err := prepareSandboxRuntime(workspace, "")
if err != nil {
t.Fatalf("the first prepare failed: %v", err)
}
release()
requireWithinTestOwned(t, first.Root, os.TempDir())
loosened := filepath.Join(first.Root, "cache", "npm")
if runtime.GOOS != "windows" {
if err := os.Chmod(loosened, 0o755); err != nil {
t.Fatal(err)
}
}
_, release, err = prepareSandboxRuntime(workspace, "")
if err != nil {
t.Fatalf("the second prepare of an intact tree failed: %v", err)
}
release()
for _, child := range runtimeTreeChildren {
info, err := os.Lstat(filepath.Join(first.Root, child))
if err != nil || !info.IsDir() {
t.Fatalf("%s is missing or not a directory after prepare: %v", child, err)
}
if runtime.GOOS != "windows" && info.Mode().Perm() != 0o700 {
t.Errorf("%s is %04o after prepare, want 0700", child, info.Mode().Perm())
}
}
}
@@ -1,9 +0,0 @@
//go:build !windows
package sandbox
// currentProcessSID has no meaning off Windows. Windows sandbox setup args are
// only ever built on Windows; this exists so the shared builder compiles.
func currentProcessSID() (string, error) {
return "", nil
}
@@ -1,30 +0,0 @@
//go:build windows
package sandbox
import (
"fmt"
"golang.org/x/sys/windows"
)
// currentProcessSID is the SID of the token running this process.
//
// Called from BuildWindowsSandboxSetupArgs, which runs in the OPERATOR'S shell
// before elevation, so it answers "who will read the stamp afterwards" rather
// than "who is provisioning it". Those are different principals whenever setup
// elevates, and the difference is the whole point: the elevated helper creates
// the runtime leaf when it is absent, so anything inferred from that leaf
// describes the installer and not the consumer.
func currentProcessSID() (string, error) {
token := windows.GetCurrentProcessToken()
user, err := token.GetTokenUser()
if err != nil {
return "", fmt.Errorf("resolve the calling user SID for sandbox setup: %w", err)
}
sid := user.User.Sid
if sid == nil {
return "", fmt.Errorf("resolve the calling user SID for sandbox setup: the token carried none")
}
return sid.String(), nil
}
-54
View File
@@ -1,54 +0,0 @@
package sandbox
import (
"os"
"testing"
)
// redirectSandboxTestTemp points EVERY variable os.TempDir reads at dir.
//
// os.TempDir reads TMPDIR on Unix and TMP, then TEMP, on Windows. A platform
// neutral test that sets only the Windows pair changes nothing on Unix, where
// fallbackSandboxRuntimeRoot then derives from the ambient temp: a path outside
// every t.TempDir the test owns, which the test goes on to create or clear. The
// cache resolver override does not reach this input, so a fixture that redirects
// the cache and two of the three variables still has one production input it
// does not own. Reported by @jatmn.
//
// It also verifies the redirect took, because a redirect that silently did
// nothing is exactly how this went unnoticed: the tests passed, or skipped, and
// the tree they made was somewhere else.
func redirectSandboxTestTemp(t *testing.T, dir string) {
t.Helper()
t.Setenv("TMPDIR", dir)
t.Setenv("TMP", dir)
t.Setenv("TEMP", dir)
got := canonicalSandboxWorkspaceRoot(os.TempDir())
want := canonicalSandboxWorkspaceRoot(dir)
if !sameWindowsRuntimeRootPath(got, want) {
t.Fatalf("SETUP INVALID: os.TempDir() is %s after redirecting it to %s, so anything derived from temp lands outside this test", got, want)
}
}
// requireWithinTestOwned fails unless path is inside one of the directories the
// test owns, compared canonically on both sides because the derivation
// canonicalizes and a raw t.TempDir() can be a second spelling of the same place
// (/var against /private/var, an 8.3 profile name on a Windows runner).
//
// Called BEFORE a derived path is created or removed. Checking afterwards
// reports a tree in somebody else's directory once it already exists.
func requireWithinTestOwned(t *testing.T, path string, owned ...string) {
t.Helper()
canonical := canonicalSandboxWorkspaceRoot(path)
for _, root := range owned {
if pathWithinRoot(canonicalSandboxWorkspaceRoot(root), canonical) {
return
}
}
t.Fatalf("%s (canonically %s) is outside every directory this test owns %v; refusing to create or remove it", path, canonical, owned)
}
// testStampPlanHash is the plan the stamp tests attest. The stamp is named after
// the plan it proves, so a test that writes one and then looks for it has to use
// the same plan on both sides; one shared value keeps that from being a typo.
const testStampPlanHash = "planhash"
+4 -1
View File
@@ -70,7 +70,10 @@ const (
const (
PermissionModeAuto PermissionMode = "auto"
PermissionModeAsk PermissionMode = "ask"
PermissionUnsafe PermissionMode = "unsafe"
PermissionFullAuto PermissionMode = "full-auto"
// Deprecated: use PermissionFullAuto. Kept as an alias so code landing
// independently of this branch still compiles after a merge.
PermissionUnsafe = PermissionFullAuto
)
const (
@@ -0,0 +1,120 @@
package sandbox
import "testing"
// THE UNPARSEABLE FALLBACK MUST BE A SUPERSET OF WHAT THE AST PATH FLAGS.
//
// The POSIX parser rejects shell syntax the invoked shell accepts, and the
// analyzer names Windows command strings as exactly that fallback case. When a
// command form loses its network category by being written in a spelling the
// parser cannot read, the approval gate disappears for it — and on Windows that
// approval IS the egress control, because there is no namespace or seatbelt
// underneath it.
//
// The serving forms were briefly dropped from the fallback on the reasoning that
// they bind rather than fetch. The AST path sets Network for them anyway, and
// says why: nothing consumes LocalServer yet, and `npm run dev` is matched by
// script name, so the repository decides what `dev` and `predev` do and either
// can reach out before a port is bound.
func TestUnparseableFallbackKeepsNetworkForServingCommands(t *testing.T) {
// Each pair is the same intent written two ways: one the POSIX parser reads,
// one it does not. Both must be classified the same.
for _, testCase := range []struct{ name, parseable, unparseable string }{
{
name: "package manager dev script",
parseable: `npm run dev`,
unparseable: `if "%OS%"=="Windows_NT" (npm run dev) else (npm start)`,
},
{
// The flagged spelling is the one that regressed: the AST path lost it
// to option-value confusion and the fallback only ever listed the
// serving word directly after the program name, so BOTH paths dropped
// the gate for the same command at the same time.
name: "package manager dev script behind a global option",
parseable: `npm --prefix ./web run dev`,
unparseable: `if "%OS%"=="Windows_NT" (npm --prefix ./web run dev) else (npm --prefix ./web start)`,
},
{
// The fetching half of the same confusion: `npm --prefix ./web install`
// really does reach the registry, and it lost the gate on both paths for
// the same reason the serving form did.
name: "package manager install behind a global option",
parseable: `npm --prefix ./web install`,
unparseable: `if "%OS%"=="Windows_NT" (npm --prefix ./web install) else (true)`,
},
{
name: "python http server",
parseable: `python -m http.server 8000`,
unparseable: `if "%OS%"=="Windows_NT" (python -m http.server 8000) else (true)`,
},
{
name: "framework dev server",
parseable: `vite --host`,
unparseable: `if "%OS%"=="Windows_NT" (vite --host) else (true)`,
},
} {
t.Run(testCase.name, func(t *testing.T) {
// The premise: the first parses and the second does not. If the parser
// ever learns this syntax, this test is measuring nothing and should
// fail loudly rather than pass vacuously.
if AnalyzeCommand(testCase.parseable).TooComplex {
t.Fatalf("SETUP INVALID: %q was expected to parse", testCase.parseable)
}
if !AnalyzeCommand(testCase.unparseable).TooComplex {
t.Fatalf("SETUP INVALID: %q was expected to defeat the parser", testCase.unparseable)
}
for _, script := range []string{testCase.parseable, testCase.unparseable} {
risk := Classify(Request{
ToolName: "bash",
SideEffect: SideEffectShell,
Args: map[string]any{"command": script},
})
if !HasRiskCategory(risk, "network") {
t.Errorf("%q was not classified as network, so it skips the NetworkDeny prompt that its parseable form receives: %v", script, risk.Categories)
}
}
})
}
}
// THE FALLBACK IS DERIVED FROM THE ANALYZER, NOT MAINTAINED BESIDE IT.
//
// The unparseable fallback is meant to be a conservative SUPERSET of everything
// the AST path flags for network. It was maintained as a separate inventory and
// drifted: the analyzer accepted "py" as a Python launcher, the regex listed
// only python, python2 and python3, and a Windows batch spelling the POSIX
// parser rejects therefore lost the approval gate that its parseable equivalent
// receives. On Windows that gate IS the egress control.
//
// Driving the loop off pythonLauncherPrograms means a launcher added there
// cannot silently weaken the fallback: this fails the moment the two disagree.
func TestUnparseableFallbackCoversEveryPythonLauncher(t *testing.T) {
if len(pythonLauncherPrograms) == 0 {
t.Fatal("SETUP INVALID: no python launchers to check")
}
for launcher := range pythonLauncherPrograms {
t.Run(launcher, func(t *testing.T) {
parseable := launcher + " -m http.server 8000"
unparseable := `if "%OS%"=="Windows_NT" (` + parseable + ") else (true)"
if AnalyzeCommand(parseable).TooComplex {
t.Fatalf("SETUP INVALID: %q was expected to parse", parseable)
}
if !AnalyzeCommand(unparseable).TooComplex {
t.Fatalf("SETUP INVALID: %q was expected to defeat the parser", unparseable)
}
for _, script := range []string{parseable, unparseable} {
risk := Classify(Request{
ToolName: "bash",
SideEffect: SideEffectShell,
Args: map[string]any{"command": script},
})
if !HasRiskCategory(risk, "network") {
t.Errorf("%q was not classified as network, so it skips the approval its parseable form receives: %v", script, risk.Categories)
}
}
})
}
}
@@ -1,216 +0,0 @@
//go:build windows
package sandbox
import (
"encoding/binary"
"fmt"
"os"
"os/exec"
"strings"
"testing"
"unsafe"
"golang.org/x/sys/windows"
)
const (
aceAdmissionLegEnv = "ZERO_TEST_ACE_ADMISSION_LEG"
// Not in x/sys/windows. Values from winnt.h.
testAccessAllowedObjectACEType = 0x5
testAccessAllowedCallbackACEType = 0x9
aceAdmissionTrustee = "S-1-5-21-1111-2222-3333-4444"
)
// rawACE lays out an ACE by hand: header, mask, then whatever the leg wants to
// sit where a plain ACE keeps its SID.
func rawACE(aceType, aceFlags byte, mask uint32, tail []byte) []byte {
ace := make([]byte, windowsPlainACEHeaderSize+len(tail))
ace[0] = aceType
ace[1] = aceFlags
binary.LittleEndian.PutUint16(ace[2:], uint16(len(ace)))
binary.LittleEndian.PutUint32(ace[4:], mask)
copy(ace[windowsPlainACEHeaderSize:], tail)
return ace
}
// aclEndingAtAGuardPage builds an ACL whose last byte is the last readable byte
// before a PAGE_NOACCESS page. A reader that trusts a length the entry did not
// vouch for walks off the end and faults, instead of quietly reading whatever
// the heap had next, which is what makes an over-read observable at all.
func aclEndingAtAGuardPage(t *testing.T, aces ...[]byte) *windows.ACL {
t.Helper()
size := 8
for _, ace := range aces {
size += len(ace)
}
if size%4 != 0 {
t.Fatalf("SETUP INVALID: ACL size %d is not DWORD aligned", size)
}
page := os.Getpagesize()
base, err := windows.VirtualAlloc(0, uintptr(2*page), windows.MEM_RESERVE|windows.MEM_COMMIT, windows.PAGE_READWRITE)
if err != nil {
t.Fatalf("VirtualAlloc: %v", err)
}
var previous uint32
if err := windows.VirtualProtect(base+uintptr(page), uintptr(page), windows.PAGE_NOACCESS, &previous); err != nil {
t.Fatalf("VirtualProtect: %v", err)
}
start := base + uintptr(page) - uintptr(size)
// The address is not Go memory, so it is turned into a pointer without the
// uintptr-to-Pointer conversion vet rejects.
region := unsafe.Slice((*byte)(*(*unsafe.Pointer)(unsafe.Pointer(&start))), size)
region[0] = 4 // ACL_REVISION_DS, the revision that admits object ACEs
binary.LittleEndian.PutUint16(region[2:], uint16(size))
binary.LittleEndian.PutUint16(region[4:], uint16(len(aces)))
offset := 8
for _, ace := range aces {
offset += copy(region[offset:], ace)
}
return (*windows.ACL)(unsafe.Pointer(&region[0]))
}
func sidBytes(t *testing.T, sid *windows.SID) []byte {
t.Helper()
return append([]byte(nil), unsafe.Slice((*byte)(unsafe.Pointer(sid)), int(windows.GetLengthSid(sid)))...)
}
// TestACEAdmissionHelperProcess is the child. Every leg puts an ACL against a
// guard page, so a regression is an access violation, and an access violation
// in the test binary itself would take every other test in the package down
// with it and could be misread as a pass.
func TestACEAdmissionHelperProcess(t *testing.T) {
leg := os.Getenv(aceAdmissionLegEnv)
if leg == "" {
return
}
wanted, err := windows.StringToSid(aceAdmissionTrustee)
if err != nil {
t.Fatal(err)
}
_, required, err := windowsACLAccess(WindowsACLAllowWrite)
if err != nil {
t.Fatal(err)
}
whole := sidBytes(t, wanted)
// The first sub-authority and no more: revision, count and authority all
// match the trustee, so a comparison that believes the count keeps going.
prefix := whole[:12]
inherit := byte(windows.CONTAINER_INHERIT_ACE | windows.OBJECT_INHERIT_ACE)
var acl *windows.ACL
switch leg {
case "plain":
acl = aclEndingAtAGuardPage(t, rawACE(windows.ACCESS_ALLOWED_ACE_TYPE, inherit, uint32(required), whole))
case "object":
acl = aclEndingAtAGuardPage(t, rawACE(testAccessAllowedObjectACEType, inherit, uint32(required), prefix))
case "callback":
acl = aclEndingAtAGuardPage(t, rawACE(testAccessAllowedCallbackACEType, inherit, uint32(required), prefix))
case "truncated":
acl = aclEndingAtAGuardPage(t, rawACE(windows.ACCESS_ALLOWED_ACE_TYPE, inherit, uint32(required), prefix))
case "object then plain":
// An unreadable entry must not end the walk: the grant behind it counts.
acl = aclEndingAtAGuardPage(t,
rawACE(testAccessAllowedObjectACEType, inherit, uint32(required), prefix),
rawACE(windows.ACCESS_ALLOWED_ACE_TYPE, inherit, uint32(required), whole))
default:
t.Fatalf("unknown leg %q", leg)
}
fmt.Printf("carries=%v\n", windowsDACLCarriesGrant(acl, wanted, required, inherit))
}
// ONLY A PLAIN ALLOW OR DENY ACE IS READ AS ONE.
//
// The attestation walks the DACL and compared every entry's would-be SID with
// the capability before it looked at the entry's type. Only ACCESS_ALLOWED_ACE
// and ACCESS_DENIED_ACE keep a SID directly behind the mask; an object ACE has a
// flags word and GUIDs there, and EqualSid is undefined for something that is
// not a SID. The bytes below are arranged so that the misreading is not
// hypothetical: each entry's tail spells the START of the trustee's SID, so its
// revision and sub-authority count match and the comparison runs on for the
// length that count claims, which is past the end of the entry and into a page
// that cannot be read. With the type and size admitted first nothing is read
// past the header, the legs answer false, and the plain entry still answers
// true so the walk has not simply stopped crediting anything.
//
// Each leg runs in a child process. An access violation cannot be recovered in
// Go, and one in this binary would end the package's other tests with it.
// Reported by @jatmn.
func TestOnlyPlainACEsAreReadDuringAttestation(t *testing.T) {
for _, testCase := range []struct {
leg string
want bool
}{
{leg: "plain", want: true},
{leg: "object", want: false},
{leg: "callback", want: false},
{leg: "truncated", want: false},
{leg: "object then plain", want: true},
} {
t.Run(testCase.leg, func(t *testing.T) {
child := exec.Command(os.Args[0], "-test.run=^TestACEAdmissionHelperProcess$", "-test.count=1")
child.Env = append(os.Environ(), aceAdmissionLegEnv+"="+testCase.leg)
output, err := child.CombinedOutput()
if err != nil {
t.Fatalf("the attestation did not survive this entry (%v); an entry was read in a layout its type does not have:\n%s", err, lastLines(string(output), 12))
}
want := fmt.Sprintf("carries=%v", testCase.want)
if !strings.Contains(string(output), want) {
t.Fatalf("want %s, got:\n%s", want, lastLines(string(output), 12))
}
})
}
}
func lastLines(text string, count int) string {
lines := strings.Split(strings.TrimRight(text, "\r\n"), "\n")
if len(lines) > count {
lines = lines[len(lines)-count:]
}
return strings.Join(lines, "\n")
}
// THE TYPE RULE HAS TO HOLD ON ITS OWN. The guard-page legs above are also
// stopped by the size rule, because their tails are short, so they pass with the
// type rule removed. An object or callback entry can just as well be long enough
// to hold the SID its would-be count claims, and then only its type says it is
// not a plain ACE. Here every entry carries the trustee's whole SID at the plain
// offset, which is the layout that would make a misread entry compare equal, and
// only the two plain types may be admitted.
func TestOnlyPlainACETypesAreAdmitted(t *testing.T) {
wanted, err := windows.StringToSid(aceAdmissionTrustee)
if err != nil {
t.Fatal(err)
}
whole := sidBytes(t, wanted)
for _, testCase := range []struct {
name string
aceType byte
admit bool
}{
{name: "allowed", aceType: windows.ACCESS_ALLOWED_ACE_TYPE, admit: true},
{name: "denied", aceType: windows.ACCESS_DENIED_ACE_TYPE, admit: true},
{name: "allowed object", aceType: 0x5, admit: false},
{name: "denied object", aceType: 0x6, admit: false},
{name: "allowed callback", aceType: 0x9, admit: false},
{name: "denied callback", aceType: 0xA, admit: false},
{name: "allowed callback object", aceType: 0xB, admit: false},
{name: "mandatory label", aceType: 0x11, admit: false},
} {
t.Run(testCase.name, func(t *testing.T) {
raw := rawACE(testCase.aceType, 0, 0x1F01FF, whole)
ace, sid := windowsPlainACESID((*windows.ACE_HEADER)(unsafe.Pointer(&raw[0])))
if (ace != nil) != testCase.admit {
t.Fatalf("type 0x%X admitted=%v, want %v", testCase.aceType, ace != nil, testCase.admit)
}
if testCase.admit && !sid.Equals(wanted) {
t.Errorf("an admitted plain entry did not yield its SID")
}
})
}
if ace, _ := windowsPlainACESID(nil); ace != nil {
t.Error("a nil header was admitted")
}
}
+12 -275
View File
@@ -6,7 +6,6 @@ import (
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"unsafe"
@@ -36,160 +35,13 @@ type windowsACLSnapshot struct {
Path string
Descriptor *windows.SECURITY_DESCRIPTOR
Materialized bool
// Identity is the object the forward apply actually modified, captured from
// the open handle. Compensation reopens BY NAME, and a name is not an
// object: see rollbackWindowsACLSnapshots.
Identity windowsObjectIdentity
}
// windowsObjectIdentity identifies a filesystem object independently of the
// name it currently answers to.
type windowsObjectIdentity struct {
volume uint32
high uint32
low uint32
valid bool
}
func windowsIdentityFromHandle(handle windows.Handle) windowsObjectIdentity {
var info windows.ByHandleFileInformation
if err := windows.GetFileInformationByHandle(handle, &info); err != nil {
return windowsObjectIdentity{}
}
return windowsObjectIdentity{
volume: info.VolumeSerialNumber,
high: info.FileIndexHigh,
low: info.FileIndexLow,
valid: true,
}
}
// matches is deliberately false when either side is unknown. A compensation
// that cannot prove it is acting on the object it changed must not act.
func (id windowsObjectIdentity) matches(other windowsObjectIdentity) bool {
return id.valid && other.valid &&
id.volume == other.volume && id.high == other.high && id.low == other.low
}
// windowsACLStampRequest asks the apply to write the runtime setup stamp THROUGH
// THE SAME HANDLE it just applied the capability ACE through.
//
// The stamp exists to prove that the directory a command later uses is the
// object setup granted the ACE to. Writing it afterwards by pathname cannot
// prove that, however carefully the second open is done: the ACE goes on
// through a rooted handle, that handle closes, network setup runs, and only then
// does the marker write re-open the name. A local process that can reach the
// user-owned runtime tree can remove the predictable root in that window and
// put an ordinary directory in its place. The re-open correctly rejects a
// junction, but an ordinary replacement is not a reparse point and is not the
// ACL-bearing object either, so it collects a valid-looking stamp. Marker
// validation then passes over a directory with no capability ACE, and the next
// WRITE_RESTRICTED command fails its cache and TMP writes with setup insisting
// it is current.
//
// Closing that means never naming the target again after the ACE lands. The
// hash is known before the apply, so the stamp can simply ride along.
type windowsACLStampRequest struct {
Root string
PlanHash string
// RootIdentity is the file identity of the runtime directory the SNAPSHOT
// read, and RootIdentified says whether that capture actually succeeded.
//
// TWO OPENS OF ONE NAME ARE NOT ONE OBJECT. The snapshot proved "these prior
// bytes belong to B" through its own handle and then closed it; the apply
// resolved the same pathname again and mutated whatever answered, without
// either of them ever proving B and that object are the same. The root owner
// can rename the prior root aside, put an ordinary directory at the
// predictable name for the snapshot, and restore the original before the
// apply. The setup lease is a sibling and does not bind the root entry.
//
// The consequence was not merely a wrong ACL. On a later failure, stamp
// compensation compared the object it found against the snapshot's identity,
// refused to restore, and left this run's stamp on a directory whose marker
// still described the previous successful setup: a failed setup invalidating a
// good one.
RootIdentity string
RootIdentified bool
}
// windowsACLStampSwapHook fires in the exact window this design closes: after
// the capability ACE is on the object and before the stamp is written. Nil in
// production; a test uses it to replace the runtime root with an ordinary
// directory, which is what a local process would do.
var windowsACLStampSwapHook func(path string)
// windowsACLStampWriteHook replaces the ride-along stamp write. Nil in
// production; a test uses it to reach the post-commit failure path, which no
// ordinary input produces once the bound handle is already open.
var windowsACLStampWriteHook func(path string) error
// verifyStampRootIdentity refuses when the object this apply holds is not the one
// the snapshot read.
//
// windowsACLStampIdentitySwapHook exists so a test can substitute the directory
// in exactly the interval between the two opens, which is otherwise unreachable
// from any ordinary input.
func verifyStampRootIdentity(handle windows.Handle, path string, stamp *windowsACLStampRequest) error {
if !stamp.RootIdentified {
return fmt.Errorf("the sandbox runtime root %s could not be identified when its prior state was recorded, so this setup cannot prove it is about to change the same directory", path)
}
identity, err := handleRuntimeIdentity(handle)
if err != nil {
return fmt.Errorf("identify the sandbox runtime root %s before applying its ACL: %w", path, err)
}
if identity != stamp.RootIdentity {
return fmt.Errorf("the sandbox runtime root %s is no longer the directory this run recorded the prior state of, so applying the ACL and stamp here would attest to an object nobody inspected", path)
}
return nil
}
// windowsACLStampIdentitySwapHook fires in the interval between the snapshot's
// close and the apply's open, which is where a substitution can actually land.
// Nil in production.
var windowsACLStampIdentitySwapHook func(path string)
// windowsSameRuntimeRootPath compares two runtime roots the way the filesystem
// does, so the stamp rides along with the right target regardless of spelling.
// Beside its only caller: in the shared file it was dead code on every other
// platform.
func windowsSameRuntimeRootPath(left, right string) bool {
left = filepath.Clean(strings.TrimSpace(left))
right = filepath.Clean(strings.TrimSpace(right))
if left == "" || right == "" {
return false
}
return strings.EqualFold(left, right)
}
// writeRidingStamp writes the stamp through the handle the capability ACE was
// applied on, or through the test hook when one is installed.
func writeRidingStamp(handle windows.Handle, path string, planHash string) error {
if windowsACLStampWriteHook != nil {
return windowsACLStampWriteHook(path)
}
return writeWindowsRuntimeStampToDirectoryHandle(handle, windowsSandboxRuntimeStampName(planHash), planHash)
}
// restoreWindowsACLThroughHandle puts a captured DACL back on the object the
// handle names, by handle rather than by pathname for the same reason the stamp
// rides along: after the apply, the name is no longer proof of the object.
func restoreWindowsACLThroughHandle(handle windows.Handle, descriptor *windows.SECURITY_DESCRIPTOR) error {
dacl, _, err := descriptor.DACL()
if err != nil {
return fmt.Errorf("read the captured windows DACL: %w", err)
}
return windows.SetSecurityInfo(handle, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION, nil, nil, dacl, nil)
}
func applyWindowsACLPlan(plan WindowsACLPlan) (func() error, error) {
return applyWindowsACLPlanWithStamp(plan, nil)
}
func applyWindowsACLPlanWithStamp(plan WindowsACLPlan, stamp *windowsACLStampRequest) (func() error, error) {
groups := groupWindowsACLPlanByPath(plan)
snapshots := make([]windowsACLSnapshot, 0, len(groups))
for _, group := range groups {
snapshot, applied, err := applyWindowsACLPathGroupWithStamp(group, stamp)
snapshot, applied, err := applyWindowsACLPathGroup(group)
if err != nil {
rollbackErr := rollbackWindowsACLSnapshots(snapshots)
if rollbackErr != nil {
@@ -238,10 +90,6 @@ func groupWindowsACLPlanByPath(plan WindowsACLPlan) []windowsACLPathGroup {
}
func applyWindowsACLPathGroup(group windowsACLPathGroup) (windowsACLSnapshot, bool, error) {
return applyWindowsACLPathGroupWithStamp(group, nil)
}
func applyWindowsACLPathGroupWithStamp(group windowsACLPathGroup, stamp *windowsACLStampRequest) (windowsACLSnapshot, bool, error) {
path := strings.TrimSpace(group.Path)
if path == "" || len(group.Entries) == 0 {
return windowsACLSnapshot{}, false, nil
@@ -253,13 +101,6 @@ func applyWindowsACLPathGroupWithStamp(group windowsACLPathGroup, stamp *windows
// elevated setup a lower-privileged local user could swap the target for a
// symlink/junction between operations and redirect the ACL change onto a
// system object it never validated (issue #728, a TOCTOU privilege boundary).
// THE INTERVAL IS HERE, BEFORE THIS OPEN. The snapshot read its object through
// its own handle and closed it; a handle already open cannot be renamed out
// from under itself, so the only place a substitution can land is between that
// close and this open. The hook exists so a test can put it exactly there.
if windowsACLStampIdentitySwapHook != nil && stamp != nil && windowsSameRuntimeRootPath(stamp.Root, path) {
windowsACLStampIdentitySwapHook(path)
}
materialized := false
handle, isDir, err := openWindowsACLTarget(path)
if err != nil {
@@ -328,18 +169,6 @@ func applyWindowsACLPathGroupWithStamp(group windowsACLPathGroup, stamp *windows
} else {
nextDACL = baseDACL
}
// BEFORE THE FIRST MUTATION, NOT BEFORE THE STAMP. Checking later would leave
// the ACL already applied to the wrong object, which is the change that
// matters most.
//
// Fails CLOSED. An unestablished identity refuses rather than passing, because
// "we could not tell" is exactly the case this exists for; treating it as
// permission would make the guard a no-op precisely when it is needed.
if stamp != nil && windowsSameRuntimeRootPath(stamp.Root, path) {
if err := verifyStampRootIdentity(handle, path, stamp); err != nil {
return fail(err)
}
}
if err := windows.SetSecurityInfo(handle, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION, nil, nil, nextDACL, nil); err != nil {
return fail(fmt.Errorf("apply windows ACL for %s: %w", path, err))
}
@@ -347,33 +176,8 @@ func applyWindowsACLPathGroupWithStamp(group windowsACLPathGroup, stamp *windows
// The handle has served its purpose (read+write bound to one object) and is
// closed now — rollback re-opens no-follow rather than holding a handle for
// the whole sandbox lifetime, since one caller discards the rollback closure.
// BEFORE THE HANDLE CLOSES, and only for the target the stamp names. This is
// the whole point: the ACE and the stamp land on one kernel object with no
// pathname resolution in between.
if stamp != nil && windowsSameRuntimeRootPath(stamp.Root, path) {
if windowsACLStampSwapHook != nil {
windowsACLStampSwapHook(path)
}
if err := writeRidingStamp(handle, path, stamp.PlanHash); err != nil {
// THE ACE AND ITS STAMP ARE ONE TRANSACTION.
//
// SetSecurityInfo above has already committed, and this function
// returns no rollback closure on its error paths, so the caller has
// nothing to compensate with. Without this restore a failed setup
// reports failure while leaving the capability grant on a pre-existing
// runtime root: the tree stays writable by the restricted token and
// nothing on disk records that it should not be.
if restoreErr := restoreWindowsACLThroughHandle(handle, descriptor); restoreErr != nil {
return fail(fmt.Errorf("stamp windows ACL target %s: %w (the committed ACL could not be restored either: %v)", path, err, restoreErr))
}
return fail(fmt.Errorf("stamp windows ACL target %s: %w", path, err))
}
}
// Captured while the handle is still open, because this is the last moment
// the object and the name are known to be the same thing.
identity := windowsIdentityFromHandle(handle)
_ = windows.CloseHandle(handle)
return windowsACLSnapshot{Path: path, Descriptor: descriptor, Materialized: materialized, Identity: identity}, true, nil
return windowsACLSnapshot{Path: path, Descriptor: descriptor, Materialized: materialized}, true, nil
}
// openWindowsACLTarget opens path for reading and rewriting its DACL without
@@ -385,24 +189,6 @@ func applyWindowsACLPathGroupWithStamp(group windowsACLPathGroup, stamp *windows
// is exactly the redirection this guard exists to prevent. A missing target is
// surfaced as os.ErrNotExist so the caller's materialize path still fires.
func openWindowsACLTarget(path string) (windows.Handle, bool, error) {
// A RUNTIME ROOT IS OPENED BY HANDLE, NOT BY NAME.
//
// FILE_FLAG_OPEN_REPARSE_POINT below protects only the FINAL component; every
// ancestor in the pathname is resolved normally. The runtime tail is the one
// part of the tree Zero creates and therefore the one part an unprivileged
// local user can predict and pre-empt, and junctions need no privilege, so a
// swap at an owned ancestor between the last check and this open redirects the
// elevated capability ACL into a directory of their choosing.
//
// Everything else here is the user's own tree, where an ancestor reparse point
// is ordinary configuration and following it is correct.
if _, _, owned := windowsSandboxRuntimeOwnedTail(path); owned {
handle, err := openWindowsRuntimeTailDirectory(path, windows.READ_CONTROL|windows.WRITE_DAC|windows.FILE_TRAVERSE)
if err != nil {
return 0, false, err
}
return handle, true, nil
}
utf16Path, err := windows.UTF16PtrFromString(path)
if err != nil {
return 0, false, fmt.Errorf("encode windows ACL target %s: %w", path, err)
@@ -816,76 +602,27 @@ func rollbackWindowsACLSnapshots(snapshots []windowsACLSnapshot) error {
var errs []error
for index := len(snapshots) - 1; index >= 0; index-- {
snapshot := snapshots[index]
// A NAME IS NOT AN OBJECT ONCE THE APPLY HANDLE HAS CLOSED.
//
// 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 these names again. Opening no-follow stops
// a reparse point but accepts an ORDINARY directory moved into the name
// since: the original is renamed aside, a substitute is created, and
// rollback then restores the pre-apply DACL onto the substitute, strips a
// stamp there, and reports success, while the moved original keeps this
// run's capability ACE and a valid stamp. Setup would claim a completed
// rollback with the modified object still reachable elsewhere, having
// also mutated something it never touched going forward.
//
// So every compensation proves it holds the object it changed, and
// otherwise leaves the substitute alone and says plainly what was left
// behind.
if snapshot.Materialized {
// NOT identity-checked, and the reason is a real limit rather than an
// oversight. A materialized target is one this run created, and its
// plan routinely denies Everyone read (that is what a protected
// metadata carve-out IS), so the attributes identity needs cannot be
// read back even by the owner: the check turned rollback of every
// materialized directory into "Access is denied". Establishing
// identity here would mean holding the apply handle open until the
// last failure point, which is a larger change than this one.
if err := os.RemoveAll(snapshot.Path); err != nil {
errs = append(errs, fmt.Errorf("remove materialized windows ACL target %s: %w", snapshot.Path, err))
}
continue
}
// ONE OPEN, AND THE IDENTITY COMES FROM THE HANDLE THAT GETS MUTATED.
//
// Checking identity through a separate open and then resolving the name
// again for the restore proves nothing about the second handle: the two
// opens are a check-then-use, and the fact established (this NAME resolved
// to the object we changed) is not the fact the write depends on (this
// HANDLE is that object). Opening once and asking the handle who it is
// removes the window rather than narrowing it.
dacl, _, err := snapshot.Descriptor.DACL()
if err != nil {
errs = append(errs, fmt.Errorf("read rollback windows DACL for %s: %w", snapshot.Path, err))
continue
}
// Re-open no-follow rather than restoring by pathname: the restore must
// land on the real object, not a reparse point swapped in since apply. The
// residual window is small because the target is ACL-restricted by now, but
// a handle keeps the restore honest. On a materialized-target rollback we
// remove it above, so only the restore-existing path opens here.
handle, _, err := openWindowsACLTarget(snapshot.Path)
if err != nil {
errs = append(errs, fmt.Errorf("re-open windows ACL target %s for rollback: %w", snapshot.Path, err))
continue
}
// TWO DIFFERENT REFUSALS. A snapshot whose identity was never established
// fails the match below exactly as a replaced target does, and both must
// leave the object alone, but telling the operator it was replaced when it
// was never identified sends them looking for a substitution that did not
// happen. Reported by CodeRabbit.
if !snapshot.Identity.valid {
_ = windows.CloseHandle(handle)
errs = append(errs, fmt.Errorf(
"windows ACL target %s could not be identified when this setup changed it, "+
"so rollback cannot prove it is the same object and leaves it with this run's grant",
snapshot.Path))
continue
}
if !snapshot.Identity.matches(windowsIdentityFromHandle(handle)) {
_ = windows.CloseHandle(handle)
errs = append(errs, fmt.Errorf(
"windows ACL target %s is no longer the object this setup modified; "+
"leaving the replacement untouched, and the original still carries this run's grant",
snapshot.Path))
continue
}
dacl, _, err := snapshot.Descriptor.DACL()
if err != nil {
_ = windows.CloseHandle(handle)
errs = append(errs, fmt.Errorf("read rollback windows DACL for %s: %w", snapshot.Path, err))
continue
}
if err := windows.SetSecurityInfo(handle, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION, nil, nil, dacl, nil); err != nil {
errs = append(errs, fmt.Errorf("rollback windows ACL for %s: %w", snapshot.Path, err))
}
@@ -48,63 +48,6 @@ func TestApplyWindowsACLPathGroupHandleBasedRoundTrip(t *testing.T) {
}
}
// Both refusals leave the target alone, but they are different facts: one
// target was never identified, the other was replaced. The operator has to be
// told which, or a missing identity reads as a substitution that never happened.
func TestRollbackTellsAnUnidentifiedTargetFromAReplacedOne(t *testing.T) {
dir := t.TempDir()
group := windowsACLPathGroup{
Path: dir,
Entries: []WindowsACLEntry{{
Action: WindowsACLAllowWrite,
Path: dir,
Capability: "S-1-1-0",
}},
}
snapshot, applied, err := applyWindowsACLPathGroup(group)
if err != nil || !applied {
t.Fatalf("apply: applied=%v err=%v", applied, err)
}
t.Cleanup(func() {
if err := rollbackWindowsACLSnapshots([]windowsACLSnapshot{snapshot}); err != nil {
t.Errorf("restore: %v", err)
}
})
other := t.TempDir()
handle, _, err := openWindowsACLTarget(other)
if err != nil {
t.Fatalf("open the other directory: %v", err)
}
otherIdentity := windowsIdentityFromHandle(handle)
_ = windows.CloseHandle(handle)
if !otherIdentity.valid {
t.Fatal("SETUP INVALID: could not identify the other directory")
}
for _, tc := range []struct {
name string
identity windowsObjectIdentity
want string
notWant string
}{
{"never identified", windowsObjectIdentity{}, "could not be identified", "no longer the object"},
{"replaced", otherIdentity, "no longer the object", "could not be identified"},
} {
t.Run(tc.name, func(t *testing.T) {
changed := snapshot
changed.Identity = tc.identity
err := rollbackWindowsACLSnapshots([]windowsACLSnapshot{changed})
if err == nil {
t.Fatal("rollback acted on a target it could not prove was the one it changed")
}
if !strings.Contains(err.Error(), tc.want) || strings.Contains(err.Error(), tc.notWant) {
t.Fatalf("rollback error = %q, want it to say %q and not %q", err, tc.want, tc.notWant)
}
})
}
}
// dirDeniesReadSID reports whether path's DACL has a DENY ACE for wantSID whose
// mask covers FILE_GENERIC_READ (DenyRead shape) without the full write-probe
// mask of experimental DenyWrite.
@@ -1,12 +0,0 @@
//go:build !windows
package sandbox
// windowsACLPlanApplied reports whether the objects a plan names still carry the
// grants it describes.
//
// Off Windows there is no DACL to read and nothing that consumes one, so the
// marker's own comparisons are the whole answer. Declared here rather than
// guarded at the call site so the setup-to-command contract keeps one shape on
// every platform.
var windowsACLPlanApplied = func(WindowsACLPlan) bool { return true }
@@ -1,7 +0,0 @@
//go:build windows
package sandbox
// windowsACLPlanApplied reads the real security descriptors. See
// windowsACLPlanStillApplied.
var windowsACLPlanApplied = windowsACLPlanStillApplied
@@ -1,207 +0,0 @@
//go:build windows
package sandbox
import (
"os"
"strings"
"unsafe"
"golang.org/x/sys/windows"
)
// windowsACLPlanStillApplied reports whether the objects a plan names still
// carry the grants it describes.
//
// THE MARKER FINGERPRINTS A PLAN, NOT AN OBJECT. Its hash is computed from
// pathnames and entries, so it records what SHOULD be granted and can never
// establish that whatever directory currently answers to that name has received
// it. The runtime root makes the difference reachable rather than theoretical:
// it is deterministic and disposable, so cleanup removes the tree, the next
// command's parent recreates the same pathname with ordinary inherited
// permissions, and the plan hash is unchanged. The fast path then skipped the
// apply entirely and the WRITE_RESTRICTED child could not write TMP or its
// language and package caches, with nothing failing to say why.
//
// PRESENCE OF THE SID IS NOT THE CONTRACT. What the child needs is the grant
// windowsACLAccess actually creates, and on a directory it needs to reach
// descendants. An ACE that still names the capability SID but has been reduced
// to a metadata or read-only mask, or that no longer propagates, leaves a
// runtime root that attests as healthy and returns ACCESS_DENIED on the first
// write into TMP or a cache: the same silent unusable runtime the attestation
// exists to eliminate. So the check compares the effective grant.
//
// Attesting costs one security-descriptor read per allow entry, on a path that
// is about to create a process, and it covers every reason a grant can be
// missing or insufficient rather than only recreation by the parent.
//
// Anything unprovable reads as "not applied". Re-applying an adequate grant is
// idempotent and cheap; skipping an inadequate one produces a sandbox that
// silently cannot write.
func windowsACLPlanStillApplied(plan WindowsACLPlan) bool {
for _, entry := range plan.Entries {
if entry.Action != WindowsACLAllowWrite {
// Only the allow grants decide whether the child can run. A missing
// DENY is a weaker boundary rather than a broken one, and re-applying
// the whole plan is what fixes either.
continue
}
if strings.TrimSpace(entry.Path) == "" || strings.TrimSpace(entry.Capability) == "" {
continue
}
_, required, err := windowsACLAccess(entry.Action)
if err != nil {
return false
}
if !windowsPathCarriesGrant(entry.Path, entry.Capability, required) {
return false
}
}
return true
}
// windowsPathCarriesGrant reports whether path's DACL gives trustee at least
// required, and whether that grant reaches descendants when path is a directory.
func windowsPathCarriesGrant(path, trustee string, required windows.ACCESS_MASK) bool {
wanted, err := windows.StringToSid(trustee)
if err != nil {
return false
}
info, err := os.Stat(path)
if err != nil {
return false
}
// windowsExplicitAccessEntries sets SUB_CONTAINERS_AND_OBJECTS_INHERIT on a
// directory, and that is what lets the child create TMP and cache entries
// underneath. A grant on the directory alone would not.
needInherit := uint8(0)
if info.IsDir() {
needInherit = windows.CONTAINER_INHERIT_ACE | windows.OBJECT_INHERIT_ACE
}
descriptor, err := windows.GetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION)
if err != nil {
return false
}
dacl, _, err := descriptor.DACL()
if err != nil || dacl == nil {
return false
}
return windowsDACLCarriesGrant(dacl, wanted, required, needInherit)
}
// windowsPlainACEHeaderSize is the part of an ACCESS_ALLOWED_ACE or
// ACCESS_DENIED_ACE in front of its SID: the ACE header and the access mask.
const windowsPlainACEHeaderSize = 8
// windowsMinimalSIDSize is a SID with no sub-authorities: revision, count and
// the six-byte identifier authority.
const windowsMinimalSIDSize = 8
// windowsPlainACESID returns the SID of a plain allow or deny ACE, or nil when
// the entry is not one or cannot be shown to hold a whole SID.
//
// THE TYPE DECIDES THE LAYOUT, SO IT IS READ FIRST. Only ACCESS_ALLOWED_ACE and
// ACCESS_DENIED_ACE keep their SID directly behind the mask. An object ACE puts
// a flags word and up to two GUIDs there, and the loop below used to cast every
// entry to the plain layout and hand whatever sat at that offset to EqualSid,
// whose behaviour is undefined for a structure that is not a SID. A sub-authority
// count read out of a flags word is a length nobody vouched for. The entry is
// now admitted on its header alone, before any byte past the header is touched.
//
// Entries in any other layout are skipped, which is the conservative reading for
// this attestation: an allow that is not read is not credited, so the worst case
// is one idempotent re-apply. A conditional or object DENY is not evaluated
// either. Setup never writes one, and nothing else has a reason to name a
// capability SID Zero minted.
//
// The size test is the same rule one level down. AceSize is what the ACL itself
// says the entry occupies, so a SID whose own count claims more than that is not
// a SID this function can stand behind, and unprovable reads as not applied.
// Reported by @jatmn.
func windowsPlainACESID(header *windows.ACE_HEADER) (*windows.ACCESS_ALLOWED_ACE, *windows.SID) {
if header == nil {
return nil, nil
}
if header.AceType != windows.ACCESS_ALLOWED_ACE_TYPE && header.AceType != windows.ACCESS_DENIED_ACE_TYPE {
return nil, nil
}
if uintptr(header.AceSize) < windowsPlainACEHeaderSize+windowsMinimalSIDSize {
return nil, nil
}
ace := (*windows.ACCESS_ALLOWED_ACE)(unsafe.Pointer(header))
sid := (*windows.SID)(unsafe.Pointer(&ace.SidStart))
// The count is the second byte of a SID, inside the eight bytes just shown to
// be there.
subAuthorities := uintptr(*(*byte)(unsafe.Add(unsafe.Pointer(sid), 1)))
if uintptr(header.AceSize) < windowsPlainACEHeaderSize+windowsMinimalSIDSize+4*subAuthorities {
return nil, nil
}
return ace, sid
}
// windowsDACLCarriesGrant is the decision windowsPathCarriesGrant makes once it
// has the DACL in hand. It is separate so the rule about which entries may be
// read can be tested against an ACL built in memory, including layouts a
// filesystem would not store.
func windowsDACLCarriesGrant(dacl *windows.ACL, wanted *windows.SID, required windows.ACCESS_MASK, needInherit uint8) bool {
if dacl == nil || wanted == nil {
return false
}
var granted windows.ACCESS_MASK
for index := uint32(0); index < uint32(dacl.AceCount); index++ {
var header *windows.ACE_HEADER
if err := windows.GetAce(dacl, index, (**windows.ACCESS_ALLOWED_ACE)(unsafe.Pointer(&header))); err != nil {
return false
}
ace, sid := windowsPlainACESID(header)
if ace == nil {
continue
}
if !sid.Equals(wanted) {
continue
}
switch ace.Header.AceType {
case windows.ACCESS_DENIED_ACE_TYPE:
// A deny naming the capability itself takes precedence over any allow,
// so the grant cannot be proven adequate. Fail closed and re-apply.
if ace.Mask&required != 0 {
return false
}
case windows.ACCESS_ALLOWED_ACE_TYPE:
// INHERIT_ONLY DOES NOT APPLY TO THE OBJECT ITSELF. An ACE carrying it
// grants descendants and grants the directory nothing, so the child can
// still be refused FILE_ADD_FILE on the runtime root while every other
// bit here looks satisfied. Checking the inherit flags alone would take
// the propagation half of the grant as evidence for the whole of it,
// which is the substitution this attestation exists to stop making.
if ace.Header.AceFlags&windows.INHERIT_ONLY_ACE != 0 {
continue
}
// AND PROPAGATION THAT STOPS AT THE IMMEDIATE CHILDREN IS NOT PROPAGATION.
//
// NO_PROPAGATE_INHERIT_ACE lets an ACE be inherited once and then stops:
// the children get it, their children do not. The flag test below reads
// only the two inherit bits, so OI|CI|NP satisfied it and the whole mask
// was credited, while the runtime tree's real consumers sit a level
// deeper - cache/npm, cache/go-build, data/go-mod, and the package files
// under them, which sandboxRuntimeEnvironment points npm_config_cache,
// GOCACHE and GOMODCACHE at. Attestation said the grant was adequate and
// the writes were refused. Reported by @jatmn.
//
// Gated on needInherit so a file entry, where inherit flags mean nothing,
// is unaffected. The apply path never sets NP for an AllowWrite entry, so
// this cannot make a grant Zero itself wrote look insufficient.
if needInherit != 0 && ace.Header.AceFlags&windows.NO_PROPAGATE_INHERIT_ACE != 0 {
continue
}
// And only ACEs that propagate count towards a directory's grant, since a
// non-inheriting one leaves descendants ungranted.
if ace.Header.AceFlags&needInherit != needInherit {
continue
}
granted |= ace.Mask
}
}
return granted&required == required
}
Loaded 100 of 166 files, more files were not shown because too many files have changed in this diff. Show more