Skip to content

feat(cli): 2.5.E — reseat-less chat modes + per-tool approval + mid-turn abort (ADR-0057)#63

Merged
cemililik merged 45 commits into
mainfrom
development
Jul 2, 2026
Merged

feat(cli): 2.5.E — reseat-less chat modes + per-tool approval + mid-turn abort (ADR-0057)#63
cemililik merged 45 commits into
mainfrom
development

Conversation

@cemililik

@cemililik cemililik commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

feat(cli): 2.5.E — reseat-less chat modes + per-tool approval + mid-turn abort (ADR-0057)

Closes the 2.5.E workstream: Claude-Code-style chat modes as a policy layer on the one session instance (no reseat), a fail-closed per-tool approval floor, an Esc mid-turn abort that keeps the session alive, and the host capability arms that close the 2.5.A deferral — all behind ADR-0057 (now Accepted).

What shipped

The mode system (reseat-less). The chat ToolHost is bound full-capability for the session; a mode controls only (a) the advertised-tool subset and (b) the per-dispatch approval — so switching is lossless. Shift+Tab cycles ask → plan → accept-edits → auto; /mode <name> jumps; the active mode is always in the footer. Default is read-only ask.

Mode A governed action (write / command / network / clipboard)
ask (default) / plan denied (+ hidden from the model)
accept-edits prompts[y] once · [a] always (session) · [n] no · [esc] abort
auto auto-approved except a protected-path write (still prompts)

Per-tool approval (fail-closed). A registry confirmAction hook runs between enforcePolicy and the side-effect; under the interactive regime a governed-class dispatch REQUIRES a decision and an absent hook ⇒ denied (a wiring bug can never let ask write). Governed classes: fs_write, egress (any kind), a model-command process, and os (read_clipboard is an un-jailed exfiltration sink — made governed after the security review). Composes with, not replaces, the Accepted ActionGuard seam (ADR-0041).

Mid-turn abort (EA7). Esc aborts the in-flight turn but keeps the session alive (one session:turn_completed{aborted}, rolls back the pending message, back to idle) — distinct from the terminal /cancel.

Host arms (the 2.5.A deferral, closed). A write-capable fs tier + protected paths (.git//.relavium//.ssh/ + shell-rc — refused in EVERY mode incl. auto, hardened against Win32 folds / NTFS ADS / 8.3 aliases / symlink-into-protected); the SSRF-hardened egress arm sharing ONE connect-by-validated-IP mechanism with media egress (+ a model-Host-header strip against virtual-host confusion); the os arm (read_clipboard/notify over fixed binaries, model data as argv/env DATA only — no injection).

Wired LIVE on every entry pointrelavium chat (fresh + chat-resume), the one-shot agent run, and the 2.5.B Home — each activates the fail-closed regime before its first turn, so no path runs a governed action ungated.

Engine amendments (behind ADR-0057, pure — no platform imports): EA3 confirmAction hook, EA4 pause/resume + abort state, EA5 agent:approval_requested event, EA7 mid-turn abort; os added to TOOL_ACTION_CLASSES.

Review discipline (per-step opus + Sonnet 5 + the mandatory security review)

Each step ran implement → commit → opus adversarial multi-agent review → fix → Sonnet 5 review → fix, plus the ADR-mandated dedicated holistic security review (the Accept gate). ~75 confirmed findings fixed, including 4 HIGH security bugs:

  • SSRF via a model-controlled Host header bypassing the IP-pin (virtual-host confusion)
  • NTFS ADS (.gitconfig::$DATA) + Win32 trailing-dot/space bypassing the protected-path floor
  • agent run (one-shot) running governed actions ungated (the flipped host without a regime)
  • the os arm ungated in read-only ask (clipboard exfiltration) → made a governed class

The holistic security review confirmed the core guarantees hold: no governed dispatch runs ungated on any entry point; no fs-jail escape; no SSRF bypass; no command injection; no secret leak.

Verification

pnpm turbo run typecheck test lint23/23 green (shared 438 · core 907 · cli 1145 + others). format:check + lint:bundle-closure clean. Leakwatch clean (my files).

Docs

ADR-0057 → Accepted (+ the security-review outcome); the canonical homes reconciled — chat-session.md (a modes + approval section), tool-registry.md + sse-event-schema.md (the os action class), built-in-tools.md (the full-capability chat posture), the roadmap + current.md + CLAUDE.md (2.5.E status), and deferred-tasks.md (egress/os/write-chat resolved; the [c] comment prompt, a non-TTY approval policy, a live egress credential resolver, the budget-pause rider, and extraRoots tracked as follow-ups).

Conformance checklist

  • Strict TS (no any / unsafe as); typed discriminated errors; AbortSignal threaded
  • packages/core stays platform-free (the mode enum + host arms live in apps/cli; the engine is mode-agnostic)
  • No vendor SDK type crosses the @relavium/llm seam; no new runtime dependency
  • Secrets never in a log / error / event / the model-facing stream (credentialRef resolved host-side)
  • One canonical home per artifact; append-only ADR (0057 was Proposed → finalized to Accepted)
  • Conventional Commits, per-package scope, Refs: ADR-0057

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added reseat-less chat modes with /mode and Shift+Tab cycling, plus mode-aware UI/footer and /mode argument validation.
    • Added per-tool approval (approve once/always, reject) with TUI intercept keys and Esc to abort only the in-flight turn.
    • Governed approvals now enforce protected-path write/egress/os behavior, including fail-closed gating for one-shot runs.
  • Bug Fixes
    • Mid-turn aborts no longer persist transcript messages; aborted turns render cleanly in the session view.
  • Security / Hardening
    • Strengthened filesystem confinement (protected/sensitive paths, hard-link aliasing) and SSRF-safe egress, plus OS tool timeouts/abort handling.
    • Improved terminal sanitization and secret-shaped value redaction in outputs/events.
  • Documentation
    • Updated CLI chat/session, contracts, roadmap, and ADR references; added approval-related SSE/event schema coverage.

cemililik and others added 27 commits June 30, 2026 02:13
PR #62 (the in-app command system) is merged, so flip 2.5.C to Done across the live-status homes and update
the next-pickup pointer to 2.5.E (chat modes + per-tool approval, ADR-0057):

- phase-2.5-cli-consolidation.md: the top spine line + the 2.5.C status block → ✅ Done (PR #62, 2026-06-30),
  noting the comprehensive first-class review on top of the per-step opus+sonnet + security passes.
- roadmap/current.md: 2.5.C summary (two-registry model, the palette, /help/notice/workflows/cost/doctor, the
  read-only --deep MCP tier, the name+args dispatch + footer hint-bar) → Done; next pickup 2.5.E.
- CLAUDE.md: extend the Phase-2.5 status sentence with 2.5.C (PR #62) + repoint the next pickup to 2.5.E.

Refs: ADR-0056
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…/os in scope, auto on Shift+Tab)

Finalize the still-Proposed ADR-0057 ahead of 2.5.E, per the maintainer-approved
design gate (it flips to Accepted after the mandatory security review):

- New "Relationship to ADR-0041 (ActionGuard)" section — confirmAction is a separate,
  lighter INTERACTIVE end-user consent primitive that COMPOSES with the Accepted
  enterprise governor seam (off-by-default, run-only approval), not an alternative;
  folding per-tool approval into ActionGuard is rejected with rationale.
- Bring the egress + os host arms and the write-capable fs tier IN SCOPE, closing the
  2.5.A deferral — egress reuses the existing connect-by-validated-IP media-egress SSRF
  mechanism (one shared primitive, never a second parser), behind a dedicated review.
- Land the additive `fsWrite` discriminator, credited to both ADR-0041 and ADR-0057.
- Put `auto` on the Shift+Tab cycle (maintainer UX call) and reframe the differentiator:
  no hidden "bypass all permissions" valve — read-only `ask` default, mode always shown,
  and no mode (auto included) writes a protected path or escapes the fs jail.
- Keep phase-2.5 §2.5.E consistent (cycle, protected-paths fallback, host-arms task).

Refs: ADR-0057, ADR-0041
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…57 EA3/EA5)

The fail-closed pre-dispatch approval seam + its shared contract — the engine half
the chat mode system (Step 2+) consumes. Pure @relavium/core + @relavium/shared.

@relavium/shared:
- TOOL_ACTION_CLASSES vocab (`fs_write | process | egress`) — shared so the engine
  derives its type from one home.
- `agent:approval_requested` dual-envelope event (EA5) carrying a secret-free preview;
  added to RUN_EVENT_TYPES + the union; drift-pin updated (21→22) + accept/reject/dual
  coverage. Session sink CARRIES it (not run-only).

@relavium/core tools:
- `ToolPolicyClass.fsWrite?` — the write-vs-read discriminator (`fsScoped` is true for
  both); landed for both ADR-0041's `fs-write` ActionClass and ADR-0057's write gate.
- `PolicyTarget.path?` (display-only) + `write_file` gains it; reads/git_status unchanged.
- The host-injected `ConfirmActionHook` / `ToolApprovalContext` on `ToolDispatchContext`
  (ADR-0037-clean: engine defines the interface + invocation point, host implements).
- `ToolDeniedByUserError` — carries the existing non-retryable `tool_denied` code.
- Registry step 4b: governed-class only (fs_write / model-controlled process / egress —
  git_status stays un-gated); FAIL-CLOSED under the approval regime (absent hook ⇒ deny);
  a no-op on the workflow author-trust path (no regression); enforcePolicy runs first;
  an abort while prompting classifies as cancelled (cancel precedence). +16 unit tests.

Canonical homes: sse-event-schema.md (the event), tool-registry.md (fsWrite + step 4b).

Refs: ADR-0057, ADR-0041
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… typed session ports, docs)

13 adversarially-verified findings from the opus review round, all fixed:

Security / correctness (medium):
- confirmDispatch is now fail-closed END TO END: a confirm hook that THROWS a non-abort
  error denies fatally (new `approval_error` reason, tool_denied) instead of the retryable
  tool_failed a host-capability throw gets; an abort raised WHILE prompting rethrows to the
  cancel path. A trailing throwIfAborted closes the window where the signal aborts during
  the prompt but the hook approves anyway — the side effect can no longer run post-abort.
- Widen the TYPED session-stream ports to carry agent:approval_requested (it was carried at
  the Zod layer only): SessionApprovalStreamEvent on SessionStreamEvent/SessionEventSink
  (agent-session.ts) + SessionStreamHandleEvent (session-handle.ts). Host-emitted + session-
  only, so InNodeEventType / the run path stay untouched. Step 4 emission now typechecks.

Shared contract:
- preview sub-object is now `.strict()` (the MaskedSecretSchema precedent) — a stray
  secret-bearing field (url/query) is rejected LOUDLY, not silently stripped; preview.command
  tightened to nonEmptyString (symmetry with path/host).
- Reconcile the stale "four dual-envelope events" prose across run-event.ts / session-handle.ts
  / agent-session.ts (agent:approval_requested is the fifth dualBase user, session-only-emitted).

Docs:
- Document the full ConfirmActionHook / ToolApprovalRequest / ToolApprovalDecision /
  ToolActionPreview seam in tool-registry.md (its declared canonical home — CLAUDE.md rule 8).

Engine:
- previewFor gains an assertNever exhaustiveness guard (the codebase idiom) for the imminent
  os action class.

Tests (+5 registry, +2 shared reject): abort-during-prompt→approve cancels (no side effect);
a non-abort hook throw denies as approval_error (not retryable); mcp_call gated (empty preview);
no-args run_command preview; ctx.signal forwarded to the hook; empty/strict preview rejects.

Refs: ADR-0057, ADR-0041
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…rrel export, test coverage)

24 adversarially-verified sonnet findings (≈15 unique; all medium/low/nit — doc-completeness,
test-coverage, and comment-precision, no behavior bugs after the opus round). All fixed:

Docs (canonical-home completeness, CLAUDE.md rule 8):
- tool-registry.md: add the ToolDeniedByUserError row to the error-taxonomy table (the three
  reason discriminants + fatal/non-retried class) + name it in the loop-correctable callout;
  fix the policyTarget inline comment to include `path?`.
- sse-event-schema.md: add AgentApprovalRequestedEvent to the illustrative RunEvent union block
  + the correlation-key note (dual-envelope, session-only-emitted in Phase 2.5).

Public surface:
- export SessionApprovalStreamEvent from @relavium/core (the Step-4 host emits this body by name).

Comment / prose accuracy:
- registry.ts: the "passes through verbatim" comment now notes the cancel-wins-all override.
- run-event.ts: fix the last stale "four" (SessionEventSchema JSDoc) and the garbled
  RunOrSessionEventSchema sentence introduced by the opus round (now: five dualBase members).

Tests (+5 core): session sink CARRIES agent:approval_requested (the inverse of the file_patch
drop); http_request + run_command approval-reject paths (side effect never reached); an aborted
signal yields cancelled (not a fail-closed deny) on a governed write; cancel-wins over a plain
hook throw (isAbort signal branch); the abort-during-prompt test now asserts the write spy;
empty preview command/host rejects (nonEmptyString symmetry).

Refs: ADR-0057, ADR-0041
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…abort (ADR-0057 EA7)

The session-lifecycle half of the mode system: a reseat-less per-turn policy seam +
the EA7 mid-turn abort. The engine stays mode-agnostic (ADR-0055) — the ask/plan/
accept-edits/auto enum lives in apps/cli and maps to this engine policy.

@relavium/shared:
- SESSION_STOP_REASONS = the five LLM StopReasons + `aborted` (a session-only concept,
  so the @relavium/llm StopReason stays clean); session:turn_completed.stopReason now
  uses this superset. Drift-pin test updated.

@relavium/core (AgentSession):
- SessionTurnPolicy { advertise?, confirm? } + setTurnPolicy(): the mutable, per-turn-
  snapshotted reseat-less mode policy. A set policy ACTIVATES the interactive-approval
  regime (threads ctx.approval → Step 1's confirmAction; fail-closed when confirm absent)
  and applies the advertise-filter (buildLlmTools narrows the model-visible tools). Absent
  ⇒ today's behavior (advertise-all, no approval regime — workflow author-trust parity).
- abort() (EA7): ends the in-flight turn (aborts its signal) but keeps the session ALIVE —
  settles ONE session:turn_completed{stopReason:'aborted'} (no error — user-initiated),
  rolls the pending user message back, returns to idle. Distinct from cancel() (terminal
  session:cancelled); handles both the turn-core-threw and the rare resolved-race paths;
  no-op when idle; cancel() wins concurrently. No new SessionStatus.
- Export SessionTurnPolicy from the barrel.

apps/cli: widen the TurnSummary.stopReason to SessionStopReason (renders the aborted turn).
Docs: agent-session-spec.md (lifecycle: setTurnPolicy + abort vs cancel) + sse-event-schema.md.
Tests (+7 core): abort→aborted+alive, rollback, idle no-op, cancel-wins, advertise-filter,
approval threading, no-policy⇒no-regime.

Note: EA4's session BUDGET pause/resume (the deferred 1.V×1.AC rider that "rides the same
machine") is NOT a 2.5.E exit criterion and stays tracked in deferred-tasks; EA7 (the
exit-criteria abort) is complete.

Refs: ADR-0057, ADR-0055
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…coverage)

16 adversarially-verified opus findings (≈11 unique; 1 high + several medium/low/nit, all
test-coverage / design-clarity — no shipped bug). All fixed:

Design simplification (resolves 5 findings about the resolved-race branch at once):
- REMOVE the resolved-race success-path branch. A late abort() that lands AFTER the turn
  already resolved is now a NO-OP — the turn completes normally (its reply is kept), so a
  just-finished reply is never discarded. abort() interrupts an in-flight turn only. This
  eliminates the hard-to-test branch AND the turn-count divergence (a resolved turn now
  counts as the normal completion it is) AND is better UX. Docs/comments updated.
- Reset #abortingTurn at turn-top: a stale flag from a prior turn's pre-`try` early return
  (a sink that called abort() during the turn_started emit) can no longer leak into a later
  turn's catch and misclassify a real failure as an abort.

Test coverage (the headline gaps):
- An ENGAGED mid-stream abort (barrier-controlled provider) — proves the abort lands AFTER a
  provider engaged + a token streamed, settles `aborted` with REAL partial usage. The prior
  abort tests only hit the pre-egress path (comment corrected, dead fixture dropped).
- The fail-closed `approval: {}` threading (a policy with no confirm → present-but-empty),
  the security-critical middle state that drives Step 1's no_approval_hook deny.
- setTurnPolicy(undefined) CLEARS the advertise-filter (re-advertises every granted tool).
- A pre-egress (un-engaged) abort does NOT burn a max_turns slot (engaged-gated cap).
- CLI: an `aborted` turn projects into a TurnSummary{stopReason:'aborted', no errorCode}.
- Drift-pin: co-located assertion that the LLM StopReason stays 5 values + rejects 'aborted'.

Docs: agent-session-spec.md (the Streaming→Streaming setTurnPolicy transition; the late-abort
no-op semantics; the setTurnPolicy "inert once cancelled" wording).

Refs: ADR-0057, ADR-0055
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…bort-arming race)

16 adversarially-verified sonnet findings, including a BLOCKER and a HIGH the opus round
missed. All fixed:

BLOCKER — persister wrote ABORTED turns as completed exchanges (apps/cli/src/chat/persister.ts):
an EA7 aborted turn carries error:undefined, so the `error === undefined` gate persisted its
user message + partial assistant text to history.db even though the engine rolled them back in
memory — orphaning the row on chat-resume. Now gated on `stopReason !== 'aborted'` too (the
real session COST still flushes unconditionally). +regression test (no rows; next turn clean).

HIGH — abort() during the turn_started-emit window (packages/core/src/engine/agent-session.ts):
the abort controller was armed AFTER the turn_started emit, so an abort() from a synchronous
emit sink set #abortingTurn but no-op'd the (undefined) signal — the turn then ran with a LIVE
signal and a later genuine failure was misclassified as `aborted`. Now the controller is armed
BEFORE the emit (released on the cancel-bail/cap early returns). +regression test (abort from
the turn_started sink settles the turn `aborted`, not the provider's reply).

Tests / docs (the gaps):
- setTurnPolicy(undefined) clears the APPROVAL regime too (not just the advertise-filter).
- formatTurnSummary renders the `aborted` stop reason; persister aborted-turn regression.
- The late-abort no-op is now documented as a STRUCTURAL guarantee (the success path never
  reads #abortingTurn) — the micro-race has no deterministic emit hook, noted in-code.
- session-view-model turnCount JSDoc corrected (counts every completed turn incl. aborts —
  a UI counter, distinct from the engine's engaged-gated cap).
- Docs: agent-session-spec.md (late-abort no-op + setTurnPolicy inert-once-cancelled + diagram
  label), sse-event-schema.md (session:cancelled ≠ abort), chat.ts /cancel comment.

Refs: ADR-0057, ADR-0055
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…m (one connect-by-validated-IP)

Extract the connect-by-validated-IP SSRF mechanism out of media-egress.ts into a shared
safe-egress.ts so the CLI tool-egress text fetch (2.5.E Step 3b) can reuse it — never a
second hand-rolled SSRF parser (ADR-0029(d) one-primitive rule, ADR-0043/0057).

- packages/db/safe-egress.ts (NEW): `connectValidated` (ONE validated hop: HTTPS+no-creds →
  DNS-resolve → range-block EVERY resolved IP → connect PINNED to the validated IP, hostname
  kept as SNI so TLS verification stays on) + `readBounded` (size-bound) + `withEgressTimeout`
  (timeout/abort + secret-free error normalization) + `nodeEgressDeps` + `SafeEgressError`.
  HopRequest gains method/headers/body (media passes GET; the tool path uses the rest);
  HopResponse gains optional headers (the tool needs them; the byte path ignores them).
- media-egress.ts is now a thin wrapper: its GET-only / FOLLOW-redirects / 200-only policy
  over the shared core. MediaEgressError/Deps/etc. are back-compat ALIASES of the shared
  types, so the 143 db tests stay green (= the refactor is behavior-preserving).

The redirect POLICY stays per-caller (not shared): media follows redirects (no allowlist;
each hop re-validated), while the tool egress (Step 3b) will NOT follow them — enforcePolicy
checked only the original url against the exact-FQDN allowedDomains allowlist, so following a
3xx to a different host would bypass it. (Documented in the safe-egress.ts header.)

Refs: ADR-0057, ADR-0043, ADR-0029
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Step 3 of ADR-0057: the two deferred 2.5.A capability arms plus the
write-safety floor that makes a write-capable chat host possible.

- egress arm (egress.ts): a text-shaped EgressCapability.fetch over the
  shared SSRF mechanism (connectValidated). Distinct from media: does NOT
  follow redirects (a 3xx is returned with its Location so following it can
  never bypass the engine's allowedDomains allowlist); resolves an opaque
  credentialRef host-side into a Bearer header so the raw secret never
  crosses back into the engine. SSRF range-block + non-HTTPS → fatal
  EgressDeniedError; network/size faults → retryable EgressCapabilityError.
- os arm (os.ts): read_clipboard + notify over FIXED host-chosen binaries
  (pbpaste/osascript/notify-send/powershell/wl-paste/xclip/xsel). The model
  controls only the notify title/body, passed exclusively as argv/env DATA
  (shell:false) — never interpolated into a shell/AppleScript/PowerShell
  script string, so there is no injection surface. Injectable spawn + the
  pure plan builders are unit-tested per platform.
- fs protected paths (fs.ts): write_file now refuses .git/ and .relavium/
  (by path segment) and shell startup files (by basename) in EVERY mode,
  auto included. Checked on the requested path before the createDirs mkdir
  AND re-checked on the realpath'd target so a symlink cannot resolve into
  a protected dir. A `.gitignore` FILE stays writable (only the DIR is
  protected). 7 new fs tests + 14 os tests + 9 egress tests, all green.

errors.ts: EgressCapabilityError/EgressDeniedError/OsCapabilityError added
to the host taxonomy (transient tool_failed vs fatal tool_denied).

The write-capable chat profile + the per-tool approval floor that gates
these arms land in Step 3d/Step 4; until then write_file stays
fail-closed-denied, so every intermediate commit is safe.

Refs: ADR-0057, ADR-0055, ADR-0053, ADR-0006
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ofile

assembleToolEnv gains a third profile, `chat-read-write` — the
full-capability chat host ADR-0057 calls for: fs-write + process + the
egress + os arms (the 2.5.A deferral, closed here). The host is
full-capability for the session; the *mode* (the advertise-filter + the
fail-closed confirmAction regime) makes it safe, never capability absence.

- egress + os wired ONLY for chat-read-write; the read-only chat + the
  workflow-run profiles keep neither (a read-only chat must not reach the
  network; the run path's egress/os is a separate author-trusted concern).
- both chat tiers (read-only AND read-write) clamp `full`→`project`: an
  unjailed READ exfiltrates ~/.ssh regardless of write-gating, so a
  write-capable chat clamps too; `full` stays only for workflow-read-write.
- egressCredentialResolver threads a host-side keychain resolver into the
  egress arm (web_search/http bearer; never crosses into the engine).
- fs readOnly:false for this profile — its writes are gated by the
  approval floor + the always-on protected-paths refusal (Step 3c), not by
  capability absence. 4 new + 1 updated factory tests (13 total, green).

The LIVE chat session-host is deliberately NOT switched to this profile
here: activating the approval regime without a confirm hook would
fail-closed-deny MCP tools (they are egress-class, `egress: 'mcp'`),
regressing the live 2.R MCP chat. The session-host flip + the [a]/[r]/[c]
hook are therefore atomic in Step 4 — correct sequencing, not deferral.

Refs: ADR-0057, ADR-0055, ADR-0006
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Aggressive multi-dimensional adversarial review of Step 3; all confirmed
findings fixed. Notable security fix is the first one.

- [HIGH] fs protected-paths Windows trailing-dot/space bypass: `.bashrc.` /
  `.bashrc ` / `.git ` fold to the REAL protected target at the Win32 FS
  layer, but the unfolded basename missed the set. New foldPathComponent
  lowercases AND strips trailing dots/spaces before matching, on the
  segment loop AND the basename — closing the bypass and the createDirs
  side-effect window. Folds always (over-deny on a case-sensitive FS is the
  safe direction). + tests for `.bashrc.`/`.bashrc `/`.git./x`.
- fs: PROTECTED_RC_BASENAMES gains .bash_aliases, .xprofile, .xinitrc,
  .xsession, and .gitconfig (core.hooksPath / `[alias] x=!cmd` ⇒ RCE);
  docstring scopes the basename floor (dir-pattern / launch-agent sources
  are out of reach, by design).
- egress: classifyEgressError no longer hard-codes a `http_request:` prefix
  (the same arm serves web_search / http-mcp_call) — the shared message is
  already a tool-agnostic `egress …` reason and the registry attaches the
  real tool id.
- os: a clipboard TIMEOUT (a hung tool) now surfaces its real reason via a
  local OsTimeoutError, not the misleading "no clipboard tool is available"
  (kept for the all-absent case).
- assemble: re-export EgressCapabilityError/EgressDeniedError/
  OsCapabilityError from the factory's single error-import seam.
- db/index: export HopRequest/HopResponse directly from the safe-egress
  block (not only via the media-egress alias).

Tests (the review's larger half): NEW safe-egress.test.ts (connectValidated
URL/range-block/pin, readBounded boundary, withEgressTimeout normalization,
the concrete nodeEgressDeps POST/body/headers wire path); egress.test.ts
gains credentialed-URL deny + abort-threading + timeout; os.test.ts gains a
controllableSpawn for mid-spawn-abort (no fall-through), timeout SIGKILL,
and BoundedBuffer truncation; assemble.test.ts gains the os-arm
advertise-filter case + the full-capability egress-kept case.

Refs: ADR-0057, ADR-0055, ADR-0029, ADR-0043
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ecurity)

The sonnet round found three HIGH security gaps the opus round missed,
plus ten correctness/completeness/test items. All verified + fixed.

HIGH:
- Host-header SSRF (safe-egress.ts): the model-supplied http_request
  `headers` included a `Host`/`:authority` the shared mechanism forwarded
  verbatim — the wire Host overrode the validated host while the IP-pin +
  SNI stayed correct, letting an allowedDomains-approved, correctly-pinned
  request reach a DIFFERENT virtual host at the same shared/CDN/proxy IP.
  connectValidated now strips Host/:authority (case-insensitive) so the
  wire Host always derives from the validated hostname.
- NTFS ADS bypass (fs.ts): `.gitconfig::$DATA` addresses the real
  `.gitconfig` default stream but folded to itself, passing the check.
  foldPathComponent now takes the pre-`:` component part.
- Win32 8.3 short-name alias (fs.ts): writeOne now realpaths an EXISTING
  finalTarget and re-runs the protected-paths check on the canonical name
  (an alias like GITCON~1 → .gitconfig is caught).

MEDIUM:
- notify-send flag injection (os.ts): a `-`/`--`-prefixed title/body was
  parsed by GOption as a flag; a `--` end-of-options marker now precedes
  the positional args.

LOW / polish:
- egress credential resolver moved behind resolveCredentialSafely so a
  rejecting keychain degrades to no-credential (the "never a crash"
  contract), never escaping fetch().
- `.ssh/` added to the protected DIRECTORY set (authorized_keys, config
  ProxyCommand, id_*, rc are all persistence/RCE vectors).
- build-engine.ts + fs.ts TOCTOU comments corrected: egress/os are a
  permanent workflow-run scope boundary (not a deferral), and the Windows
  symlink-swap residual now also covers chat-read-write — flagged for the
  Step 5 security review.
- Tests: Host-strip + lookup/servername(+IPv6) pin assertions in
  safe-egress.test.ts; rejecting-resolver degrade in egress.test.ts; ADS
  / .ssh / full-tier-floor in fs.test.ts; notify spawn-start-failure +
  the `--` guard in os.test.ts.

Refs: ADR-0057, ADR-0055, ADR-0043, ADR-0006
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…olicy)

The pure, tested foundation for the reseat-less mode system (ADR-0057).
A mode is a policy layer on the one session instance: chat-mode.ts is the
single home of the ask/plan/accept-edits/auto vocabulary and its mapping
to a SessionTurnPolicy (advertise-filter + confirm hook) the host pushes
via AgentSession.setTurnPolicy — the engine stays mode-agnostic.

- CHAT_MODES + nextMode (the Shift+Tab cycle ask→plan→accept-edits→auto),
  parseMode (tolerates the spaced "accept edits" label), MODE_LABEL /
  MODE_DESCRIPTION for the footer + /mode.
- isGovernedTool / governedToolIds: the advertise-filter hide-set for
  ask/plan — a superset of the registry's runtime governedAction that also
  hides requiresGateApproval tools (git_commit). git_status (spawnsProcess,
  NO policyTarget → fixed command) stays read-only/advertised.
- ApprovalCache: the session-scoped, in-memory once/always memory (always =
  a tool id for the session instance; once caches nothing).
- buildTurnPolicy(mode, deps): ask/plan hide governed tools + their confirm
  rejects every governed dispatch (two-layer); accept-edits advertises all +
  prompts ([a]/[r]/[c] via an injected ApprovalPrompt) with the always-cache
  short-circuit; auto auto-approves except a protected-path target, which
  falls back to a prompt (the fs-layer protected-paths refusal is the floor
  beneath every mode regardless).

The interactive prompt is injected (the REPL supplies it in 4b/4c); this
step is pure + fully unit-tested (13 tests). No live wiring yet, so the
live chat is unchanged.

Refs: ADR-0057, ADR-0055
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
All 7 opus-confirmed findings (all LOW/POLISH). One real consent-scope
subtlety + six test-completeness locks on the security-relevant paths.

- confirmFor/toDecision: an "always" answer at auto's protected-path
  fallback is no longer cached. The session once/always cache is shared
  across modes (4b/4c), so remembering an "always" made in a narrow
  protected-path context would have silently blanket-approved that tool id
  in a later accept-edits turn (a cross-mode consent escalation). toDecision
  gains an allowAlwaysCache flag — true only for accept-edits; auto's
  fallback passes false, so a protected prompt always re-asks and never
  leaks a grant.
- Tests: an exact-Set drift-lock on governedToolIds(BUILTIN_TOOLS) +
  negative isGovernedTool for notify/read_media/invoke_agent; ask/plan deny
  parametrized over fs_write AND process AND egress (not just writes);
  ask/plan advertise-hide of git_commit (the requiresGateApproval superset
  element, its only chat-path mode containment); the prompt is called with
  the exact request + forwarded AbortSignal; an "always" grant is scoped to
  its tool id (a different tool still prompts); and the auto-no-cache
  guarantee above. 17 tests, all green.

Refs: ADR-0057
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
All 6 Sonnet-5-confirmed findings (all LOW/polish). Two API refinements
that make the 4b/4c REPL wiring safer + self-documenting, plus four
test-completeness locks on the consent paths.

- ApprovalPrompt gains a `cacheable: boolean` arg: it tells the REPL
  whether an "always" answer will actually be remembered — true in
  accept-edits (offer [a]lways), false at auto's protected-path fallback
  (grey out / omit "always", since a protected prompt re-asks every time).
  A UX signal only; toDecision still enforces the rule. This closes the
  gap where the same prompt type served two differently-caching call sites
  with no signal to the host.
- confirmFor now binds a named `cacheable` local and passes it to BOTH the
  prompt and toDecision — no bare true/false literal at the call site
  (removes the boolean-trap the prior fix's shape invited).
- Tests: plan now mirrors ask exactly (advertises reads, hides governed,
  rejects fs_write/process/egress with the reason text); the always-scoping
  test proves per-tool-id (not tool+args) keying via a different-path write;
  the accept-edits reject test asserts the cache stays untouched; auto's
  protected-path prompt asserts request + cacheable:false + signal
  forwarding; the accept-edits forwarding test asserts the cacheable arg.
  17 tests, all green.

Refs: ADR-0057
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…xport

The host glue between the pure chat-mode policy and a live AgentSession,
plus the shared protected-path predicate the auto-mode fallback needs.
Pre-flip + safe: nothing live calls it yet (the REPL wires it in 4b-ii),
so the live chat is unchanged.

- fs.ts: extract `isProtectedPath(absoluteTarget): boolean` — the pure
  predicate behind assertNotProtectedPath, exported so the chat auto-mode
  approval can classify a protected target without triggering the throw.
  The two now share exactly one protected-paths definition.
- chat-mode-host.ts: makeChatModeEnv (session-scoped once/always cache +
  the governed hide-set derived from the session tool defs + the
  workspace-anchored isProtectedTarget) and applyChatMode (map a mode →
  SessionTurnPolicy and push via setTurnPolicy). The REPL owns the mode UI
  state + the prompt; this module owns the mapping so the fresh + resumed
  paths apply modes identically. 5 tests (env derivation, per-change apply,
  shared-cache persistence, isProtectedTarget, auto's protected fallback).

Refs: ADR-0057
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The store gains the REPL-side mode state and the async per-tool-approval
coordination (ADR-0057) — the hardest pure piece of the live wiring, still
safe (nothing invokes requestApproval yet; the REPL wires it next).

- snapshot carries `mode: ChatMode` (default ask) + `approval:
  PendingApproval | undefined` (the in-flight [a]/[r]/[c] request + its
  cacheable hint).
- setMode(mode): updates the footer mode + flushes (a switch feels instant).
- requestApproval: IS the ApprovalPrompt the mode controller injects —
  publishes the pending approval (flush → the REPL renders the prompt) and
  resolves when the input handler calls answerApproval. Honors the abort
  signal: an abort while pending (or an already-aborted signal) REJECTS with
  an AbortError so the dispatch routes to the registry's cancel path (isAbort
  accepts cause.name === 'AbortError'), never a denial; the listener is
  removed + the prompt cleared on every settle path.
- answerApproval(answer): the input handler's decision; a no-op if none is
  pending (a stray keypress can't crash the turn). 5 new tests (22 total).

Refs: ADR-0057
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…val keys

Extend the pure keystroke reducer (shared by chat + Home) for the ADR-0057
key contract. Safe: the existing callers' switches ignore the new action
kinds (no exhaustive assertNever), so behavior is unchanged until 4b-ii-c
wires them; both callers pass the 4-arg form (approvalPending defaults
false), so the approval intercept is dormant for them.

- ChatKey gains shift/tab/escape; ChatKeyAction gains cycle-mode (Shift+Tab),
  abort (Esc mid-turn, EA7 — distinct from cancel), and approve{once|always}
  / reject (the approval-prompt decisions).
- reduceChatKey gains an approvalPending param: when set, the approval prompt
  OWNS the keyboard (the in-flight key-swallow BYPASS, no deadlock) — [y]/1
  approve-once, [a]/2 approve-always, [n]/[r]/3 reject, Esc aborts the whole
  turn; every other key (incl. Ctrl-C / Return / printables) is swallowed.
  Otherwise Shift+Tab → cycle-mode (idle or running — applies next turn), and
  Esc while running → a mid-turn abort. 13 new tests (20 in the file).

Refs: ADR-0057
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The atomic live integration: the chat host is now the FULL-CAPABILITY
chat-read-write profile, made safe by the per-tool approval regime the
REPL activates on every session (fresh, resumed, AND the 2.5.B Home). The
proven 4b foundations (mode policy, mode-host, store coordination, input
keys) are now wired end to end.

- session-host.ts: chat flips chat-read-only → chat-read-write (fs-write +
  process + egress + os); exposes the effective `tools` for the governed
  hide-set. The comment records that safety now rests on the approval
  floor, not capability absence.
- chat.ts: createChatModeControl(built, store) — builds the session-scoped
  mode env (once/always cache + governed set + workspace-anchored
  protected check; prompt = store.requestApproval) and APPLIES THE INITIAL
  ask mode immediately, so the full-capability host is never live without
  the fail-closed regime. runReplLoop wires onAbort (→ session.abort, EA7)
  + onModeChange into the ChatDriveContext.
- drive-home.tsx: the SAME createChatModeControl before session.start() —
  the Home's in-process chat is never write-capable without the regime.
- chat-ink.tsx: useInput handles cycle-mode (Shift+Tab → onModeChange +
  nextMode), abort (Esc → onAbort), approve/reject (→ answerApproval); the
  approvalPending flag threads into reduceChatKey (the key-swallow bypass);
  ChatView renders the [y]/[a]/[n] prompt + the always-shown mode footer.
- chat-projection.ts: formatSessionFooterWithMode + formatApprovalTarget
  (secret-free, sanitized) + tests. session-host tests updated to the new
  reality (ask denies the write → tool_denied, no file; egress advertised).

Deferred to 4b-ii-d (small follow-up): the `/mode <name>` direct-jump
command (Shift+Tab already reaches every mode) and the Home's in-tree
Shift+Tab/approval key wiring (the Home chat is safe in ask today).
All 1131 tests green; typecheck / lint / format:check / bundle-closure clean.

Refs: ADR-0057, ADR-0055
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Completes Step 4b: the `/mode` direct-jump command and the 2.5.B Home's
in-tree mode/approval/abort key wiring — parity between `relavium chat`
and the Home's graduated chat.

- repl-commands.ts: a `/mode <name>` command over a new single-POSITIONAL
  value model (ReplCommand.positional) — the dispatch validates the token
  against the mode names, so an invalid mode is rejected before run. The
  ReplCommandContext gains setMode; the bare `/mode` (palette) shows the
  current mode + options.
- chat.ts: createChatLineHandler now OWNS the mode control (so `/mode` and
  the driver keys drive one control) + applies the initial ask mode; it
  returns onAbort/onModeChange (ChatLineHandler extends ChatModeControl).
  runReplLoop + drive-home consume them from the handler (one creation
  point). The slash-dispatch allow-set now includes a command's positional
  values.
- home-controller.ts: handleChatKey handles cycle-mode (Shift+Tab), abort
  (Esc), and approve/reject (the [y]/[a]/[n] intercept), threading
  approvalPending into reduceChatKey — so the Home's chat has the same
  reseat-less mode + per-tool-approval UX as `relavium chat`.
- Tests: /mode registry + dispatch (switch, bare-show, invalid-value
  reject); the registry-pinning tests updated for the 8th command. 1133
  green; typecheck / lint / format:check / bundle-closure clean.

Refs: ADR-0057, ADR-0056
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…curity)

The opus round found a HIGH security regression the live-wiring commits
missed, plus nine correctness/UX/test items. All verified + fixed.

HIGH:
- agent-run.ts: the one-shot `agent run` shares the flipped full-capability
  chat-read-write host but NEVER applied a mode/regime, so a governed
  dispatch (write_file / web_search) ran UNGATED (the workflow author-trust
  path). It now applies the fail-closed `ask` regime before the first turn
  — every governed action is denied (no interactive approver in a one-shot),
  restoring the pre-4b safety. + a test that a granted write_file is denied
  (tool_denied, no file on disk).

Correctness / UX:
- MODE_LABEL is now kebab (`accept-edits`), so the footer label is exactly
  what `/mode accept-edits` accepts (display + input agree).
- Bare `/mode` now EXPLAINS each mode (consumes the previously-dead
  MODE_DESCRIPTION), marking the current one.
- An aborted turn with NO streamed text now appends a transcript trace
  ("aborted · …"), so Esc at the approval prompt is visibly confirmed.
- Stale doc comments corrected: session-host's "read-only chat" (now
  full-capability + regime-gated) and five `[a]/[r]/[c]` sites (the real
  keys are `[y]/[a]/[n]/[esc]`; the `[c]` comment path is deferred).

Tests (the live paths, previously uncovered):
- createChatModeControl end-to-end: ask denies a governed write; accept-edits
  APPROVE (driving the real store prompt) lets the write land.
- the Home in-chat keys: Shift+Tab→onModeChange, Esc→onAbort (not /cancel),
  and the pending-approval intercept ([y]/[a]/[n] + `/` stays closed).
- the aborted-empty transcript trace.
1141 tests green; typecheck / lint / format:check / bundle-closure clean.

Refs: ADR-0057
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…+ doc accuracy)

The Sonnet 5 round (two runs, one dimension re-run after a transient
network drop) confirmed only LOW/polish gaps — the behavior was verified
correct by code-reading; these lock it against regression + fix doc drift.

- session-host.test.ts: three new createChatModeControl end-to-end guards —
  (1) the RESUMED path (buildResumedChatSession) also gates a governed
  dispatch under ask (the resume-assembly is where a future refactor could
  silently reintroduce the one-shot ungated-dispatch class opus just fixed);
  (2) auto mode's PROTECTED-path write falls back to a non-cacheable prompt
  (not auto-approved), rejected ⇒ no file — the most bespoke ADR-0057 branch,
  now proven through the real registry + fs host + store; (3) an EGRESS-class
  dispatch (http_request) is denied in ask too, not just fs_write.
- docs: ADR-0057 + phase-2.5 roadmap now describe the SHIPPED approval keys
  ([y] yes / [a] always / [n] no / [esc] abort; [c] reject-with-reason
  deferred) instead of the retired [a]/[r]/[c] — the opus fix corrected the
  code comments but not the canonical docs. (ADR-0057 stays Proposed; its
  status flips to Accepted at Step 5 after the security review.)
- chat-mode.ts: parseMode's JSDoc clarified — its whitespace→hyphen
  normalization is a defensive convenience for a programmatic caller; the
  `/mode` dispatch tokenizes on whitespace + the labels are kebab, so a
  spaced value never reaches it as one token.

The command-level chat-resume denial (a fourth flagged gap) is covered in
substance by the session-host resume guard above + the shared runReplLoop
path (already exercised by the fresh-chat coverage). 1144 tests green;
typecheck / lint / format:check / bundle-closure clean.

Refs: ADR-0057
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… review

The dedicated holistic security review (the Accept gate) passed the core
guarantees — no ungated governed dispatch, no fs-jail escape, no SSRF
bypass, no injection, no secret leak — and confirmed 2 fixable gaps.

MEDIUM — the `os` arm was NOT a governed class, so read_clipboard / notify
were advertised + ungated in EVERY chat mode incl. the read-only `ask`
default. read_clipboard is an unjailed exfiltration sink (the clipboard
routinely holds a freshly-copied secret) and notify a phishing surface.
Fix: `os` is now a governed ToolActionClass (TOOL_ACTION_CLASSES += 'os';
ToolPolicyClass.os flag on OS_POLICY), so the registry's governedAction
gates read_clipboard/notify through the confirm floor (denied in ask/plan,
prompt in accept-edits, approve in auto) AND isGovernedTool hides them in
ask/plan. invoke_agent (which shared OS_POLICY) moves to the non-governed
delegate policy so it is unaffected. previewFor('os') returns {} (the tool
id is the whole preview). The stale assemble.ts "os gated only by the
advertise-filter" comment is corrected — os is now gated by both layers.

LOW — a Windows 8.3 short-name / symlinked ancestor resolving into a
protected dir could let a createDirs write create an empty subdir inside
.git BEFORE the protected-path check fired (no protected FILE written).
Fix: jailWriteTarget now runs assertNotProtectedPath on the realpath'd
deepest-existing ancestor BEFORE mkdir, so the side-effect is refused up
front (POSIX-reproducible via a symlink-into-.git ancestor test).

Tests: os governed in chat-mode's classification + drift-lock; an
end-to-end read_clipboard denial in ask; the pre-mkdir ancestor guard.
shared 438 / core 907 / cli 1145 green; typecheck / lint / format:check /
bundle-closure clean.

Refs: ADR-0057
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The mandatory holistic security review passed (core guarantees hold; its
two findings fixed in the prior commit), so ADR-0057 flips to Accepted and
the canonical docs reconcile with what shipped.

- ADR-0057 → Accepted: the Draft note is replaced with the security-review
  outcome (no ungated dispatch on any entry point; no jail escape / SSRF
  bypass / injection / secret leak; the os-class + 8.3-createDirs gaps
  fixed) + the deferred follow-ups. ADR index status Proposed → Accepted.
- tool-registry.md + sse-event-schema.md: the `os` ToolActionClass added to
  the ToolPolicyClass shape, the governed-class list (step 4b), and the
  agent:approval_requested `action` union (preview `{}` for os).
- chat-session.md (the canonical chat-surface home): a new "Chat modes +
  per-tool approval" section (the mode table, governed classes, protected
  paths, Esc abort, the one-shot ask default) + `/mode` in the command
  table; the stale "/cancel — no per-turn abort in Phase 1" line corrected
  (Esc now aborts mid-turn, keeping the session).
- roadmap phase-2.5 + current.md + CLAUDE.md: 2.5.E marked implemented +
  reviewed on `development`, PR pending merge (not ✅ Done until merged) —
  the mode system, the approval floor, the host arms, the live wiring on
  chat/Home/one-shot, and the ~50 reviewed findings + the security gate.
- deferred-tasks.md: the CLI tool-egress fetch marked landed (2.5.E); a new
  2.5.E follow-ups section (the [c] comment prompt, a non-TTY approval
  policy, a live egress credential resolver, the session budget-pause
  rider, the project-tier extraRoots allowlist).

Refs: ADR-0057
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The final gate found only docs-accuracy + a stale comment (no code
defects) — exactly the reconcile-completeness this pass exists to catch.

- built-in-tools.md: the canonical CLI-host note said `relavium chat` is
  read-only and `write_file` is `tool_unavailable` — now false (2.5.E ships
  the full-capability chat-read-write host gated by the approval floor;
  ask denies as `tool_denied`). Corrected here + the "read-only chat
  profile" mention below it; the extraRoots claim points at deferred-tasks.
- phase-2.5-cli-consolidation.md: the top-of-doc spine header still marked
  2.5.E "next" — corrected to implemented/PR-pending, "next" → 2.5.D/F/G
  (matching the §2.5.E section, current.md, and CLAUDE.md).
- tool-registry.md: the governedAction derivation prose omitted the `os`
  branch — added (`os ⇒ os`), matching the same file's step 4b + the code.
- commands.md: the in-REPL command enumeration omitted `/mode` — added
  (chat-only) + the positional-value note.
- registry.ts: the confirmDispatch comment still listed `clipboard` among
  the never-gated tools — removed (os is governed now).
- registry.test.ts: +2 engine-level EA3 tests locking the os governed path
  (read_clipboard prompts with action 'os' + empty preview; a reject is a
  fatal tool_denied that never reads the clipboard).

core 167 tools tests green; typecheck / format:check clean.

Refs: ADR-0057
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…reconcile)

The Sonnet 5 final round found the reconcile pass had missed the 2.5.A
trailing block of deferred-tasks.md (never touched) + two engine
JSDoc/comment sites — all docs, no code defects.

- deferred-tasks.md: the pre-existing 2.5.A follow-ups block still said the
  `egress`/`os` arms are "unwired" and `relavium chat` is "read-only" —
  now false (2.5.E wired them as governed classes + the write-capable
  chat). Marked the egress/os + write-capable-chat items `[x] RESOLVED in
  2.5.E` (PR pending merge, mirroring the file's `[x]` convention); the
  section intro corrected; the `extraRoots` deferral de-duplicated to the
  single Phase-2.5.E follow-ups entry (it did NOT land in 2.5.E); the
  fs-TOCTOU residual's stale "chat is read-only" rationale replaced with
  the write-capable reality + the ADR-0057 security-review acceptance.
- core types.ts: the two ConfirmActionHook / approval-context JSDoc blocks
  still listed the governed classes as fs_write/process/egress and named
  "clipboard" as never-governed — added `os` and dropped the stale example.
- shared run-event.test.ts: the "(only fs_write | process | egress)"
  comment now includes `os`.

shared 438 tests green; core typecheck / format:check clean.

Refs: ADR-0057
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@sourcery-ai sourcery-ai 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.

Sorry @cemililik, your pull request is larger than the review limit of 150000 diff characters

@coderabbitai

coderabbitai Bot commented Jul 2, 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: defaults

Review profile: CHILL

Plan: Pro

Run ID: 8b249b5e-172b-46fa-8033-67235df50919

📥 Commits

Reviewing files that changed from the base of the PR and between 00d8d36 and f8455c5.

📒 Files selected for processing (5)
  • packages/core/src/engine/agent-turn.test.ts
  • packages/core/src/engine/agent-turn.ts
  • packages/core/src/tools/errors.ts
  • packages/core/src/tools/registry.test.ts
  • packages/core/src/tools/registry.ts
🚧 Files skipped from review as they are similar to previous changes (4)
  • packages/core/src/engine/agent-turn.test.ts
  • packages/core/src/engine/agent-turn.ts
  • packages/core/src/tools/registry.ts
  • packages/core/src/tools/registry.test.ts

📝 Walkthrough

Walkthrough

Adds ADR-0057 chat modes, per-tool approval, and mid-turn abort across shared contracts, core session/tool dispatch, CLI/TUI wiring, and docs. Separately, it extracts a shared SSRF-safe HTTPS egress layer into packages/db and reuses it from media egress.

Changes

ADR-0057 Chat Modes and Per-Tool Approval

Layer / File(s) Summary
Shared approval and abort contracts
packages/shared/src/constants.ts, packages/shared/src/run-event.ts, packages/shared/src/run-event.test.ts
Adds agent:approval_requested, SessionStopReason.aborted, approval preview validation, and matching contract tests.
Core approval gate and dispatch wiring
packages/core/src/tools/types.ts, packages/core/src/tools/errors.ts, packages/core/src/tools/registry.ts, packages/core/src/tools/bounding.ts, packages/core/src/tools/bounding.test.ts, packages/core/src/tools/registry.test.ts, packages/core/src/tools/builtins.ts
Adds approval types, denial errors, governed-action dispatch gating, and secret-redaction updates with tests.
AgentSession turn policy and abort
packages/core/src/engine/agent-session.ts, packages/core/src/engine/agent-session.test.ts, packages/core/src/engine/session-handle.ts, packages/core/src/engine/session-handle.test.ts, packages/core/src/index.ts
Adds per-turn policy, mid-turn abort, approval stream events, and session-stream carrying of approval requests.
CLI tool-host capability arms
apps/cli/src/engine/tool-host/assemble.ts, apps/cli/src/engine/tool-host/errors.ts, apps/cli/src/engine/tool-host/os.ts, apps/cli/src/engine/tool-host/fs.ts, apps/cli/src/engine/tool-host/egress.ts, tests
Adds chat-read-write, governed OS/egress arms, and protected filesystem floors.
Chat mode policy and host glue
apps/cli/src/chat/chat-mode.ts, apps/cli/src/chat/chat-mode-host.ts, tests
Adds mode parsing/cycling, approval cache, governed tool classification, and session-scoped mode application.
Session host, agent-run, and persister integration
apps/cli/src/chat/session-host.ts, apps/cli/src/commands/agent-run.ts, apps/cli/src/chat/persister.ts, tests
Switches chat hosting to the full-capability profile, applies fail-closed one-shot ask mode, and skips persisting aborted-turn messages.
CLI /mode command and REPL wiring
apps/cli/src/commands/chat.ts, apps/cli/src/commands/repl-commands.ts, tests
Adds the /mode command, mode-change callbacks, and positional validation in the REPL path.
TUI approval prompt and mode UI
apps/cli/src/render/tui/chat-ink.tsx, apps/cli/src/render/tui/chat-input.ts, apps/cli/src/render/tui/chat-projection.ts, apps/cli/src/render/tui/chat-store.ts, apps/cli/src/render/tui/home-app.tsx, apps/cli/src/render/tui/home-controller.ts, apps/cli/src/render/tui/session-view-model.ts, apps/cli/src/home/drive-home.tsx, tests
Renders approval prompts and mode footer, and handles Shift+Tab, Esc, and approval answers.
ADR-0057 acceptance and docs
docs/decisions/0057-cli-chat-modes-and-per-tool-approval.md, docs/decisions/README.md, docs/roadmap/*, docs/reference/**, CLAUDE.md
Marks ADR-0057 accepted and updates roadmap/reference documentation.

Shared SSRF-safe Egress Extraction

Layer / File(s) Summary
safe-egress module and media-egress refactor
packages/db/src/safe-egress.ts, packages/db/src/media-egress.ts, packages/db/src/index.ts, tests
Extracts a shared validated HTTPS egress mechanism and routes media egress through it.

Estimated code review effort: 5 (Critical) | ~150 minutes

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant Chat
  participant ModeControl
  participant AgentSession
  User->>Chat: /mode auto
  Chat->>ModeControl: onModeChange(auto)
  ModeControl->>AgentSession: setTurnPolicy(...)
Loading
sequenceDiagram
  participant Caller
  participant SafeEgress
  participant HTTPS
  Caller->>SafeEgress: fetchMediaBytes()
  SafeEgress->>HTTPS: connectValidated + pinned lookup
  SafeEgress-->>Caller: decoded body / status / headers
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main CLI change: reseat-less chat modes, per-tool approval, and mid-turn abort for ADR-0057.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ 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 development

Comment @coderabbitai help to get the list of available commands.

@gemini-code-assist gemini-code-assist 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.

Code Review

This pull request implements workstream 2.5.E (reseat-less chat modes, per-tool approval, and mid-turn abort) behind ADR-0057. It introduces a full-capability chat-read-write host profile that wires write-capable fs, process, egress (SSRF-hardened), and os arms. A robust, fail-closed per-tool approval regime is added to the tool dispatch lifecycle, supporting four chat modes (ask, plan, accept-edits, auto) cycled via Shift+Tab or the new /mode command. It also implements a mid-turn abort (Esc) that keeps the session alive, a protected-paths floor (refusing writes to .git, .ssh, etc. in all modes), and extracts a shared connect-by-validated-IP SSRF mechanism. I have no feedback to provide as there are no review comments.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

cemililik and others added 14 commits July 2, 2026 13:00
…63 review)

Addresses the two BLOCKERs + two HIGHs the Sonnet 5 PR-review found on the fs
host arm, all reproduced end-to-end:

- Hard-link aliasing defeated the read jail and the protected-paths write floor:
  realpath() resolves symlinks but NOT hard links, so an in-scope name could
  share an inode with an out-of-jail secret (read) or a ~/.ssh/authorized_keys
  (append). readJailedFile + the append open now fstat the OPENED fd and refuse
  st.nlink > 1 (race-free, same inode the bytes come from), mirroring the
  symlink-deny. Reads add a `kind: 'aliased'`; the unjailed `full` tier passes
  rejectAliased=false so a benign in-scope hard link (a pnpm store link) stays
  readable.
- Append could hang forever on a reader-less FIFO (the O_NONBLOCK fix present on
  the read path was never applied to append; fs.open takes no AbortSignal so
  even EA7 abort could not cancel it). The append open now uses O_NONBLOCK,
  fstat-rejects a non-regular target, and maps the reader-less-FIFO ENXIO to the
  same fatal denial.
- Reads had NO protected-path floor: read_file / list_directory could surface
  .ssh / .relavium / .git/config / credential dotfiles in every mode (default
  read-only ask included) → straight into the model context and the provider.
  A tier/mode-independent sensitive-read floor (isSensitiveReadPath) now refuses
  those on read_file, list_directory (root + nested), and glob matches.

Also folds the Sonar fs.ts:L357 item: foldPathComponent's trailing dot/space
strip is now a linear scan, not a `/[. ]+$/` backtracking regex.

+15 fs.test.ts cases (hard-link read/append reject, full-tier hard-link allow,
FIFO-append no-hang, the sensitive-read floor across read/list/glob). fs suite
77/77 green; cli typecheck + lint clean.

Refs: ADR-0057
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…, tests)

Opus adversarial review of F1 confirmed the guards are race-free/sound (no
blocker/high on correctness) and surfaced regression + catalogue + test items:

- HIGH (regression): the hard-link read refusal blocked ALL pnpm node_modules
  reads in chat (nlink>1 on Linux; clampChatTier makes the `full` escape
  unreachable there) and was platform-inconsistent (macOS clonefile ⇒ nlink==1).
  Since a cross-boundary hard link cannot be planted under node_modules without
  code execution that already defeats the sandbox — and the sensitive-read floor
  still refuses a named secret store even there — the aliasing READ guard now
  EXEMPTS a node_modules segment (isPackageStorePath). Dependency-source reads
  work again, identically on Linux and macOS; the exfil guard holds everywhere
  else and for the write/append path.
- MEDIUM: added `.git-credentials` (git's plaintext token store) to the
  sensitive-read set; the `.git/config` rule now also catches a bare repo
  (`*.git/config`) and submodule `.git/modules/*/config`.
- Denials are now actionable: the aliased-read message notes the node_modules
  exemption; the sensitive-read message tells the model to ask the user instead.
- polish: append open now maps macOS socket EOPNOTSUPP / ENODEV to the same
  fatal non-regular denial as ENXIO; fixed the readGlob comment (the aliased skip
  lives in readJailedFile, not collectGlobFiles); documented the latent
  `.relavium`/tmpDir-root collision.

+5 tests (node_modules exemption + its name-floor still applying, glob
aliased-skip, FIFO-with-reader post-open fstat, symlink-resolves-into-.ssh read,
multi-char trailing-fold, secret-absence in the denial, .git-credentials/bare/
submodule config, full credential-dotfile set). fs suite 82/82; typecheck+lint clean.

Refs: ADR-0057
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…o .pnpm)

Sonnet 5 review (two independent security lenses) found the node_modules
aliasing exemption was too broad: exempting ANY node_modules segment reopened
the hard-link exfiltration hole for an attacker-NAMED file, and the "no code
execution needed" claim was false (the node-tar hard-link path-traversal CVE
class can plant a cross-boundary hard link during a plain install; a malicious
postinstall plants a durable alias a later agent turn reads).

- Narrowed the exemption to pnpm's REAL virtual store: `isPnpmStorePath` now
  requires a `node_modules/.pnpm` adjacency (where pnpm's hard links actually
  live — a top-level `node_modules/<pkg>` is a symlink the jail resolves into
  `.pnpm`). An attacker-named `node_modules/<anything>` hard link is once again
  refused. The remaining residual (a compromised dep planting under `.pnpm`) is
  bounded, documented, and tracked for the ADR-0057 security-review record in
  deferred-tasks.md.
- MEDIUM (zero-precondition floor miss): added git's XDG credential store
  `git/credentials` (bare `credentials` basename under a `git` dir) to the
  sensitive-read floor.

+6 tests: the exemption now uses the real `.pnpm` layout; a plain
`node_modules/<x>` hard link is still refused; the exemption is exercised through
the glob path; the append hard-link guard is pinned as unconditional (no full/
store carve-out, both directions); read-floor case-fold (`.SSH/`); the XDG
`git/credentials` case (+ a bare non-git `credentials` stays readable). fs suite
87/87; typecheck + lint clean.

Refs: ADR-0057
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ion layer (PR #63 review)

Closes the F2 egress + secret-leak cluster from the Sonnet 5 PR review (high 5/6/7
+ elevation) and the folded-in external egress comments.

- HIGH (SSRF request-smuggling): `sanitizeHopHeaders` stripped only Host/:authority;
  a model-set `Content-Length` that mismatches the body smuggles a second forged
  request past the Host-strip. Now also strips content-length / transfer-encoding /
  connection / keep-alive / proxy-connection / te / upgrade / expect, so Node owns
  the framing (packages/db/src/safe-egress.ts).
- HIGH (secret leak) + ELEVATION: one shared `redactSecretShapedText` /
  `redactSecretShapedValue` layer (packages/core/src/tools/bounding.ts) — linear,
  ReDoS-safe patterns for Authorization schemes, `secret=value` pairs, and known
  token shapes. Applied to `outputSummary` (a read_clipboard / egress-body / .env
  read no longer rides the --json stream) and to `toolInput` via sanitizeInput (a
  model-set credential in a header VALUE / body / url-query is scrubbed by shape,
  header NAMES kept). The model-facing value is untouched.
- External: the host-resolved egress credential now WINS deterministically over a
  model-supplied `Authorization` (any case) — `mergeEgressHeaders` drops every case
  variant before setting the canonical header (apps/cli/src/engine/tool-host/egress.ts);
  moved the `classifyEgressError` JSDoc onto its function.
- External: `withEgressTimeout` now RACES fn against a hard deadline (+ still aborts),
  so a signal-ignoring transport times out instead of hanging; the safe-egress test
  request stub gains `destroy()` to match the real lifecycle.

+9 tests (framing-strip, timeout-race, header-casing, secret-shaped text/value +
ReDoS-safety + summary scrub, cycle-safety). core 118/118 · db 18/18 · cli egress
14/14; full core+db+cli suites + typecheck + lint all green.

Refs: ADR-0057, ADR-0053
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…-contained egress)

Opus review of F2 verified the ReDoS-linearity, the observability/model-facing
boundary, non-mutation, and engine purity as sound; fixed the recall + hardening
findings:

- HIGH (recall): redactSecretShapedText missed the JSON `"key":"value"` shape —
  exactly the OAuth/egress-response-body case the layer targets (the closing quote
  broke the key↔separator adjacency). Added an optional quote before the separator
  (still linear). Extended the standalone token-shape set (Stripe sk_live_, AWS STS
  ASIA/ABIA, github_pat_, glpat-, ya29., hf_, npm_).
- LOW hardening: sanitizeHopHeaders is now a SELF-CONTAINED request-splitting
  defense — it drops a non-token header name and any CR/LF/NUL-carrying value
  itself (not delegating to the Node transport, since openConnection is an
  injectable seam), and also strips x-forwarded-*/forwarded (vhost reroute one hop
  deeper).
- LOW: the redactSecretShapedValue cycle branch now returns '[cyclic]' (mirrors
  redactInlineMedia) instead of re-emitting the unredacted reference.

Tests: +HIGH the toolInput secret-scrub WIRING is now locked end-to-end via a
registry.dispatch http_request test (was unit-only); the ReDoS test now drives a
value-ENGAGING adversarial input (the old keyless input never entered the
backtracking machinery); + JSON-shape redaction, truncated-summary scrub,
CRLF/token/forwarding drop, no-credential Authorization passthrough. core 121 · db
19 · cli egress 15; typecheck + lint clean.

Refs: ADR-0057, ADR-0053
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…recall, DNS doc)

Sonnet 5 review (one reviewer mutation-tested every guard — all four load-bearing
mechanisms confirmed regression-proof) found additive recall gaps + honesty fixes:

- MEDIUM (recall): a QUOTED multi-word passphrase leaked its tail — the value class
  stopped at the first space (`"hunter2 dragon"` → leaked `dragon`). The value now
  has two branches: a quoted value consumes lazily through the first closing quote
  (line-bounded, still linear), an unquoted value stops at a delimiter.
- MEDIUM (recall): PEM private-key blocks (`-----BEGIN … PRIVATE KEY-----`, space-
  separated markers the `private_key` pattern can't see) went through unredacted —
  added a bounded PEM pass.
- MEDIUM (egress honesty): the `withEgressTimeout` doc overstated cancellation —
  Node's `dns.promises.lookup` is not abortable, so the deadline bounds the CALLER
  but a hung resolver keeps a background getaddrinfo alive. Doc narrowed to say so.
- LOW (egress): a resolved credential with a stray trailing newline was silently
  dropped by the request-splitting guard (→ unauthenticated); `mergeEgressHeaders`
  now trims it. Completed the hop-by-hop set (proxy-authorization/authenticate,
  trailer, keep-alive).

Tests: +HIGH the ReDoS test now asserts the long value is FULLY redacted (a
value-quantifier-narrowing regression runs faster, so a timing-only bound missed
it); + per-prefix coverage for all 13 standalone token shapes, the passphrase +
PEM cases, an over-redaction characterization pin, the dual-casing Authorization
drop, the credential-trim, and the remaining hop/forwarding headers. core 125 · db
19 · cli egress 17; typecheck + lint clean.

Refs: ADR-0057, ADR-0053
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…dlock, EA5 emit)

Closes the F3 approval cluster from the Sonnet 5 PR review (high 8/9 + the
should-fix emit) and the folded-in external comments.

- HIGH 8 (blank-check): a BLANK-preview governed tool (mcp_call / web_search —
  previewFor returns no path/command/host) let `[a]lways` cache a session-long,
  unreviewed blank check over any future server/tool/args. confirmFor now passes
  cacheable=false for a blank preview (once-only; the REPL greys out `[a]`).
- HIGH 9 (deadlock): on a non-interactive driver (plain non-TTY / --json) nothing
  answers `store.requestApproval`, so `accept-edits`/`auto` hung on an
  unanswerable promise. createChatModeControl now takes an `interactive` flag; a
  non-interactive session uses a reject-immediately prompt (deny, never hang),
  mirroring the one-shot `agent run`.
- should-fix (EA5 emit): the engine now emits `agent:approval_requested` — the
  registry's confirmDispatch calls a new `emitApprovalRequested` (best-effort;
  swallows a sink fault) just before the confirm hook, and AgentSession wires it
  to emit through the session sink stamping the turn's nodeId. The documented-but-
  never-emitted event now rides the session/--json stream.

External comments folded in:
- run-event: the AgentApprovalRequestedEvent preview is now action-bound (a
  union-level superRefine — an `egress` approval can't carry a `path`, etc.;
  `.strict()` already barred unknown keys, this bars a wrong-for-the-action one).
- home-controller: Esc now rejects a PENDING approval when onAbort is absent
  (never a dead key at a decision).
- chat-mode: parseMode narrows via an `isChatMode` type guard (no `as ChatMode`).
- agent-session: extracted `#assertSendable` (sendMessage cognitive complexity);
  the sonar never-in-union target no longer exists in the current code.
- tests: replaced the `as never` abort-signal casts with a mutableSignal double
  (registry.test) and the `as Error` catch casts with instanceof narrowing
  (chat-store.test).

+9 tests (blank-check once-only, non-interactive deny-no-hang, EA5 emit-before-
confirm + throwing-emit-safe + session sink+nodeId, action-drift schema
rejection, home abort-fallback). shared 107 · core 126 · cli 1176 all green;
typecheck + lint clean.

Refs: ADR-0057
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…est gaps)

Opus review of F3 confirmed the fail-closed floor fully intact (no blocker/high):
the EA5 emit is provably side-effect-only + sequenced after both deny gates, the
blank-check enforced at the toDecision floor, the deadlock predicate bit-aligned
with the ink-mount decision, the schema binding airtight. Fixes:

- LOW (docs): the rich sendMessage JSDoc had stranded onto #assertSendable after
  the extraction — moved back onto sendMessage.
- LOW (polish): isBlankPreview now keys a `Record<keyof ToolActionPreview>` so a
  NEW reviewable field breaks the build here (fails loud) rather than silently
  making a preview that carries it look "blank".
- LOW (honesty): reframed the agent:approval_requested semantics — it fires for
  EVERY governed dispatch that reaches the gate ("a governed action was gated"),
  not only human-prompted ones; noted attemptNumber is absent on the session path
  (a cross-seam concept) and the best-effort drop of a schema-invalid drift.

Test gaps closed:
- run-event: a positive accept case for a BLANK os/egress preview (the os/MCP
  approval class would silently break on a superRefine regression), + os/process
  action-drift reject fixtures.
- agent-session: the EA5-emitted body is now schema-VALIDATED (RunEventSchema
  safeParse with the stamped envelope), not just toMatchObject.
- registry: emit-before-confirm now also locked for a REJECTING confirm (a denied
  governed action is still traced).
- chat-mode: a CONCRETE egress preview STILL caches always (blank-check didn't
  over-correct — pins the isBlankPreview host check).
- home-controller: the onAbort-PRESENT branch (no double-settle) alongside absent.

shared 108 · core 926 · cli 1178; typecheck + lint clean.

Refs: ADR-0057
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…k derivation test)

Sonnet 5 review confirmed the fail-closed floor holds under adversarial tracing
(no blocker/high). Fixes:

- LOW (doc drift, both correctness reviewers): the SessionApprovalStreamEvent doc
  in agent-session.ts + index.ts still said "the host's ConfirmActionHook emits
  it" — F3 moved the emit to the ENGINE (confirmDispatch via emitApprovalRequested).
  Corrected both so the PR no longer ships two contradictory explanations (a stale
  doc would invite a duplicate emit).
- HIGH (test gap): the real deadlock derivation (`stdoutIsTty && !json`) + threading
  was never exercised end-to-end — the tests passed an explicit {interactive:false}.
  Extracted it to a named, exported `chatIsInteractive(io, global)` (also de-dupes
  the predicate `selectChatDriver` re-implements) and unit-locked all four cases (a
  dropped `!` now fails). The createChatModeControl({interactive:false}) e2e still
  locks the consumer deny-no-hang.
- MEDIUM (test gap): the EA5 emit is now asserted end-to-end — a REAL AgentSession
  turn → real registry confirmDispatch → the approval event landing on the handle
  stream (session-host accept-edits test), not just per-hop unit mocks.
- LOW (test gap): a CONCRETE process/command preview STILL caches always (pins
  isBlankPreview's `command` field, the one class the concrete-caches side missed).

Tracked the elevation both reviewers flagged (target-scoped approval cache + a
structured MCP preview target so mcp_call can offer a reviewable per-server always)
in deferred-tasks.md.

core 127 · cli 169 (+ session-host 43); typecheck + lint clean.

Refs: ADR-0057
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Closes the F4 should-fix cluster + the folded-in external comments.

- UX: a tool_denied / tool_unavailable turn now renders its secret-free REASON in
  the chat summary (`error: tool_denied — not allowed in ask mode (read-only)`) —
  the reason was discarded, leaving the first-time ask-mode edit a n6nedd dead-end.
  Only the vetted approval-floor codes surface the message (others' may carry
  prompt context); parity with the run path's final-summary.ts.
- tests: a governedAction drift-lock pins the EXACT engine-governed built-in set
  (write_file/run_command/http_request/web_search/mcp_call/read_clipboard/notify)
  — distinct from the CLI advertise-filter's superset (git_commit is gate-only,
  not confirm-governed); governedAction is exported (from registry.ts, not the
  package index) for it.
- tests: an auto-mode PROTECTED-path write that is APPROVED still fails — proving
  the fs protected-paths floor is the true, approval-independent floor (the reject
  branch was the only one exercised).

External comments:
- chat.ts: `/mode` now enforces its single-value positional ARITY (`/mode plan
  accept-edits` is rejected, not silently dropped) and lists the valid names on an
  invalid value (`Valid: ask, plan, accept-edits, auto.`).
- chat-input.ts: extracted the approval-prompt intercept into `reduceApprovalKey`
  so `reduceChatKey`'s cognitive complexity drops under the limit.
- docs: reconciled home.md — the mode keymap (Shift+Tab), the per-tool approval
  keys, and the Esc mid-turn abort are now documented as WIRED (2.5.E), not
  "forthcoming"; added the keymap rows; bumped Last updated.

core 926 · cli 1180; typecheck + lint clean.

Refs: ADR-0057
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…c drift)

Opus review of F4 confirmed the tool_denied/tool_unavailable errorMessage
whitelist is genuinely secret-free (all 7 ToolPolicyError sites + both denial
classes + the host-denied arms + ToolUnavailableError traced — none carries a
path/secret/url/model content); no blocker/high. Fixes:

- MEDIUM: formatTurnSummary rendered the whitelisted reason WITHOUT the
  stripTerminalControls/sanitizeInline that every sibling projection applies —
  safe-by-convention today (host-authored ASCII) but the render boundary now
  strips any ANSI/OSC/control byte regardless, and omits a dangling em-dash for
  an empty reason. +a control-byte + empty-reason test.
- LOW: the auto-mode APPROVED-still-fails test now asserts the fallback prompt was
  actually published (toolId + non-cacheable) BEFORE the approve, so it can't pass
  via the fs floor if auto silently stopped prompting.
- docs: reconciled two home.md drifts the reviewer caught — the interactive `/`
  palette is no longer listed as "forthcoming" (it landed in 2.5.C), and the
  active-mode footer indicator is now correctly attributed to 2.5.E (not folded
  into the 2.5.C context-aware hint-bar).

NOTE: this commit also re-establishes the correct branch history — F4 had been
built on a DETACHED HEAD at 653b511 (F3 implement), orphaning the F3 opus+sonnet
commits; development is now the linear F1→F2→F3(×3)→F4 chain (full monorepo green,
23/23 turbo tasks).

Refs: ADR-0057
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…t, prettier)

Sonnet 5 review: no blocker/high — the test reviewer mutation-tested all four F4
guards (none vacuous), the correctness reviewer re-confirmed the tool_denied/
tool_unavailable whitelist secret-free, and the docs-coherence reviewer verified
the branch-recovery + non-negotiables clean. Fixes (docs/comment hygiene):

- MEDIUM: F3's "host emits agent:approval_requested → the ENGINE emits it" doc fix
  had missed three spots — corrected the SessionStreamEvent JSDoc in agent-session.ts,
  tool-registry.md step 4b, and sse-event-schema.md, all now attributing the emit to
  the engine's confirmDispatch + noting it fires for EVERY governed dispatch (prompted
  or auto-decided), not only human-prompted ones.
- LOW: moved reduceChatKey's contract JSDoc back onto reduceChatKey (the extraction
  had stranded it on reduceApprovalKey); merged the duplicate governedAction JSDoc;
  tightened the SAFE_MESSAGE_CODES comment to state the real project-wide reason-only
  contract (not a two-string allowlist).

Also ran prettier across the fix range (CI runs format:check) — reformatted 8 files
whose formatting hadn't been checked per-step (F1/F2 fs/egress/bounding/chat-mode
tests + the F4 edits). format:check now clean.

core 129 · cli 124 (affected); typecheck + lint + format:check all clean.

Refs: ADR-0057
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… policy)

The F2/F3 redaction tests need secret-SHAPED strings to exercise the patterns, so
Leakwatch flagged 13 fixtures (fake JWT/AWS/GitHub/Stripe/… tokens — e.g. the AWS
docs sample AKIAIOSFODNN7EXAMPLE). Per the project convention (build test keys from
parts, never a contiguous literal), each is now assembled from split fragments
(`'sk-' + 'abcdef…'`, `'AKIA' + 'IOSFODNN7EXAMPLE'`, the PEM markers, etc.) — the
RUNTIME string is unchanged so every redaction assertion still holds, but no
contiguous secret literal appears in source. Leakwatch: 13 → 0 real fixtures.

(Two residual Leakwatch hits are non-issues: `mcp-secret.test.ts`'s `sk-P…CRET` is
pre-existing 2.R, untouched by this PR; and the entropy detector flags the new
FUNCTION NAME `redactSecretShapedText` itself — an identifier, not a secret.
Leakwatch is a local policy tool, not a CI gate.)

Refs: ADR-0057
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@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

🧹 Nitpick comments (3)
packages/core/src/engine/agent-session.ts (1)

334-433: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Reduce sendMessage complexity under the Sonar threshold.

SonarCloud reports Line 334 at cognitive complexity 16 with a limit of 15. Extracting one branch, such as hard-cap completion, keeps behavior unchanged and unblocks the check.

♻️ Proposed extraction
   async sendMessage(text: string): Promise<void> {
@@
     if (this.#turnCount >= this.#maxTurns) {
-      this.#status = 'idle';
-      this.#abort = undefined; // no turn ran — release the armed controller (this early return skips finally)
-      this.#emitTurnCompleted(
-        'error',
-        { input: 0, output: 0 },
-        {
-          code: 'turn_limit',
-          message: `session reached its hard cap of ${this.#maxTurns} turns`,
-          retryable: false,
-        },
-      );
+      this.#settleTurnLimit();
       return;
     }
@@
   }
+
+  `#settleTurnLimit`(): void {
+    this.#status = 'idle';
+    this.#abort = undefined;
+    this.#emitTurnCompleted(
+      'error',
+      { input: 0, output: 0 },
+      {
+        code: 'turn_limit',
+        message: `session reached its hard cap of ${this.#maxTurns} turns`,
+        retryable: false,
+      },
+    );
+  }
🤖 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 `@packages/core/src/engine/agent-session.ts` around lines 334 - 433, The
sendMessage method in AgentSession is just over the Sonar cognitive complexity
limit, so reduce it by extracting one self-contained branch without changing
behavior. Pull the hard-cap early return logic out into a dedicated helper (for
example, a turn-cap completion method) and call it from sendMessage, keeping the
existing abort, cancel, and error handling flow intact. Use the
AgentSession.sendMessage and `#emitTurnCompleted` symbols to locate the branch to
extract.

Source: Linters/SAST tools

packages/core/src/tools/bounding.test.ts (1)

350-355: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Avoid the unsafe cast in the redaction test.

redactSecretShapedValue returns unknown; this test can keep strict typing by asserting the returned structure directly. As per coding guidelines, “All source code must be TypeScript-first and strict: avoid any, avoid unsafe as, and prefer type guards.”

Proposed fix
-    const out = redactSecretShapedValue(input) as typeof input;
-    expect(out.headers.Authorization).toBe('Bearer [redacted]');
-    expect(out.headers['X-Trace']).toBe('keep-me'); // non-secret header value untouched
-    expect(Object.keys(out.headers)).toEqual(['Authorization', 'X-Trace']); // header NAMES preserved
-    expect(out.body).toBe('[redacted]');
-    expect(out.url).not.toContain('sk-' + 'secret9876543210');
+    const out = redactSecretShapedValue(input);
+    expect(out).toEqual({
+      url: expect.not.stringContaining('sk-' + 'secret9876543210'),
+      headers: { Authorization: 'Bearer [redacted]', 'X-Trace': 'keep-me' },
+      body: '[redacted]',
+    });
🤖 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 `@packages/core/src/tools/bounding.test.ts` around lines 350 - 355, The test in
bounding.test.ts uses an unsafe cast on the result of redactSecretShapedValue,
which returns unknown. Update the redactSecretShapedValue assertion path to keep
strict typing by narrowing the value with a type guard or by asserting the
expected object shape directly, then access headers, body, and url through the
narrowed type. Keep the existing expectations in the bounding.test.ts redaction
test, but remove the blanket as typeof input cast so the test remains
TypeScript-strict.

Source: Coding guidelines

apps/cli/src/chat/chat-mode.ts (1)

67-70: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Drop the avoidable as cast in the type guard.

This can stay fully type-guard based without widening the tuple via as, and it keeps the comment accurate. As per coding guidelines, **/*.{ts,tsx} should be TypeScript-first and strict: no unsafe as, prefer type guards.

Proposed refactor
 function isChatMode(value: string): value is ChatMode {
-  return (CHAT_MODES as readonly string[]).includes(value);
+  return CHAT_MODES.some((mode) => mode === value);
 }
🤖 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 `@apps/cli/src/chat/chat-mode.ts` around lines 67 - 70, The isChatMode type
guard currently uses an avoidable as cast on CHAT_MODES, which conflicts with
the strict TypeScript-first guideline and makes the comment inaccurate. Update
isChatMode to work directly with the CHAT_MODES tuple without widening it, while
preserving the value is ChatMode type guard behavior. Keep the implementation
centered on CHAT_MODES and isChatMode so the tuple element types remain inferred
safely and no unsafe cast is needed.

Source: Coding guidelines

🤖 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 @.claude/settings.json:
- Line 34: The agent command policy currently allows Bash(git push:*), which
would let the agent write to remotes. Remove or keep denied the git push pattern
in the settings configuration, and only reintroduce it if there is an explicitly
approved, narrowly scoped policy exception for remote writes.

In `@apps/cli/src/engine/tool-host/fs.ts`:
- Around line 156-168: The pnpm exemption in isPnpmStorePath is too broad and
currently lets rejectAliasedRead skip the alias guard for any node_modules/.pnpm
subtree, including malformed user-controlled paths. Tighten isPnpmStorePath so
it only returns true for the واقعی virtual-store layout under
node_modules/.pnpm/<pkg>@<ver>/node_modules/<pkg>/… (not just any .pnpm path),
and keep rejectAliasedRead using that stricter check to decide when to allow
reads.

In `@packages/core/src/tools/bounding.ts`:
- Around line 190-191: The issue is in redactSecretShapedWalk, where object keys
are copied verbatim into the out record and can leak secret-shaped strings into
toolInput/JSON observability logs. Update the Object.entries branch in
bounding.ts so keys are scrubbed/redacted before being assigned to out, using
the existing secret-detection logic already used for values, while keeping the
recursive walk behavior intact and avoiding leaks through redactSecretShapedWalk
and its callers.

In `@packages/shared/src/run-event.ts`:
- Around line 567-578: The approval preview validation in run-event.ts only
rejects extra fields and still allows target-bearing actions like fs_write,
process, and egress to pass with an empty preview. Update the
agent:approval_requested check to require the expected preview target for non-os
actions by validating that preview.path, preview.command, or preview.host is
present when APPROVAL_PREVIEW_FIELD[event.action] points to it, while keeping os
as the only action allowed to have a blank preview. Use the existing
event.action, APPROVAL_PREVIEW_FIELD, and event.preview validation block to add
a missing-field issue alongside the current wrong-field drift check.

---

Nitpick comments:
In `@apps/cli/src/chat/chat-mode.ts`:
- Around line 67-70: The isChatMode type guard currently uses an avoidable as
cast on CHAT_MODES, which conflicts with the strict TypeScript-first guideline
and makes the comment inaccurate. Update isChatMode to work directly with the
CHAT_MODES tuple without widening it, while preserving the value is ChatMode
type guard behavior. Keep the implementation centered on CHAT_MODES and
isChatMode so the tuple element types remain inferred safely and no unsafe cast
is needed.

In `@packages/core/src/engine/agent-session.ts`:
- Around line 334-433: The sendMessage method in AgentSession is just over the
Sonar cognitive complexity limit, so reduce it by extracting one self-contained
branch without changing behavior. Pull the hard-cap early return logic out into
a dedicated helper (for example, a turn-cap completion method) and call it from
sendMessage, keeping the existing abort, cancel, and error handling flow intact.
Use the AgentSession.sendMessage and `#emitTurnCompleted` symbols to locate the
branch to extract.

In `@packages/core/src/tools/bounding.test.ts`:
- Around line 350-355: The test in bounding.test.ts uses an unsafe cast on the
result of redactSecretShapedValue, which returns unknown. Update the
redactSecretShapedValue assertion path to keep strict typing by narrowing the
value with a type guard or by asserting the expected object shape directly, then
access headers, body, and url through the narrowed type. Keep the existing
expectations in the bounding.test.ts redaction test, but remove the blanket as
typeof input cast so the test remains TypeScript-strict.
🪄 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: defaults

Review profile: CHILL

Plan: Pro

Run ID: a29ae1ef-0155-490d-b103-422721353838

📥 Commits

Reviewing files that changed from the base of the PR and between b482dc7 and d9f31b4.

📒 Files selected for processing (33)
  • .claude/settings.json
  • apps/cli/src/chat/chat-mode.test.ts
  • apps/cli/src/chat/chat-mode.ts
  • apps/cli/src/chat/session-host.test.ts
  • apps/cli/src/commands/chat.test.ts
  • apps/cli/src/commands/chat.ts
  • apps/cli/src/engine/tool-host/egress.test.ts
  • apps/cli/src/engine/tool-host/egress.ts
  • apps/cli/src/engine/tool-host/fs.test.ts
  • apps/cli/src/engine/tool-host/fs.ts
  • apps/cli/src/render/tui/chat-input.ts
  • apps/cli/src/render/tui/chat-projection.test.ts
  • apps/cli/src/render/tui/chat-projection.ts
  • apps/cli/src/render/tui/chat-store.test.ts
  • apps/cli/src/render/tui/home-controller.test.ts
  • apps/cli/src/render/tui/home-controller.ts
  • apps/cli/src/render/tui/session-view-model.ts
  • docs/reference/cli/home.md
  • docs/reference/contracts/sse-event-schema.md
  • docs/reference/shared-core/tool-registry.md
  • docs/roadmap/deferred-tasks.md
  • packages/core/src/engine/agent-session.test.ts
  • packages/core/src/engine/agent-session.ts
  • packages/core/src/index.ts
  • packages/core/src/tools/bounding.test.ts
  • packages/core/src/tools/bounding.ts
  • packages/core/src/tools/registry.test.ts
  • packages/core/src/tools/registry.ts
  • packages/core/src/tools/types.ts
  • packages/db/src/safe-egress.test.ts
  • packages/db/src/safe-egress.ts
  • packages/shared/src/run-event.test.ts
  • packages/shared/src/run-event.ts
✅ Files skipped from review due to trivial changes (2)
  • docs/reference/cli/home.md
  • docs/roadmap/deferred-tasks.md
🚧 Files skipped from review as they are similar to previous changes (18)
  • apps/cli/src/render/tui/chat-store.test.ts
  • packages/core/src/index.ts
  • apps/cli/src/render/tui/home-controller.ts
  • packages/core/src/tools/types.ts
  • apps/cli/src/engine/tool-host/egress.ts
  • docs/reference/contracts/sse-event-schema.md
  • apps/cli/src/render/tui/home-controller.test.ts
  • packages/core/src/tools/registry.ts
  • packages/db/src/safe-egress.test.ts
  • apps/cli/src/chat/chat-mode.test.ts
  • apps/cli/src/render/tui/session-view-model.ts
  • packages/db/src/safe-egress.ts
  • packages/core/src/tools/registry.test.ts
  • apps/cli/src/chat/session-host.test.ts
  • apps/cli/src/render/tui/chat-input.ts
  • docs/reference/shared-core/tool-registry.md
  • packages/core/src/engine/agent-session.test.ts
  • packages/shared/src/run-event.test.ts

Comment thread .claude/settings.json Outdated
Comment thread apps/cli/src/engine/tool-host/fs.ts
Comment thread packages/core/src/tools/bounding.ts Outdated
Comment thread packages/shared/src/run-event.ts Outdated
Verify each finding against current code; fix the still-valid ones, skip the
rest with a reason. Behavior-preserving throughout (all tests green + the
security-critical redaction/jail/approval paths pinned by fixtures).

Inline:
- .claude/settings.json: narrow the agent's `git push` allow from the broad
  `git push:*` to the scoped, non-force `git push origin development` (a
  narrowly-scoped remote-write exception, per the review).
- fs.ts `isPnpmStorePath`: tighten the aliasing-read exemption to the REAL
  virtual-store layout `node_modules/.pnpm/<pkg>@<ver>/node_modules/<pkg>/…`
  (require the `<pkg>@<ver>` + nested `node_modules`) — a hard link planted
  DIRECTLY under `.pnpm/` is no longer exempt. New test pins the closed bypass.
- bounding.ts `redactSecretShapedWalk`: scrub object KEYS with the same detector
  as values (a normal header name passes through; a secret-SHAPED key is now
  redacted too, closing that observability leak path). New test pins it.
- run-event.ts approval preview: reject a BLANK preview for fs_write / process
  (their path/command is always resolved before the gate); egress (host optional
  for mcp/search) and os (no target) stay lenient. New test pins the rejection.

Nitpicks / Sonar:
- chat-mode.ts `isChatMode`: drop the `as readonly string[]` cast (`.some()`),
  matching the "no unsafe cast" doc + the strict-TS rule.
- agent-session.ts: extract `#completeIfTurnCapReached` from `sendMessage`
  (Sonar cognitive-complexity 16→under the ceiling).
- bounding.test.ts: replace the unsafe `as typeof input` with a cast-free
  `toEqual` shape assertion.
- chat.ts `processLine`: extract `handleSlashCommand` + a pure
  `validateSlashTokens` (Sonar complexity 20→under the ceiling).
- run-event.ts: split the union superRefine into five named refine* helpers
  (Sonar complexity 20→~1 on the composed schema).
- bounding.ts redaction regexes: fold `[A-Za-z0-9_]`→`\w` / `[A-Za-z0-9._~+/-]`
  →`[\w.~+/-]` where the sets are exactly equal; drop the redundant `apikey`
  branch (`api[_-]?key` subsumes it).

Skipped (with reason):
- agent-session.ts S6571 ("'never' overridden in union"): false positive on the
  idiomatic `DistributiveOmit<T,K> = T extends unknown ? Omit<T,K> : never` — the
  `: never` is the required (unreachable) false branch of a distributive
  conditional, not a redundant union member; it cannot be removed.
- bounding.ts standalone-token + key=value regex hard complexity-20 targets: kept
  as single analyzable LITERALS (not composed via `new RegExp`). This is required
  for CORRECTNESS: an adversarial review proved that splitting the standalone-token
  mega-alternation into per-family passes is NOT behavior-identical — an earlier
  pass inserting `[redacted]` truncates a later family's greedy match, leaking a
  trailing secret-shaped substring (e.g. a JWT whose signature holds an inner `sk-`
  run). The single leftmost-longest scan is load-bearing; a regression fixture pins
  two adjacent/embedded witnesses. Keeping them literals also preserves Sonar's
  static ReDoS (S5852) coverage on these security-critical patterns. Correctness +
  ReDoS coverage outrank the per-regex complexity metric.

Refs: ADR-0057, ADR-0055, ADR-0049
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@cemililik

Copy link
Copy Markdown
Contributor Author

@coderabbitai review all please

@coderabbitai

coderabbitai Bot commented Jul 2, 2026

Copy link
Copy Markdown

@cemililik Sure thing — I'll kick off a full review of all the changes in this PR.

✅ 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: 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 `@packages/core/src/tools/bounding.ts`:
- Around line 168-180: The standalone credential redaction regex in bounding.ts
misses OpenAI project-scoped keys because the current `sk-` branch in the
`.replace(...)` pattern only matches a fixed alphanumeric tail. Update that
`sk-` alternative to also match hyphenated OpenAI prefixes like `sk-proj-` (or
otherwise broaden the secret payload class without weakening other branches),
and keep the change localized to the standalone redactor so `bounding.test.ts`
coverage remains valid.
🪄 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: defaults

Review profile: CHILL

Plan: Pro

Run ID: 73f69f95-c448-4814-a3bb-796c0d5aebcb

📥 Commits

Reviewing files that changed from the base of the PR and between d9f31b4 and e762f8b.

📒 Files selected for processing (10)
  • .claude/settings.json
  • apps/cli/src/chat/chat-mode.ts
  • apps/cli/src/commands/chat.ts
  • apps/cli/src/engine/tool-host/fs.test.ts
  • apps/cli/src/engine/tool-host/fs.ts
  • packages/core/src/engine/agent-session.ts
  • packages/core/src/tools/bounding.test.ts
  • packages/core/src/tools/bounding.ts
  • packages/shared/src/run-event.test.ts
  • packages/shared/src/run-event.ts
🚧 Files skipped from review as they are similar to previous changes (6)
  • .claude/settings.json
  • packages/core/src/tools/bounding.test.ts
  • packages/shared/src/run-event.test.ts
  • packages/core/src/engine/agent-session.ts
  • apps/cli/src/engine/tool-host/fs.ts
  • apps/cli/src/commands/chat.ts

Comment on lines +168 to +180
// Well-known standalone credential shapes (OpenAI, Stripe, AWS incl. STS ASIA/ABIA, GitHub token + PAT,
// GitLab PAT, Slack, Google API + OAuth, HuggingFace, npm, JWT). Kept as ONE alternation ON PURPOSE: a
// single leftmost-longest scan is required so two ADJACENT / EMBEDDED credential shapes (e.g. a JWT whose
// signature segment contains an `sk-` run, or a `xox…-glpat-…` run) are covered by the outer greedy match
// as a single span. A per-family MULTI-PASS split is NOT equivalent — an earlier pass inserting `[redacted]`
// (a non-word `[`) truncates a later family's greedy match and can leave a trailing secret-shaped substring
// EXPOSED (a real redaction regression, pinned in bounding.test.ts). So this stays a single analyzable
// LITERAL and its Sonar per-regex complexity is a deliberate, documented exception — the same trade-off as
// the key=value pattern above: correctness of a security redactor + Sonar's static ReDoS (S5852) coverage
// outrank the metric. `\w` / `[\w-]` fold only the classes that are EXACTLY `[A-Za-z0-9_]` / `[A-Za-z0-9_-]`;
// the tighter `[A-Za-z0-9]` / `[A-Za-z0-9-]` families keep their narrower class (no `_`).
.replace(
/\b(?:sk-[A-Za-z0-9]{16,}|sk_(?:live|test)_[A-Za-z0-9]{16,}|A[KSB]IA[0-9A-Z]{16}|gh[pousr]_[A-Za-z0-9]{20,}|github_pat_\w{20,}|glpat-[\w-]{16,}|xox[baprs]-[A-Za-z0-9-]{10,}|AIza[\w-]{30,}|ya29\.[\w-]{20,}|hf_[A-Za-z0-9]{20,}|npm_[A-Za-z0-9]{20,}|eyJ[\w-]{10,}\.[\w-]{10,}\.[\w-]{6,})/g,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '== bounding.ts relevant lines ==\n'
nl -ba packages/core/src/tools/bounding.ts | sed -n '150,190p'

printf '\n== bounding.test.ts mentions ==\n'
rg -n "sk-|OpenAI|credential|redact|redaction" packages/core/src/tools/bounding.test.ts || true

printf '\n== regex probe ==\n'
python3 - <<'PY'
import re

pattern = re.compile(r"\b(?:sk-[A-Za-z0-9]{16,}|sk_(?:live|test)_[A-Za-z0-9]{16,}|A[KSB]IA[0-9A-Z]{16}|gh[pousr]_[A-Za-z0-9]{20,}|github_pat_\w{20,}|glpat-[\w-]{16,}|xox[baprs]-[A-Za-z0-9-]{10,}|AIza[\w-]{30,}|ya29\.[\w-]{20,}|hf_[A-Za-z0-9]{20,}|npm_[A-Za-z0-9]{20,}|eyJ[\w-]{10,}\.[\w-]{10,}\.[\w-]{6,})/g".replace('/g',''))

samples = [
    "sk-proj-abcdefghijklmnop",
    "sk-proj-abcdefghijklmnopq",
    "sk-abcdefghijklmnop",
    "sk_live_abcdefghijklmnop",
    "xsk-proj-abcdefghijklmnop",
    "api=sk-proj-abcdefghijklmnop",
    "foo sk-proj-abcdefghijklmnop bar",
]
for s in samples:
    m = pattern.search(s)
    print(f"{s!r} -> {bool(m)}", f"match={m.group(0)!r}" if m else "")
PY

Repository: HodeTech/Relavium

Length of output: 228


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '== bounding.ts relevant lines ==\n'
nl -ba packages/core/src/tools/bounding.ts | sed -n '150,190p'

printf '\n== bounding.test.ts mentions ==\n'
rg -n "sk-|OpenAI|credential|redact|redaction" packages/core/src/tools/bounding.test.ts || true

printf '\n== regex probe ==\n'
python3 - <<'PY'
import re

pattern = re.compile(r"\b(?:sk-[A-Za-z0-9]{16,}|sk_(?:live|test)_[A-Za-z0-9]{16,}|A[KSB]IA[0-9A-Z]{16}|gh[pousr]_[A-Za-z0-9]{20,}|github_pat_\w{20,}|glpat-[\w-]{16,}|xox[baprs]-[A-Za-z0-9-]{10,}|AIza[\w-]{30,}|ya29\.[\w-]{20,}|hf_[A-Za-z0-9]{20,}|npm_[A-Za-z0-9]{20,}|eyJ[\w-]{10,}\.[\w-]{10,}\.[\w-]{6,})")

samples = [
    "sk-proj-abcdefghijklmnop",
    "sk-proj-abcdefghijklmnopq",
    "sk-abcdefghijklmnop",
    "sk_live_abcdefghijklmnop",
    "xsk-proj-abcdefghijklmnop",
    "api=sk-proj-abcdefghijklmnop",
    "foo sk-proj-abcdefghijklmnop bar",
]
for s in samples:
    m = pattern.search(s)
    print(f"{s!r} -> {bool(m)}", f"match={m.group(0)!r}" if m else "")
PY

Repository: HodeTech/Relavium

Length of output: 228


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '== bounding.ts relevant lines ==\n'
sed -n '150,190p' packages/core/src/tools/bounding.ts | cat -n

printf '\n== bounding.test.ts mentions ==\n'
rg -n "sk-|OpenAI|credential|redact|redaction" packages/core/src/tools/bounding.test.ts || true

printf '\n== regex probe ==\n'
python3 - <<'PY'
import re

pattern = re.compile(r"\b(?:sk-[A-Za-z0-9]{16,}|sk_(?:live|test)_[A-Za-z0-9]{16,}|A[KSB]IA[0-9A-Z]{16}|gh[pousr]_[A-Za-z0-9]{20,}|github_pat_\w{20,}|glpat-[\w-]{16,}|xox[baprs]-[A-Za-z0-9-]{10,}|AIza[\w-]{30,}|ya29\.[\w-]{20,}|hf_[A-Za-z0-9]{20,}|npm_[A-Za-z0-9]{20,}|eyJ[\w-]{10,}\.[\w-]{10,}\.[\w-]{6,})")

samples = [
    "sk-proj-abcdefghijklmnop",
    "sk-proj-abcdefghijklmnopq",
    "sk-abcdefghijklmnop",
    "sk_live_abcdefghijklmnop",
    "xsk-proj-abcdefghijklmnop",
    "api=sk-proj-abcdefghijklmnop",
    "foo sk-proj-abcdefghijklmnop bar",
]
for s in samples:
    m = pattern.search(s)
    print(f"{s!r} -> {bool(m)}", f"match={m.group(0)!r}" if m else "")
PY

Repository: HodeTech/Relavium

Length of output: 9897


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '== sk-related references in repo ==\n'
rg -n "sk-proj|sk-[A-Za-z0-9]{16,}|sk_[a-z]+_|OpenAI API key|project key|api key" packages/core/src/tools/bounding.ts packages/core/src/tools/bounding.test.ts || true

printf '\n== bounding.test.ts around standalone token tests ==\n'
sed -n '292,332p' packages/core/src/tools/bounding.test.ts | cat -n

printf '\n== bounding.ts surrounding comment ==\n'
sed -n '19,33p' packages/core/src/tools/bounding.ts | cat -n

Repository: HodeTech/Relavium

Length of output: 3506


🌐 Web query:

OpenAI API key format sk-proj hyphen documentation

💡 Result:

The sk-proj- prefix identifies project-scoped API keys in the OpenAI platform, which have been the default format for new keys since mid-2024 [1]. These keys are scoped to a specific project within an organization, allowing for better management of rate limits, billing, and resource access [1][2]. The current OpenAI API key structure utilizes specific prefixes to denote the key's scope and purpose [1]: - sk-proj-: Project-scoped keys used for calling models. These are the default for new keys and are associated with a specific project's configuration, including its own rate limits and billing [1]. - sk-svcacct-: Service-account keys that allow API access without being tied to an individual user's account [1]. - sk-admin-: Administrative keys used for the Admin API (e.g., managing projects, members, and billing) [1]. Note that these keys typically cannot be used to call models [1]. - sk- (Legacy): Older, user-scoped keys that were tied to a person's account [1]. While these remain functional, they are no longer issued by the OpenAI platform [1]. The key format consists of the prefix followed by a random base64url payload [1]. Unlike some other services, OpenAI API keys do not contain a checksum, meaning their validity can only be confirmed by making an API call [1]. For developers managing these keys programmatically, the Admin API provides endpoints to retrieve, list, and manage project-scoped keys and service accounts [3][4][5].

Citations:


Cover OpenAI sk-proj- keys in the standalone redactor packages/core/src/tools/bounding.ts:31 only matches sk- followed by alphanumerics, so current OpenAI project-scoped keys like sk-proj-… can slip through this path and reach logs. Expand the sk- branch to cover the hyphenated prefixes or a broader secret payload class.

🧰 Tools
🪛 GitHub Check: SonarCloud Code Analysis

[warning] 180-180: Simplify this regular expression to reduce its complexity from 57 to the 20 allowed.

See more on https://sonarcloud.io/project/issues?id=HodeTech_Relavium&issues=AZ8jY07WPJPcPnDlsi3p&open=AZ8jY07WPJPcPnDlsi3p&pullRequest=63

🤖 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 `@packages/core/src/tools/bounding.ts` around lines 168 - 180, The standalone
credential redaction regex in bounding.ts misses OpenAI project-scoped keys
because the current `sk-` branch in the `.replace(...)` pattern only matches a
fixed alphanumeric tail. Update that `sk-` alternative to also match hyphenated
OpenAI prefixes like `sk-proj-` (or otherwise broaden the secret payload class
without weakening other branches), and keep the change localized to the
standalone redactor so `bounding.test.ts` coverage remains valid.

Sources: Coding guidelines, Learnings

@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)
apps/cli/src/chat/persister.ts (1)

121-144: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Don’t persist aborted prompt text through the session title.

The new rollback skips message rows for stopReason: 'aborted', but beginUserTurn() already derived title; the unconditional record('active') flush can still persist the aborted user prompt in the session row. Derive the title only inside the completed-exchange branch.

Proposed fix
         if (
           event.error === undefined &&
           event.stopReason !== 'aborted' &&
           pendingUserText !== undefined
         ) {
+          title ??= deriveSessionTitle(pendingUserText);
           appendText('user', pendingUserText);
           if (assistantText.length > 0) appendText('assistant', assistantText);
           totalInputTokens += event.tokensUsed.input;
           totalOutputTokens += event.tokensUsed.output;
@@
     },
     beginUserTurn(text: string): void {
-      // Derive the title from the FIRST user message of a titleless session (a fresh chat, or a resumed one that
-      // never got one). A blank message yields undefined, so the next non-blank message becomes the title.
-      title ??= deriveSessionTitle(text);
       pendingUserText = text;
       assistantText = '';
     },

Also applies to: 181-185

🤖 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 `@apps/cli/src/chat/persister.ts` around lines 121 - 144, The active session
flush in persister logic is still carrying forward a title derived from an
aborted user prompt even though aborted exchanges no longer persist message
rows. Update the title handling in `persister.ts` so `beginUserTurn()`-derived
title data is only applied inside the same completed-exchange branch that
appends messages, and keep `record('active')` from writing aborted prompt text.
Use the existing `pendingUserText`, `appendText`, and `updateSession` flow to
ensure only completed turns influence the session row.
🧹 Nitpick comments (2)
docs/roadmap/current.md (1)

5-5: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Stale "Last updated" header vs. newer content.

Line 5 says Last updated: 2026-06-29, but the new prose it introduces references 2.5.C's "PR #62, 2026-06-30" and a 2.5.E state that's even more recent ("PR pending merge"). For a Status: Living tracker, readers rely on this stamp for currency.

📝 Suggested fix
-> Last updated: 2026-06-29
+> Last updated: 2026-06-30

Also applies to: 144-163

🤖 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/roadmap/current.md` at line 5, The “Last updated” stamp is stale
compared with the newer roadmap content, so update the header in current.md to
reflect the most recent referenced status changes. Make sure the edit stays
consistent with the Living tracker semantics by aligning the timestamp with the
newest entries in the 2.5.C and 2.5.E sections, so readers can trust the
freshness of the document.
apps/cli/src/engine/tool-host/fs.ts (1)

674-687: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

listOne's recursive walk doesn't realpath a symlinked entry before the sensitive-read check.

real at Line 676 is the lexical join(dir, dirent.name), not a realpath-resolved value, so a symlink whose own name doesn't contain a protected segment (e.g. workspace/backup~/.ssh/id_rsa) slips past isSensitiveReadPath and gets listed — unlike collectGlobFiles (Lines 877-879), which resolves each match via realpath() before the same check. Content is still protected (a subsequent readFile on that entry is correctly denied via the resolved-path check in readOne), but the directory-listing guarantee ("never list a nested .ssh/.relavium/credential entry") isn't fully honored for symlinked pointers.

🛡️ Proposed fix — resolve symlink entries before the sensitivity check
   await walk(realRoot, opts.recursive === true, signal, inScope, async (real, rel, dirent) => {
     if (entries.length >= cap) return false; // stop the walk once the listing cap is hit
-    if (isSensitiveReadPath(real)) return true; // never list a nested .ssh/.relavium/credential entry
+    const target = dirent.isSymbolicLink()
+      ? await realpath(real).catch(() => real)
+      : real;
+    if (isSensitiveReadPath(target)) return true; // never list a nested .ssh/.relavium/credential entry
     if (matcher !== undefined && !matcher(rel)) return 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 `@apps/cli/src/engine/tool-host/fs.ts` around lines 674 - 687, The recursive
listing in listOne is checking isSensitiveReadPath against the lexical path
instead of the resolved target, so symlinked entries can bypass the
sensitive-path filter. Update the walk callback to resolve each candidate entry
(using realpath or equivalent, as done in collectGlobFiles) before the
sensitive-read check, then use that resolved path for isSensitiveReadPath while
keeping the displayed rel name 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.

Inline comments:
In `@apps/cli/src/commands/agent-run.test.ts`:
- Around line 114-116: The test in agent-run.test.ts is using an unsafe inline
type assertion on completed to access error.code, which violates the strict
TypeScript rule. Replace that cast with a small local type guard near the
assertion in the test so completed is narrowed safely before checking the error
code, keeping the existing expectation in the agent-run flow type-safe without
using unsafe as.

In `@apps/cli/src/commands/chat.ts`:
- Around line 459-464: The `onAbort` handler in `chat.ts` is using an
expression-bodied arrow that returns the result of `built.session.abort()`,
which violates the strict void-style lint rule. Update the `onAbort` callback in
the object returned near `applyChatMode` to use a block body and call
`built.session.abort()` inside it so the handler is clearly `void`-returning.

In `@apps/cli/src/render/tui/chat-ink.tsx`:
- Around line 293-295: The `ChatApp` abort handling is missing the
pending-approval fallback when `props.onAbort` is absent. Update the `'abort'`
case in `ChatApp` to mirror `handleChatKey` in `home-controller.ts`: if an
approval is pending, reject it through the active approval flow instead of
calling `props.onAbort()` unconditionally. Keep the existing `onAbort` path for
drivers that provide it, and ensure the same fallback is applied anywhere the
abort key is handled consistently, including the duplicate location around the
other abort case.

---

Outside diff comments:
In `@apps/cli/src/chat/persister.ts`:
- Around line 121-144: The active session flush in persister logic is still
carrying forward a title derived from an aborted user prompt even though aborted
exchanges no longer persist message rows. Update the title handling in
`persister.ts` so `beginUserTurn()`-derived title data is only applied inside
the same completed-exchange branch that appends messages, and keep
`record('active')` from writing aborted prompt text. Use the existing
`pendingUserText`, `appendText`, and `updateSession` flow to ensure only
completed turns influence the session row.

---

Nitpick comments:
In `@apps/cli/src/engine/tool-host/fs.ts`:
- Around line 674-687: The recursive listing in listOne is checking
isSensitiveReadPath against the lexical path instead of the resolved target, so
symlinked entries can bypass the sensitive-path filter. Update the walk callback
to resolve each candidate entry (using realpath or equivalent, as done in
collectGlobFiles) before the sensitive-read check, then use that resolved path
for isSensitiveReadPath while keeping the displayed rel name unchanged.

In `@docs/roadmap/current.md`:
- Line 5: The “Last updated” stamp is stale compared with the newer roadmap
content, so update the header in current.md to reflect the most recent
referenced status changes. Make sure the edit stays consistent with the Living
tracker semantics by aligning the timestamp with the newest entries in the 2.5.C
and 2.5.E sections, so readers can trust the freshness of the document.
🪄 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: defaults

Review profile: CHILL

Plan: Pro

Run ID: ea507302-f309-4ccc-876e-177c8bef212d

📥 Commits

Reviewing files that changed from the base of the PR and between 3778d45 and e762f8b.

📒 Files selected for processing (71)
  • .claude/settings.json
  • CLAUDE.md
  • apps/cli/src/chat/chat-mode-host.test.ts
  • apps/cli/src/chat/chat-mode-host.ts
  • apps/cli/src/chat/chat-mode.test.ts
  • apps/cli/src/chat/chat-mode.ts
  • apps/cli/src/chat/persister.test.ts
  • apps/cli/src/chat/persister.ts
  • apps/cli/src/chat/session-host.test.ts
  • apps/cli/src/chat/session-host.ts
  • apps/cli/src/commands/agent-run.test.ts
  • apps/cli/src/commands/agent-run.ts
  • apps/cli/src/commands/chat.test.ts
  • apps/cli/src/commands/chat.ts
  • apps/cli/src/commands/repl-commands.test.ts
  • apps/cli/src/commands/repl-commands.ts
  • apps/cli/src/engine/build-engine.ts
  • apps/cli/src/engine/tool-host/assemble.test.ts
  • apps/cli/src/engine/tool-host/assemble.ts
  • apps/cli/src/engine/tool-host/egress.test.ts
  • apps/cli/src/engine/tool-host/egress.ts
  • apps/cli/src/engine/tool-host/errors.ts
  • apps/cli/src/engine/tool-host/fs.test.ts
  • apps/cli/src/engine/tool-host/fs.ts
  • apps/cli/src/engine/tool-host/os.test.ts
  • apps/cli/src/engine/tool-host/os.ts
  • apps/cli/src/home/drive-home.tsx
  • apps/cli/src/render/tui/chat-ink.tsx
  • apps/cli/src/render/tui/chat-input.test.ts
  • apps/cli/src/render/tui/chat-input.ts
  • apps/cli/src/render/tui/chat-projection.test.ts
  • apps/cli/src/render/tui/chat-projection.ts
  • apps/cli/src/render/tui/chat-store.test.ts
  • apps/cli/src/render/tui/chat-store.ts
  • apps/cli/src/render/tui/home-app.tsx
  • apps/cli/src/render/tui/home-controller.test.ts
  • apps/cli/src/render/tui/home-controller.ts
  • apps/cli/src/render/tui/session-view-model.test.ts
  • apps/cli/src/render/tui/session-view-model.ts
  • docs/decisions/0057-cli-chat-modes-and-per-tool-approval.md
  • docs/decisions/README.md
  • docs/reference/cli/chat-session.md
  • docs/reference/cli/commands.md
  • docs/reference/cli/home.md
  • docs/reference/contracts/agent-session-spec.md
  • docs/reference/contracts/sse-event-schema.md
  • docs/reference/shared-core/built-in-tools.md
  • docs/reference/shared-core/tool-registry.md
  • docs/roadmap/current.md
  • docs/roadmap/deferred-tasks.md
  • docs/roadmap/phases/phase-2.5-cli-consolidation.md
  • packages/core/src/engine/agent-session.test.ts
  • packages/core/src/engine/agent-session.ts
  • packages/core/src/engine/session-handle.test.ts
  • packages/core/src/engine/session-handle.ts
  • packages/core/src/index.ts
  • packages/core/src/tools/bounding.test.ts
  • packages/core/src/tools/bounding.ts
  • packages/core/src/tools/builtins.ts
  • packages/core/src/tools/errors.ts
  • packages/core/src/tools/registry.test.ts
  • packages/core/src/tools/registry.ts
  • packages/core/src/tools/types.ts
  • packages/db/src/index.ts
  • packages/db/src/media-egress.test.ts
  • packages/db/src/media-egress.ts
  • packages/db/src/safe-egress.test.ts
  • packages/db/src/safe-egress.ts
  • packages/shared/src/constants.ts
  • packages/shared/src/run-event.test.ts
  • packages/shared/src/run-event.ts

Comment thread apps/cli/src/commands/agent-run.test.ts Outdated
Comment thread apps/cli/src/commands/chat.ts
Comment thread apps/cli/src/render/tui/chat-ink.tsx
cemililik and others added 2 commits July 2, 2026 19:30
…ailed hint (A+B)

Two ADR-0057 chat-UX improvements from the `relavium chat` file-review error report
(a read of a repo-root path from an `apps/cli` launch cwd failed opaquely).

B — recover from a host tool EXECUTION failure on the interactive surface:
- packages/core/src/engine/agent-turn.ts: add an opt-in `AgentTurnLimits.recoverToolFailures`.
  When set, a host `execution_failed` (a file-not-found read, a transient egress error) is fed
  back to the model as a correctable `isError` tool result (it can adapt / explain) instead of
  ENDING the turn with `tool_failed`. It shares the `maxToolCorrections` budget, so a model
  looping on a failing tool is still bounded. `isModelCorrectable` → `isRecoverableToolError`.
- apps/cli/src/chat/session-host.ts: wire `recoverToolFailures: true` into the AgentSession deps
  (buildSessionRuntime — shared by chat / chat-resume / Home / one-shot `agent run`).
- SURFACE-SCOPED ON PURPOSE: the default (absent/false) — every WORKFLOW node via the run engine's
  AgentRunner — keeps fail-fast so an unattended run's node-retry / run-failure path still engages
  and the model can't silently paper over a genuine host failure.

A — a static, safe hint for a tool_failed turn:
- apps/cli/src/render/tui/chat-projection.ts: `formatTurnSummary` renders an actionable, host-authored
  STATIC hint for `tool_failed` ("a path may be outside this session's workspace, or the target was
  unavailable") instead of a bare `error: tool_failed`. It does NOT echo `errorMessage` (a tool_failed
  message MAY carry model/MCP context — the reason it stays out of SAFE_MESSAGE_CODES), so no leak; the
  F4 whitelist is unchanged.

Tests: agent-turn.test.ts pins BOTH branches (recover-when-set vs fail-fast-by-default); the L603
ToolExecutionError test is re-annotated as the workflow default. chat-projection.test.ts pins the
hint shows and the raw message is never echoed. Full turbo lint/typecheck/test green + format +
fence/engine-deps/bundle-closure + Leakwatch clean.

Refs: ADR-0057, ADR-0055
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… nitpick)

Verify-each-against-current-code pass; all 6 were still valid, fixed minimally.

- agent-run.test.ts: replace an unsafe inline `as { error?: { code?: string } }`
  cast with a local `isRecord` type guard (strict-TS).
- chat.ts: `onAbort` handler → block body so it never forwards `session.abort()`'s
  return value (void-style).
- chat-ink.tsx: the ChatApp 'abort' case now mirrors home-controller — when
  `onAbort` is absent (made OPTIONAL; the no-op default dropped), Esc rejects a
  PENDING approval directly instead of a no-op that would hang the decision. The
  prop is `(() => void) | undefined` so the createElement passthrough forwards an
  absent `ctx.onAbort` under exactOptionalPropertyTypes.
- persister.ts: derive the session title inside the COMPLETED-exchange branch
  (from `pendingUserText`), not in `beginUserTurn` — so an aborted/errored first
  turn (whose rows are rolled back) never labels the row with a phantom title.
- fs.ts: `listOne` recursive walk now checks `isSensitiveReadPath` on the entry's
  REALPATH (falling back to lexical on a broken link), mirroring collectGlobFiles
  — a symlink named innocuously but pointing into a .ssh/.relavium/credential
  store is no longer surfaced in a listing. Displayed `rel` name unchanged.
- docs/roadmap/current.md: bump the stale "Last updated" stamp 2026-06-29 →
  2026-06-30 (aligns with the newest body entry).

Tests: persister aborted-title pin; fs symlink-to-sensitive-store list-skip pin
(itPosix); agent-run guard is itself the fix. #3 relies on the tested
home-controller parity (ChatApp has no unit harness). Full turbo typecheck/test/
lint green + fence/engine-deps/bundle-closure + Leakwatch clean; changed files
prettier-clean.

Refs: ADR-0057
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@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 `@packages/core/src/engine/agent-turn.ts`:
- Around line 242-244: The recovery logic in isRecoverableToolError is too broad
because it treats every execution_failed error as retryable when
limits.recoverToolFailures is enabled. Tighten this so only explicitly opt-in,
idempotent/provider-safe tool errors can recover, and keep unknown_tool and
invalid_args as-is. Update the check in agent-turn.ts to inspect a more specific
error contract on ToolDispatchError (for example a dedicated flag or metadata on
the error emitted by the tool dispatch path) rather than relying on the generic
code alone, and apply the same change anywhere else this helper is referenced.
🪄 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: defaults

Review profile: CHILL

Plan: Pro

Run ID: b4ffa908-96d4-489c-b6ea-a16c5a5722b0

📥 Commits

Reviewing files that changed from the base of the PR and between e762f8b and 00d8d36.

📒 Files selected for processing (13)
  • apps/cli/src/chat/persister.test.ts
  • apps/cli/src/chat/persister.ts
  • apps/cli/src/chat/session-host.ts
  • apps/cli/src/commands/agent-run.test.ts
  • apps/cli/src/commands/chat.ts
  • apps/cli/src/engine/tool-host/fs.test.ts
  • apps/cli/src/engine/tool-host/fs.ts
  • apps/cli/src/render/tui/chat-ink.tsx
  • apps/cli/src/render/tui/chat-projection.test.ts
  • apps/cli/src/render/tui/chat-projection.ts
  • docs/roadmap/current.md
  • packages/core/src/engine/agent-turn.test.ts
  • packages/core/src/engine/agent-turn.ts
✅ Files skipped from review due to trivial changes (1)
  • docs/roadmap/current.md
🚧 Files skipped from review as they are similar to previous changes (6)
  • apps/cli/src/commands/agent-run.test.ts
  • apps/cli/src/render/tui/chat-projection.test.ts
  • apps/cli/src/render/tui/chat-projection.ts
  • apps/cli/src/chat/persister.test.ts
  • apps/cli/src/render/tui/chat-ink.tsx
  • apps/cli/src/engine/tool-host/fs.ts

Comment thread packages/core/src/engine/agent-turn.ts Outdated
…or flag (ADR-0057)

Address a review finding on B (520eb17): isRecoverableToolError treated EVERY
`execution_failed` as recoverable when recoverToolFailures is set — too broad. A
non-idempotent side-effecting failure (a half-run `run_command`, an `http_request`
POST that may have reached the server) must NOT be fed back for a within-turn
retry. Tighten via a per-error contract, not the generic code:

- errors.ts: ToolExecutionError gains `readonly recoverable` (default false) — the
  STRICTER "safe to re-attempt this call in-turn" vs `retryable` (a fresh node may
  re-run the whole node).
- registry.ts: at the host-throw wrap (the dispatch path the finding names), stamp
  `recoverable = governedAction(def, target) === undefined` — true ONLY for an
  idempotent read (read_file / list_directory / git_status / invoke_agent /
  read_media); a governed fs_write / process / egress / os failure is false.
- agent-turn.ts: isRecoverableToolError now requires `err instanceof
  ToolExecutionError && err.recoverable` (in addition to the recoverToolFailures
  flag). unknown_tool / invalid_args unchanged; tool_denied / tool_unavailable /
  cancelled stay fatal. This is the only call site.

Net: the motivating case (a file-not-found READ) still recovers on the chat
surface; a non-idempotent tool failure ends the turn (fail-fast + the A hint), so
the model never re-attempts a side effect. Tests: agent-turn pins recover-when-
recoverable vs NOT-recover-when-not (both with the flag set); registry pins the
stamp (read_file → recoverable true, write_file → false). Full turbo lint/
typecheck/test green + format + fence/engine-deps/bundle-closure + Leakwatch clean.

Refs: ADR-0057, ADR-0041
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@sonarqubecloud

sonarqubecloud Bot commented Jul 2, 2026

Copy link
Copy Markdown

@cemililik
cemililik merged commit e0f278d into main Jul 2, 2026
9 checks passed
cemililik added a commit that referenced this pull request Jul 3, 2026
…re-recovery contract

PR #63 merged 2026-07-03 (merge e0f278d) — 2.5.E (reseat-less chat modes + per-tool
approval + mid-turn abort) plus the same-PR chat-UX follow-up (A/B: recoverToolFailures
+ the recoverable stamp + the tool_failed hint) are all in main. Update the canonical
homes:

- roadmap/current.md + phases/phase-2.5-cli-consolidation.md: 2.5.E 🟡→✅ Done
  (PR #63, 2026-07-03); the CLI-Consolidation spine (2.5.A/B/C/E) is complete; next is
  the 2.5.D/F/G experience arm. current.md "Last updated" → 2026-07-03.
- CLAUDE.md: same status flip + a one-line note on the tool-failure-recovery follow-up.
- reference/shared-core/tool-registry.md: document ToolExecutionError.recoverable in the
  error taxonomy (stricter than retryable; gates the chat within-turn recovery, idempotent
  reads only; workflows ignore it).
- reference/cli/chat-session.md: document the chat recover-from-idempotent-read behavior
  + the static secret-free tool_failed hint.
- roadmap/deferred-tasks.md: the two 2.5.E resolved items now say "PR #63, merged"; add a
  tracked follow-up (conversationally recover from a SCOPE denial — needs a HostDeniedError
  sub-class split so a scope denial is distinguishable from an approval/protected denial).

Docs-only; prettier clean.

Refs: ADR-0057
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.

1 participant