* test(tui): pin the cwd in tests that assert text is absent from the view
Nine tests built their model with an empty Options.Cwd, so newModel took
the package directory and the title bar showed the developer's real
branch and checkout path. Each then asserted that some text was absent
from the whole view, and failed wherever that branch or path happened to
contain it: TestSuggestionOverlayRenders on a branch with /model in its
name, TestSuggestionOverlayCapsRowsWithoutMoreText on a branch or path
containing "more" or "compact", the provider wizard tests on "offline",
"chatgpt" or "gpt-4.1", and so on.
They now pin Options.Cwd to isolatedCwd, a path that does not exist, so
no branch is read and the path is the same on every machine. A guard
test checks that isolatedCwd stays outside any git checkout.
* test(tui): make isolatedCwd fully qualified and absent by construction
The fixed /workspace/zero path could exist on some machine, a dev
container for one, and newModel would then read whatever is there. On
Windows it was also only drive-relative: \workspace\zero is not a fully
qualified path.
isolatedCwd is now a child of the home TestMain isolates that nothing
creates, so it is fully qualified, absent and outside any checkout on
every platform, and the title bar shows ~/not-a-checkout everywhere. A
t.TempDir path would have put the user name and the test name into the
view. The guard test checks all three properties.
* test(tui): clear the branch in view-test models instead of trusting the cwd
isolatedCwd is a child of the isolated home, which lives under the
temporary directory. With TMPDIR inside a checkout, gitBranch walks up
from that absent path to the checkout and puts its branch in the title
bar, so the guard failed on an ordinary branch and a colliding branch
broke the nine view tests again.
newViewTestModel now builds those models: it sets the isolated cwd and
clears the branch after construction, since no path can promise there
is no checkout above it. The guard keeps only what a path can promise
(fully qualified and absent), and a new test puts the home inside a
checkout on feat/model-picker and checks that neither that branch nor
the real checkout path reaches the view.
* test(tui): assert no checkout path reaches the view in the enclosing-checkout test
The test checked the branch strings and that the isolated cwd shows,
but a view could show both the isolated cwd and a real checkout path
without failing it. It now also asserts that neither the enclosing
checkout nor the directory the tests run from appears in the view.
The variable turns off both the models.dev fetch and the cached overlay,
and four tests in modelsdev_test.go inherited it from the shell. With it
set, TestRefreshModelsDevCacheRejectsBadBodyWithoutClobbering and
TestDefaultModelEntriesAppliesFreshCache fail, and
TestCachedModelsDevProvidersIgnoresStaleCache and
TestCachedModelsDevProvidersRequiresOptIn pass without reaching the
check they are named for: the nil they expect comes from the variable.
Clear it in each of them, as the two sibling refresh tests already do.
GHSA-5r4p-p66f-jhc7 (moderate): hono's serveStatic decodes the request
path a second time, so middleware on static paths can be bypassed.
Versions 4.12.4 to 4.13.10 are affected and main pins 4.13.9 through
tuistory, which release packaging installs as a local-control helper.
npm update takes hono to 4.13.13, inside every range that asks for it.
package.json is unchanged and nothing else in the lock moves.
* fix(oauth): use the crash-safe secret store and sync token publications
The encrypted-file OAuth backend carried its own AES-GCM secret implementation that had drifted from internal/securefile: a crash during first login left <secret>.lock behind and every later Save timed out instead of reclaiming it, and the secret and token blobs were published without fsync.\n\nUse securefile.Crypter with an opt-in publication directory so the secret still publishes through the sandbox-denied .secret.publish directory, add file and directory barriers to the encrypted token blob, and extend securefile with the publication directory plus destination-directory sync.\n\nMove the concurrent-creation and wrong-sized-secret coverage to securefile, add a stale-lock regression through the OAuth store, and test publication-dir use, sync ordering, sync-failure preservation, and retry after a failed secret sync. The stale-lock test fails on the previous code.
* test(oauth): cover post-rename directory-sync failures
The existing failure tests inject a file sync error before the rename. Add coverage for a destination-directory barrier failure after the token or secret has been atomically replaced: the write must report the error and the newly published bytes must remain readable. Also cover the corresponding securefile secret path, including its expected size.
* fix(securefile): never clobber a newer lock when restoring a reclaimed one
reclaimStaleLock could rename a lock that had just been replaced, then restore the quarantined copy with os.Rename, replacing a third holder's new lock. Both holders could then publish different secrets for the same file. Restore with O_CREATE|O_EXCL and discard the quarantined copy when the destination is already held.
Extend the directory-sync failure test to prove a later caller actually loads the published secret, not just its size. Add restore tests for the free and already-held cases.
govulncheck now reports GO-2026-6809 (crypto/x509 distrusts compromised
intermediate certificates) against Go 1.26.9, reachable through
daemon.WriteFrame, so Security & Code Health fails on main and on every
open PR. It is fixed in 1.26.10.
CI and release builds read the toolchain from go.mod. The stated
source-build minimum in README.md, README_ZH.md and docs/INSTALL.md
moves with it.
runExec starts a background models.dev download unless
ZERO_DISABLE_MODELS_FETCH is set. TestRunExecImageAttachmentHistory moved
the user directories into a TempDir by hand but left the fetch on, so a
5.7 MB download landed in the TempDir while its cleanup ran, and the
macOS runner failed with "unlinkat ... directory not empty". The two
older image tests had no isolation and fetched into the real user cache.
Use isolateCLIUserState in all three, which also points the cache path
into the fixture and turns the fetch off.
* fix(tools): grep stops where a file's line breaks stop
scanGrepFile treated EOF after a trailing line break - and an empty file -
as the START of another line, and handed the matcher an empty line one
number past the real content. Every pattern that matches an empty string
then reported a match on a line no reader sees:
a.txt holds "x\n"; grep pattern "^$"
-> a.txt:2: (grep -c '' GNU-style reports no such line)
same file, pattern ".*", count mode
-> "2 matches found" (one real line, one phantom)
an empty file, pattern "^$"
-> a.txt:1: (the file has no lines at all)
read_file and the file tracker's countLines already state the rule the
scan violated: "the trailing newline does not open a line", and an empty
file has no lines. The scan now stops there too, so anchored and
empty-matching patterns can only match the lines the content carries.
Real matches, numbering, count-per-hit accounting for non-empty patterns,
file mode, glob and symlink handling are unchanged.
* test(tools): make the grep line-number parity test catch the phantom
The parity test matched only "needle", a pattern that cannot match the
empty line the old EOF branch invented, so its absence-of-line-4
assertion passed on the code being fixed. The pattern now also matches an
empty line, and the expected output is asserted exactly: on the pre-fix
scanner the same run reported
grep = "a.txt:2: needle two\na.txt:4: ",
want exactly the line read_file numbers 2
so the phantom is visible right beside the real line, and the test is
about the numbering claim it states. Also gofmt-aligns the case table
that made the CI formatting check fail.
Two credentials glued end to end share one recovering property: the second
one's trigger prefix is where redaction can re-anchor. For fixed-length
bodies that anchor is exact (AKIA{16} stops at the next key), which is the
case the anchored end-chaining always covered. For unbounded bodies it
isn't: github's [A-Za-z0-9]{36,} body eats the literal "ghp" of the token
glued behind it, so the first match ends PAST the second trigger, the scan
that re-anchors at the span end finds nothing there, and the second
credential leaves the output as +_bbbb... / .eyJzdWIi... plaintext:
RedactString("ghp_aaa..36a..ghp_bbb..36b..")
-> "[REDACTED]_bbbb......" (second token's body printed in full)
RedactString(jwtA+jwtB)
-> "[REDACTED].eyJzdWIiOiI5ODc2...SA" (second token's payload+sig)
The same holds across shapes (a github key directly feeding "sk-ant-..."
through its body) and for three tokens in one run.
redactAdjacent now follows the run a match starts: at each boundary it
re-binds the match at the EARLIEST trigger occurrence inside the span that
stills validates as its shape (the head before the trigger must match the
anchored pattern exactly, and the run chained from the trigger must reach
past the span, so a mid-body lookalike and a mid-span cut that recovers
nothing leave the span alone), and continues under whichever shape matched
the swallowed trigger. Candidate examination is capped at
maxTriggerOccurrences per span so body spam of trigger lookalikes costs
what the span costs, and the first validating cut wins.
End-chaining for fixed-length bodies, single-credential shapes, custom
replacement templates, and the split path's announced no-op-for-contiguous
text guarantee are all unchanged; the regression tests pin the glued pairs
that previously leaked plus the AWS chaining and lone-token cases.
A snapshot whose identity could not be read at apply time failed the
rollback match the same way a replaced target does, and the operator was
told the target had been replaced. Both still leave the object alone;
the message now says which one happened. Reported by CodeRabbit.
The setup check read the marker's runtime root once to decide whether it
is current, then read the marker again to build the profile it
fingerprints. Reuse the first value, so a marker rewritten between the
two reads cannot put an unchecked root into the fingerprint. Reported by
CodeRabbit.
#1149 was opened before #1105 moved the module to github.com/Twigpine/zero
and merged right after it, so its two imports of internal/gitsafe still
used the old path. Nothing provides github.com/Gitlawb/zero any more, so
main stopped building at 2ffe1831.
* fix(sessions): preserve image attachment summaries on resume
Persist TUI-compatible attachment counts for ACP and exec user events.
Restore readable markers in live history, session replay and resumed
prompts without storing or resending historical image data.
Cover image-only and mixed prompts, restart recovery, malformed metadata
and the aborted-turn boundary with regression tests.
Fixes#1135
* fix(workspaceindex): follow the current module path
Use github.com/Twigpine/zero/internal/gitsafe in the ignore scanner and its
tests. The upstream module rename preceded the scanner's gitsafe change,
leaving these two imports on the old path and preventing the rebased tree
from compiling.
* fix(sessions): count only images accepted by the model
Take ACP and exec attachment counts after the vision capability gate,
matching the TUI's persisted attachment semantics. Update the ignored-image
regressions to require zero saved images and no historical image marker.
Refs #1135
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.
* fix(workspaceindex): run Scan's git calls through gitsafe
Scan asks git for ignored paths on every turn with its own gitCommand, which
turned off core.fsmonitor and dropped the repository-location variables but
still let a clean filter or diff driver from the repository's own config run
during status. It now goes through gitsafe, like the TUI and the scratch-file
warning: hooks are off as well, and a repository whose config defines a filter
or diff command takes the non-git path. The status call also passes
--ignore-submodules=dirty.
Scan makes two git calls, so gitsafe gains Check, which reads the repository
config once and builds several commands from it. Command is now Check plus one
command, and each command gets its own copy of the environment.
* test(workspaceindex): pin Scan's fallback when gitsafe refuses the repository
A repository whose own config defines a filter or diff command is refused by
gitsafe, and Scan then takes the non-git path. Scan reaches gitsafe.Check
through a package variable now, so a test can stand in for such a repository
without configuring one that names a command, and
TestScanFallsBackWhenGitsafeRefusesTheRepository checks both sides: with git
consulted, .gitignore decides and the fixed list does not apply; refused, the
fixed list applies and .gitignore is not read.
Update the Go module path, imports, install scripts, docs, workflows,
packaging and action defaults from Gitlawb/zero to Twigpine/zero. The npm
scope (already @twigpine/zero on main, with its @gitlawb/zero migration
notes), LICENSE, action author, the OpenGateway provider and historical
CHANGELOG links are unchanged.
The X-AIMLAPI-Integration-Repo header keeps Gitlawb/zero: it is what
AIMLAPI sees for attribution, so changing it needs a maintainer's yes.
docs/INSTALL.md keeps `go install github.com/Gitlawb/zero/...`, because
every tagged release so far declares that module path. The Homebrew
formula comment names the Twigpine/homebrew-tap tap.
Co-authored-by: Claude Opus 5.5 <[email protected]>
* feat(sandbox): support self-exec pattern to eliminate separate helper binary
Linux sandboxing previously required distributing a separate 'zero-linux-sandbox'
binary and looking it up on PATH.
This wires an internal '__sandbox-helper' entrypoint directly in cmd/zero/main.go
and configures findLinuxSandboxHelperCommand to fallback to os.Executable(),
enabling single-binary zero distributions with fully functional sandboxing.
* fix(sandbox): prefer PATH helper before self-exec fallback
Colocated zero-linux-sandbox stays first. A standalone helper on PATH
is used next. Self-exec of the main zero binary is only the last
fallback, so an installed helper is not ignored.
* feat(sandbox): clean formatting in main.go and add self-exec unit tests
* feat(sandbox): insert __sandbox-helper verb in bwrap plan for self-exec binary
* fix(sandbox): drive Linux selection from the self-exec helper resolver
Availability and command-plan construction now share the same helper
resolution and keep the __sandbox-helper prefix on the backend, so a
main-only install with bwrap reaches native sandboxing.
* fix(sandbox): self-exec only the zero main binary, not test binaries
* fix(sandbox): carry standalone vs self-exec identity into the inner bwrap plan
A versioned physical helper (zero-linux-sandbox-v1) must not receive
__sandbox-helper. The inner plan uses an explicit SelfExec flag, threaded
from argv, instead of guessing from filepath.Base.
* docs(install): describe self-exec as the last helper fallback
Self-execution runs only when neither a colocated nor a PATH
zero-linux-sandbox is available.
* docs(install): state colocated, PATH, then self-exec helper order
---------
Co-authored-by: cl-ment <[email protected]>
* perf(redaction): add fast-path byte checks to eliminate regex allocations on clean strings
Evaluating cascading regular expressions across every log line, tool output,
and session event incurred ~23 µs and 51 heap allocations even on strings with
zero secrets.
This introduces fast-path substring checks before invoking expensive regex
substitutions, yielding a 70x speedup and 0 B/op heap allocation on clean
strings.
* test(redaction): lengthen ghp_ fixtures and cover conditional gates
GitHub classic tokens need 36 characters after ghp_. Benchmarks and
shape tests now use a matching fixture. A table-driven test checks each
fast-path gate, including near-misses and case-insensitive Authorization.
* perf(redaction): structure triggered patterns and avoid allocation in header case checks
* test(redaction): cover secret-header and URL-password gates
Keep the redaction marker literal after url.URL rewrites userinfo, so
callers still see [REDACTED] instead of a percent-encoded form.
* fix(redaction): unescape only the generated URL password
A path or query that already contains the encoded redaction marker
must stay encoded. Only the userinfo password is rewritten in the
clear [REDACTED] form.
* fix(redaction): re-escape URL username after parse
Username() is decoded. Re-encoding with url.User keeps control bytes
out of the redacted URL so a %0AAuthorization userinfo cannot inject
a header after the header pass.
* fix(redaction): fail closed on password URLs without a host
* test(redaction): isolate query-gate coverage from assign and text secrets
* fix(redaction): fold Unicode U+017F and U+212A in containsCaseInsensitive
Go (?i) regular expressions apply Unicode simple case folding, where U+017F (ſ) folds to 's' and U+212A (K) folds to 'k'. Teach containsCaseInsensitive to fold these runes to ASCII lowercase with zero allocations so case-insensitive header gates cannot be bypassed by Unicode-folded equivalents.
---------
Co-authored-by: cl-ment <[email protected]>
* feat(providers): add Concentrate provider preset
Register Concentrate (https://api.concentrate.ai/v1) as an
OpenAI-compatible catalog provider. Concentrate is already listed in the
README Partners section; this adds the provider itself.
- models.dev has no Concentrate provider, so the id `concentrate` and the
key name CONCENTRATE_API_KEY match OpenClaude's Concentrate
integration. `Concentrate AI`, `concentrate.ai` and `concentrate-ai`
resolve to it.
- Default model deepseek-v4-flash-0731: the dated id behind the
deepseek-v4-flash alias that OpenClaude uses. Concentrate's /v1/models
keeps the rolling name only in `aliases`, and onboarding keeps the
default selected only when it matches a live id, so an alias default
made setup land on the first model in the list (claude-fable-5).
- Curated models: deepseek-v4-flash-0731 (default), deepseek-v4-pro,
kimi-k3 and glm-5.3. Context windows (1,048,576 each), tool calling and
thinking support come from Concentrate's public /v1/models.
- Regression test: a Concentrate-shaped live list must contain the
preset's default model.
- README and README_ZH: key line and setup command.
* test(providers): prove the Concentrate default check runs on the live list
The regression test passed whenever the default model was in the result,
and the curated fallback also contains it. Assert that the mock served
/v1/models and that the result includes claude-fable-5, a model only the
live response lists, so a silent fall back to the curated catalog fails.
* fix(cli): let providers subcommands run with no active provider
`zero providers list`, `use`, `remove` and `rename` exited 3 with
`no active provider configured: active provider "" not found` as soon as the
environment supplied more than one provider and config.json had no active
provider: normalizeProvidersWithOptions only elects an active provider by
itself when there is exactly one, and resolveCommandCenterConfig treated the
resulting error as fatal, so the command stopped before doing anything. A
base URL without a key counts as a second provider too (applyProviderEnv
builds a profile from a base URL alone).
The resolver already returns the successfully normalized list alongside
ErrNoActiveProvider, and the interactive TUI already falls back to a usable
entry from it in runInteractiveTUIWithSetup. Do the same here instead of
failing, so these subcommands can answer for providers that only come from
the environment.
Also pin the four env-derived provider tests to a fixed environment and a
temporary user config. They set OPENAI_API_KEY but resolved through the real
resolver against the developer's own config.json and ambient provider
variables, so they failed on any machine that also exported another provider
variable and whose user config had no active provider.
Adds regression coverage for the two-env-provider case: `providers list`
must report every detected provider and `use`/`remove`/`rename` must give
their usual env-derived answer. Verified against the fix (pass) and with the
fix reverted (exit 3, `active provider "" not found`).
Fixes#1130
* refactor(cli): report the configured active provider, not an elected one
Provider-management subcommands answer for the SAVED configuration, while
the TUI's recovery path answers "what can I run with". Adopting
firstUsableProvider in resolveCommandCenterConfig conflated the two: with
two env-derived providers it made `providers current`/`list` present a
provider that `zero exec` would still refuse to run with, and a
base-URL-only profile failed `providers list` because the election demands
a credential. Return the resolver's partial config untouched instead, so
nothing is reported as active when nothing is selected.
Also removes the OAuth store read this path picked up with firstUsableProvider.
Adds assertions that `current` shows nothing selected, `list` marks nothing
active, `use --json` leaves activeProvider empty, plus a base-URL-only case.
* fix(cli): scope the no-active-provider exception to provider management
Returning the resolver's partial config made `zero config` exit 0 with a
partial summary, contradicting the resolver's documented headless contract:
`zero config` / `zero exec` still fail with the actionable message and only
the interactive TUI recovers from a missing active provider. Split the
helper so only issue #1130's commands — `providers list`/`current` and the
unpersisted `use`/`remove`/`rename` reporters — tolerate the state; `zero
config`, `detect`, `models` and `check` keep failing as before.
Also pin the OAuth store in providerEnvSetupDeps: summarizeConfig reads
OAuth login state through the default store, so a developer's stored login
could flip these assertions. Point ZERO_OAUTH_TOKENS_PATH at a temp file
and force ZERO_OAUTH_STORAGE=file so an ambient keyring setting cannot pull
the OS keychain in either.
* fix(workspaceindex): skip git-ignored and build-cache dirs in Scan only
Scan has a fixed file budget, and build output (Cargo target/, Python
virtualenvs, caches) could exhaust it before real source was reached:
repo-map reported truncation and ranked fingerprint files above code.
Inside a git work tree, Scan now asks git for the ignored set
(`git status --porcelain=v1 -z --ignored=matching .`), so the repo's own
.gitignore, nested ignore files, info/exclude and core.excludesFile all
apply, including names no fixed list anticipates (.locust_env/). Outside
a work tree, or when git is missing, older than 2.16, fails, or exceeds
5s, a fixed list of common build/cache directory names stands in.
ShouldSkipDir is unchanged: glob, grep, list_directory and path
autocomplete share it and must keep seeing target/, .venv/ and friends.
The lookup runs with GIT_OPTIONAL_LOCKS=0 so a per-turn scan never takes
.git/index.lock next to a concurrent git command.
Fixes#1108
* fix(workspaceindex): skip a rename's original-path field when parsing status
In `git status --porcelain=v1 -z`, a rename or copy record is followed by one
more NUL field holding the original path, with no XY prefix. The parser
inspected every field, so an original name beginning with "!! " read as an
ignored entry: after `git mv "!! a" b`, a tracked file "a" disappeared from
Scan.
Move the parsing into parseIgnoredStatus, which consumes that field for R or C
in either status column, and test it directly and end to end.
* fix(workspaceindex): disable repo fsmonitor and force untracked-files=normal for status
Scan's git status lookup must not run a repository's core.fsmonitor hook or fail
when status.showUntrackedFiles=no would reject --ignored=matching.
* fix(workspaceindex): ignore inherited git repository location
Git exports GIT_DIR and GIT_WORK_TREE to hooks. With those pointing at
another repository, Scan kept .locust_env/lib/x.py, which root's own
.gitignore excludes.
* fix(workspaceindex): quote the fsmonitor hook path in the regression
On Windows, sh treats an unquoted backslash path as escapes, so the hook
ran and the marker the test stats was never created. Require a status
without the override to create that marker first.
* fix(workspaceindex): pass the fsmonitor hook path to git with forward slashes
Smoke (windows-latest) failed TestGitIgnoredPathsDisablesRepoFsmonitor
because core.fsmonitor was set to a backslash script path. Git for Windows
hands that value to sh, which consumes the separators, so the hook never
runs and the marker is never created. The test now stores a quoted forward-slash
path. The product code is unchanged.
* fix(tui,cli): stop a repository's git config from running programs in Zero's own git calls
Zero runs git status and git diff on its own: the TUI's files sweep at
startup and after each command, its pull request diffstat, and the
scratch-file warning after a run. A repository's own config can name
programs those commands run (core.fsmonitor on every status, hooks when
the index is rewritten, clean filters and diff drivers assigned through
.gitattributes), so a workspace unpacked from an archive ran its own code
on the first turn, although Zero refuses the same workspace's project
hooks and plugins until the user trusts it.
internal/gitsafe builds those commands with core.fsmonitor and
core.hooksPath turned off and GIT_OPTIONAL_LOCKS=0, and drops the
repository-location variables git exports to hooks, using the same
environment for its config check and for the command. A repository whose
own config, or per-worktree config when extensions.worktreeConfig is on,
defines a filter or diff driver command is refused, and the callers
already treat that like a directory that is not a repository. The status
and diff calls also pass --ignore-submodules=dirty, because a submodule's
own config is not checked.
* test(gitsafe): keep the real-git setup off an inherited GIT_DIR
The setup helper in TestCommandAgainstRealGit ran git with the inherited
environment, so from a hook, or a shell that exports GIT_DIR or
GIT_WORK_TREE, git init and git config would act on the repository those
name instead of the temporary one. It now uses the same environment as
Command, and the test sets both variables to a decoy repository and checks
after each setup step that the decoy was not touched.
* fix(npm): wait for npm to apply each publish, and drop the one-time @gitlawb/zero steps
npm applies a publish asynchronously: `npm publish` returns once the upload
is accepted, and the version appears later. In the 0.10.0 release the
@twigpine/zero wrapper appeared 9 seconds after publish-packages.sh stopped
checking for it, and the last platform payload about ten minutes after its
upload. Two things went wrong:
- The wrapper became visible before four of its five platform payloads, so
installs in that window resolved aliases that 404 and fell back to the
GitHub-release download. Ordering the uploads does not order what npm
shows.
- The final `latest` check gave up after 25 seconds and failed the job even
though every publish landed, which also skipped the @gitlawb/zero steps
until a re-run.
publish-packages.sh now waits until npm shows every platform version before
it publishes the wrapper, then waits until `latest` is the wrapper version.
One deadline bounds the waits (ZERO_NPM_PUBLISH_WAIT_TIMEOUT, default 30
minutes). On timeout it fails before the wrapper goes out and says what npm
never showed. These lookups treat an error as "not yet", unlike the skip
decision, which still stops on anything but E404.
The test fake can now apply a publish only after a number of later lookups,
and records which aliased versions were missing when the wrapper was
published. Against the script as released in v0.10.0, the two new tests fail
the way the release did: the job exits 1 although everything landed, and the
wrapper goes out while its platform payload is not visible.
The two release steps that published 0.10.0 once more as @gitlawb/zero have
done their job and are removed; docs/NPM_PACKAGING.md and the comments that
pointed at them now describe what happened. The Go and wrapper handling of
the old name stays, since only a build installed under it can trigger it.
* fix(npm): validate the publish wait settings, and correct the legacy publishing claim
ZERO_NPM_PUBLISH_WAIT_TIMEOUT went into Bash arithmetic as typed. A leading
zero reads as octal (0600 waited 384 seconds), and 08 is an arithmetic error
that, in a script, does not stop it: Bash prints the error, leaves the
deadline unset and carries on, so the run only died at its first wait, after
the platform payloads were published. A negative timeout ended the wait
before it started, and a bad ZERO_NPM_PUBLISH_RETRY_DELAY only failed at the
first sleep, also after publishing began.
Both settings are now checked before anything is published: whole seconds
only, converted with 10# so a leading zero is decimal, bounded before the
conversion, and capped (timeout 86400, delay 600). A bad value fails with
the variable's name.
TestPublishPackagesReadsTheWaitTimeoutAsDecimal makes the script wait, which
is where the old code broke: against the reviewed script it publishes the
platform payload and then exits 1 on "08: value too great for base".
TestPublishPackagesRejectsAnInvalidWaitSetting covers -5, abc, 1e3, an
over-range and an overflowing timeout, and a non-numeric delay; the old
script published with five of them and crashed on the sixth with
"abc: unbound variable".
docs/NPM_PACKAGING.md said nothing could publish @gitlawb/zero again.
Removing the trusted publisher stops this workflow; a maintainer could
still publish by hand, and the doc now says that.
A session's lease file lived in the Store that took it, so once that Store
was unreachable the garbage collector closed the file, and closing it drops
the lock. That made TestForkAndChildHoldTheParentTheyAreCreatedFrom fail on
main whenever a collection ran before its dry-run prune. It can also happen
in an exec run: PrepareExec takes the calling or forked session's lease on a
Store the run never touches again, so the parent's lease can disappear part
way through and a prune in another terminal could remove it.
Leases now belong to the process, as Hold documents. Every Store holding a
session shares one lease file in a process-wide table, and the file is
unlocked and closed only when the last Store holding it releases it.
* feat(npm): publish as @twigpine/zero
The npm package moves from @gitlawb/zero to @twigpine/zero, and everything
that finds Zero's GitHub repository by name now names Twigpine/zero.
Package name
- package.json, the lockfile and release-please use @twigpine/zero.
`repository` names Twigpine/zero: npm checks provenance against it, so a
publish from this repo with the old URL fails with E422. The homepage is
zero.twigpine.com.
- The name lives in package.json only. build-platform-packages.mjs names the
wrapper, platform versions, alias names and alias targets from it, and
bin/zero.js reads its own package.json for the alias it resolves and for
every reinstall hint. The Go constant npmPackageName is held equal to
package.json by a test.
Repository defaults
- The update check (DefaultRepository), install.sh, install.ps1, the npm
wrapper's fallback downloader, and the GitHub Action's zero-repo default
all used Gitlawb/zero. That name now belongs to an unrelated GitHub org, and
these paths download and run what they find there, so they point at
Twigpine/zero. Docs, README and issue-template links follow. The Go module
path (and so `go install github.com/Gitlawb/zero/...`) is unchanged here.
Publishing
- The two inline publish steps become scripts/npm/publish-packages.sh, which
reads the name from the assembled payloads, refuses to start when the
wrapper's aliases do not match the platform payloads, skips versions
already on the registry (so re-running a half-finished job is safe), and
publishes with --ignore-scripts. The dist-tag rules are unchanged.
internal/npmwrapper/publish_test.go runs it against a fake registry.
Docs explain the switch for existing installs: uninstall @gitlawb/zero first,
because both packages own the `zero` bin and npm refuses the second (EEXIST).
* feat(npm): publish 0.10.0 once more as @gitlawb/zero to point users at the new name
Deprecating @gitlawb/zero alone would strand every existing npm install.
0.9.0's `zero upgrade` runs `npm install -g @gitlawb/zero@latest`; with
nothing newer under that name npm installs nothing, prints no deprecation
warning (it only warns when it installs something), and Zero then reports
"updated via npm to 0.10.0" while staying on 0.9.0.
So this release goes out once more under the old name, as a bridge:
- The publish job assembles the same verified artifacts a second time with
the new `build-platform-packages.mjs --package-name @gitlawb/zero` and
publishes them with publish-packages.sh, after the @twigpine/zero publish.
A gate runs it only while @gitlawb/zero's latest is still 0.9.0 (or is
this version, on a re-run), so it happens once.
- The build knows its name from the package.json beside it. The Go side
reports InstallMethodNpmLegacy: `zero upgrade` refuses with the switch
steps before any release check (so it works offline too), and
`zero update --check` adds them whether or not an update is out.
- bin/zero.js prints the same steps after zero exits (the TUI would hide a
notice printed first), only when stderr is a terminal, and not for
`update`/`upgrade`, which print it themselves.
- Updating from 0.9.0 now installs a real version, so npm also shows the
deprecation message.
The steps say to uninstall @gitlawb/zero before installing @twigpine/zero:
both own the `zero` bin, npm refuses the second install with EEXIST, and a
forced install loses the bin when the old package is later removed (checked
against npm 11.16).
docs/NPM_PACKAGING.md has the maintainer runbook: bootstrap and trust the new
package, retarget the old one's trusted publisher for the bridge, then
deprecate @gitlawb/zero, revoke its publisher, and delete the bridge steps.
* fix(npm): only skip a publish when npm says the version is not there
publish-packages.sh treated any failed `npm view <name>@<version>` as "not
published". A transient network, auth or registry error on the re-run that the
skip exists for therefore published a version that was already out, and npm
rejected it with EPUBLISHCONFLICT; with the registry unreachable it published
blind. npm 11 reports every kind of missing (no such package, no such version,
including platform versions) as E404, so only E404 now means "not published".
Any other failure is retried three times and then stops the job before it
publishes anything, with npm's own error shown.
The test fake now answers a missing version with E404 the way npm does (it
returned an empty success before), and can fail lookups with a network error.
The two new tests fail on the old lookup: one with EPUBLISHCONFLICT after
transient failures, the other by publishing while the registry is unreachable.
Also renames the unused retry variable in the legacy-name gate step to `_`
(shellcheck SC2034), matching the script.
shell-quote 1.9.0 is inside the range GHSA-pqg4-j6r4-53mv covers
(>= 1.8.4, < 1.11.0). It reaches the lock through tuistory,
@opentui/react and react-devtools-core, which asks for ^1.6.1, so
refreshing the lock moves it to 1.12.0 without touching package.json.
Release packaging installs the local-control helpers from this lock,
so the old version shipped in the helper tree.
* fix(acp): persist token usage events for ACP sessions
ACP turns never set agent.Options.OnUsage, so provider token counts were
dropped on the floor. zero exec and the TUI persist the same counts as
session usage events, and `zero usage report` totals those events, which
left every editor-driven ACP session at zero requests and zero tokens.
runTurn now queues each report through usage.EventUsagePayload, the same
writer exec uses for a non-escalating run, and commits it in the turn's
existing buffered batch under the session id. A hard run failure still
returns before persistTurn, so that turn's usage is dropped with its
history. StopCancelled stays a successful outcome and keeps the usage
already reported. session/load and session/resume still skip event types
other than messages, compaction, and tools, so the restored prompt and
the wire replay do not gain a usage channel. ACP has no model escalation,
so the payload has no "model" key and no prompt, tool argument, or other
raw input.
This sits beside the permission-mode field persisted on the session
(#1134). It does not change the ACP wire protocol, exec, the TUI, or the
report reader.
Refs #1118
* test(acp): pin cancel, resume, and payload shape for usage events
The persistence test showed that a successful turn records token counts,
and the hard-failure test showed that an aborted turn records nothing.
Neither one locked the two edges that are easy to get backwards.
A client cancel is a StopCancelled outcome, not a hard failure, so the
tokens the provider already reported stay in the session log. session/load
and session/resume read that same log; the replay and the next prompt
must still be the conversation, with no usage payload on the wire and no
token counts folded into the resumed prompt. The persisted JSON is also
checked to be token-count keys only, so a prompt or tool argument cannot
ride along in the usage record.
Refs #1118
* test(cli): pin sandbox policy golden temp roots
TestRunSandboxPolicyJSONGoldenIncludesManagerBaselineFields compared the
policy snapshot to a fixed golden after stripping only the temp roots
NewScope keeps. NewScope drops a candidate that sits inside an earlier one,
while the JSON (built with a nil scope) still lists it, so a TMPDIR inside
/tmp — or TMP inside TEMP on Windows — survived normalization and failed
the golden. Pin the variables that path reads (TMPDIR on Unix, TEMP and TMP
on Windows) and cover a nested temp dir that is not inside the earlier
candidate, so the normalizer removes it instead of leaving it in the JSON.
Refs #1121
* test(cli): run the golden against an ambient nested temp dir
The nested golden created its temp dir in the package working directory and
failed when that path sat inside /tmp, so a checkout under /tmp could not
run it and the test wrote into the source tree. Stage the issue's shape
first — TMPDIR inside /tmp, or TMP inside TEMP — then let the pin override
those variables. Directories come from t.TempDir. If the nested shape cannot
be built, skip.
Refs #1121
* test(cli): give the local adoption probe a test-sized timeout
TestLocalAdoptionSavesAndReloadsEligibleModel relied on the 400ms
defaultLocalProbeTimeout, which treats a slow probe as "runtime not running"
and silently drops the candidate; under -race on a busy runner one slow
loopback round trip can exceed it and empty the detection result. Pass a
5-second test deadline instead. The production default is unchanged and stays
covered by TestDetectLocalRuntimesAppliesDefaultTimeout.
Refs #1132
* test(cli): correct the timeout-coverage note on the adoption probe
TestDetectLocalRuntimesAppliesDefaultTimeout pins the probe timeout with an
explicit 20ms deadline, so it covers timeout enforcement rather than the
400ms production default. Reword the comment accordingly; no assertion or
production behavior changes.
Addresses the CodeRabbit finding on #1136.
ACP kept the permission mode in memory only: both mode doors called setMode,
registerSession always started at PermissionModeAuto, and nothing wrote the
mode to the session store, so session/load and session/resume returned a
plan/ask session as auto after a restart.
Save the mode next to the model on both doors, store first so a failed write
fails the request instead of leaving disk behind memory, and restore it through
registerSession. A session with no saved mode (written before this change)
keeps auto; a saved value outside auto/ask/plan never comes back weaker than
ask, and unsafe stays unreachable over ACP.
Refs #1125
* test(sessions): run the session lock test on Windows and add a cross-process one
TestSessionFileLockSerializesAcrossStores was skipped on Windows with
"flock semantics differ on windows", so the LockFileEx path in
filelock_windows.go was never exercised by a test on the platform that
uses it. LockFileEx locks are per handle, which is what the test relies
on, so the skip was unnecessary: remove it.
Also add a helper-process test (same pattern as lockutil's) proving the
session lock blocks a different process and is released by the OS when
the holder exits without unlocking.
Both fail on Windows when the lock is replaced by a no-op.
Fixes#1104
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
* test(sessions): keep the session lock alive in the helper process
The helper discarded the unlock closure returned by lockSession, which is the only reference to the open lock file/handle. GC finalization could close it while the helper waits on stdin, releasing the OS lock early and making the cross-process test pass for the wrong reason. Retain the closure and runtime.KeepAlive it after io.Copy, as the lockutil helper does.
---------
Co-authored-by: Claude Sonnet 5.5 <[email protected]>
* test(tui): isolate user home, config and cache dirs for the TUI tests
newModel reads <user config>/zero/commands/*.md, and the package has 500+
newModel calls with no isolation, so a developer's own slash commands and
state leaked into the TUI tests (and tests could write to real user
directories), against the AGENTS.md "Hermetic tests" rule.
Add testutil.IsolateUserDirs, which points HOME, USERPROFILE, APPDATA,
LOCALAPPDATA and the XDG_* roots at a temp dir (covering Linux, macOS and
Windows), and call it from a TestMain in internal/tui. Per-test t.Setenv
overrides still win.
Refs #1103
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
* test(testutil): fail closed when user dir isolation cannot be applied
IsolateUserDirs now returns an error when any Setenv fails, rolling back
the variables it had already redirected, and its restore func reports
restoration failures. The TUI TestMain exits non-zero when isolation,
restoration or removal of the temp root fails, and the helper test
restores the inherited XDG_DATA_HOME via t.Setenv before clearing it.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* test(testutil): cover partial-redirect rollback and compare physical paths
Make Setenv injectable so a test can let HOME be redirected and fail the
next variable, proving IsolateUserDirs rolls back a variable it really
changed (the NUL-root case fails on the first call and cannot).
Add testutil.PathWithin, which resolves the existing part of each path
with EvalSymlinks before the containment check, and use it in both the
helper test and the TUI hermetic test so symlinked temp dirs and Windows
short names cannot cause false results.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Sonnet 5.5 <[email protected]>
The peer approval prompt's decide closure called runtimeMessageSink from
inside Update. The sink ends in program.Send, which blocks on bubbletea's
unbuffered message channel; the only reader is the event loop that is
running Update, so answering a held cross-session message hung the TUI.
Give pendingPermissionPrompt a decideCmd that returns a tea.Cmd yielding
the peerDecisionMsg, and have resolvePermissionWithReason return it
instead of calling the sink. Tests now drive the decision through the
returned command and a blocking sink, which hangs on the old code.
Fixes#1099
Co-authored-by: Claude Sonnet 5.5 <[email protected]>
TestExecCommandForegroundServerReturnsSessionAndServesHTTP parsed the
helper's listening address from the first exec_command result only, with
a 500 ms yield. On a loaded Windows runner the helper had not printed its
address yet, so the test failed even though the session was running.
Poll the session with write_stdin until the address appears (20 s
deadline), and register the cleanup before the address check so the
server is stopped on early failure too.
Fixes#1097
Co-authored-by: Claude Sonnet 5.5 <[email protected]>
The runtime root is a write root, so a sandboxed command can replace
cache, data, tmp or any of the listed grandchildren with a link to a host
directory. The fallback root is derived rather than minted, so the next
Zero process prepares that same tree unsandboxed, and it created and
chmodded every child by pathname after checking only the root and its
ancestors. It followed the planted link: a contained command could get
Zero to create npm inside a host directory and change that directory's
mode.
Everything below the root now goes through os.Root: each child is
created relative to the root's handle, which refuses any component that
leads out of it, a child that is a link at all (a junction on Windows) is
refused as an aliased component, and on Unix the mode is set on the
opened directory rather than on a name. The same path serves the
preferred cache root, which had the same pathname operations.
Reported by @jatmn.
The setup lock's release discarded both the unlock and the close error,
and elevated setup defers it after publishing the marker, so the operator
was told setup succeeded without anything knowing whether the lock had
been let go. The release now always runs both halves and returns whichever
failed, setup reports it and exits non-zero on top of the result it
already had, and the marker-only writer that holds the same lock returns
it too. The transaction order and the lock file name are unchanged.
Reported by @jatmn.
Commands and zero doctor read the runtime stamp without taking the setup
lock. The writer opened the existing stamp with FILE_OVERWRITE_IF, which
truncates at open time, and only then protected and wrote it, and rollback
deleted the stamp before recreating the previous one. A reader in either
interval saw an empty, partial or missing stamp on a setup that was still
valid, and a process that stopped there left it that way.
The new contents now go into a fresh file beside the stamp, created under
an unpredictable name with the protected DACL already in place, and that
file is renamed over the stamp through its own handle with POSIX
semantics. Rollback restores through the same publish instead of deleting
first. The validation read opens the stamp with FILE_SHARE_DELETE so a
publish can replace it while it is open; a reader without share-delete
gets a short bounded retry and then a failed setup with the previous stamp
whole and no replacement left behind.
Reported by @jatmn.
The Windows runner's TEMP is an 8.3 short path, so the raw t.TempDir()
spelling digested differently from the canonical one the record check
computes, and the test stopped at its own setup guard. Selection hands
the fallback a canonical workspace, and the test now does the same.
Resolve the stamp reader before the stamp is opened. FILE_OVERWRITE_IF
truncates at open time, so a reader that could not be established returned
an error with the previous stamp already emptied, or a new empty one left
behind. The pathname entry point now delegates to the handle writer instead
of carrying its own copy of the sequence.
Take the Windows user scope for the fallback runtime root from the token's
user SID rather than USERNAME. Setup and a later command derive that root in
two processes with independent environments, and a USERNAME that differed
between them made the command reject the root setup had recorded.
On Unix, report a created runtime directory that exists but cannot be
identified instead of treating every lookup failure as a clean undo, which
is the line the Windows build already draws.
Move runtimeTailNotOwned and windowsSameRuntimeRootPath beside their only,
Windows-only callers so they are no longer dead code on other platforms,
replace a deprecated filepath.HasPrefix in a Unix test, and bind the
junction test's planted flag to mklink's result so a failed mklink skips
instead of failing a correct acquisition.
With the type rule removed the guard-page legs still passed, because their short tails are stopped by the size rule as well. An entry in another layout can be long enough to hold the SID its would-be count claims, and then only its type says it is not a plain ACE. This drives the admission function with a whole SID at the plain offset for eight ACE types and admits only the two plain ones.
Two findings from jatmn, both on code this branch added.
The diagnostic that answers "would a command still select the recorded
runtime root?" spelled out the same steps as selection and was missing one.
os.UserCacheDir can succeed and still return a value that canonicalizes to
nothing, and selection fails closed on that with "user cache directory is
unavailable" while the diagnostic went on to derive candidates from the
empty root. Doctor could then report the recorded root as current or as
stale on a machine where every command fails before it reads a marker. Both
callers now take their inputs from resolveSandboxRuntimeRootInputs, so an
input one refuses is refused by the other with the same error. A sandbox
test drives both entry points through the resolver seam and requires
identical errors; a Windows doctor test sets LOCALAPPDATA to "." and
requires runtime-root-unresolved with the resolver cause.
The ACL attestation cast every DACL entry to the plain ACE layout and
compared whatever sat at the SID offset before looking at the entry's type.
Only ACCESS_ALLOWED_ACE and ACCESS_DENIED_ACE keep a SID there; an object
ACE has a flags word and GUIDs, and EqualSid is undefined for a structure
that is not a SID. An entry is now admitted on its header, type first and
then a size that can hold the SID its own count claims, before any byte past
the header is read. Other layouts are skipped, so an allow that is not read
is not credited and the worst case is one idempotent re-apply. The decision
is split from the filesystem read so it can be tested against an ACL built
in memory.
That test places each ACL against a PAGE_NOACCESS page with an entry whose
tail spells the start of the trustee's SID, so a reader that believes the
count walks into the guard page. Each leg runs in a child process, because
an access violation cannot be recovered and would take the package's other
tests down with it.
The fallback tests and the shared runtime fixture still had skips for a missing
user cache directory or an underivable root. With the cache and temp both owned
by the test those cannot be explained by the machine, and a skip there is how a
test passes without reaching its assertion. They are setup failures now, and
the recorded-fallback test owns its cache directory too.
The setup lock orders setups against each other and says nothing to an
eviction, which takes the runtime lease exclusively. This runs the control
against the real transaction: parked between its stamp and its marker, the
root reports in use, and it is free again once setup returns.
Five findings from review, at the boundaries between stages.
- The runtime stamp is named after the plan it attests. The runtime root is
chosen per workspace and the plan is specific to a sandbox home, so two homes
for one workspace shared one stamp file and each setup invalidated the other.
Each plan now has its own attestation, and a failed setup can only reach the
stamp of the plan it was applying. Eviction still takes them all.
- One setup at a time per sandbox home, through a lock file held from before the
snapshot until the marker is published or every compensation has run. The
shared runtime lease keeps eviction out but two setup helpers can both hold
it, so the stamp and marker of different setups could interleave, and a failed
setup could restore its snapshot over a later success.
- The native smoke test prepares its commands the way the planner does instead
of sending the bare profile to the command builder, which the elevated runner
refused before any probe ran. A portable test pins the agreement through the
real builders and parsers, starting from a bare profile.
- Test fixtures redirect TMPDIR as well as TMP and TEMP and check containment
before creating anything, so the fallback tests no longer write outside their
own temp on Unix or skip past their assertion.
- Stamp compensation classifies absence with isWindowsNotFound, which knows the
native statuses the no-follow open returns.
The elevated helper's transaction now runs in tests behind two seams (the
elevation check and the WFP install), so the regressions drive the real lock,
lease, snapshot, ACL apply with the riding stamp, marker and compensation, and
ask a later command prepared by the planner whether it validates.
windowsPathCarriesGrant read only the two inherit bits, so an allow ACE with
OBJECT_INHERIT|CONTAINER_INHERIT|NO_PROPAGATE_INHERIT satisfied the flag test
and had its whole mask credited. NO_PROPAGATE lets the ACE be inherited exactly
once: the immediate children carry it and their children do not. The runtime
tree's real consumers sit past that edge - cache/npm, cache/go-build and
data/go-mod, which sandboxRuntimeEnvironment points npm_config_cache, GOCACHE
and GOMODCACHE at - so attestation reported the grant adequate while those
writes were refused, and the unelevated path skipped the repair that would have
fixed it.
The skip is gated on needInherit so a file entry, where inherit flags are
meaningless, is unaffected, and the apply path never sets NO_PROPAGATE for an
AllowWrite entry, so a grant Zero itself wrote cannot start looking
insufficient.
Reported by @jatmn.
A command that opened the runtime lease while cleanup held it exclusively waited for the shared lock. Cleanup unlinked the lease name (marked it delete-pending on Windows) and compensated the tree before letting go, so the waiter woke on the old object, locked it, and got a lease that coordinated with nobody: the next cleanup created a fresh lease at the name, locked it unopposed, and reported the root unused while the command was running in it.
After either lock, both platforms now prove the locked object is still what the lease name resolves to under the retained parent: dev and inode against a no-follow fstatat on POSIX, volume serial and file index against a fresh no-follow open on Windows, where a name that is gone or delete-pending counts as moved on. A stale lock closes and the whole acquisition retries from tail creation, since cleanup has removed the leaf the contender was about to use; the exclusive side retries its open. Three stale objects in a row are reported. A stale lease is never undone by name, because the name is no longer that object. The created-directory ledger merges across attempts so a directory this run made on an earlier pass stays its own to compensate.
The regression drives the waiting-contender schedule through the production callers on both platforms, using the two seams as barriers and no sleeps: the contender must hold the current object, which the next cleanup proves by finding the root in use. Reported by @gnanam1990.
Brings in #1006 and the rest of main since the last rebase. Merged rather than rebased: fifty-three commits, and the marker-version comment conflicted on every replay.
One conflict: windows_setup.go keeps the marker schema at 6 with its history comments; main is at 5 with none.
The setup test #1006 added hands BuildWindowsSandboxSetupArgs straight to RunWindowsSandboxSetup as a string slice. On this branch the builder returns a WindowsSandboxSetupPlan so the creation ledger cannot be dropped, so the test now takes the plan, runs its Args, and registers its Rollback in cleanup, the same shape as the existing tests here.
The two carriers are each covered on their own now, but the interval where they
compose is the one the finding is actually about: the selection creates the lease
file, the step after it fails, so no lease object comes back and the builder takes
the early return that compensates only what the selection managed to report.
This drives BuildWindowsSandboxSetupArgs with that failure injected and asserts
nothing the invocation created is left behind, on both platforms. Falsified by
disabling the lease undo: the lease file survives, and with it every directory the
same invocation created, because it keeps their parent non-empty.
Both candidate roots are failed rather than only the preferred one, or the
selection relocates to the fallback and the build goes on to succeed.
Acquisition learned it had created a directory or a lease file at the create
itself, then published that fact only after every later step had also succeeded.
Between the two it held a fact nothing else did.
On Windows, createWindowsChildDirectory returns a handle to a directory that now
exists, and a failing handleRuntimeIdentity closed it and returned before the
ledger entry two lines below. On Unix the same gap sits under mkdirat, both for
the ownership refusal and the identity read. The lease files have the parallel
version: O_EXCL and FILE_CREATED each say this call made the file, and a failing
inspection, wrapping or lock returns no lease object, which was the only carrier
for that fact.
The consequence is not just litter. Setup returns an error while leaving
invocation-owned state behind, and where an ancestor WAS recorded, that
ancestor's compensation then fails on a child it cannot account for, so the
whole partial tree stays.
Each of those failures now undoes its own creation before returning: through the
handle on Windows, relative to the parent descriptor on Unix, never by
re-resolving the name, since the name is exactly what this descent refuses to
trust twice. Only a create by this call is undone, so a lease that was already
there still belongs to whoever is holding it, and components that completed stay
on the ledger for the caller to compensate.
createWindowsChildDirectory and both rooted lease opens now ask for DELETE, which
is what lets the undo go through the handle rather than the name.
Reported by jatmn.
Compensation took the exclusive cleanup lease to prove no command was holding
the runtime root, then gave that proof back before doing any of the work it was
proving anything about.
Two consequences. The lease was released before its own pathname was removed and
before the directory walk, so a command blocked on the shared lease was let in
mid-cleanup: it could take the old lease object, have the name unlinked out from
under it, and a later cleanup locking a fresh object at the same pathname would
then read the root as free while that command was still in it. The comment
justifying the early release said a file cannot be deleted on Windows while this
process holds it open, which is not true of a handle opened FILE_SHARE_DELETE,
and this one is: os.Remove succeeds and the name goes at once.
The second was worse and needed no race at all. An acquisition that came back
in-use appended "the lease and the tree it protects were left in place" to the
error list and then ran the directory walk anyway on the next statement. The
promise lived in the error string anyway. A contender holding the shared lease
had its root deleted and was told it had not been.
So the lease is now taken, held across the pathname removal and the whole walk,
and released at the end, and nothing is removed at all unless the acquisition
succeeded. Leaving a tree behind is recoverable by the next run; deleting a live
one is not. The lease pathname goes during the walk, immediately before the
directory that contains it: any earlier ends the exclusion everything after it
depends on, any later leaves that directory non-empty so it can never be
compensated.
Also removes the superseded pathname-based shared acquisition and both its
platform halves, which no longer had a caller. It opened the lease by full
pathname, so it followed a link planted at that name, and on Windows it opened
without FILE_SHARE_DELETE, which by itself stops cleanup from removing a lease
anyone is holding. A weaker door sitting beside the rooted one is how a later
change quietly takes it.
Reported by jatmn.
The untouched-tree case created the runtime root but not the lease beside it, so
nothing failed if compensation stopped asking whether the lease was ours. It is
the object two processes coordinate on, and removing one this invocation did not
create takes it out from under whoever did.
prepareSandboxRuntimeLeaseRecording reported the owned directories it created and
the two-result wrapper beside it dropped them. Every real selector and the setup
argument builder used the wrapper, so nothing in production ever saw that record,
and only tests called the recording form.
The consequence is a whole transaction with no undo. BuildWindowsSandboxSetupArgs
calls selectSandboxRuntimeRoot, which creates zero/runtime/v1 and the lease file
when they are not there, then releases the lease and serializes only the selected
pathname. The helper reacquires an already-existing tree so it records no
creation, and provisioning records only the leaf. Any failure before the marker
left the parents behind attested by nothing, and the sibling lease file kept v1
non-empty so even the leaf rollback could not finish.
The transaction now starts at the first mutating lease operation. The selector
returns the ledger, including on the error path where acquisition creates one
component and fails on the next, and the fallback branch appends rather than
replaces so a preferred attempt that created components before failing to lease
is not forgotten. BuildWindowsSandboxSetupArgs returns a plan rather than bare
args, so a caller cannot take the command line and leave the record behind, which
is exactly what happened last time. The CLI runs the undo when the helper does
not complete, on this side of the process boundary where the record lives.
The lease file is part of it. It is removed only when this acquisition created it,
which NtCreateFile and an O_EXCL open are what can say, and only after retaking it
exclusively. Taking it exclusively is not tidiness: it is the proof that no other
process is holding the tree about to be deleted. A holder means the lease and the
tree both stay and the caller is told, because reporting a clean undo while a tree
is still on disk sends the operator to retry against state they were told was
gone.
setupConsumerSID is a variable now. The interval between creating the tree and
returning the args is the whole reason the rollback exists, and the identity
lookup is the only step in it that can fail, so without that seam the compensation
path is unreachable from a test.
A command path takes no ledger on purpose and says so: a command is not a
transaction, publishes no marker, and what it creates is the tree it is about to
use.
Verified on Windows and on real Linux.
The fallback root moved from an atomically minted MkdirTemp parent to a
predictable /tmp/zero-u<uid>/runtime/v1/<digest>. The stable name is required,
because setup and every later process have to agree on one root without talking
to each other, but it also means another local account can name the first owned
component before this one does.
refuseAliasedRuntimeComponents answers about an ABSENT component by saying there
is nothing to alias, which is exactly the state a fresh fallback is in. After
that answer the code called os.MkdirAll on the parent and opened <root>.lease by
full pathname, and both follow a link planted in between. The guard was
authorizing writes whose destination it could not see, and the next check could
only report the problem after they had happened.
So the base is opened by name exactly once, and every component below it is
created or opened relative to a retained descriptor with O_NOFOLLOW, its
ownership and mode checked through fstat, and the lease opened relative to the
verified parent. A link at an owned component is an ELOOP or ENOTDIR from the
kernel rather than a redirection nobody noticed. The base itself keeps following
links: it belongs to the operator and is legitimately one on macOS, where /tmp
resolves through /private.
createRuntimeTailHandleRelative on this side was the same defect one layer up, an
os.Mkdir loop over full pathnames kept on the reasoning that the elevation
asymmetry the Windows descent closes does not apply here. True of the elevation,
false of the substitution. It goes through the same descent now, so there is one
implementation per platform rather than one protected platform.
Cleanup resolves the lease the same way for the same reason as on Windows: a link
at the lease name otherwise gives the shared holder and the exclusive holder two
different files, both locks succeed, and cleanup removes a root that is in use.
The two descent seams move to a file with no build tag. A seam defined beside one
of two implementations is how the other one quietly ends up untested.
Verified on real Linux, not only cross-compiled.
Falsifying turned up that an unknown third-party tag is unresolvable, so the old
pathname cleanup fails on it too, with ERROR_CANT_ACCESS_FILE rather than by
classifying anything. The case still pins that both sites refuse, and the reason
check separates a refusal from an accident, but only the symbolic-link cases show
the half that matters most: a pathname open succeeding on the target. Those need
the privilege, so their result has to be read in CI rather than locally.
FILE_OPEN_REPARSE_POINT says do not follow, not refuse. It returns a handle to
the LINK, and FILE_NON_DIRECTORY_FILE beside it excludes directories rather than
non-directory reparse objects, so a file symbolic link at <digest>.lease was
opened, wrapped and locked as though it were the lease. The flag was being read
as a guarantee it does not make.
That mattered because cleanup did not use the rooted opener at all. It called
os.OpenFile on the full pathname with no no-follow flag, followed the same
planted link, and locked its TARGET. Setup or a running command then held a
shared lock on the link while cleanup held an exclusive lock on the target, both
calls succeeded, and cleanup was free to RemoveAll a runtime root somebody was
still using. This is not the ancestor-junction case already fixed: the
substituted object is the final lease entry itself.
No-follow opening and object classification are two requirements. The lease
handle is now asked what it is before it is locked, which is the check the
directory descent in openWindowsChildNoFollow already made, and cleanup resolves
the name the way acquisition does: the same base, the same owned components, the
same no-follow opens, the same refusal. Mutual exclusion is a property of the
object, so both sides have to arrive at it the same way.
Cleanup opens and never creates the tree. A runtime root that is not there has no
lease to take, and rebuilding it in order to lock it would be inventing the thing
cleanup is about to remove.
The regression covers a file symbolic link at the exact lease name for both call
sites and asserts the link target is never locked. That needs
SeCreateSymbolicLinkPrivilege, so it skips on an ordinary account; a third-party
reparse tag needs only write access, and pins the same classification everywhere
for both sites. Controls prove a shared lease still makes cleanup report inUse
and then succeed after release, and that two holders can still share one
legitimate lease.
Setup reaches the temp fallback when the preferred cache-derived root cannot be
leased, and it records that concrete root in the marker. Command selection then
recognised the record only by deriving today's fallback from this process's
TEMP and comparing the two. A later IDE, service or terminal running with a
different TEMP matched neither of today's candidates, so the record was
ignored, selection produced a tree setup never provisioned, and the runner
rejected the marker as out of date. Re-running setup from the original
environment repeats the original answer and does not make the other
environment converge.
That is the second selection the persisted-root design existed to remove: the
record was being treated partly as history and partly as something reproducible
from the next process's environment.
The record is now recognised by its own shape, which is the question actually
being asked. It must have the owned runtime shape, its first component must be
the fallback's, and its leaf must be this workspace's fallback digest. Nothing
about its base is re-derived. The cache-derived digest is deliberately not
accepted, so a moved user cache still reports stale, which is a different
failure with a different remedy. Both call sites take it, or doctor and the
command disagree again.
The refusals that made the old comparison worth having are unchanged: a record
provisioned for another workspace has a different digest and is still refused,
and a record with no owned shape is refused outright.
snapshotWindowsSandboxRuntimeStamp reads the runtime root's identity and prior
stamp through one handle and closes it. applyWindowsACLPlanWithStamp then
resolved the same pathname again and mutated whatever answered. Neither stage
ever proved the two were the same object, so the transaction established "these
prior bytes belong to B" and wrote to A.
The root's owner can arrange that with ordinary directories and no privilege:
rename the real root aside, leave a plain directory at the predictable name for
the snapshot to read, and restore the original before the apply. Nothing is a
reparse point, so a no-follow open does not notice, and the setup lease is a
sibling of the root rather than the root entry itself.
The damage is not only a misplaced ACL. On a later failure, stamp compensation
compares what it finds against the snapshot's identity, refuses to restore, and
leaves this run's stamp on a directory whose published marker still describes
the previous successful setup, so a failed setup invalidates a good one.
The request now carries the identity the snapshot established, and the apply
verifies it before its first mutation rather than before the stamp, since the
ACL is the change that matters most. It fails closed: an identity that could not
be established refuses, because "we could not tell" is precisely the case the
guard exists for.
Two stamp tests built the request by hand without an identity and are updated to
build it the way setup does. The new regression substitutes the directory in the
one interval where a substitution can land, between the snapshot's close and the
apply's open, and asserts the substitute collects nothing and the prior stamp
survives byte for byte.
The first version of this test planted the junction before acquisition started,
which the old alias pre-check already refused, so it passed against the
implementation it was supposed to condemn. The defect is the check-then-use
window: os.MkdirAll and the pathname lease open both resolved the component
again after the check had passed.
A pre-create seam both implementations honour lets the junction land in exactly
that window.
Provisioning already descends from the fixed cache or TEMP base through
retained no-follow handles, because a predictable owned component is exactly
what an ordinary same-account process can replace with a junction. Lease
acquisition runs first and did neither: it checked the components for aliases,
then called os.MkdirAll on the parent by pathname and opened "<root>.lease" by
pathname. Both follow.
A junction dropped on zero, runtime or v1 between the check and either call put
elevated setup's first writes inside the caller's chosen target. Restoring the
component afterwards left the later handle-relative provisioning operating on
the legitimate tree, so no post-check saw it, and the rollback could not
compensate writes it had no record of. The lease file made it worse by being a
sibling of the runtime root rather than one of its owned components, so the
alias check never inspected that name at all.
Lease acquisition now splits the owned tail, creates only the base by name,
descends the components above the leaf from retained handles, and creates the
lease file relative to the deepest of them with FILE_OPEN_IF,
FILE_NON_DIRECTORY_FILE and FILE_OPEN_REPARSE_POINT. It refuses rather than
falling back when the path is not a runtime root, and it reports the
directories it created so a failure can undo them. The leaf stays with
provisioning, which records it: two owners for one directory is what the
accounting is meant to avoid.
Non-Windows keeps its previous behaviour, alias check included.
The existing lease tests pre-created the whole tree before every call, so no
test exercised the creation path, which is how this shipped green. The new
regressions plant a real junction, assert nothing lands in the redirected
target, and pin that acquisition records what it created and only that.
Three instances of one shape: the right fact was established, but only after
crossing the point where it had to become authoritative.
Runtime creation. createRuntimeDirRecording found the deepest component that
already existed with os.Stat and opened THAT by name, so existence chose the
trust boundary. On the ordinary second-workspace shape zero\runtime\v1 is
already there and only the digest is missing, so the single by-name open was v1,
one of the predictable components the rooted traversal exists to protect. A local
owner could swap it for a junction between the pre-check and that open, let
elevated setup create the digest beneath the redirected target, and restore the
original before the post-check, leaving a privileged creation recorded under a
pathname that no longer named it. The split now comes from the same inventory
that built the root: only the cache or TEMP directory above the owned tail is
opened by name, every owned component below it is reached from the retained
parent handle, and a root without that shape is refused rather than walked by
name.
Setup handoff. Zero never elevates: the operator elevates the terminal and the
helper is launched with a plain exec.Command, so the consumer SID was resolved
in a process that was already elevated and described the installer. Same-account
UAC hides that; alternate administrator credentials do not, and the stamp would
get its allow ACE for a token that can never read it. That case cannot be
detected from inside an already-elevated process, so it is excluded rather than
mis-provisioned: the helper now refuses when the carried identity is not its own
token, before anything is provisioned, and the comments no longer claim an
elevation boundary that does not exist.
Doctor. WindowsSandboxRecordedRuntimeRootIsCurrent answers current, stale, or
unresolved, and the third fell through to the pinned branch: a valid old marker
made doctor report healthy while BuildCommandPlan hit the same resolution failure
and stopped every command from launching. Unresolved is now its own warning
carrying the resolver cause, and the pinned validation profile is built only once
currentness has been established.
The regressions drive the production entry points. The recording test asserts the
single by-name open is the fixed base, which is the property; asserting only that
a swap was caught was not discriminating, because the mutated code refused for an
unrelated reason. Two rollback tests were using a root missing the leading owned
component, a shape production never builds, and now use the real one.
Doctor pinned the marker's runtime root onto the profile so the stamp check
could run without taking a lease. A command does not pin blindly: it derives
the current cache and fallback candidates and honours the marker only when its
root is one of them. Run setup with the user cache at A, relocate the cache so
commands derive B, and the stamped A tree stays behind. Doctor reused A,
constructed its expected plan, and reported healthy immediately before every
real command rejected A as not a current candidate and failed on the
out-of-date marker.
The marker's root is history, needed to validate the stamp without mutating
anything. It is not proof the root is still selectable, and doctor conflated
the two. WindowsSandboxRecordedRuntimeRootIsCurrent derives the same
candidates a command derives, through the same resolver, and applies the same
equality by calling pinnedSandboxRuntimeRoot itself, so doctor asks the
command's own question rather than a copy of it. Only a root a command would
still select is used to validate the marker; otherwise doctor reports the
setup out of date with the runtime root it found and the remedy. It still
takes no lease and creates nothing.
The regression records a marker with the cache resolver at A, flips the
resolver to B, and asserts the recorded root is no longer current while its
value is unchanged. Making the helper trust the marker unconditionally fails
it on that assertion. An earlier draft of this test planted the marker
without setting profile.Runtime, so it failed on its own setup guard and its
falsification proved nothing; that is why it is worth saying the failure is
on the property.
createRuntimeDirRecording guarded with Lstat before and after, but the
interval between them was still path-based: it found the deepest existing
ancestor with os.Stat and then created each missing component by opening its
parent BY NAME. Both follow a junction. A local user could replace the
predictable owned "zero" component with a junction after the pre-check, point
it at a directory of their choosing, let elevated setup create
runtime\v1\<hash> beneath that target, and restore the original before the
post-check. The post-check then saw an ordinary path, the plan applied
normally, and rollback reported the separately created target only as
identity-mismatched residue.
Pre and post pathname inspection was being used as authorization for an
elevated mutation. Now the deepest existing ancestor is the only thing opened
by name, and every missing component below it is addressed relative to its
parent's handle with FILE_OPEN_REPARSE_POINT: an existing component is opened
no-follow and refused if it is a link, and a missing one is created relative
to the parent handle and identified from the handle the create returned. No
name is resolved twice, so there is no interval for a swap to land in, and a
junction at any owned component is seen as the link it is. Redirected cache
and TEMP locations above the owned tail are still allowed; the restriction is
on the zero/runtime/v1/<hash> components Zero itself owns. Off Windows the
mkdir loop is unchanged.
A deterministic barrier between opening the base and touching the first owned
component lets a test plant a junction at exactly the vulnerable interval.
Driven with a real mklink /J: the descent refuses, records nothing, and the
junction target is untouched. The old by-name create against the same
junction returned nil and landed a directory under the target, which is the
hole verbatim. A control test creates the tail from handles and checks each
recorded identity against the directory the name resolves to afterwards.
Two rollback records were still assembled from facts that were never
established, so compensation was correct about the wrong thing.
The created-directory record took its identity from a pathname resolved after
creation. Win32 CreateFile cannot create a directory whatever disposition it is
given, so the CREATE_NEW attempt fell through to os.Mkdir plus a separate
reopen on EVERY real creation, and the documented creation-handle contract
never once held. The runtime parent belongs to the ordinary user, who can
rename the new directory away and put an ordinary one at the predictable name
in between; the ledger then recorded the substitute, and a failed setup could
delete it while the directory this run actually created kept the ACL and stamp
under a name nothing was tracking.
NtCreateFile with FILE_CREATE and FILE_DIRECTORY_FILE does create a directory
and returns the handle, so identity is read from the object created and there
is no second resolution to race. A collision still surfaces as a *PathError
carrying os.ErrExist, because the caller asks os.IsExist, which does not unwrap
a %w chain: wrapping alone would have turned a benign lost race into a hard
setup failure. An unidentifiable create now errors instead of manufacturing an
ownership record.
The stamp record collapsed every failure into proven absence. An encoding
failure, a root that would not open, an unreadable identity, a denied child
open and a short read all arrived as existed=false, exactly like a real
not-found. The writer uses FILE_OVERWRITE_IF and can replace an existing stamp
where the read was denied, and compensation for "did not exist" deletes the
current stamp and returns with nothing to restore, so a setup attempt that
REPORTED FAILURE destroyed the attestation of the previous successful setup.
The snapshot now returns three states. Only ERROR_FILE_NOT_FOUND and
ERROR_PATH_NOT_FOUND produce absent; a complete read produces present; anything
else is unknown and returns an error. Setup refuses on unknown before the ACL
and stamp are applied, and restore refuses to compensate an unproven prior
state, so the two halves of the guard sit on both sides of the mutation. The
zero value is unknown deliberately.
Tests cover the three states separately, an unreadable prior stamp left
byte-for-byte intact with the writer never reached, and a create/reopen barrier
that counts pathname resolutions. One honest limit on that barrier: it counts
resolutions through runtimeIdentityAfterCreate, which the non-Windows path uses
and which is documented as the pathname reopen. It would not catch a future
hand-rolled one.
Compensation verifies and mutates through a single handle now, but the records
it trusts were still built from separate pathname opens.
snapshotWindowsSandboxRuntimeStamp took the identity through a handle, closed
it, and then let os.ReadFile resolve the name again. A rename and substitution
in that interval pairs one directory's identity with another's stamp bytes, and
a rollback that correctly proves it holds the first writes the second's contents
into it, corrupting an attestation that predates this run. Reading the stamp as
a child of the identified handle removes the second resolution.
The creation ledger had the same split: os.Mkdir created the directory and
runtimeDirIdentity reopened the name, so the ledger could record a substituted
object this run never made. Rollback would then prove it held that object and
delete it, while the real one kept this run's ACL and stamp under a name nothing
was tracking. The identity now comes from the creation itself.
The lease stops cleanup selecting the root; it does not stop the parent's owner
renaming it, which is what makes both windows reachable.
The race needs an elevated installer against an unelevated renamer and is not
reproducible here, so the tests pin the contract instead: both halves of a
snapshot come back together and agree with the object at the path, and a created
directory's identity describes what was created.
Resolving the reader from the runtime leaf's owner does not name the token that
consumes the stamp, because the elevated helper CREATES that leaf when it is
absent. The owner is then commonly BUILTIN\Administrators, and the repair-identity
collision fix correctly folds the reader into the Administrators grant rather
than emitting a duplicate ACE.
Both steps are right on their own and wrong together. Production crosses a token
boundary that the test did not: a later UAC-filtered administrator carries
Administrators deny-only, and a standard user given alternate administrator
credentials is not in the group at all. Neither can satisfy an allow ACE, so the
protected stamp holds no enabled grant for the token that has to validate it,
ValidateWindowsSandboxSetupMarker fails immediately after setup reported
success, every restricted command stops before launch, and doctor calls the
setup unusable.
Resolve the consumer in the operator's shell instead, before elevation, and
carry it in as --consumer-sid. That is the same rule the runtime root already
follows two lines away: selected where the answer is knowable, not derived where
it is not.
The owner fallback stays for the paths that are not setup, notably rollback
recreating a stamp it just removed, where the leaf already exists and belongs to
whoever owns the install. A test pins that the carried identity outranks the
leaf owner, since that precedence is the entire difference between the two
designs.
The post-deletion probe distinguished only err == nil from err != nil, so every
Lstat failure took the success path. A second process holding a share-delete
handle leaves the entry delete-pending, and an access denial or sharing
violation from the probe then reported complete compensation for a directory
that is still there. A holder able to clear the disposition can make the
"removed" object visible again, after setup has already said the rollback
finished. Ordinary inspection failures were hidden the same way, and those
carry no evidence about the postcondition either.
Three outcomes rather than two: not-found proves absence and returns nil, a
successful stat means it is still present, and anything else is reported as
residue whose removal could not be verified.
The probe goes through a seam so a test can produce that third outcome, which
no real filesystem yields on demand. Reverting to the two-way check fails the
unverifiable case naming it as complete compensation.
The resolved stamp reader can BE one of the repair identities. A runtime root
created by an elevated process is commonly owned by BUILTIN\Administrators
rather than by the invoking user, which is what CI runners do.
Naming the same SID twice, once read-only as the reader and once GENERIC_ALL as
repair, let the narrower entry win: Administrators came back with mask
0x00130089 and could no longer rewrite the stamp. Setup would succeed and leave
an attestation nothing could replace.
Skip the reader entry when a repair identity already covers it, and pin it with
a test that resolves the reader to Administrators directly, so the case is
exercised on an unelevated box too. My own regression caught this, but only on
CI, because here the directory is owned by the ordinary user.
That is also why the existing assertion had to change: no-write is right for an
ordinary owner and wrong for one that is itself repair.
TestRollbackContinuesAfterACompensationFails injected its failure with a
snapshot pointing at an unwritable stamp path. That stopped meaning anything
once compensation began deriving the stamp from the verified root handle rather
than the recorded path: the restore wrote a stamp INTO the directory the
rollback then tries to remove, so the removal failed for a reason this test is
not about.
It passed on an unelevated box and failed on all three CI runners, because
whether that recreate succeeds depends on the token the tests run under.
Inject a root that is no longer the directory this run stamped, which is a shape
production produces and which writes nothing on any platform.
The identity check and the mutation used two different resolutions of the same
name: runtimeDirIdentity opened a handle, read the volume and file ID, closed
it, and then os.Remove or os.WriteFile resolved the pathname again. A rename
followed by a replacement in that interval makes the comparison true about one
object while the write or the delete lands on another. This is elevated
compensation, so a redirected pathname gives the mutation reach the replacer
does not have directly.
Open once, verify the identity on that handle, and perform the mutation through
it: the stamp relative to the directory handle, the created directory by
FileDispositionInfo on its own handle. No ancestor is re-resolved and there is
no interval to land in. A seam between the check and the mutation drives the
replacement in tests; with the old pathname resolution they fail, naming the
substitute.
Two things fell out of doing it properly.
The stamp restore now deletes and recreates through the ordinary writer rather
than overwriting. The stamp carries a protected DACL that withholds write, so
an in-place overwrite is denied under the token that wrote it, and only the
writer puts that DACL back on the replacement.
The reader ACE gains DELETE alongside read. Withholding write from the SANDBOX
is the real boundary and the capability SID has no ACE here at all. Withholding
it from the root owner is not one: they own the parent, so delete-then-create
forges a stamp exactly as well as an overwrite. What read-only actually cost
was rollback's ability to remove a stamp this run wrote.
A directory removal that reports success is verified rather than assumed, for
the reason recorded on the promote rename in #751.
Both identity capture sites discarded runtimeDirIdentity's success flag, so a
root that could not be opened was indistinguishable from one with no identity,
and both mutation sites read the empty string as "skip the check". Stamp
restoration then wrote to, or removed from, whatever answered to the pathname
afterwards, and directory rollback treated every reopened object as eligible
for removal. This is elevated compensation, so a pathname redirected after the
capture gives the mutation reach the replacer would not have directly.
Carry the flag and refuse: an identity that was never established is not
permission to mutate. The object is left alone and reported as residual state.
A root that is simply ABSENT stays quiet, because there is no object to confuse
and nothing of a previous run to put back; the created-directory rollback owns
that case. Without that distinction every fresh setup would report a
compensation failure, and a test pins it.
The test helper that builds created-directory records had the same discarded
flag, which is why the existing rollback tests passed against the fail-open
path. It now carries the identity through the way production does.
This does not close the check-then-mutate window itself: the identity is still
read on a handle that closes before the pathname operation. That half is
separate.
The stamp's DACL named WinCreatorOwnerSid at GENERIC_ALL. SetSecurityInfo does
substitute that placeholder even in a NO_INHERITANCE ACE, so a concrete SID did
land in the ACE, measured again on this head: ACE[0] carries the user SID at
mask 0x1f01ff and the readback succeeds. The mechanism was never the problem.
The identity it named was. It is whoever ran setup, and setup runs elevated.
When elevation comes from a different administrator account than the one that
later runs the command or zero doctor, the reader matches no ACE, and a reader
named in no ACE gets Access is denied on os.ReadFile. A successful setup then
hands over an attestation the launch gate and doctor cannot open.
Resolve the reader from the runtime root the stamp is created in, through the
directory handle rather than a pathname, so the grant is bound to the install
rather than to the elevation. Give it read only: nothing outside setup and
repair should be able to rewrite an attestation about the tree, and the stamp
write still succeeds because the handle was opened GENERIC_WRITE before the
DACL was applied.
A reader that could not be resolved is refused rather than protected with a
DACL naming nobody.
Owned depth is the fixed names plus the digest, so a root placed directly in
t.TempDir() put an owned component on /tmp. The Unix ownership guard then
correctly refused it, because /tmp belongs to root and the test runs as an
ordinary user. That is the guard working rather than an environment problem, and
it only appears off Windows: both ubuntu Smoke and Zero Review failed on it while
the Windows run was green.
The roots are built as <temp>/zero/runtime/v1/<leaf> now, so every component the
alias and ownership walk inspects was created by the test.
The unelevated caller took a lease only to learn which root wins and released it
at once, so nothing owned the selected root while the elevated helper provisioned
the tree, applied the ACL and stamp, installed network state and wrote the
marker. A command for another workspace scanning the same runtime parent excludes
only its own current root, so it could take this root's cleanup lease and
RemoveAll it mid-transaction, leaving setup to publish success for a pathname
that was gone. The helper now holds a shared lease on the root it was handed,
from before provisioning until after the marker write, and fails before any
persistent state is written if it cannot take one.
Compensation resolved pathnames again after the apply handles had closed, so a
rename-aside plus an ordinary directory at the same name let it strip a stamp
from the substitute, write another object's bytes onto it, and remove it as
though this run had created it, while the original kept the grant and the stamp.
The stamp snapshot and every created-directory record now carry the identity of
the object they describe and refuse a replacement, reporting the original as
residual instead.
os.SameFile cannot express that on Windows. A Windows fileStat loads its volume
serial and file index lazily, BY PATHNAME, at comparison time, so an identity
captured before a replacement and compared after reports the substitute as the
same object and the original as different: exactly backwards, and silently.
Measured rather than reasoned about. runtimeDirIdentity reads the identity
through a handle at capture time instead, with the Unix build using device and
inode so both platforms follow one rule.
Runtime preparation resolved the sandbox home from the ambient environment while
Windows platform planning resolves ZERO_WINDOWS_SANDBOX_HOME out of the command's
own spec.Env and hands that one to the runner for marker validation. A command
that explicitly selects home B, while the parent still points at home A, pinned
A's recorded root into the profile; the runner then loaded B's marker, saw a
different root, and rejected the command as out of date even though setup for B
was valid. The two homes need no different derivation rules to disagree, only
different valid selections from the same preferred/fallback pair. Selection now
takes the home the command asked for, and an empty one still resolves the ambient
environment because that is the only authority a caller without command context
has. The refusal of a root belonging to another workspace is unchanged.
Separately, plan construction creates directories, which is easy to miss because
it reads like naming: windowsSandboxProfileWithProvisionedRuntime provisions the
root it selects, and the simulated-Windows tests run on every platform. Nothing
redirected the cache, so the package wrote into the developer's real one. The
machine this was found on had 64 runtime directories and 9115 orphaned lease
files accumulated under the real runtime root. Redirected once for the package
rather than per test, because the leak was in the default and any new test that
builds a Windows plan would inherit it.
The grant check belongs beside the tier's other launch decision rather than
inside ValidateWindowsSandboxSetupMarker. That function compares what setup
intended with what this command wants, which is what its name asks and what
every consumer of it expects; folding a security-descriptor read into it made
`zero doctor` depend on real applied ACLs and report a freshly set-up machine
unhealthy.
Both tiers now attest in runWindowsSandboxCommand: the unelevated one reads the
descriptors and re-applies, the restricted-token one reads them and refuses,
because it cannot repeat an elevated provisioning. The refusal is covered
through the runner rather than only through the function, since a check that
reads correctly and is never called is the failure mode being avoided.
Three things the end-to-end pass over the runtime-root lifecycle turned up.
The elevated tier attested its runtime root with a stamp file. That proves the
directory was not removed and recreated under the same pathname and that setup
provisioned it for this configuration, and it says nothing about whether the
capability ACE still carries the permissions windowsACLAccess created or still
reaches descendants. An ordinary file survives an ACL edit untouched, so an
icacls reset, an inheritance change on a parent, or a security product rewriting
the DACL leaves a valid stamp over a runtime root the restricted child cannot
write. This is the finding already closed on the unelevated tier, still open on
the tier that runs under the restricted token. It reads the descriptors now, and
refuses with the action that fixes it rather than re-applying, because this tier
cannot repeat an elevated provisioning.
The grant check counted an INHERIT_ONLY allow ACE towards the directory's own
grant. Such an ACE grants descendants and grants the directory nothing, so the
child is still refused FILE_ADD_FILE on the runtime root while every inherit flag
the check looks for is present: the propagation half of the grant taken as
evidence for the whole of it.
Rollback checked object identity through one open and then resolved the name
again for the restore. The two opens are a check-then-use, and the fact
established is not the fact the write depends on. One open now, with the
identity read from the handle that gets mutated.
The attestation accepted any allow ACE naming the capability. That is weaker
than the contract the child depends on: windowsACLAccess grants
FILE_GENERIC_READ|WRITE|EXECUTE, and a directory entry carries
SUB_CONTAINERS_AND_OBJECTS_INHERIT so the grant reaches the TMP and cache
descendants the child actually writes.
So an ACE that still named the capability but had been reduced to a read-only
or metadata mask, or that no longer propagated, attested as healthy. Both
consumers skipped the idempotent re-apply and the restricted child reached a
marker-valid runtime root and got ACCESS_DENIED on its first write, which is
the silent unusable runtime the attestation was added to remove.
Attestation now unions the allow masks of the ACEs that actually propagate and
requires the plan's own mask, and a deny naming the capability over any
required bit fails closed rather than being ignored. The regression drives the
positive case plus four weakenings that all keep the SID: read+execute,
metadata only, full mask without inheritance, and containers-only inheritance.
Removing either half of the check fails the cases belonging to that half.
Scoping the fallback's first component to the user broke the shape the owned
tail is matched by, because the matcher compares that position against the one
fixed name. The fallback therefore stopped being recognised on Unix, which
silently costs it both protections that depend on the shape: the rooted
no-follow traversal falls back to opening the tree by name, and the
owned-component guard no longer sees it. TestTheTempFallbackRootKeepsTheOwnedShape
exists for exactly that reason and caught it on CI.
The first position now accepts either spelling, the fixed one the cache root
uses or the user-scoped one the fallback uses, and every component below it
stays fixed so a tree Zero does not own is still refused.
The scoped spelling does not exist on Windows, so the accepting branch could
not be exercised there at all and a break in it would only ever surface on
another platform's CI. The matcher reads the fallback names through a seam so
the shape test can drive both spellings, including a different user's scope,
on any platform.
The forward apply and its stamp go through one handle, so they are provably
about one object. Compensation runs later, after a network or marker failure,
and resolves those names again. Opening no-follow refuses a reparse point but
accepts an ordinary directory moved into the name since the handle closed, so a
rename plus a substitute made rollback restore the pre-apply DACL onto the
substitute and report success, while the moved original kept this run's
capability ACE. Setup claimed a completed rollback with the modified object
still reachable elsewhere, having also mutated a directory the forward
operation never touched.
The snapshot now records the object identity read from the open handle, which
is the last moment the name and the object are known to be the same thing, and
the restore refuses to act unless the name still answers to it. On a mismatch
the substitute is left alone and the error says the original is unrestored,
rather than reporting success.
Materialized targets are deliberately not identity-checked. A materialized
target is one this run created, and its plan routinely denies Everyone read,
which is what a protected metadata carve-out is, so the attributes identity
needs cannot be read back even by the owner: requiring it turned rollback of
every materialized directory into "Access is denied". Establishing identity
there means holding the apply handle open to the last failure point, which is a
larger change than this one and is called out rather than papered over.
The unelevated marker records a plan hash and entry count, both derived from
pathnames and entries. That says what should be granted; it cannot establish
that whatever directory currently answers to the name has received it.
The runtime root makes the gap reachable rather than theoretical, because it is
deterministic and disposable. Cleanup removes the tree, the next command's
parent recreates the same pathname with ordinary inherited permissions, and the
hash is unchanged, so the fast path skipped the apply and the WRITE_RESTRICTED
child could not write TMP or its language and package caches. Nothing failed to
say why.
The fast path now reads the security descriptor of each allow entry and takes
it only when the grant is actually present. That costs one descriptor read per
entry on a path that is about to create a process, and it covers every reason a
grant can be missing rather than only recreation by the parent: manual
deletion, eviction between commands, a restored backup. Any failure reads as
"not applied", because re-applying an existing grant is idempotent while
skipping an absent one produces a sandbox that silently cannot write.
The temp-derived runtime root scoped its LEAF to the user and left
zero/runtime/v1 fixed above it. Runtime preparation creates and
ownership-checks each of those at 0700, and on Unix os.TempDir() is shared
whenever TMPDIR is unset, so the first account to use the fallback created a
private directory every other account was then refused at: traversal fails on
the mode, and relaxing the mode fails the ownership guard instead. The
per-workspace digest never got the chance to separate them, and the fallback
became first-user-wins on a shared host. The MkdirTemp layout this replaced did
not have that problem, because each process sat under its own 0700 directory.
The scope moves to the shallowest component, so every ancestor the guards
create and validate is already inside one user's namespace. The workspace
digest stays the leaf, so setup and the command still derive the same root for
the same workspace.
Windows keeps the fixed names deliberately: its temp root already resolves
inside the user profile, and windowsSandboxRuntimeOwnedTail compares those
names to recognise a root built by an elevated setup running as another
account. The test pins both directions so neither platform's choice can drift
into the other.
The rollback test compared the full SDDL of the security descriptor before and
after, which carries the owner, the group and the control flags as well as the
access. It passed here and failed on the Windows runner with the three access
entries byte-identical on both sides: SetSecurityInfo sets
SE_DACL_AUTO_INHERITED when it writes a DACL, and GetNamedSecurityInfo did not
report the owner and group the same way there.
None of that grants anyone anything, and the test is about whether a capability
grant survived a failed setup, so compare the access-control entries instead.
The failure message now names the entry that survived rather than printing two
SDDL strings to diff by eye.
Three things, all on the elevated Windows setup path.
The capability ACL is committed by SetSecurityInfo before the ride-along stamp
is written, and the apply returns a rollback closure only on success, so a
stamp failure left the caller with nothing to compensate with. Setup reported
failure while leaving the grant on a pre-existing runtime root. The captured
descriptor is now restored through the same bound handle before the error goes
back, and a test hook makes that path reachable.
Runtime-root selection was best effort in BuildWindowsSandboxSetupArgs: on
failure setup carried on and wrote a marker recording no runtime root, having
already applied the ACLs. A later command selects a concrete root of its own,
finds no stamp on it and refuses, and re-running setup takes the same branch
again. That is the permanent brick the recorded-root contract exists to
prevent, reached by the one path allowed to skip it. Selection failure now
fails setup before any ACL or marker state is persisted.
The fallback-runtime test stubbed only the cache derivation, so the temp
derivation still read the process environment and the deterministic root landed
in the developer's real temp directory and stayed there. Both inputs are now
test owned, and the test asserts the redirect took rather than assuming it.
The runtime root grants the capability SID FILE_GENERIC_WRITE with
SUB_CONTAINERS_AND_OBJECTS_INHERIT, which is what lets a sandboxed command
write TMP, GOCACHE, GOMODCACHE and the package-manager caches beneath it. The
stamp is created inside that root, so it inherited the same grant. A restricted
command carrying the capability could open and overwrite the file attesting its
own setup after passing its own pre-launch validation. Its command would
continue, and every later elevated command and zero doctor would then reject
the altered plan hash until an Administrator re-ran setup: a sandboxed process
bricking the sandbox for everything after it.
Moving the attestation outside the tree is the other way to close it and is
worse. The stamp works precisely because it dies with the tree, which is how
eviction is detectable without reading an ACE. Keeping it inside with
inheritance switched off preserves that property.
The stamp now gets an explicit DACL naming only CREATOR OWNER, LocalSystem and
Administrators, applied with PROTECTED_DACL_SECURITY_INFORMATION. Protected
rather than merely explicit, because without that bit the inherited capability
ACE stays in the DACL beside whatever is set. Both writers do it, and both open
with WRITE_DAC now, which the regression caught: SetSecurityInfo needs it on the
handle and the first version failed with "Access is denied".
The regression grants a stand-in capability SID an inheritable write on a real
root, asserts an ordinary descendant DOES inherit it so the case is genuinely
exercised, then asserts the stamp neither grants that SID nor leaves its DACL
unprotected, and that setup can still read back what it wrote.
The stamp is supposed to prove that the directory a command later uses is the
one setup granted the capability ACE to, and writing it afterwards by pathname
cannot prove that however careful the second open is. The ACE went on through a
rooted handle, that handle closed, network setup ran, and only then did the
marker write re-open the name. A local process that can reach the user-owned
runtime tree removes the predictable root in that window and puts an ordinary
directory in its place. The re-open rejects a junction correctly, but an
ordinary replacement is not a reparse point and is not the ACL-bearing object
either, so it collected a valid-looking stamp. Marker validation then passed
over a directory with no capability ACE, and the next WRITE_RESTRICTED command
failed its cache and TMP writes while setup insisted it was current.
The stamp now rides along with the ACE. The marker is built before the apply so
the plan hash is known, the apply writes the stamp through the same handle it
just set the DACL on, and the marker write records the file and never names the
runtime tree again. The rollback snapshot moves ahead of the apply for the same
reason, so a failed setup restores what it found rather than its own artifact.
The regression drives the real apply and swaps the directory in the window
between the two, which is the only place the difference shows. A direct
handle-level test cannot see it: any name derived from a retained handle
resolves back to the same object, so a pathname write derived that way lands
correctly and the test passes either way. I had written that test first and it
survived the falsification, so it is gone rather than left looking like cover.
Also: the doctor stamp test was creating, removing and recreating a directory
under the developer's REAL user cache, because the runtime candidate is derived
from the process's actual cache directory. The workspace digest made a collision
with a live runtime tree unlikely rather than impossible, which is not the
standard for a test that calls RemoveAll. It redirects the cache to test-owned
storage now, and fails loudly if that ever stops working.
An audit of this branch turned up a regression this PR itself introduced, plus a
gap it left in doctor.
The temp fallback root used to come from os.MkdirTemp, which mints a random name
atomically at mode 0700 so no other local account can name it. Deriving it from
a digest of the workspace path bought stable cache reuse and gave the name away:
every component is computable, and on Linux os.TempDir() is the world-writable
/tmp whenever TMPDIR is unset. os.MkdirAll returns nil when Stat says the path
is already a directory and Stat follows links, so a link planted at an owned
component was accepted silently, the cache, data and tmp directories were built
inside whatever it pointed at, Chmod and Chtimes followed it too, and the root
was then handed to the backend as a write root (a read-write bind under bwrap)
with TMPDIR, GOCACHE, GOMODCACHE and the package-manager caches inside it. The
sandbox would have been granting the confined command write access to a
directory somebody else chose. Reproduced end to end before fixing.
The fallback digest now covers a per-user scope, and every component Zero owns
is checked for a link and for foreign ownership before anything is created
through it and again afterwards, on the shared path all three platforms take.
A link or a foreign owner is refused outright rather than relocated around,
because relocating leaves the link in place and moves to the next predictable
name; an ordinary file sitting where a component belongs is a broken machine
rather than a hostile one and still relocates, as before.
doctor built its profile with PermissionProfileFromPolicy, which never sets
Runtime, and validateWindowsSandboxRuntimeStamp returns nil early in that case.
So the one check that can tell an evicted runtime tree from a healthy one was
skipped and `zero doctor` reported a healthy sandbox on exactly the state the
stamp was added to detect. It reads the recorded root from the marker now, which
takes no lease and creates nothing.
One test in this branch was vacuous and is fixed here too: t.TempDir() names its
directory after the test, the subtest was called "link N levels above the leaf",
and strings.Contains(err.Error(), "link") was matching the path rather than the
reason, so all four subtests passed with the refusal deleted. Both that test and
the Windows traversal test now assert the sentinel and a distinctive phrase.
Three problems in the Windows setup-to-command contract.
Setup selected a runtime root, released the lease and recorded a plan hash over
pathnames, and a command later ran the same selector again. Selection consults a
lease, and a lease is a fact about one moment: with the cache root briefly
unusable setup provisioned and recorded the temp fallback, and once it freed up
the command selected the cache root instead. Two selections, two roots, a marker
that could never validate, and re-running setup did not help because setup was
equally free to pick the other one. The concrete root is recorded in the marker
now and the command consumes it. A recorded root is only honoured when it is one
of the two this workspace derives, so a root belonging to another workspace is
ignored, and a recorded root that cannot be leased fails with a message naming
the situation instead of relocating into a tree nothing provisioned.
The reparse-point checks inspected pathnames, and everything after them reopened
the tree by name. FILE_FLAG_OPEN_REPARSE_POINT governs only the final component,
so a junction planted at an owned ancestor after the last check was followed and
the elevated capability ACL landed on a leaf inside the attacker's directory.
Junctions need no privilege. A second check narrows that window and cannot close
it, so the owned tail is now walked one component at a time through NtCreateFile
relative to the handle above it, refusing a reparse point at each step, and the
resulting handle is what the ACL apply and the stamp write use. The stamp used
MkdirAll and a pathname write, which was the same hole again after the ACL had
been applied. The base above the owned components is still followed: a
redirected LOCALAPPDATA is ordinary configuration, not an attack.
Rollback returned as soon as the ACL restore reported an error, so the runtime
rollback never ran and the failure most likely to leave a machine in a strange
state was the one that skipped half the cleanup. Every compensation runs now and
the errors are joined. The stamp is part of the rollback record too: it lands
inside the runtime root before the marker is renamed into place, so a late
failure used to leave a root that was non-empty and therefore unremovable by
design. A stamp that was already there is restored rather than deleted, so a
machine whose previous setup succeeded does not start reporting itself broken.
The three P1s, and they were one defect: setup recorded what it INTENDED, a
fingerprint of a plan built from a root it merely derived, and never what it
actually provisioned.
A command derived the same cache root, failed to LEASE it, and silently
relocated to the temp fallback. Its plan then named a tree setup had never
provisioned, and the marker rejected it:
windows sandbox setup is out of date: permission roots or deny lists changed
(marker plan e0b1c3fec819, 2 entries; this command wants 8a75a38d0006, 2 entries)
blaming permissions for a runtime-root disagreement. Re-running setup could not
recover, because sandboxRuntimeRootFor rejects a candidate only for landing
inside the workspace, never for being unusable, so setup chose the same
unleasable root again. The only escapes were deleting the marker, which silently
drops WFP network enforcement, or turning the sandbox off, and the error
mentions neither. Setup and commands now select through one function, lease
attempt and fallback included, so a relocation is something they agree on.
The marker also could not tell whether the directory its pathnames resolve to
was still the one setup provisioned. cleanupSandboxRuntimeRoots evicts inactive
roots and the next run recreates the same pathname with ordinary inherited
permissions; the plan hash is over pathnames, so both marker checks reported
setup as current while the tree carried no capability ACE and a WRITE_RESTRICTED
token could not write TMP or GOCACHE. Setup now stamps the tree it provisioned,
alongside the marker and after the ACL has applied, and validation reads it. A
file inside the tree survives exactly as long as the tree does, so eviction is
detectable without needing to read an ACE.
Provisioning also created by pathname. Every component below the user cache root
is ours, a local user needs no privilege to create a junction, and planting one
at zero, runtime or v1 made the leaf land in their tree while
openWindowsACLTarget saw no reparse point ON THE LEAF and would have granted the
sandbox capability write access to a directory they control. Refused at every
component we own, before and after creation, and deliberately NOT above them:
a redirected LOCALAPPDATA is an ordinary configuration.
That last point caught a regression from the previous commit on this branch. Its
existence walk used os.Lstat, which reports a junction as not-a-directory, so a
redirected cache root was refused outright with "exists and is not a directory".
Existence follows links now; whether a link is acceptable is the separate
question above.
The marker schema is bumped, because a marker written by the previous version
has no stamp and requiring one without a bump would report every already-set-up
machine as broken rather than as out of date.
NOT VERIFIED HERE, said plainly: the elevated apply needs Administrator and this
machine is not. Everything above was exercised unelevated through the real
entry points, and the ACL write itself was not.
Two of the five findings from review. The three P1s are not addressed here and I
have said so on the PR rather than implying otherwise.
Setup materializes runtime roots before the network plan, the ACL apply, the
network apply and the marker write. Any of those can fail, and the rollback that
existed restored only ACL snapshots, so a run that reported failure still left
new persistent runtime directories behind. It could not have cleaned them up
even in principle, because provisioning returned nothing about what it had made.
Provisioning now records the components it actually created and hands back a
rollback, composed once at the top of the elevated path so no later failure can
forget it. It removes only what this run created, innermost first, and uses
os.Remove rather than os.RemoveAll: a directory that is not empty by then holds
something this run did not create, and removing it would turn a failed setup
into data loss. Refusing keeps the residue findable and reports it.
The provisioning test derived a real user-cache runtime path because it took a
bare t.TempDir() instead of the owned fixture the neighbouring tests use, so it
created and then RemoveAll'd a genuine ~/.cache/zero/runtime tree on every run
and failed outright on a read-only home. runtimeRootTestConfig now routes
through windowsRuntimeTestRoots, which redirects every derivation input before
any candidate is computed and refuses to run if a candidate escapes the owned
roots. Centralizing it there covers the other tests built on that config too.
The guard added with the provisioning tests measured a canonicalized
candidate against a raw t.TempDir(), so it fired on every machine where
those two spellings differ and passed on the one where they do not. macOS
resolves /var to /private/var and the CI Windows runner's profile has an
8.3 name, so both failed; this box has neither and stayed green.
That is the same one-sided comparison this PR fixes in
fallbackSandboxRuntimeRoot, written into the guard meant to protect
against it. Both sides go through canonicalSandboxWorkspaceRoot now.
Review found the setup marker depended on the caller's transient TEMP.
Setup run from one terminal recorded a plan containing that shell's
temp-derived candidate; a later parent process started by an IDE or a
service with a different TEMP built a different path, and every command
failed the marker's equality check while the cache runtime sat there
untouched and healthy. That is the outage in #881 arriving by another
route.
The cause was two derivations of the same thing. sandboxRuntimeRootFor
decides the tree the process uses; windowsSandboxRuntimeCandidates
separately decided the tree the marker fingerprints, and folded in BOTH
candidates so that whichever the command picked was covered. That bought
agreement at the price of putting os.TempDir() into a machine-wide
fingerprint.
This removes the second derivation rather than trying to keep two in
step. windowsSandboxRuntimeRoots pins to profile.Runtime.Root when the
profile has one, because by then prepareSandboxRuntime has chosen,
created and leased that tree, so the plan names exactly what the command
writes to, including after a lease-failure relocation. Callers with no
runtime yet (elevated setup, doctor) derive through sandboxRuntimeRootFor
itself, so setup and command cannot drift.
The lease-failure relocation in prepareSandboxRuntime is deliberately
left alone. It is load-bearing off Windows, where there is no marker and
relocation is inherently consistent, and removing it would trade this bug
for a cross-platform one.
Scope, since the finding is broader than what this closes: the runtime
root no longer tracks TEMP, but the full plan hash still does, because
PermissionProfileFromPolicy grants os.TempDir() as a write root when the
policy allows temp. That predates this PR. The test says so in a comment
rather than asserting a stability the code does not yet have.
Both new tests fail with the change reverted.
The new provisioning tests derived their runtime candidates from the real
os.UserCacheDir() and then cleared one to prove provisioning recreates it,
so every run deleted a directory under the developer's actual cache and
the tests failed outright on a read-only home. The test has no ownership
claim on that path, so it must not derive one.
windowsRuntimeTestRoots now stubs sandboxUserCacheDir and TMP/TEMP to
t.TempDir() before deriving anything, and refuses to run at all if a
derived candidate falls outside those owned roots, so a later change to
the derivation cannot quietly reintroduce this.
Measured against the real cache directory: the previous version added an
entry on every run, this one adds none.
Review found that the runtime roots this PR folds into the permission
profile were granted but never created. applyWindowsACLPlan materializes
only DenyRead targets, so an AllowWrite target that does not exist fails
the whole run with "windows ACL target does not exist" -- turning a clean
`zero sandbox setup` into a different outage than the one being fixed.
ensureWindowsSandboxRuntimeCandidates already existed for exactly this
reason and its doc comment names the failure, but the split from the
parent PR carried the function across without its call site.
Provisioning now sits with whoever computes the candidates, because the
two have to happen in the same environment:
- buildWindowsSandboxSetupACLPlan provisions, then builds the elevated
plan.
- windowsSandboxProfileWithProvisionedRuntime provisions, then returns
the command profile, and BuildCommandPlan calls it in the PARENT. The
unelevated tier applies its own plan inside the re-exec'd runner, where
TEMP points into the runtime tree, so the runner can derive neither the
paths nor the directories.
Also from review:
- doctor checked the marker against the bare profile, so `zero doctor`
reported a correctly prepared machine as out of date. It now folds in
the same roots setup and the command plan use.
- fallbackSandboxRuntimeRoot derived from a raw os.TempDir() while
pathWithinRoot compares spellings, so an aliased TEMP read as outside
the workspace. Canonicalized like the workspace and cache roots. This
closes short-name and symlink aliases, not junctions, which
EvalSymlinks returns unchanged; the comment says so rather than
implying the case is shut.
- windowsSandboxRuntimeCandidates deriving from the first workspace root
only is deliberate, not a defect: the marker compares plan hashes for
equality and a command presents one root, so widening it would put
entries in the marker no command reproduces. Documented and pinned by a
test, since the suggested fix would have reintroduced the outage.
Tests drive production entry points rather than the helpers behind them.
The previous round shipped tests that called the helpers directly and
stayed green with the call sites deleted, which is how the missing
provisioning survived. Each of the four new assertions was verified to
fail with its fix reverted.
Every exec_command on a Windows machine that had run `zero sandbox setup`
aborted with "windows sandbox setup is out of date: permission roots or
deny lists changed". File tools worked, only shell execution died, and
`zero doctor` reported the backend healthy throughout, so there was
nothing pointing at the cause. Reported in #881 with an accurate trace.
Setup fingerprinted the bare permission profile into the marker. Every
command arrived with the per-workspace runtime root already appended by
permissionProfileWithRuntime, so the plan hash the runner computed could
never match the one setup stored, and a marker written seconds earlier
was rejected forever.
Both sides now fold in the same runtime candidate set before the profile
is fingerprinted.
Three things this needs to get right, each of which broke it once.
BOTH candidates, not the one this process would pick.
sandboxRuntimeRootFor prefers the cache-derived root and falls back to
the temp-derived one when the cache sits inside the workspace, and that
choice is per process. Granting only one left a command that fell back
writing to a tree with no ACE on it.
The fallback has to be derived rather than minted. It used os.MkdirTemp
memoized in a process-global map, so the answer was private to whichever
process asked first: setup granted temp root A, the next command derived
root B, teardown cleaned a third. It is now a hash of the workspace and
creates nothing, so every process agrees without sharing state.
The runner cannot derive the candidates itself. It runs re-exec'd with
TEMP and TMP already pointed at the sandbox runtime temp, so os.TempDir()
there returns the redirected value and the temp-derived candidate comes
out rooted under the runtime tree. The profile is augmented in the parent
and passed down.
Split out of #808, which carries this fix among the Windows principal
work. That PR has open architectural questions and this does not, and
the bug is a user-visible outage on one platform, so it should not wait
on them.
On the tests, because one of them was not enough. The composition test
calls the helper directly and stays green even with the production call
site deleted, which is the same class of bug as the fix itself. The
added call-path test drives BuildCommandPlan and asserts the runtime
roots reach the runner's argv; reverting the runner call fails it with
the missing root named.
2026-09-02 11:56:23 +05:30
834 changed files with 21998 additions and 2540 deletions
echo "::error::dist-tag latest points at platform version ${latest} — a platform publish clobbered it. Fix the dist-tag before the wrapper publish." >&2
exit 1
;;
esac
echo "dist-tag latest is ${latest} (unchanged by platform publishes)."
* **cli:** let providers subcommands run with no active provider ([#1143](https://github.com/Twigpine/zero/issues/1143)) ([d1a0d04](https://github.com/Twigpine/zero/commit/d1a0d048ee2c1e7cbfb75160ec4c9f099f3c4472))
* **npm:** wait for npm to apply each publish, and drop the one-time @gitlawb/zero steps ([#1145](https://github.com/Twigpine/zero/issues/1145)) ([8e363fc](https://github.com/Twigpine/zero/commit/8e363fcaa5d51bf5b5b244692dc61bf8d4ae8799))
* **tui,cli:** stop a repository's git config from running programs in Zero's own git calls ([#1133](https://github.com/Twigpine/zero/issues/1133)) ([640d936](https://github.com/Twigpine/zero/commit/640d936bfbe00c4cd3017ca35fb7b5c15d91a1c5))
* **workspaceindex:** skip git-ignored and build-cache dirs in Scan only ([#1117](https://github.com/Twigpine/zero/issues/1117)) ([c88c77b](https://github.com/Twigpine/zero/commit/c88c77bf701039dca01d9786bf5648d779c949b3))
### Performance Improvements
* **redaction:** add fast-path byte checks to eliminate regex allocations on clean strings ([#945](https://github.com/Twigpine/zero/issues/945)) ([ce2fcff](https://github.com/Twigpine/zero/commit/ce2fcffaf10d8e8c8c5a13e06fd7809f09fc9867))
* **notify:** make permission-prompt alerts on by default and discoverable ([#1001](https://github.com/Twigpine/zero/issues/1001)) ([7f8e5f0](https://github.com/Twigpine/zero/commit/7f8e5f08bb83371ce16cc54c97ab032a010026cf))
* **npm:** publish as @twigpine/zero ([#1142](https://github.com/Twigpine/zero/issues/1142)) ([530563a](https://github.com/Twigpine/zero/commit/530563a0bb58d3cbadceb97b3ee234ef763124df))
* **sessions:** add zero sessions prune, which never removes a session another process has open ([#1089](https://github.com/Twigpine/zero/issues/1089)) ([23c27e0](https://github.com/Twigpine/zero/commit/23c27e0778e3af54c04b8749d1eda846d3253f4e))
### Bug Fixes
* **acp:** persist the session permission mode across restarts ([#1134](https://github.com/Twigpine/zero/issues/1134)) ([bb96206](https://github.com/Twigpine/zero/commit/bb96206c2781e846fd13437113eec5790afe9c81)), closes [#1125](https://github.com/Twigpine/zero/issues/1125)
* **cron:** bound run history and read recent records from the tail ([#1094](https://github.com/Twigpine/zero/issues/1094)) ([42eb6ab](https://github.com/Twigpine/zero/commit/42eb6ab5fbe4ae9037f1f4cd42a00243c456a22e))
* **daemon:** bound pending sessions and prune on completion ([#1091](https://github.com/Twigpine/zero/issues/1091)) ([8a68660](https://github.com/Twigpine/zero/commit/8a68660161b04ce9668363faa14ff72c9fbad223))
* **daemon:** do not retry workers that finished with a final exit code ([#1112](https://github.com/Twigpine/zero/issues/1112)) ([eefa978](https://github.com/Twigpine/zero/commit/eefa978f6183332b7416c70c25242bcdfba1a75c)), closes [#1100](https://github.com/Twigpine/zero/issues/1100)
* **daemon:** stop worker process groups by their launch-time identity ([#1088](https://github.com/Twigpine/zero/issues/1088)) ([ab018b3](https://github.com/Twigpine/zero/commit/ab018b3098b5cb103cf8f90b8698d32a6687bdf7)), closes [#861](https://github.com/Twigpine/zero/issues/861)
* **deps:** refresh the lockfile to patched hono and @hono/node-server ([#1080](https://github.com/Twigpine/zero/issues/1080)) ([aa52040](https://github.com/Twigpine/zero/commit/aa52040498e012c41d5e8f6027a91c18eff861d4))
* **deps:** refresh the lockfile to patched shell-quote ([#1131](https://github.com/Twigpine/zero/issues/1131)) ([c56a4c4](https://github.com/Twigpine/zero/commit/c56a4c4e6e598504ab417e8cc4f5ab8d69f1b42f))
* **execution:** reserve process capacity before launching ([#1092](https://github.com/Twigpine/zero/issues/1092)) ([bb3346a](https://github.com/Twigpine/zero/commit/bb3346a82883c19ed8815fe1e874f54950711d69))
* **mcp:** forward image tool results on the existing image channel ([#1077](https://github.com/Twigpine/zero/issues/1077)) ([0324c1d](https://github.com/Twigpine/zero/commit/0324c1d4103328eb90cb24a0b582dba335255b5c))
* **mcp:** refresh OAuth tokens for discovered endpoints and registered clients ([#1113](https://github.com/Twigpine/zero/issues/1113)) ([571af70](https://github.com/Twigpine/zero/commit/571af701bd3d8dfaf40f87b5b112b25e7def5b29)), closes [#1101](https://github.com/Twigpine/zero/issues/1101)
* **oauth:** give every loopback callback server the bounded shutdown ([#1079](https://github.com/Twigpine/zero/issues/1079)) ([5473301](https://github.com/Twigpine/zero/commit/547330197b20ece591e077e876a4b8ffd015cb6e))
* **providers:** send stable session headers to OpenCode ([#1060](https://github.com/Twigpine/zero/issues/1060)) ([99721c7](https://github.com/Twigpine/zero/commit/99721c762f37cd43ac511007a5f51d1846df959e))
* **redaction:** redact adjacent AWS keys glued end to end ([#1095](https://github.com/Twigpine/zero/issues/1095)) ([df67ede](https://github.com/Twigpine/zero/commit/df67edec13345b73a85327adc003cacc7bdd8009))
* **redaction:** redact credentials split by an invisible character ([#1067](https://github.com/Twigpine/zero/issues/1067)) ([e20b762](https://github.com/Twigpine/zero/commit/e20b7624261cef8336a8eb0fbdd44d536ad77fc2))
* **sandbox:** say when a session's sandbox enforcement is degraded ([#1082](https://github.com/Twigpine/zero/issues/1082)) ([6b5852d](https://github.com/Twigpine/zero/commit/6b5852dc18bb256af1f25745697c2f2e3ea6f112))
* **search:** redact every text field of session metadata, not ten of thirty ([#1073](https://github.com/Twigpine/zero/issues/1073)) ([9520f2b](https://github.com/Twigpine/zero/commit/9520f2bad7e09ce4a8d0d42be7ce77d2ae928ddd))
* **security:** harden shell flag parsing, write roots, and formatters ([#1066](https://github.com/Twigpine/zero/issues/1066)) ([ac5fe0d](https://github.com/Twigpine/zero/commit/ac5fe0d21849b0f74af428dd3ca12e3b80a8b7aa))
* **sessions:** keep a session lease for the life of the process, not of the Store ([#1144](https://github.com/Twigpine/zero/issues/1144)) ([883e5be](https://github.com/Twigpine/zero/commit/883e5bec24dac791d1d60618c7f4927f4659b687))
* **tools:** hint when Windows resolves POSIX paths under root ([#1063](https://github.com/Twigpine/zero/issues/1063)) ([0c6f179](https://github.com/Twigpine/zero/commit/0c6f179a8443d4dfdfbbefe5cc6caf11c2a525f0))
* **tui:** bound live dictation regions ([#985](https://github.com/Twigpine/zero/issues/985)) ([feb9927](https://github.com/Twigpine/zero/commit/feb992757c582c68f0397e070e206d4bf07071e1))
* **tui:** show provider ownership in Recent and Favorites ([#1083](https://github.com/Twigpine/zero/issues/1083)) ([1697b7a](https://github.com/Twigpine/zero/commit/1697b7a5ce948e781877cdbe9223adac62c3fde6))
* **tui:** stop held peer message Allow/Deny from deadlocking Update ([#1111](https://github.com/Twigpine/zero/issues/1111)) ([a195e88](https://github.com/Twigpine/zero/commit/a195e8848cec1a27b4fe1789ecc572b943c32186)), closes [#1099](https://github.com/Twigpine/zero/issues/1099)
* unbreak main CI (cron claim tests, Go 1.26.9) ([#1124](https://github.com/Twigpine/zero/issues/1124)) ([2c418fc](https://github.com/Twigpine/zero/commit/2c418fc8ff6763de16703128f372fe526fddd331))
// TestMain points userConfigDirForPrompt at an empty, package-wide temp
Loaded 100 of 834 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.