Compare commits

..
Author SHA1 Message Date
Vasanthdev2004 0f2cdb5eeb Merge remote-tracking branch 'origin/main' into feat/windows-sandbox-identity 2026-10-25 19:20:04 +05:30
Vasanthdev2004 1767f2dacf Merge remote-tracking branch 'origin/main' into HEAD 2026-10-21 15:59:33 +05:30
Vasanthdev2004 34129dfb5a docs(sandbox): fix the comment on windowsSandboxRuntimeRootPath
The doc comment above windowsSandboxRuntimeRootPath named
setupWindowsSandboxRuntimeRoot and said the root was created there, but
this function only resolves the candidates; the setup function below it
does the creation. It also said applyWindowsACLPlan skips a target that
does not exist, when it fails the whole run on one, which is why setup
creates every candidate first.
2026-10-21 12:48:58 +05:30
Vasanthdev2004 703884b6a1 fix(sandbox): unwind ACL rollback by path, not by apply order
Rollback walked its snapshots backwards on the assumption that they were
sorted ascending by path, so descendants came off before their
ancestors. The snapshots are in apply order, though, and the deferred
pass appends a group whose target only appeared during the first pass,
which is usually an ancestor of the groups before it (.git after
.git\config), so the ancestor was restored first. Sort a copy by path key
before the walk. Reported by CodeRabbit.
2026-10-17 18:10:40 +05:30
Vasanthdev2004 bf096f05a7 Merge remote-tracking branch 'origin/main' into feat/windows-sandbox-identity 2026-10-17 17:27:01 +05:30
Vasanthdev2004 422e0d3c82 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:50:48 +05:30
Vasanthdev2004 bcddd92eae Merge remote-tracking branch 'origin/main' into feat/windows-sandbox-identity 2026-10-17 11:50:43 +05:30
Vasanthdev2004 16e30a9f18 fix(sandbox): keep the git carveouts in a workspace inside another repository
This reverses 26958ac6 on measurement rather than on argument.

That commit skipped the config and hooks carveouts when an ancestor owned the
git metadata, so Zero would not create a control directory competing with the
ancestor's for git's discovery walk. The consequence it named does not
reproduce: a .git holding only config and hooks is not a valid repository, so
rev-parse still answers with the ancestor, status still works, and a
core.fsmonitor written into that config is never executed. A new test drives
real git and fails if a future git changes that.

What the skip cost is much larger. With no carveout on the root nothing denied
writes to <root>/.git at all, so a sandboxed command could assemble a
repository there by hand, with mkdir and a few file writes and not one git
command among them, and put core.fsmonitor in its config. Zero runs
git -C <workspace> status outside the sandbox from three places, and git
executes it. Main does not have that exposure, because main plans the
carveouts here.

The nested-repository refusal stays. It recognises git creating a repository
and cannot recognise mkdir, so it is the layer that explains itself to an
operator rather than the boundary. The boundary is the deny ACE, back where it
can be enforced at write time.

The setup guard in the refusal test now asserts the opposite of what it did:
it requires config and hooks to be denied, and fails if they disappear again.
2026-09-22 13:20:57 +05:30
Vasanthdev2004 281bc65c71 fix(sandbox): evaluate before planning in sandbox exec, and refuse a nested clone as nested
Two findings from jatmn on the nested-repository guard.

`zero sandbox exec` is documented as taking the path a shell tool takes. A
shell tool is evaluated by the engine and then planned; this went straight to
BuildCommandPlan, so nothing that lives in Evaluate applied. In a workspace
governed by an ancestor repository that is the nested-repository guard, which
is the only thing standing in for the git carveouts setup deliberately does
not plan there, and `zero sandbox exec -- git init` created the repository
with a writable config and hooks. The command now asks the engine first, with
the argv rendered as command text that keeps one argument one word, and a
denial is a denial. Running it from a terminal counts as the approval a
session prompts for and lifts only what that approval lifts.

`git clone` is both a network command and a repository-creating one, and the
network gate ran first. Under the default NetworkDeny a clone in a governed
workspace came back as a network prompt, asking for an approval that could
never make the command runnable, and the reason that decides it was never
shown. The nested-repository refusal does not depend on policy, permission or
mode, so it now comes first.

The rule's limit is written down where the rule is. It refuses git creating a
repository. It is not containment of the .git pathname: a repository
assembled with mkdir and a few writes, an archive or a script is not
recognised, and no static reading of a shell command can recognise all of
them. A test pins that limit so it is not read as coverage.
2026-09-21 14:00:19 +05:30
Vasanthdev2004 df9989eba1 fix(sandbox): resolve git aliases the way git does before the nested-repository guard
The guard classified the first whitespace-separated field of an alias value,
so an expansion carrying its own -c option (alias.a=-c alias.b=init b) or
quotes (alias.a="init") reached init unseen, and both spellings created a
repository in a nested workspace.

Alias values are now split with the rules of split_cmdline (quotes anywhere
in a word, backslash escapes outside single quotes), run through the in-alias
option handling git applies (-c and --config-env push definitions the next
lookup sees, pager switches are skipped, anything else is fatal in git and is
refused here), and followed with per-path cycle detection. Definitions from
the environment the call sets (GIT_CONFIG_COUNT with GIT_CONFIG_KEY_n and
GIT_CONFIG_VALUE_n, and GIT_CONFIG_PARAMETERS) and from --config-env are read
as well; a definition that cannot be read statically refuses. The attached
-cNAME=VALUE spelling is no longer recognised: git rejects it as an unknown
option and nothing runs.

Reported by @gnanam1990.
2026-09-18 12:18:36 +05:30
Vasanthdev2004 56ce081107 Merge remote-tracking branch 'origin/main' into feat/windows-sandbox-identity 2026-09-18 12:02:11 +05:30
Vasanthdev2004 378b38c363 fix(sandbox): resolve an inline git alias chain to its end and fail closed when it has none
The depth cap gave up with false, so a five-link alias chain ending in init
passed the nested-repository guard and git created the repository. The
resolver now follows the chain until a terminal subcommand with the visited
names kept, and under this guard a cycle, a dangling alias or an empty
expansion is a refusal rather than a pass: an alias the analyzer cannot follow
to its end is not evidence that nothing gets created. A five-link chain ending
in status stays allowed.
2026-09-15 14:03:14 +05:30
Vasanthdev2004 738881bc11 Merge remote-tracking branch 'origin/main' into feat/windows-sandbox-identity 2026-09-15 14:02:22 +05:30
Vasanthdev2004 17f55f4e5e test(sandbox,peermsg): resolve the fixture temp physically so the unix regressions run under macOS's /var symlink 2026-09-15 10:32:41 +05:30
Vasanthdev2004 6e980a8aa6 test(sandbox): a created chain step now carries the identity rollback checks 2026-09-15 10:23:57 +05:30
Vasanthdev2004 9b72d0da6a test(sandbox): check the replacement survives before reading the rollback's own report 2026-09-15 10:21:40 +05:30
Vasanthdev2004 c753df3020 fix(sandbox): create the runtime candidates the unelevated plan grants
The unelevated command's plan lists both runtime candidates, but nothing on
that tier created the one this process did not select, so with a fresh usable
cache the runner refused its own plan with "windows ACL target does not exist"
and the command never launched, on every retry. The parent now creates the
missing candidates before the runner starts, where TEMP is still the
operator's, using setup's own ownership and no-follow checks, and leaves an
existing candidate as it is so the grant the previous command applied is not
stripped and reapplied on every launch. A file or reparse point at a
candidate's name is refused by name.
2026-09-15 10:20:19 +05:30
Vasanthdev2004 d3aab2c113 fix(sandbox): put a runtime root's grant back when a setup retry fails
Preparing the runtime candidates rewrote an existing root's DACL with a
protected owner-only descriptor before the ACL transaction had a baseline, so
a retry that failed on the second candidate, or in the apply, returned with the
earlier setup's capability grant gone while the old marker still claimed
success. Setup now snapshots each existing candidate's DACL and identity before
preparation and restores it, protection state included, on every failure that
follows, and after the transaction's own rollback when a later step fails.
2026-09-15 10:20:19 +05:30
Vasanthdev2004 3ef649094d fix(sandbox): bind materialization rollback to the objects it created
The materialization record verified the anchor's identity but recorded the
created children by name, so after setup created .git/config another process
could rename it aside, put an ordinary file at that name beneath the unchanged
anchor, and have a later setup failure delete the replacement. Each created
directory and the leaf file now carry the identity read from the handle that
created them; rollback refuses to descend through a created component that is
no longer that object, and the delete disposition is set on a handle whose
identity was checked, so nothing renamed in between can be the thing removed.
2026-09-15 10:20:19 +05:30
Vasanthdev2004 b7e97c7645 fix(sandbox): validate the runtime root's parent before the lease and open the lease relative to it
prepareSandboxRuntimeLease created the parent by pathname and opened the lease
beside it before ensureRuntimeTreeDirs validated the owned path, so a link or
junction planted at the fallback anchor's reusable v1 child sent the .lease
into another directory and only then failed validation. The parent is now
walked and created without following links, and the lease is opened relative
to a no-follow handle on that validated directory, so a component swapped in
after the check cannot move the open either. Pinned on both platforms with a
redirected v1 and an untouched control.
2026-09-15 10:20:18 +05:30
Vasanthdev2004 b0ffba6391 test(sandbox): own every runtime root a fixture can produce; clean up the second native account by its real key
Tests that redirected only the workspace still ran real setup, preparation and
reclamation against the developer's cache and temp, because those roots come
from separate resolver inputs. isolateSandboxRuntimeRoots now gives each such
fixture its own cache root and temp root, so the preferred candidate and the
fallback both land inside the fixture, and the principal-setup stub redirects
temp as well as cache.

The native round-trip test provisioned its second account under the key derived
from its config but cleaned up with an unrelated literal, which the ownership
guard refused, and registered that cleanup only after the secret read and the
logon could have failed. It now derives the key from a fixture-owned workspace,
registers cleanup as soon as ownership exists, deletes only what it created,
and reports cleanup failures.

peermsg.EnsurePrivateDir closed the descriptor it started with rather than the
one it ended on, leaking the final descriptor on every fallback-runtime
preparation; the deferred close now closes the descriptor currently owned, and
a watermark test pins it at three depths on the success and refusal paths.
2026-09-15 10:10:57 +05:30
Vasanthdev2004 b9d86cee59 test(sandbox): fake the rollback deletion in the mocked fixture; align the smoke's command profile with setup
The mocked provisioning fixture faked creation and ownership but left the
inverse attached to the operating system, so an injected failure after a
faked create reached the real NetUserDel on the current user's C:\ws
principal. The deletion is a seam now, the fixture fakes and records it, and
the created and adopted cases assert exactly which removals were requested.

The real elevated smoke passed its bare profile to the command builder while
the setup builder folded runtime roots into its own, so every command
disagreed with the marker. The command side now carries the augmented
profile, and a portable test pins that the two plans hash the same.
2026-09-15 10:07:44 +05:30
Vasanthdev2004 7330a17f99 fix(sandbox): refuse principal provisioning in the helper, not only in the argument builder
BuildWindowsSandboxSetupArgs refused to serialize a principal opt-in while no
launch path exists, but both shipped entrypoints reach runWindowsSandboxSetup
through the parser, and a handwritten --sandbox-principal 1 passed the caller
check and proceeded to account, secret and ACL provisioning for a principal
nothing can run. The helper now makes the same refusal before its first effect.
The seam harness opens the launch seam for the fixtures that test the flow
behind it, and a new test drives the refusal with the seam closed.
2026-09-15 10:07:44 +05:30
Vasanthdev2004 926a1ebc41 fix(sandbox): resolve an inline git alias before deciding it is not init
In an ancestor-governed workspace with no local .git, the nested-repository
refusal read the literal subcommand token, so git -c alias.bootstrap=init
bootstrap created a repository the profile carves nothing out for. The
classifier now resolves the alias.NAME=EXPANSION settings given on the command
line to the subcommand git will run, following a short alias chain, and treats
a shell alias as creation under this one guard because it cannot be classified.
Harmless aliases and ordinary git use are unchanged.
2026-09-15 10:07:43 +05:30
Vasanthdev2004 5de72bfa4a test(sandbox): pin principal mode in the transport tests and give the derivation test a private root
The two caller-identity transport tests left PrincipalOptIn unset, so with
ZERO_WINDOWS_SANDBOX_IDENTITY=1 in the host environment they stopped at the
launch refusal before reaching the assertions they exist for. They now pin
the mode they need; the opt-in tests keep exercising environment inheritance.

TestTeardownPathDerivationCreatesNothing snapshotted the shared temp directory,
which every other package under test writes to concurrently, and asserted
resolver purity only after preparation had legitimately created the fallback
anchor. It now observes a private root and asserts purity at the boundary
between resolution and preparation.
2026-09-15 09:58:30 +05:30
Vasanthdev2004 c38fcabe67 Merge origin/main into feat/windows-sandbox-identity
Resolves the ACL conflicts with #1040 by keeping both: the anchor that main
attaches to a derived carveout, and the file materialization this branch adds.
The plan apply now opens its target without the branch's blanket ancestor
redirection check and relies on main's anchor containment instead, so a
workspace reached through a junction keeps working and a junction on the
derived tail is still refused. The rollback re-open relies on the recorded
object identity where it has one and keeps the blanket check where it does not.
2026-09-15 09:55:48 +05:30
Vasanthdev2004 8319484383 test(sandbox): compare carveout roots through the profile's own canonicalization
PermissionProfileFromPolicy stores normalizeProfilePath(root), which resolves
symlinks, so comparing against a raw t.TempDir() path matched locally and failed
on both CI runners: macOS reaches the temp directory through /var -> /private/var,
and the Windows runner's profile directory has a short-name expansion. The
carveouts were present in both failures; only the lookup missed them.
2026-09-13 19:23:47 +05:30
Vasanthdev2004 48f6a88fe4 Merge remote-tracking branch 'origin/main' into HEAD 2026-09-13 15:25:48 +05:30
Vasanthdev2004 ecbedb9112 fix(sandbox): keep a nested workspace's own git carveouts
gitMetadataWriteCarveoutSpecs asked the ancestor question unconditionally, so
a workspace nested inside another repository lost its hooks and config
carveouts even when it owned its own .git. That metadata is the workspace's,
not the ancestor's, and nothing compensated: workspaceGovernedByAncestorRepository
answers the same question by testing for a local .git, so it reported such a
workspace as not governed and the initialization refusal never fired. Commands
received plain workspace write access over a live .git/config and .git/hooks,
which is the configuration that decides what git executes.

The ancestor branch is now gated on the lstat error, which makes the condition
literally the test workspaceGovernedByAncestorRepository applies and the shared
doc comment already claimed. It cannot reintroduce the competing-control-directory
problem it was written for, because the carveouts only materialize where .git
already exists and git's discovery walk therefore already stops at this
workspace. A permission failure on the lstat keeps today's behaviour.

Reported by @jatmn.
2026-09-13 15:25:06 +05:30
Vasanthdev2004 08005ef5b3 test(sandbox): pin every selectable runtime root to the granted set
The runtime-root tests all iterate whatever windowsSandboxRuntimeCandidates
returns, so they hold for any self-consistent candidate set, including one
missing an entry. Dropping the temp-derived fallback from that set left all
three green while leaving the root prepareSandboxRuntime falls back to
ungranted, which is the third part of anandh8x's review finding: setup grants
only the initially derived root, so the fallback is not provisioned either.

The new test drives sandboxRuntimeRootFor for both outcomes, the ordinary
cache-derived selection and the one a machine makes when the cache root is
unusable, and asserts each is in the set setup grants. Driven through the
selector rather than by re-deriving the fallback, so it stays true if the
selection rule changes. Removing either candidate now fails it with the path
it would have refused.
2026-09-12 21:30:32 +05:30
Vasanthdev2004 4d3f5dc8cd fix(sandbox): clone is repository creation too, and the dual-role tests stay on test-owned roots
commandCreatesGitRepository knew only init and init-db, while risk.go routes the nested-repository refusal through that flag alone. git clone <url> . into a workspace governed by an ancestor repository lands a root .git whose config and hooks setup never planned carveouts for, the same state init produces, and with network allowed and the shell grant given it came back as an ordinary allow. clone now sets the flag; the network prompt is a separate gate and granting it does not answer this one. The regression drives Engine.Evaluate in a nested workspace with network allowed and the grant given, for the root-target spelling, a global-option spelling and a subdirectory target, with ordinary git operations, git submodule update --init and a standalone workspace as controls.

The dual-role rollback tests handed real setup C:\sandboxhome and C:\ws. The principal and ACL seams intercept the identity work, not the filesystem work: setup materializes runtime candidates under the user cache and TEMP and writes ledgers below the sandbox home, so an ordinary run wrote to the live cache, failed on drive-root permissions, or left host state behind; this box still has a C:\sandboxhome from the old form. windowsSandboxTestConfig now takes the test, uses t.TempDir for home and workspace, redirects sandboxUserCacheDir and TMP/TEMP, and asserts afterwards that the fixed paths were neither created nor changed by the run and that the redirected cache is not the live one. The offline-membership caller follows. Reported by @gnanam1990.
2026-09-12 12:04:32 +05:30
Vasanthdev2004 76d607ecdc Merge origin/main into feat/windows-sandbox-identity
Brings in #1006 (narrow restricted-token SIDs, fail closed on DenyRead) and the rest of main since the last rebase.

Four files conflicted, all unions with #1006:

- windows_acl.go: WindowsACLEntry carries NoInherit (main) beside MaterializeFile (branch); both new actions kept.
- windows_acl_apply_windows.go: DenyWrite mask takes the SYNCHRONIZE strip from main; DenyDelete and RevokeCapability cases both kept.
- windows_acl_test.go: branch assertions re-applied inside ForWorkspaceWriteProfile; the branch test #1006 rewrote under a new name comes back whole beside it.
- windows_setup.go: marker schema stays at 7, which is above main.

Main deleted windowsExplicitAccessEntries in favour of prepareWindowsACLPathGroupEntries, which the branch used for the pre-materialization validity check and in the rename-guard test. Both now call the new builder with a nil DACL, and the rule that a DenyDelete ACE never inherits moves into that builder so no plan can make it inherit.

TestElevatedSetupRefusesAVolumeRootReadGrant now expects the #1006 gate's message, since that gate refuses the same denyRead profile before setup reaches the volume-root refusal; the outcome assertions (refused, never applied) are unchanged, and the SETUP INVALID guard still proves the plan carries the volume-root grant.
2026-09-12 10:35:24 +05:30
Vasanthdev2004 a2e9558262 fix(sandbox): remove a failed secret through the handle that created it
Everything between creating the secret leaf and writing it is
handle-relative and no-follow. The failure path was not: it called
os.Remove on the pathname, which is the one resolution the rest of the
function exists to avoid, and it did not even work. The leaf is created
with FILE_SHARE_READ alone, so a delete by name while that handle is open
is a sharing violation. The error was discarded and the directory unwind
did not cover the file, so a failed provisioning left an empty secret
behind that a later run would read as a stale password.

The handle is closed first and the leaf is then deleted relative to the
pinned parent, the way rollbackWindowsACLMaterialization already removes
what it created. Closing first rather than widening the share mask keeps
the secret unshareable for delete while it exists, which is the narrower of
the two ways to make this work. A cleanup that still cannot finish is
joined into the returned error rather than dropped.

Two seams for the failure paths, since neither is reachable otherwise: the
DACL step, and the delete. My first attempt at the second held a real
second handle on the leaf to block the delete, and it blocked nothing, so
the test passed while proving the opposite of what it claimed.
2026-09-11 10:17:32 +05:30
Vasanthdev2004 8b7ffa4f46 fix(sandbox): treat only a regular .git as the worktree pointer
The rebase kept this branch version of the carveout set, which took the
pointer branch for anything that was not a directory. Main pins the
narrower rule and its tests caught the difference on every platform: a
symlink named .git, dangling or not, and a special entry such as a fifo
are not the gitdir pointer git writes for a linked worktree, so they keep
the legacy child carveouts rather than being handed single-object
protection for not being a directory.

Also picks the runtime-root candidate by excluding the shared fallback
rather than by path prefix. A CI runner spells one directory two ways, so
os.UserCacheDir and t.TempDir disagreed on a path they both meant and the
new junction test failed its own setup check there while passing here.
2026-09-11 10:17:32 +05:30
Vasanthdev2004 a45d4000e1 fix(sandbox): keep the injectable lstat seam through the carveout specs
Main and this branch fixed the linked-worktree .git pointer independently.
The branch version carries the shape each carveout needs, which the Windows
ACL plan uses to materialize .git/config as a file, so it is the one that
survives the rebase. What it lacked was the seam main's worktree tests
drive: an lstat that fails is a real state with its own required answer and
no filesystem it can be staged on. Threaded through the spec function so
both entry points share one implementation.
2026-09-11 10:17:32 +05:30
Vasanthdev2004 b12b2d71a4 fix(sandbox): build the elevated secret and runtime directories no-follow
The secret file was created relative to a pinned parent handle, but the
parent CHAIN was still made with os.MkdirAll. That walks a pathname, so a
junction planted above a component that does not exist yet sent an elevated
create to the other side of it, and the no-follow open that followed then
found an ordinary directory at the requested name because the redirection
had already happened one level up. The runtime-root candidates had the same
create, under the invoking user's own cache tree.

Both now use makeWindowsACLDirChainNoFollow, which anchors on the deepest
existing ancestor and creates each component relative to the handle above
it, and both unwind exactly what they created when a later step fails. The
secret leaf is created relative to the chain's own deepest handle rather
than reopening its parent by name, so the whole operation resolves the path
once.
2026-09-11 10:17:32 +05:30
Vasanthdev2004 9abab7318d test(cli): assert the shutdown status and that nothing outlives the run
The grace tests proved a handler ran and that escalation waited out the bound,
but never looked at the status, which is the only part of this a caller can see
and the part the report was about: 137 rather than 143.

An untrapped child now has to come back as 128+SIGTERM. That is the assertion the
other two cannot make between them, since one traps the signal and the other
ignores it, so a first phase that was still SIGKILL would leave both passing. The
graceful child exits 42 from its handler and the status has to be 42, and the
stubborn child's has to be 128+SIGKILL so "ask nicely and give up" cannot pass
either.

Plan cleanup is ordered after termination by a defer in runSandboxExec wrapped
around this call, which holds only while nothing survives the return. The
stubborn child records its pid and the run has to leave it reaped, so a refactor
that lets Wait come back with the tree still up fails here rather than deleting
the policy-report file underneath a live command.
2026-09-11 10:17:32 +05:30
Vasanthdev2004 d376b7f2f0 fix(sandbox): refuse git init in a workspace inside another repository
A workspace governed by an ancestor repository gets no git carveouts at all.
gitMetadataWriteCarveoutSpecs returns nothing for it, on every backend, and that
is deliberate: naming <root>/.git/config and <root>/.git/hooks makes the Windows
plan create, and the bubblewrap helper mount, a control directory that competes
with the ancestor's for git's discovery walk inside a repository Zero does not
own.

So there is no protection here to extend to a repository created during the
command. It lands under the plain workspace write grant with nothing denying
credential.helper or core.hooksPath, and on Windows nothing denying DELETE on
.git either. The serialized plan never changed, so the cached setup marker stays
valid and the next run does not notice.

Refuse it instead. The protection would have to be established at a moment the
sandbox is no longer at, and reporting success while the guarantee is absent is
worse than refusing.

The condition is the workspace, not the directory the command names. Resolving
that would mean tracking -C and cwd through the script, which is the same
option-parsing surface that let "git -C sub clone" past the network gate.
Refusing a git init aimed elsewhere too is the conservative side of that trade,
and the reason text says so. The detection shares gitSubcommand with the network
gate, so those global options cannot bypass this gate either, and it covers
init-db.

The refusal sits ahead of the persistent grant, session grant and allow-permission
returns in Evaluate: a broad approval to run shell commands is not an approval to
create an unprotectable repository.
2026-09-11 10:17:32 +05:30
Vasanthdev2004 95c8ac6245 fix(sandbox): stop git's global options hiding the subcommand
firstSubcommand skips words beginning with "-" but not the value that follows
one, so the network classifier read the wrong word:

    git clone https://x                  network=true
    git -C sub clone https://x           network=FALSE
    git -c user.name=x clone https://x   network=FALSE

Both spellings are ones an ordinary user types by habit, and both walked
straight through the network gate. "--opt=value" happened to survive because it
is a single token.

git now gets a subcommand parser that knows which of its global options consume
the next word. The keys are lowercase because the analyzer lowercases every
word before this point, which also means -C and -c arrive identically; that is
fine, since both consume a value and nothing here needs to tell them apart.

Found while building the nested-repository check, which needs to recognise
"git init" through the same options. Reported separately rather than folded in,
because it is a live gate bypass rather than part of that finding.

The regression drives AnalyzeCommand rather than the helper, since the gate
reads the analysis and a helper-level assertion would not have caught this. It
keeps local-only spellings classified local, so the fix cannot be "call
everything network".
2026-09-11 10:17:32 +05:30
Vasanthdev2004 c887961869 fix(cli): give a cancelled sandboxed command a graceful phase
The shutdown comments described a graceful signal followed by escalation while
the code did neither. Cancel called KillProcessTree, which is SIGKILL on Unix
and taskkill /T /F on Windows, and WaitDelay only starts counting AFTER Cancel
returns, so it could never postpone a kill that had already happened. A
directed SIGTERM reached the child as 137 rather than 143, with no chance to
flush output, drop a lock file, or run a shutdown handler. The comment
described a policy the code did not implement.

Cancel now calls execution.TerminateProcessTree, the two-phase policy the
background package already uses: SIGTERM, poll for the grace, then SIGKILL what
is still alive. On Windows that resolves to the single-phase kill, which is
correct rather than lazy, since there is no signal to send a child owning no
console. WaitDelay becomes a backstop for the case where the tree is gone but
Wait is still blocked on a pipe a grandchild holds open.

The process-group tradeoff from the previous round is untouched: the child
stays in Zero's group, so terminal and supervisor group delivery is unchanged.

Regressions run a child that traps the signal and records that its handler ran,
and a stubborn child that ignores it and must still die after the bound rather
than before it. Verified on real Linux rather than only cross-compiled, since
the graceful phase does not exist on Windows and a Windows-only run would prove
nothing about it.
2026-09-11 10:17:32 +05:30
Vasanthdev2004 a186eb46d7 fix(sandbox): decide the runtime base on a canonical spelling
The Windows Smoke leg failed on my own new tests with "has no operator-owned
base to descend from" for a perfectly ordinary tree.

runtimeCandidateBase decides ownership with a containment test, and containment
runs on spellings. A runner's temp is C:\Users\RUNNER~1\..., an 8.3 short name
that compares unequal to the long form of the same directory the cache root
resolves to. The same class of mismatch prepareSandboxRuntime already documents
for its own comparison.

ensureRuntimeTreeDirs now canonicalizes before asking, the same way the rest of
the runtime state does.

The tests were the other half of it: a bare t.TempDir() only lands under the
user cache directory on a machine where temp happens to sit there, which is
true on my box and false on the runner. They now pin the cache root
themselves, so they are about the descent rather than about where temp lives.
2026-09-11 10:17:31 +05:30
Vasanthdev2004 8099409f7c fix(cli): terminate the sandboxed command when the wrapper is cancelled
runSandboxPlannedCommand started the backend wrapper with a bare
exec.Command().Run(): no context, no signal forwarding, no shutdown path. A
terminal masks that, because terminals signal the whole foreground process
group, but a supervisor or task runner sending SIGTERM to the `zero sandbox
exec` PID killed only Zero. The wrapper and everything under it kept running,
doing filesystem and network work after the caller considered the task
cancelled, and the deferred plan cleanup never ran because the process was
gone.

The CLI's shared shutdown context is threaded in, Cancel kills the tree, and
WaitDelay bounds how long a child that ignores the first signal can hold the
wrapper open and skip cleanup.

DELIBERATELY WITHOUT execution.ConfigureProcessGroup, which the obvious fix
would add. Putting the child in its own process group closes this hole and
opens a worse one: it severs every kernel-delivered group signal, so a
supervisor escalating to a group kill, and a terminal delivering Ctrl+C to its
foreground group, would stop reaching the child. That was measured, not
assumed: with the group change a group-directed kill left both the child and
its grandchild alive, where today it reaps them. Keeping the child in Zero's
group leaves group delivery exactly as it is, while the directed-signal case,
which reached nothing at all before, now reaches the child.

The remaining gap is a grandchild on the directed-signal path, which the group
path still covers. Closing that too needs a job object on Windows and a group
Zero owns on Unix, which is the trade above and a separate decision.

The existing 128+signal mapping for a child that exits by signal is untouched;
that is a different direction of signalling.
2026-09-11 10:17:31 +05:30
Vasanthdev2004 4234357774 test(sandbox): reproduce the raced leaf at the instant it can actually happen
The first version of this regression used the anchor-stage swap hook, which
fires before the directory chain exists. Creating the parent there made the
chain the RACER's, not this run's, so createdAnything() was false, the ordinary
restore path ran, and the test passed with the fix reverted.

The mixed ownership the finding is about needs the chain to be ours and only
the leaf to be theirs, which is reachable at exactly one instant: after
makeWindowsACLDirChainNoFollow returns and before createWindowsACLChildFile
runs. windowsACLRacedLeafHook makes that instant addressable.

Reverting the disposition check now fails it with the deny ACE still on the
racer's file.
2026-09-11 10:17:31 +05:30
Vasanthdev2004 9608817f47 fix(sandbox): restore a raced leaf whose parent this run created
rollbackWindowsACLSnapshots branched on createdAnything(), which is an OR
across the whole materialization record. When this run creates the parent chain
and a racer wins the leaf, that predicate is true while the ACL-bearing file
belongs to somebody else: Chain carries Made:true and FileMade is false.

The rollback then could not remove the parent, because it was no longer empty,
and the unconditional skip meant the existing no-follow, identity-validated
restore never ran either. A file that existed before setup started was left
wearing an aborted setup's DACL, and the failure was loud about the directory
it could not remove while silent about the descriptor it never put back.

rollbackWindowsACLMaterialization now reports whether the ACL-BEARING TARGET is
among what it removed: the file leaf when FileMade is set, the deepest chain
entry otherwise. Anything it cannot prove it removed reports false, which
routes the caller to the restore that is already no-follow and TargetID-guarded
and refuses on a mismatch rather than forcing.

All three conservative properties are unchanged and none needed new code:
nothing new is ever deleted, so a raced leaf is still never removed; there is
still no pathname fallback; and a rollback that could not remove the parent
still says so while now also restoring the leaf.

The regression is the mixed-ownership case rather than a wholly created
directory becoming non-empty, driven through applyWindowsACLPlan with the leaf
created inside the existing swap hook so the race is deterministic.
2026-09-11 10:17:31 +05:30
Vasanthdev2004 673b84d644 test(sandbox): drive the runtime-tree descent through its entry point
The helper tests call ensureRuntimeTreeDirs directly, so reverting
prepareSandboxRuntime to the old pathname loop left every one of them green.
The defect was in what the entry point called, so it needs a test that goes
through the entry point.
2026-09-11 10:17:31 +05:30
Vasanthdev2004 69c68b18a2 fix(sandbox): stop following sandbox-controlled runtime descendants
The runtime root is deterministic so setup and later runners agree on one path,
and the sandboxed command is granted write access to cache, data and tmp. So it
can replace one of them with a symlink or a Windows junction on its way out.
Preparation then ran os.MkdirAll and os.Chmod on raw pathnames, which follow,
and the HOST Zero process, the ordinary user rather than the confined
principal, created package-cache directories inside a target the previous
command chose.

ensureFallbackRuntimeAnchor proves the per-user anchor and says nothing about
the reusable root or anything beneath it, so it never covered this.

Preparation now descends from the operator-owned base through retained
no-follow handles: NtCreateFile with OBJ_DONT_REPARSE and
FILE_OPEN_REPARSE_POINT on Windows, openat and mkdirat with O_NOFOLLOW plus
fchmod on the descriptor elsewhere. fchmod rather than chmod is the half that
matters off Windows, since chmod follows symlinks. The deterministic naming
contract is untouched.

Deliberately NOT peermsg.EnsurePrivateDir, which is the obvious patch and the
wrong one: it ends in a protected-DACL write that strips the sandbox
principal's grant elevated setup installed on the runtime tree, bringing back
the bare access denials from npm and go that the grant exists to prevent. That
would have passed every unit test on an unprovisioned box. This descent
validates and creates; it does not re-secure.

Two precisions worth recording. The Windows chmod is dropped rather than ported,
because it only toggles READONLY and was measured landing on the junction rather
than its target, so it bought nothing. And the exposure on the cache-derived
root predates this PR, byte-identical to main; what this PR added was making the
fallback root deterministic and persistent too, extending the same shape to it.
2026-09-11 10:17:31 +05:30
Vasanthdev2004 daf58c8962 fix(sandbox): keep a linked worktree's .git typed as a file through planning
gitMetadataWriteCarveoutSpecs types a linked worktree's or submodule's .git as
a pointer FILE. That typed result is flattened to a path before ACL planning,
and gitMetadataCarveoutIsFile tried to recover the shape by deriving suffixes
under a sentinel root whose .git never exists. The derivation therefore always
took the directory branch and produced exactly one file-shaped suffix,
.git\config, which a bare <root>\.git can never match. Both planners emitted
MaterializeFile:false for a path stage one had already typed correctly.

The window that turns a wrong plan into damage is the caller-to-elevated-helper
gap. The profile is built in the user's shell and the plan is applied in a
separately launched helper, across the UAC prompt, so a concurrent
`git worktree remove`, `git submodule deinit`, or hostile local process can
delete the pointer in between. The applier then creates a DIRECTORY at the
pointer path and the worktree is broken, persistently, because the runner
discards the rollback closure.

The carveout SET already carries the answer, so no type change is needed.
gitMetadataWriteCarveoutSpecs is the only producer of ReadOnlySubpaths, and it
emits a bare .git in exactly one case: the pointer. The directory case emits
.git\hooks and .git\config and never their parent. So the shape is read off
which carveout this is rather than guessed from a pathname.

The regression drives BuildWindowsACLPlan and applyWindowsACLPlan with the
pointer removed after planning, which is the only place the loss is visible; a
stage-one assertion on specs[0].IsFile passed for the whole time the bug was
live. A control with the pointer present keeps a green result from meaning an
apply that did nothing.
2026-09-11 10:17:31 +05:30
Vasanthdev2004 a8b295def3 test(sandbox): stop probing for privilege by mutating System32
The test applied the real ACL plan to C:\Windows\System32 and used the result
as its privilege probe: on success it skipped, having already mutated. Both
return values were discarded at the call site, so the snapshot holding the
original descriptor was gone and nothing could put it back. A process that does
hold WRITE_DAC there would leave an inheritable allow ACE for a synthetic SID
on System32 and on everything later created beneath it, permanently.

Reachability is narrower than reported, and worth writing down. System32 grants
WRITE_DAC only to TrustedInstaller; Administrators and SYSTEM get 0x1301bf on
the object, which does not include it, and their GA aces are inherit-only. So
an ordinary elevated run takes the denial branch. The branch that mutates needs
a token whose backup or restore privilege is enabled, since the applier opens
with FILE_FLAG_BACKUP_SEMANTICS, which is the normal shape of a LocalSystem
service token and therefore of a self-hosted runner installed as a service.
That could not be driven here, so it rests on documented behaviour rather than
on output. The trigger is narrow; the residue is unbounded and silent.

The coupling this test exists to pin, openWindowsACLTarget's wording against
windowsACLPlanDeniedPath's marker, is now pinned against a disposable directory
rigged to refuse WRITE_DAC. A plain deny ace is not enough there, because the
owner keeps an implicit WRITE_DAC that defeats it and the apply succeeds;
OWNER_RIGHTS is what makes the DACL the whole story. That was measured, not
assumed, and a naive deny-only fix would have relocated the same bug.

No run can now leave residue outside t.TempDir(), and the skip that used to
follow a completed mutation is a SETUP INVALID failure instead.
2026-09-11 10:17:31 +05:30
Vasanthdev2004 30d3c4791e fix(cli): keep a specified-empty sandbox environment from inheriting
exec.Cmd reads a nil Env as "inherit this process's entire environment", which
is a different statement from "run with no variables". The plan owns its
environment: directCommandEnv and scrubSensitiveEnv return a slice they built,
and that slice is non-nil with length zero when every entry was sensitive.
Testing its length collapsed those two states and turned the strictest possible
answer into the loosest one.

Reported by jatmn as a credential leak. Being precise about reachability: it is
not reachable through `zero sandbox exec` today. The child environment is
os.Environ(), so an environment holding only sensitive keys also has no
%AppData%, and config resolution fails at sandbox_exec.go before the planner
runs. Two independent checks reached that conclusion and I confirmed it at the
call site.

Fixed regardless. The guard is one assignment, and the next caller that hands
the plan a deliberately narrow environment would inherit everything instead,
silently. Both directions are pinned by a child that prints its own
environment, on every platform rather than behind a Unix-only skip.
2026-09-11 10:17:31 +05:30
Vasanthdev2004 26958ac6dd fix(sandbox): do not synthesize a .git inside an ancestor repository
A missing .git meant "this directory may become a repository", so the Windows
plan materialized .git/config and .git/hooks to get the deny ACE in place
before git first ran. That is right for a standalone directory. It is wrong
when the workspace is a subdirectory of an existing repository: the created
directory is a control directory competing with the ancestor's for git's
discovery walk, synthesized inside a repository Zero does not own.

The carveouts are now skipped when an ancestor carries git metadata, which is
git's own discovery rule. That is the same argument the linked-worktree branch
already makes: the metadata governing this workspace lives outside the write
root, the sandboxed principal has no inherited access to it, so it needs no
carveout here. The non-materialized deny-delete on <root>/.git is emitted
separately and still guards the name if a repository is ever created.

Ancestor detection accepts .git of either shape, since a linked worktree or
submodule ancestor carries it as a pointer file and still owns the directory.

Reported by jatmn. Worth recording that the stated consequence did not
reproduce here: a .git holding only config and hooks is not a valid repository,
so this git version walks past it and status, log and rev-parse still resolve
to the ancestor. The fix stands on the narrower ground that creating
repository metadata Zero does not own is wrong regardless, and anything later
adding a HEAD there would break discovery for real.
2026-09-11 10:17:31 +05:30
Vasanthdev2004 7920d3be3d fix(sandbox): key the read-grant refusal on the grant, not on the volume root
The refusal looked for a volume-root entry, which is a symptom of the
production profile rather than the thing that is unsafe.
permissionProfileReadRoots happens to seed the bare filesystem root, so that
check covered production by coincidence. A denyRead profile with a narrowed
read list carried no volume root, sailed through, and elevated setup would have
put an inheritable read ACE on C:\Windows: the same defect one level down.

The grant itself is what cannot be applied to a directory Zero does not own,
and it exists only for a denyRead profile, so that is what is refused now. Read
grants that land on the plan's own write roots are excluded, because those are
Zero's directories and were never the objection; the diagnostic names the
broadest path outside them, preferring a volume root so it reports the worst
one.

Found by probing the neighbouring case rather than by rerunning the one the
first fix was written against.
2026-09-11 10:17:31 +05:30
Vasanthdev2004 4320d08bae fix(sandbox): refuse a denyRead profile instead of ACLing the volume root
A profile that configures denyRead selects a fully restricted token, and that
token applies its restricted-SID check to reads, so the plan granted the read
capability at every read root. Production profiles seed ReadRoots with the bare
filesystem root.

The applier marks allow entries on a directory inheritable and calls
SetSecurityInfo, and Windows propagates inheritable ACEs onto existing
children. So applying that one entry was never a change to a sandbox-owned
object: it walks and rewrites DACL inheritance across unrelated system,
application and user trees on the drive, and a locked or exclusively opened
descendant leaves the result dependent on ambient filesystem state.

Paying that price does not even buy a working sandbox. A bare root resolves on
one volume, while the executables and libraries a command needs can sit on
another without being read roots of their own, so the strict token can still
fail before its executable starts. Measured separately: with the read
capability granted nowhere, the strict token cannot open cmd.exe.

So the profile is refused rather than half-served. The refusal moves out of the
unelevated tier, where it started for the narrower reason that an ordinary user
lacks the rights, and becomes the plan's own answer that both tiers consume.
Elevated setup refuses before its first mutation, which matters because that
tier CAN write the DACL. Profiles without denyRead are untouched and keep the
workspace write jail.

This leaves denyRead unavailable on Windows until there is a bounded
authorization model covering the real platform, runtime and executable
dependencies on every relevant volume, which is what #869 tracks. The
alternative on offer was a silently voided write jail or a volume-wide ACL
rewrite, and refusing is better than either.
2026-09-11 10:17:30 +05:30
Vasanthdev2004 bd7e98c05c fix(sandbox): make the capability git guard reach a workspace that gets git later
The capability planner emitted the deny-delete on .git and the deny-writes on
.git\config and .git\hooks without Materialize. On a workspace that had no
.git when setup ran, the first apply pass skipped all three as missing and the
deferred pass skipped them again, because nothing else in the capability plan
created them. Setup recorded success anyway.

A later git init then created .git, config and hooks beneath the already
granted workspace. They inherit the workspace allow, DELETE included, with no
object-specific deny of their own, so a sandboxed command could rename .git
aside, recreate it, and get credential.helper and core.hooksPath back. This is
the default backend, so the weaker of the two planners' rules was the one
almost every Windows user got.

The principal planner has materialized its carveouts from the start, which is
also what lets the applier's deferred pass land the deny-delete: creating
config and hooks creates .git as their parent. Sharing that shape rather than
only the guard's pathname is the fix.

The plan-shape test pinned materialize=false on these three entries, which
encoded exactly the behaviour that was wrong, so it is updated rather than
worked around. The new regression applies the real plan to a workspace with no
.git, runs a real git init, and reads the deny mask back off the object.
2026-09-11 10:17:30 +05:30
Vasanthdev2004 5a43074c30 fix(sandbox): recheck the runtime object on the unelevated tier too
The unelevated marker fingerprints pathnames and actions, and cleanup may
reclaim the deterministic runtime directory that plan was realized on. An
ordinary later run recreates the same pathname with the caller-private DACL and
no capability ACE, so the serialized plan is unchanged and the cached marker
returned before anything looked at the directory. The restricted child still
carried the capability SID; the new object did not grant it, and every temp,
package-cache and build-cache write failed after launch with a bare access
denial and nothing pointing at setup.

The restricted-token tier got this check in the previous commit. This tier
reaches the same lifecycle without elevation, and its answer differs: it owns
its plan, so it reapplies rather than refusing. Refusing would print advice to
run elevated setup, which is unnecessary here and unfollowable for a user with
no Administrator account.

The regression drives ensureWindowsUnelevatedSetup through provision, reclaim,
recreate, and asserts the grant is restored, with a setup guard proving the
recreated directory really lost it first. A companion pins that an intact root
still takes the cached fast path.
2026-09-11 10:17:30 +05:30
Vasanthdev2004 0904d88b26 fix(sandbox): check the runtime root still carries its grant, not just its pathname
cleanupSandboxRuntimeRoots reclaims inactive sibling workspace roots on an age
and count policy, treating them as disposable cache state. Setup and its marker
treat their DACL as durable provisioned state. When the reclaimed workspace runs
again, command-side preparation recreates the same deterministic pathname as the
ordinary caller, and the new directory inherits from its parent without the
capability SID ACE elevated setup applied to the object that used to be there.

ValidateWindowsSandboxSetupMarker still passed, because it fingerprints pathnames
and actions rather than the identity of the ACL-bearing object. So the restricted
child launched and then failed its cache, temp and package-cache writes with a
bare access-denied and nothing pointing at setup.

The command now asks the object, not the pathname: before the token is minted it
confirms the runtime root carries an allow ACE for its capability SID, and
refuses with the setup remedy when it does not. That reconciles the two owners
without making cleanup preserve trees it is meant to reclaim.

The regression applies a real plan, reclaims the directory the way cleanup does,
recreates the pathname the way an ordinary run does, and asserts the refusal
names the root and the remedy; accepting a missing grant fails it on that. Not
covered, and said plainly: a write performed by a real capability token, which
needs a provisioned sandbox an unelevated box cannot create. The ACE presence is
the fact the command consumes, and that is what is pinned.
2026-09-11 10:17:30 +05:30
Vasanthdev2004 2925a6768b fix(sandbox): validate runtime candidates before the elevated create, not after
ensureWindowsSandboxRuntimeCandidates ran as Administrator and used os.MkdirAll,
which follows links. Both candidate ancestries are prepared by the invoking user,
so a junction planted at a not-yet-created component below the cache directory,
or at the fallback anchor, made elevated setup build the runtime tree beneath a
redirected, Administrator-writable target. The ACL applier's no-follow check runs
afterwards and does spot the redirection, but the privileged create has already
happened by then and sits outside its rollback boundary. A leaf check after
MkdirAll would leave the same gap.

Candidates are now created by peermsg.EnsurePrivateDir, which descends
handle-relative with no-follow, refuses any component that is a link, and refuses
a leaf this user does not own. It is handed a physically resolved base first,
because it walks from the volume root and a redirected cache or TEMP above the
owned tail is the operator's business: the same pairing the fallback anchor
already uses, and the same mistake that refused every fallback on macOS when the
parent was left unresolved. physicalTempDir is generalised to physicalDir for
that, keeping GetFinalPathNameByHandle on Windows because EvalSymlinks does not
traverse a junction.

The regression plants a junction at the first owned component and asserts the
redirected target stays empty; letting the creator follow links again fails it on
that assertion.
2026-09-11 10:17:30 +05:30
Vasanthdev2004 faa07e3937 fix(sandbox,cli): plan the grant and its guard together, and state the tier boundary
The .git rename guard belonged to one planner rather than to the grant. The
allow-write mask both backends share includes DELETE, and it inherits from each
write root onto .git; the carveouts that actually protect git are attached to
.git/config and .git/hooks as objects, so renaming .git aside and recreating it
discards them and hands back credential.helper and core.hooksPath. The principal
planner denied DELETE there and the capability planner did not, and the
capability backend is the default. Both now derive that object from one place, so
a change to the shared mask cannot update one consumer and miss the other. The
regression reads the ACE back off the applied object rather than asserting plan
shape, because the plan is what was wrong.

The unelevated tier now states its boundary before it mutates anything. A profile
carrying denyRead runs on a strict token, which applies the restricted-SID check
to reads, so the read capability must be granted at the volume root that
permissionProfileReadRoots seeds. Elevated setup can write that DACL; an ordinary
user cannot, and the common opener asks for WRITE_DAC on every entry, so this
tier failed on that one root on every command. Dropping the root ACE instead is
not available, because the strict token would then fail its own read check for
the executable. So it is refused up front, naming the root, the denyRead cause
and elevated setup as the remedy. That last part matters: the previous
after-the-fact diagnostic told the reader that running setup elevated would NOT
fix it, which is the opposite of the truth for this case.

Separately, `sandbox exec` no longer reports a signaled child as exit 255. On
Unix ExitCode() answers -1 for signal termination and os.Exit truncates that, so
a child killed by SIGTERM was indistinguishable from one that chose to exit 255.
It now returns the conventional 128+signal, read from the ProcessState, with
Windows keeping the exit code it already reports.
2026-09-11 10:17:30 +05:30
Vasanthdev2004 834c184d4a fix(sandbox): resolve the temp dir physically before proving the fallback anchor
peermsg.EnsurePrivateDir walks every component from the root and refuses
any link. That is the right rule for the directory Zero owns and the wrong
rule for the operator's temp location above it: on macOS os.TempDir sits
under /var, a symlink to /private/var, so 8af05997 refused every fallback
on every Mac with "refusing non-directory or symlink runtime path component
var".

Anchor the fallback under the physical temp dir instead, so only the anchor
itself is a new component. EvalSymlinks off Windows; GetFinalPathNameByHandle
on Windows, where EvalSymlinks does not traverse a junction and a redirected
TEMP would trip the same way. The fixture compares against the resolved
temp dir rather than its spelling, since t.TempDir can come back as an 8.3
short name.
2026-09-11 10:17:30 +05:30
Vasanthdev2004 8da16b455f fix(sandbox): root the fallback runtime tree beneath a validated private anchor
The fallback runtime root moved from a fresh os.MkdirTemp parent to the
predictable <temp>/zero/runtime/v1/<hash> so that elevated setup and the later
command process agree on it. That agreement is required and stays. But a
predictable path directly under the shared system temp is not a private
allocation. On an ordinary shared /tmp another local user can create /tmp/zero
with mode 0700 first, and every affected user then fails before the sandbox
command starts; a pre-existing redirected component makes host-side
preparation lease, create, chmod and clean somewhere other than the derived
tree. Determinism was being used as if it were ownership.

The tree now lives beneath <temp>/zero-runtime-<user>, and that anchor is
created and validated by peermsg.EnsurePrivateDir before anything is built
under it: handle-relative descent, no-follow, refused if any component is a
link or the leaf is not owned by the current user, mode 0700. That primitive
already existed with exactly the semantics jatmn described, so it is exported
and reused rather than written a fourth time. On Unix the uid is in the name
because /tmp is shared; on Windows os.TempDir is already per-user and the
private descriptor plus owner check do the rest.

Validation runs on both paths that can hand back a fallback root:
sandboxRuntimeRootFor returns one itself when the cache lands inside the
workspace, and prepareSandboxRuntime asks for one when the cache root cannot be
leased. Both are covered, each with a junction planted at the anchor, and both
refuse with the junction target untouched; a control shows a clean fallback
still prepares beneath the anchor.

Two things went wrong on the way and are worth recording. The first draft
called pathWithinRoot with its arguments reversed, which asked whether the
anchor was beneath the root, answered false every time, and skipped the
validation entirely while every dependent test reported success through the
junction. And the first lease-failure trigger planted its file at the derived
leaf, so preparation failed on that mkdir before ever reaching the fallback
branch. Both fixed; with validation disabled the two refusal tests now die on
"reported success" while the control stays green.
2026-09-11 10:17:30 +05:30
Vasanthdev2004 c3e5e4bdfc test(sandbox): stop the launch preflight test asserting a machine's token
TestPrincipalLaunchPreflightExplainsAMissingPrivilege ran the real preflight and
then asserted SeAssignPrimaryTokenPrivilege appeared in the message. Which
privilege is missing is a property of the account running the test, not of the
code: an unelevated user often holds neither, but this box holds
SeAssignPrimaryTokenPrivilege and not SeIncreaseQuotaPrivilege, so only the
other name appeared and the test failed for a reason that was never a defect.

Its own comment said it asserted "the shape of the answer rather than a fixed
verdict, because the verdict legitimately differs by machine", and then it
hardcoded one of the two names. The comment was right and the assertion did not
match it.

principalLaunchPrivilegeError splits the rendering out from the token work, so
each combination is driven directly: neither held, only assign-primary-token,
only increase-quota, and nothing missing. Each asserts the message names what IS
missing and does NOT name the one that is held, since sending an operator after
a privilege they already have is its own failure. Making the message always name
both fails the two single-privilege cases.

The real preflight keeps a test, reduced to what does not vary: if it refuses,
the refusal names at least one of the two rather than something the operator
cannot act on. Stability across calls is unchanged.
2026-09-11 10:17:30 +05:30
Vasanthdev2004 89b2e6dfbc test(sandbox): keep the runtime-root candidates inside test-owned storage
runtimeRootTestConfig put the workspace and sandbox home under t.TempDir, but
windowsSandboxRuntimeCandidates still derived a candidate through the
production sandboxUserCacheDir seam, and the test creates every candidate and
registers os.RemoveAll cleanup for each.

So the test reached into the real user cache: it fails outright on a read-only
home, and on an ordinary developer or CI account it creates and then deletes a
path outside its own storage. The workspace hash makes a collision unlikely; it
does not make somebody else's directory test-owned.

Redirected before any candidate is derived, and restored with t.Cleanup.
Production derivation is unchanged, and the assertion that setup materializes
every granted write root still stands.

A before/after count of the real cache cannot observe this, since the test
creates and removes in the same run. The mechanism is the evidence:
prepareSandboxRuntime reads sandboxUserCacheDir, and the test RemoveAlls every
candidate that derivation produces.
2026-09-11 10:17:29 +05:30
Vasanthdev2004 cd7805062f fix(sandbox): keep principal provisioning closed until a launch path exists
The gate asked whether the CALLING process held SeAssignPrimaryTokenPrivilege
and SeIncreaseQuotaPrivilege. That is the wrong lifetime, and it got the answer
backwards where it mattered.

The process that needs those privileges is the later, ordinary command process.
Setup does not run it, and nothing carries launch authority across the UAC
boundary. So the caller-token check refused unelevated setup, which was never
the dangerous case, and PASSED elevated setup, which is precisely the case that
lands a local account, its password, its logon-right assignments, workspace
ACEs, the recovery ledger and network filter state for a backend no later
command can use. The check existed to prevent durable machine state serving
something that cannot run, and in the one configuration that creates that state
it allowed it.

Requiring every sandboxed command to run elevated would line the privileges up
and is deliberately not done: it is a worse boundary than the restricted token
it would replace, and it is not the lifecycle this is for. Until a reviewed
bootstrap or broker exists, provisioning is closed rather than half-working.

windowsPrincipalLaunchAvailable replaces the preflight var and takes no
argument, because the answer is about which mechanisms exist rather than about
the current process. It is the one place that learns to say yes when a broker
lands, and everything below it -- the plans, the ledger, the ACL and filter
work -- still builds and is still tested against it.

The runner keeps its own privilege check at launch time, which is the correct
lifetime for one; only the setup wiring is removed.

The regression test asserts the property the old shape could not express: the
refusal is stable regardless of what the calling process holds, and it does not
tell the operator to re-run from an elevated terminal, which is the advice that
produced the unusable state. Restoring the caller-passes behaviour fails both
new tests.

Refs #662, which this is foundation for and does not close.
2026-09-11 10:17:29 +05:30
Vasanthdev2004 6f0d45323b test(sandbox): assert nothing was created, not that a count held still
TestTeardownPathDerivationCreatesNothing compared the number of entries in the
SHARED temp directory before and after deriving the teardown paths. The
assertion it wants is that deriving a path creates nothing, and a count cannot
tell creation from removal: that root is also used by every other test binary
running at the same time and by the OS, so a concurrent cleanup made the count
fall and the failure read "temp directory gained -1 entries".

It compares the entry names now and reports anything that APPEARED, which is the
question actually being asked and is indifferent to whatever else disappears.
The failure also names the entry rather than a delta, so the next person sees
what was created instead of a number.
2026-09-11 10:17:29 +05:30
Vasanthdev2004 d6b4e73e0e fix(sandbox): refuse to provision a principal this caller could never launch
Measured on an ordinary unelevated session: the token holds neither
SeAssignPrimaryTokenPrivilege nor SeIncreaseQuotaPrivilege, so the principal
launch path can never engage for the process the runner is designed to be
called from. Elevated setup does not change that, because the command runs
later from the caller rather than from setup.

Until now setup succeeded completely in that situation. It created a local
account, its password, its logon-right assignments, the workspace ACEs, the
recovery ledger and the network filter state, and then every principal-mode
command refused before opening its executable. The operator was left with
durable machine state serving a backend that cannot run, and nothing said so at
the point they could still act on it.

The check runs in the caller's own process, which is the one whose privileges
decide the answer, and before anything crosses the UAC boundary. It is wired to
the same function the launch path uses, so a change to what a launch requires
cannot leave setup provisioning for a capability that no longer exists.

Three existing tests asserted argument plumbing with the opt-in on and passed
only because nothing checked; they stub the preflight now, so they no longer
depend on the privileges of whoever runs the suite.

This does not give the principal backend a working launch path. That needs a
different architecture and is not in this change.
2026-09-11 10:17:29 +05:30
Vasanthdev2004 3574f62c2f fix(sandbox): name the two role inventories, and fingerprint the whole plan
Three follow-ups from going back over the review findings. None was raised
directly; two are the same shape as things that were.

windowsPrincipalPlanFingerprint was the one of three buildWindowsPrincipalACLPlan
call sites that did not pass DenyWrite. Apply and teardown both did, so the hash
the marker carries described a different plan than the one that actually gets
applied, and a change to the policy's deny-write paths moved the applied plan
while leaving the marker where it was. It is not a live hole, because the
capability ACLPlanHash covers the same paths and moves the marker anyway. That
is also exactly what would have kept it invisible until somebody changed the
capability plan's shape.

The role list was spelled out in three places and two of them are meant to
differ, which is why writing them out by hand kept going wrong.
windowsSandboxPrincipalIsInstalled asked only the offline and online roles while
teardown retires the legacy account too, so a machine still holding the untagged
pre-split account was reported clean and the opted-out marker claimed a teardown
that had not happened. Provisioning has the opposite constraint: legacy must
never appear there or setup would recreate that account on every run of an
already-upgraded machine. Both are named now, windowsSandboxLiveRoles and
windowsSandboxRetirableRoles, with a test pinning the legacy role into exactly
one of them.

And the opt-out error said "retire the principal" when a workspace has two plus
the legacy one, all of which that re-run retires.
2026-09-11 10:17:29 +05:30
Vasanthdev2004 fffc780dc3 fix(sandbox): bind the ACL restore to the object it snapshotted
jatmn's P1 asked for creation AND cleanup to be handle-bound rather than
resolved from a pathname. Creation and the materialization unwind both are.
The DACL restore was not, and its comment read as though it were.

It re-opened the target with a no-follow open, which rules out a reparse point
swapped in since apply and nothing else. The other substitution passes it
untouched: rename the target aside and put an ordinary directory of the same
name in its place. Nothing there is a link, so the open succeeds, the pre-apply
DACL lands on the decoy, and the real object keeps the ACEs from the setup that
just aborted. The snapshot now records the volume serial and file index of the
object it read the DACL from, the restore proves it is writing back to that same
object, and a mismatch is refused rather than forced through. Leaving the real
object with the aborted setup's ACEs is the safe direction, since the caller is
failing anyway.

Three things nothing was pinning, all of which could be deleted with a green
suite. This repository has already had a fix silently reverted by a later
change, so these are worth more than their size.

rollbackWindowsACLSnapshots documented its reverse iteration as pinned by
TestRollbackUnwindsDescendantsBeforeAncestors. That test did not exist anywhere
in the repo; the only match for the name was the sentence claiming it. The
ordering is load-bearing twice over, because a materialized directory must be
empty before its own removal and SetSecurityInfo propagates inheritable ACEs
downward, so the ancestor has to go last. It exists now.

The principal ACL rollback restores the ledger alongside the DACLs, and the
neighbouring test asserted only the order of the two ACL reverts and never read
the ledger. Deleting the restore left the suite green while the paths it put
back were unnamed, so cleanup could not find them.

windowsSandboxUserIsManaged promises an account carrying the legacy bare comment
gets the workspace key stamped on. The probe for the legacy comment was seamed
and the upgrade itself was not, so nothing could observe the call. It is seamed
now, with the negative case covered too so the assertion cannot be satisfied by
an unconditional rewrite.
2026-09-11 10:17:29 +05:30
Vasanth T 272acb9324 feat(sandbox): give each workspace an offline and an online principal (#812)
* feat(sandbox): give each workspace an offline and an online principal

The principal backend stood down whenever the network was denied, which is the
default, so opting into it left the restricted-token path doing all the work in
normal use. The reason was that network denial is enforced by block filters
keyed to the offline-marker SID, and a principal token cannot carry it:
LogonUser builds a token from an account's real group memberships, and the
marker is a synthetic capability SID.

A real local group closes that gap. ZeroSandboxOffline is created by setup, the
block filters name its SID alongside the marker, and a principal is denied the
network by being a member. Each workspace therefore gets two accounts that
differ only in that membership, and the command's network mode selects between
them. A group rather than each principal's own SID because principals are per
workspace: one filter set covers every offline principal on the machine instead
of needing a filter per workspace.

Both principals are provisioned together even though a given setup run sees one
profile, because setup needs elevation and commands do not. Provisioning lazily
would mean an unelevated command discovering it needs an account it cannot
create. They also get identical filesystem access, so an approved network
command sees the same filesystem as an ordinary one.

Two orderings are load bearing. The network plan is now built AFTER
provisioning, because the group it keys to is created there; planning first
installed filters naming only the marker and left every offline principal with
an open network while looking correctly set up. And the role tag sits before
the workspace hash in the account name, so truncation at the 20 character limit
eats hash characters rather than the tag, which would otherwise collide the two
roles onto one account on exactly the workspaces most likely to truncate.

Anything that is not an explicit allow maps to the offline principal, so an
unrecognised mode loses the network rather than keeping it.

The filter identity set is resolved rather than assumed, and stays absent until
the group exists, so a machine that never provisions principals computes the
same plan as before. That matters because the plan is hashed into the setup
marker and re-derived on every command; an identity set that differed between
setup and the command path would fail every command as out of date.

Cost worth stating: this doubles the sandbox accounts on a machine, to two per
workspace.

* fix(sandbox): prove ownership before deleting a sandbox account

removeWindowsSandboxIdentity is called with a DERIVED name, so it could be
pointed at a name that happens to belong to somebody else's local account.
Deleting a user is not a recoverable mistake, and the only thing standing
between the two cases was the name matching a pattern we generate ourselves.
Raised by CodeRabbit against the test fixtures, but the production teardown path
had the same hazard, so the guard belongs there rather than in the tests.

The ownership check itself now lives on the base branch, which grew the same
helper to stop provisioning ADOPTING a squatted account. This applies it to the
other end: an account that is not ours is left alone rather than deleted. The
gated tests get the protection for free, since their pre-clean goes through the
same helper.

Also appends the trimmed offline-group SID rather than the raw one. Worth noting
the reported consequence does not hold: newWindowsWFPUserCondition canonicalises
before converting, so a padded value would have been trimmed before reaching
StringToSid. The resolver returns SID.String(), which never carries whitespace,
so this is defensive tidying rather than a fix.

* fix(sandbox): assert the filters cover principals, and report a retained account

Two follow-ups from review, both on the same theme: a control that quietly does
nothing looks identical to one that works.

The network plan must be built AFTER provisioning, because provisioning creates
the group the block filters name. Built first, the filters name only the
offline marker and every offline principal has an open network while setup
reports success. That ordering is invisible at the call site, so setup now
checks the plan actually names the offline group before installing anything and
refuses if it does not. A later refactor that moves the plan build back fails
loudly instead of producing a security control that enforces nothing.

The predicate is separate so it can be asserted directly: a plan carrying only
the marker must read as uncovered, one carrying the group as covered, case
differences must not read as missing, and a host with no group provisioned has
no principal to miss and must not be refused. Making coverage always report
true fails that test.

Removal also reported plain success when it declined to delete an account Zero
did not create. Leaving it alone is right, but telling an operator cleanup
completed when a name they may care about was deliberately retained is not.
That case is now a distinguishable sentinel, and teardown treats it as success,
since "no principal of ours under this name" is the goal state either way.

* fix(sandbox): spare adopted principals when dual-role setup rolls back

Provisioning already declined to delete an account it had adopted, and the
outer setup rollback then appended an unconditional removal for every role
that got that far. With two roles that is the common case rather than an
unlucky one: the offline role usually succeeds, so a failure in the online
role or in ACL application destroyed a principal that was working before
the run started. The removal closure is now only appended for a principal
this run created.

Threads the workspace key into the delete path as well. Ownership was
proven from the account comment alone, which on a name collision belongs
to a DIFFERENT workspace, so deleting it would have been the same
unrecoverable mistake the check exists to prevent.

Policy DenyWrite now reaches the principal ACL plan here too, matching the
single-principal path.

Fixes the mode-independence test, which required exactly one identity SID
and so failed on any Windows host that already had ZeroSandboxOffline,
where the plan legitimately carries two. CI never saw it because the Linux
and macOS jobs leave the hook nil and a fresh Windows runner has no group.
The hook is now pinned, and the test additionally asserts the property it
is named for in the group-present case, including that the infra hash
changes when the group appears, which is the cross-workspace coupling
raised for a maintainer decision.

* test(sandbox): stub the password reset and pin the resolved group SIDs

Two review points on the tests added in the previous commit.

The provisioning stub left resetWindowsSandboxUserPassword as a real
call. Nothing under test reaches it any more, because rotation moved to
the caller, but a test that resets a real managed account's password if
the code ever moves back is not a risk worth carrying. It is now stubbed
to fail the test instead, which also states the contract.

The group-present assertion checked only that two identity SIDs were
present. A duplicated offline marker or an unrelated SID would satisfy
that while meaning something quite different, so it now pins both
positions.

Also drops a duplicated stub assignment left by the rebase.

* fix(sandbox): refuse an offline group zero does not own

ensureWindowsLocalGroup accepted NERR_GroupExists and ERROR_ALIAS_EXISTS as
success without inspecting the group it was about to reuse. Setup then resolved
that group's SID and installed it on the persistent WFP deny filters, and made
the sandbox principal a member of it.

If anything else on the machine already owns a group named ZeroSandboxOffline —
another tool, a policy, a prior unrelated convention — that is not a no-op. Every
existing member abruptly loses outbound access, because the filters now name
their group. In the other direction the sandbox principal inherits whatever
permissions that group carries, which is the opposite of what an offline
principal is for.

The add now reports its raw status and the already-exists branch verifies the
group carries this setup's managed marker before adopting it, failing with an
actionable message otherwise. A lookup error fails closed rather than adopting.

NetLocalGroupAdd and the ownership lookup sit behind seams so the branch is
reachable in tests without an elevated machine; the marker compared is the
group's own, so the principals group and the offline group cannot be confused.

Reported by jatmn on #812.

* fix(sandbox): recheck offline group membership before minting a token

Network denial does not follow from picking the offline account. The WFP block
filters match the offline GROUP'S SID, and LogonUser builds a token from the
account's real memberships — so membership is the whole enforcement, and the
command path never revalidated it.

An account that drifts out of ZeroSandboxOffline through local policy, an
administrator, or a re-setup that could not re-add it still resolves, still has
its stored secret, and still logs on. Its token no longer satisfies the filter
condition, so a NetworkDeny command gets full egress under a profile that asked
for none. The stale setup marker keeps the whole path looking healthy.

The offline role now confirms the membership its mode depends on before the
secret is read, and falls back to the restricted token when it is absent. That
direction is deliberate: the restricted token carries the offline marker the
same filters match, so egress stays blocked, and only read confinement is lost.
A failed lookup surfaces rather than downgrading silently. The online role is
not checked, since it is not in that group by design.

Reported by jatmn on #812.

* fix(sandbox): derive setup's runtime root deterministically or not at all

windowsSandboxRuntimeRootPath resolved through sandboxRuntimeRootFor, which
falls back to os.MkdirTemp when the user cache lives inside the workspace and
memoizes that only in-process.

Elevated setup is its own process. It granted the principals an ACE on temp root
A; the next command, being a new process, derived temp root B, where the
principal has no ACE, and failed ordinary cache writes with a bare ACCESS_DENIED
and nothing pointing at the sandbox. Teardown, a third process, cleaned a third
directory. The three callers that have to agree exactly could not agree at all.

Setup now uses the same side-effect-free derivation teardown already used, and
reports no runtime root when that derivation is unusable rather than inventing
one. A root only the granting process can name is worse than no root: the
principal loses the runtime tree, which is a degraded sandbox, instead of the
sandbox appearing provisioned while every command fails.

TestTeardownPathDerivationCreatesNothing asserted the opposite — that setup
"should still fall back to a usable tree" — so it is inverted here, with the
reasoning recorded in the test. That assertion encoded the assumption this
finding overturns: a per-process temp tree is not usable. Restoring the fallback
fails it with the invented path in the message, and a new
TestSetupAndTeardownDeriveTheSameRuntimeRoot pins the ordinary case, so
"report none" cannot quietly become the answer everywhere.

Reported by jatmn on #812.

* style(sandbox): separate the two doc paragraphs the rebase ran together

Adapting the ACL-record test to dual roles left #808's fail-open rationale
and #812's per-role rationale as one unbroken block. Both are worth
keeping; they are two points, not one.

* fix(sandbox): fail closed when offline-group coverage cannot be verified

The post-provisioning assertion ran inside `if groupErr == nil`, so a
failed lookup skipped it and setup carried on to install filters and
write a success marker. The comment directly above it says what that
costs: a machine reporting a successful setup while every offline
principal has an open network.

An empty SID was the same hole by a different route. Resolving to
("", nil) means the group does not exist, which is the ordinary state
before provisioning and an impossible one after it, and
WindowsNetworkPlanCoversPrincipals answers true for an empty SID
(correctly, for the pre-provisioning callers that ask it). So the
assertion passed vacuously in exactly the case where the group setup
had just created was missing.

Move the check into assertWindowsNetworkPlanCoversOfflineGroup, which
takes the resolver as a parameter and fails closed on every answer that
is not a definite yes: lookup error (wrapped, so the Win32 reason still
reaches the operator), empty SID, plan omitting the group, and a nil
resolver. Setup rolls back and exits 1 on each.

Taking the resolver as a parameter is what makes the error paths
testable, which is the regression the review asked for.

Reported by @anandh8x on #812.

* fix(sandbox): scope the offline-group assert to provisioned runs

c404ebd2 made the coverage assert reject an empty group SID, closing the
vacuous pass where a missing group counted as covered. It ran the assert
unconditionally, and the offline group is only created inside
provisionWindowsSandboxIdentity, which runs only under the
ZERO_WINDOWS_SANDBOX_IDENTITY opt-in.

So on a default machine with principals opted out, the resolver reports
("", nil) exactly as it should, and setup died with "the sandbox offline
group does not exist after provisioning" on a path that worked before
c404ebd2. The empty-SID rejection is correct after provisioning and wrong
before it.

Pass provisioned to the assert and return early when it is false, gated
at the call site on the same windowsSandboxIdentityEnabled check that
decides whether principals are provisioned at all. The fail-closed
behaviour anandh8x asked for is unchanged whenever provisioning ran.

Reported by @jatmn on #812.

* fix(sandbox): keep opted-out setup markers valid, and refuse a foreign offline group

Two of jatmn's findings on this PR.

Existing markers stay compatible (maintainer decision). The offline group is
machine-global, and the plan included its SID whenever the group existed. So the
first workspace to opt in changed the computed NetworkInfraHash for every OTHER
sandbox home on the machine, and those homes rejected their own stored markers
until each was re-run from an elevated terminal, having opted into nothing.

The inclusion is now gated on THIS home's opt-in rather than on the group
existing, so an opted-out home computes exactly the plan it computed before any
of this existed. Setup and the command path read the flag from the same
environment, so they agree. Opting in after setup does invalidate that home's
marker, which is correct: it has no principals yet.

Do not install filters for an unowned offline group (P1). The ownership check
only ran through principal provisioning, so an opt-out setup reached the resolver
and adopted any local alias carrying the name. applyWindowsNetworkPlan turns
every SID in the plan into an allowed-to-match WFP descriptor, so a foreign group
meant global deny filters against every one of ITS members: anyone with a local
group by that name loses the network for those accounts because we ran setup.
The resolver now requires the managed comment, and refuses rather than skipping,
because a plan whose filters cover no principal while setup reports success is
the failure this backend exists to prevent.

Three existing tests exercised the group path without the opt-in and now set it.
The new test asserts the other direction, that an opted-out home's hash is
unchanged when another workspace creates the group, since that is the property
the decision turns on. Verified both ways: disabling the gate fails the existing
tests, making it unconditional fails the new one.

* fix(doctor): report the principal that dual-role setup actually uses

jatmn's P2. This branch made the offline principal work under NetworkDeny, but
the doctor helper still described the old restricted-token standdown, so
`zero doctor` reported active:false and told operators reads were unconfined for
a correctly provisioned offline principal, recommending they enable network or
drop the opt-in to fix something that was not broken.

WindowsSandboxPrincipalInactiveReason is removed rather than reworded. Its only
condition was the deny-mode standdown, so after this branch it could never return
anything, and a check that cannot fire is worse than no check.

What replaced it matters more than what it said. That helper existed to be the
SINGLE rule doctor and the runtime both read, precisely so they could not drift,
and drift is what happened anyway when dual-role changed the behaviour under one
of them. WindowsSandboxPrincipalRoleForNetwork is now that shared rule:
windowsSandboxRoleForNetwork delegates to it and doctor calls it, so the reported
account and the used account cannot disagree. Doctor now names which principal a
command runs as instead of asserting a standdown.

One thing the existing tests caught. Routing the shared rule through
NormalizeNetworkMode case-folds, so "ALLOW" selected the ONLINE principal where
the runtime required an exact match and failed closed to offline. Sharing a rule
is only an improvement if it shares the stricter one, so the comparison is exact
and a test pins the casing.

* fix(sandbox): converge the dual-role branch with the rebased identity work

Rebasing #812 onto the new #808 needed real resolution rather than taking a
side, and this records what each conflict actually decided.

The account key. #808 made the principal key caller-scoped so elevated setup
provisions the account the caller will later look for. #812 derived usernames
from the workspace key alone. Every username derivation now uses the caller
scoped key, including the two inline call sites a blanket substitution missed:
the identity lookup in the unrecorded-retire path and the ledger read in
windowsPrincipalRevocationPaths. That second one is why teardown could not find
a recorded root the current policy no longer named. The setup LOCK stays keyed
to the workspace on purpose, because two users setting up one shared workspace
still write DACLs on the same paths and must serialize against each other.

The network plan stays where #812 put it, after provisioning, because the block
filters are keyed to the offline group that provisioning creates. Building it
earlier, as #808 does, would install filters naming only the marker and leave
every offline principal with an open network while looking correctly set up.

Group ownership converged on #812's implementation, not mine. #808 grew a check
hardcoded to the users group; #812 already had the general
ensureWindowsLocalGroup plus windowsLocalGroupOwnedByZero, which covers both
managed groups. The narrower version was removed and its test rewritten against
the general seams, so the users group keeps the coverage anandh8x asked for
while the offline group keeps its own.

Two functions the resolution dropped and the compiler caught:
windowsSandboxPrincipalKey and windowsCurrentUserSID. Worth naming because the
previous attempt at this convergence lost the same first function silently.

Also closes jatmn's remaining findings on this branch. The opt-out installed
check now asks about BOTH role accounts rather than one, since retiring one
while the other survives is exactly the half-done teardown an opted-out marker
must not report as success. And the post-provisioning filter-coverage assert now
resolves the offline group through the existing hook rather than the concrete
function, so it is stubbable like everything else around it.

The SensitiveEnvKeys omission jatmn reported on sandbox_exec.go arrives with the
rebase; it was fixed on #808.

* fix(sandbox): keep offline coverage and grant the fallback runtime root

Two ways the dual-role split left a workspace worse off than it looked.

The block filters are machine-global and every setup installs them by
deleting and recreating one fixed set, but the plan a home builds names
the offline group only when THAT home opted in. So an ordinary opted-out
setup for a second workspace replaced the filters without the group SID,
and the first workspace's offline principal, still in the group, still
passing the runtime membership check and still holding a valid marker,
was no longer matched by any filter. A NetworkDeny command there gained
egress silently, because the second setup did exactly what it was asked.

The gate itself is left alone, because it is load bearing for a different
reason: the plan is hashed into each home's marker, and keying it on the
group's existence made the first workspace to opt in invalidate every
other home's marker on the machine. What a home RECORDS is about its own
configuration; what setup INSTALLS is about the machine. Answering both
from one plan is what forced a choice between stale markers and a silent
hole, so WindowsNetworkPlanForApply answers the second question only, at
the apply call site, leaving the fingerprinted plan untouched.

Setup also granted the principals an ACE on the cache-derived runtime
root alone, and none at all when the cache sat inside the workspace. That
was correct when the other branch minted a random per-process directory
through MkdirTemp, but fallbackSandboxRuntimeRoot now derives its path by
hashing the workspace and creates nothing, so every process agrees on it.
Commands in that layout therefore DO select it and redirect TMP, GOCACHE
and the package caches into it, against a tree neither principal could
write. Setup now grants the same candidate set the capability plan
already covers, and creates each one, since applyWindowsACLPlan fails on
a target that does not exist.

Reverting either fix fails its regression: the opted-out plan installs
filters naming only the marker SID, and setup grants nothing while
commands write to Temp\zero\runtime\v1\<hash>.

Two existing assertions had to be inverted rather than adapted, and both
were asserting the old bug. One required setup to report NO runtime root
in the cache-inside-workspace layout; the other compared setup's single
root for equality against the command's choice. Setup covers the whole
candidate set now precisely because that choice is made per process, so
the contract is membership.

* fix(sandbox): treat an unreadable offline group as a failure, and retire the pre-split principal

Three findings from review.

A resolution failure in WindowsNetworkPlanForApply returned a marker-only plan,
and the machine's WFP filters are replaced wholesale from that plan. So an
opted-out setup whose group lookup failed transiently removed the offline group
SID another workspace's principals depend on, and that workspace's NetworkDeny
commands silently regained egress while its marker and its direct membership
check both still passed. The old reasoning was that refusing to install would
trade a partial denial for no denial; that is wrong, because the alternative to
installing is leaving the existing filters alone. It is fatal now, which is only
reachable from an opted-out home since assertWindowsNetworkPlanCoversOfflineGroup
is gated on provisioned.

Splitting the single principal into offline and online roles changed both
account names without changing the marker schema, so an installation made by the
previous version kept a valid marker, setup was never re-run, and the runner
found neither zero-sbx-d<key> nor zero-sbx-n<key> and fell back to the
restricted-token backend with no read confinement. The schema version is bumped
so that installation reports as out of date, and a legacy role derives the old
untagged name so the ordered retirement can remove the account, its secret, its
logon rights, its ACEs and its ledger. It is retired, never provisioned, and
there is a test for that because the two lists are one line apart.

Doctor reported that commands run as the selected principal whenever the marker
validated, but marker validation compares serialized plans and hashes and names
no account: deleting the account or its secret, or dropping the offline account
out of its group, leaves the marker valid while the runtime falls back or fails.
Verifying liveness needs Windows-only queries internal/doctor cannot make, so
the claim is narrowed to what the marker actually proves. The role is still
reported, since that part is derived rather than assumed.
2026-09-11 10:17:28 +05:30
Vasanthdev2004 d736aae19a test(sandbox): compare normalized paths, not the raw input
TestACLComparablePathDoesNotResolveAReparsePoint failed on Windows CI
while passing locally. The test was wrong, not the code.

It asserted that windowsACLComparablePath returns a string equal to the
path passed in. GetLongPathName legitimately rewrites that string: a CI
runner's temp directory is an 8.3 short name, so expanding RUNNER~1 to
runneradmin produces a different string naming exactly the same object,
and the assertion failed on the one property the function is supposed to
have.

Both sides are now normalized before comparison, which states the real
property: a path THROUGH the reparse point must not normalize to the
target's normalization, or the guard would compare a redirected handle
against a redirected expectation and match itself. A second assertion
keeps the link component present so a resolution to something else
entirely still fails.

The test's documented limit is unchanged and still honest: a junction
cannot separate the old basis from the new one, because EvalSymlinks does
not resolve junctions either. It remains an invariant test.
2026-09-11 10:17:28 +05:30
Vasanthdev2004 129800b995 fix(sandbox): stop the redirect check resolving what it checks for
verifyWindowsACLTargetNotRedirected asks GetFinalPathNameByHandle where
the handle landed, then compared that against
canonicalSandboxWorkspaceRoot(path), which runs filepath.EvalSymlinks.
That is the same resolution the kernel had just performed, so for a
directory symlink the two sides agreed precisely BECAUSE the redirect
happened: elevated setup went on to rewrite the DACL of an object outside
the workspace while the check reported success.

Junctions were rejected, but by accident rather than by design. Go reports
a junction as ModeIrregular rather than ModeSymlink, so EvalSymlinks
refuses it, the canonicalization falls back to the lexical path, and the
mismatch surfaces. That is a property of the standard library's mode bits,
not of this guard, and a Go release that resolved mount points would
silently disarm the one case it was known to catch.

The expected side is now normalized without resolving anything.
GetLongPathName expands an 8.3 short name by reading directory entries and
does not follow a link to its target, and EqualFold still covers casing,
so the two spellings that legitimately name the same object still compare
equal. Every failure degrades to the lexically cleaned path, which can
only produce a spurious refusal, never a spurious match.

A target deliberately spelled through a symlink is now refused. That is
the intended direction: the question here is whether the object the handle
landed on is the object that was named.

On the tests, plainly: the directory-symlink regression is the one that
separates the old basis from the new, and it needs Developer Mode or
SeCreateSymbolicLinkPrivilege, so it skips on a machine without either and
skipped on mine. The junction test beside it passes against both bases and
says so in its own comment; it is an invariant test guarding the Go
behaviour the old code accidentally depended on, not a regression for this
change.
2026-09-11 10:17:28 +05:30
Vasanthdev2004 474f6e9b08 fix(sandbox): drop the unfollowable sandbox override from ACL failures
Both unelevated ACL failures told the reader to re-run with `--sandbox
forbid`. There is no such option: SandboxPreferenceForbid is an internal
engine state with no flag behind it, so acting on it produced an unknown
option and left them stuck on the failure they had just been told how to
clear. Advice that does not work costs more than none, because finding
that out takes the reader's time.

Name the real way out instead, the user config key, which is honored
from global config only so a cloned repo cannot set it. The
elevated-setup remedy beside it was already correct and stays.

Reported by jatmn against the same string on #640. It predates this
branch, having arrived with the unelevated fallback tier in #427, and
the copy on #886 is fixed separately in 1b304e13.

Also covers the secret write with the junction regression it was owed:
the caller owns the sandbox home, so they can put a reparse point where
the secret directory is expected, and the pathname version followed it
in an elevated process. The test asserts the refusal names the reparse
point and that nothing survives on the far side, since refusing while
still creating the file would leave the caller holding it.
2026-09-11 10:17:28 +05:30
Vasanthdev2004 2a765446cb fix(sandbox): stop the principal's environment naming the caller
The child environment starts as the invoking user's, and the deliberate
sandbox redirects only replace HOME, the temp variables and the per-tool
cache dirs. Everything identifying the account survived, so a command
running as the principal read USERPROFILE, APPDATA, LOCALAPPDATA,
HOMEDRIVE, HOMEPATH, USERNAME and USERDOMAIN describing the CALLER,
whose profile the principal deliberately cannot open. Native tools
resolve per-user state through exactly those, so they fail during
startup or quietly look somewhere they have no business reading.

Point them into the sandbox runtime tree, which is already granted to
the principal and already holds its caches, so the paths are writable by
construction. Naming the real Windows profile would need LoadUserProfile
to have run, and a variable pointing at a directory that does not exist
yet is a worse answer than one pointing somewhere usable.

Layered under the deliberate redirects rather than over them:
sandboxRuntimeEnvironment stays the single owner of HOME, TMPDIR, TMP
and TEMP, and a regression pins that so the two cannot drift into
setting the same variables from two places.

This is the environment half of the finding. Loading the principal's
profile and known folders is left until the launch mechanism is settled,
since LOGON_WITH_PROFILE would do it as a side effect.
2026-09-11 10:17:28 +05:30
Vasanthdev2004 81c8326072 fix(sandbox): report the privileges a principal launch actually needs
CreateProcessAsUser exempts only a restricted version of the caller's
own primary token from SE_ASSIGNPRIMARYTOKEN_NAME, which is precisely
why the ordinary restricted-token path works while holding nothing
special. A principal token comes from LogonUser against a separate local
account, so the exemption does not apply and both that privilege and
SE_INCREASE_QUOTA_NAME are required. Nothing enabled or checked either,
and a token measured on an ordinary unelevated process holds neither, so
the failure arrived as a bare "Access is denied" from inside process
creation, before the command's executable was ever opened, and read as
the command being rejected.

Enable them where they are held, since present-but-disabled still fails
the access check and that is where an elevated administrator lands, and
refuse with the specific names and a way out where they are not.

Detected by ENUMERATING the token. AdjustTokenPrivileges reports an
unheld privilege by returning success with ERROR_NOT_ALL_ASSIGNED, which
this binding does not surface: it returns nil for SeTcbPrivilege on an
ordinary process. A check built on its error passed everywhere, which is
worse than no check, since it would call the sandbox ready in exactly
the case it cannot run.

This does not make the principal launchable. It makes the reason legible
while the launch mechanism itself is settled.
2026-09-11 10:17:28 +05:30
Vasanthdev2004 b9ba54a948 fix(sandbox): write the principal secret through one pinned handle
The elevated secret write resolved its path four separate times:
MkdirAll, an O_TRUNC open, SetNamedSecurityInfo by name, then WriteFile
by name. The sandbox home belongs to the invoking user, who is the party
this sandbox contains, so each resolution was a place to swap a
component. A symlink leaf lets an Administrator truncate a file of the
caller's choosing and then rewrite its DACL; a junction alone is enough
to plant the deterministic secret somewhere the caller controls.

Create the leaf relative to a pinned no-follow parent handle, refuse it
if it is a reparse point, and apply both the DACL and the bytes to that
handle. The name is never resolved again after the create.

The payload is now sealed before the file exists, so a failure there
leaves nothing on disk rather than an empty file for someone to race.
Cleanup on failure stays by name, which is safe in the direction that
matters: at worst it misses and leaves a locked-down file, never deletes
something it did not create.
2026-09-11 10:17:28 +05:30
Vasanthdev2004 b97e744989 fix(sandbox): protect the gitdir pointer in a linked worktree
A linked worktree or submodule has .git as a FILE holding a `gitdir:`
pointer, not a directory. The principal plan names .git/config and
.git/hooks and materializes both, and the Windows materializer gets
there by descending through .git as a directory. A regular file cannot
have children, so opted-in elevated setup aborted and the sandbox could
not be used in a worktree at all. Zero's own development worktrees are
this shape, which is how it went unnoticed.

Deny the pointer file itself there instead. That is the stronger
protection rather than a fallback: a principal able to rewrite `gitdir:`
repoints the repository at a control directory of its choosing, which
subsumes editing config or planting a hook. The real control directory
sits outside the write root, so nothing is inherited there and no
carveout is needed.

Decided by an Lstat rather than a lexical guess, since the layout is a
property of the checkout. An absent .git keeps the directory-shaped
carveouts so they are still created before git first runs.
2026-09-11 10:17:28 +05:30
Vasanthdev2004 55023a9805 test(sandbox): fix the workspace-root literal in the setup-args tests
A bulk edit dropped the separator, leaving the drive-relative
`C:workspace` where `C:\workspace` was meant. Windows resolves the
first well enough that the two halves still agreed; on Linux a backslash
is an ordinary character, so the command half derived no runtime
candidates at all and the marker carried two the command never saw.

The setup half no longer pre-augments either: BuildWindowsSandboxSetupArgs
folds the runtime roots in itself, and passing them in hid that from the
one test that covers it.
2026-09-11 10:17:28 +05:30
Vasanthdev2004 9800065f65 fix(sandbox): give the principal's read roots a restricting SID
A profile with DenyRead drops WRITE_RESTRICTED, so Windows runs the
restricted-SID check over reads as well as writes. Read roots were
granted only to the principal's account SID, and the write jail removes
that SID from the restricting set on purpose, so reads passed the normal
check and matched nothing on the restricted one. The result was not a
narrower sandbox but an unusable one: no read root readable, including
the executable the command was trying to start.

Mint a read capability SID, grant it on every read root in the
capability plan, and carry it in the strict token's restricting set, so
the read allow-list and the restriction come from one value instead of
two that can drift.

Deny it on every DenyRead path too. The read roots begin at the
filesystem root, so without that the carveouts stay readable through the
new grant and the deny list stops meaning anything, which is the whole
reason the strict token is chosen. The no-write-roots case keeps its
ReadOnly deny as well: both SIDs the token can carry must be denied, not
only the newest.

Gated on DenyRead, which is what selects the strict token. Elsewhere
reads never reach the restricted check and the grant would be ACEs on
the filesystem root that buy nothing. Both halves decide from the
profile alone, so they cannot disagree about whether the entries exist.
2026-09-11 10:17:28 +05:30
Vasanthdev2004 18ffd02b93 fix(sandbox): derive the runtime roots where TEMP is the operator's
Every sandboxed command failed marker validation with two plans of the
same size and different hashes:

  marker ...\windows-setup.json has 12 entries, hash 76fda2032e66;
  this command expects 12 entries, hash 4be1dbf8b642

The temp-derived runtime candidate reads os.TempDir(), and the sandbox
points TMPDIR, TMP and TEMP at its own runtime temp for everything it
launches. The command runner inherits that env, so when it derived the
candidate set it produced a root under the runtime tree while setup,
whose TEMP is untouched, produced one under the real temp. Same count,
different path, and no command could run.

A fingerprint both halves compare cannot be a function of the caller's
environment. Derive it only where TEMP is still the operator's: the
setup args builder, the Windows command plan, and doctor. The runner now
takes the profile it is handed, and commandConfig no longer re-derives
on behalf of whoever happens to call it.

The regression settles the profile first and redirects TEMP afterwards,
in that order, so it reproduces the mismatch against the old behaviour.
2026-09-11 10:17:27 +05:30
Vasanthdev2004 bfa40a79ca fix(sandbox): say what changed when the setup marker is rejected
"permission roots or deny lists changed" told an operator that every
sandboxed command would now refuse to run, and nothing else. Not which
side is stale, not by how much, not even whether the marker belongs to
this workspace. Debugging it meant reading the source and guessing,
which is what happened.

Report the marker path, both entry counts and both hashes. The counts
separate the two shapes this takes: equal counts mean the same roots
spelled differently, unequal counts mean one side has roots the other
has never heard of.
2026-09-11 10:17:27 +05:30
Vasanthdev2004 6e342cee45 fix(sandbox): create the runtime roots setup grants
Elevated setup stopped working entirely:

  zero-windows-sandbox-setup.exe: windows ACL target does not exist:
  ...\AppData\Local\Temp\zero\runtime\v1\67b1b01412f588b9

Putting both runtime candidates into the capability plan added a write
root that nothing creates. The capability plan deliberately refuses to
materialize a write root, since an absent path is a typo or a stale
config and inventing the tree would grant write on a directory nobody
asked for, so the whole run fails on a path that is merely missing. Only
the selected root was ever created, and only on the principal path.

Create every candidate on the setup side, before the plan that grants
them is built. The regression walks the plan and fails on any granted
write root that does not exist, so the two halves cannot drift apart
again: today one function chooses the candidates and another creates
them, and nothing else couples them.

Found by the elevated end-to-end run, which is the only thing that
executes this path. No unit test reached it and CI does not run it.
2026-09-11 10:17:27 +05:30
Vasanthdev2004 03c7783905 test(sandbox): make the jail aliasing assertion able to fail
The aliasing test appended a sentinel to the returned jail and then
ranged over the caller's slice looking for it. That can never fail:
append returns a new header, so the caller's length never grows and the
range never reaches the slot the write landed in. It passed against an
implementation that returns the caller's slice verbatim, which is the
exact thing it claims to rule out. Lint noticed the symptom as an
ineffectual assignment.

Assert through the backing array instead: element storage must not be
shared, and an append must not write into the caller's spare capacity.
Both fail against an aliasing implementation.

Also drop windowsSandboxDeterministicRuntimeRootPath, which lost its
last caller to the shared runtime-candidate helper and duplicated its
derivation.
2026-09-11 10:17:27 +05:30
Vasanthdev2004 9fa86240be fix(sandbox): cover both runtime roots in the setup contract
A fresh setup marker rejected the very command it had just been written
for. Engine.run augments the profile with the selected runtime root
before the Windows runner sees it, but setup fingerprinted the bare
profile, so the extra write root changed the ACL plan hash and every
command on a restricted filesystem failed with "permission roots or
deny lists changed".

The runtime root also reached the principal ACL plan only. A principal
command runs on a WRITE_RESTRICTED token, where a write needs the
normal check and the restricting-SID check to both pass, so a root
carrying just the account ACE was still unwritable once the marker
agreed.

Derive the runtime candidates once and present them on both sides of
the setup protocol. Setup covers the cache-derived root and the
temp-derived fallback rather than whichever one it happened to select,
since selection is per process and a command that fell back would
otherwise land on an unprovisioned tree. Both are pure functions of the
workspace root, so setup can cover the set and a later process can
select from it.

Regressions fail without this: the marker one reproduces the exact
rejection, the capability-plan one shows the missing restricting-SID
grant.
2026-09-11 10:17:27 +05:30
Vasanthdev2004 282a29f610 chore: drop a stray probe artifact from the branch
probe-inside.txt is a leftover from the elevated end-to-end probe and was never
meant to be committed. A `git add -A` in the write-jail commit swept it in.

It is what the automated review's diff-hygiene check was failing on: the file
carries trailing whitespace, so `git diff --check` reported a blocker while
tests, build and smoke all passed.
2026-09-11 10:17:27 +05:30
Vasanthdev2004 602b21fab4 fix(sandbox): derive the fallback runtime root instead of minting one per process
Reported by jatmn. sandboxRuntimeRootFor falls back to a private tree when the
cache-derived runtime root would land inside the workspace, and that fallback
called os.MkdirTemp and cached the answer in a process-global map.

Separate processes therefore got separate answers. Elevated setup granted the
sandbox principal write access to the directory it created, and every later
__windows-command-runner process created a different one and pointed TMP,
GOCACHE, npm and the rest at it. Those directories are made by the calling user
and carry no ACE for the principal, so ordinary cache and temp writes failed
with a bare ACCESS_DENIED and nothing naming the sandbox. sandboxRuntimeRootFor
already documented that both callers must agree exactly; the fallback was the
branch where that could not hold.

It now hashes the workspace under os.TempDir, the same shape as the
cache-derived root, so every process reaches the same path with no shared state.
When even that lands inside the workspace it returns an error rather than
picking somewhere arbitrary: a runtime tree governed by the workspace's own
policy makes the sandbox's cache writes indistinguishable from the work it is
confining.

Two consequences worth naming.

It creates nothing now, so the split between deterministicSandboxRuntimeRoot and
the resolver is no longer about avoiding a side effect. The comments that
justified that split on those grounds were rewritten rather than left describing
behaviour the code no longer has.

Teardown can name the fallback tree for the first time. windowsPrincipalTeardown
Paths used the deterministic resolver precisely because the fallback was random
and unnameable, which meant an opted-out machine kept principal ACEs on whatever
tree the fallback had produced. It now goes through the shared resolver and
revokes what commands actually used.

Tests pin the property the defect turned on: a repeated call, which is what a
second process looks like with no shared state, must return the same root. The
old implementation could not have passed that, since only the in-process map
made repeat calls agree. Also pinned: per-workspace separation, that naming the
tree creates nothing so teardown leaves no directory behind, and that the root
stays outside the workspace.
2026-09-11 10:17:27 +05:30
Vasanthdev2004 8c242fc33e fix(sandbox): stop the principal's own SID from defeating its write jail
Reported by jatmn. The principal path appended the account's own SID to the
restricting-SID list before building a WRITE_RESTRICTED token.

That token allows a write only when BOTH the normal token and the restricting
list allow it. The account SID is already enabled in the normal token, so
listing it as a restricting SID makes the second check a formality for anything
granted to that account: every path carrying a direct principal ACE passes both
halves and is writable wherever it sits. The principal's own profile directory,
which Windows creates on first logon with exactly such an ACE, is outside every
configured write root and was writable for that reason.

It is the same defect as the World SID in the restricted list (#865), with the
account SID in place of Everyone. A SID already carried by the normal token
cannot also restrict it.

The previous reasoning, recorded in the comment this replaces, was that the ACL
plan grants the workspace to the principal SID so removing it would jail the
principal out of its own tree. That is not so, because setup applies
BuildWindowsACLPlan on every path, not only the restricted-token one, so each
configured write root already carries a capability ACE as well as the principal
ACE. Confining to the capability SIDs therefore restores the intersection the
jail is supposed to be: a write root satisfies the normal token through its
principal ACE and the restriction through its capability ACE, while a path
holding only a principal ACE now fails the restricted check.

The account SID is passed to windowsPrincipalJailSIDs and excluded there rather
than simply not passed. Naming it makes the exclusion the function's contract
instead of an omission a later edit could undo silently, and it also strips the
SID should it ever arrive through the capability list, which is the route the
World SID took.

On the test jatmn asked for: the existing jail test grants WinBuiltinGuestsSid,
a GROUP, so it exercised a configuration the product never ships and could not
have caught this. The new test plants the account SID INSIDE the capability list
and asserts it is gone, which is deliberately falsifiable: a list that never
contained it would pass against any implementation, including one that appends
the SID straight back. Verified by mutation, disabling the filter fails it with
the reported symptom. A second test pins that the capability SIDs survive, so
the fix cannot degenerate into jailing the principal out of its workspace.

Still outstanding from the same review and not addressed here: the runtime-root
fallback that hands setup and each command runner a different directory, and the
elevated end-to-end evidence.
2026-09-11 10:17:27 +05:30
Vasanthdev2004 9cb9139b77 fix(sandbox): refuse to adopt a ZeroSandboxUsers group Zero did not create
Reported by anandh8x as P2 #3. ensureWindowsSandboxGroup treated NERR_GroupExists
and ERROR_ALIAS_EXISTS as plain success, so any local group that happened to
carry our name was adopted. Its members, and every grant already keyed to it,
silently became part of the sandbox's identity.

A name is not proof of provenance, which is the same reasoning
windowsSandboxUserIsManaged already applies to an ACCOUNT of our name. The group
half was missing. An unprivileged user cannot create a local group, but an
administrator, an installer or an earlier build can, and the principal would
then inherit whatever it grants.

An existing group is now adopted only when it carries the managed comment, read
back with NetLocalGroupGetInfo. Anything else is refused by name rather than
adopted, renamed around or deleted: removing somebody else's group would be
destructive, and provisioning into it is the hole being closed.

The decision is split from the syscall into resolveWindowsSandboxGroupAdd so it
can be tested without Administrator and without leaving a real local group on
the machine running the suite. Tests cover both "already exists" statuses,
refusal for a foreign group, adoption of our own so re-running setup still
converges, no ownership probe when we just created it ourselves, an unreadable
probe surfacing rather than being guessed either way, and a real API failure
still failing.

Also rebased onto main, 17 commits behind, under the fresh-base rule. All 51
commits replayed with no conflicts.
2026-09-11 10:17:27 +05:30
Vasanthdev2004 8dd74e23d1 fix(sandbox): fail opt-out only when the principal actually survived
Three corrections, the first to my own previous commit.

Making the opt-out retirement failure fatal was too broad.
removeWindowsSandboxPrincipalForSetup deliberately continues past a secret file
owned by another administrator's setup, reporting it at the end through
errors.Join, so that one unlinkable file cannot strand the account, its logon
rights and its ACEs. The account is already gone when that error is returned.
Treating it as fatal made `zero sandbox setup` fail permanently on such a
machine and rolled back its ACL work, taking the DEFAULT restricted-token
sandbox down over inert residue.

The rule is now the narrower one that was always meant: fatal when the ACCOUNT
survived, because that is exactly what makes an opted-out marker a lie. Asked of
the account rather than inferred from the error, since the error is a join whose
parts are not separable at the call site, and the lookup fails closed so an
undeterminable state counts as still installed. A retirement that left something
inert behind still completes, and still names what it left.

Second, assertWindowsSetupRunsAsCaller cannot fire on any path Zero takes, and
said otherwise. Zero never elevates the helper: runSandboxSetupHelper is a plain
exec.Command and runWindowsSandboxSetup refuses unless already elevated, so the
operator supplies elevation by opening an elevated terminal and BOTH halves run
there. The caller SID is resolved from the process that becomes the helper, so
the two are equal by construction. The guard is kept, because it is a
correctness assertion that becomes load-bearing the moment a ShellExecute
"runas" path is added and already covers a helper invoked directly, but its doc
now says plainly what it does not do. The residual hazard has a different shape
and is loud: elevating as another administrator provisions into that account's
sandbox home, and the operator's own session finds no marker and is told to run
setup.

Third, parseSandboxExecArgs claimed to scan for the separator up front. Every
branch of its loop body returned, so it only ever examined index 0 and the scan
did not exist. Rewritten as the straight-line decision it actually was, which is
also the rule we want: only the first token is the wrapper's.

sandbox exec also built its engine without SensitiveEnvKeys, so a key named by
apiKeyEnv in the user's config was scrubbed for every sandboxed tool call and
handed to the one command whose purpose is to reproduce that environment.

Tests: the opt-out branch now covers both sides of the rule, and
parseSandboxExecArgs gets the coverage it never had, including the reported
`-- cmd --help` case and an invariant that the returned command is always a
trailing slice of the input.
2026-09-11 10:17:27 +05:30
Vasanthdev2004 d0a0506fb4 fix(sandbox): keep elevated Windows setup tied to the caller's identity
Two failures on the elevated setup path, both reported by anandh8x.

The first is caller identity. windowsSandboxPrincipalKey sampled the CURRENT
process token, and elevated setup is not the caller's process: under
over-the-shoulder UAC or `runas` it belongs to whichever administrator typed the
credentials. Setup therefore provisioned an account, a secret, an ACL ledger and
a set of grants named after that administrator, while every later command
derived the ordinary user's key, found nothing, and fell back to the restricted
token. The admin-keyed leftovers were unreachable and nothing would ever reclaim
them.

The caller's SID now crosses the UAC boundary in the setup args, exactly as the
principal opt-in already does and for exactly the same reason, and the key is
derived from it. That fixes everything merely NAMED after the invoking user.

It cannot fix the secret. CryptProtectData derives its key from the user that
stores the blob, so a password sealed by another administrator could not be
unsealed by the caller no matter how the account were named. Provisioning is
refused up front in that case, before an account or a grant exists, with a
message that says how to clear it. The refusal is scoped to the principal
opt-in: the default restricted-token sandbox stores no secret and works fine
across that boundary.

The second is the opt-out path. Retiring an existing principal could fail, and
setup printed the error and carried on to write an opted-out marker and exit 0.
Teardown being idempotent was the argument for continuing, but it is the reason
this must fail instead: a machine with nothing to retire passes straight
through, so an error here means a real account, secret, logon right, ACE set and
ledger are still installed, and the marker now claims none of them exist. Setup
returns non-zero and rolls back its ACL work.

runWindowsSandboxSetup had no coverage at all, since every step wants an
elevated token, a machine-global mutex, real DACLs, the WFP engine or a local
account. The external effects are now seams in the idiom the package already
used for two of them, and the tests drive the step order and each failure path,
including the marker never being written.
2026-09-11 10:17:26 +05:30
Vasanthdev2004 47f26f65a1 fix(sandbox): address jatmn's review on the principal backend and sandbox exec
Four of the five findings were real. Taking them in order of what they cost.

Cross-user principal collision (P1). The account name was keyed on the workspace
alone while its DPAPI secret and ACL ledger both live under the invoking user's
sandbox home, so two Windows users sharing one workspace derived the same account
with private bookkeeping. The second user found no ledger of their own and either
retired the first user's working principal or adopted it and rotated its password
while storing only their own secret; either way the first user's marker still
validated and their next command could not log on.

windowsSandboxPrincipalKey now folds the invoking user's SID into the key. The
setup LOCK deliberately keeps the workspace-only key: two users setting up one
shared workspace still write DACLs on the same paths, so they must keep
serializing even though they now provision separate accounts. Splitting the two
keys is the point, and there is a test asserting they differ.

Ledger not restored on rollback (P1). The narrowed ledger is written only after
everything succeeds, but the rollback closure restored the old ACEs and left it
narrowed. A later setup or teardown reads the ledger to decide what to revoke, so
those restored paths would never be revisited: access outside the current policy
held by an account nothing knows to clean up. The closure now writes back the
union recorded before any DACL changed. Over-recording is the safe direction.

sandbox exec skipped the production planning path (P2). It built a SandboxManager
directly, so it never reached prepareSandboxRuntime and ran without the runtime
write root that a real sandboxed command gets. It now goes through
Engine.BuildCommandPlan. This matters beyond tidiness: the command exists to
prove enforcement, and it was proving a path no tool call takes.

Plan resources leaked (P2). No plan.Cleanup(), so every invocation left a
/tmp/zero-sandbox-report-* behind on Linux. Deferred immediately after the build.

The `--` separator (P2). The reported case, `exec -- cmd --help`, already worked:
the loop hits the separator first and returns. The real break was the tolerated
separator-less form, where `exec mycmd --help` printed Zero's help and never ran
mycmd. The separator is now located before any help flag is interpreted, and help
is only read from the wrapper's own arguments.

Two existing ledger tests seeded state under a username derived from the
workspace key and had to move to the principal key. Worth noting for anyone
running this branch: an account provisioned before this commit will not be found
by the new key, and setup will retire it as unrecorded. That is correct behaviour
for an unmerged branch with no released accounts, but it is not a no-op locally.
2026-09-11 10:17:26 +05:30
Vasanthdev2004 229a6acdf6 fix(sandbox): serialize elevated setup with a per-workspace lock
anandh8x's finding #3, the last of the twelve.

Setup is a transaction, not a write. It rotates the principal's password,
stores the secret, read-modify-writes the ACL ledger, installs WFP filters and
writes the marker. Two setups for the same workspace at once interleave those
steps: the account ends up with one process's password while the stored secret
is the other's, so every later command fails to log on, and the ledger's
read-modify-write silently loses a path set so ACEs that should have been
revoked stay on disk with nothing recording them.

Atomic individual writes do not address this. Each step being all-or-nothing is
orthogonal to two processes interleaving BETWEEN steps, which is the actual
failure, so the lock spans from the elevation check through the marker write.

Nothing to port here: the reference implementation has the identical bug. Its
password rotation and secret write hold no lock, its named mutex guards only an
unrelated read-ACL-only mode and is machine-singleton, and the singleflight
that looks like a fix is process-local.

Decisions worth the reviewer's attention.

Global rather than Local namespace, because the protected state is not
session-scoped: the local account, its LSA logon rights and the WFP filters are
machine state, so setups in different logon sessions must still exclude each
other. Per-workspace rather than machine-wide, because every artifact the lock
protects is per-workspace and a global lock would serialize unrelated setups.

The DACL is Administrators and SYSTEM only, protected. A sandbox principal able
to open this object could hold it and stall every future setup, or create it
first and squat the name so real setup believes it holds a lock it does not.

The OS thread is pinned. Win32 mutex ownership is thread-affine, so a goroutine
that migrated between the wait and the release would call ReleaseMutex from a
thread that does not own the object, leaving the mutex held until process exit,
which is a deadlock for every later setup on the machine.

WAIT_ABANDONED is treated as acquired and reported. The previous holder died
inside the transaction, so the machine may be half set up; setup is idempotent
and re-running it is the repair, whereas refusing would leave no way forward.
A bounded wait rather than skip-and-succeed, because skipping here means not
setting the sandbox up at all.

On the tests: the production DACL means an unelevated process cannot open an
existing lock, so the contention test could not reach the mutex wait anywhere
that matters, including CI. The SDDL is therefore a var the wait tests relax
for themselves, and a separate test pins the production value so loosening it
takes an explicit change. Writing the test first is what surfaced this at all.
2026-09-11 10:17:26 +05:30
Vasanthdev2004 05d7c2f62c fix(sandbox): retire the principal on opt-out, and finish teardown past a foreign secret
anandh8x's findings #6 and #8. They are one commit because #6 calls straight
into the path #8 breaks, so fixing #6 alone would have shipped an opt-out that
aborts on exactly the machines #8 describes.

#8 first. readWindowsSandboxSecret already treats permission-denied as
unavailability rather than breakage, and documents why: an operator who
elevated with a separate administrative account ends up with a secret their
ordinary account cannot open. Removal did not agree. os.Remove returning a
permission error aborted teardown at its very first step, so the account, its
logon rights, its ACEs and its ledger all stayed installed because one file
could not be unlinked. The operator asked for removal, got an error, and kept a
working principal.

Removal now classifies that case distinctly and teardown carries on, reporting
the leftover secret at the end rather than swallowing it. An ordinary removal
failure is still fatal, and there is a test for the difference: misclassifying
one as the other would turn a real failure into a shrug.

#6. Opting out has to actually retire the principal, because that is what we
tell people it does. ValidateWindowsSandboxSetupMarker sends an operator here
in as many words. There was no opt-out branch at all, so the marker flipped to
opted-out while everything stayed exactly where it was: the instruction was a
lie, and the leftovers were invisible, because nothing afterwards looks for a
principal it believes was never provisioned.

Setup now runs teardown on the opt-out path. Not fatal if it does not complete:
teardown is idempotent and a machine that never had a principal passes straight
through, so a failure means genuine residue rather than a missing account. Say
so and finish a setup whose sandbox is otherwise fine.
2026-09-11 10:17:26 +05:30
Vasanthdev2004 f96b8b2f38 fix(sandbox): report an inactive principal in doctor, and finish the comment upgrade
anandh8x's findings #7 and #10.

#7. windowsSandboxPrincipalEligible stands the principal down under
network-deny, which is the DEFAULT policy, so an operator who set the opt-in to
confine reads silently gets the same-user restricted token and no read
confinement at all. Nothing told them. The runner cannot: it is re-exec'd per
command, so the notice would land on the stderr of essentially every tool call,
and it is not per-command actionable anyway. The setup marker validates happily
because setup really did provision the account. The runtime comment already
said this belonged on `zero doctor`, and doctor never carried it.

Doctor now warns, and the rule has one definition rather than two. The
eligibility check asks the same exported predicate doctor does, so the two
cannot drift into disagreeing about whether a principal is in play. Drift here
would mean doctor telling someone reads are confined while commands run on the
restricted token, which is worse than saying nothing.

A warning rather than a failure: commands work and the network is still
enforced. What is wrong is the operator's picture of what they have.

#10. windowsSandboxUserIsManaged has always claimed a legacy account is
"adopted and its comment rewritten on the way through", and it never was:
provisioning only ever set the password (USER_INFO_1003 is documented in this
file as the password-only form). So a pre-key account stayed permanently
unattributable, and every later run had to keep accepting the bare comment to
avoid orphaning it.

Adoption now stamps the workspace key via USER_INFO_1007, which ends that and
lets the bare form eventually be retired rather than accepted forever. Best
effort: the account is adopted and working either way, and failing provisioning
over a cosmetic stamp would strand a usable sandbox for bookkeeping. Reported
rather than swallowed, so a run that keeps re-adopting the same legacy account
is visible.

The legacy-comment probe is a separate function rather than folded into
windowsSandboxUserIsManaged, so that keeps its (bool, error) shape and the test
seam around it does not have to change.
2026-09-11 10:17:26 +05:30
Vasanthdev2004 d43362aca7 fix(sandbox): fingerprint principal grants in the setup marker
anandh8x's finding #4. The marker hashed BuildWindowsACLPlan, the
capability-SID plan, while principal grants are built separately by
buildWindowsPrincipalACLPlan from the same profile. So the marker was blind to
them: narrowing or removing a principal read root left setup looking current
and the old AllowRead ACE sitting on disk. Narrowing a policy has to be able
to take access away, and this was the one path where it could not.

The fingerprint hashes the plan with a fixed placeholder trustee rather than
the real principal SID. The account is recreated with a fresh SID whenever it
is reprovisioned, so hashing the real one would move the fingerprint on every
rebuild even when the granted paths were identical, and every command would
then rerun setup. What must invalidate the marker is the set of paths and
actions, which is what this captures.

Empty when the principal backend is not opted into, so the default install's
marker does not churn. Schema version goes 5 to 6 so existing markers are
treated as stale rather than read as having no principal grants.

Validation refuses a mismatch with the action to take, since the remedy is
re-running elevated setup so the stale grants are actually revoked rather than
merely re-planned.
2026-09-11 10:17:26 +05:30
Vasanthdev2004 2ca6ac69d5 feat(sandbox): add zero sandbox exec to run one command through the sandbox
Until now the sandbox could only be exercised through a full agent turn with a
model in the loop. `zero sandbox policy` reports the posture it would take and
`zero sandbox check` evaluates a hypothetical decision, but nothing ran a
command and let anyone look at what happened on disk afterwards.

That is the root of the verification gap on this branch. Enforcement is covered
almost entirely by tests asserting the shape of an ACL plan, and almost not at
all by tests asserting that a write was refused. The two are not the same
thing, and this branch has already produced the proof: the .git rename guard
was emitted correctly by the planner and silently dropped by the applier, and
four tests covering the plan passed while the ACE was absent from the
directory.

It goes through SandboxManager.BuildCommandPlan, the same path a shell tool
takes, so what it demonstrates is what users get rather than what a test double
does. Backend, enforcement level and workspace are written to stderr before the
command runs, and a downgrade is printed explicitly, so a harness can assert
the sandbox was actually engaged instead of passing because it quietly stood
down. Exit status is the command's own, which a harness asserting a refusal
needs.

Everything after `--` is the command, so its flags are never parsed as ours.

Verified by hand on Windows: a write inside the workspace succeeds and the file
exists; the same write to C:\Windows\Temp fails with UnauthorizedAccessException
and the file is not created. Both halves matter, since a command can fail for
an unrelated reason while the write still lands.

This is the prerequisite for a real smoke harness, which is the actual goal.
2026-09-11 10:17:26 +05:30
Vasanthdev2004 a18523ad65 fix(sandbox): name the root that blocks unelevated ACL setup
Found while testing the default sandbox path, not the principal path this PR
is about. One write root that the current user cannot re-DACL fails the entire
unelevated ACL plan, and the success marker is only recorded on success, so
the identical failure repeats on every later command until that root leaves
the plan. The workspace is effectively unusable in the meantime.

Refusing to run is correct and is left alone: without those ACEs there is no
write jail, so continuing would run a command that believes it is sandboxed
and is not. The defect was the diagnosis. Every failure got the same guess,
"the workspace may be on a filesystem the current user does not own", and
recommended elevated setup, which does nothing when the real problem is a
system directory sitting in the root set.

An access denial now names the exact path, says the sandbox cannot enforce a
boundary there, points at TEMP and TMP as the usual way such a path gets in,
and states plainly that elevated setup will NOT help. Other failures keep the
old message.

The extractor reads the path back out of an error string produced two
functions away, which the compiler cannot check, so a test drives the real
producer rather than hand-writing the message: if openWindowsACLTarget rewords
its error the test fails instead of the diagnostic quietly going blank. That
test earned itself immediately by catching the first version splitting on the
first colon and returning "C", since on Windows the path opens with a drive
colon. It splits on colon-space now, which a drive colon can never be.

The helper sits behind the Windows build tag beside its only caller, for the
same reason windowsACLPlanPaths was moved earlier in this branch.
2026-09-11 10:17:26 +05:30
Vasanthdev2004 b5553577fc docs(sandbox): attribute the Users-group premise the volume-root fix relies on
Cross-checking against another Windows sandbox implementation showed it joins
its account to the built-in Users group explicitly, and then verifies at apply
time whether Users already hold read before granting. Ours gets that membership
implicitly from NetUserAdd with USER_PRIV_USER and asserts the consequence in a
comment. Same conclusion, weaker footing, so the comment now says where the
membership comes from and that a hardened image which strips Users read would
need an explicit bounded read set instead.
2026-09-11 10:17:26 +05:30
Vasanthdev2004 c3d041b0af fix(sandbox): stop granting a principal read at the volume root, and report failed ACE revocation
Two of anandh8x's findings on this PR.

The read grant. permissionProfileReadRoots seeds its list with the bare
separator, because the workspace-write posture is read-all with a write jail.
That costs nothing for the capability backend, whose child runs as the caller
and could read those paths anyway. For a principal it became a real,
persistent, inheritable allow-read ACE for a separate local account at the root
of the current drive, reaching every directory that does not block inheritance.
Verified against the shipped profile rather than argued: with the filter
removed the plan emits AllowRead on the drive root.

Dropping it does not remove the reads a principal needs. It is a member of
Users, and the machine's own ACLs already grant Users read on the system and
program directories. What the grant added was read access to the places Users
are deliberately kept out of, which is the opposite of what a sandbox is for.
The volume root is detected structurally, since filepath.Dir of a root is that
same root, so drive-qualified paths, a bare separator and UNC roots are all
covered on either build host.

The teardown. removeWindowsSandboxPrincipalForSetup discarded the result of
revokeWindowsPrincipalACEs, deleted the account, deleted the ledger and
returned success. A failed revocation therefore left ACEs naming that SID on
the user's tree while the only record of which paths they sat on was removed
moments later: residue nothing could find again.

The existing reasoning for not failing hard was right and is kept, since
refusing to remove the account would strand the principal and its logon rights
permanently. So teardown still completes, but the error is now remembered, the
ledger is kept when revocation failed, and the returned error says the ledger
was kept. A record that outlives its principal is a smaller problem than
unfindable residue.

The new read-root test is built from the production profile, and skips loudly
rather than passing silently if that profile ever stops carrying a volume root.
It also asserts the workspace itself stays reachable, so the fix cannot trade a
real grant for a broken sandbox.
2026-09-11 10:17:26 +05:30
Vasanthdev2004 d2968aef28 fix(sandbox): apply the .git rename guard on a workspace that had no .git
Found by gnanam1990 and anandh8x independently, and confirmed on disk before
fixing: the deny-delete ACE that stops a principal replacing .git was silently
absent on exactly the workspace shape that matters most.

.git deliberately carries no Materialize, because creating an empty one breaks
`git init`. Groups are applied in ascending path order, so the .git group ran
while .git did not yet exist, was skipped as a non-materializing missing
target, and nothing revisited it when the .git\config carveout created .git as
its parent chain moments later. windowsACLGroupRequiresExistingTarget did not
catch it either, since it only treats AllowWrite as requiring an existing
target. Net effect: the principal could rename .git aside and recreate it
without the config and hooks carveouts, which is the escape the guard exists
to stop.

Groups skipped for a missing target are now retried once after the pass, since
a later group can create what an earlier one needed. Groups that are genuinely
absent simply skip again. Ordering is otherwise untouched, so the reverse-order
unwind that rollback depends on still holds.

Fixing the gate to demand an existing target for deny-delete would have been
wrong: it turns a fresh clone into a setup failure. Materializing .git is
likewise ruled out by git init.

The four existing guard tests assert the mask and what the planner emits, and
all four pass with the guard absent from disk, which is why a green suite said
nothing. The new tests apply the plan and read the actual DACL back through
SDDL. Verified the fresh-workspace case fails without this change and passes
with it, while the already-has-.git case passes either way, so the new coverage
is pinned to the defect rather than to the code.

Also moves windowsACLPlanPaths behind the Windows build tag. Every caller is
Windows-tagged, so defining it in the portable file made it dead code on Linux
and macOS and failed make lint-static, as anandh8x reported.
2026-09-11 10:17:25 +05:30
Vasanthdev2004 6a1b4562d0 fix(sandbox): reject protected metadata names that escape the write root
ProtectedMetadataNames is documented as names, but it is joined onto the write
root to place a deny ACE and to materialize the directory that ACE protects. A
value containing ".." or a separator puts both outside the workspace: the deny
lands on a directory the sandbox does not own, and elevated setup creates it
there. Raised by CodeRabbit on this PR.

Not reachable today, since the only caller passes a package constant. It is
guarded so that stays true if a future caller sources these from config, which
is the kind of change that would not obviously be a security decision.

The component check moves to the portable file so both users share it. The
handle-relative primitives need it because NtCreateFile resolves a relative
name containing separators the ordinary way, following any junction inside it;
the plan builder needs it for the escape above. Separators are matched
explicitly rather than through filepath.Base, because these are Windows paths
whatever the build host is, and on Linux filepath.Base leaves a
backslash-joined name untouched and would wave it through.

Rejection tests are in the portable test file, so they run on all three
platforms alongside the rest of the plan-builder coverage.
2026-09-11 10:17:25 +05:30
Vasanthdev2004 011c4dad15 fix(sandbox): bind windows ACL materialization and rollback to handles
Closes the two path-swap findings on this PR. Both had the same cause: an
object was addressed by a pathname that the kernel re-resolves at the moment
the call runs, so a workspace owner could change what that name meant in the
gap between checking it and using it. Junctions need no privilege, so this was
reachable by exactly the unprivileged user the sandbox exists to contain.

Materialization verified a component, closed the handle, then handed the same
string to os.Mkdir. Swapping the verified ancestor for a junction inside that
gap got the component created outside the approved tree, as Administrator, and
verifying again afterwards cannot un-create it. It now opens the deepest
existing ancestor once and creates each missing component relative to the
handle of the level above, so the tree it descends is pinned to objects rather
than named by strings.

Rollback called os.RemoveAll on the target pathname, so an ancestor swapped to
a junction after creation sent a recursive elevated delete into an unrelated
tree. It now records what it created as single components under an anchor and
unwinds them handle-relative, deepest first. It also removes the whole chain
rather than only the final component, which the old comment claimed was fixed
and was not, and it removes only what the kernel confirmed this run created,
so a racing creator no longer gets its directory deleted on teardown.

Three things this turned up that were not in the original findings:

The delete primitive used FILE_DELETE_ON_CLOSE, which defers the removal to
cleanup and reports a non-empty directory to nobody: open succeeded, close
succeeded, directory still there. Wired into rollback that would have been
worse than the os.RemoveAll it replaced, since rollback would report success
while leaving state on disk. It now sets the disposition explicitly, so
STATUS_DIRECTORY_NOT_EMPTY comes back to the caller. Residue is preferable to
recursing through a path someone else controls; lying about it is not.

The child create never inspected the handle it returned, so an existing
junction was handed back as the next parent in the walk and every deeper
create landed on the far side of it. Component names are now validated as
single components too: NtCreateFile resolves a relative name containing
separators the ordinary way, which would have walked straight through an
intermediate junction.

Rollback re-opens the anchor by pathname and checks its volume and file index
against what materialization saw, because replacing a directory with another
real directory of the same name needs no reparse point at all and passes every
no-follow check there is. Holding the handle instead would be stronger, but
three call sites deliberately discard the rollback closure and would leak.

Testing. makeWindowsACLDirChainNoFollow carries a seam that fires between
verifying the anchor and creating anything, because a race nobody can trigger
on demand is not a regression test. The existing junction test plants its
junction before the walk starts and so never reached this. The new test has a
control arm that performs the identical swap and creates by pathname, and
asserts the object does escape, so the fixed arm proves the hole was open
rather than proving some code ran. Also covers the file target, rollback
through the closure callers actually hold, and the non-empty case that the
old empty-directory-only delete test hid.

Validation now runs before the filesystem is touched, so a malformed entry no
longer creates a chain and then fails.
2026-09-11 10:17:25 +05:30
Vasanthdev2004 c2cf7b8f3a fix(sandbox): keep the git rename guard tests portable
The two rename-guard tests built their expectations from a hardcoded
C:\work\repo, but buildWindowsPrincipalACLPlan normalizes every write
root before it names an ACE. On Windows that root is already absolute,
so normalizing is a no-op and both tests passed locally. On Linux and
macOS it is not absolute, so the plan named a different path and both
tests failed. The plan builder is portable code with no build tag, so
these tests run on every platform.

Use an OS-neutral root and normalize the expected path the same way the
plan does, which is what the other untagged test in this package
already does. The Windows-only assertions about the ACE mask and its
inheritance stay where they are, in the file that is tagged for it.
2026-09-11 10:17:25 +05:30
Vasanthdev2004 2d5e1f7885 feat(sandbox): handle-relative directory create and delete on Windows
Groundwork for the two remaining materialization P1s. No call sites yet;
the walk and the rollback move onto these next.

Every pathname-based call re-resolves the whole path inside the kernel
when it runs, so verifying a component and then creating through it are
two separate resolutions of the same string. A workspace owner can swap
an ancestor for a junction in that gap. Demonstrated, not theorised: with
the same swap performed between verify and create, os.Mkdir put the new
directory OUTSIDE the approved tree ("landed inside approved tree: false,
ESCAPED outside approved tree: true"), and the post-create check cannot
un-create it.

A handle pins the object rather than the name, so a create resolved
against it cannot be redirected however the path is later rearranged.
os.Mkdir, os.OpenFile and os.RemoveAll are pathname-based by construction
with no relative form on Windows, hence NtCreateFile with
OBJECT_ATTRIBUTES.RootDirectory. x/sys/windows already exposes every
piece, so this adds no hand-rolled syscall bindings.

createWindowsACLChildDirectory reports whether it created or opened,
because rollback must delete only what it made; removing a directory that
already existed would destroy a user's data over an unrelated failure.
deleteWindowsACLChildDirectory is the counterpart that rollback needs:
os.RemoveAll on a pathname whose ancestor has since become a junction
recurses outside the workspace.

The fix also makes the race testable. Against the old code the swap had
to be threaded into the middle of a function; here it is three ordinary
lines between an open and a create, because the handle is held across
them. Both tests perform the real swap and assert the operation stayed
inside the verified directory, and the delete test leaves a bystander
under the decoy whose survival proves the delete never resolved by path.

Refs #808.
2026-09-11 10:17:25 +05:30
Vasanthdev2004 bcae716a07 fix(sandbox): stop a principal replacing .git to shed its carveouts
The write-denied carveouts guarding .git are attached to .git/config and
.git/hooks as OBJECTS. The workspace allow grant is inheritable and
carries DELETE, and nothing denied DELETE on .git itself, so a principal
could rename .git aside, recreate it, and create fresh config and hooks
that inherit the allow with no deny of their own. That restores
credential.helper and core.hooksPath, and with them arbitrary code
execution on the next git command.

.git could not simply join sandboxFullyProtectedMetadataNames next to
.zero and .agents: that list emits DenyWrite, whose mask includes
FILE_GENERIC_WRITE, and git has to write index, objects and refs. Its
absence from that list was correct and was also the hole.

Add WindowsACLDenyDelete: DELETE, WRITE_DAC and WRITE_OWNER only.
Renaming a directory needs DELETE on that directory, so denying it is
what closes the replacement. WRITE_DAC and WRITE_OWNER come along because
a guard the principal can rewrite is not a guard. FILE_GENERIC_WRITE and
FILE_DELETE_CHILD stay out so git keeps working.

The ACE is applied uninherited, which is why the narrow mask is safe:
inherited onto .git's children it would deny DELETE on every file inside
and git could not remove a lock file or a ref. windowsExplicitAccessEntries
hardcoded SUB_CONTAINERS_AND_OBJECTS_INHERIT for every directory entry,
so inheritance is now decided per action.

The entry is not materialized. git creates .git, and an empty one made by
setup breaks git init.

Reported by @jatmn on #808.
2026-09-11 10:17:25 +05:30
Vasanthdev2004 7f45a7cd54 test(sandbox): compare ACL record paths the way the plans do
The two new record tests failed on the Windows runner and passed
everywhere else. The plan builder runs every root through
normalizeProfilePath, whose EvalSymlinks expands the 8.3 short name
GitHub's runners hand out for TEMP, so the record held
C:\Users\runneradmin\... while t.TempDir() had returned
C:\Users\RUNNER~1\... and an EqualFold on the raw spellings called two
spellings of one directory different paths.

Production was never affected: the recorded paths and the newly planned
paths both go through that same normalization, so setup and teardown agree
with each other. This was only the test being naive about what "same path"
means on Windows, and a developer whose volume has 8.3 name generation
disabled cannot reproduce it — which is how it shipped.

The comparison now normalizes both sides through normalizeProfilePath and
keys them with windowsCapabilityPathKey, which is what the ACL plans
themselves use. Re-checked against the mutation that matters: reverting the
revocation to the new plan's paths alone still fails
TestReSetupRevokesARootTheNarrowedPolicyDropped, so the looser-looking
comparison has not defanged the assertion.
2026-09-11 10:17:25 +05:30
Vasanthdev2004 cae96b2830 fix(sandbox): revoke principal ACEs on roots that left the policy
applyWindowsPrincipalACLs revoked the trustee only from the paths of the
plan it was about to apply. A root that LEFT the policy is by definition
absent from that plan, so its ACE survived the re-setup marker validation
forces and the principal kept write access the current policy no longer
grants -- the sandbox widened as a result of being tightened. Teardown
repeated the same current-plan-only calculation, so retiring the principal
cleaned every path except that one, and then deleted the account, leaving
the ACE naming a SID nothing could resolve.

Nothing on Windows can answer "which paths hold an ACE for this SID"
without walking every volume, so the grants are now written down as they
are made: a per-principal record beside the secret, keyed the same way
because one sandbox home serves every workspace on the machine. Setup
revokes over the union of the recorded paths and the new plan's; teardown
revokes over the same union.

The record is written as that union BEFORE any DACL changes and narrowed
to the granted set after, so a crash in between leaves a superset rather
than a record missing paths the run granted. A superset is the safe
direction: revoking a path that holds no ACE for the trustee is a no-op.

The interesting case is a principal from an earlier setup with no record.
Proceeding with an empty prior set would be the fail-open, on the one path
where the prior set is not empty but unenumerable. Setup retires that
account before provisioning instead: Windows never reuses a deleted local
account's RID, so whatever ACEs cannot be found end up naming a principal
that no longer exists, and the SID minted next is one no DACL on the
machine can already carry. It needs no new operator action, which matters
-- there is no `zero sandbox teardown` to send anyone to.

Every guard is mutation-checked. Reverting the union to the new plan's
paths fails the end-to-end test against real DACLs with "the principal
kept its grant on a root the narrowed policy removed"; moving the record
after the grant, dropping the retirement, reverting teardown, and trusting
an unknown schema each turn a different test red.

Two existing tests move to the new applyWindowsPrincipalACLs signature.
2026-09-11 10:17:25 +05:30
Vasanthdev2004 f18e645c8b fix(sandbox): carry the principal opt-in through the setup protocol
Elevated setup and the commands that later use a principal each read
ZERO_WINDOWS_SANDBOX_IDENTITY from their own process environment, and setup runs
in a separate, UAC-elevated process whose environment is not the caller's.
commandConfig() returned a nil Env, so the gate in windows_setup_windows.go fell
through to os.Getenv in the elevated helper. The two halves could disagree and
nothing detected it.

Direction B is the dangerous one: command opted in, setup did not. Marker
validation passed (the marker recorded nothing about the opt-in), the principal
lookup then declined with a nil error, and the command ran on the same-user
restricted token — which by its own comment does not confine reads — while the
operator believed an account boundary was isolating it. No warning fired.
Direction A left an orphaned account holding batch-logon rights and workspace
ACEs that teardown never retires, because teardown sits inside the same opt-in
branch.

The opt-in is now resolved in the shell the user typed `zero sandbox setup` into
and serialized across the UAC boundary as --sandbox-principal 0|1. The marker
records it (schema 4 -> 5) and validation refuses on mismatch, before the ACL and
network checks. Both directions refuse rather than fall back silently: an
unreadable value is rejected outright, since guessing "off" would provision a
weaker sandbox than asked for and report success.

PrincipalOptIn is a *bool, not a bool. Unset means "consult the environment"
rather than "opted out", so the existing smoke-test callers that do not set it
keep working instead of serializing --sandbox-principal 0 while the command half
still consults os.Getenv.

Dropped the deny-mode fallback warning added alongside this. Announcing it looked
right, since deny is the default mode and an opted-in operator therefore never
gets a principal for ordinary commands. But this runner is re-exec'd per command,
so its sync.Once is once per COMMAND: the notice would print on nearly every tool
call, and noise that repeats gets filtered rather than acted on. It is also not
actionable per command. `zero doctor` carries the opt-in now and is the right
surface for a standing configuration fact. The deny-mode behaviour stays pinned
by TestPrincipalBackendDefersToRestrictedTokenWhenNetworkDenied.

Reported by jatmn on #808.
2026-09-11 10:17:25 +05:30
Vasanthdev2004 949ee3d761 fix(sandbox): keep the principal inside the Windows write jail
The principal branch handed the raw LogonUser token to CreateProcessAsUser. That
token is a full token for the account, so the sandboxed child kept every write
its ambient memberships grant. The ACL plan can add grants and denies at named
paths, but it cannot revoke what BUILTIN\Users, Authenticated Users or
NT AUTHORITY\BATCH already allow elsewhere — so an opted-in command whose
profile permitted writes only to the workspace and runtime roots could still
write C:\Users\Public\Documents, which grants BATCH modify and which a batch
logon therefore satisfies.

The principal now gets its own identity AND the restricted token, not one or the
other: reads stay confined by its ACEs, writes by the restricted-SID check.

The principal's own SID joins the capability SIDs deliberately.
applyWindowsPrincipalACLs grants the workspace to identity.SID rather than to a
capability SID, so omitting it would leave the workspace grant matching nothing
in the restricted list — a jail that locks out the inmate and no one else. The
SID is read back from the token itself rather than threaded through the call, so
it cannot drift from the identity actually running.

Not fixed here, and separate from this finding: worldSID is unconditionally in
the restricted-SID list, so any path whose DACL grants Everyone still satisfies
the restricted check on both this path and the pre-existing fallback. That
predates the principal work and is raised with the maintainers separately.

Reported by jatmn on #808.
2026-09-11 10:17:24 +05:30
Vasanthdev2004 97c8c98068 fix(sandbox): reject reparse ancestors before creating an ACL target
FILE_FLAG_OPEN_REPARSE_POINT only stops the FINAL path component being followed.
materializeWindowsACLTarget built its target with os.MkdirAll and os.OpenFile on
the pathname, and both resolve ancestors — so the ancestor reparse check on the
subsequent no-follow re-open ran only after the objects already existed.

An ordinary workspace owner needs no privilege to create a junction. Turning
.git into one before elevated setup runs, with config absent, had setup create
the target at a location of the attacker's choosing as Administrator. Rejecting
it afterwards does not undo that, and the failure path removes only the final
component, so every intermediate directory MkdirAll created outside the
workspace survived permanently.

Creation now goes through makeWindowsACLDirChainNoFollow, which walks up to the
deepest existing ancestor and verifies it no-follow first. One check suffices for
the whole chain above it because GetFinalPathNameByHandle answers for the entire
resolved path. Missing components are then created one at a time, each
re-verified immediately after creation, so a component swapped for a junction
mid-walk is caught before anything lands underneath it.

Taken over the relative-handle NtCreateFile route because x/sys/windows offers no
ergonomic relative-create primitive, and this leaves a window of one component
with an immediate post-create check rather than create-everything-then-verify.

Reported by jatmn on #808.
2026-09-11 10:17:24 +05:30
Vasanthdev2004 7070993ef9 fix(sandbox): surface a failed stale-secret cleanup after rotation
The provisioning rollback removes the stored secret when this run created the
account or rotated an adopted one's password, because in both cases what is on
disk cannot authenticate and absent beats stale: the command path treats a
missing secret as "not provisioned" and falls back, while a stale one fails the
logon and reports a broken sandbox.

That removal ignored its own error. When it failed, the invariant it exists to
keep was not restored — a credential for a password that no longer works stayed
on disk — and setup said nothing. The operator met a provisioned-but-unusable
principal on the next command instead of hearing it from the run that broke it.

undo now returns that one error and the five failure paths join it onto the
error they were already returning, so the original cause and the cleanup failure
both surface. The message names the file and what to do about it. The other undo
steps still swallow: they leave residue, while this one leaves a credential.

Reported by jatmn on #808.
2026-09-11 10:17:24 +05:30
Vasanthdev2004andClaude Opus 5 d685d83df6 fix(sandbox): stop teardown creating a directory while naming one
windowsPrincipalTeardownPaths said the runtime root was "resolved without
creating it, since teardown has no business making directories on its way
out". That was my comment and it was false: it went through
sandboxRuntimeRootFor, whose fallback calls os.MkdirTemp when the
cache-derived root would land inside the workspace. So cleanup created a
fresh temp directory, and a useless one — the fallback root is random per
process and could never match the tree the commands actually used.

sandboxRuntimeRootFor is split: deterministicSandboxRuntimeRoot computes the
cache-derived path and says whether it is usable, creating nothing, and the
existing resolver keeps the fallback on top of it. Teardown takes the pure
one and simply has no runtime tree to revoke when it reports unusable, which
is correct — there is no way to name the random root from here anyway.

The first version of the test called the pure resolver directly. It passed,
and reverting the call site to the creating one left it passing. It now
drives windowsPrincipalTeardownPaths and counts temp-directory entries
across the call, and that mutation fails it.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-11 10:17:24 +05:30
Vasanthdev2004andClaude Opus 5 7e996fa4fd fix(sandbox): close the delete-through-parent, junction-ancestor and rollback gaps
Three findings from jatmn's review, all reachable only under the opt-in
principal backend but all real.

FILE_DELETE_CHILD is no longer granted. On a parent it authorises deleting a
child whatever the child's own DACL says, so granting it on a write root
handed back the carve-outs underneath: delete .git/config, recreate it, and
the replacement inherits the grant with no deny of its own — restoring the
credential.helper and core.hooksPath control the carve-out exists to
prevent. It was granted to keep the mask symmetric with the deny mask, which
is the wrong instinct: denying a capability is not a reason to grant it. The
comment two lines up already made that argument for WRITE_DAC and
WRITE_OWNER. DELETE alone still covers removing and renaming files inside
the roots, which is what the grant is actually for — verified before
removing it.

Note this does NOT close the second route jatmn described: .git itself
carries no ACE, so renaming the whole directory aside needs only DELETE.
That needs a guard on .git and is not in this commit.

ACL targets are now rejected when a PARENT is a reparse point. CreateFile
resolves ancestors even with FILE_FLAG_OPEN_REPARSE_POINT, so the
final-component check passed while elevated setup rewrote the DACL of an
object outside the workspace. Junctions need no privilege to create, unlike
symlinks, so this was reachable by exactly the unprivileged user the sandbox
contains. GetFinalPathNameByHandle answers where the handle really landed,
covering every component in one call instead of walking the path and racing
between checks. The comparison is against the path's own resolved form, so a
differently-cased or 8.3 spelling is still accepted.

The revocation's rollback is returned instead of discarded. Discarding it
was justified on the grounds that the only failure path removes the
principal outright — true for a principal this run CREATED, false for one it
ADOPTED, which #812 keeps alive on failure rather than destroying someone
else's working account. The account survived with its previous ACEs stripped
and the new ones rolled back: logged on, and unable to reach its own
workspace. Teardown still discards it deliberately, since putting ACEs back
on an account about to be deleted is the opposite of the point.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-11 10:17:24 +05:30
Vasanthdev2004andClaude Opus 5 6b54ac0814 test(sandbox): assert the ancestor walk, not the old all-or-nothing contract
TestCanonicalWorkspaceRootFallsBackWhenResolutionFails asserted that a path
with missing segments came back as the plain cleaned path. That was the
behaviour before the ancestor walk, and Windows CI failed it correctly:

    canonical("C:\Users\RUNNER~1\...\001\never-created\deeper")
      = "C:\Users\runneradmin\...\001\never-created\deeper", want the cleaned path

The existing ancestor resolved and the missing remainder was re-appended,
which is precisely what the walk exists to do. The assertion now says that:
the result equals the canonical parent joined with the segments that do not
exist, and those segments survive rather than collapsing to the ancestor.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-11 10:17:24 +05:30
Vasanthdev2004andClaude Opus 5 8d42270e30 fix(sandbox): resolve through path segments that do not exist yet
The previous fix normalized both the workspace root and the cache root, and
macOS CI still failed the same way. EvalSymlinks fails outright when the
LEAF does not exist, and a cache root has not been created at the point it
is first normalized — so the workspace resolved (/var to /private/var)
while the cache root did not, and the containment check that decides
whether the runtime tree must move out of the workspace compared the two
anyway.

canonicalSandboxWorkspaceRoot now resolves the longest existing ancestor
and re-appends the remainder, so a path normalizes the same way whether or
not its final segments exist:

    /var/.../001/.cache        leaf missing, walk up
    /var/.../001               resolves
    /private/var/.../001/.cache

Terminates at the filesystem root, where it falls back to the cleaned
absolute path, and a path with no symlink anywhere along it is unchanged.

The regression test needs a symlink to produce a spelling only resolution
reconciles, so it skips on Windows — where creating one needs privilege —
and runs on the platforms that caught this. I could not reproduce either CI
spelling locally: this box's temp paths are already canonical and 8.3 alias
creation is disabled on the volume.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-11 10:17:24 +05:30
Vasanthdev2004andClaude Opus 5 0b5950e882 fix(sandbox): normalize the cache root too, not just the workspace root
sandboxRuntimeRootFor compares the workspace root against the runtime root
it derives from the cache root, and falls back to a private temp tree when
the derived root would land inside the workspace. The previous commit
canonicalized only the workspace root, so that comparison ran on two
different spellings of the same path and the containment check missed:

    macOS:   /var/folders/... vs /private/var/folders/...
    Windows: C:\Users\RUNNER~1\... vs C:\Users\runneradmin\...

The fallback never fired and the runtime tree was placed inside the
workspace it exists to stay out of. Both CI runners caught it; my box did
not, because its temp paths are already canonical and 8.3 alias creation is
disabled on the volume, so I could not reproduce either spelling locally.

Both inputs now go through canonicalSandboxWorkspaceRoot, on the
cross-platform path and the Windows setup path.

The regression test uses a symlink, which is the portable way to produce a
spelling only resolution reconciles — Clean cannot see through one. It
skips on Windows, where creating one needs privilege, and runs on the
platforms that caught the bug.

Two things about that test are deliberate. My first version used a
redundant-segment path, which Clean already normalizes, so reverting the
fix left it passing. My second resolved nothing before asserting, and
through the link the runtime root shares no textual prefix with the
workspace — it would have called a root sitting physically inside the
workspace "outside" and passed against the exact bug it exists for. It now
resolves before comparing.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-11 10:17:24 +05:30
Vasanthdev2004andClaude Opus 5 c6921d4145 fix(sandbox): canonicalize the workspace root on both sides, not just setup
Windows CI caught two faults in the previous commit. Both came from
canonicalizing one side of a pair.

setupWindowsSandboxRuntimeRoot resolved the workspace root while
prepareSandboxRuntime still only cleaned it, so the two disagreed exactly
where they had to agree. It passed locally because my temp paths were
already canonical; a Windows runner's TEMP is an 8.3 short path that
resolution expands:

    setup granted ...\runtime\v1\5e2d212300ccdfba
    commands use  ...\runtime\v1\92c31f8cf536dfde

The canonicalization moves to canonicalSandboxWorkspaceRoot in
runtime_state.go and both sides call it, which is what the original fix
should have done.

The carveout shape was rebuilt from the RESOLVED write root and compared
against subpaths that cannot resolve, since .git/config does not exist at
setup and normalizeProfilePath falls back to Clean when EvalSymlinks fails.
Two spellings of the same path therefore missed the lookup and .git/config
went back to being created as a directory — the original bug, reintroduced
quietly by its own fix. gitMetadataCarveoutIsFile now matches on the
trailing segments, derived from the spec list so it cannot drift from it,
and no reconstructed absolute path is compared at all.

Both failures now have regression tests that reproduce the non-canonical
root by lowercasing, which needs no short name and no privilege. Reverting
either fix fails them.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-11 10:17:24 +05:30
Vasanthdev2004andClaude Opus 5 227f4ed6ef fix(sandbox): revoke ACEs on teardown and key setup off the resolved root
Two ways setup and the command path disagreed.

Teardown removed the secret, the LSA rights and the account, but never the
ACEs. Once the account is gone its SID stops resolving and every ACE naming
it becomes an orphaned raw-SID entry on the user's own tree — precisely the
residue the capability-SID model left behind and this one exists to avoid.
Revocation now runs while the SID still resolves, by trustee so it also
clears grants written by older versions. A revoke failure is deliberately
not fatal: a path the user has since deleted cannot be cleaned, and
refusing to remove the account over it would strand the principal and its
logon rights permanently, which is worse than a leftover ACE.

The runtime root was derived from filepath.Clean(WorkspaceRoots[0]) at
setup while Engine.resolveCommandDir cleans, absolutizes and then
EvalSymlinks it. That needs no symlink to diverge — Windows opens a path in
any casing and EvalSymlinks canonicalizes it:

    setup sees   c:\users\me\myworkspace
    command sees C:\Users\me\MyWorkspace

so setup granted the principal one runtime tree and every command used
another. The grant that exists to make npm/go/pip caches writable landed
where nothing reads, surfacing as a bare ACCESS_DENIED on a cache write.
Both now go through canonicalWindowsSandboxWorkspaceRoot. An unresolvable
root falls back to the cleaned absolute path, matching the command path
rather than failing.

setupWindowsSandboxRuntimeRoot is split into derivation and creation so
teardown can name the tree without making directories on its way out.

The first version of the divergence test called the canonicalization helper
directly. It passed, and reverting setup to filepath.Clean — the actual bug
— left it passing. It now drives windowsSandboxRuntimeRootPath, and that
mutation fails it.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-11 10:17:24 +05:30
Vasanthdev2004andClaude Opus 5 8a84471dc9 fix(sandbox): revoke stale principal ACEs before re-applying the plan
windowsPrincipalRevokePlan was implemented and tested but had no production
caller, so nothing ever used it. applyWindowsACLPlan merges into the
existing DACL, which means a re-run after narrowing a write root or
shortening a deny list left the previous, wider ACEs sitting beside the new
ones: the principal kept access the current policy no longer granted, and
the sandbox silently widened as a result of being tightened.

Setup does get the chance to notice — marker validation already refuses
commands with "permission roots or deny lists changed" until setup runs
again — so the re-apply is exactly where this belongs.

The ACL step is extracted into applyWindowsPrincipalACLs: build the plan,
revoke every ACE naming this trustee on the paths it touches, then apply.
Revocation is by trustee rather than by remembered path, so it also clears
grants written by an older version of Zero. Its rollback is discarded on
purpose — the only failure path from here removes the principal outright,
and restoring stale ACEs for an account about to be deleted is the residue
this exists to prevent.

Extracting it also makes the ordering testable without new provisioning
seams, which #812 already adds with a different signature; adding them here
would have collided on its rebase.

Three tests: revocation actually drops a grant on a root that left the
policy while keeping the one that stayed (asserted against the real DACL,
counting deny ACEs as well as allow, since trustee revocation drops both);
revoking a path that was never created is a no-op rather than an error; and
the production path revokes BEFORE it applies. That last one is the one
that matters — the first two pass just as happily with the call site
deleted, and deleting it kills only the third.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-11 10:17:24 +05:30
Vasanthdev2004andClaude Opus 5 6ff26014d5 fix(sandbox): re-check principal privilege when minting a command token
Provisioning refuses an account that is already in Administrators, Power
Users or Backup Operators, but group membership is not frozen at setup. An
account provisioned clean can be added afterwards — by an operator, or by
an attacker who already has that access and would like the sandbox to hand
it back. Every command after that minted a token for a privileged account.

The re-check goes on the path that mints the token, not inside
lookupWindowsSandboxIdentity. Teardown resolves the same identity to revoke
its logon rights before deleting the account, so refusing there would leave
the very account this guards against permanently undeletable by Zero. The
command path already propagates anything that is not the not-provisioned
sentinel, so this surfaces to the operator instead of silently dropping
back to the restricted token.

Also make the gating test hermetic. Its "absent" case passed an empty map,
which falls through to os.Getenv, so a developer with the opt-in exported
saw a different result from CI:

    ZERO_WINDOWS_SANDBOX_IDENTITY=1 go test ./internal/sandbox/
    --- FAIL: TestWindowsSandboxIdentityGating/absent
        enabled = true, want false for ""

Every case there supplies an explicit map entry, so the process variable is
now pinned to prove none of them consult it. The os.Getenv fallback is what
elevated setup actually runs on — it passes no Env — so it gets its own
table rather than riding on a case that also has a map entry.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-11 10:17:23 +05:30
Vasanthdev2004andClaude Opus 5 700e749e30 fix(sandbox): materialize .git/config as a file, not a directory
Materializing the git control-plane carveouts creates a missing target so
the deny-write ACE is in place before git first runs. It did that with
os.MkdirAll on the full path, which is right for .git/hooks and wrong for
.git/config: git wants a file there.

The consequence is worse than a mis-ACL'd path. On a fresh workspace
neither carveout exists — which is exactly the case materialization was
added for, so this is the common path rather than a corner — and elevated
setup would leave a directory where git's config file belongs:

    warning: unable to access '<ws>/.git/config': Permission denied
    fatal: unknown error occurred while reading the configuration files

git init then fails outright and the workspace is unusable.

Materialization now takes the shape from the carveout definition:
gitMetadataWriteCarveoutSpecs is the single source of truth and
gitMetadataWriteCarveouts derives its list from it, so a carveout cannot be
added in one place and have its shape forgotten in the other. A file target
gets its parent chain created and then an empty file; a directory target is
unchanged. A racing creator winning the O_EXCL is treated as success, since
the target existing is all materialization needed.

The regression test runs a real `git init` over the applied plan. It names
Guests as the principal rather than Everyone — with Everyone the deny ACE
also denies the test process and git fails for an unrelated reason, which
would have made the test pass for the wrong reason once the shape was fixed.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-11 10:17:23 +05:30
Vasanthdev2004 73fc1c07ae fix(sandbox): refuse to adopt a principal in a privileged group
Adoption takes over an account whose name and ownership comment match,
resets its password and hands it to the sandbox. An account that is also
in Administrators, Power Users or Backup Operators would give the sandbox
the rights it exists to withhold: rewriting the ACLs confining it, reading
the secret locked to the invoking user, and stopping Zero. The name is
derived rather than discovered, so an account can match without anyone
intending it to.

Membership is resolved by well-known SID rather than by group name, so a
localised install where the group is Administratoren or Administrateurs is
still recognised.

Raised as a non-blocking follow-up in review; it is cheap enough to do now
rather than track.
2026-09-11 10:17:23 +05:30
Vasanthdev2004 f0bc1a3821 fix(sandbox): grant the principal the runtime tree commands write to
Four review findings on the elevated setup path.

The principal had no access to the sandbox runtime root.
permissionProfileWithRuntime appends that root to WriteRoots on every
command and redirects HOME, GOCACHE, npm_config_cache and similar into it,
but it lives under the user cache rather than the workspace, so the
profile setup builds its ACL plan from never contains it. On the
restricted-token path that costs nothing, since the child still runs as
the caller. A principal is a separate local account with none of those
rights, so every npm install, go build or pip install would have failed on
a cache write with a bare ACCESS_DENIED and nothing naming the sandbox as
the cause. Setup now resolves the same root and grants it.

The derivation is extracted so both callers share it. If setup and
prepareSandboxRuntime ever disagreed, the ACE would land on one directory
while commands used another, which is the same failure with a harder
diagnosis, so a test asserts the two agree.

The git control-plane carveouts are materialized. .git/config and
.git/hooks arrive as ReadOnlySubpaths, and applyWindowsACLPlan skips an
absent target, so on a workspace where git had not run yet the deny ACEs
were never written and the principal kept inherited write access once git
created them.

Command-time lookup verifies workspace ownership. The account name carries
only 11 characters of the workspace digest; the comment carries all of it.
Provisioning already refused a foreign account, but the command path
resolved the name straight to a SID, so the workspace that lost a
collision would have run as the other one's principal. SID resolution
still runs first, so an absent account stays the unavailable sentinel
rather than becoming a collision error.

The gated round-trip test asserted a logon with the password from a second
provisioning call. Rotation moved to the setup path, so that value is a
fresh string the account never held. It now exercises the guarantee the
setup path actually makes: the stored secret logs the principal on.
2026-09-11 10:17:23 +05:30
Vasanthdev2004 2175366d4e fix(sandbox): keep an adopted principal's logon rights on rollback
Second round of review findings, both on the elevated setup path.

The rollback revoked logon rights whenever they had been attempted,
without regard to whether this run created the account.
revokeWindowsSandboxLogonRights passes AllRights, which drops every right
the account holds and deletes its LSA object outright. On an adopted
principal that is not a rollback but destruction: a transient grant,
secret-path or secret-write failure during a re-run stripped the
SeBatchLogonRight and deny-logon rights an earlier setup had established,
leaving exactly the broken-but-present principal this path exists to
avoid. Revocation is now scoped to accounts this run created. The rights
granted to an adopted account are the ones it is supposed to hold, so
leaving them is the safe direction.

A secret the current user cannot read now falls back instead of failing
the command. The secret's DACL names whoever ran setup, so an operator who
elevated with a separate administrative account, through runas or an
over-the-shoulder UAC prompt, leaves a secret their ordinary account
cannot open. That is the documented fail-soft case, and treating it as a
hard error made every sandboxed command fail on a machine that was merely
set up by a different admin. Permission errors from the removal path are
deliberately still reported, since incomplete teardown is worth knowing
about.

Both are covered by injected-failure tests and fail if the guard is
removed. The secret read is seamed to inject the permission error, because
producing a real ERROR_ACCESS_DENIED needs DACL surgery and would test the
platform rather than the mapping.
2026-09-11 10:17:23 +05:30
Vasanthdev2004 fd27716dfc fix(sandbox): stop setup destroying a principal it did not create
Six findings from review, all on the elevated setup path.

Teardown was not scoped to what the run created. provisionWindowsSandbox
PrincipalForSetup was careful never to delete an account it had adopted,
and then setupWindowsSandboxPrincipal called removePrincipal on any ACL
failure with no such guard. Re-running elevated setup on a working machine
and hitting one transient ACL error therefore deleted the local account,
its secret and its logon rights. It now returns whether it created the
principal and the outer teardown honours it; ACEs are still reverted,
since this run applied them.

Password rotation moved to immediately before the secret is committed.
Resetting an adopted account's password at the top of provisioning meant
every later step ran against an account whose password had been replaced
with no copy stored. Any failure there left a live account authenticated
by a password nothing on disk knew, and since the account pre-existed the
rollback correctly declined to delete it, so the command path read the
absent secret as "not provisioned" and fell back to the weaker backend for
good. The two operations are now adjacent. The rollback also stops
removing the secret when this run neither created the account nor rotated
it, because that secret still works.

Policy DenyWrite now reaches the principal ACL plan. The capability plan
has always emitted these; the principal plan denied write only on
protected metadata and read-only subpaths, so once the runner used a
principal token a policy deny elsewhere was not enforced at all.

Principal deny-read entries are materialized, matching the capability
plan, so a path created after setup still gets a deny ACE.

Logon-right revocation is keyed to the attempt rather than to success.
Rights are added one at a time and the grant returns on first failure, so
a partial grant left LSA entries behind pointing at a SID that deleting
the account then made unresolvable.

The ownership comment now carries the full workspace key. The account name
holds only 11 characters of the digest, so two workspaces could derive one
name and silently share an account, a secret and an ACL identity; a
mismatch is now refused. Accounts provisioned before the key was recorded
are still adopted.

Also warns once on stderr when the opt-in is set and a provisioned
principal cannot be used, rather than downgrading in silence.
2026-09-11 10:17:23 +05:30
Vasanthdev2004 206046bc04 fix(sandbox): grant delete to the principal and keep rollback able to find it
Two findings from review, both consequences of the principal being a
separate account rather than the calling user.

WindowsACLAllowWrite granted FILE_GENERIC_WRITE, which covers creating and
modifying but not removing or renaming, and a rename needs delete on the
source. Under the old same-user token this was invisible because the caller
already held inherited rights on its own tree. A principal inherits nothing,
so it could write files it could never delete, which fails ordinary editing
and most git operations rather than an edge case. DELETE and
FILE_DELETE_CHILD are now part of the grant, matching WindowsACLDenyWrite,
which already treats delete as part of write. WRITE_DAC and WRITE_OWNER stay
out: they are denied so the principal cannot rewrite its own restrictions.

provisionWindowsSandboxIdentity returned a zero identity alongside
created=true when group attachment or SID resolution failed after NetUserAdd
had already created the account. The caller's rollback deletes by
identity.Username, so it was asked to delete the empty string and left the
account behind. Group attachment is the case that matters, being both the
enforcement boundary and something local policy can refuse. The name now
comes back with the error.

The four provisioning calls are indirected so the failure paths are
reachable in a test. Seaming only the post-creation pair would not have been
enough: every step needs an elevated caller, so the test would have stopped
at the group check and passed without reaching what it names.

Also seeds the empty-secret test with a genuinely empty file. The previous
whitespace seed was several bytes, so it never reached the length check and
failed later in DPAPI instead, which another test already covers.
2026-09-11 10:17:23 +05:30
Vasanthdev2004 e8a0642c58 test(sandbox): check the ACE type before decoding its trustee
GetAce returns a generic ACE_HEADER and the helper reinterprets it as an
ACCESS_ALLOWED_ACE. That holds for the fixed-layout types, but an object
ACE carries Flags and two GUIDs ahead of the trustee, so SidStart would
land mid-structure and Copy would read whatever bytes follow. The caller
asserts that no unexpected trustee appears in the DACL, and on such an ACE
it would print a nonsense SID rather than name the entry that does not
belong.

Nothing under test builds anything but allowed ACEs today, so this changes
no current outcome. It keeps the failure legible if that ever changes.
2026-09-11 10:17:23 +05:30
Vasanthdev2004 17d5674d61 fix(sandbox): drop the stored secret whenever provisioning fails
The cleanup added for partial provisioning left the window it existed for
uncovered. It only removed the on-disk secret when this run had written one,
and it derived the secret path after the logon-rights grant, so a failure
before that point had nothing to remove.

That is exactly the case that matters. Provisioning ALWAYS sets the account's
password, including resetting a pre-existing account's, so from the moment it
returns the stored secret is already stale. A failure in the rights grant then
left that stale secret on disk against a password that had just changed, and
the next command failed the logon and reported a broken sandbox instead of
falling back.

The path is now resolved from the account name before anything can fail, and
removal is unconditional rather than gated on having written one. Absent beats
stale: the command path treats a missing secret as "not provisioned" and falls
back to the restricted token, which is the outcome a failed setup should leave
behind.

Raised by CodeRabbit, twice from different angles, on the commit that added the
cleanup.
2026-09-11 10:17:23 +05:30
Vasanthdev2004 17879dc480 fix(sandbox): refuse a squatted account name and clean up partial provisioning
Two problems in the provisioning path, both raised in review.

The account name is derived from a workspace hash rather than discovered, so it
can be occupied by a local account that has nothing to do with Zero, whether by
coincidence or because somebody put it there. Provisioning treated "NetUserAdd
says it exists" as "this is ours", reset the account's password, added it to the
managed group and adopted it. That is a stranger's account taken over during an
elevated setup, on the strength of a name matching a pattern we generate
ourselves.

Ownership is now proven from the comment provisioning stamps before anything is
touched, and a name held by an account Zero did not create fails with a typed
collision error instead of being adopted.

Second, a failure anywhere after the account existed left it behind. The
rollback the setup path installs is only built once provisioning has returned
successfully, so nothing could undo a failure between creating the account and
storing its secret; the account, and possibly its granted logon rights, simply
stayed. Provisioning now unwinds what the run actually did, in reverse, on every
failure path.

Scoped to what THIS run created, deliberately. An account that already existed
and belongs to Zero is a working principal from an earlier setup, so deleting it
because a later run failed would turn a partial failure into a total one. For
that case the repair is dropping the stored secret instead: this run reset the
password, so the secret no longer matches, and absent beats stale because the
command path treats a missing secret as "not provisioned" and falls back rather
than failing.

The ownership gate is asserted against real accounts every Windows install
carries, which needs no privilege because it only has to establish that they are
not ours. Classifying everything as managed makes it fail.
2026-09-11 10:17:23 +05:30
Vasanthdev2004 a527ffc040 fix(sandbox): revoke logon rights before deleting a principal
Retiring a principal deleted the account but left its LSA account rights
behind, keyed to a SID that no longer resolves. That is the orphaned residue
this model is supposed to avoid, and the reason ACE revocation is keyed to the
trustee rather than to a record of what was granted; the logon-rights half was
simply missing. CodeRabbit spotted it as a test-cleanup gap, but the production
teardown path had the same hole.

revokeWindowsSandboxLogonRights drops every right held by the principal and
removes its LSA entry, and setup teardown now calls it BEFORE deleting the
account, while the SID still resolves. Removing all rights rather than naming
them is deliberate: the principal is being retired, so rights granted by an
older setup that this one no longer knows about should go too.

An account that holds no rights is not an error, since that is the state
teardown wants. That tolerance depends on STATUS_OBJECT_NAME_NOT_FOUND
surviving LsaNtStatusToWinError as something errors.Is can still match, which
is the sort of Windows errno assumption that is often wrong, so there is now an
unprivileged test asserting it, including that the tolerance does not also
swallow access-denied.

Both gated tests now clean up rights and account, in that order. The
provisioning round trip had no cleanup at all and, since it started granting a
batch logon right, was leaving both behind on whatever machine ran it.
2026-09-11 10:17:22 +05:30
Vasanthdev2004 07f6492528 fix(sandbox): reset the password when the account already exists
NetUserAdd leaves a pre-existing account completely untouched, password
included, and ensureWindowsSandboxUser treated that status as success. So a
second setup run generated a fresh random password, stored it as the secret,
and left the account still authenticating with the old one. Every later command
then failed to log on with a principal that looked correctly provisioned. Two
comments claimed the caller reset the password in that case; nothing did.
Caught by CodeRabbit.

ensureWindowsSandboxUser now reports whether the account already existed, and
provisioning resets the password through NetUserSetInfo when it did, so the
value it returns is always the account's real password. The comments now
describe what the code does.

The gated provisioning test provisions twice and then logs on with the password
from the SECOND run, which is the only honest assertion here: a stale password
is indistinguishable from a correct one until something tries to authenticate
with it.

Also makes the syscall keep-alives explicit. The LSA and LogonUser call sites
borrow Go memory that was either not kept alive at all (the policy attributes,
the rights descriptor, the three logon strings) or kept alive only after the
error check, so the failure path returned with it already collectable. The two
netapi32 sites that used a deferred no-op closure now use runtime.KeepAlive as
well, so one idiom is used throughout.
2026-09-11 10:17:22 +05:30
Vasanthdev2004 949e104844 fix(sandbox): surface a squatted principal name instead of falling back
lookupWindowsSandboxIdentity collapsed every SID-resolution failure into the
"no principal is provisioned" sentinel, which threw away the check
resolveWindowsSandboxSID deliberately makes: a name that resolves to a group or
alias rather than a user account. The command path treats that sentinel as
permission to fall back quietly, so an account name squatted by something that
is not a user reached the operator as silence and a downgrade to the restricted
token. Caught by gnanam in review.

Only ERROR_NONE_MAPPED now means setup has not run. Anything else is a
principal that exists but cannot be used, and the runtime path propagates it
rather than swallowing it, which is where the description already said the line
should sit.

The decision lives in its own function because the lookup derives its account
name from a workspace key, so a test cannot hand it a name that resolves to a
group. The test drives the classifier with a real error from a well-known local
group, needs no privilege, and fails if the old collapse-everything behaviour
is restored.

Also corrects a comment pointing at sandboxRuntimeKey, which does not exist.
The function is windowsSandboxWorkspaceKey.
2026-09-11 10:17:22 +05:30
Vasanthdev2004 0b8fcf4b1e feat(sandbox): encrypt the stored principal password to the invoking user
The file ACL stays the primary control and is what keeps the sandbox
principal from reading its own credential. It only binds while the filesystem
is the one being asked, though, so a backup or a mounted image hands over the
password in the clear. CryptProtectData ties the ciphertext to the invoking
user's logon secret, which covers exactly that gap.

The principal name is passed as entropy, so a blob copied onto another
principal's path fails to decrypt rather than authenticating the wrong
account. A secret written by an older build reads as unavailable and falls
back to the restricted token; the next elevated setup rewrites it.

The round-trip test needs no privilege, so it runs everywhere rather than
joining the gated set, and it asserts the password does not appear verbatim
in the stored bytes.
2026-09-11 10:17:22 +05:30
Vasanthdev2004 3e238cc5ea fix(sandbox): keep the restricted token when the network is denied
Network denial is enforced by WFP filters keyed to the offline-marker SID.
The restricted token carries that SID; a token from LogonUser cannot, because
it names the account rather than a synthetic capability SID. Routing a
denied-network command through a sandbox principal therefore left the block
filters matching nothing and dropped egress enforcement altogether, and deny
is the default mode.

The principal now stands down whenever the network is denied and the
restricted-token backend runs instead, so read confinement is never traded
for a silent loss of network denial. Keying the filters to the principal's
own SID is the follow-up that lifts the restriction.

The decision sits in its own predicate rather than inline: on a machine with
nothing provisioned the lookup declines for its own reasons, so a test that
called through it would have passed with the guard removed.

Also names the opt-out variable when a provisioned principal cannot be used,
since the backend is opt-in and the operator needs a way back.
2026-09-11 10:17:22 +05:30
Vasanthdev2004 645976d215 feat(sandbox): provision a sandbox principal during elevated setup
Completes the chain. Until now the principal entry points had no non-test
callers, so `zero sandbox setup` created no account and the runner seam always
fell back: the feature was inert end to end.

Setup now provisions this workspace's principal, grants it the batch logon
right, stores its password locked to the invoking user, and applies the ACL plan
that gives it read+write on the workspace and read on the declared read roots. A
principal is a separate account with no inherent access to the caller's tree, so
those grants are what make the sandbox able to run at all, and their absence
elsewhere is what puts credential stores out of reach.

Provisioning is folded into the existing rollback rather than each later failure
path having to remember it, and the rollback revokes ACEs before deleting the
account: removing the account first would leave ACEs naming a SID that no longer
resolves, which is the orphaned residue this model exists to avoid.

Gated on the same opt-in as the runner. Account creation is visible in
`net user` and is exactly what endpoint protection and enterprise policy object
to, so it happens only when asked for; without the opt-in the capability-SID
backend remains the whole of setup, unchanged.
2026-09-11 10:17:22 +05:30
Vasanthdev2004 273bdede2a test(sandbox): cover the logon-rights and token-minting half
Provisioning is now validated on real Windows, but LsaAddAccountRights and
LogonUser had still never executed, so the principal was proven to exist without
being proven usable.

The batch logon doubles as the assertion that the rights grant worked: a
LOGON32_LOGON_BATCH logon fails with ERROR_LOGON_TYPE_NOT_GRANTED unless
SeBatchLogonRight is actually held, so a token coming back is evidence the grant
landed rather than merely that the call returned success. Granting twice is
exercised too, since setup re-runs must not fail on rights already held.

The token's user SID is compared against the principal's. If a token came back
belonging to the caller the identity boundary would be an illusion and reads
would still run as the user, which is the whole thing this model exists to stop.

Removes any leftover account first and cleans up after itself, because an
interrupted earlier run would leave an account whose password no longer matches
a freshly generated one.
2026-09-11 10:17:22 +05:30
Vasanthdev2004 8dbe818934 test(sandbox): name the per-shell env syntax in the provisioning skip
The skip fired on an unset environment variable but read as though elevation
was missing. `set VAR=1` is cmd syntax and sets a shell variable rather than an
environment variable in PowerShell, so the test skipped silently after the
operator believed they had enabled it. Spell out all three shells.
2026-09-11 10:17:22 +05:30
Vasanthdev2004 ea7c04cdbb feat(sandbox): use a sandbox principal for Windows command execution
Wires the principal model into the runner and settles where the account's
password lives.

The secret is stored under the sandbox home with an explicit,
inheritance-protected DACL naming only the invoking user and SYSTEM. The sandbox
principal is deliberately absent from it: a principal that could read the file
could mint its own token and the identity boundary would be decorative. The ACL
is applied to an empty file before the password is written, so the bytes never
exist under the config directory's inherited permissions, and PROTECTED drops any
inherited ACE outright.

At command time the runner asks for a principal token first and uses it in place
of the restricted token, because a separate account has reads denied by the
filesystem rather than left open the way a same-user restricted token must leave
them. The lookup is fail-soft: opt-out, no provisioned account, or no stored
secret all report "not available" and the existing restricted-token path runs
unchanged. Only a provisioned-but-unusable identity surfaces an error, since
that means setup ran and the sandbox is broken rather than absent.

The backend stays behind ZERO_WINDOWS_SANDBOX_IDENTITY=1 while the privileged
paths are unvalidated, so no existing install changes behaviour.
2026-09-11 10:17:22 +05:30
Vasanthdev2004 17aa51b530 feat(sandbox): add Windows sandbox principals
Groundwork for closing the Windows half of #662 and #675, where
credentialDenyReadPaths is a no-op today.

Every Windows backend currently derives its token from the calling user via
CreateRestrictedToken, so the sandbox can constrain writes but not reads: a deny
ACE that would stop the sandboxed child reading a credential store names the
same account Zero runs as, and would lock Zero out too. That is why deny-read is
skipped on Windows rather than merely unimplemented.

This adds a separate local account per workspace, held in one managed group, so
the sandbox has an identity of its own:

- provisioning: managed group, stable per-workspace account name inside the
  20-character limit, crypto/rand password meeting complexity policy, SID
  resolution, idempotent so setup re-runs converge
- logon rights: grants only SeBatchLogonRight and explicitly denies interactive,
  network, remote-interactive and service logon, then mints a token with
  LogonUser pinned to the local machine
- ACLs keyed to the principal: denies emitted before allows so carve-outs
  survive, workspace granted read+write, read roots granted read, protected
  metadata denied write and materialized
- removal: revocation by trustee, so retiring a principal drops every ACE naming
  it without needing a record of what was granted

The inversion is the point. A separate account has no access to the caller's
profile at all, so credential stores are unreachable by construction rather than
by enumerating deny rules, and the same SID is what a write grant or a firewall
rule can be keyed to.

Nothing is wired into command execution yet: these paths are additive and no
existing behavior changes. See the pull request for the open question about
where the principal's password lives.
2026-09-11 10:17:22 +05:30
266 changed files with 21979 additions and 17351 deletions

No files matched your search

+1 -1
View File
@@ -1 +1 @@
{".":"0.13.0"}
{".":"0.11.0"}
-46
View File
@@ -6,52 +6,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) once the first release is
tagged. Until then, source builds report the version `dev`.
## [0.13.0](https://github.com/Twigpine/zero/compare/v0.12.0...v0.13.0) (2026-11-09)
### Features
* **mcp:** add AI/ML API as an opt-in built-in server ([#1193](https://github.com/Twigpine/zero/issues/1193)) ([16ccd78](https://github.com/Twigpine/zero/commit/16ccd78765b6a2a56b7370e3031710d718aa4757))
* **plugins:** add CLI enable/disable for plugin.json ([#1202](https://github.com/Twigpine/zero/issues/1202)) ([05c1b5e](https://github.com/Twigpine/zero/commit/05c1b5e16098e1c60212316d20ae8e055a27b5ae))
* **providers:** point OpenGateway at api.twigpine.com and trust its live model list ([#1208](https://github.com/Twigpine/zero/issues/1208)) ([47f78c6](https://github.com/Twigpine/zero/commit/47f78c66247aa96c8753d6bb95e935126ff1761e))
* **web-fetch:** add an opt-in AI/ML API Toolbox scraping backend ([#1192](https://github.com/Twigpine/zero/issues/1192)) ([c9f1794](https://github.com/Twigpine/zero/commit/c9f179482a7f6d7111ee64fa9cb8ffa2517c2397))
* **web-search:** add AI/ML API Toolbox as an opt-in backend ([#1189](https://github.com/Twigpine/zero/issues/1189)) ([9873645](https://github.com/Twigpine/zero/commit/98736457bdac6b0272e032728a5485cf557780fe))
### Bug Fixes
* **hooks:** dispatch specialistStart/Stop lifecycle hooks ([#1204](https://github.com/Twigpine/zero/issues/1204)) ([2ca84cc](https://github.com/Twigpine/zero/commit/2ca84cc127d9369765004b72cf95049fecc6c28b))
* **tui:** make reasoning stream rendering linear (coalesced deltas, O(1) append, tail-bounded preview) ([#1198](https://github.com/Twigpine/zero/issues/1198)) ([bbf779e](https://github.com/Twigpine/zero/commit/bbf779e69168d62bb1103aefdd1b10e53433209e))
### Performance Improvements
* **tui:** bounded file read, LRU rendering cache, and size caps for full-file view ([#1203](https://github.com/Twigpine/zero/issues/1203)) ([e034a93](https://github.com/Twigpine/zero/commit/e034a93815629c68976dcdab7dcc08bbe9b03693))
## [0.12.0](https://github.com/Twigpine/zero/compare/v0.11.0...v0.12.0) (2026-11-02)
### Features
* **providers:** add MiniMax-M3.1-Flash to catalog ([#1180](https://github.com/Twigpine/zero/issues/1180)) ([d99f92b](https://github.com/Twigpine/zero/commit/d99f92b81a671f186cede81614c89b8e8a7f9451))
* **sandbox:** support self-exec pattern to eliminate separate helper binary ([#946](https://github.com/Twigpine/zero/issues/946)) ([8c4e759](https://github.com/Twigpine/zero/commit/8c4e7597989ef9b3ca67b7703dfd15d92372aed2))
* **skills:** install a skill's bundled files and point the skill tool at them ([#1167](https://github.com/Twigpine/zero/issues/1167)) ([1641506](https://github.com/Twigpine/zero/commit/164150633a8b2e6ae864ae02fd7105b201eeb68e))
### Bug Fixes
* **agent:** mark headless guardrail stops incomplete ([#1182](https://github.com/Twigpine/zero/issues/1182)) ([5aa54fe](https://github.com/Twigpine/zero/commit/5aa54fe1d2b419c9bbef73605a77919c9007cf7b))
* bump Go to 1.26.10 for GO-2026-6809 ([#1168](https://github.com/Twigpine/zero/issues/1168)) ([6393a90](https://github.com/Twigpine/zero/commit/6393a90ed618a3536e09c02852f4ec829b97545b))
* **chatgpt:** refresh subscription defaults and compatibility version ([#1179](https://github.com/Twigpine/zero/issues/1179)) ([f912f80](https://github.com/Twigpine/zero/commit/f912f80d002602e11ba0ef8548ecee58442874aa))
* **deps:** refresh the lockfile to hono 4.13.13 ([#1171](https://github.com/Twigpine/zero/issues/1171)) ([de1443b](https://github.com/Twigpine/zero/commit/de1443b2edb44fd3747cc77200d54dc8cffcd0a7))
* **oauth:** reuse the crash-safe secret store and sync encrypted publications ([#1165](https://github.com/Twigpine/zero/issues/1165)) ([84c1b15](https://github.com/Twigpine/zero/commit/84c1b1554076679a342e9cac61f8f8cdbaf3a429))
* **redaction:** redact credentials whose trigger a greedy span swallowed ([#1158](https://github.com/Twigpine/zero/issues/1158)) ([a97db88](https://github.com/Twigpine/zero/commit/a97db8832fdba077f2d31bdf03ecef8d7c774fe1))
* **sessions:** capture freeform apply_patch checkpoints ([#1183](https://github.com/Twigpine/zero/issues/1183)) ([6396208](https://github.com/Twigpine/zero/commit/63962081cf130c2171407936ce3beb7021d238b8))
* **sessions:** preserve image attachment summaries on resume ([#1152](https://github.com/Twigpine/zero/issues/1152)) ([1e84849](https://github.com/Twigpine/zero/commit/1e848492e5c1919d187fb5e1eb5e7ef9bc6140dc)), closes [#1135](https://github.com/Twigpine/zero/issues/1135)
* **tools:** grep stops where a file's line breaks stop ([#1160](https://github.com/Twigpine/zero/issues/1160)) ([aa9ba84](https://github.com/Twigpine/zero/commit/aa9ba8412eb8917daa2ca5cb3fdbd0a39276fa88))
* **workspaceindex:** import gitsafe under the Twigpine module path ([#1153](https://github.com/Twigpine/zero/issues/1153)) ([ef4da13](https://github.com/Twigpine/zero/commit/ef4da1323f1097863ae278224b5f304c2fff909a))
* **workspaceindex:** run Scan's git calls through gitsafe ([#1149](https://github.com/Twigpine/zero/issues/1149)) ([2ffe183](https://github.com/Twigpine/zero/commit/2ffe1831b0950b1463ec46d487d03a5fcb679cc6))
## [0.11.0](https://github.com/Twigpine/zero/compare/v0.10.0...v0.11.0) (2026-10-15)
-80
View File
@@ -15,7 +15,6 @@
<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>
<a href="https://vercel.com/open-source-program"><img alt="Vercel OSS Program" src="https://vercel.com/oss/program-badge-2026.svg"></a>
<br>
<strong>English</strong> | <a href="README_ZH.md">中文</a>
</p>
@@ -88,18 +87,9 @@ zero exec --output-format stream-json < turns.jsonl
</picture>
</a>
</td>
<td align="center" width="150" height="80">
<a href="https://vercel.com/">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/vercel-logo-dark.svg">
<img src="docs/assets/vercel-logo.svg" alt="Vercel logo" width="120">
</picture>
</a>
</td>
</tr>
<tr>
<td align="center"><a href="https://www.gmicloud.ai/"><strong>GMI Cloud</strong></a></td>
<td align="center"><a href="https://vercel.com/"><strong>Vercel</strong></a></td>
</tr>
</table>
@@ -397,76 +387,6 @@ and can rewrite every request through it, so this is the trust you accept by
configuring one. Leave the variables unset for the checks to be enforced end to
end.
The built-in `web_search` tool is registered only when a search backend is
configured: `ZERO_WEBSEARCH_BASE_URL` (with an optional `ZERO_WEBSEARCH_API_KEY`)
for a generic JSON endpoint, `ZERO_WEBSEARCH_PROVIDER=searxng` with a SearXNG
base URL, or the AI/ML API Toolbox:
```bash
export ZERO_WEBSEARCH_PROVIDER=aimlapi
```
The AI/ML API backend runs the Toolbox tool `web/search`, and every search is
billed to your AI/ML API balance: about $0.0013 per result, with a documented
minimum of $0.0091 per search. Each search is sent with a $0.025 spending cap,
which the Toolbox enforces. It is used only when
`ZERO_WEBSEARCH_PROVIDER` is set to `aimlapi`; an AI/ML API key alone never
enables it. The key is read from your user configuration only; provider
profiles in a project `.zero` config are not used. It is the aimlapi profile's
key (the active profile when it is aimlapi, otherwise the first aimlapi
profile), including a key in the credential store, or `AIMLAPI_API_KEY` when no
aimlapi profile exists. It is resolved again on every search;
`ZERO_WEBSEARCH_API_KEY` is not used. When any aimlapi profile, or any profile
that reads `AIMLAPI_API_KEY`, points at a base URL other than
`https://api.aimlapi.com/v1`, or `AIMLAPI_INFERENCE_URL` is overridden, the key
may belong to a proxy, so it is not sent and the search fails without a request.
Requests go only to
`https://tools.aimlapi.com` (`ZERO_WEBSEARCH_BASE_URL` is ignored and redirects
are refused) and carry `X-AIMLAPI-Source: agent/zero` and an
`X-AIMLAPI-Partner-ID` header. The `domains` argument is sent to the Toolbox as
`include_domains`, and results are still filtered locally.
### AI/ML API Toolbox backend for `web_fetch`
`web_fetch` can fetch pages through the AI/ML API Toolbox tool
`search1api/crawl` instead of a direct HTTP GET. It is off unless you choose it:
```bash
export ZERO_WEBFETCH_PROVIDER=aimlapi
```
Having an AI/ML API key never switches it on by itself, and any other value (or
none) keeps the direct fetch. The variable is read on every fetch.
- **Billing:** Toolbox runs are billed to your AI/ML API balance, about
$0.0013 per run. Each run is sent with `max_cost_usd` 0.005, so a run
priced above that is refused before it starts and not charged. Rate-limited
or gateway-failed requests are retried with the same idempotency key, so a
retry is never billed twice.
- **Key:** the key comes from your `aimlapi` provider profile (including a key
in the credential store), or from `AIMLAPI_API_KEY` when there is no such
profile. It is withheld, and `web_fetch` returns an error, when any `aimlapi`
profile or any profile using `AIMLAPI_API_KEY` points at a base URL other than
`https://api.aimlapi.com/v1`, or when `AIMLAPI_INFERENCE_URL` is overridden:
the key may then belong to a proxy.
- **Destination:** requests go only to the fixed `https://tools.aimlapi.com`
endpoint, with `Authorization: Bearer <key>` and the attribution headers
`X-AIMLAPI-Source: agent/zero` and `X-AIMLAPI-Partner-ID`. Redirects are
refused. `HTTP_PROXY`/`HTTPS_PROXY` apply to these requests as to Zero's other
aimlapi.com calls.
- **No fallback:** without a usable key, or when the run fails, `web_fetch`
returns an error and does not fetch the page directly. Unset the variable to
go back to direct fetches.
- **Remote fetch:** the page is fetched by a remote service, which may serve a
cached copy, and the full URL (query string included) is sent to it. The URL
checks above still run first, so loopback, private and metadata URLs are
never handed to it. The result is Markdown with a
`Backend: aimlapi (search1api/crawl)` line; `max_bytes` cuts the Markdown,
and `format: "raw"` is refused because the original response is not
available. The complete Toolbox run response is limited to 8 MiB before JSON
decoding; larger responses return an error even if `max_bytes` is smaller.
The remote run may already have been billed when this limit is detected.
For local dev servers, use shell commands such as `curl` through `exec_command`
so the normal sandbox and permission policy applies. Long-running commands stay
attached to a background terminal session and can be listed or stopped from the
-10
View File
@@ -15,7 +15,6 @@
<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>
<a href="https://vercel.com/open-source-program"><img alt="Vercel OSS Program" src="https://vercel.com/oss/program-badge-2026.svg"></a>
<br>
<a href="README.md">English</a> | <strong>中文</strong>
</p>
@@ -86,18 +85,9 @@ zero exec --output-format stream-json < turns.jsonl
</picture>
</a>
</td>
<td align="center" width="150" height="80">
<a href="https://vercel.com/">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/vercel-logo-dark.svg">
<img src="docs/assets/vercel-logo.svg" alt="Vercel logo" width="120">
</picture>
</a>
</td>
</tr>
<tr>
<td align="center"><a href="https://www.gmicloud.ai/"><strong>GMI Cloud</strong></a></td>
<td align="center"><a href="https://vercel.com/"><strong>Vercel</strong></a></td>
</tr>
</table>
+1 -13
View File
@@ -33,16 +33,4 @@ Please include, where possible:
depend on a user explicitly disabling the sandbox (`--skip-permissions-unsafe`), are generally
out of scope — but please report anything you are unsure about.
- Zero sends no telemetry. Network calls go to the model/tool providers you configure (and, for
keyless web search, to Exa's hosted MCP endpoint at `https://mcp.exa.ai/mcp`). The built-in
AI/ML API MCP server (`https://mcp.aimlapi.com/mcp`) is off until you run
`zero mcp enable aimlapi`. Once enabled, the Bearer key follows the aimlapi
profile resolution order (inline key, then the profile's `apiKeyEnv` — default
`AIMLAPI_API_KEY` — then the credential store), or `AIMLAPI_API_KEY` alone when
no aimlapi profile exists; a non-empty `AIMLAPI_API_KEY` can override a stored
profile key for MCP billing. The automatic key is withheld if an aimlapi profile
(or a profile reading `AIMLAPI_API_KEY`) uses a base URL other than
`https://api.aimlapi.com/v1`, `AIMLAPI_INFERENCE_URL` points elsewhere, a
non-empty user Authorization header is configured, OAuth is selected, project
config has touched the server, or the key is missing or not a single token
(including Unicode whitespace). See
[docs/EXTENDING.md](docs/EXTENDING.md#built-in-servers).
keyless web search, to Exa's hosted MCP endpoint at `https://mcp.exa.ai/mcp`).
+2 -65
View File
@@ -110,8 +110,6 @@ Discovery roots (earlier wins on name collisions):
A missing directory is fine — Zero just omits it. Management commands (`zero skill add` / `zero skill remove`) always write to the primary Zero dir only; `list` / `info` search primary + `~/.agents/skills`. `skill info` reports lock source/hash when present and whether the on-disk `SKILL.md` has drifted from the recorded hash.
For a skill installed since Zero started installing bundled files, `skill info` also reports drift in any file installed with it.
```text
~/.local/share/zero/skills/ # primary (install/remove target)
run-benchmarks/
@@ -142,15 +140,6 @@ Only `name` and `description` are recognized in the frontmatter today. The `name
The `skill` tool lets the agent load any discovered skill by name (primary, agents, or plugin).
A skill can ship files beside its `SKILL.md`, in the [agentskills.io](https://agentskills.io/specification) layout: `scripts/`, `references/`, `assets/` or anything else the instructions refer to. `zero skill add` installs the whole skill directory, leaving out hidden entries such as `.git` and not following or copying symlinks (it lists any it skipped). A skill larger than 1,000 files or 32 MiB is refused. When the `skill` tool loads a skill that has such files, its output ends with the skill's directory and a list of the files, so instructions like "run `scripts/fill.py`" can be followed. Running a bundled script goes through the normal tool permissions; installing or loading a skill never runs anything.
```text
pdf/
SKILL.md
scripts/fill.py
references/form-fields.md
```
## 4. Hooks
Hooks fire shell commands on lifecycle events. Configure them in JSON:
@@ -203,11 +192,7 @@ Events the agent emits (in dispatch order):
| `specialistStart` | A sub-agent is spawned | yes (specialist name) |
| `specialistStop` | A sub-agent ends | yes (specialist name) |
For swarm members the matcher subject is the **agent type** (`subagent`, `teammate`, …), not a registered specialist profile name — swarm members run from an inline manifest keyed by agent type.
`specialistStart` fires once the child process has actually started; a launch that fails before exec (missing binary, spawn error) records no public lifecycle hooks. `specialistStop` fires once per terminal stop: completion, error, explicit stop of a recovered task, and a background task reaped by a later `TaskOutput` all share one durable claim, so a hook that notifies or cleans up does not run twice for the same sub-agent run.
A hook's exit code decides what happens next: `0` continues, non-zero blocks the tool call (`beforeTool`) or surfaces an error (`afterTool`). `sessionStart` / `sessionEnd` / `specialistStart` / `specialistStop` hooks are advisory (failures are audited but do not interrupt the run). Hook execution is recorded in the audit log; the audit is reachable from the agent's view of past actions, not from a dedicated `zero doctor` check.
A hook's exit code decides what happens next: `0` continues, non-zero blocks the tool call (`beforeTool`) or surfaces an error (`afterTool`). Hook execution is recorded in the audit log; the audit is reachable from the agent's view of past actions, not from a dedicated `zero doctor` check.
> **Roadmap.** An in-UI hooks manager is on the backlog. Today you edit the JSON directly.
@@ -261,52 +246,6 @@ or use a new server name. OAuth tokens are bound to the resolved server identity
so changing a server target can require `zero mcp oauth login <server>` again.
OAuth server names ending in `.<32 hex chars>` are reserved for token storage.
#### Built-in servers
Zero seeds two MCP servers before your config is merged. Both show up in
`zero mcp list`, and any field can be overridden in your user config.
- `exa` (`https://mcp.exa.ai/mcp`) is on by default for keyless web search.
`zero mcp disable exa` turns it off.
- `aimlapi` (`https://mcp.aimlapi.com/mcp`, the AI/ML API Toolbox, model
discovery, image/video/audio generation, chat, embeddings and balance tools)
is **off by default**, because its tool runs and generations are billed to
your AI/ML API balance. Turn it on with `zero mcp enable aimlapi`, which
writes only `"disabled": false` to your user config, and off again with
`zero mcp disable aimlapi`. A project config cannot turn it on.
When `aimlapi` connects, Zero sends your AI/ML API key as
`Authorization: Bearer <key>`. The key is resolved again on every request and
is never written to `config.json`. With an aimlapi.com provider profile
(`zero providers setup aimlapi`), the key is chosen in this order:
1. An inline key on the profile (if set in config).
2. Otherwise the environment variable named by the profile's `apiKeyEnv`
(the catalog default is `AIMLAPI_API_KEY`).
3. Otherwise the key held in the credential store when the profile uses
`apiKeyStored`.
When there is no aimlapi profile, Zero uses `AIMLAPI_API_KEY` alone. If both
`AIMLAPI_API_KEY` and a stored profile key exist, step 2 wins over the store
whenever the inline key is empty — MCP requests may bill the environment key's
account, not the stored key.
No automatic key is sent when an aimlapi profile, or any profile reading
`AIMLAPI_API_KEY`, uses a base URL other than `https://api.aimlapi.com/v1`, or
when `AIMLAPI_INFERENCE_URL` is overridden, because that credential may belong
to a proxy. No key is sent when the resolved value is not a single token (for
example a comma- or newline-separated pool), when you set your own
non-empty `Authorization` header, when the server uses `"auth": "oauth"`, or
when project config touched the server. Without a key the server answers 401.
To sign in with your aimlapi.com account instead, set `"auth": "oauth"` on the
server and run `zero mcp oauth login aimlapi`.
Every request to exactly `https://mcp.aimlapi.com` (https, default port,
whatever the server is named, including one you add by hand) also carries the
attribution headers `X-AIMLAPI-Source: agent/zero` and `X-AIMLAPI-Partner-ID`,
and gets the key under the rules above. Other MCP servers never receive these
headers or the key.
### As a server — expose Zero's tools to another agent
```bash
@@ -351,12 +290,10 @@ Install and manage:
zero plugins add ./github-pr-review # copy into ~/.config/zero/plugins/ or ./.zero/plugins/
zero plugins list
zero plugins info github-pr-review # show manifest path, enabled state, lock metadata
zero plugins disable github-pr-review # sets "enabled": false in plugin.json
zero plugins enable github-pr-review # sets "enabled": true in plugin.json
zero plugins remove github-pr-review # alias: rm
```
A plugin is active when it is present under a plugins directory and its `plugin.json` has `"enabled": true` (the default when the field is omitted). Use `zero plugins disable <id>` / `zero plugins enable <id>` to toggle that field without removing the plugin. Pass `--user` to target only user plugins and ignore `./.zero/plugins`; without it, a project plugin with the same id takes precedence. The toggle edits the `enabled` value in place — the rest of the manifest keeps its formatting, field order, and numeric literals — and serializes with `plugins add` / `plugins remove` on the same plugins root. Zero reads plugins when it starts, so the change applies to the next `zero exec` run or interactive session; an interactive session that is already running keeps the plugin tools, hooks, and skills it started with until you restart it.
A plugin is enabled by being present in the plugins directory and disabled by removing it (or by the user setting `"enabled": false` in its `plugin.json`). Plugins are not enabled or disabled by a CLI subcommand today.
Plugin commands run with the plugin directory as their working directory. Use relative paths; the loader resolves them at activation time.
-4
View File
@@ -634,10 +634,6 @@ Core tool groups include:
`HTTP_PROXY`/`HTTPS_PROXY` set, the proxy is dialed instead and resolves the
target itself, so destination enforcement ends at the proxy; see the note in
the README.
With `ZERO_WEBFETCH_PROVIDER=aimlapi` (opt-in; a key alone does not select
it), the same URL checks run and the page is then fetched as Markdown by the
billed AI/ML API Toolbox tool `search1api/crawl`, with no fallback to a
direct fetch; see the README.
- **Local-control tools**: config-gated wrappers for browser, terminal, desktop,
and artifact helpers backed by packages such as `internal/localcontrol` and
`internal/browser`.
-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="261" height="52" fill="none" viewBox="0 0 261 52"><path fill="white" d="M59.8 52H0L29.9 0zm67.82-38.45q4.9 0 8.81 2.13a15.5 15.5 0 0 1 6.22 6.32q2.3 4.2 2.38 10.26v2.06h-26.35q.27 4.4 2.58 6.92 2.38 2.47 6.36 2.47a8.4 8.4 0 0 0 7.76-4.93l9.16.67q-1.68 4.98-6.29 7.99t-10.63 3q-5.53 0-9.64-2.27a16 16 0 0 1-6.43-6.46 20 20 0 0 1-2.31-9.72q0-5.52 2.3-9.72a16 16 0 0 1 6.44-6.46 20 20 0 0 1 9.64-2.26m62.55 0q4.47 0 8.18 1.66a15.3 15.3 0 0 1 6.15 4.6q2.38 3 2.87 7.05l-9.23.47a8 8 0 0 0-2.8-5 7.7 7.7 0 0 0-5.17-1.86q-4.33 0-6.7 3-2.4 3-2.39 8.52t2.38 8.52q2.37 3 6.71 3 3.15 0 5.39-1.87 2.24-1.92 2.72-5.46l9.3.4a14.7 14.7 0 0 1-2.87 7.33 16 16 0 0 1-6.15 4.86 21 21 0 0 1-8.39 1.66q-5.53 0-9.64-2.26a16 16 0 0 1-6.44-6.46 20 20 0 0 1-2.3-9.72q0-5.52 2.3-9.72a16 16 0 0 1 6.44-6.46 20 20 0 0 1 9.64-2.26m38.66 0q4.9 0 8.8 2.13a15.5 15.5 0 0 1 6.22 6.32q2.31 4.2 2.38 10.26v2.06h-26.35q.28 4.4 2.58 6.92 2.38 2.47 6.36 2.47a8.4 8.4 0 0 0 7.77-4.93l9.15.67q-1.68 5-6.29 7.99t-10.62 3q-5.53 0-9.65-2.27a16 16 0 0 1-6.43-6.46 20 20 0 0 1-2.31-9.72q0-5.52 2.3-9.72a16 16 0 0 1 6.44-6.46 20 20 0 0 1 9.64-2.26M86.9 36.69l17.24-34.33h10.8L89.96 49.63h-6.12L58.85 2.36h10.81zm71.62-15.55a11 11 0 0 1 2.47-4.48q2.28-2.31 6.38-2.31h3.4v7.26h-3.47q-2.91 0-4.79.8a5.8 5.8 0 0 0-2.77 2.5q-.9 1.72-.9 4.37v20.35h-8.89V14.35h8.33zm101.73 28.5h-8.95V2.35h8.95zM127.62 20.26q-3.7 0-6 2.2-2.32 2.2-2.87 6.2h17.05q-.48-4.34-2.72-6.33a7.8 7.8 0 0 0-5.46-2.07m101.2 0q-3.7 0-6 2.2-2.31 2.2-2.87 6.2H237q-.5-4.34-2.73-6.33a7.8 7.8 0 0 0-5.46-2.07" style="fill:white;fill-opacity:1;"/></svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="261" height="52" fill="none" viewBox="0 0 261 52"><path fill="black" d="M59.8 52H0L29.9 0zm67.82-38.45q4.9 0 8.81 2.13a15.5 15.5 0 0 1 6.22 6.32q2.3 4.2 2.38 10.26v2.06h-26.35q.27 4.4 2.58 6.92 2.38 2.47 6.36 2.47a8.4 8.4 0 0 0 7.76-4.93l9.16.67q-1.68 4.98-6.29 7.99t-10.63 3q-5.53 0-9.64-2.27a16 16 0 0 1-6.43-6.46 20 20 0 0 1-2.31-9.72q0-5.52 2.3-9.72a16 16 0 0 1 6.44-6.46 20 20 0 0 1 9.64-2.26m62.55 0q4.47 0 8.18 1.66a15.3 15.3 0 0 1 6.15 4.6q2.38 3 2.87 7.05l-9.23.47a8 8 0 0 0-2.8-5 7.7 7.7 0 0 0-5.17-1.86q-4.33 0-6.7 3-2.4 3-2.39 8.52t2.38 8.52q2.37 3 6.71 3 3.15 0 5.39-1.87 2.24-1.92 2.72-5.46l9.3.4a14.7 14.7 0 0 1-2.87 7.33 16 16 0 0 1-6.15 4.86 21 21 0 0 1-8.39 1.66q-5.53 0-9.64-2.26a16 16 0 0 1-6.44-6.46 20 20 0 0 1-2.3-9.72q0-5.52 2.3-9.72a16 16 0 0 1 6.44-6.46 20 20 0 0 1 9.64-2.26m38.66 0q4.9 0 8.8 2.13a15.5 15.5 0 0 1 6.22 6.32q2.31 4.2 2.38 10.26v2.06h-26.35q.28 4.4 2.58 6.92 2.38 2.47 6.36 2.47a8.4 8.4 0 0 0 7.77-4.93l9.15.67q-1.68 5-6.29 7.99t-10.62 3q-5.53 0-9.65-2.27a16 16 0 0 1-6.43-6.46 20 20 0 0 1-2.31-9.72q0-5.52 2.3-9.72a16 16 0 0 1 6.44-6.46 20 20 0 0 1 9.64-2.26M86.9 36.69l17.24-34.33h10.8L89.96 49.63h-6.12L58.85 2.36h10.81zm71.62-15.55a11 11 0 0 1 2.47-4.48q2.28-2.31 6.38-2.31h3.4v7.26h-3.47q-2.91 0-4.79.8a5.8 5.8 0 0 0-2.77 2.5q-.9 1.72-.9 4.37v20.35h-8.89V14.35h8.33zm101.73 28.5h-8.95V2.35h8.95zM127.62 20.26q-3.7 0-6 2.2-2.32 2.2-2.87 6.2h17.05q-.48-4.34-2.72-6.33a7.8 7.8 0 0 0-5.46-2.07m101.2 0q-3.7 0-6 2.2-2.31 2.2-2.87 6.2H237q-.5-4.34-2.73-6.33a7.8 7.8 0 0 0-5.46-2.07" style="fill:black;fill-opacity:1;"/></svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

-34
View File
@@ -1,34 +0,0 @@
package agent
import "github.com/Twigpine/zero/internal/tools"
// CheckpointTargets returns the workspace-relative paths a tool call will mutate,
// resolving its arguments exactly as executeToolCall does. Freeform apply_patch
// input is normalized through the registered built-in tool, so absolute headers
// are rewritten relative to the selected write root and cwd is set: checkpoint
// capture and execution therefore agree on the files at stake.
//
// Best-effort: nil when the call has no capture targets or its arguments cannot
// be resolved. Checkpoint capture never fails a run.
func CheckpointTargets(workspaceRoot string, registry *tools.Registry, call ToolCall) []string {
if registry == nil {
return nil
}
args := map[string]any{}
if call.Freeform {
tool, ok := registry.Get(call.Name)
if !ok || !tools.IsBuiltInApplyPatch(tool) {
return nil
}
prepared, err := tools.PrepareFreeformApplyPatchArguments(tool, call.Arguments)
if err != nil {
return nil
}
args = prepared
} else if call.Arguments != "" {
if err := decodeToolArguments(call.Arguments, &args); err != nil {
return nil
}
}
return tools.MutationTargets(workspaceRoot, call.Name, args)
}
-99
View File
@@ -1,99 +0,0 @@
package agent
import (
"encoding/json"
"os"
"path/filepath"
"slices"
"testing"
"github.com/Twigpine/zero/internal/tools"
)
func TestCheckpointTargets(t *testing.T) {
root := t.TempDir()
// Absolute headers must be compared against the physical root: t.TempDir
// differs from it on macOS (/var -> /private/var) and via Windows short names.
if resolved, err := filepath.EvalSymlinks(root); err == nil {
root = resolved
}
if err := os.WriteFile(filepath.Join(root, "main.txt"), []byte("before\n"), 0o644); err != nil {
t.Fatal(err)
}
relativePatch := "*** Begin Patch\n*** Update File: main.txt\n@@\n-before\n+after\n*** End Patch\n"
absolutePatch := "*** Begin Patch\n*** Update File: " + filepath.Join(root, "main.txt") + "\n@@\n-before\n+after\n*** End Patch\n"
multiFilePatch := "*** Begin Patch\n*** Update File: main.txt\n@@\n-before\n+after\n*** Add File: notes.txt\n+new\n*** End Patch\n"
jsonArgs, err := json.Marshal(map[string]any{"patch": relativePatch})
if err != nil {
t.Fatal(err)
}
registry := tools.NewRegistry()
registry.Register(tools.NewScopedApplyPatchTool(root, nil))
unbuiltinRegistry := tools.NewRegistry()
unbuiltinRegistry.Register(&customApplyPatchTool{})
for _, tc := range []struct {
name string
registry *tools.Registry
call ToolCall
want []string
}{
{
name: "freeform relative header",
call: ToolCall{Name: "apply_patch", Arguments: relativePatch, Freeform: true},
want: []string{"main.txt"},
},
{
name: "freeform absolute header stays workspace-relative",
call: ToolCall{Name: "apply_patch", Arguments: absolutePatch, Freeform: true},
want: []string{"main.txt"},
},
{
name: "freeform multi-file patch",
call: ToolCall{Name: "apply_patch", Arguments: multiFilePatch, Freeform: true},
want: []string{"main.txt", "notes.txt"},
},
{
name: "json arguments",
call: ToolCall{Name: "apply_patch", Arguments: string(jsonArgs)},
want: []string{"main.txt"},
},
{
name: "freeform traversal is rejected",
call: ToolCall{Name: "apply_patch", Arguments: "*** Begin Patch\n*** Update File: ../escape.txt\n@@\n-x\n+y\n*** End Patch\n", Freeform: true},
want: nil,
},
{
name: "freeform custom apply_patch is not prepared",
registry: unbuiltinRegistry,
call: ToolCall{Name: "apply_patch", Arguments: relativePatch, Freeform: true},
want: nil,
},
{
name: "unknown freeform tool",
call: ToolCall{Name: "read_file", Arguments: "raw", Freeform: true},
want: nil,
},
{
name: "malformed json arguments",
call: ToolCall{Name: "apply_patch", Arguments: "{"},
want: nil,
},
} {
t.Run(tc.name, func(t *testing.T) {
reg := registry
if tc.registry != nil {
reg = tc.registry
}
got := CheckpointTargets(root, reg, tc.call)
if !slices.Equal(got, tc.want) {
t.Fatalf("CheckpointTargets() = %#v, want %#v", got, tc.want)
}
})
}
if got := CheckpointTargets(root, nil, ToolCall{Name: "apply_patch", Arguments: relativePatch}); got != nil {
t.Fatalf("CheckpointTargets(nil registry) = %#v, want nil", got)
}
}
-64
View File
@@ -80,9 +80,6 @@ func TestRunStopsAfterConsecutiveEmptyTurns(t *testing.T) {
if result.FinalAnswer == maxTurnsAnswer {
t.Fatalf("no-output guard must stop before reaching maxTurns, got max-turns answer")
}
if result.Incomplete {
t.Fatalf("interactive run must not be marked Incomplete; reason=%q", result.IncompleteReason)
}
}
func TestRunResetsEmptyTurnCounterOnVisibleOutput(t *testing.T) {
@@ -516,22 +513,6 @@ func repeatedFlakyTurns(n int) [][]zeroruntime.StreamEvent {
return turns
}
// alwaysSucceedingTool is the successful counterpart to alwaysFailingTool, used
// to put a productive tool turn before an empty-turn streak.
type alwaysSucceedingTool struct{}
func (alwaysSucceedingTool) Name() string { return "ok" }
func (alwaysSucceedingTool) Description() string { return "always succeeds for testing" }
func (alwaysSucceedingTool) Parameters() tools.Schema {
return tools.Schema{Type: "object", AdditionalProperties: false}
}
func (alwaysSucceedingTool) Safety() tools.Safety {
return tools.Safety{SideEffect: tools.SideEffectRead, Permission: tools.PermissionAllow}
}
func (alwaysSucceedingTool) Run(context.Context, map[string]any) tools.Result {
return tools.Result{Status: tools.StatusOK, Output: "ok"}
}
func TestRunStopsAfterRepeatedToolFailures(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(alwaysFailingTool{})
@@ -548,51 +529,6 @@ func TestRunStopsAfterRepeatedToolFailures(t *testing.T) {
if len(provider.requests) != toolFailureStopAt {
t.Fatalf("expected stop at %d failures, made %d requests", toolFailureStopAt, len(provider.requests))
}
if result.Incomplete {
t.Fatalf("interactive run must not be marked Incomplete; reason=%q", result.IncompleteReason)
}
}
// Under the headless completion gate both guardrail stops are unfinished runs,
// so they must set Result.Incomplete and a reason; exec maps that to exit 4.
// Thresholds and stop answers stay pinned by the two tests above.
func TestHeadlessGuardStopsAreIncomplete(t *testing.T) {
for _, tc := range []struct {
name string
turns [][]zeroruntime.StreamEvent
wantTurns int
wantReason string
}{
{"empty_turns", [][]zeroruntime.StreamEvent{emptyTurn(), emptyTurn(), emptyTurn()}, maxEmptyTurns, "3 consecutive turns with no output"},
{"empty_turns_after_progress", [][]zeroruntime.StreamEvent{
toolTurn("ok-1", "ok", "{}"),
emptyTurn(), emptyTurn(), emptyTurn(),
}, maxEmptyTurns + 1, "3 consecutive turns with no output"},
{"repeated_tool_failures", repeatedFlakyTurns(10), toolFailureStopAt, "flaky"},
} {
t.Run(tc.name, func(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(alwaysFailingTool{})
registry.Register(alwaysSucceedingTool{})
result, err := Run(context.Background(), "go", &mockProvider{turns: tc.turns}, Options{
Registry: registry,
MaxTurns: 12,
RequireCompletionSignal: true,
})
if err != nil {
t.Fatal(err)
}
if result.Turns != tc.wantTurns {
t.Fatalf("guard did not trigger: turns=%d want=%d", result.Turns, tc.wantTurns)
}
if !result.Incomplete {
t.Fatalf("guardrail stop under the headless gate must be Incomplete; final=%q", result.FinalAnswer)
}
if !strings.Contains(result.IncompleteReason, tc.wantReason) {
t.Fatalf("IncompleteReason = %q, want it to mention %q", result.IncompleteReason, tc.wantReason)
}
})
}
}
func TestRunInjectsToolFailureHintWithSchema(t *testing.T) {
-15
View File
@@ -587,14 +587,6 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
// counted toward the runaway cap so we stop before burning maxTurns.
if guards.observeTurn(collected) {
result.FinalAnswer = noOutputStopAnswer(result.Turns)
// Under the headless completion gate this stop is unfinished work,
// not a successful final answer: the guard exists precisely because
// the model made no progress. Interactive runs keep the gate off, so
// their behavior is unchanged.
if options.RequireCompletionSignal {
result.Incomplete = true
result.IncompleteReason = "stopped after " + strconv.Itoa(guards.emptyTurns) + " consecutive turns with no output"
}
result.Messages = copyMessages(messages)
return result, nil
}
@@ -816,13 +808,6 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
messages = appendAbortedToolResults(messages, collected.ToolCalls[index+1:])
messages = append(messages, buildToolImageMessages()...)
result.FinalAnswer = toolFailureStopAnswer(call.Name, outcome.Count)
// Same headless contract as the empty-turn guard: halting on a
// repeated failure is an unfinished run, not success. Gate-off
// (interactive / --no-completion-gate) keeps the old behavior.
if options.RequireCompletionSignal {
result.Incomplete = true
result.IncompleteReason = "the " + call.Name + " tool failed " + strconv.Itoa(outcome.Count) + " times in a row with the same error"
}
result.Messages = copyMessages(messages)
return result, nil
}
-45
View File
@@ -1,20 +1,14 @@
package agent
import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"github.com/Twigpine/zero/internal/config"
"github.com/Twigpine/zero/internal/sandbox"
"github.com/Twigpine/zero/internal/testutil"
)
// isolatedUserDirsRoot is where TestMain points every per-user directory.
var isolatedUserDirsRoot string
// TestMain points userConfigDirForPrompt at an empty, package-wide temp
// directory for every test in this package. Without this, buildSystemPrompt
// falls back to the real config.UserConfigDir, so any developer with a
@@ -23,56 +17,17 @@ var isolatedUserDirsRoot string
// across contributor machines. Tests that specifically exercise user
// guidelines stub userConfigDirForPrompt themselves via
// withSystemPromptTestUserConfigDir(Func) and restore this default on cleanup.
//
// It also points every per-user directory root at the same temp directory:
// sandboxed tool runs keep their runtime state and lease files under the user
// cache dir, so without this they land in the developer's real cache.
func TestMain(m *testing.M) {
dir, err := os.MkdirTemp("", "zero-agent-tests-*")
if err != nil {
panic(err)
}
isolatedUserDirsRoot = dir
restore, err := testutil.IsolateUserDirs(dir)
if err != nil {
os.RemoveAll(dir)
panic(err)
}
userConfigDirForPrompt = func() (string, error) { return dir, nil }
code := m.Run()
if err := restore(); err != nil {
fmt.Fprintln(os.Stderr, "agent tests: restore user dirs:", err)
code = 1
}
os.RemoveAll(dir)
os.Exit(code)
}
// Guards TestMain: the per-user directories the package can read or write
// must resolve inside the isolated root on every OS.
func TestUserDirsAreIsolatedFromTheDeveloperHome(t *testing.T) {
if isolatedUserDirsRoot == "" {
t.Fatal("TestMain did not record an isolated root")
}
home, err := os.UserHomeDir()
if err != nil {
t.Fatal(err)
}
userConfig, err := config.UserConfigDir()
if err != nil {
t.Fatal(err)
}
cache, err := os.UserCacheDir()
if err != nil {
t.Fatal(err)
}
for name, dir := range map[string]string{"home": home, "config": userConfig, "cache": cache} {
if !testutil.PathWithin(isolatedUserDirsRoot, dir) {
t.Errorf("%s dir %q is outside the isolated root %q", name, dir, isolatedUserDirsRoot)
}
}
}
func TestCoreSystemPromptIncludesCodingQualityRules(t *testing.T) {
prompt := strings.Join(strings.Fields(strings.ToLower(buildSystemPrompt(Options{}))), " ")
if strings.Contains(prompt, "clickable file paths") {
-57
View File
@@ -1,57 +0,0 @@
package aimlapi
import (
"net/http"
"net/url"
"strings"
)
const (
// SourceHeaderName and SourceValue identify Zero as the calling agent on
// requests to aimlapi.com services (the Toolbox and the MCP server).
SourceHeaderName = "X-AIMLAPI-Source"
SourceValue = "agent/zero"
// CanonicalInferenceBaseURL is the catalog inference endpoint. A provider
// profile whose base URL differs is treated as a proxy: its key is never sent
// to other aimlapi.com services.
CanonicalInferenceBaseURL = "https://api.aimlapi.com/v1"
// ToolboxBaseURL is the fixed AI/ML API Toolbox REST base. It is not
// configurable, so the key and attribution can only reach tools.aimlapi.com.
ToolboxBaseURL = "https://tools.aimlapi.com/v1/tools"
// MCPServerURL is the AI/ML API MCP server (streamable HTTP).
MCPServerURL = "https://mcp.aimlapi.com/mcp"
)
// attributedHosts are the only hostnames that receive the AI/ML API key or the
// attribution headers. Matching is exact; a suffix or substring match would
// also accept hosts such as tools.aimlapi.com.example.net.
var attributedHosts = [...]string{"api.aimlapi.com", "tools.aimlapi.com", "mcp.aimlapi.com"}
// IsAttributedURL reports whether target is an https URL on one of the
// aimlapi.com service hosts, on the default port and without userinfo.
func IsAttributedURL(target *url.URL) bool {
if target == nil || !strings.EqualFold(target.Scheme, "https") || target.User != nil {
return false
}
if port := target.Port(); port != "" && port != "443" {
return false
}
host := strings.ToLower(target.Hostname())
for _, candidate := range attributedHosts {
if host == candidate {
return true
}
}
return false
}
// ApplyAttributionHeaders sets the source and partner headers on header only
// when target is an aimlapi.com service URL; any other target is left as is.
func ApplyAttributionHeaders(header http.Header, target *url.URL) {
if header == nil || !IsAttributedURL(target) {
return
}
header.Set(SourceHeaderName, SourceValue)
header.Set(PartnerHeaderName, ResolvePartnerID(""))
}
-54
View File
@@ -1,54 +0,0 @@
package aimlapi
import (
"net/http"
"net/url"
"testing"
)
func TestIsAttributedURLMatchesExactServiceHostsOnly(t *testing.T) {
cases := map[string]bool{
"https://tools.aimlapi.com/v1/tools/run": true,
"https://mcp.aimlapi.com/mcp": true,
"https://api.aimlapi.com/v1": true,
"https://TOOLS.aimlapi.com:443/v1/tools": true,
"http://tools.aimlapi.com/v1/tools/run": false,
"https://tools.aimlapi.com:8443/v1/tools": false,
"https://[email protected]/v1/tools": false,
"https://tools.aimlapi.com.example.net/run": false,
"https://eviltools.aimlapi.com/run": false,
"https://aimlapi.com/app": false,
"https://auth.aimlapi.com/": false,
"https://example.com/?h=tools.aimlapi.com": false,
}
for raw, want := range cases {
target, err := url.Parse(raw)
if err != nil {
t.Fatalf("parse %q: %v", raw, err)
}
if got := IsAttributedURL(target); got != want {
t.Errorf("IsAttributedURL(%q) = %v, want %v", raw, got, want)
}
}
if IsAttributedURL(nil) {
t.Fatal("IsAttributedURL(nil) = true")
}
}
func TestApplyAttributionHeadersSetsSourceAndPartnerOnServiceHosts(t *testing.T) {
t.Setenv("AIMLAPI_PARTNER_ID", "")
header := http.Header{}
ApplyAttributionHeaders(header, &url.URL{Scheme: "https", Host: "tools.aimlapi.com", Path: "/v1/tools/run"})
if got := header.Get(SourceHeaderName); got != "agent/zero" {
t.Fatalf("%s = %q, want agent/zero", SourceHeaderName, got)
}
if got := header.Get(PartnerHeaderName); got != DefaultPartnerID {
t.Fatalf("%s = %q, want %q", PartnerHeaderName, got, DefaultPartnerID)
}
other := http.Header{}
ApplyAttributionHeaders(other, &url.URL{Scheme: "https", Host: "tools.aimlapi.com.example.net"})
if len(other) != 0 {
t.Fatalf("attribution reached a non-aimlapi host: %#v", other)
}
}
+2 -25
View File
@@ -964,7 +964,6 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
// report can be combined with the plugin activation's, and emit at most one
// notice when project hooks/plugins were dropped for an untrusted workspace.
hookDispatcher, hookSkip := newHookDispatcherWithExtra(workspaceRoot, pluginActivation.hooks, trustRoot, executionRunner)
specialistRuntime.attachLifecycleHooks(hookDispatcher)
emitTrustNotice(stderr, hookSkip, pluginActivation.trustSkip, mcpSkip)
// From the engine the session's commands run through, so the notice
// describes the sandbox they actually get.
@@ -1222,9 +1221,6 @@ type agentToolRuntime struct {
// the orchestrator's system-prompt delegation section. Populated alongside the
// tools, so it is present exactly when the Task tool is.
specialists []agent.SpecialistInfo
// lifecycleHooks is shared with the specialist executor so the hooks
// dispatcher (built later in startup) can be attached before any specialist runs.
lifecycleHooks *specialist.LifecycleHooks
}
// specialistInfos returns the runtime's specialist summaries, nil-safe so the
@@ -1236,26 +1232,12 @@ func (r *agentToolRuntime) specialistInfos() []agent.SpecialistInfo {
return r.specialists
}
func (r *agentToolRuntime) attachLifecycleHooks(dispatcher *hooks.Dispatcher) {
if r == nil || r.lifecycleHooks == nil || dispatcher == nil {
return
}
r.lifecycleHooks.Dispatch = func(ctx context.Context, event string, specialistName string, payload map[string]any) {
dispatcher.Dispatch(ctx, hooks.DispatchInput{
Event: hooks.Event(event),
ToolName: specialistName,
Payload: payload,
})
}
}
func registerSpecialistTools(registry *tools.Registry, workspaceRoot string, maxTeamSize int) (*agentToolRuntime, error) {
paths, err := specialist.DefaultPaths(workspaceRoot)
if err != nil {
return nil, err
}
lifecycleHooks := &specialist.LifecycleHooks{}
executor := specialist.Executor{Paths: paths, LifecycleHooks: lifecycleHooks}
executor := specialist.Executor{Paths: paths}
runtime, err := specialist.RegisterTools(registry, executor)
if err != nil {
return nil, err
@@ -1274,12 +1256,7 @@ func registerSpecialistTools(registry *tools.Registry, workspaceRoot string, max
return nil, err
}
swarm.RegisterTools(registry, sw)
return &agentToolRuntime{
specialist: runtime,
swarm: sw,
specialists: specialistSummaries(paths),
lifecycleHooks: lifecycleHooks,
}, nil
return &agentToolRuntime{specialist: runtime, swarm: sw, specialists: specialistSummaries(paths)}, nil
}
// specialistSummaries loads the available specialists (built-ins + user/project
+1 -13
View File
@@ -92,25 +92,13 @@ func runSkillAdd(args []string, dir string, stdout io.Writer, stderr io.Writer)
lines := []string{}
if result.Updated {
lines = append(lines, fmt.Sprintf("Updated skill %q.", result.Name))
switch {
case result.PreviousHash != result.Hash:
lines = append(lines, fmt.Sprintf(" hash: %s -> %s", shortHash(result.PreviousHash), shortHash(result.Hash)))
case result.PreviousTreeHash == "":
// The install this replaced predates tree hashes and copied SKILL.md
// alone, so the bundled files are new rather than changed.
lines = append(lines, " SKILL.md is unchanged; the files bundled with it are now installed too")
default:
lines = append(lines, " SKILL.md is unchanged; the files bundled with it changed")
}
lines = append(lines, fmt.Sprintf(" hash: %s -> %s", shortHash(result.PreviousHash), shortHash(result.Hash)))
} else {
lines = append(lines, fmt.Sprintf("Installed skill %q.", result.Name))
lines = append(lines, " hash: "+result.Hash)
}
lines = append(lines, " source: "+result.Source)
lines = append(lines, " path: "+result.Path)
if len(result.SkippedLinks) > 0 {
lines = append(lines, " not copied (symlinks): "+strings.Join(result.SkippedLinks, ", "))
}
if _, err := fmt.Fprintln(stdout, redaction.RedactString(strings.Join(lines, "\n"), redaction.Options{})); err != nil {
return exitCrash
}
-132
View File
@@ -7,8 +7,6 @@ import (
"path/filepath"
"strings"
"testing"
"github.com/Twigpine/zero/internal/skills"
)
func writeSourceSkillDir(t *testing.T, dir string, content string) string {
@@ -393,133 +391,3 @@ func TestRunDistributionCommandsDoNotLaunchTUI(t *testing.T) {
}
}
}
// A reinstall that only changed a bundled script must still read as an update
// (#584): the SKILL.md hash is the same, so "hash: X -> X" would say nothing.
func TestRunSkillAddReportsAnUpdateToBundledFilesOnly(t *testing.T) {
skillsDir := t.TempDir()
src := writeSourceSkillDir(t, filepath.Join(t.TempDir(), "src"),
"---\nname: pdf\ndescription: Fill forms.\n---\nRun scripts/fill.py.\n")
script := filepath.Join(src, "scripts", "fill.py")
if err := os.MkdirAll(filepath.Dir(script), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(script, []byte("print('v1')\n"), 0o755); err != nil {
t.Fatal(err)
}
deps := appDeps{skillsDir: func() string { return skillsDir }}
var stdout, stderr bytes.Buffer
if exit := runWithDeps([]string{"skill", "add", src}, &stdout, &stderr, deps); exit != 0 {
t.Fatalf("first skill add exit = %d, stderr = %s", exit, stderr.String())
}
if err := os.WriteFile(script, []byte("print('v2')\n"), 0o755); err != nil {
t.Fatal(err)
}
stdout.Reset()
stderr.Reset()
if exit := runWithDeps([]string{"skill", "add", src}, &stdout, &stderr, deps); exit != 0 {
t.Fatalf("second skill add exit = %d, stderr = %s", exit, stderr.String())
}
for _, want := range []string{`Updated skill "pdf".`, "SKILL.md is unchanged; the files bundled with it changed"} {
if !strings.Contains(stdout.String(), want) {
t.Fatalf("second add output does not say %q:\n%s", want, stdout.String())
}
}
}
// An install from before tree hashes copied SKILL.md alone and recorded no tree
// hash. Adding the skill again over that entry must not claim bundled files
// changed: there are none, or they are installed for the first time.
func TestRunSkillAddOverAnEntryWithoutATreeHash(t *testing.T) {
for _, tc := range []struct {
name string
script bool
want []string
notWant []string
}{
{
name: "SKILL.md only",
want: []string{`Installed skill "pdf".`},
notWant: []string{"Updated skill", "files bundled with it"},
},
{
name: "with a bundled script",
script: true,
want: []string{`Updated skill "pdf".`, "SKILL.md is unchanged; the files bundled with it are now installed too"},
notWant: []string{"files bundled with it changed"},
},
} {
t.Run(tc.name, func(t *testing.T) {
skillsDir := t.TempDir()
src := writeSourceSkillDir(t, filepath.Join(t.TempDir(), "src"),
"---\nname: pdf\ndescription: Fill forms.\n---\nRun scripts/fill.py.\n")
if tc.script {
script := filepath.Join(src, "scripts", "fill.py")
if err := os.MkdirAll(filepath.Dir(script), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(script, []byte("print('fill')\n"), 0o755); err != nil {
t.Fatal(err)
}
}
deps := appDeps{skillsDir: func() string { return skillsDir }}
var stdout, stderr bytes.Buffer
if exit := runWithDeps([]string{"skill", "add", src}, &stdout, &stderr, deps); exit != 0 {
t.Fatalf("first skill add exit = %d, stderr = %s", exit, stderr.String())
}
lock, err := skills.ReadLock(skillsDir)
if err != nil {
t.Fatal(err)
}
entry := lock["pdf"]
entry.TreeHash = ""
lock["pdf"] = entry
data, err := json.Marshal(lock)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(skillsDir, skills.LockFileName), data, 0o644); err != nil {
t.Fatal(err)
}
stdout.Reset()
stderr.Reset()
if exit := runWithDeps([]string{"skill", "add", src}, &stdout, &stderr, deps); exit != 0 {
t.Fatalf("second skill add exit = %d, stderr = %s", exit, stderr.String())
}
for _, want := range tc.want {
if !strings.Contains(stdout.String(), want) {
t.Fatalf("second add output does not say %q:\n%s", want, stdout.String())
}
}
for _, notWant := range tc.notWant {
if strings.Contains(stdout.String(), notWant) {
t.Fatalf("second add output says %q:\n%s", notWant, stdout.String())
}
}
})
}
}
func TestRunSkillAddListsTheSymlinksItDidNotCopy(t *testing.T) {
skillsDir := t.TempDir()
src := writeSourceSkillDir(t, filepath.Join(t.TempDir(), "src"),
"---\nname: pdf\ndescription: Fill forms.\n---\nbody\n")
outside := filepath.Join(t.TempDir(), "notes.md")
if err := os.WriteFile(outside, []byte("outside\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Symlink(outside, filepath.Join(src, "notes.md")); err != nil {
t.Skipf("symlinks unavailable: %v", err)
}
var stdout, stderr bytes.Buffer
deps := appDeps{skillsDir: func() string { return skillsDir }}
if exit := runWithDeps([]string{"skill", "add", src}, &stdout, &stderr, deps); exit != 0 {
t.Fatalf("skill add exit = %d, stderr = %s", exit, stderr.String())
}
if !strings.Contains(stdout.String(), "not copied (symlinks): notes.md") {
t.Fatalf("add output does not list the skipped symlink:\n%s", stdout.String())
}
}
+1 -2
View File
@@ -655,7 +655,6 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
// can be combined with the plugin activation's, and emit at most one notice when
// project hooks/plugins were dropped for an untrusted workspace.
hookDispatcher, hookSkip := newHookDispatcherWithExtra(workspaceRoot, pluginActivation.hooks, trustRoot, executionRunner)
specialistRuntime.attachLifecycleHooks(hookDispatcher)
emitTrustNotice(stderr, hookSkip, pluginActivation.trustSkip, mcpSkip)
result, err := agent.Run(runCtx, agentPrompt, provider, agent.Options{
MaxTurns: resolved.MaxTurns,
@@ -713,7 +712,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
"arguments": call.Arguments,
})
// Snapshot before-state of files this call will mutate (safe rewind).
if checkpoint, ok := sessionRecorder.captureCheckpoint(workspaceRoot, registry, call); ok {
if checkpoint, ok := sessionRecorder.captureCheckpoint(workspaceRoot, call); ok {
writer.checkpoint(checkpoint)
}
},
@@ -1,206 +0,0 @@
package cli
import (
"bytes"
"context"
"encoding/json"
"os"
"path/filepath"
"testing"
"github.com/Twigpine/zero/internal/agent"
"github.com/Twigpine/zero/internal/config"
"github.com/Twigpine/zero/internal/sessions"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
)
func freeformApplyPatchArguments(root string, absolute bool) string {
target := "main.txt"
if absolute {
target = filepath.Join(root, "main.txt")
}
return "*** Begin Patch\n*** Update File: " + target + "\n@@\n-before\n+after\n*** End Patch\n"
}
// captureCheckpoint must resolve a freeform apply_patch call through the same
// preparation execution uses, so the before-state is captured and rewind can
// restore it. The JSON control pins the unchanged path.
func TestCaptureCheckpointFreeformApplyPatch(t *testing.T) {
for _, tc := range []struct {
name string
freeform bool
absolute bool
jsonArgs bool
}{
{name: "freeform", freeform: true},
{name: "freeform absolute header", freeform: true, absolute: true},
{name: "json arguments", jsonArgs: true},
} {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("XDG_DATA_HOME", t.TempDir())
t.Setenv("ZERO_CHECKPOINTS", "on")
root := t.TempDir()
// The absolute-header case needs the physical root spelling the tool
// resolves (macOS /var vs /private/var, Windows short names).
if resolved, err := filepath.EvalSymlinks(root); err == nil {
root = resolved
}
if err := os.WriteFile(filepath.Join(root, "main.txt"), []byte("before\n"), 0o644); err != nil {
t.Fatal(err)
}
store := sessions.NewStore(sessions.StoreOptions{})
session, err := store.Create(sessions.CreateInput{Title: "freeform checkpoint"})
if err != nil {
t.Fatal(err)
}
if _, err := store.AppendEvent(session.SessionID, sessions.AppendEventInput{
Type: sessions.EventMessage,
Payload: map[string]string{"role": "user", "content": "apply the patch"},
}); err != nil {
t.Fatal(err)
}
recorder := &execSessionRecorder{prepared: sessions.PreparedExec{Store: store, Session: session}}
registry := tools.NewRegistry()
registry.Register(tools.NewScopedApplyPatchTool(root, nil))
call := agent.ToolCall{ID: "call", Name: "apply_patch", Freeform: tc.freeform}
if tc.jsonArgs {
encoded, err := json.Marshal(map[string]string{"patch": freeformApplyPatchArguments(root, false)})
if err != nil {
t.Fatal(err)
}
call.Arguments = string(encoded)
} else {
call.Arguments = freeformApplyPatchArguments(root, tc.absolute)
}
event, ok := recorder.captureCheckpoint(root, registry, call)
if !ok || event.Type != sessions.EventSessionCheckpoint {
t.Fatalf("captureCheckpoint() = %#v, %t; want a checkpoint event", event, ok)
}
var payload sessions.CheckpointPayload
if err := json.Unmarshal(event.Payload, &payload); err != nil {
t.Fatalf("decode checkpoint payload: %v", err)
}
if len(payload.Files) != 1 || payload.Files[0].Path != "main.txt" || payload.Files[0].Blob == "" {
t.Fatalf("checkpoint files = %#v, want main.txt with a blob", payload.Files)
}
// Simulate the tool's mutation, then rewind past it.
if err := os.WriteFile(filepath.Join(root, "main.txt"), []byte("after\n"), 0o644); err != nil {
t.Fatal(err)
}
report, err := store.ApplyRewind(session.SessionID, root, 1)
if err != nil {
t.Fatalf("ApplyRewind: %v", err)
}
if report.FilesRestored != 1 {
t.Fatalf("restore report = %#v, want one restored file", report)
}
content, err := os.ReadFile(filepath.Join(root, "main.txt"))
if err != nil {
t.Fatal(err)
}
if string(content) != "before\n" {
t.Fatalf("restored content = %q, want before newline", content)
}
})
}
}
// freeformPatchExecProvider emits one native freeform apply_patch call, then a
// final text turn.
type freeformPatchExecProvider struct {
patch string
calls int
}
func (provider *freeformPatchExecProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
ch := make(chan zeroruntime.StreamEvent, 5)
calls := provider.calls
provider.calls++
if calls == 0 {
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call_patch", ToolName: "apply_patch", ToolCallFreeform: true}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call_patch", ArgumentsFragment: provider.patch}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call_patch"}
} else {
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: "patched"}
}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
close(ch)
return ch, nil
}
// End-to-end: exec captures the freeform checkpoint and `sessions rewind`
// restores the pre-patch content.
func TestRunExecFreeformApplyPatchCheckpointsForRewind(t *testing.T) {
isolateCLIUserState(t)
t.Setenv("ZERO_CHECKPOINTS", "on")
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "main.txt"), []byte("before\n"), 0o644); err != nil {
t.Fatal(err)
}
provider := &freeformPatchExecProvider{patch: freeformApplyPatchArguments(root, false)}
var stdout, stderr bytes.Buffer
exitCode := runWithDeps([]string{
"exec", "--init-session-id", "freeform_rewind", "--skip-permissions-unsafe",
"--output-format", "stream-json", "apply the patch",
}, &stdout, &stderr, appDeps{
getwd: func() (string, error) { return root, nil },
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
return execResolvedConfig(), nil
},
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
return provider, nil
},
})
if exitCode != exitSuccess {
t.Fatalf("exit = %d, want %d; stderr=%q stdout=%q", exitCode, exitSuccess, stderr.String(), stdout.String())
}
content, err := os.ReadFile(filepath.Join(root, "main.txt"))
if err != nil {
t.Fatal(err)
}
if string(content) != "after\n" {
t.Fatalf("patched content = %q, want after newline", content)
}
store := sessions.NewStore(sessions.StoreOptions{})
events, err := store.ReadEvents("freeform_rewind")
if err != nil {
t.Fatalf("ReadEvents: %v", err)
}
checkpoints := 0
for _, event := range events {
if event.Type == sessions.EventSessionCheckpoint {
checkpoints++
}
}
if checkpoints != 1 {
t.Fatalf("checkpoint events = %d, want 1; events=%v", checkpoints, sessionEventTypes(events))
}
report, err := store.ApplyRewind("freeform_rewind", root, 1)
if err != nil {
t.Fatalf("ApplyRewind: %v", err)
}
if report.FilesRestored != 1 {
t.Fatalf("restore report = %#v, want one restored file", report)
}
content, err = os.ReadFile(filepath.Join(root, "main.txt"))
if err != nil {
t.Fatal(err)
}
if string(content) != "before\n" {
t.Fatalf("rewound content = %q, want before newline", content)
}
}
func sessionEventTypes(events []sessions.Event) []string {
types := make([]string, 0, len(events))
for _, event := range events {
types = append(types, string(event.Type))
}
return types
}
@@ -1,90 +0,0 @@
package cli
import (
"bytes"
"context"
"slices"
"strings"
"testing"
"github.com/Twigpine/zero/internal/config"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// guardStopExecProvider emits turns with no visible text and no tool calls, so
// the empty-turn guardrail stops the run after its consecutive-empty-turn cap.
type guardStopExecProvider struct{}
func (guardStopExecProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
ch := make(chan zeroruntime.StreamEvent, 1)
select {
case <-ctx.Done():
close(ch)
return ch, ctx.Err()
case ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}:
}
close(ch)
return ch, nil
}
// A guardrail stop in headless exec must use the existing INCOMPLETE contract
// (exit 4, error event, run_end status), and --no-completion-gate must keep
// reporting success. isolateCLIUserState also turns off the models.dev refresh
// that runExec starts in the background (see the #1152/#1161 flake).
func TestRunExecGuardrailStopIncompleteContract(t *testing.T) {
for _, tc := range []struct {
name string
args []string
wantExit int
wantStatus string
}{
{"gate on", []string{"exec", "--output-format", "stream-json", "complete the task"}, exitIncomplete, "incomplete"},
{"gate off", []string{"exec", "--no-completion-gate", "--output-format", "stream-json", "complete the task"}, exitSuccess, "success"},
} {
t.Run(tc.name, func(t *testing.T) {
isolateCLIUserState(t)
cwd := t.TempDir()
var stdout, stderr bytes.Buffer
exitCode := runWithDeps(tc.args, &stdout, &stderr, appDeps{
getwd: func() (string, error) { return cwd, nil },
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
return execResolvedConfig(), nil
},
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
return guardStopExecProvider{}, nil
},
})
if exitCode != tc.wantExit {
t.Fatalf("exit code = %d, want %d; stderr=%q", exitCode, tc.wantExit, stderr.String())
}
events := decodeJSONLines(t, stdout.String())
if len(events) == 0 || events[len(events)-1]["type"] != "run_end" {
t.Fatalf("expected run_end as the last event, got %#v", events)
}
runEnd := events[len(events)-1]
if runEnd["status"] != tc.wantStatus {
t.Fatalf("run_end status = %v, want %q", runEnd["status"], tc.wantStatus)
}
if runEnd["exitCode"] != float64(tc.wantExit) {
t.Fatalf("run_end exitCode = %v, want %d", runEnd["exitCode"], tc.wantExit)
}
finalEvent := findJSONEvent(t, events, "final")
if text, _ := finalEvent["text"].(string); !strings.Contains(text, "no output") {
t.Fatalf("final text = %q, want the guard stop answer", text)
}
if tc.wantStatus == "incomplete" {
errorEvent := findJSONEvent(t, events, "error")
if errorEvent["code"] != "incomplete" {
t.Fatalf("error code = %v, want incomplete", errorEvent["code"])
}
if message, _ := errorEvent["message"].(string); !strings.Contains(message, "no output") {
t.Fatalf("error message = %q, want the guard reason", message)
}
} else if slices.Contains(jsonEventTypes(events), "error") {
t.Fatalf("gate-off run must not emit an error event: %#v", events)
}
})
}
}
+7 -2
View File
@@ -1,6 +1,7 @@
package cli
import (
"encoding/json"
"fmt"
"io"
"strings"
@@ -18,11 +19,15 @@ type execSessionRecorder struct {
// captureCheckpoint snapshots the before-state of files a tool call will mutate.
// Best-effort: failures never affect the run. Returns the checkpoint event and
// true when one was recorded.
func (recorder *execSessionRecorder) captureCheckpoint(workspaceRoot string, registry *tools.Registry, call agent.ToolCall) (sessions.Event, bool) {
func (recorder *execSessionRecorder) captureCheckpoint(workspaceRoot string, call agent.ToolCall) (sessions.Event, bool) {
if recorder.prepared.Store == nil || recorder.prepared.Session.SessionID == "" {
return sessions.Event{}, false
}
targets := agent.CheckpointTargets(workspaceRoot, registry, call)
var args map[string]any
if call.Arguments != "" {
_ = json.Unmarshal([]byte(call.Arguments), &args)
}
targets := tools.MutationTargets(workspaceRoot, call.Name, args)
if len(targets) == 0 {
return sessions.Event{}, false
}
+1 -1
View File
@@ -148,7 +148,7 @@ func runExecSpecDraft(run execSpecDraftRun) int {
"name": call.Name,
"arguments": call.Arguments,
})
if checkpoint, ok := sessionRecorder.captureCheckpoint(run.workspaceRoot, run.registry, call); ok {
if checkpoint, ok := sessionRecorder.captureCheckpoint(run.workspaceRoot, call); ok {
writer.checkpoint(checkpoint)
}
},
-116
View File
@@ -2,7 +2,6 @@ package cli
import (
"context"
"errors"
"fmt"
"io"
"strings"
@@ -83,105 +82,11 @@ func runPlugins(args []string, stdout io.Writer, stderr io.Writer, deps appDeps)
return runPluginInfo(args[1:], stdout, stderr, deps)
case "remove", "rm":
return runPluginRemove(args[1:], deps.pluginsDir(), stdout, stderr)
case "enable":
return runPluginsToggle(args[1:], stdout, stderr, deps, false)
case "disable":
return runPluginsToggle(args[1:], stdout, stderr, deps, true)
default:
return writeExecUsageError(stderr, fmt.Sprintf("unknown plugins subcommand %q", args[0]))
}
}
type pluginToggleOptions struct {
json bool
user bool
}
// runPluginsToggle implements `zero plugins enable|disable <id>`.
//
// Every message it writes, including usage errors that echo the caller's
// arguments, passes through the redaction pipeline: an argument is user input
// and can be a mistyped credential.
func runPluginsToggle(args []string, stdout io.Writer, stderr io.Writer, deps appDeps, disabled bool) int {
commandName := "enable"
if disabled {
commandName = "disable"
}
usageError := func(message string) int {
return writeExecUsageError(stderr, redaction.RedactString(message, redaction.Options{}))
}
options, positional, help, err := parsePluginToggleArgs(args, commandName)
if err != nil {
return usageError(err.Error())
}
if help {
if err := writePluginsToggleHelp(stdout, commandName); err != nil {
return exitCrash
}
return exitSuccess
}
if len(positional) != 1 {
return usageError(fmt.Sprintf("usage: zero plugins %s <id> [--user] [--json]", commandName))
}
pluginID := strings.TrimSpace(positional[0])
if pluginID == "" {
return usageError(fmt.Sprintf("usage: zero plugins %s <id> [--user] [--json]", commandName))
}
cwd, err := deps.getwd()
if err != nil {
return writeAppError(stderr, "failed to resolve workspace: "+redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
loadOptions := plugins.LoadOptions{Cwd: cwd, ExcludeProject: options.user}
result, err := plugins.SetEnabledByID(loadOptions, pluginID, !disabled)
if err != nil {
if errors.Is(err, plugins.ErrNotInstalled) {
return usageError(err.Error())
}
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
state := "enabled"
if disabled {
state = "disabled"
}
if options.json {
if err := writePrettyJSON(stdout, redaction.RedactValue(result, redaction.Options{})); err != nil {
return exitCrash
}
return exitSuccess
}
message := fmt.Sprintf("Plugin %s is now %s in %s.\n", result.ID, state, result.ManifestPath)
if !result.Changed {
message = fmt.Sprintf("Plugin %s was already %s in %s.\n", result.ID, state, result.ManifestPath)
}
if _, err := fmt.Fprint(stdout, redaction.RedactString(message, redaction.Options{})); err != nil {
return exitCrash
}
return exitSuccess
}
func parsePluginToggleArgs(args []string, command string) (pluginToggleOptions, []string, bool, error) {
options := pluginToggleOptions{}
positional := []string{}
for _, arg := range args {
switch arg {
case "-h", "--help", "help":
return options, positional, true, nil
case "--json":
options.json = true
case "--user":
options.user = true
default:
if strings.HasPrefix(arg, "-") {
return options, positional, false, execUsageError{fmt.Sprintf("unknown plugins %s flag %q", command, arg)}
}
positional = append(positional, arg)
}
}
return options, positional, false, nil
}
func runHooks(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
if len(args) == 0 {
return writeExecUsageError(stderr, "hooks subcommand required. Use `zero hooks list`.")
@@ -661,31 +566,10 @@ Commands:
info <id> Show plugin details and lockfile metadata
add <git-url|path> Install a plugin (manifest-validated, pinned in plugins.lock)
remove <id> Remove an installed plugin and its lockfile entry
enable <id> Enable a plugin via its plugin.json
disable <id> Disable a plugin via its plugin.json
`)
return err
}
func writePluginsToggleHelp(w io.Writer, command string) error {
_, err := fmt.Fprintf(w, `Usage:
zero plugins %s <id> [flags]
Flags:
--user Target only user plugins (ignore ./.zero/plugins)
--json Print command result as JSON
-h, --help Show this help
Without --user, a project plugin with the same id takes precedence over a user
plugin, matching the loader's discovery order.
Zero reads plugins when it starts, so the change applies to the next zero exec
run or interactive session. A running interactive session keeps the plugins it
started with until you restart it.
`, command)
return err
}
func writePluginsListHelp(w io.Writer) error {
_, err := fmt.Fprint(w, `Usage:
zero plugins list [flags]
-232
View File
@@ -5,7 +5,6 @@ import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
@@ -334,234 +333,3 @@ func TestRunMCPPermissionsHelpDoesNotOpenStore(t *testing.T) {
})
}
}
func TestRunPluginsEnableDisable(t *testing.T) {
cwd := t.TempDir()
home := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", home)
t.Setenv("HOME", home)
pluginDir := filepath.Join(cwd, ".zero", "plugins", "demo")
if err := os.MkdirAll(pluginDir, 0o700); err != nil {
t.Fatal(err)
}
manifestPath := filepath.Join(pluginDir, "plugin.json")
if err := os.WriteFile(manifestPath, []byte(`{
"schemaVersion": 1,
"id": "zero.demo",
"name": "Demo",
"version": "1.0.0"
}
`), 0o600); err != nil {
t.Fatal(err)
}
deps := appDeps{getwd: func() (string, error) { return cwd, nil }}
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"plugins", "disable", "zero.demo", "--json"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("disable exit=%d stderr=%s", exitCode, stderr.String())
}
var disablePayload plugins.SetEnabledResult
if err := json.Unmarshal(stdout.Bytes(), &disablePayload); err != nil {
t.Fatalf("decode disable JSON: %v\n%s", err, stdout.String())
}
if disablePayload.ID != "zero.demo" || disablePayload.Enabled || !disablePayload.Changed {
t.Fatalf("unexpected disable payload: %#v", disablePayload)
}
raw, err := os.ReadFile(manifestPath)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(raw), `"enabled": false`) {
t.Fatalf("manifest not disabled: %s", raw)
}
stdout.Reset()
stderr.Reset()
exitCode = runWithDeps([]string{"plugins", "disable", "zero.demo"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("noop disable exit=%d stderr=%s", exitCode, stderr.String())
}
if !strings.Contains(stdout.String(), "was already disabled") {
t.Fatalf("noop disable output: %s", stdout.String())
}
stdout.Reset()
stderr.Reset()
exitCode = runWithDeps([]string{"plugins", "enable", "zero.demo"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("enable exit=%d stderr=%s", exitCode, stderr.String())
}
if !strings.Contains(stdout.String(), "is now enabled") {
t.Fatalf("enable output: %s", stdout.String())
}
stdout.Reset()
stderr.Reset()
exitCode = runWithDeps([]string{"plugins", "enable", "missing-plugin"}, &stdout, &stderr, deps)
if exitCode != exitUsage {
t.Fatalf("missing plugin exit=%d want %d stderr=%s", exitCode, exitUsage, stderr.String())
}
}
// The text-mode toggle output runs through the redaction pipeline, so a
// manifest path that contains something secret-shaped is masked.
func TestRunPluginsToggleTextOutputIsRedacted(t *testing.T) {
cwd := t.TempDir()
home := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", home)
t.Setenv("HOME", home)
secretDir := "sk-proj-abcdefghijklmnopqrstuvwxyz"
pluginDir := filepath.Join(cwd, ".zero", "plugins", secretDir)
if err := os.MkdirAll(pluginDir, 0o700); err != nil {
t.Fatal(err)
}
manifestPath := filepath.Join(pluginDir, "plugin.json")
if err := os.WriteFile(manifestPath, []byte(`{
"schemaVersion": 1,
"id": "zero.demo",
"name": "Demo",
"version": "1.0.0"
}
`), 0o600); err != nil {
t.Fatal(err)
}
deps := appDeps{getwd: func() (string, error) { return cwd, nil }}
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"plugins", "disable", "zero.demo"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("disable exit=%d stderr=%s", exitCode, stderr.String())
}
if strings.Contains(stdout.String(), secretDir) {
t.Fatalf("toggle text leaked a secret-shaped manifest path: %q", stdout.String())
}
if !strings.Contains(stdout.String(), "[REDACTED]") {
t.Fatalf("toggle text was not redacted: %q", stdout.String())
}
stdout.Reset()
stderr.Reset()
exitCode = runWithDeps([]string{"plugins", "disable", "zero.demo"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("noop disable exit=%d stderr=%s", exitCode, stderr.String())
}
if !strings.Contains(stdout.String(), "was already disabled") {
t.Fatalf("noop toggle output: %q", stdout.String())
}
if strings.Contains(stdout.String(), secretDir) {
t.Fatalf("noop toggle text leaked a secret-shaped manifest path: %q", stdout.String())
}
}
func TestRunPluginsToggleRejectsEmptyID(t *testing.T) {
deps := appDeps{getwd: func() (string, error) { return t.TempDir(), nil }}
for _, subcommand := range []string{"enable", "disable"} {
t.Run(subcommand, func(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"plugins", subcommand, ""}, &stdout, &stderr, deps)
if exitCode != exitUsage {
t.Fatalf("exit=%d want %d stderr=%s", exitCode, exitUsage, stderr.String())
}
if !strings.Contains(stderr.String(), "usage: zero plugins "+subcommand) {
t.Fatalf("stderr = %q", stderr.String())
}
})
}
}
func TestRunPluginsDisableUserIgnoresProject(t *testing.T) {
home := t.TempDir()
cwd := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", home)
t.Setenv("HOME", home)
userManifest := filepath.Join(home, "zero", "plugins", "demo", "plugin.json")
projectManifest := filepath.Join(cwd, ".zero", "plugins", "demo", "plugin.json")
for _, path := range []string{userManifest, projectManifest} {
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(`{
"schemaVersion": 1,
"id": "zero.demo",
"name": "Demo",
"version": "1.0.0",
"enabled": true
}
`), 0o600); err != nil {
t.Fatal(err)
}
}
deps := appDeps{getwd: func() (string, error) { return cwd, nil }}
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"plugins", "disable", "zero.demo", "--user"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("disable --user exit=%d stderr=%s", exitCode, stderr.String())
}
if !strings.Contains(stdout.String(), "is now disabled") {
t.Fatalf("disable --user output: %s", stdout.String())
}
userRaw, err := os.ReadFile(userManifest)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(userRaw), `"enabled": false`) {
t.Fatalf("user manifest not disabled: %s", userRaw)
}
projectRaw, err := os.ReadFile(projectManifest)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(projectRaw), `"enabled": false`) {
t.Fatalf("project manifest should stay enabled: %s", projectRaw)
}
}
// The toggle's usage errors echo the caller's arguments, which can be a
// mistyped credential, so they pass through redaction like its other output.
func TestRunPluginsToggleUsageErrorsAreRedacted(t *testing.T) {
home := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", home)
t.Setenv("HOME", home)
deps := appDeps{getwd: func() (string, error) { return t.TempDir(), nil }}
const secret = "sk-proj-abcdefghijklmnopqrstuvwxyz"
cases := []struct {
name string
args []string
want string
}{
{"unknown flag", []string{"zero.demo", "--token=" + secret}, "unknown plugins"},
{"missing plugin", []string{secret}, "plugin is not installed"},
}
for _, subcommand := range []string{"enable", "disable"} {
for _, tc := range cases {
t.Run(subcommand+" "+tc.name, func(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
args := append([]string{"plugins", subcommand}, tc.args...)
exitCode := runWithDeps(args, &stdout, &stderr, deps)
if exitCode != exitUsage {
t.Fatalf("exit=%d want %d stderr=%s", exitCode, exitUsage, stderr.String())
}
if strings.Contains(stderr.String(), secret) {
t.Fatalf("usage error leaked the argument: %q", stderr.String())
}
if !strings.Contains(stderr.String(), tc.want) || !strings.Contains(stderr.String(), "[REDACTED]") {
t.Fatalf("stderr = %q, want a redacted %q error", stderr.String(), tc.want)
}
})
}
}
}
+1 -1
View File
@@ -319,7 +319,7 @@ Flags:
--command <cmd> Command to run (required)
--name <name> Human-readable hook name
--description <text> Hook description
--matcher <pattern> Tool/specialist matcher (beforeTool/afterTool/specialistStart/specialistStop)
--matcher <pattern> Tool matcher (beforeTool/afterTool only)
--arg <value> Command argument (repeatable)
--user Write to user config instead of the project
--json Print command result as JSON
+3 -32
View File
@@ -741,13 +741,6 @@ func (cfg *mcpWritableConfig) upsertServer(name string, server config.MCPServerC
if err != nil {
return false, err
}
if !mergedServer.Disabled && config.IsDefaultMCPServerDisabled(name) {
// Adding a server under the name of a default that ships disabled means
// the user wants it on; the omitted flag would otherwise keep it off.
if data, err = setMCPServerRawDisabledFalse(data); err != nil {
return false, err
}
}
cfg.serverRaw[name] = data
if _, err := cfg.removeLegacyServer(name); err != nil {
return false, err
@@ -807,29 +800,11 @@ func mergeMCPServerRaw(existing json.RawMessage, server config.MCPServerConfig)
if key == "oauth" && preserveRawOAuth {
continue
}
// `mcp add` replaces the entry: it writes only a disable, never a stale
// explicit false carried over from the old entry (upsertServer adds the
// false a default that ships disabled needs).
if key == "disabled" && string(value) == "false" {
continue
}
merged[key] = value
}
return json.Marshal(merged)
}
func setMCPServerRawDisabledFalse(data json.RawMessage) (json.RawMessage, error) {
var server map[string]json.RawMessage
if err := json.Unmarshal(data, &server); err != nil {
return nil, err
}
if server == nil {
server = map[string]json.RawMessage{}
}
server["disabled"] = json.RawMessage("false")
return json.Marshal(server)
}
func (cfg *mcpWritableConfig) removeServer(name string) (bool, error) {
cfg.ensureRaw()
removed := false
@@ -881,19 +856,15 @@ func (cfg *mcpWritableConfig) setServerDisabled(name string, disabled bool) (boo
server = map[string]json.RawMessage{}
}
// A default that ships disabled (aimlapi) stays off until an entry says
// otherwise, so an absent flag reads as disabled and enabling it must be
// written out explicitly: dropping the flag would leave it off.
shippedDisabled := config.IsDefaultMCPServerDisabled(name)
current := shippedDisabled
current := false
if rawDisabled, ok := server["disabled"]; ok && len(rawDisabled) > 0 && string(rawDisabled) != "null" {
if err := json.Unmarshal(rawDisabled, &current); err != nil {
return false, false, err
}
}
changed := current != disabled
if disabled || shippedDisabled {
data, err := json.Marshal(disabled)
if disabled {
data, err := json.Marshal(true)
if err != nil {
return false, false, err
}
-260
View File
@@ -2,18 +2,10 @@ package cli
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"github.com/Twigpine/zero/internal/aimlapi"
"github.com/Twigpine/zero/internal/config"
)
@@ -52,255 +44,3 @@ func TestRunMCPDisableSeededExaDefault(t *testing.T) {
t.Fatal("expected `mcp disable exa` to turn the seeded default off in the resolved config")
}
}
// aimlapiWire stands in for https://mcp.aimlapi.com/mcp at the transport layer:
// requests keep their real URL, nothing is dialed, and each one is recorded.
type aimlapiWire struct {
mu sync.Mutex
requests []*http.Request
}
func (wire *aimlapiWire) RoundTrip(request *http.Request) (*http.Response, error) {
var body []byte
if request.Body != nil {
body, _ = io.ReadAll(request.Body)
_ = request.Body.Close()
}
wire.mu.Lock()
wire.requests = append(wire.requests, request.Clone(context.Background()))
wire.mu.Unlock()
var message struct {
ID json.RawMessage `json:"id"`
Method string `json:"method"`
}
_ = json.Unmarshal(body, &message)
respond := func(status int, payload string) *http.Response {
return &http.Response{StatusCode: status, Header: http.Header{"Content-Type": {"application/json"}}, Body: io.NopCloser(strings.NewReader(payload)), Request: request}
}
if request.URL.String() != aimlapi.MCPServerURL {
return respond(http.StatusBadGateway, `{}`), nil
}
if len(message.ID) == 0 {
return respond(http.StatusAccepted, ""), nil
}
result := `{"protocolVersion":"2024-11-05","capabilities":{},"serverInfo":{"name":"fake","version":"1"}}`
if message.Method == "tools/list" {
result = `{"tools":[{"name":"tools_search","description":"Search the Toolbox","inputSchema":{"type":"object"}}]}`
}
return respond(http.StatusOK, fmt.Sprintf(`{"jsonrpc":"2.0","id":%s,"result":%s}`, message.ID, result)), nil
}
func (wire *aimlapiWire) recorded() []*http.Request {
wire.mu.Lock()
defer wire.mu.Unlock()
return append([]*http.Request(nil), wire.requests...)
}
// isolateAIMLAPIMCPCommand redirects every root the real resolve path reads to
// a temporary home, sets an AI/ML API key in the environment, and routes the
// default HTTP transport to a recorder. It returns the user config path.
func isolateAIMLAPIMCPCommand(t *testing.T, key string) (string, *aimlapiWire, appDeps) {
t.Helper()
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".state"))
t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
t.Setenv("APPDATA", filepath.Join(home, "AppData", "Roaming"))
t.Setenv("LOCALAPPDATA", filepath.Join(home, "AppData", "Local"))
t.Setenv("ZERO_CRED_STORAGE", "file")
t.Setenv("ZERO_PROVIDER", "")
t.Setenv("AIMLAPI_INFERENCE_URL", "")
t.Setenv("AIMLAPI_PARTNER_ID", "")
t.Setenv("AIMLAPI_API_KEY", key)
configPath, err := config.DefaultUserConfigPath()
if err != nil {
t.Fatal(err)
}
writeMCPCommandRawConfig(t, configPath, `{}`)
wire := &aimlapiWire{}
previous := http.DefaultTransport
http.DefaultTransport = wire
t.Cleanup(func() { http.DefaultTransport = previous })
workspace := t.TempDir()
return configPath, wire, appDeps{getwd: func() (string, error) { return workspace, nil }}
}
func TestRunMCPEnableAIMLAPIDefaultWritesOnlyTheToggle(t *testing.T) {
configPath, wire, deps := isolateAIMLAPIMCPCommand(t, "aiml-secret-key")
var out, errBuf bytes.Buffer
if code := runWithDeps([]string{"mcp", "disable", "aimlapi", "--json"}, &out, &errBuf, deps); code != exitSuccess {
t.Fatalf("disable exit=%d stderr=%s", code, errBuf.String())
}
if !strings.Contains(out.String(), `"changed": false`) {
t.Fatalf("disabling the off-by-default server should be a no-op: %s", out.String())
}
out.Reset()
errBuf.Reset()
if code := runWithDeps([]string{"mcp", "enable", "aimlapi", "--json"}, &out, &errBuf, deps); code != exitSuccess {
t.Fatalf("enable exit=%d stderr=%s", code, errBuf.String())
}
if !strings.Contains(out.String(), `"changed": true`) {
t.Fatalf("enable should report a change: %s", out.String())
}
data, err := os.ReadFile(configPath)
if err != nil {
t.Fatal(err)
}
var written struct {
MCP struct {
Servers map[string]map[string]any `json:"servers"`
} `json:"mcp"`
}
if err := json.Unmarshal(data, &written); err != nil {
t.Fatal(err)
}
if entry := written.MCP.Servers["aimlapi"]; len(entry) != 1 || entry["disabled"] != false {
t.Fatalf("enable must write only the toggle, got %#v in %s", entry, data)
}
if strings.Contains(string(data), "aiml-secret-key") || strings.Contains(string(data), "Authorization") {
t.Fatalf("config holds credential material: %s", data)
}
cfg, err := config.ResolveMCP(config.ResolveOptions{UserConfigPath: configPath})
if err != nil {
t.Fatal(err)
}
if server := cfg.Servers["aimlapi"]; server.Disabled || config.IsUnconfiguredDefault("aimlapi", server) {
t.Fatalf("enabled aimlapi = %#v, want enabled and user-configured", server)
}
// An unrelated save rewrites the whole file through FileConfig; the
// explicit enable has to survive it or the shipped disable comes back.
if _, err := config.SetTheme(configPath, "dark"); err != nil {
t.Fatal(err)
}
cfg, err = config.ResolveMCP(config.ResolveOptions{UserConfigPath: configPath})
if err != nil {
t.Fatal(err)
}
if cfg.Servers["aimlapi"].Disabled {
data, _ := os.ReadFile(configPath)
t.Fatalf("an unrelated config save turned aimlapi off again: %s", data)
}
if len(wire.recorded()) != 0 {
t.Fatal("enable must not connect")
}
out.Reset()
if code := runWithDeps([]string{"mcp", "disable", "aimlapi"}, &out, &errBuf, deps); code != exitSuccess {
t.Fatalf("disable exit=%d stderr=%s", code, errBuf.String())
}
cfg, err = config.ResolveMCP(config.ResolveOptions{UserConfigPath: configPath})
if err != nil {
t.Fatal(err)
}
if !cfg.Servers["aimlapi"].Disabled {
t.Fatal("disable after enable must turn aimlapi off again")
}
}
func TestRunMCPAddUnderAIMLAPIDefaultNameEnablesIt(t *testing.T) {
configPath, _, deps := isolateAIMLAPIMCPCommand(t, "")
var out, errBuf bytes.Buffer
if code := runWithDeps([]string{"mcp", "add", "aimlapi", "--type", "http", "--url", aimlapi.MCPServerURL, "--auth", "oauth"}, &out, &errBuf, deps); code != exitSuccess {
t.Fatalf("add exit=%d stderr=%s", code, errBuf.String())
}
cfg, err := config.ResolveMCP(config.ResolveOptions{UserConfigPath: configPath})
if err != nil {
t.Fatal(err)
}
if server := cfg.Servers["aimlapi"]; server.Disabled || server.Auth != "oauth" {
t.Fatalf("added aimlapi = %#v, want enabled with oauth", server)
}
if code := runWithDeps([]string{"mcp", "add", "aimlapi", "--type", "http", "--url", aimlapi.MCPServerURL, "--disabled"}, &out, &errBuf, deps); code != exitSuccess {
t.Fatalf("add --disabled exit=%d stderr=%s", code, errBuf.String())
}
if cfg, err = config.ResolveMCP(config.ResolveOptions{UserConfigPath: configPath}); err != nil || !cfg.Servers["aimlapi"].Disabled {
t.Fatalf("add --disabled left aimlapi enabled: %#v, %v", cfg.Servers["aimlapi"], err)
}
}
// Through the real resolve path (no injected resolveMCPConfig): the default is
// listed as disabled, never connects, and once enabled it reaches session
// startup and `zero mcp check`, which connects with the key on the wire.
func TestAIMLAPIMCPDefaultThroughRealResolvePath(t *testing.T) {
_, wire, deps := isolateAIMLAPIMCPCommand(t, "aiml-secret-key")
var out, errBuf bytes.Buffer
if code := runWithDeps([]string{"mcp", "list"}, &out, &errBuf, deps); code != exitSuccess {
t.Fatalf("list exit=%d stderr=%s", code, errBuf.String())
}
if !strings.Contains(out.String(), "aimlapi [http] disabled https://mcp.aimlapi.com/mcp") || strings.Contains(out.String(), "aiml-secret-key") {
t.Fatalf("list output should show the disabled aimlapi default and no key:\n%s", out.String())
}
out.Reset()
if code := runWithDeps([]string{"mcp", "list", "--json"}, &out, &errBuf, deps); code != exitSuccess {
t.Fatalf("list --json exit=%d stderr=%s", code, errBuf.String())
}
if !strings.Contains(out.String(), `"aimlapi"`) || strings.Contains(out.String(), "aiml-secret-key") {
t.Fatalf("list --json = %s", out.String())
}
out.Reset()
errBuf.Reset()
if code := runWithDeps([]string{"mcp", "check", "aimlapi"}, &out, &errBuf, deps); code == exitSuccess || !strings.Contains(errBuf.String(), "is disabled") {
t.Fatalf("check of the disabled default: exit=%d stderr=%s", code, errBuf.String())
}
resolved, err := fillAppDeps(deps).resolveMCPConfig(t.TempDir(), true)
if err != nil {
t.Fatal(err)
}
critical, optional := splitMCPStartupConfig(resolved)
if _, ok := critical.Servers["aimlapi"]; ok {
t.Fatal("the disabled default must not start with a session")
}
if _, ok := optional.Servers["aimlapi"]; ok {
t.Fatal("the disabled default must not start with a session")
}
if len(wire.recorded()) != 0 {
t.Fatalf("the disabled default connected: %d requests", len(wire.recorded()))
}
if code := runWithDeps([]string{"mcp", "enable", "aimlapi"}, &out, &errBuf, deps); code != exitSuccess {
t.Fatalf("enable exit=%d stderr=%s", code, errBuf.String())
}
resolved, err = fillAppDeps(deps).resolveMCPConfig(t.TempDir(), true)
if err != nil {
t.Fatal(err)
}
critical, _ = splitMCPStartupConfig(resolved)
if _, ok := critical.Servers["aimlapi"]; !ok {
t.Fatal("an enabled aimlapi must start with the session as a user-configured server")
}
out.Reset()
errBuf.Reset()
if code := runWithDeps([]string{"mcp", "check", "aimlapi", "--json"}, &out, &errBuf, deps); code != exitSuccess {
t.Fatalf("check exit=%d stdout=%s stderr=%s", code, out.String(), errBuf.String())
}
if !strings.Contains(out.String(), `"status": "ok"`) || !strings.Contains(out.String(), `"toolCount": 1`) {
t.Fatalf("check output = %s", out.String())
}
if strings.Contains(out.String()+errBuf.String(), "aiml-secret-key") {
t.Fatalf("check output leaks the key: %s %s", out.String(), errBuf.String())
}
requests := wire.recorded()
if len(requests) == 0 {
t.Fatal("check did not connect")
}
for _, request := range requests {
if request.URL.String() != aimlapi.MCPServerURL {
t.Fatalf("request went to %s", request.URL)
}
if got := request.Header.Get("Authorization"); got != "Bearer aiml-secret-key" {
t.Fatalf("Authorization = %q, want the AI/ML API key", got)
}
if request.Header.Get(aimlapi.SourceHeaderName) != aimlapi.SourceValue || request.Header.Get(aimlapi.PartnerHeaderName) == "" {
t.Fatalf("attribution headers missing: %v", request.Header)
}
}
}
+11 -1
View File
@@ -22,7 +22,7 @@ const permissionProfileScopeNote = "permissionProfile is derived from this proce
func runSandbox(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
if len(args) == 0 {
return writeExecUsageError(stderr, "sandbox subcommand required. Use `zero sandbox policy` or `zero sandbox grants list`.")
return writeExecUsageError(stderr, "sandbox subcommand required. Use `zero sandbox policy`, `zero sandbox exec`, or `zero sandbox grants list`.")
}
switch args[0] {
case "-h", "--help", "help":
@@ -36,6 +36,8 @@ func runSandbox(args []string, stdout io.Writer, stderr io.Writer, deps appDeps)
return runSandboxSetup(args[1:], stdout, stderr, deps)
case "check":
return runSandboxCheck(args[1:], stdout, stderr, deps)
case "exec":
return runSandboxExec(args[1:], stdout, stderr, deps)
case "grants":
return runSandboxGrants(args[1:], stdout, stderr, deps)
default:
@@ -174,10 +176,17 @@ func runSandboxSetup(args []string, stdout io.Writer, stderr io.Writer, deps app
if !setupHelper.Available() {
return writeAppError(stderr, "Windows sandbox setup helper is not available", exitProvider)
}
// Resolved here, in the shell the user typed `zero sandbox setup` into, and
// carried in the args. The helper may be launched elevated, and an elevated
// process does not inherit this shell's environment. Stated explicitly rather
// than left nil (which resolves the same way) because this is the call site
// the opt-in is about.
principalOptIn := zeroSandbox.WindowsSandboxPrincipalOptIn(nil)
setupArgs, err := zeroSandbox.BuildWindowsSandboxSetupArgs(zeroSandbox.WindowsSandboxSetupArgsOptions{
CommandCWD: workspaceRoot,
WorkspaceRoots: []string{workspaceRoot},
PermissionProfile: profile,
PrincipalOptIn: &principalOptIn,
})
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
@@ -646,6 +655,7 @@ Commands:
policy Inspect active sandbox policy and platform backend
setup Run native platform sandbox setup
check Evaluate the sandbox decision for a hypothetical tool action
exec Run one command through the real sandbox
grants Manage persistent sandbox grants
`)
+358
View File
@@ -0,0 +1,358 @@
package cli
import (
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"strings"
"time"
"github.com/Twigpine/zero/internal/config"
"github.com/Twigpine/zero/internal/execution"
zeroSandbox "github.com/Twigpine/zero/internal/sandbox"
)
// runSandboxExec runs ONE command through the real sandbox and exits with its
// status.
//
// This exists because until now the sandbox could only be exercised through a
// full agent turn with a model in the loop. `zero sandbox policy` reports what
// the posture would be and `zero sandbox check` evaluates a hypothetical
// decision, but nothing actually ran a command and let you look at what
// happened on disk afterwards. The practical result is that enforcement is
// covered almost entirely by tests asserting the shape of an ACL plan, and
// almost not at all by tests asserting a write was refused.
//
// A plan can be perfectly correct and never reach the filesystem. That is not
// hypothetical here: the .git rename guard was emitted correctly by the planner
// and silently skipped by the applier, and four tests covering the plan all
// passed while the ACE was absent from disk. Something that runs the real
// binary and then stats the file is the only thing that catches that class.
//
// Deliberately NOT a debug curiosity: it takes the same path a shell tool
// takes, the engine's decision first and then the engine's command plan, so
// what it proves is what users get. It prints the decision, then the resolved
// backend and enforcement level, to stderr before running, so a harness can
// assert the sandbox was actually engaged rather than quietly downgraded.
func runSandboxExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
command, err := parseSandboxExecArgs(args)
if err != nil {
if errors.Is(err, errSandboxExecHelp) {
if writeErr := writeSandboxExecHelp(stdout); writeErr != nil {
return exitCrash
}
return exitSuccess
}
return writeExecUsageError(stderr, err.Error())
}
workspaceRoot, err := resolveWorkspaceRoot("", deps)
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
resolved, err := deps.resolveConfig(workspaceRoot, config.Overrides{})
if err != nil {
return writeAppError(stderr, err.Error(), exitProvider)
}
policy := applyConfiguredSandboxPolicy(zeroSandbox.DefaultPolicy(), resolved.Sandbox)
scope, err := zeroSandbox.NewScope(workspaceRoot, resolved.Sandbox.AdditionalWriteRoots)
if err != nil {
return writeAppError(stderr, fmt.Sprintf("resolve sandbox write roots: %v", err), exitCrash)
}
// Through the ENGINE, not a SandboxManager built here.
//
// The engine is what a real tool call goes through, and it does more than
// hand the manager a request: it resolves the permission profile, calls
// prepareSandboxRuntime, and folds the runtime state into that profile
// before planning. Building a manager directly skipped all of it, so the
// command ran without the runtime write root, and cache or temp writes could
// pass or fail differently from the sandboxed command this exists to imitate.
// A harness that exercises the wrong path is worse than no harness, because
// its result still reads as evidence.
engine := zeroSandbox.NewEngine(zeroSandbox.EngineOptions{
WorkspaceRoot: workspaceRoot,
Policy: policy,
Scope: scope,
Backend: deps.selectSandboxBackend(zeroSandbox.BackendOptions{}),
// The same scrub list a real tool call gets. Without it the engine keeps an
// empty set, and only the hardcoded names plus the provider catalog's own
// AuthEnvVars are removed from the child's environment: a key named by
// `apiKeyEnv` in the user's config would be scrubbed for every sandboxed
// tool call and handed to this one. Reproducing the production environment
// is the entire point of the command, and a credential is the last part of
// it that may differ.
SensitiveEnvKeys: providerSensitiveEnvKeys(resolved),
})
// The CLI's shared shutdown context, so Ctrl+C and a directed SIGTERM both
// arrive here rather than only at whatever the terminal happens to signal.
runCtx, stopSignals := signalContext()
defer stopSignals()
// THE DECISION COMES BEFORE THE PLAN, as it does for every real tool call.
//
// A shell tool is evaluated by the engine and only then planned
// (tools.Registry.RunWithOptions). This went straight to BuildCommandPlan, so
// it took "the same path a shell tool takes" only from the second step on, and
// every refusal that lives in Evaluate did not exist here. The one that
// matters most is the nested-repository guard: a workspace governed by an
// ancestor repository carries NO git carveouts, on the strength of that guard
// refusing to create a repository there, and `zero sandbox exec -- git init`
// created one under the plain workspace grant with a writable config and
// hooks. A harness that runs what production would have refused does not
// prove what users get. Reported by @jatmn.
//
// PermissionGranted is true because the person at the terminal typed the
// command, which is the approval a prompt asks a session user for. It lifts
// exactly what an approval lifts in a session and nothing else: a denial is
// still a denial, and an approved network command still runs inside a sandbox
// that has the network off.
decision := sandboxExecDecision(runCtx, engine, workspaceRoot, command)
fmt.Fprintf(stderr, "sandbox: decision=%s reason=%s\n", decision.Action, decision.Reason)
if decision.Action == zeroSandbox.ActionDeny {
return writeAppError(stderr, decision.ErrorString(), exitCrash)
}
plan, err := engine.BuildCommandPlan(zeroSandbox.CommandSpec{
Name: command[0],
Args: command[1:],
Dir: workspaceRoot,
Env: os.Environ(),
})
if err != nil {
return writeAppError(stderr, fmt.Sprintf("build sandbox command plan: %v", err), exitCrash)
}
// The plan owns resources beyond its construction: on Linux it allocates a
// policy-report file and registers the removal here, so without this every
// invocation leaves a /tmp/zero-sandbox-report-* behind.
defer plan.Cleanup()
// Printed before the command runs and on stderr, so it survives a command
// that writes to stdout and stays greppable by a test harness. A downgrade
// is reported loudly for the same reason: a smoke test that passes because
// the sandbox quietly stood down is worse than no smoke test.
fmt.Fprintf(stderr, "sandbox: backend=%s enforcement=%s wrapped=%t workspace=%s\n",
plan.Backend.Name, plan.EnforcementLevel, plan.Wrapped, plan.WorkspaceRoot)
if strings.TrimSpace(plan.DowngradeReason) != "" {
fmt.Fprintf(stderr, "sandbox: DOWNGRADED: %s\n", plan.DowngradeReason)
}
return runSandboxPlannedCommand(runCtx, plan, stdout, stderr)
}
// sandboxExecToolName is the tool a session would have run this command with.
// Grants and the engine's shell rules are keyed by it.
const sandboxExecToolName = "bash"
// sandboxExecDecision asks the engine the question a session asks before it
// plans a shell command, for the argv this invocation was given.
func sandboxExecDecision(ctx context.Context, engine *zeroSandbox.Engine, workspaceRoot string, argv []string) zeroSandbox.Decision {
return engine.Evaluate(ctx, zeroSandbox.Request{
WorkspaceRoot: workspaceRoot,
ToolName: sandboxExecToolName,
SideEffect: zeroSandbox.SideEffectShell,
PermissionGranted: true,
PermissionMode: zeroSandbox.PermissionModeAsk,
Args: map[string]any{"command": sandboxExecCommandText(argv)},
Reason: "zero sandbox exec",
})
}
// sandboxExecCommandText renders argv as the command text the engine analyses.
//
// The engine classifies a shell tool by parsing its command string, so argv has
// to become one without changing what it says. A word made only of characters
// the shell does not interpret is left bare, which keeps `git init` reading as a
// session would have written it. Anything else is single-quoted, so an argument
// such as "git init" stays ONE word and is not mistaken for a command, and a
// payload handed to `sh -c` reaches the analyser as the payload it is.
func sandboxExecCommandText(argv []string) string {
words := make([]string, 0, len(argv))
for _, word := range argv {
words = append(words, sandboxExecQuoteWord(word))
}
return strings.Join(words, " ")
}
func sandboxExecQuoteWord(word string) string {
if word == "" {
return "''"
}
bare := true
for _, r := range word {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
case strings.ContainsRune("_@%+=:,./-", r):
default:
bare = false
}
}
if bare {
return word
}
// A single quote cannot appear inside single quotes, so it closes them, is
// written escaped, and opens them again.
return "'" + strings.ReplaceAll(word, "'", `'\''`) + "'"
}
const (
// sandboxExecShutdownGrace is how long a cancelled child has to exit on its
// own after the graceful request, before it is killed outright.
sandboxExecShutdownGrace = 5 * time.Second
// sandboxExecShutdownPoll is how often that wait re-checks, matching the
// background package's termination policy.
sandboxExecShutdownPoll = 50 * time.Millisecond
)
func runSandboxPlannedCommand(ctx context.Context, plan zeroSandbox.CommandPlan, stdout io.Writer, stderr io.Writer) int {
// CANCELLING THE WRAPPER HAS TO REACH THE COMMAND.
//
// This started the backend wrapper with a bare exec.Command().Run(): no
// context, no forwarding, no shutdown path. A terminal masks it, because
// terminals signal the whole foreground process group, but a supervisor or
// task runner that sends SIGTERM to the zero sandbox exec PID killed only
// Zero. The wrapper and everything under it kept running, doing filesystem
// and network work after the caller considered the task cancelled, and the
// deferred plan cleanup never ran because the process was gone.
//
// DELIBERATELY NOT execution.ConfigureProcessGroup. Putting the child in its
// own process group closes this hole and opens a worse one: it severs every
// kernel-delivered group signal, so a supervisor escalating to a group kill,
// and a terminal delivering Ctrl+C to its foreground group, would stop
// reaching the child. That trades a path that works today for one that does
// not. Keeping the child in Zero's group leaves group delivery unchanged,
// while the directed-signal case, which reached nothing at all before, now
// reaches the child.
process := exec.CommandContext(ctx, plan.Name, plan.Args...)
// TWO PHASES, AND THE FIRST ONE HAS TO EXIST.
//
// The comments here used to describe a graceful signal followed by escalation
// while the code did neither: Cancel called KillProcessTree, which is SIGKILL
// on Unix and taskkill /T /F on Windows, and WaitDelay only starts counting
// AFTER Cancel returns, so it could never postpone a kill that had already
// happened. A directed SIGTERM reached the child as 137 rather than 143, with
// no chance to flush output, drop a lock file, or run a shutdown handler.
//
// TerminateProcessTree is the two-phase policy the background package already
// uses: SIGTERM, poll for the grace, then SIGKILL what is still alive. On
// Windows it is the single-phase kill, which is correct rather than lazy,
// because there is no signal to send a child that owns no console.
process.Cancel = func() error {
return execution.TerminateProcessTree(process.Process.Pid, sandboxExecShutdownGrace, sandboxExecShutdownPoll)
}
// A backstop only. Cancel above already waits out the grace and escalates, so
// this bounds the case where the tree is gone but Wait is still blocked on an
// inherited pipe a grandchild holds open.
process.WaitDelay = sandboxExecShutdownGrace + sandboxExecShutdownPoll
process.Dir = plan.Dir
if process.Dir == "" {
process.Dir = plan.WorkspaceRoot
}
// SPECIFIED-EMPTY IS NOT UNSPECIFIED.
//
// exec.Cmd treats a nil Env as "inherit this process's entire environment",
// which is a different statement from "run with no variables". The plan owns
// its environment: directCommandEnv and scrubSensitiveEnv return a slice they
// built, and that slice is non-nil with length zero when every entry was
// sensitive. Testing length collapsed those two states and turned the strictest
// possible answer into the loosest one.
//
// Not reachable through `zero sandbox exec` today, because the child
// environment is os.Environ() and an environment holding only sensitive keys
// has no %AppData%, so config resolution fails before the planner runs. Fixed
// anyway: the guard is one assignment, and the next caller that hands the plan
// a deliberately narrow environment would inherit everything instead, silently.
if plan.Env != nil {
process.Env = plan.Env
}
process.Stdin = os.Stdin
process.Stdout = stdout
process.Stderr = stderr
if err := process.Run(); err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
// The command's own status, not ours. A harness asserting "the write
// was refused" needs the refusal's exit code, not a wrapper's.
// A SIGNALED CHILD HAS NO EXIT CODE TO REPORT. ExitCode() answers -1
// there, and os.Exit truncates that to 255, so a child that took SIGTERM
// became indistinguishable from one that chose to exit 255. Fold the
// signal into the conventional 128+n a shell would report, which needs
// the ProcessState rather than the integer.
if status, signaled := signaledExitStatus(exitErr.ProcessState); signaled {
return status
}
return exitErr.ExitCode()
}
fmt.Fprintf(stderr, "sandbox exec: %v\n", err)
return exitCrash
}
return exitSuccess
}
var errSandboxExecHelp = errors.New("help requested")
// parseSandboxExecArgs takes everything after `--` as the command, so the
// command's own flags are never mistaken for ours.
func parseSandboxExecArgs(args []string) ([]string, error) {
if len(args) == 0 {
return nil, errors.New("usage: zero sandbox exec -- <command> [args...]")
}
// Only the FIRST token is ours to interpret. Everything from the second
// onwards belongs to the child, help flags included.
//
// Written as a straight-line decision on args[0] rather than a loop, because
// a loop here is a lie. An earlier version scanned for `--` "up front" and
// said so in its comment, but every branch of its body returned, so it only
// ever examined index 0 and the promised scan did not exist. The rule below
// is what that code actually implemented, and it is the rule we want: it is
// the wrapper's own prefix that can ask for the wrapper's help, and the
// prefix is at most one token long.
switch args[0] {
case "--":
// Everything after the separator is the command, verbatim, including
// `--help`. That is the documented contract: `zero sandbox exec -- cmd
// --help` must run cmd's help, not ours.
command := args[1:]
if len(command) == 0 {
return nil, errors.New("usage: zero sandbox exec -- <command> [args...]")
}
return command, nil
case "-h", "--help", "help":
return nil, errSandboxExecHelp
}
// The separator-less form, tolerated for interactive use: the first token is
// the command, so nothing after it is ours to read. Without this,
// `zero sandbox exec mycmd --help` printed OUR help and never ran mycmd,
// which is the same contract break as reading past `--`. The help text still
// shows the separator, because anything with a leading dash needs it.
return args, nil
}
func writeSandboxExecHelp(w io.Writer) error {
_, err := fmt.Fprint(w, `Usage:
zero sandbox exec -- <command> [args...]
Runs one command through the real sandbox and exits with its status.
Everything after the -- separator is the command, so its own flags are not
parsed as Zero's. The command is evaluated the way a session evaluates a shell
tool call before anything is planned: the decision is written to stderr, and a
command a session refuses is refused here too, with the same reason. Running it
from a terminal counts as the approval a session would prompt for, and lifts
only what that approval lifts. The resolved backend and enforcement level are
then written to stderr before the command runs, and a downgrade is reported
there explicitly.
Examples:
zero sandbox exec -- cmd /c echo hello
zero sandbox exec -- powershell -Command "Set-Content out.txt x"
`)
return err
}
+78
View File
@@ -0,0 +1,78 @@
package cli
import (
"context"
"io"
"os"
"runtime"
"testing"
"time"
zeroSandbox "github.com/Twigpine/zero/internal/sandbox"
)
// longLivedPlan returns a plan whose child outlives the test unless something
// terminates it.
func longLivedPlan(t *testing.T) zeroSandbox.CommandPlan {
t.Helper()
plan := zeroSandbox.CommandPlan{Dir: t.TempDir()}
if runtime.GOOS == "windows" {
plan.Name = "cmd.exe"
plan.Args = []string{"/c", "ping -n 120 127.0.0.1 >NUL"}
return plan
}
plan.Name = "/bin/sh"
plan.Args = []string{"-c", "sleep 120"}
return plan
}
// CANCELLING THE WRAPPER HAS TO REACH THE COMMAND.
//
// The sandboxed command was started with a bare exec.Command().Run(): no
// context, no forwarding, no shutdown path. A terminal masks that, because it
// signals the whole foreground process group, but a supervisor or task runner
// that sends SIGTERM to the wrapper's PID killed only Zero. The command kept
// running, doing filesystem and network work after the caller considered the
// task cancelled, and the deferred plan cleanup never ran.
//
// Driven with a real long-lived child and a real cancellation, asserting that
// the call actually returns rather than that a field is set.
func TestCancellingTheWrapperTerminatesTheSandboxedCommand(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
done := make(chan int, 1)
go func() {
done <- runSandboxPlannedCommand(ctx, longLivedPlan(t), io.Discard, io.Discard)
}()
// Let the child actually start, or cancelling proves nothing.
time.Sleep(300 * time.Millisecond)
select {
case <-done:
t.Fatal("SETUP INVALID: the child exited on its own, so cancellation was not exercised")
default:
}
cancel()
select {
case <-done:
case <-time.After(sandboxExecShutdownGrace + 10*time.Second):
t.Fatal("cancelling the wrapper did not terminate the sandboxed command; it would keep running after the caller gave up")
}
}
// And an uncancelled command still runs to completion and reports its own
// status, or the fix above would be "kill everything immediately".
func TestAnUncancelledSandboxedCommandStillReportsItsStatus(t *testing.T) {
plan := zeroSandbox.CommandPlan{Dir: t.TempDir()}
if runtime.GOOS == "windows" {
plan.Name = "cmd.exe"
plan.Args = []string{"/c", "exit 3"}
} else {
plan.Name = "/bin/sh"
plan.Args = []string{"-c", "exit 3"}
}
if code := runSandboxPlannedCommand(context.Background(), plan, io.Discard, os.Stderr); code != 3 {
t.Fatalf("exit code = %d, want the child's own 3", code)
}
}
+162
View File
@@ -0,0 +1,162 @@
package cli
import (
"bytes"
"context"
"os"
"path/filepath"
"strings"
"testing"
"github.com/Twigpine/zero/internal/config"
"github.com/Twigpine/zero/internal/sandbox"
)
// governedNestedWorkspace is a workspace inside somebody else's repository: an
// ancestor carries .git and the workspace itself does not. Setup plans no git
// carveouts for it, which is why a repository must not be created there.
func governedNestedWorkspace(t *testing.T) (ancestor, workspace string) {
t.Helper()
ancestor = t.TempDir()
if err := os.MkdirAll(filepath.Join(ancestor, ".git"), 0o700); err != nil {
t.Fatal(err)
}
workspace = filepath.Join(ancestor, "packages", "app")
if err := os.MkdirAll(filepath.Join(workspace, "sub dir"), 0o700); err != nil {
t.Fatal(err)
}
return ancestor, workspace
}
func sandboxExecTestDeps(workspace string) appDeps {
return appDeps{
getwd: func() (string, error) { return workspace, nil },
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
return config.ResolvedConfig{}, nil
},
// No native runner. A command that got past the decision would run
// unwrapped, which is exactly what makes a missing refusal visible on
// disk instead of only in a return value.
selectSandboxBackend: func(sandbox.BackendOptions) sandbox.Backend {
return sandbox.Backend{Name: sandbox.BackendUnavailable, Fallback: true, Message: "no native sandbox in this test"}
},
}
}
// `zero sandbox exec` REFUSES WHAT A SESSION WOULD REFUSE.
//
// The command is documented as taking the path a shell tool takes. A shell tool
// is evaluated by the engine and then planned; this went straight to the plan,
// so nothing that lives in Evaluate applied. In a workspace governed by an
// ancestor repository that is the nested-repository guard, and that guard is the
// only thing standing in for the git carveouts setup deliberately does not plan
// there. `zero sandbox exec -- git init` created the repository, writable config
// and hooks included.
//
// Driven through the real CLI entry point, with a backend that would run the
// command unwrapped if it were let through, and checked on disk: the refusal has
// to come with the reason an operator can act on, and no repository may exist
// afterwards. Reported by @jatmn.
func TestSandboxExecRefusesToCreateARepositoryInAGovernedWorkspace(t *testing.T) {
for _, testCase := range []struct {
name string
argv []string
made []string
}{
{name: "git init", argv: []string{"git", "init"}, made: []string{".git"}},
{name: "git init behind -C and a quoted directory", argv: []string{"git", "-C", "sub dir", "init"}, made: []string{"sub dir/.git"}},
{name: "git init behind a shell launcher", argv: []string{"sh", "-c", "git init"}, made: []string{".git"}},
{name: "git init behind env", argv: []string{"env", "GIT_TRACE=0", "git", "init"}, made: []string{".git"}},
{name: "git clone", argv: []string{"git", "clone", "https://example.invalid/repo.git", "vendored"}, made: []string{"vendored"}},
} {
t.Run(testCase.name, func(t *testing.T) {
_, workspace := governedNestedWorkspace(t)
var stdout, stderr bytes.Buffer
args := append([]string{"sandbox", "exec", "--"}, testCase.argv...)
exitCode := runWithDeps(args, &stdout, &stderr, sandboxExecTestDeps(workspace))
for _, made := range testCase.made {
if _, err := os.Lstat(filepath.Join(workspace, filepath.FromSlash(made))); err == nil {
t.Errorf("%s exists after the command: the repository was created under the plain workspace grant", made)
}
}
if exitCode == exitSuccess {
t.Errorf("exit = %d for a command a session refuses; stderr:\n%s", exitCode, stderr.String())
}
if !strings.Contains(stderr.String(), "decision=deny") {
t.Errorf("stderr does not report the decision:\n%s", stderr.String())
}
if !strings.Contains(stderr.String(), "sits inside an existing git repository") {
t.Errorf("the refusal does not carry the nested-repository reason and its remedy:\n%s", stderr.String())
}
if strings.Contains(stderr.String(), "backend=") {
t.Errorf("a command plan was built for a refused command:\n%s", stderr.String())
}
})
}
}
// The decision is the engine's, asked the way a session asks it. These are the
// controls: what must NOT be refused, so the gate above is not simply refusing
// git, or refusing this workspace.
func TestSandboxExecDecisionLeavesOrdinaryCommandsAlone(t *testing.T) {
_, governed := governedNestedWorkspace(t)
standalone := t.TempDir()
for _, testCase := range []struct {
name string
workspace string
argv []string
}{
{name: "an ordinary git command in the governed workspace", workspace: governed, argv: []string{"git", "status"}},
{name: "the words git init as one argument", workspace: governed, argv: []string{"echo", "git init"}},
{name: "git init in a workspace nobody governs", workspace: standalone, argv: []string{"git", "init"}},
} {
t.Run(testCase.name, func(t *testing.T) {
engine := sandbox.NewEngine(sandbox.EngineOptions{
WorkspaceRoot: testCase.workspace,
Policy: sandbox.DefaultPolicy(),
Backend: sandbox.Backend{Name: sandbox.BackendUnavailable, Fallback: true},
})
decision := sandboxExecDecision(context.Background(), engine, testCase.workspace, testCase.argv)
if decision.Action == sandbox.ActionDeny {
t.Fatalf("%q was refused: %s", strings.Join(testCase.argv, " "), decision.ErrorString())
}
})
}
}
// ARGV BECOMES COMMAND TEXT WITHOUT CHANGING WHAT IT SAYS. The engine classifies
// a shell command by parsing its text, so the rendering decides what the
// classifier sees. One argument has to stay one word, and a payload handed to a
// shell launcher has to arrive as that payload.
func TestSandboxExecCommandTextKeepsArgvIntact(t *testing.T) {
for _, testCase := range []struct {
name string
argv []string
want string
gitInit bool
}{
{name: "bare words stay bare", argv: []string{"git", "init"}, want: "git init", gitInit: true},
{name: "a spaced argument is one word", argv: []string{"echo", "git init"}, want: "echo 'git init'"},
{name: "a launcher payload is analysed as a payload", argv: []string{"sh", "-c", "git init"}, want: "sh -c 'git init'", gitInit: true},
{name: "a quote inside an argument", argv: []string{"printf", "it's"}, want: `printf 'it'\''s'`},
{name: "substitution syntax is inert", argv: []string{"echo", "$(git init)"}, want: "echo '$(git init)'"},
{name: "an empty argument survives", argv: []string{"printf", ""}, want: "printf ''"},
{name: "a directory with a space", argv: []string{"git", "-C", "sub dir", "init"}, want: "git -C 'sub dir' init", gitInit: true},
} {
t.Run(testCase.name, func(t *testing.T) {
text := sandboxExecCommandText(testCase.argv)
if text != testCase.want {
t.Fatalf("command text = %q, want %q", text, testCase.want)
}
analysis := sandbox.AnalyzeCommand(text)
if analysis.TooComplex {
t.Fatalf("%q does not parse, so the classifier would treat a plain argv as obfuscated", text)
}
if analysis.GitInit != testCase.gitInit {
t.Errorf("%q: GitInit = %v, want %v", text, analysis.GitInit, testCase.gitInit)
}
})
}
}
+72
View File
@@ -0,0 +1,72 @@
package cli
import (
"context"
"os"
"runtime"
"strings"
"testing"
zeroSandbox "github.com/Twigpine/zero/internal/sandbox"
)
// envPrinterPlan returns a plan whose child prints its own environment, so the
// assertion is about what the child received rather than about a field value.
func envPrinterPlan(t *testing.T, env []string) zeroSandbox.CommandPlan {
t.Helper()
plan := zeroSandbox.CommandPlan{Dir: t.TempDir(), Env: env}
if runtime.GOOS == "windows" {
plan.Name = "cmd.exe"
plan.Args = []string{"/c", "set"}
return plan
}
plan.Name = "/bin/sh"
plan.Args = []string{"-c", "env"}
return plan
}
// A PLAN THAT SPECIFIES NO VARIABLES MUST NOT INHERIT EVERY VARIABLE.
//
// exec.Cmd reads a nil Env as "inherit this process's entire environment", which
// is a different statement from "run with no variables". The plan owns its
// environment: directCommandEnv and scrubSensitiveEnv return a slice they built,
// and that slice is non-nil with length zero when every entry was sensitive.
// Testing its length collapsed the two states and turned the strictest possible
// answer into the loosest one.
//
// Not reachable through `zero sandbox exec` today, because the child environment
// is os.Environ() and an environment holding only sensitive keys has no
// %AppData%, so config resolution fails before the planner runs. Pinned anyway,
// because the next caller that hands the plan a deliberately narrow environment
// would silently inherit everything instead.
func TestAnEmptyPlannedEnvironmentDoesNotInherit(t *testing.T) {
const marker = "ZZ_SANDBOX_ENV_MARKER"
t.Setenv(marker, "leaked-value")
var out strings.Builder
// Specified, and deliberately empty.
code := runSandboxPlannedCommand(context.Background(), envPrinterPlan(t, []string{}), &out, os.Stderr)
if code != 0 {
t.Fatalf("child exited %d: %s", code, out.String())
}
if strings.Contains(out.String(), marker) {
t.Fatalf("a plan specifying no environment leaked the caller's %s to the child:\n%s", marker, out.String())
}
}
// And a nil environment still inherits, which is the meaning the planner relies
// on when it did not build one. Without this the fix above could be "never pass
// the environment", which would break every ordinary command.
func TestANilPlannedEnvironmentStillInherits(t *testing.T) {
const marker = "ZZ_SANDBOX_ENV_MARKER"
t.Setenv(marker, "inherited-value")
var out strings.Builder
code := runSandboxPlannedCommand(context.Background(), envPrinterPlan(t, nil), &out, os.Stderr)
if code != 0 {
t.Fatalf("child exited %d: %s", code, out.String())
}
if !strings.Contains(out.String(), marker) {
t.Fatalf("a nil planned environment stopped inheriting, which changes the meaning the planner relies on:\n%s", out.String())
}
}
@@ -0,0 +1,187 @@
//go:build !windows
package cli
import (
"context"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"syscall"
"testing"
"time"
zeroSandbox "github.com/Twigpine/zero/internal/sandbox"
)
// THE GRACEFUL PHASE HAS TO ACTUALLY HAPPEN.
//
// The shutdown comments described a graceful signal followed by escalation while
// the code did neither: Cancel called KillProcessTree, which is SIGKILL here, and
// WaitDelay only starts counting after Cancel returns so it could never postpone
// a kill that had already happened. A directed SIGTERM therefore reached the
// child as 137 rather than 143, with no chance to flush output, drop a lock file,
// or run a shutdown handler.
//
// Driven with a child that traps the signal and records that its handler ran, so
// the assertion is about the observed lifecycle rather than about a configured
// duration.
func TestACancelledCommandGetsAChanceToCleanUp(t *testing.T) {
marker := filepath.Join(t.TempDir(), "cleanup-ran")
// Exits 42 on the graceful signal: a distinctive code the child could only
// have chosen itself, so the status proves the handler ran rather than that
// something plausible happened.
script := "trap 'echo yes > " + marker + "; exit 42' TERM; while :; do sleep 0.05; done"
ctx, cancel := context.WithCancel(context.Background())
done := make(chan int, 1)
go func() {
done <- runSandboxPlannedCommand(ctx, zeroSandbox.CommandPlan{
Name: "/bin/sh",
Args: []string{"-c", script},
Dir: t.TempDir(),
}, io.Discard, io.Discard)
}()
// The trap has to be installed before the signal, or this proves nothing.
time.Sleep(500 * time.Millisecond)
select {
case <-done:
t.Fatal("SETUP INVALID: the child exited on its own")
default:
}
cancel()
var status int
select {
case status = <-done:
case <-time.After(sandboxExecShutdownGrace + 10*time.Second):
t.Fatal("the cancelled command never terminated")
}
// The status the caller sees is the child's own, so a harness can tell an
// orderly shutdown from a kill.
if status != 42 {
t.Errorf("status = %d, want 42; the child chose that code in its TERM handler, so anything else means it never got to finish", status)
}
if _, err := os.Stat(marker); err != nil {
t.Fatalf("the child was killed outright instead of being asked to stop, so its cleanup handler never ran: %v", err)
}
}
// And a child that ignores the request is still force-killed, after the bound
// rather than before it. Without this the fix could be "only ever ask nicely".
func TestAStubbornCancelledCommandIsStillKilled(t *testing.T) {
// Traps TERM and keeps running: only escalation can end this.
script := "trap '' TERM; while :; do sleep 0.05; done"
ctx, cancel := context.WithCancel(context.Background())
done := make(chan int, 1)
go func() {
done <- runSandboxPlannedCommand(ctx, zeroSandbox.CommandPlan{
Name: "/bin/sh",
Args: []string{"-c", script},
Dir: t.TempDir(),
}, io.Discard, io.Discard)
}()
time.Sleep(500 * time.Millisecond)
started := time.Now()
cancel()
var status int
select {
case status = <-done:
case <-time.After(sandboxExecShutdownGrace + 15*time.Second):
t.Fatal("a child ignoring the graceful request was never force-killed")
}
// After the bound, not before: a kill that lands immediately is the defect
// this replaced.
if elapsed := time.Since(started); elapsed < sandboxExecShutdownGrace {
t.Fatalf("the child was killed after %s, before the %s grace it was owed", elapsed, sandboxExecShutdownGrace)
}
// And escalation really was a kill, so "ask nicely and give up" cannot pass.
if want := 128 + int(syscall.SIGKILL); status != want {
t.Errorf("status = %d, want %d after escalation", status, want)
}
}
// AND THE STATUS HAS TO SAY WHICH SIGNAL ARRIVED.
//
// 137 rather than 143 was the reported symptom, and it is the only part of this
// a caller can see. A child that does not trap anything must come back as
// 128+SIGTERM: if the first signal were still SIGKILL, this would be 137 and the
// two cases above would both still pass, since one traps and the other ignores.
func TestACancelledCommandReportsTheGracefulSignal(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
done := make(chan int, 1)
go func() {
done <- runSandboxPlannedCommand(ctx, zeroSandbox.CommandPlan{
Name: "/bin/sh",
Args: []string{"-c", "while :; do sleep 0.05; done"},
Dir: t.TempDir(),
}, io.Discard, io.Discard)
}()
time.Sleep(500 * time.Millisecond)
cancel()
var status int
select {
case status = <-done:
case <-time.After(sandboxExecShutdownGrace + 10*time.Second):
t.Fatal("the cancelled command never terminated")
}
if want := 128 + int(syscall.SIGTERM); status != want {
t.Fatalf("status = %d, want %d; %d means the child was SIGKILLed with no graceful phase", status, want, 128+int(syscall.SIGKILL))
}
}
// TERMINATION IS COMPLETE BEFORE THE FUNCTION RETURNS.
//
// `zero sandbox exec` releases the plan with a defer around this call, so plan
// cleanup is ordered after termination only if nothing survives the return. A
// future refactor that lets Wait come back while the tree is still up would move
// cleanup on top of a live child, deleting the policy-report file it is using.
//
// The stubborn child is the one that can outlive the call, since it ignores the
// graceful request and only escalation ends it.
func TestACancelledCommandIsFullyReapedBeforeCleanupCouldRun(t *testing.T) {
pidFile := filepath.Join(t.TempDir(), "child.pid")
script := "echo $$ > " + pidFile + "; trap '' TERM; while :; do sleep 0.05; done"
ctx, cancel := context.WithCancel(context.Background())
done := make(chan int, 1)
go func() {
done <- runSandboxPlannedCommand(ctx, zeroSandbox.CommandPlan{
Name: "/bin/sh",
Args: []string{"-c", script},
Dir: t.TempDir(),
}, io.Discard, io.Discard)
}()
time.Sleep(500 * time.Millisecond)
raw, err := os.ReadFile(pidFile)
if err != nil {
t.Fatalf("SETUP INVALID: the child never recorded its pid, so there is nothing to check for survival: %v", err)
}
pid, err := strconv.Atoi(strings.TrimSpace(string(raw)))
if err != nil || pid <= 0 {
t.Fatalf("SETUP INVALID: unusable child pid %q: %v", raw, err)
}
cancel()
select {
case <-done:
case <-time.After(sandboxExecShutdownGrace + 15*time.Second):
t.Fatal("a child ignoring the graceful request was never force-killed")
}
// Signal 0 is the existence probe: no signal is sent, only the permission and
// liveness checks run, so ESRCH is the answer for a reaped process.
if err := syscall.Kill(pid, 0); err == nil {
t.Fatalf("the child (pid %d) was still alive when the run returned; plan cleanup is deferred around this call and would delete the policy-report file underneath it", pid)
} else if err != syscall.ESRCH {
t.Skipf("cannot probe pid %d here: %v", pid, err)
}
}
+28
View File
@@ -0,0 +1,28 @@
//go:build !windows
package cli
import (
"os"
"syscall"
)
// signaledExitStatus reports the conventional shell status for a child that was
// terminated by a signal, which exec.ExitError cannot represent.
//
// ExitCode() returns -1 for a signaled child, and the top level hands that to
// os.Exit, which truncates it to 255. A child that takes SIGTERM is then
// indistinguishable from one that exited 255 of its own accord, which breaks the
// documented status contract for a command whose whole job is to report the
// child's own status faithfully. 128+signal is what a shell reports and what a
// harness comparing statuses expects.
func signaledExitStatus(state *os.ProcessState) (int, bool) {
if state == nil {
return 0, false
}
status, ok := state.Sys().(syscall.WaitStatus)
if !ok || !status.Signaled() {
return 0, false
}
return 128 + int(status.Signal()), true
}
@@ -0,0 +1,65 @@
//go:build !windows
package cli
import (
"errors"
"os/exec"
"syscall"
"testing"
)
// A SIGNALED CHILD MUST NOT LOOK LIKE ONE THAT CHOSE TO EXIT 255.
//
// `sandbox exec` exists to report the child's own status faithfully, and
// exec.ExitError cannot represent signal termination: ExitCode() answers -1, and
// the top level hands that to os.Exit, which truncates it to 255. So a child
// killed by SIGTERM was indistinguishable from an ordinary exit of 255, and a
// harness comparing statuses could not tell a refusal from a kill.
//
// Driven with a real subprocess and a real signal rather than a synthetic
// WaitStatus, because the mapping is only correct if the OS agrees with it.
func TestSignaledChildReportsTheConventionalStatus(t *testing.T) {
for _, testCase := range []struct {
name string
script string
want int
}{
{name: "SIGTERM", script: "kill -TERM $$; sleep 5", want: 128 + int(syscall.SIGTERM)},
{name: "SIGINT", script: "kill -INT $$; sleep 5", want: 128 + int(syscall.SIGINT)},
} {
t.Run(testCase.name, func(t *testing.T) {
err := exec.Command("/bin/sh", "-c", testCase.script).Run()
var exitErr *exec.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("SETUP INVALID: the child did not end with an ExitError: %v", err)
}
// The behaviour being corrected: the integer alone cannot say this.
if code := exitErr.ExitCode(); code != -1 {
t.Fatalf("SETUP INVALID: a signaled child reported exit code %d, expected -1 on this platform", code)
}
status, signaled := signaledExitStatus(exitErr.ProcessState)
if !signaled {
t.Fatal("a signaled child was not recognised as signaled, so it would be reported as exit 255")
}
if status != testCase.want {
t.Fatalf("status = %d, want %d (128 + signal), which is what a shell reports", status, testCase.want)
}
})
}
}
// An ordinary non-zero exit is untouched: it has a real code and must keep it.
func TestOrdinaryExitIsNotTreatedAsSignaled(t *testing.T) {
err := exec.Command("/bin/sh", "-c", "exit 3").Run()
var exitErr *exec.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("SETUP INVALID: the child did not end with an ExitError: %v", err)
}
if _, signaled := signaledExitStatus(exitErr.ProcessState); signaled {
t.Fatal("an ordinary exit was reported as signaled, which would rewrite its status")
}
if code := exitErr.ExitCode(); code != 3 {
t.Fatalf("exit code = %d, want the child's own 3", code)
}
}
@@ -0,0 +1,12 @@
//go:build windows
package cli
import "os"
// signaledExitStatus has no Windows counterpart: a process there ends with an
// exit code, and there is no signal to fold into one. Windows keeps the exit
// code exec.ExitError already reports.
func signaledExitStatus(*os.ProcessState) (int, bool) {
return 0, false
}
+108
View File
@@ -0,0 +1,108 @@
package cli
import (
"errors"
"strings"
"testing"
)
// parseSandboxExecArgs sits on the boundary between Zero's flags and a child
// command's, and it had no tests at all: not for the reported defect, not even
// for the happy case. The contract it has to keep is that everything after the
// separator belongs to the child, help flags included, so the wrapper can never
// answer for a command it was supposed to be running.
func TestParseSandboxExecArgs(t *testing.T) {
for _, testCase := range []struct {
name string
args []string
command []string
help bool
usage bool
}{
{
// The reported defect: --help after the separator is the CHILD's.
name: "help after the separator belongs to the child",
args: []string{"--", "cmd", "--help"},
command: []string{"cmd", "--help"},
},
{
name: "a second separator is the child's too",
args: []string{"--", "cmd", "--", "inner"},
command: []string{"cmd", "--", "inner"},
},
{
name: "a flag-looking command survives the separator",
args: []string{"--", "--weird-binary"},
command: []string{"--weird-binary"},
},
{
// The separator-less form. The first token is the command, so its own
// flags are not ours to read either.
name: "help after a separatorless command belongs to the child",
args: []string{"cmd", "--help"},
command: []string{"cmd", "--help"},
},
{
// A separator arriving AFTER the command has already started is part of
// the child's argv, not a delimiter we get a second go at.
name: "a separator after the command is the child's argument",
args: []string{"ls", "--", "foo"},
command: []string{"ls", "--", "foo"},
},
{name: "bare help", args: []string{"--help"}, help: true},
{name: "short help", args: []string{"-h"}, help: true},
{name: "help subcommand", args: []string{"help"}, help: true},
{name: "help in the prefix wins", args: []string{"--help", "--", "cmd"}, help: true},
{name: "no arguments", args: nil, usage: true},
{name: "separator with nothing after it", args: []string{"--"}, usage: true},
} {
t.Run(testCase.name, func(t *testing.T) {
command, err := parseSandboxExecArgs(testCase.args)
switch {
case testCase.help:
if !errors.Is(err, errSandboxExecHelp) {
t.Fatalf("parseSandboxExecArgs(%q) = %q, %v; want the help sentinel", testCase.args, command, err)
}
case testCase.usage:
if err == nil || errors.Is(err, errSandboxExecHelp) {
t.Fatalf("parseSandboxExecArgs(%q) = %q, %v; want a usage error", testCase.args, command, err)
}
if !strings.Contains(err.Error(), "usage:") {
t.Errorf("a usage error must show the usage, got %q", err.Error())
}
default:
if err != nil {
t.Fatalf("parseSandboxExecArgs(%q) returned %v", testCase.args, err)
}
if strings.Join(command, "\x00") != strings.Join(testCase.command, "\x00") {
t.Errorf("parseSandboxExecArgs(%q) = %q, want %q", testCase.args, command, testCase.command)
}
}
})
}
}
// The wrapper must never consume a token it then fails to hand over. Whatever it
// returns as the command has to be a trailing slice of what it was given, with
// at most a leading separator removed, or an argument has gone missing on its
// way to the child.
func TestParseSandboxExecArgsNeverDropsAChildArgument(t *testing.T) {
for _, args := range [][]string{
{"--", "cmd", "--help", "-x", "--"},
{"cmd", "--help"},
{"ls", "--", "foo"},
{"--", "--weird-binary", "arg"},
} {
command, err := parseSandboxExecArgs(args)
if err != nil {
t.Fatalf("parseSandboxExecArgs(%q) returned %v", args, err)
}
if len(command) == 0 {
t.Fatalf("parseSandboxExecArgs(%q) returned an empty command", args)
}
suffix := strings.Join(args[len(args)-len(command):], "\x00")
if strings.Join(command, "\x00") != suffix {
t.Errorf("parseSandboxExecArgs(%q) = %q, which is not a trailing slice of the input; an argument was dropped or reordered", args, command)
}
}
}
@@ -1,145 +0,0 @@
package cli
import (
"bytes"
"context"
"io"
"testing"
"github.com/Twigpine/zero/internal/config"
"github.com/Twigpine/zero/internal/hooks"
"github.com/Twigpine/zero/internal/mcp"
"github.com/Twigpine/zero/internal/specialist"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/tui"
"github.com/Twigpine/zero/internal/zeroruntime"
)
func TestRegisterSpecialistToolsWiresLifecycleHooksIntoTaskOutput(t *testing.T) {
registry := tools.NewRegistry()
runtime, err := registerSpecialistTools(registry, t.TempDir(), 0)
if err != nil {
t.Fatalf("registerSpecialistTools returned error: %v", err)
}
t.Cleanup(func() { closeSpecialistRuntime(io.Discard, runtime) })
if runtime.lifecycleHooks == nil {
t.Fatal("lifecycleHooks is nil after registerSpecialistTools")
}
raw, ok := registry.Get("TaskOutput")
if !ok {
t.Fatal("TaskOutput tool not registered")
}
outputTool, ok := raw.(*specialist.OutputTool)
if !ok {
t.Fatalf("TaskOutput type = %T, want *specialist.OutputTool", raw)
}
if outputTool.LifecycleHooks != runtime.lifecycleHooks {
t.Fatal("TaskOutput LifecycleHooks is not the shared registerSpecialistTools bridge")
}
dispatcher := hooks.NewDispatcher(hooks.DispatcherOptions{})
runtime.attachLifecycleHooks(dispatcher)
if runtime.lifecycleHooks.Dispatch == nil {
t.Fatal("attachLifecycleHooks left Dispatch nil")
}
if outputTool.LifecycleHooks.Dispatch == nil {
t.Fatal("TaskOutput did not observe Dispatch after attachLifecycleHooks")
}
}
// TestRealEntryPointsAttachLifecycleHooks closes the gap the registration-level
// test above cannot see: wiring the bridge when tools register is not enough
// unless the real startup paths actually call attachLifecycleHooks before any
// run. Both surfaces that can launch specialists must do so — the interactive
// TUI and headless exec. attachLifecycleHooks mutates the same
// *specialist.LifecycleHooks the TaskOutput tool holds, so a nil Dispatch on
// that tool after a full startup proves the call never ran and the
// specialistStart/specialistStop hooks would silently never fire.
func TestRealEntryPointsAttachLifecycleHooks(t *testing.T) {
requireTaskOutputDispatchAttached := func(t *testing.T, registry *tools.Registry) {
t.Helper()
if registry == nil {
t.Fatal("startup did not reach the run registry")
}
raw, ok := registry.Get("TaskOutput")
if !ok {
t.Fatal("TaskOutput tool not registered by startup")
}
outputTool, ok := raw.(*specialist.OutputTool)
if !ok {
t.Fatalf("TaskOutput type = %T, want *specialist.OutputTool", raw)
}
if outputTool.LifecycleHooks == nil || outputTool.LifecycleHooks.Dispatch == nil {
t.Fatal("startup left the specialist lifecycle bridge unattached: specialistStart/specialistStop hooks would never fire")
}
}
t.Run("interactive TUI", func(t *testing.T) {
isolateCLIUserState(t)
cwd := t.TempDir()
var registry *tools.Registry
// Empty args route runWithDeps to the interactive TUI path; the injected
// runTUI receives the same registry specialist tools registered into.
exitCode := runWithDeps([]string{}, io.Discard, io.Discard, appDeps{
getwd: func() (string, error) { return cwd, nil },
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
return config.ResolvedConfig{
Provider: config.ProviderProfile{
Name: "p",
ProviderKind: config.ProviderKindOpenAICompatible,
BaseURL: "http://127.0.0.1/v1",
Model: "m",
},
}, nil
},
resolveMCPConfig: func(string, bool) (config.MCPConfig, error) { return config.MCPConfig{}, nil },
runTUI: func(_ context.Context, options tui.Options) int {
registry = options.Registry
return exitSuccess
},
})
if exitCode != exitSuccess {
t.Fatalf("runWithDeps exit = %d, want %d", exitCode, exitSuccess)
}
requireTaskOutputDispatchAttached(t, registry)
})
t.Run("headless exec", func(t *testing.T) {
isolateCLIUserState(t)
cwd := t.TempDir()
var registry *tools.Registry
var stdout, stderr bytes.Buffer
// --auto high is what registers specialist tools on the exec surface.
// MCP registration is the one startup chokepoint that sees the run
// registry before attachLifecycleHooks runs, so capture it there and
// assert after the run completes.
exitCode := runWithDeps([]string{
"exec", "--auto", "high", "--cwd", cwd, "--init-session-id", "hook_wireup", "hello",
}, &stdout, &stderr, appDeps{
getwd: func() (string, error) { return cwd, nil },
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
return execResolvedConfig(), nil
},
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
return echoExecProvider{}, nil
},
resolveMCPConfig: func(string, bool) (config.MCPConfig, error) {
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"wireup": {Type: "stdio", Command: "wireup-mcp"},
}}, nil
},
newMCPStore: func() (*mcp.PermissionStore, error) { return nil, nil },
registerMCPTools: func(_ context.Context, captured *tools.Registry, _ config.MCPConfig, _ mcp.RegisterOptions) (mcpToolRuntime, error) {
registry = captured
return closeFunc(func() error { return nil }), nil
},
})
if exitCode != exitSuccess {
t.Fatalf("runWithDeps exit = %d, want %d (stderr=%q)", exitCode, exitSuccess, stderr.String())
}
requireTaskOutputDispatchAttached(t, registry)
})
}
-168
View File
@@ -1,168 +0,0 @@
package config
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"unicode"
"github.com/Twigpine/zero/internal/aimlapi"
"github.com/Twigpine/zero/internal/providercatalog"
)
const aimlapiKeyEnv = "AIMLAPI_API_KEY"
var (
// ErrAIMLAPIKeyMissing means no AI/ML API key is configured.
ErrAIMLAPIKeyMissing = errors.New("no AI/ML API key is configured; set up aimlapi.com with /provider or set AIMLAPI_API_KEY")
// ErrAIMLAPIProxyProfile means the aimlapi profile points at a proxy, so its
// key belongs to that proxy and is withheld from aimlapi.com services.
ErrAIMLAPIProxyProfile = errors.New("the aimlapi provider points at a proxy base URL; its key is not sent to aimlapi.com services")
// ErrAIMLAPIKeyInvalid means the key cannot be sent as a single Bearer token.
ErrAIMLAPIKeyInvalid = errors.New("the AI/ML API key contains whitespace, a comma or a control character")
)
// AIMLAPIServiceKey returns the AI/ML API key that may be sent to the
// aimlapi.com Toolbox and MCP server. It is resolved again on every call, so a
// provider change is seen by the next request.
//
// The key comes from the aimlapi provider profile (the active one when it is
// aimlapi, otherwise the first), including a key held in the credential store,
// or from AIMLAPI_API_KEY when no aimlapi profile exists. The key is withheld
// when that profile, any aimlapi profile, or any profile reading
// AIMLAPI_API_KEY points at a base URL other than the canonical
// https://api.aimlapi.com/v1, and when AIMLAPI_INFERENCE_URL is overridden:
// in those cases the credential may belong to a proxy.
func AIMLAPIServiceKey(resolved ResolvedConfig, store APIKeyGetter, getenv func(string) string) (string, error) {
if getenv == nil {
getenv = os.Getenv
}
if override := strings.TrimSpace(getenv("AIMLAPI_INFERENCE_URL")); override != "" && !sameBaseURL(override, aimlapi.CanonicalInferenceBaseURL) {
return "", ErrAIMLAPIProxyProfile
}
for _, profile := range resolved.Providers {
if aimlapiKeyedProfile(profile) && !sameBaseURL(profile.BaseURL, aimlapi.CanonicalInferenceBaseURL) {
return "", ErrAIMLAPIProxyProfile
}
}
if profile, ok := aimlapiServiceProfile(resolved); ok {
if !sameBaseURL(profile.BaseURL, aimlapi.CanonicalInferenceBaseURL) {
return "", ErrAIMLAPIProxyProfile
}
if store != nil && profile.APIKeyStored && strings.TrimSpace(profile.APIKey) == "" && strings.TrimSpace(profile.Name) != "" {
key, found, err := store.Get(strings.TrimSpace(profile.Name))
if err != nil {
return "", fmt.Errorf("read AI/ML API credential store: %w", err)
}
if found {
profile.APIKey = key
}
}
return validAIMLAPIServiceKey(profile.APIKey)
}
return validAIMLAPIServiceKey(getenv(aimlapiKeyEnv))
}
// LoadAIMLAPIServiceKey resolves the user configuration, the environment and
// the user credential store, then applies AIMLAPIServiceKey. Project config is
// not read: provider keys are user-scoped. A config with no active provider
// still resolves its profiles (ErrNoActiveProvider), so the key is looked up in
// that partial config; every other resolve error is returned. Resolve drops a
// non-active profile it cannot normalize, so the configured profiles are
// checked as well: such a profile still withholds the key when it carries it.
func LoadAIMLAPIServiceKey() (string, error) {
userConfigPath, err := DefaultUserConfigPath()
if err != nil {
return "", err
}
existing, err := existingConfigFile(userConfigPath)
if err != nil {
return "", err
}
resolved, err := Resolve(ResolveOptions{UserConfigPath: existing, ExcludeProject: true})
if err != nil && !errors.Is(err, ErrNoActiveProvider) {
return "", err
}
if existing != "" {
fileConfig, err := loadConfigFile(existing)
if err != nil {
return "", err
}
var configured FileConfig
mergeConfig(&configured, fileConfig)
if droppedAIMLAPIProxyProfile(configured.Providers, resolved.Providers) {
return "", ErrAIMLAPIProxyProfile
}
}
// Check proxy eligibility and already-resolved keys before opening a store.
key, keyErr := AIMLAPIServiceKey(resolved, nil, os.Getenv)
if !errors.Is(keyErr, ErrAIMLAPIKeyMissing) {
return key, keyErr
}
profile, ok := aimlapiServiceProfile(resolved)
if !ok || !profile.APIKeyStored {
return key, keyErr
}
store, err := ProviderKeyStoreAt(filepath.Dir(userConfigPath))
if err != nil {
return "", fmt.Errorf("open AI/ML API credential store: %w", err)
}
return AIMLAPIServiceKey(resolved, store, os.Getenv)
}
func isAIMLAPIProfile(profile ProviderProfile) bool {
return strings.EqualFold(providercatalog.NormalizeID(profile.CatalogID), providercatalog.AIMLAPIID)
}
// aimlapiKeyedProfile reports whether profile carries an AI/ML API credential:
// an aimlapi catalog profile, or any profile that reads AIMLAPI_API_KEY.
func aimlapiKeyedProfile(profile ProviderProfile) bool {
return isAIMLAPIProfile(profile) || strings.EqualFold(strings.TrimSpace(profile.APIKeyEnv), aimlapiKeyEnv)
}
// droppedAIMLAPIProxyProfile reports whether a configured profile missing from
// the resolved list (Resolve could not normalize it) carries an AI/ML API
// credential and is not explicitly on the canonical base URL. Its endpoint was
// never resolved, so an empty base URL counts as a proxy.
func droppedAIMLAPIProxyProfile(configured, resolved []ProviderProfile) bool {
kept := make(map[string]bool, len(resolved))
for _, profile := range resolved {
kept[profile.Name] = true
}
for _, profile := range configured {
if kept[strings.TrimSpace(profile.Name)] || !aimlapiKeyedProfile(profile) {
continue
}
if !sameBaseURL(profile.BaseURL, aimlapi.CanonicalInferenceBaseURL) {
return true
}
}
return false
}
func aimlapiServiceProfile(resolved ResolvedConfig) (ProviderProfile, bool) {
if isAIMLAPIProfile(resolved.Provider) {
return resolved.Provider, true
}
for _, profile := range resolved.Providers {
if isAIMLAPIProfile(profile) {
return profile, true
}
}
return ProviderProfile{}, false
}
func validAIMLAPIServiceKey(raw string) (string, error) {
key := strings.TrimSpace(raw)
if key == "" {
return "", ErrAIMLAPIKeyMissing
}
for _, r := range key {
if unicode.IsSpace(r) || r <= ' ' || r == 0x7f || r == ',' || (r >= 0x80 && r <= 0x9f) {
return "", ErrAIMLAPIKeyInvalid
}
}
return key, nil
}
-282
View File
@@ -1,282 +0,0 @@
package config
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
func aimlapiServiceKeyEnv(values map[string]string) func(string) string {
return func(name string) string { return values[name] }
}
func TestAIMLAPIServiceKeyUsesCanonicalProfileKey(t *testing.T) {
resolved := ResolvedConfig{Providers: []ProviderProfile{
{Name: "openai", BaseURL: "https://api.openai.com/v1", APIKey: "sk-openai"},
{Name: "aimlapi", CatalogID: "aimlapi", BaseURL: "https://api.aimlapi.com/v1/", APIKey: " aiml-key "},
}}
key, err := AIMLAPIServiceKey(resolved, nil, aimlapiServiceKeyEnv(nil))
if err != nil || key != "aiml-key" {
t.Fatalf("AIMLAPIServiceKey = %q, %v; want aiml-key", key, err)
}
}
func TestAIMLAPIServiceKeyReadsCredentialStore(t *testing.T) {
resolved := ResolvedConfig{Provider: ProviderProfile{Name: "aimlapi", CatalogID: "aimlapi", BaseURL: "https://api.aimlapi.com/v1", APIKeyStored: true}}
store := fakeKeyGetter{keys: map[string]string{"aimlapi": "stored-key"}}
key, err := AIMLAPIServiceKey(resolved, store, aimlapiServiceKeyEnv(nil))
if err != nil || key != "stored-key" {
t.Fatalf("AIMLAPIServiceKey = %q, %v; want stored-key", key, err)
}
}
func TestAIMLAPIServiceKeyPrefersResolvedProfileKeyOverCredentialStore(t *testing.T) {
resolved := ResolvedConfig{Provider: ProviderProfile{
Name: "aimlapi", CatalogID: "aimlapi", BaseURL: "https://api.aimlapi.com/v1",
APIKey: "env-key", APIKeyStored: true,
}}
store := fakeKeyGetter{keys: map[string]string{"aimlapi": "stored-key"}}
key, err := AIMLAPIServiceKey(resolved, store, aimlapiServiceKeyEnv(map[string]string{"AIMLAPI_API_KEY": "env-key"}))
if err != nil || key != "env-key" {
t.Fatalf("AIMLAPIServiceKey = %q, %v; want env-key from resolved profile, not stored-key", key, err)
}
}
func TestAIMLAPIServiceKeyFallsBackToEnvironmentWithoutProfile(t *testing.T) {
key, err := AIMLAPIServiceKey(ResolvedConfig{}, nil, aimlapiServiceKeyEnv(map[string]string{"AIMLAPI_API_KEY": "env-key"}))
if err != nil || key != "env-key" {
t.Fatalf("AIMLAPIServiceKey = %q, %v; want env-key", key, err)
}
if _, err := AIMLAPIServiceKey(ResolvedConfig{}, nil, aimlapiServiceKeyEnv(nil)); !errors.Is(err, ErrAIMLAPIKeyMissing) {
t.Fatalf("missing key error = %v, want ErrAIMLAPIKeyMissing", err)
}
}
func TestAIMLAPIServiceKeyWithholdsProxyCredentials(t *testing.T) {
env := aimlapiServiceKeyEnv(map[string]string{"AIMLAPI_API_KEY": "proxy-key"})
cases := map[string]ResolvedConfig{
"active aimlapi profile on a proxy": {Provider: ProviderProfile{Name: "aimlapi", CatalogID: "aimlapi", BaseURL: "https://proxy.example/v1", APIKey: "proxy-key"}},
"a second aimlapi profile on a proxy": {Providers: []ProviderProfile{
{Name: "aimlapi", CatalogID: "aimlapi", BaseURL: "https://api.aimlapi.com/v1", APIKey: "proxy-key"},
{Name: "aimlapi-proxy", CatalogID: "aimlapi", BaseURL: "https://proxy.example/v1", APIKey: "proxy-key"},
}},
"a custom profile reading AIMLAPI_API_KEY": {Providers: []ProviderProfile{
{Name: "gateway", BaseURL: "https://proxy.example/v1", APIKeyEnv: "AIMLAPI_API_KEY", APIKey: "proxy-key"},
}},
}
for name, resolved := range cases {
key, err := AIMLAPIServiceKey(resolved, nil, env)
if !errors.Is(err, ErrAIMLAPIProxyProfile) || key != "" {
t.Errorf("%s: AIMLAPIServiceKey = %q, %v; want ErrAIMLAPIProxyProfile", name, key, err)
}
}
overridden := aimlapiServiceKeyEnv(map[string]string{"AIMLAPI_API_KEY": "proxy-key", "AIMLAPI_INFERENCE_URL": "https://staging.example/v1"})
if key, err := AIMLAPIServiceKey(ResolvedConfig{}, nil, overridden); !errors.Is(err, ErrAIMLAPIProxyProfile) || key != "" {
t.Fatalf("inference URL override: AIMLAPIServiceKey = %q, %v; want ErrAIMLAPIProxyProfile", key, err)
}
}
func TestAIMLAPIServiceKeyRejectsKeysThatAreNotOneToken(t *testing.T) {
for _, raw := range []string{"first\u00a0second", "first\u2003second", "first,second", "first\nsecond", "first\r\nsecond", "a b", "key\x00tail", "key\u0085tail"} {
resolved := ResolvedConfig{Provider: ProviderProfile{Name: "aimlapi", CatalogID: "aimlapi", BaseURL: "https://api.aimlapi.com/v1", APIKey: raw}}
if key, err := AIMLAPIServiceKey(resolved, nil, aimlapiServiceKeyEnv(nil)); !errors.Is(err, ErrAIMLAPIKeyInvalid) || key != "" {
t.Errorf("key %q: AIMLAPIServiceKey = %q, %v; want ErrAIMLAPIKeyInvalid", raw, key, err)
}
}
}
func TestLoadAIMLAPIServiceKeyResolvesUserConfig(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("APPDATA", filepath.Join(home, "AppData"))
t.Setenv("USERPROFILE", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
t.Setenv("ZERO_CRED_STORAGE", "file")
t.Setenv("ZERO_PROVIDER", "")
t.Setenv("AIMLAPI_INFERENCE_URL", "")
t.Setenv("AIMLAPI_API_KEY", "env-key")
if key, err := LoadAIMLAPIServiceKey(); err != nil || key != "env-key" {
t.Fatalf("without config: LoadAIMLAPIServiceKey = %q, %v; want env-key", key, err)
}
path, err := DefaultUserConfigPath()
if err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatal(err)
}
proxy := `{"activeProvider":"aimlapi","providers":[{"name":"aimlapi","catalogID":"aimlapi","baseURL":"https://proxy.example/v1","model":"m"}]}`
if err := os.WriteFile(path, []byte(proxy), 0o600); err != nil {
t.Fatal(err)
}
if key, err := LoadAIMLAPIServiceKey(); !errors.Is(err, ErrAIMLAPIProxyProfile) || key != "" {
t.Fatalf("proxy profile: LoadAIMLAPIServiceKey = %q, %v; want ErrAIMLAPIProxyProfile", key, err)
}
canonical := `{"activeProvider":"aimlapi","providers":[{"name":"aimlapi","catalogID":"aimlapi"}]}`
if err := os.WriteFile(path, []byte(canonical), 0o600); err != nil {
t.Fatal(err)
}
if key, err := LoadAIMLAPIServiceKey(); err != nil || key != "env-key" {
t.Fatalf("canonical profile: LoadAIMLAPIServiceKey = %q, %v; want env-key", key, err)
}
}
// Several profiles and no activeProvider make Resolve return
// ErrNoActiveProvider with the normalized profiles; the interactive app keeps
// running on that partial config, so the service key must still resolve.
func TestLoadAIMLAPIServiceKeyWithoutActiveProvider(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("APPDATA", filepath.Join(home, "AppData"))
t.Setenv("USERPROFILE", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
t.Setenv("ZERO_CRED_STORAGE", "file")
t.Setenv("ZERO_PROVIDER", "")
t.Setenv("AIMLAPI_INFERENCE_URL", "")
t.Setenv("AIMLAPI_API_KEY", "env-key")
path, err := DefaultUserConfigPath()
if err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatal(err)
}
other := `{"name":"other","provider":"openai","model":"m","apiKey":"other-key"}`
for _, tc := range []struct {
name string
active string
profile string
want string
wantErr error
}{
{name: "environment key", profile: `{"name":"second","provider":"openai","model":"m","apiKey":"second-key"}`, want: "env-key"},
{name: "stale active provider", active: "gone", profile: `{"name":"second","provider":"openai","model":"m","apiKey":"second-key"}`, want: "env-key"},
{name: "aimlapi profile key", profile: `{"name":"aimlapi","catalogID":"aimlapi","model":"m","apiKey":"profile-key"}`, want: "profile-key"},
{name: "proxy profile", profile: `{"name":"aimlapi","catalogID":"aimlapi","baseURL":"https://proxy.example/v1","model":"m"}`, wantErr: ErrAIMLAPIProxyProfile},
} {
data := `{"activeProvider":"` + tc.active + `","providers":[` + other + `,` + tc.profile + `]}`
if err := os.WriteFile(path, []byte(data), 0o600); err != nil {
t.Fatal(err)
}
key, err := LoadAIMLAPIServiceKey()
if tc.wantErr != nil {
if !errors.Is(err, tc.wantErr) || key != "" {
t.Fatalf("%s: LoadAIMLAPIServiceKey = %q, %v; want %v", tc.name, key, err, tc.wantErr)
}
continue
}
if err != nil || key != tc.want {
t.Fatalf("%s: LoadAIMLAPIServiceKey = %q, %v; want %q", tc.name, key, err, tc.want)
}
}
}
// Resolve drops a non-active profile it cannot normalize (here an unknown
// catalog entry). Such a profile still shows that AIMLAPI_API_KEY belongs to a
// proxy, so the key is withheld whether or not a provider is active.
func TestLoadAIMLAPIServiceKeyWithholdsForDroppedProxyProfile(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("APPDATA", filepath.Join(home, "AppData"))
t.Setenv("USERPROFILE", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
t.Setenv("ZERO_CRED_STORAGE", "file")
t.Setenv("ZERO_PROVIDER", "")
t.Setenv("AIMLAPI_INFERENCE_URL", "")
t.Setenv("AIMLAPI_API_KEY", "env-key")
path, err := DefaultUserConfigPath()
if err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatal(err)
}
canonical := `{"name":"aimlapi","catalogID":"aimlapi","model":"m","apiKey":"profile-key"}`
other := `{"name":"other","provider":"openai","model":"m","apiKey":"other-key"}`
dropped := `{"name":"future","catalogID":"no-such-catalog","baseURL":"https://proxy.example/v1","apiKeyEnv":"AIMLAPI_API_KEY","model":"m"}`
unrelated := `{"name":"future","catalogID":"no-such-catalog","baseURL":"https://proxy.example/v1","apiKeyEnv":"OTHER_API_KEY","model":"m"}`
for _, tc := range []struct {
name string
data string
want string
wantErr error
}{
{name: "active aimlapi profile", data: `{"activeProvider":"aimlapi","providers":[` + canonical + `,` + dropped + `]}`, wantErr: ErrAIMLAPIProxyProfile},
{name: "no active provider", data: `{"providers":[` + other + `,` + dropped + `]}`, wantErr: ErrAIMLAPIProxyProfile},
{name: "dropped profile without the key", data: `{"activeProvider":"aimlapi","providers":[` + canonical + `,` + unrelated + `]}`, want: "profile-key"},
} {
if err := os.WriteFile(path, []byte(tc.data), 0o600); err != nil {
t.Fatal(err)
}
key, err := LoadAIMLAPIServiceKey()
if tc.wantErr != nil {
if !errors.Is(err, tc.wantErr) || key != "" {
t.Fatalf("%s: LoadAIMLAPIServiceKey = %q, %v; want %v", tc.name, key, err, tc.wantErr)
}
continue
}
if err != nil || key != tc.want {
t.Fatalf("%s: LoadAIMLAPIServiceKey = %q, %v; want %q", tc.name, key, err, tc.want)
}
}
}
func TestLoadAIMLAPIServiceKeyStoreOpenFailure(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("APPDATA", filepath.Join(home, "AppData"))
t.Setenv("USERPROFILE", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
t.Setenv("ZERO_PROVIDER", "")
t.Setenv("AIMLAPI_INFERENCE_URL", "")
t.Setenv("AIMLAPI_API_KEY", "env-key")
t.Setenv("ZERO_CRED_STORAGE", "invalid-test-storage")
if key, err := LoadAIMLAPIServiceKey(); err != nil || key != "env-key" {
t.Fatalf("environment key must not require a store: %q, %v", key, err)
}
path, err := DefaultUserConfigPath()
if err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatal(err)
}
t.Setenv("AIMLAPI_API_KEY", "")
data := `{"activeProvider":"aimlapi","providers":[{"name":"aimlapi","catalogID":"aimlapi","model":"m","apiKeyStored":true}]}`
if err := os.WriteFile(path, []byte(data), 0o600); err != nil {
t.Fatal(err)
}
if key, err := LoadAIMLAPIServiceKey(); key != "" || err == nil || !strings.Contains(err.Error(), "unknown storage") {
t.Fatalf("stored key must report store failure: %q, %v", key, err)
}
t.Setenv("AIMLAPI_INFERENCE_URL", "https://proxy.example/v1")
if key, err := LoadAIMLAPIServiceKey(); key != "" || !errors.Is(err, ErrAIMLAPIProxyProfile) {
t.Fatalf("proxy rejection must precede store open: %q, %v", key, err)
}
t.Setenv("AIMLAPI_INFERENCE_URL", "")
data = `{"activeProvider":"aimlapi","providers":[{"name":"aimlapi","catalogID":"aimlapi","model":"m","apiKeyStored":true,"apiKey":"inline-key"}]}`
if err := os.WriteFile(path, []byte(data), 0o600); err != nil {
t.Fatal(err)
}
if key, err := LoadAIMLAPIServiceKey(); err != nil || key != "inline-key" {
t.Fatalf("inline key must not require a store: %q, %v", key, err)
}
}
func TestAIMLAPIServiceKeyReportsStoreReadFailure(t *testing.T) {
want := errors.New("test credential read failure")
resolved := ResolvedConfig{Provider: ProviderProfile{Name: "aimlapi", CatalogID: "aimlapi", BaseURL: "https://api.aimlapi.com/v1", APIKeyStored: true}}
if key, err := AIMLAPIServiceKey(resolved, fakeKeyGetter{err: want}, aimlapiServiceKeyEnv(nil)); key != "" || !errors.Is(err, want) {
t.Fatalf("read failure was hidden: %q, %v", key, err)
}
resolved.Provider.BaseURL = "https://proxy.example/v1"
if _, err := AIMLAPIServiceKey(resolved, fakeKeyGetter{err: want}, aimlapiServiceKeyEnv(nil)); !errors.Is(err, ErrAIMLAPIProxyProfile) {
t.Fatalf("proxy check must precede store read: %v", err)
}
}
-51
View File
@@ -1,51 +0,0 @@
package config
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
func TestEnsureChatGPTDefaultPreservesSavedSelection(t *testing.T) {
for _, saved := range []string{"", "gpt-explicit-saved"} {
name := "new profile"
if saved != "" {
name = "saved selection"
}
t.Run(name, func(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
if saved != "" {
body, err := json.Marshal(FileConfig{ActiveProvider: "codex", Providers: []ProviderProfile{{Name: "codex", CatalogID: "chatgpt", Model: saved}}})
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, body, 0600); err != nil {
t.Fatal(err)
}
}
ensured, err := EnsureCatalogProvider(path, "chatgpt")
if err != nil {
t.Fatal(err)
}
body, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
var cfg FileConfig
if err := json.Unmarshal(body, &cfg); err != nil {
t.Fatal(err)
}
want := "gpt-6.1-sol"
if saved != "" {
want = saved
}
if len(cfg.Providers) != 1 || cfg.Providers[0].Model != want {
t.Fatalf("ChatGPT default/selection = %#v, want model %q", cfg.Providers, want)
}
if saved != "" && (ensured.Name != "codex" || ensured.Created) {
t.Fatalf("saved subscription profile was replaced: %#v", ensured)
}
})
}
}
+5 -27
View File
@@ -3,48 +3,26 @@ package config
import (
"reflect"
"strings"
"github.com/Twigpine/zero/internal/aimlapi"
)
// DefaultMCPServers returns the MCP servers Zero ships built in. Exa is ENABLED
// by default so web search and page fetching work out of the box with no setup
// and no API key. They are seeded before user/project config is merged (see
// ResolveMCP), so a user can override any field — for example add an API-key
// header to lift Exa's anonymous rate limit — or disable it entirely with
// DefaultMCPServers returns the MCP servers Zero ships ENABLED by default so
// web search and page fetching work out of the box with no setup and no API
// key. They are seeded before user/project config is merged (see ResolveMCP),
// so a user can override any field — for example add an API-key header to lift
// Exa's anonymous rate limit — or disable it entirely with
// `zero mcp disable <name>` (which writes `"disabled": true`).
//
// Exa's hosted MCP server works anonymously with rate limits. Users can add an
// Exa API key for higher limits.
//
// The AI/ML API server ships DISABLED: its tool runs and generations are billed
// to the user's AI/ML API balance, so it only connects after
// `zero mcp enable aimlapi` (which writes `"disabled": false`). The disable is
// marked as explicitly set so the lower-trust project layer cannot lift it
// (see mergeMCPServer); only the user and CLI-override scopes can. The key is
// attached per request by the MCP client and is never part of this config.
func DefaultMCPServers() map[string]MCPServerConfig {
return map[string]MCPServerConfig{
"exa": {
Type: "http",
URL: "https://mcp.exa.ai/mcp",
},
"aimlapi": {
Type: "http",
URL: aimlapi.MCPServerURL,
Disabled: true,
disabledSet: true,
},
}
}
// IsDefaultMCPServerDisabled reports whether name is a built-in default that
// ships disabled, so enabling it has to be written out as `"disabled": false`
// rather than by dropping the flag.
func IsDefaultMCPServerDisabled(name string) bool {
return DefaultMCPServers()[strings.TrimSpace(name)].Disabled
}
// IsDefaultMCPServer reports whether name is one of Zero's built-in default MCP
// servers. The config commands use it so a default can be disabled/enabled even
// though it is not written to the user's config file until overridden.
-89
View File
@@ -291,92 +291,3 @@ func TestResolveMCPDisabledRetiredEntryStaysReEnableable(t *testing.T) {
t.Fatal("the disable must still carry to the successor")
}
}
func writeMCPDefaultsTestConfig(t *testing.T, data string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(data), 0o600); err != nil {
t.Fatal(err)
}
return path
}
func TestResolveMCPSeedsDisabledAIMLAPIDefault(t *testing.T) {
if !IsDefaultMCPServer("aimlapi") || !IsDefaultMCPServerDisabled("aimlapi") || IsDefaultMCPServerDisabled("exa") {
t.Fatal("aimlapi must be a built-in default that ships disabled, exa one that ships enabled")
}
// No user config at all, and a user config that never mentions aimlapi.
for name, options := range map[string]ResolveOptions{
"no config": {},
"unrelated user file": {UserConfigPath: writeMCPDefaultsTestConfig(t, `{"mcp":{"servers":{"exa":{"disabled":true}}}}`)},
"user entry with only headers": {UserConfigPath: writeMCPDefaultsTestConfig(t,
`{"mcp":{"servers":{"aimlapi":{"headers":{"X-Trace":"1"}}}}}`)},
} {
cfg, err := ResolveMCP(options)
if err != nil {
t.Fatalf("%s: ResolveMCP: %v", name, err)
}
server, ok := cfg.Servers["aimlapi"]
if !ok {
t.Fatalf("%s: the aimlapi default must be visible in the resolved config", name)
}
if server.Type != "http" || server.URL != "https://mcp.aimlapi.com/mcp" || !server.Disabled {
t.Fatalf("%s: aimlapi default = %#v, want a disabled http server at the MCP URL", name, server)
}
}
cfg, err := ResolveMCP(ResolveOptions{})
if err != nil {
t.Fatal(err)
}
if !IsUnconfiguredDefault("aimlapi", cfg.Servers["aimlapi"]) {
t.Fatal("the untouched aimlapi default should be reported as unconfigured")
}
if exa := cfg.Servers["exa"]; exa.Disabled || !IsUnconfiguredDefault("exa", exa) {
t.Fatalf("exa default changed: %#v", exa)
}
}
func TestResolveMCPUserEnablesAIMLAPIDefault(t *testing.T) {
path := writeMCPDefaultsTestConfig(t, `{"mcp":{"servers":{"aimlapi":{"disabled":false}}}}`)
cfg, err := ResolveMCP(ResolveOptions{UserConfigPath: path})
if err != nil {
t.Fatalf("ResolveMCP: %v", err)
}
server := cfg.Servers["aimlapi"]
if server.Disabled || server.URL != "https://mcp.aimlapi.com/mcp" || server.Type != "http" {
t.Fatalf("user enable did not turn the default on: %#v", server)
}
if IsUnconfiguredDefault("aimlapi", server) {
t.Fatal("an enabled aimlapi is user-configured, so its failures must be reported")
}
enable := MCPServerConfig{Disabled: false, disabledSet: true}
cfg, err = ResolveMCP(ResolveOptions{Overrides: Overrides{MCP: MCPConfig{Servers: map[string]MCPServerConfig{"aimlapi": enable}}}})
if err != nil {
t.Fatal(err)
}
if cfg.Servers["aimlapi"].Disabled {
t.Fatal("the CLI override scope must be able to enable the aimlapi default")
}
}
func TestResolveMCPProjectCannotEnableAIMLAPIDefault(t *testing.T) {
project := writeMCPDefaultsTestConfig(t, `{"mcp":{"servers":{"aimlapi":{"disabled":false}}}}`)
cfg, err := ResolveMCP(ResolveOptions{ProjectConfigPath: project})
if err != nil {
t.Fatalf("ResolveMCP: %v", err)
}
if !cfg.Servers["aimlapi"].Disabled {
t.Fatal("a project config must not turn on the billed aimlapi default")
}
user := writeMCPDefaultsTestConfig(t, `{"mcp":{"servers":{"aimlapi":{"disabled":false}}}}`)
projectDisable := writeMCPDefaultsTestConfig(t, `{"mcp":{"servers":{"aimlapi":{"disabled":true}}}}`)
cfg, err = ResolveMCP(ResolveOptions{UserConfigPath: user, ProjectConfigPath: projectDisable})
if err != nil {
t.Fatalf("ResolveMCP: %v", err)
}
if cfg.Servers["aimlapi"].Disabled {
t.Fatal("the user's explicit enable wins over the project layer, as for any server")
}
}
+4 -7
View File
@@ -455,7 +455,7 @@ func projectAPIKeyEnvAllowed(profile ProviderProfile, envName string) bool {
if baseURL == "" {
baseURL = descriptor.DefaultBaseURL
}
if !providercatalog.IsCatalogBaseURL(descriptor, baseURL) {
if !sameBaseURL(baseURL, descriptor.DefaultBaseURL) {
return false
}
return containsStringFold(descriptor.AuthEnvVars, envName)
@@ -463,7 +463,7 @@ func projectAPIKeyEnvAllowed(profile ProviderProfile, envName string) bool {
func projectBaseURLAllowed(profile ProviderProfile) bool {
descriptor, ok := catalogDescriptorForProfile(profile)
return ok && providercatalog.IsCatalogBaseURL(descriptor, profile.BaseURL)
return ok && sameBaseURL(profile.BaseURL, descriptor.DefaultBaseURL)
}
func hasInheritedProviderCredentialMaterial(project ProviderProfile, candidate ProviderProfile) bool {
@@ -1128,13 +1128,10 @@ func applyCatalogDescriptor(profile *ProviderProfile, descriptor providercatalog
if profile.Model == "" {
profile.Model = descriptor.DefaultModel
}
// A legacy catalog base URL (an earlier default that still serves the
// provider, e.g. a profile saved before a host move) counts as the catalog
// endpoint, so it keeps the catalog env var binding.
canonicalCatalogEndpoint := !explicitBaseURL || providercatalog.IsCatalogBaseURL(descriptor, profile.BaseURL)
if profile.APIKeyEnv == "" && len(descriptor.AuthEnvVars) > 0 && canonicalCatalogEndpoint {
if profile.APIKeyEnv == "" && len(descriptor.AuthEnvVars) > 0 && (!explicitBaseURL || sameBaseURL(profile.BaseURL, descriptor.DefaultBaseURL)) {
profile.APIKeyEnv = descriptor.AuthEnvVars[0]
}
canonicalCatalogEndpoint := !explicitBaseURL || sameBaseURL(profile.BaseURL, descriptor.DefaultBaseURL)
if len(descriptor.CustomHeaders) > 0 && canonicalCatalogEndpoint {
catalogHeaders := descriptor.CustomHeaders
if strings.EqualFold(strings.TrimSpace(descriptor.ID), "aimlapi") {
-68
View File
@@ -1357,74 +1357,6 @@ func TestResolveAppliesProviderCatalogDefaults(t *testing.T) {
}
}
func TestResolveAppliesOpenGatewayCatalogDefaults(t *testing.T) {
path := writeConfig(t, `{
"activeProvider": "gitlawb-opengateway",
"providers": [{
"name": "gitlawb-opengateway",
"catalogID": "gitlawb-opengateway"
}]
}`)
resolved, err := Resolve(ResolveOptions{
UserConfigPath: path,
Env: map[string]string{
"GITLAWB_OPENGATEWAY_API_KEY": "ogw_live_test",
},
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if resolved.Provider.BaseURL != "https://api.twigpine.com/v1" {
t.Fatalf("BaseURL = %q, want https://api.twigpine.com/v1", resolved.Provider.BaseURL)
}
if resolved.Provider.Model != "xiaomi/mimo-v2.6-pro" {
t.Fatalf("Model = %q, want xiaomi/mimo-v2.6-pro", resolved.Provider.Model)
}
if resolved.Provider.APIKeyEnv != "GITLAWB_OPENGATEWAY_API_KEY" || resolved.Provider.APIKey != "ogw_live_test" {
t.Fatalf("credential = (%q, %q), want GITLAWB_OPENGATEWAY_API_KEY env-resolved key", resolved.Provider.APIKeyEnv, resolved.Provider.APIKey)
}
}
// Profiles saved before the Twigpine rename persist the catalog's old base URL
// (https://opengateway.gitlawb.com/v1) explicitly. That host still serves the
// gateway, so those configs must keep resolving exactly as before: same host,
// same model, catalog env var still bound, credential still picked up.
func TestResolveKeepsLegacyOpenGatewayHostProfiles(t *testing.T) {
path := writeConfig(t, `{
"activeProvider": "gitlawb-opengateway",
"providers": [{
"name": "gitlawb-opengateway",
"catalogID": "gitlawb-opengateway",
"providerKind": "openai-compatible",
"baseURL": "https://opengateway.gitlawb.com/v1",
"model": "mimo-v2.5-pro"
}]
}`)
resolved, err := Resolve(ResolveOptions{
UserConfigPath: path,
Env: map[string]string{
"GITLAWB_OPENGATEWAY_API_KEY": "ogw_live_test",
},
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if resolved.Provider.BaseURL != "https://opengateway.gitlawb.com/v1" {
t.Fatalf("BaseURL = %q, want the saved legacy host kept", resolved.Provider.BaseURL)
}
if resolved.Provider.Model != "mimo-v2.5-pro" {
t.Fatalf("Model = %q, want the saved model kept", resolved.Provider.Model)
}
if resolved.Provider.APIKeyEnv != "GITLAWB_OPENGATEWAY_API_KEY" {
t.Fatalf("APIKeyEnv = %q, want GITLAWB_OPENGATEWAY_API_KEY", resolved.Provider.APIKeyEnv)
}
if resolved.Provider.APIKey != "ogw_live_test" {
t.Fatalf("APIKey = %q, want env-resolved key", resolved.Provider.APIKey)
}
}
func TestResolveAppliesMiniMaxCNCatalogDefaults(t *testing.T) {
path := writeConfig(t, `{
"activeProvider": "mini-cn",
-33
View File
@@ -724,39 +724,6 @@ func (cfg LocalControlDriverConfig) MarshalJSON() ([]byte, error) {
return json.Marshal(raw)
}
// MarshalJSON writes "disabled" whenever it was explicitly set, not only when
// true. A read-modify-write of config.json (SetTheme, SetActiveProvider, ...)
// would otherwise drop `"disabled": false`, which turns a default that ships
// disabled (aimlapi) back off and lets project config override a user's
// explicit enable (see mergeMCPServer).
func (server MCPServerConfig) MarshalJSON() ([]byte, error) {
type rawServer struct {
Type string `json:"type,omitempty"`
Command string `json:"command,omitempty"`
Args []string `json:"args,omitempty"`
Env map[string]string `json:"env,omitempty"`
URL string `json:"url,omitempty"`
Headers map[string]string `json:"headers,omitempty"`
Auth string `json:"auth,omitempty"`
OAuth *MCPOAuthConfig `json:"oauth,omitempty"`
Disabled *bool `json:"disabled,omitempty"`
}
raw := rawServer{
Type: server.Type,
Command: server.Command,
Args: server.Args,
Env: server.Env,
URL: server.URL,
Headers: server.Headers,
Auth: server.Auth,
OAuth: server.OAuth,
}
if server.disabledSet || server.Disabled {
raw.Disabled = &server.Disabled
}
return json.Marshal(raw)
}
func (server *MCPServerConfig) UnmarshalJSON(data []byte) error {
type rawServer struct {
Type string `json:"type"`
+58 -4
View File
@@ -100,10 +100,17 @@ func windowsSandboxSetupCheck(goos string, backend sandbox.Backend, workspaceRoo
}
profile := sandbox.PermissionProfileFromPolicy(workspaceRoot, doctorSandboxPolicy(sandboxConfig), scope)
setupConfig := sandbox.WindowsSandboxSetupConfig{
SandboxHome: sandboxHome,
CommandCWD: workspaceRoot,
WorkspaceRoots: []string{workspaceRoot},
PermissionProfile: profile,
SandboxHome: sandboxHome,
CommandCWD: workspaceRoot,
WorkspaceRoots: []string{workspaceRoot},
// Same augmentation the setup args and the command plan apply, so doctor
// fingerprints what a real command fingerprints. Safe here: doctor runs in
// the operator's shell, not behind the sandbox's TEMP redirection.
PermissionProfile: sandbox.WindowsSandboxProfileWithRuntimeRoots(profile, []string{workspaceRoot}),
// Same opt-in a command would resolve, so doctor reports the principal
// mismatch as out-of-date setup instead of passing a check the next command
// will fail.
PrincipalOptIn: sandbox.WindowsSandboxPrincipalOptIn(nil),
}
if err := sandbox.ValidateWindowsSandboxSetupMarker(setupConfig); err != nil {
result := check("sandbox.backend", "Sandbox backend", StatusWarn, fmt.Sprintf("Native sandbox backend %s is installed, but Windows sandbox setup is missing or out of date: %v.", backend.Name, err), map[string]any{
@@ -115,6 +122,53 @@ func windowsSandboxSetupCheck(goos string, backend sandbox.Backend, workspaceRoo
})
return &result
}
// Setup is valid and the opt-in is set, so report which principal a command
// will actually run as.
//
// This used to warn that the principal stood down under the DEFAULT
// network-deny policy, which was true while network denial could only be
// expressed on the restricted token: a principal token cannot carry the
// synthetic offline-marker SID the WFP filters matched. Dual-role provisioning
// removed that limit by giving each workspace an offline account whose real
// group membership the same filters match, so deny mode now runs on a
// principal exactly as allow mode does. Leaving the warning would have doctor
// telling operators their reads are unconfined while they are confined.
//
// Naming the role rather than just saying "active" is the useful part. The two
// accounts differ only in that membership, so an operator debugging network
// behaviour needs to know which one this workspace picks.
if setupConfig.PrincipalOptIn {
// Asked of the shared rule rather than re-derived here, so doctor and the
// runtime cannot disagree about which account a command runs as. Two
// copies drifting is what produced the wrong report this replaces.
role := sandbox.WindowsSandboxPrincipalRoleForNetwork(profile.Network.Mode)
// SETUP IS CURRENT IS NOT THE SAME AS THE PRINCIPAL IS LIVE, and this used
// to claim the second from the first. Marker validation compares serialized
// plans and hashes; it names no account. Delete the selected account or its
// secret, or drop the offline account out of ZeroSandboxOffline, and the
// marker still validates while the runtime either falls back to the weaker
// restricted-token path or fails outright. Doctor asserting read
// confinement in that state is worse than saying nothing, because it is the
// surface an operator checks precisely when they are unsure.
//
// Verifying liveness needs the account, a usable secret and (for offline)
// the group membership, all Windows-only live queries this package cannot
// make. So the claim is narrowed to what the marker actually proves, and
// the role is still named because that part IS derived rather than assumed
// and is what an operator debugging network behaviour needs.
result := check("sandbox.principal", "Sandbox principal", StatusPass,
fmt.Sprintf("Sandbox principal setup is current; a command in this workspace selects the %s principal. Setup state does not confirm the account is usable.", role), map[string]any{
"backend": string(backend.Name),
"platform": goos,
"optIn": true,
// Deliberately not "active": nothing here checked the account, the
// secret or the group membership.
"setupCurrent": true,
"role": role,
"networkMode": string(profile.Network.Mode),
})
return &result
}
return nil
}
@@ -0,0 +1,72 @@
package doctor
import (
"strings"
"testing"
"github.com/Twigpine/zero/internal/config"
"github.com/Twigpine/zero/internal/sandbox"
)
// A VALID MARKER IS NOT A LIVE PRINCIPAL. Marker validation compares serialized
// plans and hashes and names no account, so it stays valid after the selected
// account is deleted, its secret is removed, or the offline account is dropped
// out of ZeroSandboxOffline. In each of those states the runtime falls back to
// the restricted-token path with no read confinement, or fails outright.
//
// Doctor is the surface an operator checks precisely when they are unsure, so
// claiming read confinement it has not verified is worse than reporting less.
// This pins the narrower claim: setup is current, the role is named because it
// is derived rather than assumed, and nothing asserts the account is usable.
func TestPrincipalCheckDoesNotClaimAnUnverifiedPrincipalIsActive(t *testing.T) {
sandboxHome := t.TempDir()
workspaceRoot := t.TempDir()
t.Setenv("ZERO_WINDOWS_SANDBOX_HOME", sandboxHome)
t.Setenv("ZERO_WINDOWS_SANDBOX_IDENTITY", "1")
sandboxConfig := config.SandboxConfig{}
scope, err := sandbox.NewScope(workspaceRoot, sandboxConfig.AdditionalWriteRoots)
if err != nil {
t.Fatalf("NewScope: %v", err)
}
profile := sandbox.PermissionProfileFromPolicy(workspaceRoot, doctorSandboxPolicy(sandboxConfig), scope)
// Mirrors what windowsSandboxSetupCheck derives, so the marker it writes is
// the one the check will validate against. Nothing here provisions an
// account, which is the whole point: the marker is valid and the principal
// does not exist.
if _, err := sandbox.WriteWindowsSandboxSetupMarker(sandbox.WindowsSandboxSetupConfig{
SandboxHome: sandboxHome,
CommandCWD: workspaceRoot,
WorkspaceRoots: []string{workspaceRoot},
PermissionProfile: sandbox.WindowsSandboxProfileWithRuntimeRoots(profile, []string{workspaceRoot}),
PrincipalOptIn: true,
}); err != nil {
t.Fatalf("WriteWindowsSandboxSetupMarker: %v", err)
}
result := windowsSandboxSetupCheck("windows", sandbox.Backend{Name: sandbox.BackendWindowsRestrictedToken}, workspaceRoot, sandboxConfig)
if result == nil {
t.Fatal("expected a principal check once the marker validates and the opt-in is set")
}
if result.ID != "sandbox.principal" {
t.Fatalf("check = %q (%s): the marker did not validate, so this test is not exercising the principal branch", result.ID, result.Message)
}
lowered := strings.ToLower(result.Message)
for _, claim := range []string{"is active", "commands run as"} {
if strings.Contains(lowered, claim) {
t.Errorf("doctor asserts %q about a principal it never checked: %q", claim, result.Message)
}
}
if active, ok := result.Details["active"]; ok {
t.Errorf("details carry active=%v, which nothing here verified", active)
}
// The role is still worth reporting: it is derived from the network mode by
// the shared rule rather than assumed, and it is what an operator debugging
// network behaviour needs.
if role, ok := result.Details["role"]; !ok || strings.TrimSpace(role.(string)) == "" {
t.Errorf("the selected role should still be reported: %#v", result.Details)
}
}
+2 -2
View File
@@ -779,7 +779,7 @@ func normalizeDefinition(raw any, field string) (Definition, error) {
return Definition{}, err
}
if matcher != "" && !eventSupportsMatcher(event) {
return Definition{}, manifestError{fieldPath: field + ".matcher", message: "matcher is only supported for beforeTool, afterTool, specialistStart, and specialistStop hooks."}
return Definition{}, manifestError{fieldPath: field + ".matcher", message: "matcher is only supported for beforeTool and afterTool hooks."}
}
command, err := requiredString(obj, field+".command")
if err != nil {
@@ -843,7 +843,7 @@ func matchesHookMatcher(matcher string, toolName string) bool {
}
func eventSupportsMatcher(event Event) bool {
return event == EventBeforeTool || event == EventAfterTool || event == EventSpecialistStart || event == EventSpecialistStop
return event == EventBeforeTool || event == EventAfterTool
}
func toDiagnostic(err error, source ConfigSource, path string) Diagnostic {
+1 -41
View File
@@ -180,7 +180,7 @@ func TestLoadConfigPreservesUserDisabledStateWhenProjectOmitsEnabled(t *testing.
}
func TestLoadConfigRejectsMatchersOnLifecycleHooks(t *testing.T) {
for _, event := range []string{"sessionStart", "sessionEnd"} {
for _, event := range []string{"sessionStart", "specialistStart"} {
t.Run(event, func(t *testing.T) {
dir := t.TempDir()
projectConfigPath := filepath.Join(dir, "hooks.json")
@@ -210,46 +210,6 @@ func TestLoadConfigRejectsMatchersOnLifecycleHooks(t *testing.T) {
}
}
func TestLoadConfigAcceptsMatchersOnSpecialistHooks(t *testing.T) {
for _, tc := range []struct {
event Event
id string
}{
{event: EventSpecialistStart, id: "zero.explorer-start"},
{event: EventSpecialistStop, id: "zero.explorer-stop"},
} {
t.Run(string(tc.event), func(t *testing.T) {
dir := t.TempDir()
projectConfigPath := filepath.Join(dir, "hooks.json")
writeHookJSON(t, projectConfigPath, map[string]any{
"hooks": []any{map[string]any{
"id": tc.id,
"event": string(tc.event),
"matcher": "explorer",
"command": "node",
}},
})
result, err := LoadConfig(LoadOptions{
UserConfigPath: filepath.Join(dir, "missing-user-hooks.json"),
ProjectConfigPath: projectConfigPath,
})
if err != nil {
t.Fatalf("LoadConfig returned error: %v", err)
}
if len(result.Config.Hooks) != 1 || result.Config.Hooks[0].Matcher != "explorer" {
t.Fatalf("specialist matcher hook = %#v", result.Config.Hooks)
}
if got := hookIDs(Select(result.Config, SelectInput{Event: tc.event, ToolName: "explorer"})); !reflect.DeepEqual(got, []string{tc.id}) {
t.Fatalf("matched selection = %#v", got)
}
if got := Select(result.Config, SelectInput{Event: tc.event, ToolName: "worker"}); len(got) != 0 {
t.Fatalf("unmatched specialist should be empty, got %#v", got)
}
})
}
}
func TestConfigStorePersistsUpdates(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "hooks.json")
store, err := NewConfigStore(StoreOptions{ConfigPath: configPath})
-119
View File
@@ -1,119 +0,0 @@
package mcp
import (
"fmt"
"net/http"
"net/url"
"strings"
"github.com/Twigpine/zero/internal/aimlapi"
"github.com/Twigpine/zero/internal/config"
)
// The AI/ML API MCP server authenticates with the user's AI/ML API key.
//
// Invariant: the key and the attribution headers are attached only to an
// outgoing request whose own URL is https://mcp.aimlapi.com on the default port
// with no userinfo (exact hostname, see aimlapi.IsAttributedURL), whatever the
// server is named. The key is resolved again for every request, redirect hops
// included (see reapplyAIMLAPIRedirectHeaders), through
// config.LoadAIMLAPIServiceKey, which in the same call decides whether it may
// be sent (no proxy profile, a single token), so a provider change applies to
// the next request and a withheld or invalid key means no Authorization
// header. It lives only in that request's headers: never in Server, its
// Identity (OAuth token binding), config.json, list output or errors (an
// error response to such a request is reported without its body). A
// user-configured Authorization header, OAuth, or a project-touched server
// always goes without it.
var loadAIMLAPIServiceKey = config.LoadAIMLAPIServiceKey
var aimlapiMCPHost = func() string {
parsed, err := url.Parse(aimlapi.MCPServerURL)
if err != nil {
panic(err)
}
return parsed.Hostname()
}()
func isAIMLAPIMCPURL(target *url.URL) bool {
return aimlapi.IsAttributedURL(target) && strings.EqualFold(target.Hostname(), aimlapiMCPHost)
}
// aimlapiAutomaticHeaders are the headers applyAIMLAPIRequestHeaders may add.
var aimlapiAutomaticHeaders = [...]string{"Authorization", aimlapi.SourceHeaderName, aimlapi.PartnerHeaderName}
// applyAIMLAPIRequestHeaders runs after the server's own headers are set.
func applyAIMLAPIRequestHeaders(server Server, request *http.Request) {
if request == nil || !isAIMLAPIMCPURL(request.URL) {
return
}
aimlapi.ApplyAttributionHeaders(request.Header, request.URL)
if request.Header.Get("Authorization") != "" || strings.EqualFold(server.Auth, ServerAuthOAuth) || server.ProjectConfigured {
return
}
key, err := loadAIMLAPIServiceKey()
if err != nil || key == "" {
return
}
request.Header.Set("Authorization", "Bearer "+key)
}
// reapplyAIMLAPIRedirectHeaders gives a redirect hop the automatic headers its
// own URL earns. net/http copies the first request's headers onto every hop
// without calling the header hooks, so without this a key or attribution added
// for the first URL would follow a same-origin hop that fails isAIMLAPIMCPURL
// (userinfo, say), and an eligible hop would reuse the first hop's key instead
// of resolving it again. The hop gets back the server's own values for these
// headers, then the same rule as any other request.
func reapplyAIMLAPIRedirectHeaders(server Server, request *http.Request, first *http.Request) {
if request == nil || first == nil || !isAIMLAPIMCPURL(first.URL) {
return
}
for _, name := range aimlapiAutomaticHeaders {
request.Header.Del(name)
}
for name, value := range server.Headers {
for _, automatic := range aimlapiAutomaticHeaders {
if strings.EqualFold(name, automatic) {
request.Header.Set(name, value)
}
}
}
applyAIMLAPIRequestHeaders(server, request)
}
// sentAIMLAPIServiceKey reports whether request went out carrying the key
// applyAIMLAPIRequestHeaders injects, as opposed to the server's own
// Authorization header or OAuth.
func sentAIMLAPIServiceKey(server Server, request *http.Request) bool {
if request == nil || !isAIMLAPIMCPURL(request.URL) || strings.EqualFold(server.Auth, ServerAuthOAuth) || server.ProjectConfigured {
return false
}
return request.Header.Get("Authorization") != "" && !hasUserAuthorizationHeader(server)
}
func hasUserAuthorizationHeader(server Server) bool {
for name, value := range server.Headers {
if strings.EqualFold(name, "Authorization") && value != "" {
return true
}
}
return false
}
// aimlapiUnauthorizedHint explains how to authenticate when the AI/ML API MCP
// server rejects a request without OAuth. It names no key material.
func aimlapiUnauthorizedHint(server Server, status int) string {
if status != http.StatusUnauthorized || strings.EqualFold(server.Auth, ServerAuthOAuth) {
return ""
}
target, err := url.Parse(server.URL)
if err != nil || !isAIMLAPIMCPURL(target) {
return ""
}
oauthHint := fmt.Sprintf("To sign in instead, set \"auth\": \"oauth\" on this server and run `zero mcp oauth login %s`", server.Name)
if server.ProjectConfigured || hasUserAuthorizationHeader(server) {
return " (" + oauthHint + ")"
}
return fmt.Sprintf(" (set up the aimlapi.com provider with `zero providers setup aimlapi` or set AIMLAPI_API_KEY; automatic injection is also disabled for proxy profiles, OAuth, project config, and non-empty Authorization headers — see docs/EXTENDING.md#built-in-servers. To sign in instead, set \"auth\": \"oauth\" on this server and run `zero mcp oauth login %s`)", server.Name)
}
-537
View File
@@ -1,537 +0,0 @@
package mcp
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"github.com/Twigpine/zero/internal/aimlapi"
"github.com/Twigpine/zero/internal/config"
)
// aimlapiRecorder stands in for mcp.aimlapi.com: it records every request it
// is handed (the URL stays https://mcp.aimlapi.com/mcp, nothing is dialed) and
// answers JSON-RPC, or 401 with the server's OAuth challenge when unauthorized.
type aimlapiRecorder struct {
mu sync.Mutex
headers []http.Header
urls []string
unauthorized bool
// redirectFirstTo, when set, answers the first request with a 307 to it.
redirectFirstTo string
// failStatus, when set, answers every request with it and failBody.
failStatus int
failBody string
}
func (recorder *aimlapiRecorder) RoundTrip(request *http.Request) (*http.Response, error) {
var body []byte
if request.Body != nil {
body, _ = io.ReadAll(request.Body)
_ = request.Body.Close()
}
recorder.mu.Lock()
recorder.headers = append(recorder.headers, request.Header.Clone())
recorder.urls = append(recorder.urls, request.URL.String())
unauthorized := recorder.unauthorized
redirect := recorder.redirectFirstTo != "" && len(recorder.headers) == 1
recorder.mu.Unlock()
if redirect {
response := fakeHTTPResponse(request, http.StatusTemporaryRedirect, "")
response.Header.Set("Location", recorder.redirectFirstTo)
return response, nil
}
if recorder.failStatus != 0 {
return fakeHTTPResponse(request, recorder.failStatus, recorder.failBody), nil
}
if unauthorized {
response := fakeHTTPResponse(request, http.StatusUnauthorized, `{"error":"invalid_token"}`)
response.Header.Set("WWW-Authenticate", `Bearer resource_metadata="https://mcp.aimlapi.com/.well-known/oauth-protected-resource/mcp"`)
return response, nil
}
var message struct {
ID json.RawMessage `json:"id"`
Method string `json:"method"`
}
_ = json.Unmarshal(body, &message)
if len(message.ID) == 0 {
return fakeHTTPResponse(request, http.StatusAccepted, ""), nil
}
result := `{}`
switch message.Method {
case "initialize":
result = `{"protocolVersion":"2024-11-05","capabilities":{},"serverInfo":{"name":"fake","version":"1"}}`
case "tools/list":
result = `{"tools":[{"name":"tools_search","inputSchema":{"type":"object"}}]}`
}
return fakeHTTPResponse(request, http.StatusOK, fmt.Sprintf(`{"jsonrpc":"2.0","id":%s,"result":%s}`, message.ID, result)), nil
}
func (recorder *aimlapiRecorder) recorded() []http.Header {
recorder.mu.Lock()
defer recorder.mu.Unlock()
return append([]http.Header(nil), recorder.headers...)
}
func fakeHTTPResponse(request *http.Request, status int, body string) *http.Response {
header := http.Header{}
if body != "" {
header.Set("Content-Type", "application/json")
}
return &http.Response{
StatusCode: status,
Header: header,
Body: io.NopCloser(bytes.NewBufferString(body)),
Request: request,
}
}
// isolateAIMLAPIKeyLookup points the real key resolver at an empty temporary
// home and returns the user config path it reads.
func isolateAIMLAPIKeyLookup(t *testing.T, envKey string) string {
t.Helper()
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
t.Setenv("APPDATA", filepath.Join(home, "AppData"))
t.Setenv("ZERO_CRED_STORAGE", "file")
t.Setenv("ZERO_PROVIDER", "")
t.Setenv("AIMLAPI_INFERENCE_URL", "")
t.Setenv("AIMLAPI_PARTNER_ID", "")
t.Setenv("AIMLAPI_API_KEY", envKey)
path, err := config.DefaultUserConfigPath()
if err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatal(err)
}
return path
}
func writeAIMLAPIProviderConfig(t *testing.T, path string, baseURL string, apiKey string) {
t.Helper()
profile := map[string]any{"name": "aimlapi", "catalogID": "aimlapi", "model": "m", "apiKey": apiKey}
if baseURL != "" {
profile["baseURL"] = baseURL
}
data, err := json.Marshal(map[string]any{"activeProvider": "aimlapi", "providers": []any{profile}})
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatal(err)
}
}
func newRecordedNetworkClient(server Server, recorder *aimlapiRecorder) *networkClient {
return &networkClient{server: server, client: mcpHTTPClient(server, recorder), nextID: 1}
}
func aimlapiTestServer(name string, rawURL string) Server {
return Server{Name: name, Type: ServerTypeHTTP, URL: rawURL}
}
func assertAIMLAPIAttribution(t *testing.T, header http.Header) {
t.Helper()
if got := header.Get(aimlapi.SourceHeaderName); got != aimlapi.SourceValue {
t.Fatalf("%s = %q, want %q", aimlapi.SourceHeaderName, got, aimlapi.SourceValue)
}
if got, want := header.Get(aimlapi.PartnerHeaderName), aimlapi.ResolvePartnerID(""); got == "" || got != want {
t.Fatalf("%s = %q, want %q", aimlapi.PartnerHeaderName, got, want)
}
}
func TestAIMLAPIMCPRequestsCarryServiceKeyAndAttribution(t *testing.T) {
isolateAIMLAPIKeyLookup(t, "aiml-secret")
// A hand-added server on the exact host is treated like the built-in one.
for _, name := range []string{"aimlapi", "media"} {
recorder := &aimlapiRecorder{}
client := newRecordedNetworkClient(aimlapiTestServer(name, aimlapi.MCPServerURL), recorder)
if err := client.initialize(context.Background()); err != nil {
t.Fatalf("%s: initialize: %v", name, err)
}
if _, err := client.ListTools(context.Background()); err != nil {
t.Fatalf("%s: ListTools: %v", name, err)
}
requests := recorder.recorded()
if len(requests) != 3 {
t.Fatalf("%s: recorded %d requests, want initialize, initialized and tools/list", name, len(requests))
}
for _, header := range requests {
if got := header.Get("Authorization"); got != "Bearer aiml-secret" {
t.Fatalf("%s: Authorization = %q, want the AI/ML API key", name, got)
}
assertAIMLAPIAttribution(t, header)
}
}
}
func TestAIMLAPIMCPHeadersOnlyForExactHost(t *testing.T) {
isolateAIMLAPIKeyLookup(t, "aiml-secret")
for _, rawURL := range []string{
"https://mcp.aimlapi.com.example.net/mcp",
"http://mcp.aimlapi.com/mcp",
"https://mcp.aimlapi.com:8443/mcp",
"https://[email protected]/mcp",
"https://api.aimlapi.com/mcp",
"https://evilmcp.aimlapi.com/mcp",
"https://mcp.exa.ai/mcp",
} {
recorder := &aimlapiRecorder{}
server := aimlapiTestServer("other", rawURL)
server.Headers = map[string]string{"X-Custom": "kept"}
client := newRecordedNetworkClient(server, recorder)
if err := client.initialize(context.Background()); err != nil {
t.Fatalf("%s: initialize: %v", rawURL, err)
}
for _, header := range recorder.recorded() {
if strings.Contains(rawURL, "user@") && strings.HasPrefix(header.Get("Authorization"), "Basic ") {
// net/http turns URL userinfo into Basic auth on its own.
header.Del("Authorization")
}
// Byte-for-byte what any other MCP server gets.
want := http.Header{
"X-Custom": {"kept"},
"Content-Type": {"application/json"},
"Accept": {"application/json, text/event-stream"},
}
if fmt.Sprint(header) != fmt.Sprint(want) {
t.Fatalf("%s: headers = %v, want %v", rawURL, header, want)
}
}
}
// Port 443 spelled out and a different host case are still the exact host.
for _, rawURL := range []string{"https://mcp.aimlapi.com:443/mcp", "https://MCP.AIMLAPI.COM/mcp"} {
recorder := &aimlapiRecorder{}
client := newRecordedNetworkClient(aimlapiTestServer("aimlapi", rawURL), recorder)
if err := client.initialize(context.Background()); err != nil {
t.Fatalf("%s: initialize: %v", rawURL, err)
}
header := recorder.recorded()[0]
if header.Get("Authorization") != "Bearer aiml-secret" {
t.Fatalf("%s: Authorization = %q, want the key", rawURL, header.Get("Authorization"))
}
assertAIMLAPIAttribution(t, header)
}
}
// The key and its eligibility are resolved for every request, so switching the
// aimlapi profile to a proxy keeps the proxy's key off mcp.aimlapi.com on the
// very next request of an already-connected client and on new connections.
func TestAIMLAPIMCPKeyIsResolvedPerRequestAcrossProviderSwitch(t *testing.T) {
path := isolateAIMLAPIKeyLookup(t, "")
writeAIMLAPIProviderConfig(t, path, "", "canonical-key")
recorder := &aimlapiRecorder{}
server := aimlapiTestServer("aimlapi", aimlapi.MCPServerURL)
client := newRecordedNetworkClient(server, recorder)
if err := client.initialize(context.Background()); err != nil {
t.Fatalf("initialize: %v", err)
}
if got := recorder.recorded()[0].Get("Authorization"); got != "Bearer canonical-key" {
t.Fatalf("canonical profile: Authorization = %q, want Bearer canonical-key", got)
}
writeAIMLAPIProviderConfig(t, path, "https://proxy.example/v1", "proxy-key")
if _, err := client.ListTools(context.Background()); err != nil {
t.Fatalf("ListTools after switch: %v", err)
}
fresh := newRecordedNetworkClient(server, recorder)
if err := fresh.initialize(context.Background()); err != nil {
t.Fatalf("initialize after switch: %v", err)
}
requests := recorder.recorded()
for _, header := range requests[2:] {
if got := header.Get("Authorization"); got != "" {
t.Fatalf("proxy profile: Authorization = %q, want none", got)
}
assertAIMLAPIAttribution(t, header)
}
for _, header := range requests {
if strings.Contains(fmt.Sprint(header), "proxy-key") {
t.Fatalf("proxy key reached mcp.aimlapi.com: %v", header)
}
}
}
func TestAIMLAPIMCPUnusableKeySendsNoAuthorization(t *testing.T) {
cases := map[string]string{
"multi-line key pool": "first-key\nsecond-key",
"comma key pool": "first-key,second-key",
"no key": "",
}
for name, apiKey := range cases {
t.Run(name, func(t *testing.T) {
path := isolateAIMLAPIKeyLookup(t, "")
writeAIMLAPIProviderConfig(t, path, "", apiKey)
recorder := &aimlapiRecorder{unauthorized: true}
client := newRecordedNetworkClient(aimlapiTestServer("aimlapi", aimlapi.MCPServerURL), recorder)
err := client.initialize(context.Background())
if err == nil {
t.Fatal("initialize succeeded against a 401")
}
requests := recorder.recorded()
if len(requests) != 1 {
t.Fatalf("recorded %d requests, want one intact request", len(requests))
}
header := requests[0]
if got := header.Get("Authorization"); got != "" {
t.Fatalf("Authorization = %q, want none", got)
}
if header.Get("Content-Type") != "application/json" {
t.Fatalf("request headers broken: %v", header)
}
assertAIMLAPIAttribution(t, header)
if !strings.Contains(err.Error(), "returned HTTP 401") || !strings.Contains(err.Error(), "zero mcp oauth login aimlapi") || !strings.Contains(err.Error(), "AIMLAPI_API_KEY") {
t.Fatalf("401 error lacks the authentication hint: %v", err)
}
if strings.Contains(err.Error(), "first-key") {
t.Fatalf("error leaks key material: %v", err)
}
})
}
}
func TestAIMLAPIMCPUnauthorizedHintOnlyForAIMLAPIWithoutOAuth(t *testing.T) {
if hint := aimlapiUnauthorizedHint(aimlapiTestServer("docs", "https://mcp.example.com/mcp"), http.StatusUnauthorized); hint != "" {
t.Fatalf("hint for another server = %q", hint)
}
oauth := aimlapiTestServer("aimlapi", aimlapi.MCPServerURL)
oauth.Auth = ServerAuthOAuth
if hint := aimlapiUnauthorizedHint(oauth, http.StatusUnauthorized); hint != "" {
t.Fatalf("hint for an OAuth server = %q", hint)
}
if hint := aimlapiUnauthorizedHint(aimlapiTestServer("aimlapi", aimlapi.MCPServerURL), http.StatusForbidden); hint != "" {
t.Fatalf("hint for 403 = %q", hint)
}
}
func TestAIMLAPIMCPKeyDefersToUserAuthorizationOAuthAndProjectConfig(t *testing.T) {
isolateAIMLAPIKeyLookup(t, "aiml-secret")
userHeader := aimlapiTestServer("aimlapi", aimlapi.MCPServerURL)
userHeader.Headers = map[string]string{"authorization": "Bearer user-token"}
oauth := aimlapiTestServer("aimlapi", aimlapi.MCPServerURL)
oauth.Auth = ServerAuthOAuth
project := aimlapiTestServer("aimlapi", aimlapi.MCPServerURL)
project.ProjectConfigured = true
cases := []struct {
name string
server Server
want string
}{
{"user Authorization header", userHeader, "Bearer user-token"},
{"OAuth server", oauth, ""},
{"project-configured server", project, ""},
}
for _, tc := range cases {
recorder := &aimlapiRecorder{}
client := newRecordedNetworkClient(tc.server, recorder)
if err := client.initialize(context.Background()); err != nil {
t.Fatalf("%s: initialize: %v", tc.name, err)
}
header := recorder.recorded()[0]
if got := header.Get("Authorization"); got != tc.want {
t.Fatalf("%s: Authorization = %q, want %q", tc.name, got, tc.want)
}
assertAIMLAPIAttribution(t, header)
}
}
func TestAIMLAPIMCPSSERequestsCarryKeyAndAttribution(t *testing.T) {
isolateAIMLAPIKeyLookup(t, "aiml-secret")
server := Server{Name: "aimlapi", Type: ServerTypeSSE, URL: aimlapi.MCPServerURL}
client := &remoteSSEClient{server: server}
apply := map[string]func(*http.Request){
"stream": client.applyStreamHeaders,
"post": client.applyPostHeaders,
}
for name, applyHeaders := range apply {
request, err := http.NewRequest(http.MethodPost, aimlapi.MCPServerURL, nil)
if err != nil {
t.Fatal(err)
}
applyHeaders(request)
if got := request.Header.Get("Authorization"); got != "Bearer aiml-secret" {
t.Fatalf("%s: Authorization = %q, want the key", name, got)
}
assertAIMLAPIAttribution(t, request.Header)
other, err := http.NewRequest(http.MethodPost, "https://mcp.aimlapi.com.example.net/mcp", nil)
if err != nil {
t.Fatal(err)
}
applyHeaders(other)
if other.Header.Get("Authorization") != "" || other.Header.Get(aimlapi.SourceHeaderName) != "" || other.Header.Get(aimlapi.PartnerHeaderName) != "" {
t.Fatalf("%s: lookalike host got AI/ML API headers: %v", name, other.Header)
}
}
}
func TestAIMLAPIMCPKeyStaysOutOfServerConfigAndIdentity(t *testing.T) {
enabled := config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"aimlapi": {Type: "http", URL: aimlapi.MCPServerURL},
}}
isolateAIMLAPIKeyLookup(t, "")
withoutKey, err := NormalizeConfig(enabled)
if err != nil {
t.Fatal(err)
}
isolateAIMLAPIKeyLookup(t, "aiml-secret")
withKey, err := NormalizeConfig(enabled)
if err != nil {
t.Fatal(err)
}
if len(withKey) != 1 || withKey[0].Identity != withoutKey[0].Identity {
t.Fatalf("server identity depends on the key: %#v vs %#v", withKey, withoutKey)
}
if strings.Contains(fmt.Sprintf("%#v", withKey), "aiml-secret") {
t.Fatalf("normalized server carries the key: %#v", withKey)
}
// The shipped default is off, so it is not even normalized for a session.
defaults, err := NormalizeConfig(config.MCPConfig{Servers: config.DefaultMCPServers()})
if err != nil {
t.Fatal(err)
}
for _, server := range defaults {
if server.Name == "aimlapi" {
t.Fatalf("disabled aimlapi default was normalized for connection: %#v", server)
}
}
}
func TestAIMLAPIMCPUnauthorizedHintRespectsCredentialEligibility(t *testing.T) {
for _, tc := range []struct {
name string
headers map[string]string
project bool
wantSetup bool
}{
{"default", nil, false, true},
{"project", nil, true, false},
{"custom bearer", map[string]string{"Authorization": "Bearer manual"}, false, false},
{"mixed case header", map[string]string{"aUtHoRiZaTiOn": "Bearer manual"}, false, false},
{"empty header", map[string]string{"authorization": ""}, false, true},
} {
t.Run(tc.name, func(t *testing.T) {
server := aimlapiTestServer("aimlapi", aimlapi.MCPServerURL)
server.Headers = tc.headers
server.ProjectConfigured = tc.project
hint := aimlapiUnauthorizedHint(server, http.StatusUnauthorized)
if strings.Contains(hint, "zero providers setup aimlapi") != tc.wantSetup || !strings.Contains(hint, "zero mcp oauth login aimlapi") {
t.Fatalf("incorrect hint: %s", hint)
}
})
}
}
// net/http copies the first request's headers onto every redirect hop without
// calling the header hooks, so the redirect guard has to apply the per-request
// rule again: a same-origin hop that fails the exact-URL rule (userinfo) gets
// neither the key nor attribution, an eligible hop gets a freshly resolved key,
// and a user's own Authorization header still follows as before.
func TestAIMLAPIMCPRedirectHopsReapplyAutomaticHeaders(t *testing.T) {
isolateAIMLAPIKeyLookup(t, "")
userHeader := aimlapiTestServer("aimlapi", aimlapi.MCPServerURL)
userHeader.Headers = map[string]string{"authorization": "Bearer user-token"}
for _, tc := range []struct {
name string
server Server
location string
wantFirst string
wantHop string
wantAttribution bool
}{
{"userinfo hop", aimlapiTestServer("aimlapi", aimlapi.MCPServerURL), "https://[email protected]/mcp", "Bearer aiml-first", "", false},
{"eligible hop", aimlapiTestServer("aimlapi", aimlapi.MCPServerURL), "https://mcp.aimlapi.com/mcp/", "Bearer aiml-first", "Bearer aiml-second", true},
{"user header hop", userHeader, "https://[email protected]/mcp", "Bearer user-token", "Bearer user-token", false},
} {
t.Run(tc.name, func(t *testing.T) {
keys := []string{"aiml-first", "aiml-second"}
previous := loadAIMLAPIServiceKey
loadAIMLAPIServiceKey = func() (string, error) {
key := keys[0]
if len(keys) > 1 {
keys = keys[1:]
}
return key, nil
}
t.Cleanup(func() { loadAIMLAPIServiceKey = previous })
recorder := &aimlapiRecorder{redirectFirstTo: tc.location}
client := newRecordedNetworkClient(tc.server, recorder)
if err := client.initialize(context.Background()); err != nil {
t.Fatalf("initialize: %v", err)
}
requests := recorder.recorded()
if len(requests) < 2 || recorder.urls[1] != tc.location {
t.Fatalf("redirect was not followed: %v", recorder.urls)
}
if got := requests[0].Get("Authorization"); got != tc.wantFirst {
t.Fatalf("first hop Authorization = %q, want %q", got, tc.wantFirst)
}
hop := requests[1]
// With no key, net/http fills Basic auth from the hop's own userinfo;
// only the automatic key must be absent.
if got := hop.Get("Authorization"); (tc.wantHop == "" && strings.Contains(got, "aiml-")) || (tc.wantHop != "" && got != tc.wantHop) {
t.Fatalf("redirect hop Authorization = %q, want %q", got, tc.wantHop)
}
if tc.wantAttribution {
assertAIMLAPIAttribution(t, hop)
} else if hop.Get(aimlapi.SourceHeaderName) != "" || hop.Get(aimlapi.PartnerHeaderName) != "" {
t.Fatalf("redirect hop kept attribution headers: %v", hop)
}
})
}
}
// An error body can echo the key Zero injected; the body is left out of the
// error rather than matched, so a key cut by the 1024-byte read limit cannot
// leak its prefix either. Requests without the automatic key keep the detail.
func TestAIMLAPIMCPStatusErrorOmitsBodyWhenKeyWasInjected(t *testing.T) {
const key = "sk-echo-0123456789abcdef"
isolateAIMLAPIKeyLookup(t, key)
for _, tc := range []struct {
name string
body string
}{
{"echoed key", `{"error":"invalid credential ` + key + `"}`},
{"key across the read limit", strings.Repeat("x", 1020) + key},
} {
t.Run(tc.name, func(t *testing.T) {
recorder := &aimlapiRecorder{failStatus: http.StatusPaymentRequired, failBody: tc.body}
err := newRecordedNetworkClient(aimlapiTestServer("aimlapi", aimlapi.MCPServerURL), recorder).initialize(context.Background())
if err == nil {
t.Fatal("initialize succeeded against a failing server")
}
if got := recorder.recorded()[0].Get("Authorization"); got != "Bearer "+key {
t.Fatalf("request did not carry the injected key: %q", got)
}
if strings.Contains(err.Error(), key[:4]) || !strings.Contains(err.Error(), "HTTP 402") {
t.Fatalf("error = %q, want HTTP 402 without the echoed key", err)
}
})
}
userHeader := aimlapiTestServer("aimlapi", aimlapi.MCPServerURL)
userHeader.Headers = map[string]string{"Authorization": "Bearer user-token"}
recorder := &aimlapiRecorder{failStatus: http.StatusPaymentRequired, failBody: `{"error":"insufficient balance"}`}
err := newRecordedNetworkClient(userHeader, recorder).initialize(context.Background())
if err == nil || !strings.Contains(err.Error(), "insufficient balance") {
t.Fatalf("error without the automatic key = %v, want the response detail", err)
}
}
+2 -11
View File
@@ -427,7 +427,6 @@ func (client *networkClient) applyHeaders(request *http.Request) {
if client.sessionID != "" {
request.Header.Set("Mcp-Session-Id", client.sessionID)
}
applyAIMLAPIRequestHeaders(client.server, request)
}
func (client *remoteSSEClient) applyStreamHeaders(request *http.Request) {
@@ -435,7 +434,6 @@ func (client *remoteSSEClient) applyStreamHeaders(request *http.Request) {
request.Header.Set(key, value)
}
request.Header.Set("Accept", "text/event-stream")
applyAIMLAPIRequestHeaders(client.server, request)
}
func (client *remoteSSEClient) applyPostHeaders(request *http.Request) {
@@ -444,7 +442,6 @@ func (client *remoteSSEClient) applyPostHeaders(request *http.Request) {
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Accept", "application/json")
applyAIMLAPIRequestHeaders(client.server, request)
}
func closeResponseBody(errp *error, server Server, body io.Closer) {
@@ -610,18 +607,12 @@ func (client *remoteSSEClient) failPending(err error) {
}
func httpStatusError(server Server, response *http.Response) error {
hint := aimlapiUnauthorizedHint(server, response.StatusCode)
if sentAIMLAPIServiceKey(server, response.Request) {
// The body is server text that may echo the key Zero injected, whole or
// cut by the read limit, so it is left out rather than matched.
return fmt.Errorf("MCP %s server %s returned HTTP %d (response body omitted: the request carried your AI/ML API key)%s", server.Type, server.Name, response.StatusCode, hint)
}
body, _ := io.ReadAll(io.LimitReader(response.Body, 1024))
detail := strings.TrimSpace(string(body))
if detail == "" {
return fmt.Errorf("MCP %s server %s returned HTTP %d%s", server.Type, server.Name, response.StatusCode, hint)
return fmt.Errorf("MCP %s server %s returned HTTP %d", server.Type, server.Name, response.StatusCode)
}
return fmt.Errorf("MCP %s server %s returned HTTP %d: %s%s", server.Type, server.Name, response.StatusCode, detail, hint)
return fmt.Errorf("MCP %s server %s returned HTTP %d: %s", server.Type, server.Name, response.StatusCode, detail)
}
func decodeSSERPCMessage(reader io.Reader) (rpcMessage, error) {
-1
View File
@@ -28,7 +28,6 @@ func checkMCPRedirect(server Server) func(*http.Request, []*http.Request) error
if !sameMCPOrigin(via[0].URL, request.URL) {
return fmt.Errorf("MCP %s server %s refused cross-origin redirect to %s", server.Type, server.Name, mcpOrigin(request.URL))
}
reapplyAIMLAPIRedirectHeaders(server, request, via[0])
return nil
}
}
+1 -1
View File
@@ -10,7 +10,7 @@ import (
"strings"
)
func ensurePrivateDir(path string) error {
func EnsurePrivateDir(path string) error {
abs, err := filepath.Abs(path)
if err != nil {
return err
+8 -2
View File
@@ -12,7 +12,7 @@ import (
"golang.org/x/sys/unix"
)
func ensurePrivateDir(path string) error {
func EnsurePrivateDir(path string) error {
abs, err := filepath.Abs(path)
if err != nil {
return err
@@ -22,7 +22,13 @@ func ensurePrivateDir(path string) error {
if err != nil {
return err
}
defer unix.Close(parentFD)
// THE DESCRIPTOR THIS FUNCTION OWNS CHANGES ON EVERY ITERATION. The walk
// closes parentFD and replaces it with the child it just opened, so a defer
// that captured the first number would close a descriptor this function no
// longer owns (or one the kernel has since reused) and leave the last one it
// does own open. Close whatever is current when we leave, exactly once, on
// every path.
defer func() { unix.Close(parentFD) }()
for index, component := range components {
if component == "" {
continue
+65 -2
View File
@@ -3,9 +3,12 @@
package peermsg
import (
"fmt"
"os"
"path/filepath"
"testing"
"golang.org/x/sys/unix"
)
func TestEnsurePrivateDirRejectsSymlink(t *testing.T) {
@@ -18,7 +21,7 @@ func TestEnsurePrivateDirRejectsSymlink(t *testing.T) {
if err := os.Symlink(target, link); err != nil {
t.Fatal(err)
}
if err := ensurePrivateDir(link); err == nil {
if err := EnsurePrivateDir(link); err == nil {
t.Fatal("expected symlink runtime directory to be rejected")
}
}
@@ -33,7 +36,67 @@ func TestEnsurePrivateDirRejectsSymlinkedParent(t *testing.T) {
if err := os.Symlink(target, link); err != nil {
t.Fatal(err)
}
if err := ensurePrivateDir(filepath.Join(link, "peers")); err == nil {
if err := EnsurePrivateDir(filepath.Join(link, "peers")); err == nil {
t.Fatal("expected symlinked parent to be rejected")
}
}
// THE DESCRIPTOR THE WALK ENDS ON HAS TO BE CLOSED TOO.
//
// EnsurePrivateDir walks the path one component at a time, closing each parent
// as it opens the child. A deferred close that captured the first descriptor
// closed a number this function no longer owned and left the last one open, so
// every fallback-runtime preparation leaked a descriptor. The lowest free
// descriptor is the watermark: dup(0) hands it back, and a leak per cycle walks
// it upward. Several depths, because the kernel reuses numbers and a single
// shallow fixture can hide the leak by accident.
func TestEnsurePrivateDirClosesTheDescriptorItOwns(t *testing.T) {
lowestFree := func() int {
fd, err := unix.Dup(0)
if err != nil {
t.Fatalf("dup: %v", err)
}
unix.Close(fd)
return fd
}
// The physical path: on macOS t.TempDir() sits under /var, which is itself
// a symlink to /private/var, and this walk refuses a symlink component by
// design. Production resolves its temp root the same way before calling.
base, err := filepath.EvalSymlinks(t.TempDir())
if err != nil {
t.Fatalf("resolve temp dir: %v", err)
}
for _, depth := range []int{1, 3, 6} {
parts := []string{base}
for i := 0; i < depth; i++ {
parts = append(parts, fmt.Sprintf("level%d", i))
}
target := filepath.Join(parts...)
before := lowestFree()
for cycle := 0; cycle < 40; cycle++ {
if err := EnsurePrivateDir(target); err != nil {
t.Fatalf("depth %d cycle %d: %v", depth, cycle, err)
}
}
if after := lowestFree(); after != before {
t.Fatalf("depth %d: lowest free descriptor moved from %d to %d over 40 successful walks, so each one leaks", depth, before, after)
}
// And the failing walk: a symlink component is refused partway down.
linked := filepath.Join(base, fmt.Sprintf("linked%d", depth))
if err := os.Symlink(target, linked); err != nil {
t.Fatalf("symlink: %v", err)
}
failing := filepath.Join(linked, "below")
before = lowestFree()
for cycle := 0; cycle < 40; cycle++ {
if err := EnsurePrivateDir(failing); err == nil {
t.Fatalf("depth %d: a symlinked component was not refused", depth)
}
}
if after := lowestFree(); after != before {
t.Fatalf("depth %d: lowest free descriptor moved from %d to %d over 40 refused walks", depth, before, after)
}
}
}
+1 -1
View File
@@ -12,7 +12,7 @@ import (
"golang.org/x/sys/windows"
)
func ensurePrivateDir(path string) (resultErr error) {
func EnsurePrivateDir(path string) (resultErr error) {
abs, err := filepath.Abs(path)
if err != nil {
return err
+5 -5
View File
@@ -54,7 +54,7 @@ func TestEnsurePrivateDirAppliesOwnerOnlyProtectedDACL(t *testing.T) {
if !windowsDirectoryDACLContains(t, path, worldSID) {
t.Fatal("test setup did not grant the broad Everyone ACE")
}
if err := ensurePrivateDir(path); err != nil {
if err := EnsurePrivateDir(path); err != nil {
t.Fatal(err)
}
descriptor, err := windows.GetNamedSecurityInfo(
@@ -149,7 +149,7 @@ func windowsDirectoryDACLContains(t *testing.T, path string, wanted *windows.SID
func TestSecurePrivateDirectoryRejectsOwnerMismatch(t *testing.T) {
path := filepath.Join(t.TempDir(), "private")
if err := ensurePrivateDir(path); err != nil {
if err := EnsurePrivateDir(path); err != nil {
t.Fatal(err)
}
handle, err := openWindowsDirectory(path, windows.FILE_LIST_DIRECTORY|windows.FILE_TRAVERSE|windows.SYNCHRONIZE|windows.READ_CONTROL|windows.WRITE_DAC)
@@ -172,7 +172,7 @@ func TestSecurePrivateDirectoryRejectsOwnerMismatch(t *testing.T) {
func TestSecurePrivateDirectoryReportsDACLWriteFailure(t *testing.T) {
path := filepath.Join(t.TempDir(), "private")
if err := ensurePrivateDir(path); err != nil {
if err := EnsurePrivateDir(path); err != nil {
t.Fatal(err)
}
handle, err := openWindowsDirectory(path, windows.FILE_LIST_DIRECTORY|windows.FILE_TRAVERSE|windows.SYNCHRONIZE|windows.READ_CONTROL)
@@ -206,7 +206,7 @@ func TestEnsurePrivateDirRejectsWindowsReparseParent(t *testing.T) {
if err := os.Symlink(target, link); err != nil {
t.Skipf("cannot create Windows directory symlink: %v", err)
}
if err := ensurePrivateDir(filepath.Join(link, "peers")); err == nil {
if err := EnsurePrivateDir(filepath.Join(link, "peers")); err == nil {
t.Fatal("expected reparse-point parent to be rejected")
}
}
@@ -217,7 +217,7 @@ func TestEnsurePrivateDirRejectsWindowsFileComponent(t *testing.T) {
if err := os.WriteFile(file, []byte("not a directory"), 0o600); err != nil {
t.Fatal(err)
}
if err := ensurePrivateDir(filepath.Join(file, "peers")); err == nil {
if err := EnsurePrivateDir(filepath.Join(file, "peers")); err == nil {
t.Fatal("expected file path component to be rejected")
}
}
+3 -3
View File
@@ -158,7 +158,7 @@ func New(options Options) (*Service, error) {
}
// canonicalRuntimePath normalizes aliases in the existing prefix. It does not
// establish a security boundary; ensurePrivateDir validates that boundary when
// establish a security boundary; EnsurePrivateDir validates that boundary when
// the service starts.
func canonicalRuntimePath(path string) (string, error) {
missing := make([]string, 0, 4)
@@ -219,10 +219,10 @@ func (service *Service) Start(handler Handler) error {
if service.closed {
return errors.New("peer messaging: service is closed")
}
if err := ensurePrivateDir(service.root); err != nil {
if err := EnsurePrivateDir(service.root); err != nil {
return fmt.Errorf("peer messaging: create runtime directory: %w", err)
}
if err := ensurePrivateDir(service.registryDir()); err != nil {
if err := EnsurePrivateDir(service.registryDir()); err != nil {
return fmt.Errorf("peer messaging: create registry: %w", err)
}
endpoint, err := service.transport.Endpoint(service.root, service.nonce, service.pid)
+1 -1
View File
@@ -44,7 +44,7 @@ func canonicalPrivateDir(path string) (string, error) {
if err != nil {
return "", err
}
if err := ensurePrivateDir(canonical); err != nil {
if err := EnsurePrivateDir(canonical); err != nil {
return "", err
}
return canonical, nil
+1 -1
View File
@@ -374,7 +374,7 @@ func (tool skillTool) Run(_ context.Context, args map[string]any) tools.Result {
available := make([]string, 0, len(merged))
for _, skill := range merged {
if skill.Name == name {
return tools.Result{Status: tools.StatusOK, Output: skills.ToolOutput(skill)}
return tools.Result{Status: tools.StatusOK, Output: skill.Content}
}
available = append(available, skill.Name)
}
-25
View File
@@ -673,28 +673,3 @@ func TestExpandPluginRootPathRelative(t *testing.T) {
}
}
}
// The multi-root skill tool is the one the CLI registers, so it has to point at
// a skill's bundled files the same way the core tool does (#584).
func TestNewSkillToolPointsAtTheSkillsBundledFiles(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
dir := t.TempDir()
writeTestSkill(t, dir, "pdf", "Run scripts/fill.py.")
script := filepath.Join(dir, "pdf", "scripts", "fill.py")
if err := os.MkdirAll(filepath.Dir(script), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(script, []byte("print('fill')\n"), 0o755); err != nil {
t.Fatal(err)
}
got := NewSkillTool(dir, nil).Run(context.Background(), map[string]any{"name": "pdf"})
if got.Status != tools.StatusOK {
t.Fatalf("Status = %s (output: %s)", got.Status, got.Output)
}
if !strings.Contains(got.Output, "- scripts/fill.py") || !strings.Contains(got.Output, filepath.Join(dir, "pdf")) {
t.Fatalf("output does not point at the bundled script:\n%s", got.Output)
}
}
-552
View File
@@ -1,552 +0,0 @@
package plugins
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"strings"
"github.com/Twigpine/zero/internal/installtxn"
"github.com/Twigpine/zero/internal/pathjail"
)
// SetEnabledResult describes a successful enable/disable of one plugin manifest.
type SetEnabledResult struct {
ID string `json:"id"`
Enabled bool `json:"enabled"`
Changed bool `json:"changed"`
Source Source `json:"source"`
ManifestPath string `json:"manifestPath"`
}
// maxEnabledRelockAttempts bounds how many times SetEnabledByID re-resolves the
// plugin after it moves to another install root while waiting for the lock.
const maxEnabledRelockAttempts = 4
// FindByID returns the loaded plugin with the given id after normal discovery
// and precedence (project overrides user). A missing id returns ok=false.
func FindByID(options LoadOptions, id string) (LoadedPlugin, bool, error) {
id = strings.TrimSpace(id)
if id == "" {
return LoadedPlugin{}, false, errors.New("plugin id is required")
}
result, err := Load(options)
if err != nil {
return LoadedPlugin{}, false, err
}
for _, plugin := range result.Plugins {
if plugin.ID == id {
return plugin, true, nil
}
}
return LoadedPlugin{}, false, nil
}
// SetEnabledByID finds a plugin by id (respecting LoadOptions) and toggles the
// top-level "enabled" field of its plugin.json.
//
// The read-modify-write is serialized with plugin installs and removals: it
// takes the same cross-process installtxn lock they take on the plugin's
// install root, runs the same interrupted-transaction recovery they run under
// it, re-resolves the plugin under that lock, and publishes the
// manifest with an atomic rename while still holding it, so a concurrent
// install can never be interleaved between the read and the write. Only the
// bytes of the "enabled" value change, so field order, formatting, and numeric
// literals in the rest of the manifest survive the toggle.
func SetEnabledByID(options LoadOptions, id string, enabled bool) (SetEnabledResult, error) {
for attempt := 0; ; attempt++ {
plugin, ok, err := FindByID(options, id)
if err != nil {
return SetEnabledResult{}, err
}
if !ok {
return SetEnabledResult{}, fmt.Errorf("%w: %q", ErrNotInstalled, id)
}
// Take the install root lock before the final lookup and read so an
// install or remove cannot swap the tree between them.
unlock, err := installtxn.Lock(plugin.Root)
if err != nil {
return SetEnabledResult{}, err
}
// Put back anything an earlier install or removal was killed mid-commit,
// and abort if a transaction could not be resolved: editing a tree that is
// still owed a restore loses the edit when the next install or removal
// puts the backup back over it. See installtxn.Recover.
if err := installtxn.Recover(plugin.Root, reconcileInterrupted(plugin.Root)); err != nil {
unlock()
return SetEnabledResult{}, err
}
final, ok, err := FindByID(options, id)
if err != nil {
unlock()
return SetEnabledResult{}, err
}
if !ok {
unlock()
return SetEnabledResult{}, fmt.Errorf("%w: %q", ErrNotInstalled, id)
}
if final.Root != plugin.Root {
// Precedence moved to another root while this lock was pending;
// retry against the root that wins now so the edit still serializes
// with that root's installs.
unlock()
if attempt >= maxEnabledRelockAttempts {
return SetEnabledResult{}, fmt.Errorf("plugin %q kept changing roots while waiting for the install lock", id)
}
continue
}
changed, err := setEnabledLocked(final, enabled)
unlock()
if err != nil {
return SetEnabledResult{}, err
}
return SetEnabledResult{
ID: final.ID,
Enabled: enabled,
Changed: changed,
Source: final.Source,
ManifestPath: final.ManifestPath,
}, nil
}
}
// setEnabledLocked performs the manifest read-modify-write while the caller
// holds the installtxn lock for the plugin's install root.
//
// The lock only serializes cooperating installers; it does not stop the plugin
// directory from being swapped after discovery found it. So the whole update
// works on one directory, pinned by openPluginDir: the manifest is read and
// published, and the tree hashed for the lockfile, through that handle, never
// by re-resolving the path.
func setEnabledLocked(plugin LoadedPlugin, enabled bool) (bool, error) {
pinned, err := openPluginDir(plugin)
if err != nil {
return false, err
}
defer pinned.Close()
return setEnabledPinned(plugin, pinned, enabled)
}
// setEnabledPinned is setEnabledLocked on an already pinned plugin directory.
//
// The edit lands in the pinned directory even if the path now names another
// one, so before refreshing the lockfile or reporting any result it confirms
// that the directory entry still is the pinned directory. Otherwise the
// command would report a state that the installed plugin does not have.
func setEnabledPinned(plugin LoadedPlugin, pinned *pinnedPluginDir, enabled bool) (bool, error) {
manifestName := filepath.Base(plugin.ManifestPath)
raw, perm, err := readManifest(pinned.dir, manifestName)
if err != nil {
return false, err
}
data, changed, err := toggleEnabledManifest(raw, enabled)
if err != nil {
return false, err
}
if !changed {
if err := pinned.verifyLive(); err != nil {
return false, err
}
return false, nil
}
// Read the lockfile before the edit so the refresh below only runs when the
// installed tree still matched its recorded hash; a toggle must never mask
// pre-existing drift.
lock, entry, hashClean := cleanLockEntry(plugin, pinned.dir)
if err := publishManifest(pinned.dir, manifestName, data, perm); err != nil {
return false, err
}
if !hashClean {
if err := pinned.verifyLive(); err != nil {
return false, err
}
return true, nil
}
after, err := hashRootedTree(pinned.dir)
if err != nil {
return true, fmt.Errorf("plugin manifest updated but %s hash refresh failed: %w", LockFileName, err)
}
if err := pinned.verifyLive(); err != nil {
return false, err
}
entry.Hash = after
lock[plugin.ID] = entry
if err := writeLock(plugin.Root, lock); err != nil {
return true, fmt.Errorf("plugin manifest updated but %s hash refresh failed: %w", LockFileName, err)
}
return true, nil
}
// errPluginDirReplaced reports that the plugin's directory entry no longer
// names the directory the toggle pinned and edited.
var errPluginDirReplaced = errors.New("plugin directory was replaced while it was being updated; the plugin installed there now was not changed")
// pinnedPluginDir is the plugin directory discovery found, held open for the
// length of one toggle.
type pinnedPluginDir struct {
root *os.Root // the plugin's install root
name string // the plugin directory's entry name in root
path string // the plugin directory's path, for messages
dir *os.Root // the plugin directory itself
}
// Close releases both handles.
func (p *pinnedPluginDir) Close() {
_ = p.dir.Close()
_ = p.root.Close()
}
// verifyLive confirms that the plugin's entry in its install root is still a
// real directory, not a symlink, junction, or other reparse point, and is the
// very directory p.dir holds open.
func (p *pinnedPluginDir) verifyLive() error {
opened, err := p.dir.Stat(".")
if err != nil {
return fmt.Errorf("inspect plugin directory: %w", err)
}
entry, err := p.root.Lstat(p.name)
if errors.Is(err, fs.ErrNotExist) {
return fmt.Errorf("%w: %s", errPluginDirReplaced, p.path)
}
if err != nil {
return fmt.Errorf("inspect plugin directory: %w", err)
}
if entry.Mode()&(os.ModeSymlink|os.ModeIrregular) != 0 {
return fmt.Errorf("%w: plugin directory %s", pathjail.ErrReparse, p.path)
}
if !os.SameFile(entry, opened) {
return fmt.Errorf("%w: %s", errPluginDirReplaced, p.path)
}
return nil
}
// openPluginDir pins the plugin directory discovery found, confined to the
// plugin's install root.
//
// A link swapped in before the open is refused here. One swapped in after it
// cannot redirect anything, because every later read, write, and hash is
// relative to the returned handle rather than to a path; verifyLive then
// detects that the path no longer names the edited directory.
func openPluginDir(plugin LoadedPlugin) (*pinnedPluginDir, error) {
root, name, err := pathjail.Open(plugin.Root, plugin.PluginDir)
if err != nil {
return nil, fmt.Errorf("open plugin directory: %w", err)
}
if name == "." || filepath.Base(name) != name {
_ = root.Close()
return nil, fmt.Errorf("%w: plugin directory %s is not an entry of %s", pathjail.ErrEscapes, plugin.PluginDir, plugin.Root)
}
if err := pathjail.RefuseReparse(root, name); err != nil {
_ = root.Close()
return nil, fmt.Errorf("open plugin directory: %w", err)
}
dir, err := root.OpenRoot(name)
if err != nil {
_ = root.Close()
return nil, fmt.Errorf("open plugin directory: %w", err)
}
pinned := &pinnedPluginDir{root: root, name: name, path: plugin.PluginDir, dir: dir}
if err := pinned.verifyLive(); err != nil {
pinned.Close()
return nil, err
}
return pinned, nil
}
// readManifest reads name from dir and returns its bytes and permission bits.
// Failing on a stat error keeps the publish from widening the permissions.
func readManifest(dir *os.Root, name string) ([]byte, os.FileMode, error) {
file, err := dir.Open(name)
if err != nil {
return nil, 0, fmt.Errorf("read plugin manifest: %w", err)
}
defer file.Close()
info, err := file.Stat()
if err != nil {
return nil, 0, fmt.Errorf("inspect plugin manifest: %w", err)
}
raw, err := io.ReadAll(file)
if err != nil {
return nil, 0, fmt.Errorf("read plugin manifest: %w", err)
}
return raw, info.Mode().Perm(), nil
}
// publishManifest replaces name in dir with data by renaming a complete
// temporary sibling over it, so a concurrent reader sees either the old
// manifest or the new one, never a partial write.
func publishManifest(dir *os.Root, name string, data []byte, perm os.FileMode) error {
temp, tempName, err := pathjail.CreateTemp(dir, ".", ".zero-manifest", "")
if err != nil {
return fmt.Errorf("write plugin manifest: %w", err)
}
defer func() { _ = dir.Remove(tempName) }()
if err := temp.Chmod(perm); err != nil {
_ = temp.Close()
return fmt.Errorf("write plugin manifest: %w", err)
}
if _, err := temp.Write(data); err != nil {
_ = temp.Close()
return fmt.Errorf("write plugin manifest: %w", err)
}
if err := temp.Sync(); err != nil {
_ = temp.Close()
return fmt.Errorf("write plugin manifest: %w", err)
}
if err := temp.Close(); err != nil {
return fmt.Errorf("write plugin manifest: %w", err)
}
if err := dir.Rename(tempName, name); err != nil {
return fmt.Errorf("write plugin manifest: %w", err)
}
return nil
}
// cleanLockEntry reports whether the pinned plugin directory dir still matches
// the lockfile entry recorded for the plugin and returns both for a refresh.
func cleanLockEntry(plugin LoadedPlugin, dir *os.Root) (map[string]LockEntry, LockEntry, bool) {
lock, err := ReadLock(plugin.Root)
if err != nil {
return nil, LockEntry{}, false
}
entry, ok := lock[plugin.ID]
if !ok {
return nil, LockEntry{}, false
}
current, err := hashRootedTree(dir)
if err != nil || current != entry.Hash {
return nil, LockEntry{}, false
}
return lock, entry, true
}
// toggleEnabledManifest returns raw with the top-level "enabled" member set to
// enabled. A missing or null value counts as enabled, matching the loader's
// optionalBool fallback. changed reports whether the result differs from raw.
func toggleEnabledManifest(raw []byte, enabled bool) (out []byte, changed bool, err error) {
dec := json.NewDecoder(bytes.NewReader(raw))
openTok, err := dec.Token()
if err != nil {
return nil, false, fmt.Errorf("parse plugin manifest: %w", err)
}
if delim, ok := openTok.(json.Delim); !ok || delim != '{' {
return nil, false, errors.New("plugin manifest must be a JSON object")
}
openEnd := dec.InputOffset()
current := true
lastInvalid := false
var valueStart, valueEnd int64 = -1, -1
for dec.More() {
keyTok, err := dec.Token()
if err != nil {
return nil, false, fmt.Errorf("parse plugin manifest: %w", err)
}
key, ok := keyTok.(string)
if !ok {
return nil, false, errors.New("plugin manifest must be a JSON object")
}
keyEnd := dec.InputOffset()
if key != "enabled" {
if err := skipManifestValue(dec); err != nil {
return nil, false, fmt.Errorf("parse plugin manifest: %w", err)
}
continue
}
valueTok, err := dec.Token()
if err != nil {
return nil, false, fmt.Errorf("parse plugin manifest: %w", err)
}
var literal string
switch value := valueTok.(type) {
case bool:
current = value
literal = "false"
if value {
literal = "true"
}
case nil:
current = true
literal = "null"
default:
// Only the last occurrence of a duplicate key counts, as in the
// loader's json.Unmarshal, so an earlier non-boolean value that a
// later one overwrites is not an error. Remember it and decide after
// the last member.
if err := skipManifestComposite(dec, valueTok); err != nil {
return nil, false, fmt.Errorf("parse plugin manifest: %w", err)
}
lastInvalid = true
continue
}
lastInvalid = false
start, err := enabledValueOffset(raw, keyEnd)
if err != nil {
return nil, false, err
}
if start+int64(len(literal)) > int64(len(raw)) ||
!bytes.Equal(raw[start:start+int64(len(literal))], []byte(literal)) {
return nil, false, errors.New("parse plugin manifest: cannot locate the enabled value")
}
// A duplicate key keeps the last occurrence, matching json.Unmarshal.
valueStart, valueEnd = start, start+int64(len(literal))
}
closingTok, err := dec.Token()
if err != nil {
return nil, false, fmt.Errorf("parse plugin manifest: %w", err)
}
if delim, ok := closingTok.(json.Delim); !ok || delim != '}' {
return nil, false, errors.New("plugin manifest must be a JSON object")
}
closingEnd := dec.InputOffset()
if _, err := dec.Token(); err != io.EOF {
return nil, false, errors.New("parse plugin manifest: unexpected data after the top-level object")
}
if lastInvalid {
return nil, false, ManifestError{FieldPath: "enabled", Message: "Expected a boolean."}
}
if current == enabled {
return nil, false, nil
}
newLiteral := "false"
if enabled {
newLiteral = "true"
}
if valueStart >= 0 {
out := make([]byte, 0, len(raw)-int(valueEnd-valueStart)+len(newLiteral))
out = append(out, raw[:valueStart]...)
out = append(out, newLiteral...)
out = append(out, raw[valueEnd:]...)
return out, true, nil
}
return insertEnabledMember(raw, openEnd, closingEnd-1, newLiteral), true, nil
}
// enabledValueOffset returns the byte offset of the value that follows the
// colon after an "enabled" key token that ended at keyEnd.
func enabledValueOffset(raw []byte, keyEnd int64) (int64, error) {
i := keyEnd
for i < int64(len(raw)) && isJSONSpace(raw[i]) {
i++
}
if i >= int64(len(raw)) || raw[i] != ':' {
return 0, errors.New("parse plugin manifest: missing colon after key")
}
i++
for i < int64(len(raw)) && isJSONSpace(raw[i]) {
i++
}
return i, nil
}
// insertEnabledMember adds a top-level "enabled" member to a manifest that has
// none, mirroring the document's own whitespace style where it can. openEnd is
// the byte offset just after the opening brace and bracePos is the byte index
// of the closing brace.
func insertEnabledMember(raw []byte, openEnd, bracePos int64, literal string) []byte {
insertPos := bracePos
for insertPos > openEnd && isJSONSpace(raw[insertPos-1]) {
insertPos--
}
newline := "\n"
if bytes.Contains(raw, []byte("\r\n")) {
newline = "\r\n"
}
first := openEnd
for first < bracePos && isJSONSpace(raw[first]) {
first++
}
empty := first >= bracePos
multiline := bytes.IndexByte(raw[openEnd:bracePos], '\n') >= 0
member := `"enabled": ` + literal
var insert string
switch {
case empty && !multiline:
insert = member
case empty && multiline:
insert = newline + closingLineIndent(raw, openEnd, bracePos) + " " + member
case !empty && !multiline:
insert = "," + member
default:
insert = "," + newline + memberLineIndent(raw, openEnd, first) + member
}
out := make([]byte, 0, len(raw)+len(insert))
out = append(out, raw[:insertPos]...)
out = append(out, insert...)
out = append(out, raw[insertPos:]...)
return out
}
// memberLineIndent returns the indentation of the line the first member of a
// JSON object starts on. first is the byte index of that member's first token.
func memberLineIndent(raw []byte, openEnd, first int64) string {
lineStart := first
for lineStart > openEnd && raw[lineStart-1] != '\n' {
lineStart--
}
end := lineStart
for end < first && (raw[end] == ' ' || raw[end] == '\t') {
end++
}
return string(raw[lineStart:end])
}
// closingLineIndent returns the indentation before an object's closing brace.
func closingLineIndent(raw []byte, openEnd, bracePos int64) string {
start := bracePos
for start > openEnd && (raw[start-1] == ' ' || raw[start-1] == '\t') {
start--
}
return string(raw[start:bracePos])
}
// skipManifestValue consumes the next JSON value from dec.
func skipManifestValue(dec *json.Decoder) error {
tok, err := dec.Token()
if err != nil {
return err
}
return skipManifestComposite(dec, tok)
}
// skipManifestComposite consumes the rest of the value whose first token tok
// was just read from dec. A scalar is already complete; an object or array is
// consumed through its matching closing delimiter.
func skipManifestComposite(dec *json.Decoder, tok json.Token) error {
delim, ok := tok.(json.Delim)
if !ok || (delim != '{' && delim != '[') {
return nil
}
depth := 1
for depth > 0 {
tok, err := dec.Token()
if err != nil {
return err
}
if inner, ok := tok.(json.Delim); ok {
switch inner {
case '{', '[':
depth++
case '}', ']':
depth--
}
}
}
return nil
}
func isJSONSpace(b byte) bool {
return b == ' ' || b == '\t' || b == '\n' || b == '\r'
}
-999
View File
@@ -1,999 +0,0 @@
package plugins
import (
"errors"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"github.com/Twigpine/zero/internal/installtxn"
"github.com/Twigpine/zero/internal/pathjail"
)
func enableTestOptions(root string) LoadOptions {
return LoadOptions{Roots: []Root{{Source: SourceUser, Path: root}}}
}
func writeRawManifest(t *testing.T, root, dir, raw string) string {
t.Helper()
pluginDir := filepath.Join(root, dir)
if err := os.MkdirAll(pluginDir, 0o700); err != nil {
t.Fatal(err)
}
manifestPath := filepath.Join(pluginDir, "plugin.json")
if err := os.WriteFile(manifestPath, []byte(raw), 0o600); err != nil {
t.Fatal(err)
}
return manifestPath
}
func demoManifest(enabled bool) string {
state := "false"
if enabled {
state = "true"
}
return `{
"schemaVersion": 1,
"id": "zero.demo",
"name": "Demo",
"version": "1.0.0",
"enabled": ` + state + `
}
`
}
func TestSetEnabledByIDPreservesManifestBytes(t *testing.T) {
root := t.TempDir()
raw := `{
"schemaVersion": 1,
"id": "zero.demo",
"name": "Demo",
"version": "1.0.0",
"limits": {
"maxBytes": 9007199254740993
},
"description": "keep me",
"enabled": true,
"tools": []
}
`
manifestPath := writeRawManifest(t, root, "demo", raw)
result, err := SetEnabledByID(enableTestOptions(root), "zero.demo", false)
if err != nil {
t.Fatalf("SetEnabledByID(false): %v", err)
}
if !result.Changed || result.Enabled {
t.Fatalf("unexpected result: %#v", result)
}
disabled, err := os.ReadFile(manifestPath)
if err != nil {
t.Fatal(err)
}
want := strings.Replace(raw, `"enabled": true`, `"enabled": false`, 1)
if string(disabled) != want {
t.Fatalf("toggle changed more than the enabled value:\n got %q\nwant %q", disabled, want)
}
if !strings.Contains(string(disabled), "9007199254740993") {
t.Fatal("large integer literal was rounded by the toggle")
}
plugin, ok, err := FindByID(enableTestOptions(root), "zero.demo")
if err != nil || !ok {
t.Fatalf("FindByID after disable: ok=%v err=%v", ok, err)
}
if plugin.Enabled {
t.Fatal("loader still reports the plugin enabled after disable")
}
result, err = SetEnabledByID(enableTestOptions(root), "zero.demo", true)
if err != nil {
t.Fatalf("SetEnabledByID(true): %v", err)
}
if !result.Changed || !result.Enabled {
t.Fatalf("unexpected enable result: %#v", result)
}
enabled, err := os.ReadFile(manifestPath)
if err != nil {
t.Fatal(err)
}
if string(enabled) != raw {
t.Fatalf("round trip did not restore the original bytes:\n got %q\nwant %q", enabled, raw)
}
}
func TestSetEnabledByIDTogglesManifestAndIsIdempotent(t *testing.T) {
root := t.TempDir()
writePluginManifest(t, filepath.Join(root, "demo"), map[string]any{
"schemaVersion": 1,
"id": "zero.demo",
"name": "Demo",
"version": "1.0.0",
"description": "keep me",
"tools": []any{},
})
manifestPath := filepath.Join(root, "demo", "plugin.json")
result, err := SetEnabledByID(enableTestOptions(root), "zero.demo", false)
if err != nil {
t.Fatalf("SetEnabledByID(false): %v", err)
}
if !result.Changed {
t.Fatal("expected first disable to change the manifest")
}
raw, err := os.ReadFile(manifestPath)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(raw), `"enabled": false`) {
t.Fatalf("manifest not disabled: %s", raw)
}
if !strings.Contains(string(raw), `"keep me"`) {
t.Fatalf("description was not preserved: %s", raw)
}
result, err = SetEnabledByID(enableTestOptions(root), "zero.demo", false)
if err != nil {
t.Fatalf("SetEnabledByID(false) again: %v", err)
}
if result.Changed {
t.Fatal("expected second disable to be a no-op")
}
result, err = SetEnabledByID(enableTestOptions(root), "zero.demo", true)
if err != nil {
t.Fatalf("SetEnabledByID(true): %v", err)
}
if !result.Changed {
t.Fatal("expected enable to change the manifest")
}
}
func TestSetEnabledByIDInsertsMissingField(t *testing.T) {
t.Run("pretty manifest", func(t *testing.T) {
root := t.TempDir()
raw := `{
"schemaVersion": 1,
"id": "zero.demo",
"name": "Demo",
"version": "1.0.0"
}
`
manifestPath := writeRawManifest(t, root, "demo", raw)
if _, err := SetEnabledByID(enableTestOptions(root), "zero.demo", false); err != nil {
t.Fatalf("SetEnabledByID(false): %v", err)
}
disabled, err := os.ReadFile(manifestPath)
if err != nil {
t.Fatal(err)
}
wantDisabled := `{
"schemaVersion": 1,
"id": "zero.demo",
"name": "Demo",
"version": "1.0.0",
"enabled": false
}
`
if string(disabled) != wantDisabled {
t.Fatalf("after insert:\n got %q\nwant %q", disabled, wantDisabled)
}
if _, err := SetEnabledByID(enableTestOptions(root), "zero.demo", true); err != nil {
t.Fatalf("SetEnabledByID(true): %v", err)
}
enabled, err := os.ReadFile(manifestPath)
if err != nil {
t.Fatal(err)
}
wantEnabled := strings.Replace(wantDisabled, `"enabled": false`, `"enabled": true`, 1)
if string(enabled) != wantEnabled {
t.Fatalf("after replace:\n got %q\nwant %q", enabled, wantEnabled)
}
})
t.Run("compact manifest", func(t *testing.T) {
root := t.TempDir()
raw := `{"schemaVersion":1,"id":"zero.demo","name":"Demo","version":"1.0.0"}`
manifestPath := writeRawManifest(t, root, "demo", raw)
if _, err := SetEnabledByID(enableTestOptions(root), "zero.demo", false); err != nil {
t.Fatalf("SetEnabledByID(false): %v", err)
}
disabled, err := os.ReadFile(manifestPath)
if err != nil {
t.Fatal(err)
}
want := `{"schemaVersion":1,"id":"zero.demo","name":"Demo","version":"1.0.0","enabled": false}`
if string(disabled) != want {
t.Fatalf("compact insert:\n got %q\nwant %q", disabled, want)
}
})
t.Run("null value", func(t *testing.T) {
root := t.TempDir()
raw := `{
"schemaVersion": 1,
"id": "zero.demo",
"name": "Demo",
"version": "1.0.0",
"enabled": null
}
`
manifestPath := writeRawManifest(t, root, "demo", raw)
result, err := SetEnabledByID(enableTestOptions(root), "zero.demo", true)
if err != nil {
t.Fatalf("SetEnabledByID(true): %v", err)
}
if result.Changed {
t.Fatal("null counts as enabled, so enabling must be a no-op")
}
unchanged, err := os.ReadFile(manifestPath)
if err != nil {
t.Fatal(err)
}
if string(unchanged) != raw {
t.Fatalf("no-op toggle rewrote the manifest: %q", unchanged)
}
if _, err := SetEnabledByID(enableTestOptions(root), "zero.demo", false); err != nil {
t.Fatalf("SetEnabledByID(false): %v", err)
}
disabled, err := os.ReadFile(manifestPath)
if err != nil {
t.Fatal(err)
}
want := strings.Replace(raw, `"enabled": null`, `"enabled": false`, 1)
if string(disabled) != want {
t.Fatalf("null replacement:\n got %q\nwant %q", disabled, want)
}
})
}
func TestToggleEnabledManifestFormattingEdges(t *testing.T) {
cases := []struct {
name string
in string
enabled bool
want string
changed bool
}{
{"empty object", `{}`, false, `{"enabled": false}`, true},
{"empty object with space", `{ }`, false, `{"enabled": false }`, true},
{"empty multiline", "{\n}", false, "{\n \"enabled\": false\n}", true},
{"compact members", `{"a":1}`, false, `{"a":1,"enabled": false}`, true},
{"crlf members", "{\r\n \"a\": 1\r\n}", false, "{\r\n \"a\": 1,\r\n \"enabled\": false\r\n}", true},
{"nested enabled untouched", `{"nested":{"enabled":true},"a":2}`, false, `{"nested":{"enabled":true},"a":2,"enabled": false}`, true},
{"replace with true", `{"enabled": false}`, true, `{"enabled": true}`, true},
{"replace with false", `{"enabled":true}`, false, `{"enabled":false}`, true},
{"null to false", `{"enabled": null}`, false, `{"enabled": false}`, true},
{"null to true is a no-op", `{"enabled": null}`, true, `{"enabled": null}`, false},
{"already enabled is a no-op", `{"enabled": true}`, true, `{"enabled": true}`, false},
// The loader keeps the last duplicate, so an overwritten non-boolean
// value is not an error and only the last value is rewritten.
{"overwritten string", `{"enabled":"obsolete","enabled":true}`, false, `{"enabled":"obsolete","enabled":false}`, true},
{"overwritten composite", `{"enabled":{"a":[1,{"enabled":2}]},"enabled":null}`, false, `{"enabled":{"a":[1,{"enabled":2}]},"enabled":false}`, true},
{"overwritten number is a no-op", `{"enabled":1,"enabled":false}`, false, `{"enabled":1,"enabled":false}`, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
out, changed, err := toggleEnabledManifest([]byte(tc.in), tc.enabled)
if err != nil {
t.Fatalf("toggleEnabledManifest: %v", err)
}
if changed != tc.changed {
t.Fatalf("changed = %v, want %v", changed, tc.changed)
}
if !tc.changed {
if out != nil {
t.Fatalf("no-op returned bytes: %q", out)
}
return
}
if string(out) != tc.want {
t.Fatalf("got %q, want %q", out, tc.want)
}
})
}
}
func TestToggleEnabledManifestRejectsMalformedInput(t *testing.T) {
for _, raw := range []string{`{"enabled": "yes"}`, `{"enabled": 1}`, `{"enabled": []}`, `{"enabled": {}}`, `{"enabled": true, "enabled": "yes"}`} {
t.Run("value "+raw, func(t *testing.T) {
_, _, err := toggleEnabledManifest([]byte(raw), false)
if err == nil {
t.Fatal("expected error")
}
var manifestErr ManifestError
if !errors.As(err, &manifestErr) {
t.Fatalf("error = %v, want ManifestError", err)
}
if !strings.Contains(err.Error(), "enabled") {
t.Fatalf("error = %v, want it to name the enabled field", err)
}
})
}
for _, raw := range []string{``, `{`, `[]`, `null`, `"str"`, `{} {}`, `{}{}`} {
t.Run("document "+raw, func(t *testing.T) {
if _, _, err := toggleEnabledManifest([]byte(raw), false); err == nil {
t.Fatalf("toggleEnabledManifest(%q) succeeded, want error", raw)
}
})
}
}
// TestSetEnabledByIDHonorsTheLastDuplicateEnabledValue keeps the toggle in step
// with the loader on a manifest whose earlier "enabled" value is overwritten:
// the loader discovers the plugin from the last value, so the toggle must be
// able to change it too.
func TestSetEnabledByIDHonorsTheLastDuplicateEnabledValue(t *testing.T) {
root := t.TempDir()
writeRawManifest(t, root, "demo", `{
"schemaVersion": 1,
"id": "zero.demo",
"name": "Demo",
"version": "1.0.0",
"enabled": "obsolete",
"enabled": true
}
`)
loadedEnabled := func() bool {
t.Helper()
plugin, ok, err := FindByID(enableTestOptions(root), "zero.demo")
if err != nil || !ok {
t.Fatalf("FindByID: ok=%v err=%v", ok, err)
}
return plugin.Enabled
}
if !loadedEnabled() {
t.Fatal("the loader should discover the plugin as enabled from the last value")
}
for _, enabled := range []bool{false, true} {
result, err := SetEnabledByID(enableTestOptions(root), "zero.demo", enabled)
if err != nil {
t.Fatalf("SetEnabledByID(%v): %v", enabled, err)
}
if !result.Changed {
t.Fatalf("SetEnabledByID(%v) reported no change", enabled)
}
if got := loadedEnabled(); got != enabled {
t.Fatalf("after SetEnabledByID(%v) the loader sees enabled=%v", enabled, got)
}
}
}
// TestSetEnabledRefusesASwappedPluginDirectory swaps the plugin directory that
// discovery found for a link between the lookup and the edit. The install lock
// does not cover that window, because it only serializes cooperating
// installers. The edit must fail without following the link: neither the
// link's target nor the moved-away original may change.
func TestSetEnabledRefusesASwappedPluginDirectory(t *testing.T) {
cases := []struct {
name string
// link returns the symlink target to plant and the manifest it leads to.
link func(t *testing.T, root string) (target string, manifest string)
}{
{"to a directory outside the plugins root", func(t *testing.T, root string) (string, string) {
manifest := writeRawManifest(t, t.TempDir(), "elsewhere", demoManifest(true))
return filepath.Dir(manifest), manifest
}},
{"to another plugin in the same root", func(t *testing.T, root string) (string, string) {
// A relative link stays inside the root, so confinement to the
// root alone would follow it.
manifest := writeRawManifest(t, root, "other", strings.Replace(demoManifest(true), "zero.demo", "zero.other", 1))
return "other", manifest
}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
root := t.TempDir()
raw := demoManifest(true)
writeRawManifest(t, root, "demo", raw)
plugin, ok, err := FindByID(enableTestOptions(root), "zero.demo")
if err != nil || !ok {
t.Fatalf("FindByID: ok=%v err=%v", ok, err)
}
target, targetManifest := tc.link(t, root)
targetBefore, err := os.ReadFile(targetManifest)
if err != nil {
t.Fatal(err)
}
moved := filepath.Join(t.TempDir(), "moved-away")
if err := os.Rename(plugin.PluginDir, moved); err != nil {
t.Fatal(err)
}
if err := os.Symlink(target, plugin.PluginDir); err != nil {
t.Skipf("cannot create a directory symlink here: %v", err)
}
if _, err := setEnabledLocked(plugin, false); !errors.Is(err, pathjail.ErrReparse) {
t.Fatalf("setEnabledLocked through a swapped directory: err = %v, want %v", err, pathjail.ErrReparse)
}
if after, err := os.ReadFile(targetManifest); err != nil || string(after) != string(targetBefore) {
t.Fatalf("the edit followed the link and changed %s (err %v):\n got %q\nwant %q", targetManifest, err, after, targetBefore)
}
if after, err := os.ReadFile(filepath.Join(moved, "plugin.json")); err != nil || string(after) != raw {
t.Fatalf("the moved-away manifest changed (err %v): %q", err, after)
}
})
}
}
// TestSetEnabledDetectsAPluginDirectoryReplacedAfterOpening replaces the live
// plugin directory after the toggle pinned it. The edit then lands in the
// moved-away directory, so the toggle must not refresh the lockfile from either
// tree and must not report success: the plugin installed at the path now is
// unchanged.
func TestSetEnabledDetectsAPluginDirectoryReplacedAfterOpening(t *testing.T) {
for _, tc := range []struct {
name string
pinned bool // the pinned manifest's enabled value
changes bool // whether disabling it is a real edit
}{
{"edit", true, true},
{"no-op", false, false},
} {
t.Run(tc.name, func(t *testing.T) {
root := t.TempDir()
manifestPath := writeRawManifest(t, root, "demo", demoManifest(tc.pinned))
pluginDir := filepath.Dir(manifestPath)
hash, err := hashTree(pluginDir)
if err != nil {
t.Fatal(err)
}
if err := writeLock(root, map[string]LockEntry{"zero.demo": {Source: "./demo", Hash: hash}}); err != nil {
t.Fatal(err)
}
lockPath := filepath.Join(root, LockFileName)
lockBefore, err := os.ReadFile(lockPath)
if err != nil {
t.Fatal(err)
}
plugin, ok, err := FindByID(enableTestOptions(root), "zero.demo")
if err != nil || !ok {
t.Fatalf("FindByID: ok=%v err=%v", ok, err)
}
pinned, err := openPluginDir(plugin)
if err != nil {
t.Fatalf("openPluginDir: %v", err)
}
defer pinned.Close()
moved := filepath.Join(t.TempDir(), "moved-away")
if err := os.Rename(pluginDir, moved); err != nil {
t.Fatal(err)
}
replacement := strings.Replace(demoManifest(true), `"version": "1.0.0"`, `"version": "2.0.0"`, 1)
writeRawManifest(t, root, "demo", replacement)
// The lockfile check hashes the pinned tree, which still matches the
// recorded hash, not the replacement now at the path.
if _, _, clean := cleanLockEntry(plugin, pinned.dir); !clean {
t.Error("cleanLockEntry hashed the replacement at the path instead of the pinned directory")
}
if _, err := setEnabledPinned(plugin, pinned, false); !errors.Is(err, errPluginDirReplaced) {
t.Fatalf("setEnabledPinned after the directory was replaced: err = %v, want %v", err, errPluginDirReplaced)
}
if after, err := os.ReadFile(manifestPath); err != nil || string(after) != replacement {
t.Fatalf("the replacement manifest changed (err %v): %q", err, after)
}
if after, err := os.ReadFile(lockPath); err != nil || string(after) != string(lockBefore) {
t.Fatalf("the lockfile changed (err %v):\n got %s\nwant %s", err, after, lockBefore)
}
movedRaw, err := os.ReadFile(filepath.Join(moved, "plugin.json"))
if err != nil {
t.Fatal(err)
}
if tc.changes != (string(movedRaw) != demoManifest(tc.pinned)) {
t.Fatalf("pinned manifest edit = %v, want %v: %q", !tc.changes, tc.changes, movedRaw)
}
})
}
}
func TestSetEnabledByIDRespectsUserOnlyFilter(t *testing.T) {
dir := t.TempDir()
userRoot := filepath.Join(dir, "user")
projectRoot := filepath.Join(dir, "project")
writePluginManifest(t, filepath.Join(userRoot, "demo"), map[string]any{
"schemaVersion": 1,
"id": "zero.demo",
"name": "User Demo",
"version": "0.1.0",
"enabled": true,
})
writePluginManifest(t, filepath.Join(projectRoot, "demo"), map[string]any{
"schemaVersion": 1,
"id": "zero.demo",
"name": "Project Demo",
"version": "0.2.0",
"enabled": true,
})
result, err := SetEnabledByID(LoadOptions{
Roots: []Root{
{Source: SourceUser, Path: userRoot},
{Source: SourceProject, Path: projectRoot},
},
}, "zero.demo", false)
if err != nil {
t.Fatalf("SetEnabledByID: %v", err)
}
if result.Source != SourceProject {
t.Fatalf("source = %s, want project (precedence)", result.Source)
}
if !result.Changed || result.Enabled {
t.Fatalf("unexpected result: %#v", result)
}
userOnly, err := SetEnabledByID(LoadOptions{
Roots: []Root{
{Source: SourceUser, Path: userRoot},
{Source: SourceProject, Path: projectRoot},
},
ExcludeProject: true,
}, "zero.demo", false)
if err != nil {
t.Fatalf("SetEnabledByID user-only: %v", err)
}
if userOnly.Source != SourceUser {
t.Fatalf("user-only source = %s, want user", userOnly.Source)
}
}
func TestSetEnabledPreservesManifestMode(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Windows does not preserve chmod bits in os.Stat mode")
}
root := t.TempDir()
writePluginManifest(t, filepath.Join(root, "demo"), map[string]any{
"schemaVersion": 1,
"id": "zero.demo",
"name": "Demo",
"version": "1.0.0",
})
manifestPath := filepath.Join(root, "demo", "plugin.json")
if err := os.Chmod(manifestPath, 0o644); err != nil {
t.Fatal(err)
}
if _, err := SetEnabledByID(enableTestOptions(root), "zero.demo", false); err != nil {
t.Fatalf("SetEnabledByID: %v", err)
}
info, err := os.Stat(manifestPath)
if err != nil {
t.Fatal(err)
}
if got := info.Mode().Perm(); got != 0o644 {
t.Fatalf("mode = %04o, want 0644", got)
}
}
func TestSetEnabledByIDMissingPlugin(t *testing.T) {
_, err := SetEnabledByID(LoadOptions{Roots: []Root{{Source: SourceUser, Path: t.TempDir()}}}, "missing", false)
if err == nil {
t.Fatal("expected missing plugin error")
}
if !errors.Is(err, ErrNotInstalled) {
t.Fatalf("error = %v, want ErrNotInstalled", err)
}
}
func TestSetEnabledPublishFailureLeavesManifestIntact(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Windows directory permissions do not block writes the way this test needs")
}
if os.Geteuid() == 0 {
t.Skip("root bypasses directory permission checks")
}
root := t.TempDir()
raw := demoManifest(true)
manifestPath := writeRawManifest(t, root, "demo", raw)
pluginDir := filepath.Dir(manifestPath)
if err := os.Chmod(pluginDir, 0o555); err != nil {
t.Fatal(err)
}
defer func() { _ = os.Chmod(pluginDir, 0o755) }()
if _, err := SetEnabledByID(enableTestOptions(root), "zero.demo", false); err == nil {
t.Fatal("expected the publish to fail in a read-only plugin directory")
}
after, err := os.ReadFile(manifestPath)
if err != nil {
t.Fatal(err)
}
if string(after) != raw {
t.Fatalf("failed publish changed the manifest:\n got %q\nwant %q", after, raw)
}
}
// TestSetEnabledByIDWaitsForInstallRootLock proves the toggle takes the same
// cross-process lock Install and Remove take on the plugin's install root: it
// must not complete while an install holds that lock.
func TestSetEnabledByIDWaitsForInstallRootLock(t *testing.T) {
root := t.TempDir()
manifestPath := writeRawManifest(t, root, "demo", demoManifest(true))
unlock, err := installtxn.Lock(root)
if err != nil {
t.Fatal(err)
}
results := make(chan SetEnabledResult, 1)
failures := make(chan error, 1)
go func() {
result, err := SetEnabledByID(enableTestOptions(root), "zero.demo", false)
if err != nil {
failures <- err
return
}
results <- result
}()
select {
case result := <-results:
unlock()
t.Fatalf("toggle completed while the install lock was held: %#v", result)
case err := <-failures:
unlock()
t.Fatalf("toggle failed while the install lock was held: %v", err)
case <-time.After(200 * time.Millisecond):
}
unlock()
select {
case result := <-results:
if !result.Changed {
t.Fatalf("toggle did not report a change: %#v", result)
}
case err := <-failures:
t.Fatalf("toggle failed after the lock was released: %v", err)
case <-time.After(10 * time.Second):
t.Fatal("toggle did not finish after the install lock was released")
}
raw, err := os.ReadFile(manifestPath)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(raw), `"enabled": false`) {
t.Fatalf("manifest not disabled: %s", raw)
}
}
// TestSetEnabledByIDSynchronizesWithStagedInstallCommit pauses an install
// between staging and commit, lets the toggle try to run, then commits the
// install. The toggle must apply to the newly installed manifest instead of
// overwriting it with bytes read before the install, and the lockfile entry
// must describe the tree actually on disk.
func TestSetEnabledByIDSynchronizesWithStagedInstallCommit(t *testing.T) {
root := t.TempDir()
oldRaw := demoManifest(true)
pluginDir := filepath.Dir(writeRawManifest(t, root, "zero.demo", oldRaw))
// Build the replacement tree with the same staging primitives Install
// uses and leave it staged but uncommitted.
src := t.TempDir()
newRaw := `{
"schemaVersion": 1,
"id": "zero.demo",
"name": "Demo",
"version": "2.0.0",
"description": "reinstalled",
"enabled": true
}
`
srcPlugin := filepath.Join(src, "zero.demo")
if err := os.MkdirAll(srcPlugin, 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(srcPlugin, "plugin.json"), []byte(newRaw), 0o600); err != nil {
t.Fatal(err)
}
staged, cleanup, err := installtxn.StageDir(root)
if err != nil {
t.Fatal(err)
}
defer cleanup()
if err := copyTree(srcPlugin, staged); err != nil {
t.Fatal(err)
}
stagedHash, err := hashTree(staged)
if err != nil {
t.Fatal(err)
}
// An install holds the root lock from before its commit until after it.
unlock, err := installtxn.Lock(root)
if err != nil {
t.Fatal(err)
}
results := make(chan SetEnabledResult, 1)
failures := make(chan error, 1)
go func() {
result, err := SetEnabledByID(enableTestOptions(root), "zero.demo", false)
if err != nil {
failures <- err
return
}
results <- result
}()
select {
case result := <-results:
unlock()
t.Fatalf("toggle completed while the install lock was held: %#v", result)
case err := <-failures:
unlock()
t.Fatalf("toggle failed while the install lock was held: %v", err)
case <-time.After(200 * time.Millisecond):
}
// Commit the staged install exactly like Install does: swap the tree and
// publish the lockfile entry while still holding the lock.
lock, err := ReadLock(root)
if err != nil {
unlock()
t.Fatal(err)
}
lock["zero.demo"] = LockEntry{Source: string(SourceUser), Hash: stagedHash}
if err := installtxn.CommitDir(pluginDir, staged, func() error { return writeLock(root, lock) }); err != nil {
unlock()
t.Fatal(err)
}
unlock()
var result SetEnabledResult
select {
case result = <-results:
case err := <-failures:
t.Fatalf("toggle failed after the install committed: %v", err)
case <-time.After(10 * time.Second):
t.Fatal("toggle did not finish after the install committed")
}
if !result.Changed {
t.Fatalf("toggle result = %#v, want changed", result)
}
// The toggle must have applied to the newly installed manifest, not to the
// bytes it first read.
final, err := os.ReadFile(filepath.Join(pluginDir, "plugin.json"))
if err != nil {
t.Fatal(err)
}
want := strings.Replace(newRaw, `"enabled": true`, `"enabled": false`, 1)
if string(final) != want {
t.Fatalf("manifest after concurrent install + disable:\n got %q\nwant %q", final, want)
}
// The lockfile entry must describe the tree that is actually on disk.
entries, err := ReadLock(root)
if err != nil {
t.Fatal(err)
}
current, err := hashTree(pluginDir)
if err != nil {
t.Fatal(err)
}
if entries["zero.demo"].Hash != current {
t.Fatalf("plugins.lock hash = %q, want current tree hash %q", entries["zero.demo"].Hash, current)
}
}
func TestSetEnabledByIDRefreshesCleanLockEntry(t *testing.T) {
root := t.TempDir()
manifestPath := writeRawManifest(t, root, "zero.demo", demoManifest(true))
pluginDir := filepath.Dir(manifestPath)
current, err := hashTree(pluginDir)
if err != nil {
t.Fatal(err)
}
if err := writeLock(root, map[string]LockEntry{"zero.demo": {Source: string(SourceUser), Hash: current}}); err != nil {
t.Fatal(err)
}
if _, err := SetEnabledByID(enableTestOptions(root), "zero.demo", false); err != nil {
t.Fatalf("SetEnabledByID: %v", err)
}
entries, err := ReadLock(root)
if err != nil {
t.Fatal(err)
}
afterToggle, err := hashTree(pluginDir)
if err != nil {
t.Fatal(err)
}
if entries["zero.demo"].Hash == current {
t.Fatal("lock hash was not refreshed for a clean install")
}
if entries["zero.demo"].Hash != afterToggle {
t.Fatalf("lock hash = %q, want refreshed tree hash %q", entries["zero.demo"].Hash, afterToggle)
}
info, err := Info(InfoOptions{LoadOptions: enableTestOptions(root)}, "zero.demo")
if err != nil {
t.Fatal(err)
}
if info.HashDrift {
t.Fatal("toggle left hash drift behind for a clean install")
}
// Pre-existing drift must survive the next toggle instead of being
// absorbed into the lockfile.
if err := os.WriteFile(filepath.Join(pluginDir, "extra.txt"), []byte("edited\n"), 0o600); err != nil {
t.Fatal(err)
}
if _, err := SetEnabledByID(enableTestOptions(root), "zero.demo", true); err != nil {
t.Fatalf("SetEnabledByID(true): %v", err)
}
entries, err = ReadLock(root)
if err != nil {
t.Fatal(err)
}
if entries["zero.demo"].Hash != afterToggle {
t.Fatalf("toggle absorbed pre-existing drift: hash = %q, want %q", entries["zero.demo"].Hash, afterToggle)
}
}
// TestSetEnabledByIDReportsAPluginRemovedWhileWaiting removes the plugin while
// the toggle is blocked on the install-root lock, exactly as Remove does while
// it holds that lock. The toggle must report the plugin as not installed
// instead of editing, or recreating, a tree that no longer exists.
func TestSetEnabledByIDReportsAPluginRemovedWhileWaiting(t *testing.T) {
root := t.TempDir()
pluginDir := filepath.Dir(writeRawManifest(t, root, "zero.demo", demoManifest(true)))
unlock, err := installtxn.Lock(root)
if err != nil {
t.Fatal(err)
}
failures := make(chan error, 1)
go func() {
_, err := SetEnabledByID(enableTestOptions(root), "zero.demo", false)
failures <- err
}()
select {
case err := <-failures:
unlock()
t.Fatalf("toggle returned while the install lock was held: %v", err)
case <-time.After(200 * time.Millisecond):
}
if err := installtxn.RemoveDir(pluginDir, func() error { return nil }); err != nil {
unlock()
t.Fatal(err)
}
unlock()
select {
case err := <-failures:
if !errors.Is(err, ErrNotInstalled) {
t.Fatalf("toggle after a concurrent removal: error = %v, want ErrNotInstalled", err)
}
case <-time.After(10 * time.Second):
t.Fatal("toggle did not finish after the removal released the lock")
}
if _, err := os.Stat(pluginDir); !os.IsNotExist(err) {
t.Fatalf("toggle recreated the removed plugin: %v", err)
}
}
// TestSetEnabledByIDResolvesInterruptedUpdatesFirst covers every point an
// update can be killed at that leaves the plugin discoverable. The toggle
// holds the install-root lock, so it has to
// run the same recovery pass Install and Remove run before it reads the tree:
// otherwise it edits, or fails to find, a tree that is still owed a restore,
// and the next install or removal puts the backup back over the user's edit.
func TestSetEnabledByIDResolvesInterruptedUpdatesFirst(t *testing.T) {
for _, state := range interruptedStates() {
t.Run(state.name, func(t *testing.T) {
u := seedInterruptedUpdate(t)
workspace := state.plant(t, u)
want := state.want(u)
if _, err := os.Lstat(u.target()); os.IsNotExist(err) {
// No root lists a plugin whose only copy is in a workspace, so the
// toggle reports it not installed without taking any lock, just as
// list and info do; the next install or removal restores it.
t.Skip("nothing is at the target, so the toggle never takes the lock")
}
result, err := SetEnabledByID(enableTestOptions(u.dir), "zero.demo", false)
if err != nil {
t.Fatalf("toggle after the interrupted update: %v", err)
}
if !result.Changed || result.Enabled {
t.Fatalf("toggle result = %#v, want changed and disabled", result)
}
assertDisabledAfterRecovery(t, u, workspace, want)
// The next lock holder runs recovery again. The disable has to survive
// it rather than be replaced by a backup the toggle never saw.
if err := installOther(t, u.dir, "zero.other"); err != nil {
t.Fatalf("install after the toggle: %v", err)
}
assertDisabledAfterRecovery(t, u, workspace, want)
})
}
}
func assertDisabledAfterRecovery(t *testing.T, u interruptedUpdate, workspace string, want recoveryWant) {
t.Helper()
data, err := os.ReadFile(filepath.Join(u.target(), "generation.txt"))
if err != nil {
t.Fatalf("reading the live tree: %v", err)
}
if string(data) != want.generation {
t.Errorf("the live tree is the %q generation, want %q", data, want.generation)
}
plugin, ok, err := FindByID(enableTestOptions(u.dir), "zero.demo")
if err != nil {
t.Fatal(err)
}
if !ok {
t.Fatal("Load does not see the toggled plugin")
}
if plugin.Version != want.version {
t.Errorf("Load sees version %q, want %q", plugin.Version, want.version)
}
if plugin.Enabled {
t.Errorf("the disable did not stick: Load sees the plugin enabled")
}
_, err = os.Stat(workspace)
if want.workspace && err != nil {
t.Errorf("the workspace should be left alone, got %v", err)
}
if !want.workspace && !os.IsNotExist(err) {
t.Errorf("the resolved workspace should be cleared, got %v", err)
}
}
// TestSetEnabledByIDAbortsOnAnUnresolvedInterruptedUpdate leaves a workspace
// whose trees match neither lockfile hash. Recovery cannot pick one, so the
// toggle must fail without editing the live manifest or the lockfile.
func TestSetEnabledByIDAbortsOnAnUnresolvedInterruptedUpdate(t *testing.T) {
u := seedInterruptedUpdate(t)
workspace := u.plantWorkspace(t)
u.mustRename(t, u.target(), filepath.Join(workspace, "previous"))
u.mustRename(t, filepath.Join(workspace, "staged"), u.target())
unmatched := LockEntry{Source: u.oldSource, Hash: "sha256:0000000000000000000000000000000000000000000000000000000000000000"}
lock, err := ReadLock(u.dir)
if err != nil {
t.Fatal(err)
}
lock["zero.demo"] = unmatched
if err := writeLock(u.dir, lock); err != nil {
t.Fatal(err)
}
manifestPath := filepath.Join(u.target(), "plugin.json")
before, err := os.ReadFile(manifestPath)
if err != nil {
t.Fatal(err)
}
if _, err := SetEnabledByID(enableTestOptions(u.dir), "zero.demo", false); err == nil {
t.Fatal("toggle did not report the unresolved transaction")
}
after, err := os.ReadFile(manifestPath)
if err != nil {
t.Fatal(err)
}
if string(after) != string(before) {
t.Errorf("the aborted toggle edited the manifest:\n got %q\nwant %q", after, before)
}
assertUntouched(t, u, "new", unmatched)
if _, err := os.Stat(filepath.Join(workspace, "previous")); err != nil {
t.Errorf("the backup should still be there for a hand rescue, got %v", err)
}
}
+1 -2
View File
@@ -7,8 +7,7 @@ import (
"strings"
)
// ErrNotInstalled is returned when a plugin id cannot be found under the given
// LoadOptions (for example by Info or SetEnabledByID).
// ErrNotInstalled is returned when Info cannot find a plugin with the requested id.
var ErrNotInstalled = errors.New("plugin is not installed")
// PluginInfo combines a loaded plugin with optional lockfile metadata.
+11 -37
View File
@@ -567,32 +567,15 @@ func validInstallID(id string) bool {
// sorted order. Each file contributes its plugin-relative path, executable bit,
// and bytes, so renames, mode flips, and content edits all change the hash.
func hashTree(root string) (string, error) {
handle, err := os.OpenRoot(root)
if err != nil {
return "", err
}
defer handle.Close()
return hashRootedTree(handle)
}
// hashRootedTree is hashTree over a directory that is already open. Walking
// relative to the handle hashes the directory it names even if its path is
// replaced meanwhile, and keeps the walk inside it.
//
// The walk calls the handle's own methods rather than handle.FS(): the fs.FS
// adapter only accepts fs.ValidPath names, which must be UTF-8, so it would
// refuse file names that the filesystem and copyTree accept.
func hashRootedTree(handle *os.Root) (string, error) {
hasher := sha256.New()
if err := hashTreeInto(hasher, handle, "."); err != nil {
if err := hashTreeInto(hasher, root, root); err != nil {
return "", err
}
return "sha256:" + hex.EncodeToString(hasher.Sum(nil)), nil
}
// hashTreeInto hashes dir, a path relative to handle, into hasher.
func hashTreeInto(hasher io.Writer, handle *os.Root, dir string) error {
entries, err := readRootedDir(handle, dir)
func hashTreeInto(hasher io.Writer, root string, dir string) error {
entries, err := os.ReadDir(dir)
if err != nil {
return err
}
@@ -605,8 +588,8 @@ func hashTreeInto(hasher io.Writer, handle *os.Root, dir string) error {
if name == ".git" {
continue
}
rel := filepath.Join(dir, name)
info, err := handle.Lstat(rel)
path := filepath.Join(dir, name)
info, err := os.Lstat(path)
if err != nil {
return err
}
@@ -615,10 +598,14 @@ func hashTreeInto(hasher io.Writer, handle *os.Root, dir string) error {
// Skipped by copyTree, so excluded from the hash too.
continue
case info.IsDir():
if err := hashTreeInto(hasher, handle, rel); err != nil {
if err := hashTreeInto(hasher, root, path); err != nil {
return err
}
case info.Mode().IsRegular():
rel, err := filepath.Rel(root, path)
if err != nil {
return err
}
executable := 0
if info.Mode().Perm()&0o111 != 0 {
executable = 1
@@ -629,7 +616,7 @@ func hashTreeInto(hasher io.Writer, handle *os.Root, dir string) error {
if _, err := io.WriteString(hasher, header); err != nil {
return err
}
file, err := handle.Open(rel)
file, err := os.Open(path)
if err != nil {
return err
}
@@ -647,16 +634,3 @@ func hashTreeInto(hasher io.Writer, handle *os.Root, dir string) error {
}
return nil
}
// readRootedDir lists dir, a path relative to handle, like os.ReadDir.
func readRootedDir(handle *os.Root, dir string) ([]os.DirEntry, error) {
file, err := handle.Open(dir)
if err != nil {
return nil, err
}
entries, err := file.ReadDir(-1)
if closeErr := file.Close(); err == nil {
err = closeErr
}
return entries, err
}
-59
View File
@@ -1181,62 +1181,3 @@ func TestRecoveryLeavesAPrefixCollidingDirectoryAlone(t *testing.T) {
t.Errorf("recovery published content it does not own: %v", err)
}
}
// TestNonUTF8FileNamesAreInstalledHashedAndToggled covers a plugin whose tree
// holds a file and a directory named with bytes that are not valid UTF-8, which
// Linux allows. copyTree copies them, so hashTree must hash them too: Install
// records the hash, the toggle refreshes it, and Info sees an edit to that file
// as drift. A walk that rejects such names breaks all three.
func TestNonUTF8FileNamesAreInstalledHashedAndToggled(t *testing.T) {
src := writeSourcePlugin(t, filepath.Join(t.TempDir(), "src"), validManifest())
nested := filepath.Join("assets\xff", "data\xfe.bin")
if err := os.Mkdir(filepath.Join(src, "assets\xff"), 0o755); err != nil {
t.Skipf("this filesystem rejects non-UTF-8 names: %v", err)
}
if err := os.WriteFile(filepath.Join(src, nested), []byte("original\n"), 0o644); err != nil {
t.Skipf("this filesystem rejects non-UTF-8 names: %v", err)
}
destDir := t.TempDir()
if _, err := Install(context.Background(), InstallOptions{Source: src, Dir: destDir}); err != nil {
t.Fatalf("Install a plugin with a non-UTF-8 file name: %v", err)
}
pluginDir := filepath.Join(destDir, "zero.demo")
if data, err := os.ReadFile(filepath.Join(pluginDir, nested)); err != nil || string(data) != "original\n" {
t.Fatalf("installed copy of the non-UTF-8 file = %q, %v", data, err)
}
options := LoadOptions{Roots: []Root{{Source: SourceUser, Path: destDir}}}
before, err := ReadLock(destDir)
if err != nil {
t.Fatal(err)
}
if _, err := SetEnabledByID(options, "zero.demo", false); err != nil {
t.Fatalf("SetEnabledByID on a plugin with a non-UTF-8 file name: %v", err)
}
after, err := ReadLock(destDir)
if err != nil {
t.Fatal(err)
}
if after["zero.demo"].Hash == before["zero.demo"].Hash {
t.Fatal("the toggle did not refresh the lock hash")
}
info, err := Info(InfoOptions{LoadOptions: options}, "zero.demo")
if err != nil {
t.Fatal(err)
}
if info.HashDrift {
t.Fatal("the toggle left hash drift behind")
}
if err := os.WriteFile(filepath.Join(pluginDir, nested), []byte("edited\n"), 0o644); err != nil {
t.Fatal(err)
}
info, err = Info(InfoOptions{LoadOptions: options}, "zero.demo")
if err != nil {
t.Fatal(err)
}
if !info.HashDrift {
t.Fatal("an edit to the non-UTF-8 file was not reported as drift")
}
}
+6 -54
View File
@@ -75,12 +75,6 @@ type Descriptor struct {
// catalog-ordered lists and pickers. The recommended descriptors are the first
// entries in the catalog, in order.
Recommended bool
// LegacyBaseURLs lists earlier DefaultBaseURL values that still address
// this provider's own endpoint. Profiles saved against one keep catalog
// endpoint treatment (catalog env var binding, base-URL matching) after the
// default moves; see IsCatalogBaseURL.
LegacyBaseURLs []string
}
func RuntimeSupported(descriptor Descriptor) bool {
@@ -105,17 +99,11 @@ func RuntimeUnsupportedReason(descriptor Descriptor) string {
}
var descriptors = []Descriptor{
// Twigpine OpenGateway (formerly GitLawb OpenGateway) — the recommended
// default. An OpenAI-compatible gateway at api.twigpine.com that smart-routes
// by model id across upstream providers. Flat /v1/chat/completions with a
// Bearer ogw_live_… key; listed first and badged in every picker. The public
// GET /v1/models lists only active models and is what pickers show when
// reachable. DefaultModel must be an exact id from that list (not an alias)
// so onboarding keeps it selected after live discovery. The id and env var
// keep their gitlawb names so saved configs keep resolving; the old host
// (opengateway.gitlawb.com) still serves clients and stays a legacy catalog
// endpoint so profiles saved against it resolve unchanged.
recommended(withLegacyBaseURLs(openAICompat("gitlawb-opengateway", "Twigpine OpenGateway", "https://api.twigpine.com/v1", "xiaomi/mimo-v2.6-pro", []string{"GITLAWB_OPENGATEWAY_API_KEY"}, "gitlawb opengateway", "opengateway", "twigpine opengateway", "twigpine"), "https://opengateway.gitlawb.com/v1")),
// GitLawb OpenGateway — the recommended default. An OpenAI-compatible gateway
// that smart-routes by model id across upstream providers (xiaomi-mimo,
// minimax, qwen, google, nvidia, tencent, z-ai). Flat /v1/chat/completions
// with a Bearer ogw_live_… key; listed first and badged in every picker.
recommended(openAICompat("gitlawb-opengateway", "GitLawb OpenGateway", "https://opengateway.gitlawb.com/v1", "mimo-v2.5-pro", []string{"GITLAWB_OPENGATEWAY_API_KEY"}, "gitlawb opengateway", "opengateway")),
// aimlapi.com — OpenAI-compatible aggregating gateway, also badged as
// recommended (second, right after the OpenGateway default). The built-in TUI
// onboarding can register/top up an account and save the issued key; the partner
@@ -144,9 +132,7 @@ var descriptors = []Descriptor{
// way in; RequiresAuth is forced true so catalog consumers know the provider
// needs an interactive login before a request will succeed.
func() Descriptor {
// New ChatGPT profiles follow the vendor's GPT-5.5 replacement guidance:
// https://learn.chatgpt.com/docs/models#gpt-55-retirement
d := openAICompat("chatgpt", "ChatGPT", "https://chatgpt.com/backend-api/codex", "gpt-6.1-sol", nil)
d := openAICompat("chatgpt", "ChatGPT", "https://chatgpt.com/backend-api/codex", "gpt-5.5", nil)
d.RequiresAuth = true
return oauthProvider(d, false, false)
}(),
@@ -319,37 +305,6 @@ func recommended(descriptor Descriptor) Descriptor {
return descriptor
}
// withLegacyBaseURLs records earlier default base URLs that still serve the
// provider, so profiles saved before a host move keep resolving as the catalog
// endpoint.
func withLegacyBaseURLs(descriptor Descriptor, baseURLs ...string) Descriptor {
descriptor.LegacyBaseURLs = append(descriptor.LegacyBaseURLs, baseURLs...)
return descriptor
}
// IsCatalogBaseURL reports whether baseURL addresses the descriptor's own
// endpoint: its DefaultBaseURL or one of its LegacyBaseURLs, ignoring case,
// surrounding space, and a trailing slash. An empty baseURL never matches.
func IsCatalogBaseURL(descriptor Descriptor, baseURL string) bool {
normalized := normalizeBaseURL(baseURL)
if normalized == "" {
return false
}
if normalized == normalizeBaseURL(descriptor.DefaultBaseURL) {
return true
}
for _, legacy := range descriptor.LegacyBaseURLs {
if normalized == normalizeBaseURL(legacy) {
return true
}
}
return false
}
func normalizeBaseURL(baseURL string) string {
return strings.ToLower(strings.TrimRight(strings.TrimSpace(baseURL), "/"))
}
// oauthProvider marks a descriptor as OAuth-capable. mintsKey => the flow returns
// an API key (OpenRouter); deviceFlow => RFC 8628 device code is also supported.
func oauthProvider(descriptor Descriptor, mintsKey bool, deviceFlow bool) Descriptor {
@@ -425,9 +380,6 @@ func cloneDescriptor(descriptor Descriptor) Descriptor {
descriptor.AuthEnvVars = append([]string{}, descriptor.AuthEnvVars...)
descriptor.SupportedAPIFormats = append([]APIFormat{}, descriptor.SupportedAPIFormats...)
descriptor.Aliases = append([]string{}, descriptor.Aliases...)
if descriptor.LegacyBaseURLs != nil {
descriptor.LegacyBaseURLs = append([]string{}, descriptor.LegacyBaseURLs...)
}
if descriptor.CustomHeaders != nil {
descriptor.CustomHeaders = copyStringMap(descriptor.CustomHeaders)
}
+3 -60
View File
@@ -77,8 +77,7 @@ func TestAllHasStableUniqueIDs(t *testing.T) {
func TestRecommendedProvidersAreTopOfCatalog(t *testing.T) {
descriptors := All()
// The recommended providers are badged and pinned to the top of the catalog,
// in this order (Twigpine OpenGateway remains the default; aimlapi.com is also
// badged).
// in this order (OpenGateway remains the default; aimlapi.com is also badged).
wantTop := []string{"gitlawb-opengateway", "aimlapi"}
if len(descriptors) < len(wantTop) {
t.Fatalf("All() returned %d descriptors, want at least %d", len(descriptors), len(wantTop))
@@ -108,66 +107,12 @@ func TestRecommendedProviderEndpoint(t *testing.T) {
if !ok {
t.Fatal("gitlawb-opengateway not found in catalog")
}
if descriptor.Name != "Twigpine OpenGateway" {
t.Fatalf("OpenGateway name = %q, want %q", descriptor.Name, "Twigpine OpenGateway")
}
if descriptor.DefaultBaseURL != "https://api.twigpine.com/v1" {
t.Fatalf("OpenGateway base URL = %q, want %q", descriptor.DefaultBaseURL, "https://api.twigpine.com/v1")
}
// The default must be an exact id from the live /v1/models list (not a
// gateway alias such as the bare "mimo-v2.6-pro"): onboarding keeps the
// default selected after live discovery only on an exact id match.
if descriptor.DefaultModel != "xiaomi/mimo-v2.6-pro" {
t.Fatalf("OpenGateway default model = %q, want %q", descriptor.DefaultModel, "xiaomi/mimo-v2.6-pro")
if descriptor.DefaultBaseURL != "https://opengateway.gitlawb.com/v1" {
t.Fatalf("OpenGateway base URL = %q, want %q", descriptor.DefaultBaseURL, "https://opengateway.gitlawb.com/v1")
}
if descriptor.Transport != TransportOpenAICompatible {
t.Fatalf("OpenGateway transport = %q, want %q", descriptor.Transport, TransportOpenAICompatible)
}
// Saved configs keep the original credential env var.
if !reflect.DeepEqual(descriptor.AuthEnvVars, []string{"GITLAWB_OPENGATEWAY_API_KEY"}) {
t.Fatalf("OpenGateway auth env vars = %#v, want GITLAWB_OPENGATEWAY_API_KEY", descriptor.AuthEnvVars)
}
// Old and new names both resolve to the same stable catalog id.
for _, name := range []string{"gitlawb-opengateway", "GitLawb OpenGateway", "opengateway", "Twigpine OpenGateway", "twigpine"} {
got, ok := Get(name)
if !ok || got.ID != "gitlawb-opengateway" {
t.Fatalf("Get(%q) = (%q, %v), want gitlawb-opengateway", name, got.ID, ok)
}
}
}
func TestIsCatalogBaseURLAcceptsDefaultAndLegacyHosts(t *testing.T) {
gateway, ok := Get("gitlawb-opengateway")
if !ok {
t.Fatal("gitlawb-opengateway not found in catalog")
}
for _, baseURL := range []string{
"https://api.twigpine.com/v1",
"https://API.twigpine.com/v1/ ",
"https://opengateway.gitlawb.com/v1",
"https://opengateway.gitlawb.com/v1/",
} {
if !IsCatalogBaseURL(gateway, baseURL) {
t.Fatalf("IsCatalogBaseURL(gateway, %q) = false, want true", baseURL)
}
}
for _, baseURL := range []string{"", "https://gateway.example.com/v1", "https://opengateway.gitlawb.com/v2"} {
if IsCatalogBaseURL(gateway, baseURL) {
t.Fatalf("IsCatalogBaseURL(gateway, %q) = true, want false", baseURL)
}
}
openrouter, ok := Get("openrouter")
if !ok {
t.Fatal("openrouter not found in catalog")
}
if !IsCatalogBaseURL(openrouter, "https://openrouter.ai/api/v1") || IsCatalogBaseURL(openrouter, "https://opengateway.gitlawb.com/v1") {
t.Fatal("legacy hosts must only match their own descriptor")
}
// Get returns a copy: mutating it must not leak into the catalog.
gateway.LegacyBaseURLs[0] = "https://mutated.example.com/v1"
if again, _ := Get("gitlawb-opengateway"); again.LegacyBaseURLs[0] != "https://opengateway.gitlawb.com/v1" {
t.Fatalf("LegacyBaseURLs aliased the catalog: %#v", again.LegacyBaseURLs)
}
}
func TestAIMLAPIDescriptor(t *testing.T) {
@@ -440,8 +385,6 @@ func TestLookupNormalizesIDsAndAliases(t *testing.T) {
"custom_openai_compatible": "custom-openai-compatible",
"custom--anthropic compatible": "custom-anthropic-compatible",
"GitLawb OpenGateway": "gitlawb-opengateway",
"Twigpine OpenGateway": "gitlawb-opengateway",
"twigpine": "gitlawb-opengateway",
}
for input, want := range cases {
descriptor, ok := Get(input)
+4 -6
View File
@@ -675,12 +675,10 @@ func blockedAddrReason(addr netip.Addr) string {
}
// overrideHealthEndpoint returns a provider-specific connectivity probe URL when
// the default {baseURL}+/models path is not the right reachability check.
// Twigpine OpenGateway (formerly GitLawb OpenGateway) is a smart-routing gateway
// whose flat /v1/models 404'd on older deployments, so probe its public /health
// endpoint at the host root instead (served by api.twigpine.com and the legacy
// opengateway.gitlawb.com host), which reports real reachability without a
// per-model call.
// the default {baseURL}+/models path does not exist. GitLawb OpenGateway is a
// smart-routing gateway whose flat /v1/models endpoint 404s by design ("Use
// /v1/<provider>/<path>"), so probe its public /health endpoint at the host root
// instead, which reports real reachability without a per-model call.
func overrideHealthEndpoint(profile config.ProviderProfile, baseURL string) (string, bool) {
if profile.CatalogID != "gitlawb-opengateway" {
return "", false
@@ -179,10 +179,6 @@ func TestOverrideHealthEndpointOpenGateway(t *testing.T) {
if got != "https://opengateway.gitlawb.com/health" {
t.Fatalf("OpenGateway health endpoint = %q, want host-root /health", got)
}
got, ok = overrideHealthEndpoint(config.ProviderProfile{CatalogID: "gitlawb-opengateway"}, "https://api.twigpine.com/v1")
if !ok || got != "https://api.twigpine.com/health" {
t.Fatalf("Twigpine OpenGateway health endpoint = (%q, %v), want https://api.twigpine.com/health", got, ok)
}
if _, ok := overrideHealthEndpoint(config.ProviderProfile{CatalogID: "openai"}, "https://api.openai.com/v1"); ok {
t.Fatal("non-OpenGateway providers must not override the health endpoint")
+23 -30
View File
@@ -44,20 +44,6 @@ var minimaxCuratedModels = []Model{
OutputModalities: []string{"text"},
Source: minimaxModelSource,
},
{
ID: "MiniMax-M3.1-Flash",
Description: "fast reasoning model",
ContextWindow: 1_000_000,
ToolCall: true,
Reasoning: true,
ReasoningEfforts: []string{"low", "medium", "high", "xhigh", "max"},
DefaultReasoningEffort: "max",
InputModalities: []string{"text", "image", "video"},
OutputModalities: []string{"text"},
InputCost: 0.15,
OutputCost: 0.60,
Source: minimaxModelSource,
},
{
ID: "MiniMax-M2.7",
Description: "agentic coding model",
@@ -127,12 +113,10 @@ var curatedModels = map[string][]Model{
{ID: "deepseek/deepseek-chat", Description: "coding model"},
},
"chatgpt": {
// Suggestions only: availability comes from the authenticated Codex catalog.
// https://learn.chatgpt.com/docs/models
{ID: "gpt-6.1-sol", Description: "balanced Codex model"},
{ID: "gpt-6.1-terra", Description: "fast model for writing, visual understanding, and computer use"},
{ID: "gpt-6-astra", Description: "advanced reasoning model"},
{ID: "gpt-6-luna", Description: "efficient coding model"},
{ID: "gpt-5.5", Description: "recommended Codex model"},
{ID: "gpt-5.4", Description: "strong Codex model"},
{ID: "gpt-5.4-mini", Description: "fast Codex model"},
{ID: "gpt-5.3-codex-spark", Description: "research preview fast Codex model"},
},
"deepseek": {
{ID: "deepseek-chat", Description: "catalog default"},
@@ -225,17 +209,26 @@ var curatedModels = map[string][]Model{
},
"zai": zaiCuratedModels,
"zai-cn": zaiCuratedModels,
// Twigpine OpenGateway publishes its live, active-only model list at
// GET /v1/models; pickers and onboarding show that list whenever it is
// reachable (see providermodeldiscovery.DiscoverCatalog). This is only the
// offline fallback, so keep it to ids the gateway is expected to keep
// serving: the smart router, the catalog default, and its fast sibling. Do
// not add promo or time-limited models here. Users can still type any id
// the gateway accepts.
// OpenGateway smart-routes by model id across its upstream providers
// (see /health: xiaomi-mimo, minimax, qwen, google, nvidia, tencent, z-ai).
// These are the curated coding defaults; the gateway accepts any model its
// upstreams expose, so users can also type an id the picker doesn't list.
"gitlawb-opengateway": {
{ID: "xiaomi/mimo-v2.6-pro", Description: "catalog default", ContextWindow: 262_144},
{ID: "auto", Description: "smart routing (picks a capable model per request)"},
{ID: "xiaomi/mimo-v2.6-flash", Description: "fast model (Xiaomi MiMo V2.6 Flash)", ContextWindow: 262_144},
{ID: "mimo-v2.5-pro", Description: "catalog default (Xiaomi MiMo)"},
{ID: "xiaomi/mimo-v2.5-pro", Description: "Xiaomi MiMo V2.5 Pro"},
{ID: "mimo-v2.5-pro-ultraspeed", Description: "fast model (Xiaomi MiMo)"},
{ID: "xiaomi/mimo-v2.5", Description: "multimodal model (Xiaomi)"},
{ID: "tencent/hy3", Description: "free Tencent HY3 model"},
{ID: "MiniMax-M3", Description: "MiniMax model"},
{ID: "minimax/minimax-m3", Description: "MiniMax M3 model"},
{ID: "qwen-plus", Description: "Qwen model"},
{ID: "qwen/qwen3.7-max", Description: "Qwen flagship coding model"},
{ID: "gemini-2.5-pro", Description: "long-context model (Google)"},
{ID: "google/gemini-3.1-flash-lite", Description: "Google Gemini 3.1 Flash Lite"},
{ID: "glm-4.6", Description: "Z.ai model"},
{ID: "z-ai/glm-5.2", Description: "GLM coding & reasoning model"},
{ID: "nvidia/llama-3.1-nemotron-70b-instruct", Description: "NVIDIA NIM model"},
{ID: "nvidia/nemotron-3-ultra-550b-a55b:free", Description: "free Nemotron 3 Ultra reasoning MoE"},
},
"atomic-chat": {
{ID: "gpt-4.1", Description: "catalog default"},
+11 -29
View File
@@ -35,8 +35,8 @@ func TestModelsAreProviderScoped(t *testing.T) {
},
{
provider: "chatgpt",
want: []string{"gpt-6.1-sol", "gpt-6.1-terra", "gpt-6-astra", "gpt-6-luna"},
notWant: []string{"gpt-5.5", "gpt-5.4", "gpt-5.4-mini", "gpt-5.3-codex-spark", "openai/gpt-4.1"},
want: []string{"gpt-5.5", "gpt-5.4", "gpt-5.4-mini", "gpt-5.3-codex-spark"},
notWant: []string{"gpt-5", "gpt-4.1", "openai/gpt-4.1"},
},
{
provider: "aimlapi",
@@ -65,7 +65,7 @@ func TestModelsAreProviderScoped(t *testing.T) {
},
{
provider: "minimaxi-cn",
want: []string{"MiniMax-M3", "MiniMax-M3.1-Flash", "MiniMax-M2.7", "MiniMax-M2.1"},
want: []string{"MiniMax-M3", "MiniMax-M2.7", "MiniMax-M2.1"},
notWant: []string{"gpt-4.1", "claude-sonnet-4.5"},
},
{
@@ -74,15 +74,13 @@ func TestModelsAreProviderScoped(t *testing.T) {
notWant: []string{"gpt-4.1", "claude-sonnet-4.5"},
},
{
// Offline fallback only; the live /v1/models list is shown when reachable.
provider: "gitlawb-opengateway",
want: []string{"xiaomi/mimo-v2.6-pro", "auto", "xiaomi/mimo-v2.6-flash"},
notWant: []string{
"openai/gpt-4.1", "claude-sonnet-4.5",
"mimo-v2.5-pro", "xiaomi/mimo-v2.5-pro", "qwen-plus", "glm-4.6", "gemini-2.5-pro",
"MiniMax-M3", "nvidia/llama-3.1-nemotron-70b-instruct",
"qwen/qwen3.8-max", "qwen/qwen3.8-flash",
want: []string{
"mimo-v2.5-pro", "tencent/hy3",
"xiaomi/mimo-v2.5-pro", "xiaomi/mimo-v2.5", "minimax/minimax-m3", "qwen/qwen3.7-max",
"google/gemini-3.1-flash-lite", "z-ai/glm-5.2", "nvidia/nemotron-3-ultra-550b-a55b:free",
},
notWant: []string{"openai/gpt-4.1", "claude-sonnet-4.5"},
},
{
provider: "opencode-go-anthropic-compatible",
@@ -167,8 +165,8 @@ func TestMiniMaxModelsExposeCurrentCapabilities(t *testing.T) {
t.Fatal("provider minimax missing from catalog")
}
models := Models(descriptor)
if len(models) < 3 || models[0].ID != "MiniMax-M3" || models[1].ID != "MiniMax-M3.1-Flash" || models[2].ID != "MiniMax-M2.7" {
t.Fatalf("MiniMax current models = %#v, want MiniMax-M3, MiniMax-M3.1-Flash, then MiniMax-M2.7", modelIDs(models))
if len(models) < 2 || models[0].ID != "MiniMax-M3" || models[1].ID != "MiniMax-M2.7" {
t.Fatalf("MiniMax current models = %#v, want MiniMax-M3 followed by MiniMax-M2.7", modelIDs(models))
}
m3 := models[0]
@@ -180,23 +178,7 @@ func TestMiniMaxModelsExposeCurrentCapabilities(t *testing.T) {
t.Fatalf("MiniMax-M3 modalities = input:%#v output:%#v", m3.InputModalities, m3.OutputModalities)
}
m31Flash := models[1]
if m31Flash.ContextWindow != 1_000_000 || !m31Flash.ToolCall || !m31Flash.Reasoning {
t.Fatalf("MiniMax-M3.1-Flash capabilities = %#v, want 1M context with tools and reasoning", m31Flash)
}
if !reflect.DeepEqual(m31Flash.ReasoningEfforts, []string{"low", "medium", "high", "xhigh", "max"}) ||
m31Flash.DefaultReasoningEffort != "max" {
t.Fatalf("MiniMax-M3.1-Flash reasoning efforts = %#v default %q", m31Flash.ReasoningEfforts, m31Flash.DefaultReasoningEffort)
}
if !reflect.DeepEqual(m31Flash.InputModalities, []string{"text", "image", "video"}) ||
!reflect.DeepEqual(m31Flash.OutputModalities, []string{"text"}) {
t.Fatalf("MiniMax-M3.1-Flash modalities = input:%#v output:%#v", m31Flash.InputModalities, m31Flash.OutputModalities)
}
if m31Flash.InputCost != 0.15 || m31Flash.OutputCost != 0.60 {
t.Fatalf("MiniMax-M3.1-Flash costs = %f/%f, want 0.15/0.60", m31Flash.InputCost, m31Flash.OutputCost)
}
m27 := models[2]
m27 := models[1]
if m27.ContextWindow != 204_800 || !m27.ToolCall || !m27.Reasoning {
t.Fatalf("MiniMax-M2.7 capabilities = %#v, want 204.8K context with tools and reasoning", m27)
}
+1 -14
View File
@@ -54,22 +54,9 @@ func TestDefaultedOpenGatewayURL(t *testing.T) {
if got := defaultedOpenGatewayURL(providercatalog.Descriptor{DefaultBaseURL: "https://gw.example.com/v1"}, ""); got != "https://gw.example.com/v1/models" {
t.Fatalf("derived = %q", got)
}
if got := defaultedOpenGatewayURL(providercatalog.Descriptor{DefaultBaseURL: "::not a url"}, ""); got != "https://api.twigpine.com/v1/models" {
if got := defaultedOpenGatewayURL(providercatalog.Descriptor{DefaultBaseURL: "::not a url"}, ""); got != "https://opengateway.gitlawb.com/v1/models" {
t.Fatalf("fallback = %q", got)
}
// The catalog descriptor points at the Twigpine API host.
descriptor, ok := providercatalog.Get("gitlawb-opengateway")
if !ok {
t.Fatal("gitlawb-opengateway missing from catalog")
}
if got := defaultedOpenGatewayURL(descriptor, ""); got != "https://api.twigpine.com/v1/models" {
t.Fatalf("catalog descriptor = %q, want https://api.twigpine.com/v1/models", got)
}
// A descriptor still carrying the legacy host keeps deriving from it, so
// old-host configs resolve unchanged (the old host still serves clients).
if got := defaultedOpenGatewayURL(providercatalog.Descriptor{DefaultBaseURL: "https://opengateway.gitlawb.com/v1"}, ""); got != "https://opengateway.gitlawb.com/v1/models" {
t.Fatalf("legacy host = %q, want https://opengateway.gitlawb.com/v1/models", got)
}
}
func TestDefaultedOpenRouterURL(t *testing.T) {
+1 -12
View File
@@ -19,7 +19,7 @@ import (
const (
DefaultModelsDevURL = "https://models.dev/api.json"
defaultOpenGatewayURL = "https://api.twigpine.com/v1/models"
defaultOpenGatewayURL = "https://opengateway.gitlawb.com/v1/models"
defaultOpenRouterURL = "https://openrouter.ai/api/v1/models"
modelsDevSource = "models.dev"
openGatewaySource = "opengateway"
@@ -120,8 +120,6 @@ func ParseModelsDevProvider(body []byte, providerID string) ([]Model, error) {
// ParseOpenGatewayCatalog parses OpenGateway's live GET /v1/models payload (or the
// older {models:[...]} shape). The gateway already curates what it exposes, so
// every non-empty id is accepted rather than applying the coding-model heuristic.
// Retired entries (status "retired", listed by ?status=all) are skipped so a
// picker never offers a model the gateway no longer serves.
func ParseOpenGatewayCatalog(body []byte) ([]Model, error) {
items, err := parseOpenAIStyleModelList(body)
if err != nil {
@@ -129,9 +127,6 @@ func ParseOpenGatewayCatalog(body []byte) ([]Model, error) {
}
models := make([]Model, 0, len(items))
for _, item := range items {
if item.retired() {
continue
}
model := item.toModel("", openGatewaySource)
if model.ID == "" {
continue
@@ -229,7 +224,6 @@ func PublicLiveCatalog(providerID string) bool {
type remoteModel struct {
ID string `json:"id"`
Name string `json:"name"`
Status string `json:"status"` // OpenGateway: "active" | "retired"; absent means active
Description string `json:"description"`
ContextWindow int `json:"context_window"`
ContextWindowCamel int `json:"contextWindow"`
@@ -354,11 +348,6 @@ func (model remoteModel) toModel(key string, source string) Model {
}
}
// retired reports whether the catalog marks this entry as retired.
func (model remoteModel) retired() bool {
return strings.EqualFold(strings.TrimSpace(model.Status), "retired")
}
func normalizedServiceTiers(tiers []string, legacy []string) []string {
values := append(append([]string{}, tiers...), legacy...)
seen := map[string]bool{}
@@ -67,58 +67,6 @@ func TestParseModelsDevProviderScopesAndMapsMetadata(t *testing.T) {
}
}
func TestParseOpenGatewayCatalogSkipsRetiredModels(t *testing.T) {
// Mirrors GET https://api.twigpine.com/v1/models?status=all once the gateway
// retires models: an active entry plus a retired one carrying a successor.
body := []byte(`{
"object": "list",
"data": [
{
"id": "xiaomi/mimo-v2.6-pro",
"object": "model",
"name": "MiMo V2.6-Pro",
"description": "flagship omnimodal agent model",
"aliases": ["xiaomi/mimo-v2.5-pro", "mimo-v2.5-pro"],
"context_window": 262144,
"pricing": {"prompt": "0.000001", "completion": "0.000003"},
"supports_tools": true,
"status": "active"
},
{
"id": "qwen/qwen-plus",
"object": "model",
"name": "Qwen Plus",
"description": "retired general model",
"context_window": 131072,
"status": "retired",
"successor": "qwen/qwen3.7-max"
}
]
}`)
models, err := ParseOpenGatewayCatalog(body)
if err != nil {
t.Fatalf("ParseOpenGatewayCatalog returned error: %v", err)
}
if got := strings.Join(modelIDs(models), ","); got != "xiaomi/mimo-v2.6-pro" {
t.Fatalf("models = %q, want only the active entry", got)
}
descriptor, ok := providercatalog.Get("gitlawb-opengateway")
if !ok {
t.Fatal("gitlawb-opengateway missing from catalog")
}
// Onboarding keeps the default selected only on an exact id match against
// the live list, so the default must be a real active id, not an alias.
if !containsModelID(models, descriptor.DefaultModel) {
t.Fatalf("default model %q missing from live list %v", descriptor.DefaultModel, modelIDs(models))
}
// A list made only of retired entries is treated as empty, not offered.
if _, err := ParseOpenGatewayCatalog([]byte(`{"data":[{"id":"old/model","status":"retired"}]}`)); err == nil {
t.Fatal("expected an error when every entry is retired")
}
}
func TestParseOpenGatewayCatalogSupportsRichModelJSON(t *testing.T) {
body := []byte(`{
"models": [
@@ -1,31 +0,0 @@
package providermodeldiscovery
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/Twigpine/zero/internal/config"
)
func TestChatGPTDiscoveryUsesCurrentCompatibilityVersion(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.URL.Query().Get("client_version"); got != "0.159.0" {
t.Errorf("client_version = %q, want documented GPT-6.1 Terra compatibility floor 0.159.0", got)
}
_, _ = w.Write([]byte(`{"models":[{"slug":"gpt-future-coder"}]}`))
}))
defer server.Close()
profile := config.ProviderProfile{
CatalogID: "chatgpt", ProviderKind: config.ProviderKindOpenAICompatible,
BaseURL: server.URL + "/backend-api/codex", APIKey: "test-bearer",
}
models, err := Discover(context.Background(), profile, Options{HTTPClient: server.Client()})
if err != nil {
t.Fatal(err)
}
if len(models) != 1 || models[0].ID != "gpt-future-coder" {
t.Fatalf("models = %#v, want only gpt-future-coder", models)
}
}
+2 -9
View File
@@ -20,9 +20,8 @@ import (
)
const (
anthropicVersion = "2023-06-01"
// GPT-6.1 Terra's documented Codex client floor: https://learn.chatgpt.com/docs/models.
chatGPTModelsProtocolVersion = "0.159.0"
anthropicVersion = "2023-06-01"
chatGPTModelsProtocolVersion = "0.146.0"
)
type Model struct {
@@ -355,7 +354,6 @@ type modelsResponseItem struct {
Name string `json:"name"`
Description string `json:"description"`
Visibility string `json:"visibility"`
Status string `json:"status"`
ContextWindow int `json:"context_window"`
ContextWindowAlt int `json:"contextWindow"`
ContextLength int `json:"context_length"`
@@ -413,11 +411,6 @@ func parseModelsResponse(body []byte) ([]Model, error) {
if visibility := strings.ToLower(strings.TrimSpace(item.Visibility)); visibility != "" && visibility != "list" {
continue
}
// OpenGateway marks lifecycle with status "active" | "retired" (retired
// entries only appear with ?status=all). Never offer a retired model.
if strings.EqualFold(strings.TrimSpace(item.Status), "retired") {
continue
}
seen[id] = true
description := firstNonEmptyDiscovery(
item.DisplayName,
@@ -346,70 +346,6 @@ func TestDiscoverCatalogOpenGatewayUsesLiveListWithoutKey(t *testing.T) {
}
}
func TestDiscoverCatalogOpenGatewayDropsRetiredAndKeepsDefault(t *testing.T) {
// Both the catalog fetch and the live /v1/models probe return one active and
// one retired entry. Users must only ever see the active one, and the
// catalog's default model must be among the ids discovery returns (onboarding
// keeps the default selected only on an exact id match).
const body = `{"object":"list","data":[
{"id":"xiaomi/mimo-v2.6-pro","name":"MiMo V2.6-Pro","context_window":262144,"supports_tools":true,"status":"active"},
{"id":"qwen/qwen-plus","name":"Qwen Plus","context_window":131072,"status":"retired","successor":"qwen/qwen3.7-max"}
]}`
var paths []string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
paths = append(paths, r.URL.Path)
switch r.URL.Path {
case "/catalog/v1/models", "/v1/models":
_, _ = w.Write([]byte(body))
default:
t.Fatalf("unexpected path %q", r.URL.Path)
}
}))
defer server.Close()
descriptor, ok := providercatalog.Get("gitlawb-opengateway")
if !ok {
t.Fatal("gitlawb-opengateway missing from catalog")
}
provider := descriptor
provider.DefaultBaseURL = server.URL + "/v1"
models, err := DiscoverCatalog(context.Background(), provider, config.ProviderProfile{
CatalogID: "gitlawb-opengateway",
ProviderKind: config.ProviderKindOpenAICompatible,
BaseURL: server.URL + "/v1",
Model: descriptor.DefaultModel,
}, Options{
HTTPClient: server.Client(),
OpenGatewayURL: server.URL + "/catalog/v1/models",
})
if err != nil {
t.Fatalf("DiscoverCatalog: %v", err)
}
if got := strings.Join(modelIDs(models), ","); got != "xiaomi/mimo-v2.6-pro" {
t.Fatalf("models = %q, want only the active entry", got)
}
if models[0].ID != descriptor.DefaultModel {
t.Fatalf("default model %q missing from discovered %v", descriptor.DefaultModel, modelIDs(models))
}
if strings.Join(paths, ",") != "/catalog/v1/models,/v1/models" {
t.Fatalf("requests = %v, want catalog fetch then live probe", paths)
}
}
func TestParseModelsResponseSkipsRetiredStatus(t *testing.T) {
models, err := parseModelsResponse([]byte(`{"data":[
{"id":"active-model","status":"active"},
{"id":"retired-model","status":"Retired"},
{"id":"no-status-model"}
]}`))
if err != nil {
t.Fatalf("parseModelsResponse: %v", err)
}
if got := strings.Join(modelIDs(models), ","); got != "active-model,no-status-model" {
t.Fatalf("models = %q, want retired entry dropped", got)
}
}
func TestDiscoverCatalogOpenRouterKeepsLiveOnlyModels(t *testing.T) {
// Catalog omits anthropic/claude-sonnet-4.5 and the generic tools-only id;
// live retains both so preferLive keeps coding-capable live-only entries
+5 -22
View File
@@ -49,19 +49,6 @@ func thinkingBudgetForEffort(effort string) int {
}
}
func miniMaxM31FlashEffort(model, effort string) string {
if !strings.EqualFold(strings.TrimSpace(model), "MiniMax-M3.1-Flash") {
return ""
}
effort = strings.ToLower(strings.TrimSpace(effort))
switch effort {
case "low", "medium", "high", "xhigh", "max":
return effort
default:
return ""
}
}
// resolveThinking returns the thinking budget and the max_tokens to send. When a
// budget is requested, max_tokens is raised if needed so the budget plus a
// minimum response both fit (Anthropic rejects budget >= max_tokens). enabled is
@@ -390,15 +377,11 @@ func (provider *Provider) anthropicRequest(request zeroruntime.CompletionRequest
Messages: messages,
Stream: true,
}
// MiniMax-M3.1-Flash uses adaptive thinking with output_config.effort instead
// of Anthropic's token budget. For other models, the budget is counted against
// max_tokens, so raise max_tokens to leave room for the response. Temperature
// is intentionally left unset (Anthropic requires the default when thinking
// is on).
if effort := miniMaxM31FlashEffort(provider.model, request.ReasoningEffort); effort != "" {
mapped.Thinking = &thinkingConfig{Type: "adaptive"}
mapped.OutputConfig = &outputConfig{Effort: effort}
} else if budget, effectiveMax, enabled := resolveThinking(request.ReasoningEffort, provider.maxTokens); enabled {
// Extended thinking: enable when a reasoning effort was requested. The budget
// is counted against max_tokens, so raise max_tokens to leave room for the
// response. Temperature is intentionally left unset (Anthropic requires the
// default when thinking is on).
if budget, effectiveMax, enabled := resolveThinking(request.ReasoningEffort, provider.maxTokens); enabled {
mapped.MaxTokens = effectiveMax
mapped.Thinking = &thinkingConfig{Type: "enabled", BudgetTokens: budget}
}
@@ -266,53 +266,6 @@ func TestStreamCompletionEnablesThinkingWhenEffortRequested(t *testing.T) {
}
}
func TestStreamCompletionUsesMiniMaxAdaptiveEffort(t *testing.T) {
t.Parallel()
for _, effort := range []string{"low", "xhigh"} {
effort := effort
t.Run(effort, func(t *testing.T) {
t.Parallel()
var gotBody map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
t.Fatalf("decode request body: %v", err)
}
writeSSEEvent(w, "message_stop", `{"type":"message_stop"}`)
}))
defer server.Close()
provider, err := New(Options{BaseURL: server.URL + "/", Model: "MiniMax-M3.1-Flash", MaxTokens: 64_000})
if err != nil {
t.Fatalf("New returned error: %v", err)
}
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hi"}},
ReasoningEffort: effort,
})
if err != nil {
t.Fatalf("StreamCompletion returned error: %v", err)
}
drain(stream)
thinking, ok := gotBody["thinking"].(map[string]any)
if !ok || thinking["type"] != "adaptive" {
t.Fatalf("thinking = %#v, want adaptive", gotBody["thinking"])
}
if _, ok := thinking["budget_tokens"]; ok {
t.Fatalf("MiniMax thinking must not include budget_tokens: %#v", thinking)
}
outputConfig, ok := gotBody["output_config"].(map[string]any)
if !ok || outputConfig["effort"] != effort {
t.Fatalf("output_config = %#v, want effort %q", gotBody["output_config"], effort)
}
if mt, _ := gotBody["max_tokens"].(float64); int(mt) != 64_000 {
t.Fatalf("max_tokens = %#v, want unchanged 64000", gotBody["max_tokens"])
}
})
}
}
func TestStreamCompletionOmitsThinkingWithoutEffort(t *testing.T) {
t.Parallel()
var gotBody map[string]any
+7 -11
View File
@@ -10,21 +10,17 @@ type messagesRequest struct {
System any `json:"system,omitempty"`
// Thinking enables extended thinking when a reasoning effort was requested.
// Omitted (nil) for normal requests so behavior is unchanged.
Thinking *thinkingConfig `json:"thinking,omitempty"`
OutputConfig *outputConfig `json:"output_config,omitempty"`
Tools []anthropicTool `json:"tools,omitempty"`
Stream bool `json:"stream"`
Thinking *thinkingConfig `json:"thinking,omitempty"`
Tools []anthropicTool `json:"tools,omitempty"`
Stream bool `json:"stream"`
}
// thinkingConfig requests extended thinking. Anthropic uses "enabled" with a
// token budget; MiniMax-M3.1-Flash uses "adaptive" with output_config.effort.
// thinkingConfig requests extended thinking with a token budget. Type is always
// "enabled"; BudgetTokens is how many tokens the model may spend thinking (it is
// counted against max_tokens).
type thinkingConfig struct {
Type string `json:"type"`
BudgetTokens int `json:"budget_tokens,omitempty"`
}
type outputConfig struct {
Effort string `json:"effort"`
BudgetTokens int `json:"budget_tokens"`
}
type anthropicMessage struct {
+1 -1
View File
@@ -160,7 +160,7 @@ func TestNewPassesOpenGatewayHY3ModelThrough(t *testing.T) {
for range stream {
}
if transport.request.URL.String() != "https://api.twigpine.com/v1/chat/completions" {
if transport.request.URL.String() != "https://opengateway.gitlawb.com/v1/chat/completions" {
t.Fatalf("request URL = %q, want OpenGateway chat completions", transport.request.URL.String())
}
var body map[string]any
@@ -288,11 +288,10 @@ func TestScanSSEDataWithContextContentResetsOnData(t *testing.T) {
pr, pw := io.Pipe()
go func() {
// One data line every 30ms for ~750ms (well past the 600ms content window,
// with ~470ms of slack under the 500ms idle timeout, so a scheduling stall
// on a busy shared runner does not trip it), so the content watchdog keeps
// resetting, then close cleanly.
for i := 0; i < 25; i++ {
// One data line every 30ms for ~300ms (well past the 120ms content window,
// with comfortable slack under the 100ms idle timeout), so the content
// watchdog keeps resetting, then close cleanly.
for i := 0; i < 10; i++ {
if _, err := io.WriteString(pw, "data: chunk\n\n"); err != nil {
return
}
@@ -304,7 +303,7 @@ func TestScanSSEDataWithContextContentResetsOnData(t *testing.T) {
n := 0
done := make(chan error, 1)
go func() {
done <- ScanSSEDataWithContext(context.Background(), func() {}, pr, 500*time.Millisecond, func(string) bool { n++; return true })
done <- ScanSSEDataWithContext(context.Background(), func() {}, pr, 100*time.Millisecond, func(string) bool { n++; return true })
}()
select {
@@ -315,8 +314,8 @@ func TestScanSSEDataWithContextContentResetsOnData(t *testing.T) {
case <-time.After(3 * time.Second):
t.Fatal("ScanSSEDataWithContext hung on a producing stream")
}
if n != 25 {
t.Fatalf("handled %d data lines, want 25", n)
if n != 10 {
t.Fatalf("handled %d data lines, want 10", n)
}
}
-127
View File
@@ -63,14 +63,6 @@ import (
// rest of a key from prose written after a whole one: that prose would be eaten,
// and a following JWT would lose its header.
//
// CALLER-SUPPLIED SECRETS (Options.ExtraSecretValues) get the same treatment
// from redactExtraSecretValues. Such a value is often opaque, matching no shape
// here, so without its own gap-tolerant match a separator inside it left both
// fragments in the output. Unlike a shape, the value is known exactly and has no
// word boundary to judge, so it is found in a copy with the separators removed
// and mapped back. RedactString runs it first, before any shape matcher, so a
// caller that clips or reformats the result afterwards never sees a fragment.
//
// WHAT THIS DELIBERATELY DOES NOT TREAT AS A SEPARATOR: tab, newline and
// carriage return. Those three are real text structure rather than invisible
// filler, so a credential broken across a line boundary is still two visible
@@ -443,125 +435,6 @@ func openAIShapeIsSecret(match string) bool {
return !strings.Contains(strings.TrimPrefix(match, "sk-"), "-")
}
// redactExtraSecretValues replaces every occurrence of every caller-supplied
// secret, whole or with runs of invisible separators between its characters.
//
// All occurrences, overlapping ones included, are found in the same text and
// overlapping spans are merged before anything is replaced. Replacing one at a
// time let each replacement consume bytes another occurrence needed: a shorter
// value inside a split longer one, a periodic value ("abcabc" in
// "abc<NUL>abcabc"), a value whose first and last characters match, or two
// values that overlap all left a fragment visible. Occurrences that only touch
// stay separate replacements, as they always were.
//
// Separators inside a match are covered by its replacement; a split match never
// starts or ends on one, so those beside it stay, unless the value as written
// starts or ends with them. Tab, newline and carriage return are not separators
// (see above).
func redactExtraSecretValues(value string, secrets []string, replacement string) string {
haystack, offsets := value, []int(nil)
if containsSplitSeparator(value) {
haystack, offsets = compactSplitSeparators(value)
}
var spans [][2]int
for _, secret := range secrets {
if strings.TrimSpace(secret) == "" {
continue
}
needle := secret
if offsets != nil {
// The value as written always matches, as it did before split
// matching existed. The compacted match cannot stand in for it: a
// value made only of separators compacts to nothing, and separators
// at either end of a value fall outside its compacted match.
if strings.Contains(value, secret) {
for _, at := range overlappingIndexes(value, secret) {
spans = append(spans, [2]int{at, at + len(secret)})
}
}
needle = stripSplitSeparators(secret)
}
if needle == "" || !strings.Contains(haystack, needle) {
continue
}
for _, at := range overlappingIndexes(haystack, needle) {
start, end := at, at+len(needle)
if offsets != nil {
start, end = offsets[start], offsets[end-1]+1
}
spans = append(spans, [2]int{start, end})
}
}
if len(spans) == 0 {
return value
}
sort.Slice(spans, func(i, j int) bool { return spans[i][0] < spans[j][0] })
var out strings.Builder
out.Grow(len(value))
written := 0
for i := 0; i < len(spans); {
start, end := spans[i][0], spans[i][1]
for i++; i < len(spans) && spans[i][0] < end; i++ {
end = max(end, spans[i][1])
}
out.WriteString(value[written:start])
out.WriteString(replacement)
written = end
}
out.WriteString(value[written:])
return out.String()
}
// compactSplitSeparators returns value with every splitSecretSeparator removed,
// and for each byte of the result the offset of that byte in value.
func compactSplitSeparators(value string) (string, []int) {
var builder strings.Builder
builder.Grow(len(value))
offsets := make([]int, 0, len(value))
for offset := 0; offset < len(value); {
r, width := utf8.DecodeRuneInString(value[offset:])
if !splitSecretSeparator(r) {
builder.WriteString(value[offset : offset+width])
for i := 0; i < width; i++ {
offsets = append(offsets, offset+i)
}
}
offset += width
}
return builder.String(), offsets
}
// overlappingIndexes returns the start of every occurrence of needle in
// haystack, overlapping ones included, in one linear Knuth-Morris-Pratt pass:
// restarting a search one byte after each match is quadratic for a periodic
// value over text full of it.
func overlappingIndexes(haystack, needle string) []int {
fallback := make([]int, len(needle))
for i, k := 1, 0; i < len(needle); i++ {
for k > 0 && needle[i] != needle[k] {
k = fallback[k-1]
}
if needle[i] == needle[k] {
k++
}
fallback[i] = k
}
var starts []int
for i, k := 0, 0; i < len(haystack); i++ {
for k > 0 && haystack[i] != needle[k] {
k = fallback[k-1]
}
if haystack[i] == needle[k] {
k++
}
if k == len(needle) {
starts = append(starts, i-len(needle)+1)
k = fallback[k-1]
}
}
return starts
}
// redactControlSplitSecrets replaces credentials whose body is interrupted by
// invisible characters. It is a no-op for text containing none of them, which is
// nearly all text, so the contiguous path keeps its behavior exactly.
-108
View File
@@ -105,114 +105,6 @@ func TestRedactStringCoversTheSeparatorsInsideASplitSecret(t *testing.T) {
}
}
// A CALLER-SUPPLIED SECRET IS A CREDENTIAL TOO.
//
// ExtraSecretValues names a value the caller knows is secret, whatever its
// shape: an opaque provider key matches none of the shape matchers, so the
// split pass above never saw it, and a separator inside it left both fragments
// in the output. It has to be matched across the same separators, cover them in
// its replacement, and leave the separators beside it where they were.
func TestRedactStringRedactsExtraSecretValuesSplitByInvisibleCharacters(t *testing.T) {
const secret = "zq7Mv2Lp9Xr4Tn8Wk3"
options := Options{ExtraSecretValues: []string{secret}}
if out := RedactString("key "+secret, Options{}); !strings.Contains(out, secret) {
t.Fatalf("SETUP INVALID: a shape matcher already redacts %q, so this would not reach the explicit-value pass: %q", secret, out)
}
for _, testCase := range []struct {
name string
value string
want string
}{
{"NUL", "key " + secret[:6] + "\x00" + secret[6:], "key [REDACTED]"},
{"zero width space", "key " + secret[:1] + "\u200b" + secret[1:], "key [REDACTED]"},
{"soft hyphen near the end", "key " + secret[:len(secret)-1] + "\u00ad" + secret[len(secret)-1:], "key [REDACTED]"},
{"every gap", "key " + strings.Join(strings.Split(secret, ""), "\x1b"), "key [REDACTED]"},
{"adjacent separators", "key " + secret[:9] + "\x00\x1b\u2060" + secret[9:], "key [REDACTED]"},
{"twice", secret[:3] + "\x00" + secret[3:] + " and " + secret[:12] + "\ufeff" + secret[12:], "[REDACTED] and [REDACTED]"},
{"separators beside it stay", "\x00" + secret[:5] + "\u200b" + secret[5:] + "\x00", "\x00[REDACTED]\x00"},
{"a line break is not a gap", secret[:5] + "\n" + secret[5:], secret[:5] + "\n" + secret[5:]},
} {
t.Run(testCase.name, func(t *testing.T) {
if got := RedactString(testCase.value, options); got != testCase.want {
t.Errorf("RedactString(%q) = %q, want %q", testCase.value, got, testCase.want)
}
})
}
// A SHORTER SECRET MUST NOT CLAIM PART OF A SPLIT LONGER ONE. Longest first
// has to hold across the contiguous and split matches together: replacing
// a contiguous shorter value first left the rest of the split longer one
// ("[REDACTED]\x00Lp9Xr4Tn8Wk3") visible.
for _, testCase := range []struct {
name string
secrets []string
value string
want string
}{
{"prefix", []string{secret[:6], secret}, "key " + secret[:6] + "\x00" + secret[6:], "key [REDACTED]"},
{"suffix", []string{secret[9:], secret}, "key " + secret[:9] + "\u200b" + secret[9:], "key [REDACTED]"},
{"inside", []string{secret[4:10], secret}, "key " + secret[:2] + "\x00" + secret[2:], "key [REDACTED]"},
{"shorter alone too", []string{secret, secret[:6]}, secret[:6] + " and " + secret[:6] + "\x00" + secret[6:], "[REDACTED] and [REDACTED]"},
} {
t.Run("overlap "+testCase.name, func(t *testing.T) {
if got := RedactString(testCase.value, Options{ExtraSecretValues: testCase.secrets}); got != testCase.want {
t.Errorf("RedactString(%q, %q) = %q, want %q", testCase.value, testCase.secrets, got, testCase.want)
}
})
}
// OVERLAPPING OCCURRENCES ARE ONE SPAN. Replacing one occurrence (either
// form) and then looking for the next consumed bytes the other needed:
// "abc\x00abcabc" holds a split "abc\x00abc" overlapping a whole "abcabc",
// and replacing the whole one first left "abc\x00" in front of it. Every
// occurrence of every value, in either form, has to be found in the same
// text and overlapping ones replaced together.
for _, testCase := range []struct {
name string
secrets []string
value string
want string
}{
{"split then whole, periodic secret", []string{"abcabc"}, "abc\x00abcabc", "[REDACTED]"},
{"whole then split, periodic secret", []string{"abcabc"}, "abcabc\x00abc", "[REDACTED]"},
{"shared first and last character", []string{"xY7qx"}, "key xY7q\u200bxY7qx", "key [REDACTED]"},
{"two secrets overlapping across a split", []string{"zq7Mv2Lp", "Lp9Xr4Tn"}, "key zq7Mv2L\x00p9Xr4Tn", "key [REDACTED]"},
{"whole occurrences overlapping", []string{"xY7qx"}, "xY7qxY7qx", "[REDACTED]"},
{"adjacent occurrences stay two", []string{"abc"}, "abc\x00abc", "[REDACTED]\x00[REDACTED]"},
} {
t.Run("overlapping occurrences "+testCase.name, func(t *testing.T) {
if got := RedactString(testCase.value, Options{ExtraSecretValues: testCase.secrets}); got != testCase.want {
t.Errorf("RedactString(%q, %q) = %q, want %q", testCase.value, testCase.secrets, got, testCase.want)
}
})
}
// A SECRET AS WRITTEN IS ALWAYS REDACTED. The split match compares the text
// and the value with separators removed; it adds to the whole match and
// must never replace it. Once the text held any separator, a value made only
// of separators compacted to nothing and was skipped, and separators at
// either end of a value fell outside its compacted match, so both stayed
// visible where a plain replacement had always caught them.
for _, testCase := range []struct {
name string
secrets []string
value string
want string
}{
{"separator-only secret", []string{"\x00"}, "a\x00b", "a[REDACTED]b"},
{"separator-only secret beside another separator", []string{"\u200b"}, "a\u200bb\x00", "a[REDACTED]b\x00"},
{"separators at both ends", []string{"\u200babc\x00"}, "x\u200babc\x00y", "x[REDACTED]y"},
} {
t.Run("as written "+testCase.name, func(t *testing.T) {
if got := RedactString(testCase.value, Options{ExtraSecretValues: testCase.secrets}); got != testCase.want {
t.Errorf("RedactString(%q, %q) = %q, want %q", testCase.value, testCase.secrets, got, testCase.want)
}
})
}
// A value the caller names that is NOT in the text leaves the text alone,
// separators and all.
if value := "unrelated\x00text \u200bhere"; RedactString(value, options) != value {
t.Errorf("RedactString rewrote text without the secret in it: %q", RedactString(value, options))
}
}
// NOTHING ELSE MOVES. The compaction exists to find a match; it must never
// reach text that has no secret in it, and text with no invisible character at
// all must take exactly the path it took before.
+9 -1
View File
@@ -463,7 +463,15 @@ func RedactString(value string, options Options) string {
replacement := replacement(options)
redacted := value
if len(options.ExtraSecretValues) > 0 {
redacted = redactExtraSecretValues(redacted, options.ExtraSecretValues, replacement)
secrets := append([]string{}, options.ExtraSecretValues...)
sort.SliceStable(secrets, func(i, j int) bool {
return len(secrets[i]) > len(secrets[j])
})
for _, secret := range secrets {
if strings.TrimSpace(secret) != "" {
redacted = strings.ReplaceAll(redacted, secret, replacement)
}
}
}
if strings.Contains(redacted, "-----BEGIN ") && strings.Contains(redacted, "PRIVATE KEY") {
+77 -1
View File
@@ -20,6 +20,10 @@ type AnalysisResult struct {
TooComplex bool
// Programs lists the distinct top-level command names found, for diagnostics.
Programs []string
// GitInit is set when the script would create a git repository. It is a
// separate signal from Destructive and Network because the decision it feeds
// depends on WHERE the workspace sits, which the analyzer cannot see.
GitInit bool
}
// destructivePrograms are commands that can irrecoverably destroy data.
@@ -183,6 +187,9 @@ func analyzeInto(script string, result *AnalysisResult, seen map[string]bool, de
if commandUsesNetwork(prog, rest) {
result.Network = true
}
if commandCreatesGitRepository(call, prog, rest) {
result.GitInit = true
}
if destructivePrograms[prog] ||
(prog == "rm" && hasRecursiveForce(rest)) ||
(powerShellRemoveItemPrograms[prog] && hasPowerShellRecursiveForce(rest)) ||
@@ -277,8 +284,77 @@ func packageManagerOffline(words []string) bool {
return false
}
// gitGlobalOptionsTakingValue are git's own options that consume the NEXT word.
//
// firstSubcommand skips words beginning with "-" but not the value that follows
// one, so "git -C sub clone <url>" made it answer "sub" and the command was
// classified as touching no network at all. The "--opt=value" spelling is one
// token and needs no entry here.
//
// Keys are lowercase because the analyzer lowercases every word before it gets
// here, which also means -C and -c arrive identically. That is fine: both
// consume a value, and nothing below needs to tell them apart.
var gitGlobalOptionsTakingValue = map[string]bool{
"-c": true, "-C": true,
"--git-dir": true, "--work-tree": true, "--namespace": true,
"--exec-path": true, "--config-env": true, "--super-prefix": true,
"--attr-source": true,
}
// gitSubcommand returns git's subcommand, skipping global options AND the values
// they consume. Returns "" when the words carry no subcommand.
func gitSubcommand(words []string) string {
for index := 0; index < len(words); index++ {
word := words[index]
if word == "" {
continue
}
if !strings.HasPrefix(word, "-") {
return word
}
// "--opt=value" is one token and carries its own value.
if strings.HasPrefix(word, "--") && strings.Contains(word, "=") {
continue
}
if gitGlobalOptionsTakingValue[word] {
index++
}
}
return ""
}
// commandCreatesGitRepository reports whether this call would create a git
// repository. It shares gitSubcommand with the network gate deliberately, so the
// global options that already bypassed that gate cannot bypass this one either:
// "git -C sub init" and "git -c k=v init" both resolve to init.
//
// init-db is git's original spelling and still works today. clone creates a
// repository as surely as init does, with its config and hooks written by the
// fetch rather than by hand: "git clone <url> ." into a workspace governed by an
// ancestor repository lands a root .git whose carveouts setup never planned, so
// it is the same nested-repository decision. The network prompt is a separate
// gate and granting it does not answer this one. Reported by @gnanam1990.
//
// The subcommand is the one git runs after alias resolution, which git_alias.go
// models: definitions from the command line and from the environment the call
// sets, expanded with git's own quoting and its in-alias option handling. A
// definition this analyzer cannot read makes the command unclassifiable, and
// under this guard that is a refusal.
func commandCreatesGitRepository(call *syntax.CallExpr, prog string, args []*syntax.Word) bool {
if prog != "git" {
return false
}
words := literalWordTexts(args)
aliases := gitAliasTable{}
if !gitEnvironmentAliases(aliases, callAssignments(call, args)) {
return true
}
gitInlineAliases(aliases, words)
return gitSubcommandCreatesRepository(gitSubcommand(words), aliases)
}
func gitUsesNetwork(words []string) bool {
switch firstSubcommand(words, nil) {
switch gitSubcommand(words) {
case "clone", "fetch", "pull", "push", "ls-remote", "archive":
return true
default:
+13
View File
@@ -394,6 +394,19 @@ func (engine *Engine) Evaluate(ctx context.Context, request Request) Decision {
return deny(request, risk, block.Code, block.Path, block.Reason, false)
}
}
// BEFORE THE NETWORK GATE, because this one cannot be approved away.
//
// `git clone` is both a network command and a repository-creating one. With
// the network gate first, a clone in a governed nested workspace under the
// default NetworkDeny came back as a network prompt: the operator was asked
// to approve network access for a command that approval could never make
// runnable, and the reason that actually decides it was never shown. The
// refusal below does not depend on policy, permission or mode, so it is the
// answer whatever the network gate would have said, and it is the one with a
// remedy the operator can act on. Reported by @jatmn.
if HasRiskCategory(risk, "nested_git_init") {
return deny(request, risk, BlockNestedGitInit, "", ReasonNestedGitInit, false)
}
netMode := engine.effectiveNetworkMode(policy)
if netMode == NetworkDeny && HasRiskCategory(risk, "network") && !engine.toolNetworkExempt(request) {
if request.SideEffect == SideEffectShell && request.PermissionMode != PermissionUnsafe {
+376
View File
@@ -0,0 +1,376 @@
package sandbox
import (
"strconv"
"strings"
"mvdan.cc/sh/v3/syntax"
)
// THE NESTED-REPOSITORY GUARD CLASSIFIES THE COMMAND GIT RUNS, NOT THE ONE
// TYPED. Git rewrites its argument vector before choosing a builtin: an alias
// defined on the command line, in the environment or through --config-env is
// split with shell-style quoting, its own leading global options are applied,
// and the result is looked up again until a builtin is reached. Reading the
// literal token, or the first whitespace-separated field of an alias value,
// let `git -c alias.a='-c alias.b=init b' a .` and `git -c 'alias.a="init"' a .`
// create a repository the workspace profile carves nothing out for. What
// follows models that rewrite, and where the model runs out it refuses rather
// than guesses: under this guard the safe answer to "could this make a
// repository" is yes. Reported by @gnanam1990.
// gitAliasTable maps a lowercased alias name to every definition the command
// supplies for it. Git keeps one value per key, the last one written, and the
// order in which the environment, -c and --config-env feed that list is not
// something this guard needs to reproduce: a definition git would discard but
// which reaches init here costs one refusal, while the opposite mistake costs
// a repository. So every definition is kept, and any of them reaching init
// refuses. For the same reason an alias that shadows a builtin, which git
// ignores, is followed here rather than skipped: this file carries no list of
// git's commands, and refusing `-c alias.status=init status` loses nothing.
type gitAliasTable map[string][]gitAliasDefinition
// gitAliasDefinition is one alias value. unknown marks a definition whose text
// cannot be read statically, such as --config-env=alias.x=VAR or a
// GIT_CONFIG_VALUE_n written as $var: git will run whatever it holds, and
// this guard cannot say what that is.
type gitAliasDefinition struct {
text string
unknown bool
}
func (table gitAliasTable) define(name, text string) {
name = strings.ToLower(strings.TrimSpace(name))
if name == "" {
return
}
table[name] = append(table[name], gitAliasDefinition{text: strings.ToLower(text)})
}
func (table gitAliasTable) defineUnknown(name string) {
name = strings.ToLower(strings.TrimSpace(name))
if name == "" {
return
}
table[name] = append(table[name], gitAliasDefinition{unknown: true})
}
// defineSetting records a `key=value` config setting when key names an alias.
// readable is false when the value is only known by reference, which makes the
// alias unknown rather than absent. A setting without "=" is a boolean in git
// (`alias.x` expands to "true") and defines nothing worth following.
func (table gitAliasTable) defineSetting(setting string, readable bool) {
name, value, found := strings.Cut(setting, "=")
if !found {
return
}
alias, ok := strings.CutPrefix(strings.ToLower(strings.TrimSpace(name)), "alias.")
if !ok {
return
}
if !readable {
table.defineUnknown(alias)
return
}
table.define(alias, value)
}
// splitGitAliasExpansion tokenizes an alias value the way git's split_cmdline
// does: whitespace separates words, a single or double quote opens a quoted
// span anywhere inside a word, and a backslash outside single quotes escapes
// the next byte. An unclosed quote or a trailing backslash is a fatal error in
// git ("bad alias string"), so ok is false and nothing would have run.
func splitGitAliasExpansion(text string) (argv []string, ok bool) {
var current strings.Builder
var quoted byte
inWord := false
for index := 0; index < len(text); index++ {
c := text[index]
switch {
case quoted == 0 && isGitSpace(c):
if inWord {
argv = append(argv, current.String())
current.Reset()
inWord = false
}
case quoted == 0 && (c == '\'' || c == '"'):
quoted = c
inWord = true
case c == quoted:
quoted = 0
default:
if c == '\\' && quoted != '\'' {
index++
if index >= len(text) {
return nil, false
}
c = text[index]
}
current.WriteByte(c)
inWord = true
}
}
if quoted != 0 {
return nil, false
}
if inWord {
argv = append(argv, current.String())
}
return argv, true
}
func isGitSpace(c byte) bool {
switch c {
case ' ', '\t', '\n', '\r', '\v', '\f':
return true
}
return false
}
// gitAliasExpansionSubcommand runs a split expansion through git's own
// handling of global options inside an alias and returns the subcommand git
// would look up next. Inside an expansion git accepts -c and --config-env,
// which push settings the next lookup sees (that is how `-c alias.b=init b`
// reaches init), and the pager switches, which change nothing that runs. An
// option that changes the environment (-C, --git-dir, --bare, ...) is fatal
// inside an alias, and an expansion made only of options is an empty alias;
// both return ok false. So does an option this guard does not model, because
// "git would refuse this" and "this guard cannot read this" end the same way.
func gitAliasExpansionSubcommand(argv []string, aliases gitAliasTable) (subcommand string, ok bool) {
for index := 0; index < len(argv); index++ {
word := argv[index]
if word == "" {
return "", false
}
if !strings.HasPrefix(word, "-") {
return word, true
}
switch {
case word == "-c", word == "--config-env":
if index+1 >= len(argv) {
return "", false
}
index++
aliases.defineSetting(argv[index], word == "-c")
case strings.HasPrefix(word, "--config-env="):
aliases.defineSetting(strings.TrimPrefix(word, "--config-env="), false)
case word == "-p", word == "--paginate", word == "-P", word == "--no-pager":
default:
return "", false
}
}
return "", false
}
// gitSubcommandCreatesRepository reports whether subcommand, resolved through
// aliases the way git resolves it, is init, init-db or clone. The chain is
// followed to its end with the names on the current path kept, so a cycle is a
// refusal here the way "alias loop detected" is fatal in git. A shell alias
// runs a program this analyzer cannot classify; an expansion git cannot split,
// cannot run, or that leaves no subcommand behind is the same case, and so is
// a definition whose text is not readable. Every one of those refuses.
func gitSubcommandCreatesRepository(subcommand string, aliases gitAliasTable) bool {
return gitAliasPathCreatesRepository(subcommand, aliases, nil)
}
func gitAliasPathCreatesRepository(subcommand string, aliases gitAliasTable, path []string) bool {
switch subcommand {
case "init", "init-db", "clone":
return true
}
definitions, ok := aliases[subcommand]
if !ok {
return false
}
for _, seen := range path {
if seen == subcommand {
return true
}
}
path = append(path, subcommand)
for _, definition := range definitions {
if definition.unknown || strings.HasPrefix(definition.text, "!") {
return true
}
argv, ok := splitGitAliasExpansion(definition.text)
if !ok {
return true
}
next, ok := gitAliasExpansionSubcommand(argv, aliases)
if !ok {
return true
}
if gitAliasPathCreatesRepository(next, aliases, path) {
return true
}
}
return false
}
// gitInlineAliases records the alias definitions supplied ahead of the
// subcommand: `-c alias.NAME=EXPANSION`, and `--config-env alias.NAME=VAR` in
// both spellings, whose value lives in a variable this analyzer does not read.
// Git accepts no attached `-cNAME=VALUE` spelling ("unknown option"), so none
// is recognised here. Other global options that take a value are stepped over
// the way gitSubcommand steps over them, so a value that happens to look like
// a subcommand does not end the scan early.
func gitInlineAliases(aliases gitAliasTable, words []string) {
for index := 0; index < len(words); index++ {
word := words[index]
if word == "" {
continue
}
if !strings.HasPrefix(word, "-") {
return
}
switch {
case word == "-c", word == "--config-env":
if index+1 < len(words) {
index++
aliases.defineSetting(words[index], word == "-c")
}
case strings.HasPrefix(word, "--config-env="):
aliases.defineSetting(strings.TrimPrefix(word, "--config-env="), false)
case strings.HasPrefix(word, "--") && strings.Contains(word, "="):
case gitGlobalOptionsTakingValue[word]:
index++
}
}
}
// shellAssignment is one NAME=value the command hands to git's environment.
// readable is false when the value is an expansion this analyzer cannot see.
type shellAssignment struct {
value string
readable bool
}
// callAssignments collects the assignments in force for the program: the
// shell prefix (FOO=bar git ...) and the NAME=value words an env wrapper
// forwards (env FOO=bar git ...), which sit ahead of the program word.
func callAssignments(call *syntax.CallExpr, rest []*syntax.Word) map[string]shellAssignment {
assignments := map[string]shellAssignment{}
if call == nil {
return assignments
}
for _, assign := range call.Assigns {
if assign == nil || assign.Name == nil {
continue
}
readable := !assign.Append && assign.Array == nil && assign.Index == nil &&
(assign.Value == nil || isLiteralWord(assign.Value))
assignments[assign.Name.Value] = shellAssignment{value: wordText(assign.Value), readable: readable}
}
program := len(call.Args) - len(rest) - 1
for index := 0; index < program && index < len(call.Args); index++ {
name, value, found := strings.Cut(wordText(call.Args[index]), "=")
if !found || !isEnvName(name) {
continue
}
assignments[name] = shellAssignment{value: value, readable: isLiteralWord(call.Args[index])}
}
return assignments
}
func isEnvName(name string) bool {
if name == "" {
return false
}
for index, r := range name {
switch {
case r == '_', r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z':
case r >= '0' && r <= '9' && index > 0:
default:
return false
}
}
return true
}
// gitEnvironmentAliases records the alias definitions the command hands git
// through its environment, which git reads exactly as it reads -c:
// GIT_CONFIG_COUNT with GIT_CONFIG_KEY_n and GIT_CONFIG_VALUE_n, and
// GIT_CONFIG_PARAMETERS. It reports false when a definition this guard needs
// cannot be read: a count, a key or the parameter list written as an
// expansion, or the value of an alias key written that way. A count git would
// reject, or a numbered key with no value, is reported the same way; git dies
// on those, so refusing them costs nothing.
func gitEnvironmentAliases(aliases gitAliasTable, env map[string]shellAssignment) bool {
if parameters, ok := env["GIT_CONFIG_PARAMETERS"]; ok {
if !parameters.readable {
return false
}
settings, ok := splitGitConfigParameters(parameters.value)
if !ok {
return false
}
for _, setting := range settings {
aliases.defineSetting(setting, true)
}
}
count, ok := env["GIT_CONFIG_COUNT"]
if !ok {
return true
}
if !count.readable {
return false
}
n, err := strconv.Atoi(strings.TrimSpace(count.value))
if err != nil || n < 0 || n > 1024 {
return false
}
for index := 0; index < n; index++ {
key, ok := env["GIT_CONFIG_KEY_"+strconv.Itoa(index)]
if !ok || !key.readable {
return false
}
value, ok := env["GIT_CONFIG_VALUE_"+strconv.Itoa(index)]
if !ok {
return false
}
aliases.defineSetting(key.value+"="+value.value, value.readable)
}
return true
}
// splitGitConfigParameters dequotes GIT_CONFIG_PARAMETERS the way git does:
// whitespace-separated entries, each single-quoted, with `'\”` and `'\!'`
// standing for a quote or a bang inside one. Anything else is "bogus format"
// in git, a fatal error, so ok is false.
func splitGitConfigParameters(text string) (settings []string, ok bool) {
index := 0
for {
for index < len(text) && isGitSpace(text[index]) {
index++
}
if index >= len(text) {
return settings, true
}
if text[index] != '\'' {
return nil, false
}
index++
var current strings.Builder
for {
if index >= len(text) {
return nil, false
}
c := text[index]
index++
if c != '\'' {
current.WriteByte(c)
continue
}
if index+2 < len(text) && text[index] == '\\' && (text[index+1] == '\'' || text[index+1] == '!') && text[index+2] == '\'' {
current.WriteByte(text[index+1])
index += 3
continue
}
if index >= len(text) || isGitSpace(text[index]) {
settings = append(settings, current.String())
break
}
return nil, false
}
}
}
+103
View File
@@ -0,0 +1,103 @@
package sandbox
import (
"reflect"
"testing"
)
// The tokenizer has to agree with git's split_cmdline on the forms that decide
// which subcommand runs: quotes anywhere in a word, backslash escapes outside
// single quotes, and the two errors git dies on.
func TestSplitGitAliasExpansionMatchesGitQuoting(t *testing.T) {
for _, tc := range []struct {
expansion string
want []string
ok bool
}{
{"init", []string{"init"}, true},
{`"init"`, []string{"init"}, true},
{`in"it"`, []string{"init"}, true},
{`in\it`, []string{"init"}, true},
{`'in\it'`, []string{`in\it`}, true},
{`"in\"it"`, []string{`in"it`}, true},
{`-c alias.b=init b`, []string{"-c", "alias.b=init", "b"}, true},
{`-c "alias.b=init x" b`, []string{"-c", "alias.b=init x", "b"}, true},
{`'-c' 'alias.b=init' b`, []string{"-c", "alias.b=init", "b"}, true},
{" init \t ", []string{"init"}, true},
{"", nil, true},
{`"init`, nil, false},
{`'init`, nil, false},
{`init\`, nil, false},
} {
got, ok := splitGitAliasExpansion(tc.expansion)
if ok != tc.ok || !reflect.DeepEqual(got, tc.want) {
t.Errorf("splitGitAliasExpansion(%q) = %q, %v; want %q, %v", tc.expansion, got, ok, tc.want, tc.ok)
}
}
}
// GIT_CONFIG_PARAMETERS is git's own single-quoted list; an entry git would
// call bogus makes the whole variable unreadable rather than partly read.
func TestSplitGitConfigParametersDequotesLikeGit(t *testing.T) {
for _, tc := range []struct {
text string
want []string
ok bool
}{
{`'alias.a=init'`, []string{"alias.a=init"}, true},
{`'alias.a=init' 'x=y'`, []string{"alias.a=init", "x=y"}, true},
{` 'alias.a=in'\''it' `, []string{"alias.a=in'it"}, true},
{`'alias.a=in'\!'it'`, []string{"alias.a=in!it"}, true},
{"", nil, true},
{`alias.a=init`, nil, false},
{`'alias.a=init`, nil, false},
{`'a'x`, nil, false},
} {
got, ok := splitGitConfigParameters(tc.text)
if ok != tc.ok || !reflect.DeepEqual(got, tc.want) {
t.Errorf("splitGitConfigParameters(%q) = %q, %v; want %q, %v", tc.text, got, ok, tc.want, tc.ok)
}
}
}
// Resolution applies git's in-alias option handling: a -c inside an expansion
// defines the alias the next lookup uses, pager switches are stepped over, an
// environment-changing option or an unreadable definition refuses, and only a
// chain that ends in a real repository-creating command is creation.
func TestGitAliasResolutionFollowsExpansionOptions(t *testing.T) {
for _, tc := range []struct {
name string
subcommand string
aliases map[string]string
unknown []string
creates bool
}{
{name: "option inside expansion defines the next alias", subcommand: "a", aliases: map[string]string{"a": "-c alias.b=init b"}, creates: true},
{name: "quoted expansion", subcommand: "a", aliases: map[string]string{"a": `"init"`}, creates: true},
{name: "pager switch does not hide init", subcommand: "a", aliases: map[string]string{"a": "--no-pager init"}, creates: true},
{name: "environment-changing option refuses", subcommand: "a", aliases: map[string]string{"a": "-C . init"}, creates: true},
{name: "unclosed quote refuses", subcommand: "a", aliases: map[string]string{"a": `"in`}, creates: true},
{name: "options only refuses", subcommand: "a", aliases: map[string]string{"a": "-c x=y"}, creates: true},
{name: "config-env inside expansion is unreadable", subcommand: "a", aliases: map[string]string{"a": "--config-env=alias.b=var b"}, creates: true},
{name: "redefinition loop refuses", subcommand: "a", aliases: map[string]string{"a": "-c alias.a=init a"}, creates: true},
{name: "unknown definition refuses", subcommand: "a", unknown: []string{"a"}, creates: true},
{name: "harmless option-bearing alias", subcommand: "st", aliases: map[string]string{"st": "-c color.ui=always status"}, creates: false},
{name: "harmless quoted alias", subcommand: "st", aliases: map[string]string{"st": `"status"`}, creates: false},
{name: "harmless pager alias", subcommand: "l", aliases: map[string]string{"l": "--no-pager log --oneline"}, creates: false},
{name: "harmless chain through quotes", subcommand: "s", aliases: map[string]string{"s": "st", "st": `sta"tus"`}, creates: false},
{name: "not an alias", subcommand: "status", creates: false},
} {
t.Run(tc.name, func(t *testing.T) {
table := gitAliasTable{}
for name, expansion := range tc.aliases {
table.define(name, expansion)
}
for _, name := range tc.unknown {
table.defineUnknown(name)
}
if got := gitSubcommandCreatesRepository(tc.subcommand, table); got != tc.creates {
t.Fatalf("gitSubcommandCreatesRepository(%q) = %v, want %v", tc.subcommand, got, tc.creates)
}
})
}
}
@@ -0,0 +1,58 @@
package sandbox
import "testing"
// GIT'S GLOBAL OPTIONS CONSUME THE NEXT WORD, AND THE CLASSIFIER HAS TO KNOW.
//
// firstSubcommand skips words beginning with "-" but not the value that follows
// one. So "git -C sub clone <url>" made it answer "sub", the command classified
// as touching no network at all, and the network gate never fired. Two spellings
// an ordinary user types by habit walked straight through it.
//
// Driven through AnalyzeCommand rather than the helper, because the gate reads
// the analysis and a helper-level assertion would not have caught this.
func TestGitGlobalOptionsDoNotHideTheSubcommand(t *testing.T) {
for _, testCase := range []struct {
script string
want bool
}{
{script: "git clone https://example.com/x", want: true},
{script: "git -C sub clone https://example.com/x", want: true},
{script: "git -c user.name=x clone https://example.com/x", want: true},
{script: "git -c a=b -C sub clone https://example.com/x", want: true},
{script: "git --git-dir=/tmp/g clone https://example.com/x", want: true},
{script: "git --namespace ns fetch origin", want: true},
{script: "git -C sub push origin main", want: true},
// Local-only work stays local, or the fix would be "call everything network".
{script: "git status", want: false},
{script: "git -C sub status", want: false},
{script: "git -c a=b commit -m x", want: false},
{script: "git init", want: false},
} {
if got := AnalyzeCommand(testCase.script).Network; got != testCase.want {
t.Errorf("AnalyzeCommand(%q).Network = %v, want %v", testCase.script, got, testCase.want)
}
}
}
// The subcommand parser itself, including the option that consumes a value and
// the self-contained "--opt=value" spelling that does not.
func TestGitSubcommandSkipsOptionValues(t *testing.T) {
for _, testCase := range []struct {
words []string
want string
}{
{words: []string{"clone", "url"}, want: "clone"},
{words: []string{"-c", "sub", "clone"}, want: "clone"},
{words: []string{"--git-dir=/tmp/g", "clone"}, want: "clone"},
{words: []string{"--namespace", "ns", "fetch"}, want: "fetch"},
{words: []string{"-c", "a=b", "-c", "sub", "push"}, want: "push"},
{words: []string{"--no-pager", "status"}, want: "status"},
{words: []string{}, want: ""},
{words: []string{"-c", "a=b"}, want: ""},
} {
if got := gitSubcommand(testCase.words); got != testCase.want {
t.Errorf("gitSubcommand(%v) = %q, want %q", testCase.words, got, testCase.want)
}
}
}
@@ -0,0 +1,451 @@
package sandbox
import (
"context"
"os"
"path/filepath"
"testing"
)
// nestedGitWorkspace returns an ancestor repository and a workspace inside it
// that carries no git metadata of its own, which is the shape the whole refusal
// is about.
func nestedGitWorkspace(t *testing.T) (ancestor string, workspace string) {
t.Helper()
ancestor = t.TempDir()
if err := os.MkdirAll(filepath.Join(ancestor, ".git"), 0o700); err != nil {
t.Fatal(err)
}
workspace = filepath.Join(ancestor, "packages", "app")
if err := os.MkdirAll(workspace, 0o700); err != nil {
t.Fatal(err)
}
if _, err := os.Lstat(filepath.Join(workspace, ".git")); err == nil {
t.Fatal("SETUP INVALID: the workspace carries its own .git, which is the protected case, not this one")
}
return ancestor, workspace
}
func gitCommandRequest(workspace string, command string) Request {
return Request{
ToolName: "bash",
SideEffect: SideEffectShell,
Permission: PermissionPrompt,
PermissionMode: PermissionModeAsk,
WorkspaceRoot: workspace,
Args: map[string]any{"command": command},
}
}
func gitWorkspaceEngine(t *testing.T, workspace string) *Engine {
t.Helper()
return NewEngine(EngineOptions{
WorkspaceRoot: workspace,
Policy: DefaultPolicy(),
Backend: nativeWrappingBackend,
})
}
// THE LIFECYCLE, END TO END: the workspace is protected at write time AND the
// command that would create a repository there is refused with an explanation.
//
// The setup guard is still load-bearing, but it now asserts the opposite of
// what it once did. The carveouts were restored for a nested workspace, because
// skipping them left nothing denying writes to <root>/.git and a repository
// could be assembled there without running git at all. So the refusal is no
// longer the only thing standing between a sandboxed command and a writable
// config; it is the layer that tells an operator why, while the deny ACE is
// what stops it. If the carveouts disappear again this fails here, because the
// refusal on its own does not cover mkdir.
func TestNestedWorkspaceRefusesGitInit(t *testing.T) {
ancestor, workspace := nestedGitWorkspace(t)
carved := map[string]bool{}
for _, carveout := range gitMetadataWriteCarveouts(workspace) {
carved[filepath.Base(carveout)] = true
}
if !carved["config"] || !carved["hooks"] {
t.Fatalf("SETUP INVALID: the nested workspace planned %v, so config and hooks are not denied and the refusal below is the only protection again", gitMetadataWriteCarveouts(workspace))
}
engine := gitWorkspaceEngine(t, workspace)
decision := engine.Evaluate(context.Background(), gitCommandRequest(workspace, "git init"))
if decision.Action != ActionDeny {
t.Fatalf("git init inside the repository at %s was %s, not denied; the repository it creates gets a writable config and hooks under the workspace grant", ancestor, decision.Action)
}
if decision.Block == nil || decision.Block.Code != BlockNestedGitInit {
t.Fatalf("block = %#v, want code %s so the operator gets the nested-repository explanation and not a generic denial", decision.Block, BlockNestedGitInit)
}
}
// The refusal sits ahead of every allow path in Evaluate, so a tool already
// cleared by permissions does not walk through it.
func TestNestedGitInitRefusalOutranksAnAllowPermission(t *testing.T) {
_, workspace := nestedGitWorkspace(t)
engine := gitWorkspaceEngine(t, workspace)
request := gitCommandRequest(workspace, "git init")
request.Permission = PermissionAllow
if decision := engine.Evaluate(context.Background(), request); decision.Action != ActionDeny {
t.Fatalf("an allow permission carried git init through: %#v", decision)
}
request = gitCommandRequest(workspace, "git init")
request.PermissionMode = PermissionUnsafe
request.PermissionGranted = true
if decision := engine.Evaluate(context.Background(), request); decision.Action != ActionDeny {
t.Fatalf("unsafe mode carried git init through: %#v", decision)
}
}
// The global options that bypassed the network gate must not bypass this one.
// These are the exact spellings that classified a clone as network=false before
// gitSubcommand learned which options take a value.
func TestNestedGitInitRefusalSurvivesGitGlobalOptions(t *testing.T) {
_, workspace := nestedGitWorkspace(t)
engine := gitWorkspaceEngine(t, workspace)
for _, command := range []string{
"git init",
"git init-db",
"git -C packages/app init",
"git -c core.hooksPath=/tmp/h init",
"git --git-dir=/tmp/g init",
"git --namespace ns init",
"sh -c \"git init\"",
"sudo git init",
"git status && git init",
} {
t.Run(command, func(t *testing.T) {
decision := engine.Evaluate(context.Background(), gitCommandRequest(workspace, command))
if decision.Action != ActionDeny {
t.Fatalf("%q was %s, not denied", command, decision.Action)
}
})
}
}
// CONTROL: ordinary git in a nested workspace is untouched.
//
// Discovery still walks up to the ancestor and every command that uses it keeps
// working; only creation is refused. Without this, returning deny for anything
// containing the word git would pass the tests above.
func TestNestedWorkspaceStillRunsOrdinaryGitCommands(t *testing.T) {
_, workspace := nestedGitWorkspace(t)
engine := gitWorkspaceEngine(t, workspace)
for _, command := range []string{
"git status",
"git add .",
"git commit -m init",
"git log --oneline",
"git worktree list",
"git submodule update --init",
} {
t.Run(command, func(t *testing.T) {
decision := engine.Evaluate(context.Background(), gitCommandRequest(workspace, command))
if decision.Action == ActionDeny {
t.Fatalf("%q was denied in a nested workspace: %s", command, decision.Reason)
}
})
}
}
// CONTROL: a standalone workspace still creates repositories.
//
// It keeps its materialized config and hooks carveouts, so the protection the
// refusal stands in for is actually there and the command is allowed.
func TestStandaloneWorkspaceStillAllowsGitInit(t *testing.T) {
workspace := t.TempDir()
if gitMetadataGovernedByAncestor(workspace) {
t.Skip("this temp directory sits inside a repository, so it is not the standalone case")
}
if len(gitMetadataWriteCarveouts(workspace)) == 0 {
t.Fatal("SETUP INVALID: a standalone workspace planned no carveouts, so it has no protection to allow git init against")
}
engine := gitWorkspaceEngine(t, workspace)
decision := engine.Evaluate(context.Background(), gitCommandRequest(workspace, "git init"))
if decision.Action == ActionDeny {
t.Fatalf("git init was refused in a standalone workspace: %s", decision.Reason)
}
}
// CONTROL: a linked worktree is a workspace whose .git is a FILE.
//
// It owns git metadata, keeps its pointer-file carveout, and is not somebody
// else's repository, so nothing here is refused. Zero's own development
// worktrees are this shape, so getting it wrong would refuse git init for the
// people writing this code.
func TestLinkedWorktreeWorkspaceStillAllowsGitInit(t *testing.T) {
ancestor := t.TempDir()
if err := os.MkdirAll(filepath.Join(ancestor, ".git", "worktrees", "w"), 0o700); err != nil {
t.Fatal(err)
}
workspace := filepath.Join(ancestor, "checkout")
if err := os.MkdirAll(workspace, 0o700); err != nil {
t.Fatal(err)
}
pointer := filepath.Join(workspace, ".git")
if err := os.WriteFile(pointer, []byte("gitdir: ../.git/worktrees/w\n"), 0o600); err != nil {
t.Fatal(err)
}
// SETUP: the pointer carveout is intact, or this proves nothing about
// worktrees keeping their protection.
specs := gitMetadataWriteCarveoutSpecs(workspace)
if len(specs) != 1 || specs[0].Path != pointer || !specs[0].IsFile {
t.Fatalf("SETUP INVALID: linked worktree carveouts = %+v, want the file-shaped pointer %s", specs, pointer)
}
engine := gitWorkspaceEngine(t, workspace)
if decision := engine.Evaluate(context.Background(), gitCommandRequest(workspace, "git init")); decision.Action == ActionDeny {
t.Fatalf("git init was refused in a linked worktree, which owns its git metadata: %s", decision.Reason)
}
}
// And the workspace-level question is asked of the workspace itself, so a
// workspace that already carries .git is never treated as nested no matter what
// is above it.
func TestWorkspaceOwningGitIsNotTreatedAsNested(t *testing.T) {
ancestor := t.TempDir()
if err := os.MkdirAll(filepath.Join(ancestor, ".git"), 0o700); err != nil {
t.Fatal(err)
}
workspace := filepath.Join(ancestor, "vendored")
if err := os.MkdirAll(filepath.Join(workspace, ".git"), 0o700); err != nil {
t.Fatal(err)
}
if workspaceGovernedByAncestorRepository(workspace) {
t.Fatal("a workspace that owns .git was reported as governed by its ancestor, so its own protection would be refused instead of applied")
}
}
// CLONE IS CREATION TOO, AND THE NETWORK GRANT DOES NOT ANSWER FOR IT.
//
// "git clone <url> ." into a workspace governed by an ancestor repository lands
// a root .git whose config and hooks setup never planned carveouts for, exactly
// the state git init produces, but the guard only knew the init spelling. With
// network explicitly allowed and the shell grant already given, the clone came
// back as an ordinary allow. The repository-creation refusal is a separate
// decision from the network prompt and has to stand on its own. Reported by
// @gnanam1990.
func TestNestedWorkspaceRefusesGitCloneIntoTheRoot(t *testing.T) {
ancestor, workspace := nestedGitWorkspace(t)
policy := DefaultPolicy()
policy.Network = NetworkAllow
engine := NewEngine(EngineOptions{WorkspaceRoot: workspace, Policy: policy, Backend: nativeWrappingBackend})
for _, command := range []string{
"git clone https://example.invalid/repo.git .",
"git -C . clone https://example.invalid/repo.git .",
"git clone --depth 1 https://example.invalid/repo.git vendor/dep",
} {
request := gitCommandRequest(workspace, command)
request.Permission = PermissionAllow
request.PermissionGranted = true
decision := engine.Evaluate(context.Background(), request)
if decision.Action != ActionDeny {
t.Fatalf("%q inside the repository at %s was %s, not denied; the repository it creates gets a writable config and hooks under the workspace grant", command, ancestor, decision.Action)
}
if decision.Block == nil || decision.Block.Code != BlockNestedGitInit {
t.Fatalf("%q block = %#v, want code %s", command, decision.Block, BlockNestedGitInit)
}
}
// CONTROLS: ordinary git operations and the submodule init form stay clear
// of this guard, and a standalone workspace is not governed by anything.
for _, command := range []string{"git status", "git submodule update --init", "git fetch origin"} {
request := gitCommandRequest(workspace, command)
request.Permission = PermissionAllow
request.PermissionGranted = true
if decision := engine.Evaluate(context.Background(), request); decision.Action == ActionDeny && decision.Block != nil && decision.Block.Code == BlockNestedGitInit {
t.Fatalf("%q was refused as nested repository creation; it creates no repository", command)
}
}
standalone := t.TempDir()
if workspaceGovernedByAncestorRepository(standalone) {
t.Fatalf("SETUP INVALID: %s is governed by an ancestor repository", standalone)
}
request := gitCommandRequest(standalone, "git clone https://example.invalid/repo.git .")
request.Permission = PermissionAllow
request.PermissionGranted = true
if decision := gitWorkspaceEngine(t, standalone).Evaluate(context.Background(), request); decision.Block != nil && decision.Block.Code == BlockNestedGitInit {
t.Fatalf("clone into a standalone workspace was refused as nested: %#v", decision)
}
}
// AN INLINE ALIAS IS STILL INIT. The refusal used to read the literal
// subcommand token, and `git -c alias.bootstrap=init bootstrap` passed it with
// shell and network granted and created a repository the profile carves nothing
// out for. The alias is resolved to the subcommand git runs; a chained alias
// follows the chain; a shell alias, which this analyzer cannot classify, counts
// as creation under this guard and nowhere else.
func TestNestedWorkspaceRefusesAnInlineGitAliasForInit(t *testing.T) {
ancestor, workspace := nestedGitWorkspace(t)
policy := DefaultPolicy()
policy.Network = NetworkAllow
engine := NewEngine(EngineOptions{WorkspaceRoot: workspace, Policy: policy, Backend: nativeWrappingBackend})
for _, command := range []string{
"git init",
"git -c alias.bootstrap=init bootstrap",
"git -c alias.mk=init-db mk",
"git -c alias.get=clone get https://example.invalid/repo.git .",
"git -c alias.two=bootstrap -c alias.bootstrap=init two",
"git -c alias.a=b -c alias.b=c -c alias.c=d -c alias.d=e -c alias.e=init a .",
"git -c alias.a=b -c alias.b=a a",
"git -c alias.dangling=nowhere -c alias.nowhere= dangling",
"git -C . -c alias.bootstrap=init bootstrap",
"git -c alias.sh=!sh -c 'git init' sh",
// THE EXPANSION IS PARSED THE WAY GIT PARSES IT. A -c inside the
// expansion defines the alias the next lookup uses, and quotes are
// stripped before the subcommand is read; both of the first two
// spellings created a repository while only the first whitespace
// field of the expansion was classified.
"git -c alias.a='-c alias.b=init b' a .",
"git -c 'alias.a=\"init\"' a .",
"git -c 'alias.a=in\"it\"' a",
"git -c 'alias.a=in\\it' a",
"git -c alias.a='--no-pager init' a",
"git -c alias.a='-c alias.a=init a' a",
// Git dies on these before running anything; refusing them costs
// nothing and keeps the rule that an expansion this analyzer cannot
// read is not evidence of safety.
"git -c 'alias.a=\"in' a",
"git -c 'alias.a=-C . init' a",
// An alias defined by reference, or through the environment the call
// sets, is the same command with its definition moved out of sight.
"git --config-env=alias.a=zz a .",
"git --config-env alias.a=zz a .",
"GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=alias.a GIT_CONFIG_VALUE_0=init git a .",
"env GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=alias.a GIT_CONFIG_VALUE_0=init git a .",
"GIT_CONFIG_PARAMETERS=\"'alias.a=init'\" git a .",
"GIT_CONFIG_PARAMETERS=$params git a .",
"GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=alias.a GIT_CONFIG_VALUE_0=$cmd git a .",
} {
request := gitCommandRequest(workspace, command)
request.Permission = PermissionAllow
request.PermissionGranted = true
decision := engine.Evaluate(context.Background(), request)
if decision.Action != ActionDeny || decision.Block == nil || decision.Block.Code != BlockNestedGitInit {
t.Fatalf("%q inside the repository at %s was not refused as nested repository creation: action=%s block=%#v", command, ancestor, decision.Action, decision.Block)
}
}
// CONTROLS: a harmless alias, an unrelated -c setting, ordinary use, and
// the quoted, option-bearing and environment-defined spellings of an alias
// that runs something harmless.
for _, command := range []string{
"git -c alias.st=status st",
"git -c core.autocrlf=false status",
"git -c alias.bootstrap=init status",
"git -c alias.lg=log lg --oneline",
"git -c alias.a=b -c alias.b=c -c alias.c=d -c alias.d=e -c alias.e=status a",
"git -c alias.st='-c color.ui=always status' st",
"git -c 'alias.st=\"status\"' st",
"git -c 'alias.st=sta\"tus\"' st",
"git -c alias.l='--no-pager log --oneline' l",
"git -c alias.s=st -c 'alias.st=\"status\"' s",
"git --config-env=core.pager=p status",
"GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=alias.st GIT_CONFIG_VALUE_0=status git st",
"GIT_CONFIG_PARAMETERS=\"'alias.st=status'\" git st",
"GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.pager GIT_CONFIG_VALUE_0=$pager git log",
} {
request := gitCommandRequest(workspace, command)
request.Permission = PermissionAllow
request.PermissionGranted = true
if decision := engine.Evaluate(context.Background(), request); decision.Action == ActionDeny && decision.Block != nil && decision.Block.Code == BlockNestedGitInit {
t.Fatalf("%q was refused as nested repository creation; it creates no repository", command)
}
}
// And a standalone workspace is not governed at all, alias or not.
standalone := t.TempDir()
for _, command := range []string{
"git -c alias.bootstrap=init bootstrap",
"git -c alias.a='-c alias.b=init b' a .",
"git -c 'alias.a=\"init\"' a .",
"GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=alias.a GIT_CONFIG_VALUE_0=init git a .",
} {
request := gitCommandRequest(standalone, command)
request.Permission = PermissionAllow
request.PermissionGranted = true
if decision := gitWorkspaceEngine(t, standalone).Evaluate(context.Background(), request); decision.Block != nil && decision.Block.Code == BlockNestedGitInit {
t.Fatalf("%q in a standalone workspace was refused as nested: %#v", command, decision)
}
}
}
// THE REFUSAL THAT CANNOT BE APPROVED IS THE ONE THE OPERATOR HAS TO SEE.
//
// `git clone` carries both risks: it uses the network and it creates a
// repository. The clone test above sets NetworkAllow, which is the only policy
// under which the nested-repository branch was ever reached. Under the DEFAULT
// policy the network gate ran first and answered with a prompt, so the operator
// was asked to approve network access for a command no approval could make
// runnable, and ReasonNestedGitInit, the reason with a remedy in it, was never
// shown. Reported by @jatmn.
func TestNestedCloneIsRefusedAsNestedUnderTheDefaultNetworkPolicy(t *testing.T) {
_, workspace := nestedGitWorkspace(t)
policy := DefaultPolicy()
if policy.Network != NetworkDeny {
t.Fatalf("SETUP INVALID: the default network policy is %q, so this does not exercise the ordering", policy.Network)
}
engine := NewEngine(EngineOptions{WorkspaceRoot: workspace, Policy: policy, Backend: nativeWrappingBackend})
for _, granted := range []bool{false, true} {
request := gitCommandRequest(workspace, "git clone https://example.invalid/repo.git vendor/dep")
request.PermissionGranted = granted
decision := engine.Evaluate(context.Background(), request)
if decision.Action != ActionDeny || decision.Block == nil || decision.Block.Code != BlockNestedGitInit {
t.Fatalf("granted=%v: clone in a governed workspace was %s (block %#v, reason %q), want a %s denial", granted, decision.Action, decision.Block, decision.Reason, BlockNestedGitInit)
}
if decision.Reason != ReasonNestedGitInit {
t.Errorf("granted=%v: reason = %q, want the nested-repository reason and its remedy", granted, decision.Reason)
}
}
// CONTROLS. A network command that creates no repository still gets the
// network answer, so the reordering did not swallow that gate, and a clone
// into a standalone workspace is a network question and nothing else.
fetch := engine.Evaluate(context.Background(), gitCommandRequest(workspace, "git fetch origin"))
if fetch.Action != ActionPrompt || fetch.Reason != ReasonNetworkBlocked {
t.Errorf("git fetch in the governed workspace was %s (%q), want the network prompt", fetch.Action, fetch.Reason)
}
standalone := t.TempDir()
if workspaceGovernedByAncestorRepository(standalone) {
t.Fatalf("SETUP INVALID: %s is governed by an ancestor repository", standalone)
}
standaloneEngine := NewEngine(EngineOptions{WorkspaceRoot: standalone, Policy: policy, Backend: nativeWrappingBackend})
clone := standaloneEngine.Evaluate(context.Background(), gitCommandRequest(standalone, "git clone https://example.invalid/repo.git ."))
if clone.Action != ActionPrompt || clone.Reason != ReasonNetworkBlocked {
t.Errorf("clone into a standalone workspace was %s (%q), want the network prompt", clone.Action, clone.Reason)
}
}
// THE LIMIT OF THIS GUARD, WRITTEN DOWN SO IT IS NOT MISTAKEN FOR COVERAGE.
//
// The nested-repository rule recognises git creating a repository. It does not
// recognise a repository assembled by other means, and it cannot: mkdir and a
// few writes, an archive, or a script in any language all produce the same
// directory, and none of them is a git command. These legs are allowed today.
// They are here so that anyone reading the rule as containment of the .git
// pathname finds a test saying otherwise, and so that a change which DOES close
// it at the decision layer has to come and update this deliberately.
//
// It is not a statement that the behaviour is acceptable. A repository built
// this way carries whatever config its author wrote, and a plain `git status`
// outside the sandbox honours it. That has to be closed where the carveouts
// live, at write time. Reported by @jatmn.
func TestHandAssembledRepositoryIsNotWhatThisGuardCatches(t *testing.T) {
_, workspace := nestedGitWorkspace(t)
engine := gitWorkspaceEngine(t, workspace)
for _, command := range []string{
"mkdir .git",
"mkdir -p .git/objects .git/refs && printf 'ref: refs/heads/main\n' > .git/HEAD",
"cp -r ../template-repo/.git .git",
"python3 -c \"import os; os.makedirs('.git/refs')\"",
"tar -xf repo.tar",
} {
decision := engine.Evaluate(context.Background(), gitCommandRequest(workspace, command))
if decision.Block != nil && decision.Block.Code == BlockNestedGitInit {
t.Errorf("%q is now refused as nested repository creation. If that is deliberate, this test and the WHAT THIS IS NOT note in risk.go both need to say what the rule covers now", command)
}
}
}
@@ -0,0 +1,263 @@
package sandbox
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
// A WORKSPACE INSIDE ANOTHER REPOSITORY KEEPS ITS CARVEOUTS.
//
// This branch skipped them for a while, so that Zero would not create a .git in
// a repository it does not own. The cost was worse than the thing avoided:
// nothing then denied writes to <root>/.git, so a sandboxed command could build
// a repository there by hand and put core.fsmonitor in its config, and Zero runs
// git outside the sandbox against that workspace. The nested-repository refusal
// recognises git creating a repository; it cannot recognise mkdir.
//
// TestCarveoutsInsideAnAncestorRepositoryDoNotCaptureDiscovery measures the
// reason the skip existed and shows it does not hold.
func TestCarveoutsAreSynthesizedInsideAnAncestorRepository(t *testing.T) {
parent := t.TempDir()
if err := os.MkdirAll(filepath.Join(parent, ".git"), 0o700); err != nil {
t.Fatal(err)
}
workspace := filepath.Join(parent, "sub", "project")
if err := os.MkdirAll(workspace, 0o700); err != nil {
t.Fatal(err)
}
// SETUP: the workspace itself has no .git, or this is a different case.
if _, err := os.Lstat(filepath.Join(workspace, ".git")); err == nil {
t.Fatal("SETUP INVALID: the workspace already carries .git")
}
if !gitMetadataGovernedByAncestor(workspace) {
t.Fatal("SETUP INVALID: the ancestor is not detected, so this proves nothing")
}
specs := gitMetadataWriteCarveoutSpecs(workspace)
want := map[string]bool{
filepath.Join(workspace, ".git", "config"): true,
filepath.Join(workspace, ".git", "hooks"): true,
}
for _, spec := range specs {
delete(want, spec.Path)
}
for path := range want {
t.Errorf("a workspace inside another repository gets no carveout for %s, so nothing denies writing it", path)
}
}
// The reason the skip existed, measured rather than argued: materializing only
// config and hooks does not make the directory a repository, so git's discovery
// walk still reaches the ancestor and a config planted there is never read.
// If a future git changes that, this fails and the trade has to be reopened.
func TestCarveoutsInsideAnAncestorRepositoryDoNotCaptureDiscovery(t *testing.T) {
git, err := exec.LookPath("git")
if err != nil {
t.Skip("no git on PATH")
}
parent := t.TempDir()
run := func(dir string, args ...string) (string, error) {
command := exec.Command(git, args...)
command.Dir = dir
command.Env = append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1", "HOME="+parent, "USERPROFILE="+parent)
out, err := command.CombinedOutput()
return strings.TrimSpace(string(out)), err
}
if out, err := run(parent, "init", "-q", "."); err != nil {
t.Skipf("cannot create the ancestor repository: %v: %s", err, out)
}
workspace := filepath.Join(parent, "sub", "project")
if err := os.MkdirAll(workspace, 0o700); err != nil {
t.Fatal(err)
}
ancestor, err := run(workspace, "rev-parse", "--show-toplevel")
if err != nil {
t.Skipf("cannot resolve the ancestor: %v: %s", err, ancestor)
}
// Materialize exactly what the plan materializes, and make the config as
// hostile as a sandboxed command would if it could write one.
for _, spec := range gitMetadataWriteCarveoutSpecs(workspace) {
if spec.IsFile {
if err := os.MkdirAll(filepath.Dir(spec.Path), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(spec.Path, []byte("[core]\n\tfsmonitor = \"exit 9\"\n"), 0o600); err != nil {
t.Fatal(err)
}
continue
}
if err := os.MkdirAll(spec.Path, 0o700); err != nil {
t.Fatal(err)
}
}
after, err := run(workspace, "rev-parse", "--show-toplevel")
if err != nil {
t.Fatalf("git stopped working in the workspace after the carveouts were materialized: %v: %s", err, after)
}
if after != ancestor {
t.Fatalf("discovery moved from %s to %s: the materialized carveouts captured the walk, which is the trade this test exists to watch", ancestor, after)
}
if out, err := run(workspace, "status", "--porcelain"); err != nil {
t.Fatalf("git status failed in the workspace, so the planted config was read: %v: %s", err, out)
}
}
// And a standalone directory keeps the materialized carveouts, or the guard
// above would be satisfied by returning nothing everywhere and the protection
// would be gone for the case it was written for.
func TestCarveoutsStillCoverAStandaloneDirectory(t *testing.T) {
workspace := t.TempDir()
if gitMetadataGovernedByAncestor(workspace) {
t.Skip("this temp directory sits inside a repository, so it is not the standalone case")
}
specs := gitMetadataWriteCarveoutSpecs(workspace)
wantConfig := filepath.Join(workspace, ".git", "config")
wantHooks := filepath.Join(workspace, ".git", "hooks")
var sawConfig, sawHooks bool
for _, spec := range specs {
switch spec.Path {
case wantConfig:
sawConfig = true
if !spec.IsFile {
t.Errorf("%s is planned as a directory; creating .git/config as a directory makes git init fail", spec.Path)
}
case wantHooks:
sawHooks = true
}
}
if !sawConfig || !sawHooks {
t.Fatalf("a standalone directory lost its carveouts: config=%t hooks=%t specs=%+v", sawConfig, sawHooks, specs)
}
}
// A linked worktree is unchanged: the pointer file is denied and nothing beneath
// it is named, which is what keeps elevated setup from descending through a
// regular file.
func TestCarveoutsStillDenyTheLinkedWorktreePointer(t *testing.T) {
workspace := t.TempDir()
pointer := filepath.Join(workspace, ".git")
if err := os.WriteFile(pointer, []byte("gitdir: ../real/.git/worktrees/w\n"), 0o600); err != nil {
t.Fatal(err)
}
specs := gitMetadataWriteCarveoutSpecs(workspace)
if len(specs) != 1 || specs[0].Path != pointer || !specs[0].IsFile {
t.Fatalf("linked worktree carveouts = %+v, want exactly the file-shaped pointer %s", specs, pointer)
}
}
// The ancestor test itself has to distinguish shapes, because a linked worktree
// or submodule ancestor carries .git as a FILE and still owns this directory.
func TestAncestorDetectionAcceptsBothGitShapes(t *testing.T) {
for _, shape := range []struct {
name string
make func(t *testing.T, parent string)
}{
{name: "directory", make: func(t *testing.T, parent string) {
if err := os.MkdirAll(filepath.Join(parent, ".git"), 0o700); err != nil {
t.Fatal(err)
}
}},
{name: "pointer file", make: func(t *testing.T, parent string) {
if err := os.WriteFile(filepath.Join(parent, ".git"), []byte("gitdir: elsewhere\n"), 0o600); err != nil {
t.Fatal(err)
}
}},
} {
t.Run(shape.name, func(t *testing.T) {
parent := t.TempDir()
shape.make(t, parent)
workspace := filepath.Join(parent, "nested", "deep")
if err := os.MkdirAll(workspace, 0o700); err != nil {
t.Fatal(err)
}
if !gitMetadataGovernedByAncestor(workspace) {
t.Fatalf("an ancestor carrying .git as a %s was not recognised as governing %s", shape.name, workspace)
}
})
}
}
// A NESTED WORKSPACE THAT OWNS ITS OWN REPOSITORY KEEPS ITS OWN CARVEOUTS.
//
// The ancestor branch above exists so Zero does not synthesize a control
// directory inside somebody else's repository. It was asked unconditionally,
// so a workspace that already HAS its own .git lost the hooks and config
// carveouts too — and those are its own metadata, not the ancestor's. Nothing
// compensated: workspaceGovernedByAncestorRepository answers the same question
// by testing for a local .git, so it reported this workspace as not governed
// and the initialization refusal never fired. Commands then received plain
// workspace write access over a live .git/config and .git/hooks, which is
// exactly the configuration that decides what git executes. Reported by
// @jatmn.
//
// Asserted through the profile as well as the spec helper, because the spec
// list is only load-bearing if it reaches ReadOnlySubpaths.
func TestNestedWorkspaceOwningGitKeepsItsOwnCarveouts(t *testing.T) {
outer := t.TempDir()
if err := os.MkdirAll(filepath.Join(outer, ".git"), 0o700); err != nil {
t.Fatal(err)
}
workspace := filepath.Join(outer, "project")
if err := os.MkdirAll(filepath.Join(workspace, ".git"), 0o700); err != nil {
t.Fatal(err)
}
wantHooks := filepath.Join(workspace, ".git", "hooks")
wantConfig := filepath.Join(workspace, ".git", "config")
specs := gitMetadataWriteCarveoutSpecs(workspace)
foundHooks, foundConfig := false, false
for _, spec := range specs {
switch {
case spec.Path == wantHooks && !spec.IsFile:
foundHooks = true
case spec.Path == wantConfig && spec.IsFile:
foundConfig = true
}
}
if !foundHooks || !foundConfig {
t.Fatalf("a workspace owning its own .git lost its carveouts: hooks=%v config=%v specs=%+v",
foundHooks, foundConfig, specs)
}
// The specs only matter if they reach the profile the runner enforces.
profile := PermissionProfileFromPolicy(workspace, DefaultPolicy(), nil)
var root *WritableRoot
for index := range profile.FileSystem.WriteRoots {
if sameGitTestPath(profile.FileSystem.WriteRoots[index].Root, workspace) {
root = &profile.FileSystem.WriteRoots[index]
break
}
}
if root == nil {
t.Fatalf("no writable root for the workspace: %+v", profile.FileSystem.WriteRoots)
}
for _, want := range []string{wantHooks, wantConfig} {
covered := false
for _, sub := range root.ReadOnlySubpaths {
if sameGitTestPath(sub, want) {
covered = true
break
}
}
if !covered {
t.Errorf("%s is not read-only in the workspace write root: %+v", want, root.ReadOnlySubpaths)
}
}
}
// Compared through the profile's own canonicalization. PermissionProfileFromPolicy
// stores normalizeProfilePath(root), which resolves symlinks, so a raw t.TempDir()
// path does not match it on a runner where the temp directory is reached through
// one: macOS /var -> /private/var, and the Windows short-name expansion of the
// runner's profile directory. Comparing cleaned strings passed locally and failed
// on both CI runners for that reason alone.
func sameGitTestPath(a, b string) bool {
return normalizeProfilePath(a) == normalizeProfilePath(b)
}
@@ -0,0 +1,119 @@
//go:build windows
package sandbox
import (
"os"
"path/filepath"
"testing"
)
func pointerWorkspace(t *testing.T) (workspace string, pointer string) {
t.Helper()
workspace = t.TempDir()
pointer = filepath.Join(workspace, ".git")
if err := os.WriteFile(pointer, []byte("gitdir: ../real/.git/worktrees/w\n"), 0o600); err != nil {
t.Fatal(err)
}
return workspace, pointer
}
func pointerPlanConfig(t *testing.T, workspace string) WindowsSandboxCommandConfig {
t.Helper()
return WindowsSandboxCommandConfig{
SandboxHome: t.TempDir(),
CommandCWD: workspace,
WorkspaceRoots: []string{workspace},
PermissionProfile: PermissionProfile{
FileSystem: FileSystemPolicy{
Kind: FileSystemRestricted,
WriteRoots: []WritableRoot{{
Root: workspace,
ReadOnlySubpaths: gitMetadataWriteCarveouts(workspace),
ProtectedMetadataNames: sandboxFullyProtectedMetadataNames,
}},
},
},
}
}
// THE SHAPE HAS TO SURVIVE THE STAGE BOUNDARY, NOT JUST BE CORRECT AT STAGE ONE.
//
// gitMetadataWriteCarveoutSpecs types a linked worktree's .git as a file. That
// typed result was flattened to a path before ACL planning, and the planner tried
// to recover the shape by deriving suffixes under a sentinel root whose .git never
// exists. The derivation therefore always took the directory branch, and a bare
// <root>\.git could never match as a file.
//
// The window that turns a wrong plan into damage is the caller-to-elevated-helper
// gap: the profile is built in the user's shell and the plan is applied in a
// separately launched helper, across the UAC prompt. If the pointer goes away in
// between, the applier materializes a DIRECTORY at the pointer path and the
// worktree is broken, persistently, because the runner discards the rollback.
//
// Driven through BuildWindowsACLPlan and applyWindowsACLPlan with the pointer
// removed after planning, which is the only place this is visible. A stage-one
// assertion on specs[0].IsFile passed throughout the whole time the bug was live.
func TestALinkedWorktreePointerIsNeverMaterializedAsADirectory(t *testing.T) {
workspace, pointer := pointerWorkspace(t)
plan, err := BuildWindowsACLPlan(pointerPlanConfig(t, workspace))
if err != nil {
t.Fatalf("BuildWindowsACLPlan: %v", err)
}
// SETUP: the plan really does carry the pointer, typed as a file.
var found bool
for _, entry := range plan.Entries {
if entry.Path == pointer && entry.Action == WindowsACLDenyWrite {
found = true
if !entry.MaterializeFile {
t.Fatalf("the plan types %s as a directory; applying it would replace the worktree pointer with a directory", pointer)
}
}
}
if !found {
t.Fatalf("SETUP INVALID: no deny-write entry for the pointer %s: %+v", pointer, plan.Entries)
}
// The window: the pointer goes away between planning and applying.
if err := os.Remove(pointer); err != nil {
t.Fatal(err)
}
if _, err := applyWindowsACLPlan(plan); err != nil {
t.Skipf("cannot apply an ACL plan here: %v", err)
}
info, err := os.Lstat(pointer)
if err != nil {
// Nothing recreated is also acceptable: what must not happen is a directory.
return
}
if info.IsDir() {
t.Fatalf("a directory was created at the worktree pointer %s, which breaks the worktree", pointer)
}
}
// Control: with the pointer present at apply, it stays a file and is not
// replaced. Without this, the assertion above could be satisfied by an apply
// that does nothing at all.
func TestALinkedWorktreePointerSurvivesAnApply(t *testing.T) {
workspace, pointer := pointerWorkspace(t)
plan, err := BuildWindowsACLPlan(pointerPlanConfig(t, workspace))
if err != nil {
t.Fatalf("BuildWindowsACLPlan: %v", err)
}
if _, err := applyWindowsACLPlan(plan); err != nil {
t.Skipf("cannot apply an ACL plan here: %v", err)
}
info, err := os.Lstat(pointer)
if err != nil {
t.Fatalf("the pointer vanished across the apply: %v", err)
}
if info.IsDir() {
t.Fatalf("the pointer at %s became a directory", pointer)
}
body, err := os.ReadFile(pointer)
if err != nil || len(body) == 0 {
t.Fatalf("the pointer lost its contents: body=%q err=%v", body, err)
}
}
+195 -14
View File
@@ -96,28 +96,209 @@ var protectedMetadataNames = []string{".git", ".zero", ".agents"}
// gitMetadataWriteCarveouts below.
var sandboxFullyProtectedMetadataNames = []string{".zero", ".agents"}
// gitMetadataWriteCarveouts returns the .git paths that stay write-denied under
// the OS-level sandbox even though the rest of .git is writable to git
// subprocesses. Worktrees and submodules store .git as a regular pointer file,
// so they protect that file itself: constructing .git/hooks below it makes
// bwrap fail before the command starts. All other entry and error states retain
// the legacy child paths. The pointer is deliberately not parsed here;
// repository-controlled metadata must not redirect sandbox rules elsewhere.
// sandboxRenameProtectedMetadataName is the metadata directory that cannot be
// fully write-protected but must still not be REPLACEABLE.
//
// It is deliberately not in the list above. That list denies write, and git has
// to write index, objects and refs. But the carveouts guarding it are attached
// to .git/config and .git/hooks as objects, so a principal that renames .git and
// recreates it gets fresh paths inheriting the workspace allow with no denies,
// which restores credential.helper and core.hooksPath. The Windows ACL plan
// therefore denies DELETE on this directory alone, uninherited.
const sandboxRenameProtectedMetadataName = ".git"
// gitMetadataWriteCarveouts returns the .git subpaths that stay write-denied
// under the OS-level sandbox even though the rest of .git is writable to git
// subprocesses. Nonexistent paths are harmless no-ops in every backend's
// enforcement (seatbelt regex, bwrap ro-bind, Windows ACL deny entry).
func gitMetadataWriteCarveouts(root string) []string {
return gitMetadataWriteCarveoutsWithLstat(root, os.Lstat)
}
// gitMetadataWriteCarveoutsWithLstat is the seam the worktree tests drive.
// Which carveouts apply is decided by the SHAPE of .git, and a test cannot
// create every shape it needs to cover: an lstat that fails is a real state
// (a racing checkout, a permission error) with its own required answer, and
// there is no filesystem it can be staged on.
func gitMetadataWriteCarveoutsWithLstat(root string, lstat func(string) (os.FileInfo, error)) []string {
specs := gitMetadataWriteCarveoutSpecsWithLstat(root, lstat)
out := make([]string, 0, len(specs))
for _, spec := range specs {
out = append(out, spec.Path)
}
return out
}
// gitMetadataCarveout is a write-denied .git path together with the shape git
// expects it to have. The shape matters to exactly one backend: the Windows ACL
// plan creates a missing carveout so the deny ACE is in place before git first
// runs, and creating .git/config as a directory makes `git init` fail outright.
// Every other backend only ever names the path, so it can ignore IsFile.
type gitMetadataCarveout struct {
Path string
IsFile bool
}
// gitMetadataWriteCarveoutSpecs is the single source of truth for the carveout
// set. gitMetadataWriteCarveouts derives its list from this so a path can never
// be added in one place and have its shape forgotten in the other.
func gitMetadataWriteCarveoutSpecs(root string) []gitMetadataCarveout {
return gitMetadataWriteCarveoutSpecsWithLstat(root, os.Lstat)
}
func gitMetadataWriteCarveoutSpecsWithLstat(root string, lstat func(string) (os.FileInfo, error)) []gitMetadataCarveout {
gitPath := filepath.Join(root, ".git")
children := []string{
filepath.Join(gitPath, "hooks"),
filepath.Join(gitPath, "config"),
// A LINKED WORKTREE OR SUBMODULE HAS .git AS A FILE, NOT A DIRECTORY.
//
// It holds a `gitdir:` pointer to the real control directory, which lives
// outside this write root. Naming .git/config and .git/hooks there asks the
// Windows plan to materialize them by descending through .git as a directory,
// which cannot open a child beneath a regular file, so opted-in elevated setup
// aborts and the sandbox is unusable in any worktree. Zero's own development
// worktrees are exactly this shape.
//
// Deny the pointer file itself instead. It is the right protection rather than
// a lesser one: a principal that can rewrite `gitdir:` repoints the whole
// repository at a control directory it chooses, which subsumes editing config
// or dropping a hook. The real control directory is outside the write root, so
// the principal has no inherited access to it and needs no carveout there.
//
// Stat, not a lexical guess. Whether .git is a file is a property of the
// checkout, and the two layouts want different ACEs. A missing .git (git has
// not run yet) keeps the directory-shaped carveouts, which is what makes them
// materialize before git first runs.
//
// A REGULAR FILE, NOT MERELY A NON-DIRECTORY. The pointer this recognises is
// the `gitdir:` file git writes for a linked worktree or submodule. A symlink
// named .git, dangling or not, and a special entry such as a fifo are neither
// that nor a checkout git will use, so they keep the legacy child carveouts
// rather than being handed the stronger single-object protection on the
// strength of not being a directory.
gitInfo, gitErr := lstat(gitPath)
if gitErr == nil && gitInfo.Mode().IsRegular() {
return []gitMetadataCarveout{{Path: gitPath, IsFile: true}}
}
info, err := lstat(gitPath)
if err != nil || !info.Mode().IsRegular() {
return children
// EVERY WRITE ROOT GETS THE CARVEOUTS, INCLUDING ONE INSIDE ANOTHER REPOSITORY.
//
// The directory-shaped carveouts below are materialized by the Windows plan so
// the deny ACE is in place before git first runs. For a while this branch
// skipped them when an ancestor owned the git metadata, on the argument that
// creating .git/config and .git/hooks there synthesizes a control directory
// competing with the ancestor's for git's discovery walk. Measured against the
// git this repository builds against, it does not: a .git holding only config
// and hooks is not a valid repository, so `rev-parse --show-toplevel` still
// answers with the ancestor, `status` still works, and a core.fsmonitor
// written into that config is never executed. The test named below drives real
// git and fails if that ever stops being true.
//
// Skipping them cost much more than it saved. With no carveout on this root
// nothing denies writes to <root>/.git at all, so a sandboxed command can
// assemble a repository there by hand, with mkdir and a few file writes and
// not one git command among them, and put core.fsmonitor in its config. Zero
// then runs `git -C <workspace> status` OUTSIDE the sandbox (scratch_files.go,
// internal/repoinfo, internal/zerogit) and git executes it. The nested-repository
// refusal in risk.go does not cover that and cannot: no static reading of a
// shell command enumerates the ways to create a directory. It stays as the
// layer that explains itself to an operator, not as the boundary.
//
// So the boundary is here, where it is enforced at write time: config and
// hooks are denied on every write root, whatever owns the metadata above it.
return []gitMetadataCarveout{
{Path: filepath.Join(root, ".git", "hooks")},
{Path: filepath.Join(root, ".git", "config"), IsFile: true},
}
return []string{gitPath}
}
// workspaceGovernedByAncestorRepository is the same question the carveouts ask,
// named for the caller that asks it about a whole workspace rather than about
// one write root. Sharing the rule is the point: the reason a workspace gets no
// carveouts is exactly the reason it may not create a repository.
func workspaceGovernedByAncestorRepository(workspaceRoot string) bool {
cleaned := strings.TrimSpace(workspaceRoot)
if cleaned == "" {
return false
}
if _, err := os.Lstat(filepath.Join(cleaned, ".git")); err == nil {
// It already owns git metadata, so setup protected it and nothing here is
// being created inside a repository that is not this workspace's.
return false
}
return gitMetadataGovernedByAncestor(cleaned)
}
// gitMetadataGovernedByAncestor reports whether an ancestor of root carries git
// metadata, which is git's own discovery rule: the nearest ancestor with a .git
// entry owns this directory.
//
// Lstat rather than a resolved walk, because a .git of either shape answers the
// question: a directory is an ordinary repository root and a file is a linked
// worktree or submodule pointer. Both mean the metadata is not ours to create.
func gitMetadataGovernedByAncestor(root string) bool {
current := filepath.Clean(strings.TrimSpace(root))
if current == "" || current == "." {
return false
}
for {
parent := filepath.Dir(current)
if parent == current {
return false
}
if _, err := os.Lstat(filepath.Join(parent, ".git")); err == nil {
return true
}
current = parent
}
}
// gitMetadataCarveoutSuffixBase is a sentinel root used only to recover the
// trailing segments of the carveout specs. It is never touched on disk.
const gitMetadataCarveoutSuffixBase = string(filepath.Separator) + "zero-carveout-base"
// gitMetadataCarveoutIsFile reports whether path names a carveout git expects
// to be a file.
//
// It matches on the trailing segments rather than on a whole reconstructed
// path. The subpaths reaching the ACL plan are already normalized — resolved
// through EvalSymlinks where that succeeds — while a rebuilt spec path cannot
// be, because .git/config does not exist yet at setup and resolution falls back
// to a plain Clean. On a host where two spellings of the same path differ (an
// 8.3 short name, different casing) a whole-path equality check silently misses
// and the carveout is created as a directory again, which is the original bug
// reintroduced quietly. The suffix cannot drift from the spec list because it
// is derived from it.
func gitMetadataCarveoutIsFile(path string) bool {
candidate := strings.ToLower(filepath.Clean(strings.TrimSpace(path)))
if candidate == "" {
return false
}
// A BARE .git CARVEOUT IS THE LINKED-WORKTREE POINTER, AND ONLY THAT.
//
// The suffix derivation below runs specs against a sentinel root whose .git
// never exists, so it always takes the directory branch and yields exactly one
// file-shaped suffix, .gitconfig. A bare <root>.git could therefore never
// match, and both planners emitted MaterializeFile:false for a path the profile
// stage had already typed correctly as a file. With the pointer absent at apply
// time, the plan then created a DIRECTORY at the pointer path and broke the
// worktree.
//
// The carveout SET already carries the answer. gitMetadataWriteCarveoutSpecs is
// the only producer of ReadOnlySubpaths, and it emits a bare .git in exactly one
// case: the linked-worktree or submodule pointer. The directory case emits
// .githooks and .gitconfig and never the parent. So the shape is not being
// guessed from a pathname here, it is being read off which carveout this is.
if strings.EqualFold(filepath.Base(candidate), ".git") {
return true
}
for _, spec := range gitMetadataWriteCarveoutSpecs(gitMetadataCarveoutSuffixBase) {
if !spec.IsFile {
continue
}
suffix := strings.ToLower(strings.TrimPrefix(spec.Path, gitMetadataCarveoutSuffixBase))
if suffix != "" && strings.HasSuffix(candidate, suffix) {
return true
}
}
return false
}
func PermissionProfileFromPolicy(workspaceRoot string, policy Policy, scope *Scope) PermissionProfile {
+79
View File
@@ -0,0 +1,79 @@
package sandbox
import (
"os"
"path/filepath"
"strings"
"testing"
)
// A LINKED WORKTREE HAS .git AS A FILE.
//
// The Windows principal plan materializes every carveout, and it does so by
// descending through .git as a directory. Against a `gitdir:` pointer file that
// walk cannot open a child, so opted-in elevated setup aborts and the sandbox
// cannot be used in a worktree at all. Zero's own development worktrees have
// exactly this layout, so this is the common case for anyone working on the
// sandbox itself.
func TestGitCarveoutsHandleAWorktreeGitfile(t *testing.T) {
root := t.TempDir()
gitFile := filepath.Join(root, ".git")
// The real shape git writes for a linked worktree.
if err := os.WriteFile(gitFile, []byte("gitdir: "+filepath.Join(root, "..", "main", ".git", "worktrees", "wt")+"\n"), 0o600); err != nil {
t.Fatalf("WriteFile: %v", err)
}
specs := gitMetadataWriteCarveoutSpecs(root)
if len(specs) != 1 {
t.Fatalf("carveout specs = %#v, want only the .git pointer file", specs)
}
if filepath.Clean(specs[0].Path) != filepath.Clean(gitFile) {
t.Fatalf("carveout path = %s, want the .git pointer file %s", specs[0].Path, gitFile)
}
if !specs[0].IsFile {
t.Fatal("the .git pointer carveout is not marked as a file, so materialization would create a directory where git needs a file")
}
// Nothing may name a path BENEATH the pointer file, which is what forces the
// directory descent that aborts setup.
for _, path := range gitMetadataWriteCarveouts(root) {
rest := strings.TrimPrefix(filepath.Clean(path), filepath.Clean(gitFile))
if rest != "" {
t.Errorf("carveout %s descends through the .git pointer file, which cannot have children", path)
}
}
}
// The ordinary repository layout must keep the config and hooks carveouts. They
// are the whole reason the set exists: without them a principal reinstates
// credential.helper or core.hooksPath through inherited workspace write access.
func TestGitCarveoutsKeepConfigAndHooksForARealGitDir(t *testing.T) {
root := t.TempDir()
if err := os.MkdirAll(filepath.Join(root, ".git"), 0o700); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
paths := gitMetadataWriteCarveouts(root)
for _, want := range []string{filepath.Join(root, ".git", "hooks"), filepath.Join(root, ".git", "config")} {
found := false
for _, got := range paths {
if filepath.Clean(got) == filepath.Clean(want) {
found = true
break
}
}
if !found {
t.Errorf("carveout %s missing for an ordinary .git directory: %#v", want, paths)
}
}
}
// A workspace where git has never run keeps the directory-shaped carveouts. That
// is what lets the Windows plan create them before git first runs, so the deny
// ACEs are already in place rather than being applied to paths git will create
// later with inherited write access.
func TestGitCarveoutsKeepConfigAndHooksWhenGitIsAbsent(t *testing.T) {
root := t.TempDir()
paths := gitMetadataWriteCarveouts(root)
if len(paths) != 2 {
t.Fatalf("carveouts = %#v, want the config and hooks pair when .git is absent", paths)
}
}
Loaded 100 of 266 files, more files were not shown because too many files have changed in this diff. Show more