Commit Graph
11 Commits
Author SHA1 Message Date
Fayupable dcf229fecd refactor(tests): replace duplicated wait-for helpers with testutil.WaitFor (#1039)
Fixes #1023
2026-09-15 13:16:54 +08:00
KRATOS 90014210d2 fix(daemon): publish status files atomically (#949)
* fix(daemon): publish status files atomically

* fix(daemon): bind status publication to directory handle

* fix(daemon): validate status directory access

* fix(daemon): accept current token directory owner

* fix(daemon): migrate owned runtime directories safely

* fix(daemon): open Windows security handle relatively

* fix(daemon): use current NT directory object

* fix(observability): harden existing crash directories

* fix(security): bind crash report creation to private root

* fix(observability): atomically publish crash reports

* fix(observability): preserve committed crash reports

* fix(observability): revalidate committed crash path

* fix(daemon): secure runtime lifecycle boundaries

* fix(daemon): bind status ownership through cleanup

* fix(daemon): preserve Windows status security

* fix(daemon): retain trusted runtime root for lifecycle

* fix(daemon): roll back redirected socket binds

* fix(daemon): address status publication review
2026-09-12 18:18:12 +05:30
KRATOS 6fe0d1edcd fix(lockutil): use kernel-held advisory locks (#950)
* fix(lockutil): use kernel-held advisory locks

* fix(lockutil): harden cross-platform lock files

* test(lockutil): require one concurrent holder
2026-08-25 21:20:18 +08:00
euxaristia da41c3a75b fix(lock): prevent POSIX lock file overwrite and leak on Windows/Unix (#628)
* fix(lock): prevent POSIX lock file overwrite and leak on Windows/Unix

During lock reclamation, reclaiming a suspected stale lock renames the file aside and renames it back if not stale. On POSIX systems, the rename back silently overwrites any new lock file created in the gap. On Windows, it fails to rename back and leaks the stale file.

Introduce a cross-platform restoreLockFile helper:
- On POSIX: Use os.Link to link the file back (which atomic-fails if the target already exists), then remove the temporary link.
- On Windows: Use os.Rename (which safely fails if the target exists).
If restoring fails, delete the temporary file immediately to prevent leaks.

* fix(lockutil): fix Windows build by using golang.org/x/sys/windows

Addresses jatmn's follow-up review: the required Windows smoke check was
failing because syscall.MoveFileEx is not exported by the standard
library's syscall package on this platform, so internal/lockutil (and
everything importing it: cron, daemon, hooks, oauth) failed to compile
on Windows.

Switched to golang.org/x/sys/windows, which does export MoveFileEx with
the same signature, already a direct module dependency. Verified on a
real Windows machine that the no-flags call still fails with
ERROR_ALREADY_EXISTS when the destination exists, and that this
satisfies errors.Is(err, os.ErrExist), which cron/daemon/hooks/oauth's
reclaim paths all depend on to decide whether the sidelined lock was
genuinely superseded (safe to discard) versus some other, unrelated
failure (must be preserved).

TestRestoreLockFile already covered both the successful-restore and
fail-when-target-exists cases; it now passes on Windows.

* fix(lockutil): route lock file deletes through RemoveLockFile with Windows retry loop

* fix(lockutil): close restore split-brain and unify RemoveLockFile

RestoreLockFile could fail with errors other than os.ErrExist (hardlink-
incapable filesystems such as FAT or some FUSE/network mounts, ENOSPC,
Windows sharing violations). Callers only clean up on os.ErrExist, so any
other failure left the lock path missing with the live holder's lock
stranded in the sidelined file; the next O_EXCL create then succeeded and
two processes held the lock. RestoreLockFile now falls back to an O_EXCL
copy in that case, which preserves the no-overwrite guarantee, and a
successful restore with a failed sidelined-file cleanup reports nil
instead of a failed restore.

RemoveLockFile now has one cross-platform contract: removing an already
missing file reports nil on POSIX too, matching Windows, and daemon
release() drops its caller-side ErrNotExist filter.

Tests: TestRestoreByCopy and TestRemoveLockFile run on all platforms;
Windows-tagged tests pin the ERROR_ALREADY_EXISTS mapping to os.ErrExist
and exercise the RemoveLockFile retry loop to success and to bounded
exhaustion. The package doc comment documents the shared contract.

* refactor(swarm): extract reclaimStaleLock to mirror other lock callers

The stale-lock reclaim in acquireLock was inline, so its restore path
(a holder rotated a fresh lock in the gap between the stale check and
the rename) could not be regression-tested the way cron, daemon, hooks,
and oauth test theirs. Extract it into reclaimStaleLock with the same
shape as the other callers, behavior unchanged, and add the mirrored
regression test.

* test(hooks,oauth): add stale lock reclaim regression tests

Mirror the cron reclaim test in the two packages that lacked coverage
for reclaimStaleLock: a genuinely stale lock is reclaimed, a fresh lock
(reacquired in the gap) is restored intact rather than stolen, and a
missing lock reports no reclaim. daemon already had equivalent tests in
lock_test.go.

* fix(lock): fail closed and drop sidelined file when restore fails

A non-os.ErrExist error from RestoreLockFile means both the no-replace
restore and its O_EXCL copy fallback failed, so the lock path may be
missing while the sidelined holder is live. Callers previously swallowed
that error and their next O_EXCL create would succeed, breaking mutual
exclusion. reclaimStaleLock in cron, daemon, hooks, oauth, and swarm now
returns an error for that case and the acquire loops fail closed instead
of re-acquiring.

The sidelined .stale.* file is now removed on every restore failure, not
only os.ErrExist: once the restore has failed it has no protocol
function (release only consults the lock path), so keeping it only
leaked files.

Also replace the numeric errno literals in the Windows RemoveLockFile
retry loop with windows.ERROR_SHARING_VIOLATION and
windows.ERROR_ACCESS_DENIED.

* refactor(lockutil): extract shared ReclaimStaleLock helper

Consolidate the duplicated reclaimStaleLock functions from cron, hooks,
oauth, and swarm into lockutil.ReclaimStaleLock. Each caller now passes
an mtime-based isLive predicate to the shared helper, which owns the
rename-aside, restore-if-live, cleanup, ErrExist handling, and
fail-closed error propagation.

Daemon keeps its own reclaimStaleLock because it uses PID-based liveness
(different predicate shape and no staleAfter duration).

The per-package reclaim regression tests are removed since the shared
helper is thoroughly tested in lockutil/reclaim_test.go (dead lock
reclaimed, live lock restored, missing lock, fail-closed on restore
error, sidelined file dropped when new holder wins).

* fix(lockutil): propagate non-contention rename failures from ReclaimStaleLock

A rename-aside failure that is not a lost race previously mapped to the
benign lost-race result, so acquirers spun until their deadline and
reported a timeout instead of the real failure. Keep only a missing
source and the Windows sharing-violation/access-denied contention
errnos benign (neither Windows errno maps to os.ErrExist, and
ERROR_SHARING_VIOLATION does not map to os.ErrPermission either, so the
classification compares raw errnos) and return everything else so
callers fail closed.

Also convert daemon's private reclaimStaleLock to the shared helper so
it picks up the same contract, and reword the caller error messages
from "restore reclaimed" to "reclaim stale" since the error can now
come from the rename aside as well.

* fix(lockutil): publish restore-by-copy atomically and honor lost mailbox reclaims

restoreByCopy created the destination lock file empty before copying into
it, so a crash mid-copy could leave a live holder's lock permanently
unrepresented at the lock path. Stage the copy under a private name and
publish it with the platform's no-replace primitive instead.

swarm's mailbox lock discarded the reclaimed boolean and always retried
immediately, so a lost reclaim race (or Windows sharing-violation
contention) could hot-spin past the lock timeout instead of falling
through to the bounded wait like the other lock callers.

* fix(lockutil): shrink the reclaim/restore window with a fast replacing rename

Restoring a lock that turned out live went through the no-replace path
(plus its copy fallback on some failures) first, which is slow enough
that a fourth process's unrelated O_EXCL create could win lockPath while
it was absent for the whole call. RestoreLockFile's no-replace contract
would then report os.ErrExist and the caller silently dropped the
sidelined lock, orphaning the still-running original holder.

restoreLiveLock now tries a single replacing rename straight back first,
falling back to the no-replace path only if that fails. It shrinks the
window from a whole restore call down to roughly one syscall; it does
not close it, since the fast path can silently overwrite a competing
lock rather than detect it. A fully race-free reclaim would need an
OS-level advisory lock checked non-destructively instead of by moving
the file, which is a larger change than this.
2026-07-13 21:45:08 +08:00
euxaristia 8ea53841a5 fix(daemon): handle os.ErrPermission as collision during O_EXCL lock creation (#616)
On Windows, a concurrent holder's os.Remove leaves the lock file in a 'delete pending' state, causing a racing O_EXCL create to fail with ERROR_ACCESS_DENIED (mapped to os.ErrPermission). Treat os.ErrPermission as lock contention / already-existing lock, matching the collision checks in cron, hooks, oauth, and securefile.
2026-07-10 10:07:50 +08:00
KRATOSandgnanam1990 f7ee189ba5 fix: resolve 15 findings from the 2026-06-20 deep audit (#281)
* docs(audit): 2026-06-20 deep adversarial audit report

Audit-only deliverable (no source changed). Fresh origin/main (bfbdbb1) deep
read across 13 subsystems with per-finding adversarial re-verification, plus
reconciliation of the 2026-06-10/2026-06-13 audits.

Result: build/vet clean; race detector finds 0 data races (67/68 pkgs; the
3 internal/tools failures are -race-only timing flakes). 35 surviving findings
(1 high, 15 medium, 17 low, 2 info); 64 prior findings fixed, 14 partial,
12 still-open.

* fix(providerhealth): strip credentials on cross-host redirect (AUDIT-M10)

The connectivity probe's CheckRedirect re-validated the redirect host against
the SSRF blocklist but never stripped auth headers. Go's stdlib only auto-strips
Authorization/Cookie/WWW-Authenticate on a host change — not x-api-key,
x-goog-api-key, or custom provider headers — so a baseURL that 3xx-redirects to
an attacker-controlled public host received the provider API key verbatim.

Thread the exact set of auth-bearing header names (kind default + profile
AuthHeader + CustomHeaders keys + well-known) into the client and Header.Del
them whenever the redirect target's host:port differs from the original.
Regression test asserts the creds are dropped cross-host and kept same-host.

* fix(sessions): fsync events.jsonl append for crash durability (AUDIT-M12)

appendEventLocked wrote the event into the page cache and returned success, then
durably wrote (fsync'd) the derived metadata. A crash after the metadata fsync
but before the events.jsonl page flushed left metadata.EventCount ahead of the
log — silently dropping the last appended event, including the checkpoint a
/rewind targets. Add file.Sync() before Close so the log is at least as durable
as the metadata derived from it.

(Note: the fsync itself has no portable unit-test seam; covered by the existing
sessions append/durability suite for no-regression.)

* fix(cron): DST fall-back double-fire + claim double-grant (AUDIT-M4, AUDIT-M5)

M4: fireJob/claimFire computed the next slot from the raw sub-second now(), so
schedule.Next's DST fall-back collapse guard (which only engages for a
minute-aligned 'after') never fired — a daily job in the repeated wall-clock hour
ran twice on the fall-back day. Feed sched.Next a minute-truncated instant at the
claim and the post-exec advance (the due-check still uses the real instant so
sub-minute NextRunAt jobs are unaffected).

M5: claimFire left NextRunAt unchanged when the schedule couldn't advance
(unparseable/exhausted spec), so two concurrent schedulers both saw the job due
and both fired it. Pause the job inside the same locked claim instead: the winner
still fires once and the post-exec keeps it paused; the loser sees a non-active
job and is refused.

Tests: claimFire double-claim on an unadvanceable job (second refused, job paused)
and a DST fall-back daily job advancing past the repeat (not re-firing it).

* fix(daemon): bound the control handshake + track bridge conns for Shutdown (AUDIT-M7, M8, I1)

The remote bridge clears the connection deadline before handing the conn to
Server.ServeConn, and the local socket set none, so handleConn's two ReadControl
handshake calls could block forever — an authenticated-but-idle remote peer
pinned a bridge connection slot (32 of them exhaust the bridge), and the local
path had the same unbounded read. Set a handshake deadline in handleConn and
clear it before the (long-lived) streaming dispatch (M7, I1).

ServeConn also bypassed trackConn, so Shutdown's conns-close sweep couldn't close
a remote conn stalled in that handshake read. Track/untrack it like the local
accept loop does (M8).

Tests: handleConn returns under a shortened handshake deadline when the peer
never sends hello; Shutdown closes a ServeConn'd conn well under the 10s deadline.

* fix(swarm): atomic rename-with-verify mailbox stale-lock break (AUDIT-M13)

The stale-break compared two consecutive reads of the SAME lock file
(string(data) == string(stale)), so the equality was always true and gave no
protection: two waiters could both observe a stale lock and both os.Remove it
while a third recreated it via O_EXCL, leaving two live writers to the same
per-agent inbox (split-brain). Replace with the proven rename-with-verify used by
the cron/hooks/oauth locks: rename the stale file aside (only one racer wins the
rename), re-check the moved file's mtime is still stale before deleting, and
rename it back if a holder rotated a fresh lock in the gap.

Tests: a genuinely stale lock is reclaimed (no .stale.* leftover); a fresh held
lock is never broken. (The double-break race itself is not deterministically
unit-testable; the fix mirrors the cron/hooks/oauth pattern.)

* fix(streamjson): anchor api_key redaction so prose isn't mangled (AUDIT-M1)

The api_key secret pattern used ["'=:\s]+ as the delimiter class, so a bare
space after the marker counted — 'the api_key: foo setting' and 'apiKey value
spans' had the following word replaced with [REDACTED] in every emitted
stream-json event (text deltas, tool output, final answer). Require a real
assignment delimiter (= or :) and a credential-length body, mirroring the
already-anchored sk-/bearer patterns. Prose survives; real api_key=... secrets
still redact.

* fix(tools): snapshot exec-session lastUsedAt under its lock to fix prune race (AUDIT-L15)

sessionToPruneLocked sorted on execSession.lastUsedAt while holding only
manager.mu, but touch() writes lastUsedAt under session.mu — a data race on a
time.Time that could torn-read and mis-select the prune victim. Snapshot each
session's lastUsedAt via a session.mu-guarded getter before sorting (lock order
stays manager.mu -> session.mu; no path takes them the other way). Adds a -race
regression test driving touch() concurrently with the prune selection.

* fix(swarm): release per-job context on MaxRuns completion (AUDIT-L13)

Scheduler.run returned on MaxRuns completion without calling job.cancel(), so the
job's derived context (and the goroutine context.WithCancel spawns to propagate
parent cancellation) leaked until the whole scheduler was cancelled at session
end. Add defer job.cancel() (idempotent with Cancel/Close) so it is released on
every exit path.

* fix(agent): route post-compaction and max-turns connects through streamWithReconnect (AUDIT-L1)

The main turn connect already retries transient disconnects via
streamWithReconnect, but the post-compaction retry connect, the reactive
mid-stream reconnect, and finalAnswerAfterMaxTurns called provider.StreamCompletion
directly — a single transient hiccup on those (e.g. the final summary after a long
autonomous/cron run, or the turn right after a context-limit compaction) failed the
whole run with no retry. All three are pre-content connects, so routing them through
the helper carries no OnText-duplication risk.

* fix(cli): reject trailing args after --skip-permissions-unsafe (AUDIT-L3)

zero --skip-permissions-unsafe "fix bug" silently discarded the prompt and
launched the interactive TUI, so a scripted one-shot unsafe invocation appeared
to hang. Reject any trailing positional arg loudly (after checking for a
misplaced --add-dir first, which keeps its specific error), pointing users at
the working form: zero exec --skip-permissions-unsafe -p "...".

* fix(provideroauth): error on present-but-wrong-shape chatgpt account-id claim (AUDIT-L11)

extractChatGPTAccountID returned ("", nil) both when the id_token had no
account-id claim AND when OpenAI's auth namespace was present but the account id
was missing/empty/non-string. The latter is a claim-shape change: the caller only
warns on a non-nil error, so it silently omitted the chatgpt-account-id header and
every Codex call 401'd with no diagnostic, indistinguishable from an expired
token. Return an error for the present-but-unusable case so the login warning
fires; a genuinely absent claim still soft-skips ("", nil).

* test(tools): widen exec-session poll windows so the suite is -race-clean (AUDIT report §2)

Three exec-session timing tests assumed a 250ms-sleep / immediate child would
finish within a fixed 1000ms yield. Under -race the child (the race-instrumented
test binary) starts slowly enough to overrun it, so the tool correctly returned a
still-running session_id instead of an exit_code and the asserts failed — the
non-green -race suite the audit flagged in §2. collect() returns as soon as the
session completes, so widening the poll window to 30s makes them deterministic
under -race without slowing the passing case. Test-only; no behavior change.

* docs(audit): mark remediation status (fixed/deferred/skipped) for the 2026-06-20 audit

* docs(audit): strip trailing whitespace from the report (fix git diff --check)

* fix(tools): drain PTY output before marking an exec session done (AUDIT-M14)

A TTY exec session's stdout was copied into the buffer by a fire-and-forget
io.Copy goroutine, but startSession's Wait goroutine called markDone (and then
removed the session) as soon as command.Wait() returned — before that goroutine
had necessarily drained the master FD. The command's final output chunk (e.g. a
test runner's last PASS/FAIL line) could be lost on exit, and since the exited
session is removed it could not be polled back. This also surfaced as the flaky
TestExecCommandTTYSessionAcceptsInputOnLinux on loaded CI.

Make the PTY cleanup (called after command.Wait, before markDone) join the copy
goroutine before closing the master: the child has exited so the master EOFs once
io.Copy finishes draining; waiting for that (bounded by bashWaitDelay) guarantees
all output is buffered before the session is marked done. Closing the master
first would truncate the unread tail, so the join precedes the Close.

Linux-only path (exec_pty_linux.go); verified via GOOS=linux build+vet (the TTY
test only runs on linux/CI).

* docs(audit): mark M14 fixed (PTY drain) in remediation status

---------

Co-authored-by: gnanam1990 <[email protected]>
2026-06-21 00:44:23 +05:30
KRATOS fbbfdbf3f2 Harden background daemon: stdout integrity, lifecycle, and single-instance lock (#274)
* harden background daemon: stdout integrity, lifecycle, single-instance lock

Deep review of internal/daemon (worker pool, launcher, control server,
session fan-out, single-instance lock) found and fixes a stdout-integrity
bug plus several lifecycle/locking hazards. Each fix has a regression test;
the daemon suite passes under -race.

Worker stdout integrity
- Replace the capped bufio.Scanner (64 KiB) on a worker's stdout with an
  uncapped bufio.Reader: a legitimately large stream-json line from our own
  trusted worker is no longer silently dropped (the 1 MiB cap belongs to
  untrusted network frames, not this local pipe).
- Surface a stdout read error as a run failure instead of swallowing it as a
  clean success, and kill the worker first so it can't block writing to the
  now-unread pipe and hang Wait.

Control-server lifecycle
- Track open connections and close them on Shutdown so a single idle/hostile
  client can no longer wedge drain (and SIGTERM cleanup) forever.
- Guard the listener with a mutex and re-check the shutdown signal after bind
  so a shutdown requested in the bind window is not lost.
- Honor shutdown during buffered-history replay too.

Session fan-out
- A slow subscriber that drops live lines now receives a namespaced gap
  notice when its channel drains, instead of silently losing output.
- Hold the session lock across the (non-blocking) broadcast sends to close a
  latent send-on-closed-channel panic when a subscriber cancels mid-send.

Single-instance lock
- Reclaim a stale lock with an atomic rename-with-verify instead of a blind
  remove, so two instances starting at once cannot both reclaim and end up
  both holding the lock; a holder that reacquires in the gap is restored.

Client + pool
- Bound the control handshake with an I/O deadline so a peer that never
  replies can't wedge Dial/NewClientConn.
- Key the active-worker map by a monotonic id rather than PID (the OS reuses
  PIDs the instant a worker exits).
- Group-terminate on context cancel so a stuck worker's children are not
  orphaned.

* daemon: add -race regression test for the Session.Line lock-hold invariant

Session.Line holds s.mu across its non-blocking subscriber sends so cancel()/
finish() cannot close a subscriber channel mid-send (send-on-closed-channel
panic). That invariant had no concurrency test. Add one that drives Line
concurrently with Subscribe/cancel and finish; it panics with a data race if
the lock-hold is reverted and passes with it (verified both ways under -race).
2026-06-20 22:21:28 +05:30
Anandan ac6addaa83 Align sandbox permission blocks and backend states (#253)
* Prompt for shell network permissions

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

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

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

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

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

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

* Auto-allow native-sandboxed shell commands

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

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

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

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

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

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

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

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

* Prompt for destructive shell approval

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

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

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

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

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

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

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

* Remove web-tool network policy switch

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

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

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

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

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

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

Tested: git diff --check

* Prompt for outside-workspace file access

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

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

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

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

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

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

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

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

Tested: git diff --check

* Fix Windows network prompt test fixture

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

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

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

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

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

Tested: git diff --check

* Remove sandbox autonomy ceilings

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

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

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

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

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

* Fail closed without native sandbox

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

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

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

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

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

* Remove scoped shell network policy

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

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

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

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

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

* Align permission grant ordering and prompt text

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

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

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

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

* Remove policy-only sandbox terminology

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

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

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

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

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

Tested: scripts/sandbox-smoke.sh

Tested: git diff --check

* Fix sandbox review follow-ups

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

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

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

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

Tests: scripts/sandbox-smoke.sh

Tests: git diff --check
2026-06-19 19:50:12 +05:30
KRATOS c38444157c Audit backlog: tui mouse API, notify webhook, swarm scheduling, daemon-remote bundles, MCP/oauth store unification, OS keyring, dead-code prune (#216)
* tui: migrate off the deprecated Bubble Tea mouse Type API (SA1019)

The mouse classification helpers checked both the current Button/Action pair and
the deprecated msg.Type / tea.Mouse* constants. Bubble Tea's parser always
populates Button+Action and only DERIVES Type from them, so the Type fallbacks
were pure redundancy (a left-button drag is Action==Motion, already covered by
mouseMotion). Drop them — clears all 11 SA1019 findings in internal/tui with no
behavior change. The left-drag test now asserts the Action==Motion path directly.

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

* notify: wire the webhook sink behind an opt-in env var

The WebhookSink (Slack / generic JSON POST on turn completion) was fully
implemented, redaction-safe, and tested — but never attached to a Notifier, so
deadcode flagged its entire machinery (NewWebhookSink, Emit, text, redactLinks,
log, eventType, readSnippet) as unreachable.

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

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

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

deadcode: 100 -> 93 unreachable funcs (the 7 WebhookSink entries become
reachable); no new dead code. gofmt/vet/build(host+linux+windows)/test -race/
staticcheck(no new)/govulncheck(0) all pass.

* swarm: add opt-in recurring-spawn scheduler (wakeup + daily)

Adds a dependency-free Scheduler to internal/swarm so a member can be spawned on
a recurring schedule, plus a swarm_schedule tool to drive it.

  - Schedule is interval-based ("wakeup", every >= 1s) with an optional first
    delay and a MaxRuns cap; a daily "HH:MM" ("cron") time is expressed as a 24h
    interval whose first delay lands on the next occurrence.
  - Each fire spawns a fresh member through the existing Swarm.Spawn path, so
    members inherit the recorded policy and are bounded by the team slot cap +
    queue. Non-overlapping by default: a fire is skipped while the job's previous
    spawn is still running, preventing queue pile-up.
  - Scheduler is lazily created (Swarm.Scheduler()), opt-in (nothing runs until a
    job is added), and tied to the Swarm's base context — Swarm.Close() stops the
    scheduler first so no spawn fires during shutdown.
  - swarm_schedule tool: action add|list|cancel; prompt-gated like swarm_spawn;
    add validates schedule + agent type up front (fail fast).

The timing loop takes an injectable ticker, so the tests drive fires
deterministically with no real time (fire/skip/cap/cancel/close, plus pure
nextDailyDelay/parseClock/swarmInt). All swarm tests pass 3x under -race.

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

* daemon/remote: SessionLink + git-bundle upload over the bridge

Adds an opt-in way to ship a local repo's git history to a remote daemon and
link a remote working tree to it — without a shared filesystem or a network git
remote. The daemon core (protocol/server) is untouched; all changes live in the
remote package plus a CLI hook.

Wire:
  - The auth handshake gains an optional Mode field ("" / "session" => a daemon
    session as before; "bundle" => a one-shot git-bundle upload). Unknown modes,
    or bundle mode when uploads are disabled, are denied (fail closed).
  - Bundle transfer reuses the daemon frame codec: a header frame (link id +
    exact size), then the bundle bytes as KindData frames, then a result frame.

Server (Bridge):
  - BridgeOptions.BundleDir enables uploads (empty => refused). A received bundle
    is size-capped (default 256 MiB), staged to a temp file, `git bundle verify`-d,
    then `git clone`-d into a staging dir and atomically renamed over
    <BundleDir>/<link-id>. Link ids are sanitized to a single path component and
    containment is re-checked, so an upload can never escape the bundle dir.

Client:
  - UploadRepoBundle creates `git bundle create --all` of a work tree, hashes it
    (sha256), streams it over an authenticated bundle-mode TLS connection, and
    returns a SessionLink {address, link id, remote path, bundle sha}. SessionLink
    persists as a 0600 atomic JSON file (token never stored) with Save/Load/Validate.

CLI:
  - `daemon serve-remote --bundle-dir <d>` enables uploads.
  - `daemon link --remote <addr> --repo <dir> --id <name> [--out <file>]` uploads
    and prints the remote path (+ a ready run command); `daemon link --show <file>`
    prints a saved link.

Tests: git bundle create/verify/clone roundtrip; link-id sanitization + path
containment; SessionLink save/load/validate (0600); and a full bridge upload E2E
over real TLS (extract + content check + re-upload-replaces + disabled + bad
token). All remote tests pass 2x under -race; CLI smoke verified via the binary.

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

* mcp: unify OAuth token storage onto the shared oauth store

MCP server tokens lived in their own mcp-oauth-tokens.json with a bespoke
codec/lock, separate from the provider-login store (internal/oauth). The oauth
store already reserved the "mcp:" key namespace for exactly this. Unify them.

  - mcp.TokenStore now delegates to internal/oauth.Store, keying each server's
    token as "mcp:<server>". Its public API (StoredToken/TokenStatus/Save/Load/
    Delete/Status/FilePath) is unchanged, so callers (network client, CLI) are
    untouched. The bespoke read/write/lock code is removed in favor of the shared,
    cross-process-locked, atomic store.
  - Transparent, non-destructive migration on first construction: a legacy
    mcp-oauth-tokens.json is imported into the unified store (only entries not
    already present — a newer unified token is never overwritten), then renamed to
    a ".migrated" backup so it is imported at most once and stays recoverable.
    A missing / unreadable / unknown-schema legacy file is left untouched and
    never blocks startup. An explicit FilePath suppresses the default-path
    migration so tests/overrides can't touch the real user config.
  - Server names that can't form a valid namespaced key (oauth.ValidateKey) are
    rejected on save and skipped during migration. Added oauth.Store.FilePath().

The token format was already identical (access/refresh/type/scopes/expires), so
this is a key rename, not a format change. Existing TokenStore tests pass
unchanged (they exercise the API); added migration + namespacing + newer-wins
tests. Local run check: `zero mcp oauth status` migrates a seeded legacy file
into oauth-tokens.json under mcp:demo, renames the legacy to .migrated, and leaks
no token material.

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

* oauth: add an opt-in OS keyring storage backend

Introduces a pluggable storage backend for the unified oauth token store and a
dependency-free OS keyring implementation, selected with
ZERO_OAUTH_STORAGE=keyring (default stays the 0600 file).

  - internal/keyring: a small, third-party-dep-free secret store backed by the OS
    tooling — `security` on macOS, `secret-tool` (libsecret) on Linux; other
    platforms report unsupported. On Linux the secret is passed via stdin (never
    the argv); on macOS `security` takes it as an argument (briefly visible in the
    process list) — documented, and a reason to prefer the file backend there.
    A runner seam makes the per-OS command logic fully testable without a real
    keychain.
  - internal/oauth: Store now persists its JSON blob through a blobStore backend.
    fileBlob preserves the exact prior behavior (0600, atomic write, cross-process
    lock file). keyringBlob stores the blob as one base64 entry (keeps multi-line
    JSON a single control-char-free value) via a KeyringClient. NewStore resolves
    the backend from StoreOptions.Storage or ZERO_OAUTH_STORAGE; unknown storage
    and an unavailable keyring fail closed with a clear error.

Because both `zero auth` and the (now unified) MCP token store construct their
store through oauth.NewStore, the keyring backend is honored by both with no
call-site changes. Added oauth.Store.FilePath() returns a "keyring:" identifier
for that backend.

No new module dependency (go.mod unchanged). Tests: keyring round-trip/not-found/
unsupported/missing-binary/stdin-not-argv via an injected runner; store keyring
round-trip + base64-at-rest + storage selection; all existing file-backend tests
pass unchanged. Local run check (macOS): validated the exact `security`
add/find/delete shapes and the not-found exit code (44) against the real binary,
and `mcp oauth status` with ZERO_OAUTH_STORAGE=keyring reads the real keychain
cleanly.

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

* all: prune unused unexported functions (dead-code sweep)

Removes 15 unexported functions/types/consts that are unreachable even from
tests (every one flagged by staticcheck U1000), clearing the U1000 noise the v16
audit surfaced. Conservative by design: only unexported, test-unused symbols are
removed — exported internal API (which may be an intentional surface or used only
by tests) is deliberately left in place.

Removed:
  - agenteval: validateExpectedChangedFiles
  - providers/gemini: const providerName
  - tui: stripMarkdownInline, removeTrailingAtToken,
    noopModelSwitchCompactionPolicy (+ BeforeModelSwitch), deleteComposerLineBefore,
    deleteComposerLineAfter, deleteCompletedFileMentionBefore, mcpViewStatus,
    mcpServerLines, fitCommandOutput, (*commandPicker).clearQuery,
    tuiTheme.onPanel2, indentText

No behavior change (the symbols had no callers). staticcheck U1000 is now empty;
deadcode drops accordingly with no genuinely-new unreachable functions (verified
by a name-based diff vs origin/main). gofmt/vet/build(host+linux+windows)/full
test all pass.

* Address review feedback on the audit backlog

Resolve CodeRabbit review comments on this PR:

- keyring isNotFound: match the specific not-found exit codes (macOS `security`
  44 = errSecItemNotFound; `secret-tool` 1) instead of treating any non-zero
  exit as "no entry", so a real failure (locked keychain, permissions) surfaces
  instead of being masked as absent.
- swarm scheduler daily_at: recompute each fire as the next local HH:MM rather
  than adding a fixed 24h, so a wall-clock daily schedule does not drift across
  DST. Adds Daily/Hour/Minute to Schedule and a clock to the Scheduler.
- swarm scheduler: reject Add when the scheduler context is already canceled
  (not just when closed); re-check cancellation after a tick so a tick+cancel
  race can't spawn one extra member after Cancel/Close.
- swarm schedule_tool swarmInt: reject non-integer / non-finite JSON numbers
  (max_runs=1.9 errors instead of silently truncating to 1).
- mcp migrateLegacy: resolve a relative LegacyPath to absolute before the
  same-file guard so it can't be bypassed by a relative spelling.
- oauth keyring store: serialize the keyring read-modify-write with a
  cross-process lock file (best-effort, beside the config dir) so concurrent
  processes don't clobber each other's tokens; previously withLock was a no-op.

Adds tests for the non-not-found keyring error, the daily delay recompute, the
Add-after-cancel guard, and non-integer swarmInt.

* swarm: make nextDailyDelay roll the day, not add 24h (DST)

CodeRabbit follow-up: the run loop already recomputes a daily job's delay each
cycle, but nextDailyDelay itself rolled "passed today" to the next occurrence
with Add(24*time.Hour). On a DST changeover day (23h/25h) that fires an hour
early/late. Use time.Date(..., day+1, hour, minute, ...) so Go normalizes the
date and applies the correct local offset, holding the wall-clock HH:MM across
DST. Adds an America/New_York spring-forward test (skips if tzdata is absent).
2026-06-16 14:48:12 +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 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