Skip to content

Deep-audit remediation + sandbox hardening (batches 1–35)#189

Merged
gnanam1990 merged 43 commits into
mainfrom
audit-deep-remediation
Jun 13, 2026
Merged

Deep-audit remediation + sandbox hardening (batches 1–35)#189
gnanam1990 merged 43 commits into
mainfrom
audit-deep-remediation

Conversation

@gnanam1990

@gnanam1990 gnanam1990 commented Jun 13, 2026

Copy link
Copy Markdown
Collaborator

Overview

Remediation of findings from the 2026-06-10 deep audit, plus a focused sandbox-hardening pass. Every finding was re-verified against current main before fixing — the ledger had drifted, so ~11 rows were already fixed and several were intentional/test-guarded (not defects). Every behavioural change ships with a test; gofmt, go vet ./..., host + GOOS=linux + GOOS=windows builds, and the full suite are green on CI (all four Smoke jobs: ubuntu, macos, windows, performance). TUI findings are excluded (owned by a separate feature branch).

Scope: 89 files, ~+3.4k/−0.3k, 36 commits, no behavioural change to any default path unless a new opt-in flag is set.


Sandbox hardening (batches 23–34)

The largest part of this PR. It deepens the existing macOS seatbelt / Linux bubblewrap sandbox and closes the gap where non-shell tools bypassed the network policy. Every new enforcement feature is opt-in and off by default, so existing behaviour is unchanged unless explicitly enabled.

Network policy now covers the built-in network tools (the real gap)

  • web_fetch and web_search honour the scoped/deny policy (batches 33–34). Previously the egress proxy only constrained sandboxed shell commands; web_fetch/web_search opened their own connections and could reach any public host even under deny/scoped. A new shared gate Engine.NetworkHostAllowed(host) reuses the existing egress domain matcher as the single source of truth (allow → all; deny → none; scoped → allowlist minus denylist; empty allowlist collapses to deny).
    • web_fetch checks the host before the request and on every redirect, failing closed before any dial; the no-sandbox path is byte-for-byte unchanged.
    • web_search checks the configured backend's endpoint host before the query leaves the machine.
    • The per-host allowlist is the active enforcement on the macOS sandbox-exec backend (the only one that can route scoped egress); other backends collapse scoped to deny for these tools, which the engine already enforced at evaluation time.
    • Proven end-to-end with a registry-level test: RunWithOptions → Evaluate → RunWithSandbox → per-host allowlist blocks an unlisted host before any dial.

Backend isolation depth

  • Richer macOS seatbelt profile (batch 23): a curated com.apple.* mach-lookup allowlist and (allow signal (target self) (target pgrp)), so common toolchains work under the profile while the default-deny posture and the workspace write-boundary still hold (verified live on the host).
  • Linux seccomp AF_UNIX block (batches 30, 32): a classic-BPF seccomp program that denies Unix-socket creation (the channel bubblewrap's filesystem/network isolation leaves open), delivered as a zero-seccomp exec-wrapper and wired into the bubblewrap command via Policy.BlockUnixSockets. Discovery finds the helper next to the binary or on PATH and degrades gracefully (runs without the filter) when absent.
  • macOS denial log monitor (batch 31): with Policy.MonitorDenials, the seatbelt profile tags its default-deny and the bash path tails the unified log for that tag, surfacing blocked operations back to the agent as a <sandbox_violations> block on stderr.

Permission / egress model

  • Exact-path grants (batch 25): create and revoke grants scoped to a specific path (--path on the sandbox allow/revoke CLI), not just tool-wide.
  • Engine.Precheck (batch 26): report the violations that would block a request before it runs, so callers can fail fast with the reason.
  • Scoped-egress domain prompt (batch 27): interactive per-host authorization with a timeout and a decision cache.
  • AST-based shell analysis (batch 28): AnalyzeCommand parses commands with a real shell grammar instead of string heuristics.
  • Quote-aware segment splitting (batch 14): operators inside quotes ("a | b", "a; vim b") no longer split, so quoted program names aren't falsely flagged while real unquoted operators still do.
  • Internal cleanups: unify proxy-env generation into ProxyEnv (batch 29); remove the dead OnSandboxDecision hook (batch 24).

Config surface

Both new hardening flags are reachable from the sandbox config block and documented in the README — sandbox.blockUnixSockets (Linux) and sandbox.monitorDenials (macOS) — and map onto the policy opt-in only (config can turn a flag on, never silently off).

Honest caveats (flagged, not hidden): the runtime effect of the macOS denial monitor (batch 31) and the Linux seccomp block (batches 30, 32) can only be exercised on the respective OS — the seccomp kernel-level block needs a Linux host/CI, and on at least one macOS version seatbelt denials aren't delivered to the queryable log. Both are default-off, their wiring and logic are unit-tested on every platform, and neither can affect whether a command runs unless an operator opts in.


Correctness / robustness (batches 5, 6, 13, 15, 16, 18, 22)

  • zerogit (5) — git status --porcelain -z: renames report the destination path; non-ASCII/whitespace filenames arrive verbatim, not C-quoted.
  • config (6) — reject a negative maxTurns instead of silently dropping it; remove dead contract-gap code.
  • background/specialist (13) — kill background tasks as a process group (Setpgid + negative-PID signal + bounded wait) so forked children can't outlive a cancel; on a SetPID failure, kill + record stop instead of orphaning.
  • cron (15) — collapse the DST fall-back repeated hour so a daily job fires once, not twice.
  • mcp (16) — skip server notifications/requests that arrive before the SSE response (no more id-mismatch failures); raise the SSE event cap to a bounded ceiling.
  • agent (18) — count tool definitions in the compaction token estimate; don't deny an always-allow tool just because a grant-persist write failed.
  • cli/cron (22) — capture the actual run error from stream-json stdout instead of a generic failure.

Resource / durability / perf (batches 7, 9, 10, 11, 12, 17)

  • mcp (7) — bound captured server stderr (was unbounded for the whole process lifetime).
  • sessions (9) — build Tree from one List() snapshot, killing an N+1 disk scan.
  • specialist (10) — serialize the accounting dedup→append (a TOCTOU that wrote duplicate stop/usage events).
  • streaming runtime (11) — O(1) streamed-text accumulation; decode the cached-token counts a streaming provider reports.
  • specialist (12) — parse the child stream without a fixed line-length cap that aborted runs on large output.
  • sessions (17) — fsync session metadata before its atomic rename so a crash can't leave a torn/empty session file.

Usage / accounting (batches 19, 20)

  • usage (19) — price escalated usage from the event's own model, not the session default.
  • sessions (20) — don't copy usage events into a fork, avoiding a double-count.

Tooling (batches 4, 8, 21)

  • cli (4) — list-tools -o json; interrupted -o json runs emit a terminal event; surface session-record failures.
  • tools/providers (8) — read_file emits a truncation marker on a max_lines cut; remove a provably-dead provider-resolution branch.
  • cli/skills (21) — warn on duplicate skill names instead of silently dropping one.

CI / portability (batch 35)

  • Fixed the sole Windows CI failure: TestWriteFileSyncRoundTrips asserted an exact Unix file mode (0o600), which Windows doesn't honour (Go reports 0o666). The exact-perm check is now guarded to the platforms that enforce it; the write/read-back round-trip is still asserted everywhere.

Testing & verification

  • gofmt -l clean · go vet ./... clean.
  • Host build, GOOS=linux go build ./... (incl. the zero-seccomp wrapper), and GOOS=windows go build ./... all pass.
  • Full go test ./... green; all four CI Smoke jobs (ubuntu / macos / windows / performance) pass.
  • macOS seatbelt profile changes validated live on the host (signal + mach-lookup grants work; workspace boundary still holds).

Deliberately left (documented in commits)

Items that are intentional/test-guarded (e.g. ResolveMCP not running the provider command), perf-only with cross-process-unsafe shortcuts, nuanced usage-accounting edge cases, or low-value/forward-looking dead code. The seccomp/denial-monitor runtime validation is the only known follow-up and is gated behind default-off flags.

Summary by CodeRabbit

  • New Features

    • Shell-command safety analyzer; macOS denial monitor and Linux unix-socket blocking helper; sandbox-aware web_fetch/web_search and interactive domain prompting; JSON exec tool listing; path-scoped sandbox grants; zero-seccomp CLI.
  • Configuration

    • New sandbox flags: sandbox.blockUnixSockets, sandbox.monitorDenials; maxTurns now validated (negatives rejected).
  • Bug Fixes

    • Cron DST fallback behavior; nested-secret redaction; session metadata durability and background/process termination robustness; assorted sandbox/network hardening and stability tests.

…paction 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.
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.
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.
…x, 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).
…, 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.
…hs); 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.
…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.
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.
…ad 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.
…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.
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).
@github-actions

github-actions Bot commented Jun 13, 2026

Copy link
Copy Markdown
Contributor

Zero automated PR review

Verdict: No blockers found

Blockers

  • None found.

Validation

  • [pass] Diff hygiene: git diff --check
  • [pass] Tests: go test ./...
  • [pass] Build: go run ./cmd/zero-release build
  • [pass] Smoke build: go run ./cmd/zero-release smoke

Scope

Head: 8ec149680eed
Changed files (89): README.md, cmd/zero-seccomp/main.go, docs/audit/2026-06-10-deep-audit-status.md, docs/audit/2026-06-13-reverification.md, go.mod, go.sum, internal/agent/compaction.go, internal/agent/compaction_summarizer_test.go, internal/agent/compaction_test.go, internal/agent/loop.go, internal/agent/loop_test.go, internal/background/process_posix.go, and 77 more

This deterministic review checks validation status and basic diff hygiene. A human reviewer still owns product judgment and design quality.

…ached-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.
@coderabbitai

coderabbitai Bot commented Jun 13, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 6105caaf-1ba4-4a98-9c00-db86c86f4e6d

📥 Commits

Reviewing files that changed from the base of the PR and between 5cf23d4 and 8ec1496.

📒 Files selected for processing (2)
  • internal/cli/sandbox_test.go
  • internal/zerogit/zerogit.go
🚧 Files skipped from review as they are similar to previous changes (2)
  • internal/cli/sandbox_test.go
  • internal/zerogit/zerogit.go

Walkthrough

Large coordinated update: token/accounting and summarization plumbing, agent loop/tool wiring, sandbox policy/enforcement (seccomp, denial monitoring, runner), CLI output and JSON protocols, background process-group termination, NUL git status parsing, sessions durability, web tool sandbox gating, secrets redaction, and extensive tests/docs.

Changes

Integrated runtime, sandbox, CLI, and tooling updates

Layer / File(s) Summary
Audit ledger and reverification docs
docs/audit/2026-06-10-deep-audit-status.md, docs/audit/2026-06-13-reverification.md, README.md
Updated audit ledger, added re-verification report, and documented new sandbox flags blockUnixSockets and monitorDenials, plus scoped network-policy notes.
Compaction & summarizer usage
internal/agent/compaction.go, internal/agent/compaction_test.go, internal/agent/compaction_summarizer_test.go, internal/zeroruntime/helpers.go
Token estimates include images and per-request tool-definition tokens; OnUsage callback threaded through summarizers; CollectStreamWithOptions used with OnUsage and streamed text accumulated into builder; tests for usage forwarding and token estimates.
Agent loop and permission persistence
internal/agent/loop.go, internal/agent/loop_test.go
Compute tool partition before compaction and pass advertised tools into compactor/recover; return ctx.Err() on cancellation before interpreting stream errors; persist AlwaysAllow grants only when Sandbox configured; tests added.
Background process-group termination
internal/background/*, tests
Added ConfigureChildProcessGroup, group-aware terminateProcess with SIGTERM→SIGKILL escalation and liveness polling, exported TerminateProcess wrapper, ensured background launches setpgid, and added forked-child termination tests.
MCP/SSE and provider streaming
internal/mcp/*, internal/providers/gemini/*
boundedBuffer caps retained stderr; decodeSSERPCMessage skips JSON-RPC notification-like candidates; scanner per-event cap raised (8 MiB) and per-event aggregate bounded; Gemini streams persist cached content token counts into final usage events; tests updated.
CLI exec/cron/session
internal/cli/*
Added exec --list-tools -o json output writer; JSON interruption terminal events for exec; deferred sessionRecorder.warnIfRecordingFailed on exit; cron_run extracts last stream-json error message from STDOUT for run-record error; tests added.
CLI sandbox grants and skills
internal/cli/sandbox.go, internal/sandbox/grants.go, internal/cli/skills.go, tests
Added --path scoping for allow/deny and revoke (file-scoped grants), implemented GrantStore.RevokePath, validated non-empty path forms, and added duplicate-skill name warnings.
Sessions durability & fork behavior
internal/sessions/store.go, tests
Store.Fork skips EventUsage when copying into forks and records the number of copied non-usage events; writeMetadata uses writeFileSync and syncDir to fsync metadata and parent dir for durability; tree building refactored to snapshot/list once and detect cycles.
Specialist accounting & stream parsing
internal/specialist/*
Serialized check-then-append with a mutex to prevent duplicate specialist events; streamer switched from bufio.Scanner to ReadString loop to handle >1MiB lines; tests added for concurrency and large-line parsing.
Sandbox policy, engine, and egress prompting
internal/sandbox/*
Policy adds MonitorDenials and BlockUnixSockets, ViolationPolicyDenied introduced; Engine.NetworkHostAllowed and Engine.Precheck added; egressProxy centralizes authorize/prompt with timeout, caching, and inflight dedupe; GrantStore.RevokePath and tests added.
Sandbox runtime, runner, denial monitors, seccomp
internal/sandbox/runner.go, internal/sandbox/log_monitor_*.go, internal/sandbox/seccomp*.go, cmd/zero-seccomp/main.go, README, tests
CommandPlan MonitorTag and ProxyEnv added; darwin denial monitor implemented and non-darwin noop; classic-BPF seccomp filter builder and Linux loader added; zero-seccomp wrapper CLI added; Bubblewrap plans can prefix helper; many sandbox/profile tests added.
Shell AST analyzer & safe-command parsing
internal/sandbox/analyzer.go, internal/sandbox/safe_command.go, tests
Added AST-based AnalyzeCommand classification and robust wrapper-aware, quote/substitution-aware segment splitting and interactive detection with extensive tests.
Tools: web_fetch & web_search sandbox gating
internal/tools/web_fetch.go, internal/tools/web_search.go, tests
Added RunWithSandbox variants and networkPolicyGate; gate-aware URL validation and redirect policies; endpointHost extraction for hosted backends; same-host redirect enforcement and limits; tests ensure policy short-circuits before network dials.
Tools: read_file and bash output
internal/tools/read_file.go, internal/tools/bash.go, tests
read_file appends explicit "[truncated: ...]" continuation note and sets Truncated flag; bash tool optionally runs denial monitor and appends <sandbox_violations> block to stderr; tests added.
Secrets redaction
internal/secrets/scanner.go, tests
Redact processes longer/containing matches first by stable-sort descending match length; regression test added for nested private key block removal.
Usage pricing
internal/usage/report.go, tests
Persist Model in usage events; BuildReport prefers event model when reconstructing cost; test added verifying pricing from event model.
Git/worktrees and zerogit
internal/worktrees/worktrees.go, internal/zerogit/zerogit.go, tests
defaultRunGit separates stdout/stderr and uses command.Run; zerogit switched to git status --porcelain -z NUL format and parseStatus updated to consume rename source and emit destination; tests updated.
Misc tests & helper tweaks
various test files and small helpers
Numerous test additions, helper import updates, bounded buffer test, git-availability test, removal of obsolete config contracts file/tests, and README updates.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Possibly related PRs

Suggested reviewers

  • anandh8x
  • Vasanthdev2004
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch audit-deep-remediation

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
docs/audit/2026-06-13-reverification.md (1)

22-24: ⚡ Quick win

Consolidate or label the repeated cron_run.go rows.

These three lines describe the same fixed root cause. If the 9 total is meant to mean unique findings, the duplicates are misleading; if it means ledger rows, say that explicitly.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/audit/2026-06-13-reverification.md` around lines 22 - 24, Consolidate
the three duplicate audit rows that all describe the same fix (cronTruncate now
using cutRuneBoundary which ensures UTF-8 rune boundaries) into a single row
referencing cronTruncate and cutRuneBoundary (and optionally noting
promptExcerpt) and adjust the ledger/counting language to clarify whether the
total “9” is unique findings or ledger entries; alternatively, keep the three
rows but add a duplicate/alias label on the two repeats to indicate they
reference the same root cause.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/audit/2026-06-10-deep-audit-status.md`:
- Line 1: Update the stale heading line "# Deep-audit status ledger
(critical/high re-verified against HEAD on branch fix/audit-critical-high,
2026-06-11)" so it matches the body: change the date to 2026-06-13 and the
branch to fix/audit-batch (i.e., "# Deep-audit status ledger (critical/high
re-verified against HEAD on branch fix/audit-batch, 2026-06-13)"), leaving the
rest of the file unchanged.

In `@internal/config/types.go`:
- Around line 211-213: The validation error message for raw.MaxTurns is
inconsistent with the check: keep the existing condition that rejects only
values < 0 (i.e., allow 0) but update the error string in the function that
returns fmt.Errorf(...) to say "must be >= 0" (or equivalent) instead of "must
be greater than 0", referencing the raw.MaxTurns check in
internal/config/types.go so users aren't misled.

---

Nitpick comments:
In `@docs/audit/2026-06-13-reverification.md`:
- Around line 22-24: Consolidate the three duplicate audit rows that all
describe the same fix (cronTruncate now using cutRuneBoundary which ensures
UTF-8 rune boundaries) into a single row referencing cronTruncate and
cutRuneBoundary (and optionally noting promptExcerpt) and adjust the
ledger/counting language to clarify whether the total “9” is unique findings or
ledger entries; alternatively, keep the three rows but add a duplicate/alias
label on the two repeats to indicate they reference the same root cause.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: cca5870a-c523-4f94-bafb-9ff6b83aec31

📥 Commits

Reviewing files that changed from the base of the PR and between cc618ba and 468f3a2.

📒 Files selected for processing (32)
  • docs/audit/2026-06-10-deep-audit-status.md
  • docs/audit/2026-06-13-reverification.md
  • internal/agent/compaction.go
  • internal/agent/compaction_summarizer_test.go
  • internal/agent/compaction_test.go
  • internal/agent/loop.go
  • internal/agent/loop_test.go
  • internal/cli/deferred_wiring_test.go
  • internal/cli/exec.go
  • internal/cli/exec_protocol_test.go
  • internal/cli/exec_sessions.go
  • internal/cli/exec_spec.go
  • internal/cli/exec_test.go
  • internal/cli/exec_tools.go
  • internal/config/contracts.go
  • internal/config/contracts_test.go
  • internal/config/resolver_test.go
  • internal/config/types.go
  • internal/mcp/client.go
  • internal/mcp/client_test.go
  • internal/providers/factory.go
  • internal/secrets/scanner.go
  • internal/secrets/scanner_test.go
  • internal/sessions/lineage.go
  • internal/specialist/accounting.go
  • internal/specialist/accounting_test.go
  • internal/tools/file_tools_test.go
  • internal/tools/read_file.go
  • internal/worktrees/worktrees.go
  • internal/worktrees/worktrees_test.go
  • internal/zerogit/zerogit.go
  • internal/zerogit/zerogit_test.go
💤 Files with no reviewable changes (2)
  • internal/config/contracts.go
  • internal/config/contracts_test.go

Comment thread docs/audit/2026-06-10-deep-audit-status.md Outdated
Comment thread internal/config/types.go
… 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.
…ocess 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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/background/process_posix.go`:
- Around line 66-70: The code currently sends SIGKILL with syscall.Kill(-pid,
syscall.SIGKILL) but returns immediately; update the helper to re-run the same
liveness polling used earlier (call processGroupAlive(pid) in a loop with the
same deadline/interval) after sending SIGKILL and only return nil once
processGroupAlive(pid) is false, or return a descriptive error if the group
still exists after the second deadline; keep the existing error handling with
processGoneError(err) for the Kill call but treat a successful Kill as just the
escalation step, not success, until liveness polling confirms termination.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: d41bf0a2-1625-473b-a669-dbee19021a5b

📥 Commits

Reviewing files that changed from the base of the PR and between 922b132 and 8839cc4.

📒 Files selected for processing (5)
  • internal/background/process_posix.go
  • internal/background/process_posix_test.go
  • internal/background/process_windows.go
  • internal/background/terminate.go
  • internal/specialist/exec.go
✅ Files skipped from review due to trivial changes (1)
  • internal/background/process_windows.go

Comment thread internal/background/process_posix.go Outdated
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.
@gnanam1990
gnanam1990 marked this pull request as draft June 13, 2026 08:29
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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/sandbox/safe_command.go`:
- Around line 486-492: The parser currently treats ')' as a split point
unconditionally; add tracking of active `$(` nesting by introducing an integer
depth counter (e.g., dollarParenDepth) that is incremented where you detect `c
== '$' && i+1 < len(runes) && runes[i+1] == '('` (after you consume the '(') and
decremented when you see `c == ')'`; only call flush() and treat `)` as a
boundary when the counter is > 0 (and decrement it), otherwise treat `)` as a
literal character (append to the current buffer) so things like `echo "a) less"`
are not split into fake segments; update the two switch branches that reference
runes, i, and flush() to use this depth logic and ensure the counter is
initialized to 0 in the surrounding parsing function/state.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 6af37025-dde1-472c-b3d9-966bb85f8ead

📥 Commits

Reviewing files that changed from the base of the PR and between 8839cc4 and 5a72b32.

📒 Files selected for processing (2)
  • internal/sandbox/safe_command.go
  • internal/sandbox/safe_command_test.go

Comment thread internal/sandbox/safe_command.go

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/cron/schedule.go`:
- Around line 191-203: The DST repeat guard in Next is over-filtering: change
the condition so we only skip the candidate repeated-hour time when it's not
strictly after `after`. Concretely, in the block that calls
sameWallClockMinute(t, after) use the combined check "sameWallClockMinute(t,
after) && !t.After(after)" to advance t by one minute; otherwise return t. This
preserves the strict-`after` semantics while still collapsing the duplicate
repeated hour when the candidate isn't a later instant.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 92a6e73b-3633-4e5d-8144-3266184bc426

📥 Commits

Reviewing files that changed from the base of the PR and between 5a72b32 and 4cb6ee3.

📒 Files selected for processing (2)
  • internal/cron/next_test.go
  • internal/cron/schedule.go

Comment thread internal/cron/schedule.go
…ger 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.
…ename

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.
@gnanam1990 gnanam1990 changed the title Deep-audit remediation (batches 1–10) Deep-audit remediation (batches 1–17) Jun 13, 2026
… 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).

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
internal/agent/compaction.go (1)

375-388: 💤 Low value

lowWaterMark in recover excludes tool tokens, inconsistent with maybeCompact.

recover sets lowWaterMark = estimateTokens(result) without tool tokens (line 386), but maybeCompact compares size = estimateTokens(messages) + toolTokens against lowWaterMark (line 318). After a reactive compaction, the next turn's proactive check may see size > lowWaterMark simply because size includes tools and lowWaterMark doesn't—potentially triggering a redundant compaction attempt.

Practical impact is low since reactive compaction fires at most once per run and the worst case is one extra no-op compaction call, but the estimate should stay consistent.

Suggested fix

Have recover accept tools []zeroruntime.ToolDefinition and include tool tokens:

 func (state *compactionState) recover(
 	ctx context.Context,
 	provider Provider,
 	messages []zeroruntime.Message,
+	tools []zeroruntime.ToolDefinition,
 	errorMessage string,
 ) (compacted []zeroruntime.Message, retried bool, err error) {
 	...
 	state.reactiveAttempted = true
-	state.lowWaterMark = estimateTokens(result)
+	state.lowWaterMark = estimateTokens(result) + estimateToolDefTokens(tools)
 	return result, true, nil
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/agent/compaction.go` around lines 375 - 388, The reactive recovery
path in recover currently sets state.lowWaterMark = estimateTokens(result)
without accounting for toolTokens, which is inconsistent with maybeCompact
(which compares size = estimateTokens(messages) + toolTokens against
lowWaterMark) and can cause a redundant proactive compaction; fix by changing
recover to accept tools []zeroruntime.ToolDefinition (or equivalent source of
tool token counts) and set state.lowWaterMark = estimateTokens(result) +
computeToolTokens(tools) so lowWaterMark and maybeCompact use the same token
accounting, and update all callers of recover to pass the same tools slice (or
propagate a toolTokens int) accordingly.
internal/agent/loop.go (1)

574-586: Keep “Always allow” persistence best-effort (never gate the approved call)

In internal/agent/loop.go, PermissionDecisionAlwaysAllow sets permissionGranted=true, calls persistPermissionGrant only when options.Sandbox != nil, and discards any error (_, _ = ...). So a persistence failure can only affect whether FUTURE calls skip the prompt; it won’t deny the user’s approved action for THIS call.

Optional: consider emitting debug/telemetry when persistPermissionGrant fails, since users wouldn’t otherwise see why they got re-prompted later.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/agent/loop.go` around lines 574 - 586, The "Always allow" branch
currently honours the permission for this call but swallows persistence errors;
keep the behavior (set permissionGranted = true and only call
persistPermissionGrant when options.Sandbox != nil) but don’t silently discard
failures — capture the error returned by persistPermissionGrant(call.Name, args,
decisionReason, options) and emit a debug/telemetry/log entry (using the
existing logger/telemetry facility in this package) that includes the call.Name,
args summary and the error so persistence failures are visible while still not
blocking the approved call; leave buildPermissionEvent/requestEvent logic
unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@internal/agent/compaction.go`:
- Around line 375-388: The reactive recovery path in recover currently sets
state.lowWaterMark = estimateTokens(result) without accounting for toolTokens,
which is inconsistent with maybeCompact (which compares size =
estimateTokens(messages) + toolTokens against lowWaterMark) and can cause a
redundant proactive compaction; fix by changing recover to accept tools
[]zeroruntime.ToolDefinition (or equivalent source of tool token counts) and set
state.lowWaterMark = estimateTokens(result) + computeToolTokens(tools) so
lowWaterMark and maybeCompact use the same token accounting, and update all
callers of recover to pass the same tools slice (or propagate a toolTokens int)
accordingly.

In `@internal/agent/loop.go`:
- Around line 574-586: The "Always allow" branch currently honours the
permission for this call but swallows persistence errors; keep the behavior (set
permissionGranted = true and only call persistPermissionGrant when
options.Sandbox != nil) but don’t silently discard failures — capture the error
returned by persistPermissionGrant(call.Name, args, decisionReason, options) and
emit a debug/telemetry/log entry (using the existing logger/telemetry facility
in this package) that includes the call.Name, args summary and the error so
persistence failures are visible while still not blocking the approved call;
leave buildPermissionEvent/requestEvent logic unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 7b8f2f38-793d-476e-b9b2-009255931678

📥 Commits

Reviewing files that changed from the base of the PR and between 2da1b14 and 94a6c4a.

📒 Files selected for processing (3)
  • internal/agent/compaction.go
  • internal/agent/compaction_test.go
  • internal/agent/loop.go

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.
…uble-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.
@coderabbitai

coderabbitai Bot commented Jun 13, 2026

Copy link
Copy Markdown

@gnanam1990 Kicking off a full re-review now to verify all 5 batch-36 fixes in 6289a37. The rationale on cron/schedule.go (using the Truncate boundary check over !t.After(after)) is sound — appreciated the explanation.

✅ Action performed

Full review finished.

@Vasanthdev2004 Vasanthdev2004 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Manual review verdict: approved. I rechecked the latest head (6289a37) across the high-risk sandbox, tool execution, MCP, usage, and compaction paths and did not find a PR-blocking issue. Focused local validation passed for sandbox, tools, usage, agent, MCP, background, cron, sessions, and zerogit. Note: internal/cli timed out locally because this machine has configured MCP servers and that test path is not fully hermetic here; GitHub CI smoke is green on Windows/Ubuntu/macOS, so I am not treating it as a blocker for this PR.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (4)
internal/agent/compaction.go (1)

375-387: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Use the same token basis for reactive low-water mark as proactive compaction.

Reactive compaction stores state.lowWaterMark using message-only tokens, but proactive compaction compares message+tool-definition tokens. That mismatch can retrigger compaction repeatedly after a reactive recovery even when history hasn’t meaningfully grown.

Suggested fix
diff --git a/internal/agent/compaction.go b/internal/agent/compaction.go
@@
 func (state *compactionState) recover(
 	ctx context.Context,
 	provider Provider,
 	messages []zeroruntime.Message,
+	tools []zeroruntime.ToolDefinition,
 	errorMessage string,
 ) (compacted []zeroruntime.Message, retried bool, err error) {
@@
+	toolTokens := estimateToolDefTokens(tools)
+	before := estimateTokens(messages) + toolTokens
+	after := estimateTokens(result) + toolTokens
-	if estimateTokens(result) >= estimateTokens(messages) {
+	if after >= before {
 		return messages, false, nil
 	}
@@
-	state.lowWaterMark = estimateTokens(result)
+	state.lowWaterMark = after
 	return result, true, nil
 }

Also pass exposed at both recover call sites in internal/agent/loop.go:

- if compacted, retried, retryErr := compactor.recover(ctx, provider, messages, err.Error()); retried {
+ if compacted, retried, retryErr := compactor.recover(ctx, provider, messages, exposed, err.Error()); retried {

- if compacted, retried, retryErr := compactor.recover(ctx, provider, messages, collected.Error); retried {
+ if compacted, retried, retryErr := compactor.recover(ctx, provider, messages, exposed, collected.Error); retried {
internal/cli/exec_spec.go (1)

169-179: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Add JSON interruption terminal events in spec-draft mode.

runExecSpecDraft still routes -o json interruptions to stderr text (Interrupted.) instead of emitting terminal JSON error + done. That breaks protocol parity with runExec and can leave JSON clients waiting for a terminal record.

💡 Suggested fix
 	if err != nil {
 		if errors.Is(err, context.Canceled) || runCtx.Err() != nil {
 			sessionRecorder.append(sessions.EventError, map[string]any{"message": "interrupted"})
-			if run.options.outputFormat == execOutputStreamJSON {
+			switch run.options.outputFormat {
+			case execOutputStreamJSON:
 				writer.errorEvent("interrupted", "run cancelled by signal", false)
 				writer.runEnd("interrupted", exitInterrupted)
 				if writer.err != nil {
 					return exitCrash
 				}
-			} else {
+			case execOutputJSON:
+				writer.errorEvent("interrupted", "run cancelled by signal", false)
+				writer.writeJSON(map[string]any{
+					"type":      "done",
+					"exit_code": exitInterrupted,
+				})
+				if writer.err != nil {
+					return exitCrash
+				}
+			default:
 				fmt.Fprintln(run.stderr, "Interrupted.")
 			}
 			return exitInterrupted
 		}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/cli/exec_spec.go` around lines 169 - 179, The JSON output branch in
the cancellation handling of runExecSpecDraft is missing the terminal JSON
events; modify the cancellation block (the code checking errors.Is(err,
context.Canceled) || runCtx.Err() != nil) so that when run.options.outputFormat
== execOutputStreamJSON you emit the same sequence used by runExec: call
writer.errorEvent("interrupted", "run cancelled by signal", false) then
writer.runEnd("interrupted", exitInterrupted) and handle writer.err (return
exitCrash on error); keep the existing
sessionRecorder.append(sessions.EventError, ...) and the non-JSON fallback that
prints "Interrupted." unchanged.
internal/sessions/store.go (1)

702-713: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Sync the metadata directory after rename to fully harden crash durability.

writeFileSync flushes file bytes, but after os.Rename the directory entry is still not guaranteed durable until the parent directory is synced. A crash can still lose or roll back the renamed metadata.json.

Suggested fix
 func (store *Store) writeMetadata(session Metadata) error {
 	data, err := json.MarshalIndent(session, "", "  ")
 	if err != nil {
 		return fmt.Errorf("encode zero session metadata: %w", err)
 	}
 	path := store.metadataPath(session.SessionID)
 	tmp := fmt.Sprintf("%s.tmp-%d", path, store.idCounter.Add(1))
 	if err := writeFileSync(tmp, append(data, '\n'), 0o600); err != nil {
 		return fmt.Errorf("write zero session metadata: %w", err)
 	}
 	if err := os.Rename(tmp, path); err != nil {
 		_ = os.Remove(tmp)
 		return fmt.Errorf("replace zero session metadata: %w", err)
 	}
+	dir, err := os.Open(filepath.Dir(path))
+	if err != nil {
+		return fmt.Errorf("open zero session directory for sync: %w", err)
+	}
+	defer dir.Close()
+	if err := dir.Sync(); err != nil {
+		return fmt.Errorf("sync zero session directory: %w", err)
+	}
 	return nil
 }

Also applies to: 716-732

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/sessions/store.go` around lines 702 - 713, The code flushes the file
with writeFileSync and atomically renames tmp->path but doesn't fsync the parent
directory; after the successful os.Rename(tmp, path) open the parent directory
(e.g., via os.Open(filepath.Dir(path))), call dirFile.Sync(), close it, and
propagate any Sync error (wrap with context like "sync metadata dir") while
still removing tmp on errors; apply the same directory-sync fix to the other
occurrence in this file that also uses writeFileSync followed by os.Rename.
internal/cli/sandbox.go (1)

285-286: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Update usage errors to document the new --path flag.

Line [285] and Line [341] still print usage strings without --path, while help text now advertises it. This makes CLI recovery instructions inconsistent.

Suggested fix
- return writeExecUsageError(stderr, "usage: zero sandbox grants "+command+" <tool> [--auto low|medium|high] [--reason text] [--json]")
+ return writeExecUsageError(stderr, "usage: zero sandbox grants "+command+" <tool> [--auto low|medium|high] [--reason text] [--path <path>] [--json]")
@@
- return writeExecUsageError(stderr, "usage: zero sandbox grants revoke <tool> [--json]")
+ return writeExecUsageError(stderr, "usage: zero sandbox grants revoke <tool> [--path <path>] [--json]")

Also applies to: 341-342

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/cli/sandbox.go` around lines 285 - 286, Update the usage error
strings that are passed to writeExecUsageError to include the new --path flag;
locate the calls that construct "usage: zero sandbox grants "+command+" <tool>
[--auto low|medium|high] [--reason text] [--json]" (two occurrences) and change
them to include "[--path <path>]" in the bracketed options so the printed
recovery/help text matches the advertised --path flag.
🧹 Nitpick comments (3)
internal/sandbox/safe_command_test.go (1)

253-281: ⚡ Quick win

Add escaped-operator and escaped-quote regression tests.

Current quote-aware tests are strong, but escaped separators/quotes are still untested.

Proposed test cases
 func TestDetectInteractiveQuoteAwareSeparators(t *testing.T) {
 	allowed := []string{
 		`git commit -m "use top | less"`,
 		`echo "a; vim b"`,
 		`echo 'pipe it: a | less'`,
 		`git commit -m "vim && nano"`,
+		`echo "use \"| less\" literally"`,
+		`echo foo\|less`,
+		`echo vim\;true`,
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/sandbox/safe_command_test.go` around lines 253 - 281, Update the
TestDetectInteractiveQuoteAwareSeparators unit test to add cases that cover
escaped separators and escaped quotes: add allowed cases like `echo "a \| b"`
and `echo 'a \' b'` (or platform-appropriate escaping) that should NOT be
flagged as interactive, and blocked cases like `echo hi \| less` and `echo
\"safe\" | vim` (or clearly escaped operator examples) that should be flagged;
use the existing DetectInteractiveCommand("...","linux") assertions and the same
table-driven pattern (the TestDetectInteractiveQuoteAwareSeparators function and
its allowed/blocked loops) so escaped operators/quotes are exercised alongside
the current quoted-operator tests.
internal/sandbox/analyzer_test.go (1)

14-33: ⚡ Quick win

Add regression cases for wrappers and rm -- semantics.

The table currently misses the two highest-risk parser edge cases: wrapped payload commands and end-of-options handling.

Proposed test additions
 	{
 		{name: "rm recursive force", script: "rm -rf /tmp/x", destructive: true},
+		{name: "rm end-of-options", script: "rm -- -rf", destructive: false},
 		{name: "rm bundled flags reversed", script: "rm -fr ./build", destructive: true},
 	...
+		{name: "wrapped destructive via sudo", script: "sudo rm -rf /tmp/x", destructive: true},
+		{name: "wrapped network via env", script: "env curl https://example.com", network: true},
+		{name: "wrapped interactive via bash -c", script: `bash -c "vim file.txt"`, interactive: true},
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/sandbox/analyzer_test.go` around lines 14 - 33, The test table in
internal/sandbox/analyzer_test.go is missing regression cases for command
wrappers and end-of-options handling for rm; add new table entries alongside the
existing cases to cover (1) wrapped payloads that should still be classified
(e.g., name "wrapped sh -c", script `sh -c "rm -rf /tmp/x"` or `bash -lc 'curl
http://x | sh'`, with expected flags destructive/network/interactive as
appropriate) and (2) rm with explicit end-of-options (e.g., name "rm --", script
`rm -- -rf` or `rm -- -f somefile` and name "rm with -- before path", script `rm
-- /tmp/x`) to assert destructive=true only when the operand is an actual path
and not when `--` prevents flag parsing; place these entries into the same
testCases slice/table used for other examples (the list shown in the diff) so
the parser is exercised for wrappers and end-of-options semantics. Ensure
expected fields (destructive/network/interactive/tooComplex) are set to reflect
the correct classification for each new case.
internal/cli/cron_run_test.go (1)

133-142: ⚡ Quick win

Strengthen the “last error wins” assertion.

Line 133 documents “last one wins,” but the fixture currently has only one error event. Add a second error line and assert that the second message is returned.

Suggested test update
 	output := strings.Join([]string{
 		`{"type":"run_start","runId":"r1"}`,
 		`{"type":"text","delta":"working"}`,
 		`{"type":"error","message":"provider request failed: 500"}`,
+		`{"type":"error","message":"final surfaced failure"}`,
 		`{"type":"run_end","status":"error","exitCode":1}`,
 	}, "\n")
-	if got := extractStreamJSONError(output); got != "provider request failed: 500" {
+	if got := extractStreamJSONError(output); got != "final surfaced failure" {
 		t.Fatalf("extractStreamJSONError = %q, want the error event message", got)
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/cli/cron_run_test.go` around lines 133 - 142, The test's fixture and
assertion should prove the "last error wins" behavior: update the output string
in the test to include a second `{"type":"error",...}` line after the existing
error (e.g., `{"type":"error","message":"second failure"}`), and change the
expected value passed to extractStreamJSONError to the second error's message
(e.g., assert it returns "second failure"); this verifies extractStreamJSONError
returns the last error event message.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/background/process_posix.go`:
- Around line 46-58: terminateProcess assumes the input pid is the process group
ID and calls syscall.Kill(-pid,...), which can falsely succeed for non-leader
PIDs; first call syscall.Getpgid(pid) (or equivalent) to resolve the actual pgid
and use that pgid for the group signal and for processGroupAlive checks, falling
back to return the Getpgid error if it fails; update calls in terminateProcess
(and the duplicate block around lines 67-83) to use the resolved pgid when
calling syscall.Kill and when polling liveness via processGroupAlive, and add a
regression test that creates a process group with a leader and a non-leader
child, calls terminateProcess with the child's pid, and asserts the whole group
is terminated.

In `@internal/cli/sandbox.go`:
- Around line 301-310: The code currently treats an explicit empty --path as
"not provided" and widens scope; change the handling so explicit empty values
fail instead of falling back: in the blocks that handle options.path (the branch
that calls filepath.Abs and sets input.Scope and input.ScopeKind to
zeroSandbox.ScopeFile, and the similar logic in revoke and the other locations),
add a check that if options.path == "" then return writeExecUsageError(stderr,
"empty --path is not allowed") (or equivalent usage error) and otherwise proceed
to compute filepath.Abs and set input.Scope/input.ScopeKind; if your options
parsing supports a "wasProvided" flag prefer checking that to distinguish
omitted vs explicit empty, but if not available, treat empty string as an
explicit error. Ensure the same change is applied at the other affected sites so
explicit --path="" never widens scope.

In `@internal/cli/skills.go`:
- Around line 69-76: The duplicate-detection error is currently ignored; update
the skills.Duplicates call handling so when derr != nil you emit a redacted
warning to stderr instead of dropping the error: after calling
skills.Duplicates(dir) check if derr != nil and call fmt.Fprintf(stderr,
"warning: failed to detect duplicate skills: %v\n",
redaction.RedactString(derr.Error(), redaction.Options{})) (or similar redacted
message), otherwise keep the existing loop that prints each duplicate using
redaction.RedactString(dup.Name/dup.Winner/dup.Loser); ensure you continue using
the same stderr variable and do not change the existing duplicate-reporting
branch.

---

Outside diff comments:
In `@internal/cli/exec_spec.go`:
- Around line 169-179: The JSON output branch in the cancellation handling of
runExecSpecDraft is missing the terminal JSON events; modify the cancellation
block (the code checking errors.Is(err, context.Canceled) || runCtx.Err() !=
nil) so that when run.options.outputFormat == execOutputStreamJSON you emit the
same sequence used by runExec: call writer.errorEvent("interrupted", "run
cancelled by signal", false) then writer.runEnd("interrupted", exitInterrupted)
and handle writer.err (return exitCrash on error); keep the existing
sessionRecorder.append(sessions.EventError, ...) and the non-JSON fallback that
prints "Interrupted." unchanged.

In `@internal/cli/sandbox.go`:
- Around line 285-286: Update the usage error strings that are passed to
writeExecUsageError to include the new --path flag; locate the calls that
construct "usage: zero sandbox grants "+command+" <tool> [--auto
low|medium|high] [--reason text] [--json]" (two occurrences) and change them to
include "[--path <path>]" in the bracketed options so the printed recovery/help
text matches the advertised --path flag.

In `@internal/sessions/store.go`:
- Around line 702-713: The code flushes the file with writeFileSync and
atomically renames tmp->path but doesn't fsync the parent directory; after the
successful os.Rename(tmp, path) open the parent directory (e.g., via
os.Open(filepath.Dir(path))), call dirFile.Sync(), close it, and propagate any
Sync error (wrap with context like "sync metadata dir") while still removing tmp
on errors; apply the same directory-sync fix to the other occurrence in this
file that also uses writeFileSync followed by os.Rename.

---

Nitpick comments:
In `@internal/cli/cron_run_test.go`:
- Around line 133-142: The test's fixture and assertion should prove the "last
error wins" behavior: update the output string in the test to include a second
`{"type":"error",...}` line after the existing error (e.g.,
`{"type":"error","message":"second failure"}`), and change the expected value
passed to extractStreamJSONError to the second error's message (e.g., assert it
returns "second failure"); this verifies extractStreamJSONError returns the last
error event message.

In `@internal/sandbox/analyzer_test.go`:
- Around line 14-33: The test table in internal/sandbox/analyzer_test.go is
missing regression cases for command wrappers and end-of-options handling for
rm; add new table entries alongside the existing cases to cover (1) wrapped
payloads that should still be classified (e.g., name "wrapped sh -c", script `sh
-c "rm -rf /tmp/x"` or `bash -lc 'curl http://x | sh'`, with expected flags
destructive/network/interactive as appropriate) and (2) rm with explicit
end-of-options (e.g., name "rm --", script `rm -- -rf` or `rm -- -f somefile`
and name "rm with -- before path", script `rm -- /tmp/x`) to assert
destructive=true only when the operand is an actual path and not when `--`
prevents flag parsing; place these entries into the same testCases slice/table
used for other examples (the list shown in the diff) so the parser is exercised
for wrappers and end-of-options semantics. Ensure expected fields
(destructive/network/interactive/tooComplex) are set to reflect the correct
classification for each new case.

In `@internal/sandbox/safe_command_test.go`:
- Around line 253-281: Update the TestDetectInteractiveQuoteAwareSeparators unit
test to add cases that cover escaped separators and escaped quotes: add allowed
cases like `echo "a \| b"` and `echo 'a \' b'` (or platform-appropriate
escaping) that should NOT be flagged as interactive, and blocked cases like
`echo hi \| less` and `echo \"safe\" | vim` (or clearly escaped operator
examples) that should be flagged; use the existing
DetectInteractiveCommand("...","linux") assertions and the same table-driven
pattern (the TestDetectInteractiveQuoteAwareSeparators function and its
allowed/blocked loops) so escaped operators/quotes are exercised alongside the
current quoted-operator tests.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: da793473-7644-4a13-b6db-28021cd371bf

📥 Commits

Reviewing files that changed from the base of the PR and between cc618ba and 6289a37.

⛔ Files ignored due to path filters (1)
  • go.sum is excluded by !**/*.sum
📒 Files selected for processing (88)
  • README.md
  • cmd/zero-seccomp/main.go
  • docs/audit/2026-06-10-deep-audit-status.md
  • docs/audit/2026-06-13-reverification.md
  • go.mod
  • internal/agent/compaction.go
  • internal/agent/compaction_summarizer_test.go
  • internal/agent/compaction_test.go
  • internal/agent/loop.go
  • internal/agent/loop_test.go
  • internal/background/process_posix.go
  • internal/background/process_posix_test.go
  • internal/background/process_windows.go
  • internal/background/terminate.go
  • internal/cli/cron_run.go
  • internal/cli/cron_run_test.go
  • internal/cli/deferred_wiring_test.go
  • internal/cli/exec.go
  • internal/cli/exec_protocol_test.go
  • internal/cli/exec_sessions.go
  • internal/cli/exec_spec.go
  • internal/cli/exec_test.go
  • internal/cli/exec_tools.go
  • internal/cli/sandbox.go
  • internal/cli/sandbox_test.go
  • internal/cli/skills.go
  • internal/cli/skills_test.go
  • internal/config/contracts.go
  • internal/config/contracts_test.go
  • internal/config/resolver_test.go
  • internal/config/types.go
  • internal/cron/next_test.go
  • internal/cron/schedule.go
  • internal/mcp/client.go
  • internal/mcp/client_test.go
  • internal/mcp/network_client.go
  • internal/mcp/network_client_test.go
  • internal/providers/factory.go
  • internal/providers/gemini/provider.go
  • internal/providers/gemini/provider_test.go
  • internal/providers/gemini/types.go
  • internal/sandbox/analyzer.go
  • internal/sandbox/analyzer_test.go
  • internal/sandbox/egress.go
  • internal/sandbox/egress_test.go
  • internal/sandbox/engine.go
  • internal/sandbox/engine_test.go
  • internal/sandbox/grants.go
  • internal/sandbox/grants_test.go
  • internal/sandbox/log_monitor_darwin.go
  • internal/sandbox/log_monitor_darwin_test.go
  • internal/sandbox/log_monitor_other.go
  • internal/sandbox/runner.go
  • internal/sandbox/runner_test.go
  • internal/sandbox/safe_command.go
  • internal/sandbox/safe_command_test.go
  • internal/sandbox/seccomp.go
  • internal/sandbox/seccomp_linux.go
  • internal/sandbox/seccomp_other.go
  • internal/sandbox/seccomp_test.go
  • internal/sandbox/types.go
  • internal/secrets/scanner.go
  • internal/secrets/scanner_test.go
  • internal/sessions/lineage.go
  • internal/sessions/store.go
  • internal/sessions/store_test.go
  • internal/specialist/accounting.go
  • internal/specialist/accounting_test.go
  • internal/specialist/exec.go
  • internal/specialist/output_tool.go
  • internal/specialist/streamer.go
  • internal/specialist/streamer_test.go
  • internal/tools/bash.go
  • internal/tools/bash_tool_test.go
  • internal/tools/file_tools_test.go
  • internal/tools/read_file.go
  • internal/tools/registry.go
  • internal/tools/web_fetch.go
  • internal/tools/web_fetch_test.go
  • internal/tools/web_search.go
  • internal/tools/web_search_test.go
  • internal/usage/report.go
  • internal/usage/report_test.go
  • internal/worktrees/worktrees.go
  • internal/worktrees/worktrees_test.go
  • internal/zerogit/zerogit.go
  • internal/zerogit/zerogit_test.go
  • internal/zeroruntime/helpers.go
💤 Files with no reviewable changes (4)
  • internal/config/contracts.go
  • internal/config/contracts_test.go
  • internal/specialist/output_tool.go
  • internal/tools/registry.go

Comment thread internal/background/process_posix.go Outdated
Comment thread internal/cli/sandbox.go
Comment thread internal/cli/skills.go
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.
@gnanam1990

Copy link
Copy Markdown
Collaborator Author

Addressed the re-review findings in 591f70b (batch 37). Each behavioural fix has a regression test (several confirmed to fail without the change):

  • analyzer: wrapper-aware (sudo/env/bash -c … now classify on the payload, with bounded sh -c recursion) + honors rm -- end-of-options.
  • engine: NetworkHostAllowed now shares one backend-aware effective-mode helper with Evaluate, so scoped fails closed when the backend can't enforce scoped egress (single source of truth).
  • egress: prompt-decision cache keyed by host:port, not host.
  • safe_command: segment splitter is now escape-aware (\|, \").
  • log_monitor (darwin): Wait() the log stream child to avoid a zombie.
  • web_search: backend confined to same-host redirects (with a limit) so a hop can't egress to an unchecked host.
  • cli/sandbox: empty --path (--path= / --path "") fails closed instead of widening scope.
  • cli/skills: warns on stderr when duplicate detection fails.
  • specialist/exec: joins a failed orphan-kill with the SetPID error instead of masking it.

One deliberate deviation — background/process_posix.go: I did not adopt the suggested Getpgid(pid)kill(-pgid, …) directly. For a PID that is not its own group leader, pgid can be another group — possibly our own — and kill(-pgid) would then signal unrelated processes (including the agent). Instead I resolve the PGID and only group-signal when pgid == pid (leader), falling back to signalling the individual PID otherwise. That fixes the false-success-on-ESRCH concern you raised without the own-group hazard.

CI green on all four Smoke jobs (ubuntu/macos/windows) + vet, host/linux/windows builds.

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jun 13, 2026

Copy link
Copy Markdown

@gnanam1990 Kicking off a full re-review for batch 37 (591f70b).

Quick notes before the review lands:

  • The background/process_posix.go deviation is the right call. Blindly using kill(-pgid) without confirming the target is its own group leader is a genuine own-group hazard — your pgid == pid guard before group-signaling is the safe, correct fix.
  • The host:port cache keying for egress prompt decisions is an important correctness fix — a service on port 80 and port 443 of the same host should be authorized independently.
  • Wait()-ing the log stream child on Darwin to avoid zombies and the fails-closed behavior for empty --path are both solid safety improvements.
✅ Action performed

Full review finished.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (4)
internal/agent/compaction.go (1)

347-387: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Keep lowWaterMark on the same token basis after reactive compaction.

Line 386 stores a message-only baseline, but proactive checks use messages + tool definitions (size at Line 311). That mismatch can make unchanged history look “grown” after a reactive recover, causing redundant re-compactions and extra summarizer cost.

💡 Proposed fix
diff --git a/internal/agent/compaction.go b/internal/agent/compaction.go
@@
 func (state *compactionState) recover(
 	ctx context.Context,
 	provider Provider,
 	messages []zeroruntime.Message,
+	tools []zeroruntime.ToolDefinition,
 	errorMessage string,
 ) (compacted []zeroruntime.Message, retried bool, err error) {
@@
+	toolTokens := estimateToolDefTokens(tools)
@@
-	state.lowWaterMark = estimateTokens(result)
+	state.lowWaterMark = estimateTokens(result) + toolTokens
 	return result, true, nil
 }
diff --git a/internal/agent/loop.go b/internal/agent/loop.go
@@
-			if compacted, retried, retryErr := compactor.recover(ctx, provider, messages, err.Error()); retried {
+			if compacted, retried, retryErr := compactor.recover(ctx, provider, messages, exposed, err.Error()); retried {
@@
-			if compacted, retried, retryErr := compactor.recover(ctx, provider, messages, collected.Error); retried {
+			if compacted, retried, retryErr := compactor.recover(ctx, provider, messages, exposed, collected.Error); retried {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/agent/compaction.go` around lines 347 - 387, The reactive recover
currently sets state.lowWaterMark = estimateTokens(result), but proactive checks
compare a different "size" metric (estimateTokens(messages) plus the
tool-definitions overhead), causing mismatched baselines; change recover (in the
recover method of compactionState) to compute and store the same composite size
used by the proactive path (i.e., the identical "size" calculation referenced at
the proactive check around Line 311) and assign that to state.lowWaterMark
(reuse the same helper or code that builds that size so both paths use the exact
same token-basis), leaving reactiveAttempted/estimateTokens/result logic
unchanged.
internal/specialist/accounting.go (1)

49-52: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Make usageRolledUp reflect persisted state under concurrent finishers.

On Line 51, usageRolledUp is written from caller-local state, which can be stale under contention. If one goroutine appends usage and another wins the stop append with rolledUp=false, the persisted specialist_stop payload becomes inconsistent with the actual usage event history.

Proposed fix
 	payload["status"] = specialistStopStatus(status, exitCode, runErr)
 	payload["exitCode"] = exitCode
+	if !usageRolledUp && specialistEventExists(store, input.ParentSessionID, sessions.EventUsage, input.ChildSessionID, summary.RunID) {
+		usageRolledUp = true
+	}
 	payload["usageRolledUp"] = usageRolledUp
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/specialist/accounting.go` around lines 49 - 52, The payload
currently sets "usageRolledUp" from the caller-local variable usageRolledUp
which can be stale under concurrent finishers; update the code that builds the
specialist_stop payload to read the persisted rolledUp value returned by the
append/stop operation (or explicitly re-read the latest persisted usage/stop
record) and set payload["usageRolledUp"] from that persisted value (e.g., use
the append result's RolledUp flag or a DB read) instead of the local
usageRolledUp so the specialist_stop payload always reflects the persisted
state.
internal/cli/exec_spec.go (1)

169-179: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Emit terminal JSON interruption events in spec-draft mode, not stderr text.

runExecSpecDraft still routes interrupted -o json runs through the text fallback, so JSON consumers do not receive terminal error + done events like the main runExec path.

💡 Suggested fix
 		if errors.Is(err, context.Canceled) || runCtx.Err() != nil {
 			sessionRecorder.append(sessions.EventError, map[string]any{"message": "interrupted"})
-			if run.options.outputFormat == execOutputStreamJSON {
+			switch run.options.outputFormat {
+			case execOutputStreamJSON:
 				writer.errorEvent("interrupted", "run cancelled by signal", false)
 				writer.runEnd("interrupted", exitInterrupted)
 				if writer.err != nil {
 					return exitCrash
 				}
-			} else {
+			case execOutputJSON:
+				writer.errorEvent("interrupted", "run cancelled by signal", false)
+				writer.writeJSON(map[string]any{"type": "done", "exit_code": exitInterrupted})
+				if writer.err != nil {
+					return exitCrash
+				}
+			default:
 				fmt.Fprintln(run.stderr, "Interrupted.")
 			}
 			return exitInterrupted
 		}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/cli/exec_spec.go` around lines 169 - 179, The spec-draft interrupt
handling in runExecSpecDraft should emit terminal JSON events when
run.options.outputFormat == execOutputStreamJSON instead of falling back to
stderr text; modify the branch in runExecSpecDraft that currently does
fmt.Fprintln(run.stderr, "Interrupted.") so that when outputFormat ==
execOutputStreamJSON it calls writer.errorEvent("interrupted", "run cancelled by
signal", false) and writer.runEnd("interrupted", exitInterrupted) (and returns
exitCrash if writer.err != nil), otherwise keep the existing stderr text path;
ensure sessionRecorder.append(sessions.EventError, ...) remains unchanged.
internal/cli/sandbox.go (1)

284-286: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Update usage errors to include --path for grant set/revoke commands.

The command parser accepts --path, but the usage strings shown on argument-count failures still omit it, which can mislead users during recovery.

Suggested patch
-		return writeExecUsageError(stderr, "usage: zero sandbox grants "+command+" <tool> [--auto low|medium|high] [--reason text] [--json]")
+		return writeExecUsageError(stderr, "usage: zero sandbox grants "+command+" <tool> [--auto low|medium|high] [--reason text] [--path <path>] [--json]")
@@
-		return writeExecUsageError(stderr, "usage: zero sandbox grants revoke <tool> [--json]")
+		return writeExecUsageError(stderr, "usage: zero sandbox grants revoke <tool> [--path <path>] [--json]")

Also applies to: 340-342

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/cli/sandbox.go` around lines 284 - 286, The usage error strings for
the grants subcommands omit the accepted --path flag; update the message passed
to writeExecUsageError (the string built with "usage: zero sandbox grants
"+command+" <tool> ...") to include [--path] (e.g. add " [--path <path>]") so
users see the flag; make this change for both occurrences where the positional
length check returns the usage error (the blocks using len(positional) != 1 and
calling writeExecUsageError with the grants usage string) so set/revoke both
display the corrected usage.
🧹 Nitpick comments (2)
internal/cli/sandbox_test.go (1)

167-169: ⚡ Quick win

Assert exitUsage explicitly for rejected empty --path cases.

These cases are specifically usage errors; checking only “non-success” can hide accidental crash/error-path regressions.

Suggested patch
-		if exit := run(args...); exit == exitSuccess {
-			t.Fatalf("%v: expected a usage error for an empty --path, got success", args)
+		if exit := run(args...); exit != exitUsage {
+			t.Fatalf("%v: expected usage exit (%d) for empty --path, got %d", args, exitUsage, exit)
 		}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/cli/sandbox_test.go` around lines 167 - 169, The test currently
checks that run(args...) != exitSuccess for empty --path cases; change it to
assert the exact usage exit code by replacing the condition to if exit :=
run(args...); exit != exitUsage { t.Fatalf("%v: expected exitUsage for an empty
--path, got %v", args, exit) } so the test uses the run function and the
exitUsage and exitSuccess symbols to explicitly require an exitUsage result
rather than any non-success code.
internal/sandbox/egress_test.go (1)

301-344: ⚡ Quick win

Add a regression assertion that cache decisions are isolated per port.

This test currently proves same-host same-port caching, but not cross-port isolation. Add a second authorize call on the same host with a different port and assert an additional prompt invocation.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/sandbox/egress_test.go` around lines 301 - 344, Add a cross-port
assertion to TestEgressProxyAuthorizeDomainPrompt to ensure cache decisions are
per-port: after the existing cached allow checks, call
proxy.authorize("ask-allow.test", 80) (different port) and assert it returns
true, then check mu-guarded calls["ask-allow.test"] == 2 to verify the prompt
was invoked again for the different port; reference the existing
egressProxy.domainPrompt callback and authorize method when making the
insertion.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/sandbox/analyzer.go`:
- Around line 142-146: The parser currently uses a global
wrapperValueOptions[text] check which can skip payload tokens regardless of
which wrapper is active; change the parsing loop to track the active wrapper
token (e.g., set activeWrapper when you detect a wrapper like "sudo" or other
wrapper tokens) and replace the global lookup with a per-wrapper map lookup
(e.g., wrapperValueOptions[activeWrapper][text]) so only options for the current
wrapper consume the next token via index++; update the loop that references
text, args, and index to consult the active-wrapper-specific map and clear
activeWrapper once the wrapper's option parsing is complete; add regression
tests covering cases like "sudo -n rm -rf /tmp/x" and "sudo -n curl
https://x.test" to ensure the payload command is not skipped.

In `@internal/sandbox/egress.go`:
- Around line 256-273: The promptForHost function can still trigger concurrent
runDomainPrompt calls for the same host:port; add an in-flight deduplication so
only one caller runs runDomainPrompt and others wait for its result. Introduce
an inFlight map keyed by the same key (e.g., map[string]chan bool) guarded by
proxy.cacheMu (or a separate mutex), and in promptForHost: after checking
decisionCache, if no decision exists check if a channel exists in inFlight — if
so, unlock and wait to receive the bool result from that channel and return it;
if not, create a channel, store it in inFlight, unlock, call
proxy.runDomainPrompt(host, port), then lock, write the result into
decisionCache, remove the channel from inFlight, close the channel (or send the
result then close), unlock and return; ensure waiting goroutines receive the
same result and that the inFlight entry is removed after completion to avoid
leaks. Use the existing symbols promptForHost, runDomainPrompt, decisionCache
and cacheMu when making changes.

In `@internal/sandbox/safe_command.go`:
- Around line 504-507: The parser currently treats ')' as closing a substitution
regardless of quote state; modify the handler that checks "case c == ')' &&
substitutionDepth > 0" so it only decrements substitutionDepth and calls flush()
when the ')' is not inside double quotes (i.e., check the parser's double-quote
state / inDoubleQuotes flag and require !inDoubleQuotes before closing the
substitution). Update the logic that tracks quotes accordingly if needed so
quoted ')' inside $(...) are ignored. Add a regression test/assertion that
parses a command like echo $(printf "a) less") (or uses the existing
Parse/Tokenize function) and asserts that the quoted ')' inside the $() payload
does not terminate the substitution and does not create a spurious segment.

---

Outside diff comments:
In `@internal/agent/compaction.go`:
- Around line 347-387: The reactive recover currently sets state.lowWaterMark =
estimateTokens(result), but proactive checks compare a different "size" metric
(estimateTokens(messages) plus the tool-definitions overhead), causing
mismatched baselines; change recover (in the recover method of compactionState)
to compute and store the same composite size used by the proactive path (i.e.,
the identical "size" calculation referenced at the proactive check around Line
311) and assign that to state.lowWaterMark (reuse the same helper or code that
builds that size so both paths use the exact same token-basis), leaving
reactiveAttempted/estimateTokens/result logic unchanged.

In `@internal/cli/exec_spec.go`:
- Around line 169-179: The spec-draft interrupt handling in runExecSpecDraft
should emit terminal JSON events when run.options.outputFormat ==
execOutputStreamJSON instead of falling back to stderr text; modify the branch
in runExecSpecDraft that currently does fmt.Fprintln(run.stderr, "Interrupted.")
so that when outputFormat == execOutputStreamJSON it calls
writer.errorEvent("interrupted", "run cancelled by signal", false) and
writer.runEnd("interrupted", exitInterrupted) (and returns exitCrash if
writer.err != nil), otherwise keep the existing stderr text path; ensure
sessionRecorder.append(sessions.EventError, ...) remains unchanged.

In `@internal/cli/sandbox.go`:
- Around line 284-286: The usage error strings for the grants subcommands omit
the accepted --path flag; update the message passed to writeExecUsageError (the
string built with "usage: zero sandbox grants "+command+" <tool> ...") to
include [--path] (e.g. add " [--path <path>]") so users see the flag; make this
change for both occurrences where the positional length check returns the usage
error (the blocks using len(positional) != 1 and calling writeExecUsageError
with the grants usage string) so set/revoke both display the corrected usage.

In `@internal/specialist/accounting.go`:
- Around line 49-52: The payload currently sets "usageRolledUp" from the
caller-local variable usageRolledUp which can be stale under concurrent
finishers; update the code that builds the specialist_stop payload to read the
persisted rolledUp value returned by the append/stop operation (or explicitly
re-read the latest persisted usage/stop record) and set payload["usageRolledUp"]
from that persisted value (e.g., use the append result's RolledUp flag or a DB
read) instead of the local usageRolledUp so the specialist_stop payload always
reflects the persisted state.

---

Nitpick comments:
In `@internal/cli/sandbox_test.go`:
- Around line 167-169: The test currently checks that run(args...) !=
exitSuccess for empty --path cases; change it to assert the exact usage exit
code by replacing the condition to if exit := run(args...); exit != exitUsage {
t.Fatalf("%v: expected exitUsage for an empty --path, got %v", args, exit) } so
the test uses the run function and the exitUsage and exitSuccess symbols to
explicitly require an exitUsage result rather than any non-success code.

In `@internal/sandbox/egress_test.go`:
- Around line 301-344: Add a cross-port assertion to
TestEgressProxyAuthorizeDomainPrompt to ensure cache decisions are per-port:
after the existing cached allow checks, call proxy.authorize("ask-allow.test",
80) (different port) and assert it returns true, then check mu-guarded
calls["ask-allow.test"] == 2 to verify the prompt was invoked again for the
different port; reference the existing egressProxy.domainPrompt callback and
authorize method when making the insertion.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: e5818346-4e64-483f-86cc-82f62600de00

📥 Commits

Reviewing files that changed from the base of the PR and between cc618ba and 591f70b.

⛔ Files ignored due to path filters (1)
  • go.sum is excluded by !**/*.sum
📒 Files selected for processing (88)
  • README.md
  • cmd/zero-seccomp/main.go
  • docs/audit/2026-06-10-deep-audit-status.md
  • docs/audit/2026-06-13-reverification.md
  • go.mod
  • internal/agent/compaction.go
  • internal/agent/compaction_summarizer_test.go
  • internal/agent/compaction_test.go
  • internal/agent/loop.go
  • internal/agent/loop_test.go
  • internal/background/process_posix.go
  • internal/background/process_posix_test.go
  • internal/background/process_windows.go
  • internal/background/terminate.go
  • internal/cli/cron_run.go
  • internal/cli/cron_run_test.go
  • internal/cli/deferred_wiring_test.go
  • internal/cli/exec.go
  • internal/cli/exec_protocol_test.go
  • internal/cli/exec_sessions.go
  • internal/cli/exec_spec.go
  • internal/cli/exec_test.go
  • internal/cli/exec_tools.go
  • internal/cli/sandbox.go
  • internal/cli/sandbox_test.go
  • internal/cli/skills.go
  • internal/cli/skills_test.go
  • internal/config/contracts.go
  • internal/config/contracts_test.go
  • internal/config/resolver_test.go
  • internal/config/types.go
  • internal/cron/next_test.go
  • internal/cron/schedule.go
  • internal/mcp/client.go
  • internal/mcp/client_test.go
  • internal/mcp/network_client.go
  • internal/mcp/network_client_test.go
  • internal/providers/factory.go
  • internal/providers/gemini/provider.go
  • internal/providers/gemini/provider_test.go
  • internal/providers/gemini/types.go
  • internal/sandbox/analyzer.go
  • internal/sandbox/analyzer_test.go
  • internal/sandbox/egress.go
  • internal/sandbox/egress_test.go
  • internal/sandbox/engine.go
  • internal/sandbox/engine_test.go
  • internal/sandbox/grants.go
  • internal/sandbox/grants_test.go
  • internal/sandbox/log_monitor_darwin.go
  • internal/sandbox/log_monitor_darwin_test.go
  • internal/sandbox/log_monitor_other.go
  • internal/sandbox/runner.go
  • internal/sandbox/runner_test.go
  • internal/sandbox/safe_command.go
  • internal/sandbox/safe_command_test.go
  • internal/sandbox/seccomp.go
  • internal/sandbox/seccomp_linux.go
  • internal/sandbox/seccomp_other.go
  • internal/sandbox/seccomp_test.go
  • internal/sandbox/types.go
  • internal/secrets/scanner.go
  • internal/secrets/scanner_test.go
  • internal/sessions/lineage.go
  • internal/sessions/store.go
  • internal/sessions/store_test.go
  • internal/specialist/accounting.go
  • internal/specialist/accounting_test.go
  • internal/specialist/exec.go
  • internal/specialist/output_tool.go
  • internal/specialist/streamer.go
  • internal/specialist/streamer_test.go
  • internal/tools/bash.go
  • internal/tools/bash_tool_test.go
  • internal/tools/file_tools_test.go
  • internal/tools/read_file.go
  • internal/tools/registry.go
  • internal/tools/web_fetch.go
  • internal/tools/web_fetch_test.go
  • internal/tools/web_search.go
  • internal/tools/web_search_test.go
  • internal/usage/report.go
  • internal/usage/report_test.go
  • internal/worktrees/worktrees.go
  • internal/worktrees/worktrees_test.go
  • internal/zerogit/zerogit.go
  • internal/zerogit/zerogit_test.go
  • internal/zeroruntime/helpers.go
💤 Files with no reviewable changes (4)
  • internal/config/contracts.go
  • internal/config/contracts_test.go
  • internal/specialist/output_tool.go
  • internal/tools/registry.go

Comment thread internal/sandbox/analyzer.go
Comment thread internal/sandbox/egress.go Outdated
Comment thread internal/sandbox/safe_command.go Outdated
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.
@gnanam1990

Copy link
Copy Markdown
Collaborator Author

Addressed re-review round 2 in 89ca8d8 (batch 38), each with a regression test:

  • safe_command: a ) is only a substitution boundary when it closes an active, unquoted $(...) — a quote-state stack runs each substitution in a fresh quoting context and restores the outer quoting on its matching ). Fixes echo $(printf "a) less") while still isolating echo "$(vim x)".
  • egress: in-flight domain prompts deduped per host:port (singleflight) — concurrent requests wait on the first prompt and reuse its decision; race-tested with 25 concurrent authorizers → one prompt.
  • analyzer + safe_command: wrapper option-value consumption is now wrapper-specific, so sudo -n rm -rf / sudo -n curl are no longer mis-parsed (the valueless -n can't swallow the payload) while sudo -u root vim still consumes its value. Shared by the AST analyzer and regex detector.

CI green; go vet + -race on sandbox/background clean.

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jun 13, 2026

Copy link
Copy Markdown

@gnanam1990 Kicking off a full re-review for batch 38 (89ca8d8).

Quick notes on the three fixes:

  • safe_command quote-state stack: Running each $( substitution in a fresh quoting context and restoring outer state on the matching ) is the right design — it correctly handles echo $(printf "a) less") () closes the substitution, not the inner double-quote) while still isolating echo "$(vim x)" (the outer double-quote keeps the $() as a unit).
  • Egress singleflight: Using singleflight for in-flight prompt deduplication per host:port is the right primitive — 25-goroutine race test with one observed prompt confirms the invariant holds under concurrency.
  • Wrapper-specific option schemas: Distinguishing valueless flags (sudo -n) from value-consuming ones (sudo -u <user>) at the wrapper level is the correct fix — shared between the AST analyzer and regex detector avoids the split-brain risk.
✅ Action performed

Full review finished.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 12

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
internal/agent/compaction.go (1)

365-387: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Use a consistent token basis for lowWaterMark after reactive compaction.

Line 386 writes lowWaterMark using message-only tokens, but maybeCompact compares with messages + tool-definition tokens. After reactive compaction, this mismatch can re-trigger compaction on the next turn even when history didn’t materially grow, causing unnecessary summarization churn.

Suggested fix
--- a/internal/agent/compaction.go
+++ b/internal/agent/compaction.go
@@
 func (state *compactionState) recover(
 	ctx context.Context,
 	provider Provider,
 	messages []zeroruntime.Message,
+	tools []zeroruntime.ToolDefinition,
 	errorMessage string,
 ) (compacted []zeroruntime.Message, retried bool, err error) {
+	toolTokens := estimateToolDefTokens(tools)
@@
-	if estimateTokens(result) >= estimateTokens(messages) {
+	if estimateTokens(result)+toolTokens >= estimateTokens(messages)+toolTokens {
 		return messages, false, nil
 	}
@@
-	state.lowWaterMark = estimateTokens(result)
+	state.lowWaterMark = estimateTokens(result) + toolTokens
 	return result, true, nil
 }
--- a/internal/agent/loop.go
+++ b/internal/agent/loop.go
@@
-			if compacted, retried, retryErr := compactor.recover(ctx, provider, messages, err.Error()); retried {
+			if compacted, retried, retryErr := compactor.recover(ctx, provider, messages, exposed, err.Error()); retried {
@@
-			if compacted, retried, retryErr := compactor.recover(ctx, provider, messages, collected.Error); retried {
+			if compacted, retried, retryErr := compactor.recover(ctx, provider, messages, exposed, collected.Error); retried {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/agent/compaction.go` around lines 365 - 387, The reactive compaction
sets state.lowWaterMark using message-only tokens which mismatches
maybeCompact's comparison basis (messages + tool-definition tokens); update the
assignment so lowWaterMark is computed using the same token basis maybeCompact
uses (i.e., include the tool-definition/token overhead when calling
estimateTokens) to avoid spurious re-compactions—change the lowWaterMark update
after successful compaction in maybeCompact (the block that sets
state.reactiveAttempted and state.lowWaterMark using estimateTokens(result)) to
compute lowWaterMark with the full-history/token-basis used by maybeCompact (use
the same helper or formula that maybeCompact uses when comparing messages +
tool-definition tokens).
internal/sessions/store.go (1)

702-731: ⚠️ Potential issue | 🟠 Major

Make event writes durable before fsync/renaming metadata (and fsync the directory after rename).

In internal/sessions/store.go, appendEventLocked appends to events.jsonl and only Close()s the file (no file.Sync()), then writeMetadata persistently fsyncs/renames metadata.json. A power loss between those steps can leave EventCount / LastEventType advanced in metadata.json while the last JSONL record is not on disk, making the session internally inconsistent for subsequent appends/rewinds.

Also, writeMetadata does os.Rename without fsyncing the containing directory afterward, so the rename itself may not be crash-durable.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/sessions/store.go` around lines 702 - 731, appendEventLocked
currently writes to events.jsonl and only Close()s the file—ensure the event
record is fsynced before updating metadata: after the file.Write in
appendEventLocked call file.Sync() (and handle/return errors) before closing so
the JSONL record is durably on disk. For metadata persistence, writeMetadata
(which uses writeFileSync then os.Rename) must fsync the containing directory
after the atomic rename to make the rename durable: after os.Rename(pathTmp,
path) open the parent directory (os.Open on filepath.Dir(path)), call
dir.Sync(), close it and propagate errors. You may keep writeFileSync as-is for
file data fsync but add the directory fsync in writeMetadata; update error
handling to remove tmp on failures where appropriate.
♻️ Duplicate comments (1)
internal/zerogit/zerogit.go (1)

235-240: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Consume the extra rename/copy source field when R/C appears in either status column.

At Line 238, only code[0] is checked. Unstaged rename/copy entries (code[1] == 'R'/'C') leave the source path unconsumed and it gets misparsed as its own file entry.

Proposed fix
-		// A rename/copy (R or C in the staged column) is followed by a separate
+		// A rename/copy (R or C in either column) is followed by a separate
 		// NUL-terminated field holding the original path; consume it so it is not
 		// parsed as its own entry. This entry's own path is the destination.
-		if code[0] == 'R' || code[0] == 'C' {
+		if code[0] == 'R' || code[0] == 'C' || code[1] == 'R' || code[1] == 'C' {
 			i++
 		}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/zerogit/zerogit.go` around lines 235 - 240, The current logic only
checks code[0] when deciding to consume the extra NUL-terminated source path for
renames/copies; update the check so that if either code[0] == 'R' || code[0] ==
'C' OR code[1] == 'R' || code[1] == 'C' is true you increment i to consume the
extra field (i++), ensuring unstaged rename/copy entries (when code[1] is R/C)
are also handled; locate the check around the comment about "A rename/copy (R or
C in the staged column)..." that inspects code and modify that conditional
accordingly.
🧹 Nitpick comments (5)
internal/cli/cron_run_test.go (1)

131-146: ⚡ Quick win

Add an explicit “last error wins” assertion in the new helper test.

The helper is documented to return the last error event message, but this test only exercises a single error event. Add a two-error case to lock the contract.

Proposed test addition
 func TestExtractStreamJSONError(t *testing.T) {
@@
 	if got := extractStreamJSONError(`{"type":"run_end","status":"success"}`); got != "" {
 		t.Fatalf("no error event should yield empty, got %q", got)
 	}
+	if got := extractStreamJSONError(strings.Join([]string{
+		`{"type":"error","message":"first error"}`,
+		`{"type":"error","message":"second error"}`,
+	}, "\n")); got != "second error" {
+		t.Fatalf("extractStreamJSONError should return last error message, got %q", got)
+	}
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/cli/cron_run_test.go` around lines 131 - 146,
TestExtractStreamJSONError currently only checks a single error event; add an
assertion that verifies the helper extractStreamJSONError returns the last error
when multiple error events are present. Update the test
(TestExtractStreamJSONError) to include an output string with at least two
`{"type":"error","message":"..."}` lines (different messages) plus other events,
call extractStreamJSONError on that string, and assert the returned value equals
the last error message to lock the "last error wins" contract.
internal/cli/exec_protocol_test.go (1)

632-648: ⚡ Quick win

Assert stderr remains silent for -o json interruption.

The test narrative says JSON mode should not emit "Interrupted." on stderr, but that behavior is not currently asserted.

Suggested assertion
 	if exitCode != exitInterrupted {
 		t.Fatalf("expected interrupted exit %d, got %d: %s", exitInterrupted, exitCode, stderr.String())
 	}
+	if stderr.Len() != 0 {
+		t.Fatalf("expected empty stderr for -o json interruption, got %q", stderr.String())
+	}
 	// -o json must end the stream with a terminal error+done, not just print
 	// "Interrupted." to stderr (which the stream-json path already avoided).
 	events := decodeJSONLines(t, stdout.String())
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/cli/exec_protocol_test.go` around lines 632 - 648, The test
currently checks the JSON output events but doesn't assert that stderr is silent
for -o json interruptions; add an assertion after the exitCode check (where
exitInterrupted, stderr and stdout are available) to ensure stderr.String() is
empty or does not contain "Interrupted." (e.g., t.Fatalf if stderr.String() !=
"" or strings.Contains(stderr.String(), "Interrupted.")). Keep the new assertion
just before calling decodeJSONLines and use the existing stderr variable so the
test verifies JSON mode produces no stderr output.
internal/cli/deferred_wiring_test.go (1)

196-215: ⚡ Quick win

Validate the full tool-entry JSON contract (all entries), not only index 0.

This currently checks only payload.Tools[0]; a malformed later entry (or missing side_effect) would still pass.

Suggested test tightening
-	if payload.Tools[0].Name == "" || payload.Tools[0].Permission == "" {
-		t.Fatalf("tool entries must carry name + permission: %#v", payload.Tools[0])
-	}
+	for i, tool := range payload.Tools {
+		if tool.Name == "" || tool.Permission == "" || tool.SideEffect == "" {
+			t.Fatalf("tool[%d] must carry name + permission + side_effect: %#v", i, tool)
+		}
+	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/cli/deferred_wiring_test.go` around lines 196 - 215, The test
currently only validates payload.Tools[0]; instead iterate over all entries in
payload.Tools (for i, tool := range payload.Tools) and for each assert tool.Name
!= "" && tool.Permission != "" && tool.SideEffect != "" (use t.Fatalf with the
index to show which entry failed, e.g. t.Fatalf("tool entry %d must carry
name+permission+side_effect: %#v", i, tool)). Keep the existing Type and
non-zero length checks but replace the single-index assertions with this loop so
every tool JSON entry is validated.
internal/sessions/store_test.go (1)

196-216: ⚡ Quick win

Assert the fork-marker payload too.

This proves usage events are filtered, but it does not pin the other behavior changed in Fork: copiedEventCount now excludes usage events. If that payload regresses back to the parent’s full event count, this test still passes. Decode the trailing EventSessionFork payload and assert copiedEventCount == 1 (and ideally parentEventCount == 2).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/sessions/store_test.go` around lines 196 - 216, Update the test that
calls store.Fork(...) and ReadEvents(...) to also decode and assert the payload
of the trailing EventSessionFork event: locate the last event whose Type ==
EventSessionFork, decode its payload fields parentEventCount and
copiedEventCount (the fork marker payload produced by Fork) and add assertions
that copiedEventCount == 1 and parentEventCount == 2; keep the existing checks
that no EventUsage was copied and that the event type sequence equals
[]EventType{EventMessage, EventSessionFork}.
internal/usage/report_test.go (1)

113-151: ⚡ Quick win

Make the precedence check conflict with session metadata.

meta[0].ModelID == "" only proves the event model is used when there is no fallback. This still passes if BuildReport incorrectly prefers a non-empty session model. Set the session metadata to a different valid model and keep the expectation on "gpt-4.1" so the test actually locks in event-model precedence.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/usage/report_test.go` around lines 113 - 151, The test currently
sets meta[0].ModelID == "" which doesn't prove precedence; change the session
metadata to a different valid model id (e.g. meta[0].ModelID = "gpt-4") while
leaving the event payload model as "gpt-4.1" and keeping the expected cost
derived from "gpt-4.1". Update the meta slice in the test so BuildReport,
events, and the registry (variable registry) show a conflicting non-empty
session model to force the code path that must prefer the event's payload model.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/background/process_posix_test.go`:
- Around line 153-155: The child liveness probe currently treats any non-nil
error from syscall.Kill(childPID, 0) as the child having exited; change that to
only treat syscall.ESRCH as “child no longer exists” and fail the test for any
other error. Concretely, in the test code around the child liveness probe where
syscall.Kill(childPID, 0) is called (using childPID), capture the returned error
into a variable, if err == nil continue, if err == syscall.ESRCH break,
otherwise call t.Fatalf (or t.Fatalf-like helper) reporting the unexpected Kill
error so other errors don’t get masked.

In `@internal/background/terminate.go`:
- Around line 3-6: Update the TerminateProcess docstring to accurately describe
POSIX behavior: state that it attempts to signal the whole process group but
will fall back to signaling the single PID when the PID is not a group leader,
and explicitly mention ConfigureChildProcessGroup as the helper to ensure a
child is in its own group if callers require whole-group termination; do not
change runtime behavior here, only correct the documentation in the
TerminateProcess comment to avoid implying guaranteed group cleanup.

In `@internal/cli/sandbox.go`:
- Around line 463-482: The grant command usage text is stale and omits the
newly-supported --path flag; update the usage/error strings shown for the allow,
deny and revoke grant subcommands so they list --path alongside the other flags
(the places that return execUsageError for those subcommands and any constant
usage/help strings shown to users). Search in sandbox.go for the grant-related
usage text and the execUsageError messages used by the allow|deny|revoke
handling and add "--path" to the displayed flag list so usage errors and help
output include the new --path option.

In `@internal/mcp/network_client.go`:
- Around line 654-663: The scanner currently only enforces a per-line cap
(maxSSEEventBytes) so an event assembled from many `data:` lines (dataLines) can
grow without bound; update scanSSEEvents to track an aggregate byte count for
the current SSE event (e.g., introduce maxSSEEventTotalBytes) that is
incremented as each `data:` line is appended and reset when an event is
dispatched, and if the aggregate exceeds the cap return a clear error and abort
processing; reference and update the scanner usage in scanSSEEvents and ensure
existing per-line buffering via maxSSEEventBytes remains in place.

In `@internal/sandbox/analyzer.go`:
- Around line 136-139: The code currently returns early when
wordText(args[index]) is empty, which aborts unwrapping for expansion-only
wrapper args; modify the logic so that in the function where text :=
wordText(args[index]) is computed you only return on empty text if wrapper is
not yet set (e.g., if wrapper == "" then return "", nil), otherwise continue
scanning (skip this arg and keep looking for the actual program token) so
wrapper unwrapping still completes for cases like env "$opts" curl ...; update
the surrounding loop/control flow to continue rather than return when wrapper is
already set and add regression tests covering wrapper scenarios such as `env
"$opts" curl https://x.test` and `sudo "$maybeFlag" rm -rf /tmp/x` to ensure the
literal payload is detected later in args.

In `@internal/sandbox/egress.go`:
- Around line 299-317: The current runDomainPrompt spawns domainPrompt in a
goroutine and only times out waiting, leaking the underlying callback; change
runDomainPrompt to create a cancellable context with context.WithTimeout(ctx,
timeout) and pass that context into domainPrompt (i.e., change signature from
domainPrompt(host,int) to domainPrompt(ctx context.Context, host string, port
int) and update callers), start the goroutine with that ctx so domainPrompt can
observe cancellation and return early, and when the select picks the timeout
case call cancel() (or let the context timeout) and ensure the goroutine's done
value is still consumed or the function returns only after canceling so no
lingering goroutine or late side-effects occur; update any call sites to
propagate the new context-based contract.

In `@internal/sandbox/runner.go`:
- Around line 509-513: The current global const sandboxDenialLogTag should be
replaced with a per-plan unique tag: add a denialLogTag string field to
CommandPlan (or set it when building a CommandPlan), generate a unique value
(e.g., uuid4 or timestamp+pid) when the plan is created, and use that tag
wherever the profile text is constructed instead of the global
sandboxDenialLogTag; update StartDenialMonitor to filter by the
CommandPlan.denialLogTag for that run (and ensure any code that previously
referenced the global sandboxDenialLogTag now takes the tag from the plan or its
profile), leaving a sensible fallback only if a plan tag is missing.
- Around line 359-363: findSeccompHelper currently returns a sibling
"zero-seccomp" next to the binary if it exists but doesn't check execute
permission; change the sibling-path branch in findSeccompHelper to verify the
candidate is a regular file and is executable (e.g. using
info.Mode().IsRegular() and info.Mode()&0111 != 0 on the FileMode for the
candidate) before returning it, otherwise fall back to the existing PATH lookup
(exec.LookPath) as the PATH branch already does.

In `@internal/sessions/lineage.go`:
- Around line 131-150: Before calling store.List() in Tree, call
store.Get(rootSessionID) and return any error (so metadata-read failures
surface); then proceed to list and build byID/childrenByParent but ensure the
retrieved root Metadata is inserted into byID (and into childrenByParent if it
meets SessionKind/ParentSessionID criteria) so the root isn't lost if
store.List() skipped it. Update Tree to perform the store.Get(rootSessionID)
error check first, add the returned Metadata into the byID map, and then
continue with the existing listing, sorting, and the call to
store.treeFrom(rootSessionID, byID, childrenByParent, ...).

In `@internal/specialist/exec.go`:
- Around line 355-368: The stop path in exec.go records a stop with an empty
runId which later allows onExit to append a second stop when it has a non-empty
runId because the dedupe only matches exact run IDs; change the dedupe logic in
recordSpecialistStop to treat a stored/previously recorded empty runId as a
wildcard (match any subsequent non-empty runId) for stop-event deduplication
only, leaving actual stored runId values unchanged elsewhere; update
recordSpecialistStop's comparison/matching code so that when existingEntry.runId
== "" it considers it equal to any incoming non-empty runId (but not vice-versa
for other semantics), and add a small unit test exercising the
immediate-stop-then-onExit sequence to ensure duplicate stops are prevented.

In `@internal/tools/web_search.go`:
- Around line 187-193: The redirect policy in sameHostRedirectPolicy currently
only compares hostnames and thus allows HTTPS→HTTP downgrades; update
sameHostRedirectPolicy to require the scheme to match exactly (compare
req.URL.Scheme to via[0].URL.Scheme) and return an error refusing redirects that
change scheme (specifically reject an origin with "https" redirecting to
"http"); add a test that exercises a redirect from https://... to http://... to
assert the policy rejects the downgrade.

---

Outside diff comments:
In `@internal/agent/compaction.go`:
- Around line 365-387: The reactive compaction sets state.lowWaterMark using
message-only tokens which mismatches maybeCompact's comparison basis (messages +
tool-definition tokens); update the assignment so lowWaterMark is computed using
the same token basis maybeCompact uses (i.e., include the tool-definition/token
overhead when calling estimateTokens) to avoid spurious re-compactions—change
the lowWaterMark update after successful compaction in maybeCompact (the block
that sets state.reactiveAttempted and state.lowWaterMark using
estimateTokens(result)) to compute lowWaterMark with the
full-history/token-basis used by maybeCompact (use the same helper or formula
that maybeCompact uses when comparing messages + tool-definition tokens).

In `@internal/sessions/store.go`:
- Around line 702-731: appendEventLocked currently writes to events.jsonl and
only Close()s the file—ensure the event record is fsynced before updating
metadata: after the file.Write in appendEventLocked call file.Sync() (and
handle/return errors) before closing so the JSONL record is durably on disk. For
metadata persistence, writeMetadata (which uses writeFileSync then os.Rename)
must fsync the containing directory after the atomic rename to make the rename
durable: after os.Rename(pathTmp, path) open the parent directory (os.Open on
filepath.Dir(path)), call dir.Sync(), close it and propagate errors. You may
keep writeFileSync as-is for file data fsync but add the directory fsync in
writeMetadata; update error handling to remove tmp on failures where
appropriate.

---

Duplicate comments:
In `@internal/zerogit/zerogit.go`:
- Around line 235-240: The current logic only checks code[0] when deciding to
consume the extra NUL-terminated source path for renames/copies; update the
check so that if either code[0] == 'R' || code[0] == 'C' OR code[1] == 'R' ||
code[1] == 'C' is true you increment i to consume the extra field (i++),
ensuring unstaged rename/copy entries (when code[1] is R/C) are also handled;
locate the check around the comment about "A rename/copy (R or C in the staged
column)..." that inspects code and modify that conditional accordingly.

---

Nitpick comments:
In `@internal/cli/cron_run_test.go`:
- Around line 131-146: TestExtractStreamJSONError currently only checks a single
error event; add an assertion that verifies the helper extractStreamJSONError
returns the last error when multiple error events are present. Update the test
(TestExtractStreamJSONError) to include an output string with at least two
`{"type":"error","message":"..."}` lines (different messages) plus other events,
call extractStreamJSONError on that string, and assert the returned value equals
the last error message to lock the "last error wins" contract.

In `@internal/cli/deferred_wiring_test.go`:
- Around line 196-215: The test currently only validates payload.Tools[0];
instead iterate over all entries in payload.Tools (for i, tool := range
payload.Tools) and for each assert tool.Name != "" && tool.Permission != "" &&
tool.SideEffect != "" (use t.Fatalf with the index to show which entry failed,
e.g. t.Fatalf("tool entry %d must carry name+permission+side_effect: %#v", i,
tool)). Keep the existing Type and non-zero length checks but replace the
single-index assertions with this loop so every tool JSON entry is validated.

In `@internal/cli/exec_protocol_test.go`:
- Around line 632-648: The test currently checks the JSON output events but
doesn't assert that stderr is silent for -o json interruptions; add an assertion
after the exitCode check (where exitInterrupted, stderr and stdout are
available) to ensure stderr.String() is empty or does not contain "Interrupted."
(e.g., t.Fatalf if stderr.String() != "" or strings.Contains(stderr.String(),
"Interrupted.")). Keep the new assertion just before calling decodeJSONLines and
use the existing stderr variable so the test verifies JSON mode produces no
stderr output.

In `@internal/sessions/store_test.go`:
- Around line 196-216: Update the test that calls store.Fork(...) and
ReadEvents(...) to also decode and assert the payload of the trailing
EventSessionFork event: locate the last event whose Type == EventSessionFork,
decode its payload fields parentEventCount and copiedEventCount (the fork marker
payload produced by Fork) and add assertions that copiedEventCount == 1 and
parentEventCount == 2; keep the existing checks that no EventUsage was copied
and that the event type sequence equals []EventType{EventMessage,
EventSessionFork}.

In `@internal/usage/report_test.go`:
- Around line 113-151: The test currently sets meta[0].ModelID == "" which
doesn't prove precedence; change the session metadata to a different valid model
id (e.g. meta[0].ModelID = "gpt-4") while leaving the event payload model as
"gpt-4.1" and keeping the expected cost derived from "gpt-4.1". Update the meta
slice in the test so BuildReport, events, and the registry (variable registry)
show a conflicting non-empty session model to force the code path that must
prefer the event's payload model.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: f33d840a-4fd0-4f73-bce8-5ee818141921

📥 Commits

Reviewing files that changed from the base of the PR and between cc618ba and 89ca8d8.

⛔ Files ignored due to path filters (1)
  • go.sum is excluded by !**/*.sum
📒 Files selected for processing (88)
  • README.md
  • cmd/zero-seccomp/main.go
  • docs/audit/2026-06-10-deep-audit-status.md
  • docs/audit/2026-06-13-reverification.md
  • go.mod
  • internal/agent/compaction.go
  • internal/agent/compaction_summarizer_test.go
  • internal/agent/compaction_test.go
  • internal/agent/loop.go
  • internal/agent/loop_test.go
  • internal/background/process_posix.go
  • internal/background/process_posix_test.go
  • internal/background/process_windows.go
  • internal/background/terminate.go
  • internal/cli/cron_run.go
  • internal/cli/cron_run_test.go
  • internal/cli/deferred_wiring_test.go
  • internal/cli/exec.go
  • internal/cli/exec_protocol_test.go
  • internal/cli/exec_sessions.go
  • internal/cli/exec_spec.go
  • internal/cli/exec_test.go
  • internal/cli/exec_tools.go
  • internal/cli/sandbox.go
  • internal/cli/sandbox_test.go
  • internal/cli/skills.go
  • internal/cli/skills_test.go
  • internal/config/contracts.go
  • internal/config/contracts_test.go
  • internal/config/resolver_test.go
  • internal/config/types.go
  • internal/cron/next_test.go
  • internal/cron/schedule.go
  • internal/mcp/client.go
  • internal/mcp/client_test.go
  • internal/mcp/network_client.go
  • internal/mcp/network_client_test.go
  • internal/providers/factory.go
  • internal/providers/gemini/provider.go
  • internal/providers/gemini/provider_test.go
  • internal/providers/gemini/types.go
  • internal/sandbox/analyzer.go
  • internal/sandbox/analyzer_test.go
  • internal/sandbox/egress.go
  • internal/sandbox/egress_test.go
  • internal/sandbox/engine.go
  • internal/sandbox/engine_test.go
  • internal/sandbox/grants.go
  • internal/sandbox/grants_test.go
  • internal/sandbox/log_monitor_darwin.go
  • internal/sandbox/log_monitor_darwin_test.go
  • internal/sandbox/log_monitor_other.go
  • internal/sandbox/runner.go
  • internal/sandbox/runner_test.go
  • internal/sandbox/safe_command.go
  • internal/sandbox/safe_command_test.go
  • internal/sandbox/seccomp.go
  • internal/sandbox/seccomp_linux.go
  • internal/sandbox/seccomp_other.go
  • internal/sandbox/seccomp_test.go
  • internal/sandbox/types.go
  • internal/secrets/scanner.go
  • internal/secrets/scanner_test.go
  • internal/sessions/lineage.go
  • internal/sessions/store.go
  • internal/sessions/store_test.go
  • internal/specialist/accounting.go
  • internal/specialist/accounting_test.go
  • internal/specialist/exec.go
  • internal/specialist/output_tool.go
  • internal/specialist/streamer.go
  • internal/specialist/streamer_test.go
  • internal/tools/bash.go
  • internal/tools/bash_tool_test.go
  • internal/tools/file_tools_test.go
  • internal/tools/read_file.go
  • internal/tools/registry.go
  • internal/tools/web_fetch.go
  • internal/tools/web_fetch_test.go
  • internal/tools/web_search.go
  • internal/tools/web_search_test.go
  • internal/usage/report.go
  • internal/usage/report_test.go
  • internal/worktrees/worktrees.go
  • internal/worktrees/worktrees_test.go
  • internal/zerogit/zerogit.go
  • internal/zerogit/zerogit_test.go
  • internal/zeroruntime/helpers.go
💤 Files with no reviewable changes (4)
  • internal/tools/registry.go
  • internal/config/contracts_test.go
  • internal/config/contracts.go
  • internal/specialist/output_tool.go

Comment thread internal/background/process_posix_test.go Outdated
Comment thread internal/background/terminate.go Outdated
Comment thread internal/cli/sandbox.go
Comment thread internal/mcp/network_client.go
Comment thread internal/sandbox/analyzer.go
Comment thread internal/sandbox/runner.go Outdated
Comment thread internal/sandbox/safe_command.go
Comment thread internal/sessions/lineage.go
Comment thread internal/specialist/exec.go
Comment thread internal/tools/web_search.go
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.
@gnanam1990

Copy link
Copy Markdown
Collaborator Author

Addressed re-review round 3 in cd9abe0 (batch 39) — all 12 findings verified against current code, each behavioural fix with a regression test:

  • web_search: redirect policy now refuses a scheme change too (blocks same-host https→http downgrade that would leak query + bearer token).
  • safe_command: ) is only a boundary when it closes an active unquoted $(...); backticks get the same quote-state save/restore as $(echo "a | less" and echo "$(true | less)" no longer hide the inner pager.
  • analyzer + safe_command: wrapper option-value stays wrapper-specific; unwrapping no longer aborts on a dynamic ($x) wrapper arg once a wrapper is active (env "$opts" curl still classified).
  • egress: prompt callback is now cancellable (context cancelled on timeout) so a stuck prompt aborts instead of leaking; in-flight dedup per host:port.
  • runner: per-plan unique denial tag (pid+counter) so concurrent monitored runs can't cross-ingest denials; seccomp helper selection requires an executable regular file.
  • mcp/network_client: aggregate SSE event-size cap (not just per-line).
  • specialist/accounting: a recorded stop/usage with empty runId is a catch-all and suppresses a later runId-bearing duplicate.
  • sessions/lineage: Tree fetches the root via Get() first so corruption surfaces its real error instead of "not found".
  • background/terminate docstring + cli/sandbox usage strings + test ESRCH probe.

CI green; go vet + -race on sandbox/background clean.

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jun 13, 2026

Copy link
Copy Markdown

@gnanam1990 Kicking off a full re-review for batch 39 (cd9abe0).

Quick notes on the key fixes:

  • web_search scheme-change block: Refusing https→http downgrades on same-host redirects is the right call — without it, query params and bearer tokens would leak in plaintext. Good catch.
  • safe_command backtick handling: Applying the same quote-state save/restore to backticks as $( is the correct symmetry — both introduce a new command context that shouldn't inherit the outer quoting state.
  • Analyzer dynamic wrapper arg: Not aborting unwrapping on a $x arg once a wrapper is already active is the right heuristic — env "$opts" curl is a real pattern and should still be classified.
  • Egress cancellable prompt: Cancelling the prompt context on timeout and propagating that cancellation back to the caller prevents the goroutine leak from a stuck external prompt callback. Correct fix.
  • Per-plan unique denial tag: Using pid+counter to scope denial monitoring per concurrent run avoids cross-ingest between simultaneous sandboxed commands. The seccomp helper executable-file check is also the right defense against accidentally using a directory or symlink.
  • specialist/accounting empty runId catch-all: Recording a stop/usage event with an empty runId as a catch-all that suppresses later runId-bearing duplicates is a clean way to handle partial-state races.
  • sessions/lineage root via Get(): Fetching the root with Get() before building the tree correctly surfaces real corruption errors instead of masking them as "not found".
✅ Action performed

Full review finished.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
internal/sessions/store.go (1)

709-713: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Persist the metadata directory entry after rename for full crash durability.

Syncing the temp file is good, but rename durability is not guaranteed until the parent directory entry is synced. A crash after rename can still lose or roll back the metadata update.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/sessions/store.go` around lines 709 - 713, The rename of tmp to path
uses os.Rename but doesn't fsync the parent directory, so after a crash the
rename may be lost; after the successful os.Rename(tmp, path) call, open the
parent directory (use filepath.Dir(path)), call Sync() on that directory file
(and Close it), and if the Sync fails return an error (preserving the existing
error handling); keep the existing tmp removal on pre-rename error paths and
ensure you propagate any directory-sync error so callers know the persist
failed.
🧹 Nitpick comments (5)
internal/cli/cron_run_test.go (1)

132-145: ⚡ Quick win

Expand this test to verify the “last error wins” contract.

The fixture currently has only one error event, so a regression returning the first error would still pass. Add a second error event and assert the second message is returned.

Proposed test update
 output := strings.Join([]string{
 	`{"type":"run_start","runId":"r1"}`,
 	`{"type":"text","delta":"working"}`,
-	`{"type":"error","message":"provider request failed: 500"}`,
+	`{"type":"error","message":"transient upstream error"}`,
+	`{"type":"error","message":"provider request failed: 500"}`,
 	`{"type":"run_end","status":"error","exitCode":1}`,
 }, "\n")
 if got := extractStreamJSONError(output); got != "provider request failed: 500" {
 	t.Fatalf("extractStreamJSONError = %q, want the error event message", got)
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/cli/cron_run_test.go` around lines 132 - 145, The test should be
expanded to assert the “last error wins” behavior: in the test block that builds
output and calls extractStreamJSONError, add a second
`{"type":"error","message":"..."}` event after the first (e.g.
`{"type":"error","message":"provider request failed: 502"}`) and change the
assertion to expect the second error message (e.g. "provider request failed:
502"); keep the existing assertion that a run_end with no error yields an empty
string. Update the concrete string built in the test (the slice passed to
strings.Join) and the expected value in the t.Fatalf checks to verify
extractStreamJSONError returns the last error message.
internal/sandbox/seccomp_linux.go (1)

22-39: 💤 Low value

Potential panic if unixSocketBlockFilter() returns an empty slice.

Line 33 dereferences &kernelFilters[0] which will panic if the slice is empty. While unixSocketBlockFilter() presumably returns a static non-empty filter, a defensive check or documentation of this invariant would make the contract explicit.

🛡️ Optional: Add defensive check
 func ApplyUnixSocketBlock() error {
 	if err := unix.Prctl(unix.PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0); err != nil {
 		return fmt.Errorf("seccomp: set no_new_privs: %w", err)
 	}
 	filters := unixSocketBlockFilter()
+	if len(filters) == 0 {
+		return fmt.Errorf("seccomp: empty filter program")
+	}
 	kernelFilters := make([]unix.SockFilter, len(filters))
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/sandbox/seccomp_linux.go` around lines 22 - 39, The function
ApplyUnixSocketBlock currently dereferences &kernelFilters[0] which will panic
if unixSocketBlockFilter() returns an empty slice; add a defensive check after
building kernelFilters to handle zero-length filters: if len(kernelFilters) == 0
return a descriptive error (e.g., fmt.Errorf("seccomp: empty filter from
unixSocketBlockFilter")) and avoid taking &kernelFilters[0]; alternatively, set
prog.Filter to nil when length is zero and only assign &kernelFilters[0] when
len>0 so the call to unix.Prctl never dereferences an empty slice; update
ApplyUnixSocketBlock accordingly and keep unixSocketBlockFilter() usage the
same.
internal/sandbox/egress.go (1)

326-340: 💤 Low value

Static analysis: net.LookupPort flagged by noctx linter.

The linter flags this because net.LookupPort has a context-aware counterpart (*net.Resolver).LookupPort. However, for TCP with a numeric port string, LookupPort is effectively a pure parse operation with no I/O. If you want to silence the linter while keeping the same behavior, you can use strconv.Atoi directly or add a nolint directive.

🔧 Optional: Silence lint with strconv
+	"strconv"
 ...
 func (proxy *egressProxy) authorizeTarget(target string, defaultPort int) bool {
 	host := hostnameOnly(target)
 	if host == "" {
 		return false
 	}
 	port := defaultPort
 	if _, portStr, err := net.SplitHostPort(target); err == nil {
-		if parsed, perr := net.LookupPort("tcp", portStr); perr == nil {
+		if parsed, perr := strconv.Atoi(portStr); perr == nil && parsed > 0 && parsed <= 65535 {
 			port = parsed
 		}
 	}
 	return proxy.authorize(host, port)
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/sandbox/egress.go` around lines 326 - 340, authorizeTarget uses
net.LookupPort which the linter flags; replace the LookupPort call with a safe
parse of the numeric port string to avoid context-aware resolver use. In
authorizeTarget (and the inner logic that currently calls net.LookupPort), when
net.SplitHostPort succeeds, parse portStr with strconv.Atoi (or
strconv.ParseInt) and, on successful parse and valid port range, set port =
parsed; otherwise leave defaultPort and proceed to call proxy.authorize(host,
port). Keep hostnameOnly, net.SplitHostPort and proxy.authorize unchanged.

Source: Linters/SAST tools

internal/sandbox/runner.go (1)

562-568: 💤 Low value

Verify that denialTag is safe for seatbelt profile string interpolation.

The denialTag is interpolated directly into the seatbelt profile's deny default (with message "...") clause. While nextSandboxDenialTag() generates tags like zero-sandbox-denied-v1-<pid>-<seq> which are safe, consider passing it through sandboxProfileString() for defense-in-depth against future changes to the tag format.

🛡️ Optional hardening
 	if denialTag != "" {
 		// Tag denials so the runtime log monitor can attribute them to THIS run; the
 		// message is emitted to the unified log on every deny and StartDenialMonitor
 		// filters `log stream` for this exact (per-plan) tag.
-		denyDefault = `(deny default (with message "` + denialTag + `"))`
+		denyDefault = `(deny default (with message "` + sandboxProfileString(denialTag) + `"))`
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/sandbox/runner.go` around lines 562 - 568, The denyDefault string
currently interpolates denialTag directly; change the code that builds
denyDefault to pass denialTag through sandboxProfileString() (e.g.,
sandboxProfileString(denialTag)) so the clause becomes `(deny default (with
message " + sandboxProfileString(denialTag) + "))` (and keep the existing
empty-case behavior), ensuring denialTag is safely escaped for the seatbelt
profile; update the expression that sets denyDefault and any analogous
interpolation sites to use sandboxProfileString() instead of raw denialTag.
internal/zerogit/zerogit_test.go (1)

487-523: ⚡ Quick win

Add a code[1] rename case to this parser regression test.

Current coverage exercises staged rename (R ) only. Add a worktree rename ( R) fixture so both rename columns are protected by tests.

Suggested test extension
 	status := strings.Join([]string{
 		" M internal/a.go",  // modified in worktree only
 		"R  new name.go",    // staged rename; next field is the source
 		"old name.go",       // rename SOURCE — must be consumed, not its own entry
+		" R wt-new.go",      // worktree rename; next field is the source
+		"wt-old.go",         // rename SOURCE — must be consumed, not its own entry
 		"A  café.go",        // staged add, non-ASCII path (no octal escaping)
 		"?? un tracked.txt", // untracked, embedded space
 		"",                  // trailing empty field after the final NUL
 	}, "\x00")
 
 	files := parseStatus(status)
-	if len(files) != 4 {
-		t.Fatalf("expected 4 entries (rename source consumed), got %d: %#v", len(files), files)
+	if len(files) != 5 {
+		t.Fatalf("expected 5 entries (rename sources consumed), got %d: %#v", len(files), files)
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/zerogit/zerogit_test.go` around lines 487 - 523, The test
TestParseStatusZHandlesRenamesAndSpecialPaths only covers a staged rename
(X='R') — add a worktree rename case (Y='R') by inserting an entry like " R
worktree dest.txt" followed by its source "worktree src.txt" into the
NUL-delimited status fixture passed to parseStatus, then update the expected
count (len(files)) and assertions: assert the new destination entry appears
(Path == "worktree dest.txt") with Unstaged true and Staged false, and keep the
existing check that no rename source (e.g. "worktree src.txt") appears as its
own entry; reference the test function
TestParseStatusZHandlesRenamesAndSpecialPaths and the parseStatus call when
making these changes.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/agent/compaction.go`:
- Around line 311-312: The lowWaterMark is being stored in a different token
domain than proactive checks: proactive checks compare
estimateTokens(messages)+toolTokens (variable size) against state.threshold,
while the reactive lowWaterMark is saved as a message-only token count; make
them consistent by changing the lowWaterMark assignment to use the same combined
token domain (estimateTokens(messages) + toolTokens) and ensure any comparisons
(the size <= state.threshold checks) all use that same combined value; locate
and update the assignments/conditions that reference size,
estimateTokens(messages), toolTokens, state.threshold, and lowWaterMark so both
proactive (lines around the size calculation) and reactive (where lowWaterMark
is set) use the identical combined token count.

In `@internal/mcp/network_client.go`:
- Around line 702-708: The SSE aggregate-size counter in the SSE handling block
currently does dataBytes += len(value) + 1 for every `data:` line which wrongly
counts a newline for the first line and can cause premature rejections; change
the accounting so you only add the joining newline when the aggregate already
has content (e.g., if dataBytes > 0 then add 1 for the newline, then add
len(value)). Update the calculation that compares dataBytes to maxSSEEventBytes
(the error message remains fine) — look for the variables/data flow around
dataBytes, value, and maxSSEEventBytes in the SSE parsing code in
network_client.go and adjust that increment logic accordingly.

In `@internal/sessions/lineage.go`:
- Around line 135-138: After calling store.Get(rootSessionID) check whether root
is nil before dereferencing it: if root == nil return a not-found error
(matching the package's existing not-found error type or creating a clear
errors.New("session not found") if none exists) instead of proceeding; apply the
same nil-guard at the second occurrence around the other store.Get call so both
spots avoid panics when a session is missing (refer to the
store.Get(rootSessionID) call and the variable root/err in lineage.go).

---

Outside diff comments:
In `@internal/sessions/store.go`:
- Around line 709-713: The rename of tmp to path uses os.Rename but doesn't
fsync the parent directory, so after a crash the rename may be lost; after the
successful os.Rename(tmp, path) call, open the parent directory (use
filepath.Dir(path)), call Sync() on that directory file (and Close it), and if
the Sync fails return an error (preserving the existing error handling); keep
the existing tmp removal on pre-rename error paths and ensure you propagate any
directory-sync error so callers know the persist failed.

---

Nitpick comments:
In `@internal/cli/cron_run_test.go`:
- Around line 132-145: The test should be expanded to assert the “last error
wins” behavior: in the test block that builds output and calls
extractStreamJSONError, add a second `{"type":"error","message":"..."}` event
after the first (e.g. `{"type":"error","message":"provider request failed:
502"}`) and change the assertion to expect the second error message (e.g.
"provider request failed: 502"); keep the existing assertion that a run_end with
no error yields an empty string. Update the concrete string built in the test
(the slice passed to strings.Join) and the expected value in the t.Fatalf checks
to verify extractStreamJSONError returns the last error message.

In `@internal/sandbox/egress.go`:
- Around line 326-340: authorizeTarget uses net.LookupPort which the linter
flags; replace the LookupPort call with a safe parse of the numeric port string
to avoid context-aware resolver use. In authorizeTarget (and the inner logic
that currently calls net.LookupPort), when net.SplitHostPort succeeds, parse
portStr with strconv.Atoi (or strconv.ParseInt) and, on successful parse and
valid port range, set port = parsed; otherwise leave defaultPort and proceed to
call proxy.authorize(host, port). Keep hostnameOnly, net.SplitHostPort and
proxy.authorize unchanged.

In `@internal/sandbox/runner.go`:
- Around line 562-568: The denyDefault string currently interpolates denialTag
directly; change the code that builds denyDefault to pass denialTag through
sandboxProfileString() (e.g., sandboxProfileString(denialTag)) so the clause
becomes `(deny default (with message " + sandboxProfileString(denialTag) + "))`
(and keep the existing empty-case behavior), ensuring denialTag is safely
escaped for the seatbelt profile; update the expression that sets denyDefault
and any analogous interpolation sites to use sandboxProfileString() instead of
raw denialTag.

In `@internal/sandbox/seccomp_linux.go`:
- Around line 22-39: The function ApplyUnixSocketBlock currently dereferences
&kernelFilters[0] which will panic if unixSocketBlockFilter() returns an empty
slice; add a defensive check after building kernelFilters to handle zero-length
filters: if len(kernelFilters) == 0 return a descriptive error (e.g.,
fmt.Errorf("seccomp: empty filter from unixSocketBlockFilter")) and avoid taking
&kernelFilters[0]; alternatively, set prog.Filter to nil when length is zero and
only assign &kernelFilters[0] when len>0 so the call to unix.Prctl never
dereferences an empty slice; update ApplyUnixSocketBlock accordingly and keep
unixSocketBlockFilter() usage the same.

In `@internal/zerogit/zerogit_test.go`:
- Around line 487-523: The test TestParseStatusZHandlesRenamesAndSpecialPaths
only covers a staged rename (X='R') — add a worktree rename case (Y='R') by
inserting an entry like " R worktree dest.txt" followed by its source "worktree
src.txt" into the NUL-delimited status fixture passed to parseStatus, then
update the expected count (len(files)) and assertions: assert the new
destination entry appears (Path == "worktree dest.txt") with Unstaged true and
Staged false, and keep the existing check that no rename source (e.g. "worktree
src.txt") appears as its own entry; reference the test function
TestParseStatusZHandlesRenamesAndSpecialPaths and the parseStatus call when
making these changes.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 63779558-b828-4444-a241-ea0edc9a94b5

📥 Commits

Reviewing files that changed from the base of the PR and between cc618ba and cd9abe0.

⛔ Files ignored due to path filters (1)
  • go.sum is excluded by !**/*.sum
📒 Files selected for processing (88)
  • README.md
  • cmd/zero-seccomp/main.go
  • docs/audit/2026-06-10-deep-audit-status.md
  • docs/audit/2026-06-13-reverification.md
  • go.mod
  • internal/agent/compaction.go
  • internal/agent/compaction_summarizer_test.go
  • internal/agent/compaction_test.go
  • internal/agent/loop.go
  • internal/agent/loop_test.go
  • internal/background/process_posix.go
  • internal/background/process_posix_test.go
  • internal/background/process_windows.go
  • internal/background/terminate.go
  • internal/cli/cron_run.go
  • internal/cli/cron_run_test.go
  • internal/cli/deferred_wiring_test.go
  • internal/cli/exec.go
  • internal/cli/exec_protocol_test.go
  • internal/cli/exec_sessions.go
  • internal/cli/exec_spec.go
  • internal/cli/exec_test.go
  • internal/cli/exec_tools.go
  • internal/cli/sandbox.go
  • internal/cli/sandbox_test.go
  • internal/cli/skills.go
  • internal/cli/skills_test.go
  • internal/config/contracts.go
  • internal/config/contracts_test.go
  • internal/config/resolver_test.go
  • internal/config/types.go
  • internal/cron/next_test.go
  • internal/cron/schedule.go
  • internal/mcp/client.go
  • internal/mcp/client_test.go
  • internal/mcp/network_client.go
  • internal/mcp/network_client_test.go
  • internal/providers/factory.go
  • internal/providers/gemini/provider.go
  • internal/providers/gemini/provider_test.go
  • internal/providers/gemini/types.go
  • internal/sandbox/analyzer.go
  • internal/sandbox/analyzer_test.go
  • internal/sandbox/egress.go
  • internal/sandbox/egress_test.go
  • internal/sandbox/engine.go
  • internal/sandbox/engine_test.go
  • internal/sandbox/grants.go
  • internal/sandbox/grants_test.go
  • internal/sandbox/log_monitor_darwin.go
  • internal/sandbox/log_monitor_darwin_test.go
  • internal/sandbox/log_monitor_other.go
  • internal/sandbox/runner.go
  • internal/sandbox/runner_test.go
  • internal/sandbox/safe_command.go
  • internal/sandbox/safe_command_test.go
  • internal/sandbox/seccomp.go
  • internal/sandbox/seccomp_linux.go
  • internal/sandbox/seccomp_other.go
  • internal/sandbox/seccomp_test.go
  • internal/sandbox/types.go
  • internal/secrets/scanner.go
  • internal/secrets/scanner_test.go
  • internal/sessions/lineage.go
  • internal/sessions/store.go
  • internal/sessions/store_test.go
  • internal/specialist/accounting.go
  • internal/specialist/accounting_test.go
  • internal/specialist/exec.go
  • internal/specialist/output_tool.go
  • internal/specialist/streamer.go
  • internal/specialist/streamer_test.go
  • internal/tools/bash.go
  • internal/tools/bash_tool_test.go
  • internal/tools/file_tools_test.go
  • internal/tools/read_file.go
  • internal/tools/registry.go
  • internal/tools/web_fetch.go
  • internal/tools/web_fetch_test.go
  • internal/tools/web_search.go
  • internal/tools/web_search_test.go
  • internal/usage/report.go
  • internal/usage/report_test.go
  • internal/worktrees/worktrees.go
  • internal/worktrees/worktrees_test.go
  • internal/zerogit/zerogit.go
  • internal/zerogit/zerogit_test.go
  • internal/zeroruntime/helpers.go
💤 Files with no reviewable changes (4)
  • internal/config/contracts.go
  • internal/specialist/output_tool.go
  • internal/config/contracts_test.go
  • internal/tools/registry.go

Comment thread internal/agent/compaction.go
Comment thread internal/mcp/network_client.go
Comment thread internal/sessions/lineage.go
…ped)

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.
@gnanam1990

Copy link
Copy Markdown
Collaborator Author

Addressed re-review round 4 in 1eba7a5 — verified each against current code; 8 fixed, 1 skipped:

Fixed

  • sessions/lineagepanic fix: store.Get returns (nil, nil) for a missing session, so the batch-39 root prefetch could nil-deref. Now guards root == nil → not-found error. + regression test (was a panic).
  • agent/compaction — reactive recover() records lowWaterMark in the same combined (messages + tool-defs) domain maybeCompact uses; recover() now 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).
  • sessions/store — fsync the parent directory after the metadata rename (no-op on Windows, which can't fsync a dir).
  • sandbox/egress — port parsed with strconv.Atoi, not net.LookupPort.
  • sandbox/runner — per-plan denial tag escaped via sandboxProfileString in the profile.
  • sandbox/seccomp_linux — guard empty filter before &kernelFilters[0].
  • cli/cron_run_test — assert last-error-wins with two error events.

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. 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.

CI green; go vet + -race on sandbox/background clean.

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jun 13, 2026

Copy link
Copy Markdown

@gnanam1990 Kicking off a full re-review for batch 40 (1eba7a5).

Quick notes on the fixes:

  • sessions/lineage nil-deref guard: The store.Get returning (nil, nil) for a missing session is easy to miss — the nil-guard + regression test is the right fix, especially since it was a panic path.
  • agent/compaction reactive recover(): Aligning lowWaterMark recording to the combined messages+tool-defs domain matches the maybeCompact threshold exactly — good catch on the domain mismatch.
  • mcp/network_client SSE counter: Not counting the joining newline for the first data: line is the correct semantics — the aggregate cap should reflect actual payload bytes.
  • sessions/store parent-dir fsync: Fsyncing the parent directory after the rename is the right durability guarantee on Linux; no-op on Windows is appropriate.
  • sandbox/egress strconv.Atoi: Correct — ports here are always numeric strings, net.LookupPort would introduce unnecessary name-resolution behavior.
  • sandbox/runner denial tag escaping: Critical for correctness — an unescaped tag in a sandbox-exec profile could break or corrupt the profile structure.
  • sandbox/seccomp_linux empty filter guard: Preventing a nil dereference on &kernelFilters[0] when the slice is empty is a necessary safety check.
  • zerogit skip rationale: Sound — git porcelain v1 -z doesn't emit a source field for the worktree column; adding a fixture for output git doesn't produce would be misleading.
✅ Action performed

Full review finished.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
internal/sandbox/egress.go (1)

200-214: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Authorize absolute-form HTTPS requests with default port 443 (not 80).

Line 200 always calls authorizeTarget(target, 80), but Line 210 preserves outbound.URL.Scheme; for https://... absolute-form proxy requests, transport dials 443 while policy/prompt is evaluated for 80. This breaks port-scoped prompt/cache semantics and can allow 443 traffic under an 80 decision.

Suggested fix
 func (proxy *egressProxy) handleHTTP(w http.ResponseWriter, r *http.Request) {
@@
-	if !proxy.authorizeTarget(target, 80) {
+	defaultPort := 80
+	if strings.EqualFold(r.URL.Scheme, "https") {
+		defaultPort = 443
+	}
+	if !proxy.authorizeTarget(target, defaultPort) {
 		proxy.logDecision("deny", r.Method, requestURLString(r))
 		http.Error(w, "scoped egress: host not allowed", http.StatusForbidden)
 		return
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/sandbox/egress.go` around lines 200 - 214, The code currently calls
proxy.authorizeTarget(target, 80) before honoring the outbound URL scheme, which
causes HTTPS absolute-form requests to be authorized against port 80; fix by
deciding the default port from the request's scheme and using that when calling
authorizeTarget: after creating outbound (r.Clone) and ensuring
outbound.URL.Scheme is populated (if empty, set to "http"), compute defaultPort
:= 443 if outbound.URL.Scheme == "https" else 80, then call
proxy.authorizeTarget(target, defaultPort) (and keep outbound.RequestURI = ""
and setting outbound.URL.Host as-is).
♻️ Duplicate comments (1)
internal/zerogit/zerogit.go (1)

235-240: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Consume rename/copy source fields when R/C appears in either status column.

Line 238 only checks code[0]. For --porcelain -z, rename/copy can also be reported in code[1]; when that happens, the source-path field is left unconsumed and then misparsed as a new entry.

Proposed fix
-		// A rename/copy (R or C in the staged column) is followed by a separate
+		// A rename/copy (R or C in either status column) is followed by a separate
 		// NUL-terminated field holding the original path; consume it so it is not
 		// parsed as its own entry. This entry's own path is the destination.
-		if code[0] == 'R' || code[0] == 'C' {
+		if code[0] == 'R' || code[0] == 'C' || code[1] == 'R' || code[1] == 'C' {
 			i++
 		}

Please also add a regression case in TestParseStatusZHandlesRenamesAndSpecialPaths for an unstaged rename (" R <dest>\x00<src>\x00"), so this path stays covered.

For `git status --porcelain -z` (v1), when `R` or `C` appears in the second status column (Y), does the entry still include an extra NUL-delimited source path field (`XY <dest>\0<src>\0`)?
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/zerogit/zerogit.go` around lines 235 - 240, The parser currently
only consumes the extra NUL-delimited source path when code[0] is 'R' or 'C';
update the logic in the status parsing function in internal/zerogit/zerogit.go
(the block that examines code) to check both code[0] and code[1] and consume the
extra field if either is 'R' or 'C' (i.e., treat a rename/copy present in either
status column the same); then add a regression case to
TestParseStatusZHandlesRenamesAndSpecialPaths asserting that an unstaged rename
entry like " R <dest>\x00<src>\x00" is parsed correctly so the extra src field
is not treated as a separate entry.
🧹 Nitpick comments (1)
internal/sessions/store.go (1)

723-742: 💤 Low value

Static analyzer flags swallowed error, but the best-effort design is intentional.

The linter flags return nil at line 734 when err != nil. This is intentional per the docstring: dir sync is best-effort for durability. The file data is already synced and the rename succeeded; this just adds crash safety for the directory entry. Silencing the linter with a comment would clarify intent:

 d, err := os.Open(dir)
 if err != nil {
-    return nil
+    return nil //nolint:nilerr // best-effort: rename completed, dir sync optional
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/sessions/store.go` around lines 723 - 742, In syncDir, the
intentional early return on os.Open error (the err != nil branch) needs an
explanatory comment and a linter suppression so the analyzer doesn't treat the
swallowed error as a bug; update the err != nil branch in function syncDir to
add a short comment like "// best-effort: directory sync not required or not
supported on this platform; ignore open error" and add a nolint directive for
the static analyzer that flags swallowed errors (for example
"//nolint:staticcheck" or the project's preferred linter name) on that return so
the intent is clear and the linter is silenced.

Source: Linters/SAST tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/audit/2026-06-10-deep-audit-status.md`:
- Line 1: The narrative and the status table disagree on how the 78 re-verified
items split (narrative: 66 still_open + 12 partial; table: open: 64 + partial:
14), and the text naming three branch fixes (scanner.go:81, compaction.go:58,
worktrees.go:247) doesn't explain the redistribution; update the document so
they match by either (A) marking the narrative counts as pre-branch-fix and the
table counts as post-fix and adding one sentence that shows how the three fixes
moved 2 items from open to partial (or whatever the exact redistribution is), or
(B) if the original narrative was meant to be the final numbers, correct the
table to 66/12; ensure the chosen fix explicitly states which counts are pre- vs
post-fix, shows the arithmetic that sums to 78, and notes the effect of the
three branch fixes by name (scanner.go:81, compaction.go:58, worktrees.go:247).

In `@docs/audit/2026-06-13-reverification.md`:
- Around line 8-15: The counts in the reverification doc (still_open: 66,
partial: 12 → total 78) disagree with the related status doc (open: 64, partial:
14 → total 78); fix by reconciling and making the intent explicit: either (A)
update both documents (docs/audit/2026-06-13-reverification.md and
docs/audit/2026-06-10-deep-audit-status.md) to show both the "re-verification
baseline" and the "post-fix final" counts with clear labels (e.g.,
"reverification: still_open=66, partial=12" and "post-fix: open=64, partial=14")
and ensure the pending-by-severity numbers match the chosen breakdown, or (B)
pick a single canonical breakdown and update both files to that single set of
counts and adjust the pending-by-severity line accordingly; ensure any
explanatory note references the branch fixes (scanner.go:81, compaction.go:58,
worktrees.go:247) so readers understand why two numbers may exist.

In `@internal/agent/compaction.go`:
- Around line 435-440: The reduce pass error handling in summarizeMessagesOnce
needs to stop swallowing non-context errors: replace the current unconditional
"if reduceErr != nil { return combined, nil }" with a conditional that checks
whether reduceErr is a context cancellation/timeout (context.Canceled or
context.DeadlineExceeded) and only in that case return (combined, nil); for any
other reduceErr return the error upward (e.g., return "", reduceErr) so
auth/network/provider failures surface; reference the summarizeMessagesOnce call
and the reduceErr variable and add an errors/context check accordingly (import
errors/context if not already).

In `@internal/sandbox/safe_command.go`:
- Around line 198-222: The wrapper value-option logic only recognizes short
flags, so long flags like "--user" are treated as program tokens; update
wrapperValueOptionsByProg and wrapperConsumesValue to handle long-form options
(e.g., store/compare both short and long names or normalize by trimming leading
'-' and matching canonical names) so scanning skips the following token when a
long flag consumes a value; update wrapperConsumesValue to canonicalize option
(strip leading dashes and check both the original and canonical keys) and extend
wrapperValueOptionsByProg entries where needed for wrappers that have long
equivalents (e.g., map "--user"/"user" for sudo), and add regression tests
asserting DetectInteractiveCommand("sudo --user root vim file.txt", "linux")
blocks on vim and AnalyzeCommand("sudo --user root rm -rf /tmp/x") marks
destructive.

---

Outside diff comments:
In `@internal/sandbox/egress.go`:
- Around line 200-214: The code currently calls proxy.authorizeTarget(target,
80) before honoring the outbound URL scheme, which causes HTTPS absolute-form
requests to be authorized against port 80; fix by deciding the default port from
the request's scheme and using that when calling authorizeTarget: after creating
outbound (r.Clone) and ensuring outbound.URL.Scheme is populated (if empty, set
to "http"), compute defaultPort := 443 if outbound.URL.Scheme == "https" else
80, then call proxy.authorizeTarget(target, defaultPort) (and keep
outbound.RequestURI = "" and setting outbound.URL.Host as-is).

---

Duplicate comments:
In `@internal/zerogit/zerogit.go`:
- Around line 235-240: The parser currently only consumes the extra
NUL-delimited source path when code[0] is 'R' or 'C'; update the logic in the
status parsing function in internal/zerogit/zerogit.go (the block that examines
code) to check both code[0] and code[1] and consume the extra field if either is
'R' or 'C' (i.e., treat a rename/copy present in either status column the same);
then add a regression case to TestParseStatusZHandlesRenamesAndSpecialPaths
asserting that an unstaged rename entry like " R <dest>\x00<src>\x00" is parsed
correctly so the extra src field is not treated as a separate entry.

---

Nitpick comments:
In `@internal/sessions/store.go`:
- Around line 723-742: In syncDir, the intentional early return on os.Open error
(the err != nil branch) needs an explanatory comment and a linter suppression so
the analyzer doesn't treat the swallowed error as a bug; update the err != nil
branch in function syncDir to add a short comment like "// best-effort:
directory sync not required or not supported on this platform; ignore open
error" and add a nolint directive for the static analyzer that flags swallowed
errors (for example "//nolint:staticcheck" or the project's preferred linter
name) on that return so the intent is clear and the linter is silenced.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: e31cd7f3-defa-4535-8a99-399d5f137b10

📥 Commits

Reviewing files that changed from the base of the PR and between cc618ba and 1eba7a5.

⛔ Files ignored due to path filters (1)
  • go.sum is excluded by !**/*.sum
📒 Files selected for processing (88)
  • README.md
  • cmd/zero-seccomp/main.go
  • docs/audit/2026-06-10-deep-audit-status.md
  • docs/audit/2026-06-13-reverification.md
  • go.mod
  • internal/agent/compaction.go
  • internal/agent/compaction_summarizer_test.go
  • internal/agent/compaction_test.go
  • internal/agent/loop.go
  • internal/agent/loop_test.go
  • internal/background/process_posix.go
  • internal/background/process_posix_test.go
  • internal/background/process_windows.go
  • internal/background/terminate.go
  • internal/cli/cron_run.go
  • internal/cli/cron_run_test.go
  • internal/cli/deferred_wiring_test.go
  • internal/cli/exec.go
  • internal/cli/exec_protocol_test.go
  • internal/cli/exec_sessions.go
  • internal/cli/exec_spec.go
  • internal/cli/exec_test.go
  • internal/cli/exec_tools.go
  • internal/cli/sandbox.go
  • internal/cli/sandbox_test.go
  • internal/cli/skills.go
  • internal/cli/skills_test.go
  • internal/config/contracts.go
  • internal/config/contracts_test.go
  • internal/config/resolver_test.go
  • internal/config/types.go
  • internal/cron/next_test.go
  • internal/cron/schedule.go
  • internal/mcp/client.go
  • internal/mcp/client_test.go
  • internal/mcp/network_client.go
  • internal/mcp/network_client_test.go
  • internal/providers/factory.go
  • internal/providers/gemini/provider.go
  • internal/providers/gemini/provider_test.go
  • internal/providers/gemini/types.go
  • internal/sandbox/analyzer.go
  • internal/sandbox/analyzer_test.go
  • internal/sandbox/egress.go
  • internal/sandbox/egress_test.go
  • internal/sandbox/engine.go
  • internal/sandbox/engine_test.go
  • internal/sandbox/grants.go
  • internal/sandbox/grants_test.go
  • internal/sandbox/log_monitor_darwin.go
  • internal/sandbox/log_monitor_darwin_test.go
  • internal/sandbox/log_monitor_other.go
  • internal/sandbox/runner.go
  • internal/sandbox/runner_test.go
  • internal/sandbox/safe_command.go
  • internal/sandbox/safe_command_test.go
  • internal/sandbox/seccomp.go
  • internal/sandbox/seccomp_linux.go
  • internal/sandbox/seccomp_other.go
  • internal/sandbox/seccomp_test.go
  • internal/sandbox/types.go
  • internal/secrets/scanner.go
  • internal/secrets/scanner_test.go
  • internal/sessions/lineage.go
  • internal/sessions/store.go
  • internal/sessions/store_test.go
  • internal/specialist/accounting.go
  • internal/specialist/accounting_test.go
  • internal/specialist/exec.go
  • internal/specialist/output_tool.go
  • internal/specialist/streamer.go
  • internal/specialist/streamer_test.go
  • internal/tools/bash.go
  • internal/tools/bash_tool_test.go
  • internal/tools/file_tools_test.go
  • internal/tools/read_file.go
  • internal/tools/registry.go
  • internal/tools/web_fetch.go
  • internal/tools/web_fetch_test.go
  • internal/tools/web_search.go
  • internal/tools/web_search_test.go
  • internal/usage/report.go
  • internal/usage/report_test.go
  • internal/worktrees/worktrees.go
  • internal/worktrees/worktrees_test.go
  • internal/zerogit/zerogit.go
  • internal/zerogit/zerogit_test.go
  • internal/zeroruntime/helpers.go
💤 Files with no reviewable changes (4)
  • internal/config/contracts.go
  • internal/config/contracts_test.go
  • internal/tools/registry.go
  • internal/specialist/output_tool.go

Comment thread docs/audit/2026-06-10-deep-audit-status.md
Comment thread docs/audit/2026-06-13-reverification.md
Comment thread internal/agent/compaction.go
Comment thread internal/sandbox/safe_command.go Outdated
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.
@gnanam1990

Copy link
Copy Markdown
Collaborator Author

Addressed re-review round 5 in 5cf23d4 — all 4 findings verified against current code:

  • agent/compaction: summarizeWithFallback's reduce pass no longer swallows all errors. A context-limit error still falls back to the joined partial summaries (the documented extreme case); a non-context failure (auth/network/provider) now surfaces unchanged per the 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. 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 (both findings): 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 − 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. Both docs now agree, so the cross-file finding is resolved too.

CI green; go vet clean.

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jun 13, 2026

Copy link
Copy Markdown

@gnanam1990 Kicking off a full re-review for batch 41 (5cf23d4).

Quick notes on the four fixes:

  • agent/compaction error propagation: The distinction is correct — a context-limit error during the reduce pass is a recoverable budget signal (joined partial summaries are a valid fallback), while auth/network/provider errors are unrecoverable and must surface to the caller. The two-test coverage (propagate vs. joined-fallback, with the propagate test failing pre-fix) is exactly the right regression net here.
  • Long-form wrapper flags: Adding --user, --unset, --signal to the value-consuming lists closes the gap between short and long spellings. The --flag=value self-containment rule (no next-token consumed) is the correct invariant — good that both the AST analyzer and the regex detector share the same option schemas.
  • Docs arithmetic: 66+12+97 = 175 checks out. The derivation comment (69 open + 18 partial − 9 already-fixed-on-main = 78 pending; 88+9 = 97 fixed) makes the relationship between the summary table and the body ledger auditable.
✅ Action performed

Full review finished.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (2)
internal/cli/sandbox_test.go (1)

159-185: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Assert store immutability after each rejected empty --path case.

Line [179]-Line [185] validates store state only once at the end. A mutation in one rejected call could be masked by later calls and still end at zero grants. Capture store.List() before each invocation and compare immediately after each failed call.

Suggested patch
 import (
 	"bytes"
 	"encoding/json"
 	"errors"
 	"fmt"
 	"path/filepath"
+	"reflect"
 	"strings"
 	"testing"
 )
@@
 	for _, args := range [][]string{
 		{"sandbox", "grants", "allow", "write_file", "--path", ""},
 		{"sandbox", "grants", "allow", "write_file", "--path="},
 		{"sandbox", "grants", "revoke", "write_file", "--path", ""},
 		{"sandbox", "grants", "revoke", "write_file", "--path="},
 	} {
+		before, err := store.List()
+		if err != nil {
+			t.Fatalf("%v: List before call: %v", args, err)
+		}
 		if exit := run(args...); exit == exitSuccess {
 			t.Fatalf("%v: expected a usage error for an empty --path, got success", args)
 		}
@@
 		if !strings.Contains(stderr.String(), "path") {
 			t.Fatalf("%v: stderr should explain the empty --path, got %q", args, stderr.String())
 		}
+		after, err := store.List()
+		if err != nil {
+			t.Fatalf("%v: List after call: %v", args, err)
+		}
+		if !reflect.DeepEqual(after, before) {
+			t.Fatalf("%v: rejected call mutated grants; before=%#v after=%#v", args, before, after)
+		}
 	}
-
-	// No grant should have been created or removed by any of the rejected calls.
-	grants, err := store.List()
-	if err != nil {
-		t.Fatalf("List: %v", err)
-	}
-	if len(grants) != 0 {
-		t.Fatalf("expected no grant mutation from rejected --path calls, got %#v", grants)
-	}
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/cli/sandbox_test.go` around lines 159 - 185, The test currently
checks store.List() only once after iterating several rejected empty --path
cases, which can mask per-call mutations; update the loop around run(args...) to
call store.List() before invoking run (e.g., prevGrants, _ := store.List()),
then after each rejected run call immediately call store.List() again and assert
equality with prevGrants (using the same error handling and t.Fatalf messages
currently used), referencing the existing run(...) invocation and store.List()
usage so each rejected "--path" case verifies the store is unchanged in
isolation.
internal/zerogit/zerogit.go (1)

238-240: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Consume rename/copy source fields when R/C is present in either status column.

On Line [238], the parser only consumes the extra \x00<src> field when code[0] is R/C. In porcelain -z, rename/copy can be signaled in code[1] too, which misaligns subsequent parsing.

Proposed fix
-		// A rename/copy (R or C in the staged column) is followed by a separate
+		// A rename/copy (R or C in either status column) is followed by a separate
 		// NUL-terminated field holding the original path; consume it so it is not
 		// parsed as its own entry. This entry's own path is the destination.
-		if code[0] == 'R' || code[0] == 'C' {
+		if code[0] == 'R' || code[0] == 'C' || code[1] == 'R' || code[1] == 'C' {
 			i++
 		}
In `git status --porcelain -z` (v1), does a rename/copy entry include an extra source-path field when either status column (X or Y) is `R` or `C`?
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/zerogit/zerogit.go` around lines 238 - 240, The parser currently
only consumes the extra "\x00<src>" field when code[0] is 'R' or 'C'; update the
condition to consume that source field if either status column indicates
rename/copy by checking both code[0] and code[1] (e.g., if
code[0]=='R'||code[0]=='C'||code[1]=='R'||code[1]=='C'), ensuring you handle the
expected two-character status string in variable code, and then increment i (the
index that skips the extra field) as before so subsequent parsing stays aligned.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Duplicate comments:
In `@internal/cli/sandbox_test.go`:
- Around line 159-185: The test currently checks store.List() only once after
iterating several rejected empty --path cases, which can mask per-call
mutations; update the loop around run(args...) to call store.List() before
invoking run (e.g., prevGrants, _ := store.List()), then after each rejected run
call immediately call store.List() again and assert equality with prevGrants
(using the same error handling and t.Fatalf messages currently used),
referencing the existing run(...) invocation and store.List() usage so each
rejected "--path" case verifies the store is unchanged in isolation.

In `@internal/zerogit/zerogit.go`:
- Around line 238-240: The parser currently only consumes the extra "\x00<src>"
field when code[0] is 'R' or 'C'; update the condition to consume that source
field if either status column indicates rename/copy by checking both code[0] and
code[1] (e.g., if code[0]=='R'||code[0]=='C'||code[1]=='R'||code[1]=='C'),
ensuring you handle the expected two-character status string in variable code,
and then increment i (the index that skips the extra field) as before so
subsequent parsing stays aligned.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 245490b2-1bed-49a9-a2b3-2c5c8aaf2360

📥 Commits

Reviewing files that changed from the base of the PR and between cc618ba and 5cf23d4.

⛔ Files ignored due to path filters (1)
  • go.sum is excluded by !**/*.sum
📒 Files selected for processing (88)
  • README.md
  • cmd/zero-seccomp/main.go
  • docs/audit/2026-06-10-deep-audit-status.md
  • docs/audit/2026-06-13-reverification.md
  • go.mod
  • internal/agent/compaction.go
  • internal/agent/compaction_summarizer_test.go
  • internal/agent/compaction_test.go
  • internal/agent/loop.go
  • internal/agent/loop_test.go
  • internal/background/process_posix.go
  • internal/background/process_posix_test.go
  • internal/background/process_windows.go
  • internal/background/terminate.go
  • internal/cli/cron_run.go
  • internal/cli/cron_run_test.go
  • internal/cli/deferred_wiring_test.go
  • internal/cli/exec.go
  • internal/cli/exec_protocol_test.go
  • internal/cli/exec_sessions.go
  • internal/cli/exec_spec.go
  • internal/cli/exec_test.go
  • internal/cli/exec_tools.go
  • internal/cli/sandbox.go
  • internal/cli/sandbox_test.go
  • internal/cli/skills.go
  • internal/cli/skills_test.go
  • internal/config/contracts.go
  • internal/config/contracts_test.go
  • internal/config/resolver_test.go
  • internal/config/types.go
  • internal/cron/next_test.go
  • internal/cron/schedule.go
  • internal/mcp/client.go
  • internal/mcp/client_test.go
  • internal/mcp/network_client.go
  • internal/mcp/network_client_test.go
  • internal/providers/factory.go
  • internal/providers/gemini/provider.go
  • internal/providers/gemini/provider_test.go
  • internal/providers/gemini/types.go
  • internal/sandbox/analyzer.go
  • internal/sandbox/analyzer_test.go
  • internal/sandbox/egress.go
  • internal/sandbox/egress_test.go
  • internal/sandbox/engine.go
  • internal/sandbox/engine_test.go
  • internal/sandbox/grants.go
  • internal/sandbox/grants_test.go
  • internal/sandbox/log_monitor_darwin.go
  • internal/sandbox/log_monitor_darwin_test.go
  • internal/sandbox/log_monitor_other.go
  • internal/sandbox/runner.go
  • internal/sandbox/runner_test.go
  • internal/sandbox/safe_command.go
  • internal/sandbox/safe_command_test.go
  • internal/sandbox/seccomp.go
  • internal/sandbox/seccomp_linux.go
  • internal/sandbox/seccomp_other.go
  • internal/sandbox/seccomp_test.go
  • internal/sandbox/types.go
  • internal/secrets/scanner.go
  • internal/secrets/scanner_test.go
  • internal/sessions/lineage.go
  • internal/sessions/store.go
  • internal/sessions/store_test.go
  • internal/specialist/accounting.go
  • internal/specialist/accounting_test.go
  • internal/specialist/exec.go
  • internal/specialist/output_tool.go
  • internal/specialist/streamer.go
  • internal/specialist/streamer_test.go
  • internal/tools/bash.go
  • internal/tools/bash_tool_test.go
  • internal/tools/file_tools_test.go
  • internal/tools/read_file.go
  • internal/tools/registry.go
  • internal/tools/web_fetch.go
  • internal/tools/web_fetch_test.go
  • internal/tools/web_search.go
  • internal/tools/web_search_test.go
  • internal/usage/report.go
  • internal/usage/report_test.go
  • internal/worktrees/worktrees.go
  • internal/worktrees/worktrees_test.go
  • internal/zerogit/zerogit.go
  • internal/zerogit/zerogit_test.go
  • internal/zeroruntime/helpers.go
💤 Files with no reviewable changes (4)
  • internal/config/contracts_test.go
  • internal/specialist/output_tool.go
  • internal/config/contracts.go
  • internal/tools/registry.go

… 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.
@gnanam1990

Copy link
Copy Markdown
Collaborator Author

Round 6 (8ec1496). CI was already green — CodeRabbit's 6th pass was a COMMENT with no new findings, re-surfacing two "duplicate" refinements:

  • cli/sandbox_test.go (assert immutability per call) — done, and strengthened: the test now seeds a tool-wide grant and asserts reflect.DeepEqual(before, after) around each rejected empty---path call, so a mutation in one call can't be masked by a later one (and a buggy "revoke all" is observable — it's a silent no-op on an empty store).

  • zerogit.parseStatus (consume rename source when R/C in either column) — respectfully declined as incorrect for porcelain v1, with an explanatory code comment. Verified empirically:

    $ git mv a.txt b.txt && git status --porcelain -z   →  R  b.txt\0a.txt\0     (R in the INDEX column)
    $ mv c.txt d.txt      && git status --porcelain -z   →   D c.txt\0?? d.txt\0  (delete + untracked — NOT "R" in the worktree column)
    

    Porcelain v1 emits the extra source field only when the index column (code[0]) is R/C; the worktree column never carries R/C with a source field. 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 current index-column-only check is both correct and more robust.

gofmt + vet clean; host/linux/windows builds; suites green.

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 13, 2026

Copy link
Copy Markdown

@gnanam1990 Both decisions are sound:

  • cli/sandbox_test.go: Per-call reflect.DeepEqual snapshots are strictly stronger than a single before/after comparison — a mutation in call 1 that gets rolled back by call 2 would be invisible otherwise. Good hardening.
  • zerogit.parseStatus decline: Correct call. Git porcelain v1 XY format puts index state in X and worktree state in Y; the NUL-separated source field is emitted only when X (index column, code[0]) is R or C. The worktree column never carries R/C with a source field in v1, so your empirical verification holds. The existing index-column-only check is both correct and more robust against malformed input.

Kicking off the full re-review now.

✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@gnanam1990
gnanam1990 merged commit 474f494 into main Jun 13, 2026
6 checks passed
@Vasanthdev2004
Vasanthdev2004 deleted the audit-deep-remediation branch June 28, 2026 08:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants