Commit Graph
35 Commits
Author SHA1 Message Date
Anandan ac6addaa83 Align sandbox permission blocks and backend states (#253)
* Prompt for shell network permissions

Route network-risk shell calls through the existing permission prompt instead of hard-denying them at sandbox preflight. Approved network prompts apply the current turn/session request-permissions overlay so the effective sandbox policy is widened only after user approval.

Move network command detection into the shell AST analyzer for package installers, gh/git network subcommands, and python http.server so quoted process patterns such as pkill -f "python3 -m http.server 8000" are not misclassified as network activity.

Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox -run 'TestAnalyzeCommand|TestClassifyDoesNotFlagQuotedHttpServerPattern|TestEvaluateExemptsNetworkToolsFromDenyByDefault|TestEngineClassifiesNetworkAndDestructiveShellCommands|TestGrantRequestPermissionsNetworkOverlaysPolicyForTurn'

Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/agent -run 'TestRunPersistentCommandPrefixStillPromptsForNetwork|TestRunApprovedNetworkBashPromptAppliesTurnNetworkGrant'

Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/tools -run 'TestBash|TestRegistryRunsBashThroughSandboxEngine'

Tested: GOCACHE=/tmp/zero-go-cache go test ./...

* Auto-allow native-sandboxed shell commands

Remove the old hidden bash auto-allow environment/config path. Ordinary shell commands now use the active native sandbox as the permission boundary by default, while network, destructive, and workspace policy checks still run first.

Policy-only and disabled sandbox backends continue to require approval for shell commands, so unsandboxed execution is not silently allowed.

Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox -run 'TestSandboxedBash|TestShellSandboxActive|TestEvaluateExemptsNetworkToolsFromDenyByDefault|TestGrantRequestPermissionsNetworkOverlaysPolicyForTurn|TestAnalyzeCommand|TestClassifyDoesNotFlagQuotedHttpServerPattern'

Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/tools -run 'TestBashAuto|TestBashStillPromptsWithoutActiveSandbox|TestRegistryRunsBashThroughSandboxEngine'

Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/cli -run 'TestApplyConfiguredSandboxPolicy|TestApplyConfiguredAutonomyCeiling'

Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/agent -run 'TestRunPersistentCommandPrefixStillPromptsForNetwork|TestRunApprovedNetworkBashPromptAppliesTurnNetworkGrant'

Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/daemon -run 'TestScrubWorkerEnvRemovesReentrancyMarkers'

Tested: GOCACHE=/tmp/zero-go-cache go test ./...

* Prompt for destructive shell approval

Remove the separate DenyDestructiveShell policy layer from active sandbox decisions. Destructive shell classification now produces an approval prompt in ask/auto flows instead of a sandbox hard-deny, while no-approval paths remain blocked.

Drop the removed destructive-shell policy field from sandbox policy output, backend capabilities, snapshots, and Windows golden data.

Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/agent -run 'TestRunPromptsForDestructiveShellInsteadOfSandboxDeny|TestRunPersistentCommandPrefixStillPromptsForNetwork|TestRunApprovedNetworkBashPromptAppliesTurnNetworkGrant'

Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox -run 'TestEngineClassifiesNetworkAndDestructiveShellCommands|TestBackendCapabilitiesReflectDisabledPolicy'

Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/cli -run 'TestRunSandboxPolicy|TestEffectiveSandboxPolicy'

Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/zerocommands -run 'TestSandboxPolicySnapshotFromPolicyFillsEffectiveMode'

Tested: GOCACHE=/tmp/zero-go-cache go test ./...

* Remove web-tool network policy switch

Remove the old EnforceToolNetwork policy option and the NetworkHostAllowed per-tool gate so sandbox network policy only controls sandboxed shell egress.

Keep web_search and web_fetch on their own tool permission and URL safety paths, with tests proving deny/scoped shell network policy no longer blocks in-process web tools.

Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox -run 'TestEvaluateExemptsNetworkToolsFromShellNetworkPolicy|TestEngineAllowsNetworkSideEffectWhenShellPolicyBlocksNetwork|TestGrantRequestPermissionsNetworkOverlaysPolicyForTurn|TestScopedNetworkGateInEvaluate'

Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/tools -run 'TestWebSearchRunWithSandbox|TestWebFetchRunWithSandbox|TestRegistryRoutesWebFetchThroughSandboxPath'

Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/agent -run 'TestRunPersistentCommandPrefixStillPromptsForNetwork|TestRunApprovedNetworkBashPromptAppliesTurnNetworkGrant'

Tested: GOCACHE=/tmp/zero-go-cache go test ./...

Tested: git diff --check

* Prompt for outside-workspace file access

Convert recoverable outside-workspace file path violations into permission prompts instead of hard-denying in ask/auto modes.

Approved prompts now grant the requested filesystem scope through the existing request_permissions overlay for the current turn or session, so the follow-up sandbox evaluation and scoped file tools use the same policy path.

Keep unsafe mode, apply_patch escapes, symlink traversal, and non-recoverable path cases hard-denied.

Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox -run 'TestEngineAutoAllowsWorkspaceFileMutationTools|TestEngineDeniesOutOfWorkspacePaths|TestGrantRequestPermissionsReadDoesNotGrantWrite|TestGrantRequestPermissionsSessionPersists'

Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/agent -run 'TestRunPromptsAndAllowsOutsideWorkspaceWrite|TestRunSessionAllowsLaterOutsideWorkspaceWrite|TestRunAppliesSandboxEvenInUnsafeMode'

Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/tools -run 'TestRegistryAppliesSandboxBeforeToolExecution|TestRegistrySandboxGatesPathAliasKeys|TestRegistryAllowsPromptToolWithPersistentSandboxGrant'

Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/cli -run 'TestRunSandboxCheckJSONDeniesOutOfWorkspaceWrite'

Tested: GOCACHE=/tmp/zero-go-cache go test ./...

Tested: git diff --check

* Fix Windows network prompt test fixture

Use a Windows curl.cmd shim and cmd.exe PATH syntax for TestRunApprovedNetworkBashPromptAppliesTurnNetworkGrant while keeping the POSIX fake curl fixture for Linux and macOS.

This keeps the test focused on network prompt approval and turn network grants instead of relying on a POSIX shell script being executable on Windows.

Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/agent -run 'TestRunApprovedNetworkBashPromptAppliesTurnNetworkGrant|TestRunPersistentCommandPrefixStillPromptsForNetwork'

Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox -run 'TestClassifyASTCatchesNetworkProgramsRegexMisses|TestClassifyFlagsPipedInstallerVariants'

Tested: GOCACHE=/tmp/zero-go-cache go test ./...

Tested: git diff --check

* Remove sandbox autonomy ceilings

Sandbox permission decisions now match grants by tool and scope only, without MaxAutonomy or request autonomy checks. Persistent/session grants, permission approvals, workspace-write auto-allow, and native-sandbox shell auto-allow no longer route through an autonomy ceiling.

Remove the sandbox.maxAutonomy config and CLI surfaces, including grant --auto, policy output, sandbox check autonomy input, and sandbox grant snapshots.

MCP permission autonomy is intentionally unchanged because it is a separate MCP permission model.

Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox ./internal/agent ./internal/tools ./internal/zerocommands ./internal/config ./internal/cli ./internal/tui

Tested: GOCACHE=/tmp/zero-go-cache go test ./...

* Fail closed without native sandbox

Remove AllowPolicyOnlyRunner from sandbox policy and snapshots. A restricted/managed command plan now errors when native sandbox support is unavailable instead of running through a policy-only direct runner.

Policy-only backend data remains as diagnostics for capability/reporting paths, but it no longer authorizes command execution when a platform sandbox is required.

Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox ./internal/zerocommands ./internal/cli ./internal/tui

Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/agent ./internal/tools

Tested: GOCACHE=/tmp/zero-go-cache go test ./...

* Remove scoped shell network policy

Drop the domain-scoped shell network mode and the local scoped-egress proxy path from the active sandbox model.

Shell network policy now serializes and evaluates only deny or allow. Request-permissions network grants widen to allow without domain lists, and platform profiles consume the binary mode directly.

The Linux helper no longer accepts proxy-route flags, Windows network hashes only the binary mode, and web tool tests assert they remain separate from shell network policy.

Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox ./internal/tools ./internal/notify

Tested: GOCACHE=/tmp/zero-go-cache go test ./...

* Align permission grant ordering and prompt text

Move persistent and session grant matching ahead of recoverable sandbox prompts while keeping explicit denies and non-recoverable path violations fail-closed.

Clean TUI permission transcript/card text so normal prompts show action, reason, scope, and readable block details instead of raw permission/risk/violation key-value metadata.

Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox ./internal/tui ./internal/agent ./internal/tools

Tested: GOCACHE=/tmp/zero-go-cache go test ./...

* Remove policy-only sandbox terminology

Replace the unavailable native-backend diagnostic path with explicit unavailable support/backend states, and stop presenting missing native isolation as a policy-only fallback.

Rename structured sandbox denial metadata from violation to block across permission events, stream-json, snapshots, TUI rendering, and sandbox-check output so user-facing flow matches blocked action language.

Update README, work split docs, smoke test selectors, and Windows sandbox policy golden output for the new backend/support names and block payloads.

Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox ./internal/agent ./internal/tools ./internal/tui ./internal/cli ./internal/doctor ./internal/zerocommands ./internal/streamjson

Tested: GOCACHE=/tmp/zero-go-cache go test ./...

Tested: scripts/sandbox-smoke.sh

Tested: git diff --check

* Fix sandbox review follow-ups

Fail closed for unknown network modes, keep network prompts ahead of bash allow grants, preserve v1 grant-file reads, and classify unparseable commands with obvious network programs as network-risk.

Bump stream-json schema to v2 and document the block metadata field after the violation-to-block rename.

Tests: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox ./internal/streamjson ./internal/specialist ./internal/cli -run 'TestExec.*Stream|TestStreamJSON|TestExecProtocol|TestExecReadsStreamJSONImages|TestExecAcceptsImageOnlyStreamJSONMessage|TestExecInputFormatStreamJSON'

Tests: GOCACHE=/tmp/zero-go-cache go test ./...

Tests: scripts/sandbox-smoke.sh

Tests: git diff --check
2026-06-19 19:50:12 +05:30
Vasanth T ab3d09d78e feat(oauth): Hugging Face + ChatGPT Codex as first-class built-in OAuth presets (#245)
* feat(oauth): add Hugging Face + ChatGPT Codex as first-class built-in OAuth providers

Adds two new built-in OAuth presets that round out the existing OpenRouter and
xAI surfaces, so the TUI `/provider` OAuth chooser and `zero auth login` work
out of the box for these providers.

Hugging Face
- Preset scopes, endpoints, and OIDC issuer are pre-filled; the operator
  supplies a "public" OAuth client_id (no secret) via
  `ZERO_OAUTH_HUGGINGFACE_CLIENT_ID` after registering an app at
  huggingface.co/settings/applications/new.
- Default flow is RFC 8628 device code, so headless/SSH is the first-class
  path (matches the existing xAI posture).
- The bearer works against the OpenAI-compatible Inference Providers router
  at `https://router.huggingface.co/v1` for hundreds of OSS models.

ChatGPT (Codex backend)
- Uses the publicly-shipped Codex CLI client identity
  (`app_EMoamEEZ73f0CkXaXp7hrann`), opt-in via `ZERO_OAUTH_ALLOW_PRESETS=1`,
  overridable via `ZERO_OAUTH_CHATGPT_*`.
- The bespoke `provideroauth.ChatGPTLogin` extracts the `chatgpt_account_id`
  claim from the OIDC ID token (added as `IDToken` on the `oauth.Token`
  struct) and stores it on `Token.Account` so the Codex provider can inject
  it as a header on every request.
- A new `internal/providers/openai/codex.go` wraps the openai provider with
  the Codex-flavored request shape: `originator: codex_cli_rs`,
  `chatgpt-account-id: <claim>`, and a branded User-Agent. The factory
  branches off the catalog id (not the provider kind) so other
  openai-compatible providers keep the standard openai path unchanged.
- The openai provider gained a `SetRequestExtra` callback so the Codex
  wrapper can inject per-request headers without coupling the openai
  provider to Codex.

CLI / TUI
- `zero auth chatgpt` is a one-shot login command (analog to
  `zero auth openrouter`); the TUI's OAuth chooser now lists both new
  providers automatically (the list is built from
  `providercatalog.OAuthProviders()`).
- The provider-wizard visible-row cap was bumped from 8 to 10 so the
  common providers (OpenAI, Anthropic, Google, Ollama, OpenRouter, the
  new Hugging Face, the new ChatGPT, Groq, …) all fit without scrolling
  the test fixtures.

Tests
- `internal/oauth/presets_test.go`: presets resolve correctly with the
  expected endpoints, scopes, and flows; env overrides win; Hugging Face
  preset stays inert without a client_id (the safety posture the existing
  xAI tests assert).
- `internal/providercatalog/oauth_test.go` + `catalog_test.go`: catalog
  now exposes 4 OAuth providers (`openrouter`, `huggingface`, `chatgpt`,
  `xai`); the `chatgpt` descriptor is `OAuth=true` with
  `OAuthDeviceFlow=false` (Codex is loopback-only) and `RequiresAuth=true`
  via an inline IIFE that sets it after the openAICompat helper.
- `internal/provideroauth/chatgpt_test.go`: ID-token claim extraction
  round-trips (happy path, missing claim, missing token, tampered JWS,
  non-string claim); the full login flow runs against a stub auth server
  and verifies the preset's client_id reaches the token endpoint.
- `internal/providers/openai/codex_test.go`: every request gets the three
  Codex headers; the 401-refresh retry path keeps them on the second
  attempt; configured baseURL wins; default originator is `codex_cli_rs`;
  branded User-Agent wins over the openai default; AccountResolver is
  consulted when AccountID is empty; the header is omitted when the
  resolver says no.

Out of scope (separate PRs): multi-account / profiles, generic
template-driven OAuth wizard, MCP / provider OAuth store unification,
subscription-proxy autoconfig.

* fix(ci): gofmt chatgpt_test.go and provider_wizard.go

The ubuntu smoke job's "Check formatting" step lists these two files as
unformatted (struct field alignment). The local gofmt run on Windows
also lists ~170 other files, but those are CRLF-vs-LF noise from
checkout and not real gofmt issues on Linux; the CI only flags the
two struct-alignment changes below.

- internal/provideroauth/chatgpt_test.go: align chatgptTestServer fields
- internal/tui/provider_wizard.go: align ChatGPTOptions literal fields

* fix(codex): hit the /responses path and resolve account id per request

Addresses the two findings in anandh8x's CHANGES_REQUESTED review on #245.

The Codex provider previously routed the chat-completions body to
{baseURL}/chat/completions, which is not the path the Codex backend serves
(chatgpt.com/backend-api/codex/responses, the Responses API). Add an
Endpoint field to openai.Options so a flavor can target a sibling path on
the same host; the Codex constructor sets it to {baseURL}/responses and
falls back to the openai transport unchanged for everything else.

The factory was also passing the OAuth token's Account field as a static
AccountID at construction time. A refresh that rotates the token (and
its account claim) would leave a stale value on the wire. Drop the
static AccountID from the factory wiring; the per-request
AccountResolver (which reads the live store) is now the sole source, so
a refresh takes effect on the next outgoing request.

Tests:
- codex_test.go: handler mounted on /responses; expected path updated;
  TestCodexProviderResolverIsConsultedOnEveryRequest pins the per-request
  resolver behavior (a refresh-style sequence of calls picks up the new
  account id on the second attempt).
- factory_test.go: expected request path updated to /responses.

* Addresses the Codex path shape on /responses.

The previous fix (2381197) corrected the URL and the per-request account
resolver, but the Codex provider still spoke chat-completions: it sent
{model, messages, tools, stream, stream_options, max_completion_tokens}
to /responses and parsed data: [DONE]-terminated SSE frames
(choices[].delta). The Codex backend at chatgpt.com/backend-api/codex
serves the Responses API — input items, function_call /
function_call_output, response.output_text.delta /
response.function_call_arguments.delta events — and rejects a
chat-completions body.

The Codex provider now has its own request/stream path that matches the
Responses schema:

- internal/providers/openai/codex_responses.go (new) holds the wire
  types (responsesRequest, inputItem, contentItem, responsesTool,
  responsesEvent, itemPayload, responsePayload, usagePayload), the
  request builder (system → message/role=system, user/assistant →
  message with text or content parts, prior tool calls → function_call,
  tool results → function_call_output, tools → responsesTool), the
  Responses SSE dispatcher, the tool-call accumulator
  (response.output_item.added registers the call id + name, the
  argument deltas accumulate, response.output_item.done finalizes),
  and the terminal-event handler (response.completed emits usage +
  done, response.failed surfaces the error after the usage chunk so
  token accounting is preserved).

- internal/providers/openai/codex.go: StreamCompletion now marshals a
  Responses request and dispatches via the new streamResponses
  function. The wrapped openai Provider is still constructed for its
  validated endpoint / auth / retry / idle-timeout config and its
  SetRequestExtra callback, but the openai chat-completions stream
  parser is no longer used for Codex. injectCodexHeaders still runs
  on every attempt (including the 401-refresh retry) so the Codex
  headers stay attached to the rotated bearer.

The shared providerio plumbing (auth + 401-retry, SSE scanner with
the idle watchdog, error humanization, secret redaction) is reused
verbatim so the OAuth 401-refresh and bearer-rotation paths stay
identical to the rest of the openai-compatible surface. The
stream-end fallback surfaces a StreamEventDone with
FinishReasonLength when the Codex backend closes the stream without
a response.completed (e.g. an internal truncation), so the runtime
can react instead of hanging on a phantom completion.

Tests:
- internal/providers/openai/codex_test.go:
  - newCodexTestServer now returns a minimal Responses sequence
    (response.created + response.completed); newCodexResponsesServer
    takes a custom payload sequence for richer cases.
  - The two tests with inline handlers (TestCodexProviderResolverIs-
    ConsultedOnEveryRequest, TestCodexProviderRetriesHeadersAfter401)
    use a response.completed payload on the second attempt instead of
    data: [DONE].
  - TestCodexProviderSendsResponsesRequestShape replaces the old
    TestCodexProviderSendsRequestBody and pins the Responses shape:
    the body MUST carry an `input` array of message / function_call
    / function_call_output items and `tools` of function type, and
    MUST NOT carry the chat-completions `messages` field.
  - TestCodexProviderParsesResponsesTextDeltas: response.output_text.
    delta events surface as StreamEventText in order, the
    response.completed usage chunk surfaces as StreamEventUsage
    (with input_tokens, output_tokens, cached_input_tokens, and
    reasoning_tokens from the optional details blocks), and
    StreamEventDone follows.
  - TestCodexProviderParsesResponsesToolCalls:
    response.output_item.added emits StreamEventToolCallStart with
    the right name, response.function_call_arguments.delta events
    emit StreamEventToolCallDelta in order, response.output_item.done
    emits StreamEventToolCallEnd.
  - TestCodexProviderSendsAssistantToolCallsAsInputItems: a runtime
    message with content + tool_calls serializes as BOTH the
    assistant message and a function_call item; a tool result
    serializes as a function_call_output. The expected count is 5
    items (user + assistant text + function_call + function_call_
    output + user) — earlier this test had 4, which would have
    missed the assistant message before the function_call.
  - TestCodexProviderEmitsErrorOnResponseErrorEvent: a response.error
    event surfaces as a single StreamEventError with the server's
    message and stops the scan.
  - TestCodexProviderEmitsErrorOnMalformedStream: a payload that
    parses as JSON but lacks the `type` field is reported as a
    stream error rather than silently dropped.
  - TestCodexProviderEmitsLengthFinishWhenStreamEndsWithout-
    Completion: a stream that closes without a response.completed
    surfaces a StreamEventDone with FinishReasonLength.

Verification:
- `go build ./...` clean (with the user-owned untracked files moved
  aside per the project's working-tree convention).
- `go vet ./internal/providers/...` clean.
- `go test ./internal/providers/... ./internal/providercatalog/...
  ./internal/provideroauth/... ./internal/oauth/...` PASS.
- `gofmt -l` on the three changed files clean.
2026-06-19 07:23:53 +05:30
KRATOS a0b9876b69 OAuth / account login for providers (OpenRouter, xAI) + subscription-proxy support (#217)
* 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.
2026-06-16 15:33:36 +05:30
KRATOS 474f494b93 Deep-audit remediation + sandbox hardening (batches 1–35) (#189)
* 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.
2026-06-13 21:32:48 +05:30
Vasanth T cc618bae11 Expand agent quality eval module (#178) 2026-06-13 11:30:20 +05:30
KRATOS 47c3cc8937 wedge stage 13: launch readiness (first-run + benchmark) (#186)
* 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)
2026-06-13 11:10:37 +05:30
KRATOS abcb1107d9 wedge stage 11: GitHub Action + Slack/webhook notifier (#182)
* 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
2026-06-13 10:42:53 +05:30
Vasanth T 2192c10299 Add agent quality eval foundation (#177)
* Add agent quality eval foundation

* Fix agent eval review issues

* Address agent eval review feedback
2026-06-12 13:31:57 +05:30
KRATOS 3f3de1a29b permissions: scope-enforced grants (permission-UX Phase 0 + 1) (#170)
* 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).
2026-06-11 20:33:52 +05:30
KRATOSandClaude Fable 5 e381d8eab1 Additional write roots: --add-dir flag, global config key, /add-dir TUI command (#162)
* 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]>
2026-06-11 15:17:20 +05:30
6fb0231df8 TUI scrollback re-architecture, Lime chat surface, and deep-audit fixes (#158)
* 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]>
2026-06-10 17:18:30 +05:30
Anandan 32d68b8d09 Persist background specialist tasks (#118)
* Persist background specialist tasks

* Harden background task reload
2026-06-07 15:47:48 +05:30
Anandan 699df010e2 feat(update): verify release metadata by target (#97)
* Add update check target selection

* Reject empty update target flag
2026-06-06 15:58:41 +05:30
Anandan 3e4add95ab feat(update): expose release check target options (#96)
* Expose update check target options

* Reject non-positive update timeouts
2026-06-06 15:28:50 +05:30
Vasanth T 52fbb6d9e7 feat: add runtime permission decisions (#95) 2026-06-06 15:26:33 +05:30
Anandan 442f24c7de Remove Bun installer test dependency (#91)
* Remove Bun installer test dependency

* Harden installer smoke tests

* Stabilize npm wrapper subprocess tests

* Make npm wrapper fixture explicit ESM
2026-06-06 13:58:08 +05:30
Anandan ce8dec087d Replace npm wrapper with Node entrypoint (#88)
* Replace npm wrapper with Node entrypoint

* Fix npm wrapper test portability
2026-06-06 13:21:57 +05:30
Anandan 503b6c5177 Move performance benchmark to Go (#87)
* Move performance benchmark to Go

* Address perf benchmark review feedback

* Fix perf benchmark pipe drain order
2026-06-06 12:59:01 +05:30
Anandan 6c41984dbe Move release packaging to Go (#85)
* Move release packaging to Go

* Handle release archive close errors

* Guard release output cleanup paths
2026-06-06 11:57:57 +05:30
Anandan 04808f6abd Verify update release assets (#84) 2026-06-06 11:16:44 +05:30
Vasanth T 294e927707 docs: update work split ownership plan (#80) 2026-06-06 10:11:22 +05:30
Vasanth T ca4c57fc59 Remove legacy TypeScript runtime (#78)
* chore: remove legacy TypeScript runtime

* fix: align perf benchmark semantics

* fix: clarify harness rss benchmark
2026-06-06 09:00:54 +05:30
Vasanth T 43c256068a feat: make Go runtime the app path (#71)
* feat: make go runtime the app path

* fix: harden update semver parsing
2026-06-05 17:24:58 +05:30
Vasanth T 37f3cc17a5 feat: advance Go-first TUI and npm wrapper (#57)
* feat: advance Go-first TUI and npm wrapper

* fix: address wrapper and footer review feedback

* feat: deepen Go TUI model and exec controls

* fix: address coderabbit exec and model list feedback
2026-06-04 20:59:06 +05:30
anandh8x e42a34fa83 Build release binary with Go 2026-06-04 19:33:57 +05:30
Anandan 375184d724 Add Go M0 validation scripts (#46)
* Add Go M0 validation scripts

* Validate Go smoke package version

* Align README with Go migration
2026-06-04 13:39:27 +05:30
Vasanth T b94812d4fb Update PRD for Go-native Zero direction (#44)
* docs: update zero prd for go migration

* docs: clarify go migration validation criteria

* docs: reset m0 plan for go migration
2026-06-04 12:14:50 +05:30
KRATOS 5b01fbb49e feat: add Zero exec resume and fork sessions (#32)
* feat: add Zero exec session resume fork backend

* feat: wire Zero exec resume fork flags
2026-06-03 16:05:12 +05:30
KRATOS aee8b824e0 feat: add Zero stream-json protocol (#31)
* feat: add Zero stream-json protocol schema

* feat: wire stream-json exec mode

* fix: read piped stream-json stdin
2026-06-03 15:22:52 +05:30
Anandan 50374f4490 feat: add M2 release checksum verification (#25)
* Add M2 release checksum verification

* Cover release checksum edge cases
2026-06-03 11:44:21 +05:30
Anandan 0ac9009270 feat: add M2 performance benchmark harness (#20)
* Add M2 performance benchmark harness

* Polish performance benchmark CLI
2026-06-03 10:54:36 +05:30
Anandan f9de264e9b feat: add M2 install scripts (#19)
* Add M2 install scripts

* Harden install archive extraction

* Fix install script runtime test platform
2026-06-03 09:57:08 +05:30
Vasanth T 291af70174 feat: add m1 headless exec surface (#17) 2026-06-03 09:51:33 +05:30
Anandan b801c532ef feat: add M2 update check (#16)
* Add M2 update check command

* Harden update check version and timeout
2026-06-03 09:30:00 +05:30
Vasanth T d8192c5208 docs: add final Zero PRD and work split (#13) 2026-06-02 23:07:03 +05:30