Files
zero/internal/specialist/exec.go
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

691 lines
22 KiB
Go

package specialist
import (
"bytes"
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"os"
osexec "os/exec"
"path/filepath"
"strconv"
"strings"
"github.com/Gitlawb/zero/internal/background"
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/streamjson"
"github.com/Gitlawb/zero/internal/tools"
)
const (
sessionTagSpecialist = "specialist"
promptFileThresholdBytes = 4 * 1024
)
const SessionTagSpecialist = sessionTagSpecialist
type NewSessionIDFunc func() (string, error)
type WritePromptFileFunc func(prompt string) (string, error)
type LoadFunc func(LoadOptions) (LoadResult, error)
type RunChildFunc func(ctx context.Context, binaryPath string, args []string) (ChildRunResult, error)
type LaunchBackgroundFunc func(binaryPath string, args []string, outputFile string, onExit func(exitCode int)) (int, error)
type BackgroundManagerFunc func() (*background.Manager, error)
type Executor struct {
NewSessionID NewSessionIDFunc
WritePromptFile WritePromptFileFunc
PromptFileMaxSize int
Load LoadFunc
RunChild RunChildFunc
LaunchBackground LaunchBackgroundFunc
BinaryPath string
Paths Paths
SessionStore *sessions.Store
BackgroundManager *background.Manager
BackgroundManagerFunc BackgroundManagerFunc
BackgroundRuntime *Runtime
}
type BuildArgsInput struct {
Manifest Manifest
Prompt string
ParentSessionID string
ParentToolUseID string
ParentModel string
ParentReasoningEffort string
CurrentDepth int
Description string
Cwd string
}
type BuildResumeArgsInput struct {
SessionID string
Prompt string
CurrentDepth int
Manifest Manifest
Cwd string
}
type BuildArgsResult struct {
Args []string
SessionID string
// PromptFile is created for large prompts; callers own cleanup after exec finishes.
PromptFile string
}
type TaskParameters struct {
Name string
Prompt string
Description string
RunInBackground bool
Resume string
}
type TaskRunOptions struct {
ToolCallID string
ParentSessionID string
ParentModel string
ParentReasoningEffort string
CurrentDepth int
Cwd string
}
type ExecResult struct {
Result tools.Result
SessionID string
}
type ChildRunResult struct {
Events []streamjson.Event
Stderr string
ExitCode int
Started bool
}
func (executor Executor) Run(ctx context.Context, params TaskParameters, options TaskRunOptions) (ExecResult, error) {
if ctx == nil {
ctx = context.Background()
}
if options.CurrentDepth < 0 {
return ExecResult{}, fmt.Errorf("current depth cannot be negative")
}
if strings.TrimSpace(params.Prompt) == "" {
return ExecResult{}, fmt.Errorf("specialist prompt is required")
}
if strings.TrimSpace(params.Resume) != "" {
if params.RunInBackground {
return ExecResult{}, fmt.Errorf("specialist resume cannot run in background")
}
return executor.runResume(ctx, params, options)
}
return executor.runFresh(ctx, params, options)
}
func (executor Executor) BuildArgs(input BuildArgsInput) (BuildArgsResult, error) {
if input.CurrentDepth < 0 {
return BuildArgsResult{}, fmt.Errorf("current depth cannot be negative")
}
if strings.TrimSpace(input.Prompt) == "" {
return BuildArgsResult{}, fmt.Errorf("specialist prompt is required")
}
sessionID, err := executor.newSessionID()
if err != nil {
return BuildArgsResult{}, err
}
sessionID = strings.TrimSpace(sessionID)
if !sessions.ValidSessionID(sessionID) {
return BuildArgsResult{}, fmt.Errorf("invalid specialist session id %q", sessionID)
}
wrappedPrompt := WrapSystemPrompt(input.Manifest.Metadata.Name, input.Manifest.SystemPrompt, input.Prompt, input.Description)
promptArgs, promptFile, err := executor.buildPromptArgs(wrappedPrompt)
if err != nil {
return BuildArgsResult{}, err
}
args := []string{"exec", "--init-session-id", sessionID}
args = append(args, promptArgs...)
args = appendModelArgs(args, input.Manifest, input.ParentModel, input.ParentReasoningEffort)
args = append(args, "--auto", "high", "--output-format", "stream-json")
toolAllowlist, err := resolvedToolAllowlist(input.Manifest)
if err != nil {
return BuildArgsResult{}, err
}
if len(toolAllowlist) == 0 {
return BuildArgsResult{}, fmt.Errorf("specialist %q resolved no enabled tools", input.Manifest.Metadata.Name)
}
args = append(args, "--enabled-tools", strings.Join(toolAllowlist, ","))
args = append(args, "--depth", strconv.Itoa(input.CurrentDepth+1), "--tag", sessionTagSpecialist)
if parentSessionID := strings.TrimSpace(input.ParentSessionID); parentSessionID != "" {
args = append(args, "--calling-session-id", parentSessionID)
}
if parentToolUseID := strings.TrimSpace(input.ParentToolUseID); parentToolUseID != "" {
args = append(args, "--calling-tool-use-id", parentToolUseID)
}
// Always record the specialist name in the session title. AgentName is
// derived from the title's "name:" prefix, and resume refuses a session whose
// AgentName is empty — so a description-less run (description is optional)
// must still carry the name or it can never be resumed.
name := strings.TrimSpace(input.Manifest.Metadata.Name)
if description := strings.TrimSpace(input.Description); description != "" {
args = append(args, "--session-title", name+": "+description)
} else {
args = append(args, "--session-title", name)
}
if cwd := strings.TrimSpace(input.Cwd); cwd != "" {
args = append(args, "--cwd", cwd)
}
return BuildArgsResult{Args: args, SessionID: sessionID, PromptFile: promptFile}, nil
}
func (executor Executor) BuildResumeArgs(input BuildResumeArgsInput) (BuildArgsResult, error) {
sessionID := strings.TrimSpace(input.SessionID)
if sessionID == "" {
return BuildArgsResult{}, fmt.Errorf("resume session id is required")
}
if !sessions.ValidSessionID(sessionID) {
return BuildArgsResult{}, fmt.Errorf("invalid resume session id %q", sessionID)
}
if input.CurrentDepth < 0 {
return BuildArgsResult{}, fmt.Errorf("current depth cannot be negative")
}
if strings.TrimSpace(input.Prompt) == "" {
return BuildArgsResult{}, fmt.Errorf("specialist prompt is required")
}
promptArgs, promptFile, err := executor.buildPromptArgs(WrapResumePrompt(input.Prompt))
if err != nil {
return BuildArgsResult{}, err
}
args := []string{"exec", "--resume", sessionID}
args = append(args, promptArgs...)
args = append(args, "--auto", "high", "--output-format", "stream-json")
toolAllowlist, err := resolvedToolAllowlist(input.Manifest)
if err != nil {
return BuildArgsResult{}, err
}
if len(toolAllowlist) == 0 {
return BuildArgsResult{}, fmt.Errorf("specialist %q resolved no enabled tools", input.Manifest.Metadata.Name)
}
args = append(args, "--enabled-tools", strings.Join(toolAllowlist, ","))
args = append(args, "--depth", strconv.Itoa(input.CurrentDepth+1), "--tag", sessionTagSpecialist)
if cwd := strings.TrimSpace(input.Cwd); cwd != "" {
args = append(args, "--cwd", cwd)
}
return BuildArgsResult{Args: args, SessionID: sessionID, PromptFile: promptFile}, nil
}
func (executor Executor) runFresh(ctx context.Context, params TaskParameters, options TaskRunOptions) (ExecResult, error) {
manifest, err := executor.loadManifest(params.Name)
if err != nil {
return ExecResult{}, err
}
built, err := executor.BuildArgs(BuildArgsInput{
Manifest: manifest,
Prompt: params.Prompt,
ParentSessionID: options.ParentSessionID,
ParentToolUseID: options.ToolCallID,
ParentModel: options.ParentModel,
ParentReasoningEffort: options.ParentReasoningEffort,
CurrentDepth: options.CurrentDepth,
Description: params.Description,
Cwd: options.Cwd,
})
if err != nil {
return ExecResult{}, err
}
if params.RunInBackground {
return executor.runBackground(ctx, built, manifest, params, options)
}
return executor.runBuiltArgs(ctx, built, manifest, params, options, "foreground")
}
func (executor Executor) runResume(ctx context.Context, params TaskParameters, options TaskRunOptions) (ExecResult, error) {
session, err := executor.resumeSession(params.Resume)
if err != nil {
return ExecResult{}, err
}
specialistName := strings.TrimSpace(session.AgentName)
if specialistName == "" {
return ExecResult{}, fmt.Errorf("resume session %q does not identify a specialist", session.SessionID)
}
if requestedName := strings.TrimSpace(params.Name); requestedName != "" && requestedName != specialistName {
return ExecResult{}, fmt.Errorf("resume session %q belongs to specialist %q, not %q", session.SessionID, specialistName, requestedName)
}
manifest, err := executor.loadManifest(specialistName)
if err != nil {
return ExecResult{}, err
}
built, err := executor.BuildResumeArgs(BuildResumeArgsInput{
SessionID: params.Resume,
Prompt: params.Prompt,
CurrentDepth: options.CurrentDepth,
Manifest: manifest,
Cwd: options.Cwd,
})
if err != nil {
return ExecResult{}, err
}
return executor.runBuiltArgs(ctx, built, manifest, params, options, "resume")
}
func (executor Executor) runBackground(ctx context.Context, built BuildArgsResult, manifest Manifest, params TaskParameters, options TaskRunOptions) (ExecResult, error) {
if err := ctx.Err(); err != nil {
if built.PromptFile != "" {
cleanupPromptFile(built.PromptFile)
}
return ExecResult{}, err
}
manager, err := executor.backgroundManager()
if err != nil {
if built.PromptFile != "" {
cleanupPromptFile(built.PromptFile)
}
return ExecResult{}, err
}
if err := ctx.Err(); err != nil {
if built.PromptFile != "" {
cleanupPromptFile(built.PromptFile)
}
return ExecResult{}, err
}
outputFile, err := manager.Register(background.RegisterInput{
TaskID: built.SessionID,
Type: "specialist",
SpecialistName: manifest.Metadata.Name,
Description: params.Description,
ParentID: options.ParentSessionID,
})
if err != nil {
if built.PromptFile != "" {
cleanupPromptFile(built.PromptFile)
}
return ExecResult{}, err
}
binaryPath, err := executor.binaryPath()
if err != nil {
_ = manager.UpdateStatus(built.SessionID, background.StatusError, -1)
if built.PromptFile != "" {
cleanupPromptFile(built.PromptFile)
}
return ExecResult{}, err
}
if err := ctx.Err(); err != nil {
_ = manager.UpdateStatus(built.SessionID, background.StatusError, -1)
if built.PromptFile != "" {
cleanupPromptFile(built.PromptFile)
}
return ExecResult{}, err
}
executor.trackBackgroundPromptFile(built.SessionID, built.PromptFile)
accounting := specialistAccountingInput{
ParentSessionID: options.ParentSessionID,
ChildSessionID: built.SessionID,
SpecialistName: manifest.Metadata.Name,
Description: params.Description,
ToolCallID: options.ToolCallID,
Mode: "background",
Background: true,
}
executor.recordSpecialistStart(accounting)
pid, err := executor.launchBackground(binaryPath, built.Args, outputFile, func(exitCode int) {
status := background.StatusCompleted
if exitCode != 0 {
status = background.StatusError
}
_ = manager.MarkExited(built.SessionID, status, exitCode)
if task, ok := manager.Get(built.SessionID); ok {
summary := StreamResult{ExitCode: task.ExitCode}
if data, err := os.ReadFile(task.OutputFile); err == nil {
summary, _ = summarizeTaskData(string(data), task.ExitCode)
}
executor.recordBackgroundTaskAccounting(task, summary)
}
executor.cleanupBackgroundPromptFile(built.SessionID, built.PromptFile)
})
if err != nil {
_ = manager.UpdateStatus(built.SessionID, background.StatusError, -1)
executor.cleanupBackgroundPromptFile(built.SessionID, built.PromptFile)
executor.recordSpecialistStop(accounting, StreamResult{ExitCode: -1}, "error", -1, err, false)
return ExecResult{}, err
}
if pid > 0 {
if err := manager.SetPID(built.SessionID, pid); err != nil {
// The child is running but its PID was never recorded, so nothing can
// track or stop it later. Kill it (and its process group) now, mark the
// task errored, and record the stop so it cannot become an untracked
// orphan. The dedup in recordSpecialistStop makes the later onExit
// accounting a no-op.
if killErr := background.TerminateProcess(pid); killErr != nil {
// Don't mask a failed kill: a surviving orphan is worse than the
// SetPID error alone, so surface both to the caller.
err = errors.Join(err, fmt.Errorf("terminate orphaned pid %d: %w", pid, killErr))
}
_ = manager.UpdateStatus(built.SessionID, background.StatusError, -1)
executor.cleanupBackgroundPromptFile(built.SessionID, built.PromptFile)
executor.recordSpecialistStop(accounting, StreamResult{ExitCode: -1}, "error", -1, err, false)
return ExecResult{}, err
}
}
output := fmt.Sprintf(`Task launched in background.
task_id: %s
pid: %d
specialist: %s
description: %s
Use TaskOutput with task_id "%s" to check progress.
Use TaskStop with task_id "%s" to stop it.`,
built.SessionID,
pid,
manifest.Metadata.Name,
strings.TrimSpace(params.Description),
built.SessionID,
built.SessionID,
)
return ExecResult{
Result: tools.Result{
Status: tools.StatusOK,
Output: strings.TrimSpace(output),
Meta: map[string]string{
"task_id": built.SessionID,
"session_id": built.SessionID,
},
},
SessionID: built.SessionID,
}, nil
}
func (executor Executor) resumeSession(sessionID string) (*sessions.Metadata, error) {
sessionID = strings.TrimSpace(sessionID)
if sessionID == "" {
return nil, fmt.Errorf("resume session id is required")
}
if !sessions.ValidSessionID(sessionID) {
return nil, fmt.Errorf("invalid resume session id %q", sessionID)
}
store := executor.SessionStore
if store == nil {
store = sessions.NewStore(sessions.StoreOptions{})
}
session, err := store.Get(sessionID)
if err != nil {
return nil, err
}
if session == nil {
return nil, fmt.Errorf("resume session not found: %s", sessionID)
}
if session.SessionKind != sessions.SessionKindChild || strings.TrimSpace(session.Tag) != sessionTagSpecialist {
return nil, fmt.Errorf("resume session %q is not a specialist child session", sessionID)
}
return session, nil
}
func (executor Executor) runBuiltArgs(ctx context.Context, built BuildArgsResult, manifest Manifest, params TaskParameters, options TaskRunOptions, mode string) (ExecResult, error) {
if built.PromptFile != "" {
defer cleanupPromptFile(built.PromptFile)
}
binaryPath, err := executor.binaryPath()
if err != nil {
return ExecResult{}, err
}
accounting := specialistAccountingInput{
ParentSessionID: options.ParentSessionID,
ChildSessionID: built.SessionID,
SpecialistName: manifest.Metadata.Name,
Description: params.Description,
ToolCallID: options.ToolCallID,
Mode: mode,
Background: false,
}
executor.recordSpecialistStart(accounting)
run, err := executor.runChild(ctx, binaryPath, built.Args)
if err != nil {
exitCode := run.exitCodeOr(-1)
summary := SummarizeStream(run.Events, exitCode)
executor.recordSpecialistStop(accounting, summary, "error", summary.ExitCode, err, false)
return ExecResult{}, err
}
summary := SummarizeStream(run.Events, run.ExitCode)
rolledUp := executor.rollUpSpecialistUsage(accounting, summary)
executor.recordSpecialistStop(accounting, summary, summary.Status, summary.ExitCode, nil, rolledUp)
return ExecResult{
Result: BuildFinalResult(run.Events, run.Stderr, run.ExitCode),
SessionID: built.SessionID,
}, nil
}
func (executor Executor) loadManifest(name string) (Manifest, error) {
name = strings.TrimSpace(name)
if name == "" {
return Manifest{}, fmt.Errorf("specialist name is required")
}
load := executor.Load
if load == nil {
load = Load
}
result, err := load(LoadOptions{Paths: executor.Paths})
if err != nil {
return Manifest{}, err
}
manifest, ok := Find(result, name)
if !ok {
return Manifest{}, fmt.Errorf("specialist %q not found", name)
}
return manifest, nil
}
func (executor Executor) binaryPath() (string, error) {
if path := strings.TrimSpace(executor.BinaryPath); path != "" {
return path, nil
}
path, err := os.Executable()
if err != nil {
return "", fmt.Errorf("resolve zero executable: %w", err)
}
return path, nil
}
func (executor Executor) runChild(ctx context.Context, binaryPath string, args []string) (ChildRunResult, error) {
if executor.RunChild != nil {
return executor.RunChild(ctx, binaryPath, append([]string(nil), args...))
}
return runChildProcess(ctx, binaryPath, args)
}
func (executor Executor) launchBackground(binaryPath string, args []string, outputFile string, onExit func(exitCode int)) (int, error) {
if executor.LaunchBackground != nil {
return executor.LaunchBackground(binaryPath, append([]string(nil), args...), outputFile, onExit)
}
return launchBackgroundProcess(binaryPath, args, outputFile, onExit)
}
func (executor Executor) backgroundManager() (*background.Manager, error) {
if executor.BackgroundRuntime != nil {
return executor.BackgroundRuntime.Manager()
}
if executor.BackgroundManager != nil {
return executor.BackgroundManager, nil
}
if executor.BackgroundManagerFunc != nil {
return executor.BackgroundManagerFunc()
}
return background.NewManager("")
}
func (executor Executor) trackBackgroundPromptFile(taskID string, promptFile string) {
if promptFile == "" {
return
}
if executor.BackgroundRuntime != nil {
executor.BackgroundRuntime.TrackPromptFile(taskID, promptFile)
}
}
func (executor Executor) cleanupBackgroundPromptFile(taskID string, promptFile string) {
if promptFile == "" {
return
}
if executor.BackgroundRuntime != nil {
executor.BackgroundRuntime.UntrackPromptFile(taskID)
return
}
cleanupPromptFile(promptFile)
}
func appendModelArgs(args []string, manifest Manifest, parentModel string, parentReasoningEffort string) []string {
resolvedModel := strings.TrimSpace(manifest.Metadata.Model)
if resolvedModel == "" {
resolvedModel = strings.TrimSpace(parentModel)
}
if resolvedModel != "" {
args = append(args, "--model", resolvedModel)
}
reasoningEffort := strings.TrimSpace(manifest.Metadata.ReasoningEffort)
if reasoningEffort == "" && strings.TrimSpace(manifest.Metadata.Model) == "" {
reasoningEffort = strings.TrimSpace(parentReasoningEffort)
}
if reasoningEffort != "" {
args = append(args, "--reasoning-effort", reasoningEffort)
}
return args
}
func resolvedToolAllowlist(manifest Manifest) ([]string, error) {
if len(manifest.ResolvedTools) > 0 {
return append([]string(nil), manifest.ResolvedTools...), nil
}
return ResolveTools(manifest.Metadata.Tools)
}
func (executor Executor) buildPromptArgs(prompt string) ([]string, string, error) {
threshold := executor.PromptFileMaxSize
if threshold <= 0 {
threshold = promptFileThresholdBytes
}
if len([]byte(prompt)) <= threshold {
return []string{prompt}, "", nil
}
path, err := executor.writePromptFile(prompt)
if err != nil {
return nil, "", err
}
return []string{"--file", path}, path, nil
}
func (executor Executor) newSessionID() (string, error) {
if executor.NewSessionID != nil {
return executor.NewSessionID()
}
random := make([]byte, 12)
if _, err := rand.Read(random); err != nil {
return "", fmt.Errorf("create specialist session id: %w", err)
}
return "specialist_" + hex.EncodeToString(random), nil
}
func (executor Executor) writePromptFile(prompt string) (string, error) {
if executor.WritePromptFile != nil {
return executor.WritePromptFile(prompt)
}
return writePromptFile(prompt)
}
func writePromptFile(prompt string) (string, error) {
tmpDir, err := os.MkdirTemp("", "zero-specialist-")
if err != nil {
return "", fmt.Errorf("create specialist prompt temp dir: %w", err)
}
if err := os.Chmod(tmpDir, 0o700); err != nil {
return "", fmt.Errorf("secure specialist prompt temp dir: %w", err)
}
promptPath := filepath.Join(tmpDir, "prompt.md")
if err := os.WriteFile(promptPath, []byte(prompt), 0o600); err != nil {
return "", fmt.Errorf("write specialist prompt file: %w", err)
}
return promptPath, nil
}
func cleanupPromptFile(promptFile string) {
if promptFile == "" {
return
}
dir := filepath.Dir(promptFile)
if strings.HasPrefix(filepath.Base(dir), "zero-specialist-") {
_ = os.RemoveAll(dir)
return
}
_ = os.Remove(promptFile)
}
func runChildProcess(ctx context.Context, binaryPath string, args []string) (ChildRunResult, error) {
var stdout bytes.Buffer
var stderr bytes.Buffer
command := osexec.CommandContext(ctx, binaryPath, args...)
command.Stdout = &stdout
command.Stderr = &stderr
exitCode := 0
started := false
if err := command.Run(); err != nil {
var exitErr *osexec.ExitError
if errors.As(err, &exitErr) {
started = true
exitCode = exitErr.ExitCode()
} else {
return ChildRunResult{Stderr: stderr.String(), ExitCode: exitCode, Started: started}, fmt.Errorf("run specialist child: %w", err)
}
} else {
started = true
}
events, err := ParseStream(bytes.NewReader(stdout.Bytes()))
if err != nil {
return ChildRunResult{Stderr: stderr.String(), ExitCode: exitCode, Started: started}, err
}
return ChildRunResult{Events: events, Stderr: stderr.String(), ExitCode: exitCode, Started: started}, nil
}
func (run ChildRunResult) exitCodeOr(defaultExitCode int) int {
if run.Started || run.ExitCode != 0 {
return run.ExitCode
}
return defaultExitCode
}
func launchBackgroundProcess(binaryPath string, args []string, outputFile string, onExit func(exitCode int)) (int, error) {
file, err := os.OpenFile(outputFile, os.O_WRONLY|os.O_APPEND, 0o600)
if err != nil {
return 0, fmt.Errorf("open specialist background output: %w", err)
}
command := osexec.Command(binaryPath, args...)
command.Stdout = file
command.Stderr = file
command.Stdin = nil
// Put the child in its own process group so terminating the task later kills
// the whole group (any grandchildren it forks) instead of orphaning them.
background.ConfigureChildProcessGroup(command)
if err := command.Start(); err != nil {
_ = file.Close()
return 0, fmt.Errorf("launch specialist background child: %w", err)
}
pid := command.Process.Pid
go func() {
exitCode := 0
if err := command.Wait(); err != nil {
var exitErr *osexec.ExitError
if errors.As(err, &exitErr) {
exitCode = exitErr.ExitCode()
} else {
exitCode = 1
}
}
_ = file.Close()
if onExit != nil {
onExit(exitCode)
}
}()
return pid, nil
}