feat(cli): 2.5.E — reseat-less chat modes + per-tool approval + mid-turn abort (ADR-0057)#63
Conversation
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>
There was a problem hiding this comment.
Sorry @cemililik, your pull request is larger than the review limit of 150000 diff characters
|
Note Reviews pausedIt 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 Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (5)
🚧 Files skipped from review as they are similar to previous changes (4)
📝 WalkthroughWalkthroughAdds 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 ChangesADR-0057 Chat Modes and Per-Tool Approval
Shared SSRF-safe Egress Extraction
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(...)
sequenceDiagram
participant Caller
participant SafeEgress
participant HTTPS
Caller->>SafeEgress: fetchMediaBytes()
SafeEgress->>HTTPS: connectValidated + pinned lookup
SafeEgress-->>Caller: decoded body / status / headers
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
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.
…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>
There was a problem hiding this comment.
Actionable comments posted: 4
🧹 Nitpick comments (3)
packages/core/src/engine/agent-session.ts (1)
334-433: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winReduce
sendMessagecomplexity 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 winAvoid the unsafe cast in the redaction test.
redactSecretShapedValuereturnsunknown; 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: avoidany, avoid unsafeas, 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 winDrop the avoidable
ascast 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 unsafeas, 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
📒 Files selected for processing (33)
.claude/settings.jsonapps/cli/src/chat/chat-mode.test.tsapps/cli/src/chat/chat-mode.tsapps/cli/src/chat/session-host.test.tsapps/cli/src/commands/chat.test.tsapps/cli/src/commands/chat.tsapps/cli/src/engine/tool-host/egress.test.tsapps/cli/src/engine/tool-host/egress.tsapps/cli/src/engine/tool-host/fs.test.tsapps/cli/src/engine/tool-host/fs.tsapps/cli/src/render/tui/chat-input.tsapps/cli/src/render/tui/chat-projection.test.tsapps/cli/src/render/tui/chat-projection.tsapps/cli/src/render/tui/chat-store.test.tsapps/cli/src/render/tui/home-controller.test.tsapps/cli/src/render/tui/home-controller.tsapps/cli/src/render/tui/session-view-model.tsdocs/reference/cli/home.mddocs/reference/contracts/sse-event-schema.mddocs/reference/shared-core/tool-registry.mddocs/roadmap/deferred-tasks.mdpackages/core/src/engine/agent-session.test.tspackages/core/src/engine/agent-session.tspackages/core/src/index.tspackages/core/src/tools/bounding.test.tspackages/core/src/tools/bounding.tspackages/core/src/tools/registry.test.tspackages/core/src/tools/registry.tspackages/core/src/tools/types.tspackages/db/src/safe-egress.test.tspackages/db/src/safe-egress.tspackages/shared/src/run-event.test.tspackages/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
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>
|
@coderabbitai review all please |
|
✅ Action performedFull review finished. |
There was a problem hiding this comment.
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
📒 Files selected for processing (10)
.claude/settings.jsonapps/cli/src/chat/chat-mode.tsapps/cli/src/commands/chat.tsapps/cli/src/engine/tool-host/fs.test.tsapps/cli/src/engine/tool-host/fs.tspackages/core/src/engine/agent-session.tspackages/core/src/tools/bounding.test.tspackages/core/src/tools/bounding.tspackages/shared/src/run-event.test.tspackages/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
| // 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, |
There was a problem hiding this comment.
🔒 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 "")
PYRepository: 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 "")
PYRepository: 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 "")
PYRepository: 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 -nRepository: 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:
- 1: https://vibekit.bot/openai-api-key-format
- 2: https://airbyte.com/agentic-data/openai-api-key
- 3: https://developers.openai.com/api/reference/resources/admin/subresources/organization/subresources/projects/subresources/api_keys/methods/retrieve
- 4: https://developers.openai.com/api/reference/resources/admin/subresources/organization/subresources/projects/subresources/service_accounts/methods/create
- 5: https://developers.openai.com/api/reference/python/resources/admin/subresources/organization/subresources/projects/subresources/api_keys/methods/list
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.
🤖 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
There was a problem hiding this comment.
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 winDon’t persist aborted prompt text through the session title.
The new rollback skips message rows for
stopReason: 'aborted', butbeginUserTurn()already derivedtitle; the unconditionalrecord('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 winStale "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 aStatus: Livingtracker, readers rely on this stamp for currency.📝 Suggested fix
-> Last updated: 2026-06-29 +> Last updated: 2026-06-30Also 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.
realat Line 676 is the lexicaljoin(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 pastisSensitiveReadPathand gets listed — unlikecollectGlobFiles(Lines 877-879), which resolves each match viarealpath()before the same check. Content is still protected (a subsequentreadFileon that entry is correctly denied via the resolved-path check inreadOne), 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
📒 Files selected for processing (71)
.claude/settings.jsonCLAUDE.mdapps/cli/src/chat/chat-mode-host.test.tsapps/cli/src/chat/chat-mode-host.tsapps/cli/src/chat/chat-mode.test.tsapps/cli/src/chat/chat-mode.tsapps/cli/src/chat/persister.test.tsapps/cli/src/chat/persister.tsapps/cli/src/chat/session-host.test.tsapps/cli/src/chat/session-host.tsapps/cli/src/commands/agent-run.test.tsapps/cli/src/commands/agent-run.tsapps/cli/src/commands/chat.test.tsapps/cli/src/commands/chat.tsapps/cli/src/commands/repl-commands.test.tsapps/cli/src/commands/repl-commands.tsapps/cli/src/engine/build-engine.tsapps/cli/src/engine/tool-host/assemble.test.tsapps/cli/src/engine/tool-host/assemble.tsapps/cli/src/engine/tool-host/egress.test.tsapps/cli/src/engine/tool-host/egress.tsapps/cli/src/engine/tool-host/errors.tsapps/cli/src/engine/tool-host/fs.test.tsapps/cli/src/engine/tool-host/fs.tsapps/cli/src/engine/tool-host/os.test.tsapps/cli/src/engine/tool-host/os.tsapps/cli/src/home/drive-home.tsxapps/cli/src/render/tui/chat-ink.tsxapps/cli/src/render/tui/chat-input.test.tsapps/cli/src/render/tui/chat-input.tsapps/cli/src/render/tui/chat-projection.test.tsapps/cli/src/render/tui/chat-projection.tsapps/cli/src/render/tui/chat-store.test.tsapps/cli/src/render/tui/chat-store.tsapps/cli/src/render/tui/home-app.tsxapps/cli/src/render/tui/home-controller.test.tsapps/cli/src/render/tui/home-controller.tsapps/cli/src/render/tui/session-view-model.test.tsapps/cli/src/render/tui/session-view-model.tsdocs/decisions/0057-cli-chat-modes-and-per-tool-approval.mddocs/decisions/README.mddocs/reference/cli/chat-session.mddocs/reference/cli/commands.mddocs/reference/cli/home.mddocs/reference/contracts/agent-session-spec.mddocs/reference/contracts/sse-event-schema.mddocs/reference/shared-core/built-in-tools.mddocs/reference/shared-core/tool-registry.mddocs/roadmap/current.mddocs/roadmap/deferred-tasks.mddocs/roadmap/phases/phase-2.5-cli-consolidation.mdpackages/core/src/engine/agent-session.test.tspackages/core/src/engine/agent-session.tspackages/core/src/engine/session-handle.test.tspackages/core/src/engine/session-handle.tspackages/core/src/index.tspackages/core/src/tools/bounding.test.tspackages/core/src/tools/bounding.tspackages/core/src/tools/builtins.tspackages/core/src/tools/errors.tspackages/core/src/tools/registry.test.tspackages/core/src/tools/registry.tspackages/core/src/tools/types.tspackages/db/src/index.tspackages/db/src/media-egress.test.tspackages/db/src/media-egress.tspackages/db/src/safe-egress.test.tspackages/db/src/safe-egress.tspackages/shared/src/constants.tspackages/shared/src/run-event.test.tspackages/shared/src/run-event.ts
…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>
There was a problem hiding this comment.
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
📒 Files selected for processing (13)
apps/cli/src/chat/persister.test.tsapps/cli/src/chat/persister.tsapps/cli/src/chat/session-host.tsapps/cli/src/commands/agent-run.test.tsapps/cli/src/commands/chat.tsapps/cli/src/engine/tool-host/fs.test.tsapps/cli/src/engine/tool-host/fs.tsapps/cli/src/render/tui/chat-ink.tsxapps/cli/src/render/tui/chat-projection.test.tsapps/cli/src/render/tui/chat-projection.tsdocs/roadmap/current.mdpackages/core/src/engine/agent-turn.test.tspackages/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
…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>
|
…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>



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
ToolHostis 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+Tabcyclesask → plan → accept-edits → auto;/mode <name>jumps; the active mode is always in the footer. Default is read-onlyask.ask(default) /planaccept-edits[y]once ·[a]always (session) ·[n]no ·[esc]abortautoPer-tool approval (fail-closed). A registry
confirmActionhook runs betweenenforcePolicyand the side-effect; under the interactive regime a governed-class dispatch REQUIRES a decision and an absent hook ⇒ denied (a wiring bug can never letaskwrite). Governed classes:fs_write,egress(any kind), a model-commandprocess, andos(read_clipboardis an un-jailed exfiltration sink — made governed after the security review). Composes with, not replaces, the AcceptedActionGuardseam (ADR-0041).Mid-turn abort (EA7).
Escaborts the in-flight turn but keeps the session alive (onesession: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
fstier + 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-hardenedegressarm sharing ONE connect-by-validated-IP mechanism with media egress (+ a model-Host-header strip against virtual-host confusion); theosarm (read_clipboard/notifyover fixed binaries, model data as argv/env DATA only — no injection).Wired LIVE on every entry point —
relavium chat(fresh +chat-resume), the one-shotagent 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
confirmActionhook, EA4 pause/resume + abort state, EA5agent:approval_requestedevent, EA7 mid-turn abort;osadded toTOOL_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:
Hostheader bypassing the IP-pin (virtual-host confusion).gitconfig::$DATA) + Win32 trailing-dot/space bypassing the protected-path flooragent run(one-shot) running governed actions ungated (the flipped host without a regime)osarm ungated in read-onlyask(clipboard exfiltration) → made a governed classThe 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 lint— 23/23 green (shared 438 · core 907 · cli 1145 + others).format:check+lint:bundle-closureclean. 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(theosaction class),built-in-tools.md(the full-capability chat posture), the roadmap +current.md+CLAUDE.md(2.5.E status), anddeferred-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, andextraRootstracked as follow-ups).Conformance checklist
any/ unsafeas); typed discriminated errors;AbortSignalthreadedpackages/corestays platform-free (the mode enum + host arms live inapps/cli; the engine is mode-agnostic)@relavium/llmseam; no new runtime dependencyRefs: ADR-0057🤖 Generated with Claude Code
Summary by CodeRabbit
/modeand Shift+Tab cycling, plus mode-aware UI/footer and/modeargument validation.