Compare commits

..
Author SHA1 Message Date
Vasanthdev2004 b46e943e68 Merge remote-tracking branch 'origin/main' into fix/windows-setup-marker-runtime-root 2026-10-25 19:42:10 +05:30
Vasanth T 5d080ef46b test(tui): pin the cwd in tests that assert text is absent from the view (#1170)
* test(tui): pin the cwd in tests that assert text is absent from the view

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

End-chaining for fixed-length bodies, single-credential shapes, custom
replacement templates, and the split path's announced no-op-for-contiguous
text guarantee are all unchanged; the regression tests pin the glued pairs
that previously leaked plus the AWS chaining and lone-token cases.
2026-10-18 10:07:04 +08:00
Vasanthdev2004 de4dfbe551 fix(sandbox): say when an ACL rollback target was never identified
A snapshot whose identity could not be read at apply time failed the
rollback match the same way a replaced target does, and the operator was
told the target had been replaced. Both still leave the object alone;
the message now says which one happened. Reported by CodeRabbit.
2026-10-17 18:06:09 +05:30
Vasanthdev2004 ae30755ce7 refactor(doctor): fingerprint the recorded runtime root that was just checked
The setup check read the marker's runtime root once to decide whether it
is current, then read the marker again to build the profile it
fingerprints. Reuse the first value, so a marker rewritten between the
two reads cannot put an unchecked root into the fingerprint. Reported by
CodeRabbit.
2026-10-17 18:06:09 +05:30
Vasanthdev2004 d6d0d0cc25 Merge remote-tracking branch 'origin/main' into fix/windows-setup-marker-runtime-root 2026-10-17 17:27:08 +05:30
Vasanth T ef4da1323f fix(workspaceindex): import gitsafe under the Twigpine module path (#1153)
#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.
2026-10-17 16:59:52 +08:00
zzucs 1e848492e5 fix(sessions): preserve image attachment summaries on resume (#1152)
* 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
2026-10-17 16:59:21 +08:00
Vasanthdev2004 0121fc4f3c chore: follow the Twigpine module path
main moved the module to github.com/Twigpine/zero in #1105. Rename this
branch's imports to match, including the two in internal/workspaceindex
that main itself still carries until #1153 lands.
2026-10-17 11:51:05 +05:30
Vasanthdev2004 158d5ee62f Merge remote-tracking branch 'origin/main' into fix/windows-setup-marker-runtime-root 2026-10-17 11:51:02 +05:30
Vasanth T 2ffe1831b0 fix(workspaceindex): run Scan's git calls through gitsafe (#1149)
* 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.
2026-10-17 07:14:03 +08:00
PierrunoYTandClaude Opus 5.5 13e2596b2e chore: move repository references to Twigpine/zero (#1105)
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]>
2026-10-17 07:13:21 +08:00
HazyLabandcl-ment 8c4e759798 feat(sandbox): support self-exec pattern to eliminate separate helper binary (#946)
* 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]>
2026-10-16 14:09:28 +08:00
Vasanthdev2004 4c945485d0 chore(sandbox): ignore the read-only closes explicitly, as lint asks 2026-09-26 09:11:28 +05:30
Vasanthdev2004 7f9ca0860a fix(sandbox): prepare the runtime tree's children through a handle on its root
The runtime root is a write root, so a sandboxed command can replace
cache, data, tmp or any of the listed grandchildren with a link to a host
directory. The fallback root is derived rather than minted, so the next
Zero process prepares that same tree unsandboxed, and it created and
chmodded every child by pathname after checking only the root and its
ancestors. It followed the planted link: a contained command could get
Zero to create npm inside a host directory and change that directory's
mode.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Two things fell out of doing it properly.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Also from review:

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

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

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

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

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

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

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

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

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

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

No files matched your search

+10 -9
View File
@@ -12,7 +12,7 @@
<p align="center">
<a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-MIT-blue"></a>
<img alt="Go 1.26.9+" src="https://img.shields.io/badge/Go-1.26.9+-00ADD8?logo=go&logoColor=white">
<img alt="Go 1.26.10+" src="https://img.shields.io/badge/Go-1.26.10+-00ADD8?logo=go&logoColor=white">
<img alt="25+ providers" src="https://img.shields.io/badge/providers-25+-34E2EA">
<a href="https://discord.gg/CaQDS6wdFn"><img alt="Discord" src="https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white"></a>
<br>
@@ -158,7 +158,7 @@ irm https://raw.githubusercontent.com/Twigpine/zero/main/scripts/install.ps1 | i
### From source
Source builds require Go 1.26.9+.
Source builds require Go 1.26.10+.
```bash
git clone https://github.com/Twigpine/zero.git
@@ -173,17 +173,18 @@ If you are testing before the first public release, build from source:
go build -o zero ./cmd/zero
```
On Linux, build the sandbox helper too if you want native sandboxing:
On Linux, native sandboxing needs Bubblewrap. The main `zero` binary re-executes
itself as the helper when `zero-linux-sandbox` is absent. A separate helper next
to `zero` or on `PATH` still takes precedence:
```bash
go build -o zero-linux-sandbox ./cmd/zero-linux-sandbox
go build -o zero-seccomp ./cmd/zero-seccomp # optional compatibility wrapper
go build -o zero-linux-sandbox ./cmd/zero-linux-sandbox # optional
go build -o zero-seccomp ./cmd/zero-seccomp # optional compatibility wrapper
```
Put `zero` and `zero-linux-sandbox` in the same directory on `PATH`
(`~/.local/bin` is a good default). macOS does not need an extra helper binary.
Windows source builds can use the main `zero.exe` as their sandbox helper; release
archives still ship standalone Windows helper executables.
Put `zero` on `PATH` (`~/.local/bin` is a good default). macOS does not need an
extra helper binary. Windows source builds can use the main `zero.exe` as their
sandbox helper; release archives still ship standalone Windows helper executables.
More install details: [docs/INSTALL.md](docs/INSTALL.md).
+6 -6
View File
@@ -12,7 +12,7 @@
<p align="center">
<a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-MIT-blue"></a>
<img alt="Go 1.26.9+" src="https://img.shields.io/badge/Go-1.26.9+-00ADD8?logo=go&logoColor=white">
<img alt="Go 1.26.10+" src="https://img.shields.io/badge/Go-1.26.10+-00ADD8?logo=go&logoColor=white">
<img alt="25+ providers" src="https://img.shields.io/badge/providers-25+-34E2EA">
<a href="https://discord.gg/CaQDS6wdFn"><img alt="Discord" src="https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white"></a>
<br>
@@ -136,7 +136,7 @@ irm https://raw.githubusercontent.com/Twigpine/zero/main/scripts/install.ps1 | i
### 从源码构建
源码构建需要 Go 1.26.9+。
源码构建需要 Go 1.26.10+。
```bash
git clone https://github.com/Twigpine/zero.git
@@ -150,14 +150,14 @@ go run ./cmd/zero
go build -o zero ./cmd/zero
```
在 Linux 上,如果你需要原生沙箱,还需要构建沙箱辅助程序:
在 Linux 上,原生沙箱需要 Bubblewrap。主程序 `zero` 在找不到 `zero-linux-sandbox` 时会以自身作为辅助进程重新执行。同目录或 PATH 上的独立 helper 仍优先:
```bash
go build -o zero-linux-sandbox ./cmd/zero-linux-sandbox
go build -o zero-seccomp ./cmd/zero-seccomp # 可选的兼容性包装器
go build -o zero-linux-sandbox ./cmd/zero-linux-sandbox # 可选
go build -o zero-seccomp ./cmd/zero-seccomp # 可选的兼容性包装器
```
将 `zero` 和 `zero-linux-sandbox` 放在 `PATH` 上的同一目录中(`~/.local/bin` 是一个好的默认选择)。macOS 不需要额外的辅助二进制文件。Windows 源码构建可以使用主 `zero.exe` 作为沙箱辅助程序;发布包仍然附带独立的 Windows 辅助可执行文件。
将 `zero` 放在 `PATH` 上(`~/.local/bin` 是一个好的默认选择)。macOS 不需要额外的辅助二进制文件。Windows 源码构建可以使用主 `zero.exe` 作为沙箱辅助程序;发布包仍然附带独立的 Windows 辅助可执行文件。
更多安装细节:[docs/INSTALL.md](docs/INSTALL.md)。
+5 -5
View File
@@ -2,8 +2,8 @@
## Questions, setup help, configuration
Ask in [GitHub Discussions](https://github.com/Gitlawb/zero/discussions). Use
[Q&A](https://github.com/Gitlawb/zero/discussions/categories/q-a) so answers can
Ask in [GitHub Discussions](https://github.com/Twigpine/zero/discussions). Use
[Q&A](https://github.com/Twigpine/zero/discussions/categories/q-a) so answers can
be marked and found by the next person.
The fastest way to a useful answer is to include:
@@ -18,19 +18,19 @@ The fastest way to a useful answer is to include:
## Bugs
If Zero does something wrong (crashes, wrong output, broken behavior), file a
[bug report](https://github.com/Gitlawb/zero/issues/new/choose). If you are not
[bug report](https://github.com/Twigpine/zero/issues/new/choose). If you are not
sure whether it is a bug or a setup problem, start in Discussions and we will
promote it to an issue if it is one.
## Feature ideas
Start a thread in
[Ideas](https://github.com/Gitlawb/zero/discussions/categories/ideas). Zero is
[Ideas](https://github.com/Twigpine/zero/discussions/categories/ideas). Zero is
in controlled development (see CONTRIBUTING.md), so discussing an idea first is
also the path to getting a PR accepted.
## Security issues
Never file a public issue or discussion. Report privately via
[GitHub Security Advisories](https://github.com/Gitlawb/zero/security/advisories/new)
[GitHub Security Advisories](https://github.com/Twigpine/zero/security/advisories/new)
(see SECURITY.md).
+1 -1
View File
@@ -5,7 +5,7 @@ package main
import (
"os"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Twigpine/zero/internal/sandbox"
)
func main() {
+1 -1
View File
@@ -11,7 +11,7 @@ import (
"strconv"
"strings"
"github.com/Gitlawb/zero/internal/perfbench"
"github.com/Twigpine/zero/internal/perfbench"
)
const (
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"strings"
"testing"
"github.com/Gitlawb/zero/internal/perfbench"
"github.com/Twigpine/zero/internal/perfbench"
)
func TestParseArgsUsesCliAndEnvOverrides(t *testing.T) {
+1 -1
View File
@@ -9,7 +9,7 @@ import (
"path/filepath"
"strings"
"github.com/Gitlawb/zero/internal/perfbench"
"github.com/Twigpine/zero/internal/perfbench"
)
// taskOptions configures the `zero-perf-bench tasks` subcommand: the reproducible
+2 -2
View File
@@ -9,8 +9,8 @@ import (
"path/filepath"
"strings"
"github.com/Gitlawb/zero/internal/execprofile"
"github.com/Gitlawb/zero/internal/perfbench"
"github.com/Twigpine/zero/internal/execprofile"
"github.com/Twigpine/zero/internal/perfbench"
)
// turnOptions configures the `zero-perf-bench turn` subcommand: the per-turn
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"os"
"strings"
"github.com/Gitlawb/zero/internal/review"
"github.com/Twigpine/zero/internal/review"
)
func main() {
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"path/filepath"
"strings"
"github.com/Gitlawb/zero/internal/release"
"github.com/Twigpine/zero/internal/release"
)
func main() {
+1 -1
View File
@@ -12,7 +12,7 @@ import (
"os/exec"
"syscall"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Twigpine/zero/internal/sandbox"
)
func main() {
+1 -1
View File
@@ -3,7 +3,7 @@ package main
import (
"os"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Twigpine/zero/internal/sandbox"
)
func main() {
+1 -1
View File
@@ -3,7 +3,7 @@ package main
import (
"os"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Twigpine/zero/internal/sandbox"
)
func main() {
+5 -1
View File
@@ -3,9 +3,13 @@ package main
import (
"os"
"github.com/Gitlawb/zero/internal/cli"
"github.com/Twigpine/zero/internal/cli"
"github.com/Twigpine/zero/internal/sandbox"
)
func main() {
if len(os.Args) > 1 && os.Args[1] == sandbox.LinuxSandboxHelperSubcommand {
os.Exit(sandbox.RunLinuxSandboxHelper(os.Args[1:], os.Stderr))
}
os.Exit(cli.Run(os.Args[1:], os.Stdout, os.Stderr))
}
+27 -14
View File
@@ -185,13 +185,19 @@ describe a version that is no longer on disk. Prefer `mise upgrade`.
go install github.com/Gitlawb/zero/cmd/zero@latest
```
This builds from source, so it needs Go 1.26.9+ and it does not go through the
Every release so far declares the module path `github.com/Gitlawb/zero`, so
`go install` has to use that path; `github.com/Twigpine/zero/cmd/zero@latest`
fails with "module declares its path as" until a release is tagged with the new
module path.
This builds from source, so it needs Go 1.26.10+ and it does not go through the
release archives. Two consequences worth knowing before you pick it:
- On Linux you also need the sandbox helper, which is a separate binary and is
not installed by this command. See
- On Linux, Bubblewrap is still required for native sandboxing. Zero resolves
the helper in this order: an executable `zero-linux-sandbox` next to `zero`,
then `PATH`, then self-exec of the main binary. Standalone helpers remain
optional and preferred. See
[Sandbox Helpers For Source Builds](#sandbox-helpers-for-source-builds).
Without it, native sandboxing is unavailable.
- `zero upgrade` treats the result as a standalone install and will replace the
binary with a release build rather than rebuilding from source. If you chose
`go install` deliberately, rerun it instead.
@@ -212,25 +218,32 @@ Build a local binary:
go build -o zero ./cmd/zero
```
Source builds require Go 1.26.9+.
Source builds require Go 1.26.10+.
### Sandbox Helpers For Source Builds
Release archives include the platform sandbox helpers. If you build directly
from source, build the helpers you need:
Release archives include the platform sandbox helpers. A source build of the
main `zero` binary is enough for Linux native sandboxing. Zero resolves the
helper in this order:
Linux:
1. an executable `zero-linux-sandbox` next to `zero`
2. then `PATH`
3. then self-exec of the main binary
Bubblewrap must still be installed. Standalone helpers remain optional and
preferred.
A separate helper remains supported if it sits next to `zero` or on `PATH`:
```bash
go build -o zero ./cmd/zero
go build -o zero-linux-sandbox ./cmd/zero-linux-sandbox
go build -o zero-seccomp ./cmd/zero-seccomp
go build -o zero-linux-sandbox ./cmd/zero-linux-sandbox # optional
go build -o zero-seccomp ./cmd/zero-seccomp # optional compatibility wrapper
```
Put `zero` and `zero-linux-sandbox` in the same directory on `PATH`, for example
`~/.local/bin`. `zero-seccomp` is kept as a compatibility wrapper; the sandbox
helper applies the Unix-socket filter itself when that sandbox option is enabled.
Linux native sandboxing also requires Bubblewrap to be installed.
Put `zero` on `PATH`, for example `~/.local/bin`. `zero-seccomp` is kept as a
compatibility wrapper; the sandbox helper applies the Unix-socket filter itself
when that sandbox option is enabled.
macOS uses the system sandbox and does not need an extra helper binary.
+2 -2
View File
@@ -1,6 +1,6 @@
module github.com/Gitlawb/zero
module github.com/Twigpine/zero
go 1.26.9
go 1.26.10
require (
charm.land/bubbles/v2 v2.1.1
+27 -20
View File
@@ -13,18 +13,18 @@ import (
"strings"
"sync"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/modelregistry"
"github.com/Gitlawb/zero/internal/providercatalog"
"github.com/Gitlawb/zero/internal/providermodelcatalog"
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
"github.com/Gitlawb/zero/internal/redaction"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/usage"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/agent"
"github.com/Twigpine/zero/internal/config"
"github.com/Twigpine/zero/internal/modelregistry"
"github.com/Twigpine/zero/internal/providercatalog"
"github.com/Twigpine/zero/internal/providermodelcatalog"
"github.com/Twigpine/zero/internal/providermodeldiscovery"
"github.com/Twigpine/zero/internal/redaction"
"github.com/Twigpine/zero/internal/sandbox"
"github.com/Twigpine/zero/internal/sessions"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/usage"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// Deps are the ZERO capabilities the ACP Agent drives. The CLI fills these with
@@ -528,8 +528,6 @@ func (a *Agent) runTurn(ctx context.Context, sess *acpSession, userText string,
queue := func(input sessions.AppendEventInput) {
pendingEvents = append(pendingEvents, input)
}
queue(messageEvent("user", userText))
visionCache := make(map[string]bool)
var visionCacheMu sync.Mutex
supportsVision := func(modelID string) bool {
@@ -553,6 +551,9 @@ func (a *Agent) runTurn(ctx context.Context, sess *acpSession, userText string,
effectivePrompt = fmt.Sprintf("[Note: %s]\n\n%s", msg, userText)
images = nil
}
// Match the TUI: only images actually sent to the model enter history.
attachments := sessions.MessageAttachments{Images: len(images)}
queue(messageEvent("user", userText, attachments))
opts := agent.Options{
Cwd: sess.cwd,
@@ -593,7 +594,7 @@ func (a *Agent) runTurn(ctx context.Context, sess *acpSession, userText string,
agentPrompt := buildPrompt(sess.snapshotHistory(), effectivePrompt)
result, runErr := a.deps.RunAgent(ctx, agentPrompt, provider, opts)
if result.FinalAnswer != "" {
queue(messageEvent("assistant", result.FinalAnswer))
queue(messageEvent("assistant", result.FinalAnswer, sessions.MessageAttachments{}))
}
reason, stopErr := stopReasonFor(result, runErr)
if stopErr != nil {
@@ -614,7 +615,7 @@ func (a *Agent) runTurn(ctx context.Context, sess *acpSession, userText string,
// one file write, which is the contiguity restored here — not a disk
// transaction, and not a promise about crashes mid-fsync. Reported by @jatmn.
persistenceErr := a.persistTurn(sess.id, pendingEvents)
sess.appendHistory(turnRecord{user: userText, assistant: result.FinalAnswer})
sess.appendHistory(turnRecord{user: attachments.WithContent(userText), assistant: result.FinalAnswer})
a.warnPersistence(
note,
"save session history",
@@ -930,10 +931,14 @@ func (a *Agent) persistTurn(sessionID string, inputs []sessions.AppendEventInput
return err
}
func messageEvent(role, content string) sessions.AppendEventInput {
func messageEvent(role, content string, attachments sessions.MessageAttachments) sessions.AppendEventInput {
payload := map[string]any{"role": role, "content": content}
if role == "user" && (attachments.Images > 0 || attachments.Documents > 0) {
payload["attachments"] = attachments
}
return sessions.AppendEventInput{
Type: sessions.EventMessage,
Payload: map[string]any{"role": role, "content": content},
Payload: payload,
}
}
@@ -1094,14 +1099,16 @@ func (a *Agent) loadHistory(sessionID string, requireHistoryLog bool) ([]turnRec
continue
}
var msg struct {
Role string `json:"role"`
Content string `json:"content"`
Role string `json:"role"`
Content string `json:"content"`
Attachments sessions.MessageAttachments `json:"attachments"`
}
if json.Unmarshal(raw, &msg) != nil {
continue
}
switch msg.Role {
case "user":
msg.Content = msg.Attachments.WithContent(msg.Content)
messages = append(messages, persistedMessage{eventID: persistedMessageIdentity(sessionID, e), role: msg.Role, content: msg.Content})
if havePending {
records = append(records, turnRecord{user: pendingUser})
+12 -8
View File
@@ -19,13 +19,13 @@ import (
"testing"
"time"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/agent"
"github.com/Twigpine/zero/internal/config"
"github.com/Twigpine/zero/internal/providermodeldiscovery"
"github.com/Twigpine/zero/internal/sandbox"
"github.com/Twigpine/zero/internal/sessions"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// fakeProvider streams a canned assistant message and ends the turn — enough to
@@ -960,7 +960,11 @@ func TestACPHardTurnFailureDoesNotCommitHistory(t *testing.T) {
t.Fatal(err)
}
if err := h.client.Call(ctx, MethodSessionPrompt, PromptParams{
SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock("failed question")},
SessionID: created.SessionID,
Prompt: []ContentBlock{
TextBlock("failed question"),
ImageBlock(base64.StdEncoding.EncodeToString([]byte("failed image")), "image/png"),
},
}, &PromptResult{}); err == nil {
t.Fatal("hard RunAgent failure was reported as a successful turn")
}
+1 -1
View File
@@ -15,7 +15,7 @@ import (
"fmt"
"testing"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Twigpine/zero/internal/agent"
)
// ---- an offered option is an acceptable answer ----
+181
View File
@@ -0,0 +1,181 @@
package acp
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"strings"
"testing"
"time"
"github.com/Twigpine/zero/internal/agent"
"github.com/Twigpine/zero/internal/config"
"github.com/Twigpine/zero/internal/providermodeldiscovery"
"github.com/Twigpine/zero/internal/sessions"
"github.com/Twigpine/zero/internal/zeroruntime"
)
func TestACPImageAttachmentHistory(t *testing.T) {
for _, tc := range []struct {
name string
text string
images int
withoutVision bool
want string
}{
{name: "image only", images: 1, want: "[1 image]"},
{name: "text and images", text: "compare these", images: 2, want: "compare these [2 images]"},
{name: "ignored by model", images: 1, withoutVision: true, want: ""},
} {
t.Run(tc.name, func(t *testing.T) {
deps := testDeps(t)
deps.DiscoverModels = func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
modalities := []string{"text", "image"}
if tc.withoutVision {
modalities = []string{"text"}
}
return []providermodeldiscovery.Model{{ID: "fake-model", InputModalities: modalities}}, nil
}
type observedTurn struct {
prompt string
images int
}
turns := make(chan observedTurn, 8)
deps.RunAgent = func(_ context.Context, prompt string, _ zeroruntime.Provider, opts agent.Options) (agent.Result, error) {
turns <- observedTurn{prompt: prompt, images: len(opts.Images)}
return agent.Result{FinalAnswer: "received"}, nil
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
workspace := t.TempDir()
h := newHarness(t, deps)
defer h.stop()
var created NewSessionResult
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: workspace}, &created); err != nil {
t.Fatal(err)
}
const imageData = "image-history-bytes-not-for-storage"
encoded := base64.StdEncoding.EncodeToString([]byte(imageData))
var blocks []ContentBlock
if tc.text != "" {
blocks = append(blocks, TextBlock(tc.text))
}
for range tc.images {
blocks = append(blocks, ImageBlock(encoded, "image/png"))
}
if err := h.client.Call(ctx, MethodSessionPrompt, PromptParams{SessionID: created.SessionID, Prompt: blocks}, &PromptResult{}); err != nil {
t.Fatal(err)
}
live := <-turns
wantLiveImages := tc.images
if tc.withoutVision {
wantLiveImages = 0
}
if live.images != wantLiveImages {
t.Fatalf("live images = %d, want %d", live.images, wantLiveImages)
}
events, err := deps.Store.ReadEvents(created.SessionID)
if err != nil || len(events) < 2 {
t.Fatalf("read completed turn: events=%+v err=%v", events, err)
}
var user struct {
Role string `json:"role"`
Content string `json:"content"`
Attachments struct {
Images int `json:"images"`
} `json:"attachments"`
}
if err := json.Unmarshal(events[0].Payload, &user); err != nil {
t.Fatal(err)
}
if user.Role != "user" || user.Content != tc.text || user.Attachments.Images != wantLiveImages {
t.Errorf("stored user event = %s, want original text and attachments.images=%d", events[0].Payload, wantLiveImages)
}
for _, event := range events {
if bytes.Contains(event.Payload, []byte(encoded)) || bytes.Contains(event.Payload, []byte(imageData)) {
t.Errorf("image data leaked into session event: %s", event.Payload)
}
}
followUp := func(t *testing.T, client *clientHarness) {
t.Helper()
if err := client.client.Call(ctx, MethodSessionPrompt, PromptParams{
SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock("what did I send?")},
}, &PromptResult{}); err != nil {
t.Fatal(err)
}
got := <-turns
if want := "User: " + tc.want + "\n"; !strings.Contains(got.prompt, want) {
t.Errorf("seeded prompt missing user history %q: %q", want, got.prompt)
}
if got.images != 0 {
t.Errorf("historical images reattached: %d", got.images)
}
}
followUp(t, h)
h.stop()
// Fresh agents and stores reproduce a process restart, with no shared
// in-memory history to hide a missing attachment in the event log.
for _, method := range []string{MethodSessionLoad, MethodSessionResume} {
t.Run(method, func(t *testing.T) {
restoredDeps := deps
restoredDeps.Store = sessions.NewStore(sessions.StoreOptions{RootDir: deps.Store.RootDir})
loader := newHarness(t, restoredDeps)
defer loader.stop()
if err := loader.client.Call(ctx, method, LoadSessionParams{SessionID: created.SessionID, Cwd: workspace}, &LoadSessionResult{}); err != nil {
t.Fatal(err)
}
if method == MethodSessionLoad {
select {
case update := <-loader.notifications:
if update.SessionUpdate != UpdateUserMessageChunk || update.Content.Text != tc.want {
t.Errorf("replayed user = %+v, want %q", update, tc.want)
}
case <-ctx.Done():
t.Fatal("user turn was not replayed")
}
} else {
select {
case update := <-loader.notifications:
t.Errorf("resume replayed transcript: %+v", update)
default:
}
}
followUp(t, loader)
})
}
})
}
}
func TestACPAttachmentHistoryReadsExistingSessionMessages(t *testing.T) {
deps := testDeps(t)
meta, err := deps.Store.Create(sessions.CreateInput{SessionID: "attachment-compatibility", Cwd: t.TempDir()})
if err != nil {
t.Fatal(err)
}
for i, payload := range []string{
`{"role":"user","content":"old text"}`,
`{"role":"user","content":"","attachments":{"images":1,"documents":2}}`,
`{"role":"user","content":"still text","attachments":{"images":-1,"documents":1.5}}`,
`{"role":"assistant","content":"answer","attachments":{"images":1}}`,
} {
if _, err := deps.Store.AppendEvent(meta.SessionID, sessions.AppendEventInput{Type: sessions.EventMessage, Payload: json.RawMessage(payload)}); err != nil {
t.Fatalf("append message %d: %v", i, err)
}
}
a := &Agent{deps: deps}
_, messages, _, err := a.loadHistory(meta.SessionID, false)
if err != nil {
t.Fatal(err)
}
want := []string{"old text", "[1 image] [2 documents]", "still text", "answer"}
if len(messages) != len(want) {
t.Fatalf("messages = %+v", messages)
}
for i, text := range want {
if messages[i].content != text {
t.Errorf("message %d = %q, want %q", i, messages[i].content, text)
}
}
}
+3 -3
View File
@@ -8,9 +8,9 @@ import (
"testing"
"time"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/agent"
"github.com/Twigpine/zero/internal/sessions"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// captureAgentOptions makes every turn record the agent.Options it was built
+1 -1
View File
@@ -3,7 +3,7 @@ package acp
import (
"encoding/json"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Twigpine/zero/internal/agent"
)
// permission.go maps ZERO's permission prompt model onto ACP's
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"strings"
"testing"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Twigpine/zero/internal/agent"
)
func escalationRequest(escalates bool) agent.PermissionRequest {
+1 -1
View File
@@ -3,7 +3,7 @@ package acp
import (
"testing"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Twigpine/zero/internal/agent"
)
func TestBuildPermissionOptions(t *testing.T) {
+3 -3
View File
@@ -8,8 +8,8 @@ import (
"unicode"
"unicode/utf8"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Twigpine/zero/internal/agent"
"github.com/Twigpine/zero/internal/tools"
)
// translate.go maps ZERO's agent events onto ACP session/update payloads. The
@@ -83,7 +83,7 @@ func browserToolDetails(name string) (*BrowserToolDetails, bool) {
}
}
const zeroBrowserMetaKey = "github.com/Gitlawb/zero/browser"
const zeroBrowserMetaKey = "github.com/Twigpine/zero/browser"
// attachBrowserToolDetails stores ZERO's browser descriptor in ACP's reserved
// extension channel. Keeping this in one helper prevents start, result, and
+2 -2
View File
@@ -8,8 +8,8 @@ import (
"unicode"
"unicode/utf8"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Twigpine/zero/internal/agent"
"github.com/Twigpine/zero/internal/tools"
)
func browserDescriptor(t *testing.T, update ToolCallUpdate) BrowserToolDetails {
+3 -3
View File
@@ -8,9 +8,9 @@ import (
"testing"
"time"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/agent"
"github.com/Twigpine/zero/internal/sessions"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// `zero exec` persists every provider usage report as a session usage event, so
+2 -2
View File
@@ -5,8 +5,8 @@ import (
"strings"
"testing"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
)
func enabledSelfCorrector(t *testing.T) *SelfCorrector {
@@ -5,8 +5,8 @@ import (
"strings"
"testing"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Twigpine/zero/internal/sandbox"
"github.com/Twigpine/zero/internal/tools"
)
type normalizationRecordingShellTool struct {
+2 -2
View File
@@ -6,8 +6,8 @@ import (
"strings"
"testing"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// providerCallingAskUserThenAnswer returns a mock provider whose first turn calls
+3 -3
View File
@@ -8,9 +8,9 @@ import (
"testing"
"time"
"github.com/Gitlawb/zero/internal/testutil"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/testutil"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
)
func TestAsyncDiagnosticsNilCollectorNoOps(t *testing.T) {
+2 -2
View File
@@ -5,8 +5,8 @@ import (
"runtime"
"strings"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Twigpine/zero/internal/sandbox"
"github.com/Twigpine/zero/internal/tools"
"mvdan.cc/sh/v3/syntax"
)
+2 -2
View File
@@ -4,8 +4,8 @@ import (
"runtime"
"testing"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Twigpine/zero/internal/sandbox"
"github.com/Twigpine/zero/internal/tools"
)
func TestProposedCommandPrefixUsesSafeSimpleCommands(t *testing.T) {
+2 -2
View File
@@ -8,8 +8,8 @@ import (
"strings"
"unicode/utf8"
"github.com/Gitlawb/zero/internal/trace"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/trace"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// Session compaction.
+2 -2
View File
@@ -6,8 +6,8 @@ import (
"strings"
"testing"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// compactableHistory returns a history whose middle is large enough to trip
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"strings"
"testing"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/zeroruntime"
)
func TestCompactMessagesReturnsMetadataForManualCompaction(t *testing.T) {
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"strings"
"unicode/utf8"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// Compaction preservation.
+2 -2
View File
@@ -6,8 +6,8 @@ import (
"testing"
"unicode/utf8"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// stateConversation is a long enough conversation that Compact elides a middle
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"regexp"
"strings"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/zeroruntime"
)
const (
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"testing"
"unicode/utf8"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/zeroruntime"
)
func TestClipBytesHandlesTinyLimitsOnRuneBoundaries(t *testing.T) {
@@ -5,7 +5,7 @@ import (
"strings"
"testing"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// recentEdits extracts each mutated file's path and a one-line note from the
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"sync/atomic"
"testing"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/zeroruntime"
)
var markerPattern = regexp.MustCompile(`msg-\d+`)
+3 -3
View File
@@ -6,9 +6,9 @@ import (
"strings"
"testing"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/trace"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/trace"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// --- Pure Compact() tests -------------------------------------------------
+2 -2
View File
@@ -5,8 +5,8 @@ import (
"strings"
"testing"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// someRequestContains reports whether any message sent to the provider across all
+1 -1
View File
@@ -3,7 +3,7 @@ package agent
import (
"encoding/json"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// Context budget.
+1 -1
View File
@@ -3,7 +3,7 @@ package agent
import (
"testing"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/zeroruntime"
)
func TestMeasureContextSplitsByCategory(t *testing.T) {
+1 -1
View File
@@ -3,7 +3,7 @@ package agent
import (
"strings"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/zeroruntime"
)
type contextPlannerConfig struct {
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"reflect"
"testing"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/zeroruntime"
)
func TestContextPlannerPreservesProviderRequest(t *testing.T) {
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"fmt"
"testing"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Twigpine/zero/internal/tools"
)
// richMCPTool mimics a real MCP tool's schema (Exa/Firecrawl-style search): an
+2 -2
View File
@@ -8,8 +8,8 @@ import (
"strings"
"testing"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
)
func TestOptionsDeferThresholdFieldExists(t *testing.T) {
+2 -2
View File
@@ -4,8 +4,8 @@ import (
"context"
"testing"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Twigpine/zero/internal/sandbox"
"github.com/Twigpine/zero/internal/tools"
)
func TestExecuteToolCallCategorizesFilteredDenial(t *testing.T) {
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"context"
"testing"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Twigpine/zero/internal/tools"
)
// argsPermTool implements tools.ArgsPermissioner; plainTool does not.
+2 -2
View File
@@ -6,8 +6,8 @@ import (
"strings"
"testing"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// TestOptionsModelSwitcherFieldExists asserts the escalation hook is an
+2 -2
View File
@@ -2,8 +2,8 @@
package agent
import (
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
)
func equalStringSlices(left []string, right []string) bool {
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"strings"
"time"
"github.com/Gitlawb/zero/internal/lsp"
"github.com/Twigpine/zero/internal/lsp"
)
// fileDiagnosticsTimeout bounds one inline post-edit diagnostics check so a
+3 -3
View File
@@ -8,9 +8,9 @@ import (
"strings"
"testing"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/sandbox"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
)
type customApplyPatchTool struct {
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"strconv"
"strings"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// Guardrail thresholds for the agent loop. These keep a runaway model from
+2 -2
View File
@@ -5,8 +5,8 @@ import (
"strings"
"testing"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// emptyTurn is a stream that produces no visible text and no tool calls.
+2 -2
View File
@@ -5,8 +5,8 @@ import (
"strings"
"testing"
"github.com/Gitlawb/zero/internal/hooks"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Twigpine/zero/internal/hooks"
"github.com/Twigpine/zero/internal/tools"
)
func TestAppendHookFeedbackFormatsOutput(t *testing.T) {
+9 -9
View File
@@ -11,15 +11,15 @@ import (
"strings"
"sync"
"github.com/Gitlawb/zero/internal/execution"
"github.com/Gitlawb/zero/internal/hooks"
"github.com/Gitlawb/zero/internal/modelregistry"
"github.com/Gitlawb/zero/internal/redaction"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/streamjson"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/trace"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/execution"
"github.com/Twigpine/zero/internal/hooks"
"github.com/Twigpine/zero/internal/modelregistry"
"github.com/Twigpine/zero/internal/redaction"
"github.com/Twigpine/zero/internal/sandbox"
"github.com/Twigpine/zero/internal/streamjson"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/trace"
"github.com/Twigpine/zero/internal/zeroruntime"
)
const maxTurnsAnswer = "Agent reached maximum number of turns without a final answer."
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"context"
"testing"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// imageEchoProvider records the messages of the first request it receives, then
+7 -7
View File
@@ -13,13 +13,13 @@ import (
"strings"
"testing"
"github.com/Gitlawb/zero/internal/execution"
"github.com/Gitlawb/zero/internal/hooks"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/specmode"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/trace"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/execution"
"github.com/Twigpine/zero/internal/hooks"
"github.com/Twigpine/zero/internal/sandbox"
"github.com/Twigpine/zero/internal/specmode"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/trace"
"github.com/Twigpine/zero/internal/zeroruntime"
)
type mockProvider struct {
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"context"
"testing"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Twigpine/zero/internal/tools"
)
type safetyTool struct {
+3 -3
View File
@@ -8,9 +8,9 @@ import (
"testing"
"time"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/trace"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/trace"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// midStreamAbortProvider connects successfully but emits a transport-abort
@@ -8,10 +8,10 @@ import (
"strings"
"testing"
"github.com/Gitlawb/zero/internal/hooks"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/trace"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/hooks"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/trace"
"github.com/Twigpine/zero/internal/zeroruntime"
)
type propagationOutputTool struct {
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"context"
"sync"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Twigpine/zero/internal/tools"
)
// Parallel read-ahead for tool batches. When a turn requests several
+2 -2
View File
@@ -7,8 +7,8 @@ import (
"testing"
"time"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// probeTool records execution overlap and ordering so tests can assert what
@@ -3,7 +3,7 @@ package agent
import (
"testing"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Twigpine/zero/internal/tools"
)
func TestPartitionToolsActiveAppendsLoadedToolAfterEagerBlock(t *testing.T) {
+2 -2
View File
@@ -5,8 +5,8 @@ import (
"reflect"
"testing"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// countingSchemaTool records how many times its Parameters() schema is read, so a
@@ -4,8 +4,8 @@ import (
"context"
"testing"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Twigpine/zero/internal/sandbox"
"github.com/Twigpine/zero/internal/tools"
)
// promptShellTool is a minimal shell-style tool whose Safety requires a prompt,
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"strings"
"testing"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Twigpine/zero/internal/sandbox"
)
func TestPermissionScope(t *testing.T) {
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"strings"
"testing"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/zeroruntime"
)
func planUpdateTurn(status string) zeroruntime.CollectedStream {
@@ -4,8 +4,8 @@ import (
"path/filepath"
"testing"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Twigpine/zero/internal/sandbox"
"github.com/Twigpine/zero/internal/tools"
)
// escalatingEngine allows unsandboxed escalation: no denied reads, so
+1 -1
View File
@@ -1,7 +1,7 @@
package agent
import (
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Twigpine/zero/internal/sandbox"
)
// profileController observes per-turn run signals and decides at most one
+4 -4
View File
@@ -5,10 +5,10 @@ import (
"strings"
"testing"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/trace"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/sandbox"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/trace"
"github.com/Twigpine/zero/internal/zeroruntime"
)
func TestProfileControllerNilPolicyIsNoOp(t *testing.T) {
+2 -2
View File
@@ -4,8 +4,8 @@ import (
"strings"
"testing"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// These ceilings are a deliberate ratchet on Zero's fixed per-turn overhead — the
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"sort"
"strings"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// promptSubstrings are the cacheable components of the prompt a run sends to a
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"strings"
"testing"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// TestComputePrefixFingerprintStableAcrossCalls asserts the headline property
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"fmt"
"strings"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// Stale tool-output pruning reclaims context at ZERO token/latency cost before
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"strings"
"testing"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// bigToolResult builds a tool-result message with approximately tokens worth of
+3 -3
View File
@@ -7,9 +7,9 @@ import (
"strings"
"time"
"github.com/Gitlawb/zero/internal/errhint"
"github.com/Gitlawb/zero/internal/trace"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/errhint"
"github.com/Twigpine/zero/internal/trace"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// Mid-stream reconnect: a long autonomous task (a big refactor, a swarm member,
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"testing"
"time"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// flakyProvider fails the first failBefore connect attempts with failErr, then
+3 -3
View File
@@ -8,9 +8,9 @@ import (
"strings"
"testing"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/sandbox"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
)
func TestRequestPermissionsTurnGrantAllowsLaterToolAndCleansUp(t *testing.T) {
+4 -4
View File
@@ -7,10 +7,10 @@ import (
"path/filepath"
"strings"
"github.com/Gitlawb/zero/internal/lsp"
"github.com/Gitlawb/zero/internal/redaction"
"github.com/Gitlawb/zero/internal/trace"
"github.com/Gitlawb/zero/internal/verify"
"github.com/Twigpine/zero/internal/lsp"
"github.com/Twigpine/zero/internal/redaction"
"github.com/Twigpine/zero/internal/trace"
"github.com/Twigpine/zero/internal/verify"
)
// defaultSelfCorrectMaxAttempts bounds how many corrective rounds a single run
+2 -2
View File
@@ -6,8 +6,8 @@ import (
"strings"
"testing"
"github.com/Gitlawb/zero/internal/lsp"
"github.com/Gitlawb/zero/internal/verify"
"github.com/Twigpine/zero/internal/lsp"
"github.com/Twigpine/zero/internal/verify"
)
type fakeVerifier struct {
+2 -2
View File
@@ -5,8 +5,8 @@ import (
"sync/atomic"
"testing"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// stallProvider connects successfully (HTTP 200) but the stream emits a stall/idle
+4 -4
View File
@@ -9,10 +9,10 @@ import (
"strings"
"unicode/utf8"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/repomap"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/workspaceseed"
"github.com/Twigpine/zero/internal/config"
"github.com/Twigpine/zero/internal/repomap"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/workspaceseed"
)
// coreSystemPrompt is the de-branded coding-craft instruction set: identity,
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"strings"
"testing"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Twigpine/zero/internal/sandbox"
)
// TestMain points userConfigDirForPrompt at an empty, package-wide temp
+3 -3
View File
@@ -10,9 +10,9 @@ import (
"strings"
"unicode/utf8"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/trace"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/trace"
"github.com/Twigpine/zero/internal/zeroruntime"
)
type taskStatus string
+3 -3
View File
@@ -5,9 +5,9 @@ import (
"reflect"
"testing"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/trace"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/trace"
"github.com/Twigpine/zero/internal/zeroruntime"
)
func TestTaskStateReplayIsDeterministic(t *testing.T) {
+2 -2
View File
@@ -6,8 +6,8 @@ import (
"strings"
"testing"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
)
func TestNonVisionImageNoticeQualifiesBudgetRecovery(t *testing.T) {
+1 -1
View File
@@ -3,7 +3,7 @@ package agent
import (
"context"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// sessionProvider adapts a TurnSession back to the Provider interface so the
+2 -2
View File
@@ -6,8 +6,8 @@ import (
"strings"
"testing"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// fakeTurnSession wraps an inner provider for streaming while counting the
+7 -7
View File
@@ -3,13 +3,13 @@ package agent
import (
"context"
"github.com/Gitlawb/zero/internal/execution"
"github.com/Gitlawb/zero/internal/hooks"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/streamjson"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/trace"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/execution"
"github.com/Twigpine/zero/internal/hooks"
"github.com/Twigpine/zero/internal/sandbox"
"github.com/Twigpine/zero/internal/streamjson"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/trace"
"github.com/Twigpine/zero/internal/zeroruntime"
)
type Message = zeroruntime.Message
@@ -6,7 +6,7 @@ import (
"io"
"os"
"github.com/Gitlawb/zero-fixture/internal/release"
"github.com/Twigpine/zero-fixture/internal/release"
)
func smokeTarget(alreadyBuilt bool) string {
+1 -1
View File
@@ -1,3 +1,3 @@
module github.com/Gitlawb/zero-fixture
module github.com/Twigpine/zero-fixture
go 1.22
+1 -1
View File
@@ -10,7 +10,7 @@ import (
"strings"
"time"
"github.com/Gitlawb/zero/internal/repoinfo"
"github.com/Twigpine/zero/internal/repoinfo"
)
// BuildPrompt returns the bootstrap prompt for `zero init` / `/init`. It runs
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"testing"
"time"
"github.com/Gitlawb/zero/internal/repoinfo"
"github.com/Twigpine/zero/internal/repoinfo"
)
func intPtr(n int) *int { return &n }
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"syscall"
"time"
"github.com/Gitlawb/zero/internal/execution"
"github.com/Twigpine/zero/internal/execution"
)
// terminationGracePeriod is how long a process has to exit after SIGTERM before
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"os"
"os/exec"
"github.com/Gitlawb/zero/internal/execution"
"github.com/Twigpine/zero/internal/execution"
"golang.org/x/sys/windows"
)
Loaded 100 of 795 files, more files were not shown because too many files have changed in this diff. Show more