* test(tui): pin the cwd in tests that assert text is absent from the view
Nine tests built their model with an empty Options.Cwd, so newModel took
the package directory and the title bar showed the developer's real
branch and checkout path. Each then asserted that some text was absent
from the whole view, and failed wherever that branch or path happened to
contain it: TestSuggestionOverlayRenders on a branch with /model in its
name, TestSuggestionOverlayCapsRowsWithoutMoreText on a branch or path
containing "more" or "compact", the provider wizard tests on "offline",
"chatgpt" or "gpt-4.1", and so on.
They now pin Options.Cwd to isolatedCwd, a path that does not exist, so
no branch is read and the path is the same on every machine. A guard
test checks that isolatedCwd stays outside any git checkout.
* test(tui): make isolatedCwd fully qualified and absent by construction
The fixed /workspace/zero path could exist on some machine, a dev
container for one, and newModel would then read whatever is there. On
Windows it was also only drive-relative: \workspace\zero is not a fully
qualified path.
isolatedCwd is now a child of the home TestMain isolates that nothing
creates, so it is fully qualified, absent and outside any checkout on
every platform, and the title bar shows ~/not-a-checkout everywhere. A
t.TempDir path would have put the user name and the test name into the
view. The guard test checks all three properties.
* test(tui): clear the branch in view-test models instead of trusting the cwd
isolatedCwd is a child of the isolated home, which lives under the
temporary directory. With TMPDIR inside a checkout, gitBranch walks up
from that absent path to the checkout and puts its branch in the title
bar, so the guard failed on an ordinary branch and a colliding branch
broke the nine view tests again.
newViewTestModel now builds those models: it sets the isolated cwd and
clears the branch after construction, since no path can promise there
is no checkout above it. The guard keeps only what a path can promise
(fully qualified and absent), and a new test puts the home inside a
checkout on feat/model-picker and checks that neither that branch nor
the real checkout path reaches the view.
* test(tui): assert no checkout path reaches the view in the enclosing-checkout test
The test checked the branch strings and that the isolated cwd shows,
but a view could show both the isolated cwd and a real checkout path
without failing it. It now also asserts that neither the enclosing
checkout nor the directory the tests run from appears in the view.
The variable turns off both the models.dev fetch and the cached overlay,
and four tests in modelsdev_test.go inherited it from the shell. With it
set, TestRefreshModelsDevCacheRejectsBadBodyWithoutClobbering and
TestDefaultModelEntriesAppliesFreshCache fail, and
TestCachedModelsDevProvidersIgnoresStaleCache and
TestCachedModelsDevProvidersRequiresOptIn pass without reaching the
check they are named for: the nil they expect comes from the variable.
Clear it in each of them, as the two sibling refresh tests already do.
GHSA-5r4p-p66f-jhc7 (moderate): hono's serveStatic decodes the request
path a second time, so middleware on static paths can be bypassed.
Versions 4.12.4 to 4.13.10 are affected and main pins 4.13.9 through
tuistory, which release packaging installs as a local-control helper.
npm update takes hono to 4.13.13, inside every range that asks for it.
package.json is unchanged and nothing else in the lock moves.
* fix(oauth): use the crash-safe secret store and sync token publications
The encrypted-file OAuth backend carried its own AES-GCM secret implementation that had drifted from internal/securefile: a crash during first login left <secret>.lock behind and every later Save timed out instead of reclaiming it, and the secret and token blobs were published without fsync.\n\nUse securefile.Crypter with an opt-in publication directory so the secret still publishes through the sandbox-denied .secret.publish directory, add file and directory barriers to the encrypted token blob, and extend securefile with the publication directory plus destination-directory sync.\n\nMove the concurrent-creation and wrong-sized-secret coverage to securefile, add a stale-lock regression through the OAuth store, and test publication-dir use, sync ordering, sync-failure preservation, and retry after a failed secret sync. The stale-lock test fails on the previous code.
* test(oauth): cover post-rename directory-sync failures
The existing failure tests inject a file sync error before the rename. Add coverage for a destination-directory barrier failure after the token or secret has been atomically replaced: the write must report the error and the newly published bytes must remain readable. Also cover the corresponding securefile secret path, including its expected size.
* fix(securefile): never clobber a newer lock when restoring a reclaimed one
reclaimStaleLock could rename a lock that had just been replaced, then restore the quarantined copy with os.Rename, replacing a third holder's new lock. Both holders could then publish different secrets for the same file. Restore with O_CREATE|O_EXCL and discard the quarantined copy when the destination is already held.
Extend the directory-sync failure test to prove a later caller actually loads the published secret, not just its size. Add restore tests for the free and already-held cases.
govulncheck now reports GO-2026-6809 (crypto/x509 distrusts compromised
intermediate certificates) against Go 1.26.9, reachable through
daemon.WriteFrame, so Security & Code Health fails on main and on every
open PR. It is fixed in 1.26.10.
CI and release builds read the toolchain from go.mod. The stated
source-build minimum in README.md, README_ZH.md and docs/INSTALL.md
moves with it.
runExec starts a background models.dev download unless
ZERO_DISABLE_MODELS_FETCH is set. TestRunExecImageAttachmentHistory moved
the user directories into a TempDir by hand but left the fetch on, so a
5.7 MB download landed in the TempDir while its cleanup ran, and the
macOS runner failed with "unlinkat ... directory not empty". The two
older image tests had no isolation and fetched into the real user cache.
Use isolateCLIUserState in all three, which also points the cache path
into the fixture and turns the fetch off.
* fix(tools): grep stops where a file's line breaks stop
scanGrepFile treated EOF after a trailing line break - and an empty file -
as the START of another line, and handed the matcher an empty line one
number past the real content. Every pattern that matches an empty string
then reported a match on a line no reader sees:
a.txt holds "x\n"; grep pattern "^$"
-> a.txt:2: (grep -c '' GNU-style reports no such line)
same file, pattern ".*", count mode
-> "2 matches found" (one real line, one phantom)
an empty file, pattern "^$"
-> a.txt:1: (the file has no lines at all)
read_file and the file tracker's countLines already state the rule the
scan violated: "the trailing newline does not open a line", and an empty
file has no lines. The scan now stops there too, so anchored and
empty-matching patterns can only match the lines the content carries.
Real matches, numbering, count-per-hit accounting for non-empty patterns,
file mode, glob and symlink handling are unchanged.
* test(tools): make the grep line-number parity test catch the phantom
The parity test matched only "needle", a pattern that cannot match the
empty line the old EOF branch invented, so its absence-of-line-4
assertion passed on the code being fixed. The pattern now also matches an
empty line, and the expected output is asserted exactly: on the pre-fix
scanner the same run reported
grep = "a.txt:2: needle two\na.txt:4: ",
want exactly the line read_file numbers 2
so the phantom is visible right beside the real line, and the test is
about the numbering claim it states. Also gofmt-aligns the case table
that made the CI formatting check fail.
Two credentials glued end to end share one recovering property: the second
one's trigger prefix is where redaction can re-anchor. For fixed-length
bodies that anchor is exact (AKIA{16} stops at the next key), which is the
case the anchored end-chaining always covered. For unbounded bodies it
isn't: github's [A-Za-z0-9]{36,} body eats the literal "ghp" of the token
glued behind it, so the first match ends PAST the second trigger, the scan
that re-anchors at the span end finds nothing there, and the second
credential leaves the output as +_bbbb... / .eyJzdWIi... plaintext:
RedactString("ghp_aaa..36a..ghp_bbb..36b..")
-> "[REDACTED]_bbbb......" (second token's body printed in full)
RedactString(jwtA+jwtB)
-> "[REDACTED].eyJzdWIiOiI5ODc2...SA" (second token's payload+sig)
The same holds across shapes (a github key directly feeding "sk-ant-..."
through its body) and for three tokens in one run.
redactAdjacent now follows the run a match starts: at each boundary it
re-binds the match at the EARLIEST trigger occurrence inside the span that
stills validates as its shape (the head before the trigger must match the
anchored pattern exactly, and the run chained from the trigger must reach
past the span, so a mid-body lookalike and a mid-span cut that recovers
nothing leave the span alone), and continues under whichever shape matched
the swallowed trigger. Candidate examination is capped at
maxTriggerOccurrences per span so body spam of trigger lookalikes costs
what the span costs, and the first validating cut wins.
End-chaining for fixed-length bodies, single-credential shapes, custom
replacement templates, and the split path's announced no-op-for-contiguous
text guarantee are all unchanged; the regression tests pin the glued pairs
that previously leaked plus the AWS chaining and lone-token cases.
This branch marks PermissionModeUnsafe deprecated, so staticcheck reports
SA1019 at every use outside the agent package, including three in cli and
one in a test that came in from main. The alias is the same value, so
nothing behaves differently.
#1149 was opened before #1105 moved the module to github.com/Twigpine/zero
and merged right after it, so its two imports of internal/gitsafe still
used the old path. Nothing provides github.com/Gitlawb/zero any more, so
main stopped building at 2ffe1831.
* fix(sessions): preserve image attachment summaries on resume
Persist TUI-compatible attachment counts for ACP and exec user events.
Restore readable markers in live history, session replay and resumed
prompts without storing or resending historical image data.
Cover image-only and mixed prompts, restart recovery, malformed metadata
and the aborted-turn boundary with regression tests.
Fixes#1135
* fix(workspaceindex): follow the current module path
Use github.com/Twigpine/zero/internal/gitsafe in the ignore scanner and its
tests. The upstream module rename preceded the scanner's gitsafe change,
leaving these two imports on the old path and preventing the rebased tree
from compiling.
* fix(sessions): count only images accepted by the model
Take ACP and exec attachment counts after the vision capability gate,
matching the TUI's persisted attachment semantics. Update the ignored-image
regressions to require zero saved images and no historical image marker.
Refs #1135
main moved the module to github.com/Twigpine/zero in #1105. Rename this
branch's imports to match, including the two in internal/workspaceindex
that main itself still carries until #1153 lands.
* fix(workspaceindex): run Scan's git calls through gitsafe
Scan asks git for ignored paths on every turn with its own gitCommand, which
turned off core.fsmonitor and dropped the repository-location variables but
still let a clean filter or diff driver from the repository's own config run
during status. It now goes through gitsafe, like the TUI and the scratch-file
warning: hooks are off as well, and a repository whose config defines a filter
or diff command takes the non-git path. The status call also passes
--ignore-submodules=dirty.
Scan makes two git calls, so gitsafe gains Check, which reads the repository
config once and builds several commands from it. Command is now Check plus one
command, and each command gets its own copy of the environment.
* test(workspaceindex): pin Scan's fallback when gitsafe refuses the repository
A repository whose own config defines a filter or diff command is refused by
gitsafe, and Scan then takes the non-git path. Scan reaches gitsafe.Check
through a package variable now, so a test can stand in for such a repository
without configuring one that names a command, and
TestScanFallsBackWhenGitsafeRefusesTheRepository checks both sides: with git
consulted, .gitignore decides and the fixed list does not apply; refused, the
fixed list applies and .gitignore is not read.
Update the Go module path, imports, install scripts, docs, workflows,
packaging and action defaults from Gitlawb/zero to Twigpine/zero. The npm
scope (already @twigpine/zero on main, with its @gitlawb/zero migration
notes), LICENSE, action author, the OpenGateway provider and historical
CHANGELOG links are unchanged.
The X-AIMLAPI-Integration-Repo header keeps Gitlawb/zero: it is what
AIMLAPI sees for attribution, so changing it needs a maintainer's yes.
docs/INSTALL.md keeps `go install github.com/Gitlawb/zero/...`, because
every tagged release so far declares that module path. The Homebrew
formula comment names the Twigpine/homebrew-tap tap.
Co-authored-by: Claude Opus 5.5 <[email protected]>
* feat(sandbox): support self-exec pattern to eliminate separate helper binary
Linux sandboxing previously required distributing a separate 'zero-linux-sandbox'
binary and looking it up on PATH.
This wires an internal '__sandbox-helper' entrypoint directly in cmd/zero/main.go
and configures findLinuxSandboxHelperCommand to fallback to os.Executable(),
enabling single-binary zero distributions with fully functional sandboxing.
* fix(sandbox): prefer PATH helper before self-exec fallback
Colocated zero-linux-sandbox stays first. A standalone helper on PATH
is used next. Self-exec of the main zero binary is only the last
fallback, so an installed helper is not ignored.
* feat(sandbox): clean formatting in main.go and add self-exec unit tests
* feat(sandbox): insert __sandbox-helper verb in bwrap plan for self-exec binary
* fix(sandbox): drive Linux selection from the self-exec helper resolver
Availability and command-plan construction now share the same helper
resolution and keep the __sandbox-helper prefix on the backend, so a
main-only install with bwrap reaches native sandboxing.
* fix(sandbox): self-exec only the zero main binary, not test binaries
* fix(sandbox): carry standalone vs self-exec identity into the inner bwrap plan
A versioned physical helper (zero-linux-sandbox-v1) must not receive
__sandbox-helper. The inner plan uses an explicit SelfExec flag, threaded
from argv, instead of guessing from filepath.Base.
* docs(install): describe self-exec as the last helper fallback
Self-execution runs only when neither a colocated nor a PATH
zero-linux-sandbox is available.
* docs(install): state colocated, PATH, then self-exec helper order
---------
Co-authored-by: cl-ment <[email protected]>
* perf(redaction): add fast-path byte checks to eliminate regex allocations on clean strings
Evaluating cascading regular expressions across every log line, tool output,
and session event incurred ~23 µs and 51 heap allocations even on strings with
zero secrets.
This introduces fast-path substring checks before invoking expensive regex
substitutions, yielding a 70x speedup and 0 B/op heap allocation on clean
strings.
* test(redaction): lengthen ghp_ fixtures and cover conditional gates
GitHub classic tokens need 36 characters after ghp_. Benchmarks and
shape tests now use a matching fixture. A table-driven test checks each
fast-path gate, including near-misses and case-insensitive Authorization.
* perf(redaction): structure triggered patterns and avoid allocation in header case checks
* test(redaction): cover secret-header and URL-password gates
Keep the redaction marker literal after url.URL rewrites userinfo, so
callers still see [REDACTED] instead of a percent-encoded form.
* fix(redaction): unescape only the generated URL password
A path or query that already contains the encoded redaction marker
must stay encoded. Only the userinfo password is rewritten in the
clear [REDACTED] form.
* fix(redaction): re-escape URL username after parse
Username() is decoded. Re-encoding with url.User keeps control bytes
out of the redacted URL so a %0AAuthorization userinfo cannot inject
a header after the header pass.
* fix(redaction): fail closed on password URLs without a host
* test(redaction): isolate query-gate coverage from assign and text secrets
* fix(redaction): fold Unicode U+017F and U+212A in containsCaseInsensitive
Go (?i) regular expressions apply Unicode simple case folding, where U+017F (ſ) folds to 's' and U+212A (K) folds to 'k'. Teach containsCaseInsensitive to fold these runes to ASCII lowercase with zero allocations so case-insensitive header gates cannot be bypassed by Unicode-folded equivalents.
---------
Co-authored-by: cl-ment <[email protected]>
* feat(providers): add Concentrate provider preset
Register Concentrate (https://api.concentrate.ai/v1) as an
OpenAI-compatible catalog provider. Concentrate is already listed in the
README Partners section; this adds the provider itself.
- models.dev has no Concentrate provider, so the id `concentrate` and the
key name CONCENTRATE_API_KEY match OpenClaude's Concentrate
integration. `Concentrate AI`, `concentrate.ai` and `concentrate-ai`
resolve to it.
- Default model deepseek-v4-flash-0731: the dated id behind the
deepseek-v4-flash alias that OpenClaude uses. Concentrate's /v1/models
keeps the rolling name only in `aliases`, and onboarding keeps the
default selected only when it matches a live id, so an alias default
made setup land on the first model in the list (claude-fable-5).
- Curated models: deepseek-v4-flash-0731 (default), deepseek-v4-pro,
kimi-k3 and glm-5.3. Context windows (1,048,576 each), tool calling and
thinking support come from Concentrate's public /v1/models.
- Regression test: a Concentrate-shaped live list must contain the
preset's default model.
- README and README_ZH: key line and setup command.
* test(providers): prove the Concentrate default check runs on the live list
The regression test passed whenever the default model was in the result,
and the curated fallback also contains it. Assert that the mock served
/v1/models and that the result includes claude-fable-5, a model only the
live response lists, so a silent fall back to the curated catalog fails.
* fix(cli): let providers subcommands run with no active provider
`zero providers list`, `use`, `remove` and `rename` exited 3 with
`no active provider configured: active provider "" not found` as soon as the
environment supplied more than one provider and config.json had no active
provider: normalizeProvidersWithOptions only elects an active provider by
itself when there is exactly one, and resolveCommandCenterConfig treated the
resulting error as fatal, so the command stopped before doing anything. A
base URL without a key counts as a second provider too (applyProviderEnv
builds a profile from a base URL alone).
The resolver already returns the successfully normalized list alongside
ErrNoActiveProvider, and the interactive TUI already falls back to a usable
entry from it in runInteractiveTUIWithSetup. Do the same here instead of
failing, so these subcommands can answer for providers that only come from
the environment.
Also pin the four env-derived provider tests to a fixed environment and a
temporary user config. They set OPENAI_API_KEY but resolved through the real
resolver against the developer's own config.json and ambient provider
variables, so they failed on any machine that also exported another provider
variable and whose user config had no active provider.
Adds regression coverage for the two-env-provider case: `providers list`
must report every detected provider and `use`/`remove`/`rename` must give
their usual env-derived answer. Verified against the fix (pass) and with the
fix reverted (exit 3, `active provider "" not found`).
Fixes#1130
* refactor(cli): report the configured active provider, not an elected one
Provider-management subcommands answer for the SAVED configuration, while
the TUI's recovery path answers "what can I run with". Adopting
firstUsableProvider in resolveCommandCenterConfig conflated the two: with
two env-derived providers it made `providers current`/`list` present a
provider that `zero exec` would still refuse to run with, and a
base-URL-only profile failed `providers list` because the election demands
a credential. Return the resolver's partial config untouched instead, so
nothing is reported as active when nothing is selected.
Also removes the OAuth store read this path picked up with firstUsableProvider.
Adds assertions that `current` shows nothing selected, `list` marks nothing
active, `use --json` leaves activeProvider empty, plus a base-URL-only case.
* fix(cli): scope the no-active-provider exception to provider management
Returning the resolver's partial config made `zero config` exit 0 with a
partial summary, contradicting the resolver's documented headless contract:
`zero config` / `zero exec` still fail with the actionable message and only
the interactive TUI recovers from a missing active provider. Split the
helper so only issue #1130's commands — `providers list`/`current` and the
unpersisted `use`/`remove`/`rename` reporters — tolerate the state; `zero
config`, `detect`, `models` and `check` keep failing as before.
Also pin the OAuth store in providerEnvSetupDeps: summarizeConfig reads
OAuth login state through the default store, so a developer's stored login
could flip these assertions. Point ZERO_OAUTH_TOKENS_PATH at a temp file
and force ZERO_OAUTH_STORAGE=file so an ambient keyring setting cannot pull
the OS keychain in either.
* fix(workspaceindex): skip git-ignored and build-cache dirs in Scan only
Scan has a fixed file budget, and build output (Cargo target/, Python
virtualenvs, caches) could exhaust it before real source was reached:
repo-map reported truncation and ranked fingerprint files above code.
Inside a git work tree, Scan now asks git for the ignored set
(`git status --porcelain=v1 -z --ignored=matching .`), so the repo's own
.gitignore, nested ignore files, info/exclude and core.excludesFile all
apply, including names no fixed list anticipates (.locust_env/). Outside
a work tree, or when git is missing, older than 2.16, fails, or exceeds
5s, a fixed list of common build/cache directory names stands in.
ShouldSkipDir is unchanged: glob, grep, list_directory and path
autocomplete share it and must keep seeing target/, .venv/ and friends.
The lookup runs with GIT_OPTIONAL_LOCKS=0 so a per-turn scan never takes
.git/index.lock next to a concurrent git command.
Fixes#1108
* fix(workspaceindex): skip a rename's original-path field when parsing status
In `git status --porcelain=v1 -z`, a rename or copy record is followed by one
more NUL field holding the original path, with no XY prefix. The parser
inspected every field, so an original name beginning with "!! " read as an
ignored entry: after `git mv "!! a" b`, a tracked file "a" disappeared from
Scan.
Move the parsing into parseIgnoredStatus, which consumes that field for R or C
in either status column, and test it directly and end to end.
* fix(workspaceindex): disable repo fsmonitor and force untracked-files=normal for status
Scan's git status lookup must not run a repository's core.fsmonitor hook or fail
when status.showUntrackedFiles=no would reject --ignored=matching.
* fix(workspaceindex): ignore inherited git repository location
Git exports GIT_DIR and GIT_WORK_TREE to hooks. With those pointing at
another repository, Scan kept .locust_env/lib/x.py, which root's own
.gitignore excludes.
* fix(workspaceindex): quote the fsmonitor hook path in the regression
On Windows, sh treats an unquoted backslash path as escapes, so the hook
ran and the marker the test stats was never created. Require a status
without the override to create that marker first.
* fix(workspaceindex): pass the fsmonitor hook path to git with forward slashes
Smoke (windows-latest) failed TestGitIgnoredPathsDisablesRepoFsmonitor
because core.fsmonitor was set to a backslash script path. Git for Windows
hands that value to sh, which consumes the separators, so the hook never
runs and the marker is never created. The test now stores a quoted forward-slash
path. The product code is unchanged.
* fix(tui,cli): stop a repository's git config from running programs in Zero's own git calls
Zero runs git status and git diff on its own: the TUI's files sweep at
startup and after each command, its pull request diffstat, and the
scratch-file warning after a run. A repository's own config can name
programs those commands run (core.fsmonitor on every status, hooks when
the index is rewritten, clean filters and diff drivers assigned through
.gitattributes), so a workspace unpacked from an archive ran its own code
on the first turn, although Zero refuses the same workspace's project
hooks and plugins until the user trusts it.
internal/gitsafe builds those commands with core.fsmonitor and
core.hooksPath turned off and GIT_OPTIONAL_LOCKS=0, and drops the
repository-location variables git exports to hooks, using the same
environment for its config check and for the command. A repository whose
own config, or per-worktree config when extensions.worktreeConfig is on,
defines a filter or diff driver command is refused, and the callers
already treat that like a directory that is not a repository. The status
and diff calls also pass --ignore-submodules=dirty, because a submodule's
own config is not checked.
* test(gitsafe): keep the real-git setup off an inherited GIT_DIR
The setup helper in TestCommandAgainstRealGit ran git with the inherited
environment, so from a hook, or a shell that exports GIT_DIR or
GIT_WORK_TREE, git init and git config would act on the repository those
name instead of the temporary one. It now uses the same environment as
Command, and the test sets both variables to a decoy repository and checks
after each setup step that the decoy was not touched.
* fix(npm): wait for npm to apply each publish, and drop the one-time @gitlawb/zero steps
npm applies a publish asynchronously: `npm publish` returns once the upload
is accepted, and the version appears later. In the 0.10.0 release the
@twigpine/zero wrapper appeared 9 seconds after publish-packages.sh stopped
checking for it, and the last platform payload about ten minutes after its
upload. Two things went wrong:
- The wrapper became visible before four of its five platform payloads, so
installs in that window resolved aliases that 404 and fell back to the
GitHub-release download. Ordering the uploads does not order what npm
shows.
- The final `latest` check gave up after 25 seconds and failed the job even
though every publish landed, which also skipped the @gitlawb/zero steps
until a re-run.
publish-packages.sh now waits until npm shows every platform version before
it publishes the wrapper, then waits until `latest` is the wrapper version.
One deadline bounds the waits (ZERO_NPM_PUBLISH_WAIT_TIMEOUT, default 30
minutes). On timeout it fails before the wrapper goes out and says what npm
never showed. These lookups treat an error as "not yet", unlike the skip
decision, which still stops on anything but E404.
The test fake can now apply a publish only after a number of later lookups,
and records which aliased versions were missing when the wrapper was
published. Against the script as released in v0.10.0, the two new tests fail
the way the release did: the job exits 1 although everything landed, and the
wrapper goes out while its platform payload is not visible.
The two release steps that published 0.10.0 once more as @gitlawb/zero have
done their job and are removed; docs/NPM_PACKAGING.md and the comments that
pointed at them now describe what happened. The Go and wrapper handling of
the old name stays, since only a build installed under it can trigger it.
* fix(npm): validate the publish wait settings, and correct the legacy publishing claim
ZERO_NPM_PUBLISH_WAIT_TIMEOUT went into Bash arithmetic as typed. A leading
zero reads as octal (0600 waited 384 seconds), and 08 is an arithmetic error
that, in a script, does not stop it: Bash prints the error, leaves the
deadline unset and carries on, so the run only died at its first wait, after
the platform payloads were published. A negative timeout ended the wait
before it started, and a bad ZERO_NPM_PUBLISH_RETRY_DELAY only failed at the
first sleep, also after publishing began.
Both settings are now checked before anything is published: whole seconds
only, converted with 10# so a leading zero is decimal, bounded before the
conversion, and capped (timeout 86400, delay 600). A bad value fails with
the variable's name.
TestPublishPackagesReadsTheWaitTimeoutAsDecimal makes the script wait, which
is where the old code broke: against the reviewed script it publishes the
platform payload and then exits 1 on "08: value too great for base".
TestPublishPackagesRejectsAnInvalidWaitSetting covers -5, abc, 1e3, an
over-range and an overflowing timeout, and a non-numeric delay; the old
script published with five of them and crashed on the sixth with
"abc: unbound variable".
docs/NPM_PACKAGING.md said nothing could publish @gitlawb/zero again.
Removing the trusted publisher stops this workflow; a maintainer could
still publish by hand, and the doc now says that.
Conflicts in internal/acp/agent_test.go and
internal/cli/user_state_isolation_test.go were additions on both sides; both
are kept. Both sides also added the charmbracelet/x/ansi import to
internal/tui/view.go, which merged without a conflict marker but did not
compile; main's import, in the third-party group, is the one kept.
The offer made by shift+tab is valid only until the next user-visible input
transition, whatever delivers it. Paste, blur, mouse, the clipboard and
dictation all honoured that; peer delivery did not. A peer message raises an
approval prompt or lands in the inbox, a held message is released after a
mode sync, a status line or a peer error is appended to the transcript, an
approval expires and its prompt goes away. Each changes what the user is
looking at, so shift+tab, then peer traffic, then an unrelated ctrl+g still
entered full-auto with the confirmation several events behind them.
The offer is cleared for the whole peer message family at the dispatch
boundary in updateModel, not in the three handlers the report named. Those
were the ones that reproduced; the decision, expiry and error deliveries are
the same kind of transition and were armed the same way, and a rule written
per handler is a rule the next handler forgets.
isPeerDelivery is a closed list, and a test reads the package source for
every peer*Msg type and fails by name if one is not classified and not
driven. The regression sends one production message of each of the seven
types through Update, requires the offer gone and ctrl+g to leave the mode
alone, and then requires a fresh shift+tab to offer and confirm again, so it
cannot pass on a mode cycle that has become unreachable. The message that
needs approval also has to have raised its prompt. Reported by jatmn.
The footer reserves columns for a docked pet before it renders the status line,
so at 24 and 30 columns the offer came out truncated with no ctrl+g in sight
while ctrl+g still confirmed. The armed flag now means what it says only when
the footer, at its effective width after the reservation, shows the key: the
decision is made on the same truncation the footer applies, an offer that
cannot be shown is not raised, and a live offer is withdrawn when the terminal
shrinks under it. Pinned through the complete View at 24 and 30 columns with a
pet, with 30 pet-free and 96 with a pet as controls.
execListToolsFor supplied a temporary cwd and a mocked provider config, but
runWithDeps filled the rest with production dependencies, so --list-tools
refreshed the developer's models cache, started their MCP servers and could
touch their config before returning. The helper applies isolateCLIUserState
first, and the helper-isolation table now covers it.
Service.UpdateIdentity invokes the held-release handler synchronously for every
message that becomes deliverable, and the shell calls UpdateIdentity from inside
Update on the shift+tab and ctrl+g paths that change the permission class. The
handler called program.Send, which waits on the unbuffered message channel that
only the loop reads, and the loop was inside that Update: confirming full-auto
with a bypass-mode peer's message held froze the shell. Peer callbacks now
append to a FIFO drained by one goroutine, so the caller returns, order among
peer events is kept, and a late callback after exit returns on the cancelled
program context. The wiring is a function with a fake-able service so the
regression drives a real Bubble Tea program through the production shape.
Keeps both sides of the two model.go hunks: a mouse event and a paste still
answer a pending full-auto offer with not now, and then the terminal-attach
overlay from #1055 takes the event as main intends.
The offer renders through modeLabel into the left chip, and several statuses
replaced that chip wholesale: an active recording, a transcription, and a model
download. Arming while the mic was live therefore showed REC while ctrl+g still
committed full-auto, so the user was asked to confirm something the footer never
put in front of them, and permission prompts went away.
offerOwnsStatusChip makes a live offer outrank the statuses that merely report
what is happening, in both statusLine branches, since the tiny and non-tiny
paths reach the chip differently. The two confirmations that ask their own
question still win, because those are what the user just pressed a key for.
The confirm key consults the same rule through offerConfirmable, which takes the
armed flag as an argument because the handler captures and clears m.unsafeArmed
before its switch runs. Sharing one expression is the point: an offer that
cannot be displayed is not one that can be accepted, so a status added later
cannot reopen the gap by taking the chip.
The regression asserts the implication through the real renderer and the real
handler, matrixed over the contending states and three widths: if the mode
became full-auto, the frame shown before the key contained the offer. It also
asserts the converse, so a fix that merely made confirmation unreachable would
fail it.
Reported by @jatmn.
Splitting LocalServer out of the network classification also dropped localServerPrograms from commandUsesNetwork, so next build, vite build, nuxt generate and astro check came back Network=false. Those CLIs run the repository config, plugins and application code during a build as surely as during a serve, and on the platforms where the approval gate is the network boundary an ordinary shell grant would have run them without the separate network decision main applies. The test that pinned Network=false for builds is replaced.
Network stays conservative for the whole framework inventory; LocalServer alone carries the serve/build distinction. The framework classifier now walks the positions a subcommand can occupy instead of reading the first operand, so a global option before the subcommand (vite --config vite.prod.ts build) no longer makes a build count as a server.
Regressions: build-like and option-bearing forms are Network=true and LocalServer=false at the analyzer, serving forms are both, and Engine.Evaluate still returns the network prompt for a framework build after a bash allow grant. Reported by @gnanam1990.
Raised by CodeRabbit. Skipping flags is not enough, because it does not skip the
words they CONSUME: `npm --prefix ./web install` resolved to "./web", the
option's VALUE, and stopped being recognised at all — so a command genuinely
fetching from the network lost the approval its plain spelling gets.
The first fix offered a fixed window of two positions. That covers exactly ONE
value-taking option, and a second walks straight past it. Measured before this
change:
npm --prefix ./web install -> network (correct)
npm --prefix ./web --loglevel warn install -> NOT network
npm --registry https://r.test --prefix ./web install -> NOT network
pnpm -C ./web --filter web add left-pad -> NOT network
All four genuinely fetch.
ADJACENCY DECIDES NOW, because it is the actual grammar: an option can only
consume the word immediately after it. Every leading operand sitting right after
a flag might be that flag's value, so each opens another position; the first
operand NOT preceded by a flag cannot be anyone's value, and the window closes
there. The arity is counted rather than assumed, and no option is ever named —
which was the point of not enumerating them, since that is four tools each free
to add one.
The run has to be CONTIGUOUS, not a count of flags anywhere in the line.
Counting every flag lets TRAILING options widen the window back over words they
have nothing to do with, and three cases flip to false positives when it does:
npm run --grep foo --tag dev -> network AND localServer
npm run test --reporter x --filter dev -> network
npm run build --a x --b start -> network
The false positives the old cap protected against are unaffected, and for a
stronger reason than the cap gave: `npm run build --workspace dev` and `npm run
test -- --grep start` put an unflagged operand FIRST, so the window is one
position wide however many flags follow. The cap held only because two happened
to be short enough.
Tested as a PROPERTY rather than a list: each flagged form is paired against its
unflagged baseline, so a change that shifts both still fails. Eight pairs, the
"="-joined forms and repeated flags included. Two mutations, each caught —
restoring the fixed window mis-classifies five, dropping the contiguity
requirement widens three.
Also from a verification pass:
The doc comment claimed the cap at two was justified purely by argument-position
false positives and never said a second value-taking option defeated it. It now
describes what ships.
`npm --prefix dev install` reads a directory named "dev" as a script name. Pinned
rather than fixed: the ambiguity is the point of the window, Network is already
correct because the install really does fetch, and LocalServer only implies
Network — so the union is conservative in the safe direction.
An earlier note justified leaving internal/tui/session.go unnormalised by saying
the value feeds transcript rendering and the render cache. It feeds only the
cache fingerprint — grepping every reader of PermissionMode in that package
returns render_cache.go and nothing else — so the tradeoff described did not
exist. It is normalised now, and two spellings of one mode no longer evict the
cache for nothing.
The ACP tests asserted on ConfigOptions[1], a magic index that would move onto a
different option if the advertised order changed. They select by ID.
Rebased onto ad34dc8d. Pre-existing here and on main:
TestRunDoctorFormatsRedactedProviderDiagnostics and
TestRunDoctorConnectivityProbesProvider exit 3 in this environment.
Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
Two findings, both follow-ons to fixes I made last round.
A streaming dictation partial did not cancel the full-auto offer. The final
transcript did, and my note there said starting dictation is a keypress that
already clears the arm, which does not cover this: the offer can be armed AFTER
dictation is running, the next partial rewrites the composer, and active
dictation is not a blocking modal, so an ordinary ctrl+g still confirmed with
the user several actions past the confirmation. Cleared at the dispatch boundary
rather than inside handleDictationPartial, so a future partial-delivery path
inherits the rule. The regression drives a genuinely live session and fails if
the partial never reaches the composer, so it cannot pass on a session-mismatch
early return.
The unparseable network fallback is meant to be a superset of everything the AST
path flags, and it was maintained as a separate inventory, so it drifted. The
analyzer accepts "py" as a Python launcher and the regex listed only python,
python2 and python3:
py -m http.server 8000 parsed=true network=true
if "%OS%"=="Windows_NT" (py -m http.server 8000) else (..) parsed=false network=false
if "%OS%"=="Windows_NT" (python -m http.server ...) else .. parsed=false network=true
On Windows that approval IS the egress control. Rather than adding the alias and
leaving two lists to drift again, the launchers are one named inventory the
analyzer uses at both call sites, and the fallback test iterates it, so a
launcher added there fails immediately if the regex does not cover it.
Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
Five findings from review.
The unparseable fallback lost the serving forms on the reasoning that they bind
rather than fetch. The AST path sets Network for every recognized LocalServer
anyway, deliberately, because nothing consumes LocalServer yet and `npm run dev`
is matched by script name where the repository decides what dev and predev do.
So dropping them did not align the two paths, it split them: the POSIX parser
rejects Windows shell syntax the invoked shell accepts, and
if "%OS%"=="Windows_NT" (npm run dev) else (npm start)
came back parsed=false network=false while every parseable spelling got network.
That is the approval gate disappearing on the platform where it is the only
egress control. Restored as a superset, with a regression that asserts both
spellings of the same intent classify alike and fails loudly if the parser ever
learns the syntax.
tea.PasteMsg cancels the full-auto offer, but a right-click paste does not
arrive that way: it starts a clipboard read, and shift+tab can reach the offer
while that read is in flight. The delayed delivery inserted text and left the
arm alive, so an ordinary ctrl+g afterwards turned permission prompts off with
the user several actions past the confirmation. Cleared before the branches, on
the image delivery too, and on transcribed dictation for the same reason.
Specialist registration read the raw CLI fields while execution read the
resolved mode, so `--permission-mode full-auto` composed a registry without Task
and swarm tooling while `--auto high` and `--full-auto` composed one with it. The
mode is resolved before the registry now and the predicate consumes it, so a new
alias cannot create another unequal capability set.
The full-auto warning matched the typed --permission-mode value against three
spellings while the resolver accepts four, so `--permission-mode full_auto` was
told --auto high was responsible. It echoes what was typed instead of matching a
list that has already drifted once.
Help and validation text still led with the deprecated spelling: --permission-mode
listed "unsafe" as its value, --auto high was described as enabling "unsafe
tools", and the root-flag errors named --skip-permissions-unsafe. All now present
full-auto as the mode and the primary flag; the deprecated alias stays where its
compatibility is the point.
Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
All five of jatmn's findings.
The two P1s were the same mistake in two places: the LocalServer classification
was treated as a REPLACEMENT for Network when nothing consumes it. No policy or
runner code reads LocalServer, so classifying a serving command as local-only
granted it no host listener; it only removed the network approval it used to
get, and the command then ran under the default deny profile. On Linux that is a
network namespace and on macOS (deny network*), so `python -m http.server` and
`vite` started with no prompt and could not serve a preview to the operator's
browser.
The package-manager half is the sharper one. `npm run dev` is matched by SCRIPT
NAME, and the repository decides what `dev` and `predev` actually do; either can
curl before anything binds a port. On Windows the approval gate IS the network
protection, so inferring no-egress from a name lets that egress run unprompted.
LocalServer is now additive: the classification stays, because a scoped
host-listener path will want it, and the approval path stays until that path
exists. One line covers both findings, since both flow through the same
classification.
The PR's own tests asserted the premise being corrected, so they were rewritten
rather than flipped: building still counts as no egress, serving now asserts it
keeps BOTH the LocalServer classification and the network approval, and the
table rows moved with it.
The three P2s:
resolveExecPermissionMode accepted only the deprecated `unsafe`, so
`--permission-mode full-auto` failed usage validation before
NormalizePermissionMode was ever reached, while the warning code already had an
unreachable full-auto branch. It now accepts the canonical spelling and keeps
unsafe as the alias, and the usage text names full-auto.
newModel copied the raw mode through while the TUI tests only for
PermissionModeFullAuto, so an entry point handed the accepted legacy value
rejected ! shell escapes, published a prompting peer identity and chose low
self-correction, even though agent.Run normalized the same run to full-auto. One
value, two behaviours, decided by which layer looked at it. It is normalized at
the TUI boundary now.
Both completion inventories listed only --skip-permissions-unsafe, so no
generated shell script could suggest the documented --full-auto. Both now carry
the canonical flag alongside the deprecated alias.
Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
The rebase renamed nextPermissionMode to advancePermissionMode, and main's
plan-mode test still called the old name, so internal/tui did not build.
Also asserts the second return value. Plan must never carry a full-auto OFFER
either, or two presses from Plan would reach the mode that turns permission
prompts off entirely, starting from the one mode that promises no mutation.
Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
The rename kept a Go alias for PermissionModeUnsafe, which covers callers that
name the constant. It does not cover a mode that moves as a string, and several
do, so those paths went on comparing against "unsafe" and stopped matching.
Three places were affected:
- swarm permissionRank did not rank "full-auto", so it scored 0, the strictest
tier. A full-auto parent clamped its members harder than an ask parent.
- specialist specialistAutonomy did not match it either, so a full-auto parent's
specialists dropped to read-only "low" autonomy.
- the agent loop compares Options.PermissionMode directly, so a caller passing
the old spelling got prompts instead of full-auto.
All three fail safe, which is why nothing broke loudly. Added
agent.NormalizePermissionMode and applied it at the single point the loop reads
the mode off Options, and taught the swarm and specialist constants both
spellings. Their existing tables only listed "unsafe", which is exactly how the
rename passed review.
Separately, permission_mode_arm_test.go was never compiled. Go read the trailing
"_arm" as a GOARCH constraint and excluded the file from every amd64 build, so
the five tests covering the shift+tab full-auto offer had never run, locally or
in CI. Two panicked once they did. The file is renamed, the model construction
fixed, and a repository-wide test now fails on any _test.go whose name ends in a
GOOS/GOARCH token without an explicit build tag.
With those tests actually running, the offer gate had a real hole: unsafeArmed
was cleared only in the key handler, so a paste or a mouse click left the offer
live and a later ctrl+g committed full-auto with nobody having accepted it.
Paste and deliberate mouse actions now cancel it. Passive motion does not, since
terminals stream motion while tracking is on and cancelling on a twitch would
make the confirm key unreachable.
Also: commandRunsLocalServer matched on program name alone, so "next build" and
"vite build" claimed to bind a port while compiling. Nothing reads the flag yet,
which is why it could be wrong quietly. Fixed to require a serving subcommand,
falling back to bare-invocation behaviour when firstSubcommand lands on an
option value rather than a subcommand.
Remaining wording fixes name full-auto rather than unsafe in the retry reasons,
the exec warning, and the shell-escape notice.
Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
The rename broke CI, and it broke it in the merge rather than on the branch:
this branch is ten commits behind main, CI builds the merge, and newer main
code still references PermissionModeUnsafe and PermissionUnsafe. Deleting a
constant that other work is actively using turns an ordinary merge into a
compile failure for whoever merges second.
Both old names are restored as deprecated aliases of the full-auto constants.
Same value, so behaviour is identical, and code on either side of the rename
compiles.
My local check missed this because `go test ./internal/...` returned
"ok (cached)" for internal/agent, and a cached result cannot catch a compile
error. Verified this time with -count=1 and against an actual trial merge with
origin/main rather than the branch alone.
Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
"unsafe" was a judgement rather than a description, and it sat oddly beside
auto and ask. full-auto says what the mode does and reads as the obvious third
step: ask, auto, full-auto.
Not "unrestricted", which was the other candidate and is factually wrong. This
mode turns off permission PROMPTS. The OS sandbox stays on, the write jail
still holds, and a label implying nothing restrains the agent would be wrong in
the dangerous direction.
The on-disk value changes to "full-auto" and "unsafe" is accepted permanently
as an alias rather than migrated. That value lives in user configs and in
scripts, and a session silently falling back to auto because its saved mode no
longer parsed would be a confusing downgrade rather than a visible error.
--full-auto is the new flag; --skip-permissions-unsafe keeps working and is
documented as the deprecated spelling. The exec warning names "the full-auto
flag" generically, because either spelling reaches it and the bool does not
record which was typed: claiming --full-auto when the caller passed the old one
would be a small lie in a warning, which is the wrong place for one.
The warning also says "prompt-gated tools run without approval" rather than
anything broader, for the same reason the mode is not called unrestricted.
What deliberately does NOT change is the colour. The name can be calm; the
indicator should not be, so full-auto keeps the alarm style in the status bar
and the ctrl+g confirm gate is untouched.
The Go "unsafe" package is imported in fifteen files here, so the rename was
done by identifier token only and the imports were verified intact afterwards
rather than assumed.
Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
`python -m http.server` was denied with "network access requires approval".
Serving files out of the workspace on a local port is not reaching out, and
treating it as egress made the single most common thing an agent is asked to
do while building something stop for an approval that protected nobody.
The analyzer already had a localServerPrograms map sitting beside
networkPrograms, but commandUsesNetwork returned true for it anyway, so the
distinction had been drawn and never acted on. It is acted on now:
AnalysisResult carries LocalServer separately, so the information is preserved
rather than dropped, and a caller that does care about inbound can still see
it.
Covers the same set consistently. `next dev` and `npm run dev` are the same
intent, so classifying one as binding and the other as fetching would have left
users hitting exactly the same wall through the other door. Both are
LocalServer now, across npm, pnpm, yarn and bun, as a direct subcommand and
behind `run`.
What still counts as network is unchanged: install, add, ci, create, publish,
login, exec, and every fetching program. `npx http-server` in particular stays
network, because npx downloads the package when it is missing; the test caught
that when a first pass flipped it, which is why the bind-versus-fetch pairs are
now pinned side by side in the table.
The unparseable fallback regex is updated to agree. Left alone it would have
flagged an obfuscated dev server for network while the same command written
plainly did not, which is the kind of inconsistency that is very hard to
explain to whoever hits it.
Being honest about the edge: some of these touch the network incidentally, and
`npm run dev` may install first. The claim is narrow, that BINDING is not
EGRESS, not that dev tooling is inert. Anything that actually fetches still
matches through its own program or subcommand.
Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
Unsafe was reachable only via --skip-permissions-unsafe at launch, which is
bad discoverability: the mode exists, works, and nothing in the TUI tells you
so. It is now the third position in the shift+tab cycle, alongside auto and
ask.
What it is NOT is reachable by repeating a navigation key. Unsafe turns
permission prompts off entirely, so shift+tab only ever OFFERS it; committing
takes ctrl+g while that offer is live. Press shift+tab again instead and the
offer is declined and the cycle continues to auto, so every mode stays
reachable with shift+tab alone.
The offer lives on the model across keypresses, and that is the part worth
being careful about: a stale flag would turn a later innocent ctrl+g into a
silent drop into unsafe. So it is cleared unconditionally at the top of the key
handler and re-armed only by the shift+tab branch. Forgetting a path therefore
cancels the offer rather than leaving it live, which is the harmless direction
to be wrong in. Tests drive the real handler for eight different cancelling
keys and assert the consequence, not just the flag.
ctrl+g is guarded in the case expression rather than the body, so without a
live offer the key is not consumed at all and falls through to whatever would
normally handle it. That is what stops it being a standalone shortcut into
unsafe, and there is a test that presses it repeatedly from every other mode.
Leaving unsafe stays one press and is never gated. Getting stricter should not
need confirming.
Returning to auto from ask now costs two presses, since it passes through the
offer. That is inherent to a third position in a cycle rather than a
regression, and TestShiftTabCyclesPermissionMode was updated to walk the whole
loop rather than assume two positions.
An earlier draft of this had the second shift+tab commit unsafe, which silently
removed ask -> auto from the cycle entirely. TestPermissionModeCycleNeverReaches
UnsafeOnItsOwn walks the full loop and asserts every mode stays reachable, so
that cannot come back unnoticed.
Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
2026-09-12 10:10:22 +05:30
723 changed files with 8804 additions and 2352 deletions
* **cli:** let providers subcommands run with no active provider ([#1143](https://github.com/Twigpine/zero/issues/1143)) ([d1a0d04](https://github.com/Twigpine/zero/commit/d1a0d048ee2c1e7cbfb75160ec4c9f099f3c4472))
* **npm:** wait for npm to apply each publish, and drop the one-time @gitlawb/zero steps ([#1145](https://github.com/Twigpine/zero/issues/1145)) ([8e363fc](https://github.com/Twigpine/zero/commit/8e363fcaa5d51bf5b5b244692dc61bf8d4ae8799))
* **tui,cli:** stop a repository's git config from running programs in Zero's own git calls ([#1133](https://github.com/Twigpine/zero/issues/1133)) ([640d936](https://github.com/Twigpine/zero/commit/640d936bfbe00c4cd3017ca35fb7b5c15d91a1c5))
* **workspaceindex:** skip git-ignored and build-cache dirs in Scan only ([#1117](https://github.com/Twigpine/zero/issues/1117)) ([c88c77b](https://github.com/Twigpine/zero/commit/c88c77bf701039dca01d9786bf5648d779c949b3))
### Performance Improvements
* **redaction:** add fast-path byte checks to eliminate regex allocations on clean strings ([#945](https://github.com/Twigpine/zero/issues/945)) ([ce2fcff](https://github.com/Twigpine/zero/commit/ce2fcffaf10d8e8c8c5a13e06fd7809f09fc9867))
Loaded 100 of 723 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.