Commit Graph
259 Commits
Author SHA1 Message Date
Gnanam 1519ada0f2 notify: wire the webhook sink behind an opt-in env var
The WebhookSink (Slack / generic JSON POST on turn completion) was fully
implemented, redaction-safe, and tested — but never attached to a Notifier, so
deadcode flagged its entire machinery (NewWebhookSink, Emit, text, redactLinks,
log, eventType, readSnippet) as unreachable.

Wire it via a small, testable helper, MaybeAddWebhookSink(n, env, logf):
  - reads ZERO_NOTIFY_WEBHOOK_URL (+ optional ZERO_NOTIFY_WEBHOOK_SUMMARY)
  - no-op when the URL is unset/blank, so it is safe to call unconditionally
  - sourcing the URL from the environment keeps the secret token out of any
    on-disk config file

Attached at both notifier construction sites:
  - headless exec: failed deliveries log to stderr (never stdout); the sink
    redacts before logging
  - TUI: logf=nil so a delivery failure can't corrupt the alt-screen

The sink stays gated by the existing Mode/focus policy (verified by
TestNotifierOffSuppressesSinks), so a webhook only fires when notifications are
enabled (e.g. --notify both) — no change to default behavior. Opt-in and
fail-closed: with the env var unset nothing is attached.

deadcode: 100 -> 93 unreachable funcs (the 7 WebhookSink entries become
reachable); no new dead code. gofmt/vet/build(host+linux+windows)/test -race/
staticcheck(no new)/govulncheck(0) all pass.
2026-06-15 19:15:47 +05:30
KRATOS f857c4afb4 feat(daemon): TLS remote bridge — authenticated network access to the daemon (#211)
* feat(daemon): TLS remote bridge — authenticated network access to the daemon

Add internal/daemon/remote: an OPT-IN, TLS-only network bridge on top of the
local daemon. A remote client drives the SAME SessionManager/Pool over the
network behind bearer-token auth, reusing the daemon's framing and dispatch
unchanged. The local Unix-socket daemon stays the default and is unchanged.

- Bridge: per accepted TLS connection, authenticate THEN hand to the daemon's
  control dispatch via the new Server.ServeConn seam — a remote session runs
  under the same sandbox/risk model as a local one (never bypasses it).
- Authenticator + TokenAuthenticator: constant-time bearer-token check, token
  from $ZERO_DAEMON_REMOTE_TOKEN or a file; optional attestation hook (no-op
  default). Auth happens before any control frame; failure closes the connection
  after a small backoff; tokens are never logged.
- RemoteClient (DialRemote): verified TLS dial (never InsecureSkipVerify) + auth
  handshake, then the existing daemon.Client via the new NewClientConn seam, so
  remote and local share one protocol.
- Fail-closed: TLS mandatory (refuses without cert/key), a configurable
  min-protocol-version floor, the 1 MiB frame cap reused, and a bounded
  concurrent-connection cap that refuses overflow.
- CLI: `zero daemon serve-remote --addr --tls-cert --tls-key`; `run`/`attach`
  gain `--remote <addr> [--token --ca-cert --server-name]`.

Daemon seams added (additive, behavior unchanged): Server.ServeConn,
NewClientConn. SessionLink (event POST) and git-bundle repo transfer are
documented follow-ups.

Tests cover auth accept/reject (env + file), TLS-required, cert verification,
version floor, run/status/attach round-trip, re-attach resumption, connection
cap, and ListenAndServeTLS/Close. Gates: gofmt / vet / build (host+linux+
windows) / test -race / staticcheck (no new) / govulncheck (0) / deadcode
(no new) all pass.

* swarm: de-flake TestMailboxConcurrentSends on Windows CI

The 200-way concurrent-send stress test inherited from the swarm package timed
out on Windows CI (103/200 sends hit the 10s lock timeout): the lock's 20ms
retry sleep starves waiters when Windows file ops are slow under heavy
contention. Shorten the retry to 2ms (a freed lock is re-acquired promptly) and
raise the test's lock timeout to 60s so a legitimately-slow CI never times a send
out. Passes repeatedly under -race. (CI de-flake of a test that surfaced on
#210's Windows smoke; functionally unrelated to the OAuth changes.)

* remote: address CodeRabbit re-review (TLS dial + Close idempotency)

- client: replace the deprecated, non-cancellation-aware tls.DialWithDialer with
  a context-aware tls.Dialer.DialContext (resolves the golangci-lint noctx CI
  blocker; the context bounds the dial + TLS handshake).
- bridge: Close now clears b.listener under the lock, so a repeat Close is a
  no-op instead of returning a closed-listener error (matches its doc). Test
  asserts a double Close succeeds.

Gates: gofmt/vet/build(host+linux+windows)/test -race/staticcheck(no new)/
govulncheck(0)/deadcode(no new) all pass.
2026-06-15 18:48:07 +05:30
KRATOS bb7de29c80 feat(oauth): reusable provider OAuth engine + zero auth CLI (#210)
* feat(oauth): reusable provider OAuth engine + `zero auth` CLI

Add internal/oauth: a transport/identity-agnostic OAuth 2.0 engine that powers
provider login alongside the MCP-server OAuth zero already has. It is additive
and provider-neutral — NO providers, endpoints, or client identities are baked
into the binary; every provider is configured entirely via ZERO_OAUTH_<NAME>_*
env vars, so the engine works with any OAuth 2.0 / OIDC server.

Engine (internal/oauth):
- PKCE (S256 mandatory; "plain" refused) + per-flow CSRF state.
- Authorization-code flow: BuildAuthorizationURL / ExchangeCode / Refresh, with
  an https-only token-endpoint guard (loopback exempt) and error bodies that
  carry only error/error_description (never token material).
- Loopback callback server: 127.0.0.1-only, OS-assigned port, single use, state
  verified, then closed.
- Device-authorization grant (RFC 8628): RequestDeviceCode + PollDeviceToken
  honoring authorization_pending / slow_down / interval / expiry (headless/SSH).
- RFC 8414 + OIDC discovery (issuer-driven endpoint resolution).
- Namespaced token store ("provider:<name>" / "mcp:<name>"), 0600, file-locked
  (ownership-aware), atomic writes, malformed-fails-closed; GetFresh (on-demand
  refresh) + Handle401 (forced refresh).
- Opt-in proactive RefreshScheduler.

CLI (zero auth):
- login <provider> [--device] [--scope ...], logout, status [provider],
  refresh <provider> [--watch]. Status/output never print token material.
- Registered in app.go dispatch + top-level help.

MCP OAuth (internal/mcp) is left byte-for-byte unchanged (zero regression);
sharing the engine with MCP and encrypted-at-rest/keyring storage are documented
follow-ups. Tests cover every path (PKCE, auth URL, https-guard + redaction,
exchange/refresh, loopback capture + state-mismatch + timeout, device pending/
slow_down/expired, store namespacing/0600/locking, provider env resolution,
manager login (loopback+device)/GetFresh/Handle401, scheduler). Gates: gofmt /
vet / build (host+linux+windows) / test -race / staticcheck (no new) /
govulncheck (0) / deadcode (no new) all pass.

* oauth: fix Windows smoke — use OS-appropriate path in store-path test

TestResolveStorePathHonorsOverride hardcoded a unix "/tmp/custom/tok.json"
literal, which is not absolute on Windows (no drive), so ResolveStorePath
correctly resolves it against the current drive and the verbatim comparison
failed (D:\tmp\custom\tok.json). The production code is correct; the test now
builds the override with filepath.Join(t.TempDir(), ...) so it is absolute and
clean on every OS.

* oauth: address CodeRabbit review (11 findings)

Security / correctness:
- flow: caller-supplied ExtraAuthParams/extraParams can no longer override the
  reserved OAuth/PKCE fields (state, redirect_uri, code_challenge[_method],
  response_type, client_id) — e.g. forcing method=plain.
- device: a device-authorization response without expires_in now gets a bounded
  default lifetime (fail closed), and the poll loop re-checks expiry AFTER the
  interval sleep so it never polls past the deadline (e.g. after slow_down).
- lock: handle WriteString/Close errors on the token lock file — a partial write
  no longer strands an undeletable lock.
- store: a non-nil env map is now authoritative (hermetic) — a controlled map
  never falls back to ambient ZERO_OAUTH_* / HOME / XDG_CONFIG_HOME.
- scheduler: a transient loadForKey error backs off and retries (bounded) instead
  of permanently stopping proactive refresh.
- cli auth: reject flags a subcommand does not accept (e.g. login --watch,
  status --device) and reject empty --scope values — fail fast.

Lint blockers:
- manager: restructured the discovery fallback so the non-nil-err branch no longer
  returns nil (nilerr).
- providers: dropped the ineffectual flow initializer (ineffassign).

Tests: assert Save/Load errors in the scheduler test; the slow_down device test
no longer depends on post-expiry polling. Added tests for reserved-param
stripping, hermetic env, device default-expiry, and CLI flag validation. The
store-path test was already made OS-portable in the Windows-smoke fix.

Gates: gofmt/vet/build(host+linux+windows)/test -race/staticcheck(no new)/
govulncheck(0)/deadcode(no new) all pass.

* swarm: de-flake TestMailboxConcurrentSends on Windows CI

The 200-way concurrent-send stress test inherited from the swarm package timed
out on Windows CI (103/200 sends hit the 10s lock timeout): the lock's 20ms
retry sleep starves waiters when Windows file ops are slow under heavy
contention. Shorten the retry to 2ms (a freed lock is re-acquired promptly) and
raise the test's lock timeout to 60s so a legitimately-slow CI never times a send
out. Passes repeatedly under -race. (CI de-flake of a test that surfaced on
#210's Windows smoke; functionally unrelated to the OAuth changes.)

* oauth: address CodeRabbit re-review (4 findings)

- loopback (security): NewLoopbackListener refuses an empty CSRF state — an empty
  state would match a callback carrying no state at all, defeating the check.
- device (correctness): RequestDeviceCode now sends client_secret when set, so a
  confidential client authenticates on the device-authorization endpoint too
  (consistent with the token poll).
- cli auth: document --watch in the help Flags section.
- scheduler test: assert the seed Save error instead of ignoring it.

Also scrubbed a stray third-party reference from a loopback.go comment.
Tests added: empty-state rejection, device client_secret is sent. Gates:
gofmt/vet/build/test -race/staticcheck(no new)/govulncheck(0)/deadcode(no new)
all pass.

* mcp(oauth): delegate the OAuth engine to internal/oauth (dedup) (#212)

OAuth Phase 2: the MCP OAuth code now reuses the shared internal/oauth engine
instead of carrying its own copy of the transport/identity-agnostic primitives.
internal/mcp/oauth.go drops ~110 lines (526 -> 418): newPKCE, newState,
discoverAuthorizationServer, the authorize-URL builder, token exchange/refresh,
the token-response parser, and joinWellKnown now delegate to internal/oauth.

Behavior-preserving: MCP keeps its own OAuthConfig/StoredToken types,
LoginOptions/Login orchestration, loopback handling, registerClient (dynamic
client registration — not part of the shared engine), CLI output, and on-disk
token format (mcp-oauth-tokens.json, raw server-name keys). The existing mcp
oauth tests pass UNCHANGED. The only behavioral delta is a hardening inherited
from the shared engine: the token endpoint must be https (loopback exempt) —
a credential is no longer sent to a plaintext non-loopback endpoint.

Stacks on #210 (the internal/oauth package). Gates: gofmt/vet/build(host+
linux+windows)/test -race/staticcheck(no new)/govulncheck(0)/deadcode(no new
vs base) all pass; `zero mcp oauth status` output is identical.
2026-06-15 18:47:43 +05:30
de5d402e1f web_search: add domains allowlist + score field (#209)
* web_search: add domains allowlist + score field

The web_search tool now accepts an optional 'domains' array that filters
results to the listed hostnames before they reach the model. This is the
zero-only prompt-injection defense that none of the major CLIs ship: a
model that the user has constrained to a known-good set of domains cannot
be tricked into fetching arbitrary pages from search results.

Also surfaces the provider's optional 'score' field on each result row
when present (zero/absent scores are not rendered, to keep the common
case tidy). The 'score' field is parsed from the generic JSON shape
already understood by the httpSearchBackend.

Filters are applied after the search returns, before the empty-result
short-circuit, so a non-empty allowlist that ate every hit surfaces as a
clear 'no results matched domains' error rather than a misleading
'no results' message.

Co-Authored-By: Claude <[email protected]>

* web_search: fail closed on all-invalid domains, strip :port on canonicalize

CodeRabbit review fixes:

1. canonicalizeWebSearchHost: switch parsed.Host -> parsed.Hostname() so
   an allowlist entry like 'https://react.dev:443/path' normalizes the same
   as 'react.dev'. The previous code left ':443' in the string, which
   silently broke every match against a result URL on the default port.

2. stringListArgWebSearch: return a 'provided' bool so Run can distinguish
   'no allowlist' from 'allowlist that normalized to empty'. The previous
   code returned len(domains)==0 in both cases, and Run fell through to
   the unfiltered path — defeating the prompt-injection defense the
   parameter exists to provide. Now we error fail-closed.

3. Tests:
   - TestWebSearchDomainsFilterRejectsAllInvalidInputs: locks in the
     fail-closed contract for all-whitespace, all-empty, embedded-space,
     and mixed-invalid allowlists.
   - TestWebSearchDomainsFilterAcceptsHostPort: locks in the
     Hostname()-strips-port fix for the scheme'd case and asserts that
     scheme-less 'host:port' is rejected.
   - TestCanonicalizeWebSearchHostStripsPort: the direct unit-level
     regression test for canonicalizeWebSearchHost.

Co-Authored-By: Claude <[email protected]>

---------

Co-authored-by: Vasanthdev2004 <[email protected]>
Co-authored-by: Claude <[email protected]>
2026-06-15 18:27:21 +05:30
KRATOS 9ddef0e957 feat(swarm): multi-agent swarm over the specialist sub-agent (#207)
* feat(swarm): multi-agent swarm over the specialist sub-agent

Add internal/swarm so an orchestrator can spawn and coordinate MULTIPLE
specialist members concurrently under one team, communicate via per-agent
mailboxes, hand work off, and adopt orphaned tasks. Additive and opt-in: the
single Task tool is unchanged; swarm tools only act when invoked.

- Registry/Definition: agent roster (built-in teammate/subagent + user-defined).
- Mailbox: per-team/per-agent JSON inbox, owner-only 0600, file-locked with
  stale-break, size-capped, path-confined, and malformed-fails-closed.
- Coordinator: in-memory task registry with terminal-state guards and stable
  per-agent colors.
- Team: bounded concurrent members per team with a FIFO overflow queue that
  drains one-per-slot.
- Lifecycle: spawn / handoff / orphan-adoption with bounded relaunch on
  temporary failures; members run under the Swarm's base context so they
  outlive the spawn call.
- Tools: swarm_spawn, swarm_send, swarm_inbox, swarm_status, swarm_handoff,
  swarm_collect.
- Wiring: each member is launched through specialist.Executor and inherits the
  orchestrator's model + permission mode (never widened) and runs under the same
  sandbox/policy. Mailbox state lives under the workspace so writes fall within
  the sandbox write rules. Swarm lifetime is paired with the specialist runtime.

Scheduling (cron/wakeup) and daemon-pool-backed members are deferred follow-ups.

* swarm: address CodeRabbit review (7 findings)

- coordinator: SetStatus rejects unknown TaskStatus values (fail closed) so a
  task can never enter a state Summarize/lifecycle don't reason about.
- permission inheritance ENFORCED end-to-end (was resolved but discarded):
  thread PermissionMode through specialist.TaskRunOptions -> BuildArgs/
  BuildResumeArgs. A headless specialist child now runs "--auto high" (unsafe)
  ONLY when the parent is unsafe or no mode is given (preserves the Task tool's
  behavior); any other parent mode yields non-unsafe "--auto low", so a member
  never gains more authority than its orchestrator. Also fixed the swarm
  permission vocabulary to the real agent runtime modes (ask/spec-draft/auto/
  unsafe) instead of TUI display names, and the launcher substitutes "auto" for
  an empty mode so a member can't fall into the empty-means-high path.
- mailbox: symlink-aware confinement (EvalSymlinks) rejects an inbox dir that
  resolves outside BaseDir; dirs tightened to 0700 even if pre-existing with
  broad perms; lock release is ownership-token-aware so a stale-broken writer
  can't delete a newer writer's lock (split-brain).
- member: recover panics inside a member goroutine -> fails that member only,
  never crashes the orchestrator.
- tools: collapse() truncates on rune boundaries (no invalid UTF-8).

Tests added for every fix. Existing specialist behavior is unchanged (empty
PermissionMode keeps --auto high). Gates: gofmt/vet/build (host+linux)/test
-race/staticcheck(no new)/govulncheck(0)/deadcode(no new) all pass.

* swarm: address review round 2 (Windows lock + non-blocking notes)

Blocking (Vasanth): mailbox acquireLock now treats Windows ERROR_ACCESS_DENIED
/ ERROR_SHARING_VIOLATION as lock-contended (retry/wait) instead of a fatal
error, via a platform-aware isLockContended (lock_windows.go / lock_other.go).
A contended lock under Windows file-sharing semantics no longer fails a
send/read spuriously. TestMailboxConcurrentSends raised to 200 goroutines with a
10s lock timeout and now asserts zero send failures (regression guard).

Non-blocking notes addressed:
- Removed third-party reference-project names from doc comments (clean-room +
  provider-neutral); the Go code is original.
- Documented the inbox-file size bound's ×2 (pretty-print overhead) constant.
- Documented Send's O(N) rewrite cost + when to switch to append-only.
- permissionRank: spec-draft now ranks below ask (it never widens authority).
- Documented watch's per-member-id retry binding (Member.ID == MemberSpec.ID).
- handoff Safety.Reason now also mentions the inbox write.

Gates: gofmt · vet · build (host+linux+windows) · test -race · staticcheck
(no new) · govulncheck (0) · deadcode (no new) all pass.
2026-06-15 15:10:21 +05:30
KRATOS ba8fbb8b71 feat(sandbox): complete sandbox breadth — WSL fallback + opt-in MITM TLS inspection (#206)
* feat(sandbox): complete sandbox breadth — WSL fallback + opt-in MITM TLS inspection

Adds the two remaining sandbox features on top of the strict fail-closed core
(the other four — SOCKS5 egress, re-entrancy guard, fine-grained path lists,
sandbox-aware ripgrep — already shipped and are untouched). Both new features are
opt-in and the DEFAULT policy is unchanged (enforce + network deny, InspectTLS
off; AllowPolicyOnlyRunner keeps its existing default).

1) WSL detection + safe fail-closed fallback
   - parseWSL/detectWSL (wsl.go + wsl_linux.go reading /proc/version; wsl_other.go
     stub) classify WSL/WSL2.
   - SelectBackend: on Linux without bubblewrap, if running under WSL it selects a
     new BackendWSL instead of silently degrading to a plain policy-only runner.
   - BuildCommandPlan(BackendWSL): FAILS CLOSED unless Policy.AllowPolicyOnlyRunner
     is set (errWSLPolicyOnlyDisabled explains how to opt in). When allowed it runs
     the policy-only fallback that still routes egress through the filtering proxy
     (Backend.ProxyEgress => EnforcesScopedEgress, best-effort HTTP+SOCKS), sets
     ZERO_SANDBOXED=1 / ZERO_SANDBOX_BACKEND=wsl, and records an auditable
     least-privilege downgrade note (CommandPlan.Notes). Never runs unsandboxed
     silently.

2) Optional gated MITM TLS inspection (Policy.InspectTLS, default OFF)
   - Off => the egress proxy is a pure CONNECT passthrough, byte-for-byte
     unchanged (no CA generated).
   - On => the proxy terminates TLS toward the sandboxed client with an EPHEMERAL,
     locally generated ECDSA CA (mitm.go), minting per-host leaves on the fly, so
     it can re-check the DECRYPTED request Host through the SAME
     authorize()/domainAllowed() gate (deny-wins, empty allowlist => deny) — MITM
     widens visibility, never authority. The UPSTREAM dial is ALWAYS validated
     against the system roots (never InsecureSkipVerify; tests inject a known root
     pool to exercise validation). The CA's PUBLIC cert is written owner-only and
     surfaced via ZERO_SANDBOX_CA_CERT; MITM only runs on backends that already
     permit reading it (sandbox-exec allows file-read; WSL shares the host fs), so
     no new bind path is introduced. Trust implications documented loudly on the
     field. (No "raw passthrough only" flag exists in zero, so the mutual-exclusion
     case is N/A.)

Tests: parseWSL table; WSL fail-closed-without-opt-in and policy-only-with-proxy
(markers + ProxyEnvWithSocks) + deny-mode markers-no-proxy; MITM passthrough-by-
default (no CA), CA mints a chaining leaf, full TLS-terminate-and-forward of an
allowlisted host (upstream validated via an explicit root pool), and decrypted-
Host deny. Gates: gofmt, go vet (host + GOOS=linux), go build (host/linux/
windows), go test (incl -race), staticcheck (no new, host + linux), govulncheck
(0), deadcode -test=false under GOOS=linux (no new unreachable funcs).

* sandbox: address review on #206 (WSL + MITM)

- mitm: bound the upstream transport's TCP connect (DialContext 30s) so a
  blackholed upstream can't park a goroutine; disable upstream HTTP/2 and
  normalize the response to HTTP/1.1 before writing to the HTTP/1.1 client.
- mitm: mint leaves for the AUTHORIZED CONNECT host (not arbitrary client SNI)
  and cap the leaf cache (512) so SNI churn can't drive unbounded keygen/memory.
- mitm: re-check the decrypted Host with the FULL authorize gate
  (authorizeTarget) so a DomainPrompt-allowed host is honored consistently with
  the CONNECT check, not just domainAllowed.
- runner (WSL): preserve the caller's env in the direct-run WSL plan (it is not
  OS-wrapped), appending the sandbox markers last; the downgrade note says
  "with proxy egress" only when a proxy was actually started.
- tests: WSL caller-env preservation; deny-mode asserts no HTTP/HTTPS/ALL_PROXY
  leak + the downgrade note; MITM forward asserts HTTP 200; mitmConnect sets an
  I/O deadline.

Gates: gofmt, vet (host+linux), build (host/linux/windows), test (incl -race),
staticcheck (no new), govulncheck (0), deadcode -test=false (GOOS=linux) no new.
2026-06-15 11:38:14 +05:30
Vasanth TandClaude 265aa52ba7 Polish /permissions command output into a bounded card (#205)
* Convert /permissions output to bounded command card

Replace status: ok style with commandCard format used by /tools and /context.

Summary shows mode and grant count; grants render as bullet rows.

Co-Authored-By: Claude <[email protected]>

* Address CodeRabbit nitpick: add error path test for /permissions

Adds TestPermissionsCommandCardHandlesGrantListError and a grantLister

interface so tests can inject a stub store without a real file path.

Co-Authored-By: Claude <[email protected]>

* Fix gofmt formatting in permissions card

Tabs alignment only; no functional change.

Co-Authored-By: Claude <[email protected]>

---------

Co-authored-by: Claude <[email protected]>
2026-06-15 11:37:51 +05:30
KRATOS 135e3c5657 feat(daemon): zero daemon — worker pool + session supervisor over a local control socket (#203)
* feat(daemon): add `zero daemon` — worker pool + session supervisor over a local control socket

Adds internal/daemon + `zero daemon start|stop|status|run|attach`: a long-running
local service that supervises a pool of headless `zero exec` workers and routes
multiple agent sessions to them over an owner-only Unix-domain control socket.
Additive — interactive and one-shot exec are unchanged.

Reuses zero's building blocks:
- internal/background: ConfigureChildProcessGroup (process-group setup) +
  TerminateProcess (cross-platform terminate) for worker kill/drain. The pool
  does its own os/exec and uses background only for those helpers.
- internal/streamjson: the worker speaks stream-json on stdout; the daemon
  forwards those lines to clients.
- internal/cli exec: a worker is `zero exec --output-format stream-json`.

Pieces (reimplemented in Go from a reference daemon):
- protocol.go: framed control codec — 4-byte BE length + 1 kind byte, 1 MiB cap,
  JSON control messages, version negotiation.
- pool.go + backoff.go: bounded worker pool — at-least-once dispatch with
  exponential backoff+jitter retry capped by MaxAttempts, EXIT_TEMPFAIL(75)/
  EXIT_PERMANENT(76) handling, graceful drain + kill-timeout. zero's exec is
  one-shot, so workers are on-demand (one session per worker = the lease), not
  warm long-lived slots (a persistent worker mode is a follow-up).
- session.go: SessionManager — sessionID->worker routing, lease (one active
  request per worker), queue when the pool is full, per-session output buffer for
  attach + metrics.
- server.go + lock.go + socket.go + paths.go: single-instance PID-file lock with
  stale-lock (dead-PID) recovery, status file, owner-only socket, accept loop;
  cleans up lock/socket/status on exit.
- launcher.go: production worker launcher. client.go: control-socket client.

Security model:
- Control socket is LOCAL-ONLY (AF_UNIX loopback file, never a TCP port) and
  owner-only: socket 0600 + parent dir 0700 on POSIX (Windows relies on the
  per-user profile ACL; a dedicated pipe ACL is a follow-up).
- Workers stay sandboxed: the launcher SCRUBS the sandbox re-entrancy markers
  (ZERO_SANDBOXED / ZERO_SANDBOX_BACKEND) from each worker's env so the worker
  re-establishes its OWN sandbox instead of inheriting a pass-through plan, while
  propagating the rest of the policy config/env. The daemon never bypasses the
  sandbox.
- Control frames over the 1 MiB cap are rejected before allocation.

Robustness: a crashed worker never takes down the daemon (backoff+jitter retry,
MaxAttempts cap, EXIT_PERMANENT stops retry); drain gives a grace window then
force-kills stragglers; a stale lock from an unclean exit is reclaimed.

Out of scope (follow-up): remote session-linking; warm pre-spawned persistent
workers; Windows named-pipe ACL.

Tests: protocol round-trip + oversize/unknown-kind rejection; backoff bounds;
pool spawn/retry-backoff/permanent/cap/queue/drain; session lease/route/queue/
attach; lock single-instance + stale recovery; launcher env-scrub (incl. an
end-to-end check that the spawned worker process receives scrubbed env); server
e2e start->run->stream-json->status->attach->stop with a fake worker; CLI
usage/validation/not-running paths. Live-verified start/status/stop (0600 socket,
0700 dir, file cleanup). Platform files behind explicit //go:build tags.

* daemon: address review — help listing, extra-arg rejection, lock write error, bounded sessions

- cli/app.go: list `daemon` in `zero --help` so the command is discoverable.
- cli/daemon: `stop`/`status` reject extra arguments; `attach` rejects more than
  one session id (fail fast on typos instead of silently ignoring tokens).
- daemon/lock: check the PID fmt.Fprintf error — a partial/failed write would
  leave a malformed lock file that another process reads as stale and wrongly
  reclaims, breaking the single-instance guarantee; on write failure remove the
  file and return the error.
- daemon/session: bound the registry with MaxSessions (default 256) — evict the
  oldest FINISHED sessions once past the cap so a long-running daemon doesn't
  accumulate sessions without limit; running/queued sessions are never evicted, so
  attach-after-finish for recent sessions is preserved.

Tests: CLI extra-arg rejection; finished-over-cap eviction + running-never-evicted.
Gates: gofmt, vet, build, test (incl -race), staticcheck (no new), govulncheck (0),
deadcode (no new), GOOS=linux+windows cross-compile.
2026-06-14 22:16:37 +05:30
KRATOS 355b76cc78 sandbox: exempt first-party network tools from the Evaluate network-deny too (#202)
Follow-up to the EnforceToolNetwork change: web_search/web_fetch were still
hard-denied under NetworkDeny because the engine-level gate in Evaluate
(netMode==NetworkDeny && risk has "network") fired BEFORE the tool's
RunWithSandbox/NetworkHostAllowed ever ran. The earlier change only relaxed
NetworkHostAllowed, so the user-facing path (Evaluate -> registry) still blocked
the tool with "network access is blocked by sandbox policy".

Fix: Evaluate now skips the network-deny for a request that declares
SideEffectNetwork (the first-party in-process tools, web_search/web_fetch) when
EnforceToolNetwork is off — mirroring the NetworkHostAllowed exemption so the two
gates agree. A SHELL command merely classified as network (SideEffectShell) is
NOT exempt, so shell egress stays blocked under deny. EnforceToolNetwork restores
the strict deny for the tools.

Tests: TestEvaluateExemptsNetworkToolsFromDenyByDefault covers the full path
(default-exempt, granted->allow, EnforceToolNetwork->deny, shell stays denied);
the two existing gate tests now opt into EnforceToolNetwork to keep testing the
strict path. Gates: gofmt, vet, build, test (incl. -race), staticcheck (no new),
govulncheck (0), deadcode (no new unreachable funcs).
2026-06-14 20:36:51 +05:30
Anandan 6a9f44a716 Polish TUI transcript and reasoning display (#200)
* Polish TUI transcript and reasoning display

* Address provider and transcript review fixes

* Gate inline think tag parsing

* Cover disabled think tag parsing config
2026-06-14 18:55:18 +05:30
Vasanth T e1f1b111a1 Add command dashboard cards (#201)
* Add context command dashboard card

* Render tools command as dashboard card

* Render command cards as styled TUI cards

* Fix command card action rendering

* fix: harden command card edge cases
2026-06-14 18:34:46 +05:30
Vasanth T 284a948f00 feat: add doctor fix flow (#198)
* feat: add doctor fix flow

* fix: animate doctor connectivity status

* fix: make doctor report problem first

* fix: address doctor review feedback
2026-06-14 17:54:00 +05:30
KRATOS 671af594fb feat(sandbox): harden internal/sandbox — re-entrancy guard, SOCKS5 egress, sandbox-aware search, fine-grained path lists (#199)
* feat(sandbox): harden internal/sandbox with four opt-in capabilities

All four are off by default and fail closed; existing behavior is unchanged
when the new policy fields are empty / ZERO_SANDBOXED is unset.

1. Re-entrancy guard — ZERO_SANDBOXED=1 marks every wrapped command; a nested
   BuildCommandPlan returns a pass-through plan (no double bwrap/sandbox-exec,
   no second egress proxy). New: EnvSandboxed, IsAlreadySandboxed().

2. SOCKS5 egress — the scoped-egress proxy also serves SOCKS5 CONNECT on a
   second loopback port through the SAME authorize()/domainAllowed() gate.
   ProxyEnvWithSocks points ALL_PROXY at socks5://<addr>; HTTP(S)_PROXY stay on
   the HTTP listener; the sandbox-exec profile allows both proxy ports.
   Bubblewrap scoped still collapses to deny.

3. Sandbox-aware search — ReadExclusionGlobs(policy, scope) returns ripgrep
   --glob '!<pat>' for DenyRead subtrees; grep/glob skip read-denied subtrees
   at walk time via Engine.ReadPathExcluded/ReadDirExcluded when sandboxed.

4. Fine-grained path lists — Policy.AllowRead/DenyRead/AllowWrite/DenyWrite.
   Read = deny-then-allow (more-specific AllowRead re-includes); Write =
   DenyWrite > workspace/scope > AllowWrite > deny. AllowWrite is reflected in
   the OS write binds; DenyWrite is emitted as sandbox-exec deny rules. Each
   entry is home-expanded, made absolute, and symlink-resolved.

Tests: internal/sandbox/{reentrancy,egress_socks,pathlists}_test.go and
internal/tools/read_exclusions_test.go.
Gates: gofmt, go vet, go build, go test (incl. -race), staticcheck (no new
findings), govulncheck (0), deadcode -test=false (no new unreachable funcs).

* sandbox: address review — SOCKS5 method negotiation + cache read-exclusion roots

- egress_socks: respect SOCKS5 method negotiation (RFC 1928). The proxy now
  inspects the client's offered methods and replies 0xFF ("no acceptable
  methods") when no-auth was not offered, instead of forcing no-auth.
- pathlists/engine: resolve DenyRead/AllowRead roots ONCE per search walk via a
  new ReadExclusions matcher (Engine.ReadExclusions), replacing the per-path
  ReadPathExcluded/ReadDirExcluded that re-ran Abs/EvalSymlinks for every visited
  path during a grep/glob walk.

Re-entrancy guard left as-is by design: ZERO_SANDBOXED is an internal marker set
only on commands zero wraps; cross-process re-entrancy detection legitimately
relies on the inherited env, and a manually-exported flag is a self-opt-out, not
an external bypass.

Tests: SOCKS no-acceptable-methods rejection; nested-AllowRead descent via the
matcher. Gates: gofmt, vet, build, test (incl. -race), staticcheck (no new
findings), govulncheck (0), deadcode (no new unreachable funcs).

* sandbox: fix windows smoke test + symlink-resolve DirExcluded prefix

- pathlists_test: TestSandboxExecProfileEmitsDenyWriteRule now builds the
  expected deny clause via sandboxProfileString(secret), so it matches the
  profile's own escaping (Windows doubles backslashes). Fixes the
  windows-latest smoke failure; macOS/Linux unaffected (slash paths, no-op
  escaping).
- pathlists: ReadExclusions.DirExcluded now EvalSymlinks-resolves the candidate
  path before the nested-AllowRead prefix check, matching the resolution applied
  to allowRoots — otherwise a denied dir reached THROUGH a symlink fails to match
  a nested AllowRead root and is wrongly skipped, dropping a re-included subtree.
  Added TestDirExcludedResolvesSymlinkPrefix (skips where symlinks are
  unavailable).

Gates: gofmt, vet, build, test (incl. -race), staticcheck (no new findings),
govulncheck (0), deadcode (no new unreachable funcs).

* sandbox: strengthen re-entrancy guard with a corroborating backend marker

Addresses review feedback that the re-entrancy guard trusted a single ambient
env var. IsAlreadySandboxed now requires BOTH correlated markers that
sandboxEnvironment sets together on every wrapped command — ZERO_SANDBOXED=1 AND
a non-empty ZERO_SANDBOX_BACKEND (new EnvSandboxBackend const) — so a lone
stray/hand-exported ZERO_SANDBOXED=1 no longer forces an unsandboxed
pass-through. Pass-through (direct) plans set neither marker, so genuine nesting
still detected and double-wrapping still avoided.

Tests updated: TestIsAlreadySandboxed now asserts each marker alone is
insufficient and both together are required; the re-entrancy and wrapping plan
tests set/clear both markers explicitly.

Gates: gofmt, vet, build, test (incl. -race), staticcheck (no new findings),
govulncheck (0), deadcode (no new unreachable funcs).

* sandbox: don't subject first-party web tools to the network policy by default

The sandbox was reported as too strict: web_search (and web_fetch) were blocked
with "network access is disabled by the sandbox policy" because the default
policy is network=deny and the in-process web tools were gated by the same
NetworkHostAllowed check as the sandboxed shell's egress.

That network policy exists to confine the sandboxed SHELL's egress, which these
in-process tools don't use. New Policy.EnforceToolNetwork (off by default) makes
NetworkHostAllowed exempt web_search/web_fetch unless an operator opts in. The
sandboxed-shell egress decision (Evaluate / effectiveNetworkMode) is unchanged,
and the tools keep their own SSRF/private-IP, port, redirect, and redaction
safeguards. Set EnforceToolNetwork to hold the tools to the allow/scoped/deny
policy as before.

Tests: engine default-exempt + enforced-table cases; web_search default-allows
and enforced-when-flag; web_fetch enforced cases set the flag. Gates: gofmt, vet,
build, test (incl. -race), staticcheck (no new findings), govulncheck (0),
deadcode (no new unreachable funcs).

* sandbox: re-review fixes — disabled policy inert; path lists independent of EnforceWorkspace

- engine: ReadExclusions() / ReadExclusionGlobs() now return inert results under
  ModeDisabled, so a disabled policy never filters grep/glob (parity with
  Evaluate, which already allows everything when disabled).
- engine/pathlists: the fine-grained path lists (DenyRead/DenyWrite, with
  AllowRead/AllowWrite) now apply in Evaluate whenever the sandbox is enforcing,
  independent of EnforceWorkspace — matching the grep/glob path that already
  honors DenyRead directly. The workspace boundary (scope.validate) stays gated
  by EnforceWorkspace via a param threaded through validatePathWithPolicy /
  validateWritePath; DenyWrite wins regardless.
- reentrancy_test: TestSandboxEnvironmentMarksSandboxed now asserts BOTH
  correlated markers (ZERO_SANDBOXED=1 and ZERO_SANDBOX_BACKEND) so dropping
  either would fail the test.

Tests added: path lists enforced with EnforceWorkspace off; ReadExclusions inert
under ModeDisabled. Gates: gofmt, vet, build, test (incl. -race), staticcheck
(no new findings), govulncheck (0), deadcode (no new unreachable funcs).

* sandbox: enforce path lists even without a workspace root

Re-review follow-up: the Evaluate path-validation loop was still guarded by
request.WorkspaceRoot != "", so an engine built without a workspace root skipped
DenyRead/DenyWrite/AllowRead/AllowWrite even for absolute paths. The loop now
runs unconditionally; the workspace boundary (scope.validate) stays gated on
having a root (enforceWorkspace = EnforceWorkspace && WorkspaceRoot != ""), while
the path lists match absolute paths regardless. An unanchorable relative path
(no workspace root) fails closed when anything is configured to enforce, and is a
no-op otherwise (unchanged from prior behavior).

Test: TestEvaluatePathListsWithoutWorkspaceRoot. Gates: gofmt, vet, build, test
(incl. -race), staticcheck (no new findings), govulncheck (0), deadcode (no new
unreachable funcs).

* sandbox: base no-workspace fail-closed on normalized (enforceable) path lists

policyHasPathLists now resolves the lists (resolvePolicyPaths/
resolveWriteRootPaths) instead of counting raw config, matching how the rest of
the file normalizes them. A typo or non-existent entry (dropped during
resolution) therefore no longer makes validatePathWithPolicy fail-close every
relative request when there is no workspace root.

Gates: gofmt, vet, build, test (incl. -race), staticcheck (no new findings),
govulncheck (0), deadcode (no new unreachable funcs).
2026-06-14 17:37:40 +05:30
Vasanth T 7153ec7a7d feat: add TUI diagnostics center (#197) 2026-06-14 13:18:43 +05:30
KRATOS 73eb8d3376 Solidify sandbox: harden command analysis + wire/clean dormant policy surfaces (#196)
* Harden sandbox command risk by wiring the AST analyzer

The AST-based command analyzer (sandbox/analyzer.go) was built and tested but never
reached at runtime — the live risk classifier used only the regex detectors. Wire
AnalyzeCommand into classifyWithScope as a second opinion. It walks the parsed shell tree,
so it catches destructive/network programs the regexes miss (shred, fdisk, parted; telnet,
ftp, sftp; and commands hidden behind sudo/env wrappers or a sh -c launcher) and flags an
unparseable (obfuscated) script as elevated risk.

Purely additive — it only ADDs risk categories, so a benign, parseable command classifies
exactly as before, and a program name inside a quoted argument is not flagged (AST
precision). Revives the dormant analyzer (sandbox unreachable funcs 13 -> 6). New tests cover
the regex-missed destructive/network programs, nested launchers, the unparseable signal, the
quoted-name no-false-positive case, and benign-stays-clean. Full suite + -race green.

* Wire sandbox auto-allow-bash env opt-in; drop orphaned batch-autonomy

Two dormant sandbox surfaces resolved:

- WIRE: AutoAllowBashWhenSandboxed was enforced live (engine.go) but never set — no
  config field, and the ZERO_SANDBOX_AUTO_ALLOW_BASH env overlay (ApplyAutoAllowBashEnv)
  was unwired, so the documented opt-in did nothing. Wire it through the central policy
  builder (applyConfiguredSandboxPolicy, used by all engine constructions). Fail-safe: only
  turns ON when the env is truthy, only honored when the shell sandbox is actually active,
  default stays prompt, and an explicit config opt-in is preserved. Revives
  AutoAllowBashEnvEnabled + ApplyAutoAllowBashEnv; adds a policy-builder test.

- DELETE: RequiredAutonomyForBatch/riskToAutonomy (batch.go + test) had no consumer — the
  agent loop enforces autonomy per tool call (loop.go), so a batch-ceiling helper was
  speculative and redundant. Wiring it would have meant fabricating a batch model.

Sandbox now has no dormant funcs; the only remaining unreachable entries are the
cross-platform seccomp pair (unixSocketBlockFilter is live on Linux; ApplyUnixSocketBlock
is the intentional non-Linux no-op stub).

* Assert risk level (and add wrapper cases) in AST analyzer tests

Per review (Vasanth + CodeRabbit): the AST hardening tests checked only the risk category,
not the escalation level, so a regression that downgraded destructive/network shell commands
while preserving the category would slip through. Assert risk.Level too — Critical for
destructive/network, High for unparseable — matching the pattern used throughout the file.
Add sudo/env wrapper cases (sudo/env shred, sudo telnet) to cover effectiveProgram's wrapper
unwrapping.
2026-06-14 13:13:16 +05:30
Vasanth T 0444eb0e6f feat: add backend doctor diagnostics (#194) 2026-06-14 11:57:10 +05:30
KRATOS b1ab01f53f Hooks management command + build & supply-chain hardening (#193)
* Bump toolchain to go1.26.4 to clear reachable stdlib CVEs

go.mod pinned toolchain go1.24.13, whose bundled standard library has 9
govulncheck-reachable CVEs (crypto/tls KeyUpdate DoS, net/http2 loop, crypto/x509 x3,
net, net/url, os, net/textproto). CI and release build via go-version-file: go.mod, so
shipped binaries inherited them.

Bumping the toolchain directive to go1.26.4 clears all nine — govulncheck ./... now
reports 0 reachable (minimum that clears them is go1.25.11; go1.26.4 matches the team's
local Go). The go directive stays at 1.24.2 (minimum language version). Verified:
go build/vet/gofmt clean, full go test ./... green.

* Add govulncheck CI gate and SHA-pin GitHub Actions

- ci.yml: new 'security' job runs govulncheck as a hard gate (fails on a reachable
  vulnerability; passes now that the toolchain is bumped) plus an advisory deadcode step
  that surfaces dormant code without blocking.
- Pin every GitHub Action to a full commit SHA (checkout, upload-artifact, github-script)
  with a version comment, across all four workflows — previously only setup-go was pinned,
  leaving the mutable v4/v7 tags as a supply-chain risk.

* Add hooks management command (add/remove/enable/disable)

Wires the previously-dormant hooks.ConfigStore (10 unreachable funcs -> 0) into the CLI:

  zero hooks add <id> --event <event> --command <cmd> [--name --description --matcher --arg --user --json]
  zero hooks remove|enable|disable <id> [--user --json]

Writes the project hook config by default (<cwd>/.zero/hooks.json) or the user config with
--user, reusing the store's locking + atomic writes + validation (normalizeDefinition). New
hooks are enabled; state is managed via enable/disable. JSON output is secret-scrubbed via
redaction. Mirrors the existing 'zero mcp add/remove/enable/disable' shape. Covered by
add/remove/toggle round-trip, validation, unknown-event, and JSON-redaction tests.

* Fix govulncheck CI job: pin GOTOOLCHAIN to the go.mod toolchain

go run govulncheck@latest selected the toolchain from govulncheck's own go.mod
(downgrading to go1.25.11), which then could not load our go1.26-requiring packages
(fips140only_go1.26.go), failing the security job. Resolve GOTOOLCHAIN from go.mod's
toolchain line so govulncheck and deadcode both run under go1.26.4.

* Address review: harden CI checkout, pin tool versions, validate hook event

CodeRabbit findings on #193:
- ci.yml: persist-credentials: false on all checkout steps (smoke/performance/security)
  — these jobs run repo code and never push, so don't keep the token in git config.
- ci.yml: pin [email protected] and [email protected].0 instead of @latest, so the security
  gate is deterministic and not subject to supply-chain drift.
- hooks: add exported IsValidEvent/KnownEvents (single source); parseEvent reuses it, and
  'zero hooks add' now rejects an invalid --event with a usage error at parse time instead
  of falling through to the Upsert app-error crash path.
2026-06-14 11:56:29 +05:30
Vasanth T 6895cbb4aa feat: add backend lifecycle status command (#192) 2026-06-14 10:57:25 +05:30
KRATOS e0dd67a4da Wire LSP diagnostics into self-correct (exec + TUI) (#191)
* Wire LSP diagnostics into headless --self-correct

newExecSelfCorrector now backs the --self-correct loop with both halves: the
workspace test plan AND an lsp.Manager-based diagnostics checker (IncludeLSP=true).
The manager is lazy (Manager.Check degrades a missing language server to nil,nil),
and a deferred cleanup shuts it down at run end, terminating any spawned sessions.

This revives the internal/lsp client (44 -> 3 unreachable funcs); it was dead
because the only consumer passed a nil checker with IncludeLSP=false.

Add TestManagerCheckRealGopls: drives NewManager -> Check against a real gopls and
asserts a real diagnostic; skips when gopls is absent, so it stays CI-safe.

* Wire self-correct into the interactive TUI (on by default)

runAgentWithOptions now builds a SelfCorrector for every turn, so post-edit
verification (workspace test plan + LSP diagnostics) runs by default in the TUI.
The spec-draft planning path is excluded, matching exec. A per-turn lsp.Manager
is created and torn down when the run returns. Auto-fix vs report-only follows
the active permission mode (unsafe->high, auto->medium, ask/other->low) via
selfCorrectAutonomyForMode.

* Keep TUI self-correct fast by default; gate the test half behind /selfcorrect

The TUI default now runs only the LSP-diagnostics half of self-correct — cheap and
scoped to the changed files — so it never adds the whole-repo test plan's latency to a
turn (and a pre-existing failure can't hijack the agent). The project test plan is opt-in
per session via a new /selfcorrect [on|off] command (alias /sc); IncludeTests reads
m.selfCorrectTests, default false. exec keeps both halves.

* Remove dead session.didClose from lsp

session.didClose sent a textDocument/didClose notification but was never called (the
manager only closes documents via Shutdown), so it was unreachable even from tests and
flagged by staticcheck U1000. Removing it takes internal/lsp to 0 truly-dead funcs and 0
U1000 findings; the 2 remaining unreachable-from-main funcs (Available, URIToPath) are
public, test-covered API.

* Align /selfcorrect help with accepted arguments

The usage/description advertised only [on|off] but the handler also accepts status,
list, tests, full, and lsp. Surface the primary verbs (on|off|status) and clarify the
on/off behavior so command discovery matches the handler (CodeRabbit).

* Make /selfcorrect usage strings match the handler exactly

Both the command definition and the invalid-argument error message now list the full
accepted set [status|on|off|tests|full|lsp]; the redundant 'list' alias is dropped so docs
and handler agree exactly. Addresses Vasanth's review (the error-message usage at
session_controls.go was still [on|off]) and CodeRabbit's help-text finding.

* Clarify /selfcorrect off semantics in help text

Reword the description so 'off/lsp' reads as 'disable tests, LSP-only' rather than the
ambiguous 'return to LSP-only', which could be misread as disabling the feature entirely
(CodeRabbit).
2026-06-14 10:56:53 +05:30
Anandan d1c5af7392 Polish model status and reasoning streams (#190)
* Polish model status and reasoning streams

* Polish custom provider setup flow

* Clean up pending composer hints

* Address custom provider setup review

* Polish composer overflow handling

* Fix composer paste and wheel edge cases

* Use line counts for paste previews

* Revert "Use line counts for paste previews"

This reverts commit 4feb814d09.

* Fix composer paste preview deletion

* Disable textinput ctrl-v paste binding

* Preserve paste previews through file suggestions

* Clarify composer paste preview behavior

* Keep OpenAI reasoning out of answer text
2026-06-13 22:40:32 +05:30
Vasanth T 250bf24c9d feat: add MCP manager UX (#188)
* feat: add MCP manager UX

* fix: harden MCP manager UX

* test: stabilize agent eval timeout on Windows

* fix: preserve MCP server metadata

* fix: clarify empty MCP view

* feat: make MCP command a manager view

* fix: make MCP manager usable in TUI

* fix: make MCP manager selectable in TUI

* fix: simplify MCP manager popup

* feat: add searchable MCP marketplace

* fix: address MCP review issues

* fix: address MCP follow-up review

* feat: add MCP setup wizard

* feat: detect MCP setup requests in chat

* fix: address MCP manager review feedback

* fix: avoid hijacking MCP chat prompts

* fix: persist MCP view cache fallback
2026-06-13 22:20:42 +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
Anandan 761b797863 Render assistant Markdown in TUI (#187)
* Render assistant Markdown in TUI

Add a lightweight assistant Markdown renderer for streaming and completed transcript rows, including inline bold, code/emphasis marker cleanup, pipe tables, adaptive row separators for wrapped tables, and plain-text output for selection metadata.

Tests cover streaming markdown, completed rows, selection metadata, table wrapping, compact tables, inline markers, and width bounds with neutral fixtures.

* Refine TUI Markdown table rendering

Add outer borders for rendered assistant tables, add row separators for larger comparison tables even on wide terminals, and normalize common HTML line breaks inside table cells.

Tests cover bordered tables, wide multi-row tables, compact tables, and HTML break normalization.

* Fix Markdown inline parser edge cases

Preserve literal stars, prevent code spans from inheriting bold state, and avoid inserting spaces before punctuation split by inline marker boundaries.

Add regression checks for plain/selectable ANSI-free text and final-row done metadata on rendered Markdown rows.

* Preserve coding literals in TUI Markdown

Tighten inline delimiter parsing so code spans, Python dunder names, globs, and arithmetic stars stay literal while normal bold/emphasis still renders.

Add regressions for the reviewed code-span, dunder, glob, and operator examples.
2026-06-13 13:35:11 +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 5e68c78824 wedge stage 10: distribution (skill/plugin install, tools make) (#184)
* stage 10: distribution (skill/plugin install, tools make)

Add a file-backed distribution surface so skills and plugins can be
installed from a git URL or local path, and new plugin-tools can be
scaffolded. No central registry: a source is just a git URL or path.

- internal/skills/install.go: Install/Remove/Info + skills.lock. Validates
  SKILL.md frontmatter via the existing parser, copies it into the skills
  dir, and records a sha256 content hash. Reinstall surfaces the hash
  change; a clash with a different source is refused without --force.
- internal/plugins/install.go: same flow validated against the plugin
  manifest schema, copying the whole plugin tree (minus .git) so the
  plugin is activatable. plugins.lock records source + hash.
- internal/tools/scaffold.go: generate a prompt-gated plugin-tool skeleton
  (manifest + runnable shell/node/python entry stub) in the toolbox dir.
- CLI: zero skill {add,info,remove}, zero plugin {add,remove}, and a new
  zero tools {make,list}; wired in app.go/extensions.go/skills.go.

Safety: fetch honors the process environment (proxy/egress) and disables
git credential prompts; install never executes fetched content (skills are
markdown; plugin install scripts are never run) and installed plugins still
go through normal activation with permission gating.

* stage 10: make no-usable-name install test cross-platform

The test used a whitespace-only source dir name to force the no-usable-name rejection, which is not creatable on Windows (Smoke failure). Switched to a dir base that validSkillName rejects on every platform (contains "..") while staying creatable on Windows; the blank frontmatter still falls back to the unusable dir base, exercising the same rejection.

* stage 10: address review (tree hash, tools-list diagnostics, --json guards)

* stage 10: make canonical-source tests cross-platform (Windows)

Two Windows Smoke failures from the canonical-source review fix:
- TestInstallSameLocalSourceDifferentSpellingIsNotAClash (skills + plugins) used filepath.Rel(cwd, tempdir), which errors on Windows when the temp dir (C:) and repo (D:) are on different drives. Switched to a same-directory alternate spelling (redundant /./), which still exercises canonicalSource without a cross-drive relative path.
- TestRunSkillAddInfoRemove asserted the raw source path, but canonicalSource resolves symlinks/8.3-short-paths, so the stored+displayed source differs on Windows. Normalize the source via EvalSymlinks up front so the assertion matches on every platform.

* stage 10: address CodeRabbit nitpicks (variadic append, header comment)
2026-06-13 10:43:31 +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
KRATOS 6beb0b0908 wedge stage 09: finish plugin wiring (tools/hooks/skills) (#185)
* stage 09: finish plugin wiring (tools/hooks/skills)

Activate resolved plugin manifests so their declared tools, hooks, and
skills take effect at runtime instead of being parsed-but-unconsumed.

- internal/plugins/activate.go: Activate() registers each plugin tool into
  the tools.Registry via a command-runner adapter (JSON args on stdin,
  stdout/exit -> tools.Result, ${AGENT_PLUGIN_ROOT} expansion + env),
  builds hooks.Definition values for plugin hooks, and exposes plugin
  skill search roots. Permission maps allow/prompt/deny -> tools.Safety;
  allow only survives load-time clamping when manifest auto-approval is
  enabled, so mutating plugin tools are not auto-approved by default. A
  plugin-aware skill tool merges the default skills dir with plugin roots
  (recording duplicate names, never crashing). Malformed plugins/extensions
  are skipped with a warning; activation order is deterministic.
- internal/cli: activatePlugins() invokes activation in both bootstrap
  paths (interactive TUI in app.go and one-shot exec in exec.go), after
  core/specialist/MCP registration. newHookDispatcherWithExtra folds plugin
  hooks into the dispatcher's hook set. Fails open: a bad plugin warns and
  is skipped, never wedging startup.
- internal/plugins/plugins.go: update the stale 'not yet registered' note.

* stage 09: normalize plugin command path separators for Windows

expandPluginRoot kept the manifest's forward slashes, so a ${AGENT_PLUGIN_ROOT}/bin/x command became a mixed-separator path on Windows (C:\...\001/bin/x), failing the path tests. Added expandPluginRootPath (filepath.FromSlash on the placeholder-expanded executable path only; args keep their slashes since they may be URLs/flags) and used it for the tool and hook command paths.

* stage 09: address plugin wiring review

- surface per-plugin load diagnostics on stderr (not just top-level errors)
- skip plugin tools whose name collides with an already-registered tool
- resolve manifest-relative plugin skill paths against the plugin dir

* stage 09: fall back to plugin path/id when manifest path unknown in load diagnostic
2026-06-13 10:21:14 +05:30
KRATOS 588f8752f5 wedge stage 12: PDF ingestion (#183)
* stage 12: PDF ingestion

Add PDF ingestion to internal/imageinput so specs, design docs, and tickets
that arrive as PDF can be attached the same way images are.

- internal/imageinput/pdf.go: LoadDocument detects PDFs by magic bytes
  (%PDF-, never extension alone), enforces a 32 MiB raw-file cap and a
  256 KiB extracted-text cap (truncated with a marker, not rejected), and
  extracts the text layer in pure Go via github.com/ledongthuc/pdf. The
  parser can panic on malformed structures, so extraction is wrapped in a
  recover that turns a bad PDF into a clean error. A textless PDF with no
  rasterizer returns an explicit "no extractable text; OCR not available"
  error rather than silent empty success.
- Dependency posture: default build is pure-Go text extraction (no CGO, no
  transitive deps) so the static binary still cross-compiles. The optional
  poppler tools (pdftotext / pdftoppm) are used for richer extraction and
  page rasterization ONLY when present on PATH (same posture as the LSP
  language servers); when absent, extraction degrades to the pure-Go text
  path and never errors.
- Vision path: with a vision model and pdftoppm available, the first N pages
  (default 10) render to PNG and flow through the existing image pipeline
  (NormalizeImageMediaType, per-image cap, allow-list). Without a rasterizer
  it degrades to the text layer.
- TUI integration: /image <file.pdf> routes to the document path. The text
  layer attaches for any model and is prepended to the next prompt as a
  labeled preamble; rendered pages attach as images for vision models. The
  chip row and /image clear cover documents too.

Tests cover magic-byte detection, text extraction on a generated fixture,
both size caps, the no-text/no-raster message, malformed-input safety, the
pure-Go fallback, and the TUI attach/submit/clear wiring. go build, go test,
and gofmt are clean; CGO_ENABLED=0 cross-compiles for linux/amd64,
windows/arm64, and darwin/arm64.

* stage 12: address review (pdf page count, text cap, content-based routing)
2026-06-13 10:19:41 +05:30
KRATOS 59c332638d wedge M2: MCP server resources/prompts + sandbox scoped egress + MCP OAuth (#179)
* wedge stage 06: MCP server resources + prompts

Advertise resources and prompts capabilities in the initialize response and
handle resources/list, resources/read, prompts/list, and prompts/get alongside
the existing tools methods.

Resources enumerate in-scope workspace files via the shared workspace scanner
(reusing its gitignore-style exclusions) and read them read-only with strict
scope enforcement: traversal and out-of-scope absolute paths are rejected with
a JSON-RPC error and no contents, and binary files come back as base64 blobs.

Prompts expose a curated set of templates with simple {{arg}} substitution;
unknown prompt names return an error.

* wedge stage 07: sandbox scoped egress + auto-allow-bash

Add a middle-ground network mode between full allow and full deny:

- types.go: NetworkScoped network mode; Policy.AllowedDomains /
  Policy.DeniedDomains; Policy.AutoAllowBashWhenSandboxed plus its
  ZERO_SANDBOX_AUTO_ALLOW_BASH env surface (off by default).
- egress.go: in-process loopback (127.0.0.1:0) filtering proxy handling
  HTTP CONNECT (HTTPS tunnel) and plain HTTP. Allows only AllowedDomains
  minus DeniedDomains via exact-or-subdomain matching (github.com matches
  api.github.com but not notgithub.com or github.com.evil.com); deny wins.
  Denied targets get 403 / refused CONNECT. Decisions are logged through
  the repo redaction so query tokens never hit the audit trail. Fails
  closed: empty allowlist is rejected at construction.
- runner.go: NetworkScoped starts the proxy and routes the sandboxed
  process through it (HTTP(S)_PROXY/ALL_PROXY + NO_PROXY into the sandbox
  env; Linux keeps --unshare-net; macOS profile denies general network
  but permits localhost:proxyPort). Empty-allowlist scoped collapses to
  deny (effectiveNetwork). Any proxy-start error denies the build rather
  than falling back to open network. NetworkAllow/NetworkDeny unchanged.
- engine.go / registry.go / bash.go: honor AutoAllowBashWhenSandboxed -
  auto-allow bash without a prompt only when a wrapping sandbox is active
  for the command; ignore the flag when the sandbox is inactive. Preflight
  network gate now uses effectiveNetwork so a misconfigured scoped policy
  still fails closed. Bash defers plan.Cleanup() to shut the proxy down.

Default behaviour is unchanged for existing NetworkAllow / NetworkDeny
users and for the off-by-default auto-allow flag.

* wedge stage 08: MCP OAuth client

Add an OAuth 2.0 + PKCE authorization-code flow so ZERO can connect to
remote MCP servers that require OAuth.

- internal/mcp/oauth.go: authorization-server metadata discovery with
  config fallback/override, S256 PKCE, loopback redirect listener, state
  (CSRF) validation, code+verifier token exchange, refresh, and optional
  dynamic client registration. Tokens are never logged.
- internal/mcp/oauth_store.go: per-server token persistence in a 0600
  JSON file under the config dir, with a redaction-safe status summary
  that omits the token material.
- internal/mcp/network_client.go: OAuth-configured servers get a round
  tripper that attaches the bearer token and, on 401, refreshes once and
  retries; refresh failure surfaces an actionable re-login message.
- internal/mcp/config.go + internal/config: an MCP server entry may
  declare auth: "oauth" with optional client registration / explicit
  endpoints. Non-OAuth servers are unaffected.
- zero mcp oauth {login,logout,status} wired into the CLI surface;
  status reports presence/expiry, never the token.

* wedge M2: address review — 2 security fixes, portability, robustness (#179)

Security (Critical):
- resources: resolve symlinks BEFORE the scope check (and resolve roots) so an
  in-workspace symlink to an out-of-scope file (e.g. link -> /etc/passwd) is no
  longer readable via resources/read. resources/read echoes the requested URI
  (not the resolved path) so list/read stay consistent.
- sandbox egress: authorize the dialed URL host, not the client Host header, so
  'GET http://denied/ ... Host: allowed' can't bypass the allowlist.

Robustness/correctness:
- prompts: reject prompts/get that omits a required argument; tokenized
  substitution so {{...}} inside supplied values is preserved (not stripped).
- resources: percent-encode/decode file URIs via net/url (spaces, #, %).
- oauth: one timeout-scoped context bounds discovery + registration + callback +
  exchange; joinWellKnown preserves issuer path segments (RFC 8414).
- cli: 'mcp oauth login' rejects --json instead of silently ignoring it; the
  login test is bounded so a missed callback fails fast instead of hanging.

Portability: XDG token-store path test uses t.TempDir() so it passes on Windows
(fixes the Windows Smoke failure).

Adds regression tests for the symlink escape, Host-header bypass, required-arg
rejection, and placeholder preservation. Full go test ./... + -race green.

* wedge M2: fail closed on unenforceable scoped egress (review #179)

Addresses the two P1 scoped-egress enforcement gaps from review:

1. Policy-only fallback ran NetworkScoped with unrestricted networking. On Windows / any host without a native backend, 'network: scoped' with allowedDomains silently got open host networking because Evaluate permitted network-risk tools (expecting a proxy) while BuildCommandPlan ran a direct plan with no proxy. Evaluate now collapses scoped to deny when the active backend cannot enforce egress, so network-risk tools fail closed.

2. Bubblewrap 'scoped egress' could not reach the proxy. --unshare-net isolates the netns and there is no bridge to the host-loopback proxy (the comments claimed one; no code added it), so allowlisted egress never worked. Bubblewrap no longer starts an unreachable proxy: scoped collapses to deny (--unshare-net, no proxy env), matching Evaluate. Comments corrected; a real relay (e.g. slirp4netns) is left as future work.

Mechanism: new Backend.ScopedEgress capability (true only for sandbox-exec, which shares the host net under a seatbelt profile restricting outbound to the proxy port) + Backend.EnforcesScopedEgress(). sandbox-exec scoped egress is unchanged. Regression tests cover enforcing vs non-enforcing backends in Evaluate and the bubblewrap fail-closed plan.
2026-06-12 20:41:59 +05:30
KRATOS ae4ce8aae2 wedge build: web_search + LSP client/diagnostics (stages 01-03) (#175)
* stage 01: web_search tool

Adds a web_search tool alongside web_fetch: discovers URLs via a pluggable
searchBackend (env-configured generic JSON backend; swappable for Exa/You/
Tavily/Brave) and formats ranked results as a numbered list. Registered in
CoreNetworkTools().

Deviation from the stage spec: the spec said readOnlySafety, but the repo
enforces (TestCoreNetworkToolsExposePromptMetadata) that every core network
tool is prompt-gated network egress — so web_search matches web_fetch's
posture instead of being auto-allowed. Necessary fallout from adding the
tool: knownToolNames (+web_search), the network-tools metadata test (now
covers both), and a stale 'unknown tool' fixture that had used web_search.

* stage 02: LSP client foundation

New internal/lsp package: a direct (self-spawned, stdio) Language Server
Protocol client so unattended runs see real compiler diagnostics without an
open editor.
- client.go: JSON-RPC 2.0 with LSP Content-Length framing; concurrent
  Call/Notify, id-matched responses, server->client request auto-reply,
  notification handler, context cancellation, read-loop teardown.
- server.go: process lifecycle (spawn via os/exec, initialize/initialized
  handshake, graceful shutdown+exit with kill fallback). Handshake factored
  out (performInitialize) for pipe-based testing.
- registry.go: extension -> server command (.go/.ts/.tsx/.js/.jsx/.py/.rs),
  languageId map, PATH availability via exec.LookPath.
- types.go: minimal LSP types + PathToURI/URIToPath (Windows-drive aware).
Stdlib-only; tested over in-memory pipes with a stub server (no real gopls),
including -race for the concurrent id router.

* stage 03: LSP document sync + diagnostics

Makes the LSP client produce actionable signal so ZERO can ask 'did my edit
compile?' headlessly.
- documents.go: per-server session with document lifecycle (didOpen/didChange
  full-sync/didClose), a URI->diagnostics store fed by publishDiagnostics, and
  WaitForDiagnostics with a publish-baseline + 300ms quiet debounce (servers
  never signal 'analysis complete').
- diagnostics.go: FormatDiagnostics (path:line:col: severity: message, 1-based),
  HasErrors, FilterBySeverity, severity labels.
- manager.go: Manager — one lazily-started, reused server per language (keyed by
  binary), routes a file to the right server, Check(ctx,path,text) -> settled
  diagnostics, graceful Shutdown; unknown extension -> (nil,nil), not an error.
Concurrency-safe; tested under -race over in-memory pipes with a stub server
(no real gopls). FormatDiagnostics takes the path explicitly since a Diagnostic
carries only a range, not its document.

* wedge: address review on #175 (LSP robustness + web_search provider)

- lsp/client.go: Close() now disables the client — Call/Notify reject after
  close instead of leaking a pending entry / writing to a dead conn.
- lsp/documents.go: serialize same-document syncs through the Notify write via a
  per-URI lock and commit version/open only on success; drop delayed
  publishDiagnostics for a superseded (older positive) version;
  WaitForDiagnostics now reports whether a fresh publish arrived.
- lsp/manager.go: Check returns (nil,nil) when no publish newer than baseline
  arrives (don't return a stale prior result for new text); Shutdown joins and
  returns per-server shutdown errors instead of always reporting success.
- lsp/diagnostics.go: FormatDiagnostics collapses embedded whitespace so each
  diagnostic stays one physical line.
- tools/web_search.go: forward ZERO_WEBSEARCH_PROVIDER in the request so the
  config knob isn't inert.
Adds tests: close-disables-client, stale-version drop, multi-line message
collapse, Shutdown error propagation, httptest backend (provider+parse).

* wedge: degrade gracefully when an LSP server binary is missing (#175 review)

Manager.Check translated a missing-binary start error (exec.ErrNotFound)
into (nil,nil) so a configured extension on a machine without gopls/
pyright/rust-analyzer degrades exactly like an unsupported extension —
LSP diagnostics are an opportunistic layer, not a hard dependency. Other
start failures still surface. Adds a test injecting an exec.ErrNotFound
starter.

* wedge stage 04: self-correct loop (#176)

* stage 04: self-correct loop

After a successful mutating tool call, verify the changed files (LSP
diagnostics + project tests) and feed failures back to the model to fix —
bounded and autonomy-gated, so an unattended run fixes its own mistakes
without looping forever.

- internal/agent/selfcorrect.go: SelfCorrector with injectable diagnosticsChecker
  (lsp.Manager adapter) and projectVerifier (verify.DetectPlan+Run); AfterEdit
  produces a redacted, model-facing corrective message; per-run attempt ceiling;
  low autonomy reports without auto-attempting; medium/high auto-correct.
- internal/agent/loop.go: invoke AfterEdit after a successful mutating tool call
  (ChangedFiles non-empty), appending feedback to messages. nil SelfCorrect (the
  default) is a no-op — read-only tools never trigger it.
- internal/agent/types.go: add Options.SelfCorrect *SelfCorrector (the explicit
  options-struct change).

Single-pass verify per call (the agent loop + model edits provide the multi-
attempt cycle, not selfverify's same-command re-runs). Tested with fakes (no
gopls/shell): fail-then-pass, max-attempts abort, read-only skip, low-autonomy
report-only, LSP-only failure, secret redaction; -race clean.

CLI activation (a --self-correct flag building the SelfCorrector in exec.go) is
intentionally left out to honor this stage's file scope — the engine is exported
and ready to wire.

* wedge: propagate file-read error in self-correct LSP checker (#176 review)

CodeRabbit/golangci-lint (nilerr) flagged lspDiagnosticsChecker.Check returning
(nil, nil) after os.ReadFile fails. Propagate the error instead; the sole caller
(inspect) already skips files whose Check errors, so a deleted/unreadable file
still degrades quietly while future callers can decide. Adds a regression test.

* wedge: surface non-degradable verify errors in self-correct (#175 review)

SelfCorrector.inspect silently continued on every checker.Check error and
ignored verifier errors, so a real LSP startup/sync failure, an unreadable
changed file, or a failed plan detection produced report.Failed=false ->
OutcomePassed with no signal. Manager.Check already degrades missing servers to
(nil,nil) and DetectPlan returns an empty plan (not an error) when nothing is
detected, so a non-nil error from either is a genuine failure. Record such
errors in CorrectionReport.InspectErrors, fail the pass, and surface them in the
model feedback. Adds regression tests for both paths.

* wedge: drop third-party search-provider names from web_search comments

Keep code/comments provider-neutral: describe the backend interface as 'any
hosted search API' rather than naming specific services.

* wedge: defer self-correct feedback until after the tool-call batch (#175 review)

Self-correct feedback was appended as a user message inside the per-tool-call
loop, so a multi-tool-call turn whose first (mutating) call failed verification
produced: assistant tool_use batch -> tool_result 1 -> user feedback ->
tool_result 2. A user message interleaved between tool_results breaks strict
provider replay and later session replay/compaction.

Accumulate feedback during the batch and append it once after the loop (same
after-batch pattern already used for mid-run model escalation), so every
advertised tool call keeps its tool_result contiguous. Adds a two-tool-call
regression test asserting the feedback lands after both tool_results.

* wedge: batch self-correct AfterEdit once per turn over union of changes (#175 review)

Deferring only the feedback emission still ran SelfCorrect.AfterEdit once per
mutating tool call, which (a) consumes the per-run attempt budget multiple times
in a single turn and (b) verifies an intermediate edit that a later call in the
same batch already superseded — AfterEdit is documented as one per-run instance
holding attempt state, so this is a correctness issue, not just transcript shape.

Aggregate every mutating tool's ChangedFiles during the batch and call AfterEdit
once afterward over the deduped union, appending its feedback once (still after
all tool_results, preserving contiguity). Adds dedupeStrings and a regression
test asserting two writes in one turn consume exactly one attempt and emit one
feedback message.
2026-06-12 15:13:05 +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 a6d6939bc5 audit: fix isolated remaining findings (redaction, secrets, rune-safety, credential status) (#174)
* audit: fix redaction sibling-DAG false cycle, hoist URL regex, widen OpenAI key pattern

- redaction: seen-set now tracks only the current DFS path (delete pointer
  after recursing) so a shared reference reached via a sibling branch is no
  longer reported as CircularReference; true cycles still detected. (audit
  redaction.go:222)
- redaction: redactURLPasswords compiles its regexp once at package scope
  instead of per call. (audit redaction.go:362)
- secrets: openai_key body allows - and _ so sk-proj-/sk-svcacct- keys match,
  not just legacy sk-<alnum>. (audit scanner.go:35)

* audit: rune-safe cron run-error truncation and commit-subject length

- cronTruncate now cuts on a UTF-8 rune boundary (via cutRuneBoundary) so a
  persisted cron run-error excerpt can't end in a split rune. (audit cron_run.go:179)
- zerogit ValidateMessage counts runes, not bytes, so a valid non-ASCII commit
  subject under 72 chars is no longer rejected. (audit zerogit.go:207)

* audit: report auth-header-only profiles as having a credential

ProviderSnapshot.APIKeySet was true only for a non-empty APIKey, so a profile
authenticated solely via AuthHeaderValue rendered as 'not set' in the command
center. Treat either credential as set. (audit contracts.go:166)

* audit: address review on #174 (credential-presence coverage + consistency)

- Add ProviderSnapshotFromProfile test covering APIKeySet for api-key-only,
  auth-header-only, both, and neither (the AuthHeaderValue path CodeRabbit
  flagged as untested).
- doctor.go: report the provider 'apiKey' diagnostic as set when EITHER APIKey
  or AuthHeaderValue is present, matching ProviderSnapshot.APIKeySet — closing
  the same auth-header-only gap at the other credential-check site.
- scanner_test: also assert the [REDACTED:openai_key] placeholder is present
  (matches the pattern used by the existing redaction test).

* audit: trim credentials + surface doctor credential presence (#174 review)

- ProviderSnapshot test: add whitespace-only api-key / auth-header cases so the
  TrimSpace behavior stays covered.
- doctor.go: trim both APIKey and AuthHeaderValue before the presence check
  (matches ProviderSnapshot.APIKeySet) AND move the indicator off the sensitive
  'apiKey' detail key — check() runs RedactValue, so 'apiKey' was always scrubbed
  to [REDACTED], leaving the set/not-set indicator invisible. Now reported under
  'credentialConfigured' so it actually surfaces; added a doctor table test.

No consumer read the old detail key.

* audit: honor AuthHeaderValue in model discovery + base-URL redaction (#174 review)

Two credential-presence consistency gaps flagged on #174:

- providermodeldiscovery: the /models probe gated on (and sent Authorization
  from) profile.APIKey only, so an auth-header-only profile was skipped/sent
  unauthenticated. Gate via discoveryHasCredential (APIKey OR AuthHeaderValue)
  and build the request with providerio.ApplyAuthHeaders, matching how the live
  providers authenticate (honors AuthHeaderValue, AuthHeader, AuthScheme,
  CustomHeaders). APIKey-only behavior is unchanged.
- zerocommands.redactProviderBaseURL now also redacts AuthHeaderValue if it
  leaks into the base URL (variadic secrets, empties dropped).

Adds regression tests for auth-header-only discovery, discoveryHasCredential,
and auth-header redaction in the base URL.

* audit: doctor credential-presence test parity with contracts_test (#174 review)

Add the 'both' and 'whitespace auth header only' cases so
TestProviderConfigCheckCredentialPresence matches the six cases in
contracts_test.go's TestProviderSnapshotAPIKeySetCountsAuthHeaderValue.
2026-06-12 12:48:24 +05:30
Anandan fcfdb873d0 Polish main TUI surface (#168)
* Polish main TUI surface

Simplify the empty chat surface, replace starter prompt chips with a cleaner brand state, move model and permission state into the composer frame, quiet the footer status, and improve composer word-editing keybindings.

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

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

Tested: git diff --check

* Polish autocomplete command palette

* Polish TUI file mention picker

* Address TUI review feedback

* Keep autocomplete anchored above composer

* Center autocomplete over chat viewport

* Layer autocomplete overlays over transcript

* Prefill argument commands from palette

* Show argument hints for command completions

* Polish TUI provider setup

* Polish TUI picker and composer metadata

* Add two-step mouse selection in TUI

* Polish TUI mouse selection flows

* Make provider wizard navigation test hermetic

* Fix TUI review edge cases
2026-06-11 22:34:46 +05:30
KRATOS 06ea4f004b Edit safety: file read/write tracking, external-modification detection, post-apply hooks (#172)
* Edit safety: file version tracking, conflict detection, hook wiring

Phase 1 — FileTracker (internal/tools/file_tracker.go): per-session
map[absPath]{sha256, size, mtime}; CheckConflict flags drift only against
a known baseline, so first-touch writes are unaffected. nil = no-op.

Phase 2 — external-modification detection: read_file records the
whole-file baseline; edit_file and write_file --overwrite refuse to
clobber a file whose on-disk content drifted from it and tell the model
to re-read first, then re-baseline to what they wrote. apply_patch
forgets the files it rewrote (git apply has its own context guard).
Threaded via RunOptions/agent.Options FileTracker, built once per session
in the exec, spec, and TUI paths.

Phase 3 — post-apply validation via hooks: the dormant hooks.Dispatcher
is wired into executeToolCall. beforeTool hooks can veto a call
(DenialHookBlocked); afterTool hook output is scrubbed and surfaced to
the model. DispatchOutcome now collects hook stdout/stderr. Per-session
dispatcher built from merged user+project hooks.json; fails open and runs
nothing until a hook is configured.

* edit-safety: address review — fail closed on read, redact hook reason

- write_file overwrite: if a tracked file cannot be re-read to verify it,
  fail CLOSED (refuse the overwrite) instead of silently proceeding — the
  read error previously skipped the conflict check and could clobber an
  externally modified or replaced file.
- blockedByHookResult: scrub outcome.Reason through redaction before placing
  it in model-visible ToolResult.Output; this intercepted path bypassed the
  registry's normal output-redaction boundary.
- TestHashContentIsStableAndDistinguishing: compare separately stored hashes
  so the stability check is not a same-expression comparison (SA4000).

* edit-safety: flag ToolResult.Redacted when hook output is scrubbed

appendHookFeedback now reports whether scrubbing changed the afterTool
feedback, and blockedByHookResult reports whether it scrubbed the reason;
both set ToolResult.Redacted so the hook-intercepted paths match the
registry's redaction contract. Adds tests asserting a secret-bearing hook
reason/feedback is scrubbed and flips Redacted.
2026-06-11 20:34:35 +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
Vasanth T 0f103174d0 Add session context compaction flow (#166)
* Add session context compaction flow

* Animate manual compaction flow

* Simplify compact progress copy

* Render compact progress as compression card

* Render compact completion as success card

* Fix compaction review findings

* Fix compaction resume fallback
2026-06-11 20:16:22 +05:30
KRATOS 8d4a530423 test: isolate session store in exec/tui tests so go test stops writing to $HOME (#171)
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.
2026-06-11 18:30:45 +05:30
KRATOS 8409cb4627 tui/resume: list only standalone conversations, not sub-runs (#169)
The /resume picker called sessions.Store.List() unfiltered, so it showed every
session directory — including the child (specialist/sub-agent) and spec
draft/impl sessions an agent spawns by the dozen per conversation. That flooded
the picker (the reported '… 779 more · /resume <id>') even though the TUI
creates exactly one session per conversation.

Add Store.ListResumable()/LatestResumable() (and an IsResumableKind predicate)
that keep only regular and user-fork sessions, and use them for the picker and
for '/resume latest' so latest never lands on a newer child/spec sub-run. An
explicit '/resume <id>' still resolves any session by id.

Covered by TestListAndLatestResumableExcludeSubRuns and
TestResumePickerExcludesSubRunSessions.
2026-06-11 16:54:58 +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
gnanam1990 1a60eabf90 providerhealth: assert the last-error contract in dialValidatedAddrs test
Use distinct per-attempt errors and assert errors.Is(err, lastErr) plus the
attempt count, so a regression returning the first failure (or short-circuiting)
is caught instead of slipping past a bare non-nil check.
2026-06-11 15:02:09 +05:30
gnanam1990 0c37093974 audit: address PR review findings (security, correctness, tests)
Fixes from the code review on the critical/high audit branch:

- tools/apply_patch: parseHunkCounts now reads only the range section between
  the opening and closing "@@". A crafted section heading like
  "@@ -1,1 +1,1 @@ +1,999999" could otherwise overwrite the line count, wedge
  the parser in hunk mode, and swallow later file headers so they escaped
  validatePatchPaths / recheckPatchWriteTargets — a workspace-confinement bypass.
- providerhealth: the SSRF-hardened dial now tries every validated address in
  order instead of pinning addrs[0], so a dual-stack/multi-record provider isn't
  failed by a single dead record. Extracted dialValidatedAddrs for coverage.
- hooks/dispatch: a beforeTool hook killed by its deadline now fails CLOSED
  (vetoes the tool) instead of fail-open. A launch failure (missing binary)
  still fails open so a misconfigured hook can't wedge every tool call.
- cron: Store.Get returns a distinct ErrJobNotFound sentinel; cron_run only
  treats that as "removed during run" and still records/persists on a transient
  read error instead of silently dropping the run.
- providers/anthropic: closeOpen finalizes thinking buffers still open at stream
  end (SSE ended after thinking_delta/signature_delta but before
  content_block_stop), so they survive in the done event's ReasoningBlocks for
  replay. Finalized in index order.
- providers/gemini: corrected the stale thinkingConfig comment (no IncludeThoughts
  field; thought summaries are suppressed by skipping part.Thought parts).

Tests: regression coverage for each fix, plus the requested nitpicks —
EOF-terminated MCP line, recipe-prompt propagation assertion, checked
file.Close() in session store tests, and a shortened bash process-group timeout
test via the bashWaitDelay hook.

Note: declined the suggestion to replay a "signature" on redacted_thinking
blocks — Anthropic's redacted_thinking carries only an opaque "data" field; the
signature is for regular thinking blocks, and the stream never populates one for
redacted blocks.
2026-06-11 15:02:09 +05:30
gnanam1990 28b32297f6 hooks: add a Dispatcher that runs and audits configured hooks
The package had all the primitives (Select, ConfigStore, AuditStore) but no
caller ever ran a hook. Add a Dispatcher that selects the hooks for a lifecycle
event, runs each command (no shell) with the event payload on stdin under a
timeout, and records started/completed audit events. A beforeTool hook that
exits non-zero vetoes the tool and stops the chain; other events are advisory.
A hook that cannot be launched is recorded as an error but never blocks. The
command runner is injectable for testing.
2026-06-11 15:02:09 +05:30
gnanam1990 ea2920ae0f sessions: only tolerate a torn tail when the final line is unterminated
The torn-tail tolerance dropped any malformed final line, including a
complete-but-corrupt line ending in a newline — silently swallowing real
corruption. A genuine torn tail is an incomplete write with no trailing
newline; gate the tolerance on that so a fully-flushed corrupt line fails
loudly again, while a truncated final line is still recovered.
2026-06-11 15:02:09 +05:30
gnanam1990 de9771cee8 providers/gemini: forward reasoning effort as a thinking budget
Map a requested reasoning effort to a Gemini thinkingBudget (capped at the
lowest per-model ceiling). Capture the thoughtSignature each functionCall part
carries and preserve it on the tool call (via a new StreamEvent/ToolCall
signature field), then replay it on the functionCall part in later turns so
multi-turn function calling with thinking is not rejected. Thought-summary
parts are skipped so reasoning never leaks into the answer. No effort = no
thinking config, so default runs are unchanged.
2026-06-11 15:02:09 +05:30
gnanam1990 44411578e9 providers/anthropic: forward reasoning effort as extended thinking
Map a requested reasoning effort to an Anthropic thinking budget and enable
extended thinking, raising max_tokens so the budget plus a response both fit.
Crucially, capture the streamed thinking / redacted_thinking blocks (text +
signature) and preserve them on the assistant message so they are replayed
verbatim at the start of the next turn — without this, Anthropic rejects every
tool-using follow-up. Adds zeroruntime carriers (Message.Reasoning, ToolCall.
Signature, ReasoningBlock) shared with the upcoming Gemini work. No effort =
no thinking, so default runs are unchanged.
2026-06-11 15:02:09 +05:30
gnanam1990 24e15e2138 providers/openai: forward reasoning effort to the chat completions request
zeroruntime.CompletionRequest had no effort field, so agent.Options.Reasoning
Effort never reached the provider (cli/exec.go even noted it was 'not yet
forwarded'). Add CompletionRequest.ReasoningEffort, thread it through every
request the agent loop builds, and map it to OpenAI's reasoning_effort. The
CLI gates the forwarded value against the resolved model: known non-reasoning
models send nothing (matching the existing 'ignoring' advisory), known
reasoning models send their effective level, and unknown custom endpoints
forward the requested value as-is. Anthropic/Gemini mapping is handled
separately.
2026-06-11 15:02:09 +05:30
gnanam1990 32a6922916 agent: surface response truncation (finish_reason) to the user
Providers already normalized finish_reason into collected.FinishReason, but
the agent loop never read it, so a final answer cut off at the output token
cap (or by a content filter) was silently presented as complete. Carry the
turn's finish reason into agent.Result, add Truncated()/TruncationNotice(),
and emit a warning from the exec writer and a system row in the TUI when the
response was truncated.
2026-06-11 15:02:09 +05:30
gnanam1990 8c7761e8e7 sandbox: allow /dev nodes and temp dirs in the sandbox-exec profile
The macOS sandbox-exec profile made only the workspace writable, so
ubiquitous operations failed with 'Operation not permitted' — `> /dev/null`
and mktemp among them. The bubblewrap backend already grants these via
--dev /dev and --tmpfs /tmp; bring the sandbox-exec profile to parity by
allowing the standard device nodes and the system temp trees. The workspace
remains the only writable project location.
2026-06-11 15:02:09 +05:30
gnanam1990 ce50ad9789 providerhealth: harden default connectivity probe against SSRF
The default probe used http.DefaultClient, which (1) followed redirects
without revalidating the target, so a 3xx to an internal address bypassed
the pre-flight endpoint check, and (2) re-resolved DNS at dial time,
leaving a rebinding TOCTOU window after validateEndpoint. Build a dedicated
client that revalidates every redirect hop (capped at 5) and validates +
pins the resolved IP at dial time, dialing the IP literal so the kernel
cannot re-resolve to an unsafe address.
2026-06-11 15:02:09 +05:30