The engine and the Linux/Windows backends already enforce NetworkScoped (an
egress allowlist), but no static config could reach it: resolver.go rejected
"scoped" ("expected allow or deny"), the config-to-policy translations silently
ignored it, and there was no config field for the allowed-domains list — so
scoped was reachable only via the runtime permission flow.
- Add SandboxConfig.NetworkAllowedDomains / NetworkDeniedDomains; document
network: "scoped".
- resolver.go: accept "scoped"; reject it with an empty allowlist (fail loud
rather than let the engine silently collapse it to deny); union the domain
lists from GLOBAL user config only, never project config (mirrors
AdditionalWriteRoots) so a cloned repo cannot widen its own egress.
- applyConfiguredSandboxPolicy (the central exec/TUI/sandbox-CLI path) and
doctorSandboxPolicy: thread scoped mode + the domain lists into the Policy.
Tests: resolver accepts scoped+domains, rejects empty-allowlist scoped, rejects
unknown modes, and a project config cannot widen the allowlist; both policy
translations carry the lists through. The engine still fails closed (empty
allowlist or an unenforceable backend collapses to deny).
Add file-backed command prefix grants to the sandbox store and load them through the engine so approved prefixes skip future prompts while still running through sandbox policy checks.
Tighten reusable prefix validation for runner-style launchers and script paths, and classify find -delete as destructive.
Surface persistent prefix choices in the TUI and include approved prefixes in the run prompt context and sandbox grants list.
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/agent ./internal/sandbox ./internal/tui ./internal/cli
Tested: GOCACHE=/tmp/zero-go-cache go test ./...
Tested: git diff --check
Bash permission prompts can now expose a validated session-scoped prefix approval. Prefixes are parsed from simple shell commands or an explicit prefix_rule argument, with redirects, control operators, assignments, expansions, globs, and broad launcher prefixes rejected before the option is shown.
Approved prefixes are stored in the session sandbox engine and only skip future matching bash prompts; commands still execute through the existing sandbox backend.
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/agent ./internal/tui
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/agent ./internal/sandbox ./internal/tui ./internal/tools
Tested: GOCACHE=/tmp/zero-go-cache go test ./...
Two data-integrity gaps in the session store:
1. Sequence numbers were derived from metadata.EventCount, which is persisted
AFTER the event line (a separate file). A crash between the two writes left
EventCount behind the log, so the next append reused a sequence number -- a
duplicate id that mis-targets /rewind (findRewindTarget returns the first
match). Now the next sequence is derived from the events log itself
(lastEventSequence, an O(1) tail read that ignores a torn trailing partial),
so a stale/desynced EventCount can never cause a reused number; it falls back
to the metadata count only if the log is unreadable.
2. Checkpoint blobs and rewind restores used plain WriteFile+Rename with no
fsync, while session metadata fsyncs the temp file AND the parent dir -- so
the /rewind safety net and restored workspace files were not crash-durable.
They now go through the same writeFileSync + syncDir barrier
(writeFileAtomicSync), preserving the exact-perm chmod on restore.
Tests: stale-low metadata -> no duplicate (the reported repro); stale-high ->
benign gap; lastEventSequence over empty / multi / torn-tail / large-event logs.
* redaction: catch compound secret keys + non-bearer auth schemes; stop scanner over-redaction
Hardens the secret-redaction last line of defense after an audit of
internal/redaction + internal/secrets.
1. Compound secret keys are now redacted. IsSensitiveKey was exact-match only, so
db_password, session_secret, stripe_secret_key, auth_token, csrf_token,
ssh_private_key, backup_api_key, etc. passed through in cleartext. A new
keyLooksSensitive applies conservative structural heuristics (secret /
password / passphrase / credential / apikey segments; a singular trailing
"_token"; api_key / private_key pairs). Critically, the agent's token-COUNT
fields (max_tokens, prompt_tokens, token_count) and ordinary *_key fields
(primary_key, public_key, cache_key) are deliberately NOT redacted.
2. Authorization headers with non-bearer/basic schemes were leaking. The header
pattern now also covers token, apikey, digest, negotiate, oauth, and
aws4-hmac-sha256.
3. The secrets scanner over-redacted ordinary text: its prefixed patterns (sk-,
gh*, AIza, AKIA, xox, github_pat, jwt) lacked the word boundaries that
internal/redaction already uses, so kebab-case words like
"task-management-and-coordination" matched as a fake key. Added \b anchors.
Design note: a free-text "key: value" colon pattern was prototyped but dropped --
it mangled "file.go:line: message" output (a filename's "secret" segment looked
like a sensitive key), the same over-redaction class fix#3 addresses. The
structured paths (RedactValue for maps/structs, plus assign/JSON/query forms in
RedactString) are covered; free-text colon-form remains out of scope.
Tests: compound-key + token-count-safety matrix, auth-scheme redaction, the
file:line guard, and scanner boundary positives/negatives.
* redaction: redact the full auth-header value for multi-part schemes
The broadened header pattern captured only the first token after the scheme, so
parameterized schemes (Digest "…, response=…", AWS4-HMAC-SHA256 "…, Signature=…")
left the actual secret param visible. Redact the entire value to end of line; the
scheme name is kept. Adds tests with realistic Digest + AWS SigV4 headers.
Addresses review feedback.
* Add request permissions flow
Adds a runtime-handled request_permissions tool that can ask for turn or session filesystem/network grants and return the granted profile as structured JSON.
Temporary turn grants update the shared sandbox scope and effective policy, then clean up at run end; session grants persist in memory for the session.
Read grants now stay read-only through tool path resolution, policy evaluation, and native sandbox profile construction.
The TUI renders request_permissions with grant-specific choices, including strict review, and Esc continues without granting permissions.
Tested: GOCACHE=/tmp/zero-go-cache go test ./...
* Align request permission grants with prompts
Display and return the normalized grant roots that the sandbox actually applies, so file-like permission requests no longer show a narrower path than the directory root being granted.
Also makes the network prompt explicit about all outbound access and relabels the strict option as a model review request instead of implying enforced command review.
Tested: GOCACHE=/tmp/zero-go-cache go test ./...
* Retry transient OAuth secret read locks
Windows can briefly deny reads of the freshly published token secret while concurrent OAuth secret creation converges. Treat Windows sharing and lock violations as transient and retry those reads before returning an error.
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/oauth
Tested: GOCACHE=/tmp/zero-go-cache GOOS=windows GOARCH=amd64 go test -c ./internal/oauth -o /tmp/zero-oauth-windows.test.exe
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox ./internal/agent ./internal/tui ./internal/tools ./internal/specialist ./internal/oauth
Tested: GOCACHE=/tmp/zero-go-cache go test ./...
* feat(subagent): proactive specialist delegation + swarm execution fixes
Fixes:
- Task tool dropped the parent's permission mode, so every delegated
sub-agent ran at --auto high regardless of the parent. Forward
PermissionMode so a child never exceeds its parent's authority.
- Swarm members never executed: swarm_spawn accepts agent types
teammate/subagent, but the launcher looked those up as specialist names
(only worker/explorer/code-review exist), so every member failed with
"specialist not found". Run each member from its swarm definition via an
inline manifest on the executor.
Enhancements:
- Auto-delegation: the orchestrator system prompt now lists the available
specialists and when to use each, nudging it to offload read-heavy work to
a sub-agent so large tool output stays out of the main context.
- @-mention: a leading "@<specialist> <task>" in the TUI composer delegates
to that specialist (transcript keeps the verbatim mention); the @ palette
suggests specialists on the first token, file references mid-message.
- swarm.maxTeamSize config caps concurrent members per team (0 => default 8;
negative rejected), honored in user and project config.
- Proactive spawning: read-only specialists (explorer/code-review) auto-
approve via a new args-aware tool permission (tools.ArgsPermissioner), so
the agent delegates exploration off a normal prompt without an approval
prompt; write-capable specialists, resume, and unknown names still prompt.
The exec gate registers specialist tools at medium/high autonomy so a
non-trivial headless run can auto-delegate read-only too.
* fix(specialist): fail-safe child autonomy when PermissionMode is unset
specialistAutonomy mapped an empty PermissionMode to "--auto high" (unsafe),
silently re-introducing the privilege escalation the rest of this PR fixes: an
orchestrator that forgets to wire PermissionMode would get an unsafe child. The
Task tool and swarm both now propagate a resolved mode, so "" only occurs for a
caller that omitted it -- default that case to "low".
Only an explicit unsafe parent now yields --auto high; everything else (incl.
unset/unknown) is fail-safe low. Updates the stale "back-compat" comments and the
tests that documented the old behavior.
Addresses the non-blocking follow-up from PR review.
* tui: image-input UX — drag-drop attach, inline numbered chips, vision fallback, cursor + copy fixes
Improves the image/PDF attachment flow and two long-standing composer papercuts.
- Drag-and-drop: dropping an image/PDF onto the composer attaches it instead of
erroring as an unknown "/…" command. The dropped path is detected on paste and
on submit (absolute/escaped/quoted paths to an existing image or PDF).
- Inline numbered chips: staged attachments render as compact [Image #1] /
[Doc #1] chips INSIDE the input box; the verbose "Attached … (image/png)"
system line is dropped. Backspace on an empty composer removes the last chip
(/image clear still drops all).
- Vision fallback: a real multimodal model absent from the curated catalog
(custom / openai-compatible / local ids) now accepts images via a conservative
name match; text-only ids stay refused, and the catalog stays authoritative.
- Blinking composer cursor, including when the box is empty (previously no caret
was drawn for a focused, empty box).
- Ctrl+E releases/recaptures the mouse so native drag-select + copy works (mouse
capture otherwise intercepts terminal text selection).
Tests: dropped-path detection, vision-by-name + unknown-model fallback,
mouse-release capture toggle, attachment removal, and updated chip-render tests.
* fix(vision): a "vision-less" model name must not match the bare-vision fallback
visionCapableByName treated any id containing "vision" as multimodal, so a model
named "...vision-less..." (i.e. WITHOUT vision) was wrongly classified vision-
capable — which silently skipped the non-vision image gate and broke
TestRunExecDropsImagesOnNonVisionModelWithWarning in internal/cli.
Add mentionsVision, which still matches the "vision" word but excludes the
negated forms (vision-less / visionless / no-vision / non-vision). Used for both
the grok-vision and the general open-multimodal fallback. Added negation cases to
the name table.
* fix(tui): drag-drop path detection corrupted Windows paths (backslash unescape)
unescapeDroppedPath stripped backslashes to undo Unix terminal drag-drop escaping
("\ " -> " "), but on Windows the backslash is the path separator, so a plain
dropped path like C:\Users\x\file.png became C:Usersxfile.png and never resolved
-- TestDroppedAttachmentPath failed on windows-latest.
No-op the unescape on Windows (dropped paths there arrive quoted or plain), and
gate the Unix-only backslash-escaped positive case behind runtime.GOOS.
* perf(tools): lower default deferThreshold 10 -> 3 to stop eager MCP-schema waste
MCP tool schemas are only collapsed into compact tool_search reminder lines once
the deferred-eligible tool count reaches tools.deferThreshold. The default of 10
meant a typical single MCP server (Exa-style, a handful of tools) stayed BELOW it,
so every full MCP schema — 300-600 tokens each — was re-sent on every turn, even
for a one-word message. A small MCP set could add several thousand input tokens
per message.
Lower the default to 3 so a normal MCP server's toolset defers and stays cheap.
Capability is unchanged: every tool is still reachable; the model just loads a
deferred tool's schema via tool_search before its first use, after which it is
loaded for the rest of the run. Fully overridable via tools.deferThreshold
(0 keeps the old always-eager behavior for models without tool_search).
* test(agent): measure the deferral token saving (66% smaller tool payload)
* fix(tokens): count non-whitespace bytes so estimates match the real tokenizer
The token estimate was naive len/4, which overcounted by ~15-20%: `zero context`
reported ~6k for a prompt the provider's tokenizer actually counted as 5,203.
BPE tokenizers (the ones zero targets) fold a run's leading space into the
following token rather than emitting whitespace separately, so counting
NON-whitespace bytes / 4 tracks the real count closely (now ~5.2k, ~1% off the
measured 5,203) while staying dependency-free.
Add agent.ApproxTextTokens and route the context-budget preview
(contextreport), the compaction threshold (agent), and the TUI transcript
estimate through it so every surface uses one accurate scale. The provider's
exact usage is still authoritative on each request; this only sharpens the
pre-request preview and compaction timing.
* Align permission prompt rendering
Remove normal permission prompt risk labels from focused cards, transcript rows, and permission detail text so risk metadata is not presented as the permission UI contract.
Rename the recoverable deny option from a redirect-style prompt to a continue-without-running label that matches its current behavior.
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/tui
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/tui ./internal/agent ./internal/cli
Tested: GOCACHE=/tmp/zero-go-cache go test ./...
Tested: git diff --check
* Tighten permission prompt fallbacks
Use conservative allow/deny choices when a permission request lacks backend-supplied decisions, avoiding impossible session or persistent grant options.
Document that a failed session-grant recording attempt must not deny the current user-approved tool call; it only means a later matching call prompts again.
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/tui ./internal/agent
Tested: GOCACHE=/tmp/zero-go-cache go test ./...
Tested: git diff --check
* Split permission deny and cancel decisions
Add an explicit cancel permission decision that aborts the active run while keeping deny as a recoverable tool refusal.
Expose cancel choices for command and apply-patch approval prompts, keep apply-patch off the recoverable deny path, and stop offering persistent always approvals for command/apply-patch prompts until scoped prefix approval exists.
Classify cancel decisions as permission-decision events for TUI session logs and exec JSON output.
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/agent ./internal/tui ./internal/cli
Tested: GOCACHE=/tmp/zero-go-cache go test ./...
Tested: git diff --check
* Stabilize OAuth secret creation
Publish encrypted OAuth token secrets atomically under a short-lived creation lock so concurrent first-run callers never observe a half-written secret file.
This fixes the Zero Review failure in TestLoadOrCreateSecretConcurrentConverges.
Tested: go test ./internal/oauth -run TestLoadOrCreateSecretConcurrentConverges -count=100
Tested: go test ./internal/oauth
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/agent ./internal/tui ./internal/cli
Tested: GOCACHE=/tmp/zero-go-cache go test ./...
Tested: git diff --check
* Align permission session approvals
Add in-memory session grants, workspace-write auto-allow for built-in file mutation tools, and dynamic permission choices for once/session/future approvals.
Persist future approvals only when a grant store is configured, while keeping session approvals scoped to the active engine.
Tested: GOCACHE=/tmp/zero-go-cache go test ./...
* Guard workspace write auto-allow
Exclude protected workspace metadata from built-in file-tool auto-allow so writes under .git, .zero, and .agents re-prompt instead of silently running.
Derive apply_patch targets from patch headers inside the sandbox engine so patch-body escapes and symlink traversal are denied before workspace auto-allow.
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox -run 'TestEngineAutoAllowsWorkspaceFileMutationTools|TestEngineDoesNotAutoAllowProtectedMetadataWrites|TestEngineDeniesApplyPatchEscapesFromPatchBody|TestEngineSessionGrantDoesNotMatchSiblingFile|TestEngineDeniesAutoAllowedSymlinkEscape|TestEngineDeniesWorkspaceSymlinkTraversal|TestDeriveScope'
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox ./internal/agent ./internal/tui ./internal/tools
Tested: GOCACHE=/tmp/zero-go-cache go test ./...
Tested: git diff --check
Treat hosted web_search as an allowed read-only network discovery tool so normal searches do not stop on a permission prompt, while retaining sandbox network enforcement when tool-network policy is enabled.
Add host-scoped persistent grants for web_fetch URLs so an always-allow decision is bounded to the requested host instead of becoming a blanket tool-wide fetch grant.
Update permission prompt and transcript rendering to show network targets, scoped always labels, and preserved scope metadata across live rows, render caching, and session hydration.
Tested: GOCACHE=/tmp/zero-go-cache go test ./...
* Add sandbox architecture baseline
Record target backend names, enforcement metadata, legacy sandbox entrypoints, and policy JSON baseline coverage for the sandbox rewrite.
Add a focused sandbox smoke script with cross-platform compile checks and an opt-in real backend smoke path.
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox -run 'TestTargetBackendForPlatformBaseline|TestLegacySandboxEntrypointsAreExplicitAndExist|TestBackendPlanCarriesPhase0ManagerFields|TestCommandPlanCarriesSandboxMetadata|TestPolicyOnlyDisabledFailClosedForTargetPlatforms|TestSelectBackendChoosesPlatformAdapterWithFallback|TestBackendBuildPlanDocumentsBestEffortIsolation|TestBackendCapabilitiesReflectDisabledPolicy'
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/cli -run 'TestRunSandboxPolicyInspectTextAndJSON|TestRunSandboxPolicyJSONGoldenIncludesManagerBaselineFields|TestRunSandboxPolicyEffectiveTextAndJSON'
Tested: scripts/sandbox-smoke.sh
Tested: git diff --cached --check
* Introduce sandbox manager and permission profile
Add a normalized permission profile and sandbox manager execution request that owns target backend selection, enforcement metadata, and fail-closed validation.
Route command planning and sandbox policy output through the manager boundary while keeping legacy adapters explicit as temporary integration points.
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox -run 'TestPermissionProfileFromPolicyBuildsWorkspaceWriteProfile|TestPermissionProfileFromDisabledPolicyDoesNotRequirePlatformSandbox|TestSandboxManagerBuildsExecutionRequestFromProfile|TestSandboxManagerFailsClosedWhenNativeRequiredAndPolicyOnlyDisabled|TestTargetBackendForPlatformBaseline|TestBackendPlanCarriesPhase0ManagerFields|TestCommandPlanCarriesSandboxMetadata|TestPolicyOnlyDisabledFailClosedForTargetPlatforms'
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/cli -run 'TestRunSandboxPolicyInspectTextAndJSON|TestRunSandboxPolicyJSONGoldenIncludesManagerBaselineFields|TestRunSandboxPolicyEffectiveTextAndJSON'
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/tools -run 'TestRegistryRunsBashThroughSandboxEngine|TestBashToolReportsDisabledPolicyOnlyRunner|TestBashToolBuildsWrappedSandboxExecCommand'
Tested: scripts/sandbox-smoke.sh
Tested: git diff --cached --check
* Move sandbox backend selection into manager
Make SandboxManager own platform backend selection and keep SelectBackend as a compatibility shim for existing CLI and TUI wiring.
Add tests proving manager-owned selection and the compatibility wrapper agree.
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox -run 'TestSandboxManagerSelectsPlatformBackend|TestSelectBackendDelegatesToSandboxManagerSelection|TestSelectBackendChoosesPlatformAdapterWithFallback|TestLegacySandboxEntrypointsAreExplicitAndExist|TestSandboxManagerBuildsExecutionRequestFromProfile|TestSandboxManagerFailsClosedWhenNativeRequiredAndPolicyOnlyDisabled'
Tested: scripts/sandbox-smoke.sh
Tested: git diff --cached --check
* Route command planning through sandbox manager
Make SandboxManager build command plans from sandbox execution requests, with backend-specific wrapping isolated behind a temporary adapter.
Move Engine.BuildCommandPlan to a single manager boundary and mark buildLegacyCommandPlan as the explicit delete target for later backend work.
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox -run 'TestSandboxManagerBuildsCommandPlanThroughTemporaryAdapter|TestSandboxManagerBuildsDegradedPolicyOnlyCommandPlan|TestBuildCommandPlanWrapsBubblewrap|TestBuildCommandPlanWrapsSandboxExec|TestBuildCommandPlanUsesPolicyOnlyFallback|TestBuildCommandPlanCanRejectPolicyOnlyFallback|TestLegacySandboxEntrypointsAreExplicitAndExist|TestSandboxManagerBuildsExecutionRequestFromProfile|TestSandboxManagerFailsClosedWhenNativeRequiredAndPolicyOnlyDisabled'
Tested: scripts/sandbox-smoke.sh
Tested: git diff --cached --check
* Add Linux sandbox helper command boundary
Introduce the zero-linux-sandbox command surface and typed permission-profile argv builder used by the upcoming Linux helper rewrite.
Keep the helper fail-closed until enforcement is wired, and add parser tests plus smoke compilation for the new helper package.
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox -run 'TestBuildLinuxSandboxCommandArgsSerializesPermissionProfile|TestParseLinuxSandboxHelperArgs|TestRunLinuxSandboxHelperFailsClosedUntilEnforcementIsWired'
Tested: scripts/sandbox-smoke.sh
Tested: git diff --cached --check
* Fix sandbox policy golden path aliases
Normalize both original and symlink-resolved temp paths before comparing sandbox policy JSON goldens, so macOS /private/var aliases do not fail the smoke job.
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/cli -run 'TestRunSandboxPolicyJSONGoldenIncludesManagerBaselineFields|TestRunSandboxPolicyInspectTextAndJSON|TestRunSandboxPolicyEffectiveTextAndJSON'
Tested: scripts/sandbox-smoke.sh
Tested: git diff --cached --check
* Fix Windows sandbox smoke test assumptions
Normalize JSON-escaped Windows paths in the sandbox policy golden test and mark the fake bubblewrap backend as Linux in the command metadata test.
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/cli -run 'TestRunSandboxPolicyJSONGoldenIncludesManagerBaselineFields|TestRunSandboxPolicyInspectTextAndJSON|TestRunSandboxPolicyEffectiveTextAndJSON'
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox -run 'TestCommandPlanCarriesSandboxMetadata|TestBackendPlanCarriesPhase0ManagerFields|TestPolicyOnlyDisabledFailClosedForTargetPlatforms'
Tested: scripts/sandbox-smoke.sh
Tested: git diff --cached --check
* Stabilize sandbox policy golden test
Compare the Windows sandbox policy golden as decoded JSON after path tokenization so platform-specific byte formatting does not fail smoke tests while preserving the same field-level regression coverage.
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/cli -run TestRunSandboxPolicyJSONGoldenIncludesManagerBaselineFields
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/cli -run 'TestRunSandboxPolicyJSONGoldenIncludesManagerBaselineFields|TestRunSandboxPolicyInspectTextAndJSON|TestRunSandboxPolicyEffectiveTextAndJSON'
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox
Tested: scripts/sandbox-smoke.sh
Tested: git diff --check
* Route Linux sandboxing through helper
Move Linux command planning to invoke zero-linux-sandbox with the permission profile, command cwd, and command argv instead of exposing bubblewrap construction from the runner boundary.
The helper now builds the bubblewrap argv, binds its own executable for the inner stage, applies the seccomp/no-new-privs step before exec, and preserves the original command cwd path.
Package Linux releases with zero-linux-sandbox next to zero so release builds use the helper directly, while local development can fall back to go run for the helper.
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/cli ./internal/tools ./cmd/zero-linux-sandbox
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/release ./cmd/zero-release
Tested: scripts/sandbox-smoke.sh
Tested: git diff --check
* Normalize Linux helper cwd test paths
Compare native paths in the helper cwd regression test so Windows smoke does not fail on path separator formatting while preserving the same helper behavior assertion.
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox -run TestLinuxHelperPlanPreservesRealExtraRootCwd
Tested: scripts/sandbox-smoke.sh
Tested: git diff --check
* Add Linux Landlock helper enforcement
Implement the zero-linux-sandbox Landlock helper path with fail-closed profile validation, Landlock filesystem rules, and seccomp network-deny enforcement for the fallback backend.
Broaden the Linux real smoke coverage to cover helper write allow/deny, deny-read, temp writes, metadata protection, network deny, and direct Landlock fallback behavior.
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox
Tested: scripts/sandbox-smoke.sh
Tested: GOCACHE=/tmp/zero-go-cache go test ./cmd/zero-linux-sandbox ./internal/cli ./internal/tools ./internal/release ./cmd/zero-release
Tested: git diff --check
* Route Seatbelt profiles through permission profiles
Build macOS sandbox-exec profiles from the shared PermissionProfile so read roots, write roots, temp access, deny-read, deny-write, and protected metadata are expressed by the platform profile instead of legacy write-root-only inputs.
Keep the existing sandbox-exec adapter path as a compatibility wrapper while adding target macos-seatbelt capability handling.
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox
Tested: scripts/sandbox-smoke.sh
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/cli ./internal/tools
Tested: git diff --check
* Stabilize Seatbelt write carveouts
Emit protected metadata and read-only write-root carveouts as explicit Seatbelt deny rules after the broad write allow. This preserves deny precedence while avoiding fragile allow-rule predicates in real sandbox-exec runs.
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox
Tested: scripts/sandbox-smoke.sh
Tested: git diff --check
* Expand macOS Seatbelt runtime allowances
Restore the platform startup allowances needed by sandbox-exec-wrapped commands under restricted filesystem profiles: executable mapping, system metadata reads, pty/device access, preferences, IPC, and standard library/runtime roots.
This keeps project writes constrained to the PermissionProfile roots while preventing macOS shells from aborting before user commands run.
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox
Tested: scripts/sandbox-smoke.sh
Tested: git diff --check
* Normalize Seatbelt deny expectations in tests
Build Seatbelt deny-rule expectations from normalized profile paths so the metadata ordering test matches production behavior on Windows and Unix hosts.
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox
Tested: scripts/sandbox-smoke.sh
Tested: GOOS=windows GOARCH=amd64 GOCACHE=/tmp/zero-go-cache go test -c ./internal/sandbox -o /tmp/zero-sandbox.test.exe
Tested: git diff --check
* Add Windows sandbox runner command planning
Select the windows-restricted-token backend only when the Windows sandbox command runner is discoverable, and keep the missing-runner path as an explicit policy-only downgrade.
Add the Windows runner argument contract for command cwd, workspace roots, permission profile JSON, child environment JSON, restricted-token level, and payload command parsing.
Update sandbox policy output and tests to describe the missing Windows runner instead of an unimplemented adapter.
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/cli
Tested: GOOS=windows GOARCH=amd64 GOCACHE=/tmp/zero-go-cache go test -c ./internal/sandbox -o /tmp/zero-sandbox.test.exe
Tested: scripts/sandbox-smoke.sh
Tested: GOCACHE=/tmp/zero-go-cache go test ./...
Tested: git diff --check
* Add Windows sandbox command runner
Add the zero-windows-command-runner executable and wire windows-restricted-token plans to pass sandbox home, command cwd, workspace roots, permission profile JSON, child env JSON, sandbox level, and command argv.
Persist Windows capability SIDs under the resolved sandbox home, select read-only or writable-root capability SIDs from the permission profile, and create a restricted token before launching the payload with CreateProcessAsUserW.
Package the Windows runner next to zero.exe and extend sandbox smoke coverage to cross-build it.
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox ./internal/release ./cmd/zero-windows-command-runner ./cmd/zero-release
Tested: GOOS=windows GOARCH=amd64 GOCACHE=/tmp/zero-go-cache go build -o /tmp/zero-windows-command-runner.exe ./cmd/zero-windows-command-runner
Tested: scripts/sandbox-smoke.sh
Tested: GOCACHE=/tmp/zero-go-cache go test ./...
Tested: git diff --check
* Plan Windows sandbox ACL operations
Derive a deterministic Windows ACL operation list from the restricted permission profile, including writable root allows, protected metadata deny-write entries, explicit deny-write paths, and deny-read materialization targets.
The plan maps workspace and extra writable roots onto the persisted capability SIDs so the elevated setup helper can apply the same contract without reinterpreting the profile.
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox
Tested: GOOS=windows GOARCH=amd64 GOCACHE=/tmp/zero-go-cache go test -c ./internal/sandbox -o /tmp/zero-sandbox.test.exe
Tested: GOOS=windows GOARCH=amd64 GOCACHE=/tmp/zero-go-cache go build -o /tmp/zero-windows-command-runner.exe ./cmd/zero-windows-command-runner
Tested: GOCACHE=/tmp/zero-go-cache go test ./...
Tested: scripts/sandbox-smoke.sh
Tested: git diff --check
* Add Windows sandbox setup boundary
Add the zero-windows-sandbox-setup command, setup argument parsing, deterministic ACL plan hashing, and a setup marker contract that can detect profile changes requiring refresh.
Make Windows native backend discovery require both the command runner and setup helper, package the setup helper with Windows releases, and include it in sandbox smoke builds.
The setup command remains fail-closed until the Windows ACL applier is wired, so it cannot create a setup-complete marker without applying enforcement.
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox ./internal/release ./cmd/zero-windows-sandbox-setup
Tested: GOOS=windows GOARCH=amd64 GOCACHE=/tmp/zero-go-cache go test -c ./internal/sandbox -o /tmp/zero-sandbox.test.exe
Tested: GOOS=windows GOARCH=amd64 GOCACHE=/tmp/zero-go-cache go build -o /tmp/zero-windows-command-runner.exe ./cmd/zero-windows-command-runner
Tested: GOOS=windows GOARCH=amd64 GOCACHE=/tmp/zero-go-cache go build -o /tmp/zero-windows-sandbox-setup.exe ./cmd/zero-windows-sandbox-setup
Tested: GOCACHE=/tmp/zero-go-cache go test ./...
Tested: scripts/sandbox-smoke.sh
Tested: git diff --check
* Apply Windows sandbox ACL setup
Implement the Windows ACL setup path with grouped native DACL updates, deny-read materialization, existing DACL snapshots, and rollback for partial application failures.
Write the setup marker only after ACL setup succeeds, roll back applied ACLs if marker persistence fails, and make the Windows command runner validate the setup marker before creating the restricted token.
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox ./cmd/zero-windows-sandbox-setup ./cmd/zero-windows-command-runner
Tested: GOOS=windows GOARCH=amd64 GOCACHE=/tmp/zero-go-cache go test -c ./internal/sandbox -o /tmp/zero-sandbox.test.exe
Tested: GOOS=windows GOARCH=amd64 GOCACHE=/tmp/zero-go-cache go build -o /tmp/zero-windows-command-runner.exe ./cmd/zero-windows-command-runner
Tested: GOOS=windows GOARCH=amd64 GOCACHE=/tmp/zero-go-cache go build -o /tmp/zero-windows-sandbox-setup.exe ./cmd/zero-windows-sandbox-setup
Tested: GOCACHE=/tmp/zero-go-cache go test ./...
Tested: scripts/sandbox-smoke.sh
Tested: git diff --check
* Fail closed for Windows network policies
Add Windows network policy validation that allows only explicit network-allow until native Windows network enforcement is implemented.
Make the Windows command runner reject deny, scoped, and unset network modes before launching a restricted-token process so default network-deny cannot silently run open.
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox ./cmd/zero-windows-command-runner
Tested: GOOS=windows GOARCH=amd64 GOCACHE=/tmp/zero-go-cache go test -c ./internal/sandbox -o /tmp/zero-sandbox.test.exe
Tested: GOOS=windows GOARCH=amd64 GOCACHE=/tmp/zero-go-cache go build -o /tmp/zero-windows-command-runner.exe ./cmd/zero-windows-command-runner
Tested: GOCACHE=/tmp/zero-go-cache go test ./...
Tested: scripts/sandbox-smoke.sh
Tested: git diff --check
* Track Windows network setup state
Add a canonical Windows network-policy hash and persist it in the Windows sandbox setup marker so setup refresh can detect network mode, domain, and proxy-requirement changes.
Bump the setup marker schema and add tests for domain-order normalization plus network-change marker invalidation.
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox
Tested: GOOS=windows GOARCH=amd64 GOCACHE=/tmp/zero-go-cache go test -c ./internal/sandbox -o /tmp/zero-sandbox.test.exe
Tested: GOOS=windows GOARCH=amd64 GOCACHE=/tmp/zero-go-cache go build -o /tmp/zero-windows-command-runner.exe ./cmd/zero-windows-command-runner
Tested: GOOS=windows GOARCH=amd64 GOCACHE=/tmp/zero-go-cache go build -o /tmp/zero-windows-sandbox-setup.exe ./cmd/zero-windows-sandbox-setup
Tested: GOCACHE=/tmp/zero-go-cache go test ./...
Tested: scripts/sandbox-smoke.sh
Tested: git diff --check
* Add sandbox setup CLI command
Add zero sandbox setup to resolve the active sandbox policy, derive the Windows setup helper next to the command runner, and invoke it with the shared Windows setup argument contract.
Return text or JSON setup results, no-op cleanly for non-Windows native backends, and add tests using an injected helper runner.
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/cli ./internal/sandbox
Tested: GOOS=windows GOARCH=amd64 GOCACHE=/tmp/zero-go-cache go test -c ./internal/cli -o /tmp/zero-cli.test.exe
Tested: GOOS=windows GOARCH=amd64 GOCACHE=/tmp/zero-go-cache go test -c ./internal/sandbox -o /tmp/zero-sandbox.test.exe
Tested: GOOS=windows GOARCH=amd64 GOCACHE=/tmp/zero-go-cache go build -o /tmp/zero-windows-sandbox-setup.exe ./cmd/zero-windows-sandbox-setup
Tested: GOCACHE=/tmp/zero-go-cache go test ./...
Tested: scripts/sandbox-smoke.sh
Tested: git diff --check
* Stabilize Windows ACL canonical path test
* Add TUI sandbox setup command
* Gate TUI sandbox setup command
* Clarify sandbox doctor setup status
* Add Windows WFP network setup
Adds a Windows network enforcement plan with stable WFP provider, sublayer, and filter specs for deny-mode sandbox identities. The setup helper now installs or removes those filters transactionally before writing the setup marker, and the marker tracks the network enforcement plan so stale filters require refresh.
Scoped Windows network mode remains fail-closed until the proxy exception path is implemented, so it cannot silently become open network.
Diagnostics now report native Windows network enforcement when the restricted-token backend is active.
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox -run 'TestValidateWindowsNetworkPolicyAllowsNativeModes|TestValidateWindowsNetworkPolicyFailsClosedForScoped|TestBuildWindowsNetworkPlanForDenyUsesCapabilityIdentity|TestBuildWindowsNetworkPlanFailsClosedForScoped|TestWindowsNetworkPlanHashIsStableAcrossEntryOrder|TestWindowsSandboxSetupMarkerRefreshesWhenNetworkChanges|TestSelectBackendChoosesPlatformAdapterWithFallback'
Tested: GOCACHE=/tmp/zero-go-cache GOOS=windows GOARCH=amd64 go build -o /tmp/zero-windows-sandbox-setup.exe ./cmd/zero-windows-sandbox-setup
Tested: GOCACHE=/tmp/zero-go-cache GOOS=windows GOARCH=amd64 go build -o /tmp/zero-windows-command-runner.exe ./cmd/zero-windows-command-runner
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox ./internal/cli ./internal/doctor
Tested: GOCACHE=/tmp/zero-go-cache go test ./...
Tested: GOCACHE=/tmp/zero-go-cache scripts/sandbox-smoke.sh
Tested: git diff --check
* Add Windows sandbox network smoke
Adds a gated Windows real sandbox smoke test that runs the compiled setup helper and command runner, verifies sandboxed writes still work, and proves network=deny blocks a loopback TCP connection from a restricted-token command.
The sandbox smoke script now runs this test on Windows using the helper binaries it builds in its temporary directory.
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox -run 'TestValidateWindowsNetworkPolicyAllowsNativeModes|TestBuildWindowsNetworkPlanForDenyUsesCapabilityIdentity'
Tested: GOCACHE=/tmp/zero-go-cache GOOS=windows GOARCH=amd64 go test -c -o /tmp/zero-windows-sandbox.test ./internal/sandbox
Tested: GOCACHE=/tmp/zero-go-cache GOOS=windows GOARCH=amd64 go build -o /tmp/zero-windows-sandbox-setup.exe ./cmd/zero-windows-sandbox-setup
Tested: GOCACHE=/tmp/zero-go-cache GOOS=windows GOARCH=amd64 go build -o /tmp/zero-windows-command-runner.exe ./cmd/zero-windows-command-runner
Tested: GOCACHE=/tmp/zero-go-cache scripts/sandbox-smoke.sh
Tested: GOCACHE=/tmp/zero-go-cache go test ./...
Tested: git diff --check
* Remove legacy sandbox runner paths
Drop the old direct bubblewrap adapter, the standalone zero-seccomp wrapper, legacy adapter metadata, and the Phase 0 legacy inventory now that command planning goes through the sandbox manager backends.
Switch platform selection and tests to target backend names: linux-bwrap, macos-seatbelt, windows-restricted-token, and explicit policy-only degradation.
Keep policy as the shared permission/profile and diagnostics surface, but stop exposing legacy adapter fields in CLI policy output and tool metadata.
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox ./internal/cli ./internal/tools ./internal/zerocommands ./internal/doctor
Tested: GOCACHE=/tmp/zero-go-cache scripts/sandbox-smoke.sh
Tested: GOCACHE=/tmp/zero-go-cache go test ./...
Tested: git diff --check
* Address sandbox baseline review blockers
Restore sandbox.blockUnixSockets as a resolved config and policy field, route it into the Linux helper as --block-unix-sockets, and keep zero-seccomp as a Linux compatibility wrapper packaged with release artifacts.
Add the missing legacy-entrypoint test referenced by the smoke script, extend helper/release/config tests, and make CLI startup tests hermetic by stubbing MCP registration where they assert quiet output.
Document the compatibility path, release helper scope, and the already-resolved #237 prompt-file overlap in the PR body.
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox ./internal/cli ./internal/config ./internal/release ./cmd/zero-seccomp -run 'TestLegacySandboxEntrypointsAreExplicitAndExist|TestSandboxManagerBuildsCommandPlanThroughLinuxHelper|TestBuildLinuxSandboxCommandArgsSerializesPermissionProfile|TestParseLinuxSandboxHelperArgs|TestBuildLinuxSandboxBwrapArgsWrapsInnerSeccompStage|TestApplyConfiguredSandboxPolicyDiagnosticsFlags|TestResolveSandboxBlockUnixSocketsFromFile|TestCopyPackageFilesStagesLinuxSandboxHelper'
Tested: GOCACHE=/tmp/zero-go-cache go test ./...
Tested: GOCACHE=/tmp/zero-go-cache scripts/sandbox-smoke.sh
Tested: git diff --cached --check
Tested: local leak scan found no matches
* fix(mcp): skip an unreachable MCP server at startup instead of aborting
RegisterTools connected to every server eagerly and failed the whole batch if any
server could not be reached or list its tools — and the caller aborts startup with
exit 1 on that error. So a single unreachable MCP server prevented zero from
launching at all and disabled every other (working) server.
Make registration best-effort across servers but still atomic within a server:
each server's tools are validated as a unit before anything commits, and a server
that cannot be connected, returns a nameless tool, or conflicts is SKIPPED —
recorded on Runtime.Skipped(), not fatal. The caller surfaces each skip as a
warning and keeps launching. The "no dead tools" and no-partial-tool-set
guarantees are preserved.
* feat(mcp): ship keyless Firecrawl as a default web search/scrape server
Seed an enabled-by-default Firecrawl MCP server (keyless: 1,000 free credits/month,
no API key, no setup) into ResolveMCP before user/project config is merged, so web
search and scraping work out of the box for open-source users. A user can override
any field (self-hosted URL, bring-your-own-key header) or disable it entirely;
`zero mcp disable firecrawl` writes the override even though the default lives in
code rather than the config file.
The built-in web_fetch tool stays the always-local floor. Firecrawl is AGPL-3.0,
but zero only calls it over the network, so the license never reaches zero's own
code. README documents disable / self-host / bring-your-own-key / the privacy note
that keyless routes through firecrawl.dev.
Depends on the resilient-startup change: on an offline machine the default is
skipped with a warning, not fatal.
* perf(mcp): connect MCP servers concurrently with a per-server startup timeout
RegisterTools connected servers serially with a 30s handshake timeout, so startup
waited for the SUM of all servers and one slow/unreachable server (e.g. a hosted
endpoint blocked by the local network) blocked every other server AND the first
model response — often 10-30s of dead time per launch.
Connect all servers CONCURRENTLY, each bounded by a per-server ConnectTimeout
(default 8s). A server that doesn't connect+list in time is abandoned (its context
cancelled to tear down the half-open connection/subprocess) and skipped, so total
startup cost is now the slowest REACHABLE server, not the sum. The concurrent
phase does only connect+list (no shared state); validation, conflict detection,
and registration stay in a deterministic serial phase, so the outcome is identical
regardless of completion order. A kept server's context is released at Close — a
stdio subprocess is tied to its context, so it must outlive the connect.
Pairs with the resilient skip-on-failure behavior already in this branch.
* docs: rewrite AGENTS.md as a user-facing 'Extending Zero' guide
The previous AGENTS.md (a 19-line Go-specific instruction set) was replaced
with a user-facing extension guide for Zero as an open-source CLI agent
product. Covers:
- Project-level AGENTS.md (where to drop one, format, tips)
- Custom specialists (droids): scopes, manifest format, CLI
- Skills: discovery, SKILL.md format, ZERO_SKILLS_DIR override
- Hooks: lifecycle events, JSON config locations, exit code semantics
- MCP: as client (config.json mcp.servers) and as server (zero serve --mcp)
- Plugins: plugin.json manifest, install/enable/disable/remove
- Configuration locations: built-in < user < project < CLI < env
- End-to-end example: what a team commits vs what a contributor adds
- Reference links to README and docs/SPECIALISTS.md, docs/PRD.md, etc.
This positions AGENTS.md as the entry point for end users who want to
customize Zero, mirroring the role that similar files play in Claude Code
and Codex.
* docs(AGENTS): align Extending Zero with actual loader behavior + D-style features
Fix six claims that were wrong about Zero itself, document the new
upward path-walking the loader now does, and surface three roadmap
gaps in the relevant sections.
What changed in the doc:
- Section 1 (Drop a project AGENTS.md): rewrote the file table to
match the real priority order [AGENTS.md, ZERO.md, .zero/AGENTS.md]
and added the case-insensitive basename callout (git tracks
AGENTS.MD uppercase on case-sensitive filesystems).
- Section 1: documented the per-file (8 KiB) and total (32 KiB) caps
and the eager-load + restart-required caveat.
- Section 1: added the monorepo tip and the path-walking explanation
("walks from cwd up to git root, general-to-specific order").
- Section 2 (Custom specialists): added a roadmap note about the
planned /droids in-UI manager.
- Section 3 (Skills): corrected the discovery-root chain
(ZERO_SKILLS_DIR -> XDG_DATA_HOME/zero/skills -> ~/.local/share/zero/skills),
noted that project-level skills aren't supported yet, and called
out the plugin-declared-skill gap.
- Section 4 (Hooks): roadmap note about the planned /hooks in-UI
manager.
- Section 6 (Plugins): roadmap note about the planned /plugins in-UI
manager; cross-link to the skills gap.
- Frontmatter globs broadened to include *.json / *.toml / *.yaml /
*.yml, matching the actual file types Zero reads.
* feat(agent): make AGENTS.md loader case-insensitive + add path-walking
Two changes to the project guideline loader that bring Zero closer
in line with D's DynamicContextDiscovery and fix a real bug.
1. Case-insensitive basename matching. The git-tracked filename is
AGENTS.MD (uppercase MD), but the loader was looking for AGENTS.md
(lowercase md). On a case-sensitive filesystem (Linux, the WSL
filesystem, or a CI runner) this means the file was invisible to
the loader. The new findProjectContextFile uses ReadDir + ToLower
to resolve the on-disk filename and matches AGENTS.MD, Agents.md,
agents.md, etc. to the same entry. On Windows/macOS the visible
filename in the prompt label is now the actual on-disk name
(AGENTS.MD), not the lookup name.
2. Upward path-walking. The loader previously did a single
first-match-wins lookup at cwd. The new projectGuidelines walks
from cwd up to the nearest git root, finds the first matching
project context file at each level, and injects them in
general-to-specific order (git root first, cwd last). A monorepo
with a root AGENTS.md and per-service AGENTS.md files now sees
both, with the most specific rules last. This mirrors what D
does per-Read on the dynamic path, just at session start.
Cap behavior:
- Per-file cap is unchanged: 8 KiB.
- New total cap of 32 KiB across all matched files, enforced via a
rolling counter; when the budget is exhausted, further files are
dropped (and the per-file limit is the lesser of the per-file cap
and what's left of the total).
- Truncation marker is unchanged: \n… (truncated).
New helpers and tests:
- projectGuidelineDirs / projectGuidelineLabel / findProjectContextFile
/ resolveDirCaseInsensitive / findProjectGitRoot.
- 8 new tests covering case-insensitive matching, monorepo
path-walking, ZERO.md fallback, .zero/AGENTS.md fallback, total
cap truncation, and the helper functions.
Existing TestBuildSystemPromptInjectsWorkspaceContext still passes;
the label for a single-file repo is still AGENTS.md / AGENTS.MD as
appropriate to the on-disk name.
* docs(AGENTS): drop syntax that doesn't exist in the current code
Per the review on #237, the previous "Extending Zero" guide documented
several commands, flags, and conventions that the runtime does not
implement. Concretely:
- `zero plugins install` / `enable` / `disable` are not subcommands.
`runPlugins` (internal/cli/extensions.go) only dispatches
`list` / `add` / `remove`(`rm`). Replace the example with `add` and
note that enable/disable are not CLI verbs today (the plugin is
enabled by being present and disabled by being removed, or by
setting `"enabled": false` in its manifest).
- `zero specialist create ... --prompt-file api-reviewer.md` is wrong:
there is no `--prompt-file` flag. `runSpecialistCreate` takes the
prompt inline via `--prompt`. Show a `$(cat ...)` shell substitution
as the practical way to load a file, and add `zero specialist path`
to the example.
- `"Authorization": "Bearer ${env:GITHUB_TOKEN}"` implies env-var
expansion that does not exist: MCP header values are sent verbatim
(internal/mcp/permissions.go, client.go mergeProcessEnv). Use a
literal placeholder and document the three real ways to keep the
token out of the file (wrapper script, command substitution, server
reading its own env).
- Hook `args: ["${tool}", "${input}"]` is the same mistake plus a
shape mismatch: the actual hook payload (event, matcher, tool call
id, tool input/output, status) is delivered as JSON on stdin
(internal/hooks/dispatch.go `execCommandRunner`), not via `${...}`
substitution. Drop the placeholders, add a short bash handler that
reads stdin, and show the `args` field as verbatim CLI args.
- "use `zero doctor` to surface them" — the doctor backend
(internal/doctor/doctor.go) has no hook check; its checks are
runtime / config / provider / connectivity / sandbox / lsp.
Reword to point at the audit log instead.
Secondary: drop the `globs:` and `alwaysApply:` example and the
"Use globs: to scope which files the rules apply to" tip — the loader
injects the whole file (frontmatter included) as prompt text and does
not parse those keys. Add a one-liner noting the frontmatter is
preserved verbatim. Also drop the parenthetical "(droids)" on
§2 to match the "specialists" terminology the rest of the codebase
(and the CLI) actually use, and reword the §2/§4/§6 roadmaps that
promised in-UI managers via non-existent `/droids`, `/hooks`,
`/plugins` slash commands.
* feat(providers): add `zero providers detect` for local-runtime onboarding
Wire the previously-unwired provideronboarding detect/advice surface into the
CLI. `zero providers detect` probes for running local OpenAI-compatible runtimes
(Ollama, LM Studio) on their default ports and prints a no-key adopt command for
each, followed by the next-step actions (use / check / set key) for every
configured provider. Supports --json.
This connects DetectLocalRuntimes, LocalRuntimeCandidates,
DetectedLocalRuntime.SetupAction, ProviderActions, MissingCredentialAction,
SetupCommand, and ProviderState.Actions — a fully built, tested feature that had
no caller. Adds an injectable detectLocalRuntimes appDep for hermetic tests.
* feat(sandbox): add `zero sandbox check` to evaluate a decision as JSON
Wire the previously-unwired zerocommands sandbox-snapshot contract into the CLI.
`zero sandbox check <tool>` evaluates what the sandbox engine would decide for a
hypothetical tool action against the resolved policy, and emits the active plan
(policy + backend + restrictions), the decision (allow/prompt/deny with risk and
any violation), and any persistent grant matching the tool. Supports --json;
grant reasons are redacted.
This connects SandboxPlanSnapshotFromPlan, SandboxPolicySnapshotFromPolicy,
SandboxBackendSnapshotFromBackend, SandboxDecisionSnapshotFromDecision,
SandboxRiskSnapshotFromRisk, SandboxViolationSnapshotFromViolation, and
SandboxGrantMatchSnapshotFromLookup — a fully built, tested JSON contract that
had no production consumer. The existing `sandbox policy` output is unchanged.
* test(providermodelcatalog): cover model predicates and remote fetch layer
Add targeted tests for the audit-identified coverage gaps: the pure logic
(LooksLikeCodingModelID, modelMatchesProvider, defaultedOpenGatewayURL,
modelSortLabel/sortModels) and the HTTP fetch layer (fetchJSON, FetchModelsDev,
FetchOpenGateway, FetchRemote routing) via httptest. Package coverage 48.9% ->
79.5%. cmd/zero (main shim) and browser.OpenURL (exec wrapper) are intentionally
left to CI smoke — their only logic seams (openCommand) are already covered.
* fix(sandbox): address CodeRabbit review on `sandbox check`
- Pass the configured write-root scope (NewScope with AdditionalWriteRoots) into
the check engine, so its decision matches real enforcement and a stale
extra-root config surfaces here too instead of a workspace-only fallback.
- Validate --side-effect and --autonomy against their advertised value sets and
return a usage error on a typo, rather than letting the engine normalize it.
- Reword the unmatched-grant line to "no grant matched this action" — Lookup only
checks this tool/scope/autonomy, not whether any grant exists for the tool.
- Add tests: invalid-flag rejection and a matched-grant reason-redaction
regression.
* test(sandbox): make the out-of-workspace check test path OS-portable
Smoke (windows-latest) failed: "/etc/passwd" is not absolute on Windows, so the
check joined it into the workspace and returned "prompt" instead of "deny".
Build the out-of-workspace path under a separate temp dir so it is absolute and
outside the workspace on every OS.
* fix(providers): surface a clear error when a model host is unreachable
A local Ollama daemon serving a "-cloud" model answers on localhost but
returns HTTP 502 carrying an opaque proxied transport error (e.g.
`Post "https://ollama.com:443/...": net/http: TLS handshake timeout`) when it
cannot reach its own cloud backend. A direct hosted endpoint blocked by the
local network fails the same way at the transport layer. Both previously
surfaced as raw strings ("provider error:" / "provider stream error:") that
read like a zero bug and gave the user nothing to act on.
Add providerio.UpstreamUnreachable: it detects a transport-level connectivity
failure (TLS handshake timeout, context deadline exceeded, connection refused,
no such host, network unreachable, i/o timeout) bound to a concrete host and
rewrites it into an actionable message naming the host and explaining the
request never reached the model. It requires both a marker and a host, so the
agent's own request-deadline cancellations are left untouched.
Wire it into the OpenAI-compatible adapter's gateway-error and transport-error
paths, covering both the local daemon proxy and direct hosted providers;
secrets are still redacted. Covered by unit tests over the real error shapes
and an end-to-end 502 adapter test.
* fix(providers): don't humanize a caller-context timeout as an upstream outage
Address CodeRabbit review: in the OpenAI-compatible adapter's transport-error
path, a caller-driven cancel/deadline surfaces as an error string containing
"context deadline exceeded" plus the request host, which UpstreamUnreachable
would mislabel as "upstream unreachable" — the host is reachable; the caller's
clock ran out. Check the parent ctx.Err() first and surface it verbatim, so only
genuine connect failures (ctx still live) are humanized.
Adds a deadline regression test; the existing cancel test used Cancel, whose
"context canceled" is not a marker, so it never covered this path.
* feat(tui): Ctrl+T cycle + divider display for reasoning effort
Reasoning effort was already fully wired (per-model supported lists,
DefaultReasoningEffort, the EffectiveReasoningEffort clamp, /effort and
/mode), but it was invisible in the UI and reachable only by typing a
command. Add the opencode-style one-key fast path:
- Ctrl+T cycles the active model's effort ring (auto -> low -> medium ->
high -> auto), gated by the same modal condition shift+tab uses and a
silent no-op on models with no effort controls.
- The composer divider shows the active effort in the brand lime when
set; the segment is omitted on "auto", so its presence/absence is
itself the auto-state feedback.
Zero per-frame registry lookups: the cycle runs only on the rare
keypress and the divider branches purely on m.reasoningEffort != ""
(DefaultRegistry rebuilds the whole catalog on every call, so it must
never be touched from the render path).
Tests cover every cycle branch (auto->first, mid-ring advance,
last->auto wrap, unknown->auto, no-op on unsupported model) and the
divider show/omit paths.
Co-Authored-By: Claude <[email protected]>
* fix(tui): address CodeRabbit review on Ctrl+T effort cycle
- Block Ctrl+T effort cycling inside the detailed transcript (matches the
shift+tab gate) so state changes don't happen while the user is reading
a frozen view.
- Use a guaranteed-unplaceable sentinel in the unknown-effort reset test
instead of ReasoningEffortMinimal, so the test stays correct if Minimal
becomes a supported ring level later.
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
* fix(tui): extract shared noBlockingModal() for shortcut gates
The 7-term modal gate was duplicated verbatim between the shift+tab and
Ctrl+T cases. Fold it into a single noBlockingModal() helper so the two
shortcuts can't drift the day a new modal is added to one and not the
other. Pure refactor, no behavior change.
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
* fix(tui): hide divider effort segment on models without effort controls
The divider showed whatever m.reasoningEffort was set to, even when the
active model had no effort controls (e.g. gpt-4.1). Switching from
claude-sonnet-4.5 (low/medium/high) to gpt-4.1 would leave a stale
"high" floating in the divider even though it has no effect.
Make the segment selective: only render it when the active model exposes
availableReasoningEfforts, matching the same appearing/disappearing
state feedback the divider already uses for the auto case. The lookup
hits a hard-coded static catalog, not a per-frame rebuild, so it's
safe on the render path.
handleModelCommand already resets an unsupported effort preference on
model switch, so no changes are needed there.
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
* fix(tui): drop model-aware effort gate in divider; /effort picker already selective
The previous commit added `&& len(m.availableReasoningEfforts()) > 0` to
the divider's effort-segment gate so a stale value couldn't be shown
after switching to a model without effort controls. Reverting that
change: the underlying state mutation is already gated elsewhere
(handleModelCommand clears an unsupported preference on switch, and the
/effort picker only opens for models that actually expose effort
controls), so the divider doesn't need its own check.
The /effort picker (newEffortPicker, pickerEffort kind) is already
fully wired and tested — /effort with no args on a supported model
opens the picker; on a model with no effort controls, the text path
returns "Active model does not expose reasoning effort controls."
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
* fix(tui): open effort picker even when active model has no effort controls
Previously newEffortPicker returned nil for any model not in the hard-coded
catalog (e.g. ollama-cloud providers serving glm-5.1 or similar). The
case commandEffort dispatch fell through to handleEffortCommand, which
rendered a grey "Effort / status: warning / available: none for active
model" status card -- the very static panel users were reporting.
Now newEffortPicker always returns a picker: a single "auto" option when
the active model exposes no effort controls, or "auto" plus the model's
known efforts otherwise. handleEffortCommand already returns the "Active
model does not expose reasoning effort controls" message if any
non-auto value is later set, so no other gate is needed.
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
* feat(tui): render description hint below composer for single slash match
Claude-code-style: when the user types a slash command that resolves to
exactly one command in the autocomplete palette, surface that command's
description on a muted line just below the composer box. Multiple
matches keep the existing dropdown as the right affordance; once the
user starts typing arguments the hint clears; the @file palette keeps
its own rows and the hint stays scoped to slash commands.
The inline argument hint ([low|medium|high|auto]) still renders inside
the composer box after the slash, so both UIs coexist.
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
* feat(tui): render /effort output as a polished command card
The /effort command used to push its status text through renderCommandOutput,
which renders as a thin grey panel (faint text on panel background, neutral
border). Replace that path with renderCommandCardTranscript so the result
renders the same way as /tools, /mcp, /permissions, etc.: a lime-bordered
titled card with a key/value "State" section and a footer of next-step actions.
Three call sites updated:
- handleEffortCommand("auto") -> sets effort to auto, shows confirmation card
- handleEffortCommand(<value>) -> sets effort, shows confirmation card
- handleEffortCommand(<bad>) -> unknown / unsupported / not exposed -> shows
the same card with the relevant detail line
effortText() (the no-arg / list / picker-dismiss path) follows the same
pattern. The "status: warning" line and grey panel are gone; the warning
case instead keeps the lime accent and surfaces "no reasoning controls on
this model" in the summary, matching the rest of the TUI's visual language.
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
---------
Co-authored-by: Claude <[email protected]>
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
* tui: interactive /resume session picker + resume fixes
- /resume (no arg) now opens an interactive picker like /model and /provider:
one row per resumable session showing the title and session id (+ relative
age); arrow to highlight, type to filter, enter to load. /resume <id> and
/resume latest still resolve directly.
- Resume now loads the rehydrated (compaction-aware) event view instead of the
raw log, so a resumed session honors a prior /compact — matching the CLI's
`zero exec --resume` and the in-TUI /compact reload. Falls back to raw on a
rehydrate error.
- The resume summary reports the model/provider the continuation will actually
use (the active ones), noting the session's recorded model/provider when it
differs, instead of implying a switch that doesn't happen.
- Drop the dead, self-contradictory resumeText(arg) branch (it was unreachable
and its hint was circular); resumeText is now the no-session/error fallback.
Tests: session picker opens with title+id rows and hydrates on selection;
resume honors prior compaction (rehydrated, not raw events).
* tui: lead /resume picker rows with a precise timestamp
Same-titled sessions (e.g. the same first prompt run several times) looked like
duplicates because the row showed only the identical title plus a long id that
truncated the age away. Lead each row's label with a precise local timestamp
(HH:MM:SS today, "Jan _2 15:04" earlier this year, else the date) so distinct
sessions are obviously distinct; the full id stays as the right-aligned meta and
is what selection resolves. sessionWhen parses the RFC3339 UpdatedAt.
* tui: hide empty/no-output sessions from the /resume picker
Most of a heavily-retried prompt's sessions are empty failed runs — just the
prompt plus the no-output guardrail stop ("Agent stopped … with no output …"),
with no assistant text and no tool calls. They have nothing to resume and flood
the picker with identical-looking rows. Skip any session with no resumable
content (no tool call/result and no real non-user message) from the picker; they
stay on disk and are never deleted. Adds agent.IsNoProgressStop to recognize the
stop message from a recorded event, and a test that the picker hides an empty
run while keeping a real one.
* feat(tui): model-generated session titles (auto + /retitle backfill)
Sessions were titled from their first user message, so prompts that
started the same way (or empty/failed runs) looked identical in the
/resume picker. Generate a concise, specific title with the active
provider instead.
- sessions.Store.UpdateTitle(id, title): rewrites only Title under the
per-session lock, re-reading the latest metadata first; leaves
UpdatedAt untouched (a retitle is not activity and must not reorder
the resumable list), rejects a blank title, and no-ops an unchanged
one.
- Title generator: a bounded digest of the conversation (user/assistant
text + tool names, per-message and total caps, skipping the no-output
guardrail stop) is sent as a one-shot completion; the response is
cleaned (first content line, quotes/markup/"Title:" label stripped,
word- and rune-capped) before storing.
- Auto-title going forward: after a successful turn, a session still
carrying its default first-message title gets a title in the
background, at most once per session.
- /retitle: backfills existing resumable sessions that still have a
first-message title, one at a time, skipping empty/failed runs and
already-named sessions, with kickoff and completion status lines.
Generation runs off the Update goroutine; failures are non-fatal (the
first-message title simply stays). Adds store + TUI unit tests
(UpdateTitle semantics, digest/cleaning, auto-title one-shot, backfill
candidate selection). gofmt/vet/build(host+linux+windows)/test -race/
staticcheck all green.
* address review: tighten no-progress detection, fix fallback errors + nil guard
Three review findings on the /resume work:
- IsNoProgressStop matched any content CONTAINING the no-output marker
substring (strings.Contains), so a legitimate assistant/tool message that
quoted the marker could be misread as a failed empty run — wrongly hiding a
real session from the /resume picker and skipping its title generation. Now
it requires the full answer structure (prefix + marker + suffix); a quote no
longer matches.
- resumeEvents returned the earlier rehydration error when the raw ReadEvents
fallback itself failed, masking the actual fallback failure. Returns rawErr.
- resumeText dereferenced m.sessionStore unconditionally; a nil store (fallback/
test paths) would panic. Now renders a safe "store unavailable" message.
(newSessionPicker already guarded nil.)
Tests: IsNoProgressStop accepts the real answer and rejects a quoting message /
bare marker / prefix- or suffix-only; resumeText with a nil store returns the
safe message instead of panicking. Full gate green.
* address review (Vasanth): structural no-progress check, retryable auto-title, nil-store guard, deep resume assertion
- guardrails: IsNoProgressStop now matches the exact structure noOutputStopAnswer
emits — prefix + "<int> turns " + marker + " " + suffix — instead of
prefix && contains(marker) && suffix. A genuine message that merely quotes the
marker amid other prose is no longer misclassified as a failed empty run (which
would wrongly hide a real session from /resume and skip its title generation).
Adds the hostile-input regression cases (quoted marker, text between
marker/suffix, non-integer turn count).
- session_title: release the optimistic titledSessions gate when a generation
FAILS (provider error, empty title, store write error) so a later turn or
/retitle can retry; success keeps the one-shot gate. Kept the optimistic mark
(rather than mark-on-success) to preserve the no-double-fire guarantee while a
generation is in flight.
- session_title: guard maybeAutoTitleActiveSession against a nil sessionStore
(the title cmd calls store.UpdateTitle) — mirrors the resume nil-store guards.
- model_test: assert resumed sessionEvents with reflect.DeepEqual against the
rehydrated context, not just slice length, so a reordered/substituted-but-
same-length regression is caught.
* fix(tui): right-click pastes the clipboard directly (no menu)
A right-click now pastes the OS clipboard straight into the focused input —
composer, provider/MCP wizard, or setup — with no context menu, mirroring the
bracketed-paste behavior. Keyboard and selection copy/paste are unchanged.
- clipboard.go: pasteFromClipboardCmd reads the clipboard off the Update
goroutine; routePaste is the shared router for both bracketed paste (PasteMsg)
and right-click paste (clipboardReadMsg), so the two behave identically.
- mouse.go: a right-click returns pasteFromClipboardCmd; input_compat.go adds the
mouseRightPress helper.
- model.go: PasteMsg now routes through routePaste; clipboardReadMsg inserts on
success or shows a brief "Paste failed" status when the clipboard can't be read
(e.g. no clipboard utility on a remote session).
Tests: right-click returns the read command (chat + wizard), clipboard content
routes to the focused field, read errors surface a status, empty is a no-op.
* fix(tui): right-click paste in setup mode + scope the paste test
Setup mode routes mouse events to handleSetupMouse, which only handled
left-click and wheel — right-clicks were swallowed, so paste never fired
during onboarding. Add the same mouseRightPress -> pasteFromClipboardCmd
branch handleMouse uses; routePaste already targets the setup field.
Test: assert the right-click contract at handleMouse/handleSetupMouse
scope instead of via Update (which batches unrelated commands and masks a
regression), and cover the new setup-mode path.
Addresses review on #231: mouse.go (setup paste, critical) and
clipboard_test.go (weak Update-level assertion, minor).
The permission prompt now has a moving cursor and clickable options, on top of
the existing a/y/d hotkeys (which still resolve directly):
- ↑/↓ and Tab/Shift+Tab move the highlight across allow once / always / deny
(resting default: allow once); Enter confirms the highlighted option.
- each option is rendered on its own row, and a left-click on a row resolves
that choice directly.
- permission_prompt.go: permissionOptions + cursor move/clamp/confirm helpers.
- model.go: pendingPermissionPrompt gains a cursor; Enter confirms it, ↑/↓/Tab
move it; a/y/d are unchanged.
- rendering.go: renderFocusedPermissionPrompt renders per-option rows with the
cursor highlighted (▸ + reverse label) and returns each option's card-relative
Y offset so the caller can register clickable rows.
- transcript_selection.go: transcriptSelectableLine gains permOption/permChoice;
the pending-permission item registers each option row as clickable, and a
left-click on one resolves the permission.
Tests: cursor default/move/wrap, Enter confirms the highlight, hotkeys still
resolve directly, render emits highlighted clickable offsets. Existing card/
submit-block tests updated for the new per-option layout + Enter-confirms.
Add a bounded per-model transcript body height cache keyed by the existing row render fingerprint, then use measured spans to render only the visible alt-screen item window instead of building the full body string every frame.
Mouse hit testing now uses the same visible item layout, while inline rendering and selection copy keep the full layout path for behavior parity.
Tests cover height cache reuse, visible-only item rendering, absolute selectable body coordinates, and output parity with the previous full-layout renderer.
Tested: env GOCACHE=/tmp/zero-go-cache go test ./internal/tui
Tested: git diff --check
Tested: /bin/bash -lc "GOCACHE=/tmp/zero-go-cache go test ./..."
* Centralize TUI frame layout
Add a reusable transcript frame snapshot with terminal regions for header, body, footer, composer, and status lines. Mouse composer hit testing, overlay positioning, and transcript viewport mapping now consume the same frame geometry instead of recalculating pinned-header and clipped-footer offsets separately.
Tested: env GOCACHE=/tmp/zero-go-cache go test ./internal/tui
* Route TUI mouse hits through local regions
Return overlay hit coordinates relative to the overlay block and derive transcript hit rows from the shared body rect. This keeps the current scroll behavior while making mouse handling consume component-local geometry from the frame layout.
Tested: env GOCACHE=/tmp/zero-go-cache go test ./internal/tui
* Cover TUI frame geometry
Add focused tests for the shared frame snapshot: pinned header/body/footer regions, tiny-terminal footer clipping, composer mouse hit regions, overlay centering, and transcript mouse row mapping through the body region.
Tested: env GOCACHE=/tmp/zero-go-cache go test ./internal/tui
* Extract transcript viewport line window
Introduce a line-based transcript viewport adapter that preserves Zero's existing bottom-anchored scroll offset. Render slicing, chat scroll metrics, scrollChat, and transcript selection viewport mapping now share the same clamping and visible-window calculation.
Tested: env GOCACHE=/tmp/zero-go-cache go test ./internal/tui
* Build transcript body from layout items
Introduce a compatibility transcript body item layer for title, empty state, separators, transcript rows, pending prompts, streaming interim content, and spec review prompts. The body still renders to the same line/selectable output, but layout now records item spans and local selectable offsets for the next visible-range step.
Tested: env GOCACHE=/tmp/zero-go-cache go test ./internal/tui
Tested: /bin/bash -lc "GOCACHE=/tmp/zero-go-cache go test ./..."
* Address TUI layout review findings
Clamp overlay mouse hit regions to the visible transcript body and block transcript selection while the MCP add wizard is open. Strengthen layout and viewport edge-case tests, including full composer containment and degenerate dimensions.
Tested: env GOCACHE=/tmp/zero-go-cache go test ./internal/tui
Tested: /bin/bash -lc "GOCACHE=/tmp/zero-go-cache go test ./..."
* Make MCP wizard overlay test hit transcript text
CodeRabbit caught that the MCP add wizard blocking test clicked at x=0, which could miss selectable transcript text and pass without proving the overlay guard. This updates the test helper to return both textStart and y, then clicks that known selectable point before asserting the wizard blocks transcript selection behind the modal.
Tested: env GOCACHE=/tmp/zero-go-cache go test ./internal/tui
Tested: git diff --check
Tested: GOCACHE=/tmp/zero-go-cache go test ./...
* Route transcript windows through body layout
Thread the structured transcriptBodyLayout through alt-screen rendering, scroll metrics, and transcript mouse lookup instead of repeatedly joining and splitting the rendered body string. This preserves the current line-based viewport behavior while making the body layout the source of truth for visible windows and selectable line lookup.
This is an incremental step toward visible-item rendering and item-level caching; it does not change the transcript visual output or input flow.
Tested: env GOCACHE=/tmp/zero-go-cache go test ./internal/tui
Tested: git diff --check
Tested: /bin/bash -lc "GOCACHE=/tmp/zero-go-cache go test ./..."
* Reuse row cache for selectable transcript rows
Selectable user and assistant rows need selectable metadata for mouse interactions, but when no transcript selection is active their rendered strings are identical to the normal row render path. Route those rendered strings through renderRow so stable rows reuse the existing row cache while selection-active frames still render highlight-aware output.
This keeps transcript behavior unchanged and adds a regression test that verifies selectable assistant rows produce stable metadata while hitting the render cache on the second render.
Tested: env GOCACHE=/tmp/zero-go-cache go test ./internal/tui
Tested: git diff --check
Tested: /bin/bash -lc "GOCACHE=/tmp/zero-go-cache go test ./..."
* feat(tui): rotating "working word" for the liveness spinner
The assistant interim block used to show a static "working…" placeholder
while the model was generating. That felt dead on long runs. Replace it
with a rotating gerund tuned for the Gitlawb / Zero brand: project-name
verbs (gitlawbmaxxing, zeroling, …) weighted 1.5x so the brand word
anchors the rotation, plus feature-as-verbs, gen-Z crowd-pleasers, and
a few Claude-Code originals for variety.
The rotation is a tiny ring buffer (working_words.go) advanced on each
spinner.Tick so the verb and the glyph stay in lockstep. Brand words
are duplicated in the ring, not randomized, so the cadence is
deterministic and the first frame is always "gitlawbmaxxing" — easy
to test, easy to reason about, and the first thing a long-running user
sees is the project name. Reset() rewinds the ring when a new run
starts (model.go: submit, spec_mode.go: spec approval → impl).
Lowercase throughout to brand-differentiate from Claude Code's Title
Case defaults. No config wiring (hardcoded list); can be a follow-up
if users ask for customisation.
Tests: 7 new tests in working_words_test.go (first frame, tick,
wrap, reset, brand weighting, base-list integrity, nil safety) plus
the existing interim-block test updated to assert "gitlawbmaxxing"
as the new default. Full internal/tui/ suite green in 2.7s.
* Address review feedback on working-words PR
Three review threads to close out:
**Cadence (~12 words/sec → ~1/sec)**
@anandh8x and @gnanam1990 both flagged the on-every-tick advance as
flicker. The spinner glyph should still spin fast (it conveys liveness),
but the word needs to be readable. Solution: the model owns a
`workingVerbTicks` counter and gates the advance at
`WorkingWordsStepEvery = 12` spinner ticks (~960ms at 80ms cadence).
The `workingWords` ring stays a dumb "advance one slot" type so unit
tests can tick it rapidly without waiting for a real spinner. New test
`TestWorkingVerbAdvancesOnStepEverySpinnerTicks` locks the cadence
behaviour in.
**Brand-name "inconsistency" (intentional)**
@gnanam1990 noted the code says "Gitlawb / OpenFable" while the repo,
binary, and PR description say "Zero". Author's call: the working-words
PR ships the rebrand-ahead-of-the-rebrand verbs on purpose. Kept the
existing strings (`gitlawbmaxxing`, `openfablemaxxing`, …) and tidied
the comments so the trade-off is explicit ("a planned rename; the
spinner brand is intentionally ahead of the rest of the project").
Also fixed a stale `model.go` comment that still said "zeroling"
(superseded by `openfablemaxxing` in the previous force-push).
**Taste call (pilled / aura-farming / maxxing)**
Both reviewers flagged these as user-facing with no config escape.
Author's call: ship as-is, with a comment on `vibeVerbs` calling out
the trade-off explicitly so the next reader can see it was a
deliberate decision rather than an oversight.
**weightedRing comment vs implementation**
CodeRabbit and @gnanam1990 both caught that the comment claimed brand
verbs are "interleaved" but the implementation appends them as a
sequential block at the end of the ring. Fixed the comment to match
the implementation and added a note on how to implement true
interleaving if it's ever wanted.
**CodeRabbit nits**
- `Reset()` now also checks `len(w.weighted) == 0` for consistency
with `Tick()` and `Current()`.
- `weightedRing` body simplified to `append(ring, brandVerbs...)`
instead of the conditional loop (same result, clearer intent).
**Competitor naming in comments**
@gnanam1990 asked to trim the few "Claude Code" mentions in package
comments. Replaced the explicit "Claude Code" references with neutral
phrasing ("upstream Claude-Code-compatible spinners", "a few classic
gerunds") that keeps the design rationale without naming the
competitor. The user-facing verb list is untouched.
Rebased onto current main (1e25fde) and resolved the spec_mode.go
merge conflict by keeping both the fade-seed and the working-verb
reset on the spec-impl path.
Build + vet + gofmt clean. Full internal/tui/ test suite green except
for the pre-existing env-fragile `TestModelCommandPersistsSelectedModelToUserConfig`
from PR #220 (fails here only because of local env, not this PR).
Co-Authored-By: Claude <[email protected]>
* Fix cadence test post-advance window (CodeRabbit)
The final block of `TestWorkingVerbAdvancesOnStepEverySpinnerTicks`
had two issues caught by CodeRabbit:
1. A `_ = m` no-op statement that asserted nothing.
2. A tautological check — it compared the post-advance verb against
the *original* `before` value, which is always going to differ
after the previous block already advanced.
The real intent was to verify that the cadence counter *resets* after
each advance (rather than carrying over), so a follow-up advance
lands one full `WorkingWordsStepEvery` window later — not one window
early due to a missed reset.
Rewrote the trailing block to:
- Capture the post-advance value explicitly (`afterAdvance`).
- Drive another (WorkingWordsStepEvery - 1) ticks after the advance.
- Assert the verb stayed at `afterAdvance` — catching the "counter
doesn't reset" regression the original test was meant to guard
against.
This now meaningfully tests three regressions:
- Gate removed (verb advances every tick) — caught by the first block.
- Gate set to 1 (same effect) — caught by the first block.
- Counter doesn't reset after advance (verb advances too eagerly) —
caught by the new third block.
Updated the test docstring to call out all three failure modes.
Co-Authored-By: Claude <[email protected]>
---------
Co-authored-by: Claude <[email protected]>
* feat(tui): age-based fade for streaming assistant text
While the assistant reply streams in, the latest line appears in the
brand lime (\#caff3f) and gradually settles to the standard off-white
ink over ~1.2 seconds, line by line. The effect mirrors the lime
spinner glyph in the liveness block: a quiet visual signal that the
model is still emitting, not a stylized neon glow.
Design choices (per line, not per rune):
* 12-step palette interpolated in linear sRGB from colorAccent to
colorInk, pre-computed at init() so per-frame lookups are a
struct-field access, not a hex parse.
* 1.2s fade duration; 150ms tick (independent of the 80ms spinner).
* Hard stop at stream end: the agentResponseMsg handler flips
fadeActive = false and the next streamingFadeTickMsg short-circuits.
* Reset on terminal resize (visual line count changes) and on cancel.
* The last visual line uses lastStreamActivity (always freshest) so
the user can see exactly where the model is currently typing.
Test fixtures that pre-populate m.streamingText without going through
the agentTextMsg branch keep rendering identically to before — the
fade code is defensive when lineAges is nil.
The spec-impl run (which calls runAgent directly, bypassing the
normal launchPrompt path) seeds the fade state explicitly so its
streaming text fades the same way regular runs do.
* fix(tui): address CodeRabbit review on streaming-fade PR
Four findings from CodeRabbit's review of #223, applied:
1. model.go agentTextMsg: schedule streamingFadeTick only on the
inactive->active transition instead of on every delta. The tick
is self-perpetuating (the streamingFadeTickMsg case schedules the
next one), so re-scheduling on every incoming delta is redundant
and would create multiple concurrent tick chains during long
streams.
2. model.go agentResponseMsg: call m.resetStreamingFade() instead of
setting only fadeActive = false. Leaving lineAges and
lastStreamActivity populated allowed stale age data to carry over
to the next turn and made lineAges grow indefinitely across many
runs.
3. streaming_fade.go streamingLineBornAt: when the visual index is
beyond the lineAges slice (a wrapped middle line), clamp to the
last known logical age instead of returning time.Time{}. Wrapped
continuation lines now keep fading in step with their siblings
instead of snapping to base ink mid-paragraph.
4. streaming_fade_test.go TestStreamingFadePaletteHandlesBadHex:
assert that every bucket's foreground is the unparseable base
string instead of asserting on len(palette), which is a constant
for a fixed-size array (the original assertion passed regardless
of behavior and triggered SA4006). Also split the
OutOfRangeReturnsZero test into ClampsToLast (for the new clamp
behavior) and EmptyLineAgesReturnsZero (for the truly-empty case
that should still return zero).
---------
Co-authored-by: Claude <[email protected]>
* Migrate TUI to Bubble Tea v2
Move the TUI stack to charm.land Bubble Tea, Bubbles, and Lip Gloss v2, including key, paste, mouse, and view handling changes for the v2 APIs.
Add compatibility helpers for the new input event shapes and update TUI tests accordingly.
Refresh related Go dependencies and make the PDF truncation fixture deterministic across poppler and pure-Go extraction.
Tested: GOCACHE=/tmp/zero-go-cache GOMODCACHE=/tmp/zero-go-mod-cache go test ./internal/tui
Tested: GOCACHE=/tmp/zero-go-cache GOMODCACHE=/tmp/zero-go-mod-cache go test ./internal/imageinput -count=1 -v
Tested: GOCACHE=/tmp/zero-go-cache GOMODCACHE=/tmp/zero-go-mod-cache go test ./...
* Address provider wizard OAuth review feedback
Remove the unused mouseButton compatibility helper flagged by lint.
Correlate provider wizard OAuth and device-code async results with the active provider and attempt id so stale results from abandoned attempts are ignored.
Add regression coverage for stale browser OAuth and stale device-code results.
Tested: GOCACHE=/tmp/zero-go-cache GOMODCACHE=/tmp/zero-go-mod-cache go test ./internal/tui -run 'ProviderWizard.*OAuth|ProviderWizard.*Device|ApplyProviderWizardOAuth|RenderCredentialStepShowsOAuth' -count=1 -v
Tested: GOCACHE=/tmp/zero-go-cache GOMODCACHE=/tmp/zero-go-mod-cache go test ./internal/tui
Tested: GOCACHE=/tmp/zero-go-cache GOMODCACHE=/tmp/zero-go-mod-cache go test ./...
Tested: git diff --check
* Smooth TUI mouse scrolling
* providers: authenticate model calls with an OAuth login when present
Foundation for account/subscription login: when a user is logged in via
`zero auth login <name>`, the OpenAI and Anthropic providers now authenticate
model requests with that OAuth bearer token, falling back to the API key when
there is no login. Auto-select, per the agreed design — no config needed.
- providerio.SendWithAuthRetry: issues a request with an OAuth bearer (from a
TokenResolver) or the API key, and retries ONCE with a force-refreshed token
after a 401. A 401 means the server rejected the request without processing a
completion, so replay-after-refresh is safe — the same no-duplicate-work basis
SendWithRetry uses for 429/503. Never sends API key and bearer together.
- openai + anthropic providers take an optional OAuthResolver and use
SendWithAuthRetry. When OAuth is active the credential goes on Authorization
(so Anthropic's x-api-key is correctly omitted). Gemini is unchanged.
- providers.New threads Options.OAuthResolver to those two providers.
- cli.oauthResolverForProfile builds the resolver from the unified oauth store:
it attaches a resolver ONLY when a login already exists for the provider
name, so API-key users incur no per-request store lookups. The resolver uses
Manager.GetFresh (refresh near expiry) and Manager.Handle401 (force refresh on
the 401 retry).
- oauth: added ErrNoToken sentinel so "not logged in" is distinguishable from a
real refresh failure (login removed mid-session → clean API-key fallback).
Tests (httptest): API-key fallback (nil resolver / ok=false), bearer wins +
never-both, 401→force-refresh→retry, stops after one retry, resolver error
surfaced. Local run check (real binary + mock OAuth + mock OpenAI-compatible LLM
requiring the bearer): with a login, `zero exec` sends Bearer <oauth-token> and
gets a completion; without a login it falls back to the (wrong) API key and the
mock returns 401 — proving the OAuth path.
Gates: gofmt/vet/build(host+linux+windows)/test -race/staticcheck(no new)/
govulncheck(0)/deadcode(no new) all pass.
* providers: add ChatGPT-subscription-via-proxy preset + OAuth/subscriptions docs
Follows verified research (cross-checked across ~30 sources) into using ChatGPT
and Claude subscriptions: a subscription OAuth token does NOT work as a standard
bearer on the public APIs. OpenAI's token only works against the Cloudflare-gated
ChatGPT backend (headless clients get cf-mitigated 403); Anthropic rejects
third-party subscription OAuth, requires Claude-Code identity spoofing, 400s on
tool calls (Max overage lane), and is contractually banned with active
enforcement. So zero does NOT call those backends or spoof those clients.
Instead, the supported pattern is a local proxy that holds the subscription
session and exposes a clean OpenAI/Anthropic-compatible endpoint on 127.0.0.1;
zero points at it (it already supports custom base URLs, and C1 supplies OAuth
bearer for gateways that issue standard tokens).
- providercatalog: add `chatgpt-proxy` ("ChatGPT (local OAuth proxy)") — an
OpenAI-compatible, local, no-API-key preset defaulting to the established
proxy port (http://localhost:10531/v1), base URL overridable. Claude uses the
existing `custom-anthropic-compatible` entry (no canonical Claude-proxy port
to hardcode — not invented).
- docs/oauth-subscriptions.md: full recipe — built-in OAuth login (C1), the
verified reason subscriptions need a proxy, copy-paste config for ChatGPT and
Claude via a local proxy, and the sanctioned alternatives (API key; spawning
the real `claude` CLI for Anthropic subscription automation).
Local run check: a config using `catalogID: chatgpt-proxy` (base URL pointed at a
mock OpenAI-compatible proxy) routes `zero exec` through the proxy with no auth
header and returns a completion.
Gates: gofmt/vet/build(host+linux+windows)/test/staticcheck(clean)/govulncheck(0)/
deadcode(no new) all pass. No new module deps.
* provideroauth: OpenRouter browser PKCE login (mints an API key) + `zero auth openrouter`
Adds the cleanest real OAuth login surfaced by the provider survey: OpenRouter's
loopback PKCE flow. It needs no client_id (public), uses S256, and the exchange
mints a normal OpenRouter API key — so no token-spoofing or vendor-backend gating
is involved (unlike ChatGPT/Claude subscriptions).
- internal/provideroauth.OpenRouterLogin: binds a 127.0.0.1 loopback, opens/prints
https://openrouter.ai/auth?callback_url=…&code_challenge=…&code_challenge_method=S256,
captures the code, and POSTs it + the PKCE verifier to
/api/v1/auth/keys → {key}. PKCE binds the code, so no separate CSRF state is
needed. Injectable base URL / HTTP client / browser-opener for tests.
- cli: `zero auth openrouter` runs the flow and prints the minted key with usage
guidance. ZERO_OPENROUTER_BASE_URL overrides the endpoint (self-hosted/tests).
Tests: full flow over httptest (mint, missing-code, non-2xx exchange, empty key,
browser-error). Local run check via the real binary against a mock: the command
emits the correct PKCE authorize URL, the loopback callback is captured, and the
key is minted end-to-end.
First of the in-wizard OAuth providers; the wizard "Log in with OAuth" option
(next commit) calls this same flow and saves the key to the profile.
Gates: gofmt/vet/build(host+linux+windows)/test -race/staticcheck(no new)/
govulncheck(0)/deadcode(no new) all pass. No new module deps.
* oauth: built-in provider presets + xAI (Grok) preset
Adds an overridable preset layer to the env-driven OAuth registry so well-known
providers work without per-field env configuration. ResolveConfig now seeds each
field from a baked-in preset and lets the matching ZERO_OAUTH_<NAME>_* env var
override it (env wins); flow falls back to the preset then loopback.
First preset: xAI (Grok) — standard OIDC, PUBLIC client (no secret), loopback +
device. The access token is accepted directly as a bearer on api.x.ai/v1 (an
OpenAI-compatible endpoint), so the existing C1 wiring uses it for an xai provider
profile — no header/identity spoofing. `zero auth login xai` now works out of the
box. CAVEATS (documented in code + help): the client_id is a public Grok-CLI
client not formally documented by xAI (may change; override via env), and use
requires a SuperGrok / X Premium+ subscription.
Tests: preset resolution, env-overrides-preset (per field + flow), and that a
provider with neither preset nor env still errors. Local run check: `zero auth
login xai --device` (endpoints pointed at a mock, client_id NOT overridden) logs
in and the mock confirms the preset client_id b1a00492-… was sent.
Gates: gofmt/vet/build(host+linux+windows)/test/staticcheck(no new)/govulncheck(0)/
deadcode(no new) all pass. No new module deps.
* tui: in-wizard "Log in with OAuth" option (OpenRouter) + browser opener
Adds the credential-step OAuth login the setup wizard was missing. For a provider
that supports a usable browser OAuth flow (currently OpenRouter), the credential
screen now offers "ctrl+o to log in with OAuth in the browser (no key needed)"
alongside pasting a key.
Flow: ctrl+o → the wizard marks itself oauthPending and dispatches an async
tea.Cmd that runs provideroauth.OpenRouterLogin (opens the browser via the new
internal/browser opener, captures the loopback callback, mints a key). While it
runs the step shows "Opening your browser… (or run: zero auth openrouter)";
Esc cancels. On success the minted key fills the credential and the wizard
advances to model selection; on failure the redacted error is shown and the user
can retry or paste a key.
- internal/browser: cross-platform OpenURL (open / xdg-open / rundll32) with a
pure, unit-tested per-OS command builder.
- provider_wizard: oauthPending/oauthErr state, providerWizardSupportsOAuth
(OpenRouter only — ChatGPT/Claude use the proxy preset, others paste a key),
the async cmd + result msg, and credential-step render/keys.
- model.Update routes the OAuth result message.
Tests: openCommand per-OS; supportsOAuth (openrouter yes / openai no); ctrl+o
starts OAuth for OpenRouter and is a no-op otherwise; apply-success advances +
applies the key; apply-error stays + surfaces the message; render shows the hint
and the pending state. The interactive browser flow itself is the same one C3
verified end-to-end against a mock.
Gates: gofmt/vet/build(host+linux+windows)/test -race/staticcheck(no new — the
SA1019 mouse nits are pre-existing on origin/main)/govulncheck(0)/deadcode(no new).
No new module deps.
* docs: cover the built-in OpenRouter + xAI OAuth logins
Document the two turnkey OAuth providers added in this branch (OpenRouter browser
key-mint via `zero auth openrouter` / wizard ctrl+o; xAI via `zero auth login
xai`), alongside the existing subscription-proxy and custom-provider guidance.
* tui: method-first provider setup — a prominent "Sign in with OAuth" path
Reworks `/provider` setup to lead with a connect-method chooser, modeled on Codex
/ Claude Code / gemini-cli (verified via a UX survey), so OAuth is a first-class,
separate option instead of a key buried at the credential step.
New first step "How do you want to connect?":
❯ Sign in with OAuth (default when any OAuth provider exists)
One-click browser login, no API key to copy (OpenRouter, xAI).
Paste an API key / browse providers
Any of 20+ providers, a local model, or a subscription via proxy.
- OAuth path → a dedicated list of ONLY OAuth-capable providers
(providercatalog.OAuthProviders()), each with a mode badge; selecting one
runs the browser login (OpenRouter mints a key; xAI/preset providers store a
refreshable token via the engine) and jumps straight to model selection,
skipping the key/endpoint steps. A waiting screen shows progress + a CLI
fallback; Esc cancels; errors are redacted and retryable.
- Browse path → the existing full catalog → key/endpoint/model, unchanged.
- ChatGPT/Claude are NOT offered as in-app OAuth (they can't work) — they remain
reachable as the proxy/custom providers under "browse".
- providercatalog: Descriptor gains OAuth/OAuthMintsKey/OAuthDeviceFlow; openrouter
+ xai are flagged; OAuthProviders() is the single source of truth.
- wizard: new method step + OAuth provider list + per-provider OAuth dispatch
(reuses provideroauth.OpenRouterLogin and the oauth engine login), waiting/
render/keys/step-line/retreat all updated; mouse + keys ignored while a login
is in flight.
Tests: catalog OAuth flags + OAuthProviders(); method chooser OAuth vs browse
paths; OAuth dispatch from the list; retreat→method; render shows the chooser +
provider list; existing wizard tests updated for the new first step. All tui tests
pass under -race.
Gates: gofmt/vet/build(host+linux+windows)/test/staticcheck(no new)/govulncheck(0)/
deadcode(no new) all pass. No new module deps.
* docs: document the /provider "Sign in with OAuth" method chooser
* tui: mirror the "Sign in with OAuth" method chooser in first-run onboarding
First-run setup (onboarding.go) was a separate flow that still showed its bare
provider list. Add the same connect-method chooser so new users get the prominent
OAuth path too, keeping /provider and first-run as one mental model.
- New setupStageMethod (after Welcome): "How do you want to connect?" with
"Sign in with OAuth" (default) and "Paste an API key / browse providers".
- OAuth path filters the provider list to OAuth-capable providers, runs the same
browser login (OpenRouter mints a key → captured as the setup credential; xAI
stores a refreshable token and the profile is named after the provider so the
runtime resolver attaches the bearer), shows a waiting screen, then jumps to
model selection (skipping endpoint/name/credentials). Esc cancels; errors are
redacted.
- Browse path is the existing flow, unchanged. setupStages()/navigation/back/
progress/footer/mouse all updated; OAuth result routed via model.Update.
Tests: method chooser OAuth path (OAuth-only list + retreat clears it) and
apply-OAuth-success advances to model with the minted key; existing onboarding
tests updated for the new step (pressSetupContinue transparently skips it). All
tui tests pass under -race.
Gates: gofmt/vet/build(host+linux+windows)/test/staticcheck(no new)/govulncheck(0)/
deadcode(no new) all pass. No new module deps.
* catalog+tui: show ChatGPT/Claude in the OAuth chooser as local-proxy entries
The "Sign in with OAuth" chooser listed only the real-OAuth providers (OpenRouter,
xAI), so ChatGPT and Claude looked missing. They can't do real in-app OAuth — a
subscription token is Cloudflare-gated (ChatGPT) / ToS-restricted for third-party
use (Claude) — so instead of hiding them or faking a login, list them honestly as
"subscription · needs a local proxy" entries that route into proxy setup.
- catalog: new Descriptor.OAuthProxy flag + OAuthProxyProviders(); mark
chatgpt-proxy and a new claude-proxy (local Anthropic-compatible, keyless)
entry. localAnthropic() helper. Proxy entries are not OAuth (no fake login).
- wizard + onboarding: the OAuth list now appends the proxy entries after the
real OAuth providers (catalog order keeps real ones first). Selecting a proxy
entry leaves OAuth mode and drops into the normal browse flow with the proxy
provider selected and its default URL/model pre-filled, so the user just
points Zero at the local proxy they run. No browser login is launched.
- providerWizardNeedsEndpoint now true for proxy entries (the local port
varies); faint badge "subscription · needs a local proxy".
- docs/oauth-subscriptions.md: chooser now shows the four entries and explains
the proxy routing.
Tests: catalog classification (OAuthProviders vs OAuthProxyProviders, flags);
wizard + onboarding proxy-entry routing (leaves OAuth mode, starts no login,
lands on endpoint with the URL pre-filled). Full suite green under -race;
staticcheck/govulncheck/deadcode clean. No new deps.
* oauth+tui: add xAI device-code (RFC 8628) login to the OAuth chooser
The OAuth chooser only did browser/loopback login, which can't work on a headless
or SSH box (no browser to open). Add device-code as a first-class option for
providers that support it (xAI).
- oauth.Manager: PrepareDeviceLogin (request code) + CompleteDeviceLogin (poll +
store), splitting the monolithic device flow so the UI can display the
user_code + verification URI while it waits. CLI Login(Device:true) unchanged.
- tui (shared oauth_device.go): oauthPreferDeviceFlow() picks device by default
on SSH / headless Linux (no DISPLAY) or when ZERO_OAUTH_DEVICE is set;
oauthDevicePrepare/Complete drive the two phases off the UI goroutine.
- /provider wizard + first-run onboarding: device-capable providers show
"Enter sign in · d device code" — Enter uses the browser (or device if
headless), "d" forces device. The waiting screen shows the code + URL to
enter elsewhere, then polls; success advances to model selection. Esc cancels;
errors are redacted; device state is cleared on success/cancel/back.
- docs: document the device-code path and ZERO_OAUTH_DEVICE.
Tests: manager Prepare/Complete two-phase; wizard + onboarding 'd' shortcut,
device-code display + poll dispatch, error surfacing, and success clearing device
state. Full suite green under -race; staticcheck/govulncheck/deadcode clean. No
new deps.
* tui: authenticate model discovery with the OAuth token so the live list shows
After an xAI sign-in the bearer lives in the token store, not as a pasted key, so
the post-login model step ran /models with no credential → it always fell back to
the curated list (with a discovery error). Now the user picks from the real live
model list after OAuth, like other CLIs.
- oauthStoredToken() resolves a fresh stored token for a token-login provider.
- wizard + onboarding discovery resolve and inject that token into the discovery
profile inside the async goroutine (token refresh is network I/O); OpenRouter
still uses its minted key, API-key users are unchanged. Discovery timeout
bumped 8s→12s to cover an on-demand refresh.
- The resolved token is added to the redaction secret set so it can never leak
into a surfaced discovery error. If discovery still fails (offline), the
curated per-provider fallback list is shown as before — the user always gets a
pickable list.
Tests: oauthStoredToken reads a seeded login / returns empty when absent;
wizard discovery injects the stored token into the /models profile. Hermetic via
ZERO_OAUTH_TOKENS_PATH. Full suite green under -race; staticcheck/govulncheck/
deadcode clean. No new deps.
* catalog+tui: drop ChatGPT/Claude from the OAuth chooser — OpenRouter + xAI only
Reverts the proxy-entry listing: the "Sign in with OAuth" chooser now shows only
the providers that do real OAuth (OpenRouter, xAI). ChatGPT/Claude looked
confusing there since they can't actually sign in — they remain reachable via
"Paste an API key / browse providers" + the local-proxy recipe in
docs/oauth-subscriptions.md.
- catalog: remove the Descriptor.OAuthProxy flag, OAuthProxyProviders(), the
oauthProxyProvider/localAnthropic helpers, and the claude-proxy entry.
chatgpt-proxy stays as a plain browse/config entry (its pre-existing role).
- wizard + onboarding: OAuth list = OAuthProviders() again; removed the
proxy→browse routing, the OAuthProxy badge, and the OAuthProxy endpoint rule.
- device-code + OAuth-token model discovery (the later features) are untouched.
- docs + tests updated to the two-entry chooser.
Gates: gofmt/vet/build(host+linux+windows)/test -race/staticcheck/deadcode all
clean. No new deps.
* cli: recognize a stored OAuth login as a provider credential
A provider authenticated via OAuth (e.g. `zero auth login xai`) has no inline
key or env var, so setupRequired treated it as unconfigured and forced onboarding
on every launch — and the active provider could be passed over for a different
one. setupRequired now also counts a stored OAuth login (read from the token
store) as a credential, so an OAuth-logged-in provider stays usable without
onboarding.
Tests: providerHasOAuthLogin (by name/catalog id); setupRequired returns false
for an OAuth-logged-in provider, true for a keyless one with no login.
* Address review feedback on OAuth provider login
Resolve CodeRabbit review comments on this PR:
- PrepareDeviceLogin: self-bound the network prepare (endpoint discovery +
device-code request) with opts.Timeout (default 5m) like Login, so a caller
passing an unbounded context still gets the timeout guarantee.
- CompleteDeviceLogin: bound the poll by the device code's own ExpiresAt so an
unbounded caller context cannot leave an in-flight request hanging past the
code's lifetime (PollDeviceToken already re-checks between polls).
- OAuthProviders(): return cloned descriptors, matching All()/the other
accessors, so callers can't mutate the shared catalog backing slices.
- Onboarding applySetupOAuth / applySetupOAuthDeviceCode: ignore a stale result
for a provider the user has since switched away from, so a late login can't
capture a credential against the wrong provider.
- Onboarding: surface a failed OAuth login on the provider list the user lands
on (the waiting screen that owned the error is gone once oauthPending clears),
so the failure isn't silent and the user can retry.
- Onboarding: hide the "Sign in with OAuth" method when this setup's own
provider list has no OAuth-capable entry, so it can't be selected into an
empty provider list (setupMethodOptions).
- docs/oauth-subscriptions.md: label the bare example fences (text) and update
the Anthropic policy section for the April 4 2026 third-party cutoff.
Adds tests for the descriptor-clone isolation and the method-option filtering.
* oauth: gate built-in presets behind ZERO_OAUTH_ALLOW_PRESETS
The xAI preset baked a third-party OAuth client_id into the default
credential path, which breaks the engine's documented invariant that no
third-party OAuth client identity is used unless the operator configures
it. Rather than drop the convenience entirely, make presets opt-in:
- ResolveConfig only consults builtinOAuthPresets when ZERO_OAUTH_ALLOW_PRESETS
is truthy (presetsAllowed); by default every field must come from
ZERO_OAUTH_<NAME>_* env, so the binary's preset client_id is never used
without an explicit opt-in.
- The "not configured" error now points the user at ZERO_OAUTH_ALLOW_PRESETS
when a preset exists, so the wizard/CLI failure is self-documenting.
- Refresh the package, Registry, and preset docs to describe the opt-in;
env still overrides any preset field.
- docs/oauth-subscriptions.md: document the flag for one-click xAI sign-in.
Keeps both: invariant-clean by default, one-click xAI for opted-in users.
Tests: preset resolution now requires the opt-in; added a gate-off test
asserting xAI stays inert (and the error names the flag) without it.
* oauth: read a stored token without resolving provider config
GetFresh resolved the provider OAuth config for every read via loadForKey,
even when the stored token was still valid and no refresh would happen.
With presets gated behind ZERO_OAUTH_ALLOW_PRESETS, that made a stored xAI
login unreadable (and stalled the proactive refresh scheduler) unless the
opt-in flag was set at read time — config that is only needed to refresh.
Split loadForKey into loadToken (token only) and resolveConfigForKey
(refresh-only). GetFresh now returns a still-valid token without touching
the config; only an actual refresh (or Handle401) resolves endpoints +
client. The scheduler's load uses loadToken too, so proactive refresh
survives a gated preset and only the refresh attempt needs the config.
Fixes the Smoke failures TestOAuthStoredTokenReadsStoredLogin and
TestProviderWizardDiscoveryUsesOAuthToken (a fresh stored token must be
readable without the preset opt-in).
* Address CodeRabbit re-review round 2 on OAuth provider login
- providerio SendWithAuthRetry: resolve the auth headers BEFORE dispatching, so
a resolver error returns immediately instead of letting SendWithRetry send an
unauthenticated request (leaking the path/body).
- tui handleMouse: ignore all mouse input (clicks + wheel) while a provider-wizard
OAuth login is pending, so a stray scroll can't change the selected provider
mid-login (mirrors the keyboard pending guard; the setup mouse handler already
did this).
- cli runAuthOpenRouter: reject unexpected args/flags (e.g. --json) instead of
silently running the login, matching the other auth subcommands.
- cli auth help: align the preset wording with the opt-in gate — openrouter works
out of the box; xai needs ZERO_OAUTH_ALLOW_PRESETS=1 or ZERO_OAUTH_XAI_*.
- onboarding_test: compute selectedMethod from m.setupMethodOptions() (the
setup-filtered list) instead of providerWizardMethodOptions(), which could index
past the end when OAuth is hidden.
- docs/oauth-subscriptions.md: sharpen the Anthropic timeline — Feb 19 2026 docs
update, then the April 4 2026 enforcement that stopped subscription OAuth tokens
working in third-party harnesses; current path is an API key or pay-as-you-go.
Adds tests for the no-dispatch-on-resolver-error path and openrouter arg rejection.
* Address CodeRabbit re-review round 3 on OAuth provider login
Two findings missed in the previous round:
- oauth manager resolveConfigForKey: resolve issuer metadata before refreshing.
It returned ResolveConfig's result without calling resolveEndpoints, so a
provider configured with only ZERO_OAUTH_<NAME>_ISSUER_URL (no TOKEN_URL) had
an empty token endpoint and could not refresh on GetFresh/Handle401. Thread the
context through and run discovery; adds a test that an issuer-only provider
refreshes via the discovered token endpoint.
- provider_wizard_oauth_test: assert openrouter is actually present in the OAuth
list before selecting it, so the test can't silently pass against a different
provider if openrouter is renamed/removed.
(The re-flagged PrepareDeviceLogin timeout is already handled — it self-bounds
with opts.Timeout, same as Login.)
* Address CodeRabbit re-review round 4 (nitpicks) on OAuth provider login
- presets scopesOrPreset: return a copy of the preset scopes so a caller
appending to cfg.Scopes can't mutate the shared global preset slice.
- provideroauth/openrouter: use bytes.NewReader(payload) instead of
strings.NewReader(string(payload)) (avoid a needless []byte->string copy).
- onboarding setupCredentialSummary: show "OAuth token" on the Ready screen for
an OAuth token-login provider (e.g. xAI) instead of "env var XAI_API_KEY" — it
is authenticated by a stored token, not a key. Key-minting OAuth (OpenRouter)
still resolves to a saved key.
(The flagged "Registry doc says no built-ins" was already corrected in the
preset opt-in commit — the doc now describes the ZERO_OAUTH_ALLOW_PRESETS gate.)
Adds tests for the scope-slice copy and the OAuth-token credential summary.
* tui: migrate off the deprecated Bubble Tea mouse Type API (SA1019)
The mouse classification helpers checked both the current Button/Action pair and
the deprecated msg.Type / tea.Mouse* constants. Bubble Tea's parser always
populates Button+Action and only DERIVES Type from them, so the Type fallbacks
were pure redundancy (a left-button drag is Action==Motion, already covered by
mouseMotion). Drop them — clears all 11 SA1019 findings in internal/tui with no
behavior change. The left-drag test now asserts the Action==Motion path directly.
Gates: gofmt/vet/build(host+linux+windows)/test -race/staticcheck(SA1019 0)/
deadcode(no new) all pass.
* notify: wire the webhook sink behind an opt-in env var
The WebhookSink (Slack / generic JSON POST on turn completion) was fully
implemented, redaction-safe, and tested — but never attached to a Notifier, so
deadcode flagged its entire machinery (NewWebhookSink, Emit, text, redactLinks,
log, eventType, readSnippet) as unreachable.
Wire it via a small, testable helper, MaybeAddWebhookSink(n, env, logf):
- reads ZERO_NOTIFY_WEBHOOK_URL (+ optional ZERO_NOTIFY_WEBHOOK_SUMMARY)
- no-op when the URL is unset/blank, so it is safe to call unconditionally
- sourcing the URL from the environment keeps the secret token out of any
on-disk config file
Attached at both notifier construction sites:
- headless exec: failed deliveries log to stderr (never stdout); the sink
redacts before logging
- TUI: logf=nil so a delivery failure can't corrupt the alt-screen
The sink stays gated by the existing Mode/focus policy (verified by
TestNotifierOffSuppressesSinks), so a webhook only fires when notifications are
enabled (e.g. --notify both) — no change to default behavior. Opt-in and
fail-closed: with the env var unset nothing is attached.
deadcode: 100 -> 93 unreachable funcs (the 7 WebhookSink entries become
reachable); no new dead code. gofmt/vet/build(host+linux+windows)/test -race/
staticcheck(no new)/govulncheck(0) all pass.
* swarm: add opt-in recurring-spawn scheduler (wakeup + daily)
Adds a dependency-free Scheduler to internal/swarm so a member can be spawned on
a recurring schedule, plus a swarm_schedule tool to drive it.
- Schedule is interval-based ("wakeup", every >= 1s) with an optional first
delay and a MaxRuns cap; a daily "HH:MM" ("cron") time is expressed as a 24h
interval whose first delay lands on the next occurrence.
- Each fire spawns a fresh member through the existing Swarm.Spawn path, so
members inherit the recorded policy and are bounded by the team slot cap +
queue. Non-overlapping by default: a fire is skipped while the job's previous
spawn is still running, preventing queue pile-up.
- Scheduler is lazily created (Swarm.Scheduler()), opt-in (nothing runs until a
job is added), and tied to the Swarm's base context — Swarm.Close() stops the
scheduler first so no spawn fires during shutdown.
- swarm_schedule tool: action add|list|cancel; prompt-gated like swarm_spawn;
add validates schedule + agent type up front (fail fast).
The timing loop takes an injectable ticker, so the tests drive fires
deterministically with no real time (fire/skip/cap/cancel/close, plus pure
nextDailyDelay/parseClock/swarmInt). All swarm tests pass 3x under -race.
Gates: gofmt/vet/build(host+linux+windows)/test/staticcheck(clean)/govulncheck(0)/
deadcode(no new) all pass.
* daemon/remote: SessionLink + git-bundle upload over the bridge
Adds an opt-in way to ship a local repo's git history to a remote daemon and
link a remote working tree to it — without a shared filesystem or a network git
remote. The daemon core (protocol/server) is untouched; all changes live in the
remote package plus a CLI hook.
Wire:
- The auth handshake gains an optional Mode field ("" / "session" => a daemon
session as before; "bundle" => a one-shot git-bundle upload). Unknown modes,
or bundle mode when uploads are disabled, are denied (fail closed).
- Bundle transfer reuses the daemon frame codec: a header frame (link id +
exact size), then the bundle bytes as KindData frames, then a result frame.
Server (Bridge):
- BridgeOptions.BundleDir enables uploads (empty => refused). A received bundle
is size-capped (default 256 MiB), staged to a temp file, `git bundle verify`-d,
then `git clone`-d into a staging dir and atomically renamed over
<BundleDir>/<link-id>. Link ids are sanitized to a single path component and
containment is re-checked, so an upload can never escape the bundle dir.
Client:
- UploadRepoBundle creates `git bundle create --all` of a work tree, hashes it
(sha256), streams it over an authenticated bundle-mode TLS connection, and
returns a SessionLink {address, link id, remote path, bundle sha}. SessionLink
persists as a 0600 atomic JSON file (token never stored) with Save/Load/Validate.
CLI:
- `daemon serve-remote --bundle-dir <d>` enables uploads.
- `daemon link --remote <addr> --repo <dir> --id <name> [--out <file>]` uploads
and prints the remote path (+ a ready run command); `daemon link --show <file>`
prints a saved link.
Tests: git bundle create/verify/clone roundtrip; link-id sanitization + path
containment; SessionLink save/load/validate (0600); and a full bridge upload E2E
over real TLS (extract + content check + re-upload-replaces + disabled + bad
token). All remote tests pass 2x under -race; CLI smoke verified via the binary.
Gates: gofmt/vet/build(host+linux+windows)/test/staticcheck(no new)/
govulncheck(0)/deadcode(no new) all pass.
* mcp: unify OAuth token storage onto the shared oauth store
MCP server tokens lived in their own mcp-oauth-tokens.json with a bespoke
codec/lock, separate from the provider-login store (internal/oauth). The oauth
store already reserved the "mcp:" key namespace for exactly this. Unify them.
- mcp.TokenStore now delegates to internal/oauth.Store, keying each server's
token as "mcp:<server>". Its public API (StoredToken/TokenStatus/Save/Load/
Delete/Status/FilePath) is unchanged, so callers (network client, CLI) are
untouched. The bespoke read/write/lock code is removed in favor of the shared,
cross-process-locked, atomic store.
- Transparent, non-destructive migration on first construction: a legacy
mcp-oauth-tokens.json is imported into the unified store (only entries not
already present — a newer unified token is never overwritten), then renamed to
a ".migrated" backup so it is imported at most once and stays recoverable.
A missing / unreadable / unknown-schema legacy file is left untouched and
never blocks startup. An explicit FilePath suppresses the default-path
migration so tests/overrides can't touch the real user config.
- Server names that can't form a valid namespaced key (oauth.ValidateKey) are
rejected on save and skipped during migration. Added oauth.Store.FilePath().
The token format was already identical (access/refresh/type/scopes/expires), so
this is a key rename, not a format change. Existing TokenStore tests pass
unchanged (they exercise the API); added migration + namespacing + newer-wins
tests. Local run check: `zero mcp oauth status` migrates a seeded legacy file
into oauth-tokens.json under mcp:demo, renames the legacy to .migrated, and leaks
no token material.
Gates: gofmt/vet/build(host+linux+windows)/test -race/staticcheck(clean)/
govulncheck(0)/deadcode(no new) all pass.
* oauth: add an opt-in OS keyring storage backend
Introduces a pluggable storage backend for the unified oauth token store and a
dependency-free OS keyring implementation, selected with
ZERO_OAUTH_STORAGE=keyring (default stays the 0600 file).
- internal/keyring: a small, third-party-dep-free secret store backed by the OS
tooling — `security` on macOS, `secret-tool` (libsecret) on Linux; other
platforms report unsupported. On Linux the secret is passed via stdin (never
the argv); on macOS `security` takes it as an argument (briefly visible in the
process list) — documented, and a reason to prefer the file backend there.
A runner seam makes the per-OS command logic fully testable without a real
keychain.
- internal/oauth: Store now persists its JSON blob through a blobStore backend.
fileBlob preserves the exact prior behavior (0600, atomic write, cross-process
lock file). keyringBlob stores the blob as one base64 entry (keeps multi-line
JSON a single control-char-free value) via a KeyringClient. NewStore resolves
the backend from StoreOptions.Storage or ZERO_OAUTH_STORAGE; unknown storage
and an unavailable keyring fail closed with a clear error.
Because both `zero auth` and the (now unified) MCP token store construct their
store through oauth.NewStore, the keyring backend is honored by both with no
call-site changes. Added oauth.Store.FilePath() returns a "keyring:" identifier
for that backend.
No new module dependency (go.mod unchanged). Tests: keyring round-trip/not-found/
unsupported/missing-binary/stdin-not-argv via an injected runner; store keyring
round-trip + base64-at-rest + storage selection; all existing file-backend tests
pass unchanged. Local run check (macOS): validated the exact `security`
add/find/delete shapes and the not-found exit code (44) against the real binary,
and `mcp oauth status` with ZERO_OAUTH_STORAGE=keyring reads the real keychain
cleanly.
Gates: gofmt/vet/build(host+linux+windows)/test -race/staticcheck(no new)/
govulncheck(0)/deadcode(no new) all pass.
* all: prune unused unexported functions (dead-code sweep)
Removes 15 unexported functions/types/consts that are unreachable even from
tests (every one flagged by staticcheck U1000), clearing the U1000 noise the v16
audit surfaced. Conservative by design: only unexported, test-unused symbols are
removed — exported internal API (which may be an intentional surface or used only
by tests) is deliberately left in place.
Removed:
- agenteval: validateExpectedChangedFiles
- providers/gemini: const providerName
- tui: stripMarkdownInline, removeTrailingAtToken,
noopModelSwitchCompactionPolicy (+ BeforeModelSwitch), deleteComposerLineBefore,
deleteComposerLineAfter, deleteCompletedFileMentionBefore, mcpViewStatus,
mcpServerLines, fitCommandOutput, (*commandPicker).clearQuery,
tuiTheme.onPanel2, indentText
No behavior change (the symbols had no callers). staticcheck U1000 is now empty;
deadcode drops accordingly with no genuinely-new unreachable functions (verified
by a name-based diff vs origin/main). gofmt/vet/build(host+linux+windows)/full
test all pass.
* Address review feedback on the audit backlog
Resolve CodeRabbit review comments on this PR:
- keyring isNotFound: match the specific not-found exit codes (macOS `security`
44 = errSecItemNotFound; `secret-tool` 1) instead of treating any non-zero
exit as "no entry", so a real failure (locked keychain, permissions) surfaces
instead of being masked as absent.
- swarm scheduler daily_at: recompute each fire as the next local HH:MM rather
than adding a fixed 24h, so a wall-clock daily schedule does not drift across
DST. Adds Daily/Hour/Minute to Schedule and a clock to the Scheduler.
- swarm scheduler: reject Add when the scheduler context is already canceled
(not just when closed); re-check cancellation after a tick so a tick+cancel
race can't spawn one extra member after Cancel/Close.
- swarm schedule_tool swarmInt: reject non-integer / non-finite JSON numbers
(max_runs=1.9 errors instead of silently truncating to 1).
- mcp migrateLegacy: resolve a relative LegacyPath to absolute before the
same-file guard so it can't be bypassed by a relative spelling.
- oauth keyring store: serialize the keyring read-modify-write with a
cross-process lock file (best-effort, beside the config dir) so concurrent
processes don't clobber each other's tokens; previously withLock was a no-op.
Adds tests for the non-not-found keyring error, the daily delay recompute, the
Add-after-cancel guard, and non-integer swarmInt.
* swarm: make nextDailyDelay roll the day, not add 24h (DST)
CodeRabbit follow-up: the run loop already recomputes a daily job's delay each
cycle, but nextDailyDelay itself rolled "passed today" to the next occurrence
with Add(24*time.Hour). On a DST changeover day (23h/25h) that fires an hour
early/late. Use time.Date(..., day+1, hour, minute, ...) so Go normalizes the
date and applies the correct local offset, holding the wall-clock HH:MM across
DST. Adds an America/New_York spring-forward test (skips if tzdata is absent).
* oauth: opt-in AES-256-GCM encrypted-at-rest token storage
OAuth Phase 2: add an encrypted token-store backend. The default backend still
writes the 0600 plaintext JSON unchanged; setting ZERO_OAUTH_STORAGE=encrypted-file
encrypts the token file with AES-256-GCM under a per-user random 32-byte secret
persisted 0600 beside the token file (<tokens>.secret).
- internal/oauth/encrypt.go: aesGCMCrypter (nonce||ciphertext, GCM tamper
detection) + load-or-create secret (atomic 0600 write). Pure stdlib, no dep.
- Store gains an optional crypter: readState decrypts before unmarshal, writeState
encrypts after marshal. A missing secret, a short blob, or a failed auth tag all
fail closed (no silent empty/plaintext fallback).
- CLI: newAuthManager selects the backend from ZERO_OAUTH_STORAGE; `zero auth`
help documents it.
Tests: crypter round-trip + tamper, secret create/persist/0600/wrong-size,
store-through-encrypted (ciphertext on disk, round-trip, Status/Delete, tamper +
missing-secret fail-closed). Gates: gofmt/vet/build(host+linux+windows)/test
-race/staticcheck(no new)/govulncheck(0)/deadcode(no new) all pass. Local run
check: device login under ZERO_OAUTH_STORAGE=encrypted-file writes a ciphertext
file (no plaintext token), .secret is 0600, and `zero auth status` reads it back.
* Address review feedback on encrypted token storage
Resolve CodeRabbit review comments on this PR:
- newAuthManager: reject an unsupported ZERO_OAUTH_STORAGE value instead of
silently downgrading to plaintext. A mistyped non-empty value now fails fast
("invalid ZERO_OAUTH_STORAGE ... (supported: encrypted-file)") so a user who
believes encryption is on is never left writing plaintext.
- loadOrCreateSecret: fix the first-run TOCTOU race. os.Rename publishes by
clobbering on POSIX, so two concurrent processes could each generate a secret
and the loser would silently orphan every token it then encrypts. Create the
secret exclusively (O_CREATE|O_EXCL); the loser adopts the winner's on-disk
secret. Factored the read+size-validate into readSecretFile and dropped the
now-unused clock plumbing from the crypter.
- encrypt_test: handle os.Stat / os.ReadFile errors before dereferencing the
result (mode/index) so a real I/O failure surfaces instead of a panic.
Adds a test asserting an invalid ZERO_OAUTH_STORAGE fails fast.
* oauth: retry the secret read when losing the first-run create race
CodeRabbit follow-up: the O_EXCL create path read the on-disk secret
immediately after EEXIST, but the winner may not have finished writing it
yet, so a loser could observe a short file and fail transiently. Retry the
read briefly (bounded) so concurrent first-run invocations converge on the
winner's secret. Adds a -race concurrency test asserting convergence.
* web_search: fix#209 follow-ups (tolerant score, render gate, redaction, FQDN)
Verified-and-fixed defects from a review of #209:
- Tolerant score decode (lenientScore): a stringified/odd/null "score" no longer
makes json.Unmarshal discard the entire response; it degrades to absent.
- Score render gate rounds first (>= 0.01) so tiny positives and negatives no
longer print a noisy "score 0.00".
- Domain-filter error now goes through redactWebSearchText, matching the other
error paths.
- Trailing-dot FQDN symmetry: "react.dev." and "react.dev" normalize the same
on both the allowlist and result sides.
Left as-is (intended, tested): scheme-less host:port allowlist entries. Includes
mixed-type domains-array tests.
* web_search: only register the built-in tool when a backend is configured
When no search backend is set (ZERO_WEBSEARCH_BASE_URL / ZERO_WEBSEARCH_BACKEND),
CoreNetworkTools no longer offers the built-in web_search. Previously it was always
registered, so a model with an MCP search tool (e.g. Exa) would burn several calls
+ high-risk permission prompts on the built-in tool — which can only return
"no backend configured" — before falling back to the working MCP tool.
Now: no backend -> only web_fetch (+ any MCP search tool) is offered, so the model
uses the MCP search directly. A configured backend behaves exactly as before.
Tests: registry/web_search/specialist tests that asserted web_search is always in
CoreTools now configure a backend; added a test that it is omitted when none is set.
(Pre-existing, unrelated: internal/cli doctor/exec tests fail in the sandbox on
origin/main too — network/provider-dependent, not touched here.)
* tui: copy selected transcript text to the native OS clipboard
Selecting transcript text auto-copies on mouse release, but the copy went only
through OSC52 (ansi.SetSystemClipboard), which macOS Terminal.app doesn't support
at all and iTerm2 / some Windows Terminal builds have off by default — so the
selection silently never reached the clipboard.
Copy via the native clipboard (pbcopy / clip.exe / xclip) first, which works on
local terminals regardless of OSC52, and fall back to OSC52 for remote/SSH
sessions where no local clipboard utility is reachable. atotto/clipboard was
already in the module graph (indirect); promoted to a direct import — no new
dependency.
Gates: gofmt/vet/build(host+linux+windows)/tui test -race/staticcheck all pass.
* tui: collapse long tool output by default with click-to-expand
Long, noisy tool output (web-search / MCP / read dumps) flooded the transcript.
Now a tool result whose output exceeds the live body cap renders collapsed by
default — head + a "▸ N lines — click to expand" footer — and you click the card
(while it is live) to expand (▾) / collapse. Mirrors the existing reasoning-row
collapse, so collapsed rows also flush to scrollback clean.
Scoped to avoid hiding what matters:
- diff tools (edit_file / apply_patch / write_file) never collapse — their body
must stay reviewable (matches the deeper flush cap intent);
- the uncapped detailed transcript view (Ctrl+O, bodyCap==0) never collapses —
it still shows full output;
- short output (<= cardBodyMaxLines) stays inline as before.
Reuses transcriptRow.expanded + the row toggle (toggleReasoningRow generalized to
toggleTranscriptRow); the card head is a clickable toggle line. Running tool
cards (spinner) are unchanged.
Tests: collapse/expand, short-output inline, diff tools never collapse, toggle,
and the head exposes a click toggle; existing diff + detailed-view tests updated.
Gates: gofmt/vet/build(host+linux+windows)/tui -race/staticcheck all clean.
* cli: fall back to a usable saved provider instead of re-running onboarding
If the active provider lacked a credential (e.g. it relies on an env var that
isn't set, or an OAuth login that isn't stored), the TUI forced first-run
onboarding on every launch — even when other saved providers were fully
configured. Each onboarding then appended yet another provider.
Now, when the active provider isn't usable, fall back to the first usable saved
provider (prefer a remote keyed one over a local endpoint) for the session, and
skip onboarding. Onboarding only runs when no configured provider is usable — a
genuinely fresh setup. Saved logins persist; switch the active provider with
`zero provider use <name>`.
Tests: firstUsableProvider prefers remote keyed, falls back to local, accepts a
keyless local proxy, and reports none-usable. Gates: gofmt/vet/build
(host+linux+windows)/staticcheck clean; the only failing cli tests
(doctor/exec, network-dependent) are pre-existing on origin/main.
* tui: hold the scroll position while output streams in
The chat viewport scroll offset is measured from the bottom, so when the
transcript grew (a streaming turn) the window followed the new bottom and yanked
the user off whatever they had scrolled up to read.
syncChatScroll now pins the viewport: while the user is scrolled up, it bumps the
offset by however many lines the body grew, so the absolute view holds where they
are reading; at the bottom (offset 0) it follows the tail as before. Hooked into
the single Update funnel; only the scrolled-up path renders the body, so the
common (at-bottom) case stays cheap.
Tests: pins by the growth delta while scrolled up; resets/follows at the bottom.
Gates: gofmt/vet/build(host+linux+windows)/tui -race/staticcheck clean.
* agent: search the web proactively instead of answering "I don't know"
When a web search/fetch tool is available and the user asks about something
external the model is unsure of or that may be current (a product, library,
company, price, version, recent event), the system prompt now tells it to search
and read results before answering, rather than replying that it doesn't know
without checking. Addresses the model saying "I don't know about that" until the
user explicitly asked it to search.
Tests: prompt includes the new guidance ("search the web and read"). Existing
prompt assertions use Contains, so the addition is non-breaking.
* tui: show clean labels for MCP tool cards (mcp_exa_web_search_exa → web search)
* agent: sharpen web-search guidance (disambiguate, refine, scale)
Strengthen the search rule so the model searches before answering about an
external entity/product/library/model/version/release it doesn't recognize,
doesn't assume the most common meaning of an ambiguous name (disambiguates by the
conversation's domain), and refines + re-searches when results look off-topic —
rather than confidently answering the wrong interpretation (e.g. reading "Fable 5"
as a video game instead of an AI model). Also: don't web-search timeless facts or
workspace-answerable questions (use the file tools), keep queries short, and scale
the number of searches to the question.
Tests: prompt includes the new guidance ("search the web before answering",
"do not recognize"). Existing assertions use Contains, so non-breaking.
* test: gofmt setup_fallback_test.go (comment alignment)
* Address review feedback on web_search + chat UX
Resolve CodeRabbit review comments on this PR:
- firstUsableProvider: skip a profile whose CatalogID no longer resolves
*and* that has no explicit BaseURL (no endpoint → unusable). A stale
CatalogID with a BaseURL still works as a custom endpoint and is kept.
- providerProfileIsLocal: classify by parsed URL hostname (exact
localhost / 127.0.0.1 / ::1) instead of substring matching, so hosts
like "notlocalhost.com" are no longer misread as local.
- lenientScore: reject non-finite scores (ParseFloat accepts "NaN"/"Inf")
so the documented score filter actually holds and the renderer never
emits a non-finite value.
- syncChatScroll: shift the pinned from-bottom offset by the signed line
delta so the view also holds when the body shrinks (card collapse,
transcript clear), clamped at zero; previously only growth was handled.
- copyTranscriptSelectionCmd: report "Copy failed" when both the native
clipboard and the OSC52 fallback fail, instead of claiming success;
keep the selection so the user can retry.
- Drop vestigial ZERO_WEBSEARCH_BACKEND test env: the native backend was
removed (MCP path), so the var is read by no product code and the
references misrepresented it as a config knob.
Adds tests for the local-host parsing, the catalog/BaseURL skip, and the
non-finite score rejection.
* Polish TUI title bar
* Show PR diff stats in TUI title bar
Add a small PR status service that detects the active GitHub PR or GitLab merge request via gh/glab, computes diff stats against the PR base with git diff --shortstat, and publishes updates to the TUI.
Render the resulting +N -N #PR segment in the title bar next to the branch, linked to the PR URL, while keeping the composer footer unchanged.
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/tui
Tested: git diff --check
* Make submitted prompt bubble terminal-safe
Replace half-block top/bottom rows in submitted user prompts with background-filled padding rows. macOS Terminal renders the half-block glyphs as visible horizontal artifacts, while filled-space padding keeps the prompt bubble height without relying on ambiguous block glyph behavior.
Update selectable transcript rendering to use the same padding helper and remove the now-unused half-block theme style.
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/tui
Tested: git diff --check
* Address PR status review feedback
* feat(daemon): TLS remote bridge — authenticated network access to the daemon
Add internal/daemon/remote: an OPT-IN, TLS-only network bridge on top of the
local daemon. A remote client drives the SAME SessionManager/Pool over the
network behind bearer-token auth, reusing the daemon's framing and dispatch
unchanged. The local Unix-socket daemon stays the default and is unchanged.
- Bridge: per accepted TLS connection, authenticate THEN hand to the daemon's
control dispatch via the new Server.ServeConn seam — a remote session runs
under the same sandbox/risk model as a local one (never bypasses it).
- Authenticator + TokenAuthenticator: constant-time bearer-token check, token
from $ZERO_DAEMON_REMOTE_TOKEN or a file; optional attestation hook (no-op
default). Auth happens before any control frame; failure closes the connection
after a small backoff; tokens are never logged.
- RemoteClient (DialRemote): verified TLS dial (never InsecureSkipVerify) + auth
handshake, then the existing daemon.Client via the new NewClientConn seam, so
remote and local share one protocol.
- Fail-closed: TLS mandatory (refuses without cert/key), a configurable
min-protocol-version floor, the 1 MiB frame cap reused, and a bounded
concurrent-connection cap that refuses overflow.
- CLI: `zero daemon serve-remote --addr --tls-cert --tls-key`; `run`/`attach`
gain `--remote <addr> [--token --ca-cert --server-name]`.
Daemon seams added (additive, behavior unchanged): Server.ServeConn,
NewClientConn. SessionLink (event POST) and git-bundle repo transfer are
documented follow-ups.
Tests cover auth accept/reject (env + file), TLS-required, cert verification,
version floor, run/status/attach round-trip, re-attach resumption, connection
cap, and ListenAndServeTLS/Close. Gates: gofmt / vet / build (host+linux+
windows) / test -race / staticcheck (no new) / govulncheck (0) / deadcode
(no new) all pass.
* swarm: de-flake TestMailboxConcurrentSends on Windows CI
The 200-way concurrent-send stress test inherited from the swarm package timed
out on Windows CI (103/200 sends hit the 10s lock timeout): the lock's 20ms
retry sleep starves waiters when Windows file ops are slow under heavy
contention. Shorten the retry to 2ms (a freed lock is re-acquired promptly) and
raise the test's lock timeout to 60s so a legitimately-slow CI never times a send
out. Passes repeatedly under -race. (CI de-flake of a test that surfaced on
#210's Windows smoke; functionally unrelated to the OAuth changes.)
* remote: address CodeRabbit re-review (TLS dial + Close idempotency)
- client: replace the deprecated, non-cancellation-aware tls.DialWithDialer with
a context-aware tls.Dialer.DialContext (resolves the golangci-lint noctx CI
blocker; the context bounds the dial + TLS handshake).
- bridge: Close now clears b.listener under the lock, so a repeat Close is a
no-op instead of returning a closed-listener error (matches its doc). Test
asserts a double Close succeeds.
Gates: gofmt/vet/build(host+linux+windows)/test -race/staticcheck(no new)/
govulncheck(0)/deadcode(no new) all pass.
* feat(oauth): reusable provider OAuth engine + `zero auth` CLI
Add internal/oauth: a transport/identity-agnostic OAuth 2.0 engine that powers
provider login alongside the MCP-server OAuth zero already has. It is additive
and provider-neutral — NO providers, endpoints, or client identities are baked
into the binary; every provider is configured entirely via ZERO_OAUTH_<NAME>_*
env vars, so the engine works with any OAuth 2.0 / OIDC server.
Engine (internal/oauth):
- PKCE (S256 mandatory; "plain" refused) + per-flow CSRF state.
- Authorization-code flow: BuildAuthorizationURL / ExchangeCode / Refresh, with
an https-only token-endpoint guard (loopback exempt) and error bodies that
carry only error/error_description (never token material).
- Loopback callback server: 127.0.0.1-only, OS-assigned port, single use, state
verified, then closed.
- Device-authorization grant (RFC 8628): RequestDeviceCode + PollDeviceToken
honoring authorization_pending / slow_down / interval / expiry (headless/SSH).
- RFC 8414 + OIDC discovery (issuer-driven endpoint resolution).
- Namespaced token store ("provider:<name>" / "mcp:<name>"), 0600, file-locked
(ownership-aware), atomic writes, malformed-fails-closed; GetFresh (on-demand
refresh) + Handle401 (forced refresh).
- Opt-in proactive RefreshScheduler.
CLI (zero auth):
- login <provider> [--device] [--scope ...], logout, status [provider],
refresh <provider> [--watch]. Status/output never print token material.
- Registered in app.go dispatch + top-level help.
MCP OAuth (internal/mcp) is left byte-for-byte unchanged (zero regression);
sharing the engine with MCP and encrypted-at-rest/keyring storage are documented
follow-ups. Tests cover every path (PKCE, auth URL, https-guard + redaction,
exchange/refresh, loopback capture + state-mismatch + timeout, device pending/
slow_down/expired, store namespacing/0600/locking, provider env resolution,
manager login (loopback+device)/GetFresh/Handle401, scheduler). Gates: gofmt /
vet / build (host+linux+windows) / test -race / staticcheck (no new) /
govulncheck (0) / deadcode (no new) all pass.
* oauth: fix Windows smoke — use OS-appropriate path in store-path test
TestResolveStorePathHonorsOverride hardcoded a unix "/tmp/custom/tok.json"
literal, which is not absolute on Windows (no drive), so ResolveStorePath
correctly resolves it against the current drive and the verbatim comparison
failed (D:\tmp\custom\tok.json). The production code is correct; the test now
builds the override with filepath.Join(t.TempDir(), ...) so it is absolute and
clean on every OS.
* oauth: address CodeRabbit review (11 findings)
Security / correctness:
- flow: caller-supplied ExtraAuthParams/extraParams can no longer override the
reserved OAuth/PKCE fields (state, redirect_uri, code_challenge[_method],
response_type, client_id) — e.g. forcing method=plain.
- device: a device-authorization response without expires_in now gets a bounded
default lifetime (fail closed), and the poll loop re-checks expiry AFTER the
interval sleep so it never polls past the deadline (e.g. after slow_down).
- lock: handle WriteString/Close errors on the token lock file — a partial write
no longer strands an undeletable lock.
- store: a non-nil env map is now authoritative (hermetic) — a controlled map
never falls back to ambient ZERO_OAUTH_* / HOME / XDG_CONFIG_HOME.
- scheduler: a transient loadForKey error backs off and retries (bounded) instead
of permanently stopping proactive refresh.
- cli auth: reject flags a subcommand does not accept (e.g. login --watch,
status --device) and reject empty --scope values — fail fast.
Lint blockers:
- manager: restructured the discovery fallback so the non-nil-err branch no longer
returns nil (nilerr).
- providers: dropped the ineffectual flow initializer (ineffassign).
Tests: assert Save/Load errors in the scheduler test; the slow_down device test
no longer depends on post-expiry polling. Added tests for reserved-param
stripping, hermetic env, device default-expiry, and CLI flag validation. The
store-path test was already made OS-portable in the Windows-smoke fix.
Gates: gofmt/vet/build(host+linux+windows)/test -race/staticcheck(no new)/
govulncheck(0)/deadcode(no new) all pass.
* swarm: de-flake TestMailboxConcurrentSends on Windows CI
The 200-way concurrent-send stress test inherited from the swarm package timed
out on Windows CI (103/200 sends hit the 10s lock timeout): the lock's 20ms
retry sleep starves waiters when Windows file ops are slow under heavy
contention. Shorten the retry to 2ms (a freed lock is re-acquired promptly) and
raise the test's lock timeout to 60s so a legitimately-slow CI never times a send
out. Passes repeatedly under -race. (CI de-flake of a test that surfaced on
#210's Windows smoke; functionally unrelated to the OAuth changes.)
* oauth: address CodeRabbit re-review (4 findings)
- loopback (security): NewLoopbackListener refuses an empty CSRF state — an empty
state would match a callback carrying no state at all, defeating the check.
- device (correctness): RequestDeviceCode now sends client_secret when set, so a
confidential client authenticates on the device-authorization endpoint too
(consistent with the token poll).
- cli auth: document --watch in the help Flags section.
- scheduler test: assert the seed Save error instead of ignoring it.
Also scrubbed a stray third-party reference from a loopback.go comment.
Tests added: empty-state rejection, device client_secret is sent. Gates:
gofmt/vet/build/test -race/staticcheck(no new)/govulncheck(0)/deadcode(no new)
all pass.
* mcp(oauth): delegate the OAuth engine to internal/oauth (dedup) (#212)
OAuth Phase 2: the MCP OAuth code now reuses the shared internal/oauth engine
instead of carrying its own copy of the transport/identity-agnostic primitives.
internal/mcp/oauth.go drops ~110 lines (526 -> 418): newPKCE, newState,
discoverAuthorizationServer, the authorize-URL builder, token exchange/refresh,
the token-response parser, and joinWellKnown now delegate to internal/oauth.
Behavior-preserving: MCP keeps its own OAuthConfig/StoredToken types,
LoginOptions/Login orchestration, loopback handling, registerClient (dynamic
client registration — not part of the shared engine), CLI output, and on-disk
token format (mcp-oauth-tokens.json, raw server-name keys). The existing mcp
oauth tests pass UNCHANGED. The only behavioral delta is a hardening inherited
from the shared engine: the token endpoint must be https (loopback exempt) —
a credential is no longer sent to a plaintext non-loopback endpoint.
Stacks on #210 (the internal/oauth package). Gates: gofmt/vet/build(host+
linux+windows)/test -race/staticcheck(no new)/govulncheck(0)/deadcode(no new
vs base) all pass; `zero mcp oauth status` output is identical.
* web_search: add domains allowlist + score field
The web_search tool now accepts an optional 'domains' array that filters
results to the listed hostnames before they reach the model. This is the
zero-only prompt-injection defense that none of the major CLIs ship: a
model that the user has constrained to a known-good set of domains cannot
be tricked into fetching arbitrary pages from search results.
Also surfaces the provider's optional 'score' field on each result row
when present (zero/absent scores are not rendered, to keep the common
case tidy). The 'score' field is parsed from the generic JSON shape
already understood by the httpSearchBackend.
Filters are applied after the search returns, before the empty-result
short-circuit, so a non-empty allowlist that ate every hit surfaces as a
clear 'no results matched domains' error rather than a misleading
'no results' message.
Co-Authored-By: Claude <[email protected]>
* web_search: fail closed on all-invalid domains, strip :port on canonicalize
CodeRabbit review fixes:
1. canonicalizeWebSearchHost: switch parsed.Host -> parsed.Hostname() so
an allowlist entry like 'https://react.dev:443/path' normalizes the same
as 'react.dev'. The previous code left ':443' in the string, which
silently broke every match against a result URL on the default port.
2. stringListArgWebSearch: return a 'provided' bool so Run can distinguish
'no allowlist' from 'allowlist that normalized to empty'. The previous
code returned len(domains)==0 in both cases, and Run fell through to
the unfiltered path — defeating the prompt-injection defense the
parameter exists to provide. Now we error fail-closed.
3. Tests:
- TestWebSearchDomainsFilterRejectsAllInvalidInputs: locks in the
fail-closed contract for all-whitespace, all-empty, embedded-space,
and mixed-invalid allowlists.
- TestWebSearchDomainsFilterAcceptsHostPort: locks in the
Hostname()-strips-port fix for the scheme'd case and asserts that
scheme-less 'host:port' is rejected.
- TestCanonicalizeWebSearchHostStripsPort: the direct unit-level
regression test for canonicalizeWebSearchHost.
Co-Authored-By: Claude <[email protected]>
---------
Co-authored-by: Vasanthdev2004 <[email protected]>
Co-authored-by: Claude <[email protected]>
* feat(swarm): multi-agent swarm over the specialist sub-agent
Add internal/swarm so an orchestrator can spawn and coordinate MULTIPLE
specialist members concurrently under one team, communicate via per-agent
mailboxes, hand work off, and adopt orphaned tasks. Additive and opt-in: the
single Task tool is unchanged; swarm tools only act when invoked.
- Registry/Definition: agent roster (built-in teammate/subagent + user-defined).
- Mailbox: per-team/per-agent JSON inbox, owner-only 0600, file-locked with
stale-break, size-capped, path-confined, and malformed-fails-closed.
- Coordinator: in-memory task registry with terminal-state guards and stable
per-agent colors.
- Team: bounded concurrent members per team with a FIFO overflow queue that
drains one-per-slot.
- Lifecycle: spawn / handoff / orphan-adoption with bounded relaunch on
temporary failures; members run under the Swarm's base context so they
outlive the spawn call.
- Tools: swarm_spawn, swarm_send, swarm_inbox, swarm_status, swarm_handoff,
swarm_collect.
- Wiring: each member is launched through specialist.Executor and inherits the
orchestrator's model + permission mode (never widened) and runs under the same
sandbox/policy. Mailbox state lives under the workspace so writes fall within
the sandbox write rules. Swarm lifetime is paired with the specialist runtime.
Scheduling (cron/wakeup) and daemon-pool-backed members are deferred follow-ups.
* swarm: address CodeRabbit review (7 findings)
- coordinator: SetStatus rejects unknown TaskStatus values (fail closed) so a
task can never enter a state Summarize/lifecycle don't reason about.
- permission inheritance ENFORCED end-to-end (was resolved but discarded):
thread PermissionMode through specialist.TaskRunOptions -> BuildArgs/
BuildResumeArgs. A headless specialist child now runs "--auto high" (unsafe)
ONLY when the parent is unsafe or no mode is given (preserves the Task tool's
behavior); any other parent mode yields non-unsafe "--auto low", so a member
never gains more authority than its orchestrator. Also fixed the swarm
permission vocabulary to the real agent runtime modes (ask/spec-draft/auto/
unsafe) instead of TUI display names, and the launcher substitutes "auto" for
an empty mode so a member can't fall into the empty-means-high path.
- mailbox: symlink-aware confinement (EvalSymlinks) rejects an inbox dir that
resolves outside BaseDir; dirs tightened to 0700 even if pre-existing with
broad perms; lock release is ownership-token-aware so a stale-broken writer
can't delete a newer writer's lock (split-brain).
- member: recover panics inside a member goroutine -> fails that member only,
never crashes the orchestrator.
- tools: collapse() truncates on rune boundaries (no invalid UTF-8).
Tests added for every fix. Existing specialist behavior is unchanged (empty
PermissionMode keeps --auto high). Gates: gofmt/vet/build (host+linux)/test
-race/staticcheck(no new)/govulncheck(0)/deadcode(no new) all pass.
* swarm: address review round 2 (Windows lock + non-blocking notes)
Blocking (Vasanth): mailbox acquireLock now treats Windows ERROR_ACCESS_DENIED
/ ERROR_SHARING_VIOLATION as lock-contended (retry/wait) instead of a fatal
error, via a platform-aware isLockContended (lock_windows.go / lock_other.go).
A contended lock under Windows file-sharing semantics no longer fails a
send/read spuriously. TestMailboxConcurrentSends raised to 200 goroutines with a
10s lock timeout and now asserts zero send failures (regression guard).
Non-blocking notes addressed:
- Removed third-party reference-project names from doc comments (clean-room +
provider-neutral); the Go code is original.
- Documented the inbox-file size bound's ×2 (pretty-print overhead) constant.
- Documented Send's O(N) rewrite cost + when to switch to append-only.
- permissionRank: spec-draft now ranks below ask (it never widens authority).
- Documented watch's per-member-id retry binding (Member.ID == MemberSpec.ID).
- handoff Safety.Reason now also mentions the inbox write.
Gates: gofmt · vet · build (host+linux+windows) · test -race · staticcheck
(no new) · govulncheck (0) · deadcode (no new) all pass.
* feat(sandbox): complete sandbox breadth — WSL fallback + opt-in MITM TLS inspection
Adds the two remaining sandbox features on top of the strict fail-closed core
(the other four — SOCKS5 egress, re-entrancy guard, fine-grained path lists,
sandbox-aware ripgrep — already shipped and are untouched). Both new features are
opt-in and the DEFAULT policy is unchanged (enforce + network deny, InspectTLS
off; AllowPolicyOnlyRunner keeps its existing default).
1) WSL detection + safe fail-closed fallback
- parseWSL/detectWSL (wsl.go + wsl_linux.go reading /proc/version; wsl_other.go
stub) classify WSL/WSL2.
- SelectBackend: on Linux without bubblewrap, if running under WSL it selects a
new BackendWSL instead of silently degrading to a plain policy-only runner.
- BuildCommandPlan(BackendWSL): FAILS CLOSED unless Policy.AllowPolicyOnlyRunner
is set (errWSLPolicyOnlyDisabled explains how to opt in). When allowed it runs
the policy-only fallback that still routes egress through the filtering proxy
(Backend.ProxyEgress => EnforcesScopedEgress, best-effort HTTP+SOCKS), sets
ZERO_SANDBOXED=1 / ZERO_SANDBOX_BACKEND=wsl, and records an auditable
least-privilege downgrade note (CommandPlan.Notes). Never runs unsandboxed
silently.
2) Optional gated MITM TLS inspection (Policy.InspectTLS, default OFF)
- Off => the egress proxy is a pure CONNECT passthrough, byte-for-byte
unchanged (no CA generated).
- On => the proxy terminates TLS toward the sandboxed client with an EPHEMERAL,
locally generated ECDSA CA (mitm.go), minting per-host leaves on the fly, so
it can re-check the DECRYPTED request Host through the SAME
authorize()/domainAllowed() gate (deny-wins, empty allowlist => deny) — MITM
widens visibility, never authority. The UPSTREAM dial is ALWAYS validated
against the system roots (never InsecureSkipVerify; tests inject a known root
pool to exercise validation). The CA's PUBLIC cert is written owner-only and
surfaced via ZERO_SANDBOX_CA_CERT; MITM only runs on backends that already
permit reading it (sandbox-exec allows file-read; WSL shares the host fs), so
no new bind path is introduced. Trust implications documented loudly on the
field. (No "raw passthrough only" flag exists in zero, so the mutual-exclusion
case is N/A.)
Tests: parseWSL table; WSL fail-closed-without-opt-in and policy-only-with-proxy
(markers + ProxyEnvWithSocks) + deny-mode markers-no-proxy; MITM passthrough-by-
default (no CA), CA mints a chaining leaf, full TLS-terminate-and-forward of an
allowlisted host (upstream validated via an explicit root pool), and decrypted-
Host deny. Gates: gofmt, go vet (host + GOOS=linux), go build (host/linux/
windows), go test (incl -race), staticcheck (no new, host + linux), govulncheck
(0), deadcode -test=false under GOOS=linux (no new unreachable funcs).
* sandbox: address review on #206 (WSL + MITM)
- mitm: bound the upstream transport's TCP connect (DialContext 30s) so a
blackholed upstream can't park a goroutine; disable upstream HTTP/2 and
normalize the response to HTTP/1.1 before writing to the HTTP/1.1 client.
- mitm: mint leaves for the AUTHORIZED CONNECT host (not arbitrary client SNI)
and cap the leaf cache (512) so SNI churn can't drive unbounded keygen/memory.
- mitm: re-check the decrypted Host with the FULL authorize gate
(authorizeTarget) so a DomainPrompt-allowed host is honored consistently with
the CONNECT check, not just domainAllowed.
- runner (WSL): preserve the caller's env in the direct-run WSL plan (it is not
OS-wrapped), appending the sandbox markers last; the downgrade note says
"with proxy egress" only when a proxy was actually started.
- tests: WSL caller-env preservation; deny-mode asserts no HTTP/HTTPS/ALL_PROXY
leak + the downgrade note; MITM forward asserts HTTP 200; mitmConnect sets an
I/O deadline.
Gates: gofmt, vet (host+linux), build (host/linux/windows), test (incl -race),
staticcheck (no new), govulncheck (0), deadcode -test=false (GOOS=linux) no new.
* Convert /permissions output to bounded command card
Replace status: ok style with commandCard format used by /tools and /context.
Summary shows mode and grant count; grants render as bullet rows.
Co-Authored-By: Claude <[email protected]>
* Address CodeRabbit nitpick: add error path test for /permissions
Adds TestPermissionsCommandCardHandlesGrantListError and a grantLister
interface so tests can inject a stub store without a real file path.
Co-Authored-By: Claude <[email protected]>
* Fix gofmt formatting in permissions card
Tabs alignment only; no functional change.
Co-Authored-By: Claude <[email protected]>
---------
Co-authored-by: Claude <[email protected]>
* feat(daemon): add `zero daemon` — worker pool + session supervisor over a local control socket
Adds internal/daemon + `zero daemon start|stop|status|run|attach`: a long-running
local service that supervises a pool of headless `zero exec` workers and routes
multiple agent sessions to them over an owner-only Unix-domain control socket.
Additive — interactive and one-shot exec are unchanged.
Reuses zero's building blocks:
- internal/background: ConfigureChildProcessGroup (process-group setup) +
TerminateProcess (cross-platform terminate) for worker kill/drain. The pool
does its own os/exec and uses background only for those helpers.
- internal/streamjson: the worker speaks stream-json on stdout; the daemon
forwards those lines to clients.
- internal/cli exec: a worker is `zero exec --output-format stream-json`.
Pieces (reimplemented in Go from a reference daemon):
- protocol.go: framed control codec — 4-byte BE length + 1 kind byte, 1 MiB cap,
JSON control messages, version negotiation.
- pool.go + backoff.go: bounded worker pool — at-least-once dispatch with
exponential backoff+jitter retry capped by MaxAttempts, EXIT_TEMPFAIL(75)/
EXIT_PERMANENT(76) handling, graceful drain + kill-timeout. zero's exec is
one-shot, so workers are on-demand (one session per worker = the lease), not
warm long-lived slots (a persistent worker mode is a follow-up).
- session.go: SessionManager — sessionID->worker routing, lease (one active
request per worker), queue when the pool is full, per-session output buffer for
attach + metrics.
- server.go + lock.go + socket.go + paths.go: single-instance PID-file lock with
stale-lock (dead-PID) recovery, status file, owner-only socket, accept loop;
cleans up lock/socket/status on exit.
- launcher.go: production worker launcher. client.go: control-socket client.
Security model:
- Control socket is LOCAL-ONLY (AF_UNIX loopback file, never a TCP port) and
owner-only: socket 0600 + parent dir 0700 on POSIX (Windows relies on the
per-user profile ACL; a dedicated pipe ACL is a follow-up).
- Workers stay sandboxed: the launcher SCRUBS the sandbox re-entrancy markers
(ZERO_SANDBOXED / ZERO_SANDBOX_BACKEND) from each worker's env so the worker
re-establishes its OWN sandbox instead of inheriting a pass-through plan, while
propagating the rest of the policy config/env. The daemon never bypasses the
sandbox.
- Control frames over the 1 MiB cap are rejected before allocation.
Robustness: a crashed worker never takes down the daemon (backoff+jitter retry,
MaxAttempts cap, EXIT_PERMANENT stops retry); drain gives a grace window then
force-kills stragglers; a stale lock from an unclean exit is reclaimed.
Out of scope (follow-up): remote session-linking; warm pre-spawned persistent
workers; Windows named-pipe ACL.
Tests: protocol round-trip + oversize/unknown-kind rejection; backoff bounds;
pool spawn/retry-backoff/permanent/cap/queue/drain; session lease/route/queue/
attach; lock single-instance + stale recovery; launcher env-scrub (incl. an
end-to-end check that the spawned worker process receives scrubbed env); server
e2e start->run->stream-json->status->attach->stop with a fake worker; CLI
usage/validation/not-running paths. Live-verified start/status/stop (0600 socket,
0700 dir, file cleanup). Platform files behind explicit //go:build tags.
* daemon: address review — help listing, extra-arg rejection, lock write error, bounded sessions
- cli/app.go: list `daemon` in `zero --help` so the command is discoverable.
- cli/daemon: `stop`/`status` reject extra arguments; `attach` rejects more than
one session id (fail fast on typos instead of silently ignoring tokens).
- daemon/lock: check the PID fmt.Fprintf error — a partial/failed write would
leave a malformed lock file that another process reads as stale and wrongly
reclaims, breaking the single-instance guarantee; on write failure remove the
file and return the error.
- daemon/session: bound the registry with MaxSessions (default 256) — evict the
oldest FINISHED sessions once past the cap so a long-running daemon doesn't
accumulate sessions without limit; running/queued sessions are never evicted, so
attach-after-finish for recent sessions is preserved.
Tests: CLI extra-arg rejection; finished-over-cap eviction + running-never-evicted.
Gates: gofmt, vet, build, test (incl -race), staticcheck (no new), govulncheck (0),
deadcode (no new), GOOS=linux+windows cross-compile.
Follow-up to the EnforceToolNetwork change: web_search/web_fetch were still
hard-denied under NetworkDeny because the engine-level gate in Evaluate
(netMode==NetworkDeny && risk has "network") fired BEFORE the tool's
RunWithSandbox/NetworkHostAllowed ever ran. The earlier change only relaxed
NetworkHostAllowed, so the user-facing path (Evaluate -> registry) still blocked
the tool with "network access is blocked by sandbox policy".
Fix: Evaluate now skips the network-deny for a request that declares
SideEffectNetwork (the first-party in-process tools, web_search/web_fetch) when
EnforceToolNetwork is off — mirroring the NetworkHostAllowed exemption so the two
gates agree. A SHELL command merely classified as network (SideEffectShell) is
NOT exempt, so shell egress stays blocked under deny. EnforceToolNetwork restores
the strict deny for the tools.
Tests: TestEvaluateExemptsNetworkToolsFromDenyByDefault covers the full path
(default-exempt, granted->allow, EnforceToolNetwork->deny, shell stays denied);
the two existing gate tests now opt into EnforceToolNetwork to keep testing the
strict path. Gates: gofmt, vet, build, test (incl. -race), staticcheck (no new),
govulncheck (0), deadcode (no new unreachable funcs).
* feat(sandbox): harden internal/sandbox with four opt-in capabilities
All four are off by default and fail closed; existing behavior is unchanged
when the new policy fields are empty / ZERO_SANDBOXED is unset.
1. Re-entrancy guard — ZERO_SANDBOXED=1 marks every wrapped command; a nested
BuildCommandPlan returns a pass-through plan (no double bwrap/sandbox-exec,
no second egress proxy). New: EnvSandboxed, IsAlreadySandboxed().
2. SOCKS5 egress — the scoped-egress proxy also serves SOCKS5 CONNECT on a
second loopback port through the SAME authorize()/domainAllowed() gate.
ProxyEnvWithSocks points ALL_PROXY at socks5://<addr>; HTTP(S)_PROXY stay on
the HTTP listener; the sandbox-exec profile allows both proxy ports.
Bubblewrap scoped still collapses to deny.
3. Sandbox-aware search — ReadExclusionGlobs(policy, scope) returns ripgrep
--glob '!<pat>' for DenyRead subtrees; grep/glob skip read-denied subtrees
at walk time via Engine.ReadPathExcluded/ReadDirExcluded when sandboxed.
4. Fine-grained path lists — Policy.AllowRead/DenyRead/AllowWrite/DenyWrite.
Read = deny-then-allow (more-specific AllowRead re-includes); Write =
DenyWrite > workspace/scope > AllowWrite > deny. AllowWrite is reflected in
the OS write binds; DenyWrite is emitted as sandbox-exec deny rules. Each
entry is home-expanded, made absolute, and symlink-resolved.
Tests: internal/sandbox/{reentrancy,egress_socks,pathlists}_test.go and
internal/tools/read_exclusions_test.go.
Gates: gofmt, go vet, go build, go test (incl. -race), staticcheck (no new
findings), govulncheck (0), deadcode -test=false (no new unreachable funcs).
* sandbox: address review — SOCKS5 method negotiation + cache read-exclusion roots
- egress_socks: respect SOCKS5 method negotiation (RFC 1928). The proxy now
inspects the client's offered methods and replies 0xFF ("no acceptable
methods") when no-auth was not offered, instead of forcing no-auth.
- pathlists/engine: resolve DenyRead/AllowRead roots ONCE per search walk via a
new ReadExclusions matcher (Engine.ReadExclusions), replacing the per-path
ReadPathExcluded/ReadDirExcluded that re-ran Abs/EvalSymlinks for every visited
path during a grep/glob walk.
Re-entrancy guard left as-is by design: ZERO_SANDBOXED is an internal marker set
only on commands zero wraps; cross-process re-entrancy detection legitimately
relies on the inherited env, and a manually-exported flag is a self-opt-out, not
an external bypass.
Tests: SOCKS no-acceptable-methods rejection; nested-AllowRead descent via the
matcher. Gates: gofmt, vet, build, test (incl. -race), staticcheck (no new
findings), govulncheck (0), deadcode (no new unreachable funcs).
* sandbox: fix windows smoke test + symlink-resolve DirExcluded prefix
- pathlists_test: TestSandboxExecProfileEmitsDenyWriteRule now builds the
expected deny clause via sandboxProfileString(secret), so it matches the
profile's own escaping (Windows doubles backslashes). Fixes the
windows-latest smoke failure; macOS/Linux unaffected (slash paths, no-op
escaping).
- pathlists: ReadExclusions.DirExcluded now EvalSymlinks-resolves the candidate
path before the nested-AllowRead prefix check, matching the resolution applied
to allowRoots — otherwise a denied dir reached THROUGH a symlink fails to match
a nested AllowRead root and is wrongly skipped, dropping a re-included subtree.
Added TestDirExcludedResolvesSymlinkPrefix (skips where symlinks are
unavailable).
Gates: gofmt, vet, build, test (incl. -race), staticcheck (no new findings),
govulncheck (0), deadcode (no new unreachable funcs).
* sandbox: strengthen re-entrancy guard with a corroborating backend marker
Addresses review feedback that the re-entrancy guard trusted a single ambient
env var. IsAlreadySandboxed now requires BOTH correlated markers that
sandboxEnvironment sets together on every wrapped command — ZERO_SANDBOXED=1 AND
a non-empty ZERO_SANDBOX_BACKEND (new EnvSandboxBackend const) — so a lone
stray/hand-exported ZERO_SANDBOXED=1 no longer forces an unsandboxed
pass-through. Pass-through (direct) plans set neither marker, so genuine nesting
still detected and double-wrapping still avoided.
Tests updated: TestIsAlreadySandboxed now asserts each marker alone is
insufficient and both together are required; the re-entrancy and wrapping plan
tests set/clear both markers explicitly.
Gates: gofmt, vet, build, test (incl. -race), staticcheck (no new findings),
govulncheck (0), deadcode (no new unreachable funcs).
* sandbox: don't subject first-party web tools to the network policy by default
The sandbox was reported as too strict: web_search (and web_fetch) were blocked
with "network access is disabled by the sandbox policy" because the default
policy is network=deny and the in-process web tools were gated by the same
NetworkHostAllowed check as the sandboxed shell's egress.
That network policy exists to confine the sandboxed SHELL's egress, which these
in-process tools don't use. New Policy.EnforceToolNetwork (off by default) makes
NetworkHostAllowed exempt web_search/web_fetch unless an operator opts in. The
sandboxed-shell egress decision (Evaluate / effectiveNetworkMode) is unchanged,
and the tools keep their own SSRF/private-IP, port, redirect, and redaction
safeguards. Set EnforceToolNetwork to hold the tools to the allow/scoped/deny
policy as before.
Tests: engine default-exempt + enforced-table cases; web_search default-allows
and enforced-when-flag; web_fetch enforced cases set the flag. Gates: gofmt, vet,
build, test (incl. -race), staticcheck (no new findings), govulncheck (0),
deadcode (no new unreachable funcs).
* sandbox: re-review fixes — disabled policy inert; path lists independent of EnforceWorkspace
- engine: ReadExclusions() / ReadExclusionGlobs() now return inert results under
ModeDisabled, so a disabled policy never filters grep/glob (parity with
Evaluate, which already allows everything when disabled).
- engine/pathlists: the fine-grained path lists (DenyRead/DenyWrite, with
AllowRead/AllowWrite) now apply in Evaluate whenever the sandbox is enforcing,
independent of EnforceWorkspace — matching the grep/glob path that already
honors DenyRead directly. The workspace boundary (scope.validate) stays gated
by EnforceWorkspace via a param threaded through validatePathWithPolicy /
validateWritePath; DenyWrite wins regardless.
- reentrancy_test: TestSandboxEnvironmentMarksSandboxed now asserts BOTH
correlated markers (ZERO_SANDBOXED=1 and ZERO_SANDBOX_BACKEND) so dropping
either would fail the test.
Tests added: path lists enforced with EnforceWorkspace off; ReadExclusions inert
under ModeDisabled. Gates: gofmt, vet, build, test (incl. -race), staticcheck
(no new findings), govulncheck (0), deadcode (no new unreachable funcs).
* sandbox: enforce path lists even without a workspace root
Re-review follow-up: the Evaluate path-validation loop was still guarded by
request.WorkspaceRoot != "", so an engine built without a workspace root skipped
DenyRead/DenyWrite/AllowRead/AllowWrite even for absolute paths. The loop now
runs unconditionally; the workspace boundary (scope.validate) stays gated on
having a root (enforceWorkspace = EnforceWorkspace && WorkspaceRoot != ""), while
the path lists match absolute paths regardless. An unanchorable relative path
(no workspace root) fails closed when anything is configured to enforce, and is a
no-op otherwise (unchanged from prior behavior).
Test: TestEvaluatePathListsWithoutWorkspaceRoot. Gates: gofmt, vet, build, test
(incl. -race), staticcheck (no new findings), govulncheck (0), deadcode (no new
unreachable funcs).
* sandbox: base no-workspace fail-closed on normalized (enforceable) path lists
policyHasPathLists now resolves the lists (resolvePolicyPaths/
resolveWriteRootPaths) instead of counting raw config, matching how the rest of
the file normalizes them. A typo or non-existent entry (dropped during
resolution) therefore no longer makes validatePathWithPolicy fail-close every
relative request when there is no workspace root.
Gates: gofmt, vet, build, test (incl. -race), staticcheck (no new findings),
govulncheck (0), deadcode (no new unreachable funcs).
* Harden sandbox command risk by wiring the AST analyzer
The AST-based command analyzer (sandbox/analyzer.go) was built and tested but never
reached at runtime — the live risk classifier used only the regex detectors. Wire
AnalyzeCommand into classifyWithScope as a second opinion. It walks the parsed shell tree,
so it catches destructive/network programs the regexes miss (shred, fdisk, parted; telnet,
ftp, sftp; and commands hidden behind sudo/env wrappers or a sh -c launcher) and flags an
unparseable (obfuscated) script as elevated risk.
Purely additive — it only ADDs risk categories, so a benign, parseable command classifies
exactly as before, and a program name inside a quoted argument is not flagged (AST
precision). Revives the dormant analyzer (sandbox unreachable funcs 13 -> 6). New tests cover
the regex-missed destructive/network programs, nested launchers, the unparseable signal, the
quoted-name no-false-positive case, and benign-stays-clean. Full suite + -race green.
* Wire sandbox auto-allow-bash env opt-in; drop orphaned batch-autonomy
Two dormant sandbox surfaces resolved:
- WIRE: AutoAllowBashWhenSandboxed was enforced live (engine.go) but never set — no
config field, and the ZERO_SANDBOX_AUTO_ALLOW_BASH env overlay (ApplyAutoAllowBashEnv)
was unwired, so the documented opt-in did nothing. Wire it through the central policy
builder (applyConfiguredSandboxPolicy, used by all engine constructions). Fail-safe: only
turns ON when the env is truthy, only honored when the shell sandbox is actually active,
default stays prompt, and an explicit config opt-in is preserved. Revives
AutoAllowBashEnvEnabled + ApplyAutoAllowBashEnv; adds a policy-builder test.
- DELETE: RequiredAutonomyForBatch/riskToAutonomy (batch.go + test) had no consumer — the
agent loop enforces autonomy per tool call (loop.go), so a batch-ceiling helper was
speculative and redundant. Wiring it would have meant fabricating a batch model.
Sandbox now has no dormant funcs; the only remaining unreachable entries are the
cross-platform seccomp pair (unixSocketBlockFilter is live on Linux; ApplyUnixSocketBlock
is the intentional non-Linux no-op stub).
* Assert risk level (and add wrapper cases) in AST analyzer tests
Per review (Vasanth + CodeRabbit): the AST hardening tests checked only the risk category,
not the escalation level, so a regression that downgraded destructive/network shell commands
while preserving the category would slip through. Assert risk.Level too — Critical for
destructive/network, High for unparseable — matching the pattern used throughout the file.
Add sudo/env wrapper cases (sudo/env shred, sudo telnet) to cover effectiveProgram's wrapper
unwrapping.
* Bump toolchain to go1.26.4 to clear reachable stdlib CVEs
go.mod pinned toolchain go1.24.13, whose bundled standard library has 9
govulncheck-reachable CVEs (crypto/tls KeyUpdate DoS, net/http2 loop, crypto/x509 x3,
net, net/url, os, net/textproto). CI and release build via go-version-file: go.mod, so
shipped binaries inherited them.
Bumping the toolchain directive to go1.26.4 clears all nine — govulncheck ./... now
reports 0 reachable (minimum that clears them is go1.25.11; go1.26.4 matches the team's
local Go). The go directive stays at 1.24.2 (minimum language version). Verified:
go build/vet/gofmt clean, full go test ./... green.
* Add govulncheck CI gate and SHA-pin GitHub Actions
- ci.yml: new 'security' job runs govulncheck as a hard gate (fails on a reachable
vulnerability; passes now that the toolchain is bumped) plus an advisory deadcode step
that surfaces dormant code without blocking.
- Pin every GitHub Action to a full commit SHA (checkout, upload-artifact, github-script)
with a version comment, across all four workflows — previously only setup-go was pinned,
leaving the mutable v4/v7 tags as a supply-chain risk.
* Add hooks management command (add/remove/enable/disable)
Wires the previously-dormant hooks.ConfigStore (10 unreachable funcs -> 0) into the CLI:
zero hooks add <id> --event <event> --command <cmd> [--name --description --matcher --arg --user --json]
zero hooks remove|enable|disable <id> [--user --json]
Writes the project hook config by default (<cwd>/.zero/hooks.json) or the user config with
--user, reusing the store's locking + atomic writes + validation (normalizeDefinition). New
hooks are enabled; state is managed via enable/disable. JSON output is secret-scrubbed via
redaction. Mirrors the existing 'zero mcp add/remove/enable/disable' shape. Covered by
add/remove/toggle round-trip, validation, unknown-event, and JSON-redaction tests.
* Fix govulncheck CI job: pin GOTOOLCHAIN to the go.mod toolchain
go run govulncheck@latest selected the toolchain from govulncheck's own go.mod
(downgrading to go1.25.11), which then could not load our go1.26-requiring packages
(fips140only_go1.26.go), failing the security job. Resolve GOTOOLCHAIN from go.mod's
toolchain line so govulncheck and deadcode both run under go1.26.4.
* Address review: harden CI checkout, pin tool versions, validate hook event
CodeRabbit findings on #193:
- ci.yml: persist-credentials: false on all checkout steps (smoke/performance/security)
— these jobs run repo code and never push, so don't keep the token in git config.
- ci.yml: pin [email protected] and [email protected].0 instead of @latest, so the security
gate is deterministic and not subject to supply-chain drift.
- hooks: add exported IsValidEvent/KnownEvents (single source); parseEvent reuses it, and
'zero hooks add' now rejects an invalid --event with a usage error at parse time instead
of falling through to the Upsert app-error crash path.
* Wire LSP diagnostics into headless --self-correct
newExecSelfCorrector now backs the --self-correct loop with both halves: the
workspace test plan AND an lsp.Manager-based diagnostics checker (IncludeLSP=true).
The manager is lazy (Manager.Check degrades a missing language server to nil,nil),
and a deferred cleanup shuts it down at run end, terminating any spawned sessions.
This revives the internal/lsp client (44 -> 3 unreachable funcs); it was dead
because the only consumer passed a nil checker with IncludeLSP=false.
Add TestManagerCheckRealGopls: drives NewManager -> Check against a real gopls and
asserts a real diagnostic; skips when gopls is absent, so it stays CI-safe.
* Wire self-correct into the interactive TUI (on by default)
runAgentWithOptions now builds a SelfCorrector for every turn, so post-edit
verification (workspace test plan + LSP diagnostics) runs by default in the TUI.
The spec-draft planning path is excluded, matching exec. A per-turn lsp.Manager
is created and torn down when the run returns. Auto-fix vs report-only follows
the active permission mode (unsafe->high, auto->medium, ask/other->low) via
selfCorrectAutonomyForMode.
* Keep TUI self-correct fast by default; gate the test half behind /selfcorrect
The TUI default now runs only the LSP-diagnostics half of self-correct — cheap and
scoped to the changed files — so it never adds the whole-repo test plan's latency to a
turn (and a pre-existing failure can't hijack the agent). The project test plan is opt-in
per session via a new /selfcorrect [on|off] command (alias /sc); IncludeTests reads
m.selfCorrectTests, default false. exec keeps both halves.
* Remove dead session.didClose from lsp
session.didClose sent a textDocument/didClose notification but was never called (the
manager only closes documents via Shutdown), so it was unreachable even from tests and
flagged by staticcheck U1000. Removing it takes internal/lsp to 0 truly-dead funcs and 0
U1000 findings; the 2 remaining unreachable-from-main funcs (Available, URIToPath) are
public, test-covered API.
* Align /selfcorrect help with accepted arguments
The usage/description advertised only [on|off] but the handler also accepts status,
list, tests, full, and lsp. Surface the primary verbs (on|off|status) and clarify the
on/off behavior so command discovery matches the handler (CodeRabbit).
* Make /selfcorrect usage strings match the handler exactly
Both the command definition and the invalid-argument error message now list the full
accepted set [status|on|off|tests|full|lsp]; the redundant 'list' alias is dropped so docs
and handler agree exactly. Addresses Vasanth's review (the error-message usage at
session_controls.go was still [on|off]) and CodeRabbit's help-text finding.
* Clarify /selfcorrect off semantics in help text
Reword the description so 'off/lsp' reads as 'disable tests, LSP-only' rather than the
ambiguous 'return to LSP-only', which could be misread as disabling the feature entirely
(CodeRabbit).
* audit batch 1: redact nested secrets fully; count image tokens in compaction estimate
- secrets.Redact: replace longest matches first so a containing PRIVATE KEY
block is redacted before a nested shorter match corrupts its exact string
(audit medium internal/secrets/scanner.go:81) + regression test.
- agent compaction estimateTokens: add a flat per-image token cost so an
image-heavy context trends toward compaction instead of reading as ~0
(audit medium internal/agent/compaction.go:58) + test.
- ledger: re-verify against current main; mark scanner.go:35 and
redaction.go:362 already-fixed-on-main, record this batch's fixes.
* audit batch 2: worktrees defaultRunGit separates stdout/stderr
Capture git stdout and stderr into separate buffers instead of CombinedOutput,
so parsed rev-parse output is not polluted by git warnings and CommandResult.Stderr
carries the real error text (audit medium internal/worktrees/worktrees.go:247) + test.
* audit: re-verify all 87 pending findings against current main
Independent agent per file re-checked each open/partial finding against the
actual current code (the 06-11 ledger was verified on a since-diverged branch),
with an adversarial second pass on every already_fixed verdict. Authoritative
result: 78 genuinely pending (66 still_open + 12 partial); 9 rows were already
fixed on main and are reclassified. Full table in 2026-06-13-reverification.md.
* audit batch 3 (agent): cancellation identity, always-allow w/o sandbox, compaction usage, dedup
- loop.go: check ctx.Err() before collected.Error so a cancelled run returns
context.Canceled (errors.Is holds) instead of a plain stringified error.
- loop.go: 'Always allow' with no sandbox engine now allows THIS call instead of
denying it (persistence is skipped when there's nowhere to persist).
- loop.go: remove a duplicate schema["items"] assignment in propertyToRuntimeMap.
- compaction.go: forward OnUsage (token accounting) through the summarizer while
still omitting OnText, so compaction's token cost is counted in usage/budgets.
- tests for all three behavioral fixes (each fails pre-fix).
* audit batch 4 (cli): list-tools -o json, interrupted -o json terminal, surface session-record failures
- exec --list-tools -o json now emits a JSON tool listing instead of falling
through to the human-readable text (audit medium exec.go:182) + test.
- interrupted run with -o json now emits a terminal error+done on stdout instead
of only printing to stderr, so a JSON consumer sees a clean end (medium exec.go:500) + test.
- execSessionRecorder: surface a latched session-append failure to stderr once at
run end (deferred) instead of silently dropping it (medium exec_sessions.go:114/115) + test.
- left app.go trailing-arg ignore as-is: documented-intentional, TUI has no argv prompt.
* audit batch 5 (zerogit): status -z porcelain (renames + non-ASCII paths); document env-drop on plain Runner
- parseStatus: switch `git status` to `--porcelain -z` and parse the
NUL-delimited form (audit medium zerogit.go:212). Previously the default
--short output was split on newlines, so a rename `R old -> new` was kept as
one bogus path and a non-ASCII/whitespace filename arrived C-quoted+escaped
(e.g. `"caf\303\251.txt"`). The -z form never quotes paths and emits a
rename/copy as `XY <dest>\0<src>`, so we now record the destination verbatim
and consume the source field. + TestParseStatusZHandlesRenamesAndSpecialPaths.
- resolveRunners: document that the plain-Runner EnvRunner adapter intentionally
drops env (audit low zerogit.go:417). The branch is reached only when a caller
supplies a custom Runner without a RunGitEnv — in practice fake-Runner tests.
Production leaves both nil and gets defaultRunGit/defaultRunGitEnv, which honor
GIT_INDEX_FILE, so snapshot-diff isolation holds. Routing env calls to real git
instead would break the fake-Runner test pattern and fix nothing in production.
- update Inspect/Commit mock tests to the -z status format and the new command.
* audit batch 6 (config): reject negative maxTurns; drop dead contract-gap helpers
- FileConfig.UnmarshalJSON now rejects a negative maxTurns instead of letting the
`MaxTurns > 0` merge gates silently drop it and fall back to the default,
hiding the misconfiguration (audit medium resolver.go:136). 0 is left as the
"unset" sentinel (omitempty) and still falls back to the default; the CLI
--max-turns flag continues to reject 0 too since there it is distinguishable
from absent. + TestResolveRejectsNegativeMaxTurns / ...ZeroFallsBackToDefault.
- remove dead config/contracts.go (ContractGap, DefaultContractGaps,
FindContractGapsByMilestone, ContractOwnerRuntime) and its test — referenced
only by that test, never by production (audit low contracts.go:12).
- NOT changed: ResolveMCP intentionally does not run the provider command
(TestResolveMCPDoesNotRunProviderCommand guards this), so the "ZERO_PROVIDER_COMMAND
MCP servers dropped" row is by-design, not a defect.
* audit batch 7 (mcp): bound captured MCP server stderr
connectStdio attached a plain bytes.Buffer as cmd.Stderr that is only ever read
on initialize failure, yet it kept growing for the entire process lifetime as a
long-lived server logged to stderr (audit medium client.go:98). Replace it with
a concurrency-safe boundedBuffer capped at 64 KiB that keeps the head (enough for
init-error diagnostics) and discards the rest, reporting full write lengths so
os/exec never sees a short write. + TestBoundedBufferCapsRetainedBytes.
Left as-is (not defects worth the risk in the MCP client):
- client.go writer.write under client.mu: already designed to release the lock
before the unbounded response wait; the residual concern is a server that stops
draining stdin, an involved async rewrite for a theoretical deadlock.
- readLoop not answering server-initiated requests (ping): responding safely
needs re-locking around the shared writer; no configured server relies on it.
* audit batch 8 (tools/providers): read_file truncation marker; drop dead provider branch
- read_file: when a max_lines cut occurs, append an explicit "[truncated: N more
line(s)... set start_line=X to continue]" marker to the output. Previously only
the Result.Truncated flag was set, which is invisible in the rendered text, so
the model could not distinguish a cut read from a complete one (audit low
read_file.go:89). + TestReadFileToolMarksTruncation.
- providers/factory: simplify `if !explicitProvider || isImplicitOpenAI(...)` to
`if !explicitProvider` and delete the now-unused isImplicitOpenAI. The clause
was dead: explicitProvider==true implies ProviderKind or Provider is set, but
isImplicitOpenAI required both empty, so it could never contribute a case
(audit low factory.go:118). Behavior-preserving; providers tests still green.
* audit batch 9 (sessions): Tree builds from a single List() snapshot (no N+1 disk scans)
Store.Tree recursed via ListChildren per node, and each ListChildren ran a full
store.List() that re-read every metadata.json from disk — O(nodes * total-sessions)
reads for one tree (audit medium lineage.go:140). Snapshot all sessions once, index
children by parent in memory, and recurse over that. Child ordering is factored into
sortChildSessions so ListChildren and Tree stay identical; path-scoped cycle
detection (delete(seen,...)) is preserved. Existing tree/lineage tests still pass.
Left as-is: store.go timestamp() second precision (RFC3339) — switching to
RFC3339Nano would break lexical UpdatedAt ordering against existing second-precision
records ("...00.5Z" sorts before "...00Z"), so it is not a safe one-line change.
* audit batch 10 (specialist): serialize accounting dedup-then-append
recordSpecialistStop and appendSpecialistUsageRollup checked for an existing event
(specialistEventExists → ReadEvents) and then appended under SEPARATE store locks,
so two concurrent finishers — a foreground onExit racing a TaskOutput poll, or a
background reaper — could both pass the dedup check and write duplicate stop/usage
events (audit medium accounting.go:72). Guard each check-then-append pair with a
package-level accountingMu (Executor is used by value, so a struct field would not
be shared). + TestRecordSpecialistStopDedupesUnderConcurrency (passes under -race).
* audit batch 11 (zeroruntime/gemini): O(1) text accumulation; gemini cached-token counts
- zeroruntime CollectStreamWithOptions accumulated assistant text with
`collected.Text += event.Content`, reallocating the whole string on every chunk
(O(n^2) over a long streamed response). Accumulate in a strings.Builder on the
collector and materialize it once in flush, the single finalization point hit
before every return (audit low helpers.go:104). Existing collect tests still pass.
- gemini decoded usageMetadata without cachedContentTokenCount, so a cached-prompt
response reported zero cached input tokens while the other providers report them
(audit medium gemini/types.go:88). Decode the field and thread it through
streamState into NormalizeUsage as CachedInputTokens. + extended
TestStreamCompletionEmitsTextUsageAndReasoningTokens.
* audit batch 12 (specialist): parse child stream without a line-length cap; drop dead helper
- ParseStream used a bufio.Scanner capped at 1 MiB, so one large stream-json line
from a child (a big tool result or final answer) hit bufio.ErrTooLong and aborted
the entire specialist run (audit medium streamer.go:43). Switch to a bufio.Reader
with ReadString, which has no per-line limit; the child is our own trusted
subprocess, so its output length is the legitimate bound. Line numbering and
blank-line/EOF handling are preserved. + TestParseStreamHandlesLineLargerThan1MiB.
- remove dead formatTaskOutput (output_tool.go:244): zero callers and zero tests;
it only forwarded to formatTaskOutputSummary, which is the one actually used.
* audit batch 13 (background/specialist): kill background tasks as a process group
Background children were launched in the parent's process group and terminated by
signalling only the single PID, so any process the task forked was orphaned and
outlived a cancel; the signal also risked hitting a recycled PID after the child
was reaped (audit medium process_posix.go:38/50).
- ConfigureChildProcessGroup (Setpgid on POSIX, no-op on Windows where taskkill /T
already tree-kills) is applied at launch so the child leads its own group.
- terminateProcess now SIGTERM/SIGKILLs the negative PID (the whole group), with a
pid>1 guard so a bogus 0/1 can never expand to "our own group" or "everything",
and treats ESRCH as already-gone. Liveness polls the group. Targeting a group
(rarely recycled vs. a bare PID) also shrinks the reaped-then-recycled window.
- specialist launchBackgroundProcess opts every child into its own group; on a
SetPID failure it now kills the child + group, marks the task errored, and records
the stop (deduped) instead of leaking an untracked orphan.
- tests launch as group leaders; new TestTerminateProcessKillsForkedChildren proves
a forked grandchild dies with the group.
* audit batch 14 (sandbox): quote-aware shell segment splitting
splitShellSegments used a quote-unaware strings.Replacer, so a shell operator
inside quotes was treated as a separator: `git commit -m "use top | less"` split
into a `less` segment and `echo "a; vim b"` into a `vim` segment, both falsely
flagged as interactive (audit medium safe_command.go:138).
Replace it with a quote-aware scanner that mirrors the shell: single quotes make
everything literal; double quotes keep $(...)/`...` substitution active but treat
|, ;, && as literal; unquoted text splits on the same operator set as before
(;, |, ||, &&) plus substitution boundaries. Real unquoted operators still split,
so an interactive program behind a separator — or inside a live `"$(...)"`
substitution — is still caught (no new false negatives, the security-relevant
direction). + TestDetectInteractiveQuoteAwareSeparators covering both directions.
* audit batch 15 (cron): collapse DST fall-back repeated hour to one fire
On a fall-back day the clock repeats an hour (e.g. 01:30 EDT then 01:30 EST), so
Next("30 1 * * *", <01:30 EDT>) returned the same-day 01:30 EST — firing a daily
job twice (audit medium schedule.go:171, which previously guarded only the
spring-forward gap).
Next now skips a match whose local wall-clock minute equals `after`'s but whose
absolute time is later (the repeated-hour duplicate) and keeps searching. Normal
forward search always advances the wall-clock, so this only triggers on the
fall-back repeat; when the scheduler passes the last fire time as `after`, the
repeated hour collapses to a single fire. + TestNextDSTFallBackDoesNotRepeatHour
(spring-forward termination test still passes).
Note: Next is stateless, so it dedupes relative to `after`; a caller that passes
an arbitrary mid-hour "now" instead of the last fire time is out of scope here.
* audit batch 16 (mcp): skip SSE notifications before the response; larger SSE buffer
- decodeSSERPCMessage returned the first non-empty `message` event unconditionally,
so a server-initiated notification/request arriving before the response made the
caller see an id mismatch and fail (audit medium network_client.go:621). It now
skips any message carrying a method (notifications/requests have one; a response
does not) and returns the first real response; the caller's rpcIDMatches still
validates the id. + TestDecodeSSERPCMessageSkipsNotifications.
- raise the per-event SSE scanner cap from 1 MiB to a bounded 8 MiB so a large but
legitimate MCP message no longer hits bufio.ErrTooLong and fails the request
(audit medium network_client.go:637); still bounded against an unbounded server.
Out of scope (kept): full reconnect of a persistent SSE stream after a fatal read
error — a larger change than this remediation.
* audit batch 17 (sessions): fsync session metadata before the atomic rename
writeMetadata wrote the temp file with os.WriteFile (no sync) and renamed it into
place, so a crash right after the rename could expose a metadata file whose bytes
were never flushed — a torn or empty file that corrupts the whole session (audit
medium store.go:632). Write+fsync the temp via a new writeFileSync helper before
renaming, closing that window. + TestWriteFileSyncRoundTrips.
Deliberately scoped:
- Event-log appends (appendEventLocked) are NOT fsynced per append: that would add
a disk flush to every event (every tool call), and a hard-crash loss of the log's
unsynced tail is acceptable, whereas torn METADATA is not. Metadata is the
integrity-critical part and is what this syncs.
- Directory fsync is omitted for cross-platform simplicity (it errors on Windows);
the file fsync already prevents the torn-contents failure, the serious case.
Left for follow-up (in this bucket but not safe/clear wins here):
- hooks append sequence still re-reads the file per append; the in-memory cache is
cross-process-unsafe and the safe last-line read is fiddly for a low-volume log.
- Fork event re-append/de-dup is nuanced usage-accounting and needs its own change.
* audit batch 18 (agent): count tool-defs in compaction estimate; don't deny an always-allow on persist failure
- maybeCompact's estimate omitted the tool definitions, which ride on every
request, so the real input could exceed the model limit while the message-only
estimate stayed under threshold and compaction never fired (audit medium
compaction.go:58). partitionTools is now computed before maybeCompact (it does
not depend on the messages) and the exposed tools' estimated tokens are added to
both the threshold check and the post-compaction shrink check. + estimateToolDefTokens
and TestEstimateToolDefTokensCountsDefinitions.
- PermissionDecisionAlwaysAllow denied the call when persistPermissionGrant failed,
overriding what the user explicitly allowed (audit medium loop.go:468). Persisting
is now best-effort: a failure (or no sandbox engine) just means the grant is not
remembered and the user is re-prompted next time, not denied now. This also drops
the dead requestEvent.GrantMatched/Grant writes (audit low loop.go:473) — the
emitted event derives them from the sandbox decision via buildPermissionEvent.
Left intentional (documented, not defects): the reactive-retry path omits OnText to
avoid duplicating mid-stream text (loop.go:183); OnContext/MeasureContext is a
complete, tested context-breakdown scaffold awaiting a consumer (types.go:183).
* audit batch 19 (usage): price escalated usage from the event's own model
exec persists payload["model"] on --allow-escalation runs (the model can change
mid-run), but BuildReport priced every event from the session's Metadata.ModelID
and never read the payload, so usage from an escalated model was mis-priced at the
base model (audit medium report.go:17). usageEventPayload now carries Model and
BuildReport prefers it, falling back to the session model when absent (older
events stay byte-identical and behave as before). + TestBuildReportPricesFromEventModelWhenPresent.
* audit batch 20 (sessions): don't copy usage events into a fork (no double-count)
Fork re-appended every parent event, including provider_usage, into the child, so
a usage report aggregating the parent and the fork double-counted the parent's
usage (audit medium store.go:387). Skip EventUsage when copying — it already
counted against the parent and is not part of the conversation history a fork
replays — and record the actual copied count in the fork marker. + TestStoreForkSkipsUsageEvents.
* audit batch 21 (cli/skills): warn on duplicate skill names instead of silently dropping
skills.Load deduplicates same-named skills (first directory wins) and records the
dropped collisions via skills.Duplicates, but nothing surfaced them, so a shadowed
skill just vanished with no signal — and Duplicates had no production caller (audit
low skills.go:91). `skills list` now reports each collision to stderr (keeping
stdout/--json clean), using Duplicates. + TestRunSkillsListWarnsOnDuplicateNames.
Note: skills.Get is kept — it is a by-name accessor used throughout the package's
tests, not dead code.
* audit batch 22 (cli/cron): capture the run error from stream-json stdout
Cron jobs run exec with --output-format stream-json, so a failure is reported as an
`error` event on STDOUT, but the run record's Error was set only from stderr — so a
failed job often recorded an empty/uninformative error while the real message sat
unread in outBuf (audit low cron_run.go:150). On a non-zero exit, scan the stdout
stream-json for the error event's message and prefer it, falling back to stderr.
+ extractStreamJSONError and TestExtractStreamJSONError.
* audit batch 23 (sandbox): richer macOS seatbelt profile (signal + mach-lookup)
The generated sandbox-exec profile was default-deny with only process/sysctl/file
allowances, so two real gaps remained on macOS (sandbox quality report H1):
- A sandboxed script could not signal the children it spawned — `kill` returned
"Operation not permitted" under seatbelt, breaking common `cmd & ... kill`,
test-runner, and timeout patterns. Add `(allow signal (target self) (target pgrp))`,
which stays scoped to the command's own process group.
- XPC to system daemons was denied, so keychain (securityd/trustd), user/group
lookup (opendirectoryd), preferences (cfprefsd), network config
(SystemConfiguration), launch services, and the pasteboard all failed. Add a
curated `(allow mach-lookup ...)` allowlist (`sandboxMachServices`).
Neither touches the file-write or network rules, so the workspace boundary is
unchanged — verified on a real macOS host: `security`/`scutil`/`pbpaste`/self-kill
now succeed while writes to $HOME and /etc are still denied. iokit-open was tried
and dropped as unnecessary. + unit assertions (TestSandboxExecProfileGrantsSignalAndMachLookup)
and extended the darwin integration test with a self-kill case and an
outside-workspace deny check.
* audit batch 24 (sandbox/tools): remove dead OnSandboxDecision hook
RunOptions.OnSandboxDecision had no setter anywhere, yet registry.RunWithOptions
spawned a goroutine (with panic recovery) for it on every sandboxed tool call —
pure per-call overhead for an unreachable callback (sandbox report M1). The sandbox
decision is already surfaced to observers via the permission event
(buildPermissionEvent reads the decision), so this hook is redundant; delete the
field and its call site.
* audit batch 25 (sandbox/cli): exact-path grants — create and revoke by path
The engine already matched ScopeFile grants and DeriveScope already produced them
from path/file tool args, but the CLI could neither create one explicitly nor
revoke a single one (sandbox report L3 / prompt 7). Add:
- GrantStore.RevokePath(tool, path): revokes only the grant whose scope matches the
given file/dir, leaving tool-wide and other-path grants intact (path canonicalized
to absolute like stored scopes). + TestGrantStoreRevokePathRemovesOnlyMatchingScope.
- `zero sandbox grants allow <tool> --path <p>`: persists an exact-file (ScopeFile)
grant, path resolved to absolute so it matches lookups and revokes.
- `zero sandbox grants revoke <tool> --path <p>`: revokes just that grant (default
still revokes all grants for the tool). + TestRunSandboxGrantsCreateAndRevokeByPath.
The TUI permission-card "Allow always (exact path)" option is intentionally left for
the TUI feature branches (it overlaps #187/#188); the engine + CLI now cover the
full create/match/revoke round-trip for file-scoped grants.
* audit batch 26 (sandbox): Engine.Precheck — report violations before execution
Add Engine.Precheck(ctx, request) []Violation, which reports the sandbox
violations that would block a tool request before it runs (sandbox report / prompt 6).
It reuses Evaluate so policy is not duplicated: an allowed or merely-prompted
request yields no violations; a denied request yields its violation (via the shared
violationsFromDecision helper, with a ViolationPolicyDenied fallback for a deny that
carries no structured violation). A nil engine yields nothing.
+ TestEnginePrecheckReportsViolationsBeforeExecution.
Wiring Precheck into the batch-confirmation prompt is intentionally left to the TUI
branch: it touches prompt rendering, and short-circuiting the agent loop's deny path
would skip the before/after-tool hooks that currently fire around a policy-denied
call. The engine API is in place for that consumer.
* audit batch 27 (sandbox): scoped-egress domain prompt with timeout + caching
The scoped-egress proxy failed closed on any host outside the allowlist. Add an
opt-in DomainPrompt callback (prompt 8): an UNKNOWN host (neither allowed nor
explicitly denied) is authorized at request time via the callback instead of being
refused outright. An explicitly denied host is still refused without prompting; the
callback is bounded by PromptTimeout (default 60s, timeout/error => deny); decisions
are cached for the proxy's lifetime so a host is prompted at most once. With no
callback set the strict fail-closed behavior is unchanged. Wired through both the
CONNECT and HTTP handlers via authorizeTarget.
+ TestEgressProxyAuthorizeDomainPrompt / ...TimeoutDenies / ...NilPromptFailsClosed.
The user-facing prompt that supplies the callback is left to the permission UI (TUI)
branch; the egress mechanism (callback + timeout + cache) is in place and tested.
* audit batch 28 (sandbox): AST-based shell analysis (AnalyzeCommand)
Add AnalyzeCommand(script) AnalysisResult, a static shell analyzer built on the
mvdan.cc/sh/v3 parser (prompt 12). Walking the parsed command tree makes it a more
precise second opinion than the regex detector: a program name only counts when it
is an actual command, so `echo "vim ..."`, `printf 'open with vim'`, and
`git commit -m "rm -rf /"` are clean, while real commands are flagged —
interactive (editors/pagers + REPLs, with the same -e/script suppression as
nonInteractiveREPLFlags), destructive (rm -rf/-fr, dd, mkfs, shred, ...), and
network (curl/wget/ssh/nc/...). An unparseable script yields TooComplex so callers
can treat it as higher-risk. + TestAnalyzeCommand (16 cases) / ...EmptyIsClean.
Dependency: mvdan.cc/sh/v3 pinned to v3.11.0 — compatible with the existing go 1.24
directive (the latest v3.13.1 would have bumped the module to go 1.25). It is kept
as a standalone analyzer for callers to consult; feeding it into Engine.Evaluate is
deliberately not done here, since that would change allow/deny classification.
* audit batch 29 (sandbox): unify proxy-env generation (ProxyEnv)
Export ProxyEnv as the single source of truth for proxy-env injection (prompt 9
req 1), replacing the unexported scopedProxyEnv used by the sandboxed-shell path.
+ TestProxyEnv.
Investigation that bounds the rest of prompt 9 (documented in the ProxyEnv doc):
- web_fetch already honors HTTP_PROXY/HTTPS_PROXY — its transport clones
http.DefaultTransport, whose Proxy is http.ProxyFromEnvironment.
- MCP children already inherit the parent env (mergeProcessEnv appends os.Environ),
so they pick up any proxy vars the agent has.
So the only missing link to scope FetchUrl/MCP is a SESSION-level egress proxy whose
address reaches the agent process. That is deliberately NOT wired here: doing it
safely requires allowlisting the active LLM provider's own domain first, or the
agent's provider calls would route through the scoped proxy and be denied — a
correctness pitfall that also can't be verified end-to-end on this host. Flagged
rather than shipped blind.
* audit batch 30 (sandbox): Linux seccomp Unix-socket block — UNVERIFIED, needs Linux CI
Adds a pure-Go (no cgo, no vendored binary) seccomp BPF filter that denies
socket(AF_UNIX) with EPERM on x86-64/arm64, closing the Unix-socket gap bubblewrap's
filesystem/network isolation leaves open (prompt 11):
- internal/sandbox/seccomp.go: the classic-BPF program as a platform-neutral
[]sockFilter, built and UNIT-TESTED (structure + every jump offset in range — the
classic way a hand-written BPF filter goes silently wrong).
- seccomp_linux.go: ApplyUnixSocketBlock installs it via prctl(NO_NEW_PRIVS) +
prctl(SET_SECCOMP, MODE_FILTER). seccomp_other.go is a no-op everywhere else.
- cmd/zero-seccomp: a tiny linux-only exec wrapper that applies the filter then
execs the real command (the standard way to install seccomp in a child with
os/exec); degrades gracefully (warns + runs) if the kernel lacks seccomp.
⚠️ HONEST CAVEAT: I developed this on macOS and CANNOT run the actual AF_UNIX block
here. darwin build, `GOOS=linux` cross-compile, vet, and the structure test all pass,
but the unit test verifies the program's SHAPE, not that the kernel actually blocks
AF_UNIX — that MUST be verified on Linux CI (create a Unix socket under the wrapper
and assert EPERM). Safe to ship as-is: it is linux-only, no-op on every other
platform, and not wired into the bwrap command yet (opt-in prefixing the sandboxed
command with zero-seccomp is the remaining integration), so it cannot regress any
current path.
* audit batch 31 (sandbox): macOS denial log monitor (opt-in, default off)
Re-adds the macOS seatbelt-denial monitor as an opt-in feature (prompt 10). When
Policy.MonitorDenials is set, the sandbox-exec profile tags its default-deny with a
marker and bash tails `log stream` for that tag during the command, appending any
captured denials as a <sandbox_violations> block to the command's stderr so the
model can see what was blocked.
- DenialMonitor (darwin) tails/parses tagged denials with a capped, deduped ring
buffer and noise filtering; no-op stub on other platforms. parseSandboxDenyLine is
unit-tested. + profile-tag test + appendSandboxViolations test.
- Default OFF: with MonitorTag empty, StartDenialMonitor is a no-op and the command
path is byte-for-byte unchanged, so there is no regression or per-command overhead
unless enabled.
CAVEAT: on at least one macOS host (verified live) seatbelt denials are NOT delivered
to the `log stream`-queryable unified log, so the monitor captures nothing there. It
is the standard mechanism and works on macOS versions that do log denials; shipping
it default-off means it never affects whether a command runs.
* audit batch 32 (sandbox): wire seccomp Unix-socket block into bubblewrap (opt-in)
Completes prompt 11: the zero-seccomp helper (which installs the AF_UNIX seccomp
filter) is now actually invoked by the sandbox instead of only existing as a
standalone binary.
- bubblewrapCommandPlan, when Policy.BlockUnixSockets is set, ro-binds the
discovered zero-seccomp helper into the sandbox at its host path and prefixes the
command with it so the filter is installed before the real argv runs.
- findSeccompHelper discovers the helper next to the running executable first, then
on PATH; it returns "" when absent, so the sandbox degrades gracefully (runs
without the extra filter — bubblewrap's fs/net isolation still applies) rather
than failing the command. Exposed as a package var so the wiring is unit-tested
on any OS.
- Reachable from config: SandboxConfig.BlockUnixSockets and .MonitorDenials now map
onto the policy in applyConfiguredSandboxPolicy (opt-in only — config can turn a
hardening flag on, never off). Both default OFF.
- Tests: helper-prefix present when enabled / absent by default; config flags apply
independently and stay off when omitted.
Default OFF and Linux-only effect; the runtime seccomp enforcement still needs a
Linux host/CI to exercise end-to-end (the wiring and BPF program are unit-tested).
* audit batch 33 (sandbox): enforce scoped/deny network policy in web_fetch & web_search
Completes prompt 9: the built-in network tools now honor the SAME allow/deny/scoped
policy the sandboxed shell egress proxy enforces, instead of bypassing it. Before
this, web_fetch/web_search would reach any public host even when the policy was
NetworkDeny or NetworkScoped — the egress proxy only constrained shell commands.
- New shared gate Engine.NetworkHostAllowed(host) reuses the egress domain matcher
(domainAllowed/normalizeDomains/effectiveNetwork) as the single source of truth:
allow → all hosts; deny → none; scoped → only AllowedDomains minus DeniedDomains
(empty allowlist collapses to deny). Disabled policy / nil engine impose nothing.
- web_fetch is now sandbox-aware: the host is checked before the request AND on every
redirect, so a denied/unlisted host fails closed before any dial. Plumbed via a
networkPolicyGate so the no-sandbox path is byte-for-byte unchanged.
- web_search is sandbox-aware: the configured backend's endpoint host is checked
before the query leaves the machine; blocked under deny, and under scoped unless the
backend host is allowlisted.
- The registry already routes sandbox-aware tools through RunWithSandbox when an
engine is present, so this activates automatically wherever the sandbox is wired.
- Tests: engine gate matrix (deny/allow/scoped/denylist/empty/disabled/nil + host:port);
web_fetch & web_search block denied/unlisted hosts without dialing and allow listed
ones; endpoint-host parsing.
* audit batch 34 (sandbox): prove network-policy wiring end-to-end + document opt-in flags
Verification follow-up to batches 31-33; no behaviour change.
- Adds a registry-level test that exercises the FULL chain for the scoped-network
enforcement: registry.RunWithOptions -> engine.Evaluate (passes a scoped,
egress-capable sandbox-exec backend) -> routes web_fetch through RunWithSandbox ->
per-host allowlist blocks an unlisted host before any dial. This proves the
Tool-interface value actually satisfies sandboxAwareTool at the registry boundary,
not just in a direct method call.
- Documents the two opt-in hardening flags (sandbox.blockUnixSockets,
sandbox.monitorDenials) and the scoped web_fetch/web_search behaviour in the README
so the config keys are discoverable. Both remain off by default.
Confirmed wiring: agent loop forwards options.Sandbox to the registry; the registry
routes sandbox-aware tools (web_fetch, web_search) through RunWithSandbox when an
engine is present; MonitorTag is set only on the macOS sandbox-exec plan; the
seccomp helper prefix is added only on the bubblewrap plan when BlockUnixSockets is
set; both config flags map onto the policy via applyConfiguredSandboxPolicy.
* audit batch 35 (sessions): fix Windows CI failure in TestWriteFileSyncRoundTrips
The exact-permission assertion (perm == 0o600) added in batch 17 fails on
windows-latest because Windows does not honor Unix permission bits: Go reports
0o666 for a writable file regardless of the mode passed to OpenFile. This was the
sole cause of the failing Smoke (windows-latest) CI job; macOS and Ubuntu passed.
Guard the perm check with runtime.GOOS != "windows" so it still verifies the mode
on the platforms that enforce it, while the round-trip (write + read-back) is still
asserted on every platform.
* audit batch 36: address CodeRabbit review (5 findings)
- background/process_posix: after SIGKILL, poll until the process group is
actually gone (SIGKILL is async) and return an error if it survives past a
second deadline, instead of returning nil while descendants still race.
- sandbox/safe_command: only treat ')' as a substitution boundary when it closes
an active $(...). A literal ')' (e.g. echo "a) less") no longer splits into a
fake `less` segment and is no longer misflagged as interactive. Depth-tracked so
nested $(...) still isolate their inner command. Direct splitShellSegments test
(fails pre-fix: yields ["echo \"a", "less\""]).
- cron/schedule: the DST fall-back collapse now only applies when `after` sits
exactly on the minute boundary (its "last fire time" form). With sub-minute
precision (e.g. 01:30:30 EDT) the first 01:30 fire already passed, so the repeated
01:30 EST is the legitimate next fire and is returned — restoring strictly-after
semantics. New test fails pre-fix (skipped to next day).
- config/types: maxTurns error message now reads "must be >= 0" to match the check
(0 is accepted; only < 0 is rejected). Negative-maxTurns test asserts the message.
- docs/audit: header date/branch metadata now matches the body (2026-06-13,
fix/audit-batch) instead of the stale 2026-06-11/fix/audit-critical-high snapshot.
Verified: gofmt clean; go vet clean; host + linux + windows builds; affected suites
green. Each behavioural fix ships with a test confirmed to fail without the change.
* audit batch 37: address CodeRabbit re-review (12 findings)
Findings on the audit code from batches 21-36; each behavioural fix ships with a
regression test (several confirmed to fail without the change).
sandbox/analyzer (AnalyzeCommand):
- Unwrap launcher prefixes (sudo/env/nice/bash -c/...) and recurse into `sh -c`
payloads so `sudo rm -rf`, `env curl …`, `bash -c 'vim x'` classify on the real
command, not the launcher. Mirrors DetectInteractiveCommand.
- Honor rm's `--` end-of-options marker so `rm -- -rf` (delete a file named -rf) is
not flagged destructive.
sandbox/engine: NetworkHostAllowed now shares one backend-aware effective-mode
helper with Evaluate, so scoped collapses to deny when the backend can't enforce
scoped egress — the per-tool gate can no longer permit traffic the engine-level
decision would fail closed.
sandbox/egress: cache domain-prompt decisions by host:port, not host, so one
"allow" can't authorize other ports on the same host.
sandbox/safe_command: the segment splitter is now escape-aware (\| , \" ) so an
escaped operator/quote can't manufacture a fake interactive segment.
sandbox/log_monitor (darwin): Wait() the `log stream` child after draining stdout
so the cancelled process isn't leaked as a zombie.
tools/web_search: confine the search backend to same-host redirects (with a limit)
so a redirect chain can't egress to a host the scoped/deny policy never authorized.
cli/sandbox: an explicit empty --path (`--path=` / `--path ""`) now fails closed
instead of silently widening an allow to tool-wide or a revoke to all-grants.
cli/skills: warn on stderr when duplicate-skill detection fails instead of
swallowing the error.
specialist/exec: don't mask a failed orphan-kill — join the TerminateProcess error
with the SetPID error so a surviving process is visible.
background/process_posix: resolve the PGID and only group-signal when pid leads its
own group; otherwise signal the individual PID. Avoids both a false-success on a
non-leader ESRCH and the hazard of signalling an unrelated (possibly our own) group.
Verified: gofmt + go vet clean; host + linux + windows builds; full go test ./...
green.
* audit batch 38: address CodeRabbit re-review round 2 (3 findings)
Follow-ups to batch 37, each with a regression test:
- safe_command (splitter): a ')' is now only a substitution boundary when it
closes an active, UNQUOTED $(...). A quote-state stack saved at each '$(' lets a
substitution run in a fresh quoting context and restores the outer quoting on
its matching ')'. Fixes `echo $(printf "a) less")` splitting into a fake `less`
segment, while still isolating the inner command of `echo "$(vim x)"`.
- egress: deduplicate in-flight domain prompts per host:port. Concurrent requests
for the same unknown target now wait on the first prompt and reuse its cached
decision instead of each firing a prompt (a prompt storm) and racing to write
the final decision. Race-tested with 25 concurrent authorizers → one prompt.
- analyzer + safe_command: wrapper option-value consumption is now wrapper-specific
(wrapperValueOptionsByProg) instead of a global option set. A valueless flag like
`sudo -n` no longer swallows the real payload, so `sudo -n rm -rf` /
`sudo -n curl …` are correctly classified destructive/network; `sudo -u root vim`
still consumes its value. Shared by the AST analyzer and the regex detector so
they can't diverge.
Verified: gofmt + go vet clean; host + linux + windows builds; go test ./... and
-race on sandbox/background all green.
* audit batch 39: address CodeRabbit re-review round 3 (12 findings)
All verified against current code; each behavioural fix has a regression test.
Security / correctness:
- tools/web_search: redirect policy now also refuses a scheme change, blocking a
same-host https→http downgrade that would send the query + bearer token over
plaintext.
- sandbox/safe_command: a ')' is only a substitution boundary when it closes an
active UNQUOTED $(...), and backticks now get the same quote-state save/restore
as $( — so `echo "`a | less`"` and `echo "$(true | less)"` no longer hide the
inner pager from interactive detection.
- sandbox/analyzer + safe_command: wrapper option-value consumption stays
wrapper-specific, and unwrapping no longer aborts on a dynamic ($x) wrapper arg
once a wrapper is active, so `env "$opts" curl` / `sudo "$x" rm -rf` are still
classified.
- sandbox/egress: in-flight prompts deduped per host:port (carried from batch 38);
the prompt callback is now cancellable (context cancelled on timeout) so a stuck
prompt can abort instead of leaking; signature threaded through.
- sandbox/runner: per-plan unique denial tag (pid+counter) so concurrent monitored
sandbox-exec runs can't ingest each other's denials; helper selection now
requires an executable regular file so a non-exec sibling degrades gracefully.
- mcp/network_client: aggregate SSE event-size cap (not just per-line) so a server
can't grow memory with many unterminated data: lines.
- specialist/accounting: a previously-recorded stop/usage with an empty runId is a
catch-all and now suppresses a later runId-bearing duplicate.
- sessions/lineage: Tree fetches the root via Get() first so a corrupt root
surfaces its real error instead of degrading to "not found".
Docs/tests:
- background/terminate: docstring now states the leader-only group-kill (PID-only
fallback for non-leaders); test child-liveness probe uses ESRCH specifically.
- cli/sandbox: grant usage strings include --path.
Verified: gofmt + go vet clean; host + linux + windows builds; go test ./... and
-race on sandbox/background all green.
* audit batch 40: address CodeRabbit re-review round 4 (8 fixed, 1 skipped)
Verified each against current code.
Fixed:
- sessions/lineage: PANIC FIX — store.Get returns (nil, nil) for a missing
session, so the batch-39 root prefetch could nil-deref. Guard root == nil and
return a not-found error. Regression test added (was a panic).
- agent/compaction: the reactive recover() now records lowWaterMark in the SAME
combined (messages + tool-defs) token domain maybeCompact uses, so the proactive
shrink-guard compares like with like. recover() takes tools; callers pass
request.Tools.
- mcp/network_client: SSE aggregate counter no longer counts a joining newline for
the first data line (only when prior content exists), avoiding premature rejects.
- sessions/store: fsync the parent directory after the metadata rename so the
rename is durable across a crash (no-op on Windows, which can't fsync a dir).
- sandbox/egress: parse the target port with strconv.Atoi instead of
net.LookupPort (no context-unaware resolver use).
- sandbox/runner: escape the per-plan denial tag through sandboxProfileString when
embedding it in the seatbelt profile.
- sandbox/seccomp_linux: guard against an empty filter program before taking
&kernelFilters[0] (defensive; the program is fixed non-empty today).
- cli/cron_run_test: assert last-error-wins with two error events.
- background/terminate, cli/sandbox usage, process_posix_test ESRCH (carried).
Skipped (with reason):
- zerogit worktree-rename (Y='R') test: git porcelain v1 -z only emits the
rename-source field when the INDEX column is R/C; the worktree column never
carries 'R' with a source field in v1, so the fixture would test output git
doesn't produce, and making parseStatus consume on code[1]=='R' would risk
mis-consuming the following entry for a plain ` R` status. Left as-is.
Verified: gofmt + go vet clean; host + linux + windows builds; go test ./... and
-race on sandbox/background all green.
* audit batch 41: address CodeRabbit re-review round 5 (4 findings)
All verified against current code; code fixes have regression tests.
- agent/compaction: summarizeWithFallback's reduce pass no longer swallows ALL
errors. A context-limit error still falls back to the joined partial summaries
(documented "extreme" case), but a non-context failure (auth/network/provider)
now surfaces unchanged, per the function's contract. Two tests (propagate vs.
joined-fallback); the propagate test fails without the fix.
- sandbox/safe_command + analyzer: wrapper value-consuming options now include the
long spellings (sudo --user root, env --unset NAME, timeout --signal …), so a
long flag's value is no longer mis-read as the program (interactive/destructive/
network detection bypass). A `--flag=value` token carries its own value and never
consumes the next token. Tests for space and = forms in both the AST analyzer and
the regex detector.
- docs/audit: the status-ledger summary table's open/partial cells (64/14) were the
lone outlier — the ledger's own narrative and 2026-06-13-reverification.md both
state 66/12. Corrected to 66/12 (66+12+97 = 175); derivation: body 69 open + 18
partial minus the 9 already-fixed-on-main (3 open + 6 partial) = 66/12 = 78
pending, fixed 88+9 = 97. Added a comment documenting the summary-vs-body
relationship. The reverification doc already states 66/12, so both now agree.
Verified: gofmt + go vet clean; host + linux + windows builds; full go test ./...
green.
* audit batch 42: address CodeRabbit re-review round 6 (1 fixed, 1 kept w/ proof)
CodeRabbit re-surfaced two "duplicate" refinements; CI was already green (its 6th
pass was a COMMENT with no new findings).
- cli/sandbox_test (TestRunSandboxGrantsRejectsEmptyPath): now seeds a tool-wide
grant and asserts grant-store immutability after EACH rejected empty-`--path`
call (reflect.DeepEqual before/after), so a mutation in one call can't be masked
by a later one — and a buggy "revoke all" is actually observable (it's a no-op on
an empty store). Stronger than the suggested patch.
- zerogit.parseStatus: KEPT consuming the rename source on the index column only
(code[0]), with an explanatory comment. Verified empirically that git porcelain
v1 -z reports a rename/copy (and emits the extra source field) only in the index
column: `git mv` → "R new\0old\0"; a worktree-only rename → " D old\0?? new\0"
(delete + untracked, never "R" in code[1]). Consuming on code[1]=='R'/'C' would
match no real git output and would only risk mis-consuming the next entry on
malformed input, so the suggestion is declined as incorrect for v1.
Verified: gofmt + go vet clean; host + linux + windows builds; affected suites green.
* stage 13: launch readiness (first-run + benchmark)
First-run, model-agnostic-first:
- provideronboarding: detect local OpenAI-compatible runtimes (Ollama/LM Studio)
on their default ports and offer them with no API key required
- provideronboarding: ClassifySetupProbe maps a live connectivity probe to a
specific, fixable error class (bad key, wrong base URL, model not found,
rate limit, config) instead of a stack trace; never panics
- zero setup --verify: probe the provider after saving and report the fixable
error on failure; print a concrete 'try this' headless example on success
zero doctor hardening:
- new sandbox.backend check (bubblewrap/sandbox-exec) with a platform-specific
install remedy; warn (not fail) on policy-only fallback
- new lsp.servers check listing missing language servers with per-binary install
remedies; injectable GOOS/LookupExecutable for deterministic tests
Self-correct surface + benchmark:
- zero exec --self-correct wires the post-edit verify-and-correct loop via the
project verifier; nil corrector when off keeps the agent loop byte-identical
- perfbench task harness: reproducible Terminal-Bench-style runner over headless
zero exec + stream-json; records model + commit + self-correct flag + date
- zero-perf-bench tasks subcommand (with --dry-run) and a sample task set
- docs/BENCHMARK.md: the number, the exact command, the model, and the
with/without-self-correct methodology
* stage 13: address review (verify-state, fixture paths, run_end exit, 404 classification)
* stage 13: verifier inherits process environment (PATH/HOME/toolchain)
* stage 10: distribution (skill/plugin install, tools make)
Add a file-backed distribution surface so skills and plugins can be
installed from a git URL or local path, and new plugin-tools can be
scaffolded. No central registry: a source is just a git URL or path.
- internal/skills/install.go: Install/Remove/Info + skills.lock. Validates
SKILL.md frontmatter via the existing parser, copies it into the skills
dir, and records a sha256 content hash. Reinstall surfaces the hash
change; a clash with a different source is refused without --force.
- internal/plugins/install.go: same flow validated against the plugin
manifest schema, copying the whole plugin tree (minus .git) so the
plugin is activatable. plugins.lock records source + hash.
- internal/tools/scaffold.go: generate a prompt-gated plugin-tool skeleton
(manifest + runnable shell/node/python entry stub) in the toolbox dir.
- CLI: zero skill {add,info,remove}, zero plugin {add,remove}, and a new
zero tools {make,list}; wired in app.go/extensions.go/skills.go.
Safety: fetch honors the process environment (proxy/egress) and disables
git credential prompts; install never executes fetched content (skills are
markdown; plugin install scripts are never run) and installed plugins still
go through normal activation with permission gating.
* stage 10: make no-usable-name install test cross-platform
The test used a whitespace-only source dir name to force the no-usable-name rejection, which is not creatable on Windows (Smoke failure). Switched to a dir base that validSkillName rejects on every platform (contains "..") while staying creatable on Windows; the blank frontmatter still falls back to the unusable dir base, exercising the same rejection.
* stage 10: address review (tree hash, tools-list diagnostics, --json guards)
* stage 10: make canonical-source tests cross-platform (Windows)
Two Windows Smoke failures from the canonical-source review fix:
- TestInstallSameLocalSourceDifferentSpellingIsNotAClash (skills + plugins) used filepath.Rel(cwd, tempdir), which errors on Windows when the temp dir (C:) and repo (D:) are on different drives. Switched to a same-directory alternate spelling (redundant /./), which still exercises canonicalSource without a cross-drive relative path.
- TestRunSkillAddInfoRemove asserted the raw source path, but canonicalSource resolves symlinks/8.3-short-paths, so the stored+displayed source differs on Windows. Normalize the source via EvalSymlinks up front so the assertion matches on every platform.
* stage 10: address CodeRabbit nitpicks (variadic append, header comment)
* stage 11: GitHub Action + Slack/webhook notifier
Add a distribution channel and an output sink for unattended ZERO runs.
internal/notify:
- Add a Sink interface and Notifier fan-out. Notify now emits the terminal
bell/OSC-9 sequence AND forwards every eligible event to attached sinks.
Sinks fire even with no terminal writer (headless CI) and are gated by the
same mode/focus policy. Sink invocation happens outside the lock and a sink
panic is isolated, so a misbehaving or slow sink can never crash or stall the
run. New(w, cfg) stays backward compatible.
- Add a WebhookSink (slack.go) that POSTs a JSON payload
{text, type, message, summary?, links?} to a Slack incoming webhook or any
generic webhook. Delivery is best-effort and fails soft: non-2xx and
transport errors are logged (redacted) and swallowed. The message, summary,
links, and the webhook URL are run through redaction before send/log so
tokens never leak. The default HTTP transport honors HTTP(S)_PROXY, so a
sandboxed run's scoped-egress policy is respected. An empty URL is inert.
action.yml:
- Composite GitHub Action wrapping zero exec. Provider-agnostic: provider,
api-key, and api-key-env are inputs (no provider is hardcoded). Installs the
pinned ZERO release via the bundled checksum-verifying installer, runs the
prompt in the checked-out repo, surfaces ZERO's exit code as the step status,
captures stdout, and can post a summary to the PR or to Slack. Safety
defaults: auto=low, sandbox always active, never passes
--skip-permissions-unsafe; secrets are passed via env and never echoed.
docs/GITHUB_ACTION.md: input/output tables, copy-paste workflows (issue-labeled
triage and nightly dep-upgrade PR), security notes, and the notifier docs.
.github/workflows/zero-action-smoke.yml: validates action.yml parses, declares
the documented inputs/outputs, keeps the conservative defaults, never enables
unsafe mode, and shell-syntax-checks the embedded run blocks.
* stage 11: reword action.yml comment so the unsafe-flag validation passes
The composite step's shell comment contained the literal '--skip-permissions-unsafe', which the action-yml validation (assert the string never appears in the serialized doc, to prove the flag is never passed) flagged even though it was only descriptive. Reworded the comment to drop the literal; the action still never passes the flag and the check stays strict.
* stage 11: address review for action env/inputs and webhook URL redaction
- action.yml: reject prompt+prompt-file when both set; trim only surrounding
whitespace from add-dir entries (preserve internal spaces); export api-key
in-process (validated env name) so the run sees it; treat a commit-SHA
action_ref as not-a-tag and fall back to latest.
- notify: redact the webhook URL in outbound payload fields (not just logs);
copy caller-owned Links/ExtraSecrets slices in the constructor.
* stage 11: disable credential persistence in action smoke checkout
* stage 09: finish plugin wiring (tools/hooks/skills)
Activate resolved plugin manifests so their declared tools, hooks, and
skills take effect at runtime instead of being parsed-but-unconsumed.
- internal/plugins/activate.go: Activate() registers each plugin tool into
the tools.Registry via a command-runner adapter (JSON args on stdin,
stdout/exit -> tools.Result, ${AGENT_PLUGIN_ROOT} expansion + env),
builds hooks.Definition values for plugin hooks, and exposes plugin
skill search roots. Permission maps allow/prompt/deny -> tools.Safety;
allow only survives load-time clamping when manifest auto-approval is
enabled, so mutating plugin tools are not auto-approved by default. A
plugin-aware skill tool merges the default skills dir with plugin roots
(recording duplicate names, never crashing). Malformed plugins/extensions
are skipped with a warning; activation order is deterministic.
- internal/cli: activatePlugins() invokes activation in both bootstrap
paths (interactive TUI in app.go and one-shot exec in exec.go), after
core/specialist/MCP registration. newHookDispatcherWithExtra folds plugin
hooks into the dispatcher's hook set. Fails open: a bad plugin warns and
is skipped, never wedging startup.
- internal/plugins/plugins.go: update the stale 'not yet registered' note.
* stage 09: normalize plugin command path separators for Windows
expandPluginRoot kept the manifest's forward slashes, so a ${AGENT_PLUGIN_ROOT}/bin/x command became a mixed-separator path on Windows (C:\...\001/bin/x), failing the path tests. Added expandPluginRootPath (filepath.FromSlash on the placeholder-expanded executable path only; args keep their slashes since they may be URLs/flags) and used it for the tool and hook command paths.
* stage 09: address plugin wiring review
- surface per-plugin load diagnostics on stderr (not just top-level errors)
- skip plugin tools whose name collides with an already-registered tool
- resolve manifest-relative plugin skill paths against the plugin dir
* stage 09: fall back to plugin path/id when manifest path unknown in load diagnostic
* stage 12: PDF ingestion
Add PDF ingestion to internal/imageinput so specs, design docs, and tickets
that arrive as PDF can be attached the same way images are.
- internal/imageinput/pdf.go: LoadDocument detects PDFs by magic bytes
(%PDF-, never extension alone), enforces a 32 MiB raw-file cap and a
256 KiB extracted-text cap (truncated with a marker, not rejected), and
extracts the text layer in pure Go via github.com/ledongthuc/pdf. The
parser can panic on malformed structures, so extraction is wrapped in a
recover that turns a bad PDF into a clean error. A textless PDF with no
rasterizer returns an explicit "no extractable text; OCR not available"
error rather than silent empty success.
- Dependency posture: default build is pure-Go text extraction (no CGO, no
transitive deps) so the static binary still cross-compiles. The optional
poppler tools (pdftotext / pdftoppm) are used for richer extraction and
page rasterization ONLY when present on PATH (same posture as the LSP
language servers); when absent, extraction degrades to the pure-Go text
path and never errors.
- Vision path: with a vision model and pdftoppm available, the first N pages
(default 10) render to PNG and flow through the existing image pipeline
(NormalizeImageMediaType, per-image cap, allow-list). Without a rasterizer
it degrades to the text layer.
- TUI integration: /image <file.pdf> routes to the document path. The text
layer attaches for any model and is prepended to the next prompt as a
labeled preamble; rendered pages attach as images for vision models. The
chip row and /image clear cover documents too.
Tests cover magic-byte detection, text extraction on a generated fixture,
both size caps, the no-text/no-raster message, malformed-input safety, the
pure-Go fallback, and the TUI attach/submit/clear wiring. go build, go test,
and gofmt are clean; CGO_ENABLED=0 cross-compiles for linux/amd64,
windows/arm64, and darwin/arm64.
* stage 12: address review (pdf page count, text cap, content-based routing)
* wedge stage 06: MCP server resources + prompts
Advertise resources and prompts capabilities in the initialize response and
handle resources/list, resources/read, prompts/list, and prompts/get alongside
the existing tools methods.
Resources enumerate in-scope workspace files via the shared workspace scanner
(reusing its gitignore-style exclusions) and read them read-only with strict
scope enforcement: traversal and out-of-scope absolute paths are rejected with
a JSON-RPC error and no contents, and binary files come back as base64 blobs.
Prompts expose a curated set of templates with simple {{arg}} substitution;
unknown prompt names return an error.
* wedge stage 07: sandbox scoped egress + auto-allow-bash
Add a middle-ground network mode between full allow and full deny:
- types.go: NetworkScoped network mode; Policy.AllowedDomains /
Policy.DeniedDomains; Policy.AutoAllowBashWhenSandboxed plus its
ZERO_SANDBOX_AUTO_ALLOW_BASH env surface (off by default).
- egress.go: in-process loopback (127.0.0.1:0) filtering proxy handling
HTTP CONNECT (HTTPS tunnel) and plain HTTP. Allows only AllowedDomains
minus DeniedDomains via exact-or-subdomain matching (github.com matches
api.github.com but not notgithub.com or github.com.evil.com); deny wins.
Denied targets get 403 / refused CONNECT. Decisions are logged through
the repo redaction so query tokens never hit the audit trail. Fails
closed: empty allowlist is rejected at construction.
- runner.go: NetworkScoped starts the proxy and routes the sandboxed
process through it (HTTP(S)_PROXY/ALL_PROXY + NO_PROXY into the sandbox
env; Linux keeps --unshare-net; macOS profile denies general network
but permits localhost:proxyPort). Empty-allowlist scoped collapses to
deny (effectiveNetwork). Any proxy-start error denies the build rather
than falling back to open network. NetworkAllow/NetworkDeny unchanged.
- engine.go / registry.go / bash.go: honor AutoAllowBashWhenSandboxed -
auto-allow bash without a prompt only when a wrapping sandbox is active
for the command; ignore the flag when the sandbox is inactive. Preflight
network gate now uses effectiveNetwork so a misconfigured scoped policy
still fails closed. Bash defers plan.Cleanup() to shut the proxy down.
Default behaviour is unchanged for existing NetworkAllow / NetworkDeny
users and for the off-by-default auto-allow flag.
* wedge stage 08: MCP OAuth client
Add an OAuth 2.0 + PKCE authorization-code flow so ZERO can connect to
remote MCP servers that require OAuth.
- internal/mcp/oauth.go: authorization-server metadata discovery with
config fallback/override, S256 PKCE, loopback redirect listener, state
(CSRF) validation, code+verifier token exchange, refresh, and optional
dynamic client registration. Tokens are never logged.
- internal/mcp/oauth_store.go: per-server token persistence in a 0600
JSON file under the config dir, with a redaction-safe status summary
that omits the token material.
- internal/mcp/network_client.go: OAuth-configured servers get a round
tripper that attaches the bearer token and, on 401, refreshes once and
retries; refresh failure surfaces an actionable re-login message.
- internal/mcp/config.go + internal/config: an MCP server entry may
declare auth: "oauth" with optional client registration / explicit
endpoints. Non-OAuth servers are unaffected.
- zero mcp oauth {login,logout,status} wired into the CLI surface;
status reports presence/expiry, never the token.
* wedge M2: address review — 2 security fixes, portability, robustness (#179)
Security (Critical):
- resources: resolve symlinks BEFORE the scope check (and resolve roots) so an
in-workspace symlink to an out-of-scope file (e.g. link -> /etc/passwd) is no
longer readable via resources/read. resources/read echoes the requested URI
(not the resolved path) so list/read stay consistent.
- sandbox egress: authorize the dialed URL host, not the client Host header, so
'GET http://denied/ ... Host: allowed' can't bypass the allowlist.
Robustness/correctness:
- prompts: reject prompts/get that omits a required argument; tokenized
substitution so {{...}} inside supplied values is preserved (not stripped).
- resources: percent-encode/decode file URIs via net/url (spaces, #, %).
- oauth: one timeout-scoped context bounds discovery + registration + callback +
exchange; joinWellKnown preserves issuer path segments (RFC 8414).
- cli: 'mcp oauth login' rejects --json instead of silently ignoring it; the
login test is bounded so a missed callback fails fast instead of hanging.
Portability: XDG token-store path test uses t.TempDir() so it passes on Windows
(fixes the Windows Smoke failure).
Adds regression tests for the symlink escape, Host-header bypass, required-arg
rejection, and placeholder preservation. Full go test ./... + -race green.
* wedge M2: fail closed on unenforceable scoped egress (review #179)
Addresses the two P1 scoped-egress enforcement gaps from review:
1. Policy-only fallback ran NetworkScoped with unrestricted networking. On Windows / any host without a native backend, 'network: scoped' with allowedDomains silently got open host networking because Evaluate permitted network-risk tools (expecting a proxy) while BuildCommandPlan ran a direct plan with no proxy. Evaluate now collapses scoped to deny when the active backend cannot enforce egress, so network-risk tools fail closed.
2. Bubblewrap 'scoped egress' could not reach the proxy. --unshare-net isolates the netns and there is no bridge to the host-loopback proxy (the comments claimed one; no code added it), so allowlisted egress never worked. Bubblewrap no longer starts an unreachable proxy: scoped collapses to deny (--unshare-net, no proxy env), matching Evaluate. Comments corrected; a real relay (e.g. slirp4netns) is left as future work.
Mechanism: new Backend.ScopedEgress capability (true only for sandbox-exec, which shares the host net under a seatbelt profile restricting outbound to the proxy port) + Backend.EnforcesScopedEgress(). sandbox-exec scoped egress is unchanged. Regression tests cover enforcing vs non-enforcing backends in Evaluate and the bubblewrap fail-closed plan.
* stage 01: web_search tool
Adds a web_search tool alongside web_fetch: discovers URLs via a pluggable
searchBackend (env-configured generic JSON backend; swappable for Exa/You/
Tavily/Brave) and formats ranked results as a numbered list. Registered in
CoreNetworkTools().
Deviation from the stage spec: the spec said readOnlySafety, but the repo
enforces (TestCoreNetworkToolsExposePromptMetadata) that every core network
tool is prompt-gated network egress — so web_search matches web_fetch's
posture instead of being auto-allowed. Necessary fallout from adding the
tool: knownToolNames (+web_search), the network-tools metadata test (now
covers both), and a stale 'unknown tool' fixture that had used web_search.
* stage 02: LSP client foundation
New internal/lsp package: a direct (self-spawned, stdio) Language Server
Protocol client so unattended runs see real compiler diagnostics without an
open editor.
- client.go: JSON-RPC 2.0 with LSP Content-Length framing; concurrent
Call/Notify, id-matched responses, server->client request auto-reply,
notification handler, context cancellation, read-loop teardown.
- server.go: process lifecycle (spawn via os/exec, initialize/initialized
handshake, graceful shutdown+exit with kill fallback). Handshake factored
out (performInitialize) for pipe-based testing.
- registry.go: extension -> server command (.go/.ts/.tsx/.js/.jsx/.py/.rs),
languageId map, PATH availability via exec.LookPath.
- types.go: minimal LSP types + PathToURI/URIToPath (Windows-drive aware).
Stdlib-only; tested over in-memory pipes with a stub server (no real gopls),
including -race for the concurrent id router.
* stage 03: LSP document sync + diagnostics
Makes the LSP client produce actionable signal so ZERO can ask 'did my edit
compile?' headlessly.
- documents.go: per-server session with document lifecycle (didOpen/didChange
full-sync/didClose), a URI->diagnostics store fed by publishDiagnostics, and
WaitForDiagnostics with a publish-baseline + 300ms quiet debounce (servers
never signal 'analysis complete').
- diagnostics.go: FormatDiagnostics (path:line:col: severity: message, 1-based),
HasErrors, FilterBySeverity, severity labels.
- manager.go: Manager — one lazily-started, reused server per language (keyed by
binary), routes a file to the right server, Check(ctx,path,text) -> settled
diagnostics, graceful Shutdown; unknown extension -> (nil,nil), not an error.
Concurrency-safe; tested under -race over in-memory pipes with a stub server
(no real gopls). FormatDiagnostics takes the path explicitly since a Diagnostic
carries only a range, not its document.
* wedge: address review on #175 (LSP robustness + web_search provider)
- lsp/client.go: Close() now disables the client — Call/Notify reject after
close instead of leaking a pending entry / writing to a dead conn.
- lsp/documents.go: serialize same-document syncs through the Notify write via a
per-URI lock and commit version/open only on success; drop delayed
publishDiagnostics for a superseded (older positive) version;
WaitForDiagnostics now reports whether a fresh publish arrived.
- lsp/manager.go: Check returns (nil,nil) when no publish newer than baseline
arrives (don't return a stale prior result for new text); Shutdown joins and
returns per-server shutdown errors instead of always reporting success.
- lsp/diagnostics.go: FormatDiagnostics collapses embedded whitespace so each
diagnostic stays one physical line.
- tools/web_search.go: forward ZERO_WEBSEARCH_PROVIDER in the request so the
config knob isn't inert.
Adds tests: close-disables-client, stale-version drop, multi-line message
collapse, Shutdown error propagation, httptest backend (provider+parse).
* wedge: degrade gracefully when an LSP server binary is missing (#175 review)
Manager.Check translated a missing-binary start error (exec.ErrNotFound)
into (nil,nil) so a configured extension on a machine without gopls/
pyright/rust-analyzer degrades exactly like an unsupported extension —
LSP diagnostics are an opportunistic layer, not a hard dependency. Other
start failures still surface. Adds a test injecting an exec.ErrNotFound
starter.
* wedge stage 04: self-correct loop (#176)
* stage 04: self-correct loop
After a successful mutating tool call, verify the changed files (LSP
diagnostics + project tests) and feed failures back to the model to fix —
bounded and autonomy-gated, so an unattended run fixes its own mistakes
without looping forever.
- internal/agent/selfcorrect.go: SelfCorrector with injectable diagnosticsChecker
(lsp.Manager adapter) and projectVerifier (verify.DetectPlan+Run); AfterEdit
produces a redacted, model-facing corrective message; per-run attempt ceiling;
low autonomy reports without auto-attempting; medium/high auto-correct.
- internal/agent/loop.go: invoke AfterEdit after a successful mutating tool call
(ChangedFiles non-empty), appending feedback to messages. nil SelfCorrect (the
default) is a no-op — read-only tools never trigger it.
- internal/agent/types.go: add Options.SelfCorrect *SelfCorrector (the explicit
options-struct change).
Single-pass verify per call (the agent loop + model edits provide the multi-
attempt cycle, not selfverify's same-command re-runs). Tested with fakes (no
gopls/shell): fail-then-pass, max-attempts abort, read-only skip, low-autonomy
report-only, LSP-only failure, secret redaction; -race clean.
CLI activation (a --self-correct flag building the SelfCorrector in exec.go) is
intentionally left out to honor this stage's file scope — the engine is exported
and ready to wire.
* wedge: propagate file-read error in self-correct LSP checker (#176 review)
CodeRabbit/golangci-lint (nilerr) flagged lspDiagnosticsChecker.Check returning
(nil, nil) after os.ReadFile fails. Propagate the error instead; the sole caller
(inspect) already skips files whose Check errors, so a deleted/unreadable file
still degrades quietly while future callers can decide. Adds a regression test.
* wedge: surface non-degradable verify errors in self-correct (#175 review)
SelfCorrector.inspect silently continued on every checker.Check error and
ignored verifier errors, so a real LSP startup/sync failure, an unreadable
changed file, or a failed plan detection produced report.Failed=false ->
OutcomePassed with no signal. Manager.Check already degrades missing servers to
(nil,nil) and DetectPlan returns an empty plan (not an error) when nothing is
detected, so a non-nil error from either is a genuine failure. Record such
errors in CorrectionReport.InspectErrors, fail the pass, and surface them in the
model feedback. Adds regression tests for both paths.
* wedge: drop third-party search-provider names from web_search comments
Keep code/comments provider-neutral: describe the backend interface as 'any
hosted search API' rather than naming specific services.
* wedge: defer self-correct feedback until after the tool-call batch (#175 review)
Self-correct feedback was appended as a user message inside the per-tool-call
loop, so a multi-tool-call turn whose first (mutating) call failed verification
produced: assistant tool_use batch -> tool_result 1 -> user feedback ->
tool_result 2. A user message interleaved between tool_results breaks strict
provider replay and later session replay/compaction.
Accumulate feedback during the batch and append it once after the loop (same
after-batch pattern already used for mid-run model escalation), so every
advertised tool call keeps its tool_result contiguous. Adds a two-tool-call
regression test asserting the feedback lands after both tool_results.
* wedge: batch self-correct AfterEdit once per turn over union of changes (#175 review)
Deferring only the feedback emission still ran SelfCorrect.AfterEdit once per
mutating tool call, which (a) consumes the per-run attempt budget multiple times
in a single turn and (b) verifies an intermediate edit that a later call in the
same batch already superseded — AfterEdit is documented as one per-run instance
holding attempt state, so this is a correctness issue, not just transcript shape.
Aggregate every mutating tool's ChangedFiles during the batch and call AfterEdit
once afterward over the deduped union, appending its feedback once (still after
all tool_results, preserving contiguity). Adds dedupeStrings and a regression
test asserting two writes in one turn consume exactly one attempt and emit one
feedback message.
* audit: fix redaction sibling-DAG false cycle, hoist URL regex, widen OpenAI key pattern
- redaction: seen-set now tracks only the current DFS path (delete pointer
after recursing) so a shared reference reached via a sibling branch is no
longer reported as CircularReference; true cycles still detected. (audit
redaction.go:222)
- redaction: redactURLPasswords compiles its regexp once at package scope
instead of per call. (audit redaction.go:362)
- secrets: openai_key body allows - and _ so sk-proj-/sk-svcacct- keys match,
not just legacy sk-<alnum>. (audit scanner.go:35)
* audit: rune-safe cron run-error truncation and commit-subject length
- cronTruncate now cuts on a UTF-8 rune boundary (via cutRuneBoundary) so a
persisted cron run-error excerpt can't end in a split rune. (audit cron_run.go:179)
- zerogit ValidateMessage counts runes, not bytes, so a valid non-ASCII commit
subject under 72 chars is no longer rejected. (audit zerogit.go:207)
* audit: report auth-header-only profiles as having a credential
ProviderSnapshot.APIKeySet was true only for a non-empty APIKey, so a profile
authenticated solely via AuthHeaderValue rendered as 'not set' in the command
center. Treat either credential as set. (audit contracts.go:166)
* audit: address review on #174 (credential-presence coverage + consistency)
- Add ProviderSnapshotFromProfile test covering APIKeySet for api-key-only,
auth-header-only, both, and neither (the AuthHeaderValue path CodeRabbit
flagged as untested).
- doctor.go: report the provider 'apiKey' diagnostic as set when EITHER APIKey
or AuthHeaderValue is present, matching ProviderSnapshot.APIKeySet — closing
the same auth-header-only gap at the other credential-check site.
- scanner_test: also assert the [REDACTED:openai_key] placeholder is present
(matches the pattern used by the existing redaction test).
* audit: trim credentials + surface doctor credential presence (#174 review)
- ProviderSnapshot test: add whitespace-only api-key / auth-header cases so the
TrimSpace behavior stays covered.
- doctor.go: trim both APIKey and AuthHeaderValue before the presence check
(matches ProviderSnapshot.APIKeySet) AND move the indicator off the sensitive
'apiKey' detail key — check() runs RedactValue, so 'apiKey' was always scrubbed
to [REDACTED], leaving the set/not-set indicator invisible. Now reported under
'credentialConfigured' so it actually surfaces; added a doctor table test.
No consumer read the old detail key.
* audit: honor AuthHeaderValue in model discovery + base-URL redaction (#174 review)
Two credential-presence consistency gaps flagged on #174:
- providermodeldiscovery: the /models probe gated on (and sent Authorization
from) profile.APIKey only, so an auth-header-only profile was skipped/sent
unauthenticated. Gate via discoveryHasCredential (APIKey OR AuthHeaderValue)
and build the request with providerio.ApplyAuthHeaders, matching how the live
providers authenticate (honors AuthHeaderValue, AuthHeader, AuthScheme,
CustomHeaders). APIKey-only behavior is unchanged.
- zerocommands.redactProviderBaseURL now also redacts AuthHeaderValue if it
leaks into the base URL (variadic secrets, empties dropped).
Adds regression tests for auth-header-only discovery, discoveryHasCredential,
and auth-header redaction in the base URL.
* audit: doctor credential-presence test parity with contracts_test (#174 review)
Add the 'both' and 'whitespace auth header only' cases so
TestProviderConfigCheckCredentialPresence matches the six cases in
contracts_test.go's TestProviderSnapshotAPIKeySetCountsAuthHeaderValue.
* Edit safety: file version tracking, conflict detection, hook wiring
Phase 1 — FileTracker (internal/tools/file_tracker.go): per-session
map[absPath]{sha256, size, mtime}; CheckConflict flags drift only against
a known baseline, so first-touch writes are unaffected. nil = no-op.
Phase 2 — external-modification detection: read_file records the
whole-file baseline; edit_file and write_file --overwrite refuse to
clobber a file whose on-disk content drifted from it and tell the model
to re-read first, then re-baseline to what they wrote. apply_patch
forgets the files it rewrote (git apply has its own context guard).
Threaded via RunOptions/agent.Options FileTracker, built once per session
in the exec, spec, and TUI paths.
Phase 3 — post-apply validation via hooks: the dormant hooks.Dispatcher
is wired into executeToolCall. beforeTool hooks can veto a call
(DenialHookBlocked); afterTool hook output is scrubbed and surfaced to
the model. DispatchOutcome now collects hook stdout/stderr. Per-session
dispatcher built from merged user+project hooks.json; fails open and runs
nothing until a hook is configured.
* edit-safety: address review — fail closed on read, redact hook reason
- write_file overwrite: if a tracked file cannot be re-read to verify it,
fail CLOSED (refuse the overwrite) instead of silently proceeding — the
read error previously skipped the conflict check and could clobber an
externally modified or replaced file.
- blockedByHookResult: scrub outcome.Reason through redaction before placing
it in model-visible ToolResult.Output; this intercepted path bypassed the
registry's normal output-redaction boundary.
- TestHashContentIsStableAndDistinguishing: compare separately stored hashes
so the stability check is not a same-expression comparison (SA4000).
* edit-safety: flag ToolResult.Redacted when hook output is scrubbed
appendHookFeedback now reports whether scrubbing changed the afterTool
feedback, and blockedByHookResult reports whether it scrubbed the reason;
both set ToolResult.Redacted so the hook-intercepted paths match the
registry's redaction contract. Adds tests asserting a secret-bearing hook
reason/feedback is scrubbed and flips Redacted.
* permissions: show grant scope on the permission card and decided rows
The permission card only named the tool, so 'always' read as a blind tool-wide
yes. Derive a concise scope from the call's args (the file path, directory, or
working dir it touches) and show it on the card and the persisted decision row,
so a user can see exactly what an allow covers.
Phase 0 of the permission-UX work: presentation only, no storage change.
permissionScope + scope plumbing through PermissionEvent/PermissionRequest;
covered by TestPermissionScope.
* permissions: spec for Phase 1 scope-enforced grants
* permissions: enforce grant scope so "always allow" covers only what the card showed
Phase 0 displayed the scope a tool call touches but the persisted grant was still
tool-wide: an "always allow" on a write to one file silently authorized every
write. This scopes the grant to exactly that file or directory.
sandbox/scope.go centralizes scope derivation (DeriveScope), absolute-path
resolution (resolveScopeAbs, anchored to the workspace so a grant never leaks
across projects), and matching (grantCovers: file = exact, dir = subtree,
empty = tool-wide; a tool-wide request is never covered by a narrower grant).
Grants are now stored per-tool as a list (schema v2, with v1 files migrated as
tool-wide grants). Lookup takes the request's absolute scope and applies
deny-wins / most-specific-allow precedence. engine.Grant anchors a relative
scope to the workspace; engine.Decide derives and matches the request scope.
agent.persistPermissionGrant forwards the scope, and permissionScope now shares
DeriveScope so the card and the stored grant can never diverge. Canonicalizing
tool keys on read also closes the whitespace-padded-key lookup miss.
Covered by sandbox scope/grant/engine tests and the agent/TUI always-allow tests.
* sandbox: fix Windows-only TestResolveScopeAbs by using truly-absolute paths
A leading separator (\proj\a) is rooted but not absolute on Windows
(filepath.IsAbs requires a volume like C:), so the absolute-passthrough
case was treated as relative and anchored onto the workspace root,
producing a doubled path. Build the root via filepath.Abs so the test
exercises a genuinely-absolute path on every platform.
* test: isolate session store in exec/tui tests so go test stops writing to $HOME
TestRunExecStreamJSONRunStartUsesResolvedAPIModel,
TestRunExecReadsStreamJSONPromptFromStdin, and
TestPromptSubmitInjectsLiveSessionModelContext ran real exec sessions
but only isolated cwd (t.TempDir + getwd), not the session store, so the
default store resolved sessions.DefaultRoot() off the real $HOME and
persisted into ~/.local/share/zero/sessions on every run. Set
XDG_DATA_HOME to a per-test temp dir (the existing convention in
exec_test.go / exec_scope_test.go) so each run is fully isolated.
* permissions: address review — clean root-equiv scopes, label+fit scope rows
- DeriveScope: filepath.Clean the path before the root check so ./, ./.,
and a/.. collapse to the workspace root and surface as tool-wide instead
of a narrower directory grant (which re-prompted inconsistently). Adds
regression cases.
- renderPermissionRow: prefix the scope segment as scope:<value> across all
three branches and fitStyledLine the allow branch (it returned unfit and
could overflow narrow terminals).
- TestGrantReplacesSameScope: assert the setup store.Grant errors so a setup
failure fails at the real cause, not a stale-state assertion.
* docs: point permission-scope spec at grant_scope.go
The design spec named internal/sandbox/scope.go as the source of truth, but
DeriveScope/resolveScopeAbs/grantCovers live in grant_scope.go (scope.go is
the unrelated write-roots Scope from #162). Fix the heading and the test
reference (grant_scope_test).
TestRunExecStreamJSONRunStartUsesResolvedAPIModel,
TestRunExecReadsStreamJSONPromptFromStdin, and
TestPromptSubmitInjectsLiveSessionModelContext ran real exec sessions
but only isolated cwd (t.TempDir + getwd), not the session store, so the
default store resolved sessions.DefaultRoot() off the real $HOME and
persisted into ~/.local/share/zero/sessions on every run. Set
XDG_DATA_HOME to a per-test temp dir (the existing convention in
exec_test.go / exec_scope_test.go) so each run is fully isolated.
The /resume picker called sessions.Store.List() unfiltered, so it showed every
session directory — including the child (specialist/sub-agent) and spec
draft/impl sessions an agent spawns by the dozen per conversation. That flooded
the picker (the reported '… 779 more · /resume <id>') even though the TUI
creates exactly one session per conversation.
Add Store.ListResumable()/LatestResumable() (and an IsResumableKind predicate)
that keep only regular and user-fork sessions, and use them for the picker and
for '/resume latest' so latest never lands on a newer child/spec sub-run. An
explicit '/resume <id>' still resolves any session by id.
Covered by TestListAndLatestResumableExcludeSubRuns and
TestResumePickerExcludesSubRunSessions.
* docs: spec for additional write roots (--add-dir / /add-dir)
Co-Authored-By: Claude Fable 5 <[email protected]>
* docs: implementation plan for additional write roots
Co-Authored-By: Claude Fable 5 <[email protected]>
* sandbox: add Scope type for multi-root write access
Co-Authored-By: Claude Fable 5 <[email protected]>
* docs: write roots honored from global config only, union merge semantics
Co-Authored-By: Claude Fable 5 <[email protected]>
* sandbox: fix multi-root violation reporting and pin prefix-alias behavior
- validate: prefer ViolationSymlinkTraversal over ViolationOutsideWorkspace
when all roots deny; return original requestedPath in violation; append
--add-dir hint only on outside_workspace results; extend doc comment to
state that a symlink resolving inside any root is allowed.
- normalizePrefixForRoot: dedupe insideRoot check via pathWithinRoot (Fix 3);
widen EvalSymlinks comment to cover jump-over case; add POSIX-only note.
- WorkspaceRoot: add doc comment noting immutability/lock safety (Fix 4).
- Tests: pin multi-root traversal-preferred behavior, deterministic alias
test (TestValidateResolvesAliasedPathPrefixes), Add symlink normalization
test, and tilde-expansion error path test.
Co-Authored-By: Claude Fable 5 <[email protected]>
* sandbox: make engine path validation and risk scope-aware
Thread *Scope through EngineOptions/Engine so Evaluate and Classify use
multi-root scope validation instead of single-root validateWorkspacePaths,
letting extra write roots (--add-dir / /add-dir) be honoured by the policy
gate and risk classifier. Remove now-dead validateWorkspacePaths helper.
Co-Authored-By: Claude Fable 5 <[email protected]>
* sandbox: pin override-root scope isolation, tidy Evaluate scope handling
Co-Authored-By: Claude Fable 5 <[email protected]>
* sandbox: widen seatbelt/bubblewrap profiles and command cwd to scope roots
Co-Authored-By: Claude Fable 5 <[email protected]>
* sandbox: pin extra-root chdir behavior and document bind invariants
Co-Authored-By: Claude Fable 5 <[email protected]>
* tools: resolve paths against the shared write scope
Add PathScope interface and scoped resolver helpers to workspace.go, then
thread a scope field through all 8 file tools (read_file, list_directory,
glob, grep, write_file, edit_file, apply_patch, bash). Each existing
constructor delegates to a new NewScoped* variant; nil scope is byte-identical
to the original behavior. Registry gains CoreReadOnlyToolsScoped,
CoreWriteToolsScoped, CoreShellToolsScoped, and CoreToolsScoped.
Co-Authored-By: Claude Fable 5 <[email protected]>
* tools: restore fail-closed write-target symlink checks, share prefix normalization
* tools: report absolute paths for extra-root changes, pin scoped traversal denial
- resolveScopedPath and resolveScopedTargetPath now return the absolute
path as the second value when the matched root is an extra (non-workspace)
root, eliminating ambiguity in ChangedFiles, cwd meta, and display
summaries downstream
- Add TestScopedWriteRefusesSameRootSymlinkTraversal to pin that same-root
symlink traversal within an extra root is denied (pre-existing behavior)
- Add TestScopedWriteReportsAbsolutePathForExtraRoot to enforce the new
absolute-path contract for ChangedFiles on extra-root writes
- Extend doc comments on PathScope, ChangedFiles, changedFilesFromPatch,
and both scoped resolvers to capture the workspace-vs-absolute invariant
Co-Authored-By: Claude Fable 5 <[email protected]>
* config: add sandbox.additionalWriteRoots (global config only, union merge)
* cli: add repeatable --add-dir flag wiring scope into registry and engine
Co-Authored-By: Claude Fable 5 <[email protected]>
* cli: forward --add-dir across dispatch paths, pin scope wiring with tests
Co-Authored-By: Claude Fable 5 <[email protected]>
* cli: pin --add-dir dispatch forwarding end-to-end, close guard gaps
Review fixes for the --add-dir dispatch commit:
- Add TestRunAddDirDispatchForwardsGrantIntoExecScope: drives
runWithDeps through both the "exec" and "-p" dispatch shapes with a
provider that calls write_file inside the extra root, asserting the
grant reaches the exec sandbox scope (and a negative control without
--add-dir is denied fail-closed). Mutation-verified: dropping
addDirFlagArgs forwarding from either case fails the test.
- Remove help/version from the --add-dir allowlist so any non-TUI/
non-exec leading --add-dir hard-errors, matching the stated
requirement; extend the rejection test with those cases.
- Fail loud when --add-dir is hidden behind a stray non-flag arg on the
--skip-permissions-unsafe path instead of silently dropping the grant.
- Reject flag-like values in the inline --add-dir= spelling to match
the space form (a directory named -foo stays reachable as ./-foo).
- Move TestExecScopeReRegistrationSwapsCoreToolsByName out of the parse
test file into the new exec_scope_test.go alongside the e2e test.
Co-Authored-By: Claude Fable 5 <[email protected]>
* tui: add /add-dir command for session write-root grants
Co-Authored-By: Claude Fable 5 <[email protected]>
* sandbox: surface write roots in zero sandbox status and plan snapshots
Co-Authored-By: Claude Fable 5 <[email protected]>
* cli: surface write-roots error in --effective --json, normalize fallback root
Review fixes for the Task8 observability range:
- runSandboxPolicyEffective JSON payload gains writeRootsError
(omitempty) so --json consumers see the same fail-soft signal as
the text write_roots_error line instead of a misleading
workspace-only writeRoots list.
- The fail-soft fallback now derives its write roots from a
workspace-only Scope (which cannot fail) so the error path renders
the same symlink-resolved root as the success path.
- TestRunSandboxPolicyEffectiveWriteRootsFailSoft gains a --json leg
asserting writeRootsError names the stale root with a
workspace-only fallback; the configured-roots test asserts the key
is absent for valid roots.
- SandboxPlanSnapshot doc comment reworded to match the converter:
no current builder populates WriteRoots.
Co-Authored-By: Claude Fable 5 <[email protected]>
* sandbox: seatbelt integration test for extra write roots
Co-Authored-By: Claude Fable 5 <[email protected]>
* docs: document --add-dir and /add-dir write-root grants
Co-Authored-By: Claude Fable 5 <[email protected]>
* sandbox: pass volume-qualified paths through prefix normalization verbatim
NormalizePrefixForRoot's POSIX component walk mangled Windows drive paths
(C:\Users -> C:Users), which downstream single-root checks treated as
relative and joined under the workspace — failing the policy gate OPEN on
Windows. Volume-qualified paths now bypass the walk entirely (the macOS
/var alias problem it solves has no Windows equivalent) and windows-gated
tests pin both the verbatim pass-through and the engine denial.
Co-Authored-By: Claude Fable 5 <[email protected]>
* sandbox: make prefix normalization volume-aware for Windows alias resolution
The previous commit disabled NormalizePrefixForRoot on Windows, which broke
it the other direction: a workspace created under an 8.3 short path
(C:\Users\RUNNER~1\...) resolves via EvalSymlinks to its long form
(runneradmin), so raw short-form requests escaped the long-form root and
legitimate extra-root writes were denied. This is the same alias problem as
macOS /var -> /private/var, which the helper already solves. Start the
component walk from the volume root (C:\ or //host/share/) instead of "/"
so it resolves the prefix on Windows too; POSIX VolumeName is empty so the
walk reduces to the original "/"-rooted behavior byte-for-byte. Windows
test now pins allow-inside-root + deny-outside both directions.
Co-Authored-By: Claude Fable 5 <[email protected]>
* sandbox: address PR review findings for additional write roots
Resolve the CHANGES_REQUESTED review on PR #162:
- engine: derive workspaceRoot from scope.Roots()[0] when NewEngine is
given a Scope without an explicit WorkspaceRoot, so Evaluate's
EnforceWorkspace/classification guards no longer silently skip
(+ regression test).
- tools/workspace: scopedRoots now fails closed (returns an error) when a
non-nil PathScope exposes empty Roots(), instead of returning success
with an empty path; threaded through the scoped resolve/recheck helpers
and resolveGrepRoot.
- tools/glob: emit absolute matches when cwd resolves into an extra root
so results can't be fed back and resolve to a same-named workspace file
(+ regression test).
- tools/bash,grep,apply_patch: schema descriptions mention granted extra
roots (relative -> workspace, absolute -> granted root).
- docs/plan: fix markdownlint heading increment, malformed rule+heading,
and bare code fence.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* tools/grep, tui: address PR review for additional write roots
- grep now emits absolute, symlink-resolved paths for matches in a granted
extra (non-workspace) root, mirroring the glob fix. A bare workspace-relative
name like "report.go" otherwise resolves back under the workspace when fed to
read_file/edit_file and hits the wrong file when the name exists in both
roots. Covers content and files_with_matches output.
- tui: only reset chatScrollOffset for a real submission, not an empty Enter
(a no-op), so the viewport is no longer yanked to the bottom when nothing was
submitted. Real submissions still snap back.
- glob: schema description now mentions granted extra roots, matching the
bash/grep/apply_patch wording so the --add-dir/-add-dir flow is discoverable.
Regression tests: TestScopedGrepReturnsAbsoluteMatchesForExtraRoot,
TestEmptySubmitKeepsChatScrollOffset.
* docs: add /add-dir to the TUI commands quick-reference table
The command was documented further down but missing from the scannable
commands table, so users browsing the table wouldn't discover the
additional-write-roots flow.
---------
Co-authored-by: Claude Fable 5 <[email protected]>
Use distinct per-attempt errors and assert errors.Is(err, lastErr) plus the
attempt count, so a regression returning the first failure (or short-circuiting)
is caught instead of slipping past a bare non-nil check.
Fixes from the code review on the critical/high audit branch:
- tools/apply_patch: parseHunkCounts now reads only the range section between
the opening and closing "@@". A crafted section heading like
"@@ -1,1 +1,1 @@ +1,999999" could otherwise overwrite the line count, wedge
the parser in hunk mode, and swallow later file headers so they escaped
validatePatchPaths / recheckPatchWriteTargets — a workspace-confinement bypass.
- providerhealth: the SSRF-hardened dial now tries every validated address in
order instead of pinning addrs[0], so a dual-stack/multi-record provider isn't
failed by a single dead record. Extracted dialValidatedAddrs for coverage.
- hooks/dispatch: a beforeTool hook killed by its deadline now fails CLOSED
(vetoes the tool) instead of fail-open. A launch failure (missing binary)
still fails open so a misconfigured hook can't wedge every tool call.
- cron: Store.Get returns a distinct ErrJobNotFound sentinel; cron_run only
treats that as "removed during run" and still records/persists on a transient
read error instead of silently dropping the run.
- providers/anthropic: closeOpen finalizes thinking buffers still open at stream
end (SSE ended after thinking_delta/signature_delta but before
content_block_stop), so they survive in the done event's ReasoningBlocks for
replay. Finalized in index order.
- providers/gemini: corrected the stale thinkingConfig comment (no IncludeThoughts
field; thought summaries are suppressed by skipping part.Thought parts).
Tests: regression coverage for each fix, plus the requested nitpicks —
EOF-terminated MCP line, recipe-prompt propagation assertion, checked
file.Close() in session store tests, and a shortened bash process-group timeout
test via the bashWaitDelay hook.
Note: declined the suggestion to replay a "signature" on redacted_thinking
blocks — Anthropic's redacted_thinking carries only an opaque "data" field; the
signature is for regular thinking blocks, and the stream never populates one for
redacted blocks.
The package had all the primitives (Select, ConfigStore, AuditStore) but no
caller ever ran a hook. Add a Dispatcher that selects the hooks for a lifecycle
event, runs each command (no shell) with the event payload on stdin under a
timeout, and records started/completed audit events. A beforeTool hook that
exits non-zero vetoes the tool and stops the chain; other events are advisory.
A hook that cannot be launched is recorded as an error but never blocks. The
command runner is injectable for testing.
The torn-tail tolerance dropped any malformed final line, including a
complete-but-corrupt line ending in a newline — silently swallowing real
corruption. A genuine torn tail is an incomplete write with no trailing
newline; gate the tolerance on that so a fully-flushed corrupt line fails
loudly again, while a truncated final line is still recovered.
Map a requested reasoning effort to a Gemini thinkingBudget (capped at the
lowest per-model ceiling). Capture the thoughtSignature each functionCall part
carries and preserve it on the tool call (via a new StreamEvent/ToolCall
signature field), then replay it on the functionCall part in later turns so
multi-turn function calling with thinking is not rejected. Thought-summary
parts are skipped so reasoning never leaks into the answer. No effort = no
thinking config, so default runs are unchanged.
Map a requested reasoning effort to an Anthropic thinking budget and enable
extended thinking, raising max_tokens so the budget plus a response both fit.
Crucially, capture the streamed thinking / redacted_thinking blocks (text +
signature) and preserve them on the assistant message so they are replayed
verbatim at the start of the next turn — without this, Anthropic rejects every
tool-using follow-up. Adds zeroruntime carriers (Message.Reasoning, ToolCall.
Signature, ReasoningBlock) shared with the upcoming Gemini work. No effort =
no thinking, so default runs are unchanged.
zeroruntime.CompletionRequest had no effort field, so agent.Options.Reasoning
Effort never reached the provider (cli/exec.go even noted it was 'not yet
forwarded'). Add CompletionRequest.ReasoningEffort, thread it through every
request the agent loop builds, and map it to OpenAI's reasoning_effort. The
CLI gates the forwarded value against the resolved model: known non-reasoning
models send nothing (matching the existing 'ignoring' advisory), known
reasoning models send their effective level, and unknown custom endpoints
forward the requested value as-is. Anthropic/Gemini mapping is handled
separately.
Providers already normalized finish_reason into collected.FinishReason, but
the agent loop never read it, so a final answer cut off at the output token
cap (or by a content filter) was silently presented as complete. Carry the
turn's finish reason into agent.Result, add Truncated()/TruncationNotice(),
and emit a warning from the exec writer and a system row in the TUI when the
response was truncated.
The macOS sandbox-exec profile made only the workspace writable, so
ubiquitous operations failed with 'Operation not permitted' — `> /dev/null`
and mktemp among them. The bubblewrap backend already grants these via
--dev /dev and --tmpfs /tmp; bring the sandbox-exec profile to parity by
allowing the standard device nodes and the system temp trees. The workspace
remains the only writable project location.
The default probe used http.DefaultClient, which (1) followed redirects
without revalidating the target, so a 3xx to an internal address bypassed
the pre-flight endpoint check, and (2) re-resolved DNS at dial time,
leaving a rebinding TOCTOU window after validateEndpoint. Build a dedicated
client that revalidates every redirect hop (capped at 5) and validates +
pins the resolved IP at dial time, dialing the IP literal so the kernel
cannot re-resolve to an unsafe address.
On timeout the context cancelled only the shell, leaving backgrounded
children (e.g. 'sleep 3 & wait') orphaned. Worse, those children inherited
the stdout/stderr pipes, so Run() blocked until they exited — far past the
timeout. Set Setpgid and a Cancel that SIGKILLs the whole process group on
POSIX, plus a WaitDelay backstop so a lingering child can't hang Wait. On
Windows, set WaitDelay only (no process groups).
A specialist run with no description set --session-title to just the
description (empty), leaving AgentName empty on resume and making the
session unresumable. Always prefix the title with the specialist name and
fall back to the bare name when no description is given.
- `cron add "<expr>" --recipe X` silently dropped the explicit schedule because
the recipe set expr before the positional arg was consumed. Consume the
positional expr first so it overrides the recipe default.
- cron_run persisted the job copy read at tick start, so a pause or removal that
happened during the run was clobbered back to active. Re-read before persisting
and preserve an external pause/removal (best-effort; full atomicity needs a
lock).
ReadEvents returned an error on any malformed JSONL line, so a single
interrupted append (truncated final line) made resume hard-fail and lose the
whole session. Drop only a malformed final line (a torn tail); malformed lines
earlier in the file are real corruption and still error.
The sk- and bearer patterns were unanchored, so "sk-" inside "task-list" and
the word after a bare "bearer" were replaced with [REDACTED], corrupting
legitimate output. Anchor sk- on a word boundary and require a credential-length
body for both sk- and bearer tokens; real keys/tokens are still redacted.
- grep matched the glob against the workspace-relative path, so "*.go" with
path="subdir" found nothing while "**/*.go" matched subdir/a.go. Match the
glob relative to the search directory (ripgrep semantics); a single explicit
file is matched by its base name.
- apply_patch parsed file paths per-line, so a hunk-body line removing content
that starts with "-- " (appearing as "--- ...") was mistaken for a file
header and a valid patch was rejected. Parse headers with hunk line-counting,
matching how git apply delimits hunks.
- protocol.go read() rejected no upper bound on Content-Length, so a hostile
peer could drive make([]byte, n) with n up to ~9.2e18. Cap every framed
message at 64 MiB (both the Content-Length and newline-delimited paths).
- The MCP stdio transport is newline-delimited JSON, not LSP Content-Length
framing. write() now emits one JSON message per line; read() accepts
newline-delimited frames and still tolerates Content-Length for back-compat,
so Zero interoperates with spec-compliant MCP servers and clients.
Use Bubble Tea mouse button values for wheel handling instead of the deprecated mouse type field, and remove the unused onboarding helper.
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/tui -run 'TestMouseWheelScrollsChatWithoutRecallingInputHistory' -count=1
Tested: GOCACHE=/tmp/zero-go-cache go test ./...
Tested: GOCACHE=/tmp/zero-go-cache go vet ./...
Tested: git diff --check
Render the startup logo as the full ZERO wordmark while keeping the O in the existing lime accent and the ZER prefix in the normal ink color.
Tested: GOCACHE=/tmp/zero-go-cache go test ./...
Tested: git diff --check
Introduce zero setup plus first-run onboarding for missing provider credentials, backed by the provider catalog and config save path.
Make the TUI fullscreen in interactive mode, keep setup transitions inside the chat surface, and add managed transcript scrolling so wheel/PageUp/PageDown do not recall composer history.
Tested: GOCACHE=/tmp/zero-go-cache go test ./...
Tested: git diff --check
* docs(design): add Lime TUI prototype and rebuild spec
* tui: remove the zeroline skin system
One design remains in the tree ahead of the Lime rebuild: the zeroline
package, its TUI view/skin plumbing (Options.Skin/ThemeVariant/ThemeDark,
model skin branches, theme picker), and the zeroline CLI subcommand are
gone. /theme keeps its shell-only message.
* tui: replace the cyan palette with the Lime token table
theme.go is rebuilt around the Lime design tokens (docs/design/
zero_tui_lime.html): near-black panels, one lime accent, solid tint
stand-ins for the prototype's rgba overlays. Existing render sites map
onto the new role styles (ink/line/userPrompt/toolName/toolArg) without
layout changes; no hex exists outside theme.go.
* tui: build the Lime chat-surface components
Title bar with brand badge + context window, empty state replacing the
deleted splash (block-art 0, tagline, 1-3 starter suggestions), stream
blocks (user prompt gutter, muted interim with cursor, final-answer
accent rail, done line, bordered notes), tool cards with diff/read/
bash/grep bodies and MiniDot run spinner, borderless composer with
run-state hints, and the grouped status line. Final answers are marked
at append time; resolved tool calls collapse into their result cards.
* tui: fix Stage 2 review findings
Adversarial review of the chat-surface commit confirmed 15 issues, all
fixed here:
- wrapPlainText sliced wide runes by rune count: CJK/emoji prose could
panic the TUI or emit lines ~2x the measure; split at width instead
- a cancelled run's streamingText leaked into the next turn's interim
block; cancelRun now clears it
- orphaned tool-call cards (cancelled/errored turns) re-animated with
the spinner on any later run; the glyph is now scoped to the call's
own runID
- starter-suggestion digit capture was active while the chips were off
screen (/clear mid-run); the guard now mirrors the render condition
- prompt submits while a deferred Ctrl+C quit is draining could start a
run the quit would orphan; submission is blocked while exiting
- the running placeholder advertised ctrl+c (which quits) as the
interrupt key; it now says esc
- read-card parser expected 'N: text' but read_file emits 'N | text',
so the gutter and L-range never rendered on real output
- toolCard borders were one column short of the body rows
- diff tint bands now span the full row; NEW FILE renders green on
addBg; preamble/no-newline lines are unnumbered meta and no longer
shift hunk numbering
- card bodies and system notes paint the panel surface; the head gains
the separate faintest arg column (grep target + pattern)
- title-bar overflow now truncates the minimal candidate instead of
rendering the deleted design's 'ZERO … READY' line
- untracked the stray snake-game.html (left on disk, ignored)
* tui: restyle the interactive surfaces for Lime
Permission prompts render as the amber permission card (PERMISSION
badge, risk readout, key chips); resolved prompts collapse to one line
(allowed once/always/denied · tool) and unprompted allows fold into the
tool card's [auto] tag. Ask-user and spec-review prompts share the card
language with line borders. Autocomplete and picker overlays move onto
the panel/selection tints with the accent ❯ marker; model-picker rows
gain the provider dot and ctx · KEY_ENV metadata from the registry and
provider catalog. /resume renders its list as stacked session cards
(id + age, title, meta). Key handling is untouched everywhere.
* tui: adaptive width tiers, doc updates, final polish
Width tiers re-evaluate from the live width: ≥100 renders the full
spec; 80–99 drops the tool-arg column, header ctx, and the status
'interactive' group; 58–79 drops the diff/read gutters, bares the
badge, and keeps provider+tokens+mode; <58 renders a single-segment
header, rail-less cards, and a mode-only status line. Table-driven
tests cover {58,70,80,100,120} plus a frame-wide invariant that no
emitted line exceeds the terminal at any tier. Card bodies expand tabs
so width math holds. Dead splash-chip machinery (startupCommandNames,
startupOrder) is gone; README's TUI section now describes Lime.
* tui: fix final-review findings across Stages 3-4
A second adversarial review confirmed 14 issues, all addressed:
- permission collapse and tool-card pairing are now run-scoped (rcKey):
providers with repeating synthetic ToolCallIDs (Gemini gemini_tool_N)
could attribute a decision or result to a different run's call
- rehydrated always-grants no longer mislabel as 'allowed once'
(GrantMatched fallback)
- the width invariant now actually holds everywhere: empty-state
tagline/hint, ask-user rows, permission detail blobs, pending image
chips, and the sessions trailing hint are wrapped or fitted; the
invariant test renders the empty state and all of those rows at
widths 24-120
- every card (permission, ask-user, spec-review, sessions, notes)
drops side borders on tiny terminals, not just tool cards
- /resume card fields are sanitized against separator bytes from
user-controlled titles
- the picker's selected row paints its gap so the selBg band is solid
- dead splash helpers (normalizedStartupWidth, countLines, nonEmpty,
unreachable rowWelcome branch) and stale /theme comments are gone
- README no longer claims transcript scrolling keys or Tab-accept
behavior that never existed; chatWidth documents its 24-col floor
Tag tui-lime-s4 moves to this commit.
* audit: deep-audit report, TUI scrollback re-architecture, and cross-module fixes
TUI: settled-row flush frontier prints history to native terminal
scrollback (tea.Println, ordered + ack-serialized); View() renders only
the live tail. Full diffs (400-line flush cap) and OSC 8 file:// links
land in history; streamed interim text, cancellation markers, ask_user
exchanges, and composer input history are preserved; run-scoped dedup
keys fix Gemini's repeating tool-call ids; /exit, Ctrl+C-after-Esc, and
cross-session late flushes no longer orphan or contaminate checkpoints.
Cross-module: zero search panic + offset mapping, usage report
robustness, 529 retry, SSE keep-alive watchdog, registry corrections,
update --check on dev builds, MCP ping + mcp list --json secret
redaction, sandbox.network config knob, doctor apiKey presence, atomic
config writes, C1-safe notifications, rune-safe truncation everywhere,
gofmt + vet CI gates, dead glamour dependency dropped.
Full audit report: docs/audit/2026-06-10-deep-audit.md (175 findings).
Co-Authored-By: Claude Fable 5 <[email protected]>
* tui: hide malformed ask_user tool errors
* fix tui malformed tool retries
* fix max-turn finalization
* upgrade tui agent guardrails
* fix coderabbit guardrail findings
---------
Co-authored-by: Claude Fable 5 <[email protected]>
Co-authored-by: Vasanthdev2004 <[email protected]>
* cron: dep-free 5-field cron expression parser
* cron: next-run evaluator with Vixie DOM/DOW semantics
* cron: preset recipes + loop.md prompt resolution (cap + symlink reject)
* cron: file-backed job store (metadata.json + runs.jsonl)
* cli: zero cron add/list/rm/pause/resume over the cron store
* cli: zero cron run foreground scheduler (--once/--catch-up, exec per fire)
* cron: fix adversarial-review findings (DST loop, traversal, runaway re-fire, +)
- Next: fix infinite loop on DST spring-forward gaps (advance hours by absolute
addition + a forward-progress guard); widen the search window to 9 years so a
Feb-29 schedule across a century non-leap year (2096->2104) isn't reported as
impossible.
- store: reject path-traversal job ids (Get/Update/Remove/AppendRun) so
'cron rm ../..' can't delete outside the store; List now surfaces corrupt jobs
as a warning instead of silently dropping them.
- runner: reject impossible schedules at add even with --run-now; auto-pause a
job whose schedule can no longer advance (was re-firing every tick); only
skip-reschedule STRICTLY-overdue jobs on startup (keep exactly-due ones);
capture stderr into RunRecord.Error on non-zero exit; pass the prompt via the
inline --prompt= form so dash-leading prompts aren't misparsed.
- Regression tests for each (DST, leap gap, traversal, corrupt-list, pause,
reconcile, dash-prompt).
* cron: address #155 review (store-error surfacing, resume guard, Runs traversal, +)
- fireJob/reconcile/cronRun now surface AppendRun/Update failures to stderr
instead of discarding them (a failed write no longer silently re-fires/loses
history); fireJob takes stderr.
- cron add rejects extra positional arguments instead of silently ignoring them.
- cronResume rejects an unparseable/impossible schedule instead of reactivating a
job with a stale/zero NextRunAt.
- store.AppendRun returns the Close() error (buffered-write failures surface on
close); store.Runs now validates the id (path-traversal guard, matching the
other store methods).
- tests: Runs traversal rejection, cron add extra-args, resume-impossible.
* cron: check store errors in cron CLI tests
CodeRabbit (critical): the cron CLI tests ignored errors from store.List/Add/Get, so a store failure would surface as a misleading assertion on a zero-value job/slice instead of a clear failure. Check every store op's error across the test file (not only the two flagged sites).
* cron: DefaultRoot falls back to os.UserHomeDir when HOME is unset
Vasanth (P1): cron's DefaultRoot mirrored neither sessions.DefaultRoot nor reality — with no XDG_DATA_HOME and no HOME it produced a RELATIVE ".local/share/zero/cron" under the caller's cwd, so on Windows/restricted shells cron jobs scattered per working directory and could write repo-local data. Now falls back to os.UserHomeDir() like sessions.DefaultRoot. Adds TestDefaultRootEmptyHomeFallsBackToUserHome (asserts the path stays absolute).
* Add provider health diagnostics
* Fix provider health auth review findings
* Fix provider health test handler assertion
* Harden provider health connectivity probes
* repoinfo: language map + build/test/CI + workspace detection helpers
* repoinfo: Collect repo characterization from local git (network-free)
* cli: zero repo-info command (text + --json) over the repoinfo package
* repoinfo: derive AgeDays from the root commit, not git log --reverse -1
git applies -1 before --reverse, so the old call returned the LATEST commit
(age always ~0). Use --max-parents=0 (root commits) and take the oldest.
* repoinfo: count passthrough dirs + unicode-safe ls-tree (-z); harden tests
Adversarial-review fixes:
- DirectoryCount now counts every directory on the path to a file (expand each
file's ancestors), so directories holding only subdirectories are included.
git ls-tree -r lists blobs only, so the previous parent-of-file count
undercounted. Verified against 'git ls-tree -r -t | awk $2==tree'.
- ls-tree now uses -z (NUL-terminated, unquoted paths) instead of newline
scanning, so non-ASCII/special filenames (which git C-quotes by default) are
parsed correctly instead of being silently dropped. Drops the bufio scanner.
- Tests: assert DirectoryCount/MaxDepth; add an age-from-oldest-root-commit test
(would catch the old log --reverse -1 bug); make the CLI JSON test hermetic
via a temp git repo.
* repoinfo: strip credentials from remote URL before storing/printing
Addresses #150 review (@Vasanthdev2004 P1): a git remote can embed secrets
(e.g. https://x-access-token:[email protected]/o/r.git), which would leak in
both text and --json output. sanitizeRemoteURL strips userinfo from URL forms
and the leading user@ from scp-like forms. Tests: unit table for the sanitizer,
Collect-level strip, and end-to-end CLI text + JSON assertions that the
credential never appears.
* notify: dep-free terminal bell / OSC-9 completion notifier
* config: notify.mode + notify.focusMode (validated, default off)
* exec: --notify/--no-notify + completion notify on stderr
* tui: focus reporting + completion/awaiting-input notifications
* notify: fire on exec --use-spec completion; headless ignores focus; gate focus-reporting on enabled
* notify: validate --notify value, skip notify on empty ask_user, deterministic resolver tests
Addresses #149 review (CodeRabbit + @Vasanthdev2004):
- parseExecArgs rejects an invalid --notify value (only off/bell/notify/both),
so a CLI typo errors early instead of silently running with no notification.
- TUI OnAskUser fires AwaitingInput only when len(request.Questions) > 0 — a
request with no questions auto-resolves without prompting, so the bell/desktop
notification was a false positive.
- notify resolver tests pass Env: map[string]string{} so Resolve does not fall
back to os.Getenv (deterministic, not ambient-env dependent).
* Deferred (lazy) tool loading: tool_search + auto threshold for large MCP tool sets
Adds deferred tool loading so a large MCP tool set no longer sends every tool's full JSON schema each turn. When the number of visible MCP tools crosses a threshold (config tools.deferThreshold, default 10; 0 disables), their schemas are withheld and advertised compactly in a per-turn system-reminder; the model pulls a tool's full schema on demand via a new tool_search tool. Strictly additive: below the threshold behaviour is byte-identical to today. No new module dependencies.
- Eligibility: MCP tools opt in via an optional Deferred() bool interface (tools.IsDeferred); built-ins stay eager. No change to the core Tool interface.
- Formatting (internal/tools/deferred.go): compact one-liners wrapped in a <system-reminder> by BuildDeferredReminder; server label via MCPServerName().
- tool_search (internal/tools/tool_search.go): SideEffectNone/PermissionAllow/AdvertiseInAuto; select:Name1,Name2 (exact) or keyword (ranked); returns full schemas and signals the loop via Meta["load_tools"]. Operator-filter-aware (never lists tools hidden by --enabled-tools/--disabled-tools).
- Agent loop: partitionTools is the single source of truth; eligible counts only ToolVisible deferred tools; Meta["load_tools"] lifts to ToolResult.LoadedTools and unions into a per-run loaded-set advertised next turn. Reminder appended only to the per-turn request copy (never persisted), including both reactive-compaction retry paths. tool_search stays reachable when deferral is active (exempt from the EnabledTools allowlist).
- Config + wiring: tools.deferThreshold (default 10; 0 disables; negative rejected); tool_search registered and DeferThreshold threaded in exec and TUI, gated on the same visible-deferred count the loop uses.
Recreated on top of current main; resolves the SideEffectNone clash with #146 (escalate_model). Supersedes #147.
* deferred-tools: activate deferral only when tool_search is runnable; drop dead deferredTools()
Addresses #148 review:
- partitionTools gates 'active' on a registered, non-disabled, mode-advertised
tool_search (mirrors the executeToolCall dispatch gate). When the loader is
unavailable (explicitly disabled, missing, or not advertised e.g. spec-draft),
fall back to the eager/inactive path instead of hiding deferred tools behind a
loader the model can never call (the dead-end).
- Flip TestDisabledToolSearch... to assert the eager fallback; register a real
tool_search in the active-path tests that previously relied on activation
without a usable loader.
- Remove unused toolSearchTool.deferredTools().
* deferred-tools: disable deferral in exec when tool_search disabled; drop unused toolDefinitions shim
Further #148 review:
- exec computes one effective DeferThreshold, forced to 0 when tool_search is in
--disabled-tools, and feeds it to both registerToolSearchIfEligible and
agent.Options.DeferThreshold — so the registration gate and the loop partition
gate agree, and a disabled loader never enables deferral.
- Remove the unused toolDefinitions shim (golangci-lint unused; no callers).
* modelregistry: add UpgradeTargetID and Registry.UpgradeTarget escalation lookup
* modelregistry: seed escalation chains in catalog decoration
* modelregistry: verification gate for escalation upgrade map
* agent: add ModelSwitcher option and ToolResult.RequestedModel field
* agent: lift escalate_to_model meta into ToolResult.RequestedModel
* agent: perform at-most-one mid-run model switch in the turn loop
* tools: add SideEffectNone for control-only tools
* tools: add escalate_model control tool (metadata + args)
* cli: parse --allow-escalation exec flag
* cli: document --allow-escalation in exec help
* cli: register escalate_model tool under --allow-escalation
* cli: wire model switcher for exec escalation
* cli: guard byte-identical exec run without escalation flag
* cli: attribute post-switch usage to the escalated model
* tools: accept an explicit empty escalate_model reason
reason is an optional, informational-only arg, but the previous arg
parsing rejected an explicit reason:"" (non-empty-string check), failing
the whole escalation. Switch to stringArgWithEmpty so an absent OR empty
reason is accepted while a non-string value is still rejected. Add a test
covering both the empty and absent forms.
* agent: test (nil,nil) escalation switcher keeps original provider
When a ModelSwitcher returns (nil, nil) — no error, no replacement — the
loop's `else if newProvider != nil` guard already keeps the active
provider, so the run continues on the original model. Add an agent-layer
test that proves this (it panics if the nil guard is dropped), closing the
gap the adversarial review flagged.
Also note the deferred compaction limitation near the switch: the
compactor's context-window budget is fixed at run start and not updated on
a mid-run switch (needs a ModelSwitcher contract change to fix).
* cli: gate usage model attribution on the escalation flag and harden tests
FIX A (back-compat): the OnUsage closure wrote a "model" key into every
persisted EventUsage payload unconditionally, diverging a non-escalation
run from the byte-identical origin/main baseline. The model can only
change mid-run under --allow-escalation, so include the "model" key only
then. A new flag-OFF test asserts the persisted usage payload carries no
"model" key (mutation-proven).
Test hardening for the CLI escalation boundary:
- Wire test: each newProvider build returns a DISTINCT provider with its
own turn counter; assert the source provider served exactly the
escalation turn and the escalated provider served exactly the
post-switch answer. Fails if `provider = newProvider` is dropped.
- Mixed usage attribution: emit usage pre- and post-switch and assert the
first attributes to the original model, the second to the escalated
target (the loop's ordering guarantee).
- Switch-error path: a failed rebuild is non-fatal and post-error usage
stays attributed to the original model (currentModel unchanged).
- Top-tier decline end-to-end: a flag-on run that escalates while already
top-tier performs no switch (newProvider built exactly once) and
succeeds.
* modelregistry: validate UpgradeTargetID resolves at registry construction
* cli: update currentModel only when the switched provider is non-nil
* sandbox: make 'none' a first-class side effect (control-only tools)
---------
Co-authored-by: Gnanam <[email protected]>
* zeroruntime: add ImageBlock type and NormalizeImageMediaType
* zeroruntime: add optional Images field to Message
* zeroruntime: add SeedMessagesWithImages, delegate SeedMessages with nil
* Emit Anthropic image source blocks for user image turns
* Add inlineData part type to Gemini request schema
* Serialize user image attachments as Gemini inlineData parts
* test(openai): pin text-only chat request serialization before content refactor
* feat(openai): allow structured multimodal content parts in chat request
* feat(openai): map image attachments to image_url content parts
* test(openai): assert image content parts serialize over the request body
* modelregistry: add SupportsVision capability gate
* Add shared image-file loader package
* cli: parse repeatable exec --image flag into imagePaths
* cli: resolve exec image attachments via shared loader with sniff and size cap
* agent: thread image attachments through Options into the seeded user turn
* cli: resolve and vision-gate exec image attachments into the agent run
* cli: document exec --image flag in help output
* streamjson: add InputImage type and InputEvent.Images field
* streamjson: whitelist images field on message events only
* streamjson: allow empty content on message events that carry images
* streamjson: add ResolveImages to decode, normalize and cap input images
* streamjson: cover image parse-and-resolve round-trip and text-only invariance
* Add /image command definition to TUI
* Add synchronous vision check for TUI model
* Implement /image attach, clear, and non-vision refusal in TUI
* Render pending image chips above the input in both skins
* Thread pending images into the agent turn and clear on submit
* Wire stream-json images through to the agent run
Stream-json message images were parsed and validated but never reached
agent.Options.Images: streamjson.ResolveImages had no production caller,
so images sent over stream-json input were silently dropped.
resolveExecPrompt now also returns the resolved stream-json images
(nil for text input and for image-free stream-json input). The exec
runner merges them with --image attachments before the vision gate, so
both input sources flow through the same drop+warn and the same
agent.Options.Images wiring.
Adds an exec-level end-to-end test that feeds a base64 image on a
stream-json message event via stdin on a vision model and asserts the
image reaches the agent run (the provider request's user turn).
* Re-check TUI vision support at submit, not only at /image attach
A user could attach an image on a vision model and then switch to a
non-vision model via /model before submitting; the images were threaded
unconditionally into the agent turn, reaching a model that rejects them.
The commandPrompt submit path now re-runs the vision check against the
current effective model. When the active model can't accept images, the
pending images are dropped (not threaded) and an inline notice mirroring
the headless drop+warn wording is appended. Pending image state is still
cleared after submit either way.
Adds a test that attaches on a vision model, switches the model id to a
non-vision one, submits, and asserts the run received no images, an
inline notice was appended, and pending state was cleared.
* Bound memory with an os.Stat size pre-check in imageinput.LoadFile
LoadFile read the whole file into memory before checking the size cap,
so a multi-gigabyte file allocated a huge buffer only to be discarded.
Add an os.Stat size pre-check before os.ReadFile: a file whose size
exceeds MaxImageBytes is rejected with the same 10 MiB-limit error
before any read; a missing file surfaces the same not-found error. The
existing post-read len(data) check is kept as a TOCTOU backstop in case
the file grows between Stat and ReadFile.
* OpenAI: only the user role emits image content-parts
The Anthropic and Gemini providers only attach images in their user
branches, but OpenAI funnels every role through a single mapMessage,
which would emit image content-parts for any role carrying Images.
Guard the content-parts path to the user role: a non-user message that
happens to carry Images keeps the plain string/nil content path. Pins
the invariant with TestMapMessageNonUserRolesNeverCarryImages, asserting
assistant/tool/system messages with Images set still serialize plain
content (no content-parts array).
* Deep-copy image buffers so attachments are never aliased
Add a shared CloneImageBlocks helper in zeroruntime that deep-copies a
slice of ImageBlock including each Data byte slice (returning nil for
nil/empty input). Use it in SeedMessagesWithImages so a seeded user turn
owns an independent copy of the caller's image bytes, and in the agent
loop's copyMessages so image bytes are deep-copied alongside ToolCalls
instead of staying aliased across history/request/result copies.
Tests assert that mutating the caller's original Data bytes after seeding
leaves the seeded message unchanged, and that copyMessages produces
independent image bytes.
* Accept image-only stream-json turns and bound base64 image size
Resolve stream-json images before the prompt in the exec input path and
tolerate an empty prompt when images are present, so an image-only
message event (empty content + one image) proceeds with prompt="" and the
image reaches the agent run instead of being rejected. A turn carrying
neither text nor images still errors.
In ResolveImages, reject an oversized payload from its ENCODED length
(DecodedLen) BEFORE the base64 decode so a huge blob never allocates a
decode buffer just to be capped afterward; the post-decode length check
stays as a backstop.
Tests cover the image-only exec turn reaching the agent and the
pre-decode oversize rejection (asserting the size gate fires before any
decode).
* Bound the image read in imageinput.LoadFile with a LimitReader
Replace os.ReadFile with os.Open + io.ReadAll over an
io.LimitReader(f, MaxImageBytes+1). The os.Stat pre-check is only a
fast-path hint (a non-regular file reports a misleading size and the file
can grow between stat and read); the LimitReader is the real bound, so at
most one byte past the cap is ever buffered and an oversized or unbounded
source can never allocate a multi-gigabyte buffer. The file is closed via
defer and the existing size message is reused.
* Fix RenderChat frame-height off-by-one with image chips
cmdRegion emits TWO rows when ImageChips is set (a chip row above the
input line), but the body/overlay height accounting assumed a one-row
command region, overflowing the fixed frame by one line. Compute the
fixed-row count from the actual command-region height (1 or 2 rows) so
the composed frame stays exactly Height rows in both cases.
A RenderChat test asserts the rendered frame line-count equals the
requested height both with and without ImageChips set.
* Reject an empty inline --image= value
Route the inline --image= form through requiredInlineFlagValue, the same
empty-rejection the other inline flags use, so --image= errors with
"--image requires a value" instead of appending an empty image path. A
parse test covers the empty inline value.
* imageinput: reject non-regular files (FIFO/dir) before opening
---------
Co-authored-by: KRATOS <[email protected]>
Co-authored-by: Gnanam <[email protected]>
* config: add ValidateFile for structured config diagnostics
* doctor: add offsetToLineCol helper for JSON error positions
* doctor: validate config files in config.validation check
* cli: thread config paths into doctor and degrade on bad JSON
* doctor: inline read, single-parse config validation + utf8 col test
- Remove the osReadFile wrapper; call os.ReadFile directly at its one call site
- Add config.ValidateBytes([]byte) so each config file is read and JSON-parsed
once in configValidationCheck instead of twice (once for position, once via
ValidateFile); ValidateFile now reads then delegates to its own unmarshal for
the path-prefixed error message, keeping its public signature unchanged
- Fix the misleading skip comment in configValidationCheck: configFilesCheck
only checks path-string presence; the skip is a defensive guard for the
unreachable case where DefaultResolveOptions passed a non-empty path that
still fails to read
- Add utf8 multibyte case to TestOffsetToLineCol documenting byte-column
semantics (column counts bytes, not runes)
* sandbox: add Policy.MaxAutonomy ceiling defaulting to high
* sandbox: normalize both operands in autonomyAllowed to fail closed
* sandbox: clamp grant-allow and unsafe escalation to policy autonomy ceiling
* sandbox: add RequiredAutonomyForBatch helper with fail-closed mapping
* config: add sandbox.maxAutonomy field to file and resolved config
* config: merge sandbox.maxAutonomy across layers, env, and overrides
* cli: thread configured autonomy ceiling into sandbox engine construction
* cli: surface max_autonomy in sandbox policy formatters
* zerocommands: carry max autonomy in sandbox policy snapshot
* sandbox: default empty policy ceiling to high + reason const
Evaluate now treats an empty Policy.MaxAutonomy (from a directly-constructed
Policy that bypasses DefaultPolicy) as High instead of letting it normalize to
Low, which would silently clamp every Medium/High decision to Prompt. The
ceiling is a no-op unless explicitly configured.
Also extracts the "above policy ceiling" clamp reason into a package const
referenced at both engine clamp sites and the related tests, and adds a
non-vacuous engine test proving the empty-ceiling trap is closed.
* zerogit: add base-ref three-dot diff to changes inspect
* zerogit: cover base-ref backward-compat and real-git diff
* zerogit: propagate base ref through change snapshot
* cli: parse changes --base ref and reject on commit
* cli: thread base ref into changes inspect output and help
* changes: redact base in cli summary + guard --base= + rename test
- redactChangeSummary now redacts the Base field alongside Root/Branch/Commit/etc.
- --base= equals-form rejects leading-dash values via flagValueLooksLikeOption, closing the option-smuggling gap
- TestParseNameStatusRenameAndCopy asserts rename/copy three-field lines use the new/destination path with correct status, and confirms two-field modify is unaffected
* Add ParseDiffStat helper for git diff --stat summaries
* Add usage report aggregation over persisted session usage events
* Add zero usage report command over persisted session usage
* Wire usage command into CLI dispatch and help
* usage: validate --since format, fix redaction comments, add tests
Fix 1 (BLOCKING): parseUsageArgs now validates --since with time.Parse
against YYYY-MM-DD; returns exitUsage + "invalid --since %q: expected
YYYY-MM-DD" for bare strings like "foo", unpadded "2026-6-1", or
wrong-separator "06/01/2026". Valid dates are stored unchanged for the
existing lexical comparison.
Fix 2: Replace false "pre-redaction stat" claims in diffstat.go and
usage.go with accurate comments: the --stat summary line carries no
secret-bearing tokens so parsing the already-redacted DiffStat is safe.
Fix 3: Add three tests to usage_test.go —
- TestRunUsageDaysFilter: stubs deps.now, seeds events on two dates,
asserts --days 3 includes the recent date and excludes the old one.
- TestRunUsageInvalidSince: table-driven; verifies "foo", "2026-6-1",
"06/01/2026" each return exitUsage + validation message, while
"2026-06-01" returns exitSuccess.
- TestRunUsageEmptyStore: zero usage events → exitSuccess, output
contains header and "total" row, no panic.
* doctor: surface type-mismatch offsets and drop colliding flat line/col keys
Probe config validation against config.FileConfig instead of a bare any so
a structurally-valid document with a wrong field type (e.g. maxTurns as a
string) surfaces a *json.UnmarshalTypeError carrying the offset, instead of
losing line/col. Remove the flat top-level details["line"]/["col"] keys
that were overwritten by the last malformed file when several were present;
keep only the unambiguous per-path map entry. Tighten the existing malformed
test to assert concrete per-path line/col and add a type-mismatch test that
exercises the previously-dead UnmarshalTypeError branch.
* sandbox: fail closed on invalid configured autonomy ceiling
An invalid non-empty sandbox.maxAutonomy (e.g. "moderate") previously
survived Resolve (only trimmed, never validated), failed to normalize at the
sandbox bridge, and left the policy unchanged at the default High ceiling.
A typo therefore silently disabled the admin's intended ceiling: fail-open on
a security boundary.
Validate at resolve time (fail loud): Resolve now rejects a non-empty
maxAutonomy that does not normalize, returning a clear error. As defense in
depth, applyConfiguredAutonomyCeiling now clamps an unrecognised non-empty
value to the most restrictive ceiling (low) instead of returning the policy
unchanged, so a bad value can never widen the ceiling even via direct use.
Empty/unset stays valid and keeps the default High ceiling.
Add resolver tests for invalid-fails / valid-resolves and bridge tests for
the fail-closed clamp and valid mappings.
* sandbox: fail closed on malformed autonomy and surface policy resolve errors
Treat a genuinely-invalid request autonomy as the highest tier in the engine
so it exceeds a Medium/Low ceiling and clamps to Prompt instead of being
sanitized to Low and auto-allowing on the grant/unsafe path. Surface
resolveConfig failures in the policy command instead of silently falling back
to the permissive DefaultPolicy, which would misreport trust posture.
* usage: bucket and filter by UTC calendar day, reject empty --session
Normalize RFC3339 timestamps to their UTC calendar date before day bucketing
(report) and before the --since/--days cutoff comparison (CLI) so offset
timestamps land on the correct UTC day and the bucket and cutoff agree;
malformed timestamps fall back to the leading-10 slice. Reject empty
--session/--session-id values with a value-required error matching the --since
validation style.
* doctor: fail on unreadable config files and stop fabricating positions
Surface a present-but-unreadable config path (permissions, is-a-directory) as
a failing per-path validation detail instead of silently skipping it; a
genuinely-missing path stays a skip. Return ok=false for a JSON parse error
that carries no offset so it routes through the no-position ValidateBytes path
rather than fabricating a (1,1) line/col. Assert empty stderr on the JSON
doctor failure path.
* config: make sandbox maxAutonomy resolver tests hermetic
Pass an explicit empty Env to the maxAutonomy Resolve tests so host
environment variables cannot leak in through the nil-Env os.Getenv fallback.
* sandbox: preserve raw autonomy for ceiling check so invalid clamps under default ceiling
Defaulting invalid request.Autonomy to High only clamped under a Medium/Low
ceiling; under the default High ceiling autonomyAllowed(High, High) returned true
and still auto-allowed (fail-open). Keep the raw requested value for the ceiling
check so autonomyAllowed's unknown-tier guard fails it closed under ANY ceiling.
Adds default-High-ceiling regression tests for the unsafe and grant-allow paths.
---------
Co-authored-by: KRATOS <[email protected]>
Co-authored-by: Vasanthdev2004 <[email protected]>
* zeroline TUI: fix slash palette, token readout, @ files, ! bash
The zeroline home footer advertised affordances the code didn't keep. Fixes:
- '/' palette: autocomplete.go suppressed a bare '/' ('/' alone showed nothing). Now a bare '/' lists the full command palette (capped at 8). The footer's '/ commands' works.
- Token readout: the zeroline header was built without Cost/TotalTokens, so the bars always showed $0.00 and no token total. zerolineHeader() now threads m.usageTracker.Summary() (TotalCost + TotalTokens, with the unpriced-token fallback); the bottom bar shows a cumulative 'tok <N>' segment (zeroline.Header gains TotalTokens; humanTokens formats it).
- '@ files': '@' input now drives a workspace file picker in the existing overlay (trailingAtToken/fileSuggestions/replaceTrailingAtToken; bounded, skips .git/node_modules/vendor/dist/hidden). Selecting inserts the @path, preserving preceding prompt text. Previously '@…' was sent to the agent as a chat prompt.
- '! bash': '!cmd' now runs as a shell escape (commandBash) — executed async with a 30s timeout in the workspace, output shown in the transcript. Previously '!cmd' was sent to the agent as chat.
- '1-5 theme': left as-is — it works when the input is empty (the home screen's state); gating on empty input keeps digits typable in prompts.
Tests: bare-/ palette, @-token detection/replacement, file listing+filter+skip-dirs, !cmd parsing, header usage. build/vet/-race/full-suite + GOOS=windows green; no new deps.
* zeroline TUI: emit forward-slash @file paths (Windows fix)
fileSuggestions used filepath.Rel, which returns backslash paths on Windows, so the inserted @path token and the test assertions diverged (TestFileSuggestionsListsAndFiltersWorkspaceFiles failed on the Windows smoke). Normalize with filepath.ToSlash so @paths are forward-slash on every platform.
* zeroline TUI: gate !cmd behind unsafe mode + test humanTokens
CodeRabbit major: the !cmd shell escape ran bash -c directly with no permission check, bypassing the sandbox. It now runs ONLY in unsafe permission mode; in auto/ask it is not executed and the user is told to relaunch with --skip-permissions-unsafe. Adds TestBashEscapeGatedByPermissionMode (auto/ask gated, unsafe runs).
CodeRabbit minor: adds TestHumanTokens covering negative clamp, 0, sub-1k, 1k trim, fractional, and the 999999->1000k rounding edge.
* zeroline TUI: bound @file walk by entry count; humanTokens M-scale
Review fixes for #138:
- fileSuggestions now counts every WalkDir entry (directories included), not
just files, and stops at the budget; a directory-heavy tree could otherwise
be traversed in full each keystroke and stall the TUI. Extracted
fileSuggestionsBounded so the per-keystroke budget is unit-tested.
- humanTokens switches to "M" notation past 1,000,000 instead of rendering
"1000k"+ on large-context models; tests extended to the M boundaries.
* zeroline TUI: make unsafe mode reachable so the ! shell escape works
The "!cmd" shell escape is gated behind unsafe permission mode, but the
interactive TUI hardcoded Ask and shift+tab only cycles auto<->ask, so unsafe
was unreachable and the escape was dead code. Worse, the gate's own remediation
message ("Relaunch with --skip-permissions-unsafe") pointed at a flag the
interactive launch did not parse: "zero --skip-permissions-unsafe" fell through
to the unknown-command path.
Honor --skip-permissions-unsafe at interactive launch (both "zero" and
"zero zeroline"), mirroring exec, so unsafe mode -- and the ! escape -- is
reachable via an explicit opt-in. Thread the resolved mode through
runInteractiveTUI(WithSkin); the default stays Ask. Document the flag in
"zero --help" and add launch tests for both entrypoints.
* zeroline TUI: run the ! escape via the platform shell, not hardcoded bash
Now that --skip-permissions-unsafe makes the "!" escape reachable on every
platform, hardcoding "bash -c" left it broken on stock Windows (no bash on
PATH) and anywhere bash is not at a predictable path -- the advertised
"! bash" footer would just print an exec error.
Resolve the shell the same way the agent bash tool does: cmd.exe /d /s /c on
Windows, /bin/sh -c elsewhere. Add a platform test asserting the command is
wrapped correctly.
---------
Co-authored-by: KRATOS <[email protected]>
TestNodeWrapperReportsMissingNativeBinary intermittently hit its 30s Windows deadline with empty output on loaded CI runners (cold node spawn), then passed in ~2.5s on the next run — a runner flake, not a wrapper bug. Raise the Windows timeout to 90s so a slow cold start has headroom; non-Windows stays at 10s.
Co-authored-by: KRATOS <[email protected]>
* [LOCAL PR-Z] Agent: structured per-tool denial categorization
Adds ToolResult.DenialReason (DenialCategory: filtered / permission_denied / sandbox_violation) so a surface can tell precisely why a call was blocked instead of parsing Output. Set in the not-enabled path and deniedPermissionResult (sandbox-violation denials categorized distinctly from approval-declined). Tested.
* [LOCAL PR-Z] Secrets: scan + redact leaked secrets in bash output
New internal/secrets package: a dependency-free, precision-first scanner for high-confidence secret shapes (AWS/Google keys, GitHub/Slack tokens, private-key blocks, JWTs). bash output now runs through secrets.Redact so a command that prints a secret has it replaced with a typed placeholder + a redaction notice, instead of echoing it back into the model context (additive to the configured-key scrub at the registry boundary). A built-in regex scanner fits the CLI without the heavy gitleaks dependency. Tested: detection, no-false-positives on ordinary text, redaction, and the bash wiring.
* Tool quality: redact full private-key blocks + use DenialReason in retry classification
CodeRabbit critical: the private_key_block pattern matched only the BEGIN header, so Redact left the PEM/OpenSSH key BODY in the output — defeating the redaction. It now matches the entire BEGIN…END block (body included) so the key material is removed. Test asserts the body is gone.
CodeRabbit minor: isRetriableToolError now treats a structured ToolResult.DenialReason (filtered/permission/sandbox) as non-retriable, independent of message wording; the text checks remain a fallback for results lacking the field.
---------
Co-authored-by: KRATOS <[email protected]>
* [LOCAL PR-X] Agent: per-model system-prompt specialization
Appends a family-specific guidance block (OpenAI: markdown+native-tool preference+persistence; Gemini: tool preference+conciseness; Anthropic: comment discipline) chosen by model id. Builds on the #129 system-prompt builder. Tests for classification + addendum selection.
* [LOCAL PR-X] Agent: chunked summarization fallback on context-limit
If the elided middle is itself too large to summarize in one call, summarizeWithFallback splits it in half recursively and joins the partial summaries, so compaction keeps working past the summarizer's own input window. Non-context errors and unsplittable single messages surface unchanged. Builds on #131. Tests cover split-and-join, non-context propagation, and the single-message edge.
* Summarizer: re-summarize partial summaries instead of persisting a blob
CodeRabbit major: summarizeWithFallback joined the two partial summaries and Compact stored that as one message; if that single message later exceeded the summarizer window, the guard couldn't split it (a single message) and compaction failed again for long sessions.
After summarizing the halves, the partials are now re-summarized into ONE compressible unit. If even the combined partials don't fit (extreme), it falls back to the joined text (still the already-compacted halves, better than failing). Adds TestSummarizeWithFallbackReSummarizesPartialsIntoOne. build/-race/full-suite + GOOS=windows green.
---------
Co-authored-by: KRATOS <[email protected]>
* [LOCAL PR-Y] CLI: graceful shutdown of headless exec runs (Ctrl+C)
zero exec wrapped agent.Run in context.Background(), so Ctrl+C/SIGTERM killed the process abruptly — leaking the in-flight provider request and any spawned children. runExec now wraps the run in a signal-aware context (signalContext): a signal cancels the run, the loop returns cleanly, and exec reports 'Interrupted.' with exit 130 (stream-json: an 'interrupted' run-end). Scoped to runExec — no dispatch-wide signature churn. NOTE: SIGINT runtime delivery needs a manual Ctrl+C check before PR; unit tests cover the helper + the no-regression of the exec suite.
* [LOCAL PR-Y] Observability: crash capture (panic -> saved report)
New internal/observability package: a recovered top-level panic is written to a local crash report (timestamp, label, full stack) under ~/.zero/crashes and surfaced as a brief notice instead of a raw stack trace. Wired into runWithDeps via a named-return defer. Fail-open and dependency-free; this is the hook a Sentry/OTEL adapter attaches to for remote reporting (deferred, needs a real endpoint to verify). Tested: report formatting/writing, panic capture sets the crash exit code + writes one report, and the no-panic path is a no-op.
---------
Co-authored-by: KRATOS <[email protected]>
* Background: escalate process termination from SIGTERM to SIGKILL
terminateProcess (POSIX) sent only SIGTERM, so a background process that traps or ignores it kept running after a Kill/KillRunning — a leak, e.g. on Ctrl+C shutdown. It now sends SIGTERM (letting the process clean up), polls liveness, and force-kills with SIGKILL if the process is still alive after a grace period.
- internal/background/process_posix.go: SIGTERM -> poll (signal 0) until a 3s grace deadline -> SIGKILL; treats an already-exited process as success (ESRCH / ErrProcessDone). Grace/poll are vars so tests run fast. Windows already force-kills via taskkill /F, so it is unchanged.
Tests: a SIGTERM-ignoring process (trap, with a readiness handshake to avoid signalling before the trap installs) is killed only after the grace period; a well-behaved process dies on SIGTERM well before escalation; an already-exited pid is a no-op. -race + full-suite + GOOS=windows build green; no new deps.
* Background test: assert the terminating signal (SIGTERM vs SIGKILL)
The graceful and escalation tests now check the actual signal that killed the process (via WaitStatus.Signal()), not just timing — so a regression to an immediate SIGKILL (or to never escalating) is caught. Graceful path must be SIGTERM; the SIGTERM-ignoring process must be SIGKILL.
* Background: keep user kill intent when the exit waiter reaps during terminate
Vasanthdev P1: terminateProcess now blocks until the child exits, so the background Wait-goroutine can reap the process and MarkExited the task to 'error' BEFORE Kill marked it 'killed' — leaving a user-initiated stop persisted as 'error' even though TaskStop returned killed.
Manager.Kill now records the kill intent (markKilledIfStillRunning) BEFORE terminating. MarkExited only acts on a running task, so once the task is killed the waiter's MarkExited is a no-op and the status stays killed. If terminateProcess fails, restoreRunningAfterFailedKill reverts the optimistic mark so a still-running task isn't falsely reported killed (preserves TestManagerDoesNotMarkKilledWhenKillFails).
Adds TestManagerKillStaysKilledWhenExitWaiterReapsDuringTerminate (cross-platform: a KillProcess that simulates the waiter reaping mid-terminate; asserts final status stays killed). build/-race/full-suite + GOOS=windows green.
* Background: don't signal a stale pid + surface rollback failures in Kill
Two follow-ups to the kill-ordering fix (CodeRabbit):
1. markKilledIfStillRunning now returns whether it actually marked. If the task exited (or was reused) between the running check and the mark, Kill returns without calling killProcess — so it never signals a possibly-stale pid in the exit/reuse race.
2. restoreRunningAfterFailedKill returns its persistence error; on a failed terminate, Kill joins the kill error and the rollback error (errors.Join) instead of swallowing a failed rollback that would leave the task wrongly marked killed.
Adds TestMarkKilledIfStillRunningReportsWhetherItMarked. build/-race/full-suite + GOOS=windows green.
---------
Co-authored-by: KRATOS <[email protected]>
* Agent: preserve active plan + loaded skills across compaction
Compaction summarized the elided middle into prose, which silently dropped two pieces of structured state the model needs to keep working: the active plan (update_plan) and any loaded skill instructions (skill tool). Now Compact extracts them from the elided middle and appends them VERBATIM to the injected summary, so they survive a compaction exactly instead of being paraphrased away.
- internal/agent/compaction_preserve.go (new): extractLatestPlan (most recent update_plan, rendered as status-tagged bullets), extractLoadedSkills (skill tool calls matched to their result bodies by id, latest-per-name, 2KiB-capped per skill so a large skill can't defeat the compaction), and appendPreservedState which folds both into the summary under clear labels.
- internal/agent/compaction.go: Compact appends the preserved state; summaryInstructions now tell the summarizer to integrate any earlier summary block (delta-aware summarization) so re-compaction never drops older facts.
Tests: plan + skill preservation through Compact, no sections when absent, latest-plan-wins, and skill calls without a result body are skipped. build/vet/-race/full-suite + GOOS=windows build green; no new deps.
* Fix capBody to honor the byte budget (UTF-8 safe)
capBody sliced by RUNE count against maxPreservedSkillBytes (a BYTE budget), so multibyte UTF-8 could preserve far more than 2 KiB, and it could append the truncation note without actually truncating. Now it cuts to the byte budget on a UTF-8 rune boundary (never splitting a rune), reserves room for the note within the budget, and only adds the note on real truncation.
Adds tests: short-body unchanged, multibyte over-budget stays <= cap and valid UTF-8, and the rune-count<cap-but-bytes>cap case.
* Compaction: carry preserved plan/skills across repeated compactions
Vasanthdev P1: after the first compaction the preserved plan/skill sections live only as text in the injected summary message; on a SECOND compaction that summary is in the elided middle with no real update_plan/skill tool calls left, so the structured state was lost unless the summarizer happened to copy it (non-deterministic).
appendPreservedState now carries forward existing '## Active plan' / '## Loaded skills' sections parsed from the most recent prior summary in middle, with fresh tool calls overriding per name (skills merged by name; plan: fresh update_plan wins, else carried forward). Adds TestCompactCarriesPreservedStateAcrossRepeatedCompaction — a second compaction whose summarizer drops the sections still keeps the plan + skill body. build/vet/-race/full-suite + GOOS=windows build green.
* Compaction: serialize preserved state as JSON (lossless for arbitrary bodies)
CodeRabbit major: the carry-forward format parsed markdown delimiters (section ends at the next '## ', skills split on '### '), so a verbatim skill body containing headings (## Usage, ### Examples) or code fences was truncated or split into bogus extra skills on the next compaction — breaking the repeated-compaction guarantee.
The preserved plan + skills are now written as a single labelled line of JSON (formatPreservedState) and recovered with json.Unmarshal (parsePreservedState). JSON escapes everything, so any body — markdown headings, fences, quotes, multibyte — round-trips losslessly. Removed the markdown section/skill parsers; fresh tool calls still override the carried-forward copy by name.
Adds TestCompactPreservesSkillBodyWithMarkdownHeadings: a body with ## / ### / a code fence stays byte-identical across two compactions. build/vet/-race/full-suite + GOOS=windows green.
* Compaction test: guard compacted shape before indexing out[1]
The round-trip helper indexed out[1] without checking len(out)>=2 / role first; add the guard so a regression in Compact fails cleanly instead of panicking.
---------
Co-authored-by: KRATOS <[email protected]>
Adds MeasureContext, which breaks a request's estimated token footprint into the categories that compete for the window — system prompt, tool definitions, and conversation messages — plus the used fraction of the model context window. This gives the agent the data to report context utilization and reason about what to compact.
- internal/agent/context_measurement.go: ContextBreakdown + MeasureContext (reuses estimateTokens for messages; estimateToolTokens for tool defs on the same ~4-chars/token scale).
- Options.OnContext: opt-in per-turn callback (mirrors OnText/OnUsage) emitted with the request's budget so a surface (TUI/CLI) can show utilization; nil is a no-op.
Tests: per-category split + total, system-dominant proportion, used-fraction math, unknown-window and no-system/no-tools edge cases. build/vet/-race/full-suite + GOOS=windows build green; no new deps.
Co-authored-by: KRATOS <[email protected]>
* Providers: unified transient-failure retry (network, 429, 5xx) across all providers
Only the OpenAI provider retried before; Anthropic and Gemini surfaced the first failure, so a dropped connection or an intermittent gateway 5xx/429 failed the whole turn. Centralizes the policy so every provider behaves consistently.
- internal/providers/providerio/retry.go (new): SendWithRetry rebuilds the request each attempt (replay-safe) and retries network errors + retryable statuses (429 and 5xx) up to N attempts, backing off between tries and honoring a server Retry-After header and context cancellation. Only the INITIAL request is retried — a partially consumed stream is never replayed. Plus Backoff, ShouldRetryStatus, and RetryAfter helpers.
- anthropic/gemini: replace the bare http.Do (no retry) with SendWithRetry.
- openai: replace its bespoke retry loop + backoff func with the shared helper (now also covers 429 + Retry-After, which it didn't before). Net code reduction.
Tests: retry-then-succeed, no-retry on 400, exhausted-retries surface the last response, Retry-After parsing, and backoff context cancellation. build/vet/-race/full-suite + GOOS=windows build green; no new deps.
* Retry: don't replay transport-failed POSTs (only retry 429/5xx)
A network/transport error on a completion POST does not mean the server didn't receive it — the request may have arrived and be generating a billable, non-idempotent completion while only the response/connection failed. Replaying it could duplicate that work. SendWithRetry now returns transport errors immediately and retries ONLY server responses (429 and 5xx), where the request was explicitly rejected without effect.
Adds TestSendWithRetryDoesNotReplayTransportErrors (a transport error is attempted exactly once). Docs updated. -race + providers suite + GOOS=windows build green.
* Retry: narrow retryable statuses to 429 + 503 (not generic 5xx)
Retrying any 5xx replayed non-idempotent completion POSTs whose effect is unknown: a 500/502/504 (esp. a 504 gateway timeout) can follow an upstream that already produced a billable completion, so a retry risks duplicate work. Limit retries to 429 (rate-limited) and 503 (service unavailable) — the statuses where the server explicitly did NOT accept the request, so a replay is safe. Other 5xx now surface to the caller's HTTP-error path.
Also close the response in the transport-error test (bodyclose). Tests updated: ShouldRetryStatus maps 500/502/504 -> false, 503/429 -> true; the retry-then-succeed test uses 503.
* Retry test: check the response Body.Close() error (errcheck)
The transport-error test dropped resp.Body.Close()'s error, which errcheck flags and can break lint-gated CI. Now the close error is checked.
---------
Co-authored-by: KRATOS <[email protected]>
* Agent: rich system prompt (coding-craft + workspace context)
Replaces the one-sentence system prompt — our single biggest agentic-quality gap — with a real coding-craft instruction set adapted to our Go toolset.
- internal/agent/system_prompt.md (embedded): identity, autonomy/persistence (bias for action, end-to-end), workflow (understand -> plan -> implement -> verify -> summarize), editing discipline (prefer native read/grep/glob/edit/apply_patch over shell, one tool call per file, minimal diffs, match style), a MANDATORY testing gate (run validators after edits; never claim done while failing), tool-use guidance, communication/markdown style.
- internal/agent/system_prompt.go: buildSystemPrompt(options) assembles core + dynamic workspace context (<environment>: cwd, git branch, first AGENTS.md/ZERO.md/.zero/AGENTS.md doc, 8KiB-capped) + the existing safety confirmation policy. Omitted when cwd is unset, keeping headless/test runs deterministic.
- loop.go: prompt decls moved to system_prompt.go; callsite passes options so cwd reaches the builder.
Tests: updated the two prompt assertions; added TestBuildSystemPromptInjectsWorkspaceContext. build/vet/-race/full-suite + GOOS=windows build green; no new deps.
* Resolve relative worktree gitdir against cwd in gitBranchForPrompt
In worktree mode .git is a file like 'gitdir: ../.git/worktrees/<name>' with a RELATIVE path; the code joined it directly so HEAD lookup resolved against the process working directory and failed, dropping the Git branch from the workspace context. Now non-absolute gitdir paths are resolved against cwd.
Adds TestGitBranchForPromptResolvesRelativeWorktreeGitdir (fails without the fix). build/-race/full-suite + GOOS=windows build green.
---------
Co-authored-by: KRATOS <[email protected]>
Caches the stable per-run prefix (system prompt + tool definitions) so it is not re-billed or re-processed every turn — a direct latency and token-cost win on multi-turn sessions, which matters now that the system prompt is substantial.
- types.go: system can be sent as []systemBlock (Anthropic accepts string or blocks); add cacheControl + cache_control on the last system block and the last tool definition (two cache breakpoints covering system + tools); add cache_read_input_tokens / cache_creation_input_tokens to the usage struct.
- provider.go: buildRequest emits the cacheable system block + tags the last tool; stream usage now captures cache read/creation tokens. Anthropic reports input_tokens (uncached), cache_read, and cache_creation separately, but the runtime models cached as a subset of input (clamps cached<=input), so we report the FULL prompt as InputTokens and the cache hits as CachedInputTokens.
- Caching is GA on anthropic-version 2023-06-01 (no beta header); non-caching providers are unaffected.
Tests: updated the request-shape assertions (system block + tool cache_control); added TestStreamCompletionReportsCacheTokens. build/vet/-race/full-suite + GOOS=windows build green; no new deps.
Co-authored-by: KRATOS <[email protected]>
* CLI wiring: skills/zeroline commands, --mode presets, exec checkpoints, sandbox --effective, sessions rewind
Final module of the runtime-core split. Wires the new runtime features into the cli, PRESERVING main's merged specialist cli (the source branch predates specialist and drops it; this keeps it via a 3-way merge from the common base, adding the reskin features as ours/theirs-only changes).
- skills command (internal/cli/skills.go) listing internal/skills; zeroline-skin TUI launcher (internal/cli/zeroline.go) via runInteractiveTUIWithSkin.
- exec: --mode presets (smart/deep/fast/large/precise) via applyExecMode; model-registry alias resolution + deprecation/effort notices; ContextWindow sizing (modelContextWindow); before-mutation checkpoint recording in OnToolCall; enhanced tool-result output (ChangedFiles/Redacted/Display); permission-mode validation fix.
- sandbox policy --effective (resolved guards); sessions rewind (executes the rewind plan); interactive TUI defaults to Ask mode (advertises write/edit/bash + gates via prompts).
- internal/streamjson: EventCheckpoint/EventRestore + CheckpointInfo for structured checkpoint output.
Specialist cli preserved (specialist.go + flags + runtime + tests all intact). build/vet/-race/full-suite + GOOS=windows build green; no new deps.
* Address #128 review: make sessions rewind reachable + honor --exclude-target, mode/notice/redaction fixes
- sessions: add "rewind" to the parser whitelist (isSessionsCommand) — it was wired in dispatch + help but parseSessionsArgs rejected it as 'unknown sessions command', so it was unreachable. Regression test TestRunSessionsRewindIsReachableAndHonorsExcludeTarget.
- sessions rewind: honor --exclude-target — ApplyRewind keeps THROUGH a sequence, so when KeepTarget is false apply through TargetSequence-1, matching rewind-plan (previously rewind always applied TO the target, disagreeing with the plan). The test asserts --exclude-target keeps fewer events than the default.
- sessions rewind: redact the session id in the success + not-found messages (consistent with every other human-readable path).
- exec_parse: reject empty inline --mode= via requiredInlineFlagValue (was a silent no-op running defaults).
- exec: notice writes (model-deprecation + reasoning-effort advisories) now treat write failures as exitCrash, consistent with the rest of the command.
build/vet/-race/full-suite + GOOS=windows build green.
---------
Co-authored-by: KRATOS <[email protected]>
* TUI shell: ask_user modal, pickers, autocomplete, zeroline skin
Lands the interactive TUI shell on main. (The earlier PR for this content targeted the stacked agent-runtime branch and never reached main, which already has the agent runtime via #125; this re-targets the same reviewed content onto main.)
- ask_user: interactive questionnaire modal wired to agent.OnAskUser via an answer channel; transcript rows + session payloads; degrades gracefully without an interactive surface.
- compaction sizing: modelContextWindow() sets the agent ContextWindow from the active model's registry entry (unknown models -> 0, compaction off).
- pickers (model/theme/effort/mode) + slash-command autocomplete overlay; zeroline skin rendering (boot/home/chat) behind Options.Skin.
- checkpoint recording: before each mutating tool the run batches an EventSessionCheckpoint in order and flushes it at end-of-run and on cancel; /rewind reloads in-memory session state so dropped events don't re-reach the agent. Exposes sessions.SnapshotForCheckpoint with an orphan-blob safe-usage contract for the batch-in-order path.
build/vet/-race/full-suite + GOOS=windows build green; 116 tui tests; no new deps.
* Address #127 review (CodeRabbit): validate checkpoint session id, fix mode toggle, harden ask_user/rewind/flush
- sessions.SnapshotForCheckpoint validates the session id (ValidSessionID), matching CaptureToolCheckpoint, so an exported caller can't route blob writes through an invalid session path.
- nextPermissionMode: Unsafe now folds to Ask (the stricter landing), not Auto — toggling an Unsafe session must never make it less strict. Test added.
- ask_user: a request with zero questions resolves immediately instead of opening a prompt that stalls the run.
- cancelled-run flush: surface appendSessionEvents persist-failure rows instead of discarding them (a silently-failed checkpoint flush would degrade /rewind).
- /rewind: handle post-rewind reload failures explicitly — on ReadEvents error, clear in-memory context and report, so stale rewound-away events can't reach the next prompt.
- transcript dedupe: key ask_user rows on row.id (survives rehydration when row.askUser is nil).
- ask_user Esc test: replaced the empty branch with a real assertion (run stays pending after Esc cancels only the prompt).
build/vet/-race/full-suite + GOOS=windows build green.
* Address #127 re-review: mirror normal flow for zero-question ask_user
The zero-question fast path now records the (empty) request in the transcript and answers with an empty slice ([]string{}) instead of nil, so it matches the normal resolveAskUser flow and downstream sees a consistent Answers shape.
(Skipped the suggested 'defer Ctrl+C quit until flush failures are acknowledged': blocking quit on Ctrl+C contradicts prompt-exit expectations, is a non-minimal UX change, and the edge is rare with graceful impact — the failure rows are already appended for the common non-exit cancel, and a missed checkpoint only makes that one /rewind unavailable.)
---------
Co-authored-by: KRATOS <[email protected]>
* Agent runtime: ask_user interception, context compaction, guardrails
Ports the agent-runtime slices from the reskin branch onto main, MINUS the blueprint's own sub-agent/task interception (main already has the merged specialist Task implementation, which this preserves).
- ask_user (S6): AskUserRequest/AskUserResponse/AskUserQuestion types + Options.OnAskUser; the loop intercepts ask_user and routes to the interactive front-end, degrading gracefully (non-interactive fallback) when no handler is set. Answers are scrubbed through the redaction boundary.
- compaction (S8): Options.ContextWindow + CompactionPreserveLast; proactive compaction when the estimated history crosses a fraction of the window, and reactive recovery on context-limit errors (summarizes the old middle, keeps system + last N). ContextWindow=0 disables it, so every existing caller/test behaves identically.
- guardrails: empty-turn stop, repeated-tool-failure hint/stop, and a stale plan reminder; an aborted-placeholder is appended for unexecuted tool calls on guard stop. confirmation_policy.md is embedded into the system prompt.
Preserved from main's specialist work: the RunWithOptions tool-context fields (ToolCallID/SessionID/Model/ReasoningEffort/Depth/Cwd), propertyToRuntimeMap nested Items, and ToolAdvertised AdvertiseInAuto. Dropped Options.Provider (it existed only for the blueprint task; compaction uses Run's provider arg).
build/vet/-race/full-suite + GOOS=windows build green; no new deps. Unblocks the TUI shell + cli wiring PRs.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* Address #125 review (CodeRabbit + @Vasanthdev2004): scope failure hint, fix ask_user fallback/cancel
- Repeated-failure guard now fires only on RETRIABLE tool errors (bad args / execution failures), not policy refusals (disabled tool, permission denial, sandbox violation) — a 'match this schema' hint there misdirects the model or nudges it to retry blocked behavior. New isRetriableToolError classifier (keys off permission_action meta + the known policy-error messages). Test: TestIsRetriableToolError.
- Headless ask_user fallback now goes through registry.RunWithOptions with the full run context (ToolCallID/SessionID/Model/ReasoningEffort/Depth/Cwd, sandbox/permission) and copies the full result fields (Meta/ChangedFiles/Display), so it behaves like every other tool path instead of the bare registry.Run that dropped them.
- A canceled / timed-out ask_user prompt (OnAskUser returns context.Canceled/DeadlineExceeded) now ABORTS the run and returns that error instead of fabricating a synthetic 'no interactive user' answer and continuing to mutate the transcript. executeToolCall/executeAskUser return an abort error that the loop honors (closing out remaining advertised calls for replay validity). Test: TestRunAskUserCancellationAbortsRun.
- compaction_test: corrected the comment to match the assertion (retried text must NOT be re-streamed to OnText).
build/vet/-race/full-suite + GOOS=windows build green.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* gofmt internal/agent/guardrails_test.go
The file carried over from the reskin branch was not gofmt-clean; format it so the gofmt/lint check is green.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
---------
Co-authored-by: KRATOS <[email protected]>
Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
Module of the runtime-core split (off main). internal/mcp had no drift on main, so this is a clean extract of the zenline MCP improvements. Deps (config, tools) are on main; uses tools.PropertySchema.Properties/Required from #119. No new deps.
- schema.go: serialize nested object Properties/Required (and object-typed Items) when converting tools.PropertySchema <-> MCP JSON schema, so tools with nested args are advertised faithfully to/from MCP hosts instead of being flattened (audit H6).
- client.go/server.go: bound MCP stdio handshake/calls so a hung or non-responsive MCP server can't block the caller forever; surface a timeout instead (audit H9). hang_test.go covers it.
- registry.go: recognize the hardened client/schema paths; registry_test/schema_test added.
build/vet/-race/full-suite + GOOS=windows build green.
Co-authored-by: KRATOS <[email protected]>
Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
* Skills & plugins: skill tool, skills loader, plugin manifests
Module of the runtime-core split (off main, after #119 tools merged). Clean extract — internal/skills is new; internal/plugins had no drift on main. No new deps.
- internal/skills: loads */SKILL.md frontmatter from the skills dir (resolves its own XDG dir); no YAML dependency.
- skill tool (internal/tools/skill.go), read-only, registered in CoreReadOnlyTools (so it's in the agent core + the MCP read-only default). Path-safe: it Load()s all skills and matches by exact name — it never builds a path from the model-supplied arg, so there is no traversal surface. knownToolNames gains "skill" for the specialist cross-package invariant; CoreReadOnlyTools count test bumped 4->5.
- internal/plugins: plugin manifest metadata enrichment (Author/License/Keywords/Interface). FORWARD code — the cli plugin-loading wiring lands later.
MCP hardening (nested-schema + hung-server fix) is a SEPARATE follow-up PR. build/vet/-race/full-suite + GOOS=windows build green.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* Address #123 review (CodeRabbit): pointer metadata, skill schema alias, dir fallback, List test
- plugins: Author/Interface are now *PluginAuthor/*PluginInterface so omitempty actually omits them — a non-pointer struct is never empty to encoding/json, so the old form emitted author:{}/interface:{} and changed the serialized shape of plugins that don't set them. parseAuthor/parseInterface return nil when all fields are empty; formatAuthor takes the pointer. (Major)
- skill: schema now declares the 'skill' alias alongside 'name' (and drops strict Required) so the alias survives schema validators that reject unknown keys (AdditionalProperties:false); Run still enforces exactly-one-of via aliasedStringArg. (Major)
- skills.DefaultDir: return "" instead of a relative ".local/share/..." path when neither XDG_DATA_HOME nor a resolvable HOME exists, so skills can't bind to the process CWD (load("") already no-ops). (Minor)
- skills_test: TestListReturnsNamesAndDescriptions now asserts List strips Content so a listing can't silently leak full skill bodies. (Minor)
build/vet/-race/full-suite + GOOS=windows build green.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* Address #123 review (@Vasanthdev2004): confine skill SKILL.md to the skills root
skill is a permission-allow read-only core/MCP tool, so the loader must not follow a symlinked SKILL.md (or skill dir) out of the skills root and become an arbitrary-file reader. load() now resolves the skills root via EvalSymlinks and, for each entry, resolves SKILL.md through symlinks and verifies the real path stays under the root (confineSkillPath); an escaping or unreadable path is skipped rather than read. Mirrors the grep/read_file confinement. Legit skills under a symlinked root (e.g. macOS /tmp) still load because the root is resolved too.
Regression: TestLoadSkipsSymlinkedSkillFileEscapingRoot (a SKILL.md symlinked to a secret outside the root is skipped, not read).
build/vet/-race/full-suite + GOOS=windows build green.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* Address #123 re-review (CodeRabbit): reject non-regular SKILL.md, single-trim metadata
- skills: confineSkillPath now Lstats the resolved path and skips non-regular targets (directory/FIFO/device/socket). A FIFO/device named SKILL.md (or an in-root symlink to one) would otherwise make os.ReadFile block indefinitely — skill is a permission-allow tool over a user-controlled dir. Regression: TestLoadSkipsNonRegularSkillFile (unix-tagged; mkfifo SKILL.md, asserts Load skips it within a timeout rather than hanging). (Major)
- plugins: coerceMetaStringSlice trims each item once into a local var instead of calling strings.TrimSpace twice. (Nitpick)
build/vet/-race/full-suite + GOOS=windows build green.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
---------
Co-authored-by: KRATOS <[email protected]>
Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
* Tools: arg-tolerance, ask_user, secret scrubbing, structured results, hardening
First module of the tools->agent->tui chain that leads to the TUI shell (off main). Scope: internal/tools (+ a 1-line specialist vocabulary update).
3-way merged onto main's specialist drift (RunOptions tool-context fields, Safety.AdvertiseInAuto, optionsAwareTool dispatch, PropertySchema, base structured Result) and grafted the runtime-core additions:
- Shared arg-tolerance layer (argtolerance.go: alias-aware/type-strict arg resolution + slice coercion) adopted across all tools so weak models' non-canonical arg shapes are accepted; bool/int coercion in args.go.
- ask_user tool (interactive clarifying questions) wired into CoreReadOnlyTools; nested PropertySchema (Properties/Required) for its object questions + update_plan.
- Secret scrubbing at the registry boundary (scrubResultSecrets via internal/redaction) applied to every tool-execution path, incl. the optionsAwareTool dispatch; structured Result (Display/Redacted/ChangedFiles).
- mutation_targets (paths a mutating tool will write, for session checkpointing) + hardened grep (symlink confinement), apply_patch, bash, edit/write.
EXCLUDED per the module split: the blueprint task tool (subagents — another dev's area; the merged specialist Task is separate) and the skill tool (ships with the later skills module); plus their subagent-only registry helpers (Without/isolateForChild).
ask_user is now a core tool, so internal/specialist knownToolNames adds "ask_user" to keep the TestKnownToolNamesMatchCoreRegistry cross-package invariant. No new deps; build/vet/-race/windows/full-suite green.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* Address #119 review (CodeRabbit): scrub Display, validate apply_patch mutation paths, narrow test skips
- registry.go: scrubResultSecrets now redacts Result.Display.Summary too (not just Output), so a caller preferring Display can't bypass the boundary redaction.
- mutation_targets.go: apply_patch targets now run through validatePatchPaths(applyRoot, ...) before being returned, so a traversal patch (../x) can't yield an out-of-workspace checkpoint target. Regression case added to TestMutationTargetsRejectsEscapingPaths.
- argtolerance_test/write_tools_test: the apply_patch tests no longer Skipf on ANY non-OK result (which masked real diff/apply regressions); they skip ONLY when the git binary is unavailable (gitApplyUnavailable helper checks exec's 'executable file not found') and otherwise Fatalf.
build/vet/-race/full-suite + GOOS=windows build green.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* Address #119 re-review (CodeRabbit): best-effort multiSelect + Display.Summary scrub coverage
- ask_user: an uncoercible multiSelect no longer fails the whole call — it's a UI hint, so it defaults to false (best-effort, mirroring the lenient options path). Test: TestParseAskUserQuestionsLenientOptions extended.
- registry_test: TestRunWithOptionsScrubsSecretsForAllCallers now also leaks a secret via Result.Display.Summary and asserts it is redacted, locking in the boundary scrub for the structured-result path (secretTool gained a display field).
build/vet/-race/full-suite + GOOS=windows build green.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* Address #119 review (@Vasanthdev2004): scrub ALL registry return paths + harden intArg
[blocker] RunWithOptions now scrubs every return path, not just tool-execution: a named return + a single 'defer scrubResultSecrets(result)' covers the unknown-tool, sandbox-deny, sandbox-approval-required, permission-required and permission-denied early returns too. Previously a sandbox denial that echoed a secret-bearing path/arg returned it raw with Redacted=false. Regression: TestRunWithOptionsScrubsSecretsOnDenialPaths (permission-denied reason carrying a token -> scrubbed + Redacted).
[non-blocking] intArg fails closed on NaN/Inf/out-of-range floats (and their string forms) via a floatToInt helper, before any implementation-defined cast. Tests added for NaN/Inf/NaN-string/non-integral.
build/vet/-race/full-suite + GOOS=windows build green.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* Address #119 review (CodeRabbit grep TOCTOU + anandh8x dependency blockers)
- grep: close the confinement TOCTOU — confineGrepFile now returns the symlink-RESOLVED path and collectGrepMatches reads THAT (not the raw candidate), so a symlink swapped in between check and read can't escape the workspace.
- ask_user: removed from CoreReadOnlyTools. It needs the agent loop's interactive intercept (OnAskUser), which is in the (unmerged) agent module; without it the tool only returns the non-interactive fallback. The tool still ships (NewAskUserTool); the agent module registers it in core + wires the intercept together. Reverted the specialist knownToolNames 'ask_user' entry + the ask_user-in-core test accordingly.
- update_plan: reverted the plan schema to a flat array (item structure documented in the description). The structured nested-object Items schema depends on the agent's PropertySchema serializer (propertyToRuntimeMap) passing nested Properties/Required through to providers, which is in the agent module; a nested schema here would be silently dropped. PropertySchema.Properties/Required fields are kept (used by ask_user.go's own schema + the later MCP module).
build/vet/-race/full-suite + GOOS=windows build green; no new deps.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* Harden #119 from adversarial self-review: sandbox alias-gate, Meta scrub, intArg 2^63
A multi-dimension adversarial review of the tools diff surfaced 3 real issues beyond the reviewer rounds:
- [major/security] write_file/edit_file path aliases file_path/filename/filepath bypassed the sandbox workspace+symlink gate: sandbox.requestPaths only inspected path/cwd/file/dir, so an aliased target skipped enforcement (same class as audit H4). Aligned requestPaths with the tools' path-alias lists (internal/sandbox/risk.go). The tools' own resolveWorkspaceTargetPath still confined, but the sandbox LAYER/audit/symlink-check was silently skipped. Regression: TestRegistrySandboxGatesPathAliasKeys.
- [minor] scrubResultSecrets skipped Result.Meta, which is forwarded to the transcript and carries model-controlled strings (glob pattern, bash cwd). Now scrubs Meta values too. Covered in TestRunWithOptionsScrubsSecretsForAllCallers.
- [minor] floatToInt used f > float64(MaxInt); that constant rounds up to 2^63 so exactly 2^63 slipped to an out-of-range cast. Changed to >=. Test added for 2^63 float+string.
build/vet/-race/full-suite + GOOS=windows build green; no new deps.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
---------
Co-authored-by: KRATOS <[email protected]>
Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
* Add specialist lifecycle accounting
Record specialist start and stop events in parent sessions and roll child stream-json usage into one parent usage event.
Background TaskOutput performs the same roll-up idempotently for persisted completed tasks.
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/specialist
Tested: GOCACHE=/tmp/zero-go-cache go test ./...
Tested: git diff --check
* Harden npm wrapper smoke test on Windows
Clear inherited NODE_OPTIONS when executing the Node wrapper fixture and allow a longer Windows timeout for hosted-runner cold starts.
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/npmwrapper ./internal/specialist
Tested: GOCACHE=/tmp/zero-go-cache go test ./...
Tested: git diff --check
* Address specialist accounting review feedback
Normalize mixed usage event totals before roll-up, preserve started child exit codes on execution errors, and make accounting payload assertions fail loudly on missing or mistyped fields.
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/specialist ./internal/npmwrapper
Tested: GOCACHE=/tmp/zero-go-cache go test ./...
Tested: git diff --check
* TUI rendering (zeroline): glamour markdown, syntax highlighting, themed surfaces
Module of the runtime-core split (off main). Renamed the reskin package from the working name 'zenline' to **zeroline** (dropping the zenline name; this becomes ZERO's default polished terminal surface). Scope: internal/zeroline only — pure presentation, no internal deps.
- glamour-rendered markdown (with a bounded mdCache to avoid per-frame re-render cost) and chroma syntax highlighting (transitive via glamour).
- colored diffs, full-bleed background, a Zen home page + a vim/powerline-style statusline chat page sharing 5 switchable color themes (theme.go).
- callers (the TUI model) build the data structs from live agent state; this package turns them into styled frames. It is FORWARD code — wired by the TUI module in a later PR; exercised here by render/markdown tests.
Deps: adds github.com/charmbracelet/glamour v1.0.0 (pulls chroma/v2, goldmark, bluemonday, x/net, x/term transitively; lipgloss/bubbletea/termenv already present). go.sum updated via go mod tidy; build/vet/-race/full-suite green; no behavior change to existing packages.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* Address #114 review (CodeRabbit + @anandh8x + @Vasanthdev2004): TUI render-contract fixes
- AskUser suppresses overlays like Perm does: overlayRegion now returns early on d.AskUser != nil so suggestions/picker rows can't consume bodyH and push the focused questionnaire offscreen. Test: TestOverlaySuppressedDuringAskUser.
- Permission modal narrow-width: derived permMinBoxWidth=47 (43-cell button row + border/padding); PermLayout disables the hitboxes when the box is narrower (buttons would overflow/clip and stop matching), mirroring the vertical-clip guard. Rewrote TestPermLayoutMatchesRenderClamped: narrow widths -> inactive, wide+height-clamped -> lockstep.
- wrap() now budgets by display width (lipgloss.Width) instead of byte length, so CJK/emoji don't break the width budget. Test: TestWrapBudgetsByDisplayWidth.
- Plain/streaming path now clips each wrapped prose line: wrap doesn't break a single over-long token (long URL / unbroken CJK), so renderAssistantPlain clips wl to the budget. Test: TestPlainAssistantClipsLongUnbrokenToken.
- Streaming verbatim test strengthened to assert the literal 'partial **incomplete' survives (proves glamour is not applied while streaming).
build/vet/-race/full-suite + GOOS=windows build green.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* Address #114 re-review (@Vasanthdev2004): keep narrow perm modal + all chrome within the frame
- permModalLines: when bw < permMinBoxWidth (too narrow for the 43-cell button row) it no longer renders the full button row (which overflowed the modal frame); it renders a compact keyboard-only hint clipped to the content width. Render and hit-test now stay aligned: PermLayout already disables the mouse hitboxes at this width.
- RenderChat: added clampFrameWidth — a final frame-safety pass that clips every composed line to the frame width, so chrome (top/bottom bars) and the modal/body can never overflow the terminal at narrow widths (fixes the header/statusline-footer overflow too). Width-aware + ANSI-safe; a no-op at normal widths.
- Regression test TestPermModalFitsFrameAtNarrowWidth: RenderChat at Width:40 with a Perm prompt asserts every line stays within 40 cells while PermLayout(40,24).Active == false.
build/vet/-race/full-suite + GOOS=windows build green.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* fix: apply CodeRabbit auto-fixes
Fixed 2 file(s) based on 1 unresolved review comment.
Co-authored-by: CodeRabbit <[email protected]>
---------
Co-authored-by: KRATOS <[email protected]>
Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: CodeRabbit <[email protected]>
* Session checkpoints & safe rewind: content-addressed blobs, atomic restore, file locking
Module 4 of the runtime-core split. Scope: internal/sessions only. 3-way merged cleanly onto main's #106 Tag/Depth work (preserved); no new deps (filelock uses stdlib syscall); build/vet/-race/windows-cross-compile/full-suite green.
- CaptureToolCheckpoint snapshots a tool's target files into content-addressed (sha256) blobs before a mutating tool runs; SnapshotForCheckpoint records file mode; oversize files are skipped, identical content deduped.
- RestoreToSequence reverts to a target sequence: applies only the closest-to-target checkpoint per path, verifies each blob's sha256 before writing, preserves file mode, and confines paths with EvalSymlinks (no symlink escape). ApplyRewind runs restore+truncate+prune+marker atomically under one session lock.
- Cross-process file lock (flock) serializes session mutations; Fork copies checkpoint blobs so a fork can rewind; writeMetadata uses a unique tmp suffix.
FORWARD: this is the persistence layer; the CLI 'zero sessions rewind' and the TUI '/rewind' + pre-tool checkpoint capture wiring land in later modules. Exercised here by checkpoint_test.go / rewind_test.go.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* Address #112 review: capture-side confinement, atomic-only checkpoint API, Windows lock, test hygiene
- [critical/human blocker] snapshotForCheckpoint now confines every capture target with resolveWithinWorkspace (same EvalSymlinks/no-".." guard as restore) BEFORE stat/read, so a traversal/symlink target can't read files outside the workspace into a blob. os.Stat failures are classified correctly: only os.IsNotExist -> Absent (restore deletes); permission/IO/symlink-loop -> Skipped (restore leaves alone), instead of treating every failure as Absent. Regression test added (TestCaptureRejectsTraversalTargets).
- [major] SnapshotForCheckpoint unexported -> snapshotForCheckpoint: the blob writes and the referencing EventSessionCheckpoint must be committed atomically under one session lock, which only CaptureToolCheckpoint guarantees. Removes the unsafe snapshot-only external entry point (blob/event gap).
- [major] Windows: real LockFileEx/UnlockFileEx (filelock_windows.go) replacing the no-op, so cross-process/cross-Store session serialization holds on Windows too. No new deps (x/sys already required for the unix flock).
- [minor] checkpoint_test hygiene: check os.WriteFile/ReadDir/ReadFile/ReadEvents errors via helpers, add bounds checks before p.Files[0]/events[...] so setup failures fail at the source instead of panicking or passing spuriously.
- rewind restore: documented the residual symlink-swap TOCTOU (boundary re-resolved immediately before the mutation; full closure needs openat2 RESOLVE_BENEATH / per-component O_NOFOLLOW, tracked for the CLI/TUI rewind-wiring work).
build/vet/-race/full-suite + GOOS=windows build all green.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* Address #112 re-review: fail rewind on undecodable checkpoint payload
[major] restoreToSequenceLocked silently continued past a checkpoint whose payload failed to decode, which let a NEWER checkpoint win for that path and restore a later state than the caller requested. Treat corruption as a hard rewind error. Regression test TestRestoreFailsOnCorruptCheckpointPayload (fails-open before, errors now).
Re-review note: the earlier capture-target-validation (critical) and test bounds (minor) were already addressed in 600337b (resolveWithinWorkspace guard + len checks); those re-anchored threads are stale. The restore-side symlink-swap TOCTOU remains a documented heavy-lift residual (needs openat2/descriptor traversal), tracked for the rewind-wiring work.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* Address #112 re-review: dedupe rewind on resolved path, not raw path
[major] restoreToSequenceLocked deduped the per-path closest-to-target short-circuit on the RAW checkpoint path, so equivalent paths (./a.txt, dir/../a.txt, a symlink alias) keyed differently and a NEWER checkpoint could overwrite the closest-to-target snapshot for the same underlying file. Now resolve/confine FIRST and dedupe on the resolved workspace path (unresolvable targets dedupe on raw path). Regression test TestRestoreDedupesEquivalentRawPaths.
This + the prior commit (24e9ba2, fail-hard on undecodable payload) address both correctness blockers @Vasanthdev2004 raised after CodeRabbit's pass.
build/vet/-race/full-suite + GOOS=windows build green.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
---------
Co-authored-by: KRATOS <[email protected]>
Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
* Sandbox hardening: destructive/network/installer classification + interactive-command detection
Module 3 of the runtime-core split (off main, after #107). Scope: internal/sandbox only — clean additive extract; no new deps; whole tree builds + full suite/-race green.
LIVE (Classify is already called by sandbox.Evaluate and agent/loop.go, so this hardens existing behavior immediately):
- rm -rf targeting / $HOME ~ * — now tolerant of surrounding quotes, ${HOME} braces, combined/reordered -r/-f flags, and an optional -- separator; chmod 777 only flagged when recursive or root-targeted (single-file chmod no longer false-positives).
- network-command + piped-installer detection (curl|sh incl |zsh and other shells); command resolved across command/cmd/script/shell aliases so an alias key can't bypass the gate.
FORWARD (compiles now, wired by the later tools/bash module): DetectInteractiveCommand flags interactive programs (vim/less/...) behind wrappers (sudo/nice/timeout, and sh -c/bash -c payloads), hardened against absolute-path/quote/escape/substitution bypasses.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* Address #109 review (CodeRabbit): fix sandbox detection over/under-reach
- [critical] rm long-flag root: --no-preserve-root -rf -- "/" and ... -rf "/" now caught (quote + -- handling restored across short AND long flags).
- chmod 777 abs-path narrowed to root or a sensitive SYSTEM tree (/, /etc, /usr, …); a single-file abs path like chmod 777 /tmp/build.sh is no longer a false positive.
- piped_installer now requires a remote fetch (curl/wget/fetch/aria2c) before the pipe; a local 'cat x | bash' / 'printf | sh' is no longer mis-flagged (engine_test updated to assert the corrected semantics).
- nonInteractiveREPLFlags adds mongo/mongosh (--eval/-f/--file) so 'mongo --eval ...' isn't flagged interactive.
- programIndex now normalizes tokens (basename/quote-strip/lowercase) like firstProgram, so full-path invocations (/usr/bin/python script.py, /bin/bash -c 'vim …') classify correctly (no false positives / missed nested detections).
TDD for each; build/vet/-race/windows cross-compile/full-suite green.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
---------
Co-authored-by: KRATOS <[email protected]>
Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
Module 2 of the runtime-core split (off main, after #105). Scope: internal/modelregistry only — fully additive (the whole tree builds unchanged against it).
- Regex MatchPatterns aliases + pattern-based Resolve.
- DeprecationRule with FallbackID; ResolveWithFallback returns the active fallback model + a user notice for deprecated ids; NewRegistry validates every Deprecation.FallbackID resolves (fail-fast on misconfig).
- ReasoningEffort support: per-model ReasoningEfforts + DefaultReasoningEffort (validated to be a member of the model's set) + EffectiveReasoningEffort().
- Mode presets (modes.go: smart/deep/fast/large/precise -> model id + reasoning effort).
These are the registry-capability layer; the CLI/agent adoption (--model deprecation-fallback, --mode, -r/--reasoning-effort, enhanced 'zero models') lands in a later module to keep PRs reviewable. Build/vet/full-suite/-race green; no new deps.
Co-authored-by: KRATOS <[email protected]>
Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
* Streaming reliability: SSE idle-watchdog, dropped-call handling, finish_reason, usage
First reviewable slice of the runtime-core work (split out of the large integration branch). Scope: internal/zeroruntime + internal/providers only — fully backward-compatible/additive (the rest of the tree builds unchanged against it).
- SSE idle-timeout watchdog (reader goroutine + idle timer + ctx-cancel) across OpenAI/Anthropic/Gemini via providerio.ScanSSEDataWithContext, so a stalled-but-open upstream no longer blocks the agent forever.
- Streamed tool-call assembly hardened: nameless/dropped tool calls reported (StreamEventToolCallDropped), start-order preserved, distinct empty-id calls no longer merge.
- Terminal finish/stop reason surfaced (CollectedStream.FinishReason + Truncated()) so length/content-filter responses aren't mistaken for normal completions; OpenAI requests stream_options.include_usage (token usage no longer 0); OpenAI delegates error/redact to providerio (503/529 classified) and joins multi-line SSE data; max_completion_tokens wired.
Build/vet/full-suite/-race green.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* Address #105 review (CodeRabbit): ctx honored when idle watchdog off; cancel during retry backoff; test robustness
providerio: ScanSSEDataWithContext now runs the goroutine+select loop even when idleTimeout<=0 (idle case via a nil channel), so ctx cancellation is honored with the watchdog disabled (was a blocker: it short-circuited to the non-ctx-aware ScanSSEData). openai: a ctx cancellation during 5xx retry backoff is reported as the cancellation instead of being misclassified as the upstream 5xx. Tests: finish-reason/tool-call tests now assert the done/start event is actually emitted (not vacuously pass); new providerio regression test asserts cancel is honored with idleTimeout=0. Build/vet/-race green.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
---------
Co-authored-by: KRATOS <[email protected]>
Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
description:Extending Zero — how to write AGENTS.md files, custom specialists, skills, hooks, MCP servers, and plugins for the open-source CLI coding agent.
- Use `go run ./cmd/zero` to launch the local CLI.
- Use `go test ./...` for the repository test suite.
- Use `go run ./cmd/zero-release build` to build the release-facing binary.
- Use `go run ./cmd/zero-release smoke` to smoke-test the built binary.
- Use `go run ./cmd/zero-release package` and`go run ./cmd/zero-release verify` for release artifact checks.
- Use `go run ./cmd/zero-perf-bench` for performance smoke work.
- Do not add Bun or TypeScript build/test dependencies for repository validation.
- Use Node only when directly exercising the npm wrapper at `bin/zero.js`.
Zero is an open-source terminal coding agent in the same family as Claude Code, Codex, and Droid. Out of the box it does the obvious things — read, edit, run, search — but the design point of the project is that **every surface is configurable**. This document is the user-facing guide for that configuration.
If you only want to *use* Zero, the [README](README.md) is enough. This page is for the other three jobs:
1. Tell the agent about *your* project (drop an `AGENTS.md` in your repo).
2. Add new specialist sub-agents.
3. Wire Zero into the rest of your toolchain (MCP, skills, hooks, plugins).
## 1. Drop a project `AGENTS.md`
When Zero starts in a directory, it looks for project-level instructions and injects them into the system prompt. The lookup walks from your current working directory **up to the nearest git root** and reads the first matching file at each level — general rules at the repo root, more specific rules in sub-trees. Files are labeled with their directory in the prompt (e.g. `## Project guidelines (services/api/AGENTS.md)`).
Accepted file names, in priority order at each level:
| Path | Notes |
| --- | --- |
| `./AGENTS.md` | The classic spot — committed to your repo, shared with the team. |
| `./ZERO.md` | Brand-specific alias. Same format, lower priority. |
| `./.zero/AGENTS.md` | Project-local, hidden, gitignored. Personal notes that stay out of git. |
Matching is **case-insensitive** on the basename, so `AGENTS.md`, `Agents.md`, and `agents.md` resolve to the same file on Windows and macOS. The git-tracked filename in this repo is `AGENTS.MD` (uppercase `MD`) — keep that on case-sensitive filesystems (Linux, the WSL filesystem, or a CI runner) to match what the loader looks for.
Both files use the same format. YAML frontmatter is optional; the markdown body is loaded as instructions for the agent. Zero reads the file once at session start, so changes take effect on the next `zero` launch — not mid-session.
```markdown
# Project conventions for <your project>
- Build with `make`, not `go build` directly.
- Tests live next to the source file (`foo_test.go` next to `foo.go`).
- Run `make lint` before opening a PR.
- Never edit files under `third_party/` — those are vendored.
```
Tips:
- Keep each file under ~8 KiB. Zero caps the **total** across all matched files at 32 KiB; everything past the cap is dropped.
- Re-state rules in the imperative voice: "Run `make lint`", not "you should consider running the linter".
- Don't put secrets, model IDs, or environment-specific paths in `AGENTS.md`. Use `config.json` for those.
- In a monorepo, drop a narrower `AGENTS.md` in each sub-tree (e.g. `services/api/AGENTS.md`). Zero picks those up automatically when you launch from inside the sub-tree.
- A YAML frontmatter block (`---\n...\n---`) at the top is preserved verbatim in the injected prompt but is not parsed for `globs:` or `alwaysApply:` scoping today — keep the body self-contained.
## 2. Custom specialists
Specialists are Zero's sub-agents. Three scopes, in priority order:
| Scope | Path | Shared? |
| --- | --- | --- |
| Built-in | compiled into Zero | yes — `worker`, `explorer`, `code-review` |
| User | `~/.config/zero/specialists/*.md` | no — your machine only |
| Project | `./.zero/specialists/*.md` | yes — the repo team |
Project overrides user overrides built-in when names collide.
A specialist is a markdown manifest with frontmatter and a system prompt:
```markdown
---
description:Reviews API changes for breaking-change risk and missing tests.
tools:read-only,plan
---
You review API changes. For every changed hunk in `internal/api/` or any file
that ends in `_api.go`:
1. Confirm the public signature is backward-compatible, or note the breaking
change explicitly with the migration path.
2. Confirm a corresponding test exists in `internal/api/*_test.go` and that
the new behaviour is exercised.
3. Flag any new exported symbol without a doc comment.
Reply with one JSON object per finding: `{"file", "line", "severity", "message", "fix"}`.
```
CLI management (the prompt is passed inline via `--prompt`):
```bash
zero specialist list
zero specialist show api-reviewer
zero specialist create api-reviewer \
--project \
--description "Reviews API changes"\
--tools read-only,plan \
--prompt "$(cat api-reviewer.md)"
zero specialist edit api-reviewer --project
zero specialist delete api-reviewer --project
zero specialist path # prints the resolved specialists directory
```
The full format spec (frontmatter fields, tool scopes, prompt conventions) is in [`docs/SPECIALISTS.md`](docs/SPECIALISTS.md).
> **Roadmap.** An in-UI specialist manager (create / edit / delete / preview) is on the backlog. Today you use the `zero specialist` CLI subcommands above.
## 3. Skills
Skills are markdown instruction packs the agent can pull in on demand. Each skill is a directory containing a `SKILL.md`. Skills are **user-level only** in this version — there's no project-scoped skill directory yet, so anything you want shared with the team goes in `AGENTS.md` (section 1) or as a hook (section 4).
Discovery root: `$ZERO_SKILLS_DIR` → `$XDG_DATA_HOME/zero/skills` → `~/.local/share/zero/skills/`. A missing directory is fine — Zero just reports "no skills".
```
~/.local/share/zero/skills/
run-benchmarks/
SKILL.md
write-changelog/
SKILL.md
```
`SKILL.md` format:
```markdown
---
description:Run the project's benchmark suite and summarize the deltas.
---
# Run benchmarks
1.`make bench` — captures the wall-clock and RSS before and after.
2.`benchstat before.txt after.txt` — diffs the two.
3. Report any regression > 5% with the function name and the previous value.
```
Only `name` and `description` are recognized in the frontmatter today. The `name` defaults to the directory name. If two skills declare the same name, the one in the lexicographically-first directory wins — duplicates are dropped silently. Plugin-declared skills (section 6) are not yet merged into the loader, so a `skills:` entry inside a plugin's `plugin.json` is not visible to the `skill` tool today.
The `skill` core tool lets the agent load any discovered skill by name.
## 4. Hooks
Hooks fire shell commands on lifecycle events. Configure them in JSON:
The `args` array (when present) is passed verbatim to `exec.CommandContext`. The actual hook payload — event name, matcher, tool call id, tool name, tool input, tool output, status — is delivered to the command as **JSON on stdin**, not via `${...}` substitution. A typical handler reads stdin and decides what to do:
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.
## 5. MCP — Model Context Protocol
Zero is both an **MCP client** (it can call external MCP servers) and an **MCP server** (other agents can call its tools).
### As a client — configure MCP servers in `config.json`
zero mcp add docs --type stdio -- docs-mcp --port 7777
zero mcp add github --type http --url https://api.example.com/mcp \
--header "Authorization=Bearer YOUR_TOKEN_HERE"
zero mcp list
zero mcp check docs
zero mcp remove github
zero mcp oauth login github
```
Servers are merged from user and project configs (project wins on conflicts). Token-bearing values in `config.json` are sent verbatim — there is no `${env:...}` expansion — so prefer one of:
- A wrapper script that sources the secret and execs the real command.
- A `--header` value produced by command substitution (`"Authorization=Bearer $(print-token)"`) in a private shell config that you keep out of git.
- A secret manager that injects the env var your MCP server reads on its own (the `command` and `args` then run inside that environment).
### As a server — expose Zero's tools to another agent
```bash
zero serve --mcp
```
The server speaks MCP over stdio. Configure it from the receiving side as a `stdio` server whose command is `zero serve --mcp`.
## 6. Plugins
A plugin is a self-contained directory that bundles tools, hooks, and skills for one capability. Plugins live at:
- User: `~/.config/zero/plugins/<id>/`
- Project: `./.zero/plugins/<id>/`
Each plugin has a `plugin.json` manifest:
```json
{
"id":"github-pr-review",
"name":"GitHub PR Review",
"description":"Adds review tools for GitHub PRs.",
zero plugins add ./github-pr-review # copy into ~/.config/zero/plugins/ or ./.zero/plugins/
zero plugins list
zero plugins remove github-pr-review # alias: rm
```
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.
> **Roadmap.** An in-UI plugins manager (browse, install, enable / disable) is on the backlog. Today you use the `zero plugins` CLI subcommands above. Skills declared inside a plugin's `plugin.json` are not yet merged into the `skill` tool's discovery (see section 3).
## 7. Configuration locations
Three layers, applied in order (later layers override earlier ones):
| Layer | Path | Notes |
| --- | --- | --- |
| Built-in defaults | compiled in | Lowest priority. |
| User config | `~/.config/zero/config.json` | Your machine. Never committed. |
| Project config | `./.zero/config.json` | The repo. Committed (or not, your call). |
The user config holds things that should follow the user across projects (default provider, default model, theme). The project config holds things the team agreed on (provider catalog, sandbox policies, model restrictions).
The sandbox `additionalWriteRoots` key is **ignored in project config** by design — a checked-out repo cannot widen its own sandbox. Set it in the user config or pass `--add-dir` per-invocation.
## 8. End-to-end example
A team that wants every contributor's Zero to behave the same way commits:
Zero is a coding agent that lives in your terminal. It runs an agentic tool loop —
reading, editing, searching, and running commands in your repo — against **whatever
model you choose**. Zero has a **Go-native core** with an npm distribution wrapper;
the Go runtime is the default app path.
> Zero treats the **model as a swappable, per-task choice** — no single-vendor lock-in —
> and never mutates your system without a permission decision.
</div>
---
## Highlights
Zero is an AI coding agent that lives in your terminal. It runs a full agentic loop — reading, searching, editing, and executing in your repo — against **whatever model you choose**: frontier APIs, fast cloud inference, or a local model on your own machine. One interface, no vendor lock-in, no telemetry phoning home.
- 🔌 **Multi-provider** — OpenAI-compatible, Anthropic, and Gemini behind one interface, with a model registry (capabilities, context limits, cost). Bring your own key and endpoint.
zero exec"fix the failing test in ./pkg"# headless one-shot
zero exec -o stream-json < turns.jsonl # programmatic, for scripts & CI
```
> Zero treats the **model as a swappable, per-task choice** and **never mutates your system without a permission decision**.
## Why Zero
- 🔌 **25+ providers, one interface** — OpenAI, Anthropic, Gemini, Ollama (local & cloud), LM Studio, OpenRouter, Groq, DeepSeek, Mistral, xAI, Qwen, Kimi, GitHub Models, and any OpenAI- or Anthropic-compatible endpoint. Switch mid-session with `/model`.
- 🖥️ **A TUI that feels premium** — truecolor Bubble Tea interface with a first-run setup wizard, searchable live model picker, scrollback, themes, image input, and slash commands for everything.
- 🤖 **Headless & scriptable** — `zero exec` with `text` / `json` / `stream-json` I/O, session resume & fork, isolated `--worktree` runs, and meaningful exit codes. Built for CI.
- 🧠 **Subagents** — delegate to built-in `worker`, `explorer`, and `code-review` specialists (or generate your own) that run as real background tasks, even out-of-process.
- 📋 **Spec mode** — have the agent draft a spec first, review and approve it, *then* let it build. No more runaway sessions.
- 📈 **Mid-run model escalation** — start cheap, and let the agent request a stronger model only when it hits a wall (`--allow-escalation`).
- 🗺️ **Repo intelligence** — deterministic repo maps, workspace indexing, and context-budget reports keep the agent grounded in *your* codebase, not hallucinations.
- ⏰ **Scheduled agents** — `zero cron` runs file-backed, dependency-free agent jobs on a schedule.
- 🛡️ **Safe by default** — permission-gated mutations, autonomy ceilings, sandbox policy (writes stay inside the workspace unless you grant extra directories with `--add-dir` / `/add-dir`), and secret redaction everywhere. Unsafe mode is an explicit, loudly-labeled opt-in.
- 💾 **Durable sessions** — append-only local event store with full-text search, resume, fork, and rewind. Your history never leaves your disk.
- 🧩 **Extensible** — skills, plugins, hooks, and MCP (Zero is both an MCP client *and* an MCP server).
## Quick start
> Requires [Go](https://go.dev/) for local development and release tooling.
> The npm wrapper uses Node when Zero is installed as an npm package.
```bash
go run ./cmd/zero # launch the interactive TUI
# run from source (requires Go 1.24+)
go run ./cmd/zero
# or install a release binary
scripts/install.sh # Linux / macOS
powershell -ExecutionPolicy Bypass -File scripts/install.ps1 # Windows
```
Point Zero at a model — either set environment variables:
First launch opens a **guided setup wizard** — pick a provider, paste a key, choose a model, done. Or do it non-interactively:
```bash
exportOPENAI_API_KEY=sk-...
# optional: any OpenAI-compatible endpoint / model
exportOPENAI_BASE_URL=https://api.openai.com/v1
exportOPENAI_MODEL=gpt-4.1
exportOPENAI_API_KEY=sk-...# or ANTHROPIC_API_KEY, GEMINI_API_KEY, GROQ_API_KEY, ...
zero setup # guided first-run provider setup
zero doctor # verify config, keys, and connectivity
```
…or save reusable provider profiles in `~/.config/zero/config.json` and manage them with
`zero providers`. Run `zero doctor` anytime to verify your setup.
## Usage
### Interactive (TUI)
Local models need no key at all:
```bash
go run ./cmd/zero # or: zero after installation
# Ollama or LM Studio running locally? Zero finds them.
zero providers list
```
Inside the TUI: type to chat and press **Enter** to send. `/` opens command suggestions
(**Tab** accepts the first). When the prompt is empty, the arrow keys, **PgUp/PgDn**, and
**Home/End** scroll the transcript. **Ctrl+C** exits.
## The TUI
### Headless (`exec`)
Type to chat, **Enter** to send. `/` opens command suggestions, **Shift+Tab** cycles permission modes, **Ctrl+C** exits.
| | |
|---|---|
| `/model``/provider` | switch model or provider mid-session (searchable picker) |
| `/spec``/plan` | spec-mode drafting and live plan view |
| `/image` | attach images for vision models |
| `/resume``/rewind` | time-travel across sessions |
| `/compact``/context` | manage the context window |
| `/permissions``/tools` | inspect what the agent can touch |
| `/add-dir` | grant an extra write directory for the session, or list current write roots |
| `/theme``/style` | make it yours |
| `/doctor``/usage``/config` | health, cost, and config without leaving the chat |
Turn-completion notifications (terminal bell / OSC-9) ping you when the agent finishes or needs input — go make coffee.
## Headless `exec`
```bash
# one-shot
zero exec"explain internal/agent/loop.go and suggest one improvement"
# from a file, with a specific model, as JSON for scripts
zero exec --file task.md --model claude-sonnet-4.5 --output-format json
# pick a model and mode preset per task
zero exec --model claude-sonnet-4.5 --mode deep "refactor the session store"
# multi-turn / programmatic over stdio
# spec-first: draft → review → approve → build
zero exec --use-spec "add rate limiting to the API client"
# run in an isolated git worktree, escalate model only if needed
zero exec -w --allow-escalation "migrate the config loader to v2"
# multi-turn programmatic I/O over stdio
zero exec --input-format stream-json --output-format stream-json < turns.jsonl
# resume or fork any previous session
zero exec --resume # latest
zero exec --fork <session-id> "now try the other approach"
| **Enterprise (catalog)** | Amazon Bedrock · Vertex AI *(adapters in progress)* |
| **Anything else** | any OpenAI-compatible or Anthropic-compatible endpoint |
Any OpenAI-compatible endpoint works with just a base URL, key, and model — so local
runtimes (Ollama, gateways, etc.) plug in the same way.
The model registry tracks each model's capabilities, context window, and cost — and the live model picker discovers what your provider actually serves.
- **Go-native target**: `cmd/zero` is the default production runtime; the npm package delegates to the built Go binary.
- **Surface-agnostic core**: the agent loop streams text + tool calls, executes tools, and emits a typed event stream consumed identically by every surface.
- **Edges are interfaces**: `Provider`, `Tool`, `SessionStore`, and the permission policy are swappable.
- **Model is data**: capabilities, cost, and routing live in the registry — never hard-coded.
- **Surface-agnostic core** — the agent loop streams text + tool calls and emits one typed event stream consumed identically by the TUI, `exec`, the MCP server, and cron.
- **Edges are interfaces** — `Provider`, `Tool`, `SessionStore`, and the permission policy are swappable.
- **Model is data** — capabilities, cost, and routing live in the registry, never hard-coded.
- **Pure Go** — one static binary per platform; the npm wrapper just delegates to it.
## Project layout
```
cmd/
zero/ # production Go CLI entrypoint
zero-pr-review/ # deterministic PR review helper
zero/ production CLI entrypoint
zero-release/ release builder + smoke tests
zero-perf-bench/ performance benchmarks
zero-pr-review/ deterministic PR review helper
internal/
agent/ # Go agent loop and tool-call orchestration
cli/ # interactive/headless command surface
config/ # layered configuration and provider profiles
> Pending by severity: **high 2, medium 42, low 34**. Counts above reflect this re-verification
> plus this branch's own fixes (scanner.go:81, compaction.go:58, worktrees.go:247).
| Sev | Status | Location | Evidence |
|---|---|---|---|
| critical | fixed | internal/search/search.go:346 | search.go:363-428 findMatch maps lowered offsets back via lowerWithOffsets + snapRuneStart clamps context; /tmp probe of audit repro (100x'Ⱥ'+' hello') returns match {201 206}, valid UTF-8, no panic |
| critical | fixed | internal/mcp/protocol.go:69 | 82b9c8d: const maxMessageBytes=64MiB (protocol.go:18) bounds both framing paths — Content-Length values above the cap are rejected (:90-92) and the newline-delimited readLine errors past the cap (:78-80); stdio transport switched to newline-delimited JSON. Covered by protocol_test.go; go test green |
| high | fixed | internal/cli/usage.go:121 | usage.go:123-131: diff defaults to zerogit.DiffStat{}; resolveWorkspaceRoot/inspectChanges errors are ignored (degrade to net-LOC 0) instead of returning |
| high | fixed | internal/tui/transcript.go:137 | transcript.go:121-142 keys are run-scoped ("%d:%d:%s" with row.runID) plus effectiveToolRowID ordinal suffix; model.go:1501/1553/1572/1584 set runID on rows |
| high | fixed | internal/tui/model.go:853 | model.go:1034-1043 commandExit now calls cancelRun(), sets exiting, and defers tea.Quit while flushRunIDs is non-empty; deferred quit fires after flush at model.go:645-647 |
| high | fixed | internal/tui/model.go:599 | internal/tui/flush.go:11-30 settled-row flush frontier prints history to native scrollback via tea.Println; model.go transcriptView() renders only live tail from m.flushed; covered by internal/tui/flush_test.go |
| high | fixed | internal/tui/model.go:512 | model.go:614,633-637 flush goes to flushSessionID recorded at cancel time (model.go:1334) via appendSessionEventsTo (tui/session.go:76), not the active session |
| high | fixed | internal/tui/session_controls.go:176 | session_controls.go:182-184 blocks /rewind while len(m.flushRunIDs) > 0 ('cannot rewind while a cancelled run is still flushing'); covered by tui/session_test.go |
| high | fixed | internal/tui/model.go:584 | flush.go:71-120 settles rows into native scrollback via tea.Println; View()/transcriptView renders only rows from m.flushed (model.go:751-784) |
| high | fixed | internal/tui/transcript.go:137 | transcript.go:121-141 bakes runID into every key; within-run repeats get ordinal suffix via effectiveToolRowID (transcript.go:148, used at model.go:1493-1496 with callSeq) |
| high | fixed | internal/tui/rendering.go:525 | live cap 16 kept for tidy tail only; flushed scrollback cards use flushCardBodyMaxLines=400 (rendering.go:154-158) and Ctrl+O detailed view is uncapped (renderRowDetailed bodyCap:0, rendering.go:165; capCardLines treats <=0 as no cap, rendering.go:718-720) |
| high | fixed | internal/tui/transcript.go:135 | transcript.go:121-141 transcriptRowKey bakes runID into every key ("%d:%d:%s"); model.go:1501/1553 set row.runID and effectiveToolRowID suffixes within-run repeats |
| high | fixed | internal/tui/model.go:512 | model.go:614-637 — flushRunIDs maps cancelled run -> session id captured at cancel time (model.go:1334); flush goes via appendSessionEventsTo(flushSessionID,…) (session.go:76) when active session differs |
| high | fixed | internal/tui/run.go:26 | flush.go:71-120 settled-row frontier prints history to native scrollback via tea.Println; model.go:769 View renders only the unflushed live tail |
| high | fixed | internal/tui/transcript.go:139 | transcript.go:125 key is now "%d:%d:%s" (kind:runID:id) for all keyed kinds; transcript.go:148 effectiveToolRowID adds per-run ordinal suffix for within-run repeats |
| high | fixed | internal/tui/model.go:311 | model.go:355-360 Ctrl+C sets exiting and returns nil (no Quit) while flushRunIDs non-empty; deferred tea.Quit fires at model.go:645-647 once the cancelled run's session events flush |
| high | fixed | internal/tui/model.go:853 | model.go:1034-1043 commandExit now calls cancelRun(), sets m.exiting, and defers tea.Quit while len(m.flushRunIDs) > 0 — same protection as Ctrl+C |
| high | fixed | internal/cli/extensions.go:186 | internal/cli/extensions.go:191 passes Servers through redactMCPServerConfigs (:205-224) which replaces every env/header value with [REDACTED]; asserted by extensions_test.go:108 (go test ok) |
| high | fixed | internal/mcp/protocol.go:42 | 82b9c8d: read() now accepts newline-delimited JSON frames (isJSONStart→decodeMessage) and only falls back to Content-Length headers for back-compat; write() emits one JSON message per line. Covered by protocol_test.go; go test green |
| high | fixed | internal/streamjson/streamjson.go:310 | 3142de6: secretPatterns now anchored — sk keys require a word boundary plus a 16-char body and bearer requires a 16-char token — so 'task-list' and prose like 'bearer of bad news' are no longer redacted while real keys/tokens still are. Covered by streamjson_test.go |
| high | fixed | internal/cli/cron.go:112 | 7a47059: cronAdd now consumes an explicit positional expr (cron.go:113-118) BEFORE applying recipe defaults, so a schedule given alongside --recipe is honored; extra positionals error. Covered by cron_test.go |
| high | fixed | internal/providerhealth/providerhealth.go:285 | 600057d: default probe now uses newConnectivityClient — CheckRedirect re-validates every hop (capped at 5) and DialContext validates+pins the resolved IP, dialing the IP literal to close the DNS-rebinding TOCTOU. Covered by connectivity_client_test.go |
| high | fixed | internal/sessions/store.go:547 | 12a3f62 + 8c75074: ReadEvents now drops a torn final record (last non-empty line) only when the file lacks a trailing newline (tornTailPossible); a fully-flushed corrupt line, or any malformed earlier line, still fails loudly. Covered by store_test.go |
| high | partial | internal/cli/cron_run.go:173 | 7a47059: fireJob now re-reads the job before persisting and preserves an external pause/removal mid-run (cron_run.go:170-181), closing the pause-clobber. But internal/cron/store.go still has no flock/CAS, so the concurrent-scheduler race (two schedulers both seeing NextRunAt due) remains — single-scheduler is the supported model |
| high | fixed | internal/tools/bash.go:104 | 0dbb5e0: hardenProcessLifetime (bash_proc_unix.go) sets Setpgid, a Cancel that SIGKILLs the whole process group (negative pid), and a WaitDelay backstop so a backgrounded child cannot outlive the timeout or hang Run(); Windows sets WaitDelay only. Covered by bash_tool_test.go |
| high | fixed | internal/sandbox/runner.go:262 | 2eeaea7: sandbox-exec profile write rule now also allows the standard device nodes (/dev/null, …) and temp trees (/tmp, /private/var/folders, …) for parity with bubblewrap's --dev/--tmpfs; the workspace stays the only writable project location. Covered by runner_test.go |
| high | fixed | internal/tools/apply_patch.go:161 | b6182bf: patch headers are now parsed with hunk line-counting (git-apply semantics), so a hunk-body line like '-- /etc/old' is no longer mistaken for a file header and a valid patch is accepted. Covered by write_tools_test.go |
| high | fixed | internal/tools/grep.go:224 | b6182bf: glob now matches against the path relative to the SEARCH directory (ripgrep semantics) via filepath.Rel(target,path); a single explicit file matches by base name. probe path=subdir glob=*.go now matches subdir/a.go. Covered by file_tools_test.go |
| high | fixed | internal/providers/openai/types.go:3 | 46d495c (+7044d36/6664f71): chatCompletionRequest gained reasoning_effort; zeroruntime.CompletionRequest.ReasoningEffort is threaded through the agent loop and mapped per provider — OpenAI reasoning_effort, Anthropic extended thinking, Gemini thinking budget; CLI gates the value against the resolved model. Covered by openai/anthropic/gemini provider_test.go |
| high | fixed | internal/specialist/exec.go:166 | 39a9c55: BuildArgs always prefixes the session title with the specialist name and falls back to the bare name when description is empty, so AgentName is non-empty and a description-less run resumes. Covered by specialist/exec_test.go |
| high | fixed | internal/agent/loop.go:233 | 95a6a8e: Run now sets result.FinishReason from collected.FinishReason (and from the after-max-turns final answer); Result.Truncated()/TruncationNotice() drive an exec-writer warning and a TUI system row when a response was cut off. Covered by loop_test.go |
| high | partial | internal/hooks/hooks.go:372 | 17a91d9: a Dispatcher that runs configured hooks and audits each via NewAuditStore now exists and is tested (dispatch.go + dispatch_test.go), closing the 'zero non-test callers' gap for Select/audit. But it has ZERO production callers — not wired into the beforeTool/afterTool tool lifecycle — and runHooks still supports only `list` (extensions.go:94); no dispatch/edit CLI surface |
| low | fixed | internal/cli/exec_writer.go:412 | exec_writer.go:415 and :404 now call cutRuneBoundary (:420-431) which backs up to the last UTF-8 rune start before slicing; added in audit-fix commit 6fb0231 |
| low | fixed | internal/search/search.go:309 | search.go:318-322: resolveSessions now returns fmt.Errorf("zero session not found: %s", sessionID) when store.Get returns nil instead of an empty success |
| low | fixed | internal/doctor/doctor.go:134 | doctor.go:129-141: providerConfigCheck now reports apiKey presence ("set"/"not set") based on profile.APIKey != "", with comment explaining the change; doctor tests pass |
| low | fixed | internal/tui/transcript.go:303 | truncateTUIOutput cuts via rune-boundary cutRunes (transcript.go:302,307-318); titles use cutRunes (session.go:112, spec_mode.go:305) and cutRuneBoundary (exec_sessions.go:90, exec_writer.go:420) |
| low | fixed | go.mod:10 | go.mod:7-12 direct requires are now only bubbles/bubbletea/lipgloss/x-sys; glamour is gone entirely and x/ansi (line 18) and termenv (line 31) are demoted to // indirect |
| low | fixed | internal/tui/model.go:525 | agentResponseMsg completion path now invokes m.runCancel() before nil-ing it (model.go:651-658, with explanatory comment about the per-prompt leak) |
| low | fixed | internal/imageinput/imageinput.go:58 | imageinput.go:56-66 os.Open failure now wraps real cause ("cannot open image %s: %w") and read failure "cannot read image %s: %w"; only a stat miss reports "not found" (:38) |
| low | fixed | internal/tui/session.go:93 | All three paths rune-safe: internal/tui/session.go:112 cutRunes(..., tuiSessionTitleLimit), internal/tui/spec_mode.go:305 cutRunes, internal/cli/exec_sessions.go:90 cutRuneBoundary(title, 80) |
| low | fixed | internal/cli/exec_writer.go:412 | internal/cli/exec_writer.go:404 (truncateForStatus) and :415 (truncateForStreamJSONOutput) both use cutRuneBoundary defined at exec_writer.go:420-431 (backs up to utf8.RuneStart) |
| low | fixed | internal/sessions/replay.go:237 | replay.go:245-256 cutPromptRuneBoundary (utf8.RuneStart backoff) now used at replay.go:174-177 and 238; titles rune-safe via cutRuneBoundary (cli/exec_sessions.go:90) and cutRunes (tui/session.go:112) |
| low | fixed | internal/tui/rendering.go:68 | footerText/commandFooterText/formatCommandFooterText/defaultCommandFooterText (present at b144cf9~1 rendering.go) are absent at HEAD; dead actionAppendToolCall/ToolResult enum values also removed from transcript.go |
| low | fixed | internal/tui/transcript.go:303 | cutRunes lands cuts on rune boundaries (transcript.go:305-318), used by truncateTUIOutput for tool-result text (transcript.go:302) and session titles (session.go:112, spec_mode.go:305) |
| low | fixed | internal/tui/model.go:853 | commandExit cancels the run, sets exiting, and defers quit while flushRunIDs is non-empty (model.go:1034-1043); deferred tea.Quit fires when the last flush drains (model.go:645-647), and new prompts are gated while exiting (model.go:1012-1014) |
| low | fixed | internal/tui/session.go:93 | cutRunes (transcript.go:307-318) cuts on rune boundaries; used by tuiSessionTitle (session.go:112) and truncateTUIOutput (transcript.go:302) for tool-result text |
| low | fixed | internal/tui/rendering.go:482 | rendering.go:526 — permission card action row now renders "[esc] cancel run", matching the KeyEsc fall-through to m.cancelRun() (model.go:391-393) |
| low | fixed | internal/cli/update.go:37 | internal/update/update.go:136-142: normalizeVersionTag failure now falls back to currentVersion="0.0.0" so the check proceeds to the network call (landed in #158) |
| low | fixed | internal/notify/notify.go:134 | notify.go:137 now drops `(r >= 0x80 && r <= 0x9f)` with comment that C1 includes single-byte CSI/OSC/ST introducers (landed in #158) |
| low | fixed | internal/tui/model.go:512 | model.go:616-624 the flushRunIDs (cancelled-run) branch of agentResponseMsg now records each usage event via recordUsageEvent → m.usageTracker.Record (session_controls.go:294) |
| low | fixed | internal/tui/rendering.go:490 | rendering.go:560-564 the ask card now echoes the in-progress answer: "❯ " + fill(zeroTheme.ink).Render(input) + accent cursor, appended to the card lines |
| low | fixed | internal/tui/view.go:227 | view.go:230-235 now matches `path == home` or strings.HasPrefix(path, home+os.PathSeparator) — a path-boundary check with a comment citing the /Users/alice2 sibling case |
| low | fixed | internal/sandbox/risk.go:53 | defect not present: risk.go:11-50 patterns are package-level regexp.MustCompile compiled once at init (identical at audit commit b144cf9); per call only MatchString runs |
| low | fixed | internal/modelregistry/catalog.go:36 | catalog.go:36 claude-haiku-3.5 extra capabilities now []ModelCapability{ModelCapabilityPromptCache} — ModelCapabilityVision removed (base withBaseCapabilities adds no vision), so SupportsVision is false |
| low | fixed | internal/providers/providerio/retry.go:107 | retry.go:108-110 ShouldRetryStatus now returns true for `code == 529` alongside 429/503 (doc comment lines 99-107 updated to cover Anthropic overloaded), consistent with ClassifiedError's 529 rate-limit classification at providerio.go:224 |
| low | fixed | internal/tui/spec_mode.go:301 | spec_mode.go:305 and session.go:112 now use cutRunes (transcript.go:307-318 backs off to utf8.RuneStart boundary); no byte-index title slicing remains |
| low | fixed | internal/agent/system_prompt.go:89 | system_prompt.go:102-107 now walks cut back with `for cut > 0 && !utf8.RuneStart(content[cut])` before slicing, so truncation lands on a rune boundary (matches audit doc's rune-safe truncation fix claim) |
| low | fixed | internal/config/writer.go:53 | internal/config/writer.go:55-74 — writeConfigFile now does os.CreateTemp + Chmod(0600) + write + os.Rename (atomic temp+rename, with explanatory comment at :53); go test ./internal/config -run Upsert passes |
| low | fixed | internal/cli/exec_sessions.go:88 | exec_sessions.go:87-91: title = cutRuneBoundary(title, 80); helper (exec_writer.go:420-431) verified rune-safe by /tmp probe |
| low | fixed | internal/cli/exec_writer.go:412 | exec_writer.go:404 (truncateForStatus/stderr) and :415 (truncateForStreamJSONOutput) both use cutRuneBoundary, which backs up to a utf8.RuneStart boundary |
| low | open | internal/streamjson/streamjson.go:30 | repo-wide grep finds EventRestore only at its declaration (streamjson.go:30) — no emitter and not even test references; streamjson.go untouched by 6fb0231 or later merges |
| low | open | internal/cli/cron_run.go:150 | cron_run.go:145-151: rec.Error still set only from errBuf on code!=0; outBuf (where stream-json error events land) is captured but never read |
| low | open | internal/cli/exec_sessions.go:55 | exec_sessions.go:55 still constructs sessions.NewStore(sessions.StoreOptions{}) directly, and exec.go:375-389 PrepareExecOptions still omits the Store field; deps.newSessionStore is used by spec/usage/observability but not the exec run path |
| low | fixed | internal/secrets/scanner.go:35 | [reverified 2026-06-13] already fixed on main: scanner.go:37 pattern is now `sk-[A-Za-z0-9_-]{20,}` (allows -/_ so sk-proj-/sk-svcacct- match), with comment at :35-36. Covered by scanner_test.go TestScanDetectsModernPrefixedOpenAIKeys |
| low | open | internal/redaction/redaction.go:222 | redaction.go:221-235 pointer/map ptr added to context.seen and never deleted after subtree recursion, so a second (sibling) visit returns CircularReference |
| low | open | internal/redaction/redaction.go:174 | redaction.go:174 `interface{ StackTrace() fmt.Stringer }` unchanged; grep finds no implementer in the module and go.mod has no pkg/errors or similar dep |
| low | open | internal/zerogit/zerogit.go:417 | zerogit.go:418-420 fallback EnvRunner adapter still discards env (`_ []string`), so stagedSnapshotDiff's GIT_INDEX_FILE isolation is lost with a caller-supplied Runner |
| low | open | internal/tui/commands_test.go:46 | internal/tui/commands_test.go:46 still defines commandTestStringSliceContains; repo-wide grep finds no callers, only the definition |
| low | open | internal/tui/session_test.go:215 | internal/tui/session_test.go:215 'runtimeMessages := []tea.Msg{}' appended in RuntimeMessageSink at line 226 but never read in the test body (only runtimeMessageCh is consumed) |
| low | open | internal/sessions/store.go:555 | store.go:554-556 timestamp() still time.RFC3339 (second precision); List ties broken lexically by SessionID (store.go:331-336), not actual recency, so Latest() can still pick the wrong session on same-second ties |
| low | open | internal/tui/session_controls.go:165 | session_controls.go:165 still only increments compactRequests; sole reads are its own status display (lines 266, 280) with "Backend: state: pending integration" — no compaction is ever triggered by it |
| low | open | internal/tui/model.go:837 | model.go:1012-1014 silently drops prompt submits while m.exiting (return m, nil — no feedback row); no exiting/flushRunIDs indicator anywhere in statusLine (view.go:113-139), composerLine, or any render path |
| low | open | internal/tui/command_center.go:17 | tui/command_center.go:17-21 passes only Now/Runtime/Provider (no UserConfig/ProjectConfig) vs cli/observability.go:64-71; empty paths warn at doctor.go:120 and :273 |
| low | open | internal/background/process_posix.go:38 | process_posix.go:39-47 still polls signal-0 on os.FindProcess(pid) while the Wait goroutine (specialist/exec.go:653) reaps the child; line 50 Kill() can hit a recycled pid |
| low | open | internal/tools/registry.go:108 | repo-wide grep: OnSandboxDecision only at registry.go:19,108,116 — no caller or test ever sets it |
| low | open | internal/sandbox/grants.go:146 | grants.go unchanged since #77: Lookup trims key (grants.go:146) but readState validates raw file keys via trimming ValidateToolName (grants.go:225-227,273) and keeps the raw map key, so a whitespace-padded key validates yet never matches |
| low | open | internal/tools/read_file.go:89 | probe: max_lines=5 -> Truncated=true with no marker beyond '(lines 1-5 of 20)' header (read_file.go:102-111); rune-width sub-claim not reproducible (ASCII strconv.Itoa padding) |
| low | open | internal/providers/factory.go:118 | factory.go:115 still `if !explicitProvider \|\| isImplicitOpenAI(...)`; isImplicitOpenAI (factory.go:159-164) requires TrimSpace(ProviderKind)=="" && TrimSpace(Provider)=="", which contradicts explicitProvider==true (factory.go:147-157), so it can never return true at its sole call site |
| low | open | internal/providercatalog/catalog.go:46 | catalog.go:46 Public still declared; no constructor or descriptor ever sets it (always false); the only read is the vacuous guard in catalog_test.go:105, which already existed before the audit (added in 4accec5/#141) — no production read or write |
| low | open | internal/verify/verify.go:215 | verify.go:215 RunLoop still defined, sole caller verify_test.go:207; production loop is selfverify.Run (selfverify.go:53-74) used by cli/workflows.go |
| low | open | internal/specialist/output_tool.go:244 | output_tool.go:244 formatTaskOutput has zero callers (prod or tests); only formatTaskOutputSummary is used (output_tool.go:154) |
| low | open | internal/specialist/exec.go:346 | exec.go:347-350: on SetPID error calls cleanupBackgroundPromptFile (runtime.go:73 deletes file/dir) and returns err with no recordSpecialistStop/UpdateStatus while child keeps running |
| low | open | internal/agent/loop.go:188 | helpers.go:85 sets collected.Error = ctx.Err().Error(); loop.go:189-191 returns errors.New(collected.Error) before the ctx.Err() identity return at loop.go:193-195, so errors.Is(err, context.Canceled) still fails for that path |
| low | open | internal/agent/types.go:183 | types.go:183 OnContext field and loop.go:102-103 emit site exist, but repo-wide grep finds no "OnContext:" assignment and no MeasureContext/ContextBreakdown use in cli/tui/cmd/contextreport (only tests) |
| low | open | internal/agent/loop.go:1072 | loop.go:1120-1122 and loop.go:1129-1131 both still assign schema["items"] = propertyToRuntimeMap(*property.Items) |
| low | open | internal/agent/loop.go:473 | loop.go:530-531 set requestEvent.GrantMatched/Grant but requestEvent is never read after the switch (post-run event rebuilt at loop.go:558); fallback at loop.go:510-511 unreachable since buildPermissionEvent always returns ok=true under the shouldRequestPermission gate (Prompt-permission tools, loop.go:751-758 vs 877-894) |
| low | open | internal/agent/loop.go:183 | loop.go:184-186 retry CollectStreamWithOptions still passes only OnUsage; comment at loop.go:178-183 documents the OnText omission as deliberate anti-duplication, but streaming surfaces still receive only the aborted partial text |
| low | open | internal/zeroruntime/helpers.go:104 | helpers.go:104 still does `collected.Text += event.Content` per StreamEventText (no strings.Builder); unchanged since audit commit b144cf9 |
| low | open | internal/zerocommands/sandbox_snapshots.go:104 | grep for SandboxPlan/Decision/Policy/Backend/Risk/Violation/Hook/Plugin/MCPServer snapshots finds no consumers outside internal/zerocommands + tests; `zero sandbox policy --json` serializes raw sandbox.Policy/Backend/BackendPlan instead (internal/cli/sandbox.go:90-105) |
| low | open | internal/config/contracts.go:12 | internal/config/contracts.go still defines ContractGap/DefaultContractGaps/FindContractGapsByMilestone; only reference repo-wide is internal/config/contracts_test.go |
| low | open | internal/skills/skills.go:91 | skills.Duplicates (skills.go:91) and skills.Get (skills.go:217) have zero callers outside internal/skills; cli/skills.go uses List and tools/skill.go uses Load, so collisions are still dropped without any user-facing warning (dedup is now documented/deterministic but unsurfaced) |
| low | open | internal/plugins/plugins.go:893 | internal/plugins/plugins.go:893 allows only beforeTool/afterTool/sessionStart/sessionEnd, while internal/hooks/hooks.go:812 also accepts specialistStart/specialistStop — sets still disagree |
| low | open | internal/config/resolver.go:136 | internal/config/resolver.go:143, :173 and :502 all gate on `MaxTurns > 0` with no error path, so a configured maxTurns of 0 or negative is still silently ignored (falls back to default 30) |
| low | open | internal/cli/exec.go:353 | exec.go:375 PrepareExec called without Store (defaults to NewStore in sessions/exec_session.go:58), exec_sessions.go:55 direct NewStore, exec.go:396 time.Now() instead of deps.now |
| low | open | internal/zerocommands/contracts.go:166 | contracts.go:166 APIKeySet checks only profile.APIKey, ignoring AuthHeaderValue (valid sole credential per config/types.go:49-51); rendered "not set" at cli/command_center.go:328 |
| low | partial | internal/cli/cron.go:275 | promptExcerpt fixed via cutRuneBoundary(p,47) at cron.go:275, but cronTruncate at cron_run.go:179-184 still returns s[:max]+"…" (byte slice, can split a rune in persisted run errors) |
| low | partial | internal/tui/options.go:28 | app.go:413 production caller still sets none of the three, but newModel now self-defaults a usage.Tracker when nil (model.go:239-242) so usage works; effort/style are settable at runtime via /effort and /style |
| low | partial | internal/tui/rendering.go:62 | Footer system, actionAppendToolCall/Result, and dead theme tokens (selRow/statusOk/statusErr/line2) are removed, but listCommandNames/formatCommandHelpLines remain test-only (commands.go:297,306), resumeText's args!="" branch is still unreachable (command_center.go:44-54; sole caller session.go:122 passes ""), OnContext is still wired by no production caller (agent/loop.go:102), and stale footer comments persist (commands.go:254, autocomplete.go:64) |
| low | partial | internal/zerogit/zerogit.go:206 | truncateSubject now rune-based (zerogit.go:464-472, no invalid UTF-8), but ValidateMessage still byte-based `len(firstLine) > 72` (zerogit.go:207) so valid non-ASCII subjects are rejected |
| low | partial | internal/cli/cron.go:275 | promptExcerpt fixed (cron.go:275 uses cutRuneBoundary) but error helper cronTruncate still byte-slices: internal/cli/cron_run.go:183 'return s[:max] + "…"', used for run errors at cron_run.go:150 |
| low | partial | internal/tui/command_center.go:44 | Footer helpers now render (rendering.go:690-700, bash/grep footers 892-905/969-971), but resumeText's args!="" branch (command_center.go:43-54) is still unreachable (sole caller session.go:122 passes "") and stale footer comments persist (autocomplete.go:64, commands.go:254) |
| low | partial | internal/cli/cron.go:275 | promptExcerpt fixed via cutRuneBoundary (cron.go:275), but cronTruncate still byte-slices `s[:max]` at cron_run.go:182, splitting runes in run-record error text |
| low | partial | internal/tui/rendering.go:62 | footer builders (footerText/commandFooterText/etc.) and the four theme tokens (selRow/statusOk/statusErr/line2/panel2) are fully removed (zero grep hits), but formatCommandHelpLines/listCommandNames (commands.go:297-308) remain production code referenced only by tests, and stale "footer advertises" comments persist (commands.go:254, autocomplete.go:64) |
| medium | fixed | internal/update/update.go:135 | update.go:136-142: normalizeVersionTag error now falls back to currentVersion="0.0.0" (commented for dev builds) instead of returning the error |
| medium | fixed | internal/tui/session.go:190 | session.go:219-227 rehydrates role "ask_user"/"ask_user_answers" rows via askUserRequestFromPayload (session.go:349) and askUserAnswersText (session.go:380) |
| medium | fixed | internal/tui/model.go:1067 | cancelRun appends rowSystem "Run cancelled." plus any partial streamed answer (model.go:1336-1343); asserted by flush_test.go:105 |
| medium | fixed | internal/search/search.go:345 | search.go:363-407 findMatch maps lowered offsets back via lowerWithOffsets offset table; snapRuneStart (:417) guards context slicing; tests pass |
| medium | fixed | internal/search/search.go:108 | search.go:111-118 LoadIndex error now `result.SkippedSessions++; continue` instead of returning; SkippedSessions reported in Result (:61) |
| medium | fixed | .github/workflows/ci.yml:31 | .github/workflows/ci.yml:31-44 adds 'Check formatting' (gofmt -l, fails on output) and 'Vet' (go vet ./...) steps; gofmt -l . is clean at HEAD |
| medium | fixed | internal/tui/transcript.go:303 | internal/tui/transcript.go:302 truncateTUIOutput now calls cutRunes (rune-boundary cut, transcript.go:307-318); non-ASCII test TestCutRunesNeverSplitsUTF8 at flush_test.go:158 |
| medium | fixed | internal/cli/usage.go:46 | usage.go:47-51 collectUsageData now does set.skipped++ and continue on ReadEvents failure, so a corrupt session is skipped instead of aborting |
| medium | fixed | internal/tui/model.go:1081 | cancelRun appends partial answer then rowSystem "Run cancelled." marker (model.go:1336-1343), shared by Esc (model.go:392) and Ctrl+C (model.go:355) paths |
| medium | fixed | internal/tui/model.go:227 | WindowSizeMsg sets m.input.Width = maxInt(20, chatWidth(msg.Width)-14) so long input scrolls with cursor visible (model.go:556-558) |
| medium | fixed | internal/tui/view.go:394 | view.go:412-431 now requires a hunk header match or BOTH '--- ' and '+++ ' file headers; a lone '---' line no longer triggers the diff renderer |
| medium | fixed | internal/tui/rendering.go:261 | rendering.go:286-330 preserves explicit newlines and each line's leading indent (tabs→4 spaces), continuations keep the indent; only intra-line space runs collapse |
| medium | fixed | internal/tui/model.go:456 | WindowSizeMsg resizes composer and prints title bar at real width (model.go:553-566); flush refuses to freeze history before a real width arrives (flush.go:72-77); width/tier recomputed every render (view.go:17-19) and history is native scrollback, so only the ephemeral pre-size frame uses the 96-col default |
| medium | fixed | internal/tui/session_controls.go:176 | session_controls.go:182-184 — handleRewindCommand returns "cannot rewind while a cancelled run is still flushing" when len(m.flushRunIDs) > 0 |
| medium | fixed | internal/tui/session.go:190 | model.go:1461-1476 persists ask_user question payload AND ask_user_answers; session.go:218-227 rehydrates both via askUserRequestFromPayload/askUserAnswersText (session.go:349, 380) |
| medium | fixed | internal/tui/model.go:853 | model.go:1034-1043 — commandExit calls cancelRun(), sets exiting, defers tea.Quit until flushRunIDs drains (model.go:645); spec_mode.go:25 gates /spec on m.pending \|\| m.exiting |
| medium | fixed | internal/tui/spec_mode.go:22 | spec_mode.go:25 handleSpecCommand gates on `if m.pending \|\| m.exiting` and refuses to start, with comment citing the post-Ctrl+C flush window |
| medium | fixed | internal/tui/model.go:1081 | model.go:1340-1343 cancelRun appends the partial streamed answer plus rowSystem "Run cancelled." to m.transcript (live), in addition to the session-log marker |
| medium | fixed | internal/tui/model.go:610 | model.go:769 transcriptView loops only `for index := m.flushed` (live tail; settled rows flushed once to scrollback per flush.go), and rendering.go:184-192 memoizes stable row renders in the LRU staticRenderCache (render_cache.go:19) |
| medium | fixed | internal/tui/session.go:93 | session.go:112 tuiSessionTitle uses cutRunes; transcript.go:302/307-318 truncateTUIOutput cuts on the last rune boundary at or before the byte limit |
| medium | fixed | internal/tui/model.go:227 | model.go:558 WindowSizeMsg handler sets `m.input.Width = maxInt(20, chatWidth(msg.Width)-14)` so long input scrolls horizontally with the cursor visible |
| medium | fixed | internal/providers/providerio/providerio.go:184 | providerio.go:142-154 onComment now forwards a keepAlive marker to the consumer, which calls resetIdle() at providerio.go:203 before the keepAlive continue (line 204-205); providerio tests pass |
| medium | fixed | internal/modelregistry/catalog.go:27 | catalog.go:27 gpt-4.1 entry now reads ContextLimits{ContextWindow: 1_047_576, MaxOutputTokens: 32_768}, matching mini/nano |
| medium | fixed | internal/tui/run.go:41 | run.go:42-46 now prints `zero: tui error: <err>` to os.Stderr before returning 1 |
| medium | open | internal/mcp/client.go:302 | client.go:295-317 readLoop dispatches any id-bearing message to pending[id] without checking message.Method; grep confirms no 'ping' handling anywhere in client.go |
| medium | open | internal/mcp/client.go:246 | client.go:246 `client.writer.write(...)` is a blocking pipe write executed while client.mu is held (:228-:255) with no ctx awareness; file unchanged since the audit (only the response wait, which predates the audit, is ctx-aware) |
| medium | open | internal/mcp/client.go:98 | client.go:98-99 `var stderr bytes.Buffer; cmd.Stderr = &stderr` — plain unbounded buffer attached for the process lifetime, only read on initialize failure |
| medium | open | internal/mcp/network_client.go:621 | network_client.go:603-632 decodeSSERPCMessage stops at the first non-empty 'message' event (sets found, returns false); caller at :210 errors on id mismatch instead of scanning for the matching response — unchanged since audit |
| medium | open | internal/mcp/network_client.go:637 | network_client.go:637 `scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)`; ErrTooLong propagates via readStream->failPending (:578-:592) setting a permanent streamErr with no reconnect, so request() (:234) fails forever |
| medium | open | internal/cli/cron_run.go:115 | cron_run.go:102-126 reconcileOverdue still reschedules any active job with NextRunAt before the current minute without firing; no RunNow/RunOnce sentinel exists in internal/cron, and the file is unchanged since pre-audit commit 0f8ea50 |
| medium | open | internal/sessions/lineage.go:140 | lineage.go:140 tree() still calls ListChildren per node, which calls store.List() (lineage.go:74) re-reading every metadata.json from disk (store.go:312-327, no cache); file unchanged since pre-audit commits |
| medium | open | internal/cli/exec_sessions.go:114 | exec_sessions.go:117-123 still early-returns once recorder.err is set and stores AppendEvent errors there; recorder.err is never read anywhere (exec.go/exec_spec.go check only writer.err) |
| medium | open | internal/tui/session_controls.go:117 | m.responseStyle still only displayed (command_views.go:155); /theme //input-style render "does not have a backend setting yet" (rendering.go:47); /compact shows "state: pending integration" (session_controls.go:272); all still in commandDefinitions help/autocomplete (commands.go:189-230) |
| medium | fixed | internal/tui/session_controls.go:42 | 46d495c/7044d36/6664f71: zeroruntime.CompletionRequest now carries ReasoningEffort, threaded from agent.Options through every request the loop builds and mapped per provider (OpenAI reasoning_effort, Anthropic extended thinking with thinking-block replay, Gemini thinkingBudget). Covered by provider tests |
| medium | fixed | internal/secrets/scanner.go:81 | [reverified+fixed 2026-06-13, fix/audit-batch] Redact now replaces longest matches first (copies findings, sorts by len(Match) desc) so a containing PRIVATE KEY block is redacted before any nested shorter match corrupts its exact string. Covered by scanner_test.go TestRedactNestedSecretStillRemovesWholeBlock (fails pre-fix: leaks BEGIN/END header) |
| medium | fixed | internal/redaction/redaction.go:362 | [reverified 2026-06-13] already fixed on main: urlWithCredsPattern is a package-level var (redaction.go:367) and redactURLPasswords (:369) uses it via ReplaceAllStringFunc — no per-call regexp.MustCompile remains |
| medium | open | internal/zerogit/zerogit.go:212 | zerogit.go:223 `path := strings.TrimSpace(line[3:])` — no C-quote unescaping and no `old -> new` rename split; no -z porcelain used |
| medium | fixed | internal/worktrees/worktrees.go:247 | [reverified+fixed 2026-06-13, fix/audit-batch] defaultRunGit now captures stdout/stderr into separate bytes.Buffers and returns CommandResult{Stdout, Stderr}; git warnings no longer pollute parsed rev-parse output and error messages get real stderr. Covered by worktrees_test.go TestDefaultRunGitSeparatesStdoutAndStderr |
| medium | open | internal/sessions/store.go:632 | store.go:632-635 writeMetadata still os.WriteFile temp + os.Rename with no file/dir Sync; zero Sync() calls anywhere in internal/sessions; appendEventLocked (store.go:512-517) also unsynced |
| medium | open | internal/tui/session_controls.go:235 | session_controls.go:244 returns lastCheckpoint-1 while the tool_call is persisted immediately before its checkpoint (model.go:1505 then 1527), so truncate (rewind.go:205 keeps seq<=target) retains the dangling tool_call |
| medium | open | internal/sessions/store.go:387 | store.go:387-391 Fork re-appends every parent event (no provider_usage skip) via AppendEvent which stamps a fresh CreatedAt (store.go:495-501); BuildReport has no event-id dedupe |
| medium | open | internal/usage/report.go:17 | report.go:17-21 usageEventPayload has only token fields and report.go:102 prices from session Metadata.ModelID, while exec.go:510-512 still persists payload["model"] under --allow-escalation — never read |
| medium | open | internal/cli/exec_sessions.go:115 | exec_sessions.go:117-121 append still early-returns once recorder.err is set, and grep shows sessionRecorder.err is never read in exec.go (no surfacing at run end) |
| medium | open | internal/tui/session_controls.go:117 | m.responseStyle (session_controls.go:117) is only echoed in /style and /config text (command_views.go:155); grep shows it never reaches agent.Options/system prompt (runAgentWithOptions model.go:1366-1397 sets no style) |
| medium | open | internal/cron/schedule.go:171 | Probe at HEAD: Next("30 1 ** *", 2026-11-01 01:30 EDT) = 01:30 EST same day (repeated hour); schedule.go:171-201 guards only spring-forward gaps |
| medium | open | internal/cli/cron.go:112 | cron.go:112-114 sets expr=r.Expr while expr is still ""; cron.go:123 only uses positional[0] if expr=="" — user's expr silently ignored, no warning |
| medium | open | internal/background/process_posix.go:50 | process_posix.go:29/50 signal only pid (no kill(-pid)); launchBackgroundProcess (internal/specialist/exec.go:642) sets no Setpgid; Windows still tree-kills via taskkill /T (process_windows.go:12) |
| medium | open | internal/background/manager.go:462 | manager.go:462-471 normalizeLoadedTask rewrites Status==running to error/PID=0/ExitCode=-1; loadTasks persists it to disk at manager.go:419 |
| medium | open | internal/tui/session_controls.go:117 | session_controls.go:117 still only stores m.responseStyle; sole readers are display strings (command_views.go:155, session_controls.go:120/132) — runAgentWithOptions (model.go:1366+) never threads it into agent.Options or the system prompt, so /style still changes nothing about responses |
| medium | open | internal/tools/registry.go:224 | registry.go:190-236 Core* sets still omit escalate_model; only exec --allow-escalation registers it (cli/exec.go:157, predates audit), TUI path (cli/app.go:449) never does |
| medium | open | internal/sandbox/safe_command.go:138 | file unchanged since #109; splitShellSegments (safe_command.go:431) is quote-unaware — probe: `git commit -m "use top \| less"` flagged as 'less', `echo "a; vim b"` flagged as 'vim' |
| medium | open | internal/providers/gemini/types.go:88 | gemini/types.go:88-93 usageMetadata still has only Prompt/Candidates/Thoughts/Total token counts (no cachedContentTokenCount); grep shows CachedInputTokens is set only in openai/provider.go:253 and anthropic/provider.go:274, never in gemini |
| medium | open | internal/providers/openai/tool_state.go:95 | tool_state.go:95-100 closeOpen still emits StreamEventToolCallDropped for calls with empty id (and applyDelta:58 never streams them); zeroruntime/helpers.go:141-207 empty-ID/synthetic-key collector support remains unreachable from the adapter |
| medium | open | internal/specialist/exec.go:150 | exec.go:150 and :195 still hardcode "--auto","high"; cli/exec_tools.go:84-85 maps high -> agent.PermissionModeUnsafe |
| medium | open | internal/specialist/accounting.go:72 | accounting.go:31/80 check (ReadEvents) then :47/91 append under separate store locks; concurrent callers remain at exec.go:336 (onExit) and output_tool.go:150 (TaskOutput); no serializing mutex in package |
| medium | open | internal/specialist/streamer.go:43 | streamer.go:43 still caps scanner at 1024*1024; ErrTooLong fails ParseStream -> runChildProcess err (exec.go:624-627) aborts whole run; tool_call Args still emitted untruncated (cli/exec_writer.go:99) |
| medium | open | internal/agent/loop.go:468 | loop.go:525-528 AlwaysAllow still returns deniedPermissionResult on persistPermissionGrant error; loop.go:778-779 still errors unconditionally when options.Sandbox == nil |
| medium | partial | internal/agent/compaction.go:58 | [reverified+fixed 2026-06-13, fix/audit-batch] estimateTokens now adds len(message.Images)*imageTokenEstimate (flat per-image cost, ~1k; raw byte size is a poor proxy for vision tiles) — Images are no longer uncounted. Covered by compaction_test.go TestEstimateTokensCountsImages. REMAINING: maybeCompact still has no tool-definition term in its estimate |
| medium | open | internal/agent/compaction.go:376 | compaction.go:376 summarizeMessagesOnce uses zeroruntime.CollectStream with no OnUsage; comment at compaction.go:314-315 documents OnText/OnUsage are intentionally not forwarded |
| medium | open | internal/config/resolver.go:95 | ResolvedConfig.MCP set at resolver.go:102 has no readers (grep: zero non-test uses); MCP registration uses ResolveMCP (resolver.go:109-124) which ignores options.ProviderCommand, so ZERO_PROVIDER_COMMAND MCP servers are still dropped |
| medium | open | internal/hooks/hooks.go:503 | internal/hooks/hooks.go:503 — append still calls store.ReadEvents() (full file read + JSON parse) on every append just to compute the next Sequence |
| medium | open | internal/cli/app.go:149 | app.go:177-190: comment says trailing non-flag args "still are" ignored; rest scanned only for --add-dir then dropped before runInteractiveTUI |
| medium | open | internal/cli/exec.go:500 | exec.go:524-532: interrupted branch emits errorEvent/runEnd only for execOutputStreamJSON; json path prints "Interrupted." to stderr, no terminal JSON event |
| medium | open | internal/cli/exec.go:182 | exec.go:186-193: only stream-json is special-cased; -o json falls to writeExecToolList plain text (exec_tools.go:100-110 "Tools visible to model:") |
| medium | partial | internal/tui/transcript.go:113 | Full-slice copy removed — in-place append, unkeyed rows skip the scan (transcript.go:92-101) — but hasTranscriptRow still linearly scans all rows per keyed tool/permission/ask append (transcript.go:103-114), so tool-heavy rehydration stays O(n²) |
| medium | partial | internal/tui/transcript.go:113 | O(n) full-slice copy removed (in-place append, transcript.go:96-100), but hasTranscriptRow (transcript.go:103-114) still linearly scans all rows per keyed (tool/permission) row and m.transcript is never pruned, so tool-heavy rehydration remains O(n^2) |
| medium | partial | internal/tui/model.go:569 | transcript half fixed: interim text appended as non-final assistant row on tool call/error/cancel (model.go:710-715, 681-683, 1340-1342); but session log still records only FinalAnswer (model.go:1638-1644) — interim segments never persisted, so /resume loses them |
| medium | partial | internal/tui/transcript.go:117 | re-render gone (View renders only unflushed tail, model.go:769; render_cache.go memoizes) and copy-on-append removed (transcript.go:96-100); but hasTranscriptRow still scans the whole transcript per keyed append (transcript.go:103-114) — O(n²) cumulative for tool rows |
| medium | partial | internal/tui/transcript.go:113 | Copy fixed: transcript.go:96-100 appends in place; but hasTranscriptRow (transcript.go:103-114) still linear-scans all rows per keyed (tool/permission/ask) row, so tool-heavy /resume rehydration remains O(N²) |
| medium | partial | internal/tui/transcript.go:117 | transcript.go:100 full-slice copy removed (in-place append; non-keyed rows now O(1) via empty-key fast path), but hasTranscriptRow transcript.go:108-113 still linear-scans all rows per tool/permission/ask append — tool-heavy appends/rehydration remain quadratic |
| medium | partial | internal/tui/rendering.go:261 | rendering.go:286-303 now preserves explicit newlines and each line's leading indentation (code blocks survive), but rendering.go:305 still runs strings.Fields(body) per line, collapsing intra-line space runs so mid-line column alignment is still lost |
## Already fixed on `main` (reclassify to fixed) (9)
| Sev | Location (current) | Rationale |
|---|---|---|
| medium | `internal/cli/cron.go:106-128` | The original defect (recipe set `expr=r.Expr` before the positional was consumed, silently ignoring a user-supplied expr) is gone: cronAdd now consumes the positional expr first at cron.go:113-114 (`if len(positional)==1 && expr=="" { expr=positional[0] }`), and the recipe only fills expr if still empty (cron.go:122-123); extra positionals error. Confirmed by TestCronAddExplicitExprOverridesRecipe (cron_test.go:95-118). |
| low | `internal/cli/cron_run.go:198-205` | The original gap was that cronTruncate byte-sliced `s[:max]+"…"` and could split a multi-byte rune; current cronTruncate (cron_run.go:204) now returns `cutRuneBoundary(s, max) + "…"`, and cutRuneBoundary (exec_writer.go:420-431) backs up to a utf8.RuneStart boundary, so the rune-split defect is gone. promptExcerpt (cron.go:278) was already rune-safe. |
| low | `internal/cli/cron_run.go:198-205` | Duplicate of the same underlying issue: the cited byte-slice `return s[:max] + "…"` for run errors (used at cron_run.go:151) no longer exists; cronTruncate now calls cutRuneBoundary(s, max) which lands on a UTF-8 rune boundary, so persisted run-error text can no longer be split mid-rune. |
| low | `internal/cli/cron_run.go:198-205` | Duplicate of the same underlying issue: cronTruncate no longer byte-slices `s[:max]`; it uses the rune-safe cutRuneBoundary helper (cron_run.go:204), with a comment explaining the cut lands on a UTF-8 rune boundary, closing the run-record invalid-UTF-8 gap. |
| low | `internal/redaction/redaction.go:217-231 (pointer), :232-252 (map)` | The pointer case now does context.seen[ptr]=struct{}{} (line 225) then delete(context.seen, ptr) after redactReflect returns (line 230); the map case mirrors this (line 240 add, line 251 delete). Comments at lines 226-228 explicitly state the pointer is dropped after recursion so a shared non-cyclic reference reached via a sibling branch is not mistaken for a cycle, exactly the originally-described defect, which is gone. |
| low | `internal/sandbox/grants.go:392-406` | readState now rebuilds a new `normalized` map keyed by `key := strings.TrimSpace(name)` (grants.go:394, stored at :403) instead of retaining the raw padded file key, and Lookup indexes with `strings.TrimSpace(toolName)` (grants.go:172), so a whitespace-padded file key both validates AND matches. The ledger's premise ('unchanged since #77') is stale — this normalization was added in commit 3f3de1a (#170). |
| low | `internal/tui/session_controls.go:196-241` | handleCompactCommand no longer just increments compactRequests: when sessionCompactor or hasSessionBackedCompactor() is available it sets compactInFlight and returns tea.Batch(m.runCompact(), ...), and runCompact()->compactActiveSession() actually calls sessionStore.PlanCompaction/RecordCompaction (replay.go:145/181). The 'pending integration' status string is gone (only a negative-assertion test references it), and sessionStore is always non-nil (model.go:272-274), so real compaction is triggered. |
| low | `internal/zerocommands/contracts.go:169` | ProviderSnapshotFromProfile now sets APIKeySet via `strings.TrimSpace(profile.APIKey) != "" \|\| strings.TrimSpace(profile.AuthHeaderValue) != ""` (line 169) with a comment (lines 166-168) explaining auth-header-only profiles must not render as 'not set'; config/types.go:51 confirms AuthHeaderValue is a valid sole credential, and command_center.go:317/334 render apiKeyState(provider.APIKeySet) off this fixed boolean. |
| low | `internal/zerogit/zerogit.go:209` | The remaining gap in the partial finding (ValidateMessage still byte-based `len(firstLine) > 72`) is gone: line 209 now uses `utf8.RuneCountInString(firstLine) > 72` with an explanatory comment, and truncateSubject (line 466) is already rune-based, so valid non-ASCII subjects are no longer rejected. |
## Genuinely partial (a real gap remains) (12)
| Sev | Location (current) | Rationale |
|---|---|---|
| high | `internal/cli/cron_run.go:176-188 (re-read fix) + internal/cron/store.go:170-178 (Update, no lock)` | fireJob does re-read via store.Get and preserves an external pause (current.Status == StatusPaused at cron_run.go:186-187) and handles ErrJobNotFound/transient errors, closing the pause-clobber; but cron/store.go Update still just calls writeJob (temp+rename) with no flock/CAS anywhere in the package, so the concurrent-scheduler read-modify-write race remains exactly as the ledger noted. |
| high | `internal/hooks/dispatch.go:112 (Dispatch); wired at internal/agent/loop.go:584,620 and constructed at internal/cli/hook_dispatch.go:51` | The primary gap is closed: the Dispatcher now has production callers and IS wired into the tool lifecycle - constructed in app.go/exec.go/exec_spec.go, assigned to agent Options.Hooks, and invoked at loop.go:584 (beforeTool, with veto via blockedByHookResult) and loop.go:620 (afterTool feedback). But the original finding's CLI-surface gap remains: runHooks in internal/cli/extensions.go:88-135 still supports only `list`/help with no dispatch/edit subcommand. |
| medium | `internal/agent/compaction.go:280 (maybeCompact) / 83-96 (estimateTokens)` | The image-counting fix is present (estimateTokens line 92 adds len(message.Images)*imageTokenEstimate), but the remaining gap stands: maybeCompact's trigger uses estimateTokens(messages), which has no tool-definition term, and is called at loop.go:85 with no tools (the exposed tool defs are built afterward at loop.go:87). The tool-definition estimator estimateToolTokens exists only in the separate MeasureContext reporting path (context_measurement.go:53), not in the compaction trigger. |
| medium | `internal/tui/rendering.go:292-336 (gap at :311)` | wrapPlainText now preserves explicit newlines (split on \n, :297) and each line's leading indentation (TrimLeft + reconstructed indent, :302-305), so code blocks survive. But the word loop at :311 still iterates strings.Fields(body), which collapses intra-line space runs, so mid-line column alignment within a line is still lost exactly as the finding describes. |
| medium | `internal/tui/transcript.go:103-114` | The full-slice copy is genuinely gone (in-place append at transcript.go:100 with a comment about the old O(n²)), but hasTranscriptRow (transcript.go:108-112) still linear-scans every existing row computing transcriptRowKey per keyed tool/permission/ask append, and m.transcript is never pruned, so rehydration via appendTranscriptRow (session.go:145-146) remains O(n²). |
| medium | `internal/tui/transcript.go:103-114` | Duplicate of the same finding: copy-on-append removed (transcript.go:96-100) but hasTranscriptRow (transcript.go:108-112) still linearly scans all rows per keyed row and the transcript is never bounded, so tool-heavy rehydration stays O(n²). |
| medium | `internal/tui/transcript.go:103-114` | Duplicate of the transcript.go:113 finding: append is in-place (transcript.go:100), but hasTranscriptRow (transcript.go:108-112) still linear-scans all rows per keyed (tool/permission/ask) row, so tool-heavy /resume rehydration remains O(n²). |
| medium | `internal/tui/transcript.go:103-114` | View re-render and copy-on-append are fixed (in-place append at transcript.go:100, empty-key fast path at transcript.go:105-107), but hasTranscriptRow (transcript.go:108-112) still scans the whole transcript per keyed append — O(n²) cumulative for tool rows during rehydration. |
| medium | `internal/tui/transcript.go:103-114` | Duplicate of the transcript.go:117 finding: the full-slice copy is removed and non-keyed rows are O(1) via the empty-key fast path (transcript.go:105-107), but hasTranscriptRow (transcript.go:108-112) still linear-scans all rows per tool/permission/ask append, so those appends/rehydration remain quadratic. |
| low | `internal/agent/loop.go:553-580, 611-616` | The main "requestEvent never read after the switch" claim is now fixed (requestEvent is passed to emitDeniedPermission/deniedPermissionResult on the denial paths at 571-572,577-578), but requestEvent.GrantMatched/Grant set at 574-575 in the AlwaysAllow success case remain dead writes (the post-run event at 611-616 is rebuilt fresh) and the fallbackPermissionEvent at 555 is still unreachable because shouldRequestPermission only fires for PermissionPrompt tools, for which buildPermissionEvent never returns ok=false (false only at 1071/1076 for non-Prompt/Allow). |
| low | `internal/tui/commands.go:297,306; internal/tui/command_center.go:43-55; internal/agent/loop.go:105-106; internal/tui/commands.go:254` | The main fix landed: footer builders, theme tokens (selRow/statusOk/statusErr/line2/panel2), and actionAppendToolCall/Result are all gone (zero grep hits). But the cited gaps remain: listCommandNames (commands.go:297) and formatCommandHelpLines (commands.go:306) are referenced only by test files; resumeText's args!="" branch (command_center.go:45-55) is still unreachable since its sole non-test caller session.go:122 passes ""; OnContext (agent/loop.go:105-106) is emitted but has no production assignment; and the stale footer comment persists at commands.go:254. |
| low | `internal/tui/commands.go:297,306; internal/tui/command_center.go:43-55; internal/agent/loop.go:105-106; internal/tui/commands.go:254` | Duplicate of the other rendering.go:62 row; same underlying code. Footer builders and the theme tokens are fully removed, but formatCommandHelpLines/listCommandNames (commands.go:297-308) remain production-defined yet referenced only by tests, and a stale 'footer advertises' comment persists at commands.go:254 (the autocomplete.go:64 stale comment cited in the older row is now gone, but commands.go:254 still has one). |
## Genuinely still open (66)
| Sev | Location (current) | Rationale |
|---|---|---|
| medium | `internal/agent/compaction.go:422 (summarizeMessagesOnce)` | summarizeMessagesOnce still calls plain zeroruntime.CollectStream (compaction.go:422), which is CollectStreamWithOptions with empty CollectOptions{} (helpers.go:78), so no OnUsage callback fires; the collected Usage is discarded and the deliberate non-forwarding is now documented at summarizeClosure (compaction.go:359-361). Compaction-call token usage is still not surfaced/tracked. |
| medium | `internal/agent/loop.go:567-573, 928-931` | The AlwaysAllow case still calls deniedPermissionResult/emitDeniedPermission when persistPermissionGrant errors (loop.go:569-573), and persistPermissionGrant (now at 928-931) still errors unconditionally when options.Sandbox == nil, so choosing AlwaysAllow without a configured sandbox denies the already-approved call; both sub-claims persist (only relocated from the stale 778-779 reference). |
| medium | `internal/background/manager.go:462-471 (and persistTaskLocked call at :419)` | normalizeLoadedTask still unconditionally rewrites task.Status==StatusRunning to StatusError, sets PID=0 and ExitCode=-1, and marks changed=true; loadTasks then persists this to disk via persistTaskLocked at line 419, with no process-liveness check or reconciliation. The defect is present essentially as the auditor described. |
| medium | `internal/background/process_posix.go:29,50` | SIGTERM (line 29) and Kill() (line 50) still target only the single pid with no kill(-pid)/negative-pid group signal, and launchBackgroundProcess (specialist/exec.go:650) sets no SysProcAttr{Setpgid:true} (grep finds zero process-group setup), while Windows still tree-kills via taskkill /T /F (process_windows.go:12). All three claims hold. |
| medium | `internal/cli/app.go:200-220` | In the `--skip-permissions-unsafe` case, the code re-splits args[1:] via splitLeadingAddDirFlags and only iterates `rest` to reject a misplaced --add-dir (lines 215-219); it then calls runInteractiveTUI with merged addDirs only, never forwarding `rest`, so any trailing non-flag args are still silently dropped. The in-code comment at lines 208-209 explicitly confirms this ('were ignored on this path... and still are'), matching the original finding verbatim. |
| medium | `internal/cli/cron_run.go:103-128` | reconcileOverdue still reschedules any strictly-overdue active job (NextRunAt before nowMin) to sched.Next without firing it (lines 116-126); grep confirms no RunNow/RunOnce/FireNow sentinel exists in internal/cron, so the only way to fire backlog is the --catch-up flag that skips reconcileOverdue entirely. |
| medium | `internal/cli/exec.go:191-199` | The --list-tools branch special-cases only execOutputStreamJSON (exec.go:192-194); -o json (execOutputJSON) falls through to writeExecToolList, which calls formatExecToolList and prints the plain-text "Tools visible to model:" listing (exec_tools.go:100-110) rather than any JSON output. |
| medium | `internal/cli/exec.go:531-545` | The interrupted branch still emits writer.errorEvent/runEnd only when outputFormat == execOutputStreamJSON (exec.go:535-540); for -o json it just does fmt.Fprintln(stderr, "Interrupted.") at exec.go:542 and returns exitInterrupted, producing no terminal JSON event on stdout (unlike the provider-error path which writes type:error/type:done for json). |
| medium | `internal/cli/exec_sessions.go:116-124` | execSessionRecorder.append still early-returns when recorder.err != nil (line 117) and latches AppendEvent failures into recorder.err (line 120); a package-wide grep shows recorder.err/sessionRecorder.err is read only inside append itself, never by exec.go or exec_spec.go (which check only writer.err), so persistence errors are silently swallowed at run end. |
| medium | `internal/cli/exec_sessions.go:116-124` | Duplicate of the same recorder.err defect: append (lines 116-124) early-returns once recorder.err is set and stores AppendEvent errors there, but sessionRecorder.err is never read in exec.go or exec_spec.go, so a failed session append is never surfaced to the user at run end. |
| medium | `internal/config/resolver.go:102, 110-124` | ResolvedConfig.MCP is still set at resolver.go:102 but has zero readers: every `.MCP` access in internal/cli is the config.MCPConfig type or the separate resolveMCPConfig/ResolveMCP path, and no resolveConfig caller reads resolved.MCP. MCP registration runs through ResolveMCP (resolver.go:110-124), which only merges config-file paths and Overrides and never calls LoadProviderCommand/touches options.ProviderCommand (asserted intentional by TestResolveMCPDoesNotRunProviderCommand), so ZERO_PROVIDER_COMMAND MCP servers are still dropped. |
| medium | `internal/cron/schedule.go:171-201` | Empirically confirmed: on the 2026-11-01 US fall-back day Next("30 1 ** *") returns 01:30 EDT and then 01:30 EST again, firing the daily job twice during the repeated wall-clock hour. The Next loop and its forward-progress guard (schedule.go:182-198) only handle spring-forward non-existent instants and contain no de-duplication for the ambiguous repeated fall-back hour. |
| medium | `internal/hooks/hooks.go:503` | AuditStore.append still calls store.ReadEvents() at line 503, which os.ReadFile's the entire JSONL audit file and json.Unmarshal's every line on every single append, solely to scan for the highest existing Sequence (lines 507-513); no cached/in-memory sequence counter was introduced, so the O(n) full re-read per append is unchanged. |
| medium | `internal/mcp/client.go:246` | client.writer.write(...) at line 246 still executes while client.mu is held (locked at :228, unlocked at :255), and writer.write (protocol.go:148-163) does a blocking Write+Flush on the stdin pipe with no ctx awareness; only the response wait at :257 is ctx-aware, unchanged from the audit. |
| medium | `internal/mcp/client.go:295-318` | readLoop still dispatches any id-bearing message to pending[id] (lines 302-316) with no check of message.Method; grep finds no 'ping' or Method handling anywhere in client.go, so server-initiated requests are still ignored/misrouted exactly as described. |
| medium | `internal/mcp/client.go:98-99` | Still 'var stderr bytes.Buffer; cmd.Stderr = &stderr' — a plain unbounded buffer (no LimitReader/cap), never reset after startup, attached for the whole process lifetime and only read on initialize failure at line 114. |
| medium | `internal/mcp/network_client.go:612-642 (decodeSSERPCMessage), caller at :219` | decodeSSERPCMessage still decodes the first non-empty 'message' SSE event, sets found=true and returns false (stops scanning) without checking the message ID; the synchronous caller networkClient.request at line 219 does `if !rpcIDMatches(message.ID, id)` and errors on mismatch instead of scanning for the matching response. Defect unchanged (caller line is now :219, was the stale :210). |
| medium | `internal/mcp/network_client.go:646 (scanSSEEvents), failPending at :587-593, request gate at :243` | scanSSEEvents still calls scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) with the 1MiB cap; a bufio.Scanner ErrTooLong flows through readStream into failPending (:593) which sets a permanent client.streamErr, and request() returns that streamErr forever (:243-246) with no reconnect path (openStream is only called once at connect, :77). |
| medium | `internal/providers/gemini/types.go:103-108` | The usageMetadata struct still declares only PromptTokenCount, CandidatesTokenCount, ThoughtsTokenCount, and TotalTokenCount with no cachedContentTokenCount field; provider.go:225-228 reads only those into input/output/reasoning tokens and emitDone (provider.go:282-286) builds TokenUsage without CachedInputTokens, which is set only in openai/provider.go:253 and anthropic/provider.go:330. |
| medium | `internal/providers/openai/tool_state.go:95-101 (and applyDelta:58)` | applyDelta still returns early when call.id=="" (line 58), and closeOpen still only emits StreamEventToolCallDropped for empty-id/empty-name calls (lines 95-99) instead of streaming them with a synthetic key; every Start/Delta/End the adapter emits carries call.id, so the zeroruntime collector's empty-ID/synthetic-key support (helpers.go start/delta/end at ~173-216) is never reached from the OpenAI adapter. The provider_test still asserts such calls are dropped and never started, confirming the behavior is unchanged, and tool_state.go's last commit (4d2ce6b) predates the 2026-06-10 audit. |
| medium | `internal/sandbox/safe_command.go:431` | splitShellSegments still uses a strings.NewReplacer that blindly turns \|, ;, &&, \|\|, $(, ), backtick into segment boundaries with zero quote awareness; a live probe confirms `git commit -m "use top \| less"` is flagged as 'less' (interactive=true) and `echo "a; vim b"` is flagged as 'vim', exactly as the original finding described. The file is unchanged since commit #109 and contains no quote-state parsing. |
| medium | `internal/sessions/lineage.go:140` | tree() (lineage.go:128-154) still calls store.ListChildren(sessionID) at lineage.go:140 once per node, and ListChildren calls store.List() at lineage.go:74; List() (store.go:312-338) re-reads the whole sessions dir via os.ReadDir+Get->readMetadata (store.go:674-684, a fresh os.ReadFile+json.Unmarshal with no cache), so an N-node tree re-reads every metadata.json N times. The defect is present exactly as described. |
| medium | `internal/sessions/store.go:428-432 (Fork copy loop), 526-567 (appendEventLocked); internal/usage/report.go:74-119 (BuildReport)` | Fork's loop at store.go:428-431 re-appends every parent event (no provider_usage/EventUsage skip) via AppendEvent, which stamps a fresh CreatedAt/ID in appendEventLocked (537-543), and BuildReport (report.go:74-119) aggregates all EventUsage events with no event-id dedupe, so forked usage events are double-counted. |
| medium | `internal/sessions/store.go:686-701 (writeMetadata), 549-559 (appendEventLocked write path)` | writeMetadata still does os.WriteFile to a temp file + os.Rename (store.go:693-696) with no file or directory fsync, and a repo-wide grep finds zero Sync() calls anywhere in internal/sessions/; appendEventLocked's append write (549-557) is likewise unsynced. |
| medium | `internal/specialist/accounting.go:80-91 (appendSpecialistUsageRollup) and :31-47 (recordSpecialistStop)` | The dedup is still a non-atomic check-then-append: specialistEventExists calls store.ReadEvents (which acquires NO lock, store.go:569) and only the later AppendEvent takes the per-session lock for its own write, so concurrent callers (exec.go:343 onExit and output_tool.go:150 TaskOutput) can both pass the existence check and each append a duplicate event. No serializing mutex spans the check and append in the specialist package, and the store's lockSession/appendEventLocked machinery is not used by the accounting path. |
| medium | `internal/specialist/exec.go:150 (and :202)` | Both BuildArgs (:150) and BuildResumeArgs (:202) still hardcode args append "--auto","high", and internal/cli/exec_tools.go:84-85 maps "high" -> agent.PermissionModeUnsafe, so specialist children always run in unsafe permission mode. |
| medium | `internal/specialist/streamer.go:43` | ParseStream still calls scanner.Buffer(make([]byte,0,64*1024), 1024*1024), so a line over 1MB yields bufio.ErrTooLong; exec.go:631-634 returns that error from runChildProcess aborting the whole run, and the producer at cli/exec_writer.go:99 (parseToolCallArgs) still emits tool_call Args with no length cap. All three elements of the original finding are unchanged. |
| medium | `internal/tools/registry.go:198-245` | The Core* tool sets (CoreReadOnlyTools/CoreWriteTools/CoreShellTools at :224/CoreNetworkTools/CoreTools) still omit escalate_model; it is registered only under exec --allow-escalation (cli/exec.go:156-157), while the TUI registry built by newCoreRegistryScoped (cli/app.go:436,531-537) never registers it — app.go only mentions escalate_model in help text at :733. |
| medium | `internal/tui/model.go:2022-2028 (session log) vs model.go:854/802/1721 (transcript)` | Interim narration is appended to the live transcript on tool call (model.go:852-857), error (801-803), and cancel (1720-1722), but the session log only persists result.FinalAnswer via EventMessage (model.go:2022-2028); onText (1781-1786) and sendAgentText (2064-2069) record no session event, so the gap the ledger flagged as 'partial' is unchanged — interim segments are still never persisted and /resume loses them. |
| medium | `internal/tui/session_controls.go:148-177; internal/tui/model.go:1746-1778` | m.responseStyle is still only stored by handleStyleCommand (session_controls.go:156) and echoed in display strings (session_controls.go:159/171, command_views.go:275); runAgentWithOptions threads ReasoningEffort/SystemPrompt/PermissionMode into options but never references responseStyle, and agent.Options has no style field (no non-display ResponseStyle reader exists), so /style still changes nothing about responses. |
| medium | `internal/tui/session_controls.go:148-177; internal/tui/model.go:1746-1778` | Duplicate of the responseStyle finding: m.responseStyle is set at session_controls.go:156 but runAgentWithOptions (model.go:1746-1778) never assigns it into agent.Options or the system prompt; the only readers are display strings (command_views.go:275, session_controls.go:159/171), so the style preference is inert. |
| medium | `internal/tui/session_controls.go:148-177; internal/tui/model.go:1746-1778` | Duplicate of the responseStyle finding verified once: handleStyleCommand only stores m.responseStyle and grep shows no non-display consumer; runAgentWithOptions sets ReasoningEffort/SystemPrompt/etc. on agent.Options but never the style, so /style remains cosmetic. |
| medium | `internal/tui/session_controls.go:318 (resolveRewindTarget); internal/sessions/rewind.go:205` | resolveRewindTarget still returns lastCheckpoint-1, while OnToolCall appends EventToolCall then the EventSessionCheckpoint right after it (model.go:1886-1911) so the tool_call gets the immediately-preceding sequence; TruncateEvents keeps ev.Sequence<=target (rewind.go:205), so the tool_call (seq==target) is retained while its checkpoint (seq>target) is dropped, leaving the dangling tool_call described in the finding. |
| medium | `internal/usage/report.go:17-21 (struct) and :102 (pricing); writer at internal/cli/exec.go:521-524` | usageEventPayload (report.go:17-21) still has only PromptTokens/CompletionTokens/TotalTokens with no model field, and BuildReport at report.go:102 still prices via modelBySession[event.SessionID] from session Metadata.ModelID; meanwhile exec.go:521-522 still persists payload["model"]=currentModel under --allow-escalation, and a repo-wide grep confirms no consumer reads that per-event model key (the only usageEventPayload reader silently drops it on unmarshal). |
| medium | `internal/zerogit/zerogit.go:225` | parseStatus still does `path := strings.TrimSpace(line[3:])` with no C-quote unescaping and no `old -> new` rename split, and the caller at line 119 runs `status --short --untracked-files=all` without `-z`/porcelain, so quoted or renamed paths are still mis-parsed exactly as described. |
| low | `internal/agent/loop.go:1305-1316` | propertyToRuntimeMap still assigns schema["items"] = propertyToRuntimeMap(*property.Items) twice for the same property.Items != nil guard (lines 1305-1307 and 1314-1316); the second assignment is a redundant duplicate writing an identical value. |
| low | `internal/agent/loop.go:189-191` | The reactive-retry CollectStreamWithOptions call still passes only OnUsage and omits OnText (comment at 183-188 documents this as deliberate anti-duplication), so streaming surfaces still receive only the aborted partial text on the retried turn exactly as the finding describes. |
| low | `internal/agent/loop.go:194-201` | When ctx is canceled, internal/zeroruntime/helpers.go:89 sets collected.Error = ctx.Err().Error(); loop.go:194-197 returns errors.New(collected.Error) before the ctx.Err() identity return at 198-201, so the returned error no longer wraps the sentinel and errors.Is(err, context.Canceled) still fails on that path. |
| low | `internal/agent/types.go:196 (field); internal/agent/loop.go:105-106 (emit site); internal/agent/context_measurement.go:31 (MeasureContext/ContextBreakdown)` | The OnContext callback field (types.go:196) and its emit site (loop.go:105-106 calling MeasureContext) still exist, but a repo-wide grep finds ZERO 'OnContext:' assignments anywhere (test or prod) and no use of MeasureContext/ContextBreakdown outside internal/agent tests; cli/tui/cmd and the contextreport package never reference them (contextreport only calls agent.BuildSystemPromptPreview), so the callback remains permanently a no-op exactly as the original finding described. |
| low | `internal/background/process_posix.go:38-50` | terminateProcess still does os.FindProcess(pid) (line 24) and polls liveness via signal-0 in processAlive (lines 39-47, 59-61), while launchBackgroundProcess (specialist/exec.go:659-673) still reaps the child in a concurrent command.Wait() goroutine, so the PID can be reaped and recycled before process.Kill() at line 50 fires. Defect unchanged. |
| low | `internal/cli/cron_run.go:146-152` | rec.Error is still set only from errBuf (cronTruncate(strings.TrimSpace(errBuf.String()),500) when code!=0); outBuf is declared and passed to exec at line 146-147 but never read anywhere in fireJob, so stream-json error events on stdout are still discarded. |
| low | `internal/cli/exec.go:383-398 (PrepareExec call), exec.go:404 (time.Now), internal/cli/exec_sessions.go:55 (direct NewStore)` | All three sub-claims hold: the sessions.PrepareExec call at exec.go:383-398 omits the Store field so PrepareExec falls back to NewStore(StoreOptions{}) at exec_session.go:58-61; preflightExecSession still does sessions.NewStore(StoreOptions{}) directly at exec_sessions.go:55; and runExec uses streamjson.CreateRunID(time.Now()) at exec.go:404 instead of the injectable deps.now (which is available and used at exec.go:142/186). |
| low | `internal/cli/exec_sessions.go:55` | preflightExecSession still calls sessions.NewStore(sessions.StoreOptions{}) directly at line 55, and exec.go's PrepareExec call (exec.go:383-397) omits the Store field so sessions/exec_session.go:58-60 defaults to NewStore again; deps.newSessionStore is used by spec/usage/observability/sessions paths but never by the exec run path in exec.go. |
| low | `internal/config/contracts.go:5,12,65` | ContractGap (line 5), DefaultContractGaps (line 12), and FindContractGapsByMilestone (line 65) are all still defined, and a repo-wide grep for these symbols (plus ContractOwnerRuntime) finds references ONLY in internal/config/contracts_test.go. No production caller exists, so the dead-code defect persists exactly as described. |
| low | `internal/config/resolver.go:144,177,506` | All three MaxTurns sites (mergeConfig :144, mergeProjectConfig :177, applyOverrides :506) still gate purely on `if src/overrides.MaxTurns > 0` with no error path, and Resolve() has no MaxTurns validation, so a configured 0 or negative maxTurns is silently dropped and falls back to defaultMaxTurns=30 (resolver.go:16,22). A negative-value error path exists for tools.deferThreshold (:57-59) but none was added for MaxTurns. |
| low | `internal/plugins/plugins.go:894` | parseHookEvent's switch (plugins.go:894) still accepts only HookBeforeTool/HookAfterTool/HookSessionStart/HookSessionEnd and the HookEvent consts (plugins.go:39-42) omit specialist events entirely, whereas hooks.go parseEvent (hooks.go:812) accepts those four plus EventSpecialistStart/EventSpecialistStop, so the two validation sets still disagree exactly as described. |
| low | `internal/providercatalog/catalog.go:46` | The `Public bool` field is still declared on Descriptor (catalog.go:46) but is never assigned anywhere in the codebase: there are zero `Public:` struct-literal assignments and zero `.Public =` writes. The only read remains the vacuous guard at catalog_test.go:106 (`if descriptor.Public && !descriptor.RequiresAuth`), which is dead code since Public is always false. |
| low | `internal/providers/factory.go:115` | At the sole call site (line 115) the `\|\|` short-circuits so isImplicitOpenAI is only evaluated when explicitProvider==true, which (per explicitProviderKind at 147-157) requires ProviderKind!="" or Provider!=""; but isImplicitOpenAI (159-164) requires both ProviderKind=="" and Provider=="", so it can never return true there — the term remains dead code exactly as described. |
| low | `internal/redaction/redaction.go:174` | Line 174 still declares `var stackTracer interface{ StackTrace() fmt.Stringer }`; a repo-wide grep finds StackTrace only at redaction.go:174 and :176 (no implementer in the module), and go.mod has no pkg/errors-style dependency, so this errors.As branch is dead code that can never match exactly as the finding described. |
| low | `internal/sessions/store.go:615-617 (timestamp), 331-336 (List), 340-346 (Latest)` | timestamp() at store.go:616 still formats with time.RFC3339 (second precision), and List() at 332-334 breaks UpdatedAt ties lexically by SessionID, so Latest() (line 345 returns sessions[0]) can still pick the wrong session on same-second ties exactly as described. |
| low | `internal/skills/skills.go:91 (Duplicates); consumers: internal/cli/skills.go:62, internal/plugins/activate.go:376` | skills.Duplicates and skills.Get still have zero callers outside internal/skills (Get has zero callers anywhere, even tests), and the duplicate-collision data is never surfaced: cli/skills.go runSkillsList calls skills.List with no warning, and the skill tool discards the dups at activate.go:376 with `merged, _ := MergedSkillsLoaded(...)`. Although plugins.mergeSkills now computes a parallel DuplicateName slice that MergedSkills returns, every call site drops it, so shadowed/dropped skills are still resolved silently with no user-facing warning, exactly the defect described. |
| low | `internal/specialist/exec.go:353-358` | On manager.SetPID failure the code only calls executor.cleanupBackgroundPromptFile and returns the error; it does not call recordSpecialistStop or manager.UpdateStatus(StatusError) while the already-launched child keeps running, unlike the launch-error path at :347-352 which does record a stop. |
| low | `internal/specialist/output_tool.go:244` | formatTaskOutput is still dead code: a codebase-wide grep finds only its own definition at :244 and no callers in prod or tests; the only used formatter is formatTaskOutputSummary, called from readOutput at :154. |
| low | `internal/streamjson/streamjson.go:30` | Repo-wide grep for EventRestore (and the literal "restore" EventType) returns only the declaration at streamjson.go:30 — no emitter and no test reference, whereas the sibling EventCheckpoint is emitted at cli/exec_writer.go:119 and referenced in streamjson_test.go:435. The constant remains dead exactly as the original finding described. |
| low | `internal/tools/read_file.go:98-121` | The truncation path is unchanged from main (empty git diff): when max_lines truncates, it sets Result.Truncated=true (line 120) but the only in-output signal is the '(lines 1-5 of 20)' range header (line 114) — no distinct truncation marker is appended to Output (line 119). The rune-width sub-claim remains non-reproducible because line-number padding uses ASCII digits via strconv.Itoa (lines 105-109), exactly as the original finding concluded. |
| low | `internal/tools/registry.go:19` | Repo-wide grep finds OnSandboxDecision only at registry.go:19 (declaration), :113 (nil check), and :121 (invocation) — zero struct-literal (OnSandboxDecision:) or dot-assignment (.OnSandboxDecision =) sites anywhere in non-test or test code, so the field is still never set by any caller. |
| low | `internal/tui/command_center.go:45-55` | resumeText's args!="" branch (command_center.go:45-55) is still dead: the only production caller, handleResumeCommand at session.go:121-122, passes "" (the non-empty case is handled separately via resolveResumeSession at session.go:125), so the branch remains unreachable; one stale 'footer advertises' comment also persists at commands.go:254 (the autocomplete.go one is gone). |
| low | `internal/tui/command_center.go:17-24` | doctorText() still constructs doctor.Run(doctor.Options{Now, Runtime:"go", Provider}) with no UserConfig/ProjectConfig (command_center.go:18-22), unlike the CLI path at observability.go:64-71; so configFilesCheck (doctor.go:111-123) gets empty paths and emits the StatusWarn 'No explicit Zero config files were inspected.' exactly as the finding describes. |
| low | `internal/tui/commands_test.go:46` | The function commandTestStringSliceContains is still defined at line 46, and a repo-wide grep across all *.go files returns only the definition itself with zero call sites, matching the original dead-helper finding exactly. |
| low | `internal/tui/model.go:1363-1365` | handleSubmit still drops a prompt while m.exiting with a bare `return m, nil` and no feedback row, and no render path surfaces an exiting/flushRunIDs indicator: statusLine (view.go:137-156) shows only provider+usage, and composerLine's hint switch (model.go:1130-1134) only shows 'esc stop' when m.pending. |
| low | `internal/tui/options.go:34,39-40 (UsageTracker/ReasoningEffort/ResponseStyle); internal/cli/app.go:484-516; internal/tui/model.go:277-279` | The exact residual gap the ledger evidence describes is still present verbatim: the production caller's tui.Options{...} block in app.go (484-516) sets none of UsageTracker, ReasoningEffort, or ResponseStyle. The mitigations cited also still hold — newModel self-defaults a usage.Tracker when options.UsageTracker is nil (model.go:277-279) and effort/style remain runtime-settable via /effort (session_controls.go:81) and /style (session_controls.go:156) — so the code matches the partial state described, with the same unset-fields gap unchanged. |
| low | `internal/tui/session_test.go:215` | The local slice `runtimeMessages := []tea.Msg{}` (line 215) is still only appended to in the RuntimeMessageSink closure (line 226) and never read or asserted anywhere in the test body; the test exclusively consumes the channel runtimeMessageCh via receiveRuntimeMessage, leaving the slice write-only exactly as the finding described. |
| low | `internal/verify/verify.go:215` | RunLoop is still defined at verify.go:215 and a repo-wide grep finds only two references: the definition and its sole caller verify_test.go:207. The production retry loop is selfverify.Run (selfverify.go:53), which re-implements the attempt loop calling verify.Run directly (selfverify.go:75) and is the one wired into cli/workflows.go, so RunLoop plus LoopOptions/LoopReport remain dead production code. |
| low | `internal/zerocommands/sandbox_snapshots.go:82-104 (SandboxPlanSnapshot/SandboxDecisionSnapshot); raw serialization at internal/cli/sandbox.go:92-94,146-148` | Grep confirms SandboxPlanSnapshot/Decision/Policy/Backend/Risk/Violation snapshots are referenced only inside internal/zerocommands and its tests (no production consumer; the only new consumers in tui/command_views.go are SandboxGrantSnapshots and ProviderSnapshot, which are different types). `zero sandbox policy --json` in internal/cli/sandbox.go still builds its payload from raw zeroSandbox.Policy/Backend/BackendPlan (lines 92-94 and 146-148) instead of the snapshot constructors, exactly as the original evidence described. |
| low | `internal/zerogit/zerogit.go:419-423` | The fallback EnvRunner adapter at lines 420-422 is still `func(ctx, dir, _ []string, args ...string)` which discards the env slice and calls `runGit(ctx, dir, args...)`, so a caller-supplied Runner with no RunGitEnv still loses stagedSnapshotDiff's GIT_INDEX_FILE isolation. |
| low | `internal/agent/loop.go:194-201 (origin internal/zeroruntime/helpers.go:89)` | helpers.go:89 still sets collected.Error = ctx.Err().Error() (stringifying the cancellation, losing identity), and loop.go:194-196 returns errors.New(collected.Error) BEFORE the identity-preserving `return result, ctx.Err()` at loop.go:198-200, so on the stream-cancellation path the returned error is a plain string error and errors.Is(err, context.Canceled) still fails. |
# Agent Eval Benchmark Harness Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build a local, offline-testable benchmark harness that copies eval fixtures, runs an agent command per task, scores the resulting workspace, and reports aggregate results.
**Architecture:** Keep `internal/agenteval.Runner` as the scoring primitive. Add a `Harness` layer that materializes fixture workspaces into a work directory, initializes a clean Git baseline, invokes an injectable `AgentRunner`, then calls `Runner.Run`. The CLI exposes this as `zero eval bench`, while `zero eval run` remains the lower-level scorer for an already-mutated workspace.
**Tech Stack:** Go 1.24 standard library, existing `internal/agenteval`, existing `internal/cli`, Git CLI for workspace baselines.
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Upgrade the local agent eval harness from a smoke benchmark into a quality signal that can compare models, score traces, catch context mistakes, and preserve regression artifacts.
**Architecture:** Keep the existing `internal/agenteval` harness as the core. Add small schema fields to `Task`, score them through `Score` and `Harness.Run`, then expose model matrix and full benchmark report data through `zero eval bench`. Avoid provider calls or UI work.
**Tech Stack:** Go 1.24, standard library JSON/process/git helpers, existing `go test ./...` validation.
Add tests that load and normalize `tags`, `difficulty`, and `forbiddenChangedFiles`, reject malformed forbidden paths, and fail scoring when a forbidden file is touched.
- [ ]**Step 2: Run tests to verify failure**
Run: `go test ./internal/agenteval -run "LoadSuite|Validate|Forbidden" -count=1`
- [ ]**Step 3: Implement minimal schema and scoring**
Update the sample suite test to require richer coverage count and metadata fields.
- [ ]**Step 2: Run tests to verify failure**
Run: `go test ./internal/agenteval -run SampleSuite -count=1`
- [ ]**Step 3: Expand the fixture suite and docs**
Add more tasks using the existing `zero-mini` fixture, plus examples for `--model`, `{model}`, `requiredTraceEvents`, `contextChecks`, and report artifacts.
- [ ]**Step 4: Verify green**
Run: `go test ./internal/agenteval ./internal/cli -count=1`
### Final Verification
- [ ] Run `gofmt -l internal/agenteval internal/cli`
- [ ] Run `git diff --check`
- [ ] Run `go vet ./...`
- [ ] Run `go test ./...`
- [ ] Run `go run ./cmd/zero eval --suite internal/agenteval/testdata/sample_suite.json`
{Role:zeroruntime.MessageRoleTool,ToolCallID:"t1",Content:"Loaded 1 tool. Full schemas follow; call them on the next turn.\n\n## weather_lookup\nLook up weather.\ninput schema:\n{\n \"type\": \"object\"\n}"},
projectInstructions:="# AGENTS.md instructions for D:\\repo\n\n<INSTRUCTIONS>\nUse `go test ./internal/agent` for agent changes.\nDo not touch TUI code.\n</INSTRUCTIONS>\n\n<environment_context>\nignored runtime context\n</environment_context>"
{Role:zeroruntime.MessageRoleTool,ToolCallID:"t1",Content:"Loaded 1 tool. Full schemas follow; call them on the next turn.\n\n## weather_lookup\nLook up weather.\ninput schema:\n{\n \"type\": \"object\"\n}"},
// richMCPTool mimics a real MCP tool's schema (Exa/Firecrawl-style search): an
// object with several typed, described properties. Real MCP tools are this size
// or larger, which is why shipping their full schemas every turn is expensive.
typerichMCPToolstruct{namestring}
func(trichMCPTool)Name()string{returnt.name}
func(trichMCPTool)Description()string{
return"Search the web and return ranked, structured results with titles, URLs, snippets, and optional cleaned full-page content. Supports natural-language and keyword queries with domain and date filtering."
}
func(trichMCPTool)Parameters()tools.Schema{
returntools.Schema{
Type:"object",
AdditionalProperties:false,
Required:[]string{"query"},
Properties:map[string]tools.PropertySchema{
"query":{Type:"string",Description:"The search query to run against the web index. Supports natural-language and keyword queries."},
"num_results":{Type:"integer",Description:"Maximum number of results to return (1-100). Defaults to 10."},
"include_domains":{Type:"array",Description:"Only return results from these domains.",Items:&tools.PropertySchema{Type:"string"}},
"exclude_domains":{Type:"array",Description:"Never return results from these domains.",Items:&tools.PropertySchema{Type:"string"}},
"start_published_date":{Type:"string",Description:"ISO-8601 date; only results published on or after this date are returned."},
"end_published_date":{Type:"string",Description:"ISO-8601 date; only results published on or before this date are returned."},
"category":{Type:"string",Description:"Restrict results to a content category.",Enum:[]string{"news","research paper","company","tweet","pdf"}},
"include_text":{Type:"boolean",Description:"Include cleaned full-page text for each result (slower, larger payload)."},
" in a while. Update the plan to reflect completed and remaining steps, then continue."
}
functoolOnlyProgressReminder(turnsint)string{
return"Reminder: you've made "+strconv.Itoa(turns)+" "+toolOnlyProgressReminderMarker+
" without visible progress. Before calling more tools, summarize what you already know, state the next concrete step, and finish if you have enough information."
}
// guardState tracks the per-run signals the guardrails need. It is observable
// purely from tool-call names and per-turn output, matching what the loop holds.
typeguardStatestruct{
@@ -125,7 +174,9 @@ type guardState struct {
// staleReminderSent records whether the stale reminder has already fired for
// the current stale interval. It is cleared when a plan update opens a new
// interval, making the reminder one-shot per interval rather than per turn.
staleReminderSentbool
staleReminderSentbool
toolOnlyTurnsint
toolOnlyReminderSentbool
// toolFailures tracks consecutive same-error failures per tool, keyed by tool
// name, so the loop can hint then halt instead of looping forever.
// The actual guardrail answer (any turn count) is recognized.
for_,turns:=range[]int{1,3,12}{
if!IsNoProgressStop(noOutputStopAnswer(turns)){
t.Fatalf("the real no-output stop answer (turns=%d) should be recognized",turns)
}
}
// A legitimate message that merely QUOTES the marker must NOT be classified as
// a no-progress stop — that would wrongly hide a real session from /resume.
rejected:=[]string{
`The previous run ended "with no output (no visible text and no tool calls)" so here is what I did instead.`,
"with no output (no visible text and no tool calls)",// bare marker, no prefix/suffix
"Agent stopped after 3 turns.",// prefix only
"to avoid consuming tokens without making progress.",// suffix only
"Here is your e-commerce site.",// unrelated
"",
// Hostile input: prefix + marker + suffix ALL present, but the marker is
// quoted mid-sentence rather than being the guard's own structured answer.
// A loose prefix/contains/suffix check misclassifies this.
`Agent stopped after 3 turns. The marker is "with no output (no visible text and no tool calls)" so here it is: to avoid consuming tokens without making progress.`,
// Arbitrary text wedged between the marker and the suffix.
"Agent stopped after 3 turns with no output (no visible text and no tool calls) and then some more text to avoid consuming tokens without making progress.",
// Non-integer "turn count" between the prefix and the marker.
"Agent stopped after several turns with no output (no visible text and no tool calls) to avoid consuming tokens without making progress.",
}
for_,content:=rangerejected{
ifIsNoProgressStop(content){
t.Fatalf("content must not be classified as a no-progress stop: %q",content)
{Type:zeroruntime.StreamEventToolCallDelta,ToolCallID:"grant-1",ArgumentsFragment:`{"reason":"Need to write outside the workspace.","permissions":{"file_system":{"write":[`+quoteJSONString(target)+`]}}}`},
returnfmt.Sprintf("Verification is still failing after %d self-correction attempts; stopping auto-correction. Review the remaining failures and decide how to proceed.",maxAttempts)
}
// lspDiagnosticsChecker adapts *lsp.Manager to diagnosticsChecker by reading the
// file's current contents before asking the manager to check it.
typelspDiagnosticsCheckerstruct{
manager*lsp.Manager
}
// NewLSPDiagnosticsChecker wraps an *lsp.Manager for use as a SelfCorrector
// checker. Returns nil when manager is nil so self-correct cleanly skips LSP.
return"## Approved Command Prefixes\n\nThe following command prefixes have already been approved and do not need another permission prompt:\n"+strings.Join(lines,"\n")
}
// specialistDelegationContext nudges the orchestrator to offload read-heavy or
// parallelizable work to a specialist sub-agent via the Task tool, keeping large
// tool outputs out of the main context. It renders only when specialists are
// known (which is only where the Task tool is actually registered), so a run with
// no delegatable specialists produces the previous prompt unchanged.
b.WriteString("Delegate focused or read-heavy work to a specialist sub-agent with the Task tool instead of doing it inline. ")
b.WriteString("When a request matches a specialist's purpose, delegate to it proactively — you do not need the user to ask first. ")
b.WriteString("This keeps large tool outputs — searches, file dumps, multi-step exploration — out of your own context, so you stay fast and token-efficient. ")
b.WriteString("Prefer delegating codebase search and exploration; for independent subtasks, launch several specialists in parallel. Handle small, direct edits yourself.\n")
b.WriteString("Available specialists (call Task with the matching name when the task fits its purpose):\n")
b.WriteString("Use the active provider/model above when answering questions about what is currently running. Persisted config commands may show saved defaults that differ from this live run/session.\n")
b.WriteString("</session>")
returnb.String()
}
// workspaceContext returns an <environment> block describing the working
// directory, git branch, and any project guideline doc, so the model grounds its
// work in the actual repo. Returns "" when cwd is unset (keeps headless/test
Loaded 100 of 784 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.