From 3102ef044d4dc375517c2cd39e03ef57d4ad23a8 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Tue, 30 Jun 2026 02:13:00 +0300 Subject: [PATCH 01/45] =?UTF-8?q?docs(roadmap):=20mark=202.5.C=20=E2=9C=85?= =?UTF-8?q?=20Done=20=E2=80=94=20PR=20#62=20merged;=20next=20pickup=202.5.?= =?UTF-8?q?E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CLAUDE.md | 8 +++++++- docs/roadmap/current.md | 14 ++++++++++++-- docs/roadmap/phases/phase-2.5-cli-consolidation.md | 13 +++++++------ 3 files changed, 26 insertions(+), 9 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index bd99a614..5a33d09f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -79,7 +79,13 @@ run paths — landed **PR #60 (2026-06-28)**, behind [ADR-0055](docs/decisions/0 **2.5.B** (the bare-invocation interactive Home — the TTY-gated bare `relavium` → a read-only management strip over `history.db` that graduates into in-process chat, one ink tree + one SIGINT/SIGTERM lifecycle + bracketed paste) landed **PR #61 (2026-06-29)**, behind [ADR-0054](docs/decisions/0054-cli-bare-invocation-interactive-home.md); -**the next pickup is 2.5.C** (the in-app slash registry / command palette). For live status, per-PR history, milestone dates, and open +and **2.5.C** (the in-app command system — a curated **two-registry** model: the shell `COMMAND_MANIFEST` +(`commander` + `--help --json` + the `executeCommand` dispatch) vs the in-REPL `REPL_COMMANDS` (a filterable `/` +palette + slash commands in **both** chat and the bare Home, no command in both); `/help`, the `notice` channel, +`/workflows`, `/cost`, and `/doctor` — fast tier plus a `--deep` tier with a **redacted** provider-key probe + a +**read-only** MCP-status report that never connects/spawns (a security-review decision); the `name + args` slash +dispatch + a context-aware footer hint-bar) landed **PR #62 (2026-06-30)**, behind [ADR-0056](docs/decisions/0056-cli-in-app-slash-command-system-and-manifest.md); +**the next pickup is 2.5.E** (chat modes + per-tool approval, ADR-0057). For live status, per-PR history, milestone dates, and open obligations, see the canonical home [docs/roadmap/current.md](docs/roadmap/current.md); [README.md](README.md) is the public overview. diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md index b3a29ffc..474eaa87 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -141,8 +141,18 @@ above a live prompt that graduates into an in-process chat; rendered as a single with one SIGINT/SIGTERM lifecycle (clean Home exit `0`; an external signal → the conventional `128+signo`, 130/143; the in-Home chat's exit-`4` consumed, never leaked) and bracketed paste (DECSET 2004), all while every non-interactive path keeps the byte-for-byte help + exit-`0` meta-op ([ADR-0049](../decisions/0049-cli-machine-output-contract.md)). -Canonically homed in [home.md](../reference/cli/home.md). **Next pickup: 2.5.C** (the in-app slash registry / -command palette). See the [Phase 2.5 workstreams](phases/phase-2.5-cli-consolidation.md). +Canonically homed in [home.md](../reference/cli/home.md). **2.5.C** (the in-app command system) is ✅ **Done +(PR #62, 2026-06-30)**, behind [ADR-0056](../decisions/0056-cli-in-app-slash-command-system-and-manifest.md) +(Accepted): a curated **two-registry** model (the shell `COMMAND_MANIFEST` driving `commander` + `--help --json` ++ the `executeCommand` dispatch, vs the in-REPL `REPL_COMMANDS` driving a filterable `/` palette + slash commands +in **both** the chat and the bare Home — no command in both); `/help`, the `notice` output channel, `/workflows`, +`/cost`, and `/doctor` (fast tier: keychain/config/wired-tools; `--deep`: a **redacted** provider-key probe + a +**read-only** MCP-status report — a security-review decision: it reports the live session's already-connected +servers, never a fresh connect/spawn); plus the `name + args` slash dispatch and a context-aware footer hint-bar +surfacing `/ for commands`. Canonically homed in [commands.md](../reference/cli/commands.md) + +[chat-session.md](../reference/cli/chat-session.md). **Next pickup: 2.5.E** (chat modes + per-tool approval, +[ADR-0057](../decisions/0057-cli-chat-modes-and-per-tool-approval.md)). See the +[Phase 2.5 workstreams](phases/phase-2.5-cli-consolidation.md). Carry-over hardening is tracked in [deferred-tasks.md](deferred-tasks.md) — Phase 2 picks items up as it first touches each file. Notable inheritances: 1.AH's host-wiring half diff --git a/docs/roadmap/phases/phase-2.5-cli-consolidation.md b/docs/roadmap/phases/phase-2.5-cli-consolidation.md index c9763760..6c74b45e 100644 --- a/docs/roadmap/phases/phase-2.5-cli-consolidation.md +++ b/docs/roadmap/phases/phase-2.5-cli-consolidation.md @@ -3,8 +3,8 @@ > Status: In progress. **2.5.A** (shared tool-environment factory + capability-gap root-cause fix) is > ✅ **Done (PR #60, 2026-06-28)**, behind [ADR-0055](../../decisions/0055-cli-host-capability-seam-tool-environment-factory.md) > — **milestone M2.5-1 (secure base) reached**. Spine continues: **2.5.B** (Home) ✅ → **2.5.C** (slash registry -> + palette + `/help`/`/doctor`/`/workflows`/`/cost` + footer hint-bar) **implemented + fully reviewed, PR pending** -> → 2.5.E (modes + per-tool approval, next). Experience arm (off the spine, depends on B/C): 2.5.D / F / G. Additive +> + palette + `/help`/`/doctor`/`/workflows`/`/cost` + footer hint-bar) ✅ **Done (PR #62, 2026-06-30)** +> → 2.5.E (modes + per-tool approval, **next**). Experience arm (off the spine, depends on B/C): 2.5.D / F / G. Additive > lanes (no dependency chain): 2.5.H / I / J. - **Related**: [../README.md](../README.md), [phase-2-cli.md](phase-2-cli.md), [phase-2.6-conversational-authoring.md](phase-2.6-conversational-authoring.md), [phase-3-desktop.md](phase-3-desktop.md), [../../reference/cli/commands.md](../../reference/cli/commands.md), [../../reference/cli/chat-session.md](../../reference/cli/chat-session.md), [../../reference/cli/regression-harness.md](../../reference/cli/regression-harness.md), [../../decisions/README.md](../../decisions/README.md) (ADR-0054–0057) @@ -188,14 +188,15 @@ ADR: bare-invocation interactive-entry contract.** ### 2.5.C — In-app slash registry, command palette, `/help`, `/doctor`, `/workflows` -> **Status:** 🚧 **Implemented + fully reviewed; PR pending — not yet ✅ Done (awaits merge).** +> **Status:** ✅ **Done (PR #62, 2026-06-30).** > [ADR-0056](../../decisions/0056-cli-in-app-slash-command-system-and-manifest.md) is **Accepted (2026-06-29)**; > the command-manifest + in-REPL-slash contracts are canonically homed in > [commands.md](../../reference/cli/commands.md) and [chat-session.md](../../reference/cli/chat-session.md). > Delivered across S1–S6, each behind the per-step **opus + sonnet** review loop, with a **dedicated adversarial -> security pass** on the security-sensitive S5 (`/doctor --deep`). **Decided design:** the `/` palette is the -> discovery entry point in **both Home and chat** — a curated **two-registry** model (the shell `COMMAND_MANIFEST` -> vs the in-REPL `REPL_COMMANDS`; no command in both, so no cross-surface divergence). +> security pass** on the security-sensitive S5 (`/doctor --deep`), then a comprehensive multi-dimensional +> first-class review of the whole PR. **Decided design:** the `/` palette is the discovery entry point in **both +> Home and chat** — a curated **two-registry** model (the shell `COMMAND_MANIFEST` vs the in-REPL `REPL_COMMANDS`; +> no command in both, so no cross-surface divergence). > > **Per-step ledger** (branch `development`): > - **S1** the command manifest (drift-guarded against the commander tree) · **S2** the shared `executeCommand` From 870ccf643ba4a1f6b788266f82cc40c6c4ee9e03 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Tue, 30 Jun 2026 03:24:56 +0300 Subject: [PATCH 02/45] =?UTF-8?q?docs(adr):=202.5.E=20=E2=80=94=20revise?= =?UTF-8?q?=20ADR-0057=20(confirmAction=E2=86=94ActionGuard,=20egress/os?= =?UTF-8?q?=20in=20scope,=20auto=20on=20Shift+Tab)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- ...57-cli-chat-modes-and-per-tool-approval.md | 102 +++++++++++++++--- .../phases/phase-2.5-cli-consolidation.md | 17 ++- 2 files changed, 104 insertions(+), 15 deletions(-) diff --git a/docs/decisions/0057-cli-chat-modes-and-per-tool-approval.md b/docs/decisions/0057-cli-chat-modes-and-per-tool-approval.md index ab2cdc28..8bfb8017 100644 --- a/docs/decisions/0057-cli-chat-modes-and-per-tool-approval.md +++ b/docs/decisions/0057-cli-chat-modes-and-per-tool-approval.md @@ -2,9 +2,9 @@ - **Status**: Proposed - **Date**: 2026-06-28 -- **Related**: [ADR-0024](0024-agent-first-entry-point-agentsession.md), [ADR-0029](0029-tool-policy-hardening.md), [ADR-0028](0028-workflow-resource-governance.md), [ADR-0037](0037-engine-tool-execution-boundary.md), [ADR-0055](0055-cli-host-capability-seam-tool-environment-factory.md), [ADR-0059](0059-cli-mid-session-model-reseat.md), [phase-2.5-cli-consolidation.md](../roadmap/phases/phase-2.5-cli-consolidation.md) (2.5.E), [architectural-principles.md](../standards/architectural-principles.md) +- **Related**: [ADR-0024](0024-agent-first-entry-point-agentsession.md), [ADR-0029](0029-tool-policy-hardening.md), [ADR-0028](0028-workflow-resource-governance.md), [ADR-0037](0037-engine-tool-execution-boundary.md), [ADR-0041](0041-external-action-governance-seam.md), [ADR-0043](0043-media-egress-failover-rematerialization-ssrf.md), [ADR-0053](0053-mcp-network-transport-egress-security.md), [ADR-0055](0055-cli-host-capability-seam-tool-environment-factory.md), [ADR-0059](0059-cli-mid-session-model-reseat.md), [phase-2.5-cli-consolidation.md](../roadmap/phases/phase-2.5-cli-consolidation.md) (2.5.E), [tool-registry.md](../reference/shared-core/tool-registry.md), [action-guard-seam.md](../reference/shared-core/action-guard-seam.md), [architectural-principles.md](../standards/architectural-principles.md) -> **Draft.** Proposed alongside the Phase 2.5 plan; to be reviewed and finalized (→ Accepted) when workstream 2.5.E begins. **Security review is mandatory before Accept.** +> **Draft.** Proposed alongside the Phase 2.5 plan; to be reviewed and finalized (→ Accepted) when workstream 2.5.E begins. **Security review is mandatory before Accept** — and now covers a larger surface than the original draft: the write-capable `fs` tier + protected paths, the SSRF-hardened host `egress` arm, the `os` arm (the 2.5.A deferral closed here), and the maintainer's decision to put `auto` on the `Shift+Tab` cycle (below). Per-tool approval is built as a separate, lighter `confirmAction` primitive that **composes with** the Accepted `ActionGuard` seam ([ADR-0041](0041-external-action-governance-seam.md)) rather than reusing it — see [§Relationship to ADR-0041](#relationship-to-adr-0041-actionguard). ## Context @@ -19,6 +19,22 @@ Second, there is **no** per-tool approval infrastructure: the workflow human-gat is emitted *after* dispatch, and `ToolPolicy` carries no approval concept. Getting this wrong either loses context on every `Shift+Tab` or ships an unsafe "approve everything" escape hatch. +A third fact bounds the design. Relavium **already** has an Accepted side-effect-governance seam — the +optional, host-injected `ActionGuard` ([ADR-0041](0041-external-action-governance-seam.md) / +[action-guard-seam.md](../reference/shared-core/action-guard-seam.md)), which inserts a two-phase +`decide → commit → compensate` protocol between the guardrail check and the host side-effect for an +*external, automated* governor (off-by-default, Phase-2 enterprise). It is the **wrong tool for +interactive end-user consent**: its `require-approval` verdict is **run-only** (a session has no durable +gate — [ADR-0041](0041-external-action-governance-seam.md) §Entry-point scope, which itself anticipates the +host "surfacing an interactive approval out-of-band *before* `commit`"), and its idempotency / +compensation / tamper-evident-audit / IFC machinery is overkill for a "may I write this file?" terminal +prompt. So per-tool approval is a **separate, lighter** primitive that **composes with** — never replaces +— `ActionGuard` ([§Relationship to ADR-0041](#relationship-to-adr-0041-actionguard)). Finally, the 2.5.A +host wiring deferred the chat `egress` / `os` arms and the write-capable `fs` tier to this workstream +([ADR-0055](0055-cli-host-capability-seam-tool-environment-factory.md) §Phased wiring); they land here, +behind the approval floor, with the host-side SSRF egress mechanism the +[deferred-tasks](../roadmap/deferred-tasks.md) ledger tracks (`EgressCapability.fetch` enforcement). + ## Decision **We will make chat modes a policy layer on the same session instance — no reseat — and build per-tool @@ -33,11 +49,18 @@ approval as a new, bounded engine vertical.** `ask` mode anyway, the approval policy denies it by mode). The existing `enforcePolicy` gate is mode-agnostic (it only enforces `requiresGateApproval` / `allowedCommands`) and is the floor beneath both. The mode policy is read from the same per-turn snapshot as the advertise-filter, so a mid-turn - `/mode` change applies on the next turn. `Shift+Tab` cycles `ask → plan → accept-edits`; `auto` is - explicit-only (`/mode auto`); there is **no one-key bypass valve** - ([ADR-0029](0029-tool-policy-hardening.md)). `.git/`, `.relavium/`, and shell rc files are never - auto-written in any mode. Switching the **model** mid-session does require a reseat — the separate - concern of [ADR-0059](0059-cli-mid-session-model-reseat.md). + `/mode` change applies on the next turn. `Shift+Tab` cycles **`ask → plan → accept-edits → auto`** + (auto-approve is now a mainstream expectation, so `auto` is reachable in the cycle, not hidden behind a + typed command), and `/mode ` jumps directly. **The default is read-only `ask`**, the active mode is + **always shown in the footer**, and reaching `auto` is a deliberate, visible, reversible cycle step — so + there is **no hidden "bypass all permissions" valve**: even `auto` cannot write a **protected path** + (`.git/`, `.relavium/`, shell rc — in `auto` a protected-path write **falls back to an explicit prompt**, + never auto-approved) or escape the `fs` jail / sandbox tier, and the fail-closed `confirmAction` floor + (below) applies under **every** mode ([ADR-0029](0029-tool-policy-hardening.md) secure-by-default). That + surviving guarantee — a read-only default plus no mode that escapes protected-paths + the jail — is the + structural differentiator from the surveyed terminal CLIs, preserved even with `auto` on the cycle. + Switching the **model** mid-session does require a reseat — the separate concern of + [ADR-0059](0059-cli-mid-session-model-reseat.md). - **Per-tool approval (new vertical), fail-closed.** A registry pre-dispatch `confirmAction` hook (host-injected — the same dependency-inversion pattern as `ToolHost`, so it does not violate [ADR-0037](0037-engine-tool-execution-boundary.md)'s tool-execution boundary: the engine defines the hook @@ -49,7 +72,15 @@ approval as a new, bounded engine vertical.** process / egress arm ([ADR-0055](0055-cli-host-capability-seam-tool-environment-factory.md)), a write-/exec-/egress-class tool dispatch **requires** a `confirmAction` decision — if no hook is supplied, the dispatch is **denied**, never allowed. The advertise-filter is best-effort; this floor is the - guarantee, so a wiring bug cannot let `ask` mode write. Plus: a session-stream `agent:approval_requested` + guarantee, so a wiring bug cannot let `ask` mode write. The registry classifies a dispatch's side-effect + kind from its `ToolPolicyClass` ([tool-registry.md](../reference/shared-core/tool-registry.md)): + `spawnsProcess` and `egress` already discriminate, but `fsScoped` is `true` for **both** `read_file` and + `write_file`, so it cannot tell a write from a read. This ADR therefore lands the additive + **`fsWrite?: boolean`** flag on `ToolPolicyClass` — the **same** discriminator + [ADR-0041](0041-external-action-governance-seam.md) already proposed for its `fs-write` `ActionClass` + (canonical home [tool-registry.md](../reference/shared-core/tool-registry.md)); it is landed here and + **credited to both ADRs**, so the two stay compatible. A read-only tool (`read_file` / `list_directory` / + `git_status`) is never write-class and never requires `confirmAction`. Plus: a session-stream `agent:approval_requested` event (kept in the existing `agent:*` namespace, alongside `agent:tool_call`); an `AgentSession` pause/resume state (which today does not exist) — and an `Esc` **mid-turn abort** that aborts the in-flight turn and keeps the session alive by emitting **one** `session:turn_completed` (with an abort @@ -63,11 +94,45 @@ approval as a new, bounded engine vertical.** invocation (tool + args), **always** = this tool id for the remainder of this session instance). The `gateApproved` flag is **not** reused (it is a one-way static deny). The session-level budget pause/resume deferred from Phase 2 ([ADR-0028](0028-workflow-resource-governance.md)) rides the same machine. +- **Host arms (the 2.5.A deferral, closed here).** The write-capable `fs` tier, the `egress` arm, and the + `os` arm are wired in the CLI host this workstream — no deferral left behind. `egress` reuses the + **existing SSRF-validated** connect-by-validated-IP mechanism (DNS-resolve → validate **every** resolved + IP → connect pinned to the validated IP → re-validate on **every** redirect hop → size-bounded stream) + already shipped for media egress ([ADR-0043](0043-media-egress-failover-rematerialization-ssrf.md), + `packages/db/src/media-egress.ts`) over the one shared `isPrivateOrLocalHost` range-block + (`@relavium/shared`) — **extracted so tool egress and media egress share one implementation, never a + second SSRF parser** ([ADR-0029](0029-tool-policy-hardening.md)(d) one-primitive rule; + [ADR-0053](0053-mcp-network-transport-egress-security.md) is the sibling MCP floor). `egress` is a + **governed class**, so it always rides the fail-closed `confirmAction` floor; `web_search` resolves its + provider key as an opaque host-side `credentialRef`, never exposing it to the engine. A **dedicated + adversarial security review** covers the fs-write jail + protected paths, the egress mechanism, and the + `os` arm. + +### Relationship to ADR-0041 (`ActionGuard`) + +`confirmAction` and `ActionGuard` are **complementary layers at the same dispatch boundary, not +alternatives.** `ActionGuard` ([ADR-0041](0041-external-action-governance-seam.md)) is the *automated, +organizational* policy-decision point — an external governor that `decide`s allow / block / transform and +`commit`s with idempotency + compensation + tamper-evident audit; optional, **off-by-default**, Phase-2 +enterprise. `confirmAction` is the *interactive, end-user* consent gate — the human at the terminal answers +`[a]/[r]/[c]` — and is precisely the "out-of-band interactive approval the host surfaces before `commit`" +that [ADR-0041](0041-external-action-governance-seam.md) §Entry-point scope names for the **session** entry +point (where `ActionGuard`'s `require-approval` verdict is unavailable). When **both** are present they +**compose** in the [tool-registry.md](../reference/shared-core/tool-registry.md) dispatch lifecycle: the +[ADR-0029](0029-tool-policy-hardening.md) guardrails (steps 1–4) run first; then `ActionGuard.decide` (the +org's policy, if injected) may further restrict; then `confirmAction` (the user's consent) gates the +side-effect; then the host call (wrapped by `ActionGuard.commit` if injected). Each can only **restrict**, +never re-grant — secure-by-default holds end to end. The additive `fsWrite?` discriminator this ADR lands +is shared with, and satisfies, ADR-0041's `fs-write` `ActionClass` need. Considered reseating per mode change (rejected: silent tool-context loss + instance churn); binding a read-only host and reseating to add write capability (rejected: same loss); reusing the node-level workflow gate for per-tool approval (rejected: wrong granularity — it pauses a DAG vertex, not a tool -call); and a single bypass flag like the four surveyed competitors (rejected: secure-by-default). +call); **folding per-tool approval into the `ActionGuard` seam itself** (rejected: its `require-approval` +is run-only, it is off-by-default enterprise, and its compensation / idempotency / audit weight is wrong +for an interactive terminal prompt — it would couple 2.5.E to an unimplemented enterprise seam); and a +single bypass flag like the four surveyed competitors (rejected: secure-by-default — the default stays +read-only `ask` and no mode escapes protected-paths + the jail). ## Consequences @@ -75,7 +140,12 @@ call); and a single bypass flag like the four surveyed competitors (rejected: se - Instant, lossless mode switching; real per-tool approval with once/always memory; a true mid-turn abort that keeps the session alive (today `/cancel` kills it). -- No one-key bypass valve — the one structural differentiator from every surveyed terminal agent CLI. +- No hidden "bypass all permissions" valve — the default is read-only `ask`, the mode is always shown, and + **no** mode (auto included) writes a protected path or escapes the fs jail. That guarantee — not "auto is + unreachable by key" — is the structural differentiator from the surveyed terminal CLIs, and it survives + putting `auto` on the `Shift+Tab` cycle (a deliberate mainstream-UX call). +- One SSRF egress implementation shared by tool egress and media egress (no second parser); the additive + `fsWrite?` flag is shared with ADR-0041, so the enterprise `ActionGuard` track inherits it. - The pause/resume machine is shared by per-tool approval, mid-turn abort, and the deferred budget pause. ### Negative @@ -83,5 +153,13 @@ call); and a single bypass flag like the four surveyed competitors (rejected: se - The `ToolHost` is full-capability even in `ask` mode, so defense-in-depth rests one layer more on the policy layer than on capability absence — mitigated by the advertise-filter, the `enforcePolicy` gate, protected paths, and this ADR's mandatory security review. -- This is the most complex workstream: a new shared event, a new session state, and a REPL intercept — - isolated in 2.5.E behind this ADR so the rest of Phase 2.5 does not depend on it. +- `auto` is reachable on the `Shift+Tab` cycle, so auto-approve-writes is one cycle-position away — a + deliberate UX call, mitigated by the read-only default, the always-visible mode indicator, the + protected-paths rule (never auto in any mode), the fs jail, and the fail-closed floor; the mandatory + security review scrutinizes this trade-off explicitly. +- The CLI host now carries `fs`-write, `egress`, and `os` capabilities (the 2.5.A deferral closed), a + larger security surface — mitigated by reusing the already-reviewed SSRF connect-by-validated-IP + mechanism (one shared primitive), the protected-paths rule, and a dedicated security review. +- This is the most complex workstream: a new shared event, a new session state, a registry approval hook, + three new host arms, and a REPL intercept — isolated in 2.5.E behind this ADR so the rest of Phase 2.5 + does not depend on it. diff --git a/docs/roadmap/phases/phase-2.5-cli-consolidation.md b/docs/roadmap/phases/phase-2.5-cli-consolidation.md index 6c74b45e..494fc04e 100644 --- a/docs/roadmap/phases/phase-2.5-cli-consolidation.md +++ b/docs/roadmap/phases/phase-2.5-cli-consolidation.md @@ -270,9 +270,20 @@ approval policy** (authoritative — `enforcePolicy` is mode-agnostic and inert - Add a mutable `#mode` to `AgentSession`, snapshotted per turn; the four modes (ask / plan / accept-edits / auto) map to an advertised-tool subset + an approval policy. - `Shift+Tab` cycles `ask → plan → accept-edits`; `auto` is explicit-only (`/mode auto`); **no - one-key bypass valve** ([ADR-0029](../../decisions/0029-tool-policy-hardening.md)). -- **Protected paths:** `.git/`, `.relavium/`, shell rc files are never auto-written in any mode. + `Shift+Tab` cycles **`ask → plan → accept-edits → auto`** (auto-approve is a mainstream expectation, so + it is reachable in the cycle, not hidden behind a typed command); `/mode ` jumps directly. The + **default is read-only `ask`**, the active mode is **always shown in the footer**, and there is **no + hidden "bypass all permissions" valve**: no mode (auto included) writes a protected path or escapes the + fs jail ([ADR-0029](../../decisions/0029-tool-policy-hardening.md) secure-by-default). +- **Protected paths:** `.git/`, `.relavium/`, shell rc files are never auto-written in any mode — in + `auto` a protected-path write falls back to an explicit prompt rather than auto-approving. +- **Host arms (closes the 2.5.A deferral):** the write-capable `fs` tier, the SSRF-hardened `egress` arm, + and the `os` arm are wired in the CLI host this workstream — reusing the existing connect-by-validated-IP + media-egress mechanism ([ADR-0043](../../decisions/0043-media-egress-failover-rematerialization-ssrf.md)) + over the one shared `isPrivateOrLocalHost` range-block (extracted so tool + media egress share one + implementation, never a second SSRF parser). `egress` is a governed class and always rides the + fail-closed `confirmAction` floor. A **dedicated adversarial security review** covers the fs-write jail + + protected paths, the egress mechanism, and the `os` arm. - **Per-tool approval (new vertical — not the workflow gate), fail-closed:** a registry pre-dispatch hook (**EA3** — `confirmAction?`, host-injected like `ToolHost` so the engine boundary (ADR-0037) holds; runs between the `enforcePolicy` check and the side-effect in `packages/core/src/tools/registry.ts`). **Note:** From 4db9a064fb260a0ac7dd222a088f403b2696790d Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Tue, 30 Jun 2026 03:25:13 +0300 Subject: [PATCH 03/45] =?UTF-8?q?feat(core):=202.5.E=20Step=201=20?= =?UTF-8?q?=E2=80=94=20per-tool=20approval=20engine=20primitive=20(ADR-005?= =?UTF-8?q?7=20EA3/EA5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docs/reference/contracts/sse-event-schema.md | 5 +- docs/reference/shared-core/tool-registry.md | 14 ++ packages/core/src/index.ts | 12 +- packages/core/src/tools/builtins.ts | 16 +- packages/core/src/tools/errors.ts | 25 +++ packages/core/src/tools/registry.test.ts | 203 +++++++++++++++++++ packages/core/src/tools/registry.ts | 107 +++++++++- packages/core/src/tools/types.ts | 74 +++++++ packages/shared/src/constants.ts | 22 +- packages/shared/src/run-event.test.ts | 64 +++++- packages/shared/src/run-event.ts | 30 +++ 11 files changed, 554 insertions(+), 18 deletions(-) diff --git a/docs/reference/contracts/sse-event-schema.md b/docs/reference/contracts/sse-event-schema.md index c9b86df9..3381d8e9 100644 --- a/docs/reference/contracts/sse-event-schema.md +++ b/docs/reference/contracts/sse-event-schema.md @@ -73,6 +73,7 @@ export type RunEvent = | `agent:token` | A streaming LLM token from an agent node. | `nodeId`, `token`, `model` | | `agent:tool_call` | An agent invoked a tool. | `nodeId`, `model` (the invoking model — so a tool call is attributable across a failover), `toolId`, `toolInput` (sanitized — no secrets), `attemptNumber?` (1-based, matches `cost:updated`) | | `agent:tool_result` | A tool returned. | `nodeId`, `toolId`, `success`, `outputSummary` (truncated for UI), `attemptNumber?` | +| `agent:approval_requested` | A side-effecting tool dispatch is awaiting an **interactive per-tool approval** decision (ADR-0057 EA3/EA5). The host's `ConfirmActionHook` emits it just before prompting; the registry then awaits the verdict (approve ⇒ dispatch, reject ⇒ a fatal `tool_denied`). A **dual-envelope** event (`runId`/`sessionId`), like `agent:tool_call` — in Phase 2.5 emitted only on the chat session path (the approval regime), and **carried on the session stream** (not run-only — it is **not** dropped like `agent:file_patch_proposed`). | `nodeId`, `toolId`, `action: 'fs_write' \| 'process' \| 'egress'` (the governed side-effect class — [tool-registry.md](../shared-core/tool-registry.md)), `preview` (**secret-free, display-only**: `{ path? }` for a write, `{ command? }` for a process, `{ host? }` for egress — never a full URL/query, never a secret), `attemptNumber?` | | `agent:file_patch_proposed` | An agent proposed a file change (**gated — no write until the user accepts**; e.g. the VS Code inline-diff review). | `nodeId`, `patches: [{ uri, unifiedDiff }]` (≥1 — an empty proposal is meaningless), `attemptNumber?` | | `cost:updated` | A node's token cost was tallied (drives the cost waterfall). | `nodeId`, `model`, `inputTokens`, `outputTokens`, `costMicrocents`, `cumulativeCostMicrocents` (integer micro-cents — canonical unit in [llm-provider-seam.md](../shared-core/llm-provider-seam.md#6-usage); **includes realized media spend**, folded as a disjoint addend per [ADR-0044](../../decisions/0044-media-access-governance-read-media-save-to-cost.md) §3 — the per-unit `Usage.mediaUnits` axis is **not yet a field on this event**, deferred, see [deferred-tasks.md](../../roadmap/deferred-tasks.md)), `attemptNumber?` (1-based **within-chain** FallbackChain attempt — resets per node-retry re-dispatch; **distinct** from `node:*.attemptNumber`, see the [two attemptNumber families](#two-attemptnumber-families) note). **Generative-node variant (1.AG Section C, [ADR-0045](../../decisions/0045-async-media-job-loop-poll-checkpoint-resume-cancel.md) §5):** a `media_surface: 'generative'` agent node emits **exactly one** `cost:updated` with `inputTokens` / `outputTokens` **= 0** (no token billing — the spend rides entirely in `costMicrocents` as the per-modality media addend) and **no `attemptNumber`** (no FallbackChain on the generative path — one provider, no failover). | | `node:completed` | A node finished successfully. | `nodeId`, `output`, `tokensUsed: {input, output, model?}` (`model` only for LLM nodes), `durationMs`, `selected?` (a `condition`'s chosen target ids — the authoritative branch record checkpoint/resume restores from, 1.R; **may be an empty array** when the condition routes to no branch, dimming all downstream), `attemptNumber?` (1-based **node-retry** dispatch attempt — 1.S; absent ⇒ attempt 1), `cumulativeCostMicrocents?` (the run-wide running total snapshotted at this node boundary — the durable cost source checkpoint/resume restores from, since `cost:updated` is streamed-only; the engine always populates it. `node:failed` mirrors this field, 2.S/D-GC) | @@ -268,9 +269,9 @@ export type SessionEvent = A turn that **fails** (a provider error, a rate limit, an exhausted budget cap) still emits `session:turn_completed` with an `error?: { code, message, retryable, correlationId? }` — the same closed [`ErrorCode`](#error-code-taxonomy) taxonomy and secret-free correlation id as run events (ADR-0036) — so a surface can render the failure rather than a silent stall. On such a failure `tokensUsed` carries the turn's **real accumulated usage** — non-zero when a provider had already engaged before the failure (e.g. a tool round or a later turn that then failed), and zero when the failure was pre-egress (a no-plan-entries / budget-pause / hard-cap block engaged no provider); EA2, [ADR-0055](../../decisions/0055-cli-host-capability-seam-tool-environment-factory.md). A **cancellation** is distinct: it emits `session:cancelled` (not `turn_completed`) and the in-flight user message is rolled back from the transcript, so a cancelled turn leaves no partial assistant turn behind (see [agent-session-spec.md](agent-session-spec.md)). -Within a turn, the conversational work reuses the **same** `agent:token` / `agent:tool_call` / `agent:tool_result` / `cost:updated` event shapes the `AgentRunner` already emits — carried on the session envelope (`sessionId`). The per-turn append of user/assistant/tool messages is persisted as `session_messages` (see [database-schema.md](../desktop/database-schema.md)); the contract is owned by [agent-session-spec.md](agent-session-spec.md). On every surface session events are produced and consumed **in-process** exactly like run events — only `llm_stream` crosses IPC on the desktop ([ipc-contract.md](ipc-contract.md#run-events-are-webview-side)). So the **complete typed event stream for a session** is the five `session:*` lifecycle events (the `SessionEvent` union above) **plus** `agent:token` / `agent:tool_call` / `agent:tool_result` / `cost:updated` carrying `sessionId` — this full set is exactly what `relavium chat --json` emits. +Within a turn, the conversational work reuses the **same** `agent:token` / `agent:tool_call` / `agent:tool_result` / `cost:updated` event shapes the `AgentRunner` already emits — carried on the session envelope (`sessionId`). The per-turn append of user/assistant/tool messages is persisted as `session_messages` (see [database-schema.md](../desktop/database-schema.md)); the contract is owned by [agent-session-spec.md](agent-session-spec.md). On every surface session events are produced and consumed **in-process** exactly like run events — only `llm_stream` crosses IPC on the desktop ([ipc-contract.md](ipc-contract.md#run-events-are-webview-side)). So the **complete typed event stream for a session** is the five `session:*` lifecycle events (the `SessionEvent` union above) **plus** `agent:token` / `agent:tool_call` / `agent:tool_result` / `cost:updated` (and, on the chat approval path, `agent:approval_requested` — ADR-0057) carrying `sessionId` — this full set is exactly what `relavium chat --json` emits. -**The session stream (`SessionHandle`, 1.W).** A session is **long-lived across turns**, so — unlike a run's exactly-one-terminal `RunHandle` — the `SessionHandle.events` async-iterable stays **open across turns**: `session:turn_completed` is a per-turn boundary, **not** a stream terminal. The stream closes **only** on `session:cancelled` (the session's sole terminal); `session:exported` is a side event (1.Z), never a terminal. The bus assigns the **per-session** `sequenceNumber` — a monotonic counter keyed on `sessionId`, independent of any run's `runId` counter on the same shared bus (ADR-0036 "one bus, two namespaces") — with the **same** gap-detection / resync rule as a run. `AgentSession` (1.V) emits *envelope-free* drafts through its injected `SessionEventSink`; 1.W's `createSessionEventSink` attaches the `sessionId` and the bus stamps the `sequenceNumber` + `timestamp` at the one authoritative translation point. The bus's validation gate accepts both families via the combined `RunOrSessionEventSchema` (`@relavium/shared`). `agent:file_patch_proposed` is **run-only** (it carries `runId`, emitted by the `AgentRunner` workflow adapter — not the shared turn core), so it is **not** part of a session stream; `createSessionEventSink` drops it defensively at the seam. +**The session stream (`SessionHandle`, 1.W).** A session is **long-lived across turns**, so — unlike a run's exactly-one-terminal `RunHandle` — the `SessionHandle.events` async-iterable stays **open across turns**: `session:turn_completed` is a per-turn boundary, **not** a stream terminal. The stream closes **only** on `session:cancelled` (the session's sole terminal); `session:exported` is a side event (1.Z), never a terminal. The bus assigns the **per-session** `sequenceNumber` — a monotonic counter keyed on `sessionId`, independent of any run's `runId` counter on the same shared bus (ADR-0036 "one bus, two namespaces") — with the **same** gap-detection / resync rule as a run. `AgentSession` (1.V) emits *envelope-free* drafts through its injected `SessionEventSink`; 1.W's `createSessionEventSink` attaches the `sessionId` and the bus stamps the `sequenceNumber` + `timestamp` at the one authoritative translation point. The bus's validation gate accepts both families via the combined `RunOrSessionEventSchema` (`@relavium/shared`). `agent:file_patch_proposed` is **run-only** (it carries `runId`, emitted by the `AgentRunner` workflow adapter — not the shared turn core), so it is **not** part of a session stream; `createSessionEventSink` drops it defensively at the seam. By contrast, `agent:approval_requested` (ADR-0057 EA3/EA5) is a dual-envelope event the chat session path emits, so the sink **carries** it (it is not run-only). ## Workflow governance and reserved events diff --git a/docs/reference/shared-core/tool-registry.md b/docs/reference/shared-core/tool-registry.md index a341cf00..759c9d72 100644 --- a/docs/reference/shared-core/tool-registry.md +++ b/docs/reference/shared-core/tool-registry.md @@ -72,6 +72,14 @@ interface ToolDef { interface ToolPolicyClass { /** Needs the FS scope tier — `read_file`, `write_file`, `list_directory`. */ readonly fsScoped: boolean; + /** + * The fs operation is a WRITE (`write_file`), not a read. `fsScoped` alone is `true` for reads AND + * writes alike, so it cannot tell `write_file` from `read_file`; this additive discriminator is the one + * the per-tool approval needs to gate writes ([ADR-0057](../../decisions/0057-cli-chat-modes-and-per-tool-approval.md) EA3) + * and the `fs-write` `ActionClass` [ADR-0041](../../decisions/0041-external-action-governance-seam.md) proposes — + * landed once, credited to both. Absent/false ⇒ a read-only fs tool (never governed by approval). + */ + readonly fsWrite?: boolean; /** Spawns an OS process under the `allowedCommands` allowlist — `run_command`, `git_*`. */ readonly spawnsProcess: boolean; /** Outbound egress — DISCRIMINATED by kind, because the three paths have different policies. */ @@ -158,6 +166,7 @@ host is touched once, in the middle. 2. **Assemble the effective argument set.** Start from the model-supplied args (for an agent tool-call) and/or `ctx.config.input_mapping` (for a `tool` node, where there are no model args), apply `input_mapping`, then merge `configOnlyParams` **last (config wins)**. 3. **Validate the COMPLETE effective set** via `tool.parseArgs` **and** the secret-taint check — `input_mapping`/config-derived values are validated identically to model args; a secret-tainted value reaching a non-credential arg is rejected (0029(c)). A miss → `ToolArgsInvalidError`. 4. **Enforce the guardrail policy on the EFFECTIVE args** (the resolved command / URL is now the real value): exact `allowedCommands` (+ opt-in `allowedCommandGlobs`, deny-all-empty); per egress kind (table below); `git_commit` refused unless `ctx.gateApproved`. A denial → `ToolPolicyError` → **before any host call**. +4b. **Per-tool approval — the interactive consent gate ([ADR-0057](../../decisions/0057-cli-chat-modes-and-per-tool-approval.md) EA3).** Runs only when `ctx.approval` is present (the **interactive-approval regime** — the chat path; absent ⇒ the workflow author-trust path, unchanged) and the dispatch is a **governed class**: `fsWrite`, an `egress` of any kind, or a `spawnsProcess` with a model-controlled `command` target (so the pre-approved `git_status`, which exposes no command, is **not** gated — matching step 4). The engine consults the host-injected `ctx.approval.confirm` hook with a **secret-free preview** (resolved path / command / host — never a full URL/query, never a secret) and, before prompting, the host emits `agent:approval_requested`. **Fail-closed:** under an active regime a governed dispatch *requires* a decision — an **absent `confirm` hook ⇒ denied** (`ToolDeniedByUserError`, reason `no_approval_hook`), never silently allowed, so a wiring bug cannot let `ask` mode write. A reject ⇒ `ToolDeniedByUserError` (reason `user_rejected`) carrying the existing **`tool_denied`** code (fatal, never retried); an abort while prompting routes to the **cancellation** path (cancel precedence). The host's `confirm` owns the mode policy (ask / plan / accept-edits / auto), the once/always cache, and the protected-paths rule — the engine stays mode-agnostic. This composes *after* step 4 and, when an `ActionGuard` ([action-guard-seam.md](action-guard-seam.md)) is also injected, *alongside* it (the org governor's `decide`/`commit` and the user's `confirm` each only further restrict). 5. **Call the host capability** (the single side effect), threading `ctx.signal`. Absent capability → `ToolUnavailableError`; an `AbortSignal`-origin failure → the **cancellation** path (`cancelled`, never `tool_failed` — preserves ADR-0036 cancel precedence); any other host throw → `ToolExecutionError`. A host capability MAY itself throw a typed `ToolDispatchError` subclass for a **deterministic host-side denial** (e.g. the CLI `fs` arm throwing a `tool_denied` when a path escapes the FS scope tier, or `ToolUnavailableError` for a read-only fail-close) — the registry passes any `ToolDispatchError` through **verbatim**, exactly as it does an engine-side `ToolPolicyError`, so such a denial is fatal (never burns the node-retry budget); only a *raw* host throw becomes the retryable `ToolExecutionError`. 6. **Apply `output_mapping` to the FULL result** → workflow state gets the real value, never the bounded preview. 7. **Bound the model-facing result** (§Result bounding and spill-to-file) from the result via `ctx.limits` + the host `outputStore` — over the ceiling the model gets a preview + a spill handle, the full result still flows to `output_mapping`. @@ -173,6 +182,11 @@ interface ToolDispatchContext { readonly toolPolicy: ToolPolicy; // resolved allowedCommands/-Globs/-Domains (@relavium/shared) readonly fsScope: FsScopeTier; // 'sandboxed' | 'project' | 'full' (the @relavium/shared source of truth) readonly gateApproved: boolean; // a human-gate decision is present for this dispatch (1.Q) + // Per-tool approval regime (ADR-0057 EA3). PRESENT ⇒ the interactive-approval (chat) path: a governed-class + // dispatch requires `approval.confirm`'s decision, and an absent confirm is fail-closed → denied. ABSENT ⇒ + // the workflow author-trust path (unchanged). `confirm` is the host-injected ConfirmActionHook (the engine + // defines the interface + the invocation point, the host supplies the implementation — ADR-0037-clean). + readonly approval?: { readonly confirm?: ConfirmActionHook }; // The effective-arg keys whose resolved value is secret-tainted (ADR-0029(c)); the registry rejects // any present one from tool args (re-applying the parse-time taint gate on the effective set). 1.O/1.V // produces this; absent ⇒ no taint check (no producer yet). diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 18b9ee3d..f25f07cc 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -247,8 +247,13 @@ export { ToolUnavailableError, ToolExecutionError, ToolCancelledError, + ToolDeniedByUserError, +} from './tools/errors.js'; +export type { + ToolErrorCode, + ToolPolicyDenyReason, + ToolApprovalDenyReason, } from './tools/errors.js'; -export type { ToolErrorCode, ToolPolicyDenyReason } from './tools/errors.js'; export type { Untrusted } from './tools/untrusted.js'; export type { ToolRegistry, @@ -260,6 +265,11 @@ export type { EgressKind, ToolPolicyClass, PolicyTarget, + ConfirmActionHook, + ToolApprovalContext, + ToolApprovalRequest, + ToolApprovalDecision, + ToolActionPreview, ToolHost, FsCapability, ProcessCapability, diff --git a/packages/core/src/tools/builtins.ts b/packages/core/src/tools/builtins.ts index 9ed5f118..ac47c8ec 100644 --- a/packages/core/src/tools/builtins.ts +++ b/packages/core/src/tools/builtins.ts @@ -127,6 +127,16 @@ const FS_POLICY: ToolPolicyClass = { spawnsProcess: false, requiresGateApproval: false, }; +// `write_file` is a WRITE, so the per-tool approval gates it (ADR-0057 EA3, `fsWrite: true`). A SEPARATE +// object from FS_POLICY: the read-only `read_file` / `list_directory` must keep `fsWrite` absent, or the +// reads would be gated too. `enforcePolicy` is inert for it (no command/domain target, no gate) — the +// `confirmAction` floor is the authoritative writer gate. +const FS_WRITE_POLICY: ToolPolicyClass = { + fsScoped: true, + fsWrite: true, + spawnsProcess: false, + requiresGateApproval: false, +}; const OS_POLICY: ToolPolicyClass = { fsScoped: false, spawnsProcess: false, @@ -182,7 +192,11 @@ const writeFileTool = defineBuiltin({ required: ['path', 'content'], additionalProperties: false, }, - policy: FS_POLICY, + policy: FS_WRITE_POLICY, + // The resolved target path the per-tool approval prompt + `agent:approval_requested` preview show + // (ADR-0057 EA3). NOT a guardrail target — `enforcePolicy` reads only `command`/`url`, so this changes no + // allowlist behavior; it is display-only. + policyTarget: (args) => ({ path: args.path }), dispatch: (args, host, ctx) => requireFs(host, 'write_file').writeFile( args.path, diff --git a/packages/core/src/tools/errors.ts b/packages/core/src/tools/errors.ts index b46e9b5b..b213c30b 100644 --- a/packages/core/src/tools/errors.ts +++ b/packages/core/src/tools/errors.ts @@ -82,6 +82,31 @@ export type ToolPolicyDenyReason = | 'gate_required' // git_commit reached without a human-gate approval | 'media_scope_denied'; // read_media: the requesting scope is not in the handle's allowedScopes (ADR-0044 §1) +/** + * A per-tool approval (ADR-0057 EA3) denied the dispatch — the user rejected it, or (fail-closed) the + * interactive-approval regime was active for a governed-class tool but no `confirm` hook was wired. Carries + * the **existing** `tool_denied` run code (already absent from `RETRYABLE_ERROR_CODES`), so a user denial is + * **fatal, never retried** (re-issuing the same call just re-prompts/re-denies — like a guardrail denial). A + * distinct class from {@link ToolPolicyError} (a guardrail/grant denial of the engine's OWN policy): this is + * the user's interactive consent verdict, with its own reason discriminant. The message is secret-free. + */ +export class ToolDeniedByUserError extends ToolDispatchError { + readonly code = 'tool_denied'; + readonly runErrorCode: ErrorCode = 'tool_denied'; + readonly retryable = false; + /** Why the dispatch was denied — a stable label, never an authored value. */ + readonly reason: ToolApprovalDenyReason; + + constructor(toolId: ToolId, reason: ToolApprovalDenyReason, message: string) { + super(message, toolId, undefined); + this.name = 'ToolDeniedByUserError'; + this.reason = reason; + } +} +export type ToolApprovalDenyReason = + | 'user_rejected' // the user (or the host's mode policy) rejected the interactive approval prompt + | 'no_approval_hook'; // fail-closed: the approval regime was active for a governed tool but no confirm hook was wired + /** The effective argument set failed the tool's validator or the secret-taint check. Field names only. */ export class ToolArgsInvalidError extends ToolDispatchError { readonly code = 'invalid_args'; diff --git a/packages/core/src/tools/registry.test.ts b/packages/core/src/tools/registry.test.ts index 238866e3..4ebfa801 100644 --- a/packages/core/src/tools/registry.test.ts +++ b/packages/core/src/tools/registry.test.ts @@ -5,6 +5,7 @@ import { BUILTIN_TOOLS, BUILTIN_TOOL_IDS } from './builtins.js'; import { ToolArgsInvalidError, ToolCancelledError, + ToolDeniedByUserError, ToolExecutionError, ToolPolicyError, ToolUnavailableError, @@ -16,6 +17,8 @@ import type { EgressRequest, EgressResponse, FsCapability, + ToolApprovalDecision, + ToolApprovalRequest, ToolCallPart, ToolDispatchContext, ToolHost, @@ -339,6 +342,206 @@ describe('ToolRegistry — git_commit human-gate', () => { }); }); +/* --- per-tool approval (ADR-0057 EA3) --- */ + +describe('ToolRegistry — per-tool approval (ADR-0057 EA3)', () => { + // The `vi.fn` function-type generic types `.mock.calls[0][0]` as ToolApprovalRequest (no unused impl + // param) and contextually types the decision literals (`'approve'`/`'reject'`). + type ConfirmFn = (req: ToolApprovalRequest) => Promise; + const approving = () => vi.fn(() => Promise.resolve({ outcome: 'approve' })); + const rejecting = (reason?: string) => + vi.fn(() => + Promise.resolve({ outcome: 'reject', ...(reason === undefined ? {} : { reason }) }), + ); + + /** A host whose `writeFile` is a spy, so a denied write can be proven to never reach the side effect. */ + function hostWithWriteSpy(): { host: ToolHost; writeFile: ReturnType } { + const writeFile = vi.fn((path: string, data: string) => + Promise.resolve({ path, bytesWritten: data.length }), + ); + return { + writeFile, + host: stubHost({ + fs: { + readFile: () => + Promise.resolve({ + content: 'x', + mimeType: 'text/plain', + sizeBytes: 1, + lastModified: 't', + }), + writeFile, + listDirectory: () => Promise.resolve({ entries: [] }), + }, + }), + }; + } + + // --- the regime gate: present ⇒ chat (governed), absent ⇒ workflow author-trust (unchanged) --- + + it('does NOT prompt on the workflow path (no ctx.approval) — a governed write dispatches under the floor', async () => { + const { host, writeFile } = hostWithWriteSpy(); + const out = await createToolRegistry({ tools: BUILTIN_TOOLS, host }).dispatch( + call('write_file', { path: './out.txt', content: 'hi' }), + ctx(), // no approval regime + ); + expect(out.events.result.success).toBe(true); + expect(writeFile).toHaveBeenCalledOnce(); + }); + + it('prompts a governed write under the approval regime and dispatches on approve, with an fs_write preview', async () => { + const confirm = approving(); + const { host, writeFile } = hostWithWriteSpy(); + const out = await createToolRegistry({ tools: BUILTIN_TOOLS, host }).dispatch( + call('write_file', { path: './out.txt', content: 'hi' }), + ctx({ approval: { confirm } }), + ); + expect(out.events.result.success).toBe(true); + expect(writeFile).toHaveBeenCalledOnce(); + expect(confirm).toHaveBeenCalledOnce(); + const req = confirm.mock.calls[0]?.[0]; + expect(req).toMatchObject({ + toolId: 'write_file', + action: 'fs_write', + preview: { path: './out.txt' }, + }); + }); + + it('denies a rejected write as a fatal tool_denied (user_rejected) and never reaches the side effect', async () => { + const confirm = rejecting(); + const { host, writeFile } = hostWithWriteSpy(); + const err = await rejectsWith( + createToolRegistry({ tools: BUILTIN_TOOLS, host }).dispatch( + call('write_file', { path: './out.txt', content: 'hi' }), + ctx({ approval: { confirm } }), + ), + ); + expect(err).toBeInstanceOf(ToolDeniedByUserError); + expect(err.code).toBe('tool_denied'); + expect(err.runErrorCode).toBe('tool_denied'); + expect(err.retryable).toBe(false); + expect(err.reason).toBe('user_rejected'); + expect(writeFile).not.toHaveBeenCalled(); + }); + + it('echoes a host-supplied, secret-free reject reason in the (secret-free) message', async () => { + const err = await rejectsWith( + registry().dispatch( + call('write_file', { path: './out.txt', content: 'hi' }), + ctx({ approval: { confirm: rejecting('writes are not allowed in ask mode') } }), + ), + ); + expect(err.message).toContain('writes are not allowed in ask mode'); + }); + + it('is FAIL-CLOSED: an active regime with no confirm hook denies a governed write (no_approval_hook)', async () => { + const { host, writeFile } = hostWithWriteSpy(); + const err = await rejectsWith( + createToolRegistry({ tools: BUILTIN_TOOLS, host }).dispatch( + call('write_file', { path: './out.txt', content: 'hi' }), + ctx({ approval: {} }), // regime active, but the hook was never wired (a bug) + ), + ); + expect(err.reason).toBe('no_approval_hook'); + expect(writeFile).not.toHaveBeenCalled(); + }); + + // --- only governed classes are gated (read-only / pre-approved tools bypass the prompt) --- + + it('does NOT prompt for a read-only fs tool (read_file) even under the regime', async () => { + const confirm = approving(); + const out = await registry().dispatch( + call('read_file', { path: 'a.txt' }), + ctx({ approval: { confirm } }), + ); + expect(out.events.result.success).toBe(true); + expect(confirm).not.toHaveBeenCalled(); + }); + + it('does NOT prompt for the pre-approved git_status (a process tool with no model command target)', async () => { + const confirm = approving(); + const out = await registry().dispatch(call('git_status', {}), ctx({ approval: { confirm } })); + expect(out.events.result.success).toBe(true); + expect(confirm).not.toHaveBeenCalled(); + }); + + // --- the governed process / egress classes, with their previews --- + + it('prompts a model-controlled run_command (process) with the resolved command preview', async () => { + const confirm = approving(); + const out = await registry().dispatch( + call('run_command', { command: 'ls', args: ['-la'] }), + ctx({ approval: { confirm }, toolPolicy: { allowedCommands: ['ls -la'] } }), + ); + expect(out.events.result.success).toBe(true); + const req = confirm.mock.calls[0]?.[0]; + expect(req).toMatchObject({ + toolId: 'run_command', + action: 'process', + preview: { command: 'ls -la' }, + }); + }); + + it('prompts http_request (egress) with the host-only preview (never the full URL)', async () => { + const confirm = approving(); + const out = await registry().dispatch( + call('http_request', { url: 'https://api.example.com/secret?token=abc' }), + ctx({ approval: { confirm }, toolPolicy: { allowedDomains: ['api.example.com'] } }), + ); + expect(out.events.result.success).toBe(true); + const req = confirm.mock.calls[0]?.[0]; + expect(req).toMatchObject({ + toolId: 'http_request', + action: 'egress', + preview: { host: 'api.example.com' }, + }); + // the query string (and its token) never enters the preview — assert over all recorded call args + expect(JSON.stringify(confirm.mock.calls)).not.toContain('token'); + }); + + it('prompts web_search (egress) with an empty preview — it exposes no pre-dispatch URL target', async () => { + const confirm = approving(); + const out = await registry().dispatch( + call('web_search', { query: 'hi' }), + ctx({ + approval: { confirm }, + config: { parameters: { endpoint: 'https://search.example.com' } }, + }), + ); + expect(out.events.result.success).toBe(true); + const req = confirm.mock.calls[0]?.[0]; + expect(req).toMatchObject({ toolId: 'web_search', action: 'egress', preview: {} }); + }); + + // --- ordering + cancellation --- + + it('runs the enforcePolicy floor BEFORE approval — a denied run_command never reaches the prompt', async () => { + const confirm = approving(); + const err = await rejectsWith( + registry().dispatch( + call('run_command', { command: 'rm', args: ['-rf', '/'] }), + ctx({ approval: { confirm }, toolPolicy: { allowedCommands: [] } }), // empty ⇒ deny-all + ), + ); + expect(err).toBeInstanceOf(ToolPolicyError); + expect(err.reason).toBe('command_not_allowed'); + expect(confirm).not.toHaveBeenCalled(); + }); + + it('classifies an abort raised while prompting as cancelled (cancel precedence), not a denial', async () => { + const confirm = vi.fn(() => + Promise.reject(Object.assign(new Error('prompt aborted'), { name: 'AbortError' })), + ); + const err = await rejectsWith( + registry().dispatch( + call('write_file', { path: './out.txt', content: 'hi' }), + ctx({ approval: { confirm } }), + ), + ); + expect(err).toBeInstanceOf(ToolCancelledError); + }); +}); + /* --- config-only params + I/O mapping --- */ describe('ToolRegistry — config-only params and I/O mapping', () => { diff --git a/packages/core/src/tools/registry.ts b/packages/core/src/tools/registry.ts index 35b5c92f..fa76b4d6 100644 --- a/packages/core/src/tools/registry.ts +++ b/packages/core/src/tools/registry.ts @@ -7,12 +7,13 @@ * [tool-registry.md](../../../../docs/reference/shared-core/tool-registry.md). */ -import { extractHttpsHost } from '@relavium/shared'; +import { extractHttpsHost, type ToolActionClass } from '@relavium/shared'; import { boundForModel, redactInlineMedia } from './bounding.js'; import { ToolArgsInvalidError, ToolCancelledError, + ToolDeniedByUserError, ToolDispatchError, ToolExecutionError, ToolPolicyError, @@ -22,6 +23,8 @@ import { markUntrusted } from './untrusted.js'; import { DEFAULT_TOOL_RESULT_LIMITS, type CreateToolRegistryOptions, + type PolicyTarget, + type ToolActionPreview, type ToolCallPart, type ToolDef, type ToolDispatchContext, @@ -96,16 +99,23 @@ async function dispatch( throw toArgsInvalid(def.id, cause); } - // 4. Enforce the guardrail policy on the EFFECTIVE args (the resolved command/URL is now real). - enforcePolicy(def, args, ctx); + // 4. Enforce the guardrail policy on the EFFECTIVE args (the resolved command/URL is now real). The + // policy target is resolved once here and reused by the per-tool approval step (4b) below. + const target = def.policyTarget?.(args) ?? {}; + enforcePolicy(def, target, ctx); - // 5-7. The single side effect + output_mapping (FULL result) + model-facing bounding — all under one - // classification ladder so a spill-time abort surfaces as `cancelled` (ADR-0036 precedence) and any - // other tail failure is a classified tool error, never a raw escape. + // 4b-7. The per-tool approval gate + the single side effect + output_mapping (FULL result) + model-facing + // bounding — all under one classification ladder so a spill-time (or prompt-time) abort surfaces as + // `cancelled` (ADR-0036 precedence) and any other tail failure is a classified tool error, never a raw + // escape. A `ToolDeniedByUserError` from 4b is a `ToolDispatchError` and passes through the ladder verbatim. let outputMapped: unknown; let bounded: Awaited>; try { throwIfAborted(ctx, def.id); + // 4b. Per-tool approval (ADR-0057 EA3): under the interactive-approval regime (chat), a governed-class + // dispatch REQUIRES a confirmAction decision before the side effect; the workflow author-trust path + // (no `ctx.approval`) skips it. A denial is a fatal `tool_denied`; an abort while prompting is cancelled. + await confirmDispatch(def, target, ctx); const output = await def.dispatch(args, host, ctx); // Abort that lands AFTER the host resolved must still classify as cancelled, not a success. throwIfAborted(ctx, def.id); @@ -269,7 +279,7 @@ function zodIssuePaths(cause: unknown): readonly string[] { * Step 4 — guardrail policy on the effective args. * ------------------------------------------------------------------------------------------------ */ -function enforcePolicy(def: ToolDef, args: unknown, ctx: ToolDispatchContext): void { +function enforcePolicy(def: ToolDef, target: PolicyTarget, ctx: ToolDispatchContext): void { if (def.policy.requiresGateApproval && !ctx.gateApproved) { throw new ToolPolicyError( def.id, @@ -278,8 +288,6 @@ function enforcePolicy(def: ToolDef, args: unknown, ctx: ToolDispatchContext): v ); } - const target = def.policyTarget?.(args) ?? {}; - if (def.policy.spawnsProcess && target.command !== undefined) { if (!commandAllowed(target.command, ctx.toolPolicy)) { throw new ToolPolicyError( @@ -310,6 +318,87 @@ function commandAllowed(command: string, policy: import('@relavium/shared').Tool return false; // empty/absent ⇒ deny-all (symmetry with allowedDomains) } +/* ------------------------------------------------------------------------------------------------ * + * Step 4b — per-tool approval (ADR-0057 EA3). Fail-closed under an active interactive-approval regime; + * a no-op on the workflow author-trust path (no `ctx.approval`) so the workflow path is unchanged. + * ------------------------------------------------------------------------------------------------ */ + +async function confirmDispatch( + def: ToolDef, + target: PolicyTarget, + ctx: ToolDispatchContext, +): Promise { + const approval = ctx.approval; + if (approval === undefined) { + return; // the workflow author-trust path — governed tools proceed under the enforcePolicy floor above + } + const action = governedAction(def, target); + if (action === undefined) { + return; // a read-only / pre-approved tool (fs read, git_status, clipboard, invoke_agent) is never gated + } + // Fail-closed: an active approval regime with no confirm hook DENIES a governed dispatch — so a wiring bug + // (the chat host wired a write arm but not the hook) can never let `ask` mode write. The floor is the hook, + // not the advertise-filter. + if (approval.confirm === undefined) { + throw new ToolDeniedByUserError( + def.id, + 'no_approval_hook', + `tool \`${def.id}\` requires interactive approval, but no approval hook is wired`, + ); + } + throwIfAborted(ctx, def.id); // do not prompt for a turn that is already aborting + const decision = await approval.confirm( + { toolId: def.id, action, preview: previewFor(action, target) }, + ctx.signal, + ); + if (decision.outcome !== 'approve') { + // `decision.reason` is a host-supplied, secret-free label (e.g. "writes are not allowed in ask mode"). + const why = + decision.reason !== undefined && decision.reason.length > 0 + ? `: ${decision.reason}` + : ' by the user'; + throw new ToolDeniedByUserError(def.id, 'user_rejected', `tool \`${def.id}\` denied${why}`); + } +} + +/** + * The side-effecting action class a per-tool approval governs, or `undefined` for an un-gated tool. A + * model-controlled `run_command` (a resolved `command` target) is `process`; the pre-approved `git_status` + * (no command target) is NOT governed — matching `enforcePolicy`, which runs the command allowlist only when + * a command target is present. An fs READ, clipboard, and `invoke_agent` are not governed + * ([ADR-0041](../../../../docs/decisions/0041-external-action-governance-seam.md) §ActionClass); every egress + * IS, even a read-only `web_search` (an exfiltration sink). + */ +function governedAction(def: ToolDef, target: PolicyTarget): ToolActionClass | undefined { + if (def.policy.fsWrite === true) { + return 'fs_write'; + } + if (def.policy.egress !== undefined) { + return 'egress'; + } + if (def.policy.spawnsProcess && target.command !== undefined) { + return 'process'; + } + return undefined; +} + +/** A secret-free preview for the approval prompt: the resolved path / command / host (never a full URL). */ +function previewFor(action: ToolActionClass, target: PolicyTarget): ToolActionPreview { + switch (action) { + case 'fs_write': + return target.path === undefined ? {} : { path: target.path }; + case 'process': + return target.command === undefined ? {} : { command: target.command }; + case 'egress': { + if (target.url === undefined) { + return {}; // web_search / mcp_call expose no pre-dispatch URL target — the action class is enough + } + const parsed = extractHttpsHost(target.url); + return parsed === null ? {} : { host: parsed.host }; + } + } +} + function enforceHttpEgress(toolId: ToolId, url: string, ctx: ToolDispatchContext): void { const parsed = extractHttpsHost(url); if (parsed === null || parsed.hasCredentials) { diff --git a/packages/core/src/tools/types.ts b/packages/core/src/tools/types.ts index ff9ded6b..0706ba23 100644 --- a/packages/core/src/tools/types.ts +++ b/packages/core/src/tools/types.ts @@ -15,6 +15,7 @@ import type { FsScopeTier, MediaSource, Scope, + ToolActionClass, } from '@relavium/shared'; import type { Untrusted } from './untrusted.js'; @@ -47,6 +48,14 @@ export type EgressKind = 'http' | 'search' | 'mcp'; export interface ToolPolicyClass { /** Needs the FS scope tier — `read_file`, `write_file`, `list_directory`. */ readonly fsScoped: boolean; + /** + * The fs operation is a WRITE (`write_file`), not a read — `fsScoped` alone is `true` for reads AND + * writes alike, so it cannot tell `write_file` from `read_file`. The additive discriminator the + * per-tool approval needs to gate writes (ADR-0057 EA3) and the `fs-write` ActionClass + * [ADR-0041](../../../../docs/decisions/0041-external-action-governance-seam.md) already proposed — + * landed here, credited to both. Absent/false ⇒ a read-only fs tool (never governed by approval). + */ + readonly fsWrite?: boolean; /** Spawns an OS process under the `allowedCommands` allowlist — `run_command`, `git_*`. */ readonly spawnsProcess: boolean; /** Outbound egress, discriminated by kind — `http_request` / `web_search` / `mcp_call`. */ @@ -252,6 +261,59 @@ export interface MediaReadAccess { readRange(handle: string, range: ByteRange, signal?: AbortSignalLike): Promise; } +/* ------------------------------------------------------------------------------------------------ * + * Per-tool approval (ADR-0057 EA3) — the interactive consent seam. A host-injected hook (the same + * dependency-inversion pattern as `ToolHost`, so ADR-0037's tool-execution boundary holds: the engine + * defines the interface + the invocation point, the host supplies the implementation). The registry + * consults it BETWEEN the `enforcePolicy` guardrail floor and the host side-effect, for a GOVERNED-class + * dispatch only (fs_write / a model-controlled process / egress — never a read-only fs / `git_status` / + * clipboard tool). The engine stays mode-agnostic: the host's hook owns the mode policy (ask / plan / + * accept-edits / auto), the once/always cache, the protected-paths rule, and emitting + * `agent:approval_requested`; the engine only asks "may this governed action proceed?" and honors it. + * ------------------------------------------------------------------------------------------------ */ + +/** A secret-free, display-only preview of the side effect the user is approving (ADR-0057). */ +export interface ToolActionPreview { + /** fs_write — the resolved target path. */ + readonly path?: string; + /** process — the resolved command string (what `allowedCommands` matched). */ + readonly command?: string; + /** egress — the target host ONLY (never the full URL / query string, never a secret). */ + readonly host?: string; +} + +/** What the engine asks the host to confirm — the governed action class + a secret-free preview. */ +export interface ToolApprovalRequest { + readonly toolId: ToolId; + readonly action: ToolActionClass; + readonly preview: ToolActionPreview; +} + +/** The host's verdict. `reject.reason` is an optional, secret-free, display-safe label echoed in the error. */ +export type ToolApprovalDecision = + | { readonly outcome: 'approve' } + | { readonly outcome: 'reject'; readonly reason?: string }; + +/** + * The host-injected interactive consent hook. MUST be secret-free and SHOULD honor the `AbortSignal` (an + * abort while prompting routes to the engine's cancel path, not a denial). Returns the verdict the registry + * lowers into its existing control flow (approve ⇒ dispatch; reject ⇒ a fatal `tool_denied`). + */ +export type ConfirmActionHook = ( + request: ToolApprovalRequest, + signal?: AbortSignalLike, +) => Promise; + +/** + * The per-dispatch approval regime (ADR-0057 EA3). Its PRESENCE on {@link ToolDispatchContext} marks the + * interactive-approval (chat) path: a governed-class dispatch then REQUIRES a `confirm` decision, and an + * ABSENT `confirm` is **fail-closed → denied** (a wiring bug can never let a write through). ABSENT on the + * workflow author-trust path — governed tools proceed under the `enforcePolicy` floor, unchanged. + */ +export interface ToolApprovalContext { + readonly confirm?: ConfirmActionHook; +} + export interface ToolDispatchContext { readonly nodeId: string; /** The node's narrowed grant (ADR-0029(b)); a dispatch outside it is refused (registered ≠ authorized). */ @@ -264,6 +326,12 @@ export interface ToolDispatchContext { readonly fsScope: FsScopeTier; /** A human-gate decision is present for this dispatch (1.Q) — required by `git_commit`. */ readonly gateApproved: boolean; + /** + * Per-tool approval regime (ADR-0057 EA3). PRESENT ⇒ the interactive-approval (chat) path: a + * governed-class dispatch (fs_write / a model-controlled process / egress) requires a `confirm` decision, + * and an absent `confirm` is fail-closed → denied. ABSENT ⇒ the workflow author-trust path, unchanged. + */ + readonly approval?: ToolApprovalContext; /** Names of effective-arg keys that are secret-tainted (ADR-0029(c)) — rejected from non-credential args. */ readonly secretArgKeys?: ReadonlySet; /** Engine delegate for `invoke_agent` — pure orchestration, not a ToolHost I/O capability. */ @@ -315,6 +383,12 @@ export interface PolicyTarget { readonly command?: string; /** The outbound URL the `allowedDomains` exact-FQDN allowlist + SSRF policy applies to (ADR-0029(d)). */ readonly url?: string; + /** + * The resolved fs target path of a WRITE — the per-tool approval preview shows it (ADR-0057 EA3). NOT a + * guardrail target (`enforcePolicy` reads only `command`/`url`), so supplying it changes no allowlist + * behavior; it is the display target for the approval prompt + the `agent:approval_requested` event. + */ + readonly path?: string; } /** The engine-side registry + dispatcher. One instance, shared by both entry points (1.O / 1.V). */ diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index c015719d..50ef73e5 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -16,9 +16,9 @@ export type SchemaVersion = typeof SCHEMA_VERSION; * The canonical, **colon-namespaced** run-event type names (sse-event-schema.md). * Never the legacy dotted names (`node.started`), never `node:error`/`run:error`, * and the per-event ordinal is always `sequenceNumber`, never `seqNo`. Order mirrors the - * `RunEvent` union in the spec: `agent:file_patch_proposed` sits after `agent:tool_result`, and - * the four governance events (`run:paused`, `run:timeout`, `budget:warning`, `budget:paused`; - * ADR-0028) close the list. + * `RunEvent` union in the spec: `agent:approval_requested` + `agent:file_patch_proposed` sit after + * `agent:tool_result`, and the four governance events (`run:paused`, `run:timeout`, `budget:warning`, + * `budget:paused`; ADR-0028) close the list. */ export const RUN_EVENT_TYPES = [ 'run:started', @@ -26,6 +26,10 @@ export const RUN_EVENT_TYPES = [ 'agent:token', 'agent:tool_call', 'agent:tool_result', + // A side-effecting tool dispatch is awaiting an interactive per-tool approval decision (ADR-0057 EA3/EA5). + // A dual-envelope event (runId on a run, sessionId on a session); in Phase 2.5 it is session-only (the chat + // approval regime). The host's ConfirmActionHook emits it before prompting; never carries a secret. + 'agent:approval_requested', 'agent:file_patch_proposed', 'cost:updated', 'node:completed', @@ -116,6 +120,18 @@ export const RETRYABLE_ERROR_CODES = [ ] as const satisfies readonly ErrorCode[]; export type RetryableErrorCode = (typeof RETRYABLE_ERROR_CODES)[number]; +/** + * The side-effecting **tool ACTION classes** a per-tool approval governs (ADR-0057 EA3). Derived from a + * tool's `ToolPolicyClass` (tool-registry.md): `fs_write` (a `write_file`), `process` (a model-controlled + * `run_command` — **not** the pre-approved `git_status`, which exposes no model command), and `egress` + * (`http_request` / `web_search` / `mcp_call`). Read-only fs reads, `git_status`, and clipboard are **not** + * governed (mirrors [ADR-0041](../decisions/0041-external-action-governance-seam.md) §ActionClass). Carried + * by `agent:approval_requested` and the engine's `ConfirmActionHook`; it lives here so `@relavium/shared` + * owns the vocabulary and the engine derives its `ToolActionClass` type from this one list (no second home). + */ +export const TOOL_ACTION_CLASSES = ['fs_write', 'process', 'egress'] as const; +export type ToolActionClass = (typeof TOOL_ACTION_CLASSES)[number]; + /** * The five-value LLM **stop reason** vocabulary, used today by `session:turn_completed`. * Intended canonical home: `@relavium/shared`, with the `@relavium/llm` seam re-exporting it diff --git a/packages/shared/src/run-event.test.ts b/packages/shared/src/run-event.test.ts index 5e5f66ef..148dd6df 100644 --- a/packages/shared/src/run-event.test.ts +++ b/packages/shared/src/run-event.test.ts @@ -44,6 +44,14 @@ const valid: Record> = { success: true, outputSummary: 'ok', }, + 'agent:approval_requested': { + type: 'agent:approval_requested', + ...env, + nodeId: 'n', + toolId: 'write_file', + action: 'fs_write', + preview: { path: './out.txt' }, + }, 'agent:file_patch_proposed': { type: 'agent:file_patch_proposed', ...env, @@ -211,6 +219,29 @@ const reject: Record> = { toolId: 't', outputSummary: 'ok', }, + 'agent:approval_requested (missing action)': { + type: 'agent:approval_requested', + ...env, + nodeId: 'n', + toolId: 'write_file', + preview: { path: './out.txt' }, + }, + 'agent:approval_requested (bad action)': { + type: 'agent:approval_requested', + ...env, + nodeId: 'n', + toolId: 'write_file', + action: 'fs_read', // not a governed action class (only fs_write | process | egress) + preview: { path: './out.txt' }, + }, + 'agent:approval_requested (empty toolId)': { + type: 'agent:approval_requested', + ...env, + nodeId: 'n', + toolId: '', + action: 'fs_write', + preview: { path: './out.txt' }, + }, 'cost:updated (float costMicrocents)': { ...valid['cost:updated'], costMicrocents: 12.5 }, 'node:completed (bad tokensUsed)': { type: 'node:completed', @@ -345,7 +376,7 @@ describe('RunEvent union — every variant', () => { ).toBe(true); }); - it('covers exactly the 21 canonical colon-namespaced names, pinned to a literal list', () => { + it('covers exactly the 22 canonical colon-namespaced names, pinned to a literal list', () => { // A hardcoded contract list — independent of RUN_EVENT_TYPES — so the union and the // constant cannot silently drift together. const CONTRACT_NAMES = [ @@ -354,6 +385,7 @@ describe('RunEvent union — every variant', () => { 'agent:token', 'agent:tool_call', 'agent:tool_result', + 'agent:approval_requested', 'agent:file_patch_proposed', 'cost:updated', 'node:completed', @@ -377,7 +409,7 @@ describe('RunEvent union — every variant', () => { // RunEventSchema wraps the union in the correlation-key refinement; reach the raw union. expect(RunEventSchema.innerType().options).toHaveLength(CONTRACT_NAMES.length); expect(new Set(RUN_EVENT_TYPES)).toEqual(new Set(CONTRACT_NAMES)); - expect(Object.keys(valid)).toEqual(CONTRACT_NAMES); // the matrix covers all 21 + expect(Object.keys(valid)).toEqual(CONTRACT_NAMES); // the matrix covers all 22 }); it('pins the RunEvent discriminant to RunEventType (type-level)', () => { @@ -624,6 +656,7 @@ describe('event envelope + ErrorCode + attemptNumber invariants', () => { for (const name of [ 'agent:tool_call', 'agent:tool_result', + 'agent:approval_requested', 'node:completed', 'cost:updated', 'agent:file_patch_proposed', @@ -633,6 +666,33 @@ describe('event envelope + ErrorCode + attemptNumber invariants', () => { } }); + it('carries agent:approval_requested on either envelope (dual) with a secret-free preview (ADR-0057 EA5)', () => { + const base = { + type: 'agent:approval_requested', + timestamp: '2026-06-04T00:00:00.000Z', + sequenceNumber: 9, + nodeId: 'n', + toolId: 'run_command', + action: 'process', + preview: { command: 'npm test' }, + }; + // dual: accepted on a run (runId) AND on a session (sessionId), rejected with neither / both. + expect(RunEventSchema.safeParse({ ...base, runId: 'run-1' }).success).toBe(true); + expect(RunEventSchema.safeParse({ ...base, sessionId: 'sess-1' }).success).toBe(true); + expect(RunEventSchema.safeParse(base).success).toBe(false); // neither correlation key + expect(RunEventSchema.safeParse({ ...base, runId: 'r', sessionId: 's' }).success).toBe(false); // both + // egress preview carries the host only (a secret-free, query-free target) + expect( + RunEventSchema.safeParse({ + ...base, + sessionId: 'sess-1', + toolId: 'http_request', + action: 'egress', + preview: { host: 'api.example.com' }, + }).success, + ).toBe(true); + }); + it('rejects an agent:file_patch_proposed with an empty patches array', () => { expect( RunEventSchema.safeParse({ ...valid['agent:file_patch_proposed'], patches: [] }).success, diff --git a/packages/shared/src/run-event.ts b/packages/shared/src/run-event.ts index 6b2f1dc5..d1e3aa2f 100644 --- a/packages/shared/src/run-event.ts +++ b/packages/shared/src/run-event.ts @@ -9,6 +9,7 @@ import { LLM_PROVIDERS, MEDIA_BILLED_MODALITIES, STOP_REASONS, + TOOL_ACTION_CLASSES, } from './constants.js'; import { GateTypeSchema, TimeoutActionSchema } from './node.js'; @@ -176,6 +177,34 @@ export const AgentToolResultEventSchema = z.object({ attemptNumber: positiveInt.optional(), // 1-based within-chain attempt — matches cost:updated }); +/** The closed side-effecting action class a per-tool approval governs (ADR-0057 EA3). The `ToolActionClass` + * TYPE is owned by constants.ts (the `TOOL_ACTION_CLASSES` tuple); this is its validating schema. */ +export const ToolActionClassSchema = z.enum(TOOL_ACTION_CLASSES); + +/** + * A side-effecting tool dispatch is awaiting an interactive per-tool approval decision (ADR-0057 EA3/EA5). + * The host's `ConfirmActionHook` emits it just before prompting the user; the registry then awaits the + * verdict (approve ⇒ dispatch, reject ⇒ a fatal `tool_denied`). A **dual-envelope** event (`runId` on a run, + * `sessionId` on a session) in the `agent:*` namespace, like `agent:tool_call` — in Phase 2.5 it is emitted + * only on the chat session path (the approval regime), and the session sink carries it (not run-only, so it + * is NOT dropped like `agent:file_patch_proposed`). The `preview` is **secret-free and display-only**: the + * resolved target path / command / host (never a full URL+query, never a secret). + */ +export const AgentApprovalRequestedEventSchema = z.object({ + type: z.literal('agent:approval_requested'), + ...dualBase, + nodeId: nonEmptyString, + toolId: nonEmptyString, + action: ToolActionClassSchema, + preview: z.object({ + path: nonEmptyString.optional(), // fs_write — the resolved target path + command: z.string().optional(), // process — the resolved command string the user approves + host: nonEmptyString.optional(), // egress — the target host only (never the full URL / query string) + }), + attemptNumber: positiveInt.optional(), // 1-based within-chain attempt — matches cost:updated/agent:tool_call +}); +export type AgentApprovalRequestedEvent = z.infer; + export const AgentFilePatchProposedEventSchema = z.object({ type: z.literal('agent:file_patch_proposed'), ...runBase, @@ -414,6 +443,7 @@ const RunEventUnionSchema = z.discriminatedUnion('type', [ AgentTokenEventSchema, AgentToolCallEventSchema, AgentToolResultEventSchema, + AgentApprovalRequestedEventSchema, AgentFilePatchProposedEventSchema, CostUpdatedEventSchema, NodeCompletedEventSchema, From 376011af2536867bfe21e0e832b8a31cc58e6213 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Tue, 30 Jun 2026 03:49:03 +0300 Subject: [PATCH 04/45] =?UTF-8?q?fix(core):=202.5.E=20Step=201=20=E2=80=94?= =?UTF-8?q?=20address=20opus=20review=20(fail-closed=20hardening,=20typed?= =?UTF-8?q?=20session=20ports,=20docs)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docs/reference/shared-core/tool-registry.md | 48 +++++++++++ packages/core/src/engine/agent-session.ts | 23 ++++- packages/core/src/engine/session-handle.ts | 13 ++- packages/core/src/tools/errors.ts | 3 +- packages/core/src/tools/registry.test.ts | 93 ++++++++++++++++++++- packages/core/src/tools/registry.ts | 36 +++++++- packages/shared/src/run-event.test.ts | 20 ++++- packages/shared/src/run-event.ts | 36 ++++---- 8 files changed, 241 insertions(+), 31 deletions(-) diff --git a/docs/reference/shared-core/tool-registry.md b/docs/reference/shared-core/tool-registry.md index 759c9d72..da2b854e 100644 --- a/docs/reference/shared-core/tool-registry.md +++ b/docs/reference/shared-core/tool-registry.md @@ -203,6 +203,54 @@ config-only VALUES and the `input_mapping` / `output_mapping` come from. `invoke **engine-provided delegate**, not a `ToolHost` capability — `invoke_agent` is pure orchestration (dispatch another node by id), no platform I/O, no full router selection this phase. +### The per-tool approval seam (`ConfirmActionHook`, ADR-0057 EA3) + +`ctx.approval.confirm` is the host-injected interactive-consent hook the dispatch lifecycle's step 4b +consults — the same dependency-inversion pattern as the `ToolHost` (the engine defines the interface + +the invocation point; the host supplies the implementation, so [ADR-0037](../../decisions/0037-engine-tool-execution-boundary.md)'s +tool-execution boundary holds). The host implementation owns the mode policy (ask / plan / accept-edits / +auto), the once/always cache, the protected-paths rule, and emitting `agent:approval_requested` — the +engine stays mode-agnostic and only asks "may this governed action proceed?" then honors the verdict. The +preview is **secret-free, display-only**. + +```ts +/** Present on `ctx.approval` ⇒ the interactive-approval regime is active (the chat path). Absent ⇒ the + * workflow author-trust path (governed tools proceed under the step-4 floor, unchanged). */ +interface ToolApprovalContext { + readonly confirm?: ConfirmActionHook; // ABSENT under an active regime ⇒ fail-closed deny (no_approval_hook) +} + +type ConfirmActionHook = ( + request: ToolApprovalRequest, + signal?: AbortSignalLike, // SHOULD be honored — an abort while prompting routes to the engine's cancel path +) => Promise; + +interface ToolApprovalRequest { + readonly toolId: ToolId; + readonly action: ToolActionClass; // 'fs_write' | 'process' | 'egress' (@relavium/shared TOOL_ACTION_CLASSES) + readonly preview: ToolActionPreview; +} + +/** Secret-free, display-only — never a full URL/query, never a secret. */ +interface ToolActionPreview { + readonly path?: string; // fs_write — the resolved target path + readonly command?: string; // process — the resolved command string + readonly host?: string; // egress — the target host ONLY +} + +type ToolApprovalDecision = + | { readonly outcome: 'approve' } + | { readonly outcome: 'reject'; readonly reason?: string }; // a secret-free, display-safe label +``` + +A reject ⇒ `ToolDeniedByUserError` (reason `user_rejected`); a hook that throws a non-abort error ⇒ the same +fatal `tool_denied` (reason `approval_error`, fail-closed — consent could not be obtained, never the +retryable `tool_failed` a *host-capability* throw gets); an absent hook under an active regime ⇒ +`tool_denied` (reason `no_approval_hook`). All three carry the existing non-retryable `tool_denied` +`ErrorCode`. The action class is derived from `ToolPolicyClass` (§The `ToolDef`): `fsWrite` ⇒ `fs_write`, +any `egress` ⇒ `egress`, and a `spawnsProcess` **with a model-controlled `command` target** ⇒ `process` +(so the pre-approved `git_status`, which exposes no command, is **not** gated). + ## Guardrail enforcement (policy = engine-pure; mechanism = host) The canonical guardrail home is [security-review.md §Sandbox-and-tool-policy](../../standards/security-review.md#sandbox-and-tool-policy-run_command-node-tools-secret-inputs) and [ADR-0029](../../decisions/0029-tool-policy-hardening.md); this table shows **where each half runs** — every engine-pure check is on the **effective** args (step 4). diff --git a/packages/core/src/engine/agent-session.ts b/packages/core/src/engine/agent-session.ts index 898b1ea8..c77730ce 100644 --- a/packages/core/src/engine/agent-session.ts +++ b/packages/core/src/engine/agent-session.ts @@ -26,6 +26,7 @@ import type { Agent, AbortSignalLike, + AgentApprovalRequestedEvent, ErrorCode, SessionContext, SessionEvent, @@ -73,11 +74,25 @@ export type SessionLifecycleEvent = DistributiveOmit< >; /** - * Everything a session emits, envelope-less: the five `session:*` lifecycle bodies plus the four - * dual-envelope in-turn bodies the turn core produces (`agent:token` / `agent:tool_call` / - * `agent:tool_result` / `cost:updated`). The injected sink receives these; 1.W routes them onto the bus. + * The host-emitted per-tool approval body (ADR-0057 EA5), envelope-less — a **session-carried** event the + * chat approval regime emits through the sink. It is NOT a turn-core in-node event (so not in + * `NodeStreamEvent` / the run path); the host's `ConfirmActionHook` emits it via this same sink. */ -export type SessionStreamEvent = SessionLifecycleEvent | NodeStreamEvent; +export type SessionApprovalStreamEvent = DistributiveOmit< + AgentApprovalRequestedEvent, + 'runId' | 'sessionId' | 'timestamp' | 'sequenceNumber' +>; + +/** + * Everything a session emits, envelope-less: the five `session:*` lifecycle bodies, the four dual-envelope + * in-turn bodies the turn core produces (`agent:token` / `agent:tool_call` / `agent:tool_result` / + * `cost:updated`), and the host-emitted `agent:approval_requested` body (ADR-0057). The injected sink + * receives these; 1.W routes them onto the bus. + */ +export type SessionStreamEvent = + | SessionLifecycleEvent + | NodeStreamEvent + | SessionApprovalStreamEvent; /** The injected emission port. 1.V emits through it; 1.W implements it over the shared `RunEventBus`. */ export type SessionEventSink = (event: SessionStreamEvent) => void; diff --git a/packages/core/src/engine/session-handle.ts b/packages/core/src/engine/session-handle.ts index 80743272..26251fde 100644 --- a/packages/core/src/engine/session-handle.ts +++ b/packages/core/src/engine/session-handle.ts @@ -16,6 +16,7 @@ */ import type { + AgentApprovalRequestedEvent, AgentToolCallEvent, AgentToolResultEvent, AgentTokenEvent, @@ -29,15 +30,17 @@ import type { RunEventBus } from './event-bus.js'; import { BoundedEventStream, DEFAULT_STREAM_CAPACITY } from './event-stream.js'; /** - * The fully-stamped events a session stream carries: the five `session:*` lifecycle events plus the four + * The fully-stamped events a session stream carries: the five `session:*` lifecycle events, the four * dual-envelope in-turn events (`agent:token` / `agent:tool_call` / `agent:tool_result` / `cost:updated`), - * here carrying `sessionId`. The complete session stream per sse-event-schema.md §"Session event namespace". + * and the host-emitted `agent:approval_requested` (ADR-0057) — all carrying `sessionId`. The complete + * session stream per sse-event-schema.md §"Session event namespace". */ export type SessionStreamHandleEvent = | SessionEvent | AgentTokenEvent | AgentToolCallEvent | AgentToolResultEvent + | AgentApprovalRequestedEvent | CostUpdatedEvent; /** The session's sole stream terminal — `turn_completed` is per-turn; `exported` (1.Z) is a side event. */ @@ -80,13 +83,15 @@ export function createSessionEventSink(bus: RunEventBus, sessionId: string): Ses // (agent-turn.ts), so a session never actually emits it. It is the one `NodeStreamEvent` arm with no // session-carrying schema member, so it could not validate on the bus. Drop it at this single // translation point — the contract boundary 1.W owns — keeping the session stream to its - // sse-event-schema.md contract (the five `session:*` + the four dual `agent:*`/`cost:updated`). + // sse-event-schema.md contract (the five `session:*`, the four dual `agent:*`/`cost:updated`, and the + // host-emitted `agent:approval_requested` — ADR-0057). if (event.type === 'agent:file_patch_proposed') { return; } // Attach the correlation key; the bus then stamps the per-session sequenceNumber + timestamp and // validates against the combined RunOrSessionEventSchema. After the guard the body is a `session:*` - // lifecycle body or one of the four dual `agent:*`/`cost:updated` bodies, so `+ sessionId` is a + // lifecycle body, one of the four dual `agent:*`/`cost:updated` bodies, or the host-emitted + // `agent:approval_requested` body, so `+ sessionId` is a // BusEventDraft the session-side `emit` overload accepts (a session lifecycle → `SessionEventDraft`, // a dual body → the optional-`sessionId` `RunEventDraft` arm). The bus stamps and returns it. bus.emit({ ...event, sessionId }); diff --git a/packages/core/src/tools/errors.ts b/packages/core/src/tools/errors.ts index b213c30b..0f2ebd54 100644 --- a/packages/core/src/tools/errors.ts +++ b/packages/core/src/tools/errors.ts @@ -105,7 +105,8 @@ export class ToolDeniedByUserError extends ToolDispatchError { } export type ToolApprovalDenyReason = | 'user_rejected' // the user (or the host's mode policy) rejected the interactive approval prompt - | 'no_approval_hook'; // fail-closed: the approval regime was active for a governed tool but no confirm hook was wired + | 'no_approval_hook' // fail-closed: the approval regime was active for a governed tool but no confirm hook was wired + | 'approval_error'; // fail-closed: the confirm hook itself threw (a non-abort fault), so consent could not be obtained /** The effective argument set failed the tool's validator or the secret-taint check. Field names only. */ export class ToolArgsInvalidError extends ToolDispatchError { diff --git a/packages/core/src/tools/registry.test.ts b/packages/core/src/tools/registry.test.ts index 4ebfa801..27720272 100644 --- a/packages/core/src/tools/registry.test.ts +++ b/packages/core/src/tools/registry.test.ts @@ -345,9 +345,13 @@ describe('ToolRegistry — git_commit human-gate', () => { /* --- per-tool approval (ADR-0057 EA3) --- */ describe('ToolRegistry — per-tool approval (ADR-0057 EA3)', () => { - // The `vi.fn` function-type generic types `.mock.calls[0][0]` as ToolApprovalRequest (no unused impl - // param) and contextually types the decision literals (`'approve'`/`'reject'`). - type ConfirmFn = (req: ToolApprovalRequest) => Promise; + // The `vi.fn` function-type generic types `.mock.calls[0]` as [ToolApprovalRequest, signal?] (no + // unused impl param) and contextually types the decision literals (`'approve'`/`'reject'`). The 2nd + // (signal) param lets a test assert ctx.signal is forwarded to the hook. + type ConfirmFn = ( + req: ToolApprovalRequest, + signal?: AbortSignalLike, + ) => Promise; const approving = () => vi.fn(() => Promise.resolve({ outcome: 'approve' })); const rejecting = (reason?: string) => vi.fn(() => @@ -540,6 +544,89 @@ describe('ToolRegistry — per-tool approval (ADR-0057 EA3)', () => { ); expect(err).toBeInstanceOf(ToolCancelledError); }); + + it('cancels (never executes) when the signal aborts WHILE prompting and the hook still resolves approve', async () => { + // The fail-closed cancellation contract: a hook that ignores the AbortSignal and approves anyway must + // NOT reach the side effect — the trailing throwIfAborted in confirmDispatch catches it. + const signal = { aborted: false }; + const { host, writeFile } = hostWithWriteSpy(); + const confirm = vi.fn(() => { + signal.aborted = true; // the run is cancelled while the prompt is pending… + return Promise.resolve({ outcome: 'approve' }); // …but the hook approves anyway + }); + const err = await rejectsWith( + createToolRegistry({ tools: BUILTIN_TOOLS, host }).dispatch( + call('write_file', { path: './out.txt', content: 'hi' }), + ctx({ approval: { confirm }, signal: signal as never }), + ), + ); + expect(err).toBeInstanceOf(ToolCancelledError); + expect(writeFile).not.toHaveBeenCalled(); + }); + + it('is FAIL-CLOSED on a hook that THROWS a non-abort error — denies (approval_error), never retryable', async () => { + const { host, writeFile } = hostWithWriteSpy(); + const confirm = vi.fn(() => Promise.reject(new Error('hook bug'))); + const err = await rejectsWith( + createToolRegistry({ tools: BUILTIN_TOOLS, host }).dispatch( + call('write_file', { path: './out.txt', content: 'hi' }), + ctx({ approval: { confirm } }), + ), + ); + expect(err).toBeInstanceOf(ToolDeniedByUserError); + expect(err.reason).toBe('approval_error'); + expect(err.runErrorCode).toBe('tool_denied'); // fatal, NOT the retryable tool_failed a host throw gets + expect(err.retryable).toBe(false); + expect(writeFile).not.toHaveBeenCalled(); + }); + + it('gates mcp_call (egress) — confirm prompts with an empty preview; approve runs it, reject denies it', async () => { + const call_ = vi.fn(() => Promise.resolve({ ok: true })); + const host = stubHost({ mcp: { call: call_ } }); + const reg = () => createToolRegistry({ tools: BUILTIN_TOOLS, host }); + // approve + const confirm = approving(); + const out = await reg().dispatch( + call('mcp_call', { server: 's', tool: 't' }), + ctx({ approval: { confirm } }), + ); + expect(out.events.result.success).toBe(true); + expect(call_).toHaveBeenCalledOnce(); + expect(confirm.mock.calls[0]?.[0]).toMatchObject({ + toolId: 'mcp_call', + action: 'egress', + preview: {}, + }); + // reject — the side effect must not run + call_.mockClear(); + const err = await rejectsWith( + reg().dispatch( + call('mcp_call', { server: 's', tool: 't' }), + ctx({ approval: { confirm: rejecting() } }), + ), + ); + expect(err.reason).toBe('user_rejected'); + expect(call_).not.toHaveBeenCalled(); + }); + + it('previews a no-args run_command as the bare command (join yields { command:"ls" }, not "ls ")', async () => { + const confirm = approving(); + await registry().dispatch( + call('run_command', { command: 'ls' }), + ctx({ approval: { confirm }, toolPolicy: { allowedCommands: ['ls'] } }), + ); + expect(confirm.mock.calls[0]?.[0]?.preview).toEqual({ command: 'ls' }); + }); + + it('forwards ctx.signal to the confirm hook as its second argument', async () => { + const signal = { aborted: false }; + const confirm = approving(); + await registry().dispatch( + call('write_file', { path: './out.txt', content: 'hi' }), + ctx({ approval: { confirm }, signal: signal as never }), + ); + expect(confirm.mock.calls[0]?.[1]).toBe(signal); + }); }); /* --- config-only params + I/O mapping --- */ diff --git a/packages/core/src/tools/registry.ts b/packages/core/src/tools/registry.ts index fa76b4d6..e3d9088d 100644 --- a/packages/core/src/tools/registry.ts +++ b/packages/core/src/tools/registry.ts @@ -25,6 +25,7 @@ import { type CreateToolRegistryOptions, type PolicyTarget, type ToolActionPreview, + type ToolApprovalDecision, type ToolCallPart, type ToolDef, type ToolDispatchContext, @@ -347,10 +348,27 @@ async function confirmDispatch( ); } throwIfAborted(ctx, def.id); // do not prompt for a turn that is already aborting - const decision = await approval.confirm( - { toolId: def.id, action, preview: previewFor(action, target) }, - ctx.signal, - ); + + let decision: ToolApprovalDecision; + try { + decision = await approval.confirm( + { toolId: def.id, action, preview: previewFor(action, target) }, + ctx.signal, + ); + } catch (cause) { + // An abort raised WHILE prompting is a cancellation (cancel precedence) — rethrow so the dispatch + // ladder classifies it as `cancelled`, never a denial. Any OTHER throw is a fault in the consent layer + // itself; the approval can't be obtained, so fail-closed: DENY (the side effect must not run on a broken + // gate — never the retryable `tool_failed` a host-capability throw gets). + if (isAbort(cause, ctx)) { + throw cause; + } + throw new ToolDeniedByUserError( + def.id, + 'approval_error', + `tool \`${def.id}\` denied: the approval could not be obtained`, + ); + } if (decision.outcome !== 'approve') { // `decision.reason` is a host-supplied, secret-free label (e.g. "writes are not allowed in ask mode"). const why = @@ -359,6 +377,10 @@ async function confirmDispatch( : ' by the user'; throw new ToolDeniedByUserError(def.id, 'user_rejected', `tool \`${def.id}\` denied${why}`); } + // An abort that landed WHILE the prompt was pending — the hook approved anyway / ignored the signal — + // must still cancel: the governed side effect must not run. Mirrors the post-dispatch guard in dispatch() + // (cancel precedence). Without this, an `approve` that resolves after the signal aborted would proceed. + throwIfAborted(ctx, def.id); } /** @@ -396,6 +418,12 @@ function previewFor(action: ToolActionClass, target: PolicyTarget): ToolActionPr const parsed = extractHttpsHost(target.url); return parsed === null ? {} : { host: parsed.host }; } + default: { + // Exhaustiveness guard — a future ToolActionClass member (e.g. an `os` action) fails loud HERE at + // compile time (the `never` assignment) with a precise error, not a generic "not all paths return". + const exhaustive: never = action; + return exhaustive; + } } } diff --git a/packages/shared/src/run-event.test.ts b/packages/shared/src/run-event.test.ts index 148dd6df..77086b5f 100644 --- a/packages/shared/src/run-event.test.ts +++ b/packages/shared/src/run-event.test.ts @@ -242,6 +242,24 @@ const reject: Record> = { action: 'fs_write', preview: { path: './out.txt' }, }, + 'agent:approval_requested (empty preview path)': { + type: 'agent:approval_requested', + ...env, + nodeId: 'n', + toolId: 'write_file', + action: 'fs_write', + preview: { path: '' }, // path/host are nonEmptyString — an empty display value is rejected + }, + 'agent:approval_requested (stray secret-bearing preview field — .strict)': { + type: 'agent:approval_requested', + ...env, + nodeId: 'n', + toolId: 'http_request', + action: 'egress', + // .strict() on `preview` rejects an unexpected field LOUDLY — a host wiring bug that put a full + // URL/query (a secret-bearing field) into the preview is a parse failure, not a silent strip. + preview: { host: 'api.example.com', url: 'https://api.example.com/x?token=abc' }, + }, 'cost:updated (float costMicrocents)': { ...valid['cost:updated'], costMicrocents: 12.5 }, 'node:completed (bad tokensUsed)': { type: 'node:completed', @@ -605,7 +623,7 @@ describe('SessionEvent union — the agent-first namespace', () => { }); describe('event envelope + ErrorCode + attemptNumber invariants', () => { - it('enforces exactly one of runId / sessionId on the four dual-envelope events', () => { + it('enforces exactly one of runId / sessionId on the dual-envelope events', () => { // A reused event carries runId on a run and sessionId on a session — never neither, never both. const dual = { type: 'agent:token', diff --git a/packages/shared/src/run-event.ts b/packages/shared/src/run-event.ts index d1e3aa2f..ec063bff 100644 --- a/packages/shared/src/run-event.ts +++ b/packages/shared/src/run-event.ts @@ -37,12 +37,14 @@ const runBase = { runId: nonEmptyString, ...timestampSeq }; const sessionBase = { sessionId: nonEmptyString, ...timestampSeq }; /** - * The dual envelope for the four events reused across both streams (`agent:token` / - * `agent:tool_call` / `agent:tool_result` / `cost:updated`): they carry `runId` on a run and - * `sessionId` on a session. A `discriminatedUnion` *member* can't carry a cross-field - * refinement, so the "exactly one of runId / sessionId" invariant is enforced at the **union** - * level (see `RunEventSchema`). Run-only / session-only events satisfy it by construction (the - * other key isn't declared, so it is stripped on parse), so the check only constrains these four. + * The dual envelope for the events that may carry EITHER correlation key: the four reused across both + * streams (`agent:token` / `agent:tool_call` / `agent:tool_result` / `cost:updated`) plus + * `agent:approval_requested` (dual at the schema level, but session-only-emitted in Phase 2.5 — the chat + * approval regime). They carry `runId` on a run and `sessionId` on a session. A `discriminatedUnion` + * *member* can't carry a cross-field refinement, so the "exactly one of runId / sessionId" invariant is + * enforced at the **union** level (see `RunEventSchema`). Run-only / session-only events satisfy it by + * construction (the other key isn't declared, so it is stripped on parse), so the check only constrains + * these `dualBase` events. */ const dualBase = { runId: nonEmptyString.optional(), @@ -196,11 +198,15 @@ export const AgentApprovalRequestedEventSchema = z.object({ nodeId: nonEmptyString, toolId: nonEmptyString, action: ToolActionClassSchema, - preview: z.object({ - path: nonEmptyString.optional(), // fs_write — the resolved target path - command: z.string().optional(), // process — the resolved command string the user approves - host: nonEmptyString.optional(), // egress — the target host only (never the full URL / query string) - }), + // `.strict()` (the MaskedSecretSchema precedent) makes this secret-hygiene boundary REJECT an unexpected + // field (a stray `url` / `query` a host wiring bug might add) LOUDLY rather than silently stripping it. + preview: z + .object({ + path: nonEmptyString.optional(), // fs_write — the resolved target path + command: nonEmptyString.optional(), // process — the resolved command (always non-empty: `min(1)` + join) + host: nonEmptyString.optional(), // egress — the target host only (never the full URL / query string) + }) + .strict(), attemptNumber: positiveInt.optional(), // 1-based within-chain attempt — matches cost:updated/agent:tool_call }); export type AgentApprovalRequestedEvent = z.infer; @@ -467,8 +473,9 @@ const RunEventUnionSchema = z.discriminatedUnion('type', [ * **exactly one of `runId` / `sessionId`** correlation-key invariant (sse-event-schema.md * §"Correlation key"). Run-only / session-only events satisfy it by construction — a stray * opposite key is stripped by their `z.object` before this refine runs, so the parsed output - * stays compliant (the deliberate non-strict, forward-compatible posture). The four - * dual-envelope events declare both keys as optional, so this is where neither/both is rejected. + * stays compliant (the deliberate non-strict, forward-compatible posture). The `dualBase` events (the + * four reused agent/cost events plus `agent:approval_requested`) declare both keys as optional, so this + * is where neither/both is rejected. */ export const RunEventSchema = RunEventUnionSchema.superRefine((event, ctx) => { const hasRunId = 'runId' in event && event.runId !== undefined; @@ -589,7 +596,8 @@ export type SessionEvent = z.infer; * The combined event the shared `RunEventBus` carries — the `run:*`/`node:*` family **and** the * `session:*` family on **one** bus (ADR-0036 "one bus, two namespaces"). A `z.union` (not a flat * discriminated union) so each family keeps its own refinements — notably `RunEventSchema`'s correlation-key - * cross-check and its dual-envelope members (the four `agent:*`/`cost:updated` events already validate here + * cross-check and its dual-envelope members (the four `agent:*`/`cost:updated` events, plus the session-only + * `agent:approval_requested`, already validate here * carrying `sessionId`); a `session:*` lifecycle event matches the `SessionEventSchema` arm. This is the * single validation gate the bus parses against; the per-correlation-key `sequenceNumber` is assigned there. */ From c470871c9818164227b1a0e2120115509881c885 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Tue, 30 Jun 2026 04:10:22 +0300 Subject: [PATCH 05/45] =?UTF-8?q?fix(core):=202.5.E=20Step=201=20=E2=80=94?= =?UTF-8?q?=20address=20sonnet=20review=20(doc=20completeness,=20barrel=20?= =?UTF-8?q?export,=20test=20coverage)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docs/reference/contracts/sse-event-schema.md | 3 +- docs/reference/shared-core/tool-registry.md | 5 +- .../core/src/engine/session-handle.test.ts | 26 +++++++ packages/core/src/index.ts | 3 + packages/core/src/tools/registry.test.ts | 71 ++++++++++++++++++- packages/core/src/tools/registry.ts | 4 +- packages/shared/src/run-event.test.ts | 18 ++++- packages/shared/src/run-event.ts | 16 ++--- 8 files changed, 131 insertions(+), 15 deletions(-) diff --git a/docs/reference/contracts/sse-event-schema.md b/docs/reference/contracts/sse-event-schema.md index 3381d8e9..dfc1648f 100644 --- a/docs/reference/contracts/sse-event-schema.md +++ b/docs/reference/contracts/sse-event-schema.md @@ -33,7 +33,7 @@ interface BaseEvent { } ``` -> **Correlation key.** Exactly one of `runId` / `sessionId` is present — `runId` on a workflow run, `sessionId` on an agent session. The reused `agent:token` / `agent:tool_call` / `agent:tool_result` / `cost:updated` events carry `runId` on a run and `sessionId` on a session; consumers route on whichever is present. +> **Correlation key.** Exactly one of `runId` / `sessionId` is present — `runId` on a workflow run, `sessionId` on an agent session. The reused `agent:token` / `agent:tool_call` / `agent:tool_result` / `cost:updated` events carry `runId` on a run and `sessionId` on a session; `agent:approval_requested` (ADR-0057) is also dual-envelope but **session-only-emitted** in Phase 2.5. Consumers route on whichever is present. `sequenceNumber` is monotonic per run and is the basis for **gap detection**: if a consumer sees a jump in `sequenceNumber`, it triggers a full state resync (re-read the durable run state) rather than trusting a partial view. This is what makes reconnection lossless. The **envelope** fields (`sessionId` / `runId`, `sequenceNumber`, `timestamp`) are stamped by the bus, not the producer: `WorkflowEngine` emits through the `RunEventBus`, and `AgentSession` (1.V) emits *envelope-free payload drafts* through an injected `SessionEventSink` — wiring that sink onto the bus, where the per-session `sequenceNumber` (and its same gap/resync rule) is assigned, is **1.W**. So a session's monotonic numbering is the bus's responsibility, not the session core's. @@ -46,6 +46,7 @@ export type RunEvent = | AgentTokenEvent | AgentToolCallEvent | AgentToolResultEvent + | AgentApprovalRequestedEvent // dual-envelope; session-only-emitted in Phase 2.5 (ADR-0057) | AgentFilePatchProposedEvent | CostUpdatedEvent | NodeCompletedEvent diff --git a/docs/reference/shared-core/tool-registry.md b/docs/reference/shared-core/tool-registry.md index da2b854e..22ad74b3 100644 --- a/docs/reference/shared-core/tool-registry.md +++ b/docs/reference/shared-core/tool-registry.md @@ -58,7 +58,7 @@ interface ToolDef { * SECURITY: a `spawnsProcess` / `egress:'http'` tool that omits this has its allowlist check silently * skipped (fail-open) — supply it, or pin the model-controlled args as config-only. */ - readonly policyTarget?: (args: Args) => PolicyTarget; // { command?: string; url?: string } + readonly policyTarget?: (args: Args) => PolicyTarget; // { command?: string; url?: string; path?: string } /** * Pure dispatcher: validated+merged effective args in, the FULL result out. Performs side effects @@ -172,7 +172,7 @@ host is touched once, in the middle. 7. **Bound the model-facing result** (§Result bounding and spill-to-file) from the result via `ctx.limits` + the host `outputStore` — over the ceiling the model gets a preview + a spill handle, the full result still flows to `output_mapping`. 8. **Mark the result untrusted** (§Untrusted-data taint) and hand the structured `tool_call` / `tool_result` data + its taint/secret markers to the bus's single translation point ([ADR-0036](../../decisions/0036-run-loop-substrate-event-bus-and-execution-host.md)) for `agent:tool_call` / `agent:tool_result` emission. -> **Loop-correctable vs terminal.** `UnknownToolError` and `ToolArgsInvalidError` are **thrown** by the registry; the agent loop (1.O) **catches** them and synthesizes a correctable `isError` `tool_result` (from the secret-free `error.message`) so the model can fix its call, within a **bounded correction budget** it owns — escalating to a node `ErrorCode` only when that budget is spent. A `ToolPolicyError` is structurally fatal (`tool_denied`) and **never** fed back as a correctable result (re-asking a denied tool just burns budget). See [agent-runner.md §the failure ladder](agent-runner.md). A `ToolCancelledError` maps to `cancelled` ahead of all other classifications (cancel wins). +> **Loop-correctable vs terminal.** `UnknownToolError` and `ToolArgsInvalidError` are **thrown** by the registry; the agent loop (1.O) **catches** them and synthesizes a correctable `isError` `tool_result` (from the secret-free `error.message`) so the model can fix its call, within a **bounded correction budget** it owns — escalating to a node `ErrorCode` only when that budget is spent. A `ToolPolicyError` — and, identically, a `ToolDeniedByUserError` (the per-tool approval denial, ADR-0057) — is structurally fatal (`tool_denied`) and **never** fed back as a correctable result (re-asking a denied tool just burns budget). See [agent-runner.md §the failure ladder](agent-runner.md). A `ToolCancelledError` maps to `cancelled` ahead of all other classifications (cancel wins). ```ts interface ToolDispatchContext { @@ -320,6 +320,7 @@ codes by [sse-event-schema.md](../contracts/sse-event-schema.md#error-code-taxon |-------|------|-----------------|-------| | `UnknownToolError` | id not an exact match | `tool_failed` | fatal (loop-correctable first) | | `ToolPolicyError` | a guardrail / grant denial — `not_granted`, `provider_executed`, `command_not_allowed`, `domain_not_allowed`, `insecure_url`, `gate_required`, `media_scope_denied` (the full `ToolPolicyDenyReason` union; `media_scope_denied` is `read_media`'s scope-set denial, [ADR-0044](../../decisions/0044-media-access-governance-read-media-save-to-cost.md) §1) | `tool_denied` | **fatal** (never retried) | +| `ToolDeniedByUserError` | an interactive **per-tool approval** denial ([ADR-0057](../../decisions/0057-cli-chat-modes-and-per-tool-approval.md) EA3) — `user_rejected` (rejected by the user / mode policy), `no_approval_hook` (fail-closed: a governed dispatch under an active regime with no confirm hook wired), `approval_error` (fail-closed: the hook threw a non-abort error, so consent could not be obtained) | `tool_denied` | **fatal** (never retried; not loop-correctable — re-asking re-prompts/re-denies, like `ToolPolicyError`) | | `ToolArgsInvalidError` | effective args fail `parseArgs` / secret-taint | `validation` | fatal (loop-correctable first) | | `ToolUnavailableError` | the required `ToolHost` capability is absent (host/config gap, not the model's fault) | `tool_unavailable` | **fatal** (names the tool + the unwired arm actionably — never a bare `internal`; EA1, [ADR-0055](../../decisions/0055-cli-host-capability-seam-tool-environment-factory.md)) | | `ToolExecutionError` | the host capability threw a non-cancel error (cause kept off the message, for logs) | `tool_failed` | retryable (node budget) | diff --git a/packages/core/src/engine/session-handle.test.ts b/packages/core/src/engine/session-handle.test.ts index d2f37ced..c187a314 100644 --- a/packages/core/src/engine/session-handle.test.ts +++ b/packages/core/src/engine/session-handle.test.ts @@ -192,4 +192,30 @@ describe('createSessionEventSink (1.W) — AgentSession envelope-free drafts → // The dropped event consumed no sequence number — cancelled is seq 1, not 2. expect(events.map((e) => e.sequenceNumber)).toEqual([0, 1]); }); + + it('CARRIES the host-emitted agent:approval_requested onto the session stream (ADR-0057, not dropped)', async () => { + // The inverse of the file_patch_proposed drop: approval_requested IS a session-carried event, so the + // sink attaches the sessionId, the bus stamps a sequence, and a consumer sees it. A future refactor that + // accidentally added it to the drop guard would fail here. + const b = bus(); + const handle = createSessionHandle(b, 'sess-1', () => undefined); + const sink = createSessionEventSink(b, 'sess-1'); + sink({ type: 'session:started', agentRef: 'chatter', model: 'claude-opus-4-8', context: CTX }); + sink({ + type: 'agent:approval_requested', + nodeId: 'n', + toolId: 'write_file', + action: 'fs_write', + preview: { path: './out.txt' }, + }); + sink({ type: 'session:cancelled' }); + const events = await drain(handle.events); + expect(events.map((e) => e.type)).toEqual([ + 'session:started', + 'agent:approval_requested', + 'session:cancelled', + ]); + expect(events.every((e) => e.sessionId === 'sess-1')).toBe(true); + expect(events.map((e) => e.sequenceNumber)).toEqual([0, 1, 2]); // it consumed a sequence (was carried) + }); }); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f25f07cc..e3b71167 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -191,6 +191,9 @@ export type { SessionEventSink, SessionStreamEvent, SessionLifecycleEvent, + // The envelope-less `agent:approval_requested` body the host's ConfirmActionHook emits through the + // SessionEventSink (ADR-0057 EA5; the Step-4 chat-approval wiring needs to reference it by name). + SessionApprovalStreamEvent, } from './engine/agent-session.js'; // Session checkpoint/resume (1.Y) — reconstruct the in-flight state from a persisted transcript (1.X) so a // session continues after a restart; the host loads via the @relavium/db SessionStore and hands the result diff --git a/packages/core/src/tools/registry.test.ts b/packages/core/src/tools/registry.test.ts index 27720272..4e02a920 100644 --- a/packages/core/src/tools/registry.test.ts +++ b/packages/core/src/tools/registry.test.ts @@ -532,17 +532,40 @@ describe('ToolRegistry — per-tool approval (ADR-0057 EA3)', () => { expect(confirm).not.toHaveBeenCalled(); }); - it('classifies an abort raised while prompting as cancelled (cancel precedence), not a denial', async () => { + it('classifies an abort raised while prompting (hook throws AbortError) as cancelled, not a denial', async () => { + const { host, writeFile } = hostWithWriteSpy(); const confirm = vi.fn(() => Promise.reject(Object.assign(new Error('prompt aborted'), { name: 'AbortError' })), ); const err = await rejectsWith( - registry().dispatch( + createToolRegistry({ tools: BUILTIN_TOOLS, host }).dispatch( call('write_file', { path: './out.txt', content: 'hi' }), ctx({ approval: { confirm } }), ), ); expect(err).toBeInstanceOf(ToolCancelledError); + expect(writeFile).not.toHaveBeenCalled(); + }); + + it('cancel-wins-all: a signal that aborts mid-prompt + a hook that throws a PLAIN error classifies as cancelled, not approval_error', async () => { + // Exercises the `ctx.signal?.aborted` branch of isAbort in the confirm catch (the AbortError test + // above exercises only the cause.name branch), and proves cancel precedence over the fail-closed deny. + // The signal must flip DURING the prompt (not before): an already-aborted signal short-circuits at the + // dispatch entry guard before the hook ever runs. + const signal = { aborted: false }; + const { host, writeFile } = hostWithWriteSpy(); + const confirm = vi.fn(() => { + signal.aborted = true; // cancelled while the prompt is pending… + return Promise.reject(new Error('hook bug while cancelling')); // …and the hook then throws a plain error + }); + const err = await rejectsWith( + createToolRegistry({ tools: BUILTIN_TOOLS, host }).dispatch( + call('write_file', { path: './out.txt', content: 'hi' }), + ctx({ approval: { confirm }, signal: signal as never }), + ), + ); + expect(err).toBeInstanceOf(ToolCancelledError); + expect(writeFile).not.toHaveBeenCalled(); }); it('cancels (never executes) when the signal aborts WHILE prompting and the hook still resolves approve', async () => { @@ -627,6 +650,50 @@ describe('ToolRegistry — per-tool approval (ADR-0057 EA3)', () => { ); expect(confirm.mock.calls[0]?.[1]).toBe(signal); }); + + it('denies a rejected http_request (egress) — the host fetch is never reached', async () => { + const fetch = vi.fn(() => Promise.resolve({ status: 200, headers: {}, body: '{}' })); + const err = await rejectsWith( + createToolRegistry({ tools: BUILTIN_TOOLS, host: stubHost({ egress: { fetch } }) }).dispatch( + call('http_request', { url: 'https://api.example.com/x' }), + ctx({ + approval: { confirm: rejecting() }, + toolPolicy: { allowedDomains: ['api.example.com'] }, + }), + ), + ); + expect(err.reason).toBe('user_rejected'); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('denies a rejected run_command (process) — the host spawn is never reached', async () => { + const spawn = vi.fn(() => + Promise.resolve({ exitCode: 0, stdout: 'ok', stderr: '', durationMs: 1 }), + ); + const err = await rejectsWith( + createToolRegistry({ tools: BUILTIN_TOOLS, host: stubHost({ process: { spawn } }) }).dispatch( + call('run_command', { command: 'ls' }), + ctx({ approval: { confirm: rejecting() }, toolPolicy: { allowedCommands: ['ls'] } }), + ), + ); + expect(err.reason).toBe('user_rejected'); + expect(spawn).not.toHaveBeenCalled(); + }); + + it('cancel-wins-all: an already-aborted signal yields cancelled (never a fail-closed deny) for a governed write', async () => { + // An already-aborted signal short-circuits at the dispatch entry guard (cancel precedence, ADR-0036) + // BEFORE the approval gate runs — so a would-be fail-closed `no_approval_hook` deny on an aborted turn + // correctly surfaces as cancelled, not denied, and the side effect never runs. + const { host, writeFile } = hostWithWriteSpy(); + const err = await rejectsWith( + createToolRegistry({ tools: BUILTIN_TOOLS, host }).dispatch( + call('write_file', { path: './out.txt', content: 'hi' }), + ctx({ approval: {}, signal: { aborted: true } as never }), + ), + ); + expect(err).toBeInstanceOf(ToolCancelledError); + expect(writeFile).not.toHaveBeenCalled(); + }); }); /* --- config-only params + I/O mapping --- */ diff --git a/packages/core/src/tools/registry.ts b/packages/core/src/tools/registry.ts index e3d9088d..1e849379 100644 --- a/packages/core/src/tools/registry.ts +++ b/packages/core/src/tools/registry.ts @@ -108,7 +108,9 @@ async function dispatch( // 4b-7. The per-tool approval gate + the single side effect + output_mapping (FULL result) + model-facing // bounding — all under one classification ladder so a spill-time (or prompt-time) abort surfaces as // `cancelled` (ADR-0036 precedence) and any other tail failure is a classified tool error, never a raw - // escape. A `ToolDeniedByUserError` from 4b is a `ToolDispatchError` and passes through the ladder verbatim. + // escape. A `ToolDeniedByUserError` from 4b is a `ToolDispatchError` and passes through the ladder verbatim + // — UNLESS the signal is concurrently aborted, in which case the `isAbort` check below takes precedence + // (cancel-wins-all, ADR-0036) and the denial becomes `ToolCancelledError`. let outputMapped: unknown; let bounded: Awaited>; try { diff --git a/packages/shared/src/run-event.test.ts b/packages/shared/src/run-event.test.ts index 77086b5f..f7aba746 100644 --- a/packages/shared/src/run-event.test.ts +++ b/packages/shared/src/run-event.test.ts @@ -248,7 +248,23 @@ const reject: Record> = { nodeId: 'n', toolId: 'write_file', action: 'fs_write', - preview: { path: '' }, // path/host are nonEmptyString — an empty display value is rejected + preview: { path: '' }, // path/command/host are all nonEmptyString — an empty display value is rejected + }, + 'agent:approval_requested (empty preview command)': { + type: 'agent:approval_requested', + ...env, + nodeId: 'n', + toolId: 'run_command', + action: 'process', + preview: { command: '' }, // command was tightened to nonEmptyString (symmetry with path/host) + }, + 'agent:approval_requested (empty preview host)': { + type: 'agent:approval_requested', + ...env, + nodeId: 'n', + toolId: 'http_request', + action: 'egress', + preview: { host: '' }, }, 'agent:approval_requested (stray secret-bearing preview field — .strict)': { type: 'agent:approval_requested', diff --git a/packages/shared/src/run-event.ts b/packages/shared/src/run-event.ts index ec063bff..a4a2fdb1 100644 --- a/packages/shared/src/run-event.ts +++ b/packages/shared/src/run-event.ts @@ -578,10 +578,10 @@ export const SessionExportedEventSchema = z.object({ }); /** - * The five `session:*` lifecycle events. Within a turn a session also reuses the four - * dual-envelope events above (`agent:token` / `agent:tool_call` / `agent:tool_result` / - * `cost:updated`), carried with `sessionId` — so the complete session stream is this union - * plus those four. + * The five `session:*` lifecycle events. Within a turn a session also reuses the four dual-envelope events + * above (`agent:token` / `agent:tool_call` / `agent:tool_result` / `cost:updated`) plus, on the chat + * approval path, `agent:approval_requested` (ADR-0057) — all carried with `sessionId` — so the complete + * session stream is this union plus those five (four when the approval regime is inactive). */ export const SessionEventSchema = z.discriminatedUnion('type', [ SessionStartedEventSchema, @@ -596,10 +596,10 @@ export type SessionEvent = z.infer; * The combined event the shared `RunEventBus` carries — the `run:*`/`node:*` family **and** the * `session:*` family on **one** bus (ADR-0036 "one bus, two namespaces"). A `z.union` (not a flat * discriminated union) so each family keeps its own refinements — notably `RunEventSchema`'s correlation-key - * cross-check and its dual-envelope members (the four `agent:*`/`cost:updated` events, plus the session-only - * `agent:approval_requested`, already validate here - * carrying `sessionId`); a `session:*` lifecycle event matches the `SessionEventSchema` arm. This is the - * single validation gate the bus parses against; the per-correlation-key `sequenceNumber` is assigned there. + * cross-check and its five `dualBase` members (the four `agent:*`/`cost:updated` events plus + * `agent:approval_requested`, which carry `sessionId` when session-emitted and `runId` on a run); a + * `session:*` lifecycle event matches the `SessionEventSchema` arm. This is the single validation gate the + * bus parses against; the per-correlation-key `sequenceNumber` is assigned there. */ export const RunOrSessionEventSchema = z.union([RunEventSchema, SessionEventSchema]); export type RunOrSessionEvent = RunEvent | SessionEvent; From 52bc115e203e905714b09259d50c4829ee3d0e15 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Tue, 30 Jun 2026 04:32:31 +0300 Subject: [PATCH 06/45] =?UTF-8?q?feat(core):=202.5.E=20Step=202=20?= =?UTF-8?q?=E2=80=94=20AgentSession=20reseat-less=20modes=20+=20mid-turn?= =?UTF-8?q?=20abort=20(ADR-0057=20EA7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/cli/src/render/tui/session-view-model.ts | 6 +- .../reference/contracts/agent-session-spec.md | 6 +- docs/reference/contracts/sse-event-schema.md | 4 +- .../core/src/engine/agent-session.test.ts | 151 +++++++++++++++++- packages/core/src/engine/agent-session.ts | 135 +++++++++++++++- packages/core/src/index.ts | 2 + packages/shared/src/constants.ts | 10 ++ packages/shared/src/run-event.test.ts | 5 +- packages/shared/src/run-event.ts | 9 +- 9 files changed, 312 insertions(+), 16 deletions(-) diff --git a/apps/cli/src/render/tui/session-view-model.ts b/apps/cli/src/render/tui/session-view-model.ts index 6fb68512..66f6bcba 100644 --- a/apps/cli/src/render/tui/session-view-model.ts +++ b/apps/cli/src/render/tui/session-view-model.ts @@ -1,5 +1,5 @@ import type { SessionStreamHandleEvent } from '@relavium/core'; -import type { StopReason } from '@relavium/shared'; +import type { SessionStopReason } from '@relavium/shared'; /** * The pure, framework-free view model for the `relavium chat` ink REPL (workstream **2.M**) — the session @@ -30,7 +30,9 @@ export interface ToolCallView { /** The per-turn summary shown after a completed assistant turn. */ export interface TurnSummary { - readonly stopReason: StopReason; + // The SESSION stop-reason superset — the five LLM `StopReason`s plus `'aborted'` (the EA7 mid-turn abort, + // ADR-0057); a `session:turn_completed` can carry `'aborted'`, so this mirrors the event field exactly. + readonly stopReason: SessionStopReason; readonly tokensUsed: { readonly input: number; readonly output: number }; readonly durationMs?: number; /** The closed error-taxonomy code (safe to display) — the projection renders this, not the message. */ diff --git a/docs/reference/contracts/agent-session-spec.md b/docs/reference/contracts/agent-session-spec.md index 92a4dccc..cf0f8d51 100644 --- a/docs/reference/contracts/agent-session-spec.md +++ b/docs/reference/contracts/agent-session-spec.md @@ -38,6 +38,8 @@ stateDiagram-v2 Idle --> Streaming: sendMessage(text) Streaming --> Streaming: tool-call round-trip Streaming --> Idle: assistant turn complete + Streaming --> Idle: abort (Esc) — turn ends, session lives (ADR-0057) + Idle --> Idle: setTurnPolicy (reseat-less mode change, ADR-0057) Idle --> Idle: resume (reload from history.db) Idle --> [*]: cancel / end Idle --> Exported: export → .relavium.yaml @@ -47,7 +49,9 @@ stateDiagram-v2 | --- | --- | | **start** | Open a session for an `agentRef` with an initial [`SessionContext`](#session-context). Allocates a `sessionId` and persists the session row. | | **sendMessage** | Append a user [`SessionMessage`](#session-messages), run one assistant turn through the `AgentRunner` (streaming + tool-call loop), and append the assistant + tool messages. | -| **cancel** | Abort the in-flight turn via `AbortSignal`; the session stays resumable. | +| **setTurnPolicy** | Set/clear the **reseat-less mode policy** (ADR-0057) — the advertise-filter + the interactive approval hook — on the **same** session instance (no reseat, no tool-context loss). Snapshotted at each turn start, so a change applies on the **next** turn. The ask / plan / accept-edits / auto enum lives in the host; this is its mode-agnostic engine projection. | +| **abort** | **Mid-turn abort** (ADR-0057 EA7): end the *in-flight turn* via its `AbortSignal` but **keep the session alive** — settle **one** `session:turn_completed{stopReason:'aborted'}` (no error), roll the pending user message back, and return to `idle`. **Distinct from `cancel`** (which is terminal): no `session:cancelled`, no new status. No-op when no turn is in flight; a concurrent `cancel` wins. | +| **cancel** | Abort the in-flight turn via `AbortSignal` **and end the session** (the terminal `session:cancelled`); the session stays resumable from its persisted transcript. | | **resume** | Reload a persisted session (messages + context) and continue. | | **export** | Serialize the session to a `.relavium.yaml` scaffold ([export](#export-to-workflow)). | diff --git a/docs/reference/contracts/sse-event-schema.md b/docs/reference/contracts/sse-event-schema.md index dfc1648f..eefc602e 100644 --- a/docs/reference/contracts/sse-event-schema.md +++ b/docs/reference/contracts/sse-event-schema.md @@ -263,12 +263,12 @@ interface BaseSessionEvent { export type SessionEvent = | SessionStartedEvent // 'session:started' — { agentRef, model, context } | SessionTurnStartedEvent // 'session:turn_started' — a user message began an assistant turn - | SessionTurnCompletedEvent // 'session:turn_completed' — { stopReason, tokensUsed, error? } + | SessionTurnCompletedEvent // 'session:turn_completed' — { stopReason, tokensUsed, error? }; stopReason is the SESSION superset (the 5 LLM StopReasons + 'aborted' — the EA7 mid-turn abort, ADR-0057) | SessionCancelledEvent // 'session:cancelled' — the in-flight turn was aborted | SessionExportedEvent; // 'session:exported' — { workflowPath } (chat-to-workflow export) ``` -A turn that **fails** (a provider error, a rate limit, an exhausted budget cap) still emits `session:turn_completed` with an `error?: { code, message, retryable, correlationId? }` — the same closed [`ErrorCode`](#error-code-taxonomy) taxonomy and secret-free correlation id as run events (ADR-0036) — so a surface can render the failure rather than a silent stall. On such a failure `tokensUsed` carries the turn's **real accumulated usage** — non-zero when a provider had already engaged before the failure (e.g. a tool round or a later turn that then failed), and zero when the failure was pre-egress (a no-plan-entries / budget-pause / hard-cap block engaged no provider); EA2, [ADR-0055](../../decisions/0055-cli-host-capability-seam-tool-environment-factory.md). A **cancellation** is distinct: it emits `session:cancelled` (not `turn_completed`) and the in-flight user message is rolled back from the transcript, so a cancelled turn leaves no partial assistant turn behind (see [agent-session-spec.md](agent-session-spec.md)). +A turn that **fails** (a provider error, a rate limit, an exhausted budget cap) still emits `session:turn_completed` with an `error?: { code, message, retryable, correlationId? }` — the same closed [`ErrorCode`](#error-code-taxonomy) taxonomy and secret-free correlation id as run events (ADR-0036) — so a surface can render the failure rather than a silent stall. On such a failure `tokensUsed` carries the turn's **real accumulated usage** — non-zero when a provider had already engaged before the failure (e.g. a tool round or a later turn that then failed), and zero when the failure was pre-egress (a no-plan-entries / budget-pause / hard-cap block engaged no provider); EA2, [ADR-0055](../../decisions/0055-cli-host-capability-seam-tool-environment-factory.md). A **cancellation** is distinct: it emits `session:cancelled` (not `turn_completed`) and the in-flight user message is rolled back from the transcript, so a cancelled turn leaves no partial assistant turn behind (see [agent-session-spec.md](agent-session-spec.md)). A **mid-turn abort** (ADR-0057 EA7, the `Esc` interrupt) is distinct from BOTH: it settles **one** `session:turn_completed` with `stopReason: 'aborted'` and **no** `error` (it is user-initiated, not a failure), rolls the pending user message back, and **keeps the session alive** (→ idle) — it is **not** `session:cancelled` (the session continues). `'aborted'` is a session-only stop-reason (the `@relavium/llm` `StopReason` stays its five values). Within a turn, the conversational work reuses the **same** `agent:token` / `agent:tool_call` / `agent:tool_result` / `cost:updated` event shapes the `AgentRunner` already emits — carried on the session envelope (`sessionId`). The per-turn append of user/assistant/tool messages is persisted as `session_messages` (see [database-schema.md](../desktop/database-schema.md)); the contract is owned by [agent-session-spec.md](agent-session-spec.md). On every surface session events are produced and consumed **in-process** exactly like run events — only `llm_stream` crosses IPC on the desktop ([ipc-contract.md](ipc-contract.md#run-events-are-webview-side)). So the **complete typed event stream for a session** is the five `session:*` lifecycle events (the `SessionEvent` union above) **plus** `agent:token` / `agent:tool_call` / `agent:tool_result` / `cost:updated` (and, on the chat approval path, `agent:approval_requested` — ADR-0057) carrying `sessionId` — this full set is exactly what `relavium chat --json` emits. diff --git a/packages/core/src/engine/agent-session.test.ts b/packages/core/src/engine/agent-session.test.ts index b9af213b..5db66487 100644 --- a/packages/core/src/engine/agent-session.test.ts +++ b/packages/core/src/engine/agent-session.test.ts @@ -13,8 +13,9 @@ import { } from '@relavium/shared'; import { describe, expect, it } from 'vitest'; +import { BUILTIN_TOOLS } from '../tools/builtins.js'; import { ToolExecutionError } from '../tools/errors.js'; -import type { ToolRegistry, ToolResultPart } from '../tools/types.js'; +import type { ToolDispatchContext, ToolRegistry, ToolResultPart } from '../tools/types.js'; import { markUntrusted } from '../tools/untrusted.js'; import { AgentSession, @@ -635,3 +636,151 @@ describe('AgentSession → createSessionEventSink → RunEventBus → SessionHan expect(events.map((e) => e.sequenceNumber)).toEqual(events.map((_, i) => i)); }); }); + +describe('AgentSession — reseat-less modes + mid-turn abort (ADR-0057 Step 2)', () => { + it('abort() ends the in-flight turn as turn_completed{aborted} (no error) and keeps the session alive', async () => { + const { deps, events } = harness([textTurn('partial'), textTurn('second')]); + const s = session(deps); + s.start(); + const p = s.sendMessage('hi'); + s.abort(); // mid-turn: the turn core is suspended at the provider stream await + await p; + + const completes = events.filter((e) => e.type === 'session:turn_completed'); + expect(completes).toHaveLength(1); + const aborted = completes[0]; + expect(aborted?.type === 'session:turn_completed' && aborted.stopReason).toBe('aborted'); + // aborted carries NO error (user-initiated, not a failure) and is NOT the terminal session:cancelled. + expect(aborted?.type === 'session:turn_completed' ? aborted.error : 'x').toBeUndefined(); + expect(typesOf(events)).not.toContain('session:cancelled'); + + // The session is alive — a second turn runs to normal completion. + await s.sendMessage('again'); + const after = events.filter((e) => e.type === 'session:turn_completed'); + expect(after).toHaveLength(2); + expect(after[1]?.type === 'session:turn_completed' && after[1].stopReason).toBe('stop'); + }); + + it('abort() rolls the pending user message back — the aborted turn leaves no transcript trace', async () => { + const scripts = [textTurn('partial'), textTurn('answer')]; + const seenUserTexts: string[][] = []; + const provider: LlmProvider = { + id: 'anthropic', + supports: CAPS, + generate: () => { + throw new Error('unused'); + }, + stream: (req) => { + seenUserTexts.push( + req.messages + .filter((m) => m.role === 'user') + .flatMap((m) => m.content.flatMap((c) => (c.type === 'text' ? [c.text] : []))), + ); + return streamOf(scripts[seenUserTexts.length - 1] ?? []); + }, + }; + const { deps } = harness(scripts, { resolveProvider: () => provider }); + const s = session(deps); + s.start(); + const p = s.sendMessage('aborted-msg'); + s.abort(); + await p; + await s.sendMessage('kept-msg'); + // The LAST outbound request (turn 2) carries ONLY 'kept-msg' — never ['aborted-msg', 'kept-msg']. + // The aborted turn's user message was rolled back, so it is not carried into the next turn's transcript. + // (Robust to whether turn 1 reached the provider before the abort landed.) + expect(seenUserTexts.at(-1)).toEqual(['kept-msg']); + }); + + it('abort() is a no-op when no turn is in flight (idle) — emits nothing, the session stays usable', async () => { + const { deps, events } = harness([textTurn('ok')]); + const s = session(deps); + s.start(); + s.abort(); // idle — nothing to abort + expect(typesOf(events)).toEqual(['session:started']); + await s.sendMessage('hi'); + expect(events.filter((e) => e.type === 'session:turn_completed')).toHaveLength(1); + }); + + it('cancel() wins over a concurrent abort() — session:cancelled is the terminal, no turn_completed{aborted}', async () => { + const { deps, events } = harness([textTurn('partial')]); + const s = session(deps); + s.start(); + const p = s.sendMessage('hi'); + s.abort(); + s.cancel(); // terminal precedence over the abort + await p; + expect(typesOf(events)).toContain('session:cancelled'); + expect(events.filter((e) => e.type === 'session:turn_completed')).toHaveLength(0); + }); + + it('setTurnPolicy advertise-filter narrows the model-visible tool set (lossless, next turn)', async () => { + const readFileDef = BUILTIN_TOOLS.find((t) => t.id === 'read_file'); + if (readFileDef === undefined) throw new Error('read_file builtin missing'); + const scripts = [textTurn('a'), textTurn('b')]; + let advertised: string[] = []; + let n = 0; + const provider: LlmProvider = { + id: 'anthropic', + supports: CAPS, + generate: () => { + throw new Error('unused'); + }, + stream: (req) => { + advertised = (req.tools ?? []).map((t) => t.name); + return streamOf(scripts[n++] ?? []); + }, + }; + const reader = AgentSchema.parse({ + id: 'reader', + model: 'claude-opus-4-8', + provider: 'anthropic', + system_prompt: 'x', + tools: ['read_file'], + }); + const { deps } = harness(scripts, { resolveProvider: () => provider, tools: [readFileDef] }); + const s = session(deps, reader); + s.start(); + await s.sendMessage('no policy'); // advertise every granted tool + expect(advertised).toContain('read_file'); + s.setTurnPolicy({ advertise: (id) => id !== 'read_file' }); // filter it out next turn + await s.sendMessage('filtered'); + expect(advertised).not.toContain('read_file'); + }); + + it('setTurnPolicy activates the approval regime — the dispatch context carries the confirm hook', async () => { + const confirm = (): Promise<{ outcome: 'approve' }> => Promise.resolve({ outcome: 'approve' }); + let captured: ToolDispatchContext | undefined; + const capturing: ToolRegistry = { + has: () => true, + list: () => ['echo'], + dispatch: (toolCall, ctx) => { + captured = ctx; + return echoRegistry.dispatch(toolCall, ctx); + }, + }; + const { deps } = harness([toolUseTurn('c1'), textTurn('done')], {}, capturing); + const s = session(deps, TOOL_AGENT); + s.start(); + s.setTurnPolicy({ confirm }); + await s.sendMessage('use echo'); + expect(captured?.approval?.confirm).toBe(confirm); + }); + + it('no turn policy ⇒ no approval regime in the dispatch context (workflow author-trust parity)', async () => { + let captured: ToolDispatchContext | undefined; + const capturing: ToolRegistry = { + has: () => true, + list: () => ['echo'], + dispatch: (toolCall, ctx) => { + captured = ctx; + return echoRegistry.dispatch(toolCall, ctx); + }, + }; + const { deps } = harness([toolUseTurn('c1'), textTurn('done')], {}, capturing); + const s = session(deps, TOOL_AGENT); + s.start(); + await s.sendMessage('use echo'); // no setTurnPolicy + expect(captured?.approval).toBeUndefined(); + }); +}); diff --git a/packages/core/src/engine/agent-session.ts b/packages/core/src/engine/agent-session.ts index c77730ce..49061fbd 100644 --- a/packages/core/src/engine/agent-session.ts +++ b/packages/core/src/engine/agent-session.ts @@ -30,7 +30,7 @@ import type { ErrorCode, SessionContext, SessionEvent, - StopReason, + SessionStopReason, ToolPolicy, } from '@relavium/shared'; import { @@ -42,7 +42,13 @@ import { type ToolDef as LlmToolDef, } from '@relavium/llm'; -import type { ToolDef, ToolDispatchContext, ToolRegistry } from '../tools/types.js'; +import type { + ConfirmActionHook, + ToolDef, + ToolDispatchContext, + ToolId, + ToolRegistry, +} from '../tools/types.js'; import { AgentTurnError, DEFAULT_AGENT_TURN_LIMITS, @@ -97,6 +103,34 @@ export type SessionStreamEvent = /** The injected emission port. 1.V emits through it; 1.W implements it over the shared `RunEventBus`. */ export type SessionEventSink = (event: SessionStreamEvent) => void; +/** + * The **reseat-less mode** projection (ADR-0057) — the engine-side, **mode-agnostic** per-turn policy a host + * sets on the SAME session instance (no reseat, no tool-context loss). The ask / plan / accept-edits / auto + * **enum lives in the host** (`apps/cli`, [ADR-0055](../../../../docs/decisions/0055-cli-host-capability-seam-tool-environment-factory.md)): + * the host maps its current mode to this policy and pushes it via {@link AgentSession.setTurnPolicy}; the + * session **snapshots it at each turn start**, so a mid-turn change applies on the next turn. Setting a policy + * **activates the interactive-approval regime** for governed tools (the dispatch context's `approval` is + * present), so a write/process/egress dispatch requires a `confirm` decision — fail-closed if `confirm` is + * absent (ADR-0057 EA3). Absent (the default) ⇒ today's behavior: all granted tools advertised, no approval + * regime (the workflow author-trust floor). + */ +export interface SessionTurnPolicy { + /** + * Which of the agent's granted tools to **advertise** to the model this turn (the mode advertise-filter): + * `true` keeps the tool in the model-visible set. A tool filtered OUT is never offered, but the `confirm` + * floor remains authoritative if the model names it anyway (best-effort filter + fail-closed gate). Absent + * ⇒ advertise every granted tool. + */ + readonly advertise?: (toolId: ToolId) => boolean; + /** + * The host's interactive per-tool approval hook ([ConfirmActionHook]{@link ConfirmActionHook}) threaded into + * the dispatch context's approval regime. Absent **while a policy is set** ⇒ fail-closed (a governed + * dispatch is denied — a wiring bug can't let `ask` mode write). The host's hook owns the mode policy + * (ask denies writes, accept-edits prompts, auto auto-approves), the once/always cache, and protected paths. + */ + readonly confirm?: ConfirmActionHook; +} + /** * The session's injected dependencies — **platform capabilities only**, mirroring `AgentRunnerDeps` * (1.O). `resolveProvider` keeps an adapter from ever being imported by core; `keyFor` / `sleep` / @@ -198,6 +232,18 @@ export class AgentSession { #status: SessionStatus = 'created'; /** The in-flight turn's controller, so {@link cancel} can abort it; `undefined` between turns. */ #abort: AbortControllerLike | undefined; + /** + * The reseat-less mode policy (ADR-0057), mutated by {@link setTurnPolicy} and **snapshotted per turn**. + * Present ⇒ the interactive-approval regime is active + the advertise-filter applies. `undefined` ⇒ + * today's behavior (all granted tools advertised, no approval regime). + */ + #turnPolicy: SessionTurnPolicy | undefined; + /** + * Set by {@link abort} to mark the in-flight turn as **user-aborted** (EA7) — distinct from `cancel()`'s + * terminal `'cancelled'` status. The `sendMessage` catch reads it to settle the turn as + * `session:turn_completed{stopReason:'aborted'}` and keep the session alive (→ `idle`). Cleared each turn. + */ + #abortingTurn = false; /** Memoized provider fallback plan (the agent binding is fixed for the session). */ #plan: PlanResult | undefined; @@ -250,6 +296,17 @@ export class AgentSession { }); } + /** + * Set (or clear) the **reseat-less mode policy** (ADR-0057) — the advertise-filter + the interactive + * approval hook — on this SAME session instance. The host calls it when its mode changes (e.g. `Shift+Tab`); + * the change is **lossless** (no reseat, no tool-context loss) and applies on the **next** turn (each + * `sendMessage` snapshots the policy at turn start). Pass `undefined` to clear it (back to advertise-all / + * no-approval-regime). Allowed in any non-terminal state, including mid-turn (it takes effect next turn). + */ + setTurnPolicy(policy: SessionTurnPolicy | undefined): void { + this.#turnPolicy = policy; + } + /** * Run one user turn end to end: append the user message, drive the turn core (streaming + tool loop + * fallback), append the assistant reply, and emit `session:turn_started` → `session:turn_completed`. @@ -295,8 +352,11 @@ export class AgentSession { this.#messages.push({ role: 'user', content: [{ type: 'text', text }] }); const abort = this.#deps.newAbortController(); this.#abort = abort; + // Snapshot the reseat-less mode policy for the whole turn (ADR-0057): a mid-turn setTurnPolicy applies + // only on the NEXT turn, so the advertise-filter + approval regime stay consistent within this turn. + const turnPolicy = this.#turnPolicy; try { - const result = await this.#runTurn(abort.signal); + const result = await this.#runTurn(abort.signal, turnPolicy); // A cancel landed mid-turn — the cancel path owns the terminal session:cancelled; stay quiet, but // roll the user message back so a cancelled turn leaves no dangling user turn in the transcript // (the "only completed exchanges" invariant — matters for 1.X persistence / 1.Z export). @@ -304,6 +364,18 @@ export class AgentSession { this.#messages.pop(); return; } + // EA7: an abort that landed just as the turn RESOLVED (the narrow race past the turn core's last + // `throwIfAborted`) is still honored — discard the completed reply and settle `aborted`, so `Esc` + // always yields an aborted turn, never a surprise completed one. The session stays alive (→ idle). + if (this.#abortingTurn) { + this.#messages.pop(); + this.#emitTurnCompleted('aborted', { + input: result.usage.input, + output: result.usage.output, + model: result.model, + }); + return; + } this.#turnCount += 1; // Append the assistant reply to the cross-turn transcript as TEXT-ONLY. The turn core keeps the // within-turn tool_use/tool_result pairs internal (they never leave runAgentTurn — it returns only the @@ -330,9 +402,21 @@ export class AgentSession { // orphaning it. this.#messages.pop(); if (this.#statusIs('cancelled')) return; // cancel-during-turn: session:cancelled is the terminal + if (this.#abortingTurn) { + // EA7 mid-turn abort: the turn core threw on the aborted signal (an AgentTurnError 'cancelled'). + // Settle as ONE `session:turn_completed{stopReason:'aborted'}` — NO error (user-initiated, not a + // failure) — and keep the session alive; the `finally` returns #status to idle. Count the turn + // against the hard cap only when a provider engaged + report its real EA2 usage (consistent with + // #settleTurnError). It is NOT `cancel()`/`session:cancelled` (which is terminal). + const aborted = err instanceof AgentTurnError ? err : undefined; + if (aborted?.engaged === true) this.#turnCount += 1; + this.#emitTurnCompleted('aborted', aborted?.usage ?? { input: 0, output: 0 }); + return; + } this.#settleTurnError(err); // emits the terminal by error class; RE-THROWS an unclassified error } finally { this.#abort = undefined; + this.#abortingTurn = false; // clear the per-turn EA7 marker (no stale abort leaks into the next turn) if (this.#statusIs('running')) this.#status = 'idle'; } } @@ -405,8 +489,25 @@ export class AgentSession { this.#deps.emit({ type: 'session:cancelled' }); } + /** + * **Mid-turn abort** (ADR-0057 EA7) — `Esc` ends the *in-flight turn* but **keeps the session alive** + * (unlike {@link cancel}, which is terminal). It aborts the turn's signal; the `sendMessage` catch then + * settles the turn as **one** `session:turn_completed{stopReason:'aborted'}` (no `error` — it is + * user-initiated, not a failure), rolls the pending user message back, and returns `#status` to `idle` so + * the next `sendMessage` continues the conversation. No-op unless a turn is in flight (`running`); a + * `cancel()` already in progress wins (terminal precedence). There is **no** new session status. + */ + abort(): void { + if (this.#status !== 'running') return; // nothing in flight (or already terminal) — nothing to abort + this.#abortingTurn = true; + this.#abort?.abort(); + } + /** Build (memoized) the fallback plan and drive one turn through the shared core. */ - async #runTurn(signal: AbortSignalLike): Promise { + async #runTurn( + signal: AbortSignalLike, + turnPolicy: SessionTurnPolicy | undefined, + ): Promise { const plan = this.#resolvePlan(); if (!plan.ok) { // A host-wiring gap (a provider was not resolved) — a classified, non-retryable internal failure. @@ -420,8 +521,16 @@ export class AgentSession { toolPolicy: this.#deps.toolPolicy ?? {}, fsScope: this.#context.fsScopeTier, gateApproved: false, // a chat loop provides no human gate — git_commit stays denied (parity with 1.O) + // ADR-0057: a set mode policy ACTIVATES the interactive-approval regime — a governed (write/process/ + // egress) dispatch then requires the host's `confirm` decision, fail-closed when `confirm` is absent + // (`approval: {}`). No policy ⇒ no `approval` key ⇒ the workflow author-trust floor, unchanged. + ...(turnPolicy === undefined + ? {} + : { approval: turnPolicy.confirm === undefined ? {} : { confirm: turnPolicy.confirm } }), }; - const llmTools = buildLlmTools(this.#deps.tools, grantedToolIds); + // Advertise-filter (ADR-0057): narrow the model-visible tool set per the host's mode (best-effort; the + // confirm floor stays authoritative). No policy / no filter ⇒ advertise every granted tool. + const llmTools = buildLlmTools(this.#deps.tools, grantedToolIds, turnPolicy?.advertise); return runAgentTurn({ system: this.#agent.system_prompt, messages: this.#messages, @@ -464,7 +573,7 @@ export class AgentSession { } #emitTurnCompleted( - stopReason: StopReason, + stopReason: SessionStopReason, tokensUsed: { input: number; output: number; model?: string }, error?: { code: ErrorCode; message: string; retryable: boolean }, ): void { @@ -520,11 +629,21 @@ export class AgentSession { } } -/** The agent's granted tools as LLM-visible defs, validated through the seam schema (no unsafe cast). */ -function buildLlmTools(defs: readonly ToolDef[], granted: ReadonlySet): LlmToolDef[] { +/** + * The agent's granted tools as LLM-visible defs, validated through the seam schema (no unsafe cast). + * `advertise` is the optional mode advertise-filter (ADR-0057): a granted tool it rejects is **not** offered + * to the model this turn (the `confirm` floor stays authoritative if the model names it anyway). Absent ⇒ + * every granted tool is advertised. + */ +function buildLlmTools( + defs: readonly ToolDef[], + granted: ReadonlySet, + advertise?: (toolId: string) => boolean, +): LlmToolDef[] { const out: LlmToolDef[] = []; for (const def of defs) { if (!granted.has(def.id)) continue; + if (advertise !== undefined && !advertise(def.id)) continue; // mode advertise-filter (ADR-0057) const parsed = ToolDefSchema.safeParse({ name: def.id, ...(def.description.length > 0 ? { description: def.description } : {}), diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e3b71167..4f5e678b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -194,6 +194,8 @@ export type { // The envelope-less `agent:approval_requested` body the host's ConfirmActionHook emits through the // SessionEventSink (ADR-0057 EA5; the Step-4 chat-approval wiring needs to reference it by name). SessionApprovalStreamEvent, + // The reseat-less mode policy (advertise-filter + confirm hook) a host pushes via setTurnPolicy (ADR-0057). + SessionTurnPolicy, } from './engine/agent-session.js'; // Session checkpoint/resume (1.Y) — reconstruct the in-flight state from a persisted transcript (1.X) so a // session continues after a restart; the host loads via the @relavium/db SessionStore and hands the result diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 50ef73e5..f426d17d 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -141,6 +141,16 @@ export type ToolActionClass = (typeof TOOL_ACTION_CLASSES)[number]; export const STOP_REASONS = ['stop', 'length', 'tool_use', 'content_filter', 'error'] as const; export type StopReason = (typeof STOP_REASONS)[number]; +/** + * The **session** turn stop-reason vocabulary — the five LLM {@link STOP_REASONS} **plus** `aborted`, the + * user's **mid-turn abort** (ADR-0057 EA7: `Esc` ends the in-flight turn but keeps the session alive, so the + * turn settles with `session:turn_completed{stopReason:'aborted'}`, **not** `session:cancelled`). `aborted` is + * a session-lifecycle concept, **not** an LLM stop reason, so it lives here and the `@relavium/llm` seam's + * `StopReason` stays the clean five-value set. Only `session:turn_completed.stopReason` uses this superset. + */ +export const SESSION_STOP_REASONS = [...STOP_REASONS, 'aborted'] as const; +export type SessionStopReason = (typeof SESSION_STOP_REASONS)[number]; + /** * The four **media input modalities** a `media` content part can carry (ADR-0031). The modality * of a part is derived from its MIME type (`image/*`, `audio/*`, `video/*`, `application/pdf`), diff --git a/packages/shared/src/run-event.test.ts b/packages/shared/src/run-event.test.ts index f7aba746..6e87f471 100644 --- a/packages/shared/src/run-event.test.ts +++ b/packages/shared/src/run-event.test.ts @@ -609,9 +609,12 @@ describe('SessionEvent union — the agent-first namespace', () => { expect(withSelection({ file: 'a.ts', startLine: 5, endLine: 1 })).toBe(false); }); - it('binds session:turn_completed.stopReason to the closed StopReason enum', () => { + it('binds session:turn_completed.stopReason to the SESSION stop-reason enum (the five LLM values + aborted)', () => { const ok = validSession['session:turn_completed']; expect(SessionEventSchema.safeParse({ ...ok, stopReason: 'tool_use' }).success).toBe(true); + // EA7 (ADR-0057): the session superset adds `aborted` (the mid-turn abort) — accepted here, but NOT in + // the LLM StopReason vocabulary (the @relavium/llm seam stays the clean five values). + expect(SessionEventSchema.safeParse({ ...ok, stopReason: 'aborted' }).success).toBe(true); expect(SessionEventSchema.safeParse({ ...ok, stopReason: 'banana' }).success).toBe(false); }); diff --git a/packages/shared/src/run-event.ts b/packages/shared/src/run-event.ts index a4a2fdb1..885da14c 100644 --- a/packages/shared/src/run-event.ts +++ b/packages/shared/src/run-event.ts @@ -8,6 +8,7 @@ import { FS_SCOPE_TIERS, LLM_PROVIDERS, MEDIA_BILLED_MODALITIES, + SESSION_STOP_REASONS, STOP_REASONS, TOOL_ACTION_CLASSES, } from './constants.js'; @@ -91,6 +92,9 @@ export const ErrorCodeSchema = z.enum(ERROR_CODES); /** The five-value LLM stop reason. Canonical home — the `@relavium/llm` seam re-exports this. */ export const StopReasonSchema = z.enum(STOP_REASONS); +/** The session turn stop reason — the five LLM values plus `aborted` (the EA7 mid-turn abort, ADR-0057). */ +export const SessionStopReasonSchema = z.enum(SESSION_STOP_REASONS); + /** * The shared failure shape: a closed `code`, a user-safe `message`, `retryable`, and an optional, * secret-free `correlationId` the engine stamps at the single producer-side translation point so a @@ -560,7 +564,10 @@ export const SessionTurnStartedEventSchema = z.object({ export const SessionTurnCompletedEventSchema = z.object({ type: z.literal('session:turn_completed'), ...sessionBase, - stopReason: StopReasonSchema, + // The session superset of `StopReason` — the five LLM values plus `aborted` (the EA7 mid-turn abort: the + // turn ends but the session stays alive, ADR-0057). `aborted` carries NO `error` (it is user-initiated, + // not a failure); a failed turn uses `stopReason: 'error'` + the `error` field. + stopReason: SessionStopReasonSchema, tokensUsed: TokensUsedSchema, // A failed turn (provider error, rate limit, cancellation) still completes — with an error. error: z.object(eventErrorFields).optional(), From b98a1c3780fc59569a6b9f570df526a628a00b6d Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Tue, 30 Jun 2026 04:54:10 +0300 Subject: [PATCH 07/45] =?UTF-8?q?fix(core):=202.5.E=20Step=202=20=E2=80=94?= =?UTF-8?q?=20address=20opus=20review=20(simplify=20late-abort,=20+coverag?= =?UTF-8?q?e)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../src/render/tui/session-view-model.test.ts | 21 ++- .../reference/contracts/agent-session-spec.md | 1 + .../core/src/engine/agent-session.test.ts | 127 +++++++++++++++++- packages/core/src/engine/agent-session.ts | 35 +++-- packages/shared/src/run-event.test.ts | 6 +- 5 files changed, 167 insertions(+), 23 deletions(-) diff --git a/apps/cli/src/render/tui/session-view-model.test.ts b/apps/cli/src/render/tui/session-view-model.test.ts index 1c1d99d6..fc589b65 100644 --- a/apps/cli/src/render/tui/session-view-model.test.ts +++ b/apps/cli/src/render/tui/session-view-model.test.ts @@ -1,5 +1,5 @@ import type { SessionStreamHandleEvent } from '@relavium/core'; -import type { ErrorCode } from '@relavium/shared'; +import type { ErrorCode, SessionStopReason } from '@relavium/shared'; import { describe, expect, it } from 'vitest'; import { @@ -66,7 +66,7 @@ function events() { cumulativeCostMicrocents: cumulative, }), turnCompleted: ( - opts: { stopReason?: 'stop' | 'tool_use'; error?: { code: ErrorCode; message: string } } = {}, + opts: { stopReason?: SessionStopReason; error?: { code: ErrorCode; message: string } } = {}, ): SessionStreamHandleEvent => ({ type: 'session:turn_completed', ...stamp(), @@ -141,6 +141,23 @@ describe('session-view-model', () => { } }); + it('projects a mid-turn abort (ADR-0057 EA7) into a turn summary with stopReason "aborted" and no error', () => { + const e = events(); + const state = reduceAll([ + e.started(), + e.turnStarted(), + e.token('partial'), + e.turnCompleted({ stopReason: 'aborted' }), // EA7: aborted carries NO error + ]); + const entry = state.transcript[0]; + expect(entry).toMatchObject({ role: 'assistant', text: 'partial' }); + if (entry?.role === 'assistant') { + expect(entry.summary.stopReason).toBe('aborted'); + expect(entry.summary.errorCode).toBeUndefined(); // user-initiated, not a failure + } + expect(state.status).toBe('idle'); // the session stays alive after an abort + }); + it('drops a pre-tool preamble from the stored assistant text (mirrors result.text), annotates the call', () => { const e = events(); const state = reduceAll([ diff --git a/docs/reference/contracts/agent-session-spec.md b/docs/reference/contracts/agent-session-spec.md index cf0f8d51..5825697d 100644 --- a/docs/reference/contracts/agent-session-spec.md +++ b/docs/reference/contracts/agent-session-spec.md @@ -40,6 +40,7 @@ stateDiagram-v2 Streaming --> Idle: assistant turn complete Streaming --> Idle: abort (Esc) — turn ends, session lives (ADR-0057) Idle --> Idle: setTurnPolicy (reseat-less mode change, ADR-0057) + Streaming --> Streaming: setTurnPolicy (mode change applies next turn, ADR-0057) Idle --> Idle: resume (reload from history.db) Idle --> [*]: cancel / end Idle --> Exported: export → .relavium.yaml diff --git a/packages/core/src/engine/agent-session.test.ts b/packages/core/src/engine/agent-session.test.ts index 5db66487..2ea70cce 100644 --- a/packages/core/src/engine/agent-session.test.ts +++ b/packages/core/src/engine/agent-session.test.ts @@ -639,11 +639,14 @@ describe('AgentSession → createSessionEventSink → RunEventBus → SessionHan describe('AgentSession — reseat-less modes + mid-turn abort (ADR-0057 Step 2)', () => { it('abort() ends the in-flight turn as turn_completed{aborted} (no error) and keeps the session alive', async () => { - const { deps, events } = harness([textTurn('partial'), textTurn('second')]); + // This abort lands PRE-EGRESS (synchronous abort() → the turn core throws at its pre-egress + // throwIfAborted before the provider stream), so the turn engaged no provider. The mid-stream + // (engaged) abort is exercised by the barrier test below. + const { deps, events } = harness([textTurn('hi')]); const s = session(deps); s.start(); const p = s.sendMessage('hi'); - s.abort(); // mid-turn: the turn core is suspended at the provider stream await + s.abort(); await p; const completes = events.filter((e) => e.type === 'session:turn_completed'); @@ -783,4 +786,124 @@ describe('AgentSession — reseat-less modes + mid-turn abort (ADR-0057 Step 2)' await s.sendMessage('use echo'); // no setTurnPolicy expect(captured?.approval).toBeUndefined(); }); + + it('a policy WITHOUT a confirm hook threads approval:{} — the fail-closed regime (no_approval_hook floor)', async () => { + // The security-critical middle state: a set policy with no confirm activates the regime as approval:{} + // (present-but-empty), which the Step-1 registry floor turns into a fail-closed `no_approval_hook` deny. + // A regression collapsing this to `undefined` would silently re-grant the author-trust floor to a moded + // session (letting `ask` mode write), so pin the threading here. + let captured: ToolDispatchContext | undefined; + const capturing: ToolRegistry = { + has: () => true, + list: () => ['echo'], + dispatch: (toolCall, ctx) => { + captured = ctx; + return echoRegistry.dispatch(toolCall, ctx); + }, + }; + const { deps } = harness([toolUseTurn('c1'), textTurn('done')], {}, capturing); + const s = session(deps, TOOL_AGENT); + s.start(); + s.setTurnPolicy({ advertise: () => true }); // a policy, but NO confirm + await s.sendMessage('use echo'); + expect(captured?.approval).toStrictEqual({}); // present (regime active) but confirm-less (fail-closed) + }); + + it('setTurnPolicy(undefined) CLEARS the regime — re-advertises every granted tool', async () => { + // The clear path (e.g. Shift+Tab back to a no-filter mode) must re-advertise all granted tools. The + // approval-key half of the clear is the same observable as the no-policy test above (approval undefined). + const readFileDef = BUILTIN_TOOLS.find((t) => t.id === 'read_file'); + if (readFileDef === undefined) throw new Error('read_file builtin missing'); + const scripts = [textTurn('a'), textTurn('b')]; + let advertised: string[] = []; + let n = 0; + const provider: LlmProvider = { + id: 'anthropic', + supports: CAPS, + generate: () => { + throw new Error('unused'); + }, + stream: (req) => { + advertised = (req.tools ?? []).map((t) => t.name); + return streamOf(scripts[n++] ?? []); + }, + }; + const reader = AgentSchema.parse({ + id: 'reader', + model: 'claude-opus-4-8', + provider: 'anthropic', + system_prompt: 'x', + tools: ['read_file'], + }); + const { deps } = harness(scripts, { resolveProvider: () => provider, tools: [readFileDef] }); + const s = session(deps, reader); + s.start(); + s.setTurnPolicy({ advertise: () => false }); // filter read_file OUT + await s.sendMessage('filtered'); + expect(advertised).not.toContain('read_file'); + s.setTurnPolicy(undefined); // CLEAR + await s.sendMessage('cleared'); + expect(advertised).toContain('read_file'); // re-advertised + }); + + it('aborts an ENGAGED mid-stream turn — real partial usage, counted, session alive (barrier-controlled)', async () => { + let release: () => void = () => {}; + const barrier = new Promise((r) => { + release = r; + }); + async function* blockingStream(): AsyncGenerator { + yield { type: 'text_delta', text: 'partial' }; // a provider engaged + a token streamed + await barrier; // hold the turn open mid-stream until the test releases it + yield { type: 'stop', stopReason: 'stop', usage: { inputTokens: 7, outputTokens: 4 } }; + } + let n = 0; + const provider: LlmProvider = { + id: 'anthropic', + supports: CAPS, + generate: () => { + throw new Error('unused'); + }, + stream: () => (n++ === 0 ? blockingStream() : streamOf(textTurn('next'))), + }; + const { deps, events } = harness([], { resolveProvider: () => provider }); + const s = session(deps, AGENT); + s.start(); + const p = s.sendMessage('hi'); + await new Promise((r) => setTimeout(r, 0)); // let the turn engage + stream 'partial', then block on the barrier + s.abort(); // mid-stream abort — a provider HAS engaged + release(); + await p; + + const tokens = events.filter((e) => e.type === 'agent:token'); + expect(tokens.map((e) => (e.type === 'agent:token' ? e.token : ''))).toContain('partial'); // engaged + const completes = events.filter((e) => e.type === 'session:turn_completed'); + expect(completes).toHaveLength(1); + const aborted = completes[0]; + expect(aborted?.type === 'session:turn_completed' && aborted.stopReason).toBe('aborted'); + // EA2: the aborted turn reports REAL accumulated usage (a provider engaged), not a hardcoded zero. + expect(aborted?.type === 'session:turn_completed' && aborted.tokensUsed.input).toBeGreaterThan( + 0, + ); + // The session is alive — a second turn still runs. + await s.sendMessage('again'); + expect(events.filter((e) => e.type === 'session:turn_completed')).toHaveLength(2); + }); + + it('a pre-egress (un-engaged) abort does NOT burn a max_turns slot — the cap is engaged-gated', async () => { + // maxTurns=1: an aborted, un-engaged turn must not consume the only slot, so the next turn still runs. + const { deps, events } = harness([textTurn('ok')], { maxTurns: 1 }); + const s = session(deps, AGENT); + s.start(); + const p = s.sendMessage('abort me'); + s.abort(); // pre-egress (un-engaged) + await p; + await s.sendMessage('real turn'); // must NOT hit the cap + const completes = events.filter((e) => e.type === 'session:turn_completed'); + expect(completes).toHaveLength(2); + expect(completes[0]?.type === 'session:turn_completed' && completes[0].stopReason).toBe( + 'aborted', + ); + // The second turn ran (not blocked by turn_limit) — the aborted un-engaged turn did not count. + expect(completes[1]?.type === 'session:turn_completed' && completes[1].stopReason).toBe('stop'); + }); }); diff --git a/packages/core/src/engine/agent-session.ts b/packages/core/src/engine/agent-session.ts index 49061fbd..3d374e05 100644 --- a/packages/core/src/engine/agent-session.ts +++ b/packages/core/src/engine/agent-session.ts @@ -301,7 +301,8 @@ export class AgentSession { * approval hook — on this SAME session instance. The host calls it when its mode changes (e.g. `Shift+Tab`); * the change is **lossless** (no reseat, no tool-context loss) and applies on the **next** turn (each * `sendMessage` snapshots the policy at turn start). Pass `undefined` to clear it (back to advertise-all / - * no-approval-regime). Allowed in any non-terminal state, including mid-turn (it takes effect next turn). + * no-approval-regime). Callable in **any** state, including mid-turn (it takes effect next turn); it is + * **inert once `cancelled`** (a cancelled session runs no further turn, so the policy is never read again). */ setTurnPolicy(policy: SessionTurnPolicy | undefined): void { this.#turnPolicy = policy; @@ -325,6 +326,10 @@ export class AgentSession { ); } + // Clear any stale EA7 abort marker BEFORE arming the turn, so an `abort()` a prior turn's synchronous + // turn_started-emit sink set (which then took a pre-`try` early return, bypassing the `finally` reset) + // can never leak into this turn's catch path and misclassify a real failure as an abort. + this.#abortingTurn = false; this.#status = 'running'; this.#deps.emit({ type: 'session:turn_started' }); // A cancel can fire SYNCHRONOUSLY inside the turn_started emit (a host whose sink calls cancel()). If it @@ -364,18 +369,10 @@ export class AgentSession { this.#messages.pop(); return; } - // EA7: an abort that landed just as the turn RESOLVED (the narrow race past the turn core's last - // `throwIfAborted`) is still honored — discard the completed reply and settle `aborted`, so `Esc` - // always yields an aborted turn, never a surprise completed one. The session stays alive (→ idle). - if (this.#abortingTurn) { - this.#messages.pop(); - this.#emitTurnCompleted('aborted', { - input: result.usage.input, - output: result.usage.output, - model: result.model, - }); - return; - } + // EA7 note: an `abort()` that lands AFTER the turn fully resolved (a late `Esc`, past the turn core's + // last `throwIfAborted`) is a no-op — the turn already produced its reply, so it completes NORMALLY + // (the reply is kept, the turn is counted). `abort()` interrupts an IN-FLIGHT turn only; a turn the + // model already finished is not discarded. The `#abortingTurn` marker is cleared by the `finally`. this.#turnCount += 1; // Append the assistant reply to the cross-turn transcript as TEXT-ONLY. The turn core keeps the // within-turn tool_use/tool_result pairs internal (they never leave runAgentTurn — it returns only the @@ -491,11 +488,13 @@ export class AgentSession { /** * **Mid-turn abort** (ADR-0057 EA7) — `Esc` ends the *in-flight turn* but **keeps the session alive** - * (unlike {@link cancel}, which is terminal). It aborts the turn's signal; the `sendMessage` catch then - * settles the turn as **one** `session:turn_completed{stopReason:'aborted'}` (no `error` — it is - * user-initiated, not a failure), rolls the pending user message back, and returns `#status` to `idle` so - * the next `sendMessage` continues the conversation. No-op unless a turn is in flight (`running`); a - * `cancel()` already in progress wins (terminal precedence). There is **no** new session status. + * (unlike {@link cancel}, which is terminal). It aborts the turn's signal; the turn core then throws on + * the abort and the `sendMessage` catch settles the turn as **one** `session:turn_completed{stopReason: + * 'aborted'}` (no `error` — it is user-initiated, not a failure), rolls the pending user message back, and + * returns `#status` to `idle` so the next `sendMessage` continues the conversation. A **late** abort that + * lands after the turn already RESOLVED is a no-op — that turn completes normally (its reply is kept), so + * a just-finished reply is never discarded. No-op unless a turn is in flight (`running`); a `cancel()` + * already in progress wins (terminal precedence). There is **no** new session status. */ abort(): void { if (this.#status !== 'running') return; // nothing in flight (or already terminal) — nothing to abort diff --git a/packages/shared/src/run-event.test.ts b/packages/shared/src/run-event.test.ts index 6e87f471..66177f53 100644 --- a/packages/shared/src/run-event.test.ts +++ b/packages/shared/src/run-event.test.ts @@ -6,6 +6,7 @@ import { MaskedSecretSchema, RunEventSchema, SessionEventSchema, + StopReasonSchema, } from './run-event.js'; import type { RunEvent, RunEventType } from './index.js'; @@ -613,9 +614,12 @@ describe('SessionEvent union — the agent-first namespace', () => { const ok = validSession['session:turn_completed']; expect(SessionEventSchema.safeParse({ ...ok, stopReason: 'tool_use' }).success).toBe(true); // EA7 (ADR-0057): the session superset adds `aborted` (the mid-turn abort) — accepted here, but NOT in - // the LLM StopReason vocabulary (the @relavium/llm seam stays the clean five values). + // the LLM StopReason vocabulary (the @relavium/llm seam stays the clean five values). Pin BOTH halves + // co-located: the LLM StopReason has exactly five members and REJECTS 'aborted'. expect(SessionEventSchema.safeParse({ ...ok, stopReason: 'aborted' }).success).toBe(true); expect(SessionEventSchema.safeParse({ ...ok, stopReason: 'banana' }).success).toBe(false); + expect(StopReasonSchema.options).toHaveLength(5); + expect(StopReasonSchema.safeParse('aborted').success).toBe(false); }); it('rejects session variants missing/emptying a required field', () => { From 646ea0722aaa00a7ea7c11e68c499f305d6778dd Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Tue, 30 Jun 2026 05:19:57 +0300 Subject: [PATCH 08/45] =?UTF-8?q?fix:=202.5.E=20Step=202=20=E2=80=94=20add?= =?UTF-8?q?ress=20sonnet=20review=20(2=20real=20bugs:=20persister=20+=20ab?= =?UTF-8?q?ort-arming=20race)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/cli/src/chat/persister.test.ts | 22 +++++++ apps/cli/src/chat/persister.ts | 37 ++++++----- apps/cli/src/commands/chat.ts | 5 +- .../src/render/tui/chat-projection.test.ts | 11 ++++ apps/cli/src/render/tui/session-view-model.ts | 6 +- .../reference/contracts/agent-session-spec.md | 6 +- docs/reference/contracts/sse-event-schema.md | 2 +- .../core/src/engine/agent-session.test.ts | 63 ++++++++++++++++++- packages/core/src/engine/agent-session.ts | 14 +++-- 9 files changed, 140 insertions(+), 26 deletions(-) diff --git a/apps/cli/src/chat/persister.test.ts b/apps/cli/src/chat/persister.test.ts index 0a7ffdc3..07538059 100644 --- a/apps/cli/src/chat/persister.test.ts +++ b/apps/cli/src/chat/persister.test.ts @@ -236,6 +236,28 @@ describe('createSessionPersister', () => { expect(full?.session.status).toBe('active'); }); + it('persists NO messages for a mid-turn ABORTED turn (EA7), then the next turn persists cleanly', async () => { + // An aborted turn (stopReason:'aborted', error:undefined — ADR-0057) is rolled back by the engine just + // like an error turn, so the persister must NOT write its rows: gating only on `error === undefined` + // would orphan the user message in history.db (no in-memory counterpart on chat-resume). + const { built, persister } = await setup(scriptedResolver([textTurn('kept-reply')])); + persister.start(); + built.session.start(); + persister.beginUserTurn('abort me'); + const p = built.session.sendMessage('abort me'); + built.session.abort(); // mid-turn abort (pre-egress → the turn settles 'aborted', no provider script used) + await p; + expect(store.loadFull('sess-1')?.messages).toHaveLength(0); // the aborted turn left no rows + + // pendingUserText was reset by the aborted turn, so the next successful turn persists cleanly. + persister.beginUserTurn('kept'); + await built.session.sendMessage('kept'); + const full = store.loadFull('sess-1'); + expect(full?.messages).toHaveLength(2); // ONLY the kept turn's user + assistant + expect(textOf(full?.messages[0]?.content ?? [])).toBe('kept'); // user + expect(textOf(full?.messages[1]?.content ?? [])).toBe('kept-reply'); // assistant (the first script) + }); + it('marks the session ended on cancel (its sole terminal), leaving it resumable', async () => { const { built, persister } = await setup(scriptedResolver([textTurn('hi')])); persister.start(); diff --git a/apps/cli/src/chat/persister.ts b/apps/cli/src/chat/persister.ts index cf4c4b89..6a91ea55 100644 --- a/apps/cli/src/chat/persister.ts +++ b/apps/cli/src/chat/persister.ts @@ -20,9 +20,11 @@ import { deriveSessionTitle } from './session-title.js'; * Persistence mirrors `AgentSession`'s own `#messages` exactly so a reconstructed transcript is faithful: * each **completed** turn persists the user message + the **text-only** assistant reply (the final * `result.text`, captured by accumulating `agent:token` and resetting on each `agent:tool_call` so a - * pre-tool preamble is dropped, never the mid-turn `tool_use`/`tool_result` pairs); an **error** turn - * persists nothing (the engine rolls its user message back, keeping the transcript to completed exchanges). - * No secret value ever reaches a row (keys ride the keychain; `secret`-typed args are never interpolated). + * pre-tool preamble is dropped, never the mid-turn `tool_use`/`tool_result` pairs); an **error** turn AND a + * mid-turn **aborted** turn (EA7, `stopReason:'aborted'`, ADR-0057) persist **no messages** (the engine rolls + * the user message back, keeping the transcript to completed exchanges) — though the real session COST is + * still flushed for both. No secret value ever reaches a row (keys ride the keychain; `secret`-typed args + * are never interpolated). */ export interface SessionPersisterDeps { @@ -116,17 +118,24 @@ export function createSessionPersister(deps: SessionPersisterDeps): SessionPersi totalCostMicrocents = event.cumulativeCostMicrocents; return; case 'session:turn_completed': - // Only a COMPLETED exchange (no error) writes MESSAGES — an error turn is rolled back by the engine, - // keeping the transcript to completed exchanges. But the session COST (the cumulative from - // cost:updated) is real even for a failed turn — the engine never decrements it — so flush the row - // UNCONDITIONALLY so a resumed budget governor seeds from the true spend (ADR-0028), not an - // understated one. The token COLUMNS, by contrast, are NOT accumulated on a failed turn — that is - // gated below on `event.error === undefined`. (EA2/ADR-0055 now delivers a real, non-zero `tokensUsed` - // on a failed turn, but those tokens belong to a rolled-back exchange and must not inflate the - // session-wide token totals; only the cost, from `cost:updated`, is kept.) - // A completed exchange always has a user message (the REPL calls beginUserTurn before sendMessage); - // gating the whole exchange on it prevents an orphaned assistant row with no preceding user row. - if (event.error === undefined && pendingUserText !== undefined) { + // Only a COMPLETED exchange writes MESSAGES — both an ERROR turn AND an ABORTED turn (EA7, + // `stopReason:'aborted'`, ADR-0057) are rolled back by the engine (`#messages.pop()`), so persisting + // their rows would orphan a user message with no in-memory counterpart on chat-resume. Gating on + // BOTH (`error === undefined && stopReason !== 'aborted'`) keeps the durable transcript to completed + // exchanges, matching the engine. But the session COST (the cumulative from cost:updated) is real + // even for a failed/aborted turn — the engine never decrements it — so flush the row UNCONDITIONALLY + // so a resumed budget governor seeds from the true spend (ADR-0028), not an understated one. The + // token COLUMNS, by contrast, are NOT accumulated on a non-persisted turn (gated with the messages). + // (EA2/ADR-0055 delivers a real, non-zero `tokensUsed` on a failed/aborted turn, but those tokens + // belong to a rolled-back exchange and must not inflate the session-wide token totals; only the cost, + // from `cost:updated`, is kept.) A completed exchange always has a user message (the REPL calls + // beginUserTurn before sendMessage); gating the whole exchange on it prevents an orphaned assistant + // row with no preceding user row. + if ( + event.error === undefined && + event.stopReason !== 'aborted' && + pendingUserText !== undefined + ) { appendText('user', pendingUserText); if (assistantText.length > 0) appendText('assistant', assistantText); totalInputTokens += event.tokensUsed.input; diff --git a/apps/cli/src/commands/chat.ts b/apps/cli/src/commands/chat.ts index 480ba502..a0fdd2f8 100644 --- a/apps/cli/src/commands/chat.ts +++ b/apps/cli/src/commands/chat.ts @@ -432,8 +432,9 @@ export function createChatLineHandler( stop = true; }, cancel: () => { - // 1.V has no per-turn abort that keeps the session alive, so /cancel ends the (persisted, resumable) - // session — its in-flight turn is aborted and `chat-resume` (2.N) can reload it later. + // `/cancel` ends the session TERMINALLY (session:cancelled) — its in-flight turn is aborted and + // `chat-resume` (2.N) can reload the persisted session later. For a mid-turn abort that KEEPS the + // session alive (Esc), use `session.abort()` (EA7, ADR-0057) — wired into the REPL in 2.5.E Step 4. cancelOnce(); stop = true; }, diff --git a/apps/cli/src/render/tui/chat-projection.test.ts b/apps/cli/src/render/tui/chat-projection.test.ts index 6468e85f..54a582a7 100644 --- a/apps/cli/src/render/tui/chat-projection.test.ts +++ b/apps/cli/src/render/tui/chat-projection.test.ts @@ -33,6 +33,17 @@ describe('chat-projection', () => { expect(line).toContain('error: turn_limit'); }); + it('renders the EA7 "aborted" stop reason as a plain label (no error segment)', () => { + const line = formatTurnSummary({ + stopReason: 'aborted', + tokensUsed: { input: 7, output: 4 }, + }); + const parts = line.split(' · '); + expect(parts[0]).toBe('aborted'); // the aborted turn renders its stop reason, not an error + expect(line).not.toContain('error'); + expect(line).toContain(formatTokens({ input: 7, output: 4 })); + }); + it('omits the duration segment when the duration is unknown (stop + tokens only)', () => { const line = formatTurnSummary({ stopReason: 'stop', tokensUsed: { input: 1, output: 1 } }); const parts = line.split(' · '); diff --git a/apps/cli/src/render/tui/session-view-model.ts b/apps/cli/src/render/tui/session-view-model.ts index 66f6bcba..ce988e78 100644 --- a/apps/cli/src/render/tui/session-view-model.ts +++ b/apps/cli/src/render/tui/session-view-model.ts @@ -62,7 +62,11 @@ export interface SessionViewState { readonly liveToolCalls: readonly ToolCallView[]; /** The session-wide running cost, authoritatively stamped onto every `cost:updated`. */ readonly cumulativeCostMicrocents: number; - /** Completed turns that engaged the provider (success or failure) — the chat-mode turn counter. */ + /** + * The chat-mode turn counter — incremented on **every** `session:turn_completed` (success, failure, OR + * an EA7 `aborted` turn). This is a monotonic UI display count, distinct from the engine's hard-cap + * `#turnCount` (which counts only provider-engaged turns); a footer counter wants every attempted turn. + */ readonly turnCount: number; /** The wall-clock (ms) of the in-flight turn's `session:turn_started`, for the completed-turn duration * (required-nullable, not optional, so it can be reset to `undefined` between turns under diff --git a/docs/reference/contracts/agent-session-spec.md b/docs/reference/contracts/agent-session-spec.md index 5825697d..c84ca223 100644 --- a/docs/reference/contracts/agent-session-spec.md +++ b/docs/reference/contracts/agent-session-spec.md @@ -40,7 +40,7 @@ stateDiagram-v2 Streaming --> Idle: assistant turn complete Streaming --> Idle: abort (Esc) — turn ends, session lives (ADR-0057) Idle --> Idle: setTurnPolicy (reseat-less mode change, ADR-0057) - Streaming --> Streaming: setTurnPolicy (mode change applies next turn, ADR-0057) + Streaming --> Streaming: setTurnPolicy (stored; no effect this turn, applies next, ADR-0057) Idle --> Idle: resume (reload from history.db) Idle --> [*]: cancel / end Idle --> Exported: export → .relavium.yaml @@ -50,8 +50,8 @@ stateDiagram-v2 | --- | --- | | **start** | Open a session for an `agentRef` with an initial [`SessionContext`](#session-context). Allocates a `sessionId` and persists the session row. | | **sendMessage** | Append a user [`SessionMessage`](#session-messages), run one assistant turn through the `AgentRunner` (streaming + tool-call loop), and append the assistant + tool messages. | -| **setTurnPolicy** | Set/clear the **reseat-less mode policy** (ADR-0057) — the advertise-filter + the interactive approval hook — on the **same** session instance (no reseat, no tool-context loss). Snapshotted at each turn start, so a change applies on the **next** turn. The ask / plan / accept-edits / auto enum lives in the host; this is its mode-agnostic engine projection. | -| **abort** | **Mid-turn abort** (ADR-0057 EA7): end the *in-flight turn* via its `AbortSignal` but **keep the session alive** — settle **one** `session:turn_completed{stopReason:'aborted'}` (no error), roll the pending user message back, and return to `idle`. **Distinct from `cancel`** (which is terminal): no `session:cancelled`, no new status. No-op when no turn is in flight; a concurrent `cancel` wins. | +| **setTurnPolicy** | Set/clear the **reseat-less mode policy** (ADR-0057) — the advertise-filter + the interactive approval hook — on the **same** session instance (no reseat, no tool-context loss). Snapshotted at each turn start, so a change applies on the **next** turn. The ask / plan / accept-edits / auto enum lives in the host; this is its mode-agnostic engine projection. Callable in any state, including mid-turn; **inert once cancelled** (a cancelled session runs no further turn, so the policy is never read again). | +| **abort** | **Mid-turn abort** (ADR-0057 EA7): end the *in-flight turn* via its `AbortSignal` but **keep the session alive** — settle **one** `session:turn_completed{stopReason:'aborted'}` (no error), roll the pending user message back, and return to `idle`. **Distinct from `cancel`** (which is terminal): no `session:cancelled`, no new status. No-op when no turn is in flight; a concurrent `cancel` wins. A **late** abort that lands after the turn already resolved is **also a no-op** — that turn completes normally and its reply is **kept** (`abort` interrupts an in-flight turn only, never discards a finished one). | | **cancel** | Abort the in-flight turn via `AbortSignal` **and end the session** (the terminal `session:cancelled`); the session stays resumable from its persisted transcript. | | **resume** | Reload a persisted session (messages + context) and continue. | | **export** | Serialize the session to a `.relavium.yaml` scaffold ([export](#export-to-workflow)). | diff --git a/docs/reference/contracts/sse-event-schema.md b/docs/reference/contracts/sse-event-schema.md index eefc602e..8eea8ea3 100644 --- a/docs/reference/contracts/sse-event-schema.md +++ b/docs/reference/contracts/sse-event-schema.md @@ -264,7 +264,7 @@ export type SessionEvent = | SessionStartedEvent // 'session:started' — { agentRef, model, context } | SessionTurnStartedEvent // 'session:turn_started' — a user message began an assistant turn | SessionTurnCompletedEvent // 'session:turn_completed' — { stopReason, tokensUsed, error? }; stopReason is the SESSION superset (the 5 LLM StopReasons + 'aborted' — the EA7 mid-turn abort, ADR-0057) - | SessionCancelledEvent // 'session:cancelled' — the in-flight turn was aborted + | SessionCancelledEvent // 'session:cancelled' — cancel() was called; the session ends (terminal). NOT a mid-turn abort (EA7), which keeps the session alive — see session:turn_completed{stopReason:'aborted'}. | SessionExportedEvent; // 'session:exported' — { workflowPath } (chat-to-workflow export) ``` diff --git a/packages/core/src/engine/agent-session.test.ts b/packages/core/src/engine/agent-session.test.ts index 2ea70cce..778619c2 100644 --- a/packages/core/src/engine/agent-session.test.ts +++ b/packages/core/src/engine/agent-session.test.ts @@ -869,7 +869,10 @@ describe('AgentSession — reseat-less modes + mid-turn abort (ADR-0057 Step 2)' const s = session(deps, AGENT); s.start(); const p = s.sendMessage('hi'); - await new Promise((r) => setTimeout(r, 0)); // let the turn engage + stream 'partial', then block on the barrier + // A setTimeout(0) macrotask fires only AFTER all pending microtasks drain (single-threaded JS), so the + // turn has deterministically engaged, streamed 'partial', and parked at `await barrier` by the time this + // resolves — making the mid-stream abort below non-flaky, not a timing guess. + await new Promise((r) => setTimeout(r, 0)); s.abort(); // mid-stream abort — a provider HAS engaged release(); await p; @@ -906,4 +909,62 @@ describe('AgentSession — reseat-less modes + mid-turn abort (ADR-0057 Step 2)' // The second turn ran (not blocked by turn_limit) — the aborted un-engaged turn did not count. expect(completes[1]?.type === 'session:turn_completed' && completes[1].stopReason).toBe('stop'); }); + + it('abort() from the turn_started emit sink aborts THIS turn (the controller is armed BEFORE the emit)', async () => { + // Regression: if the controller were armed AFTER the turn_started emit, an abort() from the emit sink + // would set #abortingTurn but no-op the (undefined) signal, so a later real failure would misclassify as + // 'aborted'. With the controller armed first, the abort actually aborts the turn → it settles 'aborted'. + const sinkEvents: SessionStreamEvent[] = []; + const ref: { s?: AgentSession } = {}; // a const holder — the emit closure reads it before `s` exists + const { deps } = harness([textTurn('hi')], { + emit: (e) => { + sinkEvents.push(e); + if (e.type === 'session:turn_started') ref.s?.abort(); + }, + }); + const s = session(deps); + ref.s = s; + s.start(); + await s.sendMessage('hi'); + const completes = sinkEvents.filter((e) => e.type === 'session:turn_completed'); + expect(completes).toHaveLength(1); + expect(completes[0]?.type === 'session:turn_completed' && completes[0].stopReason).toBe( + 'aborted', + ); + expect(sinkEvents.map((e) => e.type)).not.toContain('session:cancelled'); + }); + + // NOTE on the LATE-abort no-op (abort() landing in the microtask gap AFTER the turn core's final abort + // check but BEFORE the success path runs): it is **structurally** a no-op — the success path has NO + // `#abortingTurn` read, so it always completes the turn normally regardless of the flag (the `finally` + // clears it). There is no deterministic emit hook past the core's last `throwIfAborted` to drive that exact + // gap (a hook on the last in-turn event, cost:updated, lands BEFORE that check → a mid-stream abort, which + // the engaged-mid-stream test above already covers), so the guarantee is pinned by the code's structure, + // not a contrived race test. + + it('setTurnPolicy(undefined) CLEARS the approval regime too — the dispatch context drops the approval key', async () => { + const confirm = (): Promise<{ outcome: 'approve' }> => Promise.resolve({ outcome: 'approve' }); + let captured: ToolDispatchContext | undefined; + const capturing: ToolRegistry = { + has: () => true, + list: () => ['echo'], + dispatch: (toolCall, ctx) => { + captured = ctx; + return echoRegistry.dispatch(toolCall, ctx); + }, + }; + const { deps } = harness( + [toolUseTurn('c1'), textTurn('a'), toolUseTurn('c2'), textTurn('b')], + {}, + capturing, + ); + const s = session(deps, TOOL_AGENT); + s.start(); + s.setTurnPolicy({ confirm }); + await s.sendMessage('with regime'); + expect(captured?.approval?.confirm).toBe(confirm); // regime active + s.setTurnPolicy(undefined); // CLEAR + await s.sendMessage('cleared'); + expect(captured?.approval).toBeUndefined(); // regime gone — back to author-trust parity + }); }); diff --git a/packages/core/src/engine/agent-session.ts b/packages/core/src/engine/agent-session.ts index 3d374e05..59546b6d 100644 --- a/packages/core/src/engine/agent-session.ts +++ b/packages/core/src/engine/agent-session.ts @@ -331,17 +331,24 @@ export class AgentSession { // can never leak into this turn's catch path and misclassify a real failure as an abort. this.#abortingTurn = false; this.#status = 'running'; + // Arm the abort controller BEFORE the turn_started emit. A host whose sink calls abort() synchronously + // inside that emit must abort THIS turn's real signal — if #abort were still undefined, abort()'s + // `#abort?.abort()` would be a no-op while still setting #abortingTurn, and a later GENUINE failure would + // then be misclassified as `aborted`. The cancel-bail + cap block release it on their early returns. + const abort = this.#deps.newAbortController(); + this.#abort = abort; this.#deps.emit({ type: 'session:turn_started' }); // A cancel can fire SYNCHRONOUSLY inside the turn_started emit (a host whose sink calls cancel()). If it // did, session:cancelled is the terminal — bail before the cap block and before any egress, else we would // overwrite the 'cancelled' status back to 'idle' and emit a second terminal, or silently egress an - // already-cancelled turn. + // already-cancelled turn. (cancel() already cleared #abort.) if (this.#statusIs('cancelled')) return; // Hard turn cap — checked AFTER turn_started (the turn was attempted) but BEFORE any egress: the // blocked turn completes loudly with turn_limit and never calls a provider. 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 }, @@ -355,8 +362,6 @@ export class AgentSession { } this.#messages.push({ role: 'user', content: [{ type: 'text', text }] }); - const abort = this.#deps.newAbortController(); - this.#abort = abort; // Snapshot the reseat-less mode policy for the whole turn (ADR-0057): a mid-turn setTurnPolicy applies // only on the NEXT turn, so the advertise-filter + approval regime stay consistent within this turn. const turnPolicy = this.#turnPolicy; @@ -372,7 +377,8 @@ export class AgentSession { // EA7 note: an `abort()` that lands AFTER the turn fully resolved (a late `Esc`, past the turn core's // last `throwIfAborted`) is a no-op — the turn already produced its reply, so it completes NORMALLY // (the reply is kept, the turn is counted). `abort()` interrupts an IN-FLIGHT turn only; a turn the - // model already finished is not discarded. The `#abortingTurn` marker is cleared by the `finally`. + // model already finished is not discarded. This success path **never reads `#abortingTurn`** — that is + // precisely what makes a late abort structurally invisible here; the `finally` still clears the marker. this.#turnCount += 1; // Append the assistant reply to the cross-turn transcript as TEXT-ONLY. The turn core keeps the // within-turn tool_use/tool_result pairs internal (they never leave runAgentTurn — it returns only the From 0b73e962fa477b7bbebf210e1b073294d80ea62a Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Tue, 30 Jun 2026 05:34:23 +0300 Subject: [PATCH 09/45] =?UTF-8?q?refactor(db):=202.5.E=20Step=203a=20?= =?UTF-8?q?=E2=80=94=20extract=20the=20shared=20SSRF=20egress=20mechanism?= =?UTF-8?q?=20(one=20connect-by-validated-IP)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- packages/db/src/index.ts | 15 ++ packages/db/src/media-egress.test.ts | 14 +- packages/db/src/media-egress.ts | 366 +++++---------------------- packages/db/src/safe-egress.ts | 315 +++++++++++++++++++++++ 4 files changed, 409 insertions(+), 301 deletions(-) create mode 100644 packages/db/src/safe-egress.ts diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 7d506d88..bdfa1079 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -115,6 +115,21 @@ export { type HopResponse, } from './media-egress.js'; +// The shared SSRF egress mechanism (ADR-0029(d)/0043/0057) — `connectValidated` (one validated hop) + +// `readBounded` + `withEgressTimeout`, reused by media egress AND the CLI tool-egress text fetch (2.5.E) so +// there is exactly one connect-by-validated-IP implementation. Node-side; the pure engine never imports it. +export { + connectValidated, + readBounded, + withEgressTimeout, + isRedirectStatus, + nodeEgressDeps, + SafeEgressError, + type SafeEgressErrorCode, + type EgressDeps, + type EgressMethod, +} from './safe-egress.js'; + // Media references (1.AF/D12c + D11, ADR-0042/0044) — the media_objects/media_references retention + authz // junction store. A host wires `describe` behind the read_media MediaReadAccess delegate + `removeRunReferences` // behind the engine's terminal sweep; the pure engine never imports it. diff --git a/packages/db/src/media-egress.test.ts b/packages/db/src/media-egress.test.ts index a46d2f96..68765459 100644 --- a/packages/db/src/media-egress.test.ts +++ b/packages/db/src/media-egress.test.ts @@ -288,11 +288,13 @@ describe('fetchMediaBytes (1.AF/D9, ADR-0043 — SSRF-validated, size-bounded me ).rejects.toMatchObject({ code: 'network' }); }); - it('exposes a typed MediaEgressError', () => { + it('exposes a typed MediaEgressError (the shared SafeEgressError, aliased) with a code discriminant', () => { const err = new MediaEgressError('blocked_host', 'x'); expect(err).toBeInstanceOf(Error); expect(err.code).toBe('blocked_host'); - expect(err.name).toBe('MediaEgressError'); + // MediaEgressError is now an alias of the shared SafeEgressError (one egress error type) — the runtime + // class name reflects that; instanceof + the `code` discriminant are the load-bearing behaviors. + expect(err.name).toBe('SafeEgressError'); }); }); @@ -397,6 +399,7 @@ describe('nodeMediaEgressDeps — the Node mechanism wiring (E43-7)', () => { url: 'https://media.example.com:8443/a/b?c=d', hostname: 'media.example.com', pinnedIp: '203.0.113.10', + method: 'GET', }; const pending = nodeMediaEgressDeps.openConnection(request, new AbortController().signal); const { options, onResponse } = lastHttpsCall(); @@ -418,7 +421,12 @@ describe('nodeMediaEgressDeps — the Node mechanism wiring (E43-7)', () => { it('openConnection derives family 6 for an IPv6 pin and defaults a port-less url to 443', async () => { vi.mocked(httpsRequest).mockReturnValue(stubClientRequest()); const pending = nodeMediaEgressDeps.openConnection( - { url: 'https://v6.example.com/x', hostname: 'v6.example.com', pinnedIp: '2001:db8::1' }, + { + url: 'https://v6.example.com/x', + hostname: 'v6.example.com', + pinnedIp: '2001:db8::1', + method: 'GET', + }, new AbortController().signal, ); const { options, onResponse } = lastHttpsCall(); diff --git a/packages/db/src/media-egress.ts b/packages/db/src/media-egress.ts index 7f4bbd4b..cc1f6add 100644 --- a/packages/db/src/media-egress.ts +++ b/packages/db/src/media-egress.ts @@ -1,58 +1,42 @@ -import { lookup as dnsLookup } from 'node:dns/promises'; -import { request as httpsRequest } from 'node:https'; -import { isIP } from 'node:net'; +import type { AbortSignalLike } from '@relavium/shared'; import { - extractHttpsHost, - isPrivateOrLocalHost, - urlHasCredentials, - type AbortSignalLike, -} from '@relavium/shared'; + connectValidated, + isRedirectStatus, + nodeEgressDeps, + readBounded, + SafeEgressError, + withEgressTimeout, + type EgressDeps, +} from './safe-egress.js'; /** * `fetchMediaBytes` (1.AF/D9, [ADR-0043](../../../docs/decisions/0043-media-egress-failover-rematerialization-ssrf.md) - * §2/§3) — the host **mechanism** half of media egress: the Node/filesystem-host reference - * implementation the engine binds into a {@link MediaUrlFetch} hook so `deInlineMedia` can re-host a - * `url` media source to bytes. The engine owns the **policy** (the one shared SSRF primitive + the size - * bound); this performs the validated I/O, in one place, never an adapter: + * §2/§3) — the host **mechanism** half of media egress: the Node/filesystem-host reference implementation + * the engine binds into a `MediaUrlFetch` hook so `deInlineMedia` can re-host a `url` media source to bytes. * - * - **HTTPS-only, no embedded credentials** — rejected via the shared {@link extractHttpsHost} / - * {@link urlHasCredentials} primitives (never a second hand-rolled parser). - * - **DNS-rebind defense (TOCTOU)** — resolve the hostname, validate **every** resolved IP against the - * shared {@link isPrivateOrLocalHost} range-block, then **connect by the validated IP** (a pinned - * `lookup`) so the address checked is the address connected to. - * - **Per-hop redirect re-validation** — every `3xx` `Location` re-runs the whole HTTPS + no-creds + - * resolve + range-block + pin cycle on the new target (a redirect-to-private / -to-http is blocked - * mid-fetch), bounded by `maxRedirects`. A redirect body is never read. - * - **Streamed, size-bounded** — the body is consumed chunk-by-chunk and aborted the moment it exceeds - * `maxBytes`; an over-size response is never fully buffered. - * - **TLS verification is never disabled** — the request connects to the pinned IP but keeps the - * original hostname as the SNI `servername`, so the certificate is validated against the hostname. + * It is a thin wrapper over the **shared SSRF egress mechanism** ([safe-egress.ts](safe-egress.ts)) — the + * one connect-by-validated-IP primitive that the CLI tool-egress text fetch ([ADR-0057](../../../docs/decisions/0057-cli-chat-modes-and-per-tool-approval.md) + * 2.5.E) also reuses, so there is never a second hand-rolled SSRF parser (ADR-0029(d)). This file owns only + * the media-specific POLICY: GET-only, FOLLOW redirects (each hop re-validated by `connectValidated`; media + * has no `allowedDomains` allowlist, only the range-block), `200`-only, and size-bounded bytes. * - * Errors are a typed {@link MediaEgressError} whose message names a **reason only** — never the url, the - * resolved IP, a host stack, or bytes (ADR-0043 §4 secret-free discipline). The DNS resolver and the - * connection opener are injectable ({@link MediaEgressDeps}) so the SSRF policy + redirect + size-bound - * orchestration is deterministically unit-testable without real network/DNS; the default deps are Node. + * Errors are a typed {@link MediaEgressError} (= the shared `SafeEgressError`) whose message names a reason + * only — never the url, the resolved IP, a host stack, or bytes (ADR-0043 §4). The DNS resolver + connection + * opener are injectable ({@link MediaEgressDeps}) so the policy is deterministically unit-testable. */ -/** Why a media egress fetch failed — a secret-free, reason-only discriminant. */ -export type MediaEgressErrorCode = - | 'insecure_url' // not HTTPS, embeds credentials, or a malformed authority - | 'blocked_host' // resolves to (or is) a private/loopback/link-local/metadata address - | 'too_many_redirects' - | 'too_large' // body exceeded the configured maximum download size - | 'bad_status' // a non-200, non-redirect HTTP status - | 'network'; // the connection failed / was aborted - -/** A typed media-egress failure. The `message` names a reason only — never the url/IP/bytes (rule 6). */ -export class MediaEgressError extends Error { - readonly code: MediaEgressErrorCode; - constructor(code: MediaEgressErrorCode, message: string) { - super(message); - this.name = 'MediaEgressError'; - this.code = code; - } -} +// Back-compat public surface: the media API names alias the shared egress types (one implementation). +export { + SafeEgressError as MediaEgressError, + nodeEgressDeps as nodeMediaEgressDeps, +} from './safe-egress.js'; +export type { + SafeEgressErrorCode as MediaEgressErrorCode, + EgressDeps as MediaEgressDeps, + HopRequest, + HopResponse, +} from './safe-egress.js'; export interface FetchMediaBytesOptions { /** The per-fetch upper bound on the streamed body in bytes (the engine supplies this policy). */ @@ -74,269 +58,55 @@ export interface FetchMediaBytesOptions { readonly allowPrivate?: boolean; } -/** One redirect-free HTTP response the orchestrator inspects (status + Location + a body stream). */ -export interface HopResponse { - readonly status: number; - readonly location: string | undefined; - readonly body: AsyncIterable; - /** Abort the underlying socket — called when we stop reading early (a redirect, an error, an over-size body). */ - readonly dispose: () => void; -} - -/** One pinned request the connection opener must perform (no redirect following — the orchestrator owns that). */ -export interface HopRequest { - readonly url: string; - readonly hostname: string; - /** The pre-validated IP the connection MUST be pinned to (TOCTOU defense — never re-resolve here). */ - readonly pinnedIp: string; -} - -/** Injectable I/O primitives — Node by default; faked in tests so the SSRF policy is deterministic. */ -export interface MediaEgressDeps { - /** Resolve a hostname to its IP(s) (an IP literal resolves to itself). */ - readonly resolveHost: (hostname: string) => Promise; - /** Open ONE pinned HTTPS connection and return its (unread) response. */ - readonly openConnection: (request: HopRequest, signal: AbortSignal) => Promise; -} - const DEFAULT_TIMEOUT_MS = 30_000; const DEFAULT_MAX_REDIRECTS = 5; -/** True for the redirect statuses we follow (a `Location` is required, re-validated per hop). */ -function isRedirectStatus(status: number): boolean { - return status === 301 || status === 302 || status === 303 || status === 307 || status === 308; -} - -/** - * Validate an egress URL's scheme + authority via the shared SSRF policy primitive and return its - * lowercased host. Throws `insecure_url` for a non-HTTPS scheme, a malformed authority, or embedded - * credentials — never a second hand-rolled parser. - */ -function validateEgressHost(url: string): string { - if (urlHasCredentials(url)) { - throw new MediaEgressError('insecure_url', 'media egress url must not embed credentials'); - } - const parsed = extractHttpsHost(url); - if (parsed === null) { - throw new MediaEgressError('insecure_url', 'media egress url must be a well-formed https url'); - } - if (parsed.hasCredentials) { - throw new MediaEgressError('insecure_url', 'media egress url must not embed credentials'); - } - return parsed.host; -} - /** - * Resolve `host` and validate the host literal AND **every** resolved IP against the shared range-block. - * Fail-closed: any private/loopback/link-local/metadata address (unless `allowPrivate`) blocks the whole - * fetch — so a multi-record name with one private answer cannot slip through. - */ -async function resolveValidatedIps( - host: string, - deps: MediaEgressDeps, - allowPrivate: boolean, -): Promise { - if (!allowPrivate && isPrivateOrLocalHost(host)) { - throw new MediaEgressError('blocked_host', 'media egress target is a private/loopback address'); - } - const ips = await deps.resolveHost(host); - if (ips.length === 0) { - throw new MediaEgressError('blocked_host', 'media egress target did not resolve to an address'); - } - for (const ip of ips) { - // Every resolved value MUST be an IP literal — otherwise a (buggy/malicious) resolver returning a - // hostname would pass the range-block (a hostname is not a private IP) and become the pinned `lookup` - // target, defeating the connect-by-validated-IP guarantee. Fail-closed on a non-IP. - if (isIP(ip) === 0) { - throw new MediaEgressError('blocked_host', 'media egress resolver returned a non-IP address'); - } - if (!allowPrivate && isPrivateOrLocalHost(ip)) { - throw new MediaEgressError( - 'blocked_host', - 'media egress target resolves to a private/loopback address', - ); - } - } - return ips; -} - -/** Consume a body stream, aborting the moment it exceeds `maxBytes`; concat the bounded chunks. */ -async function readBounded( - body: AsyncIterable, - maxBytes: number, - dispose: () => void, -): Promise { - const chunks: Uint8Array[] = []; - let total = 0; - try { - for await (const chunk of body) { - total += chunk.length; - if (total > maxBytes) { - throw new MediaEgressError('too_large', 'media egress response exceeded the maximum size'); - } - chunks.push(chunk); - } - } finally { - dispose(); // abort the socket (harmless if the body already ended) - } - const out = new Uint8Array(total); - let offset = 0; - for (const chunk of chunks) { - out.set(chunk, offset); - offset += chunk.length; - } - return out; -} - -/** One validated hop's result: a redirect `Location` to follow, or the delivered size-bounded bytes. */ -type HopOutcome = - | { readonly kind: 'redirect'; readonly location: string } - | { readonly kind: 'bytes'; readonly bytes: Uint8Array }; - -/** - * Perform ONE validated hop: validate the url + resolve / range-block / pin the host, open the pinned - * connection, and either surface a redirect `Location` (the caller re-validates it on the next hop) or - * read the size-bounded body. Split out of {@link fetchMediaBytes} to keep its cognitive complexity in - * budget (sonar S3776); any raw throw here is normalized to a typed `MediaEgressError` by the caller. - */ -async function performHop( - target: string, - deps: MediaEgressDeps, - allowPrivate: boolean, - signal: AbortSignal, - maxBytes: number, -): Promise { - const host = validateEgressHost(target); - const ips = await resolveValidatedIps(host, deps, allowPrivate); - // Connect by the FIRST validated IP — every IP was range-checked + confirmed an IP literal above, so - // pinning means the address validated is the address connected to (no re-resolve TOCTOU window). - const pinnedIp = ips[0]; - if (pinnedIp === undefined) { - // Unreachable: `resolveValidatedIps` throws `blocked_host` on an empty result rather than returning `[]`. - // Fail closed (never fall back to pinning the UNVALIDATED hostname) so a future return-convention change - // can't silently reopen the re-resolve window. - throw new MediaEgressError('blocked_host', 'no validated IP to pin the connection to'); - } - const response = await deps.openConnection({ url: target, hostname: host, pinnedIp }, signal); - if (isRedirectStatus(response.status)) { - response.dispose(); // never read a redirect body - const location = response.location; - if (location === undefined || location.length === 0) { - throw new MediaEgressError('bad_status', 'media egress redirect had no Location'); - } - return { kind: 'redirect', location }; - } - if (response.status !== 200) { - response.dispose(); - throw new MediaEgressError('bad_status', 'media egress received a non-200 status'); - } - return { kind: 'bytes', bytes: await readBounded(response.body, maxBytes, response.dispose) }; -} - -/** - * Fetch the bytes at a public-HTTPS `url`, enforcing the full SSRF + size-bound policy. The host - * mechanism the engine binds into a `MediaUrlFetch` hook (the engine supplies `maxBytes` + the - * `AbortSignal`). See the file header for the security contract. Its ONLY thrown type is - * {@link MediaEgressError} — every raw resolver / socket / `new URL` / body-read error is normalized. + * Fetch the bytes at a public-HTTPS `url`, enforcing the full SSRF + size-bound policy via the shared + * mechanism. Media FOLLOWS redirects (each hop re-validated) and requires a final `200`. Its ONLY thrown + * type is {@link MediaEgressError} — every raw error is normalized by `withEgressTimeout`. */ export async function fetchMediaBytes( url: string, options: FetchMediaBytesOptions, - deps: MediaEgressDeps = nodeMediaEgressDeps, + deps: EgressDeps = nodeEgressDeps, ): Promise { const maxRedirects = options.maxRedirects ?? DEFAULT_MAX_REDIRECTS; const allowPrivate = options.allowPrivate ?? false; - const controller = new AbortController(); - const abort = (): void => controller.abort(); - if (options.signal?.aborted === true) { - controller.abort(); - } - options.signal?.addEventListener('abort', abort); // removed in the finally below - const timer = setTimeout(abort, options.timeoutMs ?? DEFAULT_TIMEOUT_MS); - try { - let target = url; - for (let redirects = 0; ; redirects += 1) { - if (redirects > maxRedirects) { - throw new MediaEgressError( - 'too_many_redirects', - 'media egress exceeded the redirect limit', + return withEgressTimeout( + options.signal, + options.timeoutMs ?? DEFAULT_TIMEOUT_MS, + async (signal) => { + let target = url; + for (let redirects = 0; ; redirects += 1) { + if (redirects > maxRedirects) { + throw new SafeEgressError( + 'too_many_redirects', + 'media egress exceeded the redirect limit', + ); + } + const response = await connectValidated( + target, + { allowPrivate, method: 'GET' }, + deps, + signal, ); + if (isRedirectStatus(response.status)) { + response.dispose(); // never read a redirect body + const location = response.location; + if (location === undefined || location.length === 0) { + throw new SafeEgressError('bad_status', 'media egress redirect had no Location'); + } + // A relative Location resolves against the current url; the next iteration re-validates it (per-hop). + target = new URL(location, target).toString(); + continue; + } + if (response.status !== 200) { + response.dispose(); + throw new SafeEgressError('bad_status', 'media egress received a non-200 status'); + } + return readBounded(response.body, options.maxBytes, response.dispose); } - const outcome = await performHop( - target, - deps, - allowPrivate, - controller.signal, - options.maxBytes, - ); - if (outcome.kind === 'bytes') { - return outcome.bytes; - } - // A relative Location resolves against the current url; the next iteration re-validates it (per-hop). - target = new URL(outcome.location, target).toString(); - } - } catch (error) { - if (error instanceof MediaEgressError) { - throw error; // a typed failure (blocked_host / too_large / bad_status / …) — preserve the discriminant - } - // Any RAW throw is normalized to the typed, secret-free network failure — a resolver DNS error, an - // openConnection socket error, a malformed-Location `new URL` TypeError, or an aborted body read — so - // fetchMediaBytes's ONLY thrown type is MediaEgressError (the function's contract; never a raw leak). - throw new MediaEgressError('network', 'media egress request failed'); - } finally { - clearTimeout(timer); - options.signal?.removeEventListener('abort', abort); - } + }, + ); } - -/** The default Node deps: `node:dns` lookup (IP literal → itself) + a pinned `node:https` GET. */ -export const nodeMediaEgressDeps: MediaEgressDeps = { - resolveHost: async (hostname: string): Promise => { - if (isIP(hostname) !== 0) { - return [hostname]; // already an IP literal — no DNS round-trip - } - const records = await dnsLookup(hostname, { all: true }); - return records.map((record) => record.address); - }, - openConnection: (request: HopRequest, signal: AbortSignal): Promise => - new Promise((resolve, reject) => { - const parsed = new URL(request.url); - const family = isIP(request.pinnedIp) === 6 ? 6 : 4; - const clientRequest = httpsRequest( - { - protocol: 'https:', - hostname: request.hostname, - // The URL's port (default 443) is honored as-is — a public CDN media URL may legitimately serve - // over a non-443 HTTPS port. This is safe under the current default wiring (allowPrivate: false): - // the private/loopback/link-local IP range block (resolveValidatedIps) prevents reaching an internal - // service on ANY port, so no port allow-list is needed. If the BYOK local-endpoint allowPrivate - // opt-in is ever wired, that ADR MUST add an explicit port allow-list decision (a crafted - // https://host:22/ to a permitted-private address would otherwise be reachable). See SEC-EGRESS-3. - port: parsed.port === '' ? 443 : Number(parsed.port), - path: `${parsed.pathname}${parsed.search}`, - method: 'GET', - servername: request.hostname, // SNI + certificate hostname — TLS verification stays ON - // Pin to the pre-validated IP: the agent connects to exactly this address, never re-resolving. - lookup: (_hostname, _opts, callback) => callback(null, request.pinnedIp, family), - signal, - }, - (incoming) => { - const location = incoming.headers.location; - resolve({ - status: incoming.statusCode ?? 0, - location: typeof location === 'string' ? location : undefined, - body: incoming, - dispose: () => { - incoming.destroy(); - clientRequest.destroy(); - }, - }); - }, - ); - // A secret-free network failure — never echo the underlying message (it can carry the host/IP). - clientRequest.on('error', () => - reject(new MediaEgressError('network', 'media egress request failed')), - ); - clientRequest.end(); - }), -}; diff --git a/packages/db/src/safe-egress.ts b/packages/db/src/safe-egress.ts new file mode 100644 index 00000000..81a9fb62 --- /dev/null +++ b/packages/db/src/safe-egress.ts @@ -0,0 +1,315 @@ +import { lookup as dnsLookup } from 'node:dns/promises'; +import { request as httpsRequest } from 'node:https'; +import { isIP } from 'node:net'; + +import { + extractHttpsHost, + isPrivateOrLocalHost, + urlHasCredentials, + type AbortSignalLike, +} from '@relavium/shared'; + +/** + * The ONE shared host-side SSRF egress mechanism — `connectValidated` performs a single validated hop + * (HTTPS-only + no-creds → DNS-resolve → range-block **every** resolved IP → connect **pinned** to the + * validated IP, keeping the hostname as the SNI so TLS verification stays on), and `readBounded` / + * `withEgressTimeout` provide the size-bound + timeout/error-normalization wiring. Both the media-egress + * byte fetch ([media-egress.ts](media-egress.ts), [ADR-0043](../../../docs/decisions/0043-media-egress-failover-rematerialization-ssrf.md)) + * and the CLI tool-egress text fetch ([ADR-0057](../../../docs/decisions/0057-cli-chat-modes-and-per-tool-approval.md) 2.5.E) + * reuse this — **never a second hand-rolled SSRF parser** (ADR-0029(d) one-primitive rule). The range-block + * itself (`isPrivateOrLocalHost`) and the URL policy (`extractHttpsHost` / `urlHasCredentials`) are the + * shared `@relavium/shared` primitives. + * + * The redirect POLICY differs per caller and stays per-caller (it is not part of the shared mechanism): + * media FOLLOWS redirects (no allowlist; each hop is re-validated by calling `connectValidated` again), + * while the tool egress does 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 that + * allowlist; the tool returns the `3xx` (status + `Location`) so the model can re-issue a re-validated call. + * + * Errors are a typed {@link SafeEgressError} whose message names a **reason only** — never the url, the + * resolved IP, a host stack, or bytes. The DNS resolver + connection opener are injectable ({@link EgressDeps}) + * so the policy is deterministically unit-testable without real network/DNS; the default deps are Node. + */ + +/** Why a safe-egress fetch failed — a secret-free, reason-only discriminant. */ +export type SafeEgressErrorCode = + | 'insecure_url' // not HTTPS, embeds credentials, or a malformed authority + | 'blocked_host' // resolves to (or is) a private/loopback/link-local/metadata address + | 'too_many_redirects' + | 'too_large' // body exceeded the configured maximum download size + | 'bad_status' // a non-200, non-redirect HTTP status (media only; the tool returns any status) + | 'network'; // the connection failed / was aborted + +/** A typed egress failure. The `message` names a reason only — never the url/IP/bytes (secret-free). */ +export class SafeEgressError extends Error { + readonly code: SafeEgressErrorCode; + constructor(code: SafeEgressErrorCode, message: string) { + super(message); + this.name = 'SafeEgressError'; + this.code = code; + } +} + +/** The HTTP methods an egress hop may use. Media is GET-only; the tool `http_request` allows the four. */ +export type EgressMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'; + +/** One pinned request the connection opener must perform (no redirect following — the caller owns that). */ +export interface HopRequest { + readonly url: string; + readonly hostname: string; + /** The pre-validated IP the connection MUST be pinned to (TOCTOU defense — never re-resolve here). */ + readonly pinnedIp: string; + readonly method: EgressMethod; + /** Request headers (incl. a host-resolved credential, already attached by the caller). */ + readonly headers?: Readonly> | undefined; + readonly body?: string | undefined; +} + +/** One redirect-free HTTP response the caller inspects (status + headers + Location + a body stream). */ +export interface HopResponse { + readonly status: number; + /** Response headers — populated by the Node deps; a media fake may omit them (the byte path ignores them). */ + readonly headers?: Readonly> | undefined; + readonly location: string | undefined; + readonly body: AsyncIterable; + /** Abort the underlying socket — called when we stop reading early (a redirect, an error, an over-size body). */ + readonly dispose: () => void; +} + +/** Injectable I/O primitives — Node by default; faked in tests so the SSRF policy is deterministic. */ +export interface EgressDeps { + /** Resolve a hostname to its IP(s) (an IP literal resolves to itself). */ + readonly resolveHost: (hostname: string) => Promise; + /** Open ONE pinned HTTPS connection and return its (unread) response. */ + readonly openConnection: (request: HopRequest, signal: AbortSignal) => Promise; +} + +/** True for the redirect statuses callers may follow (a `Location` is required, re-validated per hop). */ +export function isRedirectStatus(status: number): boolean { + return status === 301 || status === 302 || status === 303 || status === 307 || status === 308; +} + +/** + * Validate an egress URL's scheme + authority via the shared SSRF policy primitives and return its + * lowercased host. Throws `insecure_url` for a non-HTTPS scheme, a malformed authority, or embedded + * credentials — never a second hand-rolled parser. + */ +function validateEgressHost(url: string): string { + if (urlHasCredentials(url)) { + throw new SafeEgressError('insecure_url', 'egress url must not embed credentials'); + } + const parsed = extractHttpsHost(url); + if (parsed === null) { + throw new SafeEgressError('insecure_url', 'egress url must be a well-formed https url'); + } + if (parsed.hasCredentials) { + throw new SafeEgressError('insecure_url', 'egress url must not embed credentials'); + } + return parsed.host; +} + +/** + * Resolve `host` and validate the host literal AND **every** resolved IP against the shared range-block. + * Fail-closed: any private/loopback/link-local/metadata address (unless `allowPrivate`) blocks the whole + * fetch — so a multi-record name with one private answer cannot slip through. + */ +async function resolveValidatedIps( + host: string, + deps: EgressDeps, + allowPrivate: boolean, +): Promise { + if (!allowPrivate && isPrivateOrLocalHost(host)) { + throw new SafeEgressError('blocked_host', 'egress target is a private/loopback address'); + } + const ips = await deps.resolveHost(host); + if (ips.length === 0) { + throw new SafeEgressError('blocked_host', 'egress target did not resolve to an address'); + } + for (const ip of ips) { + // Every resolved value MUST be an IP literal — otherwise a (buggy/malicious) resolver returning a + // hostname would pass the range-block (a hostname is not a private IP) and become the pinned `lookup` + // target, defeating the connect-by-validated-IP guarantee. Fail-closed on a non-IP. + if (isIP(ip) === 0) { + throw new SafeEgressError('blocked_host', 'egress resolver returned a non-IP address'); + } + if (!allowPrivate && isPrivateOrLocalHost(ip)) { + throw new SafeEgressError( + 'blocked_host', + 'egress target resolves to a private/loopback address', + ); + } + } + return ips; +} + +/** + * Perform ONE validated hop: validate the url + resolve / range-block / pin the host, then open the pinned + * connection. The caller decides what to do with the response (follow a redirect — re-validating by calling + * this again — or read the body). This is the **single** connect-by-validated-IP mechanism; a raw throw is + * normalized to a typed `SafeEgressError('network')` by {@link withEgressTimeout}. + */ +export async function connectValidated( + target: string, + opts: { + readonly allowPrivate: boolean; + readonly method: EgressMethod; + readonly headers?: Readonly> | undefined; + readonly body?: string | undefined; + }, + deps: EgressDeps, + signal: AbortSignal, +): Promise { + const host = validateEgressHost(target); + const ips = await resolveValidatedIps(host, deps, opts.allowPrivate); + // Connect by the FIRST validated IP — every IP was range-checked + confirmed an IP literal above, so + // pinning means the address validated is the address connected to (no re-resolve TOCTOU window). + const pinnedIp = ips[0]; + if (pinnedIp === undefined) { + // Unreachable: `resolveValidatedIps` throws `blocked_host` on an empty result rather than returning `[]`. + // Fail closed (never fall back to pinning the UNVALIDATED hostname) so a future return-convention change + // can't silently reopen the re-resolve window. + throw new SafeEgressError('blocked_host', 'no validated IP to pin the connection to'); + } + return deps.openConnection( + { + url: target, + hostname: host, + pinnedIp, + method: opts.method, + headers: opts.headers, + body: opts.body, + }, + signal, + ); +} + +/** Consume a body stream, aborting the moment it exceeds `maxBytes`; concat the bounded chunks. */ +export async function readBounded( + body: AsyncIterable, + maxBytes: number, + dispose: () => void, +): Promise { + const chunks: Uint8Array[] = []; + let total = 0; + try { + for await (const chunk of body) { + total += chunk.length; + if (total > maxBytes) { + throw new SafeEgressError('too_large', 'egress response exceeded the maximum size'); + } + chunks.push(chunk); + } + } finally { + dispose(); // abort the socket (harmless if the body already ended) + } + const out = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + out.set(chunk, offset); + offset += chunk.length; + } + return out; +} + +/** + * The shared timeout + abort + error-normalization wrapper. Composes the caller's `signal` with a timeout + * into one `AbortController`, runs `fn(controller.signal)`, and guarantees the ONLY thrown type is a typed, + * secret-free {@link SafeEgressError} — every raw resolver / socket / `new URL` / body-read error becomes + * `SafeEgressError('network')`, never a raw leak. + */ +export async function withEgressTimeout( + signal: AbortSignalLike | undefined, + timeoutMs: number, + fn: (signal: AbortSignal) => Promise, +): Promise { + const controller = new AbortController(); + const abort = (): void => controller.abort(); + if (signal?.aborted === true) { + controller.abort(); + } + signal?.addEventListener('abort', abort); // removed in the finally below + const timer = setTimeout(abort, timeoutMs); + try { + return await fn(controller.signal); + } catch (error) { + if (error instanceof SafeEgressError) { + throw error; // a typed failure (blocked_host / too_large / bad_status / …) — preserve the discriminant + } + // Any RAW throw is normalized to the typed, secret-free network failure. + throw new SafeEgressError('network', 'egress request failed'); + } finally { + clearTimeout(timer); + signal?.removeEventListener('abort', abort); + } +} + +/** The default Node deps: `node:dns` lookup (IP literal → itself) + a pinned `node:https` request. */ +export const nodeEgressDeps: EgressDeps = { + resolveHost: async (hostname: string): Promise => { + if (isIP(hostname) !== 0) { + return [hostname]; // already an IP literal — no DNS round-trip + } + const records = await dnsLookup(hostname, { all: true }); + return records.map((record) => record.address); + }, + openConnection: (request: HopRequest, signal: AbortSignal): Promise => + new Promise((resolve, reject) => { + const parsed = new URL(request.url); + const family = isIP(request.pinnedIp) === 6 ? 6 : 4; + const clientRequest = httpsRequest( + { + protocol: 'https:', + hostname: request.hostname, + // The URL's port (default 443) is honored as-is — a public CDN/API URL may legitimately serve over + // a non-443 HTTPS port. Safe under the default wiring (allowPrivate: false): the private/loopback/ + // link-local IP range block (resolveValidatedIps) prevents reaching an internal service on ANY + // port, so no port allow-list is needed. If a BYOK local-endpoint allowPrivate opt-in is ever + // wired, that ADR MUST add an explicit port allow-list decision (SEC-EGRESS-3). + port: parsed.port === '' ? 443 : Number(parsed.port), + path: `${parsed.pathname}${parsed.search}`, + method: request.method, + ...(request.headers === undefined ? {} : { headers: request.headers }), + servername: request.hostname, // SNI + certificate hostname — TLS verification stays ON + // Pin to the pre-validated IP: the agent connects to exactly this address, never re-resolving. + lookup: (_hostname, _opts, callback) => callback(null, request.pinnedIp, family), + signal, + }, + (incoming) => { + const location = incoming.headers.location; + resolve({ + status: incoming.statusCode ?? 0, + headers: flattenHeaders(incoming.headers), + location: typeof location === 'string' ? location : undefined, + body: incoming, + dispose: () => { + incoming.destroy(); + clientRequest.destroy(); + }, + }); + }, + ); + // A secret-free network failure — never echo the underlying message (it can carry the host/IP). + clientRequest.on('error', () => + reject(new SafeEgressError('network', 'egress request failed')), + ); + if (request.body !== undefined) { + clientRequest.write(request.body); + } + clientRequest.end(); + }), +}; + +/** Flatten Node's `IncomingHttpHeaders` (string | string[] | undefined) to a plain string record. */ +function flattenHeaders( + headers: Readonly>, +): Record { + const out: Record = {}; + for (const [key, value] of Object.entries(headers)) { + if (typeof value === 'string') { + out[key] = value; + } else if (Array.isArray(value)) { + out[key] = value.join(', '); + } + } + return out; +} From e7d74491c2fffde4f26bd45a3fabd6e306dcb2d9 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Tue, 30 Jun 2026 08:07:10 +0300 Subject: [PATCH 10/45] =?UTF-8?q?feat(cli):=202.5.E=20Step=203=20=E2=80=94?= =?UTF-8?q?=20egress=20+=20os=20host=20arms,=20fs=20protected=20paths?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/cli/src/engine/tool-host/egress.test.ts | 152 +++++++++++ apps/cli/src/engine/tool-host/egress.ts | 113 ++++++++ apps/cli/src/engine/tool-host/errors.ts | 9 + apps/cli/src/engine/tool-host/fs.test.ts | 53 ++++ apps/cli/src/engine/tool-host/fs.ts | 48 ++++ apps/cli/src/engine/tool-host/os.test.ts | 184 +++++++++++++ apps/cli/src/engine/tool-host/os.ts | 269 +++++++++++++++++++ 7 files changed, 828 insertions(+) create mode 100644 apps/cli/src/engine/tool-host/egress.test.ts create mode 100644 apps/cli/src/engine/tool-host/egress.ts create mode 100644 apps/cli/src/engine/tool-host/os.test.ts create mode 100644 apps/cli/src/engine/tool-host/os.ts diff --git a/apps/cli/src/engine/tool-host/egress.test.ts b/apps/cli/src/engine/tool-host/egress.test.ts new file mode 100644 index 00000000..8ca8f5df --- /dev/null +++ b/apps/cli/src/engine/tool-host/egress.test.ts @@ -0,0 +1,152 @@ +import type { EgressDeps, HopRequest } from '@relavium/db'; +import { describe, expect, it } from 'vitest'; + +import { createNodeEgressCapability } from './egress.js'; +import { EgressCapabilityError, EgressDeniedError } from './errors.js'; + +async function* bodyOf(text: string): AsyncGenerator { + await Promise.resolve(); + if (text.length > 0) yield new TextEncoder().encode(text); +} + +interface FakeResponse { + readonly status: number; + readonly headers?: Record; + readonly body?: string; +} + +/** A deterministic egress-deps fake: a host→IPs resolver + a single scripted response. */ +function fakeDeps(config: { + readonly resolve?: Record; + readonly response: FakeResponse; +}): { deps: EgressDeps; calls: HopRequest[] } { + const calls: HopRequest[] = []; + const deps: EgressDeps = { + resolveHost: (host) => Promise.resolve(config.resolve?.[host] ?? [host]), + openConnection: (request) => { + calls.push(request); + return Promise.resolve({ + status: config.response.status, + headers: config.response.headers, + location: config.response.headers?.['location'], + body: bodyOf(config.response.body ?? ''), + dispose: () => {}, + }); + }, + }; + return { deps, calls }; +} + +const PUBLIC = { 'api.example.com': ['203.0.113.10'] } as const; // a public TEST-NET-3 IP + +describe('createNodeEgressCapability (2.5.E Step 3) — text egress over the shared SSRF mechanism', () => { + it('performs a GET, pinned to the validated IP, returning status + headers + decoded text', async () => { + const { deps, calls } = fakeDeps({ + resolve: PUBLIC, + response: { + status: 200, + headers: { 'content-type': 'application/json' }, + body: '{"ok":true}', + }, + }); + const egress = createNodeEgressCapability({ deps }); + const res = await egress.fetch({ method: 'GET', url: 'https://api.example.com/x' }); + expect(res.status).toBe(200); + expect(res.body).toBe('{"ok":true}'); + expect(res.headers['content-type']).toBe('application/json'); + expect(calls[0]?.pinnedIp).toBe('203.0.113.10'); // connect-by-validated-IP + expect(calls[0]?.method).toBe('GET'); + expect(calls[0]?.hostname).toBe('api.example.com'); // SNI keeps the hostname + }); + + it('DENIES a private/loopback target (SSRF range-block) as a fatal EgressDeniedError', async () => { + const { deps } = fakeDeps({ + resolve: { 'evil.example.com': ['127.0.0.1'] }, + response: { status: 200 }, + }); + const egress = createNodeEgressCapability({ deps }); + const err = await egress + .fetch({ method: 'GET', url: 'https://evil.example.com/x' }) + .catch((e: unknown) => e); + expect(err).toBeInstanceOf(EgressDeniedError); + expect((err as EgressDeniedError).runErrorCode).toBe('tool_denied'); // fatal, never retried + expect((err as EgressDeniedError).retryable).toBe(false); + }); + + it('DENIES a non-HTTPS url as a fatal EgressDeniedError (before any DNS)', async () => { + const { deps, calls } = fakeDeps({ resolve: PUBLIC, response: { status: 200 } }); + const egress = createNodeEgressCapability({ deps }); + await expect( + egress.fetch({ method: 'GET', url: 'http://api.example.com/x' }), + ).rejects.toBeInstanceOf(EgressDeniedError); + expect(calls).toHaveLength(0); // rejected at the url policy gate, never opened a connection + }); + + it('does NOT follow redirects — a 3xx is RETURNED with its Location (avoids the allowedDomains bypass)', async () => { + const { deps, calls } = fakeDeps({ + resolve: PUBLIC, + response: { status: 302, headers: { location: 'https://other.example.com/y' } }, + }); + const egress = createNodeEgressCapability({ deps }); + const res = await egress.fetch({ method: 'GET', url: 'https://api.example.com/x' }); + expect(res.status).toBe(302); + expect(res.headers['location']).toBe('https://other.example.com/y'); // surfaced for the model to re-issue + expect(calls).toHaveLength(1); // exactly ONE hop — the redirect was never chased + }); + + it('returns a non-200, non-redirect status (e.g. 404) rather than throwing — the model surfaces it', async () => { + const { deps } = fakeDeps({ resolve: PUBLIC, response: { status: 404, body: 'not found' } }); + const egress = createNodeEgressCapability({ deps }); + const res = await egress.fetch({ method: 'GET', url: 'https://api.example.com/x' }); + expect(res.status).toBe(404); + expect(res.body).toBe('not found'); + }); + + it('resolves an opaque credentialRef host-side and attaches it as a bearer header (never the engine)', async () => { + const { deps, calls } = fakeDeps({ resolve: PUBLIC, response: { status: 200 } }); + const egress = createNodeEgressCapability({ + deps, + resolveCredential: (ref) => + Promise.resolve(ref === 'kc:search' ? ['SECRET', 'KEY'].join('-') : undefined), + }); + await egress.fetch({ + method: 'GET', + url: 'https://api.example.com/x', + credentialRef: 'kc:search', + }); + expect(calls[0]?.headers?.['authorization']).toBe('Bearer SECRET-KEY'); + }); + + it('proceeds WITHOUT a credential when the ref does not resolve (a provider 401 surfaces, never a crash)', async () => { + const { deps, calls } = fakeDeps({ resolve: PUBLIC, response: { status: 200 } }); + const egress = createNodeEgressCapability({ + deps, + resolveCredential: () => Promise.resolve(undefined), + }); + await egress.fetch({ + method: 'GET', + url: 'https://api.example.com/x', + credentialRef: 'kc:missing', + }); + expect(calls[0]?.headers?.['authorization']).toBeUndefined(); + }); + + it('forwards a POST method + body to the hop', async () => { + const { deps, calls } = fakeDeps({ resolve: PUBLIC, response: { status: 200, body: 'ok' } }); + const egress = createNodeEgressCapability({ deps }); + await egress.fetch({ method: 'POST', url: 'https://api.example.com/x', body: '{"q":1}' }); + expect(calls[0]?.method).toBe('POST'); + expect(calls[0]?.body).toBe('{"q":1}'); + }); + + it('fails an over-size response as a transient EgressCapabilityError (retryable)', async () => { + const { deps } = fakeDeps({ + resolve: PUBLIC, + response: { status: 200, body: 'x'.repeat(500) }, + }); + const egress = createNodeEgressCapability({ deps, maxResponseBytes: 100 }); + await expect( + egress.fetch({ method: 'GET', url: 'https://api.example.com/x' }), + ).rejects.toBeInstanceOf(EgressCapabilityError); + }); +}); diff --git a/apps/cli/src/engine/tool-host/egress.ts b/apps/cli/src/engine/tool-host/egress.ts new file mode 100644 index 00000000..910a3e67 --- /dev/null +++ b/apps/cli/src/engine/tool-host/egress.ts @@ -0,0 +1,113 @@ +import type { EgressCapability, EgressRequest, EgressResponse } from '@relavium/core'; +import { + connectValidated, + nodeEgressDeps, + readBounded, + SafeEgressError, + withEgressTimeout, + type EgressDeps, +} from '@relavium/db'; +import type { AbortSignalLike } from '@relavium/shared'; + +import { EgressCapabilityError, EgressDeniedError } from './errors.js'; + +/** + * The host-side `egress` capability arm (2.5.E Step 3, [ADR-0057](../../../../../docs/decisions/0057-cli-chat-modes-and-per-tool-approval.md)) — + * the text-shaped `EgressCapability.fetch` the engine's `http_request` / `web_search` / `mcp_call`-via-http + * tools dispatch through. It is a thin caller of the **shared** SSRF mechanism ([safe-egress.ts](../../../../../packages/db/src/safe-egress.ts), + * `connectValidated`) — the same connect-by-validated-IP primitive media egress uses, never a second parser. + * + * Policy (distinct from media's): it does **NOT follow redirects**. The engine's `enforcePolicy` validated + * only the ORIGINAL url against the exact-FQDN `allowedDomains` allowlist, so following a `3xx` to a different + * host would bypass that allowlist (the SSRF range-block alone does not). Instead, **any** status (incl. a + * `3xx`) is returned to the model with its `Location` header so the model can re-issue a re-validated call. + * The opaque `credentialRef` is resolved host-side ({@link NodeEgressCapabilityConfig.resolveCredential}) and + * attached as a bearer header INSIDE this trusted boundary — the raw secret never reaches the engine + * ([ADR-0006](../../../../../docs/decisions/0006-os-keychain-for-api-keys.md)). + */ + +/** A 1 MiB raw-body cap (the model-facing result is further bounded by the registry to ~50 KiB). */ +const DEFAULT_MAX_RESPONSE_BYTES = 1024 * 1024; +const DEFAULT_TIMEOUT_MS = 30_000; + +export interface NodeEgressCapabilityConfig { + /** The raw response-body byte cap (default 1 MiB); an over-size response fails `too_large`. */ + readonly maxResponseBytes?: number; + /** Overall request timeout in ms (default 30000). */ + readonly timeoutMs?: number; + /** + * Resolve an opaque `credentialRef` to its secret VALUE, host-side (the keychain) — never logged, never + * returned to the engine. Absent (or an unresolved ref) ⇒ the request proceeds with no credential (a + * provider that requires one returns 401, surfaced to the model — never a crash). + */ + readonly resolveCredential?: (ref: string) => Promise; + /** Injectable egress deps (Node DNS + pinned HTTPS by default; faked in tests). */ + readonly deps?: EgressDeps; +} + +export function createNodeEgressCapability( + config: NodeEgressCapabilityConfig = {}, +): EgressCapability { + const maxResponseBytes = config.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES; + const timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const deps = config.deps ?? nodeEgressDeps; + const decoder = new TextDecoder(); + + return { + fetch: async (request: EgressRequest, signal?: AbortSignalLike): Promise => { + // Resolve the opaque credentialRef host-side and attach it as a bearer header INSIDE the trusted + // boundary — the raw secret never crosses back into the engine. No ref / no resolver ⇒ no header. + const credential = + request.credentialRef === undefined + ? undefined + : await config.resolveCredential?.(request.credentialRef); + const headers: Record = { ...request.headers }; + if (credential !== undefined && credential.length > 0) { + headers['authorization'] = `Bearer ${credential}`; + } + + try { + return await withEgressTimeout(signal, timeoutMs, async (sig) => { + const response = await connectValidated( + request.url, + { + allowPrivate: false, // BYOK local-endpoint opt-in is deferred — fail-closed on private targets + method: request.method, + headers, + ...(request.body === undefined ? {} : { body: request.body }), + }, + deps, + sig, + ); + // NO redirect following (allowedDomains bypass — see the file header): return ANY status, with the + // raw body read under the size cap and decoded as UTF-8 text. + const bytes = await readBounded(response.body, maxResponseBytes, response.dispose); + return { + status: response.status, + headers: response.headers ?? {}, + body: decoder.decode(bytes), + }; + }); + } catch (error) { + throw classifyEgressError(error); + } + }, + }; +} + +/** + * Map the shared {@link SafeEgressError} to the host error taxonomy: an SSRF range-block or non-HTTPS/ + * credentialed-url denial is a **deterministic** {@link EgressDeniedError} (fatal `tool_denied` — re-issuing + * re-denies, never burns the node-retry budget); a transient network/size failure is an + * {@link EgressCapabilityError} (retryable `tool_failed`). Messages stay reason-only (no url/IP/bytes). An + * abort is classified `cancelled` by the registry's cancel-precedence regardless of this class. + */ +function classifyEgressError(error: unknown): Error { + if (error instanceof SafeEgressError) { + if (error.code === 'insecure_url' || error.code === 'blocked_host') { + return new EgressDeniedError(`http_request: ${error.message}`); + } + return new EgressCapabilityError(`http_request: ${error.message}`); + } + return error instanceof Error ? error : new EgressCapabilityError('http_request: egress failed'); +} diff --git a/apps/cli/src/engine/tool-host/errors.ts b/apps/cli/src/engine/tool-host/errors.ts index c6165163..a1cde7d2 100644 --- a/apps/cli/src/engine/tool-host/errors.ts +++ b/apps/cli/src/engine/tool-host/errors.ts @@ -35,6 +35,15 @@ export class HostDeniedError extends ToolDispatchError { } } +/** The `egress` arm's transient failure — a network error or an over-size response (→ retryable `tool_failed`). */ +export class EgressCapabilityError extends HostCapabilityError {} + +/** The `egress` arm's deterministic denial — an SSRF range-block or a non-HTTPS / credentialed url (→ fatal `tool_denied`). */ +export class EgressDeniedError extends HostDeniedError {} + +/** The `os` arm's transient failure — the platform clipboard/notify command was unavailable or errored. */ +export class OsCapabilityError extends HostCapabilityError {} + /** * Cooperative cancellation — throw a reason-only {@link HostCapabilityError} (carrying the arm's own message) * before a potentially slow host operation if the run already aborted. The registry's cancel-precedence then diff --git a/apps/cli/src/engine/tool-host/fs.test.ts b/apps/cli/src/engine/tool-host/fs.test.ts index d8a8bf93..ed256ddd 100644 --- a/apps/cli/src/engine/tool-host/fs.test.ts +++ b/apps/cli/src/engine/tool-host/fs.test.ts @@ -351,6 +351,59 @@ describe('createNodeFsCapability — write (read-write profile)', () => { }); }); +describe('createNodeFsCapability — protected paths (denied in EVERY mode, auto included) — ADR-0057', () => { + it('refuses to write inside `.git/` — FATAL tool_denied, and never creates the dir', async () => { + await expect( + sandboxed().writeFile('.git/config', '[evil]', { createDirs: true }), + ).rejects.toBeInstanceOf(FsScopeDeniedError); + // The early (pre-mkdir) check means even `createDirs` cannot bring `.git/` into being. + await expect(readdir(join(workspace, '.git'))).rejects.toThrow(); + }); + + it('refuses to write a nested file under `.git/` (a hooks script)', async () => { + await mkdir(join(workspace, '.git'), { recursive: true }); + await expect( + sandboxed().writeFile('.git/hooks/pre-commit', '#!/bin/sh\nrm -rf /', { createDirs: true }), + ).rejects.toBeInstanceOf(FsScopeDeniedError); + }); + + it('refuses to write inside `.relavium/` — the local config/secrets dir', async () => { + await expect( + sandboxed().writeFile('.relavium/config.json', '{}', { createDirs: true }), + ).rejects.toBeInstanceOf(FsScopeDeniedError); + }); + + it('refuses to write a shell startup file by basename (`.zshrc`, `.bashrc`, `.profile`)', async () => { + for (const rc of ['.zshrc', '.bashrc', '.profile', 'config.fish']) { + await expect(sandboxed().writeFile(rc, 'evil', {})).rejects.toBeInstanceOf( + FsScopeDeniedError, + ); + } + }); + + it('matches `.git` case-insensitively (`.GIT/`) — the over-deny is the safe direction', async () => { + await expect( + sandboxed().writeFile('.GIT/config', 'x', { createDirs: true }), + ).rejects.toBeInstanceOf(FsScopeDeniedError); + }); + + it('refuses a symlink whose REALPATH resolves INTO `.git/` (the post-jail re-check)', async () => { + await mkdir(join(workspace, '.git'), { recursive: true }); + // `link` is a workspace-relative symlink pointing at the (in-workspace) `.git` dir; writing `link/config` + // passes the lexical pre-check (no `.git` segment) but the realpath'd finalTarget lands inside `.git/`. + await symlink(join(workspace, '.git'), join(workspace, 'link')); + await expect(sandboxed().writeFile('link/config', 'x', {})).rejects.toBeInstanceOf( + FsScopeDeniedError, + ); + }); + + it('ALLOWS a `.gitignore` FILE — only the `.git` DIRECTORY segment is protected', async () => { + const result = await sandboxed().writeFile('.gitignore', 'node_modules\n', {}); + expect(result.bytesWritten).toBeGreaterThan(0); + expect((await sandboxed().readFile('.gitignore', {})).content).toBe('node_modules\n'); + }); +}); + describe('createNodeFsCapability — read-only profile (2.5.A chat)', () => { it('fail-closes write_file as ToolUnavailableError (→ tool_unavailable), never touching disk', async () => { const fs = sandboxed({ readOnly: true }); diff --git a/apps/cli/src/engine/tool-host/fs.ts b/apps/cli/src/engine/tool-host/fs.ts index e5fc0df0..32b197ca 100644 --- a/apps/cli/src/engine/tool-host/fs.ts +++ b/apps/cli/src/engine/tool-host/fs.ts @@ -302,6 +302,49 @@ export async function readJailedFile(real: string, sizeLimit: number): Promise = new Set(['.git', '.relavium']); +const PROTECTED_RC_BASENAMES: ReadonlySet = new Set([ + '.bashrc', + '.bash_profile', + '.bash_login', + '.bash_logout', + '.profile', + '.zshrc', + '.zprofile', + '.zshenv', + '.zlogin', + '.zlogout', + '.cshrc', + '.tcshrc', + '.kshrc', + '.login', + 'config.fish', // fish — ~/.config/fish/config.fish + 'profile.ps1', + 'microsoft.powershell_profile.ps1', // PowerShell profiles +]); + +/** Deny a write to a protected path (a `.git`/`.relavium` directory, or a shell startup file). FATAL. */ +function assertNotProtectedPath(absoluteTarget: string): void { + for (const segment of absoluteTarget.split(sep)) { + if (segment.length > 0 && PROTECTED_DIR_SEGMENTS.has(segment.toLowerCase())) { + throw new FsScopeDeniedError( + 'write_file: refusing to write inside a protected directory (.git / .relavium)', + ); + } + } + if (PROTECTED_RC_BASENAMES.has(basename(absoluteTarget).toLowerCase())) { + throw new FsScopeDeniedError('write_file: refusing to write a shell startup file'); + } +} + async function writeOne( config: NodeFsCapabilityConfig, path: string, @@ -315,6 +358,10 @@ async function writeOne( throw new ToolUnavailableError('write_file', 'fs (read-only in this session)'); } throwIfAborted(signal); + // Protected paths are denied in EVERY mode (auto included). Checked on the REQUESTED path before the jail + // mkdir so a `createDirs` write cannot even create an empty `.git/`, then re-checked on the realpath'd + // target below so a symlink cannot resolve INTO a protected directory. + assertNotProtectedPath(resolve(config.workspaceDir, path)); const inScope = await buildScopeChecker(config); const { realDir, finalTarget } = await jailWriteTarget( config, @@ -323,6 +370,7 @@ async function writeOne( opts.createDirs === true, ); await assertNotSymlink(finalTarget); // never write THROUGH an existing symlink at the final component + assertNotProtectedPath(finalTarget); // re-check the REALPATH'd target — a symlink must not reach a protected path throwIfAborted(signal); const bytes = Buffer.from(data, 'utf8'); if (opts.append === true) { diff --git a/apps/cli/src/engine/tool-host/os.test.ts b/apps/cli/src/engine/tool-host/os.test.ts new file mode 100644 index 00000000..8a2feb09 --- /dev/null +++ b/apps/cli/src/engine/tool-host/os.test.ts @@ -0,0 +1,184 @@ +import { EventEmitter } from 'node:events'; + +import { describe, expect, it } from 'vitest'; + +import { EgressCapabilityError } from './errors.js'; // sibling check: the os errors live in the same module +import { + clipboardReadPlans, + createNodeOsCapability, + notifyPlan, + type OsSpawnFn, + type OsSpawnLike, +} from './os.js'; +import { OsCapabilityError } from './errors.js'; + +/** One scripted result per spawn call: a stdout payload + an exit code, or a synchronous spawn `throw`. */ +interface ScriptedCall { + readonly stdout?: string; + readonly code?: number | null; + readonly throwOnSpawn?: boolean; + readonly emitError?: boolean; +} + +interface RecordedCall { + readonly executable: string; + readonly args: readonly string[]; + readonly env: NodeJS.ProcessEnv; +} + +/** A deterministic fake `spawn`: replays `script` calls in order, recording each (executable, args, env). */ +function fakeSpawn(script: readonly ScriptedCall[]): { + spawnImpl: OsSpawnFn; + calls: RecordedCall[]; +} { + const calls: RecordedCall[] = []; + let index = 0; + const spawnImpl: OsSpawnFn = (executable, args, options) => { + const scripted = script[index] ?? {}; + index += 1; + calls.push({ executable, args, env: options.env }); + if (scripted.throwOnSpawn === true) throw new Error('ENOENT'); + const child = new EventEmitter() as EventEmitter & OsSpawnLike; + const stdout = new EventEmitter(); + Object.defineProperty(child, 'stdout', { value: stdout }); + Object.defineProperty(child, 'kill', { value: () => undefined }); + queueMicrotask(() => { + if (scripted.emitError === true) { + child.emit('error'); + return; + } + if (scripted.stdout !== undefined && scripted.stdout.length > 0) { + stdout.emit('data', Buffer.from(scripted.stdout, 'utf8')); + } + child.emit('close', scripted.code ?? 0); + }); + return child; + }; + return { spawnImpl, calls }; +} + +describe('notifyPlan — the per-platform spawn plan (injection-free arg/env construction)', () => { + const input = { title: 'Done', body: 'evil"; rm -rf / #$(whoami)' } as const; + + it('macOS: osascript reads title/body from env via `system attribute` — NEVER in argv', () => { + const plan = notifyPlan('darwin', input); + expect(plan.executable).toBe('osascript'); + expect(plan.env?.['RELAVIUM_NOTIFY_TITLE']).toBe('Done'); + expect(plan.env?.['RELAVIUM_NOTIFY_BODY']).toBe(input.body); + // The dangerous body never appears in the script argv — it is read as `system attribute` DATA. + expect(plan.args.join('\n')).not.toContain(input.body); + expect(plan.args.join('\n')).toContain('system attribute'); + }); + + it('Windows: powershell reads title/body from $env: — the body is NEVER interpolated into the script', () => { + const plan = notifyPlan('win32', input); + expect(plan.executable).toBe('powershell'); + expect(plan.env?.['RELAVIUM_NOTIFY_BODY']).toBe(input.body); + expect(plan.args.join('\n')).not.toContain(input.body); + expect(plan.args.join('\n')).toContain('$env:RELAVIUM_NOTIFY_BODY'); + }); + + it('Linux: notify-send takes title + body as positional ARGS (shell:false data, no env overlay)', () => { + const plan = notifyPlan('linux', input); + expect(plan.executable).toBe('notify-send'); + expect(plan.args).toEqual(['Done', input.body]); + expect(plan.env).toBeUndefined(); + }); +}); + +describe('clipboardReadPlans — per-platform candidate order', () => { + it('macOS is pbpaste; Windows is Get-Clipboard; Linux tries Wayland then X11 (xclip, xsel)', () => { + expect(clipboardReadPlans('darwin').map((p) => p.executable)).toEqual(['pbpaste']); + expect(clipboardReadPlans('win32').map((p) => p.executable)).toEqual(['powershell']); + expect(clipboardReadPlans('linux').map((p) => p.executable)).toEqual([ + 'wl-paste', + 'xclip', + 'xsel', + ]); + }); +}); + +describe('createNodeOsCapability — readClipboard', () => { + it('returns the clipboard text from the first successful candidate', async () => { + const { spawnImpl, calls } = fakeSpawn([{ stdout: 'hello clip', code: 0 }]); + const os = createNodeOsCapability({ platform: 'darwin', spawnImpl }); + expect(await os.readClipboard()).toBe('hello clip'); + expect(calls[0]?.executable).toBe('pbpaste'); + }); + + it('falls through a missing Linux candidate to the next that works (wl-paste absent → xclip)', async () => { + const { spawnImpl, calls } = fakeSpawn([ + { throwOnSpawn: true }, // wl-paste not installed + { stdout: 'from xclip', code: 0 }, // xclip succeeds + ]); + const os = createNodeOsCapability({ platform: 'linux', spawnImpl }); + expect(await os.readClipboard()).toBe('from xclip'); + expect(calls.map((c) => c.executable)).toEqual(['wl-paste', 'xclip']); + }); + + it('throws OsCapabilityError (retryable tool_failed) when no clipboard tool is available', async () => { + const { spawnImpl } = fakeSpawn([ + { throwOnSpawn: true }, + { throwOnSpawn: true }, + { throwOnSpawn: true }, + ]); + const os = createNodeOsCapability({ platform: 'linux', spawnImpl }); + const err = await os.readClipboard().catch((e: unknown) => e); + expect(err).toBeInstanceOf(OsCapabilityError); + expect((err as OsCapabilityError).message).toBe('no clipboard tool is available'); + }); + + it('treats a non-zero exit as a read failure (falls through, then surfaces a read error)', async () => { + const { spawnImpl } = fakeSpawn([{ code: 1 }]); + const os = createNodeOsCapability({ platform: 'darwin', spawnImpl }); + await expect(os.readClipboard()).rejects.toBeInstanceOf(OsCapabilityError); + }); + + it('does not run any candidate when the signal is already aborted', async () => { + const { spawnImpl, calls } = fakeSpawn([{ stdout: 'x', code: 0 }]); + const os = createNodeOsCapability({ platform: 'darwin', spawnImpl }); + const ac = new AbortController(); + ac.abort(); + await expect(os.readClipboard(ac.signal)).rejects.toBeInstanceOf(Error); + expect(calls).toHaveLength(0); + }); +}); + +describe('createNodeOsCapability — notify', () => { + it('spawns the platform notifier and resolves on exit 0, passing title/body via env (macOS)', async () => { + const { spawnImpl, calls } = fakeSpawn([{ code: 0 }]); + const os = createNodeOsCapability({ platform: 'darwin', spawnImpl }); + await os.notify({ title: 'Title', body: 'Body' }); + expect(calls[0]?.executable).toBe('osascript'); + expect(calls[0]?.env['RELAVIUM_NOTIFY_TITLE']).toBe('Title'); + expect(calls[0]?.env['RELAVIUM_NOTIFY_BODY']).toBe('Body'); + }); + + it('rejects with OsCapabilityError when the notifier exits non-zero', async () => { + const { spawnImpl } = fakeSpawn([{ code: 2 }]); + const os = createNodeOsCapability({ platform: 'linux', spawnImpl }); + await expect(os.notify({ title: 't', body: 'b' })).rejects.toBeInstanceOf(OsCapabilityError); + }); + + it('rejects with OsCapabilityError when the notifier fails to spawn (error event)', async () => { + const { spawnImpl } = fakeSpawn([{ emitError: true }]); + const os = createNodeOsCapability({ platform: 'linux', spawnImpl }); + await expect(os.notify({ title: 't', body: 'b' })).rejects.toBeInstanceOf(OsCapabilityError); + }); + + it('merges the notify env OVERLAY over the ambient env (so DISPLAY/DBUS survive)', async () => { + const { spawnImpl, calls } = fakeSpawn([{ code: 0 }]); + const os = createNodeOsCapability({ platform: 'darwin', spawnImpl }); + await os.notify({ title: 't', body: 'b' }); + // The ambient PATH is still present alongside the injected notify vars. + expect(calls[0]?.env['PATH']).toBe(process.env['PATH']); + expect(calls[0]?.env['RELAVIUM_NOTIFY_TITLE']).toBe('t'); + }); +}); + +describe('os errors share the host taxonomy', () => { + it('OsCapabilityError and EgressCapabilityError are distinct transient host errors', () => { + expect(new OsCapabilityError('x')).toBeInstanceOf(OsCapabilityError); + expect(new OsCapabilityError('x')).not.toBeInstanceOf(EgressCapabilityError); + }); +}); diff --git a/apps/cli/src/engine/tool-host/os.ts b/apps/cli/src/engine/tool-host/os.ts new file mode 100644 index 00000000..07f0be09 --- /dev/null +++ b/apps/cli/src/engine/tool-host/os.ts @@ -0,0 +1,269 @@ +import { spawn } from 'node:child_process'; + +import type { NotifyInput, OsCapability } from '@relavium/core'; +import type { AbortSignalLike } from '@relavium/shared'; + +import { OsCapabilityError, throwIfAborted as throwIfAbortedShared } from './errors.js'; + +/** + * The Node host **mechanism** half of the `ToolHost.os` capability arm (2.5.E Step 3, [ADR-0057](../../../../../docs/decisions/0057-cli-chat-modes-and-per-tool-approval.md)) — + * it backs the engine's `read_clipboard` + `notify` tools. Each is a thin spawn of a FIXED, host-chosen + * platform binary (`pbpaste` / `osascript` / `notify-send` / `powershell` / `wl-paste` / `xclip` / `xsel`) — + * **never a model-chosen command**, unlike the `process` arm. The model controls only the notify title/body, + * which are passed **exclusively as process ARGV or env** (`shell: false`) — never interpolated into a shell, + * AppleScript, or PowerShell **script string** — so there is no command/script-injection surface. + * + * Because the binary is fixed and the model's input never reaches anything that *evaluates* it (an AppleScript + * `system attribute` / a PowerShell `$env:` read / a positional `notify-send` arg are all DATA), the spawn + * inherits the ambient env — desktop integration genuinely needs it (`DISPLAY`, `WAYLAND_DISPLAY`, + * `DBUS_SESSION_BUS_ADDRESS`, `XAUTHORITY`). This is the deliberate distinction from the `process` arm, which + * runs *model/workflow-allowlisted arbitrary* commands and therefore MUST minimize the env. + * + * Clipboard text is **untrusted** model-facing data; a failure names a REASON only (the I3 boundary) and maps + * to the retryable `tool_failed` via {@link OsCapabilityError} — a desktop service can be transiently + * unavailable (no `DISPLAY`/DBUS yet), and a missing helper on one Linux box may have a sibling that works. + */ + +const DEFAULT_TIMEOUT_MS = 10_000; +/** A 1 MiB clipboard-read cap (the model-facing result is further bounded by the registry). */ +const DEFAULT_MAX_BUFFER_BYTES = 1024 * 1024; + +/** Env var names the platform notify scripts read title/body from (DATA, never interpolated into the script). */ +const NOTIFY_TITLE_ENV = 'RELAVIUM_NOTIFY_TITLE'; +const NOTIFY_BODY_ENV = 'RELAVIUM_NOTIFY_BODY'; + +/** + * The Windows notification: a fixed script LITERAL (no model input interpolated) that reads the title/body from + * `$env:` as string data and shows a balloon tip. The brief `Start-Sleep` keeps the host process alive long + * enough for the async balloon to fire before `Dispose`. + */ +const WINDOWS_NOTIFY_SCRIPT = [ + "$ErrorActionPreference='Stop';", + 'Add-Type -AssemblyName System.Windows.Forms;', + 'Add-Type -AssemblyName System.Drawing;', + '$n = New-Object System.Windows.Forms.NotifyIcon;', + '$n.Icon = [System.Drawing.SystemIcons]::Information;', + `$n.BalloonTipTitle = $env:${NOTIFY_TITLE_ENV};`, + `$n.BalloonTipText = $env:${NOTIFY_BODY_ENV};`, + '$n.Visible = $true;', + '$n.ShowBalloonTip(5000);', + 'Start-Sleep -Milliseconds 250;', + '$n.Dispose();', +].join(' '); + +export interface NodeOsCapabilityConfig { + /** Per-call timeout in ms (default {@link DEFAULT_TIMEOUT_MS}). */ + readonly timeoutMs?: number; + /** Clipboard-read capture ceiling in bytes (default {@link DEFAULT_MAX_BUFFER_BYTES}). */ + readonly maxBufferBytes?: number; + /** The platform key (defaults to the host `process.platform`) — injectable so the dispatch is unit-testable. */ + readonly platform?: NodeJS.Platform; + /** Injectable spawn (defaults to node `child_process.spawn`) — faked in tests so no real GUI binary runs. */ + readonly spawnImpl?: OsSpawnFn; +} + +/** The minimal child surface the os arm uses — a real node `ChildProcess` structurally satisfies it. */ +export interface OsSpawnLike { + readonly stdout: { on(event: 'data', listener: (chunk: Buffer) => void): void } | null; + on(event: 'error', listener: () => void): void; + on(event: 'close', listener: (code: number | null) => void): void; + kill(signal: 'SIGKILL'): void; +} +export type OsSpawnFn = ( + executable: string, + args: readonly string[], + options: { readonly shell: false; readonly windowsHide: true; readonly env: NodeJS.ProcessEnv }, +) => OsSpawnLike; + +const nodeSpawn: OsSpawnFn = (executable, args, options) => spawn(executable, [...args], options); + +interface SpawnPlan { + readonly executable: string; + readonly args: readonly string[]; + /** An env OVERLAY merged over the ambient env (used to pass notify title/body as DATA). */ + readonly env?: Record; +} + +/** + * Build a node-backed {@link OsCapability}. The returned object holds no ambient state beyond the immutable + * config; the model never influences WHICH binary runs. + */ +export function createNodeOsCapability(config: NodeOsCapabilityConfig = {}): OsCapability { + const timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const maxBufferBytes = config.maxBufferBytes ?? DEFAULT_MAX_BUFFER_BYTES; + const platform = config.platform ?? process.platform; + const spawnImpl = config.spawnImpl ?? nodeSpawn; + + return { + readClipboard: async (signal) => { + throwIfAborted(signal); + // Try each platform candidate in order (Linux: Wayland → X11); the first that runs and exits 0 wins. A + // missing binary or a non-zero exit falls through to the next; an abort propagates immediately. The + // default error stands when EVERY candidate fails to spawn (none installed); a candidate that *ran* but + // exited non-zero upgrades the message to the more specific read failure. + let lastError = new OsCapabilityError('no clipboard tool is available'); + for (const plan of clipboardReadPlans(platform)) { + let result: CaptureResult; + try { + result = await spawnCapturing(spawnImpl, plan, { signal, timeoutMs, maxBufferBytes }); + } catch { + throwIfAborted(signal); // an abort wins; a missing candidate falls through to the next + continue; + } + if (result.exitCode === 0) return result.stdout; + lastError = new OsCapabilityError('the clipboard could not be read'); + } + throw lastError; + }, + + notify: async (input, signal) => { + throwIfAborted(signal); + const result = await spawnCapturing(spawnImpl, notifyPlan(platform, input), { + signal, + timeoutMs, + maxBufferBytes, + }); + if (result.exitCode !== 0) { + throw new OsCapabilityError('the notification could not be delivered'); + } + }, + }; +} + +/** The ordered clipboard-read candidates for a platform (Linux tries Wayland, then the two X11 helpers). */ +export function clipboardReadPlans(platform: NodeJS.Platform): readonly SpawnPlan[] { + switch (platform) { + case 'darwin': + return [{ executable: 'pbpaste', args: [] }]; + case 'win32': + return [ + { + executable: 'powershell', + args: ['-NoProfile', '-NonInteractive', '-Command', 'Get-Clipboard'], + }, + ]; + default: + return [ + { executable: 'wl-paste', args: ['--no-newline'] }, + { executable: 'xclip', args: ['-selection', 'clipboard', '-o'] }, + { executable: 'xsel', args: ['--clipboard', '--output'] }, + ]; + } +} + +/** + * The notify spawn plan for a platform. On macOS + Windows the title/body ride in via ENV (read by the script + * as `system attribute` / `$env:` DATA); on Linux they are positional `notify-send` ARGS (`shell: false`) — in + * EVERY case data, never interpolated into a script/shell string, so a `"` / `;` / `$()` in the body is inert. + */ +export function notifyPlan(platform: NodeJS.Platform, input: NotifyInput): SpawnPlan { + switch (platform) { + case 'darwin': + return { + executable: 'osascript', + args: [ + '-e', + `display notification (system attribute "${NOTIFY_BODY_ENV}") with title (system attribute "${NOTIFY_TITLE_ENV}")`, + ], + env: { [NOTIFY_TITLE_ENV]: input.title, [NOTIFY_BODY_ENV]: input.body }, + }; + case 'win32': + return { + executable: 'powershell', + args: ['-NoProfile', '-NonInteractive', '-Command', WINDOWS_NOTIFY_SCRIPT], + env: { [NOTIFY_TITLE_ENV]: input.title, [NOTIFY_BODY_ENV]: input.body }, + }; + default: + return { executable: 'notify-send', args: [input.title, input.body] }; + } +} + +interface CaptureResult { + readonly exitCode: number; + readonly stdout: string; +} + +/** Spawn a fixed host binary (`shell: false`), capturing bounded stdout, with a timeout + abort kill. */ +function spawnCapturing( + spawnImpl: OsSpawnFn, + plan: SpawnPlan, + opts: { signal: AbortSignalLike | undefined; timeoutMs: number; maxBufferBytes: number }, +): Promise { + return new Promise((resolvePromise, reject) => { + let child: OsSpawnLike; + try { + child = spawnImpl(plan.executable, plan.args, { + shell: false, // SECURITY: no shell — the binary is fixed and model input rides as argv/env data, not code + windowsHide: true, + env: plan.env === undefined ? process.env : { ...process.env, ...plan.env }, + }); + } catch { + reject(new OsCapabilityError('the os command could not be started')); + return; + } + + const stdout = new BoundedBuffer(opts.maxBufferBytes); + let settled = false; + let aborted = false; + let timedOut = false; + + const timer = setTimeout(() => { + timedOut = true; + child.kill('SIGKILL'); + }, opts.timeoutMs); + const onAbort = (): void => { + aborted = true; + child.kill('SIGKILL'); + }; + if (opts.signal !== undefined) opts.signal.addEventListener('abort', onAbort); + + const finish = (fn: () => void): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (opts.signal !== undefined) opts.signal.removeEventListener('abort', onAbort); + fn(); + }; + + child.stdout?.on('data', (chunk: Buffer) => stdout.push(chunk)); + child.on('error', () => + finish(() => reject(new OsCapabilityError('the os command failed to run'))), + ); + child.on('close', (code) => + finish(() => { + if (aborted) { + reject(new OsCapabilityError('the os command was aborted')); + } else if (timedOut && code === null) { + reject(new OsCapabilityError('the os command timed out')); + } else { + resolvePromise({ exitCode: code ?? 1, stdout: stdout.text() }); + } + }), + ); + }); +} + +/** A byte-bounded UTF-8 accumulator: appends until the cap, then drops the rest (clipboard memory guard). */ +class BoundedBuffer { + readonly #chunks: Buffer[] = []; + readonly #max: number; + #bytes = 0; + constructor(max: number) { + this.#max = max; + } + push(chunk: Buffer): void { + if (this.#bytes >= this.#max) return; + const room = this.#max - this.#bytes; + const slice = chunk.length <= room ? chunk : chunk.subarray(0, room); + this.#chunks.push(slice); + this.#bytes += slice.length; + } + text(): string { + return Buffer.concat(this.#chunks).toString('utf8'); + } +} + +/** Cooperative cancellation — reject before spawning if the run already aborted (shared reason-only helper). */ +function throwIfAborted(signal: AbortSignalLike | undefined): void { + throwIfAbortedShared(signal, 'the os command was aborted'); +} From e5a83b6ce33932128338c67b46fce3722d32eaf1 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Tue, 30 Jun 2026 08:17:12 +0300 Subject: [PATCH 11/45] =?UTF-8?q?feat(cli):=202.5.E=20Step=203d=20?= =?UTF-8?q?=E2=80=94=20full-capability=20chat-read-write=20factory=20profi?= =?UTF-8?q?le?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../cli/src/engine/tool-host/assemble.test.ts | 79 +++++++++++++++++-- apps/cli/src/engine/tool-host/assemble.ts | 75 +++++++++++++----- 2 files changed, 129 insertions(+), 25 deletions(-) diff --git a/apps/cli/src/engine/tool-host/assemble.test.ts b/apps/cli/src/engine/tool-host/assemble.test.ts index 71448f91..65bdcdd7 100644 --- a/apps/cli/src/engine/tool-host/assemble.test.ts +++ b/apps/cli/src/engine/tool-host/assemble.test.ts @@ -8,8 +8,10 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { assembleToolEnv, wiredToolIds } from './assemble.js'; /** - * The factory is the single seam both surfaces use (ADR-0055). These tests pin the two profiles' wired arms + - * the read-only fail-close, and the advertise-filter's tool→arm mapping (the subtle MCP-vs-egress case). + * The factory is the single seam both surfaces use (ADR-0055). These tests pin the three profiles' wired arms + * (read-only chat, the full-capability `chat-read-write` host that closes the 2.5.E egress/os deferral, and the + * author-trusted run host), the read-only fail-close, the chat-tier clamp, and the advertise-filter's tool→arm + * mapping (the subtle MCP-vs-egress case). */ let workspace: string; @@ -49,14 +51,69 @@ describe('assembleToolEnv', () => { expect((await fs.readFile('out.txt', {})).content).toBe('data'); }); - it('does NOT wire egress or os in 2.5.A (deferred to 2.5.E)', () => { + it('does NOT wire egress or os for the read-only chat or the workflow-run profile', () => { + for (const profile of ['chat-read-only', 'workflow-read-write'] as const) { + const { host } = assembleToolEnv({ + profile, + fsScopeTier: 'sandboxed', + workspaceDir: workspace, + }); + expect(host.egress).toBeUndefined(); + expect(host.os).toBeUndefined(); + } + }); + + it('chat-read-write: the full-capability host wires fs-write + process + egress + os (ADR-0057)', async () => { + const { host, policy } = assembleToolEnv({ + profile: 'chat-read-write', + fsScopeTier: 'sandboxed', + workspaceDir: workspace, + }); + expect(host.fs).toBeDefined(); + expect(host.process).toBeDefined(); + expect(host.egress).toBeDefined(); // the egress arm — gated by the ADR-0057 approval floor at the session + expect(host.os).toBeDefined(); // the os arm (clipboard/notify) — non-governed, gated by the advertise-filter + // fs is write-capable (readOnly:false) — its writes are gated by the approval floor + protected-paths, not + // by capability absence; a plain in-workspace write succeeds at the host layer. + const written = await host.fs!.writeFile('w.txt', 'data', {}); + expect(written.bytesWritten).toBe(4); + expect(policy).toEqual({}); + }); + + it('chat-read-write: clamps the `full` tier to `project` (a write-capable chat still cannot read whole-FS)', async () => { + const outside = join(workspace, '..', 'outside-rw-clamp'); + await rm(outside, { recursive: true, force: true }).catch(() => undefined); + await mkdir(outside, { recursive: true }); + await writeFile(join(outside, 'secret.txt'), 'SECRET'); + try { + const { host } = assembleToolEnv({ + profile: 'chat-read-write', + fsScopeTier: 'full', + workspaceDir: workspace, + }); + await expect(host.fs!.readFile(join(outside, 'secret.txt'), {})).rejects.toThrow(); + } finally { + await rm(outside, { recursive: true, force: true }).catch(() => undefined); + } + }); + + it('chat-read-write: threads the egress credentialRef resolver into the egress arm (host-side bearer)', async () => { + let resolvedRef: string | undefined; const { host } = assembleToolEnv({ - profile: 'workflow-read-write', + profile: 'chat-read-write', fsScopeTier: 'sandboxed', workspaceDir: workspace, + egressCredentialResolver: (ref) => { + resolvedRef = ref; + return Promise.resolve(undefined); + }, }); - expect(host.egress).toBeUndefined(); - expect(host.os).toBeUndefined(); + // A fetch to a non-resolving public host fails (no network in the unit env) AFTER consulting the resolver; + // we only assert the resolver was threaded, not the (environment-dependent) network outcome. + await host + .egress!.fetch({ method: 'GET', url: 'https://example.test/x', credentialRef: 'kc:search' }) + .catch(() => undefined); + expect(resolvedRef).toBe('kc:search'); }); it('clamps the `full` tier to `project` for chat (read-only does not stop whole-FS exfiltration)', async () => { @@ -118,6 +175,16 @@ describe('wiredToolIds (advertise-filter)', () => { expect(kept).toEqual(['read_file']); // egress not wired ⇒ http_request / web_search not advertised }); + it('keeps egress (http/search) tools on the full-capability chat-read-write host', () => { + const fullHost: ToolHost = assembleToolEnv({ + profile: 'chat-read-write', + fsScopeTier: 'sandboxed', + workspaceDir: '/tmp/relavium-advertise-full', + }).host; + const kept = wiredToolIds(['read_file', 'http_request', 'web_search'], fullHost, defs); + expect(kept).toEqual(['read_file', 'http_request', 'web_search']); // egress wired ⇒ all advertised + }); + it('keeps write_file because its fs arm is present (the read-only writeFile fail-closes at dispatch instead)', () => { // The advertise-filter is capability-presence based; the read-only fs writeFile is the authoritative gate. expect(wiredToolIds(['write_file'], fsHost, defs)).toEqual(['write_file']); diff --git a/apps/cli/src/engine/tool-host/assemble.ts b/apps/cli/src/engine/tool-host/assemble.ts index 9c648a83..3b06ec78 100644 --- a/apps/cli/src/engine/tool-host/assemble.ts +++ b/apps/cli/src/engine/tool-host/assemble.ts @@ -1,7 +1,9 @@ import type { FsScopeTier, ToolDef, ToolHost } from '@relavium/core'; import type { ToolPolicy } from '@relavium/shared'; +import { createNodeEgressCapability } from './egress.js'; import { createNodeFsCapability } from './fs.js'; +import { createNodeOsCapability } from './os.js'; import { createNodeProcessCapability } from './process.js'; // Re-export the host-arm error vocabulary from the factory so a consumer narrowing on a denial/transient failure @@ -21,15 +23,19 @@ export { ProcessCapabilityError, ProcessDeniedError } from './process.js'; * {@link ToolHost} capability arms (here), the {@link ToolPolicy} allowlists (here for chat; the run path uses * the workflow's resolved policy per node), and the per-dispatch `fsScope` (carried on the dispatch context). * - * **Phased wiring (2.5.A):** the chat profile is **read-only** (`fs` read+list, `process` serving the - * pre-approved `git_status`); the run profile is **read+write** (the workflow-author trust model governs it). - * The `egress` and `os` arms are **not** wired here — `egress` lands with [ADR-0057](../../../../../docs/decisions/0057-cli-chat-modes-and-per-tool-approval.md)/2.5.E - * behind the per-tool approval floor. The caller merges any inbound-MCP arm onto `host` with a conditional - * spread (a true merge, never a replace). + * **Phased wiring:** three profiles. `chat-read-only` (2.5.A) is `fs` read+list + `process` serving the + * pre-approved `git_status`. `workflow-read-write` is the author-trusted read+write run host (`fs`-write + + * `process`, full tier). `chat-read-write` ([ADR-0057](../../../../../docs/decisions/0057-cli-chat-modes-and-per-tool-approval.md), + * 2.5.E) is the **full-capability** chat host — `fs`-write + `process` + the `egress` and `os` arms (the + * 2.5.A deferral, closed here) — that the per-tool **approval floor** makes safe: the host is full-capability + * for the session and the *mode* (advertise-filter + the fail-closed `confirmAction` regime) gates it, never + * capability absence (ADR-0057 §Decision). The chat tiers (read-only AND read-write) clamp `full` → `project` + * (an unjailed READ exfiltrates `~/.ssh`); `full` stays only for the author-trusted workflow profile. The + * caller merges any inbound-MCP arm onto `host` with a conditional spread (a true merge, never a replace). */ -/** The two 2.5.A profiles: a read-only chat host vs the read+write workflow-run host. */ -export type ToolEnvProfile = 'chat-read-only' | 'workflow-read-write'; +/** The three host profiles: read-only chat, the full-capability (approval-gated) chat host, and the run host. */ +export type ToolEnvProfile = 'chat-read-only' | 'chat-read-write' | 'workflow-read-write'; export interface AssembleToolEnvOptions { readonly profile: ToolEnvProfile; @@ -39,22 +45,33 @@ export interface AssembleToolEnvOptions { readonly workspaceDir: string; /** An optional extra sandboxed fs root (e.g. `~/.relavium/tmp/`); absent ⇒ workspace-only. */ readonly tmpDir?: string; + /** + * Resolve a `web_search` / `http_request` `credentialRef` to its secret VALUE host-side (the keychain) for + * the `egress` arm — never logged, never returned to the engine ([ADR-0006](../../../../../docs/decisions/0006-os-keychain-for-api-keys.md)). + * Consulted ONLY by the `chat-read-write` profile (the one that wires `egress`); absent ⇒ an egress request + * proceeds with no credential (a provider that needs one returns 401, surfaced to the model — never a crash). + */ + readonly egressCredentialResolver?: (ref: string) => Promise; } export interface AssembledToolEnv { - /** The capability arms (`fs` + `process`; `egress`/`os` deferred). The caller merges the MCP arm on top. */ + /** + * The capability arms: `fs` + `process` always; the `chat-read-write` profile additionally wires `egress` + + * `os`. The caller merges the MCP arm on top. + */ readonly host: ToolHost; /** The chat-default policy (empty allowlists). The run path overrides per node with the workflow policy. */ readonly policy: ToolPolicy; } /** - * The EFFECTIVE fs-scope tier for a chat (read-only) session: `full` clamps to `project` (workspace-only). - * SECURITY — read-only does NOT neutralize `full` for the lowest-trust surface: an unjailed READ can - * exfiltrate `~/.ssh` / `~/.aws/credentials` back to the model/provider. `full` stays for the author-trusted - * `workflow-read-write` profile. (Tracked: a 2.5.E approval-gated `full` chat.) Exported so the caller can - * stamp the SAME effective tier on the `SessionContext.fsScope` it persists — keeping the dispatch-context - * tier and the host jail consistent (ADR-0055's "three channels"). + * The EFFECTIVE fs-scope tier for a chat session (read-only OR the approval-gated read-write): `full` clamps to + * `project` (workspace-only). SECURITY — neither read-only NOR the approval floor neutralizes `full` for the + * lowest-trust surface: an unjailed READ can exfiltrate `~/.ssh` / `~/.aws/credentials` back to the + * model/provider regardless of write-gating, so BOTH chat tiers clamp. `full` stays for the author-trusted + * `workflow-read-write` profile. Exported so the caller can stamp the SAME effective tier on the + * `SessionContext.fsScope` it persists — keeping the dispatch-context tier and the host jail consistent + * (ADR-0055's "three channels"). */ export function clampChatTier(tier: FsScopeTier): FsScopeTier { return tier === 'full' ? 'project' : tier; @@ -63,11 +80,17 @@ export function clampChatTier(tier: FsScopeTier): FsScopeTier { /** Assemble the `ToolHost` + chat-default `ToolPolicy` for a profile. Pure construction — no I/O here. */ export function assembleToolEnv(opts: AssembleToolEnvOptions): AssembledToolEnv { const readOnly = opts.profile === 'chat-read-only'; - const tier: FsScopeTier = readOnly ? clampChatTier(opts.fsScopeTier) : opts.fsScopeTier; + const isChat = opts.profile === 'chat-read-only' || opts.profile === 'chat-read-write'; + // Both chat tiers clamp `full`→`project` (see clampChatTier); only the author-trusted workflow keeps `full`. + const tier: FsScopeTier = isChat ? clampChatTier(opts.fsScopeTier) : opts.fsScopeTier; + // The full-capability chat host (ADR-0057): `egress` + `os` ride on top of fs-write + process, made safe by + // the per-tool approval floor (the regime + advertise-filter the session-host activates), NOT by absence. + const wireEgressOs = opts.profile === 'chat-read-write'; const host: ToolHost = { - // NOTE: the factory does not yet pass `extraRoots`, so the `project` tier behaves as workspace-only in - // 2.5.A (it can only NARROW the jail — never a hole). The `project` path-allowlist is wired with the - // approval-gated surface in 2.5.E; until then `project` == `sandboxed`-minus-tmp. + // NOTE: the factory does not yet pass `extraRoots`, so the `project` tier behaves as workspace-only (it can + // only NARROW the jail — never a hole); until the `project` path-allowlist lands, `project` == + // `sandboxed`-minus-tmp. The `chat-read-write` profile sets `readOnly:false` — its writes are gated by the + // ADR-0057 approval floor (and the always-on protected-paths refusal in fs.ts), never by capability absence. fs: createNodeFsCapability({ tier, workspaceDir: opts.workspaceDir, @@ -78,7 +101,21 @@ export function assembleToolEnv(opts: AssembleToolEnvOptions): AssembledToolEnv // the empty `allowedCommands` default denies `run_command`, and `git_status` exposes no model-controlled // command. Loosening the chat `allowedCommands` in a future profile is therefore a security-review trigger. process: createNodeProcessCapability({ workspaceDir: opts.workspaceDir }), - // egress / os are intentionally absent in 2.5.A (deferred to ADR-0057/2.5.E behind the approval floor). + // egress + os: wired ONLY for the full-capability `chat-read-write` profile (ADR-0057 closes the 2.5.A + // deferral). `egress` rides the fail-closed approval floor (a governed class); `os` (clipboard/notify) is + // non-governed and gated only by the mode's advertise-filter. The read-only chat + workflow profiles wire + // neither: a read-only chat must not reach the network, and the workflow run path's egress/os is a separate + // (author-trusted) concern, not part of the chat approval story. + ...(wireEgressOs + ? { + egress: createNodeEgressCapability( + opts.egressCredentialResolver === undefined + ? {} + : { resolveCredential: opts.egressCredentialResolver }, + ), + os: createNodeOsCapability(), + } + : {}), }; // Chat default: empty allowedCommands ⇒ `run_command` denied; `git_status` is pre-approved and exposes no // model-controlled command, so an empty allowlist never blocks it (ADR-0055). NOTE: this is inert today — From d84b502b3ac423176da87fbbe597a86ce1ea307d Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Tue, 30 Jun 2026 08:55:39 +0300 Subject: [PATCH 12/45] =?UTF-8?q?fix(cli):=202.5.E=20Step=203=20=E2=80=94?= =?UTF-8?q?=20address=20opus=20review=20(16=20findings)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../cli/src/engine/tool-host/assemble.test.ts | 10 + apps/cli/src/engine/tool-host/assemble.ts | 8 +- apps/cli/src/engine/tool-host/egress.test.ts | 45 +++ apps/cli/src/engine/tool-host/egress.ts | 13 +- apps/cli/src/engine/tool-host/fs.test.ts | 29 +- apps/cli/src/engine/tool-host/fs.ts | 31 +- apps/cli/src/engine/tool-host/os.test.ts | 65 ++++ apps/cli/src/engine/tool-host/os.ts | 20 +- packages/db/src/index.ts | 7 +- packages/db/src/safe-egress.test.ts | 301 ++++++++++++++++++ 10 files changed, 507 insertions(+), 22 deletions(-) create mode 100644 packages/db/src/safe-egress.test.ts diff --git a/apps/cli/src/engine/tool-host/assemble.test.ts b/apps/cli/src/engine/tool-host/assemble.test.ts index 65bdcdd7..672772d8 100644 --- a/apps/cli/src/engine/tool-host/assemble.test.ts +++ b/apps/cli/src/engine/tool-host/assemble.test.ts @@ -201,4 +201,14 @@ describe('wiredToolIds (advertise-filter)', () => { it('keeps an unknown granted id (a dynamically-registered tool resolved elsewhere)', () => { expect(wiredToolIds(['not_a_builtin'], fsHost, defs)).toEqual(['not_a_builtin']); }); + + it('keeps os tools (read_clipboard/notify) even when host.os is absent — the EA1 backstop is their gate', () => { + // OS_POLICY carries no arm class (no fsScoped/spawnsProcess/egress), so requiredArmPresent falls through to + // keep them: the advertise-filter is best-effort for os tools and the dispatch tool_unavailable backstop + // (EA1) is their authoritative gate. `fsHost` has no `os` arm, yet both are still advertised. + expect(wiredToolIds(['read_clipboard', 'notify'], fsHost, defs)).toEqual([ + 'read_clipboard', + 'notify', + ]); + }); }); diff --git a/apps/cli/src/engine/tool-host/assemble.ts b/apps/cli/src/engine/tool-host/assemble.ts index 3b06ec78..c81c006e 100644 --- a/apps/cli/src/engine/tool-host/assemble.ts +++ b/apps/cli/src/engine/tool-host/assemble.ts @@ -10,7 +10,13 @@ import { createNodeProcessCapability } from './process.js'; // has ONE import site — the seam's public entry point — rather than reaching past it into each arm module. The // shared bases let a caller catch the whole class (`HostDeniedError` = every fatal `tool_denied`) or a specific // arm (`FsScopeDeniedError`). `export…from` keeps these pure pass-throughs (no local binding). -export { HostCapabilityError, HostDeniedError } from './errors.js'; +export { + EgressCapabilityError, + EgressDeniedError, + HostCapabilityError, + HostDeniedError, + OsCapabilityError, +} from './errors.js'; export { FsCapabilityError, FsScopeDeniedError } from './fs.js'; export { ProcessCapabilityError, ProcessDeniedError } from './process.js'; diff --git a/apps/cli/src/engine/tool-host/egress.test.ts b/apps/cli/src/engine/tool-host/egress.test.ts index 8ca8f5df..b53ff379 100644 --- a/apps/cli/src/engine/tool-host/egress.test.ts +++ b/apps/cli/src/engine/tool-host/egress.test.ts @@ -149,4 +149,49 @@ describe('createNodeEgressCapability (2.5.E Step 3) — text egress over the sha egress.fetch({ method: 'GET', url: 'https://api.example.com/x' }), ).rejects.toBeInstanceOf(EgressCapabilityError); }); + + it('DENIES a credentialed url (user:pass@host) as a fatal EgressDeniedError, before any connection', async () => { + const { deps, calls } = fakeDeps({ resolve: PUBLIC, response: { status: 200 } }); + const egress = createNodeEgressCapability({ deps }); + await expect( + egress.fetch({ method: 'GET', url: 'https://user:pass@api.example.com/x' }), + ).rejects.toBeInstanceOf(EgressDeniedError); + expect(calls).toHaveLength(0); // rejected at the url policy gate, never opened a connection + }); + + it('threads the caller abort signal through to the connection (the cancel contract)', async () => { + let innerAborted: boolean | undefined; + const deps: EgressDeps = { + resolveHost: () => Promise.resolve(['203.0.113.10']), + openConnection: (_request, signal) => + new Promise((_resolve, reject) => { + innerAborted = signal.aborted; + signal.addEventListener('abort', () => reject(new Error('aborted'))); + if (signal.aborted) reject(new Error('aborted')); + }), + }; + const egress = createNodeEgressCapability({ deps }); + const ac = new AbortController(); + ac.abort(); + await egress + .fetch({ method: 'GET', url: 'https://api.example.com/x' }, ac.signal) + .catch(() => undefined); + expect(innerAborted).toBe(true); // the composed inner signal reached the connection already-aborted + }); + + it('times out a hung connection as a transient EgressCapabilityError (retryable)', async () => { + const deps: EgressDeps = { + resolveHost: () => Promise.resolve(['203.0.113.10']), + // Honors the abort signal (as the real node deps do) but never resolves on its own — the 5ms timeout + // composed by withEgressTimeout aborts it, surfacing a normalized transient failure to the model. + openConnection: (_request, signal) => + new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => reject(new Error('aborted by the timeout'))); + }), + }; + const egress = createNodeEgressCapability({ deps, timeoutMs: 5 }); + await expect( + egress.fetch({ method: 'GET', url: 'https://api.example.com/x' }), + ).rejects.toBeInstanceOf(EgressCapabilityError); + }); }); diff --git a/apps/cli/src/engine/tool-host/egress.ts b/apps/cli/src/engine/tool-host/egress.ts index 910a3e67..9491868a 100644 --- a/apps/cli/src/engine/tool-host/egress.ts +++ b/apps/cli/src/engine/tool-host/egress.ts @@ -99,15 +99,18 @@ export function createNodeEgressCapability( * Map the shared {@link SafeEgressError} to the host error taxonomy: an SSRF range-block or non-HTTPS/ * credentialed-url denial is a **deterministic** {@link EgressDeniedError} (fatal `tool_denied` — re-issuing * re-denies, never burns the node-retry budget); a transient network/size failure is an - * {@link EgressCapabilityError} (retryable `tool_failed`). Messages stay reason-only (no url/IP/bytes). An - * abort is classified `cancelled` by the registry's cancel-precedence regardless of this class. + * {@link EgressCapabilityError} (retryable `tool_failed`). The shared message is already a tool-agnostic, + * reason-only `egress …` string (no url/IP/bytes), so it is passed through verbatim — the SAME arm backs + * `http_request`, `web_search`, and http-transport `mcp_call`, and the registry attaches the actual invoking + * tool id when it surfaces the error, so an arm-side `http_request:` prefix would MISATTRIBUTE a `web_search` + * failure. An abort is classified `cancelled` by the registry's cancel-precedence regardless of this class. */ function classifyEgressError(error: unknown): Error { if (error instanceof SafeEgressError) { if (error.code === 'insecure_url' || error.code === 'blocked_host') { - return new EgressDeniedError(`http_request: ${error.message}`); + return new EgressDeniedError(error.message); } - return new EgressCapabilityError(`http_request: ${error.message}`); + return new EgressCapabilityError(error.message); } - return error instanceof Error ? error : new EgressCapabilityError('http_request: egress failed'); + return error instanceof Error ? error : new EgressCapabilityError('egress request failed'); } diff --git a/apps/cli/src/engine/tool-host/fs.test.ts b/apps/cli/src/engine/tool-host/fs.test.ts index ed256ddd..963cf103 100644 --- a/apps/cli/src/engine/tool-host/fs.test.ts +++ b/apps/cli/src/engine/tool-host/fs.test.ts @@ -373,8 +373,18 @@ describe('createNodeFsCapability — protected paths (denied in EVERY mode, auto ).rejects.toBeInstanceOf(FsScopeDeniedError); }); - it('refuses to write a shell startup file by basename (`.zshrc`, `.bashrc`, `.profile`)', async () => { - for (const rc of ['.zshrc', '.bashrc', '.profile', 'config.fish']) { + it('refuses startup/config files by basename (shell rc, X login, .gitconfig, .bash_aliases)', async () => { + for (const rc of [ + '.zshrc', + '.bashrc', + '.profile', + 'config.fish', + '.bash_aliases', + '.xprofile', + '.xinitrc', + '.xsession', + '.gitconfig', // user-global git config: core.hooksPath / `[alias] x = !cmd` ⇒ RCE + ]) { await expect(sandboxed().writeFile(rc, 'evil', {})).rejects.toBeInstanceOf( FsScopeDeniedError, ); @@ -387,6 +397,21 @@ describe('createNodeFsCapability — protected paths (denied in EVERY mode, auto ).rejects.toBeInstanceOf(FsScopeDeniedError); }); + it('refuses a Win32-folding trailing-dot/space variant (`.bashrc.`, `.bashrc `, `.git./x`)', async () => { + // Win32 silently strips trailing dots/spaces at open time, so these land on the REAL protected target; + // foldPathComponent denies them on every platform (an over-deny on a case-sensitive FS is the safe way). + for (const name of ['.bashrc.', '.bashrc ', '.gitconfig.', 'profile.ps1 ']) { + await expect(sandboxed().writeFile(name, 'evil', {})).rejects.toBeInstanceOf( + FsScopeDeniedError, + ); + } + // The folded `.git.` directory segment is caught by the EARLY check, so no `.git.` dir is ever created. + await expect( + sandboxed().writeFile('.git./config', 'x', { createDirs: true }), + ).rejects.toBeInstanceOf(FsScopeDeniedError); + await expect(readdir(join(workspace, '.git.'))).rejects.toThrow(); + }); + it('refuses a symlink whose REALPATH resolves INTO `.git/` (the post-jail re-check)', async () => { await mkdir(join(workspace, '.git'), { recursive: true }); // `link` is a workspace-relative symlink pointing at the (in-workspace) `.git` dir; writing `link/config` diff --git a/apps/cli/src/engine/tool-host/fs.ts b/apps/cli/src/engine/tool-host/fs.ts index 32b197ca..ec9deab6 100644 --- a/apps/cli/src/engine/tool-host/fs.ts +++ b/apps/cli/src/engine/tool-host/fs.ts @@ -306,9 +306,17 @@ export async function readJailedFile(real: string, sizeLimit: number): Promise = new Set(['.git', '.relavium']); const PROTECTED_RC_BASENAMES: ReadonlySet = new Set([ @@ -316,6 +324,7 @@ const PROTECTED_RC_BASENAMES: ReadonlySet = new Set([ '.bash_profile', '.bash_login', '.bash_logout', + '.bash_aliases', // sourced by the default Debian/Ubuntu .bashrc '.profile', '.zshrc', '.zprofile', @@ -326,21 +335,31 @@ const PROTECTED_RC_BASENAMES: ReadonlySet = new Set([ '.tcshrc', '.kshrc', '.login', + '.xprofile', // X11 login scripts — executed at graphical login + '.xinitrc', + '.xsession', + '.gitconfig', // user-global git config — core.hooksPath / `[alias] x = !cmd` ⇒ RCE on the next git command 'config.fish', // fish — ~/.config/fish/config.fish 'profile.ps1', 'microsoft.powershell_profile.ps1', // PowerShell profiles ]); -/** Deny a write to a protected path (a `.git`/`.relavium` directory, or a shell startup file). FATAL. */ +/** Fold a path component the way a filesystem does for matching: lowercase + strip the trailing dots/spaces a + * case-insensitive/Win32 FS silently drops (so `.BASHRC` / `.bashrc.` / `.git ` all fold to the protected name). */ +function foldPathComponent(name: string): string { + return name.toLowerCase().replace(/[. ]+$/u, ''); +} + +/** Deny a write to a protected path (a `.git`/`.relavium` directory, or a startup/config file). FATAL. */ function assertNotProtectedPath(absoluteTarget: string): void { for (const segment of absoluteTarget.split(sep)) { - if (segment.length > 0 && PROTECTED_DIR_SEGMENTS.has(segment.toLowerCase())) { + if (PROTECTED_DIR_SEGMENTS.has(foldPathComponent(segment))) { throw new FsScopeDeniedError( 'write_file: refusing to write inside a protected directory (.git / .relavium)', ); } } - if (PROTECTED_RC_BASENAMES.has(basename(absoluteTarget).toLowerCase())) { + if (PROTECTED_RC_BASENAMES.has(foldPathComponent(basename(absoluteTarget)))) { throw new FsScopeDeniedError('write_file: refusing to write a shell startup file'); } } diff --git a/apps/cli/src/engine/tool-host/os.test.ts b/apps/cli/src/engine/tool-host/os.test.ts index 8a2feb09..defe4618 100644 --- a/apps/cli/src/engine/tool-host/os.test.ts +++ b/apps/cli/src/engine/tool-host/os.test.ts @@ -57,6 +57,35 @@ function fakeSpawn(script: readonly ScriptedCall[]): { return { spawnImpl, calls }; } +/** + * A spawn whose children stay "running" — they never emit `close` on their own, so a timeout timer or an + * abort handler is what ends them. `kill('SIGKILL')` models the real OS: it bumps `killCount` and then emits + * `close` with a NULL code (a signal-killed process has no natural exit code), which is exactly what + * spawnCapturing's timeout/abort branches key on. + */ +function controllableSpawn(): { + spawnImpl: OsSpawnFn; + calls: RecordedCall[]; + killCount: () => number; +} { + const calls: RecordedCall[] = []; + let killCount = 0; + const spawnImpl: OsSpawnFn = (executable, args, options) => { + calls.push({ executable, args, env: options.env }); + const child = new EventEmitter() as EventEmitter & OsSpawnLike; + const stdout = new EventEmitter(); + Object.defineProperty(child, 'stdout', { value: stdout }); + Object.defineProperty(child, 'kill', { + value: () => { + killCount += 1; + queueMicrotask(() => child.emit('close', null)); // SIGKILL ⇒ death ⇒ close with a null code + }, + }); + return child; // never emits close on its own — only a timeout/abort kill ends it + }; + return { spawnImpl, calls, killCount: () => killCount }; +} + describe('notifyPlan — the per-platform spawn plan (injection-free arg/env construction)', () => { const input = { title: 'Done', body: 'evil"; rm -rf / #$(whoami)' } as const; @@ -176,6 +205,42 @@ describe('createNodeOsCapability — notify', () => { }); }); +describe('createNodeOsCapability — timeout + mid-spawn abort + the bounded buffer (the SIGKILL paths)', () => { + it('SIGKILLs a hung clipboard candidate past timeoutMs and rejects with the timeout reason', async () => { + const { spawnImpl, killCount } = controllableSpawn(); + const os = createNodeOsCapability({ platform: 'darwin', spawnImpl, timeoutMs: 10 }); + const err = await os.readClipboard().catch((e: unknown) => e); + expect(err).toBeInstanceOf(OsCapabilityError); + expect((err as OsCapabilityError).message).toBe('the os command timed out'); // not the misleading default + expect(killCount()).toBeGreaterThanOrEqual(1); // the timer SIGKILLed the hung child + }); + + it('SIGKILLs a hung notify spawn past timeoutMs and rejects', async () => { + const { spawnImpl, killCount } = controllableSpawn(); + const os = createNodeOsCapability({ platform: 'linux', spawnImpl, timeoutMs: 10 }); + await expect(os.notify({ title: 't', body: 'b' })).rejects.toBeInstanceOf(OsCapabilityError); + expect(killCount()).toBeGreaterThanOrEqual(1); + }); + + it('aborts an IN-FLIGHT clipboard spawn and does NOT probe the next candidate', async () => { + const { spawnImpl, calls, killCount } = controllableSpawn(); + const os = createNodeOsCapability({ platform: 'linux', spawnImpl }); // 3 candidates (wl-paste, xclip, xsel) + const ac = new AbortController(); + const pending = os.readClipboard(ac.signal); + await Promise.resolve(); // let the first candidate spawn + register its abort listener + ac.abort(); // onAbort ⇒ SIGKILL ⇒ close(null) with aborted=true ⇒ reject; the loop must NOT fall through + await expect(pending).rejects.toBeInstanceOf(Error); + expect(calls).toHaveLength(1); // ONLY wl-paste — the abort stopped the loop; xclip/xsel never spawned + expect(killCount()).toBeGreaterThanOrEqual(1); + }); + + it('caps clipboard output at maxBufferBytes (the memory guard)', async () => { + const { spawnImpl } = fakeSpawn([{ stdout: 'hello world', code: 0 }]); // 11 bytes + const os = createNodeOsCapability({ platform: 'darwin', spawnImpl, maxBufferBytes: 5 }); + expect(await os.readClipboard()).toBe('hello'); // capped to the first 5 bytes + }); +}); + describe('os errors share the host taxonomy', () => { it('OsCapabilityError and EgressCapabilityError are distinct transient host errors', () => { expect(new OsCapabilityError('x')).toBeInstanceOf(OsCapabilityError); diff --git a/apps/cli/src/engine/tool-host/os.ts b/apps/cli/src/engine/tool-host/os.ts index 07f0be09..405ac6be 100644 --- a/apps/cli/src/engine/tool-host/os.ts +++ b/apps/cli/src/engine/tool-host/os.ts @@ -24,6 +24,10 @@ import { OsCapabilityError, throwIfAborted as throwIfAbortedShared } from './err * unavailable (no `DISPLAY`/DBUS yet), and a missing helper on one Linux box may have a sibling that works. */ +/** A clipboard/notify spawn that exceeded its timeout — a {@link OsCapabilityError} the read loop can single + * out from a missing-binary spawn error (a hung tool is a real failure worth surfacing; an absent one is not). */ +class OsTimeoutError extends OsCapabilityError {} + const DEFAULT_TIMEOUT_MS = 10_000; /** A 1 MiB clipboard-read cap (the model-facing result is further bounded by the registry). */ const DEFAULT_MAX_BUFFER_BYTES = 1024 * 1024; @@ -98,16 +102,20 @@ export function createNodeOsCapability(config: NodeOsCapabilityConfig = {}): OsC readClipboard: async (signal) => { throwIfAborted(signal); // Try each platform candidate in order (Linux: Wayland → X11); the first that runs and exits 0 wins. A - // missing binary or a non-zero exit falls through to the next; an abort propagates immediately. The - // default error stands when EVERY candidate fails to spawn (none installed); a candidate that *ran* but - // exited non-zero upgrades the message to the more specific read failure. + // missing binary, a non-zero exit, or a timeout falls through to the next; an abort propagates + // immediately. "no clipboard tool is available" stays the message when every candidate is simply absent + // (the most likely cause — none installed); a candidate that RAN but exited non-zero, or one that existed + // but HUNG (a timeout), upgrades the message so it is not misleading on a single-candidate platform. let lastError = new OsCapabilityError('no clipboard tool is available'); for (const plan of clipboardReadPlans(platform)) { let result: CaptureResult; try { result = await spawnCapturing(spawnImpl, plan, { signal, timeoutMs, maxBufferBytes }); - } catch { - throwIfAborted(signal); // an abort wins; a missing candidate falls through to the next + } catch (error) { + throwIfAborted(signal); // an abort wins; a missing/failed candidate falls through to the next + // A timeout (the tool exists but hung) is a real failure worth surfacing; a spawn-start error is + // most likely a missing binary, for which the default "no tool available" is the more helpful reason. + if (error instanceof OsTimeoutError) lastError = error; continue; } if (result.exitCode === 0) return result.stdout; @@ -234,7 +242,7 @@ function spawnCapturing( if (aborted) { reject(new OsCapabilityError('the os command was aborted')); } else if (timedOut && code === null) { - reject(new OsCapabilityError('the os command timed out')); + reject(new OsTimeoutError('the os command timed out')); } else { resolvePromise({ exitCode: code ?? 1, stdout: stdout.text() }); } diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index bdfa1079..81b99584 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -111,8 +111,7 @@ export { type FetchMediaBytesOptions, type MediaEgressDeps, type MediaEgressErrorCode, - type HopRequest, - type HopResponse, + // `HopRequest`/`HopResponse` are exported directly from the shared safe-egress block below (their true home). } from './media-egress.js'; // The shared SSRF egress mechanism (ADR-0029(d)/0043/0057) — `connectValidated` (one validated hop) + @@ -128,6 +127,10 @@ export { type SafeEgressErrorCode, type EgressDeps, type EgressMethod, + // `HopRequest`/`HopResponse` are part of THIS mechanism's public surface (the CLI egress arm + its test + // consume them from `@relavium/db`); export them directly here rather than only via the media-egress alias. + type HopRequest, + type HopResponse, } from './safe-egress.js'; // Media references (1.AF/D12c + D11, ADR-0042/0044) — the media_objects/media_references retention + authz diff --git a/packages/db/src/safe-egress.test.ts b/packages/db/src/safe-egress.test.ts new file mode 100644 index 00000000..6685be52 --- /dev/null +++ b/packages/db/src/safe-egress.test.ts @@ -0,0 +1,301 @@ +import { request as httpsRequest } from 'node:https'; + +import { describe, expect, it, vi } from 'vitest'; + +import { + connectValidated, + nodeEgressDeps, + readBounded, + SafeEgressError, + withEgressTimeout, + type EgressDeps, + type HopRequest, +} from './safe-egress.js'; + +// Mock `node:https` so the CONCRETE `nodeEgressDeps.openConnection` (the un-injectable pin/SNI/headers/body +// wiring) is asserted without a real socket. The fake-deps tests below never reach the real openConnection. +vi.mock('node:https', () => ({ request: vi.fn() })); + +/** + * safe-egress.ts is THE single connect-by-validated-IP SSRF primitive shared by media + CLI tool egress + * (ADR-0029(d)/0043/0057). These tests pin its OWN contract directly (the media wrapper covers the media + * policy on top): URL validation, range-blocking every resolved IP, connect-pinning, the size bound, the + * timeout/abort normalization, and the concrete Node POST/body/headers-on-the-wire path. + */ + +const sig = (): AbortSignal => new AbortController().signal; + +/** A deterministic fake `EgressDeps`: a host→IPs map + a single 200 response, capturing the pinned HopRequest. */ +function fakeDeps(opts: { + resolve?: Record; + onOpen?: (request: HopRequest) => void; +}): EgressDeps { + return { + resolveHost: (host) => Promise.resolve(opts.resolve?.[host] ?? [host]), + openConnection: (request) => { + opts.onOpen?.(request); + return Promise.resolve({ + status: 200, + location: undefined, + body: (async function* empty(): AsyncGenerator { + await Promise.resolve(); + for (const chunk of [] as readonly Uint8Array[]) yield chunk; // an empty body stream + })(), + dispose: () => undefined, + }); + }, + }; +} + +async function* bytes(...chunks: readonly number[][]): AsyncGenerator { + for (const chunk of chunks) { + await Promise.resolve(); + yield new Uint8Array(chunk); + } +} + +describe('connectValidated — the one validated hop (URL policy + range-block + pin)', () => { + it('pins the FIRST validated IP and forwards method/headers/body to the hop', async () => { + let captured: HopRequest | undefined; + const deps = fakeDeps({ + resolve: { 'api.example.com': ['203.0.113.5', '203.0.113.6'] }, + onOpen: (request) => (captured = request), + }); + await connectValidated( + 'https://api.example.com/p?q=1', + { + allowPrivate: false, + method: 'POST', + headers: { authorization: 'Bearer X' }, + body: '{"a":1}', + }, + deps, + sig(), + ); + expect(captured?.pinnedIp).toBe('203.0.113.5'); // the first range-checked IP — no re-resolve TOCTOU + expect(captured?.hostname).toBe('api.example.com'); + expect(captured?.method).toBe('POST'); + expect(captured?.headers?.['authorization']).toBe('Bearer X'); + expect(captured?.body).toBe('{"a":1}'); + }); + + it('rejects a non-HTTPS url and a credentialed url with insecure_url (before any DNS)', async () => { + let resolved = false; + const deps: EgressDeps = { + resolveHost: (h) => { + resolved = true; + return Promise.resolve([h]); + }, + openConnection: () => Promise.reject(new Error('must not connect')), + }; + await expect( + connectValidated( + 'http://api.example.com/x', + { allowPrivate: false, method: 'GET' }, + deps, + sig(), + ), + ).rejects.toMatchObject({ code: 'insecure_url' }); + await expect( + connectValidated( + 'https://u:p@api.example.com/x', + { allowPrivate: false, method: 'GET' }, + deps, + sig(), + ), + ).rejects.toMatchObject({ code: 'insecure_url' }); + expect(resolved).toBe(false); // the url gate runs before the resolver + }); + + it('blocks a private IP among MANY resolved answers (one bad answer fails the whole fetch)', async () => { + const deps = fakeDeps({ resolve: { 'evil.example.com': ['203.0.113.5', '127.0.0.1'] } }); + await expect( + connectValidated( + 'https://evil.example.com/x', + { allowPrivate: false, method: 'GET' }, + deps, + sig(), + ), + ).rejects.toMatchObject({ code: 'blocked_host' }); + }); + + it('blocks a resolver that returns a NON-IP (it would defeat connect-by-validated-IP)', async () => { + const deps = fakeDeps({ resolve: { 'evil.example.com': ['cdn.internal.corp'] } }); + await expect( + connectValidated( + 'https://evil.example.com/x', + { allowPrivate: false, method: 'GET' }, + deps, + sig(), + ), + ).rejects.toMatchObject({ code: 'blocked_host' }); + }); + + it('blocks an empty DNS result (fail-closed — never pins the unvalidated hostname)', async () => { + const deps = fakeDeps({ resolve: { 'nx.example.com': [] } }); + await expect( + connectValidated( + 'https://nx.example.com/x', + { allowPrivate: false, method: 'GET' }, + deps, + sig(), + ), + ).rejects.toMatchObject({ code: 'blocked_host' }); + }); + + it('allowPrivate:true permits a loopback target (the BYOK local-endpoint opt-in)', async () => { + let opened = false; + const deps = fakeDeps({ resolve: { localhost: ['127.0.0.1'] }, onOpen: () => (opened = true) }); + await connectValidated( + 'https://localhost/x', + { allowPrivate: true, method: 'GET' }, + deps, + sig(), + ); + expect(opened).toBe(true); + }); +}); + +describe('readBounded — the size bound', () => { + it('returns the body at EXACTLY maxBytes and disposes', async () => { + let disposed = false; + const out = await readBounded(bytes([1, 2, 3]), 3, () => (disposed = true)); + expect(Array.from(out)).toEqual([1, 2, 3]); + expect(disposed).toBe(true); + }); + + it('throws too_large ONE byte over the cap and still disposes', async () => { + let disposed = false; + await expect( + readBounded(bytes([1, 2], [3, 4]), 3, () => (disposed = true)), + ).rejects.toMatchObject({ + code: 'too_large', + }); + expect(disposed).toBe(true); // the finally disposes even on the over-size throw + }); +}); + +describe('withEgressTimeout — timeout + abort + error normalization', () => { + it('normalizes a raw throw to SafeEgressError(network) and preserves a typed SafeEgressError', async () => { + await expect( + withEgressTimeout(undefined, 1000, () => Promise.reject(new Error('raw boom (host leak)'))), + ).rejects.toMatchObject({ name: 'SafeEgressError', code: 'network' }); + await expect( + withEgressTimeout(undefined, 1000, () => + Promise.reject(new SafeEgressError('too_large', 'x')), + ), + ).rejects.toMatchObject({ code: 'too_large' }); + }); + + it('aborts the inner signal immediately when the outer signal is already aborted', async () => { + const ac = new AbortController(); + ac.abort(); + let innerAborted: boolean | undefined; + await withEgressTimeout(ac.signal, 1000, (inner) => { + innerAborted = inner.aborted; + return Promise.resolve('ok'); + }); + expect(innerAborted).toBe(true); + }); + + it('fires the timeout: the inner signal aborts and the call rejects (normalized to network)', async () => { + const result = withEgressTimeout( + undefined, + 5, + (inner) => + new Promise((_resolve, reject) => { + inner.addEventListener('abort', () => reject(new Error('aborted by the timeout'))); + }), + ); + await expect(result).rejects.toMatchObject({ code: 'network' }); + }); +}); + +/* --- the concrete Node mechanism (nodeEgressDeps.openConnection) — the body/headers-on-the-wire path --- */ + +interface CapturedHttpsOptions { + readonly hostname?: string; + readonly method?: string; + readonly headers?: Readonly>; +} + +type FakeIncoming = AsyncIterable & { + readonly statusCode: number; + readonly headers: { readonly location?: string }; + readonly destroy: () => void; +}; + +function fakeIncoming(status: number, chunks: readonly Uint8Array[] = []): FakeIncoming { + const stream = (async function* gen(): AsyncGenerator { + await Promise.resolve(); + for (const chunk of chunks) yield chunk; + })(); + return Object.assign(stream, { statusCode: status, headers: {}, destroy: () => undefined }); +} + +/** A ClientRequest stub: openConnection calls `.on('error',…)`, optionally `.write(body)`, then `.end()`. */ +function stubClientRequest(): { + on: ReturnType; + write: ReturnType; + end: ReturnType; +} { + return { on: vi.fn(), write: vi.fn(), end: vi.fn() }; +} + +function lastHttpsCall(): { + options: CapturedHttpsOptions; + onResponse: (incoming: FakeIncoming) => void; +} { + const call = vi.mocked(httpsRequest).mock.calls.at(-1); + if (call === undefined) throw new Error('expected https.request to have been called'); + const [options, onResponse] = call; + if ( + typeof options !== 'object' || + options === null || + options instanceof URL || + typeof onResponse !== 'function' + ) { + throw new Error('expected https.request(optionsObject, responseCallback)'); + } + return { options, onResponse: onResponse as (incoming: FakeIncoming) => void }; +} + +describe('nodeEgressDeps.openConnection — the concrete body/headers wire path (2.5.E)', () => { + it('forwards method + headers AND writes the body for a POST', async () => { + const client = stubClientRequest(); + vi.mocked(httpsRequest).mockReturnValue(client as unknown as ReturnType); + const request: HopRequest = { + url: 'https://api.example.com/p', + hostname: 'api.example.com', + pinnedIp: '203.0.113.5', + method: 'POST', + headers: { authorization: 'Bearer SECRET-VALUE' }, + body: '{"q":1}', + }; + const pending = nodeEgressDeps.openConnection(request, sig()); + const { options, onResponse } = lastHttpsCall(); + onResponse(fakeIncoming(200)); + await pending; + expect(options.method).toBe('POST'); + expect(options.headers?.['authorization']).toBe('Bearer SECRET-VALUE'); // the host-resolved credential header + expect(client.write).toHaveBeenCalledWith('{"q":1}'); // the body actually reaches the wire + }); + + it('does NOT write a body for a GET with no body', async () => { + const client = stubClientRequest(); + vi.mocked(httpsRequest).mockReturnValue(client as unknown as ReturnType); + const pending = nodeEgressDeps.openConnection( + { + url: 'https://api.example.com/x', + hostname: 'api.example.com', + pinnedIp: '203.0.113.5', + method: 'GET', + }, + sig(), + ); + lastHttpsCall().onResponse(fakeIncoming(200)); + await pending; + expect(client.write).not.toHaveBeenCalled(); + expect(client.end).toHaveBeenCalled(); + }); +}); From b5b8df3e08c06ab41bd48e979322d54d1fe004a4 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Thu, 2 Jul 2026 03:13:38 +0300 Subject: [PATCH 13/45] =?UTF-8?q?fix(cli):=202.5.E=20Step=203=20=E2=80=94?= =?UTF-8?q?=20address=20sonnet=20review=20(13=20findings,=203=20HIGH=20sec?= =?UTF-8?q?urity)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/cli/src/engine/build-engine.ts | 8 +- apps/cli/src/engine/tool-host/egress.test.ts | 17 ++++ apps/cli/src/engine/tool-host/egress.ts | 42 +++++++--- apps/cli/src/engine/tool-host/fs.test.ts | 39 +++++++++ apps/cli/src/engine/tool-host/fs.ts | 58 ++++++++----- apps/cli/src/engine/tool-host/os.test.ts | 19 ++++- apps/cli/src/engine/tool-host/os.ts | 4 +- packages/db/src/safe-egress.test.ts | 88 ++++++++++++++++++++ packages/db/src/safe-egress.ts | 23 ++++- 9 files changed, 259 insertions(+), 39 deletions(-) diff --git a/apps/cli/src/engine/build-engine.ts b/apps/cli/src/engine/build-engine.ts index 842d37d4..ab872d6a 100644 --- a/apps/cli/src/engine/build-engine.ts +++ b/apps/cli/src/engine/build-engine.ts @@ -59,9 +59,11 @@ export interface BuildEngineOptions { * The `ToolHost` (2.5.A, ADR-0055): when `options.toolEnv` is given, the shared factory wires the * **read+write** `fs` + `process` arms jailed to the workspace at the resolved `fs_scope` (the workflow-author * trust model governs the run path); the inbound-MCP `McpCapability` (2.R) is then **merged** on top with a - * conditional spread — a true merge, never a replace. The `egress` / `os` arms stay unwired in 2.5.A (egress - * lands with ADR-0057/2.5.E behind the approval floor), so a tool needing one is cleanly `tool_unavailable`. - * Absent `toolEnv` (the in-memory unit/harness path) ⇒ a fail-closed `{}` base host. + * conditional spread — a true merge, never a replace. The `egress` / `os` arms are **intentionally never wired + * on this run path** — they belong only to the ADR-0057 approval-gated `chat-read-write` profile (a permanent + * scope boundary, not a 2.5.E deferral: workflow-run egress/os is a separate author-trusted concern) — so a + * tool needing one is cleanly `tool_unavailable`. Absent `toolEnv` (the in-memory unit/harness path) ⇒ a + * fail-closed `{}` base host. */ export async function buildEngine(options: BuildEngineOptions = {}): Promise { const host = options.host ?? createCliHost(); diff --git a/apps/cli/src/engine/tool-host/egress.test.ts b/apps/cli/src/engine/tool-host/egress.test.ts index b53ff379..e98bd6c3 100644 --- a/apps/cli/src/engine/tool-host/egress.test.ts +++ b/apps/cli/src/engine/tool-host/egress.test.ts @@ -131,6 +131,23 @@ describe('createNodeEgressCapability (2.5.E Step 3) — text egress over the sha expect(calls[0]?.headers?.['authorization']).toBeUndefined(); }); + it('degrades a REJECTING credential resolver (a keychain fault) to no credential — never a crash', async () => { + const { deps, calls } = fakeDeps({ resolve: PUBLIC, response: { status: 200 } }); + const egress = createNodeEgressCapability({ + deps, + resolveCredential: () => Promise.reject(new Error('keychain locked')), + }); + // The request still succeeds credential-less (the "never a crash" contract) — the raw keychain error does + // NOT escape fetch(), and no authorization header is attached. + const res = await egress.fetch({ + method: 'GET', + url: 'https://api.example.com/x', + credentialRef: 'kc:x', + }); + expect(res.status).toBe(200); + expect(calls[0]?.headers?.['authorization']).toBeUndefined(); + }); + it('forwards a POST method + body to the hop', async () => { const { deps, calls } = fakeDeps({ resolve: PUBLIC, response: { status: 200, body: 'ok' } }); const egress = createNodeEgressCapability({ deps }); diff --git a/apps/cli/src/engine/tool-host/egress.ts b/apps/cli/src/engine/tool-host/egress.ts index 9491868a..cb5f763d 100644 --- a/apps/cli/src/engine/tool-host/egress.ts +++ b/apps/cli/src/engine/tool-host/egress.ts @@ -55,18 +55,20 @@ export function createNodeEgressCapability( return { fetch: async (request: EgressRequest, signal?: AbortSignalLike): Promise => { - // Resolve the opaque credentialRef host-side and attach it as a bearer header INSIDE the trusted - // boundary — the raw secret never crosses back into the engine. No ref / no resolver ⇒ no header. - const credential = - request.credentialRef === undefined - ? undefined - : await config.resolveCredential?.(request.credentialRef); - const headers: Record = { ...request.headers }; - if (credential !== undefined && credential.length > 0) { - headers['authorization'] = `Bearer ${credential}`; - } - try { + // Resolve the opaque credentialRef host-side and attach it as a bearer header INSIDE the trusted + // boundary — the raw secret never crosses back into the engine. No ref / no resolver / a resolver that + // THROWS (a native-keychain fault) all ⇒ no header: the request proceeds credential-less per the + // documented "never a crash" contract — a provider that needs the credential returns 401, surfaced to + // the model. `resolveCredentialSafely` degrades a rejecting resolver to `undefined` rather than failing. + const credential = + request.credentialRef === undefined + ? undefined + : await resolveCredentialSafely(config.resolveCredential, request.credentialRef); + const headers: Record = { ...request.headers }; + if (credential !== undefined && credential.length > 0) { + headers['authorization'] = `Bearer ${credential}`; + } return await withEgressTimeout(signal, timeoutMs, async (sig) => { const response = await connectValidated( request.url, @@ -105,6 +107,24 @@ export function createNodeEgressCapability( * tool id when it surfaces the error, so an arm-side `http_request:` prefix would MISATTRIBUTE a `web_search` * failure. An abort is classified `cancelled` by the registry's cancel-precedence regardless of this class. */ +/** + * Resolve a `credentialRef` host-side, degrading an absent OR **rejecting** resolver to `undefined` so a + * native-keychain fault proceeds credential-less (the documented "never a crash" contract) instead of failing + * the whole request. A swallowed throw here is the intended fail-soft — NOT a silent secret error (the secret + * value itself is never touched on this path). + */ +async function resolveCredentialSafely( + resolveCredential: ((ref: string) => Promise) | undefined, + ref: string, +): Promise { + if (resolveCredential === undefined) return undefined; + try { + return await resolveCredential(ref); + } catch { + return undefined; + } +} + function classifyEgressError(error: unknown): Error { if (error instanceof SafeEgressError) { if (error.code === 'insecure_url' || error.code === 'blocked_host') { diff --git a/apps/cli/src/engine/tool-host/fs.test.ts b/apps/cli/src/engine/tool-host/fs.test.ts index 963cf103..72ed3764 100644 --- a/apps/cli/src/engine/tool-host/fs.test.ts +++ b/apps/cli/src/engine/tool-host/fs.test.ts @@ -422,11 +422,50 @@ describe('createNodeFsCapability — protected paths (denied in EVERY mode, auto ); }); + it('refuses to write inside `.ssh/` — the whole directory is protected (keys, config ProxyCommand, rc)', async () => { + for (const target of ['.ssh/authorized_keys', '.ssh/config', '.ssh/id_ed25519', '.ssh/rc']) { + await expect(sandboxed().writeFile(target, 'x', { createDirs: true })).rejects.toBeInstanceOf( + FsScopeDeniedError, + ); + } + }); + + it('refuses an NTFS Alternate-Data-Stream variant (`.gitconfig::$DATA` addresses the real `.gitconfig`)', async () => { + // `name::$DATA` / `name:stream` address the default stream of `name` on NTFS; foldPathComponent takes the + // pre-`:` part so the bare protected name is matched on every platform (an over-deny on POSIX is safe). + for (const name of ['.gitconfig::$DATA', '.bashrc:stream', '.git::$DATA/config']) { + await expect( + sandboxed().writeFile(name, 'evil', { createDirs: true }), + ).rejects.toBeInstanceOf(FsScopeDeniedError); + } + }); + + // NOTE: the writeOne realpath re-check (canonicalTarget) also denies a Win32 8.3 short-name alias + // (`GITCON~1` → `.gitconfig`) of an EXISTING target — but 8.3 aliasing is an NTFS behavior with no POSIX + // equivalent (realpath resolves symlinks, not hardlinks/short-names), so it is not reproducible on the CI + // platform; its pass-path (an existing non-protected target realpaths cleanly) is exercised by the overwrite + // test above. A final-component SYMLINK — the POSIX aliasing vector — is caught earlier by assertNotSymlink. + it('ALLOWS a `.gitignore` FILE — only the `.git` DIRECTORY segment is protected', async () => { const result = await sandboxed().writeFile('.gitignore', 'node_modules\n', {}); expect(result.bytesWritten).toBeGreaterThan(0); expect((await sandboxed().readFile('.gitignore', {})).content).toBe('node_modules\n'); }); + + it('denies protected paths under the `full` (unjailed) tier too — a tier-independent floor', async () => { + // ADR-0057: protected paths hold "even where the fs jail would allow them" — `full` is the no-jail case. + const fullTier = createNodeFsCapability({ + tier: 'full', + workspaceDir: workspace, + readOnly: false, + }); + await expect( + fullTier.writeFile('.git/config', '[evil]', { createDirs: true }), + ).rejects.toBeInstanceOf(FsScopeDeniedError); + await expect(fullTier.writeFile('.bashrc', 'evil', {})).rejects.toBeInstanceOf( + FsScopeDeniedError, + ); + }); }); describe('createNodeFsCapability — read-only profile (2.5.A chat)', () => { diff --git a/apps/cli/src/engine/tool-host/fs.ts b/apps/cli/src/engine/tool-host/fs.ts index ec9deab6..d0afc472 100644 --- a/apps/cli/src/engine/tool-host/fs.ts +++ b/apps/cli/src/engine/tool-host/fs.ts @@ -304,21 +304,26 @@ export async function readJailedFile(real: string, sizeLimit: number): Promise = new Set(['.git', '.relavium']); +const PROTECTED_DIR_SEGMENTS: ReadonlySet = new Set(['.git', '.relavium', '.ssh']); const PROTECTED_RC_BASENAMES: ReadonlySet = new Set([ '.bashrc', '.bash_profile', @@ -344,18 +349,20 @@ const PROTECTED_RC_BASENAMES: ReadonlySet = new Set([ 'microsoft.powershell_profile.ps1', // PowerShell profiles ]); -/** Fold a path component the way a filesystem does for matching: lowercase + strip the trailing dots/spaces a - * case-insensitive/Win32 FS silently drops (so `.BASHRC` / `.bashrc.` / `.git ` all fold to the protected name). */ +/** Fold a path component the way a filesystem does for matching: take the pre-`:` part (NTFS `name::$DATA` + * addresses `name`), lowercase (case-insensitive FS), and strip trailing dots/spaces (Win32 drops those at open + * time) — so `.BASHRC` / `.bashrc.` / `.git ` / `.gitconfig::$DATA` all fold to the bare protected name. */ function foldPathComponent(name: string): string { - return name.toLowerCase().replace(/[. ]+$/u, ''); + const beforeStream = name.split(':', 1)[0] ?? name; // NTFS Alternate-Data-Stream / drive qualifier + return beforeStream.toLowerCase().replace(/[. ]+$/u, ''); } -/** Deny a write to a protected path (a `.git`/`.relavium` directory, or a startup/config file). FATAL. */ +/** Deny a write to a protected path (a `.git`/`.relavium`/`.ssh` directory, or a startup/config file). FATAL. */ function assertNotProtectedPath(absoluteTarget: string): void { for (const segment of absoluteTarget.split(sep)) { if (PROTECTED_DIR_SEGMENTS.has(foldPathComponent(segment))) { throw new FsScopeDeniedError( - 'write_file: refusing to write inside a protected directory (.git / .relavium)', + 'write_file: refusing to write inside a protected directory (.git / .relavium / .ssh)', ); } } @@ -378,8 +385,8 @@ async function writeOne( } throwIfAborted(signal); // Protected paths are denied in EVERY mode (auto included). Checked on the REQUESTED path before the jail - // mkdir so a `createDirs` write cannot even create an empty `.git/`, then re-checked on the realpath'd - // target below so a symlink cannot resolve INTO a protected directory. + // mkdir so a `createDirs` write cannot even create an empty `.git/`, then re-checked on the (realDir-)jailed + // finalTarget so a parent symlink cannot resolve INTO a protected directory. assertNotProtectedPath(resolve(config.workspaceDir, path)); const inScope = await buildScopeChecker(config); const { realDir, finalTarget } = await jailWriteTarget( @@ -389,7 +396,13 @@ async function writeOne( opts.createDirs === true, ); await assertNotSymlink(finalTarget); // never write THROUGH an existing symlink at the final component - assertNotProtectedPath(finalTarget); // re-check the REALPATH'd target — a symlink must not reach a protected path + assertNotProtectedPath(finalTarget); // re-check the jailed target (parent realpath'd) against the fold + // Belt-and-suspenders for Win32 final-component name-aliasing: `finalTarget` keeps the LEXICAL basename + // (jailWriteTarget only realpath's the parent). If the target already EXISTS, its realpath is the canonical + // long name, so a Win32 8.3 short-name alias (`GITCON~1` → `.gitconfig`) or an NTFS stream path that folds to + // a non-protected basename is caught HERE. A not-yet-existing target has no alias, so the checks above suffice. + const canonicalTarget = await realpath(finalTarget).catch(() => undefined); + if (canonicalTarget !== undefined) assertNotProtectedPath(canonicalTarget); throwIfAborted(signal); const bytes = Buffer.from(data, 'utf8'); if (opts.append === true) { @@ -398,8 +411,10 @@ async function writeOne( // `assertNotSymlink` lstat above and the write (a swapped-in symlink can't redirect the append out of // scope). A symlink there fails ELOOP/ENOTDIR, which we map to the FATAL `tool_denied` (not the retryable // `tool_failed` `guarded` would otherwise assign a raw fs error). NOTE: `O_NOFOLLOW` is `0` on Windows (no - // kernel enforcement); the `assertNotSymlink` lstat above still covers the non-race case, and append is the - // author-trusted workflow-run path (chat is read-only), so the residual Windows race is accepted for 2.5.A. + // kernel enforcement); the `assertNotSymlink` lstat above still covers the non-race case. The write arm now + // also serves the approval-gated `chat-read-write` profile (ADR-0057), not only the author-trusted + // workflow-run path; the residual Windows-only symlink-swap race is flagged for the mandatory ADR-0057 + // security review (Step 5) to re-affirm as accepted — the protected-paths floor + fs jail still hold. const handle = await open( finalTarget, constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW, @@ -425,8 +440,9 @@ async function writeOne( // O_CREAT|O_EXCL|O_WRONLY (the 'wx' flag) plus O_NOFOLLOW — no-follow PARITY with the append path: the open // refuses to follow a final-component symlink even though the random temp name already makes a pre-placed // symlink there implausible (defense in depth). The residual gap is a PARENT-dir swap between `jailWriteTarget`'s - // realpath and this write — not closable in Node (no `openat`); the write arm is the author-trusted - // workflow-run path (chat is read-only), so that residual is accepted for 2.5.A. + // realpath and this write — not closable in Node (no `openat`); the write arm now also serves the + // approval-gated `chat-read-write` profile (ADR-0057), so that Windows-only residual is flagged for the + // mandatory ADR-0057 security review (Step 5), with the protected-paths floor + fs jail still in force. await writeFile(tmp, bytes, { flag: constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, }); diff --git a/apps/cli/src/engine/tool-host/os.test.ts b/apps/cli/src/engine/tool-host/os.test.ts index defe4618..59bae6ff 100644 --- a/apps/cli/src/engine/tool-host/os.test.ts +++ b/apps/cli/src/engine/tool-host/os.test.ts @@ -107,12 +107,18 @@ describe('notifyPlan — the per-platform spawn plan (injection-free arg/env con expect(plan.args.join('\n')).toContain('$env:RELAVIUM_NOTIFY_BODY'); }); - it('Linux: notify-send takes title + body as positional ARGS (shell:false data, no env overlay)', () => { + it('Linux: notify-send takes title + body as positional ARGS after `--` (flag-injection safe, no env overlay)', () => { const plan = notifyPlan('linux', input); expect(plan.executable).toBe('notify-send'); - expect(plan.args).toEqual(['Done', input.body]); + // The `--` end-of-options marker means a title/body starting with `-` is DATA, never a parsed flag. + expect(plan.args).toEqual(['--', 'Done', input.body]); expect(plan.env).toBeUndefined(); }); + + it('Linux: a `-`-prefixed title is still positional (behind `--`), not parsed as a notify-send flag', () => { + const plan = notifyPlan('linux', { title: '--help', body: '-x' }); + expect(plan.args).toEqual(['--', '--help', '-x']); + }); }); describe('clipboardReadPlans — per-platform candidate order', () => { @@ -195,6 +201,15 @@ describe('createNodeOsCapability — notify', () => { await expect(os.notify({ title: 't', body: 'b' })).rejects.toBeInstanceOf(OsCapabilityError); }); + it('rejects with OsCapabilityError when the notifier binary is not installed (synchronous spawn throw)', async () => { + // The single most likely real-world notify failure: `notify-send` simply absent (ENOENT at spawn). + const { spawnImpl } = fakeSpawn([{ throwOnSpawn: true }]); + const os = createNodeOsCapability({ platform: 'linux', spawnImpl }); + const err = await os.notify({ title: 't', body: 'b' }).catch((e: unknown) => e); + expect(err).toBeInstanceOf(OsCapabilityError); + expect((err as OsCapabilityError).message).toBe('the os command could not be started'); + }); + it('merges the notify env OVERLAY over the ambient env (so DISPLAY/DBUS survive)', async () => { const { spawnImpl, calls } = fakeSpawn([{ code: 0 }]); const os = createNodeOsCapability({ platform: 'darwin', spawnImpl }); diff --git a/apps/cli/src/engine/tool-host/os.ts b/apps/cli/src/engine/tool-host/os.ts index 405ac6be..a9304a91 100644 --- a/apps/cli/src/engine/tool-host/os.ts +++ b/apps/cli/src/engine/tool-host/os.ts @@ -182,7 +182,9 @@ export function notifyPlan(platform: NodeJS.Platform, input: NotifyInput): Spawn env: { [NOTIFY_TITLE_ENV]: input.title, [NOTIFY_BODY_ENV]: input.body }, }; default: - return { executable: 'notify-send', args: [input.title, input.body] }; + // `--` terminates notify-send's GOption parsing, so a title/body that begins with `-`/`--` is treated as + // positional DATA, never a flag (an argument-injection surface `shell:false` alone does not close). + return { executable: 'notify-send', args: ['--', input.title, input.body] }; } } diff --git a/packages/db/src/safe-egress.test.ts b/packages/db/src/safe-egress.test.ts index 6685be52..6eccfc56 100644 --- a/packages/db/src/safe-egress.test.ts +++ b/packages/db/src/safe-egress.test.ts @@ -79,6 +79,37 @@ describe('connectValidated — the one validated hop (URL policy + range-block + expect(captured?.body).toBe('{"a":1}'); }); + it('STRIPS a caller-supplied Host / :authority header (virtual-host-confusion SSRF defense)', async () => { + let captured: HopRequest | undefined; + const deps = fakeDeps({ + resolve: { 'api.example.com': ['203.0.113.5'] }, + onOpen: (request) => (captured = request), + }); + await connectValidated( + 'https://api.example.com/x', + { + allowPrivate: false, + method: 'GET', + // A model could set these to reroute an allowlisted, correctly-pinned request to a DIFFERENT vhost at + // the same shared IP — the mechanism must drop them so the wire Host derives from the validated host. + headers: { + Host: 'internal-admin.example', + host: 'evil.example', + ':authority': 'evil2.example', + 'x-keep': 'ok', + authorization: 'Bearer X', + }, + }, + deps, + sig(), + ); + expect(captured?.headers?.['Host']).toBeUndefined(); + expect(captured?.headers?.['host']).toBeUndefined(); + expect(captured?.headers?.[':authority']).toBeUndefined(); + expect(captured?.headers?.['x-keep']).toBe('ok'); // unrelated headers pass through + expect(captured?.headers?.['authorization']).toBe('Bearer X'); // the legit credential is untouched + }); + it('rejects a non-HTTPS url and a credentialed url with insecure_url (before any DNS)', async () => { let resolved = false; const deps: EgressDeps = { @@ -217,6 +248,12 @@ interface CapturedHttpsOptions { readonly hostname?: string; readonly method?: string; readonly headers?: Readonly>; + readonly servername?: string; + readonly lookup?: ( + hostname: string, + options: unknown, + callback: (err: unknown, address: string, family: number) => void, + ) => void; } type FakeIncoming = AsyncIterable & { @@ -281,6 +318,57 @@ describe('nodeEgressDeps.openConnection — the concrete body/headers wire path expect(client.write).toHaveBeenCalledWith('{"q":1}'); // the body actually reaches the wire }); + it('pins the connection to the validated IP via the lookup callback and keeps the hostname as SNI', async () => { + const client = stubClientRequest(); + vi.mocked(httpsRequest).mockReturnValue(client as unknown as ReturnType); + const pending = nodeEgressDeps.openConnection( + { + url: 'https://api.example.com/x', + hostname: 'api.example.com', + pinnedIp: '203.0.113.5', + method: 'GET', + }, + sig(), + ); + const { options, onResponse } = lastHttpsCall(); + // SNI + certificate host stay the validated hostname (TLS verification is against api.example.com), while + // the socket is pinned to the pre-validated IP — the lookup callback returns pinnedIp, never re-resolving. + expect(options.servername).toBe('api.example.com'); + expect(options.hostname).toBe('api.example.com'); + let pinnedTo: string | undefined; + let pinnedFamily: number | undefined; + options.lookup?.('api.example.com', {}, (_err: unknown, address: string, family: number) => { + pinnedTo = address; + pinnedFamily = family; + }); + expect(pinnedTo).toBe('203.0.113.5'); + expect(pinnedFamily).toBe(4); // an IPv4 pin resolves family 4 + onResponse(fakeIncoming(200)); + await pending; + }); + + it('pins an IPv6 target with family 6', async () => { + const client = stubClientRequest(); + vi.mocked(httpsRequest).mockReturnValue(client as unknown as ReturnType); + const pending = nodeEgressDeps.openConnection( + { + url: 'https://api.example.com/x', + hostname: 'api.example.com', + pinnedIp: '2606:2800:220:1:248:1893:25c8:1946', + method: 'GET', + }, + sig(), + ); + const { options, onResponse } = lastHttpsCall(); + let pinnedFamily: number | undefined; + options.lookup?.('api.example.com', {}, (_e: unknown, _a: string, family: number) => { + pinnedFamily = family; + }); + expect(pinnedFamily).toBe(6); + onResponse(fakeIncoming(200)); + await pending; + }); + it('does NOT write a body for a GET with no body', async () => { const client = stubClientRequest(); vi.mocked(httpsRequest).mockReturnValue(client as unknown as ReturnType); diff --git a/packages/db/src/safe-egress.ts b/packages/db/src/safe-egress.ts index 81a9fb62..cfb9ae83 100644 --- a/packages/db/src/safe-egress.ts +++ b/packages/db/src/safe-egress.ts @@ -176,13 +176,34 @@ export async function connectValidated( hostname: host, pinnedIp, method: opts.method, - headers: opts.headers, + headers: sanitizeHopHeaders(opts.headers), body: opts.body, }, signal, ); } +/** + * Drop a caller-supplied `Host` / `:authority` header so the wire `Host` is ALWAYS derived from the validated + * hostname. The IP-pin + SNI fix the TRANSPORT destination, but a shared/CDN/reverse-proxy IP routes at the + * APPLICATION layer by the `Host` header — so a model-set `Host` (the `http_request` `headers` arg is + * model-controlled) could otherwise send an `allowedDomains`-approved, correctly-pinned request to a DIFFERENT + * virtual host at the same IP (a virtual-host-confusion SSRF the pin/SNI alone don't stop). Matched + * case-insensitively; the legitimate `authorization` credential header is untouched. + */ +function sanitizeHopHeaders( + headers: Readonly> | undefined, +): Readonly> | undefined { + if (headers === undefined) return undefined; + const out: Record = {}; + for (const [key, value] of Object.entries(headers)) { + const k = key.trim().toLowerCase(); + if (k === 'host' || k === ':authority') continue; // never let the caller override the validated Host + out[key] = value; + } + return out; +} + /** Consume a body stream, aborting the moment it exceeds `maxBytes`; concat the bounded chunks. */ export async function readBounded( body: AsyncIterable, From 5d51fcf5c2c731eaf5f392c740fc8de1ad902fd4 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Thu, 2 Jul 2026 03:24:57 +0300 Subject: [PATCH 14/45] =?UTF-8?q?feat(cli):=202.5.E=20Step=204a=20?= =?UTF-8?q?=E2=80=94=20chat-mode=20policy=20foundation=20(mode=20=E2=86=92?= =?UTF-8?q?=20turn=20policy)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/cli/src/chat/chat-mode.test.ts | 166 +++++++++++++++++++++++ apps/cli/src/chat/chat-mode.ts | 200 ++++++++++++++++++++++++++++ 2 files changed, 366 insertions(+) create mode 100644 apps/cli/src/chat/chat-mode.test.ts create mode 100644 apps/cli/src/chat/chat-mode.ts diff --git a/apps/cli/src/chat/chat-mode.test.ts b/apps/cli/src/chat/chat-mode.test.ts new file mode 100644 index 00000000..6f9a692b --- /dev/null +++ b/apps/cli/src/chat/chat-mode.test.ts @@ -0,0 +1,166 @@ +import { BUILTIN_TOOLS, type ToolApprovalRequest, type ToolDef } from '@relavium/core'; +import { describe, expect, it, vi } from 'vitest'; + +import { + ApprovalCache, + buildTurnPolicy, + CHAT_MODES, + DEFAULT_CHAT_MODE, + governedToolIds, + isGovernedTool, + nextMode, + parseMode, + type ApprovalAnswer, + type ApprovalPrompt, +} from './chat-mode.js'; + +function builtin(id: string): ToolDef { + const def = BUILTIN_TOOLS.find((d) => d.id === id); + if (def === undefined) throw new Error(`missing builtin ${id}`); + return def; +} + +const req = (over: Partial = {}): ToolApprovalRequest => ({ + toolId: 'write_file', + action: 'fs_write', + preview: { path: 'notes.md' }, + ...over, +}); + +describe('mode cycle + parsing', () => { + it('cycles ask → plan → accept-edits → auto → ask (Shift+Tab order)', () => { + expect(DEFAULT_CHAT_MODE).toBe('ask'); + expect(CHAT_MODES).toEqual(['ask', 'plan', 'accept-edits', 'auto']); + expect(nextMode('ask')).toBe('plan'); + expect(nextMode('plan')).toBe('accept-edits'); + expect(nextMode('accept-edits')).toBe('auto'); + expect(nextMode('auto')).toBe('ask'); // wraps + }); + + it('parses a mode name, tolerating the spaced label, and rejects the unknown', () => { + expect(parseMode('plan')).toBe('plan'); + expect(parseMode(' AUTO ')).toBe('auto'); + expect(parseMode('accept edits')).toBe('accept-edits'); // spaced label → kebab + expect(parseMode('accept-edits')).toBe('accept-edits'); + expect(parseMode('yolo')).toBeUndefined(); + }); +}); + +describe('isGovernedTool — mirrors the registry governedAction (advertise-filter hide set)', () => { + it('governs write_file (fsWrite), http_request/web_search/mcp_call (egress), run_command (model command)', () => { + expect(isGovernedTool(builtin('write_file'))).toBe(true); + expect(isGovernedTool(builtin('http_request'))).toBe(true); + expect(isGovernedTool(builtin('web_search'))).toBe(true); + expect(isGovernedTool(builtin('mcp_call'))).toBe(true); + expect(isGovernedTool(builtin('run_command'))).toBe(true); + expect(isGovernedTool(builtin('git_commit'))).toBe(true); + }); + + it('does NOT govern the read-only tools (read_file, list_directory, git_status without a policyTarget)', () => { + expect(isGovernedTool(builtin('read_file'))).toBe(false); + expect(isGovernedTool(builtin('list_directory'))).toBe(false); + expect(isGovernedTool(builtin('git_status'))).toBe(false); // spawnsProcess but NO policyTarget ⇒ read-only + expect(isGovernedTool(builtin('read_clipboard'))).toBe(false); // os, non-governed + }); + + it('governs a discovered MCP tool (egress: mcp)', () => { + const mcpTool: ToolDef = { ...builtin('mcp_call'), id: 'mcp__srv__do', source: 'mcp' }; + expect(governedToolIds([mcpTool, builtin('read_file')])).toEqual(new Set(['mcp__srv__do'])); + }); +}); + +describe('ApprovalCache — session once/always memory', () => { + it('remembers an always-approval by tool id; once caches nothing', () => { + const cache = new ApprovalCache(); + expect(cache.isAlways('write_file')).toBe(false); + cache.rememberAlways('write_file'); + expect(cache.isAlways('write_file')).toBe(true); + expect(cache.isAlways('http_request')).toBe(false); // scoped per tool id + }); +}); + +describe('buildTurnPolicy — the mode → { advertise, confirm } mapping', () => { + const deps = (over: Partial[1]> = {}) => ({ + governed: governedToolIds(BUILTIN_TOOLS), + prompt: vi.fn(() => Promise.resolve({ outcome: 'approve', scope: 'once' })), + cache: new ApprovalCache(), + ...over, + }); + + it('ask: hides governed tools AND its confirm rejects every governed dispatch (two-layer)', async () => { + const d = deps(); + const policy = buildTurnPolicy('ask', d); + expect(policy.advertise?.('read_file')).toBe(true); + expect(policy.advertise?.('git_status')).toBe(true); // read-only process tool stays advertised + expect(policy.advertise?.('write_file')).toBe(false); + expect(policy.advertise?.('http_request')).toBe(false); + const decision = await policy.confirm!(req()); + expect(decision).toEqual({ outcome: 'reject', reason: 'not allowed in ask mode (read-only)' }); + expect(d.prompt).not.toHaveBeenCalled(); // ask never prompts + }); + + it('plan: same read-only posture as ask', async () => { + const policy = buildTurnPolicy('plan', deps()); + expect(policy.advertise?.('write_file')).toBe(false); + expect((await policy.confirm!(req())).outcome).toBe('reject'); + }); + + it('accept-edits: advertises all + prompts; an "always" answer is cached so the next call skips the prompt', async () => { + const prompt = vi.fn(() => + Promise.resolve({ outcome: 'approve', scope: 'always' }), + ); + const d = deps({ prompt }); + const policy = buildTurnPolicy('accept-edits', d); + expect(policy.advertise).toBeUndefined(); // every granted tool advertised + expect(await policy.confirm!(req())).toEqual({ outcome: 'approve' }); + expect(prompt).toHaveBeenCalledTimes(1); + // Second call to the SAME tool id is short-circuited by the always cache — no second prompt. + expect(await policy.confirm!(req())).toEqual({ outcome: 'approve' }); + expect(prompt).toHaveBeenCalledTimes(1); + }); + + it('accept-edits: a "once" approval does NOT cache — the next call re-prompts', async () => { + const prompt = vi.fn(() => + Promise.resolve({ outcome: 'approve', scope: 'once' }), + ); + const policy = buildTurnPolicy('accept-edits', deps({ prompt })); + await policy.confirm!(req()); + await policy.confirm!(req()); + expect(prompt).toHaveBeenCalledTimes(2); + }); + + it('accept-edits: a rejection is passed through with its reason', async () => { + const prompt = vi.fn(() => + Promise.resolve({ outcome: 'reject', reason: 'nope' }), + ); + const decision = await buildTurnPolicy('accept-edits', deps({ prompt })).confirm!(req()); + expect(decision).toEqual({ outcome: 'reject', reason: 'nope' }); + }); + + it('auto: advertises all + auto-approves a normal target without prompting', async () => { + const d = deps(); + const policy = buildTurnPolicy('auto', d); + expect(policy.advertise).toBeUndefined(); + expect(await policy.confirm!(req())).toEqual({ outcome: 'approve' }); + expect(d.prompt).not.toHaveBeenCalled(); + }); + + it('auto: a PROTECTED-path target falls back to an explicit prompt', async () => { + const prompt = vi.fn(() => + Promise.resolve({ outcome: 'reject' }), + ); + const policy = buildTurnPolicy('auto', { + ...deps({ prompt }), + isProtectedTarget: (preview) => preview.path === '.git/config', + }); + // A normal write auto-approves… + expect(await policy.confirm!(req({ preview: { path: 'ok.md' } }))).toEqual({ + outcome: 'approve', + }); + expect(prompt).not.toHaveBeenCalled(); + // …but a protected-path write prompts. + const decision = await policy.confirm!(req({ preview: { path: '.git/config' } })); + expect(prompt).toHaveBeenCalledTimes(1); + expect(decision.outcome).toBe('reject'); + }); +}); diff --git a/apps/cli/src/chat/chat-mode.ts b/apps/cli/src/chat/chat-mode.ts new file mode 100644 index 00000000..46abf590 --- /dev/null +++ b/apps/cli/src/chat/chat-mode.ts @@ -0,0 +1,200 @@ +import type { + ConfirmActionHook, + SessionTurnPolicy, + ToolActionPreview, + ToolApprovalDecision, + ToolApprovalRequest, + ToolDef, +} from '@relavium/core'; +import type { AbortSignalLike } from '@relavium/shared'; + +/** + * The CLI chat **mode** model (2.5.E Step 4, [ADR-0057](../../../../docs/decisions/0057-cli-chat-modes-and-per-tool-approval.md)). + * A mode is a POLICY LAYER on the one session instance — never a reseat: it maps to a + * {@link SessionTurnPolicy} the host pushes via `AgentSession.setTurnPolicy`, controlling only (a) the + * model-advertised tool subset and (b) the per-dispatch approval decision. The engine stays mode-agnostic; + * this module is the single home of the ask / plan / accept-edits / auto vocabulary and its two mappings. + * + * Two-layer safety (ADR-0057): the advertise-filter is BEST-EFFORT (it keeps a governed tool out of the + * model's reach), while the `confirm` hook is AUTHORITATIVE — the registry calls it for every governed-class + * dispatch (fs_write / egress / a model-controlled process), so even if the model names a hidden tool the + * mode policy still decides. `ask`/`plan` deny governed actions; `accept-edits` prompts (`[a]/[r]/[c]`) with a + * session once/always memory; `auto` auto-approves EXCEPT a protected-path write, which falls back to a prompt + * (and the fs layer hard-denies protected paths regardless — the floor beneath every mode). + */ + +/** The four chat modes, in `Shift+Tab` cycle order (ADR-0057 — `auto` is on the cycle, not hidden). */ +export const CHAT_MODES = ['ask', 'plan', 'accept-edits', 'auto'] as const; +export type ChatMode = (typeof CHAT_MODES)[number]; + +/** The default mode: read-only `ask` (secure by default — no governed action without a deliberate mode step). */ +export const DEFAULT_CHAT_MODE: ChatMode = 'ask'; + +/** Short human labels for the footer indicator + `/mode` output. */ +export const MODE_LABEL: Record = { + ask: 'ask', + plan: 'plan', + 'accept-edits': 'accept edits', + auto: 'auto', +}; + +/** One-line descriptions (the `/mode` help + palette). */ +export const MODE_DESCRIPTION: Record = { + ask: 'read-only — writes, commands, and network are declined', + plan: 'read-only — draft a plan before acting', + 'accept-edits': 'prompt before each write / command / network call', + auto: 'auto-approve actions (protected paths still prompt; the fs jail still holds)', +}; + +/** The next mode in the `Shift+Tab` cycle: ask → plan → accept-edits → auto → ask. */ +export function nextMode(mode: ChatMode): ChatMode { + const index = CHAT_MODES.indexOf(mode); + return CHAT_MODES[(index + 1) % CHAT_MODES.length] ?? DEFAULT_CHAT_MODE; +} + +/** Parse a `/mode ` argument to a {@link ChatMode}, tolerating the spaced label (`accept edits`). */ +export function parseMode(input: string): ChatMode | undefined { + const normalized = input.trim().toLowerCase().replace(/\s+/gu, '-'); + return (CHAT_MODES as readonly string[]).includes(normalized) + ? (normalized as ChatMode) + : undefined; +} + +/** + * The REPL's interactive answer to an approval prompt — RICHER than the engine's approve/reject because it + * carries the once/always SCOPE (the engine only needs the final approve/reject). `always` is remembered for + * the tool id for the rest of the session instance; `once` approves just this invocation. + */ +export type ApprovalAnswer = + | { readonly outcome: 'approve'; readonly scope: 'once' | 'always' } + | { readonly outcome: 'reject'; readonly reason?: string }; + +/** The interactive prompt the REPL supplies (accept-edits, and auto's protected-path fallback). */ +export type ApprovalPrompt = ( + request: ToolApprovalRequest, + signal?: AbortSignalLike, +) => Promise; + +/** + * The session-scoped, IN-MEMORY once/always cache (ADR-0057 — NOT persisted across resume, so a `chat-resume` + * re-prompts). `always` = a tool id approved for the remainder of this session instance; `once` caches + * nothing (it approves a single invocation, then the next identical call re-prompts). + */ +export class ApprovalCache { + readonly #always = new Set(); + /** Whether this tool id was previously "always"-approved this session. */ + isAlways(toolId: string): boolean { + return this.#always.has(toolId); + } + /** Remember an "always" approval for a tool id (the remainder of this session instance). */ + rememberAlways(toolId: string): void { + this.#always.add(toolId); + } +} + +/** + * Whether a tool is a mutating/side-effecting action the `ask`/`plan` advertise-filter HIDES. It is a superset + * of the registry's runtime `confirmAction` `governedAction`: it also hides a `requiresGateApproval` tool + * (`git_commit`), which `confirmAction` does NOT gate (its `enforcePolicy` human-gate floor denies it on the + * chat path instead) but which is plainly not read-only. Covered: `write_file` (`fsWrite`), any `egress` + * (`http_request` / `web_search` / `mcp_call` / a discovered MCP tool), a process tool that resolves a + * MODEL-CONTROLLED command (a `policyTarget` — `run_command`), and a gate-approval tool. `git_status` has NO + * `policyTarget` and no gate flag ⇒ read-only, advertised in every mode; `read_file` / `list_directory` / + * `read_clipboard` are likewise read-only. (The `confirm` floor stays authoritative for the confirmAction + * classes regardless of what the filter offers.) + */ +export function isGovernedTool(def: ToolDef): boolean { + if (def.policy.fsWrite === true) return true; + if (def.policy.egress !== undefined) return true; + if (def.policy.requiresGateApproval === true) return true; + return def.policy.spawnsProcess === true && def.policyTarget !== undefined; +} + +/** The set of governed tool ids across a def list — the advertise-filter's `ask`/`plan` hide set. */ +export function governedToolIds(defs: readonly ToolDef[]): ReadonlySet { + const out = new Set(); + for (const def of defs) { + if (isGovernedTool(def)) out.add(def.id); + } + return out; +} + +/** The inputs {@link buildTurnPolicy} needs beyond the mode itself — the session-scoped consent machinery. */ +export interface TurnPolicyDeps { + /** Governed tool ids (from {@link governedToolIds}) — hidden by the `ask`/`plan` advertise-filter. */ + readonly governed: ReadonlySet; + /** The REPL interactive prompt (accept-edits, and auto's protected-path fallback). */ + readonly prompt: ApprovalPrompt; + /** The session once/always memory. */ + readonly cache: ApprovalCache; + /** + * Whether an approval preview targets a protected path, so `auto` falls back to a prompt rather than + * auto-approving (ADR-0057). Absent ⇒ `auto` auto-approves every governed action (the fs-layer + * protected-paths refusal is still the hard floor either way). + */ + readonly isProtectedTarget?: (preview: ToolActionPreview) => boolean; +} + +/** + * Map a {@link ChatMode} to the {@link SessionTurnPolicy} the host pushes to `AgentSession.setTurnPolicy`. + * Setting ANY policy activates the fail-closed approval regime (the dispatch context's `approval` is present), + * so a governed dispatch always requires a `confirm` decision — the reason a wiring bug can never let `ask` + * write. `ask`/`plan` also hide governed tools from the model (best-effort); `accept-edits`/`auto` advertise + * every granted tool and rely on `confirm`. + */ +export function buildTurnPolicy(mode: ChatMode, deps: TurnPolicyDeps): SessionTurnPolicy { + const advertise = advertiseFor(mode, deps.governed); + const confirm = confirmFor(mode, deps); + return advertise === undefined ? { confirm } : { advertise, confirm }; +} + +/** The advertise-filter for a mode: `ask`/`plan` offer only non-governed tools; the others offer all. */ +function advertiseFor( + mode: ChatMode, + governed: ReadonlySet, +): ((toolId: string) => boolean) | undefined { + if (mode === 'ask' || mode === 'plan') { + return (toolId) => !governed.has(toolId); + } + return undefined; // accept-edits / auto advertise every granted tool — the confirm floor gates them +} + +/** The per-mode approval hook. The registry only invokes it for a GOVERNED dispatch, so every call is gated. */ +function confirmFor(mode: ChatMode, deps: TurnPolicyDeps): ConfirmActionHook { + return async (request, signal): Promise => { + switch (mode) { + case 'ask': + case 'plan': + return { outcome: 'reject', reason: `not allowed in ${MODE_LABEL[mode]} mode (read-only)` }; + case 'accept-edits': + if (deps.cache.isAlways(request.toolId)) return { outcome: 'approve' }; + return toDecision(await deps.prompt(request, signal), request.toolId, deps.cache); + case 'auto': + // auto auto-approves — except a protected-path target, which falls back to an explicit prompt (the fs + // layer also hard-denies protected paths, so this is the graceful UX, not the security floor). + if (deps.isProtectedTarget?.(request.preview) === true) { + return toDecision(await deps.prompt(request, signal), request.toolId, deps.cache); + } + return { outcome: 'approve' }; + default: { + const exhaustive: never = mode; + return exhaustive; + } + } + }; +} + +/** Lower a REPL {@link ApprovalAnswer} to the engine's approve/reject, updating the once/always cache. */ +function toDecision( + answer: ApprovalAnswer, + toolId: string, + cache: ApprovalCache, +): ToolApprovalDecision { + if (answer.outcome === 'reject') { + return answer.reason === undefined + ? { outcome: 'reject' } + : { outcome: 'reject', reason: answer.reason }; + } + if (answer.scope === 'always') cache.rememberAlways(toolId); + return { outcome: 'approve' }; +} From ea5d9a146003a132becc9aaa2cc9dc92c64e04c7 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Thu, 2 Jul 2026 07:47:46 +0300 Subject: [PATCH 15/45] =?UTF-8?q?fix(cli):=202.5.E=20Step=204a=20=E2=80=94?= =?UTF-8?q?=20address=20opus=20review=20(7=20findings)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/cli/src/chat/chat-mode.test.ts | 97 +++++++++++++++++++++++++++-- apps/cli/src/chat/chat-mode.ts | 19 ++++-- 2 files changed, 107 insertions(+), 9 deletions(-) diff --git a/apps/cli/src/chat/chat-mode.test.ts b/apps/cli/src/chat/chat-mode.test.ts index 6f9a692b..f9479288 100644 --- a/apps/cli/src/chat/chat-mode.test.ts +++ b/apps/cli/src/chat/chat-mode.test.ts @@ -67,6 +67,25 @@ describe('isGovernedTool — mirrors the registry governedAction (advertise-filt const mcpTool: ToolDef = { ...builtin('mcp_call'), id: 'mcp__srv__do', source: 'mcp' }; expect(governedToolIds([mcpTool, builtin('read_file')])).toEqual(new Set(['mcp__srv__do'])); }); + + it('locks governedToolIds(BUILTIN_TOOLS) to the EXACT governed set — a drift guard on the hide-set', () => { + // Any new builtin with a mutating policy, or a dropped policy flag, forces a deliberate update here — the + // advertise hide-set must never silently drift (a mutating tool advertised in ask, or a read-only one hidden). + expect(governedToolIds(BUILTIN_TOOLS)).toEqual( + new Set([ + 'write_file', + 'run_command', + 'git_commit', + 'http_request', + 'web_search', + 'mcp_call', + ]), + ); + // Explicit negatives for the read-only tools the positive cases above don't name. + for (const id of ['notify', 'read_media', 'invoke_agent']) { + expect(isGovernedTool(builtin(id))).toBe(false); + } + }); }); describe('ApprovalCache — session once/always memory', () => { @@ -87,22 +106,42 @@ describe('buildTurnPolicy — the mode → { advertise, confirm } mapping', () = ...over, }); - it('ask: hides governed tools AND its confirm rejects every governed dispatch (two-layer)', async () => { + it('ask: hides governed tools (incl. git_commit) AND its confirm rejects EVERY governed class (two-layer)', async () => { const d = deps(); const policy = buildTurnPolicy('ask', d); expect(policy.advertise?.('read_file')).toBe(true); expect(policy.advertise?.('git_status')).toBe(true); // read-only process tool stays advertised expect(policy.advertise?.('write_file')).toBe(false); expect(policy.advertise?.('http_request')).toBe(false); - const decision = await policy.confirm!(req()); - expect(decision).toEqual({ outcome: 'reject', reason: 'not allowed in ask mode (read-only)' }); + // git_commit is the requiresGateApproval superset element — hidden here is its ONLY mode-level containment + // on the chat path (the confirm hook is never invoked for it, since governedAction returns undefined). + expect(policy.advertise?.('git_commit')).toBe(false); + // The authoritative floor: confirm rejects fs_write AND process AND egress dispatches, not just writes. + for (const r of [ + req(), + req({ toolId: 'run_command', action: 'process', preview: { command: 'ls' } }), + req({ toolId: 'http_request', action: 'egress', preview: { host: 'x.com' } }), + ]) { + expect(await policy.confirm!(r)).toEqual({ + outcome: 'reject', + reason: 'not allowed in ask mode (read-only)', + }); + } expect(d.prompt).not.toHaveBeenCalled(); // ask never prompts }); - it('plan: same read-only posture as ask', async () => { + it('plan: same read-only posture as ask (hides git_commit; rejects process + egress too)', async () => { const policy = buildTurnPolicy('plan', deps()); expect(policy.advertise?.('write_file')).toBe(false); + expect(policy.advertise?.('git_commit')).toBe(false); expect((await policy.confirm!(req())).outcome).toBe('reject'); + expect( + ( + await policy.confirm!( + req({ toolId: 'run_command', action: 'process', preview: { command: 'ls' } }), + ) + ).outcome, + ).toBe('reject'); }); it('accept-edits: advertises all + prompts; an "always" answer is cached so the next call skips the prompt', async () => { @@ -119,6 +158,36 @@ describe('buildTurnPolicy — the mode → { advertise, confirm } mapping', () = expect(prompt).toHaveBeenCalledTimes(1); }); + it('accept-edits: forwards the exact request + AbortSignal to the prompt (the preview the user approves)', async () => { + const prompt = vi.fn(() => + Promise.resolve({ outcome: 'approve', scope: 'once' }), + ); + const policy = buildTurnPolicy('accept-edits', deps({ prompt })); + const signal = new AbortController().signal; + const request = req({ + toolId: 'run_command', + action: 'process', + preview: { command: 'ls -la' }, + }); + await policy.confirm!(request, signal); + expect(prompt).toHaveBeenCalledWith(request, signal); // the secret-free preview + the cancel signal reach the host + }); + + it('accept-edits: an "always" grant is scoped to its tool id — a DIFFERENT governed tool still prompts', async () => { + const prompt = vi.fn(() => + Promise.resolve({ outcome: 'approve', scope: 'always' }), + ); + const policy = buildTurnPolicy('accept-edits', deps({ prompt })); + await policy.confirm!(req()); // always-approve write_file + await policy.confirm!(req()); // write_file short-circuits (no 2nd prompt) + expect(prompt).toHaveBeenCalledTimes(1); + // A different tool id is NOT covered by the write_file grant — it prompts. + await policy.confirm!( + req({ toolId: 'run_command', action: 'process', preview: { command: 'ls' } }), + ); + expect(prompt).toHaveBeenCalledTimes(2); + }); + it('accept-edits: a "once" approval does NOT cache — the next call re-prompts', async () => { const prompt = vi.fn(() => Promise.resolve({ outcome: 'approve', scope: 'once' }), @@ -163,4 +232,24 @@ describe('buildTurnPolicy — the mode → { advertise, confirm } mapping', () = expect(prompt).toHaveBeenCalledTimes(1); expect(decision.outcome).toBe('reject'); }); + + it('auto: an "always" answer at the protected-path fallback is NOT cached (no cross-mode escalation)', async () => { + // The session cache is shared across modes; an "always" at an auto protected-path prompt must not blanket- + // approve that tool id in a later accept-edits turn (a consent-scope violation). So the auto fallback never + // remembers — the protected prompt re-asks, and accept-edits still prompts for the same tool. + const cache = new ApprovalCache(); + const prompt = vi.fn(() => + Promise.resolve({ outcome: 'approve', scope: 'always' }), + ); + const autoPolicy = buildTurnPolicy('auto', { + ...deps({ prompt, cache }), + isProtectedTarget: () => true, + }); + await autoPolicy.confirm!(req()); + expect(cache.isAlways('write_file')).toBe(false); // the always grant was dropped + // A subsequent accept-edits turn (same shared cache) still prompts for write_file — no leaked blanket grant. + const acceptPolicy = buildTurnPolicy('accept-edits', deps({ prompt, cache })); + await acceptPolicy.confirm!(req()); + expect(prompt).toHaveBeenCalledTimes(2); // auto's prompt + accept-edits' prompt (no short-circuit) + }); }); diff --git a/apps/cli/src/chat/chat-mode.ts b/apps/cli/src/chat/chat-mode.ts index 46abf590..c618a65c 100644 --- a/apps/cli/src/chat/chat-mode.ts +++ b/apps/cli/src/chat/chat-mode.ts @@ -168,12 +168,16 @@ function confirmFor(mode: ChatMode, deps: TurnPolicyDeps): ConfirmActionHook { return { outcome: 'reject', reason: `not allowed in ${MODE_LABEL[mode]} mode (read-only)` }; case 'accept-edits': if (deps.cache.isAlways(request.toolId)) return { outcome: 'approve' }; - return toDecision(await deps.prompt(request, signal), request.toolId, deps.cache); + // An "always" answer here IS remembered (the accept-edits once/always memory). + return toDecision(await deps.prompt(request, signal), request.toolId, deps.cache, true); case 'auto': // auto auto-approves — except a protected-path target, which falls back to an explicit prompt (the fs - // layer also hard-denies protected paths, so this is the graceful UX, not the security floor). + // layer also hard-denies protected paths, so this is the graceful UX, not the security floor). Its + // answer is NOT cacheable: a protected-path prompt must re-ask every time, and — since the session + // cache is shared across modes — an "always" here must not silently blanket-approve that tool id in a + // later accept-edits turn (a cross-mode consent escalation). So the auto fallback never remembers. if (deps.isProtectedTarget?.(request.preview) === true) { - return toDecision(await deps.prompt(request, signal), request.toolId, deps.cache); + return toDecision(await deps.prompt(request, signal), request.toolId, deps.cache, false); } return { outcome: 'approve' }; default: { @@ -184,17 +188,22 @@ function confirmFor(mode: ChatMode, deps: TurnPolicyDeps): ConfirmActionHook { }; } -/** Lower a REPL {@link ApprovalAnswer} to the engine's approve/reject, updating the once/always cache. */ +/** + * Lower a REPL {@link ApprovalAnswer} to the engine's approve/reject. An `always` answer is remembered ONLY + * when `allowAlwaysCache` is set (accept-edits) — auto's protected-path fallback passes `false` so a narrow + * protected-context grant can never leak into a later mode's blanket approval via the shared session cache. + */ function toDecision( answer: ApprovalAnswer, toolId: string, cache: ApprovalCache, + allowAlwaysCache: boolean, ): ToolApprovalDecision { if (answer.outcome === 'reject') { return answer.reason === undefined ? { outcome: 'reject' } : { outcome: 'reject', reason: answer.reason }; } - if (answer.scope === 'always') cache.rememberAlways(toolId); + if (allowAlwaysCache && answer.scope === 'always') cache.rememberAlways(toolId); return { outcome: 'approve' }; } From 5e07961f495642fe5cad6316ff134d41ae0eb4f0 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Thu, 2 Jul 2026 08:05:02 +0300 Subject: [PATCH 16/45] =?UTF-8?q?fix(cli):=202.5.E=20Step=204a=20=E2=80=94?= =?UTF-8?q?=20address=20Sonnet=205=20review=20(6=20findings)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/cli/src/chat/chat-mode.test.ts | 46 +++++++++++++++++++---------- apps/cli/src/chat/chat-mode.ts | 23 +++++++++++---- 2 files changed, 48 insertions(+), 21 deletions(-) diff --git a/apps/cli/src/chat/chat-mode.test.ts b/apps/cli/src/chat/chat-mode.test.ts index f9479288..f4d35d99 100644 --- a/apps/cli/src/chat/chat-mode.test.ts +++ b/apps/cli/src/chat/chat-mode.test.ts @@ -130,18 +130,24 @@ describe('buildTurnPolicy — the mode → { advertise, confirm } mapping', () = expect(d.prompt).not.toHaveBeenCalled(); // ask never prompts }); - it('plan: same read-only posture as ask (hides git_commit; rejects process + egress too)', async () => { - const policy = buildTurnPolicy('plan', deps()); + it('plan: same read-only posture as ask (advertises reads, hides governed, rejects all 3 classes)', async () => { + const d = deps(); + const policy = buildTurnPolicy('plan', d); + expect(policy.advertise?.('read_file')).toBe(true); + expect(policy.advertise?.('git_status')).toBe(true); expect(policy.advertise?.('write_file')).toBe(false); expect(policy.advertise?.('git_commit')).toBe(false); - expect((await policy.confirm!(req())).outcome).toBe('reject'); - expect( - ( - await policy.confirm!( - req({ toolId: 'run_command', action: 'process', preview: { command: 'ls' } }), - ) - ).outcome, - ).toBe('reject'); + for (const r of [ + req(), + req({ toolId: 'run_command', action: 'process', preview: { command: 'ls' } }), + req({ toolId: 'http_request', action: 'egress', preview: { host: 'x.com' } }), + ]) { + expect(await policy.confirm!(r)).toEqual({ + outcome: 'reject', + reason: 'not allowed in plan mode (read-only)', + }); + } + expect(d.prompt).not.toHaveBeenCalled(); }); it('accept-edits: advertises all + prompts; an "always" answer is cached so the next call skips the prompt', async () => { @@ -170,7 +176,8 @@ describe('buildTurnPolicy — the mode → { advertise, confirm } mapping', () = preview: { command: 'ls -la' }, }); await policy.confirm!(request, signal); - expect(prompt).toHaveBeenCalledWith(request, signal); // the secret-free preview + the cancel signal reach the host + // The secret-free preview + the cacheable hint (true in accept-edits) + the cancel signal reach the host. + expect(prompt).toHaveBeenCalledWith(request, true, signal); }); it('accept-edits: an "always" grant is scoped to its tool id — a DIFFERENT governed tool still prompts', async () => { @@ -179,7 +186,8 @@ describe('buildTurnPolicy — the mode → { advertise, confirm } mapping', () = ); const policy = buildTurnPolicy('accept-edits', deps({ prompt })); await policy.confirm!(req()); // always-approve write_file - await policy.confirm!(req()); // write_file short-circuits (no 2nd prompt) + // The cache is keyed by tool id ONLY (not tool+args), so a write_file to a DIFFERENT path still short-circuits. + await policy.confirm!(req({ preview: { path: 'other.md' } })); expect(prompt).toHaveBeenCalledTimes(1); // A different tool id is NOT covered by the write_file grant — it prompts. await policy.confirm!( @@ -198,12 +206,14 @@ describe('buildTurnPolicy — the mode → { advertise, confirm } mapping', () = expect(prompt).toHaveBeenCalledTimes(2); }); - it('accept-edits: a rejection is passed through with its reason', async () => { + it('accept-edits: a rejection is passed through with its reason and NEVER touches the cache', async () => { + const cache = new ApprovalCache(); const prompt = vi.fn(() => Promise.resolve({ outcome: 'reject', reason: 'nope' }), ); - const decision = await buildTurnPolicy('accept-edits', deps({ prompt })).confirm!(req()); + const decision = await buildTurnPolicy('accept-edits', deps({ prompt, cache })).confirm!(req()); expect(decision).toEqual({ outcome: 'reject', reason: 'nope' }); + expect(cache.isAlways('write_file')).toBe(false); // a reject never remembers }); it('auto: advertises all + auto-approves a normal target without prompting', async () => { @@ -227,9 +237,13 @@ describe('buildTurnPolicy — the mode → { advertise, confirm } mapping', () = outcome: 'approve', }); expect(prompt).not.toHaveBeenCalled(); - // …but a protected-path write prompts. - const decision = await policy.confirm!(req({ preview: { path: '.git/config' } })); + // …but a protected-path write prompts, forwarding the request + cacheable:false (the REPL greys out + // "always" here) + the cancel signal. + const signal = new AbortController().signal; + const protectedReq = req({ preview: { path: '.git/config' } }); + const decision = await policy.confirm!(protectedReq, signal); expect(prompt).toHaveBeenCalledTimes(1); + expect(prompt).toHaveBeenCalledWith(protectedReq, false, signal); expect(decision.outcome).toBe('reject'); }); diff --git a/apps/cli/src/chat/chat-mode.ts b/apps/cli/src/chat/chat-mode.ts index c618a65c..af3fca9f 100644 --- a/apps/cli/src/chat/chat-mode.ts +++ b/apps/cli/src/chat/chat-mode.ts @@ -69,9 +69,16 @@ export type ApprovalAnswer = | { readonly outcome: 'approve'; readonly scope: 'once' | 'always' } | { readonly outcome: 'reject'; readonly reason?: string }; -/** The interactive prompt the REPL supplies (accept-edits, and auto's protected-path fallback). */ +/** + * The interactive prompt the REPL supplies (accept-edits, and auto's protected-path fallback). `cacheable` + * tells the REPL whether an "always" answer will actually be REMEMBERED for the session: `true` in accept-edits + * (offer `[a]lways`), `false` at auto's protected-path fallback (a protected prompt re-asks every time, so the + * REPL should grey out / omit the `always` choice rather than silently discard it). It is a UX signal only — + * the `toDecision` floor still enforces the same rule if a prompt returns `always` anyway. + */ export type ApprovalPrompt = ( request: ToolApprovalRequest, + cacheable: boolean, signal?: AbortSignalLike, ) => Promise; @@ -166,20 +173,26 @@ function confirmFor(mode: ChatMode, deps: TurnPolicyDeps): ConfirmActionHook { case 'ask': case 'plan': return { outcome: 'reject', reason: `not allowed in ${MODE_LABEL[mode]} mode (read-only)` }; - case 'accept-edits': + case 'accept-edits': { if (deps.cache.isAlways(request.toolId)) return { outcome: 'approve' }; // An "always" answer here IS remembered (the accept-edits once/always memory). - return toDecision(await deps.prompt(request, signal), request.toolId, deps.cache, true); - case 'auto': + const cacheable = true; + const answer = await deps.prompt(request, cacheable, signal); + return toDecision(answer, request.toolId, deps.cache, cacheable); + } + case 'auto': { // auto auto-approves — except a protected-path target, which falls back to an explicit prompt (the fs // layer also hard-denies protected paths, so this is the graceful UX, not the security floor). Its // answer is NOT cacheable: a protected-path prompt must re-ask every time, and — since the session // cache is shared across modes — an "always" here must not silently blanket-approve that tool id in a // later accept-edits turn (a cross-mode consent escalation). So the auto fallback never remembers. if (deps.isProtectedTarget?.(request.preview) === true) { - return toDecision(await deps.prompt(request, signal), request.toolId, deps.cache, false); + const cacheable = false; + const answer = await deps.prompt(request, cacheable, signal); + return toDecision(answer, request.toolId, deps.cache, cacheable); } return { outcome: 'approve' }; + } default: { const exhaustive: never = mode; return exhaustive; From 8f200363f3046f9c1e0185d4ccaf8a0527ef27e9 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Thu, 2 Jul 2026 08:10:25 +0300 Subject: [PATCH 17/45] =?UTF-8?q?feat(cli):=202.5.E=20Step=204b-i=20?= =?UTF-8?q?=E2=80=94=20mode-controller=20seam=20+=20isProtectedPath=20expo?= =?UTF-8?q?rt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/cli/src/chat/chat-mode-host.test.ts | 95 ++++++++++++++++++++++++ apps/cli/src/chat/chat-mode-host.ts | 80 ++++++++++++++++++++ apps/cli/src/engine/tool-host/fs.ts | 19 ++++- 3 files changed, 191 insertions(+), 3 deletions(-) create mode 100644 apps/cli/src/chat/chat-mode-host.test.ts create mode 100644 apps/cli/src/chat/chat-mode-host.ts diff --git a/apps/cli/src/chat/chat-mode-host.test.ts b/apps/cli/src/chat/chat-mode-host.test.ts new file mode 100644 index 00000000..5682027c --- /dev/null +++ b/apps/cli/src/chat/chat-mode-host.test.ts @@ -0,0 +1,95 @@ +import { mkdtemp, realpath, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { BUILTIN_TOOLS, type SessionTurnPolicy } from '@relavium/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { applyChatMode, makeChatModeEnv } from './chat-mode-host.js'; +import { type ApprovalPrompt } from './chat-mode.js'; + +let workspace: string; +beforeEach(async () => { + workspace = await realpath(await mkdtemp(join(tmpdir(), 'relavium-modehost-'))); +}); +afterEach(async () => { + await rm(workspace, { recursive: true, force: true }).catch(() => undefined); +}); + +/** A fake session that records every pushed turn policy. */ +function fakeSession(): { + session: { setTurnPolicy: (p: SessionTurnPolicy | undefined) => void }; + policies: (SessionTurnPolicy | undefined)[]; +} { + const policies: (SessionTurnPolicy | undefined)[] = []; + return { session: { setTurnPolicy: (p) => policies.push(p) }, policies }; +} + +const prompt: ApprovalPrompt = () => Promise.resolve({ outcome: 'approve', scope: 'once' }); + +describe('makeChatModeEnv + applyChatMode', () => { + it('derives the governed hide-set from the session tools and pushes the mapped policy on apply', () => { + const { session, policies } = fakeSession(); + const env = makeChatModeEnv({ session, tools: BUILTIN_TOOLS, workspaceDir: workspace, prompt }); + applyChatMode(env, 'ask'); + expect(policies).toHaveLength(1); + const policy = policies[0]; + expect(policy?.confirm).toBeDefined(); // the regime is active (fail-closed) in every mode + expect(policy?.advertise?.('read_file')).toBe(true); + expect(policy?.advertise?.('write_file')).toBe(false); // governed hidden in ask + }); + + it('applies a new policy on every mode change (accept-edits advertises all)', () => { + const { session, policies } = fakeSession(); + const env = makeChatModeEnv({ session, tools: BUILTIN_TOOLS, workspaceDir: workspace, prompt }); + applyChatMode(env, 'ask'); + applyChatMode(env, 'accept-edits'); + expect(policies).toHaveLength(2); + expect(policies[1]?.advertise).toBeUndefined(); // accept-edits advertises every granted tool + expect(policies[1]?.confirm).toBeDefined(); + }); + + it('shares ONE cache across mode changes — an "always" grant persists across re-applies', () => { + const { session } = fakeSession(); + const env = makeChatModeEnv({ session, tools: BUILTIN_TOOLS, workspaceDir: workspace, prompt }); + applyChatMode(env, 'accept-edits'); + env.cache.rememberAlways('write_file'); + applyChatMode(env, 'ask'); // switch mode — a fresh policy, but over the SAME env/cache + applyChatMode(env, 'accept-edits'); + expect(env.cache.isAlways('write_file')).toBe(true); // the cache lives on the env, not per-policy + }); + + it('isProtectedTarget resolves a preview path against the workspace and matches a protected target', () => { + const { session } = fakeSession(); + const env = makeChatModeEnv({ session, tools: BUILTIN_TOOLS, workspaceDir: workspace, prompt }); + expect(env.isProtectedTarget({ path: '.git/config' })).toBe(true); + expect(env.isProtectedTarget({ path: '.ssh/authorized_keys' })).toBe(true); + expect(env.isProtectedTarget({ path: 'notes.md' })).toBe(false); + expect(env.isProtectedTarget({})).toBe(false); // no path (egress/process preview) ⇒ never protected + expect(env.isProtectedTarget({ command: 'ls' })).toBe(false); + }); + + it('auto uses isProtectedTarget: a protected write prompts, a normal write auto-approves', async () => { + const { session, policies } = fakeSession(); + const promptSpy = vi.fn(() => Promise.resolve({ outcome: 'reject' })); + const env = makeChatModeEnv({ + session, + tools: BUILTIN_TOOLS, + workspaceDir: workspace, + prompt: promptSpy, + }); + applyChatMode(env, 'auto'); + const confirm = policies[0]?.confirm; + expect(confirm).toBeDefined(); + // A normal write auto-approves without prompting… + expect( + await confirm!({ toolId: 'write_file', action: 'fs_write', preview: { path: 'ok.md' } }), + ).toEqual({ + outcome: 'approve', + }); + expect(promptSpy).not.toHaveBeenCalled(); + // …a protected write falls back to the prompt. + await confirm!({ toolId: 'write_file', action: 'fs_write', preview: { path: '.git/config' } }); + expect(promptSpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/cli/src/chat/chat-mode-host.ts b/apps/cli/src/chat/chat-mode-host.ts new file mode 100644 index 00000000..6ffd0138 --- /dev/null +++ b/apps/cli/src/chat/chat-mode-host.ts @@ -0,0 +1,80 @@ +import { resolve } from 'node:path'; + +import type { AgentSession, ToolActionPreview, ToolDef } from '@relavium/core'; + +import { isProtectedPath } from '../engine/tool-host/fs.js'; +import { + ApprovalCache, + buildTurnPolicy, + governedToolIds, + type ApprovalPrompt, + type ChatMode, +} from './chat-mode.js'; + +/** + * The host glue between the pure {@link buildTurnPolicy} policy (chat-mode.ts) and a live {@link AgentSession} + * (2.5.E Step 4b, [ADR-0057](../../../../docs/decisions/0057-cli-chat-modes-and-per-tool-approval.md)). It + * holds the SESSION-SCOPED consent machinery — the once/always {@link ApprovalCache}, the governed hide-set + * (derived once from the session's tool defs), and the protected-path predicate (bound to the workspace) — + * and applies a mode by pushing the mapped `SessionTurnPolicy` to `session.setTurnPolicy`. The REPL owns the + * `mode` UI state (footer + Shift+Tab) + the interactive `prompt`; this module owns the mapping so both the + * fresh and resumed session paths apply modes identically. Creating it does NOT itself change any policy — + * the caller applies the initial (ask) mode via {@link applyChatMode}, so nothing here has a live effect until + * the REPL wires it (keeping the pre-wire state safe). + */ + +/** The session-scoped environment a mode is applied against — built once per session. */ +export interface ChatModeEnv { + /** The live session the mapped turn policy is pushed to. */ + readonly session: Pick; + /** Governed tool ids (the ask/plan advertise hide-set) — derived once from the session's granted defs. */ + readonly governed: ReadonlySet; + /** The session once/always memory (shared across mode changes — an "always" persists until the session ends). */ + readonly cache: ApprovalCache; + /** The REPL's interactive `[a]/[r]/[c]` prompt (accept-edits, and auto's protected-path fallback). */ + readonly prompt: ApprovalPrompt; + /** Whether an approval preview targets a protected path — `auto` then falls back to a prompt (ADR-0057). */ + readonly isProtectedTarget: (preview: ToolActionPreview) => boolean; +} + +export interface MakeChatModeEnvOptions { + readonly session: Pick; + /** The session's granted tool defs (built-ins + discovered MCP) — the governed hide-set is derived from these. */ + readonly tools: readonly ToolDef[]; + /** The session working dir — the anchor a relative preview path is resolved against for the protected check. */ + readonly workspaceDir: string; + /** The REPL's interactive approval prompt. */ + readonly prompt: ApprovalPrompt; +} + +/** Build the session-scoped {@link ChatModeEnv} (pure — no policy is applied until {@link applyChatMode}). */ +export function makeChatModeEnv(opts: MakeChatModeEnvOptions): ChatModeEnv { + return { + session: opts.session, + governed: governedToolIds(opts.tools), + cache: new ApprovalCache(), + prompt: opts.prompt, + // fs_write is the only preview class with a path; egress/process have none, so they are never "protected" + // (auto approves them directly). A relative path resolves against the session workspace — the SAME anchor + // the fs jail uses — so the classification matches what the fs layer would enforce. + isProtectedTarget: (preview) => + preview.path !== undefined && isProtectedPath(resolve(opts.workspaceDir, preview.path)), + }; +} + +/** + * Apply a chat mode to the session — map it to a `SessionTurnPolicy` (advertise-filter + the fail-closed + * confirm hook) and push it via `setTurnPolicy`. Setting ANY policy activates the interactive-approval regime, + * so a governed dispatch always requires a `confirm` decision (fail-closed). Idempotent; call it on session + * start (the initial mode) and on every mode change. + */ +export function applyChatMode(env: ChatModeEnv, mode: ChatMode): void { + env.session.setTurnPolicy( + buildTurnPolicy(mode, { + governed: env.governed, + prompt: env.prompt, + cache: env.cache, + isProtectedTarget: env.isProtectedTarget, + }), + ); +} diff --git a/apps/cli/src/engine/tool-host/fs.ts b/apps/cli/src/engine/tool-host/fs.ts index d0afc472..e2cf8ea9 100644 --- a/apps/cli/src/engine/tool-host/fs.ts +++ b/apps/cli/src/engine/tool-host/fs.ts @@ -357,8 +357,23 @@ function foldPathComponent(name: string): string { return beforeStream.toLowerCase().replace(/[. ]+$/u, ''); } +/** + * Whether an absolute path is protected — a `.git`/`.relavium`/`.ssh` directory segment, or a startup/config + * basename. The pure predicate behind {@link assertNotProtectedPath}; exported so a caller (the ADR-0057 chat + * `auto`-mode approval, which falls back to an explicit prompt on a protected target) can classify a target + * WITHOUT triggering the throw — the two share exactly one protected-paths definition. + */ +export function isProtectedPath(absoluteTarget: string): boolean { + for (const segment of absoluteTarget.split(sep)) { + if (PROTECTED_DIR_SEGMENTS.has(foldPathComponent(segment))) return true; + } + return PROTECTED_RC_BASENAMES.has(foldPathComponent(basename(absoluteTarget))); +} + /** Deny a write to a protected path (a `.git`/`.relavium`/`.ssh` directory, or a startup/config file). FATAL. */ function assertNotProtectedPath(absoluteTarget: string): void { + if (!isProtectedPath(absoluteTarget)) return; + // Re-derive which class matched for a precise (still reason-only) message. for (const segment of absoluteTarget.split(sep)) { if (PROTECTED_DIR_SEGMENTS.has(foldPathComponent(segment))) { throw new FsScopeDeniedError( @@ -366,9 +381,7 @@ function assertNotProtectedPath(absoluteTarget: string): void { ); } } - if (PROTECTED_RC_BASENAMES.has(foldPathComponent(basename(absoluteTarget)))) { - throw new FsScopeDeniedError('write_file: refusing to write a shell startup file'); - } + throw new FsScopeDeniedError('write_file: refusing to write a shell startup file'); } async function writeOne( From 1a363f4e3679b55bf93177b1c0ec0307c1c05597 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Thu, 2 Jul 2026 08:17:41 +0300 Subject: [PATCH 18/45] =?UTF-8?q?feat(cli):=202.5.E=20Step=204b-ii-a=20?= =?UTF-8?q?=E2=80=94=20chat-store=20mode=20+=20approval=20coordination?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/cli/src/render/tui/chat-store.test.ts | 58 +++++++++++++++ apps/cli/src/render/tui/chat-store.ts | 85 +++++++++++++++++++++- 2 files changed, 140 insertions(+), 3 deletions(-) diff --git a/apps/cli/src/render/tui/chat-store.test.ts b/apps/cli/src/render/tui/chat-store.test.ts index 8d3611b9..32543392 100644 --- a/apps/cli/src/render/tui/chat-store.test.ts +++ b/apps/cli/src/render/tui/chat-store.test.ts @@ -256,3 +256,61 @@ describe('createChatStore', () => { ]); }); }); + +describe('createChatStore — chat mode + per-tool approval coordination (ADR-0057)', () => { + const approvalRequest = { + toolId: 'write_file', + action: 'fs_write', + preview: { path: 'notes.md' }, + } as const; + + it('defaults to ask mode; setMode updates the snapshot mode and flushes immediately', () => { + const store = createChatStore(false); + expect(store.getSnapshot().mode).toBe('ask'); + let repaints = 0; + store.subscribe(() => (repaints += 1)); + store.setMode('accept-edits'); + expect(repaints).toBe(1); + expect(store.getSnapshot().mode).toBe('accept-edits'); + }); + + it('requestApproval publishes a pending approval, then resolves when answerApproval is called', async () => { + const store = createChatStore(false); + expect(store.getSnapshot().approval).toBeUndefined(); + const pending = store.requestApproval(approvalRequest, true); + // The prompt is now published for the REPL to render (with its cacheable hint). + expect(store.getSnapshot().approval).toEqual({ request: approvalRequest, cacheable: true }); + store.answerApproval({ outcome: 'approve', scope: 'always' }); + expect(await pending).toEqual({ outcome: 'approve', scope: 'always' }); + expect(store.getSnapshot().approval).toBeUndefined(); // cleared on settle + }); + + it('answerApproval with nothing pending is a no-op (a stray keypress)', () => { + const store = createChatStore(false); + expect(() => store.answerApproval({ outcome: 'approve', scope: 'once' })).not.toThrow(); + expect(store.getSnapshot().approval).toBeUndefined(); + }); + + it('an abort while an approval is pending REJECTS with an AbortError and clears the prompt (cancel, not deny)', async () => { + const store = createChatStore(false); + const ac = new AbortController(); + const pending = store.requestApproval(approvalRequest, false, ac.signal); + expect(store.getSnapshot().approval).toEqual({ request: approvalRequest, cacheable: false }); + ac.abort(); + const err = await pending.catch((e: unknown) => e); + expect(err).toBeInstanceOf(Error); + expect((err as Error).name).toBe('AbortError'); // classified as a CANCEL by the registry's isAbort + expect(store.getSnapshot().approval).toBeUndefined(); + }); + + it('an ALREADY-aborted signal rejects immediately and never publishes a prompt', async () => { + const store = createChatStore(false); + const ac = new AbortController(); + ac.abort(); + const err = await store + .requestApproval(approvalRequest, true, ac.signal) + .catch((e: unknown) => e); + expect((err as Error).name).toBe('AbortError'); + expect(store.getSnapshot().approval).toBeUndefined(); // no prompt was ever shown + }); +}); diff --git a/apps/cli/src/render/tui/chat-store.ts b/apps/cli/src/render/tui/chat-store.ts index 753eaaec..cac2159a 100644 --- a/apps/cli/src/render/tui/chat-store.ts +++ b/apps/cli/src/render/tui/chat-store.ts @@ -1,5 +1,11 @@ -import type { SessionStreamHandleEvent } from '@relavium/core'; +import type { SessionStreamHandleEvent, ToolApprovalRequest } from '@relavium/core'; +import { + DEFAULT_CHAT_MODE, + type ApprovalAnswer, + type ApprovalPrompt, + type ChatMode, +} from '../../chat/chat-mode.js'; import { formatSessionFooter, sanitizeInline, stripTerminalControls } from './chat-projection.js'; import { appendNotice, @@ -24,9 +30,20 @@ import { * rate is capped). */ +/** A pending per-tool approval the REPL renders as a `[a]/[r]/[c]` prompt (ADR-0057, EA3/EA5). */ +export interface PendingApproval { + readonly request: ToolApprovalRequest; + /** Whether an "always" answer will be remembered (accept-edits) — the prompt greys it out when false. */ + readonly cacheable: boolean; +} + /** The immutable snapshot the ink component reads each frame (a stable reference between flushes). */ export interface ChatStoreSnapshot { readonly state: SessionViewState; + /** The active chat mode (ADR-0057) — REPL-set (Shift+Tab / `/mode`), shown in the footer. */ + readonly mode: ChatMode; + /** The in-flight approval prompt, if a governed tool dispatch is awaiting the user's decision. */ + readonly approval: PendingApproval | undefined; readonly tick: number; readonly color: boolean; } @@ -54,6 +71,18 @@ export interface ChatStoreController extends ChatStore { flush: () => void; /** The persistent one-line session summary (model · cost · turns) for after-unmount output (Step-5 teardown). */ summaryText: () => string; + /** Set the active chat mode (Shift+Tab / `/mode`) — updates the footer; the caller also re-applies the turn + * policy via `applyChatMode`. Flushes immediately (a mode switch feels instant). */ + setMode: (mode: ChatMode) => void; + /** + * The {@link ApprovalPrompt} the mode controller injects: publish a pending approval (flush → the REPL + * renders the prompt) and RESOLVE when the input handler calls {@link answerApproval}. Honors the abort + * signal — an abort while pending REJECTS with the signal's reason so the dispatch routes to the engine's + * cancel path (not a denial). At most ONE approval is pending at a time (the turn blocks on it). + */ + requestApproval: ApprovalPrompt; + /** Answer the in-flight approval (the input handler's `[a]/[r]/[c]` decision) — a no-op if none is pending. */ + answerApproval: (answer: ApprovalAnswer) => void; } /** @@ -71,12 +100,16 @@ const HIGH_FREQUENCY_EVENTS: ReadonlySet = new export function createChatStore(color: boolean, seed?: SessionViewSeed): ChatStoreController { const listeners = new Set<() => void>(); let state = initialSessionViewState(seed); + let mode: ChatMode = DEFAULT_CHAT_MODE; + let approval: PendingApproval | undefined; + // The resolver for the in-flight approval promise (set while `approval` is published; cleared on settle). + let settleApproval: ((answer: ApprovalAnswer) => void) | undefined; let tickCount = 0; let dirty = false; - let snapshot: ChatStoreSnapshot = { state, tick: tickCount, color }; + let snapshot: ChatStoreSnapshot = { state, mode, approval, tick: tickCount, color }; const flush = (): void => { - snapshot = { state, tick: tickCount, color }; + snapshot = { state, mode, approval, tick: tickCount, color }; for (const listener of listeners) { listener(); } @@ -121,5 +154,51 @@ export function createChatStore(color: boolean, seed?: SessionViewSeed): ChatSto }, flush, summaryText: () => formatSessionFooter(state), + setMode: (next) => { + mode = next; + flush(); + }, + requestApproval: (request, cacheable, signal) => + new Promise((resolve, reject) => { + // Honor an already-aborted turn: reject with an AbortError so the dispatch routes to cancel, not deny. + if (signal?.aborted === true) { + reject(abortError()); + return; + } + const clear = (): void => { + approval = undefined; + settleApproval = undefined; + signal?.removeEventListener('abort', onAbort); + }; + const onAbort = (): void => { + // An abort while the prompt is pending is a CANCEL, not a denial — reject with an AbortError; the + // registry's confirmDispatch re-throws an abort (cancel precedence: ctx.signal.aborted OR an + // `AbortError`-named cause) rather than denying. + clear(); + reject(abortError()); + flush(); + }; + settleApproval = (answer) => { + clear(); + resolve(answer); + }; + approval = { request, cacheable }; + signal?.addEventListener('abort', onAbort); + flush(); // render the [a]/[r]/[c] prompt + }), + answerApproval: (answer) => { + // A no-op if nothing is pending (a stray keypress); else settle the in-flight promise + repaint. + if (settleApproval === undefined) return; + settleApproval(answer); + flush(); + }, }; } + +/** An `AbortError`-named error so a pending-approval rejection is classified as a CANCEL by the registry's + * `isAbort` (which also accepts `cause.name === 'AbortError'`), never as a denial. */ +function abortError(): Error { + const err = new Error('the approval was aborted'); + err.name = 'AbortError'; + return err; +} From 7c17a7b54807f152e24c069a8c77fc66960f2bc0 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Thu, 2 Jul 2026 08:22:12 +0300 Subject: [PATCH 19/45] =?UTF-8?q?feat(cli):=202.5.E=20Step=204b-ii-b=20?= =?UTF-8?q?=E2=80=94=20chat-input=20mode-cycle=20/=20abort=20/=20approval?= =?UTF-8?q?=20keys?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/cli/src/render/tui/chat-input.test.ts | 49 ++++++++++++++++++++++ apps/cli/src/render/tui/chat-input.ts | 39 ++++++++++++++--- 2 files changed, 82 insertions(+), 6 deletions(-) diff --git a/apps/cli/src/render/tui/chat-input.test.ts b/apps/cli/src/render/tui/chat-input.test.ts index c4c0ed7f..5837c931 100644 --- a/apps/cli/src/render/tui/chat-input.test.ts +++ b/apps/cli/src/render/tui/chat-input.test.ts @@ -47,6 +47,55 @@ describe('reduceChatKey', () => { expect(reduceChatKey('v', { meta: true }, 'h', false)).toEqual({ kind: 'none' }); // Meta-V expect(reduceChatKey('', KEY, 'h', false)).toEqual({ kind: 'none' }); // a bare modifier press }); + + it('maps Shift+Tab to cycle-mode (idle OR running — a mode change applies to the next turn)', () => { + expect(reduceChatKey('', { tab: true, shift: true }, 'h', false)).toEqual({ + kind: 'cycle-mode', + }); + expect(reduceChatKey('', { tab: true, shift: true }, 'h', true)).toEqual({ + kind: 'cycle-mode', + }); + // A plain Tab (no shift) is NOT a mode cycle. + expect(reduceChatKey('', { tab: true }, 'h', false)).toEqual({ kind: 'none' }); + }); + + it('maps Esc to a mid-turn abort ONLY while running (idle Esc is inert, not an abort)', () => { + expect(reduceChatKey('', { escape: true }, 'h', true)).toEqual({ kind: 'abort' }); + expect(reduceChatKey('', { escape: true }, 'h', false)).toEqual({ kind: 'none' }); // idle Esc: nothing to abort + }); +}); + +describe('reduceChatKey — approval-prompt intercept (in-flight key-swallow bypass, ADR-0057)', () => { + const PENDING = true; + it('maps [y]/1 to approve-once and [a]/2 to approve-always', () => { + expect(reduceChatKey('y', KEY, '', true, PENDING)).toEqual({ kind: 'approve', scope: 'once' }); + expect(reduceChatKey('1', KEY, '', true, PENDING)).toEqual({ kind: 'approve', scope: 'once' }); + expect(reduceChatKey('a', KEY, '', true, PENDING)).toEqual({ + kind: 'approve', + scope: 'always', + }); + expect(reduceChatKey('2', KEY, '', true, PENDING)).toEqual({ + kind: 'approve', + scope: 'always', + }); + }); + + it('maps [n]/[r]/3 to reject', () => { + expect(reduceChatKey('n', KEY, '', true, PENDING)).toEqual({ kind: 'reject' }); + expect(reduceChatKey('r', KEY, '', true, PENDING)).toEqual({ kind: 'reject' }); + expect(reduceChatKey('3', KEY, '', true, PENDING)).toEqual({ kind: 'reject' }); + }); + + it('maps Esc to abort (cancels the whole turn AND the pending approval)', () => { + expect(reduceChatKey('', { escape: true }, '', true, PENDING)).toEqual({ kind: 'abort' }); + }); + + it('SWALLOWS every other key while an approval is pending (no deadlock, no stray edit)', () => { + // Even Ctrl-C / Return / a printable are ignored during the approval — only y/a/n/1/2/3/Esc act. + expect(reduceChatKey('c', { ctrl: true }, '', true, PENDING)).toEqual({ kind: 'none' }); + expect(reduceChatKey('', { return: true }, '', true, PENDING)).toEqual({ kind: 'none' }); + expect(reduceChatKey('z', KEY, '', true, PENDING)).toEqual({ kind: 'none' }); + }); }); describe('applyChatEdit (the functional-updater body)', () => { diff --git a/apps/cli/src/render/tui/chat-input.ts b/apps/cli/src/render/tui/chat-input.ts index 652b1db7..f9432a9d 100644 --- a/apps/cli/src/render/tui/chat-input.ts +++ b/apps/cli/src/render/tui/chat-input.ts @@ -9,9 +9,12 @@ export interface ChatKey { readonly ctrl?: boolean; readonly meta?: boolean; + readonly shift?: boolean; readonly return?: boolean; readonly backspace?: boolean; readonly delete?: boolean; + readonly tab?: boolean; + readonly escape?: boolean; } /** @@ -26,22 +29,46 @@ export type ChatKeyAction = | { readonly kind: 'append'; readonly char: string } | { readonly kind: 'backspace' } | { readonly kind: 'submit'; readonly line: string } - | { readonly kind: 'cancel' }; + | { readonly kind: 'cancel' } + /** Shift+Tab — advance the chat mode (ask → plan → accept-edits → auto → ask), ADR-0057. */ + | { readonly kind: 'cycle-mode' } + /** Esc mid-turn — abort the in-flight turn but KEEP the session alive (EA7), distinct from `cancel`. */ + | { readonly kind: 'abort' } + /** An approval-prompt decision (accept-edits / auto's protected-path fallback): `[y]` once / `[a]` always. */ + | { readonly kind: 'approve'; readonly scope: 'once' | 'always' } + /** An approval-prompt rejection (`[n]`). */ + | { readonly kind: 'reject' }; /** - * Reduce one keystroke of the chat prompt to an action. Ctrl-C maps to `cancel` even mid-turn (so a streaming - * turn can always be interrupted); while a turn is `running` every OTHER key is ignored (one turn at a time); - * Return submits the current buffer (`input`); backspace/delete is a `backspace` op; a printable char (not a - * ctrl/meta chord) is an `append` op. The edit ops carry no buffer value — the caller folds them functionally, - * preserving the original `ChatApp` accumulating semantics across a batched multi-event chunk. + * Reduce one keystroke of the chat prompt to an action. + * + * When an approval is pending (`approvalPending`), the prompt OWNS the keyboard — the in-flight key-swallow + * bypass (ADR-0057, no deadlock): `[y]`/`1` approve once, `[a]`/`2` approve always, `[n]`/`3` reject, `Esc` + * aborts the whole turn; every other key is ignored. Otherwise: `Ctrl-C` maps to `cancel` even mid-turn (a + * streaming turn can always be interrupted); `Shift+Tab` cycles the mode (harmless mid-turn — it applies to + * the next turn); `Esc` while `running` is a mid-turn `abort` (EA7); while a turn is `running` every OTHER key + * is ignored (one turn at a time); `Return` submits the buffer; backspace/delete is a `backspace` op; a + * printable char (not a ctrl/meta chord) is an `append` op. The edit ops carry no buffer value — the caller + * folds them functionally, preserving the accumulating semantics across a batched multi-event chunk. */ export function reduceChatKey( char: string, key: ChatKey, input: string, running: boolean, + approvalPending = false, ): ChatKeyAction { + if (approvalPending) { + // The approval prompt intercept — bypasses the running-swallow so the prompt can never deadlock. + if (key.escape === true) return { kind: 'abort' }; // Esc cancels the whole turn (and this pending approval) + if (char === 'y' || char === '1') return { kind: 'approve', scope: 'once' }; + if (char === 'a' || char === '2') return { kind: 'approve', scope: 'always' }; + if (char === 'n' || char === 'r' || char === '3') return { kind: 'reject' }; + return { kind: 'none' }; // ignore any other key while an approval is pending + } if (key.ctrl === true && char === 'c') return { kind: 'cancel' }; + if (key.tab === true && key.shift === true) return { kind: 'cycle-mode' }; // Shift+Tab cycles the chat mode + if (key.escape === true && running) return { kind: 'abort' }; // mid-turn abort, keeps the session (EA7) if (running) return { kind: 'none' }; // one turn at a time — ignore typing while the assistant streams if (key.return === true) return { kind: 'submit', line: input }; if (key.backspace === true || key.delete === true) return { kind: 'backspace' }; From 4c2f7c06ad3ffac2b2e0357041ff8d86522e6568 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Thu, 2 Jul 2026 09:10:38 +0300 Subject: [PATCH 20/45] =?UTF-8?q?feat(cli):=202.5.E=20Step=204b-ii-c=20?= =?UTF-8?q?=E2=80=94=20wire=20chat=20modes=20+=20approval=20+=20abort=20li?= =?UTF-8?q?ve?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ` 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) --- apps/cli/src/chat/session-host.test.ts | 24 +++++-- apps/cli/src/chat/session-host.ts | 19 ++++-- apps/cli/src/commands/chat.ts | 53 +++++++++++++++ apps/cli/src/home/drive-home.tsx | 8 ++- apps/cli/src/render/tui/chat-ink.tsx | 68 ++++++++++++++++--- .../src/render/tui/chat-projection.test.ts | 34 ++++++++++ apps/cli/src/render/tui/chat-projection.ts | 21 ++++++ apps/cli/src/render/tui/home-app.tsx | 4 +- apps/cli/src/render/tui/home-controller.ts | 5 ++ 9 files changed, 214 insertions(+), 22 deletions(-) diff --git a/apps/cli/src/chat/session-host.test.ts b/apps/cli/src/chat/session-host.test.ts index 74a0a7e2..3f790204 100644 --- a/apps/cli/src/chat/session-host.test.ts +++ b/apps/cli/src/chat/session-host.test.ts @@ -19,6 +19,7 @@ import type { LlmProvider, LlmRequest, StreamChunk } from '@relavium/llm'; import { CHAT_TEXT_CAPABILITY_FLAGS } from '../test-support.js'; import type { ProviderResolver } from '../engine/providers.js'; +import { applyChatMode, makeChatModeEnv } from './chat-mode-host.js'; import { buildDefaultChatAgent } from './default-agent.js'; import { buildChatSession, @@ -764,7 +765,7 @@ describe('buildChatSession + 2.5.A tool-host wiring (ADR-0055)', () => { expect(errors).toEqual([]); }); - it('write_file in a chat session fail-closes as tool_unavailable (chat is read-only until 2.5.E)', async () => { + it('write_file in a chat session is DENIED by the ask-mode approval floor (2.5.E) — no file on disk', async () => { const workspace = mkdtempSync(join(tmpdir(), 'relavium-ws-')); const built = await build({ cwd: workspace, @@ -773,6 +774,17 @@ describe('buildChatSession + 2.5.A tool-host wiring (ADR-0055)', () => { callWithArgs('c1', 'write_file', { path: 'x.txt', content: 'pwned' }), ]), }); + // The chat host is now WRITE-capable (chat-read-write); safety rests on the mode's fail-closed approval + // regime the REPL activates before any turn. Apply the default `ask` mode here so this reflects production: + // `ask` denies every governed dispatch (two-layer — the advertise-filter also hides write_file, but the + // scripted model calls it anyway, so the confirm floor is what denies it). + const env = makeChatModeEnv({ + session: built.session, + tools: built.tools, + workspaceDir: workspace, + prompt: () => Promise.resolve({ outcome: 'reject' }), + }); + applyChatMode(env, 'ask'); built.session.start(); await built.session.sendMessage('write a file'); built.session.cancel(); @@ -780,17 +792,17 @@ describe('buildChatSession + 2.5.A tool-host wiring (ADR-0055)', () => { const completed = events.find((e) => e.type === 'session:turn_completed'); expect(completed?.type === 'session:turn_completed' ? completed.error?.code : undefined).toBe( - 'tool_unavailable', // the read-only fs writeFile fail-closed (EA1) + 'tool_denied', // the ask-mode confirm floor denied the governed write (ADR-0057 EA3) ); - expect(existsSync(join(workspace, 'x.txt'))).toBe(false); // it fail-closed BEFORE any write — no file on disk + expect(existsSync(join(workspace, 'x.txt'))).toBe(false); // denied BEFORE any write — no file on disk }); - it('the advertise-filter drops an unwired tool from the EFFECTIVE grant; the original keeps it', async () => { + it('the advertise-filter keeps http_request now that egress is wired (chat-read-write, 2.5.E)', async () => { const workspace = mkdtempSync(join(tmpdir(), 'relavium-ws-')); const { providers, requests } = capturingResolver([textTurn('hi')]); const built = await build({ cwd: workspace, - agentRef: writeAgent(['read_file', 'http_request']), // http_request needs egress — NOT wired in 2.5.A + agentRef: writeAgent(['read_file', 'http_request']), // egress IS wired in the full-capability chat host providers, }); built.session.start(); @@ -800,7 +812,7 @@ describe('buildChatSession + 2.5.A tool-host wiring (ADR-0055)', () => { const advertised = (requests[0]?.tools ?? []).map((t) => t.name); expect(advertised).toContain('read_file'); // fs wired ⇒ advertised - expect(advertised).not.toContain('http_request'); // egress unwired ⇒ NOT advertised + expect(advertised).toContain('http_request'); // egress wired now ⇒ advertised (was dropped in 2.5.A) expect(built.agent.tools).toEqual(['read_file', 'http_request']); // the ORIGINAL keeps the author's grant }); }); diff --git a/apps/cli/src/chat/session-host.ts b/apps/cli/src/chat/session-host.ts index d94afc63..a0c156fa 100644 --- a/apps/cli/src/chat/session-host.ts +++ b/apps/cli/src/chat/session-host.ts @@ -117,6 +117,12 @@ export interface BuiltChatSession { readonly agent: AgentDefinition; /** The frozen session context (working dir + fs-scope tier) the session ran against. */ readonly context: SessionContext; + /** + * The EFFECTIVE granted tool defs the session runs with (built-ins + discovered MCP) — the REPL derives the + * chat-mode governed hide-set from these ([chat-mode-host.ts](chat-mode-host.ts), ADR-0057). Exposed here so + * the fresh + resumed paths both build the mode environment from the SAME def set the registry dispatches. + */ + readonly tools: readonly ToolDef[]; /** * Push a SURFACE-originated session event onto the same per-session bus (so it shares the monotonic * `sequenceNumber` of the live stream). Used by the in-REPL `/export` to emit `session:exported` under @@ -165,12 +171,13 @@ function buildSessionRuntime( const bus = new RunEventBus({ now: () => new Date(opts.now()).toISOString() }); const providers = opts.providers ?? createProviderResolver(); const tools = mcp === undefined ? BUILTIN_TOOLS : [...BUILTIN_TOOLS, ...mcp.toolDefs]; - // 2.5.A (ADR-0055): the shared factory wires the READ-ONLY chat host (fs read+list, process for the - // pre-approved git_status) jailed to the session's fs-scope tier AND the chat-default `ToolPolicy`. Building - // it is pure construction (no I/O), so we always assemble it for the policy even when a test injects its own - // `toolHost` (e.g. a fail-closed `{}` for a capability-gap assertion); production also takes its host. + // 2.5.E (ADR-0057): the shared factory wires the FULL-CAPABILITY chat host (fs read+WRITE, process, egress, + // os) jailed to the session's fs-scope tier. Safety rests on the mode's per-tool APPROVAL floor, not on + // capability absence — the REPL activates the fail-closed `confirmAction` regime via `applyChatMode` (default + // `ask` denies every governed action). Building it is pure (no I/O), so we always assemble it for the policy + // even when a test injects its own `toolHost` (e.g. a fail-closed `{}` for a capability-gap assertion). const factoryEnv = assembleToolEnv({ - profile: 'chat-read-only', + profile: 'chat-read-write', fsScopeTier: context.fsScopeTier, workspaceDir: context.workingDir, }); @@ -255,6 +262,7 @@ export async function buildChatSession(opts: BuildChatSessionOptions): Promise mcp.close() }), @@ -400,6 +408,7 @@ export async function buildResumedChatSession( sessionId: record.id, agent, context, + tools: deps.tools, emitSessionEvent: emit, resumeState, nextSequenceNumber, diff --git a/apps/cli/src/commands/chat.ts b/apps/cli/src/commands/chat.ts index a0fdd2f8..1a53db5c 100644 --- a/apps/cli/src/commands/chat.ts +++ b/apps/cli/src/commands/chat.ts @@ -17,6 +17,8 @@ import { REPL_COMMANDS_BY_NAME, type ReplCommandContext, } from './repl-commands.js'; +import { type ChatMode } from '../chat/chat-mode.js'; +import { applyChatMode, makeChatModeEnv } from '../chat/chat-mode-host.js'; import { createSessionPersister, type SessionPersister } from '../chat/persister.js'; import { buildChatSession, @@ -108,6 +110,17 @@ export interface ChatDriveContext { * before `process.exit`. Absent ⇒ nothing to force-close. */ readonly onForceExit?: () => Promise; + /** + * Mid-turn abort (EA7, ADR-0057) — abort the in-flight turn but KEEP the session alive (distinct from + * `/cancel`, which is terminal). The ink driver wires `Esc` to this; a plain/JSON driver ignores it. + */ + readonly onAbort?: () => void; + /** + * Switch the chat mode (ADR-0057) — updates the footer + re-applies the turn policy (advertise-filter + + * fail-closed approval regime) on the SAME session instance (no reseat). The ink driver wires `Shift+Tab` + * (cycle) + the `/mode` command to this. Absent on a driver that has no mode UI (the mode stays the default). + */ + readonly onModeChange?: (mode: ChatMode) => void; } export type ChatDriver = (ctx: ChatDriveContext) => Promise; @@ -381,6 +394,42 @@ interface ReplWiring { * loop), and on teardown emit the session's sole terminal (`session:cancelled`, idempotent) + close the * persister and the db. `/exit`, `/cancel`, and an input-stream EOF all end the session with **exit code 4**. */ +/** The mode/abort control surface a driver wires to its keys + the `/mode` command (ADR-0057). */ +export interface ChatModeControl { + /** Mid-turn abort (EA7) — abort the in-flight turn, keeping the session alive. */ + readonly onAbort: () => void; + /** Switch the chat mode: update the footer + re-apply the turn policy on the same session (no reseat). */ + readonly onModeChange: (mode: ChatMode) => void; +} + +/** + * Wire the reseat-less chat mode system (ADR-0057) for a built session — used by BOTH the `chat`/`chat-resume` + * REPL and the 2.5.B Home's in-process chat, so the full-capability host is NEVER live without the fail-closed + * approval regime. It builds the session-scoped mode env (the once/always cache, the governed hide-set from the + * effective tool defs, the workspace-anchored protected-path check; the interactive prompt IS the store's + * `requestApproval`) and **applies the initial mode immediately** — so the regime is active from the first turn + * (default `ask` denies every governed action). The returned `onModeChange` re-applies on a Shift+Tab / `/mode`. + */ +export function createChatModeControl( + built: Pick, + store: ChatStoreController, +): ChatModeControl { + const modeEnv = makeChatModeEnv({ + session: built.session, + tools: built.tools, + workspaceDir: built.context.workingDir, + prompt: store.requestApproval, + }); + applyChatMode(modeEnv, store.getSnapshot().mode); + return { + onAbort: () => built.session.abort(), + onModeChange: (mode) => { + store.setMode(mode); + applyChatMode(modeEnv, mode); + }, + }; +} + /** The slash-aware line handler + the session's cancel/stop state. */ export interface ChatLineHandler { /** Handle one line (a slash command or a message); awaits the turn for a message. */ @@ -556,6 +605,8 @@ async function runReplLoop(wiring: ReplWiring, deps: ChatReplDeps): Promise { { built, opened, store, persister, doctorProbes: chatDoctorProbes }, deps, ); + // ADR-0057: activate the reseat-less mode system (applies the initial `ask` mode → the fail-closed + // approval regime) BEFORE the session opens, so the full-capability chat host is never live without the + // per-tool approval floor — the SAME guarantee the `chat` command's runReplLoop provides. + const { onAbort, onModeChange } = createChatModeControl(built, store); // Subscribe the view store BEFORE opening the session so the synchronous session:started is observed. unsubscribe = built.handle.subscribe((event) => store.apply(event)); frame = setInterval(() => store.tick(), FRAME_MS); @@ -200,7 +204,7 @@ export async function driveHome(deps: HomeDeps): Promise { await built.closeMcp?.().catch(() => undefined); // best-effort; never orphan a spawned stdio child } }; - return { store, processLine, shouldStop, teardown }; + return { store, processLine, shouldStop, teardown, onAbort, onModeChange }; } catch (err) { clearInterval(frame); // reclaim whatever the wiring managed to acquire before the throw unsubscribe?.(); diff --git a/apps/cli/src/render/tui/chat-ink.tsx b/apps/cli/src/render/tui/chat-ink.tsx index 14c2d4af..d60bc603 100644 --- a/apps/cli/src/render/tui/chat-ink.tsx +++ b/apps/cli/src/render/tui/chat-ink.tsx @@ -21,13 +21,15 @@ import { } from './palette-reducer.js'; import { spinnerFrame } from './format.js'; import { - formatSessionFooter, + formatApprovalTarget, + formatSessionFooterWithMode, formatToolCall, formatTurnSummary, sanitizeInline, stripTerminalControls, } from './chat-projection.js'; -import type { ChatStoreController } from './chat-store.js'; +import { nextMode, type ChatMode } from '../../chat/chat-mode.js'; +import type { ChatStoreController, PendingApproval } from './chat-store.js'; import type { SessionViewState, TranscriptEntry } from './session-view-model.js'; /** @@ -77,6 +79,10 @@ interface ChatAppProps { readonly onExit: () => void; /** Called when a turn rejects UNEXPECTEDLY (a re-thrown turn-core bug) — the driver tears down + propagates. */ readonly onError: (err: unknown) => void; + /** Mid-turn abort (EA7) — Esc aborts the in-flight turn, keeping the session alive. */ + readonly onAbort: () => void; + /** Switch the chat mode (Shift+Tab cycle) — re-applies the turn policy on the same session (ADR-0057). */ + readonly onModeChange: (mode: ChatMode) => void; } interface ChatViewProps { @@ -86,6 +92,10 @@ interface ChatViewProps { /** The current prompt buffer (owned by the input owner — `ChatApp` or the Home's `RootApp`). */ readonly input: string; readonly running: boolean; + /** The active chat mode (ADR-0057) — shown in the footer so `auto` is never a hidden state. */ + readonly mode: ChatMode; + /** An in-flight per-tool approval — when set, the `[y]/[a]/[n]` prompt replaces the idle prompt. */ + readonly approval?: PendingApproval | undefined; /** When the `/` palette is open it owns the bottom of the view, so the idle prompt + footer are suppressed (2.5.C S3b). */ readonly paletteOpen?: boolean; } @@ -98,7 +108,7 @@ interface ChatViewProps { * sequence cannot corrupt the terminal or inject ANSI/OSC. */ export function ChatView(props: Readonly): ReactElement { - const { state, tick, color, input, running, paletteOpen } = props; + const { state, tick, color, input, running, mode, approval, paletteOpen } = props; // When the palette is open it renders its own query line + hint below, so suppress the idle prompt + footer to // avoid two competing prompts (the palette owns the input focus until it closes). const showIdlePrompt = !running && paletteOpen !== true; @@ -156,13 +166,34 @@ export function ChatView(props: Readonly): ReactElement { {state.warnings.map((w) => `⚠ ${stripTerminalControls(w)}`).join('\n')} )} - {formatSessionFooter(state)} + + {/* The per-tool approval prompt (ADR-0057) — shown mid-turn when a governed dispatch awaits consent. It + OWNS the keyboard via the reduceChatKey approval-intercept (no deadlock): [y] once, [a] always (only + when the answer is cacheable — accept-edits, not auto's protected-path fallback), [n] no, Esc aborts. */} + {approval !== undefined && ( + + + {`Approve ${sanitizeInline(approval.request.toolId)}${ + formatApprovalTarget(approval.request).length > 0 + ? ` → ${formatApprovalTarget(approval.request)}` + : '' + }?`} + + + {approval.cacheable + ? '[y] yes [a] always [n] no [esc] abort' + : '[y] yes [n] no [esc] abort'} + + + )} + + {formatSessionFooterWithMode(state, mode)} ); } export function ChatApp(props: Readonly): ReactElement { - const { state, tick, color } = useSyncExternalStore( + const { state, tick, color, mode, approval } = useSyncExternalStore( props.store.subscribe, props.store.getSnapshot, ); @@ -232,12 +263,14 @@ export function ChatApp(props: Readonly): ReactElement { applyPalette(step.state); return; } - // Open the palette on a literal '/' at an idle, EMPTY prompt — the discovery entry point. - if (shouldOpenPalette(char, key, isRunning, inputRef.current.length)) { + // A pending approval OWNS the keyboard (never opens the palette) — the reduceChatKey approval-intercept. + const approvalPending = props.store.getSnapshot().approval !== undefined; + // Open the palette on a literal '/' at an idle, EMPTY prompt — the discovery entry point (never mid-approval). + if (!approvalPending && shouldOpenPalette(char, key, isRunning, inputRef.current.length)) { applyPalette(INITIAL_PALETTE_STATE); return; } - const action = reduceChatKey(char, key, inputRef.current, isRunning); + const action = reduceChatKey(char, key, inputRef.current, isRunning, approvalPending); switch (action.kind) { case 'cancel': if (!cancelFired.current) { @@ -253,6 +286,19 @@ export function ChatApp(props: Readonly): ReactElement { applyInput(() => ''); submit(action.line); return; + case 'cycle-mode': + // Shift+Tab: advance the mode (read fresh from the store, not the render closure) + re-apply the policy. + props.onModeChange(nextMode(props.store.getSnapshot().mode)); + return; + case 'abort': + props.onAbort(); // Esc — mid-turn abort (keeps the session; distinct from Ctrl-C /cancel) + return; + case 'approve': + props.store.answerApproval({ outcome: 'approve', scope: action.scope }); + return; + case 'reject': + props.store.answerApproval({ outcome: 'reject' }); + return; case 'none': return; } @@ -266,6 +312,8 @@ export function ChatApp(props: Readonly): ReactElement { color={color} input={input} running={running} + mode={mode} + approval={approval} paletteOpen={palette !== undefined} /> {palette !== undefined && ( @@ -348,6 +396,10 @@ export function driveInk(ctx: ChatDriveContext): Promise { // An unexpected turn-core throw rejects `exited` → the finally tears down + the rejection propagates out // of the command (mapped to exit 1), matching the plain driver where the throw escapes the for-await loop. onError: (err) => rejectExit(err), + // ADR-0057 mode/abort wiring — the REPL loop always supplies these; default to a no-op so a driver + // wired without them (a test harness) degrades to a fixed mode rather than crashing on a keypress. + onAbort: ctx.onAbort ?? ((): void => undefined), + onModeChange: ctx.onModeChange ?? ((): void => undefined), }), { // OUR /cancel (Ctrl-C) handler drives the cooperative cancel — never ink's process.exit. diff --git a/apps/cli/src/render/tui/chat-projection.test.ts b/apps/cli/src/render/tui/chat-projection.test.ts index 54a582a7..fd475b4f 100644 --- a/apps/cli/src/render/tui/chat-projection.test.ts +++ b/apps/cli/src/render/tui/chat-projection.test.ts @@ -1,7 +1,11 @@ import { describe, expect, it } from 'vitest'; +import type { ToolApprovalRequest } from '@relavium/core'; + import { + formatApprovalTarget, formatSessionFooter, + formatSessionFooterWithMode, formatToolCall, formatTurnSummary, stripTerminalControls, @@ -110,6 +114,36 @@ describe('chat-projection', () => { }); }); + describe('formatSessionFooterWithMode', () => { + it('appends the active mode label to the footer (always shown — auto is never hidden)', () => { + const state = { ...initialSessionViewState(), turnCount: 2 }; + expect(formatSessionFooterWithMode(state, 'ask')).toMatch(/· ask mode$/); + expect(formatSessionFooterWithMode(state, 'accept-edits')).toMatch(/· accept edits mode$/); + expect(formatSessionFooterWithMode(state, 'auto')).toMatch(/· auto mode$/); + }); + }); + + describe('formatApprovalTarget', () => { + const req = (preview: ToolApprovalRequest['preview']): ToolApprovalRequest => ({ + toolId: 'write_file', + action: 'fs_write', + preview, + }); + it('surfaces the resolved path / command / host from the preview', () => { + expect(formatApprovalTarget(req({ path: 'notes.md' }))).toBe('notes.md'); + expect(formatApprovalTarget(req({ command: 'git commit' }))).toBe('git commit'); + expect(formatApprovalTarget(req({ host: 'example.com' }))).toBe('example.com'); + }); + it('is empty when the preview carries no target (web_search / mcp_call)', () => { + expect(formatApprovalTarget(req({}))).toBe(''); + }); + it('sanitizes the target so a preview value cannot inject control sequences', () => { + const target = formatApprovalTarget(req({ path: '\x1b[31mx\x07\nname' })); + // eslint-disable-next-line no-control-regex -- asserting the ABSENCE of control bytes + expect(target).not.toMatch(/[\x00-\x1f\x7f]/); + }); + }); + describe('stripTerminalControls', () => { it('removes ANSI CSI + OSC escapes and bare control bytes, keeping printable text + tab/newline', () => { // OSC title-write, CSI color, a CR, a NUL — all stripped; the real text + \n + \t survive. diff --git a/apps/cli/src/render/tui/chat-projection.ts b/apps/cli/src/render/tui/chat-projection.ts index 8d976c98..a4525994 100644 --- a/apps/cli/src/render/tui/chat-projection.ts +++ b/apps/cli/src/render/tui/chat-projection.ts @@ -1,3 +1,6 @@ +import type { ToolApprovalRequest } from '@relavium/core'; + +import { MODE_LABEL, type ChatMode } from '../../chat/chat-mode.js'; import { formatCostUsd, formatDuration, formatTokens } from './format.js'; import type { SessionViewState, ToolCallView, TurnSummary } from './session-view-model.js'; @@ -82,3 +85,21 @@ export function formatSessionFooter(state: SessionViewState): string { ].filter((part): part is string => part !== undefined); return parts.join(' · '); } + +/** The footer including the active chat mode (ADR-0057) — the mode is always shown so `auto` is never hidden. */ +export function formatSessionFooterWithMode(state: SessionViewState, mode: ChatMode): string { + const base = formatSessionFooter(state); + const modePart = `${MODE_LABEL[mode]} mode`; + return base.length > 0 ? `${base} · ${modePart}` : modePart; +} + +/** + * The secret-free target line for an approval prompt — the resolved path / command / host from the preview + * (the registry already stripped any secret / query string). Sanitized for display; empty when the action + * class carries no pre-dispatch target (e.g. `web_search` / `mcp_call`, where the action class alone is shown). + */ +export function formatApprovalTarget(request: ToolApprovalRequest): string { + const { path, command, host } = request.preview; + const target = path ?? command ?? host ?? ''; + return target.length > 0 ? sanitizeInline(target) : ''; +} diff --git a/apps/cli/src/render/tui/home-app.tsx b/apps/cli/src/render/tui/home-app.tsx index bf43a363..e4bbb87f 100644 --- a/apps/cli/src/render/tui/home-app.tsx +++ b/apps/cli/src/render/tui/home-app.tsx @@ -36,7 +36,7 @@ export interface RootAppProps { function ChatRegion( props: Readonly<{ store: ChatStoreController; input: string; palette: PaletteState | undefined }>, ): ReactElement { - const { state, tick, color } = useSyncExternalStore( + const { state, tick, color, mode, approval } = useSyncExternalStore( props.store.subscribe, props.store.getSnapshot, ); @@ -48,6 +48,8 @@ function ChatRegion( color={color} input={props.input} running={state.status === 'running'} + mode={mode} + approval={approval} paletteOpen={props.palette !== undefined} /> {props.palette !== undefined && ( diff --git a/apps/cli/src/render/tui/home-controller.ts b/apps/cli/src/render/tui/home-controller.ts index a1e28111..9f9ecd3a 100644 --- a/apps/cli/src/render/tui/home-controller.ts +++ b/apps/cli/src/render/tui/home-controller.ts @@ -3,6 +3,7 @@ import { HOME_PALETTE_COMMANDS, type ReplCommandContext, } from '../../commands/repl-commands.js'; +import { type ChatMode } from '../../chat/chat-mode.js'; import { formatDoctorReport, runDoctorChecks, type DoctorProbes } from '../../chat/doctor.js'; import type { HomeSnapshot, HomeStore } from '../../home/home-store.js'; import { applyChatEdit, dropLastCodePoint, reduceChatKey, type ChatKey } from './chat-input.js'; @@ -38,6 +39,10 @@ export interface HomeChatSession { readonly processLine: (line: string) => Promise; /** `true` once `/exit` or `/cancel` has run — the chat ends and the Home returns. */ readonly shouldStop: () => boolean; + /** Mid-turn abort (EA7) — abort the in-flight turn, keeping the session alive (Esc). Present once wired. */ + readonly onAbort?: () => void; + /** Switch the chat mode (Shift+Tab / `/mode`) — re-applies the turn policy on the same session (ADR-0057). */ + readonly onModeChange?: (mode: ChatMode) => void; /** Best-effort, IDEMPOTENT teardown of THIS chat (persister + frame loop + subscription + MCP), never the shared db. */ readonly teardown: () => Promise; } From de120b12d42a344d9488bddc96ae5dc9ca08388a Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Thu, 2 Jul 2026 09:24:29 +0300 Subject: [PATCH 21/45] =?UTF-8?q?feat(cli):=202.5.E=20Step=204b-ii-d=20?= =?UTF-8?q?=E2=80=94=20/mode=20command=20+=20Home=20mode/approval=20keys?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ` 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) --- apps/cli/src/commands/chat.test.ts | 19 +++++++ apps/cli/src/commands/chat.ts | 57 +++++++++++++++++---- apps/cli/src/commands/repl-commands.test.ts | 26 ++++++++-- apps/cli/src/commands/repl-commands.ts | 23 +++++++++ apps/cli/src/home/drive-home.tsx | 18 +++---- apps/cli/src/render/tui/home-controller.ts | 23 +++++++-- 6 files changed, 140 insertions(+), 26 deletions(-) diff --git a/apps/cli/src/commands/chat.test.ts b/apps/cli/src/commands/chat.test.ts index da40eeeb..e1ec98b2 100644 --- a/apps/cli/src/commands/chat.test.ts +++ b/apps/cli/src/commands/chat.test.ts @@ -271,6 +271,25 @@ describe('chatCommand', () => { expect(err()).toContain("/doctor: unknown argument '--bogus'"); }); + it('/mode switches the mode; a bare /mode shows the current mode + options (ADR-0057)', async () => { + const { d, err, store, sessionId } = deps( + ['/mode', '/mode auto', 'hello', '/exit'], + [textTurn('hi')], + ); + await chatCommand({ agent: undefined }, d); + const out = err(); + expect(out).toContain('mode: ask'); // the bare /mode shows the default (ask) + the options + expect(out).toContain('mode: auto'); // /mode auto applied + // /mode is read-only: the session continued and the 'hello' turn persisted (user + assistant = 2). + expect(store.loadFull(sessionId)?.messages).toHaveLength(2); + }); + + it('rejects an invalid /mode value at the dispatch (a positional not in the mode set)', async () => { + const { d, err } = deps(['/mode bogus', '/exit'], [textTurn('hi')]); + await chatCommand({ agent: undefined }, d); + expect(err()).toContain("/mode: unknown argument 'bogus'"); // the positional-value validation rejects it + }); + it('/workflows reports a project-less cwd without crashing the REPL', async () => { const { d, err, store, sessionId } = deps(['/workflows', 'hello', '/exit'], [textTurn('hi')]); await chatCommand({ agent: undefined }, d); // the test cwd is a fresh temp dir ⇒ no .relavium/ project diff --git a/apps/cli/src/commands/chat.ts b/apps/cli/src/commands/chat.ts index 1a53db5c..83c6f63e 100644 --- a/apps/cli/src/commands/chat.ts +++ b/apps/cli/src/commands/chat.ts @@ -17,7 +17,7 @@ import { REPL_COMMANDS_BY_NAME, type ReplCommandContext, } from './repl-commands.js'; -import { type ChatMode } from '../chat/chat-mode.js'; +import { CHAT_MODES, MODE_LABEL, parseMode, type ChatMode } from '../chat/chat-mode.js'; import { applyChatMode, makeChatModeEnv } from '../chat/chat-mode-host.js'; import { createSessionPersister, type SessionPersister } from '../chat/persister.js'; import { @@ -430,8 +430,8 @@ export function createChatModeControl( }; } -/** The slash-aware line handler + the session's cancel/stop state. */ -export interface ChatLineHandler { +/** The slash-aware line handler + the session's cancel/stop state + the mode/abort control (ADR-0057). */ +export interface ChatLineHandler extends ChatModeControl { /** Handle one line (a slash command or a message); awaits the turn for a message. */ readonly processLine: (raw: string) => Promise; /** Emit the session's sole terminal (`session:cancelled`, idempotent) — the teardown caller fires it. */ @@ -459,6 +459,10 @@ export function createChatLineHandler( built.session.cancel(); // the session's sole terminal (session:cancelled) — persister marks it 'ended' } }; + // The reseat-less mode system (ADR-0057) — created HERE so both the `/mode` command (below) and the driver's + // keys (Shift+Tab / Esc) drive the SAME control. It applies the initial `ask` mode immediately, so the + // full-capability host is never live without the fail-closed approval regime (default `ask` denies governed). + const modeControl = createChatModeControl(built, store); // Surface command output (the /help list, /workflows catalog, /cost line) the right way for the active surface: // the live ink view (TTY, non-`--json`) renders a NOTICE cleanly in-conversation; on the plain (non-TTY) and @@ -561,6 +565,25 @@ export function createChatLineHandler( emitOutput('doctor: check failed'); } }, + // `/mode [name]` (ADR-0057): switch the chat mode, or (bare `/mode`) show the current mode + the options. + // The dispatch already validated `modeArg` against the mode names, so a non-empty arg parses; still + // fail-soft. Applying re-pushes the turn policy on the SAME session (no reseat), effective next turn. + setMode: (modeArg) => { + const requested = modeArg.trim(); + if (requested.length === 0) { + emitOutput( + `mode: ${MODE_LABEL[store.getSnapshot().mode]} (options: ${CHAT_MODES.join(', ')})`, + ); + return; + } + const mode = parseMode(requested); + if (mode === undefined) { + emitOutput(`/mode: unknown mode '${requested.replace(/[^\x20-\x7e]/g, '?').slice(0, 16)}'`); + return; + } + modeControl.onModeChange(mode); + emitOutput(`mode: ${MODE_LABEL[mode]}`); + }, }; const processLine = async (raw: string): Promise => { @@ -574,9 +597,14 @@ export function createChatLineHandler( const command = name !== undefined && name.length > 0 ? REPL_COMMANDS_BY_NAME.get(name) : undefined; if (command !== undefined) { - // Reject a token the command does not declare (a zero-arg command rejects ANY token). Echo it SANITIZED - // through the notice channel — interactive errors belong in-view (ink), not on stderr behind the live view. - const allowed = new Set((command.args ?? []).map((arg) => arg.flag)); + // Reject a token the command does not declare (a zero-arg command rejects ANY token). A command may also + // declare a single POSITIONAL value set (e.g. `/mode plan`), whose values join the allowed set — so an + // invalid mode is rejected HERE, before run. Echo a bad token SANITIZED through the notice channel — + // interactive errors belong in-view (ink), not on stderr behind the live view. + const allowed = new Set([ + ...(command.args ?? []).map((arg) => arg.flag), + ...(command.positional?.values ?? []), + ]); const bad = tokens.find((token) => !allowed.has(token)); if (bad !== undefined) { emitOutput( @@ -598,14 +626,23 @@ export function createChatLineHandler( await built.session.sendMessage(line); }; - return { processLine, cancelOnce, shouldStop: () => stop }; + return { + processLine, + cancelOnce, + shouldStop: () => stop, + onAbort: modeControl.onAbort, + onModeChange: modeControl.onModeChange, + }; } async function runReplLoop(wiring: ReplWiring, deps: ChatReplDeps): Promise { const { built, opened, store, persister, startSession, intro } = wiring; - const { processLine, cancelOnce, shouldStop } = createChatLineHandler(wiring, deps); - - const { onAbort, onModeChange } = createChatModeControl(built, store); + // createChatLineHandler owns the mode control (so `/mode` + the driver keys drive one control) — it applies + // the initial mode, activating the fail-closed approval regime before the first turn. + const { processLine, cancelOnce, shouldStop, onAbort, onModeChange } = createChatLineHandler( + wiring, + deps, + ); // persister.start() subscribes for the turn events + adopts/inserts the session row; it does NOT consume // session:started, so it is safe before the driver. The session-open action (fresh start() / resume no-op) diff --git a/apps/cli/src/commands/repl-commands.test.ts b/apps/cli/src/commands/repl-commands.test.ts index 1bb9ea7f..f7c0a4e0 100644 --- a/apps/cli/src/commands/repl-commands.test.ts +++ b/apps/cli/src/commands/repl-commands.test.ts @@ -19,6 +19,7 @@ interface CapabilityCalls { readonly showWorkflows: number; readonly showCost: number; readonly runDoctor: number; + readonly setMode: number; } /** A fully-spied REPL context — each capability is a spy so a command's `run` can be asserted to call exactly one. */ @@ -31,6 +32,7 @@ function spyContext(): { ctx: ReplCommandContext; calls: () => CapabilityCalls } showWorkflows: vi.fn(), showCost: vi.fn(), runDoctor: vi.fn(), + setMode: vi.fn(), }; return { ctx: spies, @@ -41,6 +43,7 @@ function spyContext(): { ctx: ReplCommandContext; calls: () => CapabilityCalls } help: spies.help.mock.calls.length, showWorkflows: spies.showWorkflows.mock.calls.length, showCost: spies.showCost.mock.calls.length, + setMode: spies.setMode.mock.calls.length, runDoctor: spies.runDoctor.mock.calls.length, }), }; @@ -51,7 +54,16 @@ describe('curated REPL command registry (ADR-0056 amendment)', () => { const names = REPL_COMMANDS.map((command) => command.name); expect(new Set(names).size).toBe(names.length); expect(REPL_COMMANDS_BY_NAME.size).toBe(names.length); - expect(names).toEqual(['help', 'exit', 'cancel', 'export', 'workflows', 'cost', 'doctor']); + expect(names).toEqual([ + 'help', + 'exit', + 'cancel', + 'export', + 'workflows', + 'cost', + 'doctor', + 'mode', + ]); }); it('each command run() invokes EXACTLY its one capability', async () => { @@ -63,6 +75,7 @@ describe('curated REPL command registry (ADR-0056 amendment)', () => { ['workflows', 'showWorkflows'], ['cost', 'showCost'], ['doctor', 'runDoctor'], + ['mode', 'setMode'], ]; for (const [name, capability] of cases) { const { ctx, calls } = spyContext(); @@ -76,7 +89,8 @@ describe('curated REPL command registry (ADR-0056 amendment)', () => { counts.help + counts.showWorkflows + counts.showCost + - counts.runDoctor; + counts.runDoctor + + counts.setMode; expect(total, `${name} calls exactly one capability`).toBe(1); } }); @@ -91,7 +105,9 @@ describe('curated REPL command registry (ADR-0056 amendment)', () => { }); it('replCommandList renders the slash hint, formatReplHelp lists every command', () => { - expect(replCommandList()).toBe('/help, /exit, /cancel, /export, /workflows, /cost, /doctor'); + expect(replCommandList()).toBe( + '/help, /exit, /cancel, /export, /workflows, /cost, /doctor, /mode', + ); const help = formatReplHelp(); for (const command of REPL_COMMANDS) { expect(help).toContain(`/${command.name}`); @@ -101,7 +117,7 @@ describe('curated REPL command registry (ADR-0056 amendment)', () => { it('effects are sound: export writes, the rest are read', () => { expect(REPL_COMMANDS_BY_NAME.get('export')?.effect).toBe('write'); - for (const name of ['help', 'exit', 'cancel', 'workflows', 'cost', 'doctor']) { + for (const name of ['help', 'exit', 'cancel', 'workflows', 'cost', 'doctor', 'mode']) { expect(REPL_COMMANDS_BY_NAME.get(name)?.effect).toBe('read'); } }); @@ -118,6 +134,7 @@ describe('curated REPL command registry (ADR-0056 amendment)', () => { 'workflows', 'cost', 'doctor', + 'mode', ]); expect(CHAT_PALETTE_COMMANDS.map((c) => c.name)).toEqual([ 'exit', @@ -126,6 +143,7 @@ describe('curated REPL command registry (ADR-0056 amendment)', () => { 'workflows', 'cost', 'doctor', + 'mode', ]); // The bare Home now offers /exit + /doctor (pre-chat diagnostics — a real Home capability via homeReplCtx). expect(HOME_PALETTE_COMMANDS.map((c) => c.name)).toEqual(['exit', 'doctor']); diff --git a/apps/cli/src/commands/repl-commands.ts b/apps/cli/src/commands/repl-commands.ts index 231541e1..3b5ec39b 100644 --- a/apps/cli/src/commands/repl-commands.ts +++ b/apps/cli/src/commands/repl-commands.ts @@ -13,6 +13,7 @@ * those three can never disagree. The set is deliberately small and **alias-free**. */ +import { CHAT_MODES } from '../chat/chat-mode.js'; import type { CommandEffect } from './manifest.js'; /** @@ -36,6 +37,9 @@ export interface ReplCommandContext { readonly showCost: () => void | Promise; /** Run the `/doctor` health check (2.5.C S5); `deep` adds the network/process tier (key + MCP validation). */ readonly runDoctor: (deep: boolean) => void | Promise; + /** Switch the chat mode (ADR-0057). Receives the raw mode-name token (empty ⇒ show the current mode + options). + * The surface parses + applies it (re-applying the turn policy on the same session) and reports the result. */ + readonly setMode: (modeArg: string) => void | Promise; } /** A flag a {@link ReplCommand} accepts after its name (e.g. `/doctor --deep`). Flags only — the curated set has @@ -57,6 +61,10 @@ export interface ReplCommand { /** The flags this command accepts after its name (omitted ⇒ zero-arg). The dispatch rejects any token not * listed here, so a zero-arg command still rejects `/exit now`. */ readonly args?: readonly ReplArg[]; + /** An optional single POSITIONAL value the command accepts (e.g. `/mode plan`) — the dispatch accepts a token + * in `values` (in addition to any declared flags) and rejects anything else, so an invalid value is caught + * before `run`. The palette still submits the BARE `/` (no positional), so `run` handles the empty arg. */ + readonly positional?: { readonly name: string; readonly values: readonly string[] }; /** Run the command; receives the validated post-name arg tokens (empty for a zero-arg command). May be async * (an awaited `Promise`), so `/cost` / `/doctor` are safe. */ readonly run: (ctx: ReplCommandContext, args: readonly string[]) => void | Promise; @@ -144,6 +152,17 @@ const RAW_REPL_COMMANDS: readonly ReplCommand[] = [ run: (ctx, args) => ctx.runDoctor(args.includes('--deep')), availableIn: ['home', 'chat'], }, + { + name: 'mode', + label: 'Mode', + description: 'Switch the chat mode: ask / plan / accept-edits / auto (or Shift+Tab to cycle).', + effect: 'read', + // A single positional mode name; the dispatch validates it against these values, so `run` only ever sees a + // valid mode or an empty arg (the palette's bare `/mode` — which shows the current mode + the options). + positional: { name: 'mode', values: [...CHAT_MODES] }, + run: (ctx, args) => ctx.setMode(args[0] ?? ''), + availableIn: ['chat'], + }, ]; /** DEEP-freeze a curated command — the entry, its `args` array + each flag, and its `availableIn` array — so no @@ -154,6 +173,10 @@ function freezeReplCommand(command: ReplCommand): ReplCommand { command.args.forEach((arg) => Object.freeze(arg)); Object.freeze(command.args); } + if (command.positional !== undefined) { + Object.freeze(command.positional.values); + Object.freeze(command.positional); + } Object.freeze(command.availableIn); return Object.freeze(command); } diff --git a/apps/cli/src/home/drive-home.tsx b/apps/cli/src/home/drive-home.tsx index 5aa959f1..d84dfd94 100644 --- a/apps/cli/src/home/drive-home.tsx +++ b/apps/cli/src/home/drive-home.tsx @@ -4,7 +4,7 @@ import { createRunHistoryReader } from '@relavium/db'; import { render } from 'ink'; import { createElement } from 'react'; -import { createChatLineHandler, createChatModeControl } from '../commands/chat.js'; +import { createChatLineHandler } from '../commands/chat.js'; import { buildChatSession, type BuiltChatSession } from '../chat/session-host.js'; import { assembleDoctorProbes } from '../chat/doctor-host.js'; import type { DoctorProbes } from '../chat/doctor.js'; @@ -177,14 +177,14 @@ export async function driveHome(deps: HomeDeps): Promise { now, uuid, }); - const { processLine, cancelOnce, shouldStop } = createChatLineHandler( - { built, opened, store, persister, doctorProbes: chatDoctorProbes }, - deps, - ); - // ADR-0057: activate the reseat-less mode system (applies the initial `ask` mode → the fail-closed - // approval regime) BEFORE the session opens, so the full-capability chat host is never live without the - // per-tool approval floor — the SAME guarantee the `chat` command's runReplLoop provides. - const { onAbort, onModeChange } = createChatModeControl(built, store); + // createChatLineHandler owns the mode control (ADR-0057): it applies the initial `ask` mode → the + // fail-closed approval regime — BEFORE the session opens, so the full-capability chat host is never live + // without the per-tool approval floor (the SAME guarantee the `chat` command's runReplLoop provides). + const { processLine, cancelOnce, shouldStop, onAbort, onModeChange } = + createChatLineHandler( + { built, opened, store, persister, doctorProbes: chatDoctorProbes }, + deps, + ); // Subscribe the view store BEFORE opening the session so the synchronous session:started is observed. unsubscribe = built.handle.subscribe((event) => store.apply(event)); frame = setInterval(() => store.tick(), FRAME_MS); diff --git a/apps/cli/src/render/tui/home-controller.ts b/apps/cli/src/render/tui/home-controller.ts index 9f9ecd3a..3bf95dd7 100644 --- a/apps/cli/src/render/tui/home-controller.ts +++ b/apps/cli/src/render/tui/home-controller.ts @@ -3,7 +3,7 @@ import { HOME_PALETTE_COMMANDS, type ReplCommandContext, } from '../../commands/repl-commands.js'; -import { type ChatMode } from '../../chat/chat-mode.js'; +import { nextMode, type ChatMode } from '../../chat/chat-mode.js'; import { formatDoctorReport, runDoctorChecks, type DoctorProbes } from '../../chat/doctor.js'; import type { HomeSnapshot, HomeStore } from '../../home/home-store.js'; import { applyChatEdit, dropLastCodePoint, reduceChatKey, type ChatKey } from './chat-input.js'; @@ -265,6 +265,8 @@ export function createHomeController(deps: HomeControllerDeps): HomeController { help: () => undefined, showWorkflows: () => undefined, showCost: () => undefined, + setMode: () => undefined, // `/mode` is chat-only (not in HOME_PALETTE_COMMANDS); inert in the Home surface + runDoctor: async (deep) => { if (exiting) return; const runId = (doctorRunId += 1); // a new run; a prompt edit/submit or a later run bumps this, invalidating us @@ -320,12 +322,14 @@ export function createHomeController(deps: HomeControllerDeps): HomeController { const handleChatKey = (active: HomeChatSession, input: string, key: ChatKey): void => { if (tearingDown === active) return; // a key arriving mid-teardown must not drive sendMessage on a cancelled session const running = active.store.getSnapshot().state.status === 'running'; + // A pending approval OWNS the keyboard (never opens the palette) — the reduceChatKey approval-intercept. + const approvalPending = active.store.getSnapshot().approval !== undefined; // Open the `/` palette when idle at an EMPTY prompt (a literal '/', not a chord) — the discovery entry point. - if (shouldOpenPalette(input, key, running, state.input.length)) { + if (!approvalPending && shouldOpenPalette(input, key, running, state.input.length)) { set({ palette: INITIAL_PALETTE_STATE }); return; } - const action = reduceChatKey(input, key, state.input, running); + const action = reduceChatKey(input, key, state.input, running, approvalPending); switch (action.kind) { case 'cancel': if (!cancelFired) { @@ -341,6 +345,19 @@ export function createHomeController(deps: HomeControllerDeps): HomeController { set({ input: '' }); sendChatLine(active, action.line); return; + case 'cycle-mode': + // Shift+Tab: advance the chat mode on the SAME session (ADR-0057; no reseat) — parity with `relavium chat`. + active.onModeChange?.(nextMode(active.store.getSnapshot().mode)); + return; + case 'abort': + active.onAbort?.(); // Esc — mid-turn abort (keeps the session; distinct from /cancel) + return; + case 'approve': + active.store.answerApproval({ outcome: 'approve', scope: action.scope }); + return; + case 'reject': + active.store.answerApproval({ outcome: 'reject' }); + return; case 'none': return; } From d9e9cb65f3369617181e17376c53bdfa5ed10352 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Thu, 2 Jul 2026 09:54:42 +0300 Subject: [PATCH 22/45] =?UTF-8?q?fix(cli):=202.5.E=20Step=204b=20=E2=80=94?= =?UTF-8?q?=20address=20opus=20review=20(10=20findings,=201=20HIGH=20secur?= =?UTF-8?q?ity)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/cli/src/chat/chat-mode-host.ts | 3 +- apps/cli/src/chat/chat-mode.ts | 9 ++- apps/cli/src/chat/session-host.test.ts | 55 ++++++++++++- apps/cli/src/chat/session-host.ts | 8 +- apps/cli/src/commands/agent-run.test.ts | 32 +++++++- apps/cli/src/commands/agent-run.ts | 17 ++++ apps/cli/src/commands/chat.ts | 17 +++- .../src/render/tui/chat-projection.test.ts | 2 +- apps/cli/src/render/tui/chat-store.ts | 7 +- .../src/render/tui/home-controller.test.ts | 80 ++++++++++++++++++- .../src/render/tui/session-view-model.test.ts | 15 ++++ apps/cli/src/render/tui/session-view-model.ts | 5 +- 12 files changed, 228 insertions(+), 22 deletions(-) diff --git a/apps/cli/src/chat/chat-mode-host.ts b/apps/cli/src/chat/chat-mode-host.ts index 6ffd0138..a1fe8ac6 100644 --- a/apps/cli/src/chat/chat-mode-host.ts +++ b/apps/cli/src/chat/chat-mode-host.ts @@ -31,7 +31,8 @@ export interface ChatModeEnv { readonly governed: ReadonlySet; /** The session once/always memory (shared across mode changes — an "always" persists until the session ends). */ readonly cache: ApprovalCache; - /** The REPL's interactive `[a]/[r]/[c]` prompt (accept-edits, and auto's protected-path fallback). */ + /** The REPL's interactive `[y] yes / [a] always / [n] no / [esc] abort` prompt (accept-edits, and auto's + * protected-path fallback). A reject-with-typed-reason (`[c]` comment) is a deferred follow-up. */ readonly prompt: ApprovalPrompt; /** Whether an approval preview targets a protected path — `auto` then falls back to a prompt (ADR-0057). */ readonly isProtectedTarget: (preview: ToolActionPreview) => boolean; diff --git a/apps/cli/src/chat/chat-mode.ts b/apps/cli/src/chat/chat-mode.ts index af3fca9f..548b4adf 100644 --- a/apps/cli/src/chat/chat-mode.ts +++ b/apps/cli/src/chat/chat-mode.ts @@ -18,7 +18,7 @@ import type { AbortSignalLike } from '@relavium/shared'; * Two-layer safety (ADR-0057): the advertise-filter is BEST-EFFORT (it keeps a governed tool out of the * model's reach), while the `confirm` hook is AUTHORITATIVE — the registry calls it for every governed-class * dispatch (fs_write / egress / a model-controlled process), so even if the model names a hidden tool the - * mode policy still decides. `ask`/`plan` deny governed actions; `accept-edits` prompts (`[a]/[r]/[c]`) with a + * mode policy still decides. `ask`/`plan` deny governed actions; `accept-edits` prompts (`[y]/[a]/[n]`) with a * session once/always memory; `auto` auto-approves EXCEPT a protected-path write, which falls back to a prompt * (and the fs layer hard-denies protected paths regardless — the floor beneath every mode). */ @@ -30,15 +30,16 @@ export type ChatMode = (typeof CHAT_MODES)[number]; /** The default mode: read-only `ask` (secure by default — no governed action without a deliberate mode step). */ export const DEFAULT_CHAT_MODE: ChatMode = 'ask'; -/** Short human labels for the footer indicator + `/mode` output. */ +/** Short labels for the footer indicator + `/mode` output. Kept identical to the mode ids (kebab, no spaces) + * so the footer's `accept-edits mode` is exactly what `/mode accept-edits` accepts — display + input agree. */ export const MODE_LABEL: Record = { ask: 'ask', plan: 'plan', - 'accept-edits': 'accept edits', + 'accept-edits': 'accept-edits', auto: 'auto', }; -/** One-line descriptions (the `/mode` help + palette). */ +/** One-line descriptions, listed by the bare `/mode` output (the chat command's mode-discovery affordance). */ export const MODE_DESCRIPTION: Record = { ask: 'read-only — writes, commands, and network are declined', plan: 'read-only — draft a plan before acting', diff --git a/apps/cli/src/chat/session-host.test.ts b/apps/cli/src/chat/session-host.test.ts index 3f790204..b3cc0f09 100644 --- a/apps/cli/src/chat/session-host.test.ts +++ b/apps/cli/src/chat/session-host.test.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -18,7 +18,9 @@ import { createMcpSecretResolver } from '../secrets/mcp-secret.js'; import type { LlmProvider, LlmRequest, StreamChunk } from '@relavium/llm'; import { CHAT_TEXT_CAPABILITY_FLAGS } from '../test-support.js'; +import { createChatModeControl } from '../commands/chat.js'; import type { ProviderResolver } from '../engine/providers.js'; +import { createChatStore } from '../render/tui/chat-store.js'; import { applyChatMode, makeChatModeEnv } from './chat-mode-host.js'; import { buildDefaultChatAgent } from './default-agent.js'; import { @@ -797,6 +799,57 @@ describe('buildChatSession + 2.5.A tool-host wiring (ADR-0055)', () => { expect(existsSync(join(workspace, 'x.txt'))).toBe(false); // denied BEFORE any write — no file on disk }); + it('createChatModeControl (the LIVE wiring) gates a governed dispatch under the default ask regime', async () => { + // Prove the PRODUCTION seam — createChatModeControl(built, store) applies the initial ask mode via the real + // store.requestApproval prompt — denies a governed write end-to-end (not just the manual-env path above). + const workspace = mkdtempSync(join(tmpdir(), 'relavium-ws-')); + const built = await build({ + cwd: workspace, + agentRef: writeAgent(['write_file']), + providers: scriptedResolver([ + callWithArgs('c1', 'write_file', { path: 'x.txt', content: 'pwned' }), + ]), + }); + createChatModeControl(built, createChatStore(false)); // applies ask → the fail-closed regime is now active + built.session.start(); + await built.session.sendMessage('write a file'); + built.session.cancel(); + const events = await drainHandle(built.handle.events); + const completed = events.find((e) => e.type === 'session:turn_completed'); + expect(completed?.type === 'session:turn_completed' ? completed.error?.code : undefined).toBe( + 'tool_denied', + ); + expect(existsSync(join(workspace, 'x.txt'))).toBe(false); + }); + + it('createChatModeControl accept-edits: an APPROVE lets the governed write through the store prompt', async () => { + const workspace = mkdtempSync(join(tmpdir(), 'relavium-ws-')); + const built = await build({ + cwd: workspace, + agentRef: writeAgent(['write_file']), + providers: scriptedResolver([ + callWithArgs('c1', 'write_file', { path: 'ok.txt', content: 'hi' }), + textTurn('done'), + ]), + }); + const store = createChatStore(false); + const control = createChatModeControl(built, store); + control.onModeChange('accept-edits'); // switch to accept-edits (prompts each governed write) + built.session.start(); + const turn = built.session.sendMessage('write a file'); + // Drive the interactive prompt: wait for the published approval, then approve it. + for (let i = 0; i < 200 && store.getSnapshot().approval === undefined; i += 1) { + await new Promise((r) => setImmediate(r)); + } + expect(store.getSnapshot().approval?.request.toolId).toBe('write_file'); + store.answerApproval({ outcome: 'approve', scope: 'once' }); + await turn; + built.session.cancel(); + await drainHandle(built.handle.events); + expect(existsSync(join(workspace, 'ok.txt'))).toBe(true); // approved ⇒ the write landed + expect(readFileSync(join(workspace, 'ok.txt'), 'utf8')).toBe('hi'); + }); + it('the advertise-filter keeps http_request now that egress is wired (chat-read-write, 2.5.E)', async () => { const workspace = mkdtempSync(join(tmpdir(), 'relavium-ws-')); const { providers, requests } = capturingResolver([textTurn('hi')]); diff --git a/apps/cli/src/chat/session-host.ts b/apps/cli/src/chat/session-host.ts index a0c156fa..0cd6343e 100644 --- a/apps/cli/src/chat/session-host.ts +++ b/apps/cli/src/chat/session-host.ts @@ -58,7 +58,7 @@ export interface BuildChatSessionOptions { readonly uuid: () => string; /** The provider seam (injectable for tests); defaults to the env/keychain resolver, like `relavium run`. */ readonly providers?: ProviderResolver; - /** The tool-execution host (injectable for tests); defaults to the read-only chat factory host (2.5.A). */ + /** The tool-execution host (injectable for tests); defaults to the full-capability chat host (chat-read-write); its writes/egress are gated by the ADR-0057 approval regime, not capability absence. */ readonly toolHost?: ToolHost; /** * Injectable MCP connect-all (2.R) — tests pass a fake that never spawns a child; production uses the real @@ -223,7 +223,7 @@ export async function buildChatSession(opts: BuildChatSessionOptions): Promise number; /** The provider seam (injectable for tests); defaults to the env/keychain resolver. */ readonly providers?: ProviderResolver; - /** The tool-execution host (injectable for tests); defaults to the read-only chat factory host (2.5.A). */ + /** The tool-execution host (injectable for tests); defaults to the full-capability chat host (chat-read-write); its writes/egress are gated by the ADR-0057 approval regime, not capability absence. */ readonly toolHost?: ToolHost; /** Injectable MCP connect-all (2.R; see {@link BuildChatSessionOptions.startMcpClient}). */ readonly startMcpClient?: (servers: readonly McpServerConfig[]) => Promise; @@ -363,7 +363,7 @@ export async function buildResumedChatSession( `session ${record.id} has no stored agent snapshot and cannot be resumed`, ); } - // Clamp the restored fs-scope tier to the host-allowed ceiling (full→project for the read-only chat surface), + // Clamp the restored fs-scope tier to the host-allowed ceiling (full→project for the chat surface), // mirroring buildChatSession — so a PRE-2.5.A session persisted with a broader `full` scope resumes at the tier // the host actually jails to, keeping the dispatch context, the host jail, and the persisted record consistent. const context: SessionContext = { diff --git a/apps/cli/src/commands/agent-run.test.ts b/apps/cli/src/commands/agent-run.test.ts index a4119331..615ab14c 100644 --- a/apps/cli/src/commands/agent-run.test.ts +++ b/apps/cli/src/commands/agent-run.test.ts @@ -1,10 +1,11 @@ -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { Readable } from 'node:stream'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import type { StreamChunk } from '@relavium/llm'; import { startMcpClient as realStartMcpClient, type McpConnection } from '@relavium/mcp'; import { buildChatSession } from '../chat/session-host.js'; @@ -87,6 +88,35 @@ describe('agentRunCommand (2.Q)', () => { expect(out()).toContain('the summary'); }); + it('DENIES a governed dispatch in the non-interactive one-shot — no user to approve (ADR-0057)', async () => { + // agent run shares the full-capability chat-read-write host; a one-shot has no interactive approver, so the + // fail-closed `ask` regime must deny a governed write (never execute it), restoring the pre-4b safety. + writeFileSync( + join(cwd, 'writer.agent.yaml'), + `${AGENT_YAML.replace(' - read_file', ' - write_file')}`, + ); + const writeCall: StreamChunk[] = [ + { type: 'tool_call_start', id: 'c1', name: 'write_file' }, + { + type: 'tool_call_delta', + id: 'c1', + argsJsonDelta: JSON.stringify({ path: 'pwned.txt', content: 'x' }), + }, + { type: 'tool_call_end', id: 'c1' }, + { type: 'stop', stopReason: 'tool_use', usage: { inputTokens: 1, outputTokens: 1 } }, + ]; + const { d, out } = deps('write a file', { + json: true, + providers: scriptedResolver([writeCall]), + }); + await agentRunCommand({ agent: join(cwd, 'writer.agent.yaml'), input: [] }, d); + const completed = parseNdjson(out()).find((e) => e['type'] === 'session:turn_completed'); + expect((completed as { error?: { code?: string } } | undefined)?.error?.code).toBe( + 'tool_denied', + ); + expect(existsSync(join(cwd, 'pwned.txt'))).toBe(false); // denied BEFORE any write — no file on disk + }); + it('an MCP-declaring agent: surfaces dropped tools to stderr and tears the connection down after the turn (2.R)', async () => { // The one-shot's OWN command-level MCP wiring: surfaceMcpSkipped (→ stderr, not the --json stdout) + the // closeMcp teardown in the finally. Drives the REAL buildChatSession over a fake connection (no spawn). diff --git a/apps/cli/src/commands/agent-run.ts b/apps/cli/src/commands/agent-run.ts index e8317ee8..634ab31d 100644 --- a/apps/cli/src/commands/agent-run.ts +++ b/apps/cli/src/commands/agent-run.ts @@ -3,6 +3,7 @@ import { StringDecoder } from 'node:string_decoder'; import type { SessionStreamHandleEvent } from '@relavium/core'; +import { applyChatMode, makeChatModeEnv } from '../chat/chat-mode-host.js'; import { cassetteResolver, loadCassette } from '../chat/fixture.js'; import { buildChatSession, type BuiltChatSession } from '../chat/session-host.js'; import { loadResolvedConfig } from '../config/load.js'; @@ -146,6 +147,22 @@ async function runOneShotTurn( turnErrorCode = event.error.code; } }); + // ADR-0057: `agent run` is a NON-interactive one-shot over the SAME full-capability chat-read-write host + // (session-host.ts). There is no user to approve a governed action, so apply the fail-closed `ask` regime + // BEFORE the first turn — every governed dispatch (write / egress / a model-command process) is denied, + // restoring the pre-4b fail-closed behavior (read-only tools still work). A deliberate author-trusted + // one-shot would be a separate, security-reviewed decision using the workflow-read-write profile, not this. + const modeEnv = makeChatModeEnv({ + session: built.session, + tools: built.tools, + workspaceDir: built.context.workingDir, + prompt: () => + Promise.resolve({ + outcome: 'reject', + reason: 'interactive approval is unavailable in a one-shot agent run', + }), + }); + applyChatMode(modeEnv, 'ask'); built.session.start(); await built.session.sendMessage(message); } catch (err) { diff --git a/apps/cli/src/commands/chat.ts b/apps/cli/src/commands/chat.ts index 83c6f63e..fff6e822 100644 --- a/apps/cli/src/commands/chat.ts +++ b/apps/cli/src/commands/chat.ts @@ -17,7 +17,13 @@ import { REPL_COMMANDS_BY_NAME, type ReplCommandContext, } from './repl-commands.js'; -import { CHAT_MODES, MODE_LABEL, parseMode, type ChatMode } from '../chat/chat-mode.js'; +import { + CHAT_MODES, + MODE_DESCRIPTION, + MODE_LABEL, + parseMode, + type ChatMode, +} from '../chat/chat-mode.js'; import { applyChatMode, makeChatModeEnv } from '../chat/chat-mode-host.js'; import { createSessionPersister, type SessionPersister } from '../chat/persister.js'; import { @@ -571,9 +577,14 @@ export function createChatLineHandler( setMode: (modeArg) => { const requested = modeArg.trim(); if (requested.length === 0) { - emitOutput( - `mode: ${MODE_LABEL[store.getSnapshot().mode]} (options: ${CHAT_MODES.join(', ')})`, + // Bare `/mode`: show the current mode + EXPLAIN each one (a discovery affordance — the palette submits + // this bare form), listing every mode with its one-line description and marking the active one. + const current = store.getSnapshot().mode; + const rows = CHAT_MODES.map( + (m) => + ` ${MODE_LABEL[m].padEnd(12)} ${MODE_DESCRIPTION[m]}${m === current ? ' (current)' : ''}`, ); + emitOutput(`mode: ${MODE_LABEL[current]}\n${rows.join('\n')}`); return; } const mode = parseMode(requested); diff --git a/apps/cli/src/render/tui/chat-projection.test.ts b/apps/cli/src/render/tui/chat-projection.test.ts index fd475b4f..ec4fac54 100644 --- a/apps/cli/src/render/tui/chat-projection.test.ts +++ b/apps/cli/src/render/tui/chat-projection.test.ts @@ -118,7 +118,7 @@ describe('chat-projection', () => { it('appends the active mode label to the footer (always shown — auto is never hidden)', () => { const state = { ...initialSessionViewState(), turnCount: 2 }; expect(formatSessionFooterWithMode(state, 'ask')).toMatch(/· ask mode$/); - expect(formatSessionFooterWithMode(state, 'accept-edits')).toMatch(/· accept edits mode$/); + expect(formatSessionFooterWithMode(state, 'accept-edits')).toMatch(/· accept-edits mode$/); expect(formatSessionFooterWithMode(state, 'auto')).toMatch(/· auto mode$/); }); }); diff --git a/apps/cli/src/render/tui/chat-store.ts b/apps/cli/src/render/tui/chat-store.ts index cac2159a..8b7a8520 100644 --- a/apps/cli/src/render/tui/chat-store.ts +++ b/apps/cli/src/render/tui/chat-store.ts @@ -30,7 +30,8 @@ import { * rate is capped). */ -/** A pending per-tool approval the REPL renders as a `[a]/[r]/[c]` prompt (ADR-0057, EA3/EA5). */ +/** A pending per-tool approval the REPL renders as a `[y] yes / [a] always / [n] no / [esc] abort` prompt + * (ADR-0057, EA3/EA5). A reject-with-typed-reason (`[c]` comment) is a deferred follow-up. */ export interface PendingApproval { readonly request: ToolApprovalRequest; /** Whether an "always" answer will be remembered (accept-edits) — the prompt greys it out when false. */ @@ -81,7 +82,7 @@ export interface ChatStoreController extends ChatStore { * cancel path (not a denial). At most ONE approval is pending at a time (the turn blocks on it). */ requestApproval: ApprovalPrompt; - /** Answer the in-flight approval (the input handler's `[a]/[r]/[c]` decision) — a no-op if none is pending. */ + /** Answer the in-flight approval (the input handler's `[y]/[a]/[n]` decision) — a no-op if none is pending. */ answerApproval: (answer: ApprovalAnswer) => void; } @@ -184,7 +185,7 @@ export function createChatStore(color: boolean, seed?: SessionViewSeed): ChatSto }; approval = { request, cacheable }; signal?.addEventListener('abort', onAbort); - flush(); // render the [a]/[r]/[c] prompt + flush(); // render the [y]/[a]/[n] prompt }), answerApproval: (answer) => { // A no-op if nothing is pending (a stray keypress); else settle the in-flight promise + repaint. diff --git a/apps/cli/src/render/tui/home-controller.test.ts b/apps/cli/src/render/tui/home-controller.test.ts index 80846632..e8c921a8 100644 --- a/apps/cli/src/render/tui/home-controller.test.ts +++ b/apps/cli/src/render/tui/home-controller.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { type ChatMode } from '../../chat/chat-mode.js'; import type { DoctorProbes } from '../../chat/doctor.js'; import type { HomeSnapshot, HomeStore } from '../../home/home-store.js'; import { createChatStore, type ChatStoreController } from './chat-store.js'; @@ -43,24 +44,36 @@ const EMPTY: HomeSnapshot = { /** A controllable {@link HomeChatSession} fake: records the lines it processes, exposes a teardown spy, and lets a * test script `shouldStop` (the /exit·/cancel signal) and the turn outcome (resolve vs reject). */ function makeSession( - opts: { onProcess?: () => Promise; stop?: () => boolean; running?: boolean } = {}, + opts: { + onProcess?: () => Promise; + stop?: () => boolean; + running?: boolean; + store?: ReturnType; + onAbort?: () => void; + onModeChange?: (mode: ChatMode) => void; + } = {}, ): { session: HomeChatSession; teardown: ReturnType; lines: string[]; + store: ReturnType; } { const lines: string[] = []; const teardown = vi.fn(() => Promise.resolve()); + // A custom store wins; else running ⇒ a status-running store, idle ⇒ a fresh one (the running-gate is false). + const store = opts.store ?? (opts.running === true ? runningStore() : createChatStore(false)); const session: HomeChatSession = { - store: opts.running === true ? runningStore() : createChatStore(false), // idle ⇒ the running-gate is false + store, processLine: async (line) => { lines.push(line); if (opts.onProcess) await opts.onProcess(); }, shouldStop: opts.stop ?? (() => false), + ...(opts.onAbort === undefined ? {} : { onAbort: opts.onAbort }), + ...(opts.onModeChange === undefined ? {} : { onModeChange: opts.onModeChange }), teardown, }; - return { session, teardown, lines }; + return { session, teardown, lines, store }; } /** Flush the microtask + macrotask queue so the controller's async `startChat`/`processLine` chains settle. */ @@ -885,4 +898,65 @@ describe('createHomeController (2.5.B lifecycle / ADR-0054)', () => { expect(c.getSnapshot().input).toBe('pasted'); }); }); + + describe('in-chat mode / approval / abort keys (ADR-0057)', () => { + const inChat = async (made: ReturnType): Promise => { + const c = createHomeController({ + doctorProbes: STUB_DOCTOR_PROBES, + startChat: () => Promise.resolve(made.session), + homeStore, + onExit: vi.fn(), + onError: vi.fn(), + }); + type(c, 'hi'); + c.handleKey('', ENTER); + await flush(); // loading → chat, first line sent — now handleKey routes to handleChatKey + return c; + }; + const approvalReq = { + toolId: 'write_file', + action: 'fs_write', + preview: { path: 'x' }, + } as const; + + it('Shift+Tab cycles the chat mode via onModeChange (ask → plan)', async () => { + const onModeChange = vi.fn(); + const c = await inChat(makeSession({ onModeChange })); + c.handleKey('', { tab: true, shift: true }); + expect(onModeChange).toHaveBeenCalledWith('plan'); // default ask → plan + }); + + it('Esc aborts the in-flight turn via onAbort (mid-turn; the session is not cancelled)', async () => { + const onAbort = vi.fn(); + const made = makeSession({ onAbort, running: true }); + const c = await inChat(made); + c.handleKey('', { escape: true }); + expect(onAbort).toHaveBeenCalledTimes(1); + expect(made.lines).not.toContain('/cancel'); // abort ≠ /cancel — the session stays alive + }); + + it('a pending approval intercepts keys: `/` stays closed and `[y]` approves-once', async () => { + const store = createChatStore(false); + const c = await inChat(makeSession({ store })); + const pending = store.requestApproval(approvalReq, true); + c.handleKey('/', {}); // the approval owns the keyboard — the palette must NOT open + expect(c.getSnapshot().palette).toBeUndefined(); + c.handleKey('y', {}); + await expect(pending).resolves.toEqual({ outcome: 'approve', scope: 'once' }); + }); + + it('a pending approval: `[a]` approves-always, `[n]` rejects', async () => { + const alwaysStore = createChatStore(false); + const ca = await inChat(makeSession({ store: alwaysStore })); + const alwaysPending = alwaysStore.requestApproval(approvalReq, true); + ca.handleKey('a', {}); + await expect(alwaysPending).resolves.toEqual({ outcome: 'approve', scope: 'always' }); + + const rejectStore = createChatStore(false); + const cr = await inChat(makeSession({ store: rejectStore })); + const rejectPending = rejectStore.requestApproval(approvalReq, true); + cr.handleKey('n', {}); + await expect(rejectPending).resolves.toEqual({ outcome: 'reject' }); + }); + }); }); diff --git a/apps/cli/src/render/tui/session-view-model.test.ts b/apps/cli/src/render/tui/session-view-model.test.ts index fc589b65..f3e5951d 100644 --- a/apps/cli/src/render/tui/session-view-model.test.ts +++ b/apps/cli/src/render/tui/session-view-model.test.ts @@ -158,6 +158,21 @@ describe('session-view-model', () => { expect(state.status).toBe('idle'); // the session stays alive after an abort }); + it('an abort with NO streamed text still appends a trace entry (Esc at the approval prompt is confirmed)', () => { + const e = events(); + const state = reduceAll([ + e.started(), + e.turnStarted(), + // No token — the common abort-during-approval case (Esc at the [y]/[a]/[n] prompt before any text streamed). + e.turnCompleted({ stopReason: 'aborted' }), + ]); + expect(state.transcript).toHaveLength(1); // NOT silently dropped + const entry = state.transcript[0]; + expect(entry).toMatchObject({ role: 'assistant', text: '' }); + if (entry?.role === 'assistant') expect(entry.summary.stopReason).toBe('aborted'); + expect(state.status).toBe('idle'); + }); + it('drops a pre-tool preamble from the stored assistant text (mirrors result.text), annotates the call', () => { const e = events(); const state = reduceAll([ diff --git a/apps/cli/src/render/tui/session-view-model.ts b/apps/cli/src/render/tui/session-view-model.ts index ce988e78..aebb2c36 100644 --- a/apps/cli/src/render/tui/session-view-model.ts +++ b/apps/cli/src/render/tui/session-view-model.ts @@ -317,7 +317,10 @@ function reduceTurnCompleted(base: SessionViewState, event: TurnCompletedEvent): : { errorCode: event.error.code, errorMessage: event.error.message }), }; const text = base.liveTokens; - const show = text.length > 0 || event.error !== undefined; + // Append an entry for a turn that produced text, that ERRORED, OR that was ABORTED (EA7) — so an Esc during + // an approval prompt (before any assistant text streamed) still leaves a visible trace ("aborted · …" via the + // summary), confirming the abort took effect rather than silently clearing the live region. + const show = text.length > 0 || event.error !== undefined || event.stopReason === 'aborted'; const transcript = show ? appendTranscript(base.transcript, { role: 'assistant', text, summary }) : base.transcript; From c855c9ef2f5bb034732c1a3fddcfa2dc7c8a42d8 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Thu, 2 Jul 2026 10:28:15 +0300 Subject: [PATCH 23/45] =?UTF-8?q?fix(cli):=202.5.E=20Step=204b=20=E2=80=94?= =?UTF-8?q?=20address=20Sonnet=205=20review=20(regression=20guards=20+=20d?= =?UTF-8?q?oc=20accuracy)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/cli/src/chat/chat-mode.ts | 7 +- apps/cli/src/chat/session-host.test.ts | 81 +++++++++++++++++++ ...57-cli-chat-modes-and-per-tool-approval.md | 7 +- .../phases/phase-2.5-cli-consolidation.md | 5 +- 4 files changed, 94 insertions(+), 6 deletions(-) diff --git a/apps/cli/src/chat/chat-mode.ts b/apps/cli/src/chat/chat-mode.ts index 548b4adf..41315bd9 100644 --- a/apps/cli/src/chat/chat-mode.ts +++ b/apps/cli/src/chat/chat-mode.ts @@ -53,7 +53,12 @@ export function nextMode(mode: ChatMode): ChatMode { return CHAT_MODES[(index + 1) % CHAT_MODES.length] ?? DEFAULT_CHAT_MODE; } -/** Parse a `/mode ` argument to a {@link ChatMode}, tolerating the spaced label (`accept edits`). */ +/** + * Parse a mode name to a {@link ChatMode}. Case-insensitive; it also normalizes internal whitespace to a + * hyphen (`accept edits` → `accept-edits`) as a DEFENSIVE convenience for any direct caller — note the `/mode` + * slash dispatch tokenizes on whitespace, so a spaced value never reaches here as one token (the labels are + * kebab, so a user types `accept-edits`); the normalization only matters to a programmatic caller. + */ export function parseMode(input: string): ChatMode | undefined { const normalized = input.trim().toLowerCase().replace(/\s+/gu, '-'); return (CHAT_MODES as readonly string[]).includes(normalized) diff --git a/apps/cli/src/chat/session-host.test.ts b/apps/cli/src/chat/session-host.test.ts index b3cc0f09..aaadb75b 100644 --- a/apps/cli/src/chat/session-host.test.ts +++ b/apps/cli/src/chat/session-host.test.ts @@ -468,6 +468,35 @@ describe('buildResumedChatSession (2.N)', () => { expect(built.nextSequenceNumber).toBe(2); }); + it('createChatModeControl gates a governed dispatch on the RESUMED session too (regression guard)', async () => { + // The resumed path shares buildSessionRuntime + runReplLoop→createChatModeControl with the fresh path, so + // the fail-closed ask regime activates here too. Lock it against a future refactor that special-cases the + // resume assembly and silently reintroduces the ungated-dispatch class the opus round fixed for one-shot. + const workspace = mkdtempSync(join(tmpdir(), 'relavium-ws-')); + const built = await buildResumedChatSession({ + chat: EMPTY_CHAT, + record: record({ + agentSnapshot: { ...RESUME_AGENT, tools: ['write_file'] }, + context: { workingDir: workspace, fsScopeTier: 'project' }, + }), + messages: [message(0, 'user', 'hi'), message(1, 'assistant', 'hello')], + now: () => Date.parse(ISO), + providers: scriptedResolver([ + callWithArgs('c1', 'write_file', { path: 'x.txt', content: 'p' }), + ]), + }); + createChatModeControl(built, createChatStore(false)); // ask regime, applied to the resumed session + // A resumed session lands at idle and continues without start(); the next sendMessage runs a turn. + await built.session.sendMessage('write a file'); + built.session.cancel(); + const events = await drainHandle(built.handle.events); + const completed = events.find((e) => e.type === 'session:turn_completed'); + expect(completed?.type === 'session:turn_completed' ? completed.error?.code : undefined).toBe( + 'tool_denied', + ); + expect(existsSync(join(workspace, 'x.txt'))).toBe(false); + }); + it('CLAMPS a persisted full fs-scope tier down to project on resume (read-only chat ceiling, ADR-0055)', async () => { // SECURITY regression: a session persisted (e.g. pre-2.5.A) with the broad `full` tier must resume at the // read-only chat ceiling — `project`, never `full` — so a resumed chat can't read `~/.ssh` / `~/.aws` back @@ -822,6 +851,29 @@ describe('buildChatSession + 2.5.A tool-host wiring (ADR-0055)', () => { expect(existsSync(join(workspace, 'x.txt'))).toBe(false); }); + it('createChatModeControl ask: denies an EGRESS-class dispatch too (http_request), not just fs_write', async () => { + // The confirm floor rejects EVERY governed class; prove the egress class end-to-end (governedAction maps + // http_request → 'egress', a distinct ToolActionClass) — the deny happens BEFORE dispatch, so the egress + // arm's fetch never runs (no outbound request), and the turn fails tool_denied. + const workspace = mkdtempSync(join(tmpdir(), 'relavium-ws-')); + const built = await build({ + cwd: workspace, + agentRef: writeAgent(['http_request']), + providers: scriptedResolver([ + callWithArgs('c1', 'http_request', { url: 'https://example.test/x' }), + ]), + }); + createChatModeControl(built, createChatStore(false)); // ask regime active + built.session.start(); + await built.session.sendMessage('fetch a url'); + built.session.cancel(); + const events = await drainHandle(built.handle.events); + const completed = events.find((e) => e.type === 'session:turn_completed'); + expect(completed?.type === 'session:turn_completed' ? completed.error?.code : undefined).toBe( + 'tool_denied', + ); + }); + it('createChatModeControl accept-edits: an APPROVE lets the governed write through the store prompt', async () => { const workspace = mkdtempSync(join(tmpdir(), 'relavium-ws-')); const built = await build({ @@ -850,6 +902,35 @@ describe('buildChatSession + 2.5.A tool-host wiring (ADR-0055)', () => { expect(readFileSync(join(workspace, 'ok.txt'), 'utf8')).toBe('hi'); }); + it('createChatModeControl auto: a PROTECTED-path write FALLS BACK to a prompt (not auto-approved)', async () => { + // The most bespoke ADR-0057 branch, end-to-end (real registry + fs host + store): in auto mode a + // protected-path target must NOT auto-approve — it publishes a non-cacheable prompt. Rejecting it denies + // the write (the fs protected-paths floor would refuse it regardless — this proves the graceful fallback). + const workspace = mkdtempSync(join(tmpdir(), 'relavium-ws-')); + const built = await build({ + cwd: workspace, + agentRef: writeAgent(['write_file']), + providers: scriptedResolver([ + callWithArgs('c1', 'write_file', { path: '.git/config', content: '[evil]' }), + ]), + }); + const store = createChatStore(false); + createChatModeControl(built, store).onModeChange('auto'); + built.session.start(); + const turn = built.session.sendMessage('write a protected file'); + for (let i = 0; i < 200 && store.getSnapshot().approval === undefined; i += 1) { + await new Promise((r) => setImmediate(r)); + } + // auto did NOT auto-approve the protected target — it published a prompt, marked non-cacheable. + expect(store.getSnapshot().approval?.request.toolId).toBe('write_file'); + expect(store.getSnapshot().approval?.cacheable).toBe(false); + store.answerApproval({ outcome: 'reject' }); + await turn; + built.session.cancel(); + await drainHandle(built.handle.events); + expect(existsSync(join(workspace, '.git', 'config'))).toBe(false); + }); + it('the advertise-filter keeps http_request now that egress is wired (chat-read-write, 2.5.E)', async () => { const workspace = mkdtempSync(join(tmpdir(), 'relavium-ws-')); const { providers, requests } = capturingResolver([textTurn('hi')]); diff --git a/docs/decisions/0057-cli-chat-modes-and-per-tool-approval.md b/docs/decisions/0057-cli-chat-modes-and-per-tool-approval.md index 8bfb8017..2143f375 100644 --- a/docs/decisions/0057-cli-chat-modes-and-per-tool-approval.md +++ b/docs/decisions/0057-cli-chat-modes-and-per-tool-approval.md @@ -85,8 +85,9 @@ approval as a new, bounded engine vertical.** pause/resume state (which today does not exist) — and an `Esc` **mid-turn abort** that aborts the in-flight turn and keeps the session alive by emitting **one** `session:turn_completed` (with an abort stop-reason), rolling back the pending user message, and returning `#status` to `idle` (the engine has no - `aborted` status and this is **not** `cancel()`, which is terminal); a REPL `[approve]/[reject]/[comment]` - intercept that bypasses the in-flight key-swallow gate (so the prompt cannot deadlock); a typed + `aborted` status and this is **not** `cancel()`, which is terminal); a REPL approve/reject intercept + (shipped keys `[y]` yes-once / `[a]` always / `[n]` no / `[esc]` abort; the `[c]` reject-with-typed-reason + path is a deferred follow-up) that bypasses the in-flight key-swallow gate (so the prompt cannot deadlock); a typed `ToolDeniedByUserError` carrying the **existing** `tool_denied` `ErrorCode` (already non-retryable — it is absent from `RETRYABLE_ERROR_CODES` in `@relavium/shared/constants`, so a user deny is final, not a retried execution error); and a session-scoped, **in-memory** once/always cache (instance-scoped — **not** @@ -115,7 +116,7 @@ alternatives.** `ActionGuard` ([ADR-0041](0041-external-action-governance-seam.m organizational* policy-decision point — an external governor that `decide`s allow / block / transform and `commit`s with idempotency + compensation + tamper-evident audit; optional, **off-by-default**, Phase-2 enterprise. `confirmAction` is the *interactive, end-user* consent gate — the human at the terminal answers -`[a]/[r]/[c]` — and is precisely the "out-of-band interactive approval the host surfaces before `commit`" +`[y]`/`[a]`/`[n]` (`[esc]` aborts) — and is precisely the "out-of-band interactive approval the host surfaces before `commit`" that [ADR-0041](0041-external-action-governance-seam.md) §Entry-point scope names for the **session** entry point (where `ActionGuard`'s `require-approval` verdict is unavailable). When **both** are present they **compose** in the [tool-registry.md](../reference/shared-core/tool-registry.md) dispatch lifecycle: the diff --git a/docs/roadmap/phases/phase-2.5-cli-consolidation.md b/docs/roadmap/phases/phase-2.5-cli-consolidation.md index 494fc04e..10b7d462 100644 --- a/docs/roadmap/phases/phase-2.5-cli-consolidation.md +++ b/docs/roadmap/phases/phase-2.5-cli-consolidation.md @@ -292,7 +292,8 @@ approval policy** (authoritative — `enforcePolicy` is mode-agnostic and inert **fail-closed** — when a write/process/egress arm is wired, a write-/exec-/egress-class dispatch **requires** a decision; **absent hook ⇒ deny** (so an advertise-filter or wiring bug can never let `ask` mode write). Plus: an `agent:approval_requested` event (**EA5**), an `AgentSession` pause/resume state (**EA4**), a REPL - `[approve]/[reject]/[comment]` intercept that bypasses the in-flight key-swallow gate (no deadlock), a + `[y]`/`[a]`/`[n]`/`[esc]` intercept (approve-once / always / reject / abort; `[c]` reject-with-reason + deferred) that bypasses the in-flight key-swallow gate (no deadlock), a typed `ToolDeniedByUserError` (the existing `tool_denied` `ErrorCode`, already non-retryable), and a session-scoped, **in-memory** once/always cache (not persisted across resume; **once** = this invocation (tool+args), **always** = this tool id for this session instance). The existing `gateApproved` flag is a @@ -304,7 +305,7 @@ approval policy** (authoritative — `enforcePolicy` is mode-agnostic and inert machinery; the session-level budget pause/resume deferred from Phase 2 rides the same machine. **Acceptance:** `Shift+Tab` switches modes instantly with **no** tool-context loss; ask mode advertises -read-only tools; accept-edits prompts before each write with `[a]/[r]/[c]` and an once/always memory; +read-only tools; accept-edits prompts before each write with `[y]/[a]/[n]` and an once/always memory; a rejection is a clean `tool_denied`, not a retry; `Esc` aborts a turn and the session continues; auto is sandbox-bounded with protected paths honoured. A security review of the reseat-less mode model (defense-in-depth trade-off) passes. **Required ADR: per-tool approval + reseat-less chat mode system From abcf95449dbc223372a4842a0914928b1f0c0b79 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Thu, 2 Jul 2026 11:02:19 +0300 Subject: [PATCH 24/45] =?UTF-8?q?fix(core,cli):=202.5.E=20Step=205=20?= =?UTF-8?q?=E2=80=94=20address=20the=20mandatory=20ADR-0057=20security=20r?= =?UTF-8?q?eview?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/cli/src/chat/chat-mode.test.ts | 9 ++++++--- apps/cli/src/chat/chat-mode.ts | 11 ++++++----- apps/cli/src/chat/session-host.test.ts | 20 ++++++++++++++++++++ apps/cli/src/engine/tool-host/assemble.ts | 9 +++++---- apps/cli/src/engine/tool-host/fs.test.ts | 12 ++++++++++++ apps/cli/src/engine/tool-host/fs.ts | 11 ++++++++--- packages/core/src/tools/builtins.ts | 13 +++++++++---- packages/core/src/tools/registry.ts | 16 ++++++++++++---- packages/core/src/tools/types.ts | 7 +++++++ packages/shared/src/constants.ts | 15 +++++++++------ 10 files changed, 94 insertions(+), 29 deletions(-) diff --git a/apps/cli/src/chat/chat-mode.test.ts b/apps/cli/src/chat/chat-mode.test.ts index f4d35d99..2a1c818c 100644 --- a/apps/cli/src/chat/chat-mode.test.ts +++ b/apps/cli/src/chat/chat-mode.test.ts @@ -47,20 +47,21 @@ describe('mode cycle + parsing', () => { }); describe('isGovernedTool — mirrors the registry governedAction (advertise-filter hide set)', () => { - it('governs write_file (fsWrite), http_request/web_search/mcp_call (egress), run_command (model command)', () => { + it('governs write_file (fsWrite), egress, run_command (model command), and os (read_clipboard/notify)', () => { expect(isGovernedTool(builtin('write_file'))).toBe(true); expect(isGovernedTool(builtin('http_request'))).toBe(true); expect(isGovernedTool(builtin('web_search'))).toBe(true); expect(isGovernedTool(builtin('mcp_call'))).toBe(true); expect(isGovernedTool(builtin('run_command'))).toBe(true); expect(isGovernedTool(builtin('git_commit'))).toBe(true); + expect(isGovernedTool(builtin('read_clipboard'))).toBe(true); // os — an un-jailed exfiltration sink (ADR-0057) + expect(isGovernedTool(builtin('notify'))).toBe(true); // os — a native-notification side effect }); it('does NOT govern the read-only tools (read_file, list_directory, git_status without a policyTarget)', () => { expect(isGovernedTool(builtin('read_file'))).toBe(false); expect(isGovernedTool(builtin('list_directory'))).toBe(false); expect(isGovernedTool(builtin('git_status'))).toBe(false); // spawnsProcess but NO policyTarget ⇒ read-only - expect(isGovernedTool(builtin('read_clipboard'))).toBe(false); // os, non-governed }); it('governs a discovered MCP tool (egress: mcp)', () => { @@ -79,10 +80,12 @@ describe('isGovernedTool — mirrors the registry governedAction (advertise-filt 'http_request', 'web_search', 'mcp_call', + 'read_clipboard', + 'notify', ]), ); // Explicit negatives for the read-only tools the positive cases above don't name. - for (const id of ['notify', 'read_media', 'invoke_agent']) { + for (const id of ['read_media', 'invoke_agent']) { expect(isGovernedTool(builtin(id))).toBe(false); } }); diff --git a/apps/cli/src/chat/chat-mode.ts b/apps/cli/src/chat/chat-mode.ts index 41315bd9..5335fdd0 100644 --- a/apps/cli/src/chat/chat-mode.ts +++ b/apps/cli/src/chat/chat-mode.ts @@ -110,15 +110,16 @@ export class ApprovalCache { * of the registry's runtime `confirmAction` `governedAction`: it also hides a `requiresGateApproval` tool * (`git_commit`), which `confirmAction` does NOT gate (its `enforcePolicy` human-gate floor denies it on the * chat path instead) but which is plainly not read-only. Covered: `write_file` (`fsWrite`), any `egress` - * (`http_request` / `web_search` / `mcp_call` / a discovered MCP tool), a process tool that resolves a - * MODEL-CONTROLLED command (a `policyTarget` — `run_command`), and a gate-approval tool. `git_status` has NO - * `policyTarget` and no gate flag ⇒ read-only, advertised in every mode; `read_file` / `list_directory` / - * `read_clipboard` are likewise read-only. (The `confirm` floor stays authoritative for the confirmAction - * classes regardless of what the filter offers.) + * (`http_request` / `web_search` / `mcp_call` / a discovered MCP tool), an `os` action (`read_clipboard` / + * `notify`), a process tool that resolves a MODEL-CONTROLLED command (a `policyTarget` — `run_command`), and a + * gate-approval tool. `git_status` has NO `policyTarget` and no gate flag ⇒ read-only, advertised in every + * mode; `read_file` / `list_directory` are likewise read-only. (The `confirm` floor stays authoritative for + * the confirmAction classes regardless of what the filter offers.) */ export function isGovernedTool(def: ToolDef): boolean { if (def.policy.fsWrite === true) return true; if (def.policy.egress !== undefined) return true; + if (def.policy.os === true) return true; // read_clipboard / notify — a governed os action (ADR-0057) if (def.policy.requiresGateApproval === true) return true; return def.policy.spawnsProcess === true && def.policyTarget !== undefined; } diff --git a/apps/cli/src/chat/session-host.test.ts b/apps/cli/src/chat/session-host.test.ts index aaadb75b..bbf6eda6 100644 --- a/apps/cli/src/chat/session-host.test.ts +++ b/apps/cli/src/chat/session-host.test.ts @@ -874,6 +874,26 @@ describe('buildChatSession + 2.5.A tool-host wiring (ADR-0055)', () => { ); }); + it('createChatModeControl ask: denies an OS-class dispatch too (read_clipboard) — the exfil sink is gated', async () => { + // ADR-0057 §security review: read_clipboard reads ambient secret-bearing OS state, so it is a governed os + // action — denied in ask (never advertised, and the confirm floor rejects it if the model calls it anyway). + const workspace = mkdtempSync(join(tmpdir(), 'relavium-ws-')); + const built = await build({ + cwd: workspace, + agentRef: writeAgent(['read_clipboard']), + providers: scriptedResolver([callWithArgs('c1', 'read_clipboard', {})]), + }); + createChatModeControl(built, createChatStore(false)); // ask regime active + built.session.start(); + await built.session.sendMessage('read my clipboard'); + built.session.cancel(); + const events = await drainHandle(built.handle.events); + const completed = events.find((e) => e.type === 'session:turn_completed'); + expect(completed?.type === 'session:turn_completed' ? completed.error?.code : undefined).toBe( + 'tool_denied', + ); + }); + it('createChatModeControl accept-edits: an APPROVE lets the governed write through the store prompt', async () => { const workspace = mkdtempSync(join(tmpdir(), 'relavium-ws-')); const built = await build({ diff --git a/apps/cli/src/engine/tool-host/assemble.ts b/apps/cli/src/engine/tool-host/assemble.ts index c81c006e..97d9f02f 100644 --- a/apps/cli/src/engine/tool-host/assemble.ts +++ b/apps/cli/src/engine/tool-host/assemble.ts @@ -108,10 +108,11 @@ export function assembleToolEnv(opts: AssembleToolEnvOptions): AssembledToolEnv // command. Loosening the chat `allowedCommands` in a future profile is therefore a security-review trigger. process: createNodeProcessCapability({ workspaceDir: opts.workspaceDir }), // egress + os: wired ONLY for the full-capability `chat-read-write` profile (ADR-0057 closes the 2.5.A - // deferral). `egress` rides the fail-closed approval floor (a governed class); `os` (clipboard/notify) is - // non-governed and gated only by the mode's advertise-filter. The read-only chat + workflow profiles wire - // neither: a read-only chat must not reach the network, and the workflow run path's egress/os is a separate - // (author-trusted) concern, not part of the chat approval story. + // deferral). BOTH are GOVERNED classes that ride the fail-closed approval floor — `egress` (an + // exfiltration sink) and `os` (`read_clipboard` reads ambient secret-bearing OS state; `notify` paints a + // native notification), so both are denied in `ask`/`plan` and prompt in `accept-edits`. The read-only chat + // + workflow profiles wire neither: a read-only chat must not reach the network or the clipboard, and the + // workflow run path's egress/os is a separate (author-trusted) concern, not part of the chat approval story. ...(wireEgressOs ? { egress: createNodeEgressCapability( diff --git a/apps/cli/src/engine/tool-host/fs.test.ts b/apps/cli/src/engine/tool-host/fs.test.ts index 72ed3764..e3bde923 100644 --- a/apps/cli/src/engine/tool-host/fs.test.ts +++ b/apps/cli/src/engine/tool-host/fs.test.ts @@ -446,6 +446,18 @@ describe('createNodeFsCapability — protected paths (denied in EVERY mode, auto // platform; its pass-path (an existing non-protected target realpaths cleanly) is exercised by the overwrite // test above. A final-component SYMLINK — the POSIX aliasing vector — is caught earlier by assertNotSymlink. + it('refuses createDirs THROUGH a symlinked ancestor resolving into `.git/` BEFORE any mkdir side-effect', async () => { + // The deepest-existing ancestor is realpath'd + protected-checked BEFORE mkdir, so a symlink/alias ancestor + // resolving into `.git` can't even create an empty subdir there (the Win32 8.3 `GIT~1` case, reproduced on + // POSIX via a symlink). Denied at the ancestor, not merely at the final file. + await mkdir(join(workspace, '.git'), { recursive: true }); + await symlink(join(workspace, '.git'), join(workspace, 'glink')); + await expect( + sandboxed().writeFile('glink/newdir/x', 'y', { createDirs: true }), + ).rejects.toBeInstanceOf(FsScopeDeniedError); + await expect(readdir(join(workspace, '.git', 'newdir'))).rejects.toThrow(); // no empty subdir was created + }); + it('ALLOWS a `.gitignore` FILE — only the `.git` DIRECTORY segment is protected', async () => { const result = await sandboxed().writeFile('.gitignore', 'node_modules\n', {}); expect(result.bytesWritten).toBeGreaterThan(0); diff --git a/apps/cli/src/engine/tool-host/fs.ts b/apps/cli/src/engine/tool-host/fs.ts index e2cf8ea9..5f5db26f 100644 --- a/apps/cli/src/engine/tool-host/fs.ts +++ b/apps/cli/src/engine/tool-host/fs.ts @@ -557,9 +557,14 @@ async function jailWriteTarget( ): Promise<{ realDir: string; finalTarget: string }> { const lexical = lexicalTarget(config, path); const targetDir = dirname(lexical); - // Verify the deepest EXISTING ancestor is in-scope before creating anything, so a symlinked ancestor - // pointing outside the scope is caught before a single byte or stray dir is written there. - assertInScope(inScope, await deepestExistingReal(targetDir)); + // Verify the deepest EXISTING ancestor is in-scope AND not protected before creating anything, so a + // symlinked/aliased ancestor pointing outside the scope — OR resolving INTO a protected dir (a Win32 8.3 + // short name like `GIT~1` → `.git`, which the lexical pre-check misses) — is caught before `mkdir` creates + // even an empty subdir there. This closes the createDirs side-effect the realpath'd finalTarget check alone + // would only catch AFTER the mkdir. + const deepestReal = await deepestExistingReal(targetDir); + assertInScope(inScope, deepestReal); + assertNotProtectedPath(deepestReal); if (createDirs) await mkdir(targetDir, { recursive: true }); // The parent must now exist (created above, or pre-existing) — re-resolve + re-check (tightens the window). const realDir = await realpath(targetDir).catch(() => { diff --git a/packages/core/src/tools/builtins.ts b/packages/core/src/tools/builtins.ts index ac47c8ec..41104e01 100644 --- a/packages/core/src/tools/builtins.ts +++ b/packages/core/src/tools/builtins.ts @@ -137,14 +137,18 @@ const FS_WRITE_POLICY: ToolPolicyClass = { spawnsProcess: false, requiresGateApproval: false, }; +// read_clipboard / notify — a GOVERNED os action (ADR-0057 §security review): the clipboard is ambient, +// un-jailed OS state that routinely holds a freshly-copied secret, so a read is an exfiltration sink, and +// notify paints a native desktop notification; both ride the interactive approval floor. const OS_POLICY: ToolPolicyClass = { fsScoped: false, spawnsProcess: false, + os: true, requiresGateApproval: false, }; -// read_media reads from the host MediaStore via the engine `ctx.mediaRead` delegate (NOT the FS-scope tier, -// NOT an egress) and is read-only, so it needs no guardrail target / gate (ADR-0044 §1; the path-jail + -// scope-set authz + Range gate live inside the dispatch). It bypasses the Phase-2 ActionGuard. +// read_media (via `ctx.mediaRead`) + invoke_agent (via `ctx.invokeAgent`) are delegate-backed, read-only / +// orchestration tools with NO guardrail-class capability — they carry no path/command/host/os target, so they +// are NOT governed (the path-jail / scope authz / sub-agent policy live inside their own dispatch). ADR-0044 §1. const MEDIA_POLICY: ToolPolicyClass = { fsScoped: false, spawnsProcess: false, @@ -552,7 +556,8 @@ const invokeAgentTool = defineBuiltin({ required: ['nodeId'], additionalProperties: false, }, - policy: OS_POLICY, + // A delegate-backed orchestration tool (NOT an os action) — the non-governed delegate policy, not OS_POLICY. + policy: MEDIA_POLICY, dispatch: (args, _host, ctx) => { if (ctx.invokeAgent === undefined) { // Not a ToolHost I/O capability — an engine delegate. Absent ⇒ the same typed unavailable error. diff --git a/packages/core/src/tools/registry.ts b/packages/core/src/tools/registry.ts index 1e849379..a45c38f6 100644 --- a/packages/core/src/tools/registry.ts +++ b/packages/core/src/tools/registry.ts @@ -389,9 +389,10 @@ async function confirmDispatch( * The side-effecting action class a per-tool approval governs, or `undefined` for an un-gated tool. A * model-controlled `run_command` (a resolved `command` target) is `process`; the pre-approved `git_status` * (no command target) is NOT governed — matching `enforcePolicy`, which runs the command allowlist only when - * a command target is present. An fs READ, clipboard, and `invoke_agent` are not governed + * a command target is present. An fs READ and `invoke_agent` are not governed * ([ADR-0041](../../../../docs/decisions/0041-external-action-governance-seam.md) §ActionClass); every egress - * IS, even a read-only `web_search` (an exfiltration sink). + * IS, even a read-only `web_search` (an exfiltration sink); and an `os` action (`read_clipboard` / `notify`) + * IS — the clipboard is ambient, un-jailed OS state that routinely holds a freshly-copied secret (ADR-0057). */ function governedAction(def: ToolDef, target: PolicyTarget): ToolActionClass | undefined { if (def.policy.fsWrite === true) { @@ -403,6 +404,9 @@ function governedAction(def: ToolDef, target: PolicyTarget): ToolActionClass | u if (def.policy.spawnsProcess && target.command !== undefined) { return 'process'; } + if (def.policy.os === true) { + return 'os'; + } return undefined; } @@ -420,9 +424,13 @@ function previewFor(action: ToolActionClass, target: PolicyTarget): ToolActionPr const parsed = extractHttpsHost(target.url); return parsed === null ? {} : { host: parsed.host }; } + case 'os': + // read_clipboard / notify carry no path/command/host target — the action class + the tool id (on the + // approval request) are the whole preview (the prompt reads "Approve read_clipboard?"). + return {}; default: { - // Exhaustiveness guard — a future ToolActionClass member (e.g. an `os` action) fails loud HERE at - // compile time (the `never` assignment) with a precise error, not a generic "not all paths return". + // Exhaustiveness guard — a future ToolActionClass member fails loud HERE at compile time (the `never` + // assignment) with a precise error, not a generic "not all paths return". const exhaustive: never = action; return exhaustive; } diff --git a/packages/core/src/tools/types.ts b/packages/core/src/tools/types.ts index 0706ba23..479ce39b 100644 --- a/packages/core/src/tools/types.ts +++ b/packages/core/src/tools/types.ts @@ -60,6 +60,13 @@ export interface ToolPolicyClass { readonly spawnsProcess: boolean; /** Outbound egress, discriminated by kind — `http_request` / `web_search` / `mcp_call`. */ readonly egress?: EgressKind; + /** + * An OS-integration action — `read_clipboard` (an unjailed read of ambient, secret-bearing OS state) / + * `notify` (a native desktop notification). Governed like the other action classes so the interactive + * approval floor gates it (ADR-0057 §security review): the clipboard is an exfiltration sink no fs jail + * covers, so it must not run unapproved in a read-only mode. Absent/false ⇒ not an os action. + */ + readonly os?: boolean; /** Requires a human-gate approval in an automated workflow before it may execute — `git_commit`. */ readonly requiresGateApproval: boolean; } diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index f426d17d..eb6ec0c0 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -123,13 +123,16 @@ export type RetryableErrorCode = (typeof RETRYABLE_ERROR_CODES)[number]; /** * The side-effecting **tool ACTION classes** a per-tool approval governs (ADR-0057 EA3). Derived from a * tool's `ToolPolicyClass` (tool-registry.md): `fs_write` (a `write_file`), `process` (a model-controlled - * `run_command` — **not** the pre-approved `git_status`, which exposes no model command), and `egress` - * (`http_request` / `web_search` / `mcp_call`). Read-only fs reads, `git_status`, and clipboard are **not** - * governed (mirrors [ADR-0041](../decisions/0041-external-action-governance-seam.md) §ActionClass). Carried - * by `agent:approval_requested` and the engine's `ConfirmActionHook`; it lives here so `@relavium/shared` - * owns the vocabulary and the engine derives its `ToolActionClass` type from this one list (no second home). + * `run_command` — **not** the pre-approved `git_status`, which exposes no model command), `egress` + * (`http_request` / `web_search` / `mcp_call`), and `os` (`read_clipboard` / `notify` — the clipboard is + * ambient, un-jailed OS state that routinely holds a freshly-copied secret, so a READ is an exfiltration + * sink, and `notify` paints a native desktop notification; both are gated like any governed action, ADR-0057 + * §security review). Read-only fs reads + `git_status` are **not** governed (mirrors + * [ADR-0041](../decisions/0041-external-action-governance-seam.md) §ActionClass). Carried by + * `agent:approval_requested` and the engine's `ConfirmActionHook`; it lives here so `@relavium/shared` owns + * the vocabulary and the engine derives its `ToolActionClass` type from this one list (no second home). */ -export const TOOL_ACTION_CLASSES = ['fs_write', 'process', 'egress'] as const; +export const TOOL_ACTION_CLASSES = ['fs_write', 'process', 'egress', 'os'] as const; export type ToolActionClass = (typeof TOOL_ACTION_CLASSES)[number]; /** From b4d6bc2170fb9426f9e1bee257e48d1353d9ed4d Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Thu, 2 Jul 2026 11:09:41 +0300 Subject: [PATCH 25/45] =?UTF-8?q?docs:=202.5.E=20Step=205=20=E2=80=94=20re?= =?UTF-8?q?concile=20docs=20+=20ADR-0057=20=E2=86=92=20Accepted?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CLAUDE.md | 12 ++++++-- ...57-cli-chat-modes-and-per-tool-approval.md | 19 +++++++++++-- docs/decisions/README.md | 2 +- docs/reference/cli/chat-session.md | 18 ++++++++++-- docs/reference/contracts/sse-event-schema.md | 2 +- docs/reference/shared-core/tool-registry.md | 8 ++++-- docs/roadmap/current.md | 12 ++++++-- docs/roadmap/deferred-tasks.md | 28 ++++++++++++++++++- .../phases/phase-2.5-cli-consolidation.md | 17 +++++++++++ 9 files changed, 103 insertions(+), 15 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5a33d09f..e4683b1c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -85,9 +85,15 @@ palette + slash commands in **both** chat and the bare Home, no command in both) `/workflows`, `/cost`, and `/doctor` — fast tier plus a `--deep` tier with a **redacted** provider-key probe + a **read-only** MCP-status report that never connects/spawns (a security-review decision); the `name + args` slash dispatch + a context-aware footer hint-bar) landed **PR #62 (2026-06-30)**, behind [ADR-0056](docs/decisions/0056-cli-in-app-slash-command-system-and-manifest.md); -**the next pickup is 2.5.E** (chat modes + per-tool approval, ADR-0057). For live status, per-PR history, milestone dates, and open -obligations, see the canonical home [docs/roadmap/current.md](docs/roadmap/current.md); [README.md](README.md) -is the public overview. +and **2.5.E** (the reseat-less chat mode system — ask / plan / accept-edits / auto on `Shift+Tab` + `/mode` — the +fail-closed per-tool `confirmAction` floor (`[y]/[a]/[n]` + a session once/always cache), the `Esc` mid-turn abort +(EA7), and the host arms closing the 2.5.A deferral: a write-capable `fs` tier + **protected paths** refused in +every mode incl. `auto`, the SSRF-hardened `egress` arm shared with media, and the `os` arm as a governed action +class — wired live into `relavium chat`, one-shot `agent run`, and the Home, each activating the regime before its +first turn) is 🟡 **implemented + reviewed on `development`; PR pending merge**, behind [ADR-0057](docs/decisions/0057-cli-chat-modes-and-per-tool-approval.md) +(**Accepted** after the mandatory holistic security review). **The next pickup is the 2.5 experience arm (2.5.D / F / G).** +For live status, per-PR history, milestone dates, and open obligations, see the canonical home +[docs/roadmap/current.md](docs/roadmap/current.md); [README.md](README.md) is the public overview. ## Non-negotiable rules for AI agents diff --git a/docs/decisions/0057-cli-chat-modes-and-per-tool-approval.md b/docs/decisions/0057-cli-chat-modes-and-per-tool-approval.md index 2143f375..00ee3a24 100644 --- a/docs/decisions/0057-cli-chat-modes-and-per-tool-approval.md +++ b/docs/decisions/0057-cli-chat-modes-and-per-tool-approval.md @@ -1,10 +1,23 @@ # ADR-0057: Reseat-less chat modes and per-tool approval (with mid-turn abort) -- **Status**: Proposed -- **Date**: 2026-06-28 +- **Status**: Accepted +- **Date**: 2026-06-28 (Accepted 2026-07-02, after the mandatory security review) - **Related**: [ADR-0024](0024-agent-first-entry-point-agentsession.md), [ADR-0029](0029-tool-policy-hardening.md), [ADR-0028](0028-workflow-resource-governance.md), [ADR-0037](0037-engine-tool-execution-boundary.md), [ADR-0041](0041-external-action-governance-seam.md), [ADR-0043](0043-media-egress-failover-rematerialization-ssrf.md), [ADR-0053](0053-mcp-network-transport-egress-security.md), [ADR-0055](0055-cli-host-capability-seam-tool-environment-factory.md), [ADR-0059](0059-cli-mid-session-model-reseat.md), [phase-2.5-cli-consolidation.md](../roadmap/phases/phase-2.5-cli-consolidation.md) (2.5.E), [tool-registry.md](../reference/shared-core/tool-registry.md), [action-guard-seam.md](../reference/shared-core/action-guard-seam.md), [architectural-principles.md](../standards/architectural-principles.md) -> **Draft.** Proposed alongside the Phase 2.5 plan; to be reviewed and finalized (→ Accepted) when workstream 2.5.E begins. **Security review is mandatory before Accept** — and now covers a larger surface than the original draft: the write-capable `fs` tier + protected paths, the SSRF-hardened host `egress` arm, the `os` arm (the 2.5.A deferral closed here), and the maintainer's decision to put `auto` on the `Shift+Tab` cycle (below). Per-tool approval is built as a separate, lighter `confirmAction` primitive that **composes with** the Accepted `ActionGuard` seam ([ADR-0041](0041-external-action-governance-seam.md)) rather than reusing it — see [§Relationship to ADR-0041](#relationship-to-adr-0041-actionguard). +> **Accepted (2.5.E shipped, 2026-07-02).** The mandatory security review ran as a dedicated adversarial pass +> over the whole surface — the write-capable `fs` tier + protected paths, the SSRF-hardened host `egress` arm, +> the `os` arm (the 2.5.A deferral closed here), and the maintainer's decision to put `auto` on the `Shift+Tab` +> cycle. It confirmed the core guarantees hold (no governed dispatch runs ungated on any entry point — chat / +> Home / one-shot `agent run` / resume; no fs-jail escape; no SSRF bypass; no command injection; no secret +> leak) and surfaced two gaps that were fixed before Accept: (1) the `os` arm is now a **governed action class** +> (`read_clipboard` is an un-jailed exfiltration sink, so it rides the approval floor like `egress`, not the +> advertise-filter alone); (2) a Windows 8.3 / symlinked-ancestor `createDirs` empty-subdir side-effect inside a +> protected dir is now refused before the `mkdir`. Per-tool approval is a separate, lighter `confirmAction` +> primitive that **composes with** the Accepted `ActionGuard` seam ([ADR-0041](0041-external-action-governance-seam.md)) +> rather than reusing it — see [§Relationship to ADR-0041](#relationship-to-adr-0041-actionguard). Deferred +> follow-ups (tracked in [../roadmap/deferred-tasks.md](../roadmap/deferred-tasks.md)): the `[c]` +> reject-with-typed-reason prompt, a plain/non-TTY non-interactive approval policy, a live `web_search`/http +> egress credential resolver, and the session-level budget pause/resume that rides the same EA4 machine. ## Context diff --git a/docs/decisions/README.md b/docs/decisions/README.md index ca90674f..6ab11613 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -100,7 +100,7 @@ flowchart TD | 0054 | [Bare `relavium` invocation opens an interactive Home (TTY only), preserving the meta-op contract](0054-cli-bare-invocation-interactive-home.md) | Accepted | 2026-06-29 | | 0055 | [Shared CLI tool-environment factory — `ToolHost`, `ToolPolicy`, and dispatch context as separate channels](0055-cli-host-capability-seam-tool-environment-factory.md) | Accepted | 2026-06-28 | | 0056 | [In-app slash command system driven by a single command manifest](0056-cli-in-app-slash-command-system-and-manifest.md) | Accepted | 2026-06-29 | -| 0057 | [Reseat-less chat modes and per-tool approval (with mid-turn abort)](0057-cli-chat-modes-and-per-tool-approval.md) | Proposed | 2026-06-28 | +| 0057 | [Reseat-less chat modes and per-tool approval (with mid-turn abort)](0057-cli-chat-modes-and-per-tool-approval.md) | Accepted | 2026-06-28 | | 0058 | [`@relavium/authoring` package and the conversational-authoring pre-flight contract](0058-relavium-authoring-package-and-conversational-authoring.md) | Proposed | 2026-06-28 | | 0059 | [Mid-session model switching via host-side reseat (refines ADR-0024)](0059-cli-mid-session-model-reseat.md) | Proposed | 2026-06-28 | | 0060 | [Session `{{ctx.*}}` prompt interpolation](0060-session-ctx-prompt-interpolation.md) | Proposed | 2026-06-28 | diff --git a/docs/reference/cli/chat-session.md b/docs/reference/cli/chat-session.md index 2ca4ec9f..7c89bae8 100644 --- a/docs/reference/cli/chat-session.md +++ b/docs/reference/cli/chat-session.md @@ -49,13 +49,27 @@ A small, **alias-free**, curated set of slash commands drives the REPL itself (n | --- | --- | | `/help` | List the available slash commands (**2.5.C**). Typing `/` at an idle prompt instead opens the interactive, filterable **`/` palette** over these commands (in both the chat and the bare Home). | | `/exit` | End the session cleanly and quit the REPL (**exit code 4**, below). | -| `/cancel` | End the session (aborting any in-flight turn — relevant when entered as **Ctrl-C** mid-turn in TTY mode; a typed `/cancel` runs between turns). In Phase 1 the engine has no per-turn abort that keeps a session alive, so `/cancel` terminates it — but the session is **persisted and resumable** via `relavium chat-resume ` (2.N). Exits with code 4. | +| `/cancel` | End the session **terminally** (aborting any in-flight turn — also entered as **Ctrl-C**). The session is **persisted and resumable** via `relavium chat-resume ` (2.N). Exits with code 4. For a mid-turn abort that KEEPS the session alive, press **Esc** (2.5.E, below) — that is distinct from `/cancel`. | | `/export` | Export the session-so-far to a `.relavium.yaml` scaffold (same ADR-0026 contract as `relavium chat-export`). Writes the file (named `.relavium.yaml`) and reports the path; under `--json` it emits a `session:exported` event on the stream. It does **not** mark the session row `exported` (a later turn's persist would clobber that) — use `relavium chat-export` for the durable provenance mark. **Live (2.P / 2.Q).** | | `/workflows` | List the project's discovered workflows + agents (the disk catalog) as an in-view **notice** (**2.5.C S4**). A project-less cwd is reported, not an error. Chat-only today. | | `/cost` | Show the session's cumulative spend as an in-view **notice** (**2.5.C S4**); the per-model breakdown is Phase 2.6.C. Chat-only. | +| `/mode [name]` | Switch the chat **mode** — `ask` / `plan` / `accept-edits` / `auto` (**2.5.E**, below); bare `/mode` shows the current mode + explains each. `Shift+Tab` cycles them. Chat-only. | | `/doctor` | Run a setup health check as a **notice** (**2.5.C S5**). Fast tier: OS keychain reachable · config valid · wired tool capabilities. `--deep` adds provider-key validation (a bounded, **redacted** live ping per configured key — the key never reaches the output) + the live session's MCP status (the bound agent's connected servers + any tools the manager dropped). The `--deep` MCP tier is **read-only** — it reports the already-connected session, never a fresh connect/spawn (a security-review decision). Available in **both** the chat and the bare Home (pre-chat diagnostics); the Home palette runs the fast tier, `--deep` is typed in a chat. | -An unrecognized `/…` command — or an **undeclared argument** on a known one (`/exit now`) — prints a one-line, secret-free notice and the prompt returns. A command may accept flags (`/doctor --deep`); the `/` palette runs the bare form, so a flag is opt-in by typing it. The idle footer surfaces `/ for commands` at an empty prompt (2.5.C S6). In a TTY, **Ctrl-C** is equivalent to `/cancel` (the `ink` REPL runs in raw mode, so the kernel does not raise SIGINT — the REPL handles it). +An unrecognized `/…` command — or an **undeclared argument** on a known one (`/exit now`) — prints a one-line, secret-free notice and the prompt returns. A command may accept flags (`/doctor --deep`) or a single positional value (`/mode plan`); the `/` palette runs the bare form, so a flag/value is opt-in by typing it. The idle footer surfaces `/ for commands` at an empty prompt (2.5.C S6). In a TTY, **Ctrl-C** is equivalent to `/cancel` (the `ink` REPL runs in raw mode, so the kernel does not raise SIGINT — the REPL handles it). + +## Chat modes + per-tool approval (2.5.E, [ADR-0057](../../decisions/0057-cli-chat-modes-and-per-tool-approval.md)) + +The session's `ToolHost` is bound **full-capability** for its lifetime (fs read+write, process, egress, os); a **mode** is a policy layer on that one instance — **no reseat**, so switching never loses tool context. `Shift+Tab` cycles **`ask → plan → accept-edits → auto`** and `/mode ` jumps directly; the active mode is always shown in the footer. The default is read-only **`ask`**. Two layers enforce it: a best-effort per-turn **advertise-filter** (a governed tool is not offered to the model) and the **authoritative, fail-closed `confirmAction` floor** — so even if the model names a hidden tool, the mode still decides. + +| Mode | Advertised tools | A governed action (write / command / network / clipboard) | +| --- | --- | --- | +| `ask` (default) | read-only (`read_file`, `list_directory`, `git_status`) | **denied** | +| `plan` | read-only | **denied** | +| `accept-edits` | all granted | **prompts** each time — `[y]` yes (once) · `[a]` always (this tool, this session) · `[n]` no · `[esc]` abort | +| `auto` | all granted | **auto-approved** — EXCEPT a **protected-path** write, which still prompts | + +**Governed classes** (what the floor gates): a write (`fsWrite`), any egress (`http_request` / `web_search` / `mcp_call` / a discovered MCP tool), an `os` action (`read_clipboard` — an un-jailed read of ambient, secret-bearing OS state — and `notify`), and a `run_command` with a model-chosen command. Read-only fs reads + `git_status` are never gated. **Protected paths** (`.git/`, `.relavium/`, `.ssh/`, shell-startup files) are refused in **every** mode including `auto` (there is no bypass valve), and no mode escapes the `fs` jail / scope tier. An **`Esc`** mid-turn aborts the in-flight turn but **keeps the session alive** (distinct from `/cancel`): it settles one `session:turn_completed` (an `aborted` stop-reason), rolls back the pending message, and returns to idle. The once/always memory is **in-memory** and per-session — a `chat-resume` re-prompts. The one-shot `relavium agent run` (non-interactive) runs `ask` (governed actions denied — no approver). ## Streaming diff --git a/docs/reference/contracts/sse-event-schema.md b/docs/reference/contracts/sse-event-schema.md index 8eea8ea3..56be48b3 100644 --- a/docs/reference/contracts/sse-event-schema.md +++ b/docs/reference/contracts/sse-event-schema.md @@ -74,7 +74,7 @@ export type RunEvent = | `agent:token` | A streaming LLM token from an agent node. | `nodeId`, `token`, `model` | | `agent:tool_call` | An agent invoked a tool. | `nodeId`, `model` (the invoking model — so a tool call is attributable across a failover), `toolId`, `toolInput` (sanitized — no secrets), `attemptNumber?` (1-based, matches `cost:updated`) | | `agent:tool_result` | A tool returned. | `nodeId`, `toolId`, `success`, `outputSummary` (truncated for UI), `attemptNumber?` | -| `agent:approval_requested` | A side-effecting tool dispatch is awaiting an **interactive per-tool approval** decision (ADR-0057 EA3/EA5). The host's `ConfirmActionHook` emits it just before prompting; the registry then awaits the verdict (approve ⇒ dispatch, reject ⇒ a fatal `tool_denied`). A **dual-envelope** event (`runId`/`sessionId`), like `agent:tool_call` — in Phase 2.5 emitted only on the chat session path (the approval regime), and **carried on the session stream** (not run-only — it is **not** dropped like `agent:file_patch_proposed`). | `nodeId`, `toolId`, `action: 'fs_write' \| 'process' \| 'egress'` (the governed side-effect class — [tool-registry.md](../shared-core/tool-registry.md)), `preview` (**secret-free, display-only**: `{ path? }` for a write, `{ command? }` for a process, `{ host? }` for egress — never a full URL/query, never a secret), `attemptNumber?` | +| `agent:approval_requested` | A side-effecting tool dispatch is awaiting an **interactive per-tool approval** decision (ADR-0057 EA3/EA5). The host's `ConfirmActionHook` emits it just before prompting; the registry then awaits the verdict (approve ⇒ dispatch, reject ⇒ a fatal `tool_denied`). A **dual-envelope** event (`runId`/`sessionId`), like `agent:tool_call` — in Phase 2.5 emitted only on the chat session path (the approval regime), and **carried on the session stream** (not run-only — it is **not** dropped like `agent:file_patch_proposed`). | `nodeId`, `toolId`, `action: 'fs_write' \| 'process' \| 'egress' \| 'os'` (the governed side-effect class — [tool-registry.md](../shared-core/tool-registry.md)), `preview` (**secret-free, display-only**: `{ path? }` for a write, `{ command? }` for a process, `{ host? }` for egress, `{}` for an `os` action like `read_clipboard`/`notify` — never a full URL/query, never a secret), `attemptNumber?` | | `agent:file_patch_proposed` | An agent proposed a file change (**gated — no write until the user accepts**; e.g. the VS Code inline-diff review). | `nodeId`, `patches: [{ uri, unifiedDiff }]` (≥1 — an empty proposal is meaningless), `attemptNumber?` | | `cost:updated` | A node's token cost was tallied (drives the cost waterfall). | `nodeId`, `model`, `inputTokens`, `outputTokens`, `costMicrocents`, `cumulativeCostMicrocents` (integer micro-cents — canonical unit in [llm-provider-seam.md](../shared-core/llm-provider-seam.md#6-usage); **includes realized media spend**, folded as a disjoint addend per [ADR-0044](../../decisions/0044-media-access-governance-read-media-save-to-cost.md) §3 — the per-unit `Usage.mediaUnits` axis is **not yet a field on this event**, deferred, see [deferred-tasks.md](../../roadmap/deferred-tasks.md)), `attemptNumber?` (1-based **within-chain** FallbackChain attempt — resets per node-retry re-dispatch; **distinct** from `node:*.attemptNumber`, see the [two attemptNumber families](#two-attemptnumber-families) note). **Generative-node variant (1.AG Section C, [ADR-0045](../../decisions/0045-async-media-job-loop-poll-checkpoint-resume-cancel.md) §5):** a `media_surface: 'generative'` agent node emits **exactly one** `cost:updated` with `inputTokens` / `outputTokens` **= 0** (no token billing — the spend rides entirely in `costMicrocents` as the per-modality media addend) and **no `attemptNumber`** (no FallbackChain on the generative path — one provider, no failover). | | `node:completed` | A node finished successfully. | `nodeId`, `output`, `tokensUsed: {input, output, model?}` (`model` only for LLM nodes), `durationMs`, `selected?` (a `condition`'s chosen target ids — the authoritative branch record checkpoint/resume restores from, 1.R; **may be an empty array** when the condition routes to no branch, dimming all downstream), `attemptNumber?` (1-based **node-retry** dispatch attempt — 1.S; absent ⇒ attempt 1), `cumulativeCostMicrocents?` (the run-wide running total snapshotted at this node boundary — the durable cost source checkpoint/resume restores from, since `cost:updated` is streamed-only; the engine always populates it. `node:failed` mirrors this field, 2.S/D-GC) | diff --git a/docs/reference/shared-core/tool-registry.md b/docs/reference/shared-core/tool-registry.md index 22ad74b3..1d0ddf0d 100644 --- a/docs/reference/shared-core/tool-registry.md +++ b/docs/reference/shared-core/tool-registry.md @@ -84,6 +84,10 @@ interface ToolPolicyClass { readonly spawnsProcess: boolean; /** Outbound egress — DISCRIMINATED by kind, because the three paths have different policies. */ readonly egress?: 'http' | 'search' | 'mcp'; + /** An OS-integration action — `read_clipboard` (an un-jailed read of ambient, secret-bearing OS state) / + * `notify`. A governed action class (ADR-0057 §security review): gated by the interactive approval floor, + * never merely the advertise-filter. Absent/false ⇒ not an os action. */ + readonly os?: boolean; /** Requires a human-gate approval in an automated workflow before it may execute — `git_commit`. */ readonly requiresGateApproval: boolean; } @@ -166,7 +170,7 @@ host is touched once, in the middle. 2. **Assemble the effective argument set.** Start from the model-supplied args (for an agent tool-call) and/or `ctx.config.input_mapping` (for a `tool` node, where there are no model args), apply `input_mapping`, then merge `configOnlyParams` **last (config wins)**. 3. **Validate the COMPLETE effective set** via `tool.parseArgs` **and** the secret-taint check — `input_mapping`/config-derived values are validated identically to model args; a secret-tainted value reaching a non-credential arg is rejected (0029(c)). A miss → `ToolArgsInvalidError`. 4. **Enforce the guardrail policy on the EFFECTIVE args** (the resolved command / URL is now the real value): exact `allowedCommands` (+ opt-in `allowedCommandGlobs`, deny-all-empty); per egress kind (table below); `git_commit` refused unless `ctx.gateApproved`. A denial → `ToolPolicyError` → **before any host call**. -4b. **Per-tool approval — the interactive consent gate ([ADR-0057](../../decisions/0057-cli-chat-modes-and-per-tool-approval.md) EA3).** Runs only when `ctx.approval` is present (the **interactive-approval regime** — the chat path; absent ⇒ the workflow author-trust path, unchanged) and the dispatch is a **governed class**: `fsWrite`, an `egress` of any kind, or a `spawnsProcess` with a model-controlled `command` target (so the pre-approved `git_status`, which exposes no command, is **not** gated — matching step 4). The engine consults the host-injected `ctx.approval.confirm` hook with a **secret-free preview** (resolved path / command / host — never a full URL/query, never a secret) and, before prompting, the host emits `agent:approval_requested`. **Fail-closed:** under an active regime a governed dispatch *requires* a decision — an **absent `confirm` hook ⇒ denied** (`ToolDeniedByUserError`, reason `no_approval_hook`), never silently allowed, so a wiring bug cannot let `ask` mode write. A reject ⇒ `ToolDeniedByUserError` (reason `user_rejected`) carrying the existing **`tool_denied`** code (fatal, never retried); an abort while prompting routes to the **cancellation** path (cancel precedence). The host's `confirm` owns the mode policy (ask / plan / accept-edits / auto), the once/always cache, and the protected-paths rule — the engine stays mode-agnostic. This composes *after* step 4 and, when an `ActionGuard` ([action-guard-seam.md](action-guard-seam.md)) is also injected, *alongside* it (the org governor's `decide`/`commit` and the user's `confirm` each only further restrict). +4b. **Per-tool approval — the interactive consent gate ([ADR-0057](../../decisions/0057-cli-chat-modes-and-per-tool-approval.md) EA3).** Runs only when `ctx.approval` is present (the **interactive-approval regime** — the chat path; absent ⇒ the workflow author-trust path, unchanged) and the dispatch is a **governed class**: `fsWrite`, an `egress` of any kind, an `os` action (`read_clipboard` / `notify` — the clipboard is an un-jailed exfiltration sink), or a `spawnsProcess` with a model-controlled `command` target (so the pre-approved `git_status`, which exposes no command, is **not** gated — matching step 4). The engine consults the host-injected `ctx.approval.confirm` hook with a **secret-free preview** (resolved path / command / host — never a full URL/query, never a secret) and, before prompting, the host emits `agent:approval_requested`. **Fail-closed:** under an active regime a governed dispatch *requires* a decision — an **absent `confirm` hook ⇒ denied** (`ToolDeniedByUserError`, reason `no_approval_hook`), never silently allowed, so a wiring bug cannot let `ask` mode write. A reject ⇒ `ToolDeniedByUserError` (reason `user_rejected`) carrying the existing **`tool_denied`** code (fatal, never retried); an abort while prompting routes to the **cancellation** path (cancel precedence). The host's `confirm` owns the mode policy (ask / plan / accept-edits / auto), the once/always cache, and the protected-paths rule — the engine stays mode-agnostic. This composes *after* step 4 and, when an `ActionGuard` ([action-guard-seam.md](action-guard-seam.md)) is also injected, *alongside* it (the org governor's `decide`/`commit` and the user's `confirm` each only further restrict). 5. **Call the host capability** (the single side effect), threading `ctx.signal`. Absent capability → `ToolUnavailableError`; an `AbortSignal`-origin failure → the **cancellation** path (`cancelled`, never `tool_failed` — preserves ADR-0036 cancel precedence); any other host throw → `ToolExecutionError`. A host capability MAY itself throw a typed `ToolDispatchError` subclass for a **deterministic host-side denial** (e.g. the CLI `fs` arm throwing a `tool_denied` when a path escapes the FS scope tier, or `ToolUnavailableError` for a read-only fail-close) — the registry passes any `ToolDispatchError` through **verbatim**, exactly as it does an engine-side `ToolPolicyError`, so such a denial is fatal (never burns the node-retry budget); only a *raw* host throw becomes the retryable `ToolExecutionError`. 6. **Apply `output_mapping` to the FULL result** → workflow state gets the real value, never the bounded preview. 7. **Bound the model-facing result** (§Result bounding and spill-to-file) from the result via `ctx.limits` + the host `outputStore` — over the ceiling the model gets a preview + a spill handle, the full result still flows to `output_mapping`. @@ -227,7 +231,7 @@ type ConfirmActionHook = ( interface ToolApprovalRequest { readonly toolId: ToolId; - readonly action: ToolActionClass; // 'fs_write' | 'process' | 'egress' (@relavium/shared TOOL_ACTION_CLASSES) + readonly action: ToolActionClass; // 'fs_write' | 'process' | 'egress' | 'os' (@relavium/shared TOOL_ACTION_CLASSES) readonly preview: ToolActionPreview; } diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md index 474eaa87..10b4bc34 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -150,8 +150,16 @@ in **both** the chat and the bare Home — no command in both); `/help`, the `no **read-only** MCP-status report — a security-review decision: it reports the live session's already-connected servers, never a fresh connect/spawn); plus the `name + args` slash dispatch and a context-aware footer hint-bar surfacing `/ for commands`. Canonically homed in [commands.md](../reference/cli/commands.md) + -[chat-session.md](../reference/cli/chat-session.md). **Next pickup: 2.5.E** (chat modes + per-tool approval, -[ADR-0057](../decisions/0057-cli-chat-modes-and-per-tool-approval.md)). See the +[chat-session.md](../reference/cli/chat-session.md). **2.5.E** (chat modes + per-tool approval + mid-turn abort) +is 🟡 **implemented + reviewed on `development`; PR pending merge**, behind +[ADR-0057](../decisions/0057-cli-chat-modes-and-per-tool-approval.md) (now **Accepted** after the mandatory +security review): the reseat-less mode system (ask / plan / accept-edits / auto on `Shift+Tab` + `/mode`), the +fail-closed per-tool `confirmAction` floor (`[y]/[a]/[n]` + a session once/always cache), the `Esc` mid-turn +abort (EA7), and the host arms closing the 2.5.A deferral — a write-capable `fs` tier + **protected paths** +(refused in every mode incl. `auto`), the SSRF-hardened `egress` arm (shared with media), and the `os` arm (now +a governed action class). Wired live into `relavium chat`, one-shot `agent run`, and the Home (each activates +the regime before its first turn). Every step passed the mandated opus + Sonnet 5 loop plus the dedicated +holistic security review (~50 findings fixed, 4 HIGH). **Next: 2.5.D / F / G** (the experience arm). See the [Phase 2.5 workstreams](phases/phase-2.5-cli-consolidation.md). Carry-over hardening is tracked in [deferred-tasks.md](deferred-tasks.md) — Phase 2 picks diff --git a/docs/roadmap/deferred-tasks.md b/docs/roadmap/deferred-tasks.md index 8af33585..383e6488 100644 --- a/docs/roadmap/deferred-tasks.md +++ b/docs/roadmap/deferred-tasks.md @@ -40,7 +40,7 @@ Severity is the review's verified rating. Check an item off in the PR that resol defined in `packages/core/src/tools/types.ts`). When the desktop or CLI surface implements that fetch hook, it must apply these runtime checks. The current `assertHttpsBaseUrl` and `refineInFlightMediaPart` URL validation are construction-time / seam-ingestion-time policy; they catch malformed URLs but cannot - catch DNS rebinding or a public hostname resolving to a private IP. **Scope split (resolving the earlier "Phase 2" framing):** the **media** url-carrier mechanism is **pulled into 1.AF** on a new bytes-shaped media-egress capability ([ADR-0043](../decisions/0043-media-egress-failover-rematerialization-ssrf.md)); the **general tool/MCP** `EgressCapability.fetch` enforcement still lands when the desktop/CLI surface implements that fetch hook. *(packages/core/src/tools/types.ts; security-review.md; media → 1.AF/ADR-0043; tool/MCP → surface fetch hook)* + catch DNS rebinding or a public hostname resolving to a private IP. **Scope split (resolving the earlier "Phase 2" framing):** the **media** url-carrier mechanism is **pulled into 1.AF** on a new bytes-shaped media-egress capability ([ADR-0043](../decisions/0043-media-egress-failover-rematerialization-ssrf.md)); the **CLI tool** `EgressCapability.fetch` **landed in 2.5.E** ([ADR-0057](../decisions/0057-cli-chat-modes-and-per-tool-approval.md)) — `apps/cli/src/engine/tool-host/egress.ts` over the shared `connectValidated` connect-by-validated-IP mechanism (`packages/db/src/safe-egress.ts`), with the Host/`:authority`-header strip; the **desktop** surface's fetch hook still lands when the desktop implements it. *(packages/core/src/tools/types.ts; security-review.md; media → 1.AF/ADR-0043; CLI tool → 2.5.E/ADR-0057; desktop → surface fetch hook)* - [ ] **MCP SDK network transport — upgrade to connect-by-validated-IP ([ADR-0053](../decisions/0053-mcp-network-transport-egress-security.md) §2).** 2.R ships **pre-connect host validation** as the floor for the `http` (Streamable HTTP) / `websocket` MCP transports — the `@modelcontextprotocol/sdk` opens its **own** socket, architecturally distinct from the `EgressCapability.fetch` hook above. When the SDK transport exposes an injectable `fetch`/dialer hook, upgrade to **connect-by-validated-IP**: resolve DNS → validate the IP against the shared range-block primitive → connect to that IP, re-validating on each redirect hop — closing the residual DNS-rebind window. **The dialer + redirect re-validation MUST enforce the authored `host:port`** (ADR-0053 §3 / SEC-EGRESS-3), not just the host: an `allow_local_endpoint` server is permitted exactly its declared `host:port`, so a resolved/redirected target on a *different* port of the same permitted-private host (`:6379`/`:5432`/`:22`/the Docker socket) must be re-blocked. (2.R's pre-connect floor is host:port-safe by construction — the SDK dials exactly the one authored url — so this constraint binds the dialer, not the floor.) Each MCP network mechanism gets a dedicated security-review pass when it lands. *(packages/mcp/src; ADR-0053 §2/§3; ADR-0043 mechanism)* - [ ] **MCP `stdio` spawn — import-trust/consent gate + `npx` dependency pinning ([ADR-0052](../decisions/0052-inbound-mcp-client-package-lifecycle-registration.md) §2).** Spawning a declared `stdio` MCP server runs arbitrary local code / an `npx`-installed package. 2.R treats a server declared in the user's **own** committed YAML as author trust; the **imported/shared untrusted workflow** case is out of baseline scope. When the import/share path matures, gate the first spawn of a server from an untrusted-provenance `.relavium.yaml` behind explicit consent, and pin the `npx` package version/integrity for the built-in auto-install servers. *(packages/mcp/src; apps/cli; ADR-0052 §2; ADR-0029 trust model)* - [x] **MCP host boundary — strip `McpConnectError.cause` from `--json` / event output (2.R Step 3, [ADR-0052](../decisions/0052-inbound-mcp-client-package-lifecycle-registration.md) §2).** *Resolved in the 2.R Step 3 host wiring:* `startMcpClientFailLoud` (apps/cli/src/engine/mcp-servers.ts) wraps an `McpError` into a typed `CliError` whose message is the secret-free MCP summary with **no** `{ cause }` attached, and the top-level `--json` renderer (apps/cli/src/process/render-error.ts) serializes only `{ type, code, message }` — never `cause`. Regression-locked by `run.test.ts` (`expect(err.cause).toBeUndefined()`). *(apps/cli; packages/mcp/src/errors.ts; 2.R Step 3)* @@ -485,6 +485,32 @@ Severity is the review's verified rating. Check an item off in the PR that resol pass over the session prompt against a `RunScope` built from `context.variables` (deliberately deferred — no surface needed it before 2.Q). *(medium · packages/core/src/engine/agent-session.ts)* +## Phase 2.5.E (chat modes + per-tool approval) follow-ups + +> **2026-07-02 2.5.E ([ADR-0057](../decisions/0057-cli-chat-modes-and-per-tool-approval.md), Accepted).** The +> reseat-less mode system + per-tool approval + mid-turn abort + the host arms shipped. These bounded pieces +> were deliberately deferred (each is additive, none blocks the mode system's security guarantees): + +- [ ] **`[c]` reject-with-typed-reason approval prompt.** The `ApprovalAnswer` reject variant already carries an + optional `reason` (surfaced in the `tool_denied` message), but the REPL prompt only wires `[y]`/`[a]`/`[n]`/`[esc]` + — a `[c]` that captures a free-text comment (a rejection with feedback for the model) is a bounded input-mode + addition to `reduceChatKey` + the ink/Home prompt. *(low · apps/cli/src/render/tui/chat-input.ts + chat-ink.tsx + home-controller.ts)* +- [ ] **Plain / non-TTY non-interactive approval policy.** The interactive `[y]/[a]/[n]` prompt is TTY/controller-only. + A non-TTY chat defaults to `ask` (which denies governed WITHOUT prompting, so no deadlock), and `auto` auto-approves + a non-protected target; the only unhandled niche is a non-TTY session switched to `accept-edits` (or `auto` hitting a + protected path) — its `requestApproval` would publish an unanswerable prompt. Add an explicit non-interactive policy + (deny, or a configured default) for the plain/`--json` driver so that niche is deterministic. *(low · apps/cli/src/commands/chat.ts drivePlain/driveJson)* +- [ ] **Live `web_search` / `http_request` egress credential resolver.** `assembleToolEnv` accepts an + `egressCredentialResolver` and the egress arm attaches it host-side as a Bearer, but the chat/Home session-host does + not yet wire it to the keychain — so a `web_search` needing a provider key currently 401s (surfaced, never a crash). + Wire the provider-key resolver through when the chat surface needs authenticated egress. *(low · apps/cli/src/chat/session-host.ts)* +- [ ] **Session-level budget pause/resume (rides the EA4 machine).** The EA4 pause/resume state landed for mid-turn + abort + approval; the ADR-0028 session budget `pause_for_approval` can now ride the same machine (today a chat + cost-cap trip settles the turn loudly as `budget_exceeded` — the REPL is the approval gate). See also the 1.V + session-budget follow-up above. *(medium · apps/cli/src/chat + agent-session.ts)* +- [ ] **`project`-tier `extraRoots` allowlist (carried from 2.5.A).** The `project` fs tier behaves as + workspace-only until the path-allowlist lands (it can only NARROW the jail, never open a hole). *(low · apps/cli/src/engine/tool-host/assemble.ts)* + ## Phase-2 CLI (2.D) follow-ups > **2026-06-22 2.D (`relavium run`) implementation.** The CLI was wired to `@relavium/core` — the first diff --git a/docs/roadmap/phases/phase-2.5-cli-consolidation.md b/docs/roadmap/phases/phase-2.5-cli-consolidation.md index 10b7d462..1b548a7e 100644 --- a/docs/roadmap/phases/phase-2.5-cli-consolidation.md +++ b/docs/roadmap/phases/phase-2.5-cli-consolidation.md @@ -259,6 +259,23 @@ preserved; zero engine/seam change. ### 2.5.E — Chat modes (reseat-less) + per-tool approval + mid-turn abort +> **Status:** 🟡 **Implemented + reviewed on `development`; PR pending merge** (behind +> [ADR-0057](../../decisions/0057-cli-chat-modes-and-per-tool-approval.md), now **Accepted** after the +> mandatory security review). Shipped: the full reseat-less mode system (ask / plan / accept-edits / auto on +> the `Shift+Tab` cycle + `/mode`), per-tool approval (the fail-closed `confirmAction` floor — `[y]/[a]/[n]` +> with a session once/always cache), mid-turn `Esc` abort (EA7), and the host capability arms that close the +> 2.5.A deferral — a write-capable `fs` tier + **protected paths** (`.git/`/`.relavium/`/`.ssh/` + shell-rc, +> refused in EVERY mode incl. `auto`, Win32-fold / NTFS-ADS / 8.3 / symlink hardened), the SSRF-hardened +> `egress` arm (one shared connect-by-validated-IP mechanism with media; Host-header-strip), and the `os` arm +> (`read_clipboard`/`notify`) — **now a governed action class** so the clipboard exfiltration sink rides the +> approval floor. Wired LIVE into `relavium chat`, the one-shot `agent run`, and the 2.5.B Home (each activates +> the regime before its first turn — no path runs a governed action ungated). Engine amendments EA3/EA4/EA5/EA7 +> landed. Each step went through the mandated loop (opus + Sonnet 5 adversarial review, ~50 findings fixed incl. +> 4 HIGH security bugs) plus the dedicated holistic security review (the Accept gate). **Deferred follow-ups** +> ([../deferred-tasks.md](../deferred-tasks.md)): the `[c]` reject-with-typed-reason prompt, a plain/non-TTY +> non-interactive approval policy, a live `web_search`/http egress credential resolver, and the session-level +> budget pause/resume (rides the EA4 machine). + The capability workstream. Claude-Code-style modes — but a mode is a **policy layer on the same session instance**, not a reseat: the `ToolHost` is bound full-capability for the session lifetime, and the mode controls only (a) the model-advertised tool subset (a per-turn `buildLlmTools` filter) From b4286ff007807001e3e683caa231787a47c07751 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Thu, 2 Jul 2026 11:27:46 +0300 Subject: [PATCH 26/45] =?UTF-8?q?fix:=202.5.E=20Step=205=20=E2=80=94=20add?= =?UTF-8?q?ress=20opus=20review=20(10=20docs/coherence=20findings)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docs/reference/cli/commands.md | 2 +- docs/reference/shared-core/built-in-tools.md | 4 +-- docs/reference/shared-core/tool-registry.md | 5 ++-- .../phases/phase-2.5-cli-consolidation.md | 5 ++-- packages/core/src/tools/registry.test.ts | 27 +++++++++++++++++++ packages/core/src/tools/registry.ts | 2 +- 6 files changed, 37 insertions(+), 8 deletions(-) diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index 36d127aa..80d4fe9b 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -149,7 +149,7 @@ The **command manifest** is the one source the **shell** command surfaces derive ### In-REPL slash commands -The interactive `/` palette + slash commands inside the **Home and chat** are a SEPARATE, **curated** surface ([ADR-0056](../../decisions/0056-cli-in-app-slash-command-system-and-manifest.md) amendment, 2.5.C) — the runtime registry is `apps/cli/src/commands/repl-commands.ts` (`REPL_COMMANDS`), the single source for the palette, the `/help` list, and the unknown-slash hint. It surfaces only the commands that make sense in a live REPL — lifecycle (`/exit`, `/cancel`, `/export`) and info/discovery (`/help`, `/workflows`, `/cost`, `/doctor`; `/clear` is a future addition). The heavy, session-starting shell commands above (`run`, `chat`, `provider`, …) are **never** in-REPL slashes — they stay shell-only (`relavium …`). A bare `/` at an **empty** prompt opens the filterable palette (the footer hint-bar surfaces `/ for commands` exactly there, 2.5.C S6); an unknown slash — or an undeclared argument on a known command (`/exit now`) — prints a sanitized, secret-free hint. A command may declare flags (`/doctor --deep`); the palette runs the bare form, so a flag is opt-in by typing it. There is no separate `/shortcuts` command — the palette renders its own nav hints (`↑/↓ · Enter · Esc`) and the footer surfaces `/`, so keys stay discoverable in context. +The interactive `/` palette + slash commands inside the **Home and chat** are a SEPARATE, **curated** surface ([ADR-0056](../../decisions/0056-cli-in-app-slash-command-system-and-manifest.md) amendment, 2.5.C) — the runtime registry is `apps/cli/src/commands/repl-commands.ts` (`REPL_COMMANDS`), the single source for the palette, the `/help` list, and the unknown-slash hint. It surfaces only the commands that make sense in a live REPL — lifecycle (`/exit`, `/cancel`, `/export`), info/discovery (`/help`, `/workflows`, `/cost`, `/doctor`; `/clear` is a future addition), and — in a chat — `/mode ` to switch the chat mode (2.5.E, [chat-session.md](chat-session.md)). The heavy, session-starting shell commands above (`run`, `chat`, `provider`, …) are **never** in-REPL slashes — they stay shell-only (`relavium …`). A bare `/` at an **empty** prompt opens the filterable palette (the footer hint-bar surfaces `/ for commands` exactly there, 2.5.C S6); an unknown slash — or an undeclared argument on a known command (`/exit now`) — prints a sanitized, secret-free hint. A command may declare flags (`/doctor --deep`) or a single positional value (`/mode plan`); the palette runs the bare form, so a flag/value is opt-in by typing it. There is no separate `/shortcuts` command — the palette renders its own nav hints (`↑/↓ · Enter · Esc`) and the footer surfaces `/`, so keys stay discoverable in context. ### `relavium run` diff --git a/docs/reference/shared-core/built-in-tools.md b/docs/reference/shared-core/built-in-tools.md index 8eb790ca..93ab072c 100644 --- a/docs/reference/shared-core/built-in-tools.md +++ b/docs/reference/shared-core/built-in-tools.md @@ -74,10 +74,10 @@ Every file-touching tool runs under a per-workflow filesystem **scope tier**. Th The active tier is set in project config (see [../contracts/config-spec.md](../contracts/config-spec.md), `fs_scope`) and can be tightened per workflow. The shell allowlist for `run_command` (`spec.tools.allowedCommands` in the workflow — see [workflow-yaml-spec.md](../contracts/workflow-yaml-spec.md#tool-policy-spectools)) is independent of the FS tier — a workflow can be sandboxed *and* still have an empty command allowlist. -> **CLI host (2.5.A, [ADR-0055](../../decisions/0055-cli-host-capability-seam-tool-environment-factory.md)) interim posture:** the Node `fs` capability backing the CLI surfaces enforces the tier with a `realpath` + `commonpath` jail (a `..` traversal or a symlinked component/ancestor that escapes the tier is a **fatal `tool_denied`**, never retried). Until a media store is wired into that arm, `read_file` **fail-closes** on a binary/media file (a clear `tool_failed`) rather than returning a durable handle or inline base64 — the durable-handle path (ADR-0031) is a tracked follow-up. The `relavium chat` default profile wires this arm **read-only**, so `write_file` is `tool_unavailable` there until [ADR-0057](../../decisions/0057-cli-chat-modes-and-per-tool-approval.md)/2.5.E lands the per-tool approval floor. The factory does not yet pass an `extraRoots` allowlist, so the **Project-scoped** tier behaves as **workspace-only** here (it can only narrow the jail, never open a hole — `project` == `sandboxed`-minus-tmp); the path-allowlist arrives with the approval-gated surface in 2.5.E. For the read-only chat surface a declared **Full access** tier is **clamped to Project-scoped** (an unjailed read could exfiltrate `~/.ssh` / `~/.aws/credentials` back to the model); `full` stays intact only for the author-trusted workflow-run profile. +> **CLI host ([ADR-0055](../../decisions/0055-cli-host-capability-seam-tool-environment-factory.md) / [ADR-0057](../../decisions/0057-cli-chat-modes-and-per-tool-approval.md)) posture:** the Node `fs` capability backing the CLI surfaces enforces the tier with a `realpath` + `commonpath` jail (a `..` traversal or a symlinked component/ancestor that escapes the tier is a **fatal `tool_denied`**, never retried). Until a media store is wired into that arm, `read_file` **fail-closes** on a binary/media file (a clear `tool_failed`) rather than returning a durable handle or inline base64 — the durable-handle path (ADR-0031) is a tracked follow-up. Since 2.5.E the `relavium chat` default profile is the **full-capability `chat-read-write`** host — `write_file` / `egress` / `os` ARE wired, but a governed dispatch is gated by the **per-tool approval floor**, not capability absence: the default read-only `ask` mode **denies** them (as `tool_denied`, not `tool_unavailable`); a protected path (`.git/` / `.relavium/` / `.ssh/` / shell-rc) is refused in every mode. The factory does not yet pass an `extraRoots` allowlist, so the **Project-scoped** tier behaves as **workspace-only** here (it can only narrow the jail, never open a hole — `project` == `sandboxed`-minus-tmp); the path-allowlist is a tracked follow-up ([deferred-tasks.md](../../roadmap/deferred-tasks.md)). For the chat surface a declared **Full access** tier is **clamped to Project-scoped** — an unjailed read could exfiltrate `~/.ssh` / `~/.aws/credentials` back to the model, a risk a write-capable chat shares — so it clamps too; `full` stays intact only for the author-trusted workflow-run profile. ## Where tools run -Built-in tools execute inside `@relavium/core`. On the desktop the engine and its tool dispatch run in the **WebView's JS runtime** (a Tauri WebView has no backing Node context); privileged side-effects are delegated to the Rust core through explicit Tauri commands — the authenticated LLM HTTPS egress goes through the `llm_stream` command ([ADR-0018](../../decisions/0018-desktop-execution-and-rust-egress.md)), and `run_command` and similar shell executions spawn real OS child processes through the shell plugin under the allowlist. In the CLI and VS Code surfaces the same tools run in the Node.js host process. What is identical across surfaces is the **engine-level tool contract** — the dispatch semantics and the documented result shapes — because the engine is shared. What varies is the **execution location** (above) and **which host capabilities are wired, and their posture** — e.g. the CLI's read-only `relavium chat` profile, the `read_file` binary/media fail-close until a media store is wired, and the active fs-scope tier. Those are host- and phase-specific and are governed by their authoritative ADRs (the [CLI-host note](#filesystem-permission-tiers) above, [ADR-0055](../../decisions/0055-cli-host-capability-seam-tool-environment-factory.md), [ADR-0057](../../decisions/0057-cli-chat-modes-and-per-tool-approval.md)), **not** restated as a cross-surface guarantee here: a tool that is *available* behaves and returns identically everywhere, while its *availability* is never promised to be uniform. See [../../architecture/execution-model.md](../../architecture/execution-model.md) and [../../architecture/shared-core-engine.md](../../architecture/shared-core-engine.md). +Built-in tools execute inside `@relavium/core`. On the desktop the engine and its tool dispatch run in the **WebView's JS runtime** (a Tauri WebView has no backing Node context); privileged side-effects are delegated to the Rust core through explicit Tauri commands — the authenticated LLM HTTPS egress goes through the `llm_stream` command ([ADR-0018](../../decisions/0018-desktop-execution-and-rust-egress.md)), and `run_command` and similar shell executions spawn real OS child processes through the shell plugin under the allowlist. In the CLI and VS Code surfaces the same tools run in the Node.js host process. What is identical across surfaces is the **engine-level tool contract** — the dispatch semantics and the documented result shapes — because the engine is shared. What varies is the **execution location** (above) and **which host capabilities are wired, and their posture** — e.g. the CLI's approval-gated `chat-read-write` `relavium chat` profile (governed actions denied in the default `ask` mode), the `read_file` binary/media fail-close until a media store is wired, and the active fs-scope tier. Those are host- and phase-specific and are governed by their authoritative ADRs (the [CLI-host note](#filesystem-permission-tiers) above, [ADR-0055](../../decisions/0055-cli-host-capability-seam-tool-environment-factory.md), [ADR-0057](../../decisions/0057-cli-chat-modes-and-per-tool-approval.md)), **not** restated as a cross-surface guarantee here: a tool that is *available* behaves and returns identically everywhere, while its *availability* is never promised to be uniform. See [../../architecture/execution-model.md](../../architecture/execution-model.md) and [../../architecture/shared-core-engine.md](../../architecture/shared-core-engine.md). Because `@relavium/core` has **zero platform-specific imports**, the engine cannot itself touch the filesystem, spawn a process, or open a socket. It owns the tool **policy and dispatch** (registration, exact-match resolution, argument validation, the guardrails below, result bounding, untrusted-data tainting) and performs every side effect through a host-injected **`ToolHost`** capability seam — the same purity seam as the `read_file` interpolation filter ([ADR-0018](../../decisions/0018-desktop-execution-and-rust-egress.md)'s injected transport, generalized). The seam, the dispatch lifecycle, and the `ToolDef` shape are canonical in [tool-registry.md](tool-registry.md); the decision is [ADR-0037](../../decisions/0037-engine-tool-execution-boundary.md). diff --git a/docs/reference/shared-core/tool-registry.md b/docs/reference/shared-core/tool-registry.md index 1d0ddf0d..4995f1d8 100644 --- a/docs/reference/shared-core/tool-registry.md +++ b/docs/reference/shared-core/tool-registry.md @@ -252,8 +252,9 @@ fatal `tool_denied` (reason `approval_error`, fail-closed — consent could not retryable `tool_failed` a *host-capability* throw gets); an absent hook under an active regime ⇒ `tool_denied` (reason `no_approval_hook`). All three carry the existing non-retryable `tool_denied` `ErrorCode`. The action class is derived from `ToolPolicyClass` (§The `ToolDef`): `fsWrite` ⇒ `fs_write`, -any `egress` ⇒ `egress`, and a `spawnsProcess` **with a model-controlled `command` target** ⇒ `process` -(so the pre-approved `git_status`, which exposes no command, is **not** gated). +any `egress` ⇒ `egress`, a `spawnsProcess` **with a model-controlled `command` target** ⇒ `process` +(so the pre-approved `git_status`, which exposes no command, is **not** gated), and `os` ⇒ `os` +(`read_clipboard` — an un-jailed exfiltration sink — / `notify`). ## Guardrail enforcement (policy = engine-pure; mechanism = host) diff --git a/docs/roadmap/phases/phase-2.5-cli-consolidation.md b/docs/roadmap/phases/phase-2.5-cli-consolidation.md index 1b548a7e..6a00c4f3 100644 --- a/docs/roadmap/phases/phase-2.5-cli-consolidation.md +++ b/docs/roadmap/phases/phase-2.5-cli-consolidation.md @@ -4,8 +4,9 @@ > ✅ **Done (PR #60, 2026-06-28)**, behind [ADR-0055](../../decisions/0055-cli-host-capability-seam-tool-environment-factory.md) > — **milestone M2.5-1 (secure base) reached**. Spine continues: **2.5.B** (Home) ✅ → **2.5.C** (slash registry > + palette + `/help`/`/doctor`/`/workflows`/`/cost` + footer hint-bar) ✅ **Done (PR #62, 2026-06-30)** -> → 2.5.E (modes + per-tool approval, **next**). Experience arm (off the spine, depends on B/C): 2.5.D / F / G. Additive -> lanes (no dependency chain): 2.5.H / I / J. +> → 2.5.E (modes + per-tool approval + mid-turn abort) 🟡 **implemented + reviewed; PR pending merge** (ADR-0057 +> Accepted). **Next: the experience arm 2.5.D / F / G** (off the spine, depends on B/C). Additive lanes (no +> dependency chain): 2.5.H / I / J. - **Related**: [../README.md](../README.md), [phase-2-cli.md](phase-2-cli.md), [phase-2.6-conversational-authoring.md](phase-2.6-conversational-authoring.md), [phase-3-desktop.md](phase-3-desktop.md), [../../reference/cli/commands.md](../../reference/cli/commands.md), [../../reference/cli/chat-session.md](../../reference/cli/chat-session.md), [../../reference/cli/regression-harness.md](../../reference/cli/regression-harness.md), [../../decisions/README.md](../../decisions/README.md) (ADR-0054–0057) diff --git a/packages/core/src/tools/registry.test.ts b/packages/core/src/tools/registry.test.ts index 4e02a920..f7fa24be 100644 --- a/packages/core/src/tools/registry.test.ts +++ b/packages/core/src/tools/registry.test.ts @@ -517,6 +517,33 @@ describe('ToolRegistry — per-tool approval (ADR-0057 EA3)', () => { expect(req).toMatchObject({ toolId: 'web_search', action: 'egress', preview: {} }); }); + it('prompts read_clipboard (os) with an empty preview and dispatches on approve (an exfil sink is gated)', async () => { + const confirm = approving(); + const out = await registry().dispatch( + call('read_clipboard', {}), + ctx({ approval: { confirm } }), + ); + expect(out.events.result.success).toBe(true); + const req = confirm.mock.calls[0]?.[0]; + expect(req).toMatchObject({ toolId: 'read_clipboard', action: 'os', preview: {} }); + }); + + it('denies a rejected read_clipboard as fatal tool_denied and never reads the clipboard', async () => { + const readClipboard = vi.fn(() => Promise.resolve('a-secret')); + const host: ToolHost = { + ...stubHost(), + os: { readClipboard, notify: () => Promise.resolve() }, + }; + const err = await rejectsWith( + registry(host).dispatch( + call('read_clipboard', {}), + ctx({ approval: { confirm: rejecting() } }), + ), + ); + expect(err.code).toBe('tool_denied'); + expect(readClipboard).not.toHaveBeenCalled(); // denied BEFORE the host call — no clipboard read + }); + // --- ordering + cancellation --- it('runs the enforcePolicy floor BEFORE approval — a denied run_command never reaches the prompt', async () => { diff --git a/packages/core/src/tools/registry.ts b/packages/core/src/tools/registry.ts index a45c38f6..346d5495 100644 --- a/packages/core/src/tools/registry.ts +++ b/packages/core/src/tools/registry.ts @@ -337,7 +337,7 @@ async function confirmDispatch( } const action = governedAction(def, target); if (action === undefined) { - return; // a read-only / pre-approved tool (fs read, git_status, clipboard, invoke_agent) is never gated + return; // a read-only / pre-approved tool (fs read, git_status, invoke_agent) is never gated } // Fail-closed: an active approval regime with no confirm hook DENIES a governed dispatch — so a wiring bug // (the chat host wired a write arm but not the hook) can never let `ask` mode write. The floor is the hook, From b482dc7bd5eed1c1b71146f680ad65ae93c04ce1 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Thu, 2 Jul 2026 11:46:54 +0300 Subject: [PATCH 27/45] =?UTF-8?q?docs:=202.5.E=20Step=205=20=E2=80=94=20ad?= =?UTF-8?q?dress=20Sonnet=205=20review=20(deferred-tasks=20+=20JSDoc=20rec?= =?UTF-8?q?oncile)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docs/roadmap/deferred-tasks.md | 39 +++++++++++---------------- packages/core/src/tools/types.ts | 8 +++--- packages/shared/src/run-event.test.ts | 2 +- 3 files changed, 21 insertions(+), 28 deletions(-) diff --git a/docs/roadmap/deferred-tasks.md b/docs/roadmap/deferred-tasks.md index 383e6488..4d0fe9f1 100644 --- a/docs/roadmap/deferred-tasks.md +++ b/docs/roadmap/deferred-tasks.md @@ -586,27 +586,19 @@ Severity is the review's verified rating. Check an item off in the PR that resol > **2026-06-28 2.5.A ([ADR-0055](../decisions/0055-cli-host-capability-seam-tool-environment-factory.md)).** > The shared CLI **tool-environment factory** landed the `fs` + `process` `ToolHost` arms behind one -> `assembleToolEnv({ profile, fsScopeTier, workspaceDir })` seam (read-only chat / read-write run), the -> advertise-filter, the `tool_unavailable` (EA1) fail-closed backstop, and real failed-turn usage (EA2). This -> directly advances the 2.D *"CLI `ToolHost` is fail-closed"* item above — the **fs** and **process** halves are -> now wired and security-reviewed; the **egress** and **os** halves remain deferred (below). The items here were -> confirmed by the PR #60 review passes (a 10-lens excellence workflow + two verify-against-code rounds) and -> deliberately **not** taken in-PR — none blocks the milestone. - -- [ ] **`egress` + `os` host arms are still unwired (fail-closed).** 2.5.A wired only `fs`/`process`; a built-in - needing `egress` (`http_request`/`web_search`) or `os` surfaces a clean `tool_unavailable` (EA1). `egress` - lands with [ADR-0057](../decisions/0057-cli-chat-modes-and-per-tool-approval.md)/2.5.E behind the per-tool - approval floor + the existing host-side SSRF primitive (DNS-resolve + connect-by-validated-IP + per-hop - redirect re-validation); `os` follows the same approval-gated path. *(medium · apps/cli/src/engine/tool-host/; - security-review.md; the 2.D ToolHost item above)* +> `assembleToolEnv({ profile, fsScopeTier, workspaceDir })` seam, the advertise-filter, the `tool_unavailable` +> (EA1) fail-closed backstop, and real failed-turn usage (EA2). This directly advanced the 2.D *"CLI `ToolHost` +> is fail-closed"* item above. The `fs`/`process` halves were wired + security-reviewed in 2.5.A; the **`egress` +> and `os` halves + the write-capable chat tier landed in 2.5.E** ([ADR-0057](../decisions/0057-cli-chat-modes-and-per-tool-approval.md)) +> behind the per-tool approval floor (see the resolved items below + the *Phase 2.5.E follow-ups* section). The +> 2.5.A items were confirmed by the PR #60 review passes and deliberately **not** taken in-PR — none blocked the milestone. + +- [x] **`egress` + `os` host arms wired (governed) — RESOLVED in 2.5.E.** *Landed in 2.5.E ([ADR-0057](../decisions/0057-cli-chat-modes-and-per-tool-approval.md), on `development`, PR pending merge):* `apps/cli/src/engine/tool-host/egress.ts` (over the shared `connectValidated` connect-by-validated-IP mechanism, Host/`:authority`-header-strip) + `os.ts`, wired by the `chat-read-write` factory profile as **governed** classes on the fail-closed approval floor (denied in `ask`, prompt in `accept-edits`). *(apps/cli/src/engine/tool-host/; security-review.md)* - [ ] **Project-tier path-allowlist (`extraRoots`) not yet passed by the factory.** The `project` tier therefore behaves as **workspace-only** (it can only narrow the jail, never open a hole — `project` == - `sandboxed`-minus-tmp). The real path-allowlist + the native first-run approval arrive with the approval-gated - surface in 2.5.E. *(medium · apps/cli/src/engine/tool-host/assemble.ts; ADR-0057; built-in-tools.md fs-tier note)* -- [ ] **Write-capable chat is deferred to 2.5.E.** The `relavium chat` default profile is **read-only**, so - `write_file` is `tool_unavailable` and a declared `full` tier is **clamped to `project`** (an unjailed read - could exfiltrate `~/.ssh` / `~/.aws`). Per-tool approval ([ADR-0057](../decisions/0057-cli-chat-modes-and-per-tool-approval.md)) - is the gate that unlocks a write-capable / `full` chat. *(medium · apps/cli/src/chat/session-host.ts; ADR-0057)* + `sandboxed`-minus-tmp). It did **not** land in 2.5.E — carried forward under the *Phase 2.5.E follow-ups* + entry above (single tracking point). *(low · apps/cli/src/engine/tool-host/assemble.ts; ADR-0057; built-in-tools.md fs-tier note)* +- [x] **Write-capable chat — RESOLVED in 2.5.E.** *Landed in 2.5.E ([ADR-0057](../decisions/0057-cli-chat-modes-and-per-tool-approval.md), PR pending merge):* the `relavium chat` default profile is now the full-capability `chat-read-write` host — `write_file` is wired and gated by the per-tool approval floor (denied in the default `ask` mode as `tool_denied`, not `tool_unavailable`); a declared `full` tier is still **clamped to `project`** for the chat surface (an unjailed read exfiltrates `~/.ssh` / `~/.aws`, a write-capable chat shares that risk). *(apps/cli/src/chat/session-host.ts; ADR-0057)* - [ ] **Profile-unaware advertise-filter.** `wiredToolIds` narrows the grant by which `ToolHost` **arm** is wired, not by the profile's *read-only* posture — `write_file` is still advertised on a read-only chat host and only fail-closes at dispatch (`tool_unavailable`). Correct and safe (the dispatch backstop is authoritative), but a @@ -618,10 +610,11 @@ Severity is the review's verified rating. Check an item off in the PR that resol `openat`/`openat2` (nor Linux `RESOLVE_BENEATH`) to pin the parent by fd, so a pure-path open re-walks the (possibly swapped) parents. The window is narrowed to the gap between `jailExisting`/`jailWriteTarget`'s `realpath` and the immediately-following open. Additionally, `O_NOFOLLOW` is `0` on **Windows**, so even the - final-component guard there rests on the pre-op `lstat` alone (the non-race case). Reads are bounded and the - write arm is the author-trusted workflow-run path (chat is read-only), so both residuals are accepted for - 2.5.A; close the parent-swap gap with a native `openat`-based helper (or a Rust-side resolver) if a - write-capable / untrusted-read surface raises the bar. *(low · apps/cli/src/engine/tool-host/fs.ts)* + final-component guard there rests on the pre-op `lstat` alone (the non-race case). Reads are bounded, and the + write arm now serves the approval-gated `chat-read-write` chat as well as the author-trusted workflow-run path; + the **ADR-0057 mandatory security review explicitly re-assessed and accepted** this Windows-only parent-swap + residual for the write-capable surface (the protected-paths refusal + fs jail still hold). Close the gap with a + native `openat`-based helper (or a Rust-side resolver) if an untrusted-read surface raises the bar. *(low · apps/cli/src/engine/tool-host/fs.ts)* - [ ] **Deliberate non-fixes from the PR #60 excellence review (recorded, not bugs).** Each was weighed and skipped with a reason: (a) **no host-arm memoization** (fs scope-checker, process base-env, exec cache) — each would cache a security-relevant `realpath` on an I/O-dominated cold path, defeating the per-call diff --git a/packages/core/src/tools/types.ts b/packages/core/src/tools/types.ts index 479ce39b..1749e755 100644 --- a/packages/core/src/tools/types.ts +++ b/packages/core/src/tools/types.ts @@ -273,8 +273,8 @@ export interface MediaReadAccess { * dependency-inversion pattern as `ToolHost`, so ADR-0037's tool-execution boundary holds: the engine * defines the interface + the invocation point, the host supplies the implementation). The registry * consults it BETWEEN the `enforcePolicy` guardrail floor and the host side-effect, for a GOVERNED-class - * dispatch only (fs_write / a model-controlled process / egress — never a read-only fs / `git_status` / - * clipboard tool). The engine stays mode-agnostic: the host's hook owns the mode policy (ask / plan / + * dispatch only (fs_write / a model-controlled process / egress / an `os` action like `read_clipboard` — + * never a read-only fs read / `git_status` / `invoke_agent`). The engine stays mode-agnostic: the host's hook owns the mode policy (ask / plan / * accept-edits / auto), the once/always cache, the protected-paths rule, and emitting * `agent:approval_requested`; the engine only asks "may this governed action proceed?" and honors it. * ------------------------------------------------------------------------------------------------ */ @@ -335,8 +335,8 @@ export interface ToolDispatchContext { readonly gateApproved: boolean; /** * Per-tool approval regime (ADR-0057 EA3). PRESENT ⇒ the interactive-approval (chat) path: a - * governed-class dispatch (fs_write / a model-controlled process / egress) requires a `confirm` decision, - * and an absent `confirm` is fail-closed → denied. ABSENT ⇒ the workflow author-trust path, unchanged. + * governed-class dispatch (fs_write / a model-controlled process / egress / an `os` action) requires a + * `confirm` decision, and an absent `confirm` is fail-closed → denied. ABSENT ⇒ the workflow author-trust path, unchanged. */ readonly approval?: ToolApprovalContext; /** Names of effective-arg keys that are secret-tainted (ADR-0029(c)) — rejected from non-credential args. */ diff --git a/packages/shared/src/run-event.test.ts b/packages/shared/src/run-event.test.ts index 66177f53..39cad887 100644 --- a/packages/shared/src/run-event.test.ts +++ b/packages/shared/src/run-event.test.ts @@ -232,7 +232,7 @@ const reject: Record> = { ...env, nodeId: 'n', toolId: 'write_file', - action: 'fs_read', // not a governed action class (only fs_write | process | egress) + action: 'fs_read', // not a governed action class (only fs_write | process | egress | os) preview: { path: './out.txt' }, }, 'agent:approval_requested (empty toolId)': { From 3c976df8f57eabc1b8d141798569674022724ccc Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Thu, 2 Jul 2026 13:00:42 +0300 Subject: [PATCH 28/45] =?UTF-8?q?fix(cli):=202.5.E=20F1=20=E2=80=94=20clos?= =?UTF-8?q?e=20the=20fs=20hard-link/FIFO/read-secret=20gaps=20(PR=20#63=20?= =?UTF-8?q?review)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/cli/src/engine/tool-host/fs.test.ts | 126 +++++++++++++++++++ apps/cli/src/engine/tool-host/fs.ts | 150 ++++++++++++++++++++--- 2 files changed, 261 insertions(+), 15 deletions(-) diff --git a/apps/cli/src/engine/tool-host/fs.test.ts b/apps/cli/src/engine/tool-host/fs.test.ts index e3bde923..bcd65c63 100644 --- a/apps/cli/src/engine/tool-host/fs.test.ts +++ b/apps/cli/src/engine/tool-host/fs.test.ts @@ -1,5 +1,6 @@ import { execFileSync } from 'node:child_process'; import { + link, mkdir, mkdtemp, readdir, @@ -156,6 +157,25 @@ describe('createNodeFsCapability — read (jailed)', () => { }, 10_000, ); + + itPosix('refuses to read a hard-linked file that aliases an out-of-jail inode — FATAL tool_denied', async () => { + // The single POSIX aliasing vector the symlink jail misses: a hard link inside the workspace to an outside + // secret. realpath keeps it in-scope, but the fd's nlink > 1 refuses it before any byte is read. + await writeFile(join(outside, 'secret.txt'), 'TOP SECRET'); + await link(join(outside, 'secret.txt'), join(workspace, 'notes.txt')); + const err: unknown = await sandboxed() + .readFile('notes.txt', {}) + .catch((e: unknown) => e); + expect(err).toBeInstanceOf(FsScopeDeniedError); + if (err instanceof FsScopeDeniedError) expect(err.retryable).toBe(false); + }); + + itPosix('the full (unjailed) tier reads a hard-linked file — benign in-scope store links stay readable', async () => { + await writeFile(join(outside, 'lib.txt'), 'LIB'); + await link(join(outside, 'lib.txt'), join(workspace, 'lib.txt')); + const fs = createNodeFsCapability({ tier: 'full', workspaceDir: workspace, readOnly: false }); + expect((await fs.readFile('lib.txt', {})).content).toBe('LIB'); + }); }); /** @@ -210,6 +230,25 @@ describe('readJailedFile — the post-jail fd read guard', () => { }, 10_000, ); + + itPosix('flags a HARD-LINKED regular file as `aliased` (st.nlink > 1) — the exfiltration-alias guard', async () => { + // realpath resolves symlinks but NOT hard links; a hard link INSIDE the jail can share its inode with a name + // OUTSIDE it, so "realpath ∈ scope" no longer implies "content ∈ scope". The fd's st.nlink > 1 is the + // race-free signal — refused like a symlink. (link() needs the same filesystem; workspace+outside share one.) + await writeFile(join(outside, 'secret.txt'), 'TOP SECRET'); + const alias = join(workspace, 'innocent.txt'); + await link(join(outside, 'secret.txt'), alias); // hard link — realpath(alias) === alias, but nlink === 2 + expect((await readJailedFile(alias, 1 << 20)).kind).toBe('aliased'); + }); + + itPosix('reads a hard-linked file when rejectAliased is false (the full/unjailed tier — pnpm store links)', async () => { + await writeFile(join(outside, 'store.txt'), 'STORE'); + const alias = join(workspace, 'dep.txt'); + await link(join(outside, 'store.txt'), alias); + const r = await readJailedFile(alias, 1 << 20, false); + expect(r.kind).toBe('file'); + if (r.kind === 'file') expect(r.bytes.toString('utf8')).toBe('STORE'); + }); }); describe('createNodeFsCapability — glob read', () => { @@ -337,6 +376,32 @@ describe('createNodeFsCapability — write (read-write profile)', () => { expect(await readFile(join(outside, 'target.txt'), 'utf8')).toBe('original'); // never clobbered }); + itPosix('refuses to APPEND to a HARD-LINKED file — the aliasing bypass of the protected-paths floor', async () => { + // A hard link is not a symlink (assertNotSymlink passes) and its own path has no protected segment + // (assertNotProtectedPath passes), yet its inode may be a stand-in ~/.ssh/authorized_keys. The post-open + // fstat (st.nlink > 1, race-free on the fd) refuses it before O_APPEND writes through the shared inode. + await writeFile(join(outside, 'authorized_keys'), 'original'); + await link(join(outside, 'authorized_keys'), join(workspace, 'notes.txt')); + await expect( + sandboxed().writeFile('notes.txt', 'ssh-rsa AAAA-injected', { append: true }), + ).rejects.toBeInstanceOf(FsScopeDeniedError); + expect(await readFile(join(outside, 'authorized_keys'), 'utf8')).toBe('original'); // never appended + }); + + itPosix( + 'refuses to APPEND to a FIFO WITHOUT blocking — a reader-less pipe fails closed, never hangs the process', + async () => { + // The append open lacked O_NONBLOCK, so a reader-less FIFO blocked the write forever (unkillable — fs.open + // takes no AbortSignal). O_NONBLOCK now makes the reader-less open return ENXIO immediately → fatal denial. + // If this regresses to a blocking open, the test times out (fail) rather than hanging the suite forever. + execFileSync('mkfifo', [join(workspace, 'pipe')]); + await expect( + sandboxed().writeFile('pipe', 'data', { append: true }), + ).rejects.toBeInstanceOf(FsScopeDeniedError); + }, + 10_000, + ); + it('cleans up the temp file when the atomic publish fails — no `.relavium-write.*.tmp` orphan', async () => { // Force the rename to fail by pointing the final target at an existing NON-EMPTY directory: `rename(tmp, // dir)` throws (ENOTEMPTY/EISDIR/EEXIST). The write must reject, AND the `finally` must remove the temp it @@ -480,6 +545,67 @@ describe('createNodeFsCapability — protected paths (denied in EVERY mode, auto }); }); +describe('createNodeFsCapability — sensitive-read floor (credential/secret stores refused) — ADR-0057 review', () => { + it('refuses to read inside `.ssh/` (private keys / authorized_keys / known_hosts)', async () => { + await mkdir(join(workspace, '.ssh'), { recursive: true }); + await writeFile(join(workspace, '.ssh', 'id_rsa'), 'PRIVATE KEY'); + await expect(sandboxed().readFile('.ssh/id_rsa', {})).rejects.toBeInstanceOf(FsScopeDeniedError); + }); + + it('refuses to read a repo-local `.git/config` but ALLOWS a plain file named `config`', async () => { + await mkdir(join(workspace, '.git'), { recursive: true }); + await writeFile(join(workspace, '.git', 'config'), '[remote]\n url = https://u:tok@h/r'); + await expect(sandboxed().readFile('.git/config', {})).rejects.toBeInstanceOf(FsScopeDeniedError); + // A file named `config` NOT under `.git/` is not a credential store — read is allowed. + await writeFile(join(workspace, 'config'), 'plain-config'); + expect((await sandboxed().readFile('config', {})).content).toBe('plain-config'); + }); + + it('refuses the credential dotfiles (.gitconfig / .netrc / .npmrc) and inside `.relavium/`', async () => { + for (const f of ['.gitconfig', '.netrc', '.npmrc']) { + await writeFile(join(workspace, f), 'token=secret'); + await expect(sandboxed().readFile(f, {})).rejects.toBeInstanceOf(FsScopeDeniedError); + } + await mkdir(join(workspace, '.relavium'), { recursive: true }); + await writeFile(join(workspace, '.relavium', 'keys.json'), '{"k":"v"}'); + await expect(sandboxed().readFile('.relavium/keys.json', {})).rejects.toBeInstanceOf( + FsScopeDeniedError, + ); + }); + + it('ALLOWS a normal `.gitignore` — only the credential set is sensitive, not every dotfile', async () => { + await writeFile(join(workspace, '.gitignore'), 'node_modules\n'); + expect((await sandboxed().readFile('.gitignore', {})).content).toBe('node_modules\n'); + }); + + it('is tier-independent — the full (unjailed) tier still refuses a `.ssh/` read', async () => { + await mkdir(join(workspace, '.ssh'), { recursive: true }); + await writeFile(join(workspace, '.ssh', 'id_rsa'), 'KEY'); + const fs = createNodeFsCapability({ tier: 'full', workspaceDir: workspace, readOnly: false }); + await expect(fs.readFile('.ssh/id_rsa', {})).rejects.toBeInstanceOf(FsScopeDeniedError); + }); + + it('a glob never surfaces a sensitive match but returns the safe ones', async () => { + await writeFile(join(workspace, 'a.txt'), 'SAFE'); + await mkdir(join(workspace, '.ssh'), { recursive: true }); + await writeFile(join(workspace, '.ssh', 'known_hosts.txt'), 'SECRET-HOST'); + const r = await sandboxed().readFile('**/*.txt', { glob: true }); + expect(r.content).toContain('SAFE'); + expect(r.content).not.toContain('SECRET-HOST'); + }); + + it('list_directory refuses a sensitive ROOT and skips a nested sensitive entry in a recursive walk', async () => { + await mkdir(join(workspace, '.ssh'), { recursive: true }); + await writeFile(join(workspace, '.ssh', 'id_rsa'), 'KEY'); + await expect(sandboxed().listDirectory('.ssh', {})).rejects.toBeInstanceOf(FsScopeDeniedError); + await writeFile(join(workspace, 'top.txt'), 'x'); + const { entries } = await sandboxed().listDirectory('.', { recursive: true }); + const names = entries.map((e) => e.name); + expect(names).toContain('top.txt'); + expect(names.some((n) => n.includes('.ssh'))).toBe(false); // neither the dir nor its contents are listed + }); +}); + describe('createNodeFsCapability — read-only profile (2.5.A chat)', () => { it('fail-closes write_file as ToolUnavailableError (→ tool_unavailable), never touching disk', async () => { const fs = sandboxed({ readOnly: true }); diff --git a/apps/cli/src/engine/tool-host/fs.ts b/apps/cli/src/engine/tool-host/fs.ts index 5f5db26f..6fa1630f 100644 --- a/apps/cli/src/engine/tool-host/fs.ts +++ b/apps/cli/src/engine/tool-host/fs.ts @@ -150,13 +150,23 @@ async function readOne( } const inScope = await buildScopeChecker(config); const real = await jailExisting(config, inScope, path); - const result = await readJailedFile(real, maxReadBytes); + // The confidentiality floor: refuse to read a secret/credential store (.ssh, .relavium, .git/config, the + // credential dotfiles) in EVERY mode and tier — checked on the realpath'd target so a symlink/alias into one + // is caught. Reads flow into the model's context (then to the provider), so this mirrors, on the read side, + // the protected-paths WRITE floor's mode/tier independence (ADR-0057). + assertNotSensitiveReadPath(real); + const result = await readJailedFile(real, maxReadBytes, config.tier !== 'full'); if (result.kind === 'directory') { throw new FsCapabilityError('read_file: the path is a directory, not a file'); } if (result.kind === 'special') { throw new FsCapabilityError('read_file: the path is not a regular file'); } + if (result.kind === 'aliased') { + throw new FsScopeDeniedError( + 'read_file: refusing to read a hard-linked file (its content may be shared with a file outside the sandbox)', + ); + } if (result.kind === 'binary') { // The durable-media-handle path (ADR-0031) needs a wired media store; the 2.5.A fs arm has none, so a // binary/media file fail-closes (never inline base64) rather than corrupting the text channel. @@ -196,10 +206,16 @@ async function readGlob( // can neither redirect the read nor make the size charged diverge from the bytes read. The bounded prefix // probe skips a binary match WITHOUT charging the budget or loading it; the budget is enforced against the // fd's own size BEFORE the full read, so an over-budget text file is never loaded just to be rejected. - const result = await readJailedFile(m.real, maxReadBytes - totalBytes); - // skip a non-text match (dir / special / binary); never charge the budget. (collectGlobFiles already filters - // to regular files, so `special` is belt-and-suspenders — a swap to a FIFO after the walk would land here.) - if (result.kind === 'directory' || result.kind === 'special' || result.kind === 'binary') + const result = await readJailedFile(m.real, maxReadBytes - totalBytes, config.tier !== 'full'); + // skip a non-text match (dir / special / aliased / binary); never charge the budget. (collectGlobFiles + // already filters to regular, non-hard-linked, non-sensitive files, so these are belt-and-suspenders — a + // swap to a FIFO or a hard link after the walk would land here.) + if ( + result.kind === 'directory' || + result.kind === 'special' || + result.kind === 'aliased' || + result.kind === 'binary' + ) continue; if (result.kind === 'oversize') { throw new FsCapabilityError( @@ -224,10 +240,11 @@ async function readGlob( /** Prefix length for binary detection — a NUL byte in the first 8 KiB marks a non-text file (the git convention). */ const BINARY_PROBE_BYTES = 8192; -/** The outcome of a single jailed read: a directory, a non-regular/binary/oversize fail-class, or text + stat. */ +/** The outcome of a single jailed read: a directory, a non-regular/aliased/binary/oversize fail-class, or text + stat. */ export type JailedRead = | { kind: 'directory' } | { kind: 'special' } + | { kind: 'aliased' } | { kind: 'binary' } | { kind: 'oversize'; size: number } | { kind: 'file'; bytes: Buffer; size: number; mtimeMs: number }; @@ -245,14 +262,29 @@ export type JailedRead = * cancelled) — and the fstat below then fails any non-regular file closed. `sizeLimit` bounds the read so an * over-budget file is never loaded just to be rejected. * + * HARD-LINK ALIASING (`rejectAliased`): `realpath()` resolves symlinks but NOT hard links, so a regular file + * INSIDE the jail can be a second name for an inode whose OTHER name is OUTSIDE the jail (an SSH key) — the + * "realpath ∈ scope ⇒ content ∈ scope" invariant the jail relies on does not hold for a hard link, and neither + * `O_NOFOLLOW` (guards only a symlinked final component) nor the scope check (sees only the in-scope name) catches + * it. `st.nlink > 1` on the OPENED fd is the race-free signal (same inode the bytes come from); a hard-linked + * regular file is refused (`kind: 'aliased'`) exactly like a symlink. Gated on `isFile()` (a directory + * legitimately has `nlink > 1`). `rejectAliased` is `false` only for the unjailed `full` tier — where a benign + * in-scope hard link (e.g. a pnpm content-store link under `node_modules`) is read normally; the jailed tiers + * refuse it, trading that read for the aliasing-exfiltration guarantee. + * * CAVEAT (binary heuristic): a NUL-free file in a legacy single-byte encoding (Latin-1 / CP1252 / MacRoman) * passes as "text" and is decoded as UTF-8, yielding U+FFFD replacements for its high bytes — an accepted v1 * limitation (the durable-media-handle path that would carry such files faithfully is the deferred follow-up). * - * Exported for direct security testing of the post-jail fd guard (the no-follow / single-fd properties), which a - * black-box `readFile` test cannot reach because the jail already resolves any symlink to its canonical target. + * Exported for direct security testing of the post-jail fd guard (the no-follow / single-fd / no-hard-link + * properties), which a black-box `readFile` test cannot reach because the jail already resolves any symlink to + * its canonical target. */ -export async function readJailedFile(real: string, sizeLimit: number): Promise { +export async function readJailedFile( + real: string, + sizeLimit: number, + rejectAliased = true, +): Promise { const handle = await open( real, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK, @@ -276,6 +308,10 @@ export async function readJailedFile(real: string, sizeLimit: number): Promise 1) return { kind: 'aliased' }; const probeLen = Math.min(BINARY_PROBE_BYTES, st.size); if (probeLen > 0) { const probe = Buffer.allocUnsafe(probeLen); @@ -354,7 +390,12 @@ const PROTECTED_RC_BASENAMES: ReadonlySet = new Set([ * time) — so `.BASHRC` / `.bashrc.` / `.git ` / `.gitconfig::$DATA` all fold to the bare protected name. */ function foldPathComponent(name: string): string { const beforeStream = name.split(':', 1)[0] ?? name; // NTFS Alternate-Data-Stream / drive qualifier - return beforeStream.toLowerCase().replace(/[. ]+$/u, ''); + const lowered = beforeStream.toLowerCase(); + // Strip trailing dots/spaces (Win32 drops them at open time) via a linear scan — NOT a `/[. ]+$/` regex, whose + // `+$` backtracks (flagged by the static analyzer) and would break this file's no-backtracking-RegExp posture. + let end = lowered.length; + while (end > 0 && (lowered[end - 1] === '.' || lowered[end - 1] === ' ')) end -= 1; + return lowered.slice(0, end); } /** @@ -384,6 +425,48 @@ function assertNotProtectedPath(absoluteTarget: string): void { throw new FsScopeDeniedError('write_file: refusing to write a shell startup file'); } +/** + * Directory segments whose contents are refused to READ in every mode/tier — a whole `.ssh/` (private keys, + * `authorized_keys`, `known_hosts`) and `.relavium/` (this CLI's local config/secrets dir). Narrower than the + * write floor's {@link PROTECTED_DIR_SEGMENTS} (which also blocks whole `.git/` for WRITE-side hook RCE, a + * concern that does not apply to a read) — reads leak CONFIDENTIALITY, so the read floor targets the + * secret/credential stores specifically. + */ +const SENSITIVE_READ_DIR_SEGMENTS: ReadonlySet = new Set(['.ssh', '.relavium']); +/** Credential/secret dotfiles refused to READ by basename (global git creds, npm/pypi/pg/netrc tokens). */ +const SENSITIVE_READ_BASENAMES: ReadonlySet = new Set([ + '.gitconfig', // user-global git config — `[credential]`, insteadOf URLs with embedded tokens + '.netrc', + '.npmrc', // `_authToken` + '.pypirc', + '.pgpass', +]); + +/** + * Whether an absolute path is a secret/credential store that must never be read into the model's context (and + * thence to the provider): under a `.ssh`/`.relavium` segment, a repo-local `.git/config` (embeds remote-URL + * credentials), or a credential dotfile. The read-side confidentiality analogue of {@link isProtectedPath}; + * exported for direct testing. Folds each component like the write floor (NTFS ADS / case / trailing dot-space). + */ +export function isSensitiveReadPath(absoluteTarget: string): boolean { + const folded = absoluteTarget.split(sep).map(foldPathComponent); + for (const segment of folded) { + if (SENSITIVE_READ_DIR_SEGMENTS.has(segment)) return true; + } + // A repo-local `.git/config` (any `.git` segment with a `config` basename, incl. submodule `.git/modules/*/config`). + if (folded.includes('.git') && foldPathComponent(basename(absoluteTarget)) === 'config') return true; + return SENSITIVE_READ_BASENAMES.has(foldPathComponent(basename(absoluteTarget))); +} + +/** Deny a read of a secret/credential store (`.ssh`/`.relavium`, `.git/config`, a credential dotfile). FATAL. */ +function assertNotSensitiveReadPath(absoluteTarget: string): void { + if (isSensitiveReadPath(absoluteTarget)) { + throw new FsScopeDeniedError( + 'refusing to read a credential/secret store (.ssh / .relavium / .git config / a credential dotfile)', + ); + } +} + async function writeOne( config: NodeFsCapabilityConfig, path: string, @@ -424,21 +507,51 @@ async function writeOne( // `assertNotSymlink` lstat above and the write (a swapped-in symlink can't redirect the append out of // scope). A symlink there fails ELOOP/ENOTDIR, which we map to the FATAL `tool_denied` (not the retryable // `tool_failed` `guarded` would otherwise assign a raw fs error). NOTE: `O_NOFOLLOW` is `0` on Windows (no - // kernel enforcement); the `assertNotSymlink` lstat above still covers the non-race case. The write arm now - // also serves the approval-gated `chat-read-write` profile (ADR-0057), not only the author-trusted - // workflow-run path; the residual Windows-only symlink-swap race is flagged for the mandatory ADR-0057 - // security review (Step 5) to re-affirm as accepted — the protected-paths floor + fs jail still hold. + // kernel enforcement); the `assertNotSymlink` lstat above still covers the non-race case. `O_NONBLOCK` opens + // a FIFO/device WITHOUT blocking — a reader-less FIFO would otherwise hang the write FOREVER (fs.open takes + // no AbortSignal, so even the EA7 mid-turn abort could not cancel it), and the fstat below fails it closed. + // The write arm now also serves the approval-gated `chat-read-write` profile (ADR-0057), not only the + // author-trusted workflow-run path; the residual Windows-only symlink-swap race is flagged for the mandatory + // ADR-0057 security review (Step 5) to re-affirm as accepted — the protected-paths floor + fs jail still hold. const handle = await open( finalTarget, - constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW, + constants.O_WRONLY | + constants.O_APPEND | + constants.O_CREAT | + constants.O_NOFOLLOW | + constants.O_NONBLOCK, ).catch((error: unknown) => { const code = errnoCode(error); if (code === 'ELOOP' || code === 'ENOTDIR') { throw new FsScopeDeniedError('write_file: refusing to write through a symlink'); } + if (code === 'ENXIO') { + // O_WRONLY | O_NONBLOCK on a reader-less FIFO returns ENXIO IMMEDIATELY (instead of blocking forever) — + // a non-regular target we refuse anyway; surface the same fatal denial the post-open fstat would give. + throw new FsScopeDeniedError( + 'write_file: refusing to append to a non-regular file (a FIFO/device/socket)', + ); + } throw error; }); try { + // Race-free guards on the OPENED inode (same fd — no TOCTOU vs. a path re-stat): (1) refuse a NON-REGULAR + // target — a reader-less FIFO/device would hang the write forever (O_NONBLOCK above returned the fd so this + // fstat can run); (2) refuse a HARD-LINKED regular file (st.nlink > 1) — lstat, realpath, and the + // protected-path fold all see only this one in-scope, non-symlink name, but the shared inode may ALSO be a + // name outside the jail or a protected file (~/.ssh/authorized_keys), and O_APPEND would write straight + // through to it, defeating the protected-paths floor. Both mirror the read-side fd guards (readJailedFile). + const st = await handle.stat(); + if (!st.isFile()) { + throw new FsScopeDeniedError( + 'write_file: refusing to append to a non-regular file (a FIFO/device/socket)', + ); + } + if (st.nlink > 1) { + throw new FsScopeDeniedError( + 'write_file: refusing to append to a hard-linked file (its content may be shared with a file outside the sandbox)', + ); + } await handle.write(bytes); } finally { await handle.close(); @@ -481,6 +594,9 @@ async function listOne( throwIfAborted(signal); const inScope = await buildScopeChecker(config); const realRoot = await jailExisting(config, inScope, path); + // Refuse to enumerate a credential/secret store (recon of key/secret filenames) — the read-side confidentiality + // floor, applied to the requested root and to each nested entry the recursive walk reaches below. + assertNotSensitiveReadPath(realRoot); const rootInfo = await stat(realRoot); if (!rootInfo.isDirectory()) { throw new FsCapabilityError('list_directory: the path is not a directory'); @@ -490,6 +606,7 @@ async function listOne( const entries: DirEntry[] = []; 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 if (matcher !== undefined && !matcher(rel)) return true; const info = await lstat(real).catch(() => undefined); if (info === undefined) return true; // a vanished/inaccessible entry is skipped, never fatal @@ -690,6 +807,9 @@ async function collectGlobFiles( // Defense in depth: a matched FILE is realpath-jailed (a symlinked file could still point outside scope). const realResolved = await realpath(real).catch(() => undefined); if (realResolved === undefined || !inScope(realResolved)) return true; // skip, never read out of scope + // Never surface a credential/secret store through a glob (the sensitive-read floor); the per-fd hard-link + // guard in readJailedFile then still refuses an aliased match the readGlob loop reaches. + if (isSensitiveReadPath(realResolved)) return true; // stat the RESOLVED target (not the walk path) so an in-scope symlink TO a regular file still reads — a // bare `lstat(real)` on a symlink reports `isFile() === false` and would wrongly drop a legitimate match. const info = await stat(realResolved).catch(() => undefined); From eed6b0d9d4ff2b6f93893ba84b80f77b5882e3d3 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Thu, 2 Jul 2026 13:20:43 +0300 Subject: [PATCH 29/45] =?UTF-8?q?fix(cli):=202.5.E=20F1=20=E2=80=94=20addr?= =?UTF-8?q?ess=20opus=20review=20(pnpm=20read=20escape,=20catalogue,=20tes?= =?UTF-8?q?ts)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/cli/src/engine/tool-host/fs.test.ts | 90 +++++++++++++++++++++--- apps/cli/src/engine/tool-host/fs.ts | 64 +++++++++++++---- 2 files changed, 129 insertions(+), 25 deletions(-) diff --git a/apps/cli/src/engine/tool-host/fs.test.ts b/apps/cli/src/engine/tool-host/fs.test.ts index bcd65c63..0dabb093 100644 --- a/apps/cli/src/engine/tool-host/fs.test.ts +++ b/apps/cli/src/engine/tool-host/fs.test.ts @@ -1,8 +1,10 @@ import { execFileSync } from 'node:child_process'; +import { constants } from 'node:fs'; import { link, mkdir, mkdtemp, + open, readdir, readFile, realpath, @@ -11,7 +13,7 @@ import { writeFile, } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { dirname, join } from 'node:path'; /** POSIX-only tests (a FIFO is created via `mkfifo`); Windows has no equivalent special file. */ const itPosix = process.platform === 'win32' ? it.skip : it; @@ -158,7 +160,7 @@ describe('createNodeFsCapability — read (jailed)', () => { 10_000, ); - itPosix('refuses to read a hard-linked file that aliases an out-of-jail inode — FATAL tool_denied', async () => { + itPosix('refuses to read a hard-linked file that aliases an out-of-jail inode — FATAL tool_denied, no leak', async () => { // The single POSIX aliasing vector the symlink jail misses: a hard link inside the workspace to an outside // secret. realpath keeps it in-scope, but the fd's nlink > 1 refuses it before any byte is read. await writeFile(join(outside, 'secret.txt'), 'TOP SECRET'); @@ -167,7 +169,10 @@ describe('createNodeFsCapability — read (jailed)', () => { .readFile('notes.txt', {}) .catch((e: unknown) => e); expect(err).toBeInstanceOf(FsScopeDeniedError); - if (err instanceof FsScopeDeniedError) expect(err.retryable).toBe(false); + if (err instanceof FsScopeDeniedError) { + expect(err.retryable).toBe(false); + expect(String(err.message)).not.toContain('TOP SECRET'); // the denial never echoes the aliased content + } }); itPosix('the full (unjailed) tier reads a hard-linked file — benign in-scope store links stay readable', async () => { @@ -176,6 +181,27 @@ describe('createNodeFsCapability — read (jailed)', () => { const fs = createNodeFsCapability({ tier: 'full', workspaceDir: workspace, readOnly: false }); expect((await fs.readFile('lib.txt', {})).content).toBe('LIB'); }); + + itPosix('EXEMPTS a node_modules (package-store) hard link from the aliasing guard — pnpm reads work, cross-OS', async () => { + // pnpm hard-links package files from a shared store (nlink>1 on Linux). A cross-boundary hard link cannot be + // planted under node_modules without code execution that already defeats the sandbox, so the aliasing READ + // guard exempts a node_modules segment — the dependency-source read succeeds (and behaves the same on macOS, + // where pnpm clones ⇒ nlink==1). The out-of-workspace target stands in for the shared content store. + await writeFile(join(outside, 'store-index.js'), 'module.exports = 1'); + await mkdir(join(workspace, 'node_modules', 'lib'), { recursive: true }); + await link(join(outside, 'store-index.js'), join(workspace, 'node_modules', 'lib', 'index.js')); + expect((await sandboxed().readFile('node_modules/lib/index.js', {})).content).toBe( + 'module.exports = 1', + ); + }); + + it('STILL refuses a credential dotfile under node_modules — the exemption is aliasing-only, not the name floor', async () => { + await mkdir(join(workspace, 'node_modules', 'evil'), { recursive: true }); + await writeFile(join(workspace, 'node_modules', 'evil', '.npmrc'), '//r/:_authToken=SECRET'); + await expect(sandboxed().readFile('node_modules/evil/.npmrc', {})).rejects.toBeInstanceOf( + FsScopeDeniedError, + ); + }); }); /** @@ -283,6 +309,17 @@ describe('createNodeFsCapability — glob read', () => { expect(result.content).not.toContain('LEAKED'); }); + itPosix('SKIPS a hard-linked (aliased) match but still returns the safe ones — no wholesale glob failure', async () => { + // The glob analogue of the symlink-skip above: a hard link (not under node_modules) aliasing an outside + // file must be skipped by readJailedFile's per-fd nlink guard, not fail the whole glob read. + await writeFile(join(outside, 'leak.ts'), 'LEAKED'); + await link(join(outside, 'leak.ts'), join(workspace, 'hard.ts')); + await writeFile(join(workspace, 'real.ts'), 'REAL'); + const result = await sandboxed().readFile('*.ts', { glob: true }); + expect(result.content).toContain('REAL'); + expect(result.content).not.toContain('LEAKED'); + }); + it('skips a binary match but still returns the text matches', async () => { await writeFile(join(workspace, 'text.ts'), 'TEXT'); await writeFile(join(workspace, 'bin.ts'), Buffer.from([0x41, 0x00, 0x42])); @@ -402,6 +439,24 @@ describe('createNodeFsCapability — write (read-write profile)', () => { 10_000, ); + itPosix( + 'refuses to APPEND to a FIFO that HAS a reader — the post-open !isFile fstat branch (not the ENXIO path)', + async () => { + // With a reader present, the writer's O_WRONLY|O_NONBLOCK open SUCCEEDS (no ENXIO), so the distinct + // post-open `fstat`→`!isFile()` guard is what refuses it. Exercises the second write-side non-regular branch. + execFileSync('mkfifo', [join(workspace, 'p2')]); + const reader = await open(join(workspace, 'p2'), constants.O_RDONLY | constants.O_NONBLOCK); + try { + await expect( + sandboxed().writeFile('p2', 'x', { append: true }), + ).rejects.toBeInstanceOf(FsScopeDeniedError); + } finally { + await reader.close(); + } + }, + 10_000, + ); + it('cleans up the temp file when the atomic publish fails — no `.relavium-write.*.tmp` orphan', async () => { // Force the rename to fail by pointing the final target at an existing NON-EMPTY directory: `rename(tmp, // dir)` throws (ENOTEMPTY/EISDIR/EEXIST). The write must reject, AND the `finally` must remove the temp it @@ -465,7 +520,9 @@ describe('createNodeFsCapability — protected paths (denied in EVERY mode, auto it('refuses a Win32-folding trailing-dot/space variant (`.bashrc.`, `.bashrc `, `.git./x`)', async () => { // Win32 silently strips trailing dots/spaces at open time, so these land on the REAL protected target; // foldPathComponent denies them on every platform (an over-deny on a case-sensitive FS is the safe way). - for (const name of ['.bashrc.', '.bashrc ', '.gitconfig.', 'profile.ps1 ']) { + // The MULTI-char cases (`.bashrc..`, two trailing spaces) lock the linear run-strip that replaced `/[. ]+$/` + // — a regression stripping only one trailing char would pass the single-char cases but fail these. + for (const name of ['.bashrc.', '.bashrc ', '.gitconfig.', 'profile.ps1 ', '.bashrc..', '.gitconfig ']) { await expect(sandboxed().writeFile(name, 'evil', {})).rejects.toBeInstanceOf( FsScopeDeniedError, ); @@ -552,17 +609,19 @@ describe('createNodeFsCapability — sensitive-read floor (credential/secret sto await expect(sandboxed().readFile('.ssh/id_rsa', {})).rejects.toBeInstanceOf(FsScopeDeniedError); }); - it('refuses to read a repo-local `.git/config` but ALLOWS a plain file named `config`', async () => { - await mkdir(join(workspace, '.git'), { recursive: true }); - await writeFile(join(workspace, '.git', 'config'), '[remote]\n url = https://u:tok@h/r'); - await expect(sandboxed().readFile('.git/config', {})).rejects.toBeInstanceOf(FsScopeDeniedError); - // A file named `config` NOT under `.git/` is not a credential store — read is allowed. + it('refuses a `.git/config` — repo-local, a submodule `.git/modules/*/config`, and a bare `*.git/config`', async () => { + for (const rel of ['.git/config', '.git/modules/sub/config', 'myrepo.git/config']) { + await mkdir(join(workspace, dirname(rel)), { recursive: true }); + await writeFile(join(workspace, rel), '[remote]\n url = https://u:tok@h/r'); + await expect(sandboxed().readFile(rel, {})).rejects.toBeInstanceOf(FsScopeDeniedError); + } + // A file named `config` NOT under any git dir is not a credential store — read is allowed. await writeFile(join(workspace, 'config'), 'plain-config'); expect((await sandboxed().readFile('config', {})).content).toBe('plain-config'); }); - it('refuses the credential dotfiles (.gitconfig / .netrc / .npmrc) and inside `.relavium/`', async () => { - for (const f of ['.gitconfig', '.netrc', '.npmrc']) { + it('refuses every credential dotfile (.gitconfig / .git-credentials / .netrc / .npmrc / .pypirc / .pgpass) and `.relavium/`', async () => { + for (const f of ['.gitconfig', '.git-credentials', '.netrc', '.npmrc', '.pypirc', '.pgpass']) { await writeFile(join(workspace, f), 'token=secret'); await expect(sandboxed().readFile(f, {})).rejects.toBeInstanceOf(FsScopeDeniedError); } @@ -573,6 +632,15 @@ describe('createNodeFsCapability — sensitive-read floor (credential/secret sto ); }); + it('refuses a symlink whose REALPATH resolves INTO a sensitive store (the floor runs on the realpath)', async () => { + // Mirror of the write-side "symlink resolves INTO .git/" test: the read floor checks the realpath'd target, + // so an innocuously-named symlink pointing into `.ssh/` is refused (a lexical-name-only check would leak). + await mkdir(join(workspace, '.ssh'), { recursive: true }); + await writeFile(join(workspace, '.ssh', 'id_rsa'), 'PRIVATE KEY'); + await symlink(join(workspace, '.ssh', 'id_rsa'), join(workspace, 'innocent.txt')); + await expect(sandboxed().readFile('innocent.txt', {})).rejects.toBeInstanceOf(FsScopeDeniedError); + }); + it('ALLOWS a normal `.gitignore` — only the credential set is sensitive, not every dotfile', async () => { await writeFile(join(workspace, '.gitignore'), 'node_modules\n'); expect((await sandboxed().readFile('.gitignore', {})).content).toBe('node_modules\n'); diff --git a/apps/cli/src/engine/tool-host/fs.ts b/apps/cli/src/engine/tool-host/fs.ts index 6fa1630f..36271df5 100644 --- a/apps/cli/src/engine/tool-host/fs.ts +++ b/apps/cli/src/engine/tool-host/fs.ts @@ -136,6 +136,25 @@ async function guarded(op: () => Promise): Promise { * read_file * ------------------------------------------------------------------------------------------------ */ +/** + * A package-manager store path (any `node_modules` segment). pnpm/npm hard-link package files from a shared + * content store, so those files legitimately carry `nlink > 1` on Linux/ext4 (macOS/APFS clones them instead ⇒ + * `nlink == 1`). The hard-link aliasing READ guard exempts a store path: a cross-boundary hard link there cannot + * be planted without code execution that already defeats the sandbox (a git clone / tarball cannot carry a hard + * link to an out-of-tree path), the local-first trust model owns the workspace, and the sensitive-read floor + * still refuses a named secret store even UNDER `node_modules`. This keeps the exfiltration guard everywhere else + * while making a dependency-source read work — and behave the SAME across Linux and macOS. + */ +function isPackageStorePath(absolutePath: string): boolean { + return absolutePath.split(sep).some((segment) => segment === 'node_modules'); +} + +/** Whether the hard-link aliasing read guard applies to `real`: ON for the jailed tiers, OFF for the unjailed + * `full` tier and for a package-store path ({@link isPackageStorePath}). */ +function rejectAliasedRead(config: NodeFsCapabilityConfig, real: string): boolean { + return config.tier !== 'full' && !isPackageStorePath(real); +} + async function readOne( config: NodeFsCapabilityConfig, maxReadBytes: number, @@ -155,7 +174,7 @@ async function readOne( // is caught. Reads flow into the model's context (then to the provider), so this mirrors, on the read side, // the protected-paths WRITE floor's mode/tier independence (ADR-0057). assertNotSensitiveReadPath(real); - const result = await readJailedFile(real, maxReadBytes, config.tier !== 'full'); + const result = await readJailedFile(real, maxReadBytes, rejectAliasedRead(config, real)); if (result.kind === 'directory') { throw new FsCapabilityError('read_file: the path is a directory, not a file'); } @@ -164,7 +183,7 @@ async function readOne( } if (result.kind === 'aliased') { throw new FsScopeDeniedError( - 'read_file: refusing to read a hard-linked file (its content may be shared with a file outside the sandbox)', + 'read_file: refusing to read a hard-linked file — its content may be shared with a file outside the sandbox (package-store links under node_modules are exempt)', ); } if (result.kind === 'binary') { @@ -206,10 +225,11 @@ async function readGlob( // can neither redirect the read nor make the size charged diverge from the bytes read. The bounded prefix // probe skips a binary match WITHOUT charging the budget or loading it; the budget is enforced against the // fd's own size BEFORE the full read, so an over-budget text file is never loaded just to be rejected. - const result = await readJailedFile(m.real, maxReadBytes - totalBytes, config.tier !== 'full'); - // skip a non-text match (dir / special / aliased / binary); never charge the budget. (collectGlobFiles - // already filters to regular, non-hard-linked, non-sensitive files, so these are belt-and-suspenders — a - // swap to a FIFO or a hard link after the walk would land here.) + const result = await readJailedFile(m.real, maxReadBytes - totalBytes, rejectAliasedRead(config, m.real)); + // skip a non-text match (dir / special / aliased / binary); never charge the budget. collectGlobFiles + // already filters to regular, in-scope, non-sensitive files — but it does NOT check nlink, so the + // hard-link (`aliased`) skip is enforced HERE by readJailedFile's per-fd guard (the primary aliasing + // filter for a glob read); `directory`/`special` are the belt-and-suspenders for a post-walk swap. if ( result.kind === 'directory' || result.kind === 'special' || @@ -431,11 +451,17 @@ function assertNotProtectedPath(absoluteTarget: string): void { * write floor's {@link PROTECTED_DIR_SEGMENTS} (which also blocks whole `.git/` for WRITE-side hook RCE, a * concern that does not apply to a read) — reads leak CONFIDENTIALITY, so the read floor targets the * secret/credential stores specifically. + * + * NOTE (latent): `.relavium` also names this CLI's sanctioned scratch root `~/.relavium/tmp/` (the `tmpDir` + * sandboxed root). Both this read floor AND the write {@link PROTECTED_DIR_SEGMENTS} would refuse that root — but + * no call site wires `tmpDir` today, so the collision is inert. Resolve it (home-anchored match, or exclude the + * wired tmp root) BEFORE any caller passes `tmpDir`. Tracked in docs/roadmap/deferred-tasks.md. */ const SENSITIVE_READ_DIR_SEGMENTS: ReadonlySet = new Set(['.ssh', '.relavium']); -/** Credential/secret dotfiles refused to READ by basename (global git creds, npm/pypi/pg/netrc tokens). */ +/** Credential/secret dotfiles refused to READ by basename (git creds, npm/pypi/pg/netrc tokens). */ const SENSITIVE_READ_BASENAMES: ReadonlySet = new Set([ '.gitconfig', // user-global git config — `[credential]`, insteadOf URLs with embedded tokens + '.git-credentials', // git `store` helper — verbatim `https://user:TOKEN@host` lines (the plaintext token store) '.netrc', '.npmrc', // `_authToken` '.pypirc', @@ -453,16 +479,23 @@ export function isSensitiveReadPath(absoluteTarget: string): boolean { for (const segment of folded) { if (SENSITIVE_READ_DIR_SEGMENTS.has(segment)) return true; } - // A repo-local `.git/config` (any `.git` segment with a `config` basename, incl. submodule `.git/modules/*/config`). - if (folded.includes('.git') && foldPathComponent(basename(absoluteTarget)) === 'config') return true; + // A git `config` embeds remote-URL credentials: catch it under a `.git` dir (repo / submodule `.git/modules/*` + // / worktree) AND under a bare-repo dir (`myrepo.git/config`) — any segment ENDING in `.git` with a `config` + // basename. Over-denying a stray `x.git/config` that isn't a repo is the safe direction for a read floor. + if ( + foldPathComponent(basename(absoluteTarget)) === 'config' && + folded.some((segment) => segment === '.git' || segment.endsWith('.git')) + ) { + return true; + } return SENSITIVE_READ_BASENAMES.has(foldPathComponent(basename(absoluteTarget))); } -/** Deny a read of a secret/credential store (`.ssh`/`.relavium`, `.git/config`, a credential dotfile). FATAL. */ +/** Deny a read of a secret/credential store (`.ssh`/`.relavium`, a git `config`, a credential dotfile). FATAL. */ function assertNotSensitiveReadPath(absoluteTarget: string): void { if (isSensitiveReadPath(absoluteTarget)) { throw new FsScopeDeniedError( - 'refusing to read a credential/secret store (.ssh / .relavium / .git config / a credential dotfile)', + 'refusing to read a credential/secret store (.ssh / .relavium / a git config / a credential dotfile) — ask the user to share any needed content instead', ); } } @@ -525,9 +558,12 @@ async function writeOne( if (code === 'ELOOP' || code === 'ENOTDIR') { throw new FsScopeDeniedError('write_file: refusing to write through a symlink'); } - if (code === 'ENXIO') { - // O_WRONLY | O_NONBLOCK on a reader-less FIFO returns ENXIO IMMEDIATELY (instead of blocking forever) — - // a non-regular target we refuse anyway; surface the same fatal denial the post-open fstat would give. + if (code === 'ENXIO' || code === 'EOPNOTSUPP' || code === 'ENODEV') { + // A non-regular special file that fails the OPEN itself rather than reaching the fstat: O_WRONLY | + // O_NONBLOCK on a reader-less FIFO returns ENXIO immediately (instead of blocking forever); a socket + // special file returns ENXIO (Linux) or EOPNOTSUPP (macOS); a device with no driver returns ENODEV. All + // are targets we refuse anyway — surface the same fatal denial the post-open fstat gives, not a raw + // (retryable, path-leaking) fs error. throw new FsScopeDeniedError( 'write_file: refusing to append to a non-regular file (a FIFO/device/socket)', ); From 8203648a4945db79a3eecce5036495229d06c017 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Thu, 2 Jul 2026 13:36:38 +0300 Subject: [PATCH 30/45] =?UTF-8?q?fix(cli):=202.5.E=20F1=20=E2=80=94=20addr?= =?UTF-8?q?ess=20Sonnet=205=20review=20(narrow=20pnpm=20exemption=20to=20.?= =?UTF-8?q?pnpm)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/` is a symlink the jail resolves into `.pnpm`). An attacker-named `node_modules/` 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/` 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) --- apps/cli/src/engine/tool-host/fs.test.ts | 67 ++++++++++++++++++++---- apps/cli/src/engine/tool-host/fs.ts | 44 +++++++++++----- docs/roadmap/deferred-tasks.md | 13 +++++ 3 files changed, 101 insertions(+), 23 deletions(-) diff --git a/apps/cli/src/engine/tool-host/fs.test.ts b/apps/cli/src/engine/tool-host/fs.test.ts index 0dabb093..6b54bf46 100644 --- a/apps/cli/src/engine/tool-host/fs.test.ts +++ b/apps/cli/src/engine/tool-host/fs.test.ts @@ -182,19 +182,39 @@ describe('createNodeFsCapability — read (jailed)', () => { expect((await fs.readFile('lib.txt', {})).content).toBe('LIB'); }); - itPosix('EXEMPTS a node_modules (package-store) hard link from the aliasing guard — pnpm reads work, cross-OS', async () => { - // pnpm hard-links package files from a shared store (nlink>1 on Linux). A cross-boundary hard link cannot be - // planted under node_modules without code execution that already defeats the sandbox, so the aliasing READ - // guard exempts a node_modules segment — the dependency-source read succeeds (and behaves the same on macOS, - // where pnpm clones ⇒ nlink==1). The out-of-workspace target stands in for the shared content store. + itPosix('EXEMPTS a pnpm virtual-store hard link (node_modules/.pnpm/…) from the aliasing guard — pnpm reads work', async () => { + // pnpm hard-links package files into node_modules/.pnpm/@/node_modules//… (nlink>1 on Linux). + // The aliasing READ guard is disabled ONLY for that virtual-store layout, so a dependency-source read there + // succeeds. The out-of-workspace target stands in for pnpm's shared content store. await writeFile(join(outside, 'store-index.js'), 'module.exports = 1'); - await mkdir(join(workspace, 'node_modules', 'lib'), { recursive: true }); - await link(join(outside, 'store-index.js'), join(workspace, 'node_modules', 'lib', 'index.js')); - expect((await sandboxed().readFile('node_modules/lib/index.js', {})).content).toBe( - 'module.exports = 1', + const pkgDir = join(workspace, 'node_modules', '.pnpm', 'lib@1.0.0', 'node_modules', 'lib'); + await mkdir(pkgDir, { recursive: true }); + await link(join(outside, 'store-index.js'), join(pkgDir, 'index.js')); + expect( + (await sandboxed().readFile('node_modules/.pnpm/lib@1.0.0/node_modules/lib/index.js', {})).content, + ).toBe('module.exports = 1'); + }); + + itPosix('STILL refuses a hard link under a plain node_modules dir (NO .pnpm) — the exemption is store-shape-scoped', async () => { + // A hard link an attacker names `node_modules/` (not the pnpm virtual store) is NOT exempt — the + // narrowed guard only trusts the node_modules/.pnpm adjacency, closing the attacker-named-dir bypass. + await writeFile(join(outside, 'secret.txt'), 'TOP SECRET'); + await mkdir(join(workspace, 'node_modules', 'evil'), { recursive: true }); + await link(join(outside, 'secret.txt'), join(workspace, 'node_modules', 'evil', 'data.bin')); + await expect(sandboxed().readFile('node_modules/evil/data.bin', {})).rejects.toBeInstanceOf( + FsScopeDeniedError, ); }); + itPosix('EXEMPTS a pnpm-store hard link through the GLOB read path too (readGlob recomputes the exemption)', async () => { + await writeFile(join(outside, 'dep.ts'), 'export const x = 1'); + const pkgDir = join(workspace, 'node_modules', '.pnpm', 'dep@1', 'node_modules', 'dep'); + await mkdir(pkgDir, { recursive: true }); + await link(join(outside, 'dep.ts'), join(pkgDir, 'index.ts')); + const result = await sandboxed().readFile('node_modules/.pnpm/**/*.ts', { glob: true }); + expect(result.content).toContain('export const x = 1'); + }); + it('STILL refuses a credential dotfile under node_modules — the exemption is aliasing-only, not the name floor', async () => { await mkdir(join(workspace, 'node_modules', 'evil'), { recursive: true }); await writeFile(join(workspace, 'node_modules', 'evil', '.npmrc'), '//r/:_authToken=SECRET'); @@ -425,6 +445,18 @@ describe('createNodeFsCapability — write (read-write profile)', () => { expect(await readFile(join(outside, 'authorized_keys'), 'utf8')).toBe('original'); // never appended }); + itPosix('the APPEND hard-link guard is unconditional — even the full tier / a store path is refused (no read-side carve-out)', async () => { + // The append guard has NO `full`-tier or pnpm-store exemption (unlike the read guard): appending MODIFIES the + // shared inode, a strictly worse outcome, so it is refused in every tier. Pin both directions of that asymmetry. + await writeFile(join(outside, 'target'), 'original'); + await link(join(outside, 'target'), join(workspace, 'alias.txt')); + const fullTier = createNodeFsCapability({ tier: 'full', workspaceDir: workspace, readOnly: false }); + await expect( + fullTier.writeFile('alias.txt', 'x', { append: true }), + ).rejects.toBeInstanceOf(FsScopeDeniedError); + expect(await readFile(join(outside, 'target'), 'utf8')).toBe('original'); + }); + itPosix( 'refuses to APPEND to a FIFO WITHOUT blocking — a reader-less pipe fails closed, never hangs the process', async () => { @@ -632,6 +664,23 @@ describe('createNodeFsCapability — sensitive-read floor (credential/secret sto ); }); + it('folds the read floor like the write floor — a case variant (`.SSH/`) is still refused', async () => { + await mkdir(join(workspace, '.SSH'), { recursive: true }); + await writeFile(join(workspace, '.SSH', 'id_rsa'), 'KEY'); + await expect(sandboxed().readFile('.SSH/id_rsa', {})).rejects.toBeInstanceOf(FsScopeDeniedError); + }); + + it('refuses the git XDG credential store `git/credentials` (bare `credentials` under a `git` dir)', async () => { + await mkdir(join(workspace, 'git'), { recursive: true }); + await writeFile(join(workspace, 'git', 'credentials'), 'https://u:tok@h'); + await expect(sandboxed().readFile('git/credentials', {})).rejects.toBeInstanceOf( + FsScopeDeniedError, + ); + // a bare `credentials` NOT under a git dir is not floored (avoids over-blocking an unrelated file). + await writeFile(join(workspace, 'credentials'), 'not-a-secret'); + expect((await sandboxed().readFile('credentials', {})).content).toBe('not-a-secret'); + }); + it('refuses a symlink whose REALPATH resolves INTO a sensitive store (the floor runs on the realpath)', async () => { // Mirror of the write-side "symlink resolves INTO .git/" test: the read floor checks the realpath'd target, // so an innocuously-named symlink pointing into `.ssh/` is refused (a lexical-name-only check would leak). diff --git a/apps/cli/src/engine/tool-host/fs.ts b/apps/cli/src/engine/tool-host/fs.ts index 36271df5..59589c7a 100644 --- a/apps/cli/src/engine/tool-host/fs.ts +++ b/apps/cli/src/engine/tool-host/fs.ts @@ -137,22 +137,34 @@ async function guarded(op: () => Promise): Promise { * ------------------------------------------------------------------------------------------------ */ /** - * A package-manager store path (any `node_modules` segment). pnpm/npm hard-link package files from a shared - * content store, so those files legitimately carry `nlink > 1` on Linux/ext4 (macOS/APFS clones them instead ⇒ - * `nlink == 1`). The hard-link aliasing READ guard exempts a store path: a cross-boundary hard link there cannot - * be planted without code execution that already defeats the sandbox (a git clone / tarball cannot carry a hard - * link to an out-of-tree path), the local-first trust model owns the workspace, and the sensitive-read floor - * still refuses a named secret store even UNDER `node_modules`. This keeps the exfiltration guard everywhere else - * while making a dependency-source read work — and behave the SAME across Linux and macOS. + * Whether `real` sits inside **pnpm's virtual store** — a `node_modules/.pnpm/…` adjacency. pnpm is the one + * package manager that hard-links package files from a content-addressable store, and those hard links live ONLY + * under `node_modules/.pnpm/@/node_modules//…` (a top-level `node_modules/` is a symlink INTO + * `.pnpm`, which the jail's realpath resolves to this same store path before the check). npm/yarn-classic copy + * files (`nlink == 1`) and never need this; macOS/APFS pnpm clones (`nlink == 1`) so the guard is a no-op there. + * + * SECURITY (narrowed after the ADR-0057 review): the hard-link aliasing read guard is disabled ONLY for this + * specific store layout — NOT for any `node_modules` segment (an earlier, too-broad form let an attacker-named + * `node_modules/` hard link exfiltrate). The residual is deliberate and bounded: reaching under + * `node_modules/.pnpm/` to plant a cross-boundary hard link requires a COMPROMISED DEPENDENCY in the tree (a + * malicious postinstall, or a hard-link path-traversal in the extractor — the node-tar CVE class), at which point + * the same actor already has local RCE; a benign git clone / normal tarball cannot carry such a link. We accept + * this to keep dependency-source reads working (a core coding-agent flow) rather than blocking every pnpm read on + * Linux; the sensitive-read floor still refuses a NAMED secret store even under the store. Tracked for the + * ADR-0057 security-review record in docs/roadmap/deferred-tasks.md. */ -function isPackageStorePath(absolutePath: string): boolean { - return absolutePath.split(sep).some((segment) => segment === 'node_modules'); +function isPnpmStorePath(absolutePath: string): boolean { + const folded = absolutePath.split(sep).map(foldPathComponent); + for (let i = 0; i + 1 < folded.length; i += 1) { + if (folded[i] === 'node_modules' && folded[i + 1] === '.pnpm') return true; + } + return false; } /** Whether the hard-link aliasing read guard applies to `real`: ON for the jailed tiers, OFF for the unjailed - * `full` tier and for a package-store path ({@link isPackageStorePath}). */ + * `full` tier and for pnpm's virtual store ({@link isPnpmStorePath}). */ function rejectAliasedRead(config: NodeFsCapabilityConfig, real: string): boolean { - return config.tier !== 'full' && !isPackageStorePath(real); + return config.tier !== 'full' && !isPnpmStorePath(real); } async function readOne( @@ -183,7 +195,7 @@ async function readOne( } if (result.kind === 'aliased') { throw new FsScopeDeniedError( - 'read_file: refusing to read a hard-linked file — its content may be shared with a file outside the sandbox (package-store links under node_modules are exempt)', + 'read_file: refusing to read a hard-linked file — its content may be shared with a file outside the sandbox (only pnpm virtual-store links under node_modules/.pnpm are exempt)', ); } if (result.kind === 'binary') { @@ -482,13 +494,17 @@ export function isSensitiveReadPath(absoluteTarget: string): boolean { // A git `config` embeds remote-URL credentials: catch it under a `.git` dir (repo / submodule `.git/modules/*` // / worktree) AND under a bare-repo dir (`myrepo.git/config`) — any segment ENDING in `.git` with a `config` // basename. Over-denying a stray `x.git/config` that isn't a repo is the safe direction for a read floor. + const base = foldPathComponent(basename(absoluteTarget)); if ( - foldPathComponent(basename(absoluteTarget)) === 'config' && + base === 'config' && folded.some((segment) => segment === '.git' || segment.endsWith('.git')) ) { return true; } - return SENSITIVE_READ_BASENAMES.has(foldPathComponent(basename(absoluteTarget))); + // git's XDG credential store `$XDG_CONFIG_HOME/git/credentials` — same plaintext `user:TOKEN@host` lines as + // `.git-credentials`, but the basename is a bare `credentials` under a `git` dir (no leading dot). + if (base === 'credentials' && folded.includes('git')) return true; + return SENSITIVE_READ_BASENAMES.has(base); } /** Deny a read of a secret/credential store (`.ssh`/`.relavium`, a git `config`, a credential dotfile). FATAL. */ diff --git a/docs/roadmap/deferred-tasks.md b/docs/roadmap/deferred-tasks.md index 4d0fe9f1..9ba936fe 100644 --- a/docs/roadmap/deferred-tasks.md +++ b/docs/roadmap/deferred-tasks.md @@ -510,6 +510,19 @@ Severity is the review's verified rating. Check an item off in the PR that resol session-budget follow-up above. *(medium · apps/cli/src/chat + agent-session.ts)* - [ ] **`project`-tier `extraRoots` allowlist (carried from 2.5.A).** The `project` fs tier behaves as workspace-only until the path-allowlist lands (it can only NARROW the jail, never open a hole). *(low · apps/cli/src/engine/tool-host/assemble.ts)* +- [ ] **fs hard-link aliasing — the pnpm virtual-store read exemption (accepted residual, ADR-0057 review record).** + The hard-link aliasing READ guard (`st.nlink > 1` ⇒ refused) is disabled ONLY for pnpm's `node_modules/.pnpm/…` + virtual store (`isPnpmStorePath`), so dependency-source reads work on Linux (where pnpm hard-links). The bounded + residual: a **compromised dependency** (a malicious postinstall, or a hard-link path-traversal in the extractor — + the node-tar CVE class) could plant a cross-boundary hard link UNDER `node_modules/.pnpm/` that a later read would + follow; the same actor already has local RCE, and the sensitive-read floor still refuses a NAMED secret store even + there. A future opt-out (`allow_aliased_reads` config, or resolving the inode's other name against a tool-known + pnpm store root) would let a stricter deployment disable even this. *(low · apps/cli/src/engine/tool-host/fs.ts)* +- [ ] **fs `.relavium` sensitive-read/write segment vs. the `~/.relavium/tmp` sandboxed root (latent).** Both the + read floor (`SENSITIVE_READ_DIR_SEGMENTS`) and the write floor (`PROTECTED_DIR_SEGMENTS`) match a `.relavium` + segment anywhere, so they would refuse the sanctioned `tmpDir` scratch root — inert today (no call site wires + `tmpDir`). Resolve (home-anchored match, or exclude the wired tmp root) before any caller passes `tmpDir`. + *(low · apps/cli/src/engine/tool-host/fs.ts + assemble.ts)* ## Phase-2 CLI (2.D) follow-ups From a4766c044e1b3129c27eef47fef48b07ed00e5d4 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Thu, 2 Jul 2026 13:48:25 +0300 Subject: [PATCH 31/45] =?UTF-8?q?fix(cli,core,db):=202.5.E=20F2=20?= =?UTF-8?q?=E2=80=94=20egress=20framing-strip=20+=20one=20secret-redaction?= =?UTF-8?q?=20layer=20(PR=20#63=20review)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/cli/src/engine/tool-host/egress.test.ts | 20 ++++++ apps/cli/src/engine/tool-host/egress.ts | 45 +++++++++---- packages/core/src/tools/bounding.test.ts | 68 +++++++++++++++++++- packages/core/src/tools/bounding.ts | 57 +++++++++++++++- packages/core/src/tools/registry.ts | 15 +++-- packages/db/src/safe-egress.test.ts | 59 ++++++++++++++++- packages/db/src/safe-egress.ts | 61 +++++++++++++----- 7 files changed, 286 insertions(+), 39 deletions(-) diff --git a/apps/cli/src/engine/tool-host/egress.test.ts b/apps/cli/src/engine/tool-host/egress.test.ts index e98bd6c3..612a01a9 100644 --- a/apps/cli/src/engine/tool-host/egress.test.ts +++ b/apps/cli/src/engine/tool-host/egress.test.ts @@ -117,6 +117,26 @@ describe('createNodeEgressCapability (2.5.E Step 3) — text egress over the sha expect(calls[0]?.headers?.['authorization']).toBe('Bearer SECRET-KEY'); }); + it('the host-resolved credential WINS over a model-supplied Authorization header (any case)', async () => { + // The model could inject its own `Authorization` in any casing; when a credential is resolved host-side it + // must win deterministically — no stale/duplicate auth header on the wire. + const { deps, calls } = fakeDeps({ resolve: PUBLIC, response: { status: 200 } }); + const egress = createNodeEgressCapability({ + deps, + resolveCredential: () => Promise.resolve('RESOLVED'), + }); + await egress.fetch({ + method: 'GET', + url: 'https://api.example.com/x', + credentialRef: 'kc:x', + headers: { Authorization: 'Bearer MODEL-INJECTED', 'X-Keep': 'yes' }, + }); + // Only ONE authorization header reaches the hop, carrying the resolved credential; no capitalized duplicate. + expect(calls[0]?.headers?.['authorization']).toBe('Bearer RESOLVED'); + expect(calls[0]?.headers?.['Authorization']).toBeUndefined(); + expect(calls[0]?.headers?.['X-Keep']).toBe('yes'); // unrelated model headers still pass through + }); + it('proceeds WITHOUT a credential when the ref does not resolve (a provider 401 surfaces, never a crash)', async () => { const { deps, calls } = fakeDeps({ resolve: PUBLIC, response: { status: 200 } }); const egress = createNodeEgressCapability({ diff --git a/apps/cli/src/engine/tool-host/egress.ts b/apps/cli/src/engine/tool-host/egress.ts index cb5f763d..2a9a9db5 100644 --- a/apps/cli/src/engine/tool-host/egress.ts +++ b/apps/cli/src/engine/tool-host/egress.ts @@ -65,10 +65,7 @@ export function createNodeEgressCapability( request.credentialRef === undefined ? undefined : await resolveCredentialSafely(config.resolveCredential, request.credentialRef); - const headers: Record = { ...request.headers }; - if (credential !== undefined && credential.length > 0) { - headers['authorization'] = `Bearer ${credential}`; - } + const headers = mergeEgressHeaders(request.headers, credential); return await withEgressTimeout(signal, timeoutMs, async (sig) => { const response = await connectValidated( request.url, @@ -98,15 +95,29 @@ export function createNodeEgressCapability( } /** - * Map the shared {@link SafeEgressError} to the host error taxonomy: an SSRF range-block or non-HTTPS/ - * credentialed-url denial is a **deterministic** {@link EgressDeniedError} (fatal `tool_denied` — re-issuing - * re-denies, never burns the node-retry budget); a transient network/size failure is an - * {@link EgressCapabilityError} (retryable `tool_failed`). The shared message is already a tool-agnostic, - * reason-only `egress …` string (no url/IP/bytes), so it is passed through verbatim — the SAME arm backs - * `http_request`, `web_search`, and http-transport `mcp_call`, and the registry attaches the actual invoking - * tool id when it surfaces the error, so an arm-side `http_request:` prefix would MISATTRIBUTE a `web_search` - * failure. An abort is classified `cancelled` by the registry's cancel-precedence regardless of this class. + * Merge the model-supplied request headers with the host-resolved credential so the resolved credential ALWAYS + * wins deterministically. `request.headers` is model-controlled, so it may carry an `Authorization` in any case + * (or a duplicate) — when a credential is resolved, every case variant of `authorization` is dropped BEFORE the + * canonical `authorization: Bearer …` is set, so the wire never carries a stale/ambiguous auth header. With no + * resolved credential, the model headers pass through unchanged (the `Host`/framing strip still applies in + * `sanitizeHopHeaders`). */ +function mergeEgressHeaders( + modelHeaders: Readonly> | undefined, + credential: string | undefined, +): Record { + const hasCredential = credential !== undefined && credential.length > 0; + const out: Record = {}; + for (const [key, value] of Object.entries(modelHeaders ?? {})) { + if (hasCredential && key.trim().toLowerCase() === 'authorization') continue; // resolved credential wins + out[key] = value; + } + if (hasCredential) { + out['authorization'] = `Bearer ${credential}`; + } + return out; +} + /** * Resolve a `credentialRef` host-side, degrading an absent OR **rejecting** resolver to `undefined` so a * native-keychain fault proceeds credential-less (the documented "never a crash" contract) instead of failing @@ -125,6 +136,16 @@ async function resolveCredentialSafely( } } +/** + * Map the shared {@link SafeEgressError} to the host error taxonomy: an SSRF range-block or non-HTTPS/ + * credentialed-url denial is a **deterministic** {@link EgressDeniedError} (fatal `tool_denied` — re-issuing + * re-denies, never burns the node-retry budget); a transient network/size failure is an + * {@link EgressCapabilityError} (retryable `tool_failed`). The shared message is already a tool-agnostic, + * reason-only `egress …` string (no url/IP/bytes), so it is passed through verbatim — the SAME arm backs + * `http_request`, `web_search`, and http-transport `mcp_call`, and the registry attaches the actual invoking + * tool id when it surfaces the error, so an arm-side `http_request:` prefix would MISATTRIBUTE a `web_search` + * failure. An abort is classified `cancelled` by the registry's cancel-precedence regardless of this class. + */ function classifyEgressError(error: unknown): Error { if (error instanceof SafeEgressError) { if (error.code === 'insecure_url' || error.code === 'blocked_host') { diff --git a/packages/core/src/tools/bounding.test.ts b/packages/core/src/tools/bounding.test.ts index 198e57a9..781a61c9 100644 --- a/packages/core/src/tools/bounding.test.ts +++ b/packages/core/src/tools/bounding.test.ts @@ -1,7 +1,12 @@ import type { AbortSignalLike } from '@relavium/shared'; import { describe, expect, it, vi } from 'vitest'; -import { boundForModel, utf8ByteLength } from './bounding.js'; +import { + boundForModel, + redactSecretShapedText, + redactSecretShapedValue, + utf8ByteLength, +} from './bounding.js'; import type { ToolHost, ToolResultLimits } from './types.js'; const BIG: ToolResultLimits = { maxBytes: 50_000, maxLines: 2000 }; @@ -215,4 +220,65 @@ describe('boundForModel', () => { const date = await boundForModel({ at: new Date('2026-06-19T00:00:00.000Z') }, BIG, host()); expect(date.summary).toContain('2026-06-19'); // Date → ISO string via JSON.stringify, not {} }); + + it('scrubs a secret-shaped value from the summary (outputSummary) but keeps the model-facing value', async () => { + // A read_clipboard / egress-body / .env-style result carrying a live token must not ride outputSummary + // (→ agent:tool_result.outputSummary → the --json stream). The model-facing value keeps the real bytes. + const result = `export API_KEY=sk-${'abcdef0123456789abcdef'} and Authorization: Bearer tok${'_live_9f8e7d6c5b4a'}`; + const bounded = await boundForModel(result, BIG, host()); + expect(bounded.summary).not.toContain('sk-' + 'abcdef0123456789abcdef'); + expect(bounded.summary).not.toContain('tok_live_9f8e7d6c5b4a'); + expect(bounded.summary).toContain('[redacted]'); + expect(bounded.value).toBe(result); // the model still sees the real content + }); +}); + +describe('redactSecretShapedText', () => { + it('redacts Authorization schemes, secret=value pairs, and known token shapes', () => { + // `Authorization: Bearer ` is caught by BOTH the scheme pattern and the `authorization=value` pattern; + // the over-redaction is the safe direction — assert the token is gone, not an exact shape. + const authScrubbed = redactSecretShapedText('Authorization: Bearer abcdef123456789'); + expect(authScrubbed).not.toContain('abcdef123456789'); + expect(authScrubbed).toContain('[redacted]'); + expect(redactSecretShapedText('db_password=hunter2secret')).toBe('[redacted]'); + const apiKey = redactSecretShapedText('MY_API_KEY = "sk-' + 'XYZ12345abcdef"'); + expect(apiKey).not.toContain('sk-' + 'XYZ12345abcdef'); + expect(apiKey).toContain('[redacted]'); + expect(redactSecretShapedText('token AKIA' + 'IOSFODNN7EXAMPLE here')).toContain('[redacted]'); + expect(redactSecretShapedText('ghp' + '_0123456789abcdef0123456789abcdefABCD')).toBe('[redacted]'); + }); + + it('leaves ordinary text (and short non-secret values) intact', () => { + expect(redactSecretShapedText('the quick brown fox')).toBe('the quick brown fox'); + expect(redactSecretShapedText('count = 42')).toBe('count = 42'); // not a secret-ish key + }); + + it('is ReDoS-safe on a long adversarial input (completes fast, no catastrophic backtracking)', () => { + const evil = `${'a'.repeat(50_000)} password=`; // key with no value → no runaway + const started = performance.now(); + redactSecretShapedText(evil); + expect(performance.now() - started).toBeLessThan(200); + }); +}); + +describe('redactSecretShapedValue', () => { + it('scrubs string values at any nesting, keeping object keys (header names) intact', () => { + const input = { + url: 'https://api.example.com/x?api_key=sk-' + 'secret9876543210', + headers: { Authorization: 'Bearer tok_abcdef123456', 'X-Trace': 'keep-me' }, + body: 'client_secret=shhhhhhhhhh', + }; + 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'); + }); + + it('is cycle-safe', () => { + const cyclic: Record = { a: 1 }; + cyclic['self'] = cyclic; + expect(() => redactSecretShapedValue(cyclic)).not.toThrow(); + }); }); diff --git a/packages/core/src/tools/bounding.ts b/packages/core/src/tools/bounding.ts index be298977..66293445 100644 --- a/packages/core/src/tools/bounding.ts +++ b/packages/core/src/tools/bounding.ts @@ -129,6 +129,57 @@ function redactInlineMediaForText(value: unknown, seen: WeakSet): unknow return out; } +/** + * Redact secret-SHAPED substrings from a text projection bound for an **observability field** (the event + * `outputSummary` / `toolInput`) — NOT the model-facing value (the model must still see the real tool result + * and its own args). A model can put a live credential in an `http_request` header/body, and a tool result can + * carry one (a `read_clipboard`, an egress response body, a `.env` read); either would otherwise ride the + * `--json` machine stream (ADR-0049) / a log verbatim. High-recall and deliberately conservative (a false + * positive redacts a non-secret in a DISPLAY field only — never the model's copy): it targets `Authorization` + * schemes, `…secret…=value` pairs, and well-known token shapes (OpenAI/AWS/GitHub/Slack/Google/JWT). Every + * pattern is LINEAR — bounded repetition + single character classes, no nested quantifiers — per the engine's + * no-backtracking-RegExp / no-ReDoS posture. + */ +export function redactSecretShapedText(text: string): string { + return ( + text + // An `Authorization`-style scheme + its token: `Bearer ` / `Basic ` / `Token `. + .replace(/\b(bearer|basic|token)\s+[A-Za-z0-9._~+/-]{8,}={0,2}/gi, '$1 [redacted]') + // A secret-ish key (bounded wrappers keep this ReDoS-safe) + `=`/`:` + its value. + .replace( + /\b[\w-]{0,32}(?:password|passwd|secret|token|api[_-]?key|apikey|authorization|access[_-]?key|private[_-]?key|client[_-]?secret)[\w-]{0,16}\s*[=:]\s*["']?[^\s"',;&]{6,}/gi, + '[redacted]', + ) + // Well-known standalone credential shapes. + .replace( + /\b(?:sk-[A-Za-z0-9]{16,}|AKIA[0-9A-Z]{16}|gh[pousr]_[A-Za-z0-9]{20,}|xox[baprs]-[A-Za-z0-9-]{10,}|AIza[0-9A-Za-z_-]{30,}|eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{6,})/g, + '[redacted]', + ) + ); +} + +/** + * Walk an arbitrary value applying {@link redactSecretShapedText} to every string, leaving structure intact — + * the toolInput twin of the summary scrub. Applied to the `agent:tool_call.toolInput` event field so a + * model-set credential in a header VALUE / body / url-query never rides the observability stream (the object + * KEYS — e.g. a header name — are preserved; only string values are scrubbed). Cycle-safe; display-only. + */ +export function redactSecretShapedValue(value: unknown): unknown { + return redactSecretShapedWalk(value, new WeakSet()); +} + +function redactSecretShapedWalk(value: unknown, seen: WeakSet): unknown { + if (typeof value === 'string') return redactSecretShapedText(value); + if (typeof value !== 'object' || value === null) return value; + if (seen.has(value)) return value; + seen.add(value); + if (Array.isArray(value)) return value.map((item) => redactSecretShapedWalk(item, seen)); + if (!isPlainObject(value)) return value; // Date/RegExp/Map/… — leave for native handling + const out: Record = {}; + for (const [key, item] of Object.entries(value)) out[key] = redactSecretShapedWalk(item, seen); + return out; +} + /** Render any result to the text the model would see (a string is itself; else compact JSON). Inline * media bytes are redacted first so the summary/spill/preview can never carry base64 (I3). */ function toText(result: unknown): string { @@ -154,9 +205,11 @@ function toText(result: unknown): string { function makeSummary(text: string): string { // Cap the scanned input so the whitespace-collapse never runs over an oversized result (the summary - // is bounded to SUMMARY_MAX regardless). + // is bounded to SUMMARY_MAX regardless). Scrub secret-shaped substrings BEFORE the length cap so a + // credential in the result (a read_clipboard, an egress body, a `.env` read) never rides `outputSummary` + // to the `--json` stream / a log (the model-facing value is untouched — this is the observability copy). const slice = text.length > SUMMARY_MAX * 8 ? text.slice(0, SUMMARY_MAX * 8) : text; - const oneLine = slice.replace(/\s+/g, ' ').trim(); + const oneLine = redactSecretShapedText(slice.replace(/\s+/g, ' ').trim()); return oneLine.length <= SUMMARY_MAX ? oneLine : `${oneLine.slice(0, SUMMARY_MAX)}…`; } diff --git a/packages/core/src/tools/registry.ts b/packages/core/src/tools/registry.ts index 346d5495..f3407ee2 100644 --- a/packages/core/src/tools/registry.ts +++ b/packages/core/src/tools/registry.ts @@ -9,7 +9,7 @@ import { extractHttpsHost, type ToolActionClass } from '@relavium/shared'; -import { boundForModel, redactInlineMedia } from './bounding.js'; +import { boundForModel, redactInlineMedia, redactSecretShapedValue } from './bounding.js'; import { ToolArgsInvalidError, ToolCancelledError, @@ -552,12 +552,15 @@ function sanitizeInput( delete out[key]; } } - // Redact inline media bytes from every surviving arg value before it becomes `agent:tool_call.toolInput`: - // that field rides the event/IPC/log stream (an I3 boundary), and a model can emit a base64 `data:` URI - // or a `{ kind:'base64', data }` object as a tool argument. Symmetric to the `outputSummary` redaction on - // the result side — display-only, the dispatch already ran on the real args. + // Redact inline media bytes AND secret-shaped values from every surviving arg before it becomes + // `agent:tool_call.toolInput`: that field rides the event/IPC/log/`--json` stream (an I3 boundary). The + // `secretArgKeys` deletion above only covers KNOWN top-level key names, so a model-set credential in an + // arbitrary place (an `http_request` `Authorization` header value, a token in the body or url query) would + // otherwise pass through — `redactSecretShapedValue` scrubs it by shape, keeping the object keys (header + // names) intact. Symmetric to the `outputSummary` scrub on the result side — display-only, the dispatch + // already ran on the real args. for (const key of Object.keys(out)) { - out[key] = redactInlineMedia(out[key]); + out[key] = redactSecretShapedValue(redactInlineMedia(out[key])); } return out; } diff --git a/packages/db/src/safe-egress.test.ts b/packages/db/src/safe-egress.test.ts index 6eccfc56..71046e7c 100644 --- a/packages/db/src/safe-egress.test.ts +++ b/packages/db/src/safe-egress.test.ts @@ -110,6 +110,49 @@ describe('connectValidated — the one validated hop (URL policy + range-block + expect(captured?.headers?.['authorization']).toBe('Bearer X'); // the legit credential is untouched }); + it('STRIPS caller-supplied framing / hop-by-hop headers (HTTP request-smuggling defense)', async () => { + let captured: HopRequest | undefined; + const deps = fakeDeps({ + resolve: { 'api.example.com': ['203.0.113.5'] }, + onOpen: (request) => (captured = request), + }); + await connectValidated( + 'https://api.example.com/x', + { + allowPrivate: false, + method: 'POST', + // A model-set Content-Length that MISMATCHES the body length is a request-smuggling primitive (the + // surplus body bytes are parsed as a second, forged request on a keep-alive socket) — the mechanism must + // drop it (+ the other framing/hop-by-hop headers) so Node computes the framing from the real body. + headers: { + 'Content-Length': '3', + 'Transfer-Encoding': 'chunked', + Connection: 'keep-alive', + 'Proxy-Connection': 'keep-alive', + TE: 'trailers', + Upgrade: 'websocket', + Expect: '100-continue', + 'x-keep': 'ok', + }, + body: 'a much longer body than three bytes', + }, + deps, + sig(), + ); + for (const stripped of [ + 'Content-Length', + 'Transfer-Encoding', + 'Connection', + 'Proxy-Connection', + 'TE', + 'Upgrade', + 'Expect', + ]) { + expect(captured?.headers?.[stripped]).toBeUndefined(); + } + expect(captured?.headers?.['x-keep']).toBe('ok'); + }); + it('rejects a non-HTTPS url and a credentialed url with insecure_url (before any DNS)', async () => { let resolved = false; const deps: EgressDeps = { @@ -240,6 +283,13 @@ describe('withEgressTimeout — timeout + abort + error normalization', () => { ); await expect(result).rejects.toMatchObject({ code: 'network' }); }); + + it('times out a fn that IGNORES its signal — the deadline race rejects, never hangs', async () => { + // A fn that never resolves and never observes `inner` (a signal-ignoring transport). The timeout must win + // the race and reject deterministically rather than hanging the caller forever. + const result = withEgressTimeout(undefined, 5, () => new Promise(() => undefined)); + await expect(result).rejects.toMatchObject({ name: 'SafeEgressError', code: 'network' }); + }); }); /* --- the concrete Node mechanism (nodeEgressDeps.openConnection) — the body/headers-on-the-wire path --- */ @@ -270,13 +320,18 @@ function fakeIncoming(status: number, chunks: readonly Uint8Array[] = []): FakeI return Object.assign(stream, { statusCode: status, headers: {}, destroy: () => undefined }); } -/** A ClientRequest stub: openConnection calls `.on('error',…)`, optionally `.write(body)`, then `.end()`. */ +/** + * A ClientRequest stub: openConnection calls `.on('error',…)`, optionally `.write(body)`, then `.end()`, and + * `dispose()` (via the response) calls `.destroy()` — the stub carries all four so it matches the lifecycle + * `nodeEgressDeps.openConnection` actually drives. + */ function stubClientRequest(): { on: ReturnType; write: ReturnType; end: ReturnType; + destroy: ReturnType; } { - return { on: vi.fn(), write: vi.fn(), end: vi.fn() }; + return { on: vi.fn(), write: vi.fn(), end: vi.fn(), destroy: vi.fn() }; } function lastHttpsCall(): { diff --git a/packages/db/src/safe-egress.ts b/packages/db/src/safe-egress.ts index cfb9ae83..a0b3402c 100644 --- a/packages/db/src/safe-egress.ts +++ b/packages/db/src/safe-egress.ts @@ -184,21 +184,40 @@ export async function connectValidated( } /** - * Drop a caller-supplied `Host` / `:authority` header so the wire `Host` is ALWAYS derived from the validated - * hostname. The IP-pin + SNI fix the TRANSPORT destination, but a shared/CDN/reverse-proxy IP routes at the - * APPLICATION layer by the `Host` header — so a model-set `Host` (the `http_request` `headers` arg is - * model-controlled) could otherwise send an `allowedDomains`-approved, correctly-pinned request to a DIFFERENT - * virtual host at the same IP (a virtual-host-confusion SSRF the pin/SNI alone don't stop). Matched - * case-insensitively; the legitimate `authorization` credential header is untouched. + * Caller-supplied headers that MUST never reach the wire — dropped case-insensitively so a model-controlled + * `http_request` `headers` arg cannot subvert routing or message framing: + * - `host` / `:authority` — the wire `Host` is ALWAYS derived from the validated hostname. The IP-pin + SNI fix + * the TRANSPORT destination, but a shared/CDN/reverse-proxy IP routes at the APPLICATION layer by `Host`, so a + * model-set `Host` could send an `allowedDomains`-approved, correctly-pinned request to a DIFFERENT virtual + * host at the same IP (virtual-host-confusion SSRF the pin/SNI alone don't stop). + * - `content-length` / `transfer-encoding` — Node computes the framing from the actual body bytes. A model-set + * `content-length` that MISMATCHES the body is a classic HTTP request-smuggling primitive (the surplus bytes + * are parsed as a SECOND, fully attacker-controlled request — forged `Host` included — on a keep-alive + * connection), bypassing the very Host-strip above; letting Node own the framing closes it. + * - `connection` / `keep-alive` / `proxy-connection` / `te` / `upgrade` / `expect` — hop-by-hop / protocol- + * negotiation headers a caller has no business setting (connection reuse, 100-continue, protocol upgrade). + * The legitimate `authorization` credential header (attached host-side) is untouched. */ +const STRIPPED_HOP_HEADERS: ReadonlySet = new Set([ + 'host', + ':authority', + 'content-length', + 'transfer-encoding', + 'connection', + 'keep-alive', + 'proxy-connection', + 'te', + 'upgrade', + 'expect', +]); + function sanitizeHopHeaders( headers: Readonly> | undefined, ): Readonly> | undefined { if (headers === undefined) return undefined; const out: Record = {}; for (const [key, value] of Object.entries(headers)) { - const k = key.trim().toLowerCase(); - if (k === 'host' || k === ':authority') continue; // never let the caller override the validated Host + if (STRIPPED_HOP_HEADERS.has(key.trim().toLowerCase())) continue; out[key] = value; } return out; @@ -233,10 +252,14 @@ export async function readBounded( } /** - * The shared timeout + abort + error-normalization wrapper. Composes the caller's `signal` with a timeout - * into one `AbortController`, runs `fn(controller.signal)`, and guarantees the ONLY thrown type is a typed, - * secret-free {@link SafeEgressError} — every raw resolver / socket / `new URL` / body-read error becomes - * `SafeEgressError('network')`, never a raw leak. + * The shared timeout + abort + error-normalization wrapper. Composes the caller's `signal` with a timeout into + * one `AbortController`, RACES `fn(controller.signal)` against a hard timeout, and guarantees the ONLY thrown + * type is a typed, secret-free {@link SafeEgressError} — every raw resolver / socket / `new URL` / body-read + * error becomes `SafeEgressError('network')`, never a raw leak. + * + * The timeout does BOTH: it `abort()`s the controller (cooperative cancellation for an `fn` that honors the + * signal — the Node deps do, tearing down the socket) AND rejects the race after `timeoutMs`, so an `fn` that + * IGNORES its signal still fails deterministically at the deadline instead of hanging the caller forever. */ export async function withEgressTimeout( signal: AbortSignalLike | undefined, @@ -249,17 +272,23 @@ export async function withEgressTimeout( controller.abort(); } signal?.addEventListener('abort', abort); // removed in the finally below - const timer = setTimeout(abort, timeoutMs); + let timer: ReturnType | undefined; + const deadline = new Promise((_resolve, reject) => { + timer = setTimeout(() => { + abort(); // still cancel the inner op (a signal-honoring fn tears down); the reject guards a signal-ignoring one + reject(new SafeEgressError('network', 'egress request timed out')); + }, timeoutMs); + }); try { - return await fn(controller.signal); + return await Promise.race([fn(controller.signal), deadline]); } catch (error) { if (error instanceof SafeEgressError) { - throw error; // a typed failure (blocked_host / too_large / bad_status / …) — preserve the discriminant + throw error; // a typed failure (blocked_host / too_large / bad_status / timeout / …) — preserve the discriminant } // Any RAW throw is normalized to the typed, secret-free network failure. throw new SafeEgressError('network', 'egress request failed'); } finally { - clearTimeout(timer); + if (timer !== undefined) clearTimeout(timer); signal?.removeEventListener('abort', abort); } } From 10af4bef59ef5641ea304861b36f6e87c73b5e31 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Thu, 2 Jul 2026 14:01:37 +0300 Subject: [PATCH 32/45] =?UTF-8?q?fix(core,db):=202.5.E=20F2=20=E2=80=94=20?= =?UTF-8?q?address=20opus=20review=20(JSON=20secret=20shape,=20self-contai?= =?UTF-8?q?ned=20egress)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/cli/src/engine/tool-host/egress.test.ts | 11 +++++++ packages/core/src/tools/bounding.test.ts | 33 ++++++++++++++++++-- packages/core/src/tools/bounding.ts | 13 +++++--- packages/core/src/tools/registry.test.ts | 26 +++++++++++++++ packages/db/src/safe-egress.test.ts | 27 ++++++++++++++++ packages/db/src/safe-egress.ts | 16 ++++++++++ 6 files changed, 118 insertions(+), 8 deletions(-) diff --git a/apps/cli/src/engine/tool-host/egress.test.ts b/apps/cli/src/engine/tool-host/egress.test.ts index 612a01a9..d9658185 100644 --- a/apps/cli/src/engine/tool-host/egress.test.ts +++ b/apps/cli/src/engine/tool-host/egress.test.ts @@ -137,6 +137,17 @@ describe('createNodeEgressCapability (2.5.E Step 3) — text egress over the sha expect(calls[0]?.headers?.['X-Keep']).toBe('yes'); // unrelated model headers still pass through }); + it('with NO host credential, a model-supplied Authorization passes through unchanged', async () => { + const { deps, calls } = fakeDeps({ resolve: PUBLIC, response: { status: 200 } }); + const egress = createNodeEgressCapability({ deps }); // no resolveCredential + await egress.fetch({ + method: 'GET', + url: 'https://api.example.com/x', + headers: { Authorization: 'Bearer MODEL-OWN' }, + }); + expect(calls[0]?.headers?.['Authorization']).toBe('Bearer MODEL-OWN'); // untouched — nothing to override + }); + it('proceeds WITHOUT a credential when the ref does not resolve (a provider 401 surfaces, never a crash)', async () => { const { deps, calls } = fakeDeps({ resolve: PUBLIC, response: { status: 200 } }); const egress = createNodeEgressCapability({ diff --git a/packages/core/src/tools/bounding.test.ts b/packages/core/src/tools/bounding.test.ts index 781a61c9..e0cf235f 100644 --- a/packages/core/src/tools/bounding.test.ts +++ b/packages/core/src/tools/bounding.test.ts @@ -231,6 +231,18 @@ describe('boundForModel', () => { expect(bounded.summary).toContain('[redacted]'); expect(bounded.value).toBe(result); // the model still sees the real content }); + + it('scrubs the summary even when the result is TRUNCATED — the preview (model-facing) keeps the bytes', async () => { + const secret = 'sk-' + 'abcdef0123456789abcdef'; + const spill = vi.fn(() => Promise.resolve({ ref: 'run://spill/1', byteLength: 2048 })); + const bounded = await boundForModel( + `${secret} ${'z'.repeat(2000)}`, + TINY, + host({ outputStore: { spill } }), + ); + expect(bounded.truncated).toBe(true); + expect(bounded.summary).not.toContain(secret); // outputSummary is scrubbed (derived from the full text) + }); }); describe('redactSecretShapedText', () => { @@ -248,16 +260,31 @@ describe('redactSecretShapedText', () => { expect(redactSecretShapedText('ghp' + '_0123456789abcdef0123456789abcdefABCD')).toBe('[redacted]'); }); + it('redacts the JSON `"key":"value"` shape — an OAuth / egress response body', () => { + for (const s of [ + '{"access_token":"a1b2c3d4e5f6g7h8"}', + '{"client_secret":"shhhhhhhhhh"}', + '{ "api_key": "sk-' + 'value-here-1234" }', + ]) { + expect(redactSecretShapedText(s)).toContain('[redacted]'); + } + expect(redactSecretShapedText('{"access_token":"a1b2c3d4e5f6g7h8"}')).not.toContain( + 'a1b2c3d4e5f6g7h8', + ); + }); + it('leaves ordinary text (and short non-secret values) intact', () => { expect(redactSecretShapedText('the quick brown fox')).toBe('the quick brown fox'); expect(redactSecretShapedText('count = 42')).toBe('count = 42'); // not a secret-ish key }); - it('is ReDoS-safe on a long adversarial input (completes fast, no catastrophic backtracking)', () => { - const evil = `${'a'.repeat(50_000)} password=`; // key with no value → no runaway + it('is ReDoS-safe on a value-ENGAGING adversarial input (no catastrophic backtracking)', () => { + // Unlike a keyless run, this drives BOTH the scheme-token run (200k) and the key=value value (50k) — the + // exact machinery a quadratic pattern would blow up on. Linear completes in ms; the bound would fail a regression. + const evil = `Authorization: Bearer ${'a'.repeat(200_000)} my_secret="${'x'.repeat(50_000)}"`; const started = performance.now(); redactSecretShapedText(evil); - expect(performance.now() - started).toBeLessThan(200); + expect(performance.now() - started).toBeLessThan(500); }); }); diff --git a/packages/core/src/tools/bounding.ts b/packages/core/src/tools/bounding.ts index 66293445..a0fc73ba 100644 --- a/packages/core/src/tools/bounding.ts +++ b/packages/core/src/tools/bounding.ts @@ -145,14 +145,17 @@ export function redactSecretShapedText(text: string): string { text // An `Authorization`-style scheme + its token: `Bearer ` / `Basic ` / `Token `. .replace(/\b(bearer|basic|token)\s+[A-Za-z0-9._~+/-]{8,}={0,2}/gi, '$1 [redacted]') - // A secret-ish key (bounded wrappers keep this ReDoS-safe) + `=`/`:` + its value. + // A secret-ish key (bounded wrappers keep this ReDoS-safe) + `=`/`:` + its value. The optional `["']?` + // BEFORE the separator catches the JSON `"access_token":"…"` shape (an OAuth/egress response body) where + // a closing quote sits between the key and the `:`. .replace( - /\b[\w-]{0,32}(?:password|passwd|secret|token|api[_-]?key|apikey|authorization|access[_-]?key|private[_-]?key|client[_-]?secret)[\w-]{0,16}\s*[=:]\s*["']?[^\s"',;&]{6,}/gi, + /\b[\w-]{0,32}(?:password|passwd|secret|token|api[_-]?key|apikey|authorization|access[_-]?key|private[_-]?key|client[_-]?secret)[\w-]{0,16}["']?\s*[=:]\s*["']?[^\s"',;&]{6,}/gi, '[redacted]', ) - // Well-known standalone credential shapes. + // Well-known standalone credential shapes (OpenAI, Stripe, AWS incl. STS ASIA/ABIA, GitHub tokens + PAT, + // GitLab PAT, Slack, Google API + OAuth, HuggingFace, npm, JWT). .replace( - /\b(?:sk-[A-Za-z0-9]{16,}|AKIA[0-9A-Z]{16}|gh[pousr]_[A-Za-z0-9]{20,}|xox[baprs]-[A-Za-z0-9-]{10,}|AIza[0-9A-Za-z_-]{30,}|eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{6,})/g, + /\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_[A-Za-z0-9_]{20,}|glpat-[A-Za-z0-9_-]{16,}|xox[baprs]-[A-Za-z0-9-]{10,}|AIza[0-9A-Za-z_-]{30,}|ya29\.[A-Za-z0-9_-]{20,}|hf_[A-Za-z0-9]{20,}|npm_[A-Za-z0-9]{20,}|eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{6,})/g, '[redacted]', ) ); @@ -171,7 +174,7 @@ export function redactSecretShapedValue(value: unknown): unknown { function redactSecretShapedWalk(value: unknown, seen: WeakSet): unknown { if (typeof value === 'string') return redactSecretShapedText(value); if (typeof value !== 'object' || value === null) return value; - if (seen.has(value)) return value; + if (seen.has(value)) return '[cyclic]'; // break the cycle (mirrors redactInlineMedia) — never re-emit the ref seen.add(value); if (Array.isArray(value)) return value.map((item) => redactSecretShapedWalk(item, seen)); if (!isPlainObject(value)) return value; // Date/RegExp/Map/… — leave for native handling diff --git a/packages/core/src/tools/registry.test.ts b/packages/core/src/tools/registry.test.ts index f7fa24be..f4bbe8e2 100644 --- a/packages/core/src/tools/registry.test.ts +++ b/packages/core/src/tools/registry.test.ts @@ -799,6 +799,32 @@ describe('ToolRegistry — config-only params and I/O mapping', () => { // No fragment of the payload survives anywhere in the event field. expect(JSON.stringify(out.events.call.toolInput)).not.toContain('aGVsbG8'); }); + + it('scrubs a secret-shaped value from agent:tool_call.toolInput (a model-set credential in a header/body/url)', async () => { + // The security-load-bearing wiring: sanitizeInput runs redactSecretShapedValue so a model-injected token in + // an http_request header VALUE / body / url-query never rides toolInput → the --json/event/log stream. The + // header NAME is kept; the dispatch still ran on the real args. + const out = await registry().dispatch( + call('http_request', { + url: 'https://api.example.com/x?api_key=sk-' + 'abcdef0123456789xyz', + body: 'client_secret=supersecretvalue123', + headers: { Authorization: 'Bearer tok_live_abcdef123456', 'x-keep': 'plain' }, + }), + ctx({ toolPolicy: { allowedDomains: ['api.example.com'] } }), + ); + const toolInput = out.events.call.toolInput as { + url: string; + body: string; + headers: Record; + }; + expect(toolInput.headers['Authorization']).toBe('Bearer [redacted]'); // value scrubbed, NAME kept + expect(toolInput.headers['x-keep']).toBe('plain'); // non-secret header untouched + expect(toolInput.body).toBe('[redacted]'); + expect(toolInput.url).not.toContain('sk-' + 'abcdef0123456789xyz'); + const serialized = JSON.stringify(out.events.call.toolInput); + expect(serialized).not.toContain('supersecretvalue123'); + expect(serialized).not.toContain('tok_live_abcdef123456'); + }); }); /* --- capability availability + execution + cancellation --- */ diff --git a/packages/db/src/safe-egress.test.ts b/packages/db/src/safe-egress.test.ts index 71046e7c..e2e3fd1a 100644 --- a/packages/db/src/safe-egress.test.ts +++ b/packages/db/src/safe-egress.test.ts @@ -153,6 +153,33 @@ describe('connectValidated — the one validated hop (URL policy + range-block + expect(captured?.headers?.['x-keep']).toBe('ok'); }); + it('DROPS a non-token header name and a CR/LF/NUL-carrying value (request-splitting defense, transport-independent)', async () => { + let captured: HopRequest | undefined; + const deps = fakeDeps({ + resolve: { 'api.example.com': ['203.0.113.5'] }, + onOpen: (request) => (captured = request), + }); + await connectValidated( + 'https://api.example.com/x', + { + allowPrivate: false, + method: 'GET', + headers: { + 'X-Forwarded-Host': 'evil-backend.example', // forwarding header → vhost reroute + 'X-Inject': 'ok\r\nHost: evil.example', // CRLF in value would splice a second header line + 'Bad Name': 'v', // a space makes it a non-token name + 'X-Good': 'keep', + }, + }, + deps, + sig(), + ); + expect(captured?.headers?.['X-Forwarded-Host']).toBeUndefined(); + expect(captured?.headers?.['X-Inject']).toBeUndefined(); // CR/LF value dropped + expect(captured?.headers?.['Bad Name']).toBeUndefined(); // non-token name dropped + expect(captured?.headers?.['X-Good']).toBe('keep'); + }); + it('rejects a non-HTTPS url and a credentialed url with insecure_url (before any DNS)', async () => { let resolved = false; const deps: EgressDeps = { diff --git a/packages/db/src/safe-egress.ts b/packages/db/src/safe-egress.ts index a0b3402c..a2e1c809 100644 --- a/packages/db/src/safe-egress.ts +++ b/packages/db/src/safe-egress.ts @@ -196,7 +196,14 @@ export async function connectValidated( * connection), bypassing the very Host-strip above; letting Node own the framing closes it. * - `connection` / `keep-alive` / `proxy-connection` / `te` / `upgrade` / `expect` — hop-by-hop / protocol- * negotiation headers a caller has no business setting (connection reuse, 100-continue, protocol upgrade). + * - `x-forwarded-*` / `forwarded` — a model-set forwarding header can reroute a proxy-trusting target to a + * different backend vhost / spoof the source IP its ACLs see (the vhost-confusion class, one hop deeper). * The legitimate `authorization` credential header (attached host-side) is untouched. + * + * Independently of the name filter, a header whose NAME is not a legal HTTP token or whose VALUE carries a + * CR / LF / NUL is DROPPED — so a model cannot inject a second header/request line (request splitting) even if + * a future `openConnection` transport is swapped for one that doesn't itself reject those (the default Node dep + * does, but this shared SSRF primitive must not delegate that guarantee). */ const STRIPPED_HOP_HEADERS: ReadonlySet = new Set([ 'host', @@ -209,8 +216,15 @@ const STRIPPED_HOP_HEADERS: ReadonlySet = new Set([ 'te', 'upgrade', 'expect', + 'x-forwarded-host', + 'x-forwarded-for', + 'x-forwarded-proto', + 'forwarded', ]); +/** A legal HTTP header field-name is one or more RFC 7230 `tchar`s. Linear (single class), no backtracking. */ +const HTTP_TOKEN_NAME = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/; + function sanitizeHopHeaders( headers: Readonly> | undefined, ): Readonly> | undefined { @@ -218,6 +232,8 @@ function sanitizeHopHeaders( const out: Record = {}; for (const [key, value] of Object.entries(headers)) { if (STRIPPED_HOP_HEADERS.has(key.trim().toLowerCase())) continue; + if (!HTTP_TOKEN_NAME.test(key)) continue; // a non-token name (space/CRLF/`:authority`-style) is dropped + if (/[\r\n\0]/.test(value)) continue; // a CR/LF/NUL in the value would splice a second header/request line out[key] = value; } return out; From b69a892951bdb89df70fc45f198a7edb163115ed Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Thu, 2 Jul 2026 14:16:40 +0300 Subject: [PATCH 33/45] =?UTF-8?q?fix(core,db,cli):=202.5.E=20F2=20?= =?UTF-8?q?=E2=80=94=20address=20Sonnet=205=20review=20(passphrase/PEM=20r?= =?UTF-8?q?ecall,=20DNS=20doc)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/cli/src/engine/tool-host/egress.test.ts | 24 ++++++++ apps/cli/src/engine/tool-host/egress.ts | 9 ++- packages/core/src/tools/bounding.test.ts | 58 ++++++++++++++++++-- packages/core/src/tools/bounding.ts | 15 ++++- packages/db/src/safe-egress.test.ts | 24 +++++++- packages/db/src/safe-egress.ts | 14 ++++- 6 files changed, 129 insertions(+), 15 deletions(-) diff --git a/apps/cli/src/engine/tool-host/egress.test.ts b/apps/cli/src/engine/tool-host/egress.test.ts index d9658185..db51d9db 100644 --- a/apps/cli/src/engine/tool-host/egress.test.ts +++ b/apps/cli/src/engine/tool-host/egress.test.ts @@ -148,6 +148,30 @@ describe('createNodeEgressCapability (2.5.E Step 3) — text egress over the sha expect(calls[0]?.headers?.['Authorization']).toBe('Bearer MODEL-OWN'); // untouched — nothing to override }); + it('drops BOTH case variants when the model sets Authorization AND authorization simultaneously', async () => { + const { deps, calls } = fakeDeps({ resolve: PUBLIC, response: { status: 200 } }); + const egress = createNodeEgressCapability({ deps, resolveCredential: () => Promise.resolve('WON') }); + await egress.fetch({ + method: 'GET', + url: 'https://api.example.com/x', + credentialRef: 'kc:x', + headers: { Authorization: 'Bearer A', authorization: 'Bearer B' }, // both casings present + }); + expect(calls[0]?.headers?.['Authorization']).toBeUndefined(); + expect(calls[0]?.headers?.['authorization']).toBe('Bearer WON'); // exactly one, the resolved credential + }); + + it('TRIMS a resolved credential with stray surrounding whitespace (a pasted-key footgun)', async () => { + const { deps, calls } = fakeDeps({ resolve: PUBLIC, response: { status: 200 } }); + const egress = createNodeEgressCapability({ + deps, + resolveCredential: () => Promise.resolve(' KEY-WITH-NEWLINE\n'), + }); + await egress.fetch({ method: 'GET', url: 'https://api.example.com/x', credentialRef: 'kc:x' }); + // Without the trim the CR/LF value would be dropped by the request-splitting guard → sent unauthenticated. + expect(calls[0]?.headers?.['authorization']).toBe('Bearer KEY-WITH-NEWLINE'); + }); + it('proceeds WITHOUT a credential when the ref does not resolve (a provider 401 surfaces, never a crash)', async () => { const { deps, calls } = fakeDeps({ resolve: PUBLIC, response: { status: 200 } }); const egress = createNodeEgressCapability({ diff --git a/apps/cli/src/engine/tool-host/egress.ts b/apps/cli/src/engine/tool-host/egress.ts index 2a9a9db5..244f91ec 100644 --- a/apps/cli/src/engine/tool-host/egress.ts +++ b/apps/cli/src/engine/tool-host/egress.ts @@ -106,14 +106,19 @@ function mergeEgressHeaders( modelHeaders: Readonly> | undefined, credential: string | undefined, ): Record { - const hasCredential = credential !== undefined && credential.length > 0; + // Trim the resolved credential: a stored key pasted with a trailing newline is a common footgun, and an + // untrimmed CR/LF in the value would be dropped by sanitizeHopHeaders' request-splitting guard — sending the + // request UNauthenticated with no diagnostic. Trimming keeps the common case working (an internal CR/LF is a + // malformed token that legitimately fails downstream). + const trimmed = credential?.trim(); + const hasCredential = trimmed !== undefined && trimmed.length > 0; const out: Record = {}; for (const [key, value] of Object.entries(modelHeaders ?? {})) { if (hasCredential && key.trim().toLowerCase() === 'authorization') continue; // resolved credential wins out[key] = value; } if (hasCredential) { - out['authorization'] = `Bearer ${credential}`; + out['authorization'] = `Bearer ${trimmed}`; } return out; } diff --git a/packages/core/src/tools/bounding.test.ts b/packages/core/src/tools/bounding.test.ts index e0cf235f..09544557 100644 --- a/packages/core/src/tools/bounding.test.ts +++ b/packages/core/src/tools/bounding.test.ts @@ -273,18 +273,68 @@ describe('redactSecretShapedText', () => { ); }); + it('redacts a QUOTED multi-word passphrase WHOLE — no interior-space tail leak', () => { + // The value must be consumed through the closing quote, not stopped at the first space. + const out = redactSecretShapedText('{"password":"hunter2 dragon rider"}'); + expect(out).not.toContain('dragon'); + expect(out).not.toContain('rider'); + }); + + it('redacts a PEM private-key block (space-separated markers the key-pattern cannot see)', () => { + const pem = [ + '-----BEGIN ' + 'OPENSSH PRIVATE KEY-----', + 'b3BlbnNzaC1r' + 'ZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAA', + 'MIIEvQIBADAN' + 'BgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQ', + '-----END ' + 'OPENSSH PRIVATE KEY-----', + ].join('\n'); + const out = redactSecretShapedText(`key material:\n${pem}\ndone`); + expect(out).toContain('[redacted]'); + expect(out).not.toContain('MIIEvQ' + 'IBADAN'); + expect(out).not.toContain('b3Blbn' + 'NzaC1r'); + }); + + it('redacts every standalone token PREFIX shape (locks the extended alternation against a typo)', () => { + const tokens = [ + 'sk-' + 'abcdef0123456789ABCDEF', + 'sk' + '_live_abcdef0123456789ABCD', + 'AKIA' + 'IOSFODNN7EXAMPLE', + 'ASIA' + 'IOSFODNN7EXAMPLE', + 'ghp' + '_0123456789abcdef0123456789abcdefABCD', + 'github' + '_pat_0123456789abcdefABCDEF_more', + 'glpat' + '-0123456789abcdefABCD', + 'xoxb' + '-0123456789-abcdefABCDEF', + 'AIza' + 'SyA0123456789abcdefABCDEF0123456789', + 'ya29' + '.a0AbCdEf0123456789_-abcdef', + 'hf' + '_0123456789abcdefABCDEFGHIJ', + 'npm' + '_0123456789abcdefABCDEFGHIJ', + 'eyJ' + 'hbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY.SflKxwRJSMeKKF2QT4', + ]; + for (const tok of tokens) { + expect(redactSecretShapedText(`value ${tok} end`)).not.toContain(tok); + } + }); + it('leaves ordinary text (and short non-secret values) intact', () => { expect(redactSecretShapedText('the quick brown fox')).toBe('the quick brown fox'); expect(redactSecretShapedText('count = 42')).toBe('count = 42'); // not a secret-ish key }); - it('is ReDoS-safe on a value-ENGAGING adversarial input (no catastrophic backtracking)', () => { - // Unlike a keyless run, this drives BOTH the scheme-token run (200k) and the key=value value (50k) — the - // exact machinery a quadratic pattern would blow up on. Linear completes in ms; the bound would fail a regression. + it('over-redaction is the safe direction — a benign `token: ` is swept up (documented tradeoff)', () => { + // A DISPLAY-field false positive is acceptable (the model's copy is untouched). This pins the deliberate + // over-redaction so a future "tighten precision" change is a conscious test edit, not a silent secret leak. + expect(redactSecretShapedText('token abcdefghijklmnop')).toContain('[redacted]'); + }); + + it('is ReDoS-safe on a value-ENGAGING input AND fully redacts it (timing + correctness)', () => { + // Drives BOTH the scheme-token run (200k) and a long quoted value (50k) — the machinery a quadratic pattern + // blows up on. The correctness assertions catch a quantifier-narrowing regression that would leak the tail + // (a pure timing bound would pass such a regression — it runs FASTER, not slower). const evil = `Authorization: Bearer ${'a'.repeat(200_000)} my_secret="${'x'.repeat(50_000)}"`; const started = performance.now(); - redactSecretShapedText(evil); + const out = redactSecretShapedText(evil); expect(performance.now() - started).toBeLessThan(500); + expect(out).not.toContain('a'.repeat(100)); // the bearer token tail is gone + expect(out).not.toContain('x'.repeat(100)); // the long quoted value tail is gone }); }); diff --git a/packages/core/src/tools/bounding.ts b/packages/core/src/tools/bounding.ts index a0fc73ba..0c52f2a3 100644 --- a/packages/core/src/tools/bounding.ts +++ b/packages/core/src/tools/bounding.ts @@ -143,13 +143,22 @@ function redactInlineMediaForText(value: unknown, seen: WeakSet): unknow export function redactSecretShapedText(text: string): string { return ( text + // A PEM private-key block (multi-line, space-separated markers the `private_key` key-pattern can't see). + // The body span is bounded (`{0,20000}?`, lazy) so an unterminated block can't drive an unbounded scan. + .replace( + /-----BEGIN [A-Z0-9 ]{0,40}PRIVATE KEY-----[\s\S]{0,20000}?-----END [A-Z0-9 ]{0,40}PRIVATE KEY-----/g, + '[redacted]', + ) // An `Authorization`-style scheme + its token: `Bearer ` / `Basic ` / `Token `. .replace(/\b(bearer|basic|token)\s+[A-Za-z0-9._~+/-]{8,}={0,2}/gi, '$1 [redacted]') // A secret-ish key (bounded wrappers keep this ReDoS-safe) + `=`/`:` + its value. The optional `["']?` - // BEFORE the separator catches the JSON `"access_token":"…"` shape (an OAuth/egress response body) where - // a closing quote sits between the key and the `:`. + // BEFORE the separator catches the JSON `"access_token":"…"` shape (an OAuth/egress response body). The + // value has two branches: a QUOTED value consumes lazily through the FIRST matching closing quote (so a + // passphrase with interior spaces — `"hunter2 dragon"` — is redacted whole, however long, not just up to + // the first space), bounded by the line (`[^\r\n]`); an UNQUOTED value runs to the next whitespace/ + // delimiter. Lazy `*?` with a single-class body + a fixed backref is linear (finds the first close) ⇒ ReDoS-safe. .replace( - /\b[\w-]{0,32}(?:password|passwd|secret|token|api[_-]?key|apikey|authorization|access[_-]?key|private[_-]?key|client[_-]?secret)[\w-]{0,16}["']?\s*[=:]\s*["']?[^\s"',;&]{6,}/gi, + /\b[\w-]{0,32}(?:password|passwd|secret|token|api[_-]?key|apikey|authorization|access[_-]?key|private[_-]?key|client[_-]?secret)[\w-]{0,16}["']?\s*[=:]\s*(?:(["'])[^\r\n]*?\1|[^\s"',;&]{6,})/gi, '[redacted]', ) // Well-known standalone credential shapes (OpenAI, Stripe, AWS incl. STS ASIA/ABIA, GitHub tokens + PAT, diff --git a/packages/db/src/safe-egress.test.ts b/packages/db/src/safe-egress.test.ts index e2e3fd1a..d0a1884e 100644 --- a/packages/db/src/safe-egress.test.ts +++ b/packages/db/src/safe-egress.test.ts @@ -128,8 +128,12 @@ describe('connectValidated — the one validated hop (URL policy + range-block + 'Content-Length': '3', 'Transfer-Encoding': 'chunked', Connection: 'keep-alive', + 'Keep-Alive': 'timeout=5', 'Proxy-Connection': 'keep-alive', + 'Proxy-Authorization': 'Basic xxx', + 'Proxy-Authenticate': 'Basic', TE: 'trailers', + Trailer: 'X-Checksum', Upgrade: 'websocket', Expect: '100-continue', 'x-keep': 'ok', @@ -143,8 +147,12 @@ describe('connectValidated — the one validated hop (URL policy + range-block + 'Content-Length', 'Transfer-Encoding', 'Connection', + 'Keep-Alive', 'Proxy-Connection', + 'Proxy-Authorization', + 'Proxy-Authenticate', 'TE', + 'Trailer', 'Upgrade', 'Expect', ]) { @@ -166,6 +174,9 @@ describe('connectValidated — the one validated hop (URL policy + range-block + method: 'GET', headers: { 'X-Forwarded-Host': 'evil-backend.example', // forwarding header → vhost reroute + 'X-Forwarded-For': '10.0.0.1', + 'X-Forwarded-Proto': 'http', + Forwarded: 'for=10.0.0.1', 'X-Inject': 'ok\r\nHost: evil.example', // CRLF in value would splice a second header line 'Bad Name': 'v', // a space makes it a non-token name 'X-Good': 'keep', @@ -174,9 +185,16 @@ describe('connectValidated — the one validated hop (URL policy + range-block + deps, sig(), ); - expect(captured?.headers?.['X-Forwarded-Host']).toBeUndefined(); - expect(captured?.headers?.['X-Inject']).toBeUndefined(); // CR/LF value dropped - expect(captured?.headers?.['Bad Name']).toBeUndefined(); // non-token name dropped + for (const stripped of [ + 'X-Forwarded-Host', + 'X-Forwarded-For', + 'X-Forwarded-Proto', + 'Forwarded', + 'X-Inject', // CR/LF value dropped + 'Bad Name', // non-token name dropped + ]) { + expect(captured?.headers?.[stripped]).toBeUndefined(); + } expect(captured?.headers?.['X-Good']).toBe('keep'); }); diff --git a/packages/db/src/safe-egress.ts b/packages/db/src/safe-egress.ts index a2e1c809..3e457d3f 100644 --- a/packages/db/src/safe-egress.ts +++ b/packages/db/src/safe-egress.ts @@ -214,7 +214,10 @@ const STRIPPED_HOP_HEADERS: ReadonlySet = new Set([ 'keep-alive', 'proxy-connection', 'te', + 'trailer', 'upgrade', + 'proxy-authenticate', + 'proxy-authorization', 'expect', 'x-forwarded-host', 'x-forwarded-for', @@ -273,9 +276,14 @@ export async function readBounded( * type is a typed, secret-free {@link SafeEgressError} — every raw resolver / socket / `new URL` / body-read * error becomes `SafeEgressError('network')`, never a raw leak. * - * The timeout does BOTH: it `abort()`s the controller (cooperative cancellation for an `fn` that honors the - * signal — the Node deps do, tearing down the socket) AND rejects the race after `timeoutMs`, so an `fn` that - * IGNORES its signal still fails deterministically at the deadline instead of hanging the caller forever. + * The timeout does BOTH: it `abort()`s the controller (cooperative cancellation) AND rejects the race after + * `timeoutMs`, so the CALLER is always unblocked at the deadline — even for an `fn` that ignores its signal. + * + * CANCELLATION SCOPE: the abort reaches the CONNECTION phase (`openConnection` passes the signal to + * `https.request`, which tears the socket down), but NOT the DNS-resolution phase — Node's + * `dns.promises.lookup` is not abortable, so a hung authoritative resolver keeps a background `getaddrinfo` + * running (a libuv threadpool slot) until the OS resolver's own timeout, even though the deadline already + * rejected the caller. The deadline bounds the caller's wait, not that background resource. */ export async function withEgressTimeout( signal: AbortSignalLike | undefined, From 6145202425fafbb698ff08ee9695736bced32055 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Thu, 2 Jul 2026 14:41:52 +0300 Subject: [PATCH 34/45] =?UTF-8?q?fix(cli,core,shared):=202.5.E=20F3=20?= =?UTF-8?q?=E2=80=94=20approval=20semantics=20(blank-check,=20deadlock,=20?= =?UTF-8?q?EA5=20emit)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/cli/src/chat/chat-mode.test.ts | 14 ++++ apps/cli/src/chat/chat-mode.ts | 27 +++++-- apps/cli/src/chat/session-host.test.ts | 29 +++++++ apps/cli/src/commands/chat.ts | 32 +++++--- apps/cli/src/render/tui/chat-store.test.ts | 6 +- .../src/render/tui/home-controller.test.ts | 10 +++ apps/cli/src/render/tui/home-controller.ts | 9 ++- .../core/src/engine/agent-session.test.ts | 31 ++++++++ packages/core/src/engine/agent-session.ts | 30 +++++++- packages/core/src/tools/registry.test.ts | 77 +++++++++++++++---- packages/core/src/tools/registry.ts | 16 +++- packages/core/src/tools/types.ts | 7 ++ packages/shared/src/run-event.test.ts | 16 ++++ packages/shared/src/run-event.ts | 28 +++++++ 14 files changed, 295 insertions(+), 37 deletions(-) diff --git a/apps/cli/src/chat/chat-mode.test.ts b/apps/cli/src/chat/chat-mode.test.ts index 2a1c818c..a287921d 100644 --- a/apps/cli/src/chat/chat-mode.test.ts +++ b/apps/cli/src/chat/chat-mode.test.ts @@ -167,6 +167,20 @@ describe('buildTurnPolicy — the mode → { advertise, confirm } mapping', () = expect(prompt).toHaveBeenCalledTimes(1); }); + it('accept-edits: a BLANK-preview tool (mcp_call/web_search) NEVER caches "always" — no session-long blank check', async () => { + const prompt = vi.fn(() => + Promise.resolve({ outcome: 'approve', scope: 'always' }), + ); + const policy = buildTurnPolicy('accept-edits', deps({ prompt })); + const blank = req({ toolId: 'mcp_call', action: 'egress', preview: {} }); // no path/command/host to review + expect(await policy.confirm!(blank)).toEqual({ outcome: 'approve' }); + // The prompt is told cacheable=false (the REPL greys out `[a]`), and the "always" is downgraded to once… + expect(prompt).toHaveBeenNthCalledWith(1, blank, false, undefined); + // …so the SECOND identical call re-prompts (not blanket-approved for the whole session). + expect(await policy.confirm!(blank)).toEqual({ outcome: 'approve' }); + expect(prompt).toHaveBeenCalledTimes(2); + }); + it('accept-edits: forwards the exact request + AbortSignal to the prompt (the preview the user approves)', async () => { const prompt = vi.fn(() => Promise.resolve({ outcome: 'approve', scope: 'once' }), diff --git a/apps/cli/src/chat/chat-mode.ts b/apps/cli/src/chat/chat-mode.ts index 5335fdd0..379d847f 100644 --- a/apps/cli/src/chat/chat-mode.ts +++ b/apps/cli/src/chat/chat-mode.ts @@ -61,9 +61,12 @@ export function nextMode(mode: ChatMode): ChatMode { */ export function parseMode(input: string): ChatMode | undefined { const normalized = input.trim().toLowerCase().replace(/\s+/gu, '-'); - return (CHAT_MODES as readonly string[]).includes(normalized) - ? (normalized as ChatMode) - : undefined; + return isChatMode(normalized) ? normalized : undefined; +} + +/** A type guard that narrows a string to {@link ChatMode} via the {@link CHAT_MODES} tuple — no `as` cast. */ +function isChatMode(value: string): value is ChatMode { + return (CHAT_MODES as readonly string[]).includes(value); } /** @@ -173,6 +176,16 @@ function advertiseFor( return undefined; // accept-edits / auto advertise every granted tool — the confirm floor gates them } +/** + * Whether an approval preview carries NO concrete target to review — no `path` (fs_write), `command` (process), + * or `host` (egress http). True for `mcp_call` / `web_search` (`previewFor` returns `{}`), whose action class is + * "enough" to gate but shows the user no specific server/tool/args. Such a grant must be once-only (never an + * `always`-cached blank check). + */ +function isBlankPreview(preview: ToolActionPreview): boolean { + return preview.path === undefined && preview.command === undefined && preview.host === undefined; +} + /** The per-mode approval hook. The registry only invokes it for a GOVERNED dispatch, so every call is gated. */ function confirmFor(mode: ChatMode, deps: TurnPolicyDeps): ConfirmActionHook { return async (request, signal): Promise => { @@ -182,8 +195,12 @@ function confirmFor(mode: ChatMode, deps: TurnPolicyDeps): ConfirmActionHook { return { outcome: 'reject', reason: `not allowed in ${MODE_LABEL[mode]} mode (read-only)` }; case 'accept-edits': { if (deps.cache.isAlways(request.toolId)) return { outcome: 'approve' }; - // An "always" answer here IS remembered (the accept-edits once/always memory). - const cacheable = true; + // An "always" answer is remembered (the once/always memory) ONLY when the preview showed a concrete + // target. A BLANK preview (mcp_call / web_search — `previewFor` returns no path/command/host) gives the + // user nothing to review, so `[a]lways` there would be an unreviewed, session-long blank check over any + // future server/tool/args (a prompt-injection-after-one-grant hazard, ADR-0057 review). For those the + // grant is once-only (cacheable=false ⇒ the REPL greys out `[a]` and toDecision never caches). + const cacheable = !isBlankPreview(request.preview); const answer = await deps.prompt(request, cacheable, signal); return toDecision(answer, request.toolId, deps.cache, cacheable); } diff --git a/apps/cli/src/chat/session-host.test.ts b/apps/cli/src/chat/session-host.test.ts index bbf6eda6..84b62203 100644 --- a/apps/cli/src/chat/session-host.test.ts +++ b/apps/cli/src/chat/session-host.test.ts @@ -497,6 +497,35 @@ describe('buildResumedChatSession (2.N)', () => { expect(existsSync(join(workspace, 'x.txt'))).toBe(false); }); + it('createChatModeControl with interactive:false DENIES a governed dispatch without HANGING (High 9 deadlock)', async () => { + // On a non-interactive driver (plain non-TTY / --json) nothing answers `requestApproval`. In `accept-edits` + // (a mode that would prompt on a TTY) the reject-immediately prompt must DENY the write, not publish an + // unanswerable promise — so `sendMessage` RESOLVES (a regression would hang here and time the test out). + const workspace = mkdtempSync(join(tmpdir(), 'relavium-ws-')); + const built = await buildResumedChatSession({ + chat: EMPTY_CHAT, + record: record({ + agentSnapshot: { ...RESUME_AGENT, tools: ['write_file'] }, + context: { workingDir: workspace, fsScopeTier: 'project' }, + }), + messages: [message(0, 'user', 'hi'), message(1, 'assistant', 'hello')], + now: () => Date.parse(ISO), + providers: scriptedResolver([ + callWithArgs('c1', 'write_file', { path: 'x.txt', content: 'p' }), + ]), + }); + const control = createChatModeControl(built, createChatStore(false), { interactive: false }); + control.onModeChange('accept-edits'); // a prompting mode — but nothing can answer on this driver + await built.session.sendMessage('write a file'); // MUST resolve (deny), never hang + built.session.cancel(); + const events = await drainHandle(built.handle.events); + const completed = events.find((e) => e.type === 'session:turn_completed'); + expect(completed?.type === 'session:turn_completed' ? completed.error?.code : undefined).toBe( + 'tool_denied', + ); + expect(existsSync(join(workspace, 'x.txt'))).toBe(false); + }, 10_000); + it('CLAMPS a persisted full fs-scope tier down to project on resume (read-only chat ceiling, ADR-0055)', async () => { // SECURITY regression: a session persisted (e.g. pre-2.5.A) with the broad `full` tier must resume at the // read-only chat ceiling — `project`, never `full` — so a resumed chat can't read `~/.ssh` / `~/.aws` back diff --git a/apps/cli/src/commands/chat.ts b/apps/cli/src/commands/chat.ts index fff6e822..00bf9d0f 100644 --- a/apps/cli/src/commands/chat.ts +++ b/apps/cli/src/commands/chat.ts @@ -22,6 +22,7 @@ import { MODE_DESCRIPTION, MODE_LABEL, parseMode, + type ApprovalPrompt, type ChatMode, } from '../chat/chat-mode.js'; import { applyChatMode, makeChatModeEnv } from '../chat/chat-mode-host.js'; @@ -419,12 +420,26 @@ export interface ChatModeControl { export function createChatModeControl( built: Pick, store: ChatStoreController, + opts?: { readonly interactive?: boolean }, ): ChatModeControl { + // The interactive prompt is the store's `requestApproval`, answered by the ink UI / Home controller. On a + // NON-interactive driver (plain non-TTY, or `--json`) NOTHING answers it — `store.requestApproval` would + // return an unanswerable promise and DEADLOCK the turn (High 9). So a non-interactive session uses a + // reject-immediately prompt: a governed dispatch in `accept-edits`/`auto` is denied (never a hang), mirroring + // the one-shot `agent run`. `interactive` defaults true (the ink REPL + the Home are always a TTY). + const interactive = opts?.interactive ?? true; + const prompt: ApprovalPrompt = interactive + ? store.requestApproval + : () => + Promise.resolve({ + outcome: 'reject', + reason: 'interactive approval is unavailable on this non-interactive driver', + }); const modeEnv = makeChatModeEnv({ session: built.session, tools: built.tools, workspaceDir: built.context.workingDir, - prompt: store.requestApproval, + prompt, }); applyChatMode(modeEnv, store.getSnapshot().mode); return { @@ -465,17 +480,16 @@ export function createChatLineHandler( built.session.cancel(); // the session's sole terminal (session:cancelled) — persister marks it 'ended' } }; + // Whether an interactive prompt (the ink UI / Home controller) can answer an approval: the ink view is mounted + // iff stdout is a TTY and not `--json` (keep in sync with `selectChatDriver` in render/tui/chat-ink.tsx). On a + // non-interactive driver nothing answers `requestApproval`, so the mode control uses a reject-immediately + // prompt (no deadlock). Also drives `emitOutput` (a NOTICE in-view vs. a stderr diagnostic). + const interactive = deps.io.stdoutIsTty && !deps.global.json; + // The reseat-less mode system (ADR-0057) — created HERE so both the `/mode` command (below) and the driver's // keys (Shift+Tab / Esc) drive the SAME control. It applies the initial `ask` mode immediately, so the // full-capability host is never live without the fail-closed approval regime (default `ask` denies governed). - const modeControl = createChatModeControl(built, store); - - // Surface command output (the /help list, /workflows catalog, /cost line) the right way for the active surface: - // the live ink view (TTY, non-`--json`) renders a NOTICE cleanly in-conversation; on the plain (non-TTY) and - // `--json` paths ink is NOT mounted (those drivers render the event stream, not the chat store), so write to - // stderr — a diagnostic that keeps stdout the pure event stream. (ink is mounted iff stdoutIsTty && !json — keep - // this condition in sync with `selectChatDriver` in render/tui/chat-ink.tsx, which picks driveInk on the same test.) - const interactive = deps.io.stdoutIsTty && !deps.global.json; + const modeControl = createChatModeControl(built, store, { interactive }); const emitOutput = (text: string): void => { if (interactive) { store.notice(text); diff --git a/apps/cli/src/render/tui/chat-store.test.ts b/apps/cli/src/render/tui/chat-store.test.ts index 32543392..bcd4f7dc 100644 --- a/apps/cli/src/render/tui/chat-store.test.ts +++ b/apps/cli/src/render/tui/chat-store.test.ts @@ -299,7 +299,8 @@ describe('createChatStore — chat mode + per-tool approval coordination (ADR-00 ac.abort(); const err = await pending.catch((e: unknown) => e); expect(err).toBeInstanceOf(Error); - expect((err as Error).name).toBe('AbortError'); // classified as a CANCEL by the registry's isAbort + // Narrow via instanceof (no `as Error`) before reading `.name` — classified as a CANCEL by isAbort. + if (err instanceof Error) expect(err.name).toBe('AbortError'); expect(store.getSnapshot().approval).toBeUndefined(); }); @@ -310,7 +311,8 @@ describe('createChatStore — chat mode + per-tool approval coordination (ADR-00 const err = await store .requestApproval(approvalRequest, true, ac.signal) .catch((e: unknown) => e); - expect((err as Error).name).toBe('AbortError'); + expect(err).toBeInstanceOf(Error); + if (err instanceof Error) expect(err.name).toBe('AbortError'); expect(store.getSnapshot().approval).toBeUndefined(); // no prompt was ever shown }); }); diff --git a/apps/cli/src/render/tui/home-controller.test.ts b/apps/cli/src/render/tui/home-controller.test.ts index e8c921a8..4b398b97 100644 --- a/apps/cli/src/render/tui/home-controller.test.ts +++ b/apps/cli/src/render/tui/home-controller.test.ts @@ -935,6 +935,16 @@ describe('createHomeController (2.5.B lifecycle / ADR-0054)', () => { expect(made.lines).not.toContain('/cancel'); // abort ≠ /cancel — the session stays alive }); + it('Esc REJECTS a pending approval when onAbort is ABSENT — never a dead key / stuck prompt', async () => { + // A session wired WITHOUT onAbort must still let Esc resolve a pending approval (reject) rather than leave + // it hung — the home-controller abort fallback (a pending approval + no onAbort ⇒ answerApproval reject). + const store = createChatStore(false); + const c = await inChat(makeSession({ store })); // no onAbort + const pending = store.requestApproval(approvalReq, true); + c.handleKey('', { escape: true }); + await expect(pending).resolves.toEqual({ outcome: 'reject' }); + }); + it('a pending approval intercepts keys: `/` stays closed and `[y]` approves-once', async () => { const store = createChatStore(false); const c = await inChat(makeSession({ store })); diff --git a/apps/cli/src/render/tui/home-controller.ts b/apps/cli/src/render/tui/home-controller.ts index 3bf95dd7..1256f006 100644 --- a/apps/cli/src/render/tui/home-controller.ts +++ b/apps/cli/src/render/tui/home-controller.ts @@ -350,7 +350,14 @@ export function createHomeController(deps: HomeControllerDeps): HomeController { active.onModeChange?.(nextMode(active.store.getSnapshot().mode)); return; case 'abort': - active.onAbort?.(); // Esc — mid-turn abort (keeps the session; distinct from /cancel) + // Esc — mid-turn abort (keeps the session; distinct from /cancel). `onAbort` aborts the turn, whose + // signal also rejects any in-flight approval. If `onAbort` is absent (a session wired without it), a + // PENDING approval would otherwise hang — reject it directly so Esc is never a dead key at a decision. + if (active.onAbort !== undefined) { + active.onAbort(); + } else if (active.store.getSnapshot().approval !== undefined) { + active.store.answerApproval({ outcome: 'reject' }); + } return; case 'approve': active.store.answerApproval({ outcome: 'approve', scope: action.scope }); diff --git a/packages/core/src/engine/agent-session.test.ts b/packages/core/src/engine/agent-session.test.ts index 778619c2..d01b5792 100644 --- a/packages/core/src/engine/agent-session.test.ts +++ b/packages/core/src/engine/agent-session.test.ts @@ -770,6 +770,37 @@ describe('AgentSession — reseat-less modes + mid-turn abort (ADR-0057 Step 2)' expect(captured?.approval?.confirm).toBe(confirm); }); + it('wires emitApprovalRequested (EA5) — it emits agent:approval_requested through the sink, stamping the nodeId', async () => { + let captured: ToolDispatchContext | undefined; + const capturing: ToolRegistry = { + has: () => true, + list: () => ['echo'], + dispatch: (toolCall, ctx) => { + captured = ctx; + return echoRegistry.dispatch(toolCall, ctx); + }, + }; + const { deps, events } = harness([toolUseTurn('c1'), textTurn('done')], {}, capturing); + const s = session(deps, TOOL_AGENT); + s.start(); + s.setTurnPolicy({ confirm: () => Promise.resolve({ outcome: 'approve' }) }); + await s.sendMessage('use echo'); + // The engine provided the emit; invoking it (as confirmDispatch does) puts a valid event on the sink. + captured?.approval?.emitApprovalRequested?.({ + toolId: 'write_file', + action: 'fs_write', + preview: { path: './out.txt' }, + }); + const approvalEvent = events.find((e) => e.type === 'agent:approval_requested'); + expect(approvalEvent).toMatchObject({ + type: 'agent:approval_requested', + nodeId: TOOL_AGENT.id, // stamped from the session's agentRef (matches the in-turn events) + toolId: 'write_file', + action: 'fs_write', + preview: { path: './out.txt' }, + }); + }); + it('no turn policy ⇒ no approval regime in the dispatch context (workflow author-trust parity)', async () => { let captured: ToolDispatchContext | undefined; const capturing: ToolRegistry = { diff --git a/packages/core/src/engine/agent-session.ts b/packages/core/src/engine/agent-session.ts index 59546b6d..ade2b77d 100644 --- a/packages/core/src/engine/agent-session.ts +++ b/packages/core/src/engine/agent-session.ts @@ -315,7 +315,8 @@ export class AgentSession { * turn failure still **completes** — with `stopReason: 'error'` and the mapped error code. Resolves * when the turn settles; a cancel mid-turn resolves quietly (the terminal is `session:cancelled`). */ - async sendMessage(text: string): Promise { + /** Guard the send preconditions: the session must be started and idle (not running/cancelled/ended). */ + #assertSendable(): void { if (this.#status === 'created') { throw new SessionStateError('not_started', `session ${this.sessionId}: call start() first`); } @@ -325,6 +326,10 @@ export class AgentSession { `session ${this.sessionId} is ${this.#status}; cannot send a message`, ); } + } + + async sendMessage(text: string): Promise { + this.#assertSendable(); // Clear any stale EA7 abort marker BEFORE arming the turn, so an `abort()` a prior turn's synchronous // turn_started-emit sink set (which then took a pre-`try` early return, bypassing the `finally` reset) @@ -531,7 +536,28 @@ export class AgentSession { // (`approval: {}`). No policy ⇒ no `approval` key ⇒ the workflow author-trust floor, unchanged. ...(turnPolicy === undefined ? {} - : { approval: turnPolicy.confirm === undefined ? {} : { confirm: turnPolicy.confirm } }), + : { + // No confirm hook ⇒ `approval: {}` (the fail-closed floor — a governed dispatch is denied with + // no_approval_hook, before any emit). WITH a hook, also wire EA5: the engine emits + // `agent:approval_requested` (stamping this turn's nodeId) through the same session sink the in-turn + // bodies use, just before the host's confirm hook prompts — a durable observability trace of the + // pending decision on the session / `--json` stream (ADR-0057). + approval: + turnPolicy.confirm === undefined + ? {} + : { + confirm: turnPolicy.confirm, + emitApprovalRequested: (request) => { + this.#deps.emit({ + type: 'agent:approval_requested', + nodeId: this.#agentRef, + toolId: request.toolId, + action: request.action, + preview: request.preview, + }); + }, + }, + }), }; // Advertise-filter (ADR-0057): narrow the model-visible tool set per the host's mode (best-effort; the // confirm floor stays authoritative). No policy / no filter ⇒ advertise every granted tool. diff --git a/packages/core/src/tools/registry.test.ts b/packages/core/src/tools/registry.test.ts index f4bbe8e2..225064be 100644 --- a/packages/core/src/tools/registry.test.ts +++ b/packages/core/src/tools/registry.test.ts @@ -344,6 +344,15 @@ describe('ToolRegistry — git_commit human-gate', () => { /* --- per-tool approval (ADR-0057 EA3) --- */ +/** + * A MUTABLE {@link AbortSignalLike} test double: `aborted` can be flipped mid-prompt (a cancel-during-confirm + * test), and the listeners are no-ops (the registry's `isAbort` reads `.aborted` / `cause.name`, never the + * listener path). Assignable to the `readonly aborted` param without an `as never` cast. + */ +function mutableSignal(aborted = false): { aborted: boolean } & AbortSignalLike { + return { aborted, addEventListener: () => undefined, removeEventListener: () => undefined }; +} + describe('ToolRegistry — per-tool approval (ADR-0057 EA3)', () => { // The `vi.fn` function-type generic types `.mock.calls[0]` as [ToolApprovalRequest, signal?] (no // unused impl param) and contextually types the decision literals (`'approve'`/`'reject'`). The 2nd @@ -411,6 +420,46 @@ describe('ToolRegistry — per-tool approval (ADR-0057 EA3)', () => { }); }); + it('emits agent:approval_requested (EA5) BEFORE invoking confirm — same request; an emit fault never breaks the floor', async () => { + const order: string[] = []; + const confirm = vi.fn(() => { + order.push('confirm'); + return Promise.resolve({ outcome: 'approve' }); + }); + const emitApprovalRequested = vi.fn<(req: ToolApprovalRequest) => void>(() => + order.push('emit'), + ); + const { host } = hostWithWriteSpy(); + await createToolRegistry({ tools: BUILTIN_TOOLS, host }).dispatch( + call('write_file', { path: './out.txt', content: 'hi' }), + ctx({ approval: { confirm, emitApprovalRequested } }), + ); + expect(order).toEqual(['emit', 'confirm']); // the observability event fires just BEFORE the prompt + expect(emitApprovalRequested).toHaveBeenCalledWith( + expect.objectContaining({ toolId: 'write_file', action: 'fs_write', preview: { path: './out.txt' } }), + ); + // The confirm hook received the SAME request object (one construction, EA5-emitted then confirmed). + expect(emitApprovalRequested.mock.calls[0]?.[0]).toBe(confirm.mock.calls[0]?.[0]); + }); + + it('a THROWING emitApprovalRequested does not break the approval decision (best-effort observability)', async () => { + const confirm = approving(); + const { host, writeFile } = hostWithWriteSpy(); + const out = await createToolRegistry({ tools: BUILTIN_TOOLS, host }).dispatch( + call('write_file', { path: './out.txt', content: 'hi' }), + ctx({ + approval: { + confirm, + emitApprovalRequested: () => { + throw new Error('sink boom'); + }, + }, + }), + ); + expect(out.events.result.success).toBe(true); // the emit fault was swallowed; the write still dispatched + expect(writeFile).toHaveBeenCalledOnce(); + }); + it('denies a rejected write as a fatal tool_denied (user_rejected) and never reaches the side effect', async () => { const confirm = rejecting(); const { host, writeFile } = hostWithWriteSpy(); @@ -579,7 +628,7 @@ describe('ToolRegistry — per-tool approval (ADR-0057 EA3)', () => { // above exercises only the cause.name branch), and proves cancel precedence over the fail-closed deny. // The signal must flip DURING the prompt (not before): an already-aborted signal short-circuits at the // dispatch entry guard before the hook ever runs. - const signal = { aborted: false }; + const signal = mutableSignal(); const { host, writeFile } = hostWithWriteSpy(); const confirm = vi.fn(() => { signal.aborted = true; // cancelled while the prompt is pending… @@ -588,7 +637,7 @@ describe('ToolRegistry — per-tool approval (ADR-0057 EA3)', () => { const err = await rejectsWith( createToolRegistry({ tools: BUILTIN_TOOLS, host }).dispatch( call('write_file', { path: './out.txt', content: 'hi' }), - ctx({ approval: { confirm }, signal: signal as never }), + ctx({ approval: { confirm }, signal: signal }), ), ); expect(err).toBeInstanceOf(ToolCancelledError); @@ -598,7 +647,7 @@ describe('ToolRegistry — per-tool approval (ADR-0057 EA3)', () => { it('cancels (never executes) when the signal aborts WHILE prompting and the hook still resolves approve', async () => { // The fail-closed cancellation contract: a hook that ignores the AbortSignal and approves anyway must // NOT reach the side effect — the trailing throwIfAborted in confirmDispatch catches it. - const signal = { aborted: false }; + const signal = mutableSignal(); const { host, writeFile } = hostWithWriteSpy(); const confirm = vi.fn(() => { signal.aborted = true; // the run is cancelled while the prompt is pending… @@ -607,7 +656,7 @@ describe('ToolRegistry — per-tool approval (ADR-0057 EA3)', () => { const err = await rejectsWith( createToolRegistry({ tools: BUILTIN_TOOLS, host }).dispatch( call('write_file', { path: './out.txt', content: 'hi' }), - ctx({ approval: { confirm }, signal: signal as never }), + ctx({ approval: { confirm }, signal: signal }), ), ); expect(err).toBeInstanceOf(ToolCancelledError); @@ -669,11 +718,11 @@ describe('ToolRegistry — per-tool approval (ADR-0057 EA3)', () => { }); it('forwards ctx.signal to the confirm hook as its second argument', async () => { - const signal = { aborted: false }; + const signal = mutableSignal(); const confirm = approving(); await registry().dispatch( call('write_file', { path: './out.txt', content: 'hi' }), - ctx({ approval: { confirm }, signal: signal as never }), + ctx({ approval: { confirm }, signal: signal }), ); expect(confirm.mock.calls[0]?.[1]).toBe(signal); }); @@ -715,7 +764,7 @@ describe('ToolRegistry — per-tool approval (ADR-0057 EA3)', () => { const err = await rejectsWith( createToolRegistry({ tools: BUILTIN_TOOLS, host }).dispatch( call('write_file', { path: './out.txt', content: 'hi' }), - ctx({ approval: {}, signal: { aborted: true } as never }), + ctx({ approval: {}, signal: mutableSignal(true) }), ), ); expect(err).toBeInstanceOf(ToolCancelledError); @@ -864,7 +913,7 @@ describe('ToolRegistry — host capability, execution, cancellation', () => { const err = await rejectsWith( registry().dispatch( call('read_file', { path: 'a' }), - ctx({ signal: { aborted: true } as never }), + ctx({ signal: mutableSignal(true) }), ), ); expect(err).toBeInstanceOf(ToolCancelledError); @@ -1151,7 +1200,7 @@ describe('ToolRegistry — glob, provider_executed, and mid-dispatch cancel', () }); it('classifies an abort that flips mid-dispatch (plain host error) as cancelled, not tool_failed', async () => { - const signal = { aborted: false }; + const signal = mutableSignal(); const host = stubHost({ fs: fsWith(() => { signal.aborted = true; // the run is cancelled while the host is in-flight @@ -1161,7 +1210,7 @@ describe('ToolRegistry — glob, provider_executed, and mid-dispatch cancel', () const err = await rejectsWith( createToolRegistry({ tools: BUILTIN_TOOLS, host }).dispatch( call('read_file', { path: 'a' }), - ctx({ signal: signal as never }), + ctx({ signal: signal }), ), ); expect(err).toBeInstanceOf(ToolCancelledError); @@ -1187,7 +1236,7 @@ describe('ToolRegistry — glob, provider_executed, and mid-dispatch cancel', () describe('ToolRegistry — abort precedence after each await (M-3 line-109, H-1 post-bounding)', () => { it('classifies an abort that lands after the host RESOLVES (M-3 / line 109)', async () => { - const signal = { aborted: false }; + const signal = mutableSignal(); const host = stubHost({ fs: fsWith(() => { signal.aborted = true; // cancelled while in-flight, but the host RESOLVES (no throw) @@ -1202,14 +1251,14 @@ describe('ToolRegistry — abort precedence after each await (M-3 line-109, H-1 const err = await rejectsWith( createToolRegistry({ tools: BUILTIN_TOOLS, host }).dispatch( call('read_file', { path: 'a' }), - ctx({ signal: signal as never }), + ctx({ signal: signal }), ), ); expect(err).toBeInstanceOf(ToolCancelledError); }); it('classifies an abort that lands during bounding (H-1 / post-boundForModel guard)', async () => { - const signal = { aborted: false }; + const signal = mutableSignal(); const big = 'z'.repeat(5000); const host = stubHost({ fs: fsWith(() => @@ -1230,7 +1279,7 @@ describe('ToolRegistry — abort precedence after each await (M-3 line-109, H-1 const err = await rejectsWith( createToolRegistry({ tools: BUILTIN_TOOLS, host }).dispatch( call('read_file', { path: 'a' }), - ctx({ signal: signal as never, limits: { maxBytes: 10, maxLines: 1 } }), + ctx({ signal: signal, limits: { maxBytes: 10, maxLines: 1 } }), ), ); expect(err).toBeInstanceOf(ToolCancelledError); diff --git a/packages/core/src/tools/registry.ts b/packages/core/src/tools/registry.ts index f3407ee2..26f64588 100644 --- a/packages/core/src/tools/registry.ts +++ b/packages/core/src/tools/registry.ts @@ -26,6 +26,7 @@ import { type PolicyTarget, type ToolActionPreview, type ToolApprovalDecision, + type ToolApprovalRequest, type ToolCallPart, type ToolDef, type ToolDispatchContext, @@ -351,12 +352,19 @@ async function confirmDispatch( } throwIfAborted(ctx, def.id); // do not prompt for a turn that is already aborting + const request: ToolApprovalRequest = { toolId: def.id, action, preview: previewFor(action, target) }; + // EA5: emit the observability event just before prompting so a durable trace of the pending decision rides + // the session / `--json` stream (the session stamps the envelope + nodeId). Side-effect only — a throwing or + // absent emitter must NOT change the fail-closed floor, so it is best-effort (swallow any emit fault). + try { + approval.emitApprovalRequested?.(request); + } catch { + /* an observability emit must never break the approval decision */ + } + let decision: ToolApprovalDecision; try { - decision = await approval.confirm( - { toolId: def.id, action, preview: previewFor(action, target) }, - ctx.signal, - ); + decision = await approval.confirm(request, ctx.signal); } catch (cause) { // An abort raised WHILE prompting is a cancellation (cancel precedence) — rethrow so the dispatch // ladder classifies it as `cancelled`, never a denial. Any OTHER throw is a fault in the consent layer diff --git a/packages/core/src/tools/types.ts b/packages/core/src/tools/types.ts index 1749e755..00760828 100644 --- a/packages/core/src/tools/types.ts +++ b/packages/core/src/tools/types.ts @@ -319,6 +319,13 @@ export type ConfirmActionHook = ( */ export interface ToolApprovalContext { readonly confirm?: ConfirmActionHook; + /** + * Emit the `agent:approval_requested` observability event (ADR-0057 EA5) — called by `confirmDispatch` for + * every GOVERNED dispatch, just before invoking {@link confirm}, so a durable trace of the pending decision + * rides the session/`--json` stream (the session fills the envelope + `nodeId`). Optional and side-effect + * only: the fail-closed confirm floor is unaffected by its presence or absence. + */ + readonly emitApprovalRequested?: (request: ToolApprovalRequest) => void; } export interface ToolDispatchContext { diff --git a/packages/shared/src/run-event.test.ts b/packages/shared/src/run-event.test.ts index 39cad887..dd8ccce8 100644 --- a/packages/shared/src/run-event.test.ts +++ b/packages/shared/src/run-event.test.ts @@ -251,6 +251,22 @@ const reject: Record> = { action: 'fs_write', preview: { path: '' }, // path/command/host are all nonEmptyString — an empty display value is rejected }, + 'agent:approval_requested (egress preview carrying a path — action drift)': { + type: 'agent:approval_requested', + ...env, + nodeId: 'n', + toolId: 'http_request', + action: 'egress', + preview: { path: './out.txt' }, // an egress approval must carry `host` ONLY — a path is action drift (superRefine) + }, + 'agent:approval_requested (fs_write preview carrying a host — action drift)': { + type: 'agent:approval_requested', + ...env, + nodeId: 'n', + toolId: 'write_file', + action: 'fs_write', + preview: { host: 'evil.example' }, // fs_write carries `path` ONLY — a host is action drift + }, 'agent:approval_requested (empty preview command)': { type: 'agent:approval_requested', ...env, diff --git a/packages/shared/src/run-event.ts b/packages/shared/src/run-event.ts index 885da14c..e4f16d8d 100644 --- a/packages/shared/src/run-event.ts +++ b/packages/shared/src/run-event.ts @@ -215,6 +215,18 @@ export const AgentApprovalRequestedEventSchema = z.object({ }); export type AgentApprovalRequestedEvent = z.infer; +/** + * The preview field a per-tool approval action class produces — `fs_write` → path, `process` → command, + * `egress` → host, `os` → none (a blank preview). Consumed by the union-level `superRefine` (a + * discriminatedUnion member can't carry its own cross-field refinement) to reject an action-preview DRIFT a + * host-wiring bug could introduce (e.g. an `egress` approval must never surface a `path`) — `.strict()` on the + * preview already bars an UNKNOWN key; this bars a KNOWN-but-wrong-for-the-action one. + */ +const APPROVAL_PREVIEW_FIELD: Record< + (typeof TOOL_ACTION_CLASSES)[number], + 'path' | 'command' | 'host' | undefined +> = { fs_write: 'path', process: 'command', egress: 'host', os: undefined }; + export const AgentFilePatchProposedEventSchema = z.object({ type: z.literal('agent:file_patch_proposed'), ...runBase, @@ -543,6 +555,22 @@ export const RunEventSchema = RunEventUnionSchema.superRefine((event, ctx) => { path: ['deadlineAt'], }); } + // Bind an approval preview to its action class (ADR-0057 EA5): a preview may carry ONLY the field its action + // produces (see {@link APPROVAL_PREVIEW_FIELD}) — an `egress` approval must never surface a `path`, etc. This + // rejects an action drift a host-wiring bug could add. Enforced here (not on the member) because the member is + // a discriminatedUnion option and a member-level `.refine()` would make it a ZodEffects. + if (event.type === 'agent:approval_requested') { + const allowed = APPROVAL_PREVIEW_FIELD[event.action]; + for (const key of ['path', 'command', 'host'] as const) { + if (key !== allowed && event.preview[key] !== undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `preview.${key} is not valid for a ${event.action} approval`, + path: ['preview', key], + }); + } + } + } }); export type RunEvent = z.infer; From 3b96612f7095a4fa5c892473cfe44d4ae1f0d3ba Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Thu, 2 Jul 2026 14:57:50 +0300 Subject: [PATCH 35/45] =?UTF-8?q?fix(cli,core,shared):=202.5.E=20F3=20?= =?UTF-8?q?=E2=80=94=20address=20opus=20review=20(docs/polish=20+=20test?= =?UTF-8?q?=20gaps)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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` 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) --- apps/cli/src/chat/chat-mode.test.ts | 15 ++++++++ apps/cli/src/chat/chat-mode.ts | 10 ++++- .../src/render/tui/home-controller.test.ts | 13 +++++++ .../core/src/engine/agent-session.test.ts | 10 +++++ packages/core/src/engine/agent-session.ts | 14 +++---- packages/core/src/tools/registry.test.ts | 19 ++++++++++ packages/core/src/tools/registry.ts | 8 ++-- packages/shared/src/run-event.test.ts | 37 +++++++++++++++++++ packages/shared/src/run-event.ts | 13 +++++-- 9 files changed, 124 insertions(+), 15 deletions(-) diff --git a/apps/cli/src/chat/chat-mode.test.ts b/apps/cli/src/chat/chat-mode.test.ts index a287921d..07c75f34 100644 --- a/apps/cli/src/chat/chat-mode.test.ts +++ b/apps/cli/src/chat/chat-mode.test.ts @@ -167,6 +167,21 @@ describe('buildTurnPolicy — the mode → { advertise, confirm } mapping', () = expect(prompt).toHaveBeenCalledTimes(1); }); + it('accept-edits: a CONCRETE egress preview (host) STILL caches "always" — the blank-check did not over-correct', async () => { + const prompt = vi.fn(() => + Promise.resolve({ outcome: 'approve', scope: 'always' }), + ); + const policy = buildTurnPolicy('accept-edits', deps({ prompt })); + const egressReq = req({ toolId: 'http_request', action: 'egress', preview: { host: 'api.example.com' } }); + expect(await policy.confirm!(egressReq)).toEqual({ outcome: 'approve' }); + expect(prompt).toHaveBeenNthCalledWith(1, egressReq, true, undefined); // cacheable=true (a concrete host) + // A concrete-preview grant IS cached — the second call short-circuits (proves isBlankPreview's `host` check). + expect( + await policy.confirm!(req({ toolId: 'http_request', action: 'egress', preview: { host: 'api.example.com' } })), + ).toEqual({ outcome: 'approve' }); + expect(prompt).toHaveBeenCalledTimes(1); + }); + it('accept-edits: a BLANK-preview tool (mcp_call/web_search) NEVER caches "always" — no session-long blank check', async () => { const prompt = vi.fn(() => Promise.resolve({ outcome: 'approve', scope: 'always' }), diff --git a/apps/cli/src/chat/chat-mode.ts b/apps/cli/src/chat/chat-mode.ts index 379d847f..8f7a0c00 100644 --- a/apps/cli/src/chat/chat-mode.ts +++ b/apps/cli/src/chat/chat-mode.ts @@ -183,7 +183,15 @@ function advertiseFor( * `always`-cached blank check). */ function isBlankPreview(preview: ToolActionPreview): boolean { - return preview.path === undefined && preview.command === undefined && preview.host === undefined; + // Keyed by `keyof ToolActionPreview` so a NEW reviewable field breaks the build HERE (it must be added below) + // rather than silently making a preview that carries it look "blank" — which would re-open the `always` blank + // check this closes. Every field must be absent for the preview to count as blank. + const fields: Record = { + path: preview.path, + command: preview.command, + host: preview.host, + }; + return Object.values(fields).every((value) => value === undefined); } /** The per-mode approval hook. The registry only invokes it for a GOVERNED dispatch, so every call is gated. */ diff --git a/apps/cli/src/render/tui/home-controller.test.ts b/apps/cli/src/render/tui/home-controller.test.ts index 4b398b97..5c29f52a 100644 --- a/apps/cli/src/render/tui/home-controller.test.ts +++ b/apps/cli/src/render/tui/home-controller.test.ts @@ -945,6 +945,19 @@ describe('createHomeController (2.5.B lifecycle / ADR-0054)', () => { await expect(pending).resolves.toEqual({ outcome: 'reject' }); }); + it('Esc with onAbort PRESENT aborts the turn and does NOT also answer the approval (no double-settle)', async () => { + // The `if onAbort` branch wins: the turn is aborted (its signal resolves the approval); the fallback + // answerApproval must NOT also fire (a refactor to two independent `if`s would double-settle). + const onAbort = vi.fn(); + const store = createChatStore(false); + const c = await inChat(makeSession({ onAbort, store })); + const pending = store.requestApproval(approvalReq, true); + void pending.catch(() => undefined); // onAbort is a mock (doesn't fire the signal) — avoid an unhandled reject + c.handleKey('', { escape: true }); + expect(onAbort).toHaveBeenCalledTimes(1); + expect(store.getSnapshot().approval).not.toBeUndefined(); // the fallback did NOT answer it — onAbort owns the abort + }); + it('a pending approval intercepts keys: `/` stays closed and `[y]` approves-once', async () => { const store = createChatStore(false); const c = await inChat(makeSession({ store })); diff --git a/packages/core/src/engine/agent-session.test.ts b/packages/core/src/engine/agent-session.test.ts index d01b5792..ea558632 100644 --- a/packages/core/src/engine/agent-session.test.ts +++ b/packages/core/src/engine/agent-session.test.ts @@ -7,6 +7,7 @@ import type { } from '@relavium/llm'; import { AgentSchema, + RunEventSchema, SessionContextSchema, type Agent, type SessionContext, @@ -799,6 +800,15 @@ describe('AgentSession — reseat-less modes + mid-turn abort (ADR-0057 Step 2)' action: 'fs_write', preview: { path: './out.txt' }, }); + // The emitted body, once the sink stamps the session envelope (1.W), is a SCHEMA-VALID run event — the + // action-bound preview + dual-envelope refinements accept it (this is what the bus parses against). + const validated = RunEventSchema.safeParse({ + ...approvalEvent, + sessionId: 's1', + timestamp: '2026-06-19T00:00:00.000Z', + sequenceNumber: 0, + }); + expect(validated.success).toBe(true); }); it('no turn policy ⇒ no approval regime in the dispatch context (workflow author-trust parity)', async () => { diff --git a/packages/core/src/engine/agent-session.ts b/packages/core/src/engine/agent-session.ts index ade2b77d..e7015c97 100644 --- a/packages/core/src/engine/agent-session.ts +++ b/packages/core/src/engine/agent-session.ts @@ -308,13 +308,6 @@ export class AgentSession { this.#turnPolicy = policy; } - /** - * Run one user turn end to end: append the user message, drive the turn core (streaming + tool loop + - * fallback), append the assistant reply, and emit `session:turn_started` → `session:turn_completed`. - * A turn past the hard cap is blocked **loudly** with `turn_limit` and **no egress**. A classified - * turn failure still **completes** — with `stopReason: 'error'` and the mapped error code. Resolves - * when the turn settles; a cancel mid-turn resolves quietly (the terminal is `session:cancelled`). - */ /** Guard the send preconditions: the session must be started and idle (not running/cancelled/ended). */ #assertSendable(): void { if (this.#status === 'created') { @@ -328,6 +321,13 @@ export class AgentSession { } } + /** + * Run one user turn end to end: append the user message, drive the turn core (streaming + tool loop + + * fallback), append the assistant reply, and emit `session:turn_started` → `session:turn_completed`. + * A turn past the hard cap is blocked **loudly** with `turn_limit` and **no egress**. A classified + * turn failure still **completes** — with `stopReason: 'error'` and the mapped error code. Resolves + * when the turn settles; a cancel mid-turn resolves quietly (the terminal is `session:cancelled`). + */ async sendMessage(text: string): Promise { this.#assertSendable(); diff --git a/packages/core/src/tools/registry.test.ts b/packages/core/src/tools/registry.test.ts index 225064be..748a6b16 100644 --- a/packages/core/src/tools/registry.test.ts +++ b/packages/core/src/tools/registry.test.ts @@ -442,6 +442,25 @@ describe('ToolRegistry — per-tool approval (ADR-0057 EA3)', () => { expect(emitApprovalRequested.mock.calls[0]?.[0]).toBe(confirm.mock.calls[0]?.[0]); }); + it('emits agent:approval_requested even for a REJECTING confirm — a denied governed action is still traced', async () => { + const order: string[] = []; + const confirm = vi.fn(() => { + order.push('confirm'); + return Promise.resolve({ outcome: 'reject', reason: 'no' }); + }); + const emitApprovalRequested = vi.fn<(req: ToolApprovalRequest) => void>(() => order.push('emit')); + const { host, writeFile } = hostWithWriteSpy(); + await rejectsWith( + createToolRegistry({ tools: BUILTIN_TOOLS, host }).dispatch( + call('write_file', { path: './out.txt', content: 'hi' }), + ctx({ approval: { confirm, emitApprovalRequested } }), + ), + ); + expect(order).toEqual(['emit', 'confirm']); // the trace fires regardless of the (reject) outcome + expect(emitApprovalRequested).toHaveBeenCalledOnce(); + expect(writeFile).not.toHaveBeenCalled(); + }); + it('a THROWING emitApprovalRequested does not break the approval decision (best-effort observability)', async () => { const confirm = approving(); const { host, writeFile } = hostWithWriteSpy(); diff --git a/packages/core/src/tools/registry.ts b/packages/core/src/tools/registry.ts index 26f64588..723be4d0 100644 --- a/packages/core/src/tools/registry.ts +++ b/packages/core/src/tools/registry.ts @@ -353,9 +353,11 @@ async function confirmDispatch( throwIfAborted(ctx, def.id); // do not prompt for a turn that is already aborting const request: ToolApprovalRequest = { toolId: def.id, action, preview: previewFor(action, target) }; - // EA5: emit the observability event just before prompting so a durable trace of the pending decision rides - // the session / `--json` stream (the session stamps the envelope + nodeId). Side-effect only — a throwing or - // absent emitter must NOT change the fail-closed floor, so it is best-effort (swallow any emit fault). + // EA5: emit the observability event for EVERY governed dispatch that reaches this gate, just before the host + // decides — a durable "a governed action was gated" trace on the session / `--json` stream (the session + // stamps the envelope + nodeId), whether the host then prompts a human or auto-decides. Side-effect only — a + // throwing or absent emitter must NOT change the fail-closed floor, so it is best-effort (swallow any fault; + // a schema-invalid drift would throw inside the sink's parse and is dropped here rather than breaking the turn). try { approval.emitApprovalRequested?.(request); } catch { diff --git a/packages/shared/src/run-event.test.ts b/packages/shared/src/run-event.test.ts index dd8ccce8..14a5a508 100644 --- a/packages/shared/src/run-event.test.ts +++ b/packages/shared/src/run-event.test.ts @@ -267,6 +267,22 @@ const reject: Record> = { action: 'fs_write', preview: { host: 'evil.example' }, // fs_write carries `path` ONLY — a host is action drift }, + 'agent:approval_requested (os preview carrying a path — action drift)': { + type: 'agent:approval_requested', + ...env, + nodeId: 'n', + toolId: 'read_clipboard', + action: 'os', + preview: { path: './leak.txt' }, // os carries NO field — any of path/command/host is drift + }, + 'agent:approval_requested (process preview carrying a host — action drift)': { + type: 'agent:approval_requested', + ...env, + nodeId: 'n', + toolId: 'run_command', + action: 'process', + preview: { host: 'evil.example' }, // process carries `command` ONLY — a host is action drift + }, 'agent:approval_requested (empty preview command)': { type: 'agent:approval_requested', ...env, @@ -427,6 +443,27 @@ describe('RunEvent union — every variant', () => { ).toBe(true); }); + it('ACCEPTS a BLANK approval preview for os and egress (the action-bind superRefine must not over-reject)', () => { + // APPROVAL_PREVIEW_FIELD maps os → undefined (no field) and egress → host (OPTIONAL), so an all-empty + // preview is VALID for read_clipboard/notify (os) and mcp_call/web_search (blank egress). A regression that + // mis-resolved the allowed key would silently break the whole os / MCP approval class — pin the accept side. + for (const [toolId, action] of [ + ['read_clipboard', 'os'], + ['mcp_call', 'egress'], + ] as const) { + expect( + RunEventSchema.safeParse({ + type: 'agent:approval_requested', + ...env, + nodeId: 'n', + toolId, + action, + preview: {}, + }).success, + ).toBe(true); + } + }); + it('covers exactly the 22 canonical colon-namespaced names, pinned to a literal list', () => { // A hardcoded contract list — independent of RUN_EVENT_TYPES — so the union and the // constant cannot silently drift together. diff --git a/packages/shared/src/run-event.ts b/packages/shared/src/run-event.ts index e4f16d8d..8653bfdc 100644 --- a/packages/shared/src/run-event.ts +++ b/packages/shared/src/run-event.ts @@ -188,13 +188,18 @@ export const AgentToolResultEventSchema = z.object({ export const ToolActionClassSchema = z.enum(TOOL_ACTION_CLASSES); /** - * A side-effecting tool dispatch is awaiting an interactive per-tool approval decision (ADR-0057 EA3/EA5). - * The host's `ConfirmActionHook` emits it just before prompting the user; the registry then awaits the - * verdict (approve ⇒ dispatch, reject ⇒ a fatal `tool_denied`). A **dual-envelope** event (`runId` on a run, + * A GOVERNED tool dispatch reached the per-tool approval gate (ADR-0057 EA3/EA5) — a durable trace that the + * governed action was gated. The engine's `confirmDispatch` emits it just before invoking the host's + * `ConfirmActionHook`, whether that hook then PROMPTS a human (accept-edits / auto's protected-path fallback) + * or DECIDES without one (ask/plan auto-deny, auto auto-approve) — so a `--json` consumer should read it as + * "a governed action was gated", NOT "the user was asked N times". The registry then awaits the verdict + * (approve ⇒ dispatch, reject ⇒ a fatal `tool_denied`). A **dual-envelope** event (`runId` on a run, * `sessionId` on a session) in the `agent:*` namespace, like `agent:tool_call` — in Phase 2.5 it is emitted * only on the chat session path (the approval regime), and the session sink carries it (not run-only, so it * is NOT dropped like `agent:file_patch_proposed`). The `preview` is **secret-free and display-only**: the - * resolved target path / command / host (never a full URL+query, never a secret). + * resolved target path / command / host (never a full URL+query, never a secret). `attemptNumber` is absent on + * the session path today (the registry's `ToolApprovalRequest` carries no chain-attempt index — a cross-seam + * concept); a `--json` consumer correlates to the following `agent:tool_call` by `sequenceNumber` proximity. */ export const AgentApprovalRequestedEventSchema = z.object({ type: z.literal('agent:approval_requested'), From 2839e1e648beb1cf3e9d0c52a1fd97bff5c5acea Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Thu, 2 Jul 2026 15:17:09 +0300 Subject: [PATCH 36/45] =?UTF-8?q?fix(cli,core):=202.5.E=20F3=20=E2=80=94?= =?UTF-8?q?=20address=20Sonnet=205=20review=20(doc=20drift,=20deadlock=20d?= =?UTF-8?q?erivation=20test)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/cli/src/chat/chat-mode.test.ts | 14 ++++++++++++++ apps/cli/src/chat/session-host.test.ts | 11 ++++++++++- apps/cli/src/commands/chat.test.ts | 10 ++++++++++ apps/cli/src/commands/chat.ts | 23 ++++++++++++++++++----- docs/roadmap/deferred-tasks.md | 9 +++++++++ packages/core/src/engine/agent-session.ts | 8 +++++--- packages/core/src/index.ts | 2 +- 7 files changed, 67 insertions(+), 10 deletions(-) diff --git a/apps/cli/src/chat/chat-mode.test.ts b/apps/cli/src/chat/chat-mode.test.ts index 07c75f34..d7bda3aa 100644 --- a/apps/cli/src/chat/chat-mode.test.ts +++ b/apps/cli/src/chat/chat-mode.test.ts @@ -182,6 +182,20 @@ describe('buildTurnPolicy — the mode → { advertise, confirm } mapping', () = expect(prompt).toHaveBeenCalledTimes(1); }); + it('accept-edits: a CONCRETE process preview (command) STILL caches "always" — pins isBlankPreview`s command check', async () => { + const prompt = vi.fn(() => + Promise.resolve({ outcome: 'approve', scope: 'always' }), + ); + const policy = buildTurnPolicy('accept-edits', deps({ prompt })); + const procReq = req({ toolId: 'run_command', action: 'process', preview: { command: 'ls -la' } }); + expect(await policy.confirm!(procReq)).toEqual({ outcome: 'approve' }); + expect(prompt).toHaveBeenNthCalledWith(1, procReq, true, undefined); // cacheable=true (a concrete command) + expect( + await policy.confirm!(req({ toolId: 'run_command', action: 'process', preview: { command: 'ls -la' } })), + ).toEqual({ outcome: 'approve' }); + expect(prompt).toHaveBeenCalledTimes(1); // cached — a dropped `command` check would force a re-prompt here + }); + it('accept-edits: a BLANK-preview tool (mcp_call/web_search) NEVER caches "always" — no session-long blank check', async () => { const prompt = vi.fn(() => Promise.resolve({ outcome: 'approve', scope: 'always' }), diff --git a/apps/cli/src/chat/session-host.test.ts b/apps/cli/src/chat/session-host.test.ts index 84b62203..0e5bc67b 100644 --- a/apps/cli/src/chat/session-host.test.ts +++ b/apps/cli/src/chat/session-host.test.ts @@ -946,9 +946,18 @@ describe('buildChatSession + 2.5.A tool-host wiring (ADR-0055)', () => { store.answerApproval({ outcome: 'approve', scope: 'once' }); await turn; built.session.cancel(); - await drainHandle(built.handle.events); + const events = await drainHandle(built.handle.events); expect(existsSync(join(workspace, 'ok.txt'))).toBe(true); // approved ⇒ the write landed expect(readFileSync(join(workspace, 'ok.txt'), 'utf8')).toBe('hi'); + // EA5 end-to-end: a real AgentSession turn → real registry confirmDispatch → the emit lands on the handle + // stream (locks the full compose the unit tests only prove per-hop — nodeId stamped, action-bound preview). + const approvalEvent = events.find((e) => e.type === 'agent:approval_requested'); + expect(approvalEvent?.type).toBe('agent:approval_requested'); + if (approvalEvent?.type === 'agent:approval_requested') { + expect(approvalEvent.toolId).toBe('write_file'); + expect(approvalEvent.action).toBe('fs_write'); + expect(approvalEvent.preview.path).toContain('ok.txt'); // the resolved target, nodeId-stamped by the session + } }); it('createChatModeControl auto: a PROTECTED-path write FALLS BACK to a prompt (not auto-approved)', async () => { diff --git a/apps/cli/src/commands/chat.test.ts b/apps/cli/src/commands/chat.test.ts index e1ec98b2..6ba06135 100644 --- a/apps/cli/src/commands/chat.test.ts +++ b/apps/cli/src/commands/chat.test.ts @@ -25,6 +25,7 @@ import { createChatStore } from '../render/tui/chat-store.js'; import { captureIo, parseNdjson } from '../test-support.js'; import { chatCommand, + chatIsInteractive, chatResumeCommand, driveJson, drivePlain, @@ -1038,6 +1039,15 @@ describe('makePlainPrinter', () => { }); }); +describe('chatIsInteractive (the High-9 deadlock derivation — mirrors selectChatDriver`s ink-mount)', () => { + it('is true ONLY for a TTY without --json; false when piped OR --json (a dropped `!` would break this)', () => { + expect(chatIsInteractive({ stdoutIsTty: true }, { json: false })).toBe(true); // ink mounts → can prompt + expect(chatIsInteractive({ stdoutIsTty: false }, { json: false })).toBe(false); // piped → reject-immediately + expect(chatIsInteractive({ stdoutIsTty: true }, { json: true })).toBe(false); // --json → reject-immediately + expect(chatIsInteractive({ stdoutIsTty: false }, { json: true })).toBe(false); + }); +}); + describe('selectChatDriver', () => { // A ctx whose stdin is already at EOF, so the PLAIN driver resolves immediately. The ink driver would mount // and block on input forever, so a resolving promise PROVES the plain branch was chosen. If the routing diff --git a/apps/cli/src/commands/chat.ts b/apps/cli/src/commands/chat.ts index 00bf9d0f..007f507e 100644 --- a/apps/cli/src/commands/chat.ts +++ b/apps/cli/src/commands/chat.ts @@ -417,6 +417,19 @@ export interface ChatModeControl { * `requestApproval`) and **applies the initial mode immediately** — so the regime is active from the first turn * (default `ask` denies every governed action). The returned `onModeChange` re-applies on a Shift+Tab / `/mode`. */ +/** + * Whether the chat surface can answer an interactive approval prompt — the ink UI is mounted (stdout is a TTY + * AND not `--json`), the SAME condition `selectChatDriver` (render/tui/chat-ink.tsx) picks `driveInk` on. A + * non-interactive driver (plain non-TTY / `--json`) has nothing to answer `requestApproval`, so the mode control + * uses a reject-immediately prompt (no deadlock, High 9). Named + exported so the derivation is unit-locked. + */ +export function chatIsInteractive( + io: Pick, + global: Pick, +): boolean { + return io.stdoutIsTty && !global.json; +} + export function createChatModeControl( built: Pick, store: ChatStoreController, @@ -480,11 +493,11 @@ export function createChatLineHandler( built.session.cancel(); // the session's sole terminal (session:cancelled) — persister marks it 'ended' } }; - // Whether an interactive prompt (the ink UI / Home controller) can answer an approval: the ink view is mounted - // iff stdout is a TTY and not `--json` (keep in sync with `selectChatDriver` in render/tui/chat-ink.tsx). On a - // non-interactive driver nothing answers `requestApproval`, so the mode control uses a reject-immediately - // prompt (no deadlock). Also drives `emitOutput` (a NOTICE in-view vs. a stderr diagnostic). - const interactive = deps.io.stdoutIsTty && !deps.global.json; + // Whether an interactive prompt (the ink UI / Home controller) can answer an approval — the ink view is + // mounted (the same condition `selectChatDriver` picks driveInk on). On a non-interactive driver nothing + // answers `requestApproval`, so the mode control uses a reject-immediately prompt (no deadlock). Also drives + // `emitOutput` (a NOTICE in-view vs. a stderr diagnostic). + const interactive = chatIsInteractive(deps.io, deps.global); // The reseat-less mode system (ADR-0057) — created HERE so both the `/mode` command (below) and the driver's // keys (Shift+Tab / Esc) drive the SAME control. It applies the initial `ask` mode immediately, so the diff --git a/docs/roadmap/deferred-tasks.md b/docs/roadmap/deferred-tasks.md index 9ba936fe..695482b5 100644 --- a/docs/roadmap/deferred-tasks.md +++ b/docs/roadmap/deferred-tasks.md @@ -518,6 +518,15 @@ Severity is the review's verified rating. Check an item off in the PR that resol follow; the same actor already has local RCE, and the sensitive-read floor still refuses a NAMED secret store even there. A future opt-out (`allow_aliased_reads` config, or resolving the inode's other name against a tool-known pnpm store root) would let a stricter deployment disable even this. *(low · apps/cli/src/engine/tool-host/fs.ts)* +- [ ] **Target-scoped approval cache + a per-tool preview target (ADR-0057 review elevation).** The once/always + `ApprovalCache` is keyed by tool id only, so an `[a]lways` grant blankets every allowlisted target of that tool + for the session (a `write_file` always covers every non-protected path; an `http_request` always covers every + `allowedDomains` host). It is bounded (enforcePolicy's allowlists + the fs protected-paths floor still gate each + dispatch) and is the documented accept-edits semantics — but keying by `(toolId, target)` (a path prefix for + fs, a host for egress, a server for mcp) would make `always` track what the user actually reviewed. Pairs with: + surface the MCP server/tool in `ToolActionPreview` (today `mcp_call`/`web_search` return a BLANK preview, so + F3 correctly forbids caching their `always`) — a structured `{mcpServer,mcpTool}` preview would turn the + blank-check downgrade into a real, reviewable, cacheable per-server grant. *(medium · apps/cli/src/chat/chat-mode.ts + packages/core/src/tools/{types,registry,builtins}.ts + run-event.ts)* - [ ] **fs `.relavium` sensitive-read/write segment vs. the `~/.relavium/tmp` sandboxed root (latent).** Both the read floor (`SENSITIVE_READ_DIR_SEGMENTS`) and the write floor (`PROTECTED_DIR_SEGMENTS`) match a `.relavium` segment anywhere, so they would refuse the sanctioned `tmpDir` scratch root — inert today (no call site wires diff --git a/packages/core/src/engine/agent-session.ts b/packages/core/src/engine/agent-session.ts index e7015c97..45f11359 100644 --- a/packages/core/src/engine/agent-session.ts +++ b/packages/core/src/engine/agent-session.ts @@ -80,9 +80,11 @@ export type SessionLifecycleEvent = DistributiveOmit< >; /** - * The host-emitted per-tool approval body (ADR-0057 EA5), envelope-less — a **session-carried** event the - * chat approval regime emits through the sink. It is NOT a turn-core in-node event (so not in - * `NodeStreamEvent` / the run path); the host's `ConfirmActionHook` emits it via this same sink. + * The per-tool approval body (ADR-0057 EA5), envelope-less — a **session-carried** event the chat approval + * regime emits through the sink. It is NOT a turn-core in-node event (so not in `NodeStreamEvent` / the run + * path). The ENGINE emits it: the registry's `confirmDispatch` calls the dispatch context's + * `emitApprovalRequested` port (which {@link AgentSession} wires to this sink, stamping the turn's `nodeId`) + * for every governed dispatch, just before invoking the host's `ConfirmActionHook`. */ export type SessionApprovalStreamEvent = DistributiveOmit< AgentApprovalRequestedEvent, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 4f5e678b..53f1685a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -191,7 +191,7 @@ export type { SessionEventSink, SessionStreamEvent, SessionLifecycleEvent, - // The envelope-less `agent:approval_requested` body the host's ConfirmActionHook emits through the + // The envelope-less `agent:approval_requested` body the engine's confirmDispatch emits through the // SessionEventSink (ADR-0057 EA5; the Step-4 chat-approval wiring needs to reference it by name). SessionApprovalStreamEvent, // The reseat-less mode policy (advertise-filter + confirm hook) a host pushes via setTurnPolicy (ADR-0057). From d24b612196b7c9c2908a26f5ccdbb4d542235925 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Thu, 2 Jul 2026 15:33:31 +0300 Subject: [PATCH 37/45] =?UTF-8?q?fix(cli,core,docs):=202.5.E=20F4=20?= =?UTF-8?q?=E2=80=94=20UX/tests/docs=20should-fix=20(PR=20#63=20review)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/cli/src/chat/session-host.test.ts | 30 ++++++++++++++++ apps/cli/src/commands/chat.test.ts | 12 +++++-- apps/cli/src/commands/chat.ts | 22 ++++++++++-- apps/cli/src/render/tui/chat-input.ts | 23 ++++++++----- .../src/render/tui/chat-projection.test.ts | 28 +++++++++++++++ apps/cli/src/render/tui/chat-projection.ts | 25 ++++++++++++-- apps/cli/src/render/tui/session-view-model.ts | 5 +-- docs/reference/cli/home.md | 16 +++++---- packages/core/src/tools/registry.test.ts | 34 ++++++++++++++++++- packages/core/src/tools/registry.ts | 7 +++- 10 files changed, 177 insertions(+), 25 deletions(-) diff --git a/apps/cli/src/chat/session-host.test.ts b/apps/cli/src/chat/session-host.test.ts index 0e5bc67b..d6fd6b28 100644 --- a/apps/cli/src/chat/session-host.test.ts +++ b/apps/cli/src/chat/session-host.test.ts @@ -989,6 +989,36 @@ describe('buildChatSession + 2.5.A tool-host wiring (ADR-0055)', () => { expect(existsSync(join(workspace, '.git', 'config'))).toBe(false); }); + it('auto: even an APPROVED protected-path write STILL fails — the fs floor is the true, approval-INDEPENDENT floor', async () => { + // The complement of the reject test: prove the fs-layer protected-paths refusal (not the prompt) is the real + // floor. Answer the auto fallback prompt with APPROVE and assert the write is STILL denied + never lands — + // so a future refactor that coupled the two layers (letting an approval bypass the fs floor) fails here. + const workspace = mkdtempSync(join(tmpdir(), 'relavium-ws-')); + const built = await build({ + cwd: workspace, + agentRef: writeAgent(['write_file']), + providers: scriptedResolver([ + callWithArgs('c1', 'write_file', { path: '.git/config', content: '[evil]' }), + ]), + }); + const store = createChatStore(false); + createChatModeControl(built, store).onModeChange('auto'); + built.session.start(); + const turn = built.session.sendMessage('write a protected file'); + for (let i = 0; i < 200 && store.getSnapshot().approval === undefined; i += 1) { + await new Promise((r) => setImmediate(r)); + } + store.answerApproval({ outcome: 'approve', scope: 'once' }); // APPROVE — the fs floor must refuse it anyway + await turn; + built.session.cancel(); + const events = await drainHandle(built.handle.events); + const completed = events.find((e) => e.type === 'session:turn_completed'); + expect(completed?.type === 'session:turn_completed' ? completed.error?.code : undefined).toBe( + 'tool_denied', + ); + expect(existsSync(join(workspace, '.git', 'config'))).toBe(false); // never written despite the approval + }); + it('the advertise-filter keeps http_request now that egress is wired (chat-read-write, 2.5.E)', async () => { const workspace = mkdtempSync(join(tmpdir(), 'relavium-ws-')); const { providers, requests } = capturingResolver([textTurn('hi')]); diff --git a/apps/cli/src/commands/chat.test.ts b/apps/cli/src/commands/chat.test.ts index 6ba06135..ccc384de 100644 --- a/apps/cli/src/commands/chat.test.ts +++ b/apps/cli/src/commands/chat.test.ts @@ -285,10 +285,18 @@ describe('chatCommand', () => { expect(store.loadFull(sessionId)?.messages).toHaveLength(2); }); - it('rejects an invalid /mode value at the dispatch (a positional not in the mode set)', async () => { + it('rejects an invalid /mode value at the dispatch, LISTING the valid names (a positional not in the mode set)', async () => { const { d, err } = deps(['/mode bogus', '/exit'], [textTurn('hi')]); await chatCommand({ agent: undefined }, d); - expect(err()).toContain("/mode: unknown argument 'bogus'"); // the positional-value validation rejects it + const out = err(); + expect(out).toContain("/mode: unknown argument 'bogus'"); // the positional-value validation rejects it + expect(out).toContain('Valid: ask, plan, accept-edits, auto.'); // …and teaches the four names + }); + + it('rejects `/mode plan accept-edits` — a single-value positional takes ONE value, not silently dropping extras', async () => { + const { d, err } = deps(['/mode plan accept-edits', '/exit'], [textTurn('hi')]); + await chatCommand({ agent: undefined }, d); + expect(err()).toContain('/mode: takes a single mode value (got 2).'); // arity enforced, not silently dropped }); it('/workflows reports a project-less cwd without crashing the REPL', async () => { diff --git a/apps/cli/src/commands/chat.ts b/apps/cli/src/commands/chat.ts index 007f507e..9a703221 100644 --- a/apps/cli/src/commands/chat.ts +++ b/apps/cli/src/commands/chat.ts @@ -639,17 +639,35 @@ export function createChatLineHandler( // declare a single POSITIONAL value set (e.g. `/mode plan`), whose values join the allowed set — so an // invalid mode is rejected HERE, before run. Echo a bad token SANITIZED through the notice channel — // interactive errors belong in-view (ink), not on stderr behind the live view. + const positionalValues = command.positional?.values ?? []; const allowed = new Set([ ...(command.args ?? []).map((arg) => arg.flag), - ...(command.positional?.values ?? []), + ...positionalValues, ]); + // When the command declares a positional, list its valid values in the rejection (so `/mode aggressive` + // teaches the four names, not just "unknown argument"). + const validHint = + command.positional === undefined ? '' : ` Valid: ${positionalValues.join(', ')}.`; const bad = tokens.find((token) => !allowed.has(token)); if (bad !== undefined) { emitOutput( - `/${command.name}: unknown argument '${bad.replace(/[^\x20-\x7e]/g, '?').slice(0, 32)}'.`, + `/${command.name}: unknown argument '${bad.replace(/[^\x20-\x7e]/g, '?').slice(0, 32)}'.${validHint}`, ); return; } + // Enforce the declared positional ARITY: a `{ name, values }` positional is a SINGLE value, so more than + // one positional-value token (`/mode plan accept-edits`) is rejected here rather than silently dropping + // the extras downstream. (Flag tokens are not positional values, so they don't count against the arity.) + if (command.positional !== undefined) { + const positionalSet = new Set(positionalValues); + const positionalCount = tokens.filter((token) => positionalSet.has(token)).length; + if (positionalCount > 1) { + emitOutput( + `/${command.name}: takes a single ${command.positional.name} value (got ${positionalCount}).${validHint}`, + ); + return; + } + } await command.run(replCtx, tokens); // may be async (/cost, /doctor); never fire-and-forget return; } diff --git a/apps/cli/src/render/tui/chat-input.ts b/apps/cli/src/render/tui/chat-input.ts index f9432a9d..0e243e3f 100644 --- a/apps/cli/src/render/tui/chat-input.ts +++ b/apps/cli/src/render/tui/chat-input.ts @@ -51,6 +51,20 @@ export type ChatKeyAction = * printable char (not a ctrl/meta chord) is an `append` op. The edit ops carry no buffer value — the caller * folds them functionally, preserving the accumulating semantics across a batched multi-event chunk. */ +/** + * The approval-prompt keystroke intercept (accept-edits / auto's protected-path fallback), extracted so + * {@link reduceChatKey} stays flat: `[y]`/`1` approve once, `[a]`/`2` approve always, `[n]`/`r`/`3` reject, + * `Esc` aborts the whole turn (and this pending approval); every other key is ignored. It bypasses the + * running-swallow so a pending approval can never deadlock. + */ +function reduceApprovalKey(char: string, key: ChatKey): ChatKeyAction { + if (key.escape === true) return { kind: 'abort' }; + if (char === 'y' || char === '1') return { kind: 'approve', scope: 'once' }; + if (char === 'a' || char === '2') return { kind: 'approve', scope: 'always' }; + if (char === 'n' || char === 'r' || char === '3') return { kind: 'reject' }; + return { kind: 'none' }; +} + export function reduceChatKey( char: string, key: ChatKey, @@ -58,14 +72,7 @@ export function reduceChatKey( running: boolean, approvalPending = false, ): ChatKeyAction { - if (approvalPending) { - // The approval prompt intercept — bypasses the running-swallow so the prompt can never deadlock. - if (key.escape === true) return { kind: 'abort' }; // Esc cancels the whole turn (and this pending approval) - if (char === 'y' || char === '1') return { kind: 'approve', scope: 'once' }; - if (char === 'a' || char === '2') return { kind: 'approve', scope: 'always' }; - if (char === 'n' || char === 'r' || char === '3') return { kind: 'reject' }; - return { kind: 'none' }; // ignore any other key while an approval is pending - } + if (approvalPending) return reduceApprovalKey(char, key); if (key.ctrl === true && char === 'c') return { kind: 'cancel' }; if (key.tab === true && key.shift === true) return { kind: 'cycle-mode' }; // Shift+Tab cycles the chat mode if (key.escape === true && running) return { kind: 'abort' }; // mid-turn abort, keeps the session (EA7) diff --git a/apps/cli/src/render/tui/chat-projection.test.ts b/apps/cli/src/render/tui/chat-projection.test.ts index ec4fac54..4eca5bbf 100644 --- a/apps/cli/src/render/tui/chat-projection.test.ts +++ b/apps/cli/src/render/tui/chat-projection.test.ts @@ -37,6 +37,34 @@ describe('chat-projection', () => { expect(line).toContain('error: turn_limit'); }); + it('surfaces the secret-free REASON for a tool_denied / tool_unavailable turn (the actionable ADR-0057 codes)', () => { + const denied = formatTurnSummary({ + stopReason: 'error', + tokensUsed: { input: 0, output: 0 }, + errorCode: 'tool_denied', + errorMessage: 'not allowed in ask mode (read-only)', + }); + expect(denied).toContain('error: tool_denied — not allowed in ask mode (read-only)'); + const unavailable = formatTurnSummary({ + stopReason: 'error', + tokensUsed: { input: 0, output: 0 }, + errorCode: 'tool_unavailable', + errorMessage: 'fs (read-only in this session)', + }); + expect(unavailable).toContain('error: tool_unavailable — fs (read-only in this session)'); + }); + + it('does NOT render the message for a non-whitelisted code (it may carry prompt context)', () => { + const line = formatTurnSummary({ + stopReason: 'error', + tokensUsed: { input: 0, output: 0 }, + errorCode: 'execution_failed', + errorMessage: 'some model-derived context that must not be shown', + }); + expect(line).toContain('error: execution_failed'); + expect(line).not.toContain('model-derived context'); + }); + it('renders the EA7 "aborted" stop reason as a plain label (no error segment)', () => { const line = formatTurnSummary({ stopReason: 'aborted', diff --git a/apps/cli/src/render/tui/chat-projection.ts b/apps/cli/src/render/tui/chat-projection.ts index a4525994..2b558e23 100644 --- a/apps/cli/src/render/tui/chat-projection.ts +++ b/apps/cli/src/render/tui/chat-projection.ts @@ -51,11 +51,30 @@ export function sanitizeInline(text: string): string { } /** - * A one-line per-turn summary shown after a completed assistant turn: the stop reason (or the error code), - * the turn's token usage, and its duration. Secret-free — it carries only counts/codes, never argument text. + * Error codes whose `errorMessage` is a vetted, secret-free, host-supplied LABEL safe to render in-chat — the + * actionable approval-floor denials (ADR-0057): `tool_denied` ("not allowed in ask mode (read-only)", "refusing + * to write inside a protected directory") and `tool_unavailable` ("fs (read-only in this session)"). Other + * codes' messages may carry prompt/model context, so only the code is shown for them. + */ +const SAFE_MESSAGE_CODES: ReadonlySet = new Set(['tool_denied', 'tool_unavailable']); + +/** + * A one-line per-turn summary shown after a completed assistant turn: the stop reason (or the error code +, + * for the vetted approval-floor codes, its secret-free reason), the turn's token usage, and its duration. + * Secret-free — it carries only counts/codes + a whitelisted reason label, never argument text. */ export function formatTurnSummary(summary: TurnSummary): string { - const head = summary.errorCode === undefined ? summary.stopReason : `error: ${summary.errorCode}`; + let head: string; + if (summary.errorCode === undefined) { + head = summary.stopReason; + } else if (summary.errorMessage !== undefined && SAFE_MESSAGE_CODES.has(summary.errorCode)) { + // Surface WHY a governed action was denied — the reason is the only place it reaches the user, and the turn + // died on it (e.g. `error: tool_denied — not allowed in ask mode (read-only)`). Parity with the run path's + // final-summary.ts, which already renders errorMessage for the same error taxonomy. + head = `error: ${summary.errorCode} — ${summary.errorMessage.replace(/\s+/gu, ' ').trim()}`; + } else { + head = `error: ${summary.errorCode}`; + } const parts = [ head, formatTokens(summary.tokensUsed), diff --git a/apps/cli/src/render/tui/session-view-model.ts b/apps/cli/src/render/tui/session-view-model.ts index aebb2c36..e352910e 100644 --- a/apps/cli/src/render/tui/session-view-model.ts +++ b/apps/cli/src/render/tui/session-view-model.ts @@ -37,8 +37,9 @@ export interface TurnSummary { readonly durationMs?: number; /** The closed error-taxonomy code (safe to display) — the projection renders this, not the message. */ readonly errorCode?: string; - /** The classified error message — kept for diagnostics, but NOT rendered (it may carry prompt context); - * `formatTurnSummary` surfaces only `errorCode`. */ + /** The classified error message. `formatTurnSummary` renders it ONLY for the vetted secret-free approval-floor + * codes (`tool_denied` / `tool_unavailable`, whose message is a host-supplied label) — other codes' messages + * may carry prompt context, so only `errorCode` is shown for them. */ readonly errorMessage?: string; } diff --git a/docs/reference/cli/home.md b/docs/reference/cli/home.md index 399c2268..c3db5a2b 100644 --- a/docs/reference/cli/home.md +++ b/docs/reference/cli/home.md @@ -1,8 +1,8 @@ # `relavium` — Bare-invocation interactive Home -> Last updated: 2026-06-29 +> Last updated: 2026-07-02 -- **Status**: Reference — the Home surface is delivered by **2.5.B** (the bare-invocation Home, the read-only management strip over `history.db`, the single-ink-tree mode machine, one SIGINT/SIGTERM lifecycle, and bracketed paste). The in-app slash palette / `/help` / `/doctor` (2.5.C), the reseat-less mode keymap + per-tool approval (2.5.E), and the Home-side `/models` picker land in later 2.5 workstreams and **extend** this same canonical home. +- **Status**: Reference — the Home surface is delivered by **2.5.B** (the bare-invocation Home, the read-only management strip over `history.db`, the single-ink-tree mode machine, one SIGINT/SIGTERM lifecycle, and bracketed paste); the in-app slash palette / `/help` / `/doctor` + the context-aware footer landed in **2.5.C**; and the reseat-less mode keymap (`Shift+Tab` / `/mode`) + the fail-closed per-tool approval + the `Esc` mid-turn abort landed in **2.5.E** ([ADR-0057](../../decisions/0057-cli-chat-modes-and-per-tool-approval.md)) — all **extend** this same canonical home. The Home-side `/models` picker is a later 2.5 workstream. - **Surface**: CLI (a bare `relavium` with no subcommand, on a TTY) - **Scope**: Phase 2.5, local-first. The Home is a thin surface over the **same** `@relavium/core` engine + `AgentSession` as `relavium chat` — it is a management + chat shell, never an IDE. - **Related**: [commands.md](commands.md), [chat-session.md](chat-session.md), [../contracts/config-spec.md](../contracts/config-spec.md), [../contracts/agent-session-spec.md](../contracts/agent-session-spec.md), [../../decisions/0054-cli-bare-invocation-interactive-home.md](../../decisions/0054-cli-bare-invocation-interactive-home.md), [../../decisions/0049-cli-machine-output-contract.md](../../decisions/0049-cli-machine-output-contract.md), [../../decisions/0047-cli-framework-commander-ink-clack.md](../../decisions/0047-cli-framework-commander-ink-clack.md), [../../decisions/0024-agent-first-entry-point-agentsession.md](../../decisions/0024-agent-first-entry-point-agentsession.md), [../../decisions/0007-desktop-is-not-an-ide.md](../../decisions/0007-desktop-is-not-an-ide.md) @@ -75,12 +75,15 @@ The chat session is built **after** the ink mount (an explicit loading state), s | printable | append to the prompt buffer | append (idle) / ignored mid-turn | ignored | | Return | submit → start a chat | submit the turn (idle) / ignored mid-turn | ignored | | Backspace / Delete | erase one char | erase one char (idle) / ignored mid-turn | ignored | +| **Shift+Tab** (2.5.E) | — | **cycle the chat mode** (ask → plan → accept-edits → auto), ADR-0057 | — | +| **Esc** (2.5.E) | — | **abort the in-flight turn** (mid-turn, EA7 — keeps the session; distinct from `/cancel`) | — | +| **`[y]`/`[a]`/`[n]`/`[esc]`** (2.5.E) | — | **answer a pending per-tool approval** — `[y]` once · `[a]` always · `[n]` no · `[esc]` abort (the prompt owns the keyboard) | — | | **Ctrl-C** | **clean exit (`0`)** | **`/cancel`** → end the chat, return to Home | **bail out** (exit) | | **Ctrl-D** (EOF) | **clean exit (`0`)** on an **empty** prompt (a non-empty buffer keeps it — no data loss) | — | **clean exit (`0`)** (the prompt is empty while building, so EOF bails the build like Ctrl-C) | -Ctrl-C is **always** an escape — it is honored even in the `loading` state (so a hung build is never an unkillable wedge) and even mid-bracketed-paste (so a dropped paste-end marker can never trap the user). The richer slash palette, `@`-mention, `!`-shell, `Ctrl+J` multiline, history recall, and the ask/plan/accept-edits/auto **mode keymap** with per-tool approval are forthcoming (2.5.C / 2.5.E) and extend this table. +Ctrl-C is **always** an escape — it is honored even in the `loading` state (so a hung build is never an unkillable wedge) and even mid-bracketed-paste (so a dropped paste-end marker can never trap the user). The reseat-less **mode keymap** (`Shift+Tab` + `/mode`) and the fail-closed **per-tool approval** landed in **2.5.E** ([ADR-0057](../../decisions/0057-cli-chat-modes-and-per-tool-approval.md); wired into the Home via the same `home-controller.ts` key routing — see [chat-session.md](chat-session.md)). The richer slash palette, `@`-mention, `!`-shell, `Ctrl+J` multiline, and history recall are forthcoming and extend this table. -The **footer hint-bar** in 2.5.B is the single fixed line under the prompt — `type a message to start a new chat · Ctrl-C to exit` — plus the `Ctrl-C to exit` line on the degrade frame. The context-aware hint-bar (the two or three most-relevant keys per context/mode) lands in 2.5.C and extends this fixed footer. +The **footer hint-bar** in 2.5.B was the single fixed line; the context-aware hint-bar (the two or three most-relevant keys per context/mode, including the active mode) landed in **2.5.C** and extends it. ## Bracketed paste (DECSET 2004) @@ -110,7 +113,8 @@ A chat launched from the Home has its **own** exit code `4` ([chat-session.md](c Settled in 2.5.B, recorded here as the canonical home (not re-litigated elsewhere): -- **In-flight build surface** — a slow `buildChatSession` shows a static `Starting chat…` loading state that echoes the pending message (the typed text never visually vanishes). A richer spinner + an in-build abort ride the 2.5.E mid-turn-abort work. +- **In-flight build surface** — a slow `buildChatSession` shows a static `Starting chat…` loading state that echoes the pending message (the typed text never visually vanishes). A richer spinner is deferred; the `Esc` mid-turn abort (once a chat is built) landed in 2.5.E. - **Attention ordering is a recency proxy, not a deadline sort** — gates are ordered by the paused run's `created_at DESC` (a glanceable proxy); true gate-recency / soonest-expiry-first would need the pending-gate read to carry the raise time. The renderer escalates an *expired* gate to red regardless of position. - **Continue excludes lifted runs by status** — a failed or human-gated run lives only in *Attention*, never duplicated in *Continue*; the strip over-fetches to backfill *Continue* to its limit. -- **Forthcoming, extending this doc** — the interactive `/` palette UI (filterable, keyboard-navigable — 2.5.C S3b; the curated slash command set + the command-manifest shape are already homed in [commands.md](commands.md)), `@`-mention semantics, and the ask/plan/accept-edits/auto mode keymap (2.5.C / 2.5.E); the Home-side `/models` picker over a connected-provider catalog; and an in-app message-queue/type-ahead while a turn runs (deferred, see [../../roadmap/deferred-tasks.md](../../roadmap/deferred-tasks.md)). +- **Landed since 2.5.B** — the interactive `/` palette UI (filterable, keyboard-navigable — 2.5.C S3b; the curated slash command set + the command-manifest shape are homed in [commands.md](commands.md)) and the ask/plan/accept-edits/auto mode keymap + per-tool approval + `Esc` abort (2.5.E, [ADR-0057](../../decisions/0057-cli-chat-modes-and-per-tool-approval.md)). +- **Forthcoming, extending this doc** — `@`-mention semantics; the Home-side `/models` picker over a connected-provider catalog; and an in-app message-queue/type-ahead while a turn runs (deferred, see [../../roadmap/deferred-tasks.md](../../roadmap/deferred-tasks.md)). diff --git a/packages/core/src/tools/registry.test.ts b/packages/core/src/tools/registry.test.ts index 748a6b16..978750aa 100644 --- a/packages/core/src/tools/registry.test.ts +++ b/packages/core/src/tools/registry.test.ts @@ -11,7 +11,7 @@ import { ToolUnavailableError, UnknownToolError, } from './errors.js'; -import { createToolRegistry } from './registry.js'; +import { createToolRegistry, governedAction } from './registry.js'; import { isUntrusted, unwrapUntrusted } from './untrusted.js'; import type { EgressRequest, @@ -344,6 +344,38 @@ describe('ToolRegistry — git_commit human-gate', () => { /* --- per-tool approval (ADR-0057 EA3) --- */ +describe('governedAction — the authoritative engine confirm-floor classifier (drift-lock)', () => { + it('pins the EXACT engine-governed built-in set + its action class (distinct from the CLI advertise superset)', () => { + // Compute each tool's target the SAME way confirmDispatch does — `def.policyTarget?.(args) ?? {}` — so only a + // MODEL-command process tool (run_command's policyTarget yields a `command`) trips the process case; a + // pre-approved process tool (git_status / git_commit — no policyTarget) does not. Each policyTarget reads + // only its own field, so one permissive sample serves all. + const sampleArgs = { command: 'ls', args: [] as string[], path: 'p.txt', url: 'https://example.com' }; + const governed: Record = {}; + for (const def of BUILTIN_TOOLS) { + const target = def.policyTarget?.(sampleArgs) ?? {}; + const action = governedAction(def, target); + if (action !== undefined) governed[def.id] = action; + } + // A new governed built-in (or a policy-flag change) MUST update this map — the fail-closed floor can't drift. + expect(governed).toEqual({ + write_file: 'fs_write', + run_command: 'process', + http_request: 'egress', + web_search: 'egress', + mcp_call: 'egress', + read_clipboard: 'os', + notify: 'os', + }); + // git_commit is NOT engine-governed here: its requiresGateApproval is enforced by the human-gate floor + // (enforcePolicy), NOT confirmAction — the exact distinction from the CLI advertise-filter's governedToolIds + // superset (chat-mode.test.ts), which DOES hide git_commit. Pin both halves of that asymmetry. + expect(governed).not.toHaveProperty('git_commit'); + expect(governed).not.toHaveProperty('read_file'); + expect(governed).not.toHaveProperty('git_status'); + }); +}); + /** * A MUTABLE {@link AbortSignalLike} test double: `aborted` can be flipped mid-prompt (a cancel-during-confirm * test), and the listeners are no-ops (the registry's `isAbort` reads `.aborted` / `cause.name`, never the diff --git a/packages/core/src/tools/registry.ts b/packages/core/src/tools/registry.ts index 723be4d0..0b8c6e8b 100644 --- a/packages/core/src/tools/registry.ts +++ b/packages/core/src/tools/registry.ts @@ -404,7 +404,12 @@ async function confirmDispatch( * IS, even a read-only `web_search` (an exfiltration sink); and an `os` action (`read_clipboard` / `notify`) * IS — the clipboard is ambient, un-jailed OS state that routinely holds a freshly-copied secret (ADR-0057). */ -function governedAction(def: ToolDef, target: PolicyTarget): ToolActionClass | undefined { +/** + * Classify a dispatch's governed ACTION class (the authoritative confirmAction floor) — or `undefined` for a + * read-only / pre-approved tool that is never gated. Exported (from this module, NOT the package index) so a + * drift-lock test can pin the exact engine-governed set, distinct from the CLI advertise-filter's superset. + */ +export function governedAction(def: ToolDef, target: PolicyTarget): ToolActionClass | undefined { if (def.policy.fsWrite === true) { return 'fs_write'; } From 52b40e6602a9c7fc7519239085ee2a0acaebddcc Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Thu, 2 Jul 2026 15:55:04 +0300 Subject: [PATCH 38/45] =?UTF-8?q?fix(cli,docs):=202.5.E=20F4=20=E2=80=94?= =?UTF-8?q?=20address=20opus=20review=20(terminal-sanitize=20+=20doc=20dri?= =?UTF-8?q?ft)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/cli/src/chat/session-host.test.ts | 4 ++++ .../src/render/tui/chat-projection.test.ts | 22 +++++++++++++++++++ apps/cli/src/render/tui/chat-projection.ts | 14 ++++++++---- docs/reference/cli/home.md | 4 ++-- 4 files changed, 38 insertions(+), 6 deletions(-) diff --git a/apps/cli/src/chat/session-host.test.ts b/apps/cli/src/chat/session-host.test.ts index d6fd6b28..ee5c3777 100644 --- a/apps/cli/src/chat/session-host.test.ts +++ b/apps/cli/src/chat/session-host.test.ts @@ -1008,6 +1008,10 @@ describe('buildChatSession + 2.5.A tool-host wiring (ADR-0055)', () => { for (let i = 0; i < 200 && store.getSnapshot().approval === undefined; i += 1) { await new Promise((r) => setImmediate(r)); } + // The fallback prompt WAS published (non-cacheable) — so the approve below is genuinely consumed, not a + // no-op that would let the fs floor pass the test even if auto stopped prompting. + expect(store.getSnapshot().approval?.request.toolId).toBe('write_file'); + expect(store.getSnapshot().approval?.cacheable).toBe(false); store.answerApproval({ outcome: 'approve', scope: 'once' }); // APPROVE — the fs floor must refuse it anyway await turn; built.session.cancel(); diff --git a/apps/cli/src/render/tui/chat-projection.test.ts b/apps/cli/src/render/tui/chat-projection.test.ts index 4eca5bbf..81481b9b 100644 --- a/apps/cli/src/render/tui/chat-projection.test.ts +++ b/apps/cli/src/render/tui/chat-projection.test.ts @@ -65,6 +65,28 @@ describe('chat-projection', () => { expect(line).not.toContain('model-derived context'); }); + it('terminal-sanitizes the rendered reason (strips ANSI/OSC/control bytes) and omits an empty reason', () => { + const ESC = String.fromCharCode(0x1b); + const BEL = String.fromCharCode(0x07); + const line = formatTurnSummary({ + stopReason: 'error', + tokensUsed: { input: 0, output: 0 }, + errorCode: 'tool_denied', + errorMessage: `${ESC}]0;pwn${BEL}denied\r\n here`, // an OSC title-set + BEL + CRLF + }); + // eslint-disable-next-line no-control-regex -- asserting NO control byte survives the sanitizer + expect(/[\u0000-\u001f\u007f]/.test(line)).toBe(false); // no raw control byte reaches the terminal + expect(line).toContain('error: tool_denied'); + // A whitespace-only reason renders the bare code (no dangling em-dash). + const empty = formatTurnSummary({ + stopReason: 'error', + tokensUsed: { input: 0, output: 0 }, + errorCode: 'tool_denied', + errorMessage: ' ', + }); + expect(empty.split(' \u00b7 ')[0]).toBe('error: tool_denied'); + }); + it('renders the EA7 "aborted" stop reason as a plain label (no error segment)', () => { const line = formatTurnSummary({ stopReason: 'aborted', diff --git a/apps/cli/src/render/tui/chat-projection.ts b/apps/cli/src/render/tui/chat-projection.ts index 2b558e23..db5e7da1 100644 --- a/apps/cli/src/render/tui/chat-projection.ts +++ b/apps/cli/src/render/tui/chat-projection.ts @@ -64,14 +64,20 @@ const SAFE_MESSAGE_CODES: ReadonlySet = new Set(['tool_denied', 'tool_un * Secret-free — it carries only counts/codes + a whitelisted reason label, never argument text. */ export function formatTurnSummary(summary: TurnSummary): string { + // Terminal-sanitize the whitelisted reason (like every other display string here) BEFORE the whitespace + // collapse — the whitelisted messages are host-authored ASCII today, but the render boundary must strip any + // ANSI/OSC/control byte regardless so the whitelist stays robust to a future producer. + const reason = + summary.errorMessage === undefined ? '' : sanitizeInline(summary.errorMessage).replace(/\s+/gu, ' ').trim(); let head: string; if (summary.errorCode === undefined) { head = summary.stopReason; - } else if (summary.errorMessage !== undefined && SAFE_MESSAGE_CODES.has(summary.errorCode)) { + } else if (reason.length > 0 && SAFE_MESSAGE_CODES.has(summary.errorCode)) { // Surface WHY a governed action was denied — the reason is the only place it reaches the user, and the turn - // died on it (e.g. `error: tool_denied — not allowed in ask mode (read-only)`). Parity with the run path's - // final-summary.ts, which already renders errorMessage for the same error taxonomy. - head = `error: ${summary.errorCode} — ${summary.errorMessage.replace(/\s+/gu, ' ').trim()}`; + // died on it (e.g. `error: tool_denied — not allowed in ask mode (read-only)`). Unlike the run path's + // final-summary.ts (which renders errorMessage for every code), the chat path restricts it to the vetted + // approval-floor codes, since a chat turn is interactive/lower-trust. + head = `error: ${summary.errorCode} — ${reason}`; } else { head = `error: ${summary.errorCode}`; } diff --git a/docs/reference/cli/home.md b/docs/reference/cli/home.md index c3db5a2b..684efe04 100644 --- a/docs/reference/cli/home.md +++ b/docs/reference/cli/home.md @@ -81,9 +81,9 @@ The chat session is built **after** the ink mount (an explicit loading state), s | **Ctrl-C** | **clean exit (`0`)** | **`/cancel`** → end the chat, return to Home | **bail out** (exit) | | **Ctrl-D** (EOF) | **clean exit (`0`)** on an **empty** prompt (a non-empty buffer keeps it — no data loss) | — | **clean exit (`0`)** (the prompt is empty while building, so EOF bails the build like Ctrl-C) | -Ctrl-C is **always** an escape — it is honored even in the `loading` state (so a hung build is never an unkillable wedge) and even mid-bracketed-paste (so a dropped paste-end marker can never trap the user). The reseat-less **mode keymap** (`Shift+Tab` + `/mode`) and the fail-closed **per-tool approval** landed in **2.5.E** ([ADR-0057](../../decisions/0057-cli-chat-modes-and-per-tool-approval.md); wired into the Home via the same `home-controller.ts` key routing — see [chat-session.md](chat-session.md)). The richer slash palette, `@`-mention, `!`-shell, `Ctrl+J` multiline, and history recall are forthcoming and extend this table. +Ctrl-C is **always** an escape — it is honored even in the `loading` state (so a hung build is never an unkillable wedge) and even mid-bracketed-paste (so a dropped paste-end marker can never trap the user). The reseat-less **mode keymap** (`Shift+Tab` + `/mode`) and the fail-closed **per-tool approval** landed in **2.5.E** ([ADR-0057](../../decisions/0057-cli-chat-modes-and-per-tool-approval.md); wired into the Home via the same `home-controller.ts` key routing — see [chat-session.md](chat-session.md)); the interactive `/` command palette landed in **2.5.C**. `@`-mention, `!`-shell, `Ctrl+J` multiline, and history recall are forthcoming and extend this table. -The **footer hint-bar** in 2.5.B was the single fixed line; the context-aware hint-bar (the two or three most-relevant keys per context/mode, including the active mode) landed in **2.5.C** and extends it. +The **footer hint-bar** in 2.5.B was the single fixed line; the context-aware hint-bar (the two or three most-relevant keys per context) landed in **2.5.C**, and the always-visible **active-mode footer indicator** (`formatSessionFooterWithMode`) arrived with **2.5.E** (ADR-0057) — together they extend that fixed footer. ## Bracketed paste (DECSET 2004) From 7bc659b7966bc6a6fd3cac464c869053ca33a515 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Thu, 2 Jul 2026 16:33:33 +0300 Subject: [PATCH 39/45] =?UTF-8?q?fix(cli,core,docs):=202.5.E=20F4=20?= =?UTF-8?q?=E2=80=94=20address=20Sonnet=205=20review=20(doc-emit=20drift,?= =?UTF-8?q?=20prettier)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/cli/src/chat/chat-mode.test.ts | 20 +- apps/cli/src/engine/tool-host/egress.test.ts | 5 +- apps/cli/src/engine/tool-host/fs.test.ts | 266 +++++++++++-------- apps/cli/src/engine/tool-host/fs.ts | 6 +- apps/cli/src/render/tui/chat-input.ts | 23 +- apps/cli/src/render/tui/chat-projection.ts | 17 +- docs/reference/contracts/sse-event-schema.md | 2 +- docs/reference/shared-core/tool-registry.md | 2 +- packages/core/src/engine/agent-session.ts | 5 +- packages/core/src/tools/bounding.test.ts | 3 +- packages/core/src/tools/registry.test.ts | 22 +- packages/core/src/tools/registry.ts | 21 +- 12 files changed, 238 insertions(+), 154 deletions(-) diff --git a/apps/cli/src/chat/chat-mode.test.ts b/apps/cli/src/chat/chat-mode.test.ts index d7bda3aa..f11be212 100644 --- a/apps/cli/src/chat/chat-mode.test.ts +++ b/apps/cli/src/chat/chat-mode.test.ts @@ -172,12 +172,18 @@ describe('buildTurnPolicy — the mode → { advertise, confirm } mapping', () = Promise.resolve({ outcome: 'approve', scope: 'always' }), ); const policy = buildTurnPolicy('accept-edits', deps({ prompt })); - const egressReq = req({ toolId: 'http_request', action: 'egress', preview: { host: 'api.example.com' } }); + const egressReq = req({ + toolId: 'http_request', + action: 'egress', + preview: { host: 'api.example.com' }, + }); expect(await policy.confirm!(egressReq)).toEqual({ outcome: 'approve' }); expect(prompt).toHaveBeenNthCalledWith(1, egressReq, true, undefined); // cacheable=true (a concrete host) // A concrete-preview grant IS cached — the second call short-circuits (proves isBlankPreview's `host` check). expect( - await policy.confirm!(req({ toolId: 'http_request', action: 'egress', preview: { host: 'api.example.com' } })), + await policy.confirm!( + req({ toolId: 'http_request', action: 'egress', preview: { host: 'api.example.com' } }), + ), ).toEqual({ outcome: 'approve' }); expect(prompt).toHaveBeenCalledTimes(1); }); @@ -187,11 +193,17 @@ describe('buildTurnPolicy — the mode → { advertise, confirm } mapping', () = Promise.resolve({ outcome: 'approve', scope: 'always' }), ); const policy = buildTurnPolicy('accept-edits', deps({ prompt })); - const procReq = req({ toolId: 'run_command', action: 'process', preview: { command: 'ls -la' } }); + const procReq = req({ + toolId: 'run_command', + action: 'process', + preview: { command: 'ls -la' }, + }); expect(await policy.confirm!(procReq)).toEqual({ outcome: 'approve' }); expect(prompt).toHaveBeenNthCalledWith(1, procReq, true, undefined); // cacheable=true (a concrete command) expect( - await policy.confirm!(req({ toolId: 'run_command', action: 'process', preview: { command: 'ls -la' } })), + await policy.confirm!( + req({ toolId: 'run_command', action: 'process', preview: { command: 'ls -la' } }), + ), ).toEqual({ outcome: 'approve' }); expect(prompt).toHaveBeenCalledTimes(1); // cached — a dropped `command` check would force a re-prompt here }); diff --git a/apps/cli/src/engine/tool-host/egress.test.ts b/apps/cli/src/engine/tool-host/egress.test.ts index db51d9db..19205624 100644 --- a/apps/cli/src/engine/tool-host/egress.test.ts +++ b/apps/cli/src/engine/tool-host/egress.test.ts @@ -150,7 +150,10 @@ describe('createNodeEgressCapability (2.5.E Step 3) — text egress over the sha it('drops BOTH case variants when the model sets Authorization AND authorization simultaneously', async () => { const { deps, calls } = fakeDeps({ resolve: PUBLIC, response: { status: 200 } }); - const egress = createNodeEgressCapability({ deps, resolveCredential: () => Promise.resolve('WON') }); + const egress = createNodeEgressCapability({ + deps, + resolveCredential: () => Promise.resolve('WON'), + }); await egress.fetch({ method: 'GET', url: 'https://api.example.com/x', diff --git a/apps/cli/src/engine/tool-host/fs.test.ts b/apps/cli/src/engine/tool-host/fs.test.ts index 6b54bf46..00d10c43 100644 --- a/apps/cli/src/engine/tool-host/fs.test.ts +++ b/apps/cli/src/engine/tool-host/fs.test.ts @@ -160,60 +160,76 @@ describe('createNodeFsCapability — read (jailed)', () => { 10_000, ); - itPosix('refuses to read a hard-linked file that aliases an out-of-jail inode — FATAL tool_denied, no leak', async () => { - // The single POSIX aliasing vector the symlink jail misses: a hard link inside the workspace to an outside - // secret. realpath keeps it in-scope, but the fd's nlink > 1 refuses it before any byte is read. - await writeFile(join(outside, 'secret.txt'), 'TOP SECRET'); - await link(join(outside, 'secret.txt'), join(workspace, 'notes.txt')); - const err: unknown = await sandboxed() - .readFile('notes.txt', {}) - .catch((e: unknown) => e); - expect(err).toBeInstanceOf(FsScopeDeniedError); - if (err instanceof FsScopeDeniedError) { - expect(err.retryable).toBe(false); - expect(String(err.message)).not.toContain('TOP SECRET'); // the denial never echoes the aliased content - } - }); + itPosix( + 'refuses to read a hard-linked file that aliases an out-of-jail inode — FATAL tool_denied, no leak', + async () => { + // The single POSIX aliasing vector the symlink jail misses: a hard link inside the workspace to an outside + // secret. realpath keeps it in-scope, but the fd's nlink > 1 refuses it before any byte is read. + await writeFile(join(outside, 'secret.txt'), 'TOP SECRET'); + await link(join(outside, 'secret.txt'), join(workspace, 'notes.txt')); + const err: unknown = await sandboxed() + .readFile('notes.txt', {}) + .catch((e: unknown) => e); + expect(err).toBeInstanceOf(FsScopeDeniedError); + if (err instanceof FsScopeDeniedError) { + expect(err.retryable).toBe(false); + expect(String(err.message)).not.toContain('TOP SECRET'); // the denial never echoes the aliased content + } + }, + ); - itPosix('the full (unjailed) tier reads a hard-linked file — benign in-scope store links stay readable', async () => { - await writeFile(join(outside, 'lib.txt'), 'LIB'); - await link(join(outside, 'lib.txt'), join(workspace, 'lib.txt')); - const fs = createNodeFsCapability({ tier: 'full', workspaceDir: workspace, readOnly: false }); - expect((await fs.readFile('lib.txt', {})).content).toBe('LIB'); - }); + itPosix( + 'the full (unjailed) tier reads a hard-linked file — benign in-scope store links stay readable', + async () => { + await writeFile(join(outside, 'lib.txt'), 'LIB'); + await link(join(outside, 'lib.txt'), join(workspace, 'lib.txt')); + const fs = createNodeFsCapability({ tier: 'full', workspaceDir: workspace, readOnly: false }); + expect((await fs.readFile('lib.txt', {})).content).toBe('LIB'); + }, + ); - itPosix('EXEMPTS a pnpm virtual-store hard link (node_modules/.pnpm/…) from the aliasing guard — pnpm reads work', async () => { - // pnpm hard-links package files into node_modules/.pnpm/@/node_modules//… (nlink>1 on Linux). - // The aliasing READ guard is disabled ONLY for that virtual-store layout, so a dependency-source read there - // succeeds. The out-of-workspace target stands in for pnpm's shared content store. - await writeFile(join(outside, 'store-index.js'), 'module.exports = 1'); - const pkgDir = join(workspace, 'node_modules', '.pnpm', 'lib@1.0.0', 'node_modules', 'lib'); - await mkdir(pkgDir, { recursive: true }); - await link(join(outside, 'store-index.js'), join(pkgDir, 'index.js')); - expect( - (await sandboxed().readFile('node_modules/.pnpm/lib@1.0.0/node_modules/lib/index.js', {})).content, - ).toBe('module.exports = 1'); - }); + itPosix( + 'EXEMPTS a pnpm virtual-store hard link (node_modules/.pnpm/…) from the aliasing guard — pnpm reads work', + async () => { + // pnpm hard-links package files into node_modules/.pnpm/@/node_modules//… (nlink>1 on Linux). + // The aliasing READ guard is disabled ONLY for that virtual-store layout, so a dependency-source read there + // succeeds. The out-of-workspace target stands in for pnpm's shared content store. + await writeFile(join(outside, 'store-index.js'), 'module.exports = 1'); + const pkgDir = join(workspace, 'node_modules', '.pnpm', 'lib@1.0.0', 'node_modules', 'lib'); + await mkdir(pkgDir, { recursive: true }); + await link(join(outside, 'store-index.js'), join(pkgDir, 'index.js')); + expect( + (await sandboxed().readFile('node_modules/.pnpm/lib@1.0.0/node_modules/lib/index.js', {})) + .content, + ).toBe('module.exports = 1'); + }, + ); - itPosix('STILL refuses a hard link under a plain node_modules dir (NO .pnpm) — the exemption is store-shape-scoped', async () => { - // A hard link an attacker names `node_modules/` (not the pnpm virtual store) is NOT exempt — the - // narrowed guard only trusts the node_modules/.pnpm adjacency, closing the attacker-named-dir bypass. - await writeFile(join(outside, 'secret.txt'), 'TOP SECRET'); - await mkdir(join(workspace, 'node_modules', 'evil'), { recursive: true }); - await link(join(outside, 'secret.txt'), join(workspace, 'node_modules', 'evil', 'data.bin')); - await expect(sandboxed().readFile('node_modules/evil/data.bin', {})).rejects.toBeInstanceOf( - FsScopeDeniedError, - ); - }); + itPosix( + 'STILL refuses a hard link under a plain node_modules dir (NO .pnpm) — the exemption is store-shape-scoped', + async () => { + // A hard link an attacker names `node_modules/` (not the pnpm virtual store) is NOT exempt — the + // narrowed guard only trusts the node_modules/.pnpm adjacency, closing the attacker-named-dir bypass. + await writeFile(join(outside, 'secret.txt'), 'TOP SECRET'); + await mkdir(join(workspace, 'node_modules', 'evil'), { recursive: true }); + await link(join(outside, 'secret.txt'), join(workspace, 'node_modules', 'evil', 'data.bin')); + await expect(sandboxed().readFile('node_modules/evil/data.bin', {})).rejects.toBeInstanceOf( + FsScopeDeniedError, + ); + }, + ); - itPosix('EXEMPTS a pnpm-store hard link through the GLOB read path too (readGlob recomputes the exemption)', async () => { - await writeFile(join(outside, 'dep.ts'), 'export const x = 1'); - const pkgDir = join(workspace, 'node_modules', '.pnpm', 'dep@1', 'node_modules', 'dep'); - await mkdir(pkgDir, { recursive: true }); - await link(join(outside, 'dep.ts'), join(pkgDir, 'index.ts')); - const result = await sandboxed().readFile('node_modules/.pnpm/**/*.ts', { glob: true }); - expect(result.content).toContain('export const x = 1'); - }); + itPosix( + 'EXEMPTS a pnpm-store hard link through the GLOB read path too (readGlob recomputes the exemption)', + async () => { + await writeFile(join(outside, 'dep.ts'), 'export const x = 1'); + const pkgDir = join(workspace, 'node_modules', '.pnpm', 'dep@1', 'node_modules', 'dep'); + await mkdir(pkgDir, { recursive: true }); + await link(join(outside, 'dep.ts'), join(pkgDir, 'index.ts')); + const result = await sandboxed().readFile('node_modules/.pnpm/**/*.ts', { glob: true }); + expect(result.content).toContain('export const x = 1'); + }, + ); it('STILL refuses a credential dotfile under node_modules — the exemption is aliasing-only, not the name floor', async () => { await mkdir(join(workspace, 'node_modules', 'evil'), { recursive: true }); @@ -277,24 +293,30 @@ describe('readJailedFile — the post-jail fd read guard', () => { 10_000, ); - itPosix('flags a HARD-LINKED regular file as `aliased` (st.nlink > 1) — the exfiltration-alias guard', async () => { - // realpath resolves symlinks but NOT hard links; a hard link INSIDE the jail can share its inode with a name - // OUTSIDE it, so "realpath ∈ scope" no longer implies "content ∈ scope". The fd's st.nlink > 1 is the - // race-free signal — refused like a symlink. (link() needs the same filesystem; workspace+outside share one.) - await writeFile(join(outside, 'secret.txt'), 'TOP SECRET'); - const alias = join(workspace, 'innocent.txt'); - await link(join(outside, 'secret.txt'), alias); // hard link — realpath(alias) === alias, but nlink === 2 - expect((await readJailedFile(alias, 1 << 20)).kind).toBe('aliased'); - }); + itPosix( + 'flags a HARD-LINKED regular file as `aliased` (st.nlink > 1) — the exfiltration-alias guard', + async () => { + // realpath resolves symlinks but NOT hard links; a hard link INSIDE the jail can share its inode with a name + // OUTSIDE it, so "realpath ∈ scope" no longer implies "content ∈ scope". The fd's st.nlink > 1 is the + // race-free signal — refused like a symlink. (link() needs the same filesystem; workspace+outside share one.) + await writeFile(join(outside, 'secret.txt'), 'TOP SECRET'); + const alias = join(workspace, 'innocent.txt'); + await link(join(outside, 'secret.txt'), alias); // hard link — realpath(alias) === alias, but nlink === 2 + expect((await readJailedFile(alias, 1 << 20)).kind).toBe('aliased'); + }, + ); - itPosix('reads a hard-linked file when rejectAliased is false (the full/unjailed tier — pnpm store links)', async () => { - await writeFile(join(outside, 'store.txt'), 'STORE'); - const alias = join(workspace, 'dep.txt'); - await link(join(outside, 'store.txt'), alias); - const r = await readJailedFile(alias, 1 << 20, false); - expect(r.kind).toBe('file'); - if (r.kind === 'file') expect(r.bytes.toString('utf8')).toBe('STORE'); - }); + itPosix( + 'reads a hard-linked file when rejectAliased is false (the full/unjailed tier — pnpm store links)', + async () => { + await writeFile(join(outside, 'store.txt'), 'STORE'); + const alias = join(workspace, 'dep.txt'); + await link(join(outside, 'store.txt'), alias); + const r = await readJailedFile(alias, 1 << 20, false); + expect(r.kind).toBe('file'); + if (r.kind === 'file') expect(r.bytes.toString('utf8')).toBe('STORE'); + }, + ); }); describe('createNodeFsCapability — glob read', () => { @@ -329,16 +351,19 @@ describe('createNodeFsCapability — glob read', () => { expect(result.content).not.toContain('LEAKED'); }); - itPosix('SKIPS a hard-linked (aliased) match but still returns the safe ones — no wholesale glob failure', async () => { - // The glob analogue of the symlink-skip above: a hard link (not under node_modules) aliasing an outside - // file must be skipped by readJailedFile's per-fd nlink guard, not fail the whole glob read. - await writeFile(join(outside, 'leak.ts'), 'LEAKED'); - await link(join(outside, 'leak.ts'), join(workspace, 'hard.ts')); - await writeFile(join(workspace, 'real.ts'), 'REAL'); - const result = await sandboxed().readFile('*.ts', { glob: true }); - expect(result.content).toContain('REAL'); - expect(result.content).not.toContain('LEAKED'); - }); + itPosix( + 'SKIPS a hard-linked (aliased) match but still returns the safe ones — no wholesale glob failure', + async () => { + // The glob analogue of the symlink-skip above: a hard link (not under node_modules) aliasing an outside + // file must be skipped by readJailedFile's per-fd nlink guard, not fail the whole glob read. + await writeFile(join(outside, 'leak.ts'), 'LEAKED'); + await link(join(outside, 'leak.ts'), join(workspace, 'hard.ts')); + await writeFile(join(workspace, 'real.ts'), 'REAL'); + const result = await sandboxed().readFile('*.ts', { glob: true }); + expect(result.content).toContain('REAL'); + expect(result.content).not.toContain('LEAKED'); + }, + ); it('skips a binary match but still returns the text matches', async () => { await writeFile(join(workspace, 'text.ts'), 'TEXT'); @@ -433,29 +458,39 @@ describe('createNodeFsCapability — write (read-write profile)', () => { expect(await readFile(join(outside, 'target.txt'), 'utf8')).toBe('original'); // never clobbered }); - itPosix('refuses to APPEND to a HARD-LINKED file — the aliasing bypass of the protected-paths floor', async () => { - // A hard link is not a symlink (assertNotSymlink passes) and its own path has no protected segment - // (assertNotProtectedPath passes), yet its inode may be a stand-in ~/.ssh/authorized_keys. The post-open - // fstat (st.nlink > 1, race-free on the fd) refuses it before O_APPEND writes through the shared inode. - await writeFile(join(outside, 'authorized_keys'), 'original'); - await link(join(outside, 'authorized_keys'), join(workspace, 'notes.txt')); - await expect( - sandboxed().writeFile('notes.txt', 'ssh-rsa AAAA-injected', { append: true }), - ).rejects.toBeInstanceOf(FsScopeDeniedError); - expect(await readFile(join(outside, 'authorized_keys'), 'utf8')).toBe('original'); // never appended - }); + itPosix( + 'refuses to APPEND to a HARD-LINKED file — the aliasing bypass of the protected-paths floor', + async () => { + // A hard link is not a symlink (assertNotSymlink passes) and its own path has no protected segment + // (assertNotProtectedPath passes), yet its inode may be a stand-in ~/.ssh/authorized_keys. The post-open + // fstat (st.nlink > 1, race-free on the fd) refuses it before O_APPEND writes through the shared inode. + await writeFile(join(outside, 'authorized_keys'), 'original'); + await link(join(outside, 'authorized_keys'), join(workspace, 'notes.txt')); + await expect( + sandboxed().writeFile('notes.txt', 'ssh-rsa AAAA-injected', { append: true }), + ).rejects.toBeInstanceOf(FsScopeDeniedError); + expect(await readFile(join(outside, 'authorized_keys'), 'utf8')).toBe('original'); // never appended + }, + ); - itPosix('the APPEND hard-link guard is unconditional — even the full tier / a store path is refused (no read-side carve-out)', async () => { - // The append guard has NO `full`-tier or pnpm-store exemption (unlike the read guard): appending MODIFIES the - // shared inode, a strictly worse outcome, so it is refused in every tier. Pin both directions of that asymmetry. - await writeFile(join(outside, 'target'), 'original'); - await link(join(outside, 'target'), join(workspace, 'alias.txt')); - const fullTier = createNodeFsCapability({ tier: 'full', workspaceDir: workspace, readOnly: false }); - await expect( - fullTier.writeFile('alias.txt', 'x', { append: true }), - ).rejects.toBeInstanceOf(FsScopeDeniedError); - expect(await readFile(join(outside, 'target'), 'utf8')).toBe('original'); - }); + itPosix( + 'the APPEND hard-link guard is unconditional — even the full tier / a store path is refused (no read-side carve-out)', + async () => { + // The append guard has NO `full`-tier or pnpm-store exemption (unlike the read guard): appending MODIFIES the + // shared inode, a strictly worse outcome, so it is refused in every tier. Pin both directions of that asymmetry. + await writeFile(join(outside, 'target'), 'original'); + await link(join(outside, 'target'), join(workspace, 'alias.txt')); + const fullTier = createNodeFsCapability({ + tier: 'full', + workspaceDir: workspace, + readOnly: false, + }); + await expect(fullTier.writeFile('alias.txt', 'x', { append: true })).rejects.toBeInstanceOf( + FsScopeDeniedError, + ); + expect(await readFile(join(outside, 'target'), 'utf8')).toBe('original'); + }, + ); itPosix( 'refuses to APPEND to a FIFO WITHOUT blocking — a reader-less pipe fails closed, never hangs the process', @@ -464,9 +499,9 @@ describe('createNodeFsCapability — write (read-write profile)', () => { // takes no AbortSignal). O_NONBLOCK now makes the reader-less open return ENXIO immediately → fatal denial. // If this regresses to a blocking open, the test times out (fail) rather than hanging the suite forever. execFileSync('mkfifo', [join(workspace, 'pipe')]); - await expect( - sandboxed().writeFile('pipe', 'data', { append: true }), - ).rejects.toBeInstanceOf(FsScopeDeniedError); + await expect(sandboxed().writeFile('pipe', 'data', { append: true })).rejects.toBeInstanceOf( + FsScopeDeniedError, + ); }, 10_000, ); @@ -479,9 +514,9 @@ describe('createNodeFsCapability — write (read-write profile)', () => { execFileSync('mkfifo', [join(workspace, 'p2')]); const reader = await open(join(workspace, 'p2'), constants.O_RDONLY | constants.O_NONBLOCK); try { - await expect( - sandboxed().writeFile('p2', 'x', { append: true }), - ).rejects.toBeInstanceOf(FsScopeDeniedError); + await expect(sandboxed().writeFile('p2', 'x', { append: true })).rejects.toBeInstanceOf( + FsScopeDeniedError, + ); } finally { await reader.close(); } @@ -554,7 +589,14 @@ describe('createNodeFsCapability — protected paths (denied in EVERY mode, auto // foldPathComponent denies them on every platform (an over-deny on a case-sensitive FS is the safe way). // The MULTI-char cases (`.bashrc..`, two trailing spaces) lock the linear run-strip that replaced `/[. ]+$/` // — a regression stripping only one trailing char would pass the single-char cases but fail these. - for (const name of ['.bashrc.', '.bashrc ', '.gitconfig.', 'profile.ps1 ', '.bashrc..', '.gitconfig ']) { + for (const name of [ + '.bashrc.', + '.bashrc ', + '.gitconfig.', + 'profile.ps1 ', + '.bashrc..', + '.gitconfig ', + ]) { await expect(sandboxed().writeFile(name, 'evil', {})).rejects.toBeInstanceOf( FsScopeDeniedError, ); @@ -638,7 +680,9 @@ describe('createNodeFsCapability — sensitive-read floor (credential/secret sto it('refuses to read inside `.ssh/` (private keys / authorized_keys / known_hosts)', async () => { await mkdir(join(workspace, '.ssh'), { recursive: true }); await writeFile(join(workspace, '.ssh', 'id_rsa'), 'PRIVATE KEY'); - await expect(sandboxed().readFile('.ssh/id_rsa', {})).rejects.toBeInstanceOf(FsScopeDeniedError); + await expect(sandboxed().readFile('.ssh/id_rsa', {})).rejects.toBeInstanceOf( + FsScopeDeniedError, + ); }); it('refuses a `.git/config` — repo-local, a submodule `.git/modules/*/config`, and a bare `*.git/config`', async () => { @@ -667,7 +711,9 @@ describe('createNodeFsCapability — sensitive-read floor (credential/secret sto it('folds the read floor like the write floor — a case variant (`.SSH/`) is still refused', async () => { await mkdir(join(workspace, '.SSH'), { recursive: true }); await writeFile(join(workspace, '.SSH', 'id_rsa'), 'KEY'); - await expect(sandboxed().readFile('.SSH/id_rsa', {})).rejects.toBeInstanceOf(FsScopeDeniedError); + await expect(sandboxed().readFile('.SSH/id_rsa', {})).rejects.toBeInstanceOf( + FsScopeDeniedError, + ); }); it('refuses the git XDG credential store `git/credentials` (bare `credentials` under a `git` dir)', async () => { @@ -687,7 +733,9 @@ describe('createNodeFsCapability — sensitive-read floor (credential/secret sto await mkdir(join(workspace, '.ssh'), { recursive: true }); await writeFile(join(workspace, '.ssh', 'id_rsa'), 'PRIVATE KEY'); await symlink(join(workspace, '.ssh', 'id_rsa'), join(workspace, 'innocent.txt')); - await expect(sandboxed().readFile('innocent.txt', {})).rejects.toBeInstanceOf(FsScopeDeniedError); + await expect(sandboxed().readFile('innocent.txt', {})).rejects.toBeInstanceOf( + FsScopeDeniedError, + ); }); it('ALLOWS a normal `.gitignore` — only the credential set is sensitive, not every dotfile', async () => { diff --git a/apps/cli/src/engine/tool-host/fs.ts b/apps/cli/src/engine/tool-host/fs.ts index 59589c7a..84bac31b 100644 --- a/apps/cli/src/engine/tool-host/fs.ts +++ b/apps/cli/src/engine/tool-host/fs.ts @@ -237,7 +237,11 @@ async function readGlob( // can neither redirect the read nor make the size charged diverge from the bytes read. The bounded prefix // probe skips a binary match WITHOUT charging the budget or loading it; the budget is enforced against the // fd's own size BEFORE the full read, so an over-budget text file is never loaded just to be rejected. - const result = await readJailedFile(m.real, maxReadBytes - totalBytes, rejectAliasedRead(config, m.real)); + const result = await readJailedFile( + m.real, + maxReadBytes - totalBytes, + rejectAliasedRead(config, m.real), + ); // skip a non-text match (dir / special / aliased / binary); never charge the budget. collectGlobFiles // already filters to regular, in-scope, non-sensitive files — but it does NOT check nlink, so the // hard-link (`aliased`) skip is enforced HERE by readJailedFile's per-fd guard (the primary aliasing diff --git a/apps/cli/src/render/tui/chat-input.ts b/apps/cli/src/render/tui/chat-input.ts index 0e243e3f..7ef284b6 100644 --- a/apps/cli/src/render/tui/chat-input.ts +++ b/apps/cli/src/render/tui/chat-input.ts @@ -39,18 +39,6 @@ export type ChatKeyAction = /** An approval-prompt rejection (`[n]`). */ | { readonly kind: 'reject' }; -/** - * Reduce one keystroke of the chat prompt to an action. - * - * When an approval is pending (`approvalPending`), the prompt OWNS the keyboard — the in-flight key-swallow - * bypass (ADR-0057, no deadlock): `[y]`/`1` approve once, `[a]`/`2` approve always, `[n]`/`3` reject, `Esc` - * aborts the whole turn; every other key is ignored. Otherwise: `Ctrl-C` maps to `cancel` even mid-turn (a - * streaming turn can always be interrupted); `Shift+Tab` cycles the mode (harmless mid-turn — it applies to - * the next turn); `Esc` while `running` is a mid-turn `abort` (EA7); while a turn is `running` every OTHER key - * is ignored (one turn at a time); `Return` submits the buffer; backspace/delete is a `backspace` op; a - * printable char (not a ctrl/meta chord) is an `append` op. The edit ops carry no buffer value — the caller - * folds them functionally, preserving the accumulating semantics across a batched multi-event chunk. - */ /** * The approval-prompt keystroke intercept (accept-edits / auto's protected-path fallback), extracted so * {@link reduceChatKey} stays flat: `[y]`/`1` approve once, `[a]`/`2` approve always, `[n]`/`r`/`3` reject, @@ -65,6 +53,17 @@ function reduceApprovalKey(char: string, key: ChatKey): ChatKeyAction { return { kind: 'none' }; } +/** + * Reduce one keystroke of the chat prompt to an action. + * + * When an approval is pending (`approvalPending`), the prompt OWNS the keyboard (see {@link reduceApprovalKey}) — + * the in-flight key-swallow bypass (ADR-0057, no deadlock). Otherwise: `Ctrl-C` maps to `cancel` even mid-turn (a + * streaming turn can always be interrupted); `Shift+Tab` cycles the mode (harmless mid-turn — it applies to + * the next turn); `Esc` while `running` is a mid-turn `abort` (EA7); while a turn is `running` every OTHER key + * is ignored (one turn at a time); `Return` submits the buffer; backspace/delete is a `backspace` op; a + * printable char (not a ctrl/meta chord) is an `append` op. The edit ops carry no buffer value — the caller + * folds them functionally, preserving the accumulating semantics across a batched multi-event chunk. + */ export function reduceChatKey( char: string, key: ChatKey, diff --git a/apps/cli/src/render/tui/chat-projection.ts b/apps/cli/src/render/tui/chat-projection.ts index db5e7da1..e1be9d99 100644 --- a/apps/cli/src/render/tui/chat-projection.ts +++ b/apps/cli/src/render/tui/chat-projection.ts @@ -51,10 +51,15 @@ export function sanitizeInline(text: string): string { } /** - * Error codes whose `errorMessage` is a vetted, secret-free, host-supplied LABEL safe to render in-chat — the - * actionable approval-floor denials (ADR-0057): `tool_denied` ("not allowed in ask mode (read-only)", "refusing - * to write inside a protected directory") and `tool_unavailable` ("fs (read-only in this session)"). Other - * codes' messages may carry prompt/model context, so only the code is shown for them. + * Error codes whose `errorMessage` is safe to render in-chat. This leans on a load-bearing project-wide + * contract, not a two-string allowlist: EVERY `tool_denied` / `tool_unavailable` message across the engine is, + * by the `ToolDispatchError` reason-only rule (packages/core/src/tools/errors.ts; tool-registry.md §errors), a + * static host/engine-authored LABEL that never interpolates an argument value, path, URL, or secret — the + * ADR-0057 approval-floor denials ("not allowed in ask mode (read-only)", "refusing to write inside a protected + * directory", "fs (read-only in this session)") plus every `ToolPolicyError` / `HostDeniedError` reason. Other + * codes (validation / execution_failed / …) MAY carry prompt/model context, so only the code is shown for them. + * A new denial subclass becomes chat-visible automatically once its code is one of these — which is safe + * precisely because the reason-only contract binds it too (the message is still terminal-sanitized regardless). */ const SAFE_MESSAGE_CODES: ReadonlySet = new Set(['tool_denied', 'tool_unavailable']); @@ -68,7 +73,9 @@ export function formatTurnSummary(summary: TurnSummary): string { // collapse — the whitelisted messages are host-authored ASCII today, but the render boundary must strip any // ANSI/OSC/control byte regardless so the whitelist stays robust to a future producer. const reason = - summary.errorMessage === undefined ? '' : sanitizeInline(summary.errorMessage).replace(/\s+/gu, ' ').trim(); + summary.errorMessage === undefined + ? '' + : sanitizeInline(summary.errorMessage).replace(/\s+/gu, ' ').trim(); let head: string; if (summary.errorCode === undefined) { head = summary.stopReason; diff --git a/docs/reference/contracts/sse-event-schema.md b/docs/reference/contracts/sse-event-schema.md index 56be48b3..f898fc2c 100644 --- a/docs/reference/contracts/sse-event-schema.md +++ b/docs/reference/contracts/sse-event-schema.md @@ -74,7 +74,7 @@ export type RunEvent = | `agent:token` | A streaming LLM token from an agent node. | `nodeId`, `token`, `model` | | `agent:tool_call` | An agent invoked a tool. | `nodeId`, `model` (the invoking model — so a tool call is attributable across a failover), `toolId`, `toolInput` (sanitized — no secrets), `attemptNumber?` (1-based, matches `cost:updated`) | | `agent:tool_result` | A tool returned. | `nodeId`, `toolId`, `success`, `outputSummary` (truncated for UI), `attemptNumber?` | -| `agent:approval_requested` | A side-effecting tool dispatch is awaiting an **interactive per-tool approval** decision (ADR-0057 EA3/EA5). The host's `ConfirmActionHook` emits it just before prompting; the registry then awaits the verdict (approve ⇒ dispatch, reject ⇒ a fatal `tool_denied`). A **dual-envelope** event (`runId`/`sessionId`), like `agent:tool_call` — in Phase 2.5 emitted only on the chat session path (the approval regime), and **carried on the session stream** (not run-only — it is **not** dropped like `agent:file_patch_proposed`). | `nodeId`, `toolId`, `action: 'fs_write' \| 'process' \| 'egress' \| 'os'` (the governed side-effect class — [tool-registry.md](../shared-core/tool-registry.md)), `preview` (**secret-free, display-only**: `{ path? }` for a write, `{ command? }` for a process, `{ host? }` for egress, `{}` for an `os` action like `read_clipboard`/`notify` — never a full URL/query, never a secret), `attemptNumber?` | +| `agent:approval_requested` | A side-effecting tool dispatch is awaiting an **interactive per-tool approval** decision (ADR-0057 EA3/EA5). The engine's `confirmDispatch` emits it — for **every** governed dispatch reaching the gate, whether the host then prompts a human or auto-decides — just before invoking the host's `ConfirmActionHook`; the registry then awaits the verdict (approve ⇒ dispatch, reject ⇒ a fatal `tool_denied`). A **dual-envelope** event (`runId`/`sessionId`), like `agent:tool_call` — in Phase 2.5 emitted only on the chat session path (the approval regime), and **carried on the session stream** (not run-only — it is **not** dropped like `agent:file_patch_proposed`). | `nodeId`, `toolId`, `action: 'fs_write' \| 'process' \| 'egress' \| 'os'` (the governed side-effect class — [tool-registry.md](../shared-core/tool-registry.md)), `preview` (**secret-free, display-only**: `{ path? }` for a write, `{ command? }` for a process, `{ host? }` for egress, `{}` for an `os` action like `read_clipboard`/`notify` — never a full URL/query, never a secret), `attemptNumber?` | | `agent:file_patch_proposed` | An agent proposed a file change (**gated — no write until the user accepts**; e.g. the VS Code inline-diff review). | `nodeId`, `patches: [{ uri, unifiedDiff }]` (≥1 — an empty proposal is meaningless), `attemptNumber?` | | `cost:updated` | A node's token cost was tallied (drives the cost waterfall). | `nodeId`, `model`, `inputTokens`, `outputTokens`, `costMicrocents`, `cumulativeCostMicrocents` (integer micro-cents — canonical unit in [llm-provider-seam.md](../shared-core/llm-provider-seam.md#6-usage); **includes realized media spend**, folded as a disjoint addend per [ADR-0044](../../decisions/0044-media-access-governance-read-media-save-to-cost.md) §3 — the per-unit `Usage.mediaUnits` axis is **not yet a field on this event**, deferred, see [deferred-tasks.md](../../roadmap/deferred-tasks.md)), `attemptNumber?` (1-based **within-chain** FallbackChain attempt — resets per node-retry re-dispatch; **distinct** from `node:*.attemptNumber`, see the [two attemptNumber families](#two-attemptnumber-families) note). **Generative-node variant (1.AG Section C, [ADR-0045](../../decisions/0045-async-media-job-loop-poll-checkpoint-resume-cancel.md) §5):** a `media_surface: 'generative'` agent node emits **exactly one** `cost:updated` with `inputTokens` / `outputTokens` **= 0** (no token billing — the spend rides entirely in `costMicrocents` as the per-modality media addend) and **no `attemptNumber`** (no FallbackChain on the generative path — one provider, no failover). | | `node:completed` | A node finished successfully. | `nodeId`, `output`, `tokensUsed: {input, output, model?}` (`model` only for LLM nodes), `durationMs`, `selected?` (a `condition`'s chosen target ids — the authoritative branch record checkpoint/resume restores from, 1.R; **may be an empty array** when the condition routes to no branch, dimming all downstream), `attemptNumber?` (1-based **node-retry** dispatch attempt — 1.S; absent ⇒ attempt 1), `cumulativeCostMicrocents?` (the run-wide running total snapshotted at this node boundary — the durable cost source checkpoint/resume restores from, since `cost:updated` is streamed-only; the engine always populates it. `node:failed` mirrors this field, 2.S/D-GC) | diff --git a/docs/reference/shared-core/tool-registry.md b/docs/reference/shared-core/tool-registry.md index 4995f1d8..369941b3 100644 --- a/docs/reference/shared-core/tool-registry.md +++ b/docs/reference/shared-core/tool-registry.md @@ -170,7 +170,7 @@ host is touched once, in the middle. 2. **Assemble the effective argument set.** Start from the model-supplied args (for an agent tool-call) and/or `ctx.config.input_mapping` (for a `tool` node, where there are no model args), apply `input_mapping`, then merge `configOnlyParams` **last (config wins)**. 3. **Validate the COMPLETE effective set** via `tool.parseArgs` **and** the secret-taint check — `input_mapping`/config-derived values are validated identically to model args; a secret-tainted value reaching a non-credential arg is rejected (0029(c)). A miss → `ToolArgsInvalidError`. 4. **Enforce the guardrail policy on the EFFECTIVE args** (the resolved command / URL is now the real value): exact `allowedCommands` (+ opt-in `allowedCommandGlobs`, deny-all-empty); per egress kind (table below); `git_commit` refused unless `ctx.gateApproved`. A denial → `ToolPolicyError` → **before any host call**. -4b. **Per-tool approval — the interactive consent gate ([ADR-0057](../../decisions/0057-cli-chat-modes-and-per-tool-approval.md) EA3).** Runs only when `ctx.approval` is present (the **interactive-approval regime** — the chat path; absent ⇒ the workflow author-trust path, unchanged) and the dispatch is a **governed class**: `fsWrite`, an `egress` of any kind, an `os` action (`read_clipboard` / `notify` — the clipboard is an un-jailed exfiltration sink), or a `spawnsProcess` with a model-controlled `command` target (so the pre-approved `git_status`, which exposes no command, is **not** gated — matching step 4). The engine consults the host-injected `ctx.approval.confirm` hook with a **secret-free preview** (resolved path / command / host — never a full URL/query, never a secret) and, before prompting, the host emits `agent:approval_requested`. **Fail-closed:** under an active regime a governed dispatch *requires* a decision — an **absent `confirm` hook ⇒ denied** (`ToolDeniedByUserError`, reason `no_approval_hook`), never silently allowed, so a wiring bug cannot let `ask` mode write. A reject ⇒ `ToolDeniedByUserError` (reason `user_rejected`) carrying the existing **`tool_denied`** code (fatal, never retried); an abort while prompting routes to the **cancellation** path (cancel precedence). The host's `confirm` owns the mode policy (ask / plan / accept-edits / auto), the once/always cache, and the protected-paths rule — the engine stays mode-agnostic. This composes *after* step 4 and, when an `ActionGuard` ([action-guard-seam.md](action-guard-seam.md)) is also injected, *alongside* it (the org governor's `decide`/`commit` and the user's `confirm` each only further restrict). +4b. **Per-tool approval — the interactive consent gate ([ADR-0057](../../decisions/0057-cli-chat-modes-and-per-tool-approval.md) EA3).** Runs only when `ctx.approval` is present (the **interactive-approval regime** — the chat path; absent ⇒ the workflow author-trust path, unchanged) and the dispatch is a **governed class**: `fsWrite`, an `egress` of any kind, an `os` action (`read_clipboard` / `notify` — the clipboard is an un-jailed exfiltration sink), or a `spawnsProcess` with a model-controlled `command` target (so the pre-approved `git_status`, which exposes no command, is **not** gated — matching step 4). The engine consults the host-injected `ctx.approval.confirm` hook with a **secret-free preview** (resolved path / command / host — never a full URL/query, never a secret); just before invoking that hook, the **engine's `confirmDispatch`** emits `agent:approval_requested` (via `ctx.approval.emitApprovalRequested`) for **every** governed dispatch reaching this gate — whether the host then prompts a human (accept-edits / auto's protected-path fallback) or auto-decides (ask/plan deny, auto approve). **Fail-closed:** under an active regime a governed dispatch *requires* a decision — an **absent `confirm` hook ⇒ denied** (`ToolDeniedByUserError`, reason `no_approval_hook`), never silently allowed, so a wiring bug cannot let `ask` mode write. A reject ⇒ `ToolDeniedByUserError` (reason `user_rejected`) carrying the existing **`tool_denied`** code (fatal, never retried); an abort while prompting routes to the **cancellation** path (cancel precedence). The host's `confirm` owns the mode policy (ask / plan / accept-edits / auto), the once/always cache, and the protected-paths rule — the engine stays mode-agnostic. This composes *after* step 4 and, when an `ActionGuard` ([action-guard-seam.md](action-guard-seam.md)) is also injected, *alongside* it (the org governor's `decide`/`commit` and the user's `confirm` each only further restrict). 5. **Call the host capability** (the single side effect), threading `ctx.signal`. Absent capability → `ToolUnavailableError`; an `AbortSignal`-origin failure → the **cancellation** path (`cancelled`, never `tool_failed` — preserves ADR-0036 cancel precedence); any other host throw → `ToolExecutionError`. A host capability MAY itself throw a typed `ToolDispatchError` subclass for a **deterministic host-side denial** (e.g. the CLI `fs` arm throwing a `tool_denied` when a path escapes the FS scope tier, or `ToolUnavailableError` for a read-only fail-close) — the registry passes any `ToolDispatchError` through **verbatim**, exactly as it does an engine-side `ToolPolicyError`, so such a denial is fatal (never burns the node-retry budget); only a *raw* host throw becomes the retryable `ToolExecutionError`. 6. **Apply `output_mapping` to the FULL result** → workflow state gets the real value, never the bounded preview. 7. **Bound the model-facing result** (§Result bounding and spill-to-file) from the result via `ctx.limits` + the host `outputStore` — over the ceiling the model gets a preview + a spill handle, the full result still flows to `output_mapping`. diff --git a/packages/core/src/engine/agent-session.ts b/packages/core/src/engine/agent-session.ts index 45f11359..4a461744 100644 --- a/packages/core/src/engine/agent-session.ts +++ b/packages/core/src/engine/agent-session.ts @@ -94,8 +94,9 @@ export type SessionApprovalStreamEvent = DistributiveOmit< /** * Everything a session emits, envelope-less: the five `session:*` lifecycle bodies, the four dual-envelope * in-turn bodies the turn core produces (`agent:token` / `agent:tool_call` / `agent:tool_result` / - * `cost:updated`), and the host-emitted `agent:approval_requested` body (ADR-0057). The injected sink - * receives these; 1.W routes them onto the bus. + * `cost:updated`), and the engine-emitted `agent:approval_requested` body (ADR-0057 EA5 — the registry's + * `confirmDispatch` emits it via the dispatch context's `emitApprovalRequested`). The injected sink receives + * these; 1.W routes them onto the bus. */ export type SessionStreamEvent = | SessionLifecycleEvent diff --git a/packages/core/src/tools/bounding.test.ts b/packages/core/src/tools/bounding.test.ts index 09544557..b85ebac3 100644 --- a/packages/core/src/tools/bounding.test.ts +++ b/packages/core/src/tools/bounding.test.ts @@ -224,7 +224,8 @@ describe('boundForModel', () => { it('scrubs a secret-shaped value from the summary (outputSummary) but keeps the model-facing value', async () => { // A read_clipboard / egress-body / .env-style result carrying a live token must not ride outputSummary // (→ agent:tool_result.outputSummary → the --json stream). The model-facing value keeps the real bytes. - const result = `export API_KEY=sk-${'abcdef0123456789abcdef'} and Authorization: Bearer tok${'_live_9f8e7d6c5b4a'}`; + const result = + `export API_KEY=sk-${'abcdef0123456789abcdef'} and Authorization: Bearer tok${'_live_9f8e7d6c5b4a'}`; const bounded = await boundForModel(result, BIG, host()); expect(bounded.summary).not.toContain('sk-' + 'abcdef0123456789abcdef'); expect(bounded.summary).not.toContain('tok_live_9f8e7d6c5b4a'); diff --git a/packages/core/src/tools/registry.test.ts b/packages/core/src/tools/registry.test.ts index 978750aa..eed68976 100644 --- a/packages/core/src/tools/registry.test.ts +++ b/packages/core/src/tools/registry.test.ts @@ -350,7 +350,12 @@ describe('governedAction — the authoritative engine confirm-floor classifier ( // MODEL-command process tool (run_command's policyTarget yields a `command`) trips the process case; a // pre-approved process tool (git_status / git_commit — no policyTarget) does not. Each policyTarget reads // only its own field, so one permissive sample serves all. - const sampleArgs = { command: 'ls', args: [] as string[], path: 'p.txt', url: 'https://example.com' }; + const sampleArgs = { + command: 'ls', + args: [] as string[], + path: 'p.txt', + url: 'https://example.com', + }; const governed: Record = {}; for (const def of BUILTIN_TOOLS) { const target = def.policyTarget?.(sampleArgs) ?? {}; @@ -468,7 +473,11 @@ describe('ToolRegistry — per-tool approval (ADR-0057 EA3)', () => { ); expect(order).toEqual(['emit', 'confirm']); // the observability event fires just BEFORE the prompt expect(emitApprovalRequested).toHaveBeenCalledWith( - expect.objectContaining({ toolId: 'write_file', action: 'fs_write', preview: { path: './out.txt' } }), + expect.objectContaining({ + toolId: 'write_file', + action: 'fs_write', + preview: { path: './out.txt' }, + }), ); // The confirm hook received the SAME request object (one construction, EA5-emitted then confirmed). expect(emitApprovalRequested.mock.calls[0]?.[0]).toBe(confirm.mock.calls[0]?.[0]); @@ -480,7 +489,9 @@ describe('ToolRegistry — per-tool approval (ADR-0057 EA3)', () => { order.push('confirm'); return Promise.resolve({ outcome: 'reject', reason: 'no' }); }); - const emitApprovalRequested = vi.fn<(req: ToolApprovalRequest) => void>(() => order.push('emit')); + const emitApprovalRequested = vi.fn<(req: ToolApprovalRequest) => void>(() => + order.push('emit'), + ); const { host, writeFile } = hostWithWriteSpy(); await rejectsWith( createToolRegistry({ tools: BUILTIN_TOOLS, host }).dispatch( @@ -962,10 +973,7 @@ describe('ToolRegistry — host capability, execution, cancellation', () => { it('refuses to start when the signal is already aborted', async () => { const err = await rejectsWith( - registry().dispatch( - call('read_file', { path: 'a' }), - ctx({ signal: mutableSignal(true) }), - ), + registry().dispatch(call('read_file', { path: 'a' }), ctx({ signal: mutableSignal(true) })), ); expect(err).toBeInstanceOf(ToolCancelledError); expect(err.runErrorCode).toBe('cancelled'); diff --git a/packages/core/src/tools/registry.ts b/packages/core/src/tools/registry.ts index 0b8c6e8b..606ad636 100644 --- a/packages/core/src/tools/registry.ts +++ b/packages/core/src/tools/registry.ts @@ -352,7 +352,11 @@ async function confirmDispatch( } throwIfAborted(ctx, def.id); // do not prompt for a turn that is already aborting - const request: ToolApprovalRequest = { toolId: def.id, action, preview: previewFor(action, target) }; + const request: ToolApprovalRequest = { + toolId: def.id, + action, + preview: previewFor(action, target), + }; // EA5: emit the observability event for EVERY governed dispatch that reaches this gate, just before the host // decides — a durable "a governed action was gated" trace on the session / `--json` stream (the session // stamps the envelope + nodeId), whether the host then prompts a human or auto-decides. Side-effect only — a @@ -396,18 +400,15 @@ async function confirmDispatch( } /** - * The side-effecting action class a per-tool approval governs, or `undefined` for an un-gated tool. A - * model-controlled `run_command` (a resolved `command` target) is `process`; the pre-approved `git_status` - * (no command target) is NOT governed — matching `enforcePolicy`, which runs the command allowlist only when - * a command target is present. An fs READ and `invoke_agent` are not governed + * Classify a dispatch's governed ACTION class — the authoritative confirmAction floor — or `undefined` for an + * un-gated tool. A model-controlled `run_command` (a resolved `command` target) is `process`; the pre-approved + * `git_status` (no command target) is NOT governed — matching `enforcePolicy`, which runs the command allowlist + * only when a command target is present. An fs READ and `invoke_agent` are not governed * ([ADR-0041](../../../../docs/decisions/0041-external-action-governance-seam.md) §ActionClass); every egress * IS, even a read-only `web_search` (an exfiltration sink); and an `os` action (`read_clipboard` / `notify`) * IS — the clipboard is ambient, un-jailed OS state that routinely holds a freshly-copied secret (ADR-0057). - */ -/** - * Classify a dispatch's governed ACTION class (the authoritative confirmAction floor) — or `undefined` for a - * read-only / pre-approved tool that is never gated. Exported (from this module, NOT the package index) so a - * drift-lock test can pin the exact engine-governed set, distinct from the CLI advertise-filter's superset. + * Exported (from this module, NOT the package index) so a drift-lock test can pin the exact engine-governed + * set, distinct from the CLI advertise-filter's superset. */ export function governedAction(def: ToolDef, target: PolicyTarget): ToolActionClass | undefined { if (def.policy.fsWrite === true) { From d7e90b0856d158062b51f67b632ca0d92ccbe341 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Thu, 2 Jul 2026 16:43:43 +0300 Subject: [PATCH 40/45] =?UTF-8?q?test(core):=202.5.E=20=E2=80=94=20split?= =?UTF-8?q?=20secret-shaped=20redaction=20fixtures=20(Leakwatch=20policy)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- packages/core/src/tools/bounding.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/core/src/tools/bounding.test.ts b/packages/core/src/tools/bounding.test.ts index b85ebac3..62084440 100644 --- a/packages/core/src/tools/bounding.test.ts +++ b/packages/core/src/tools/bounding.test.ts @@ -224,8 +224,7 @@ describe('boundForModel', () => { it('scrubs a secret-shaped value from the summary (outputSummary) but keeps the model-facing value', async () => { // A read_clipboard / egress-body / .env-style result carrying a live token must not ride outputSummary // (→ agent:tool_result.outputSummary → the --json stream). The model-facing value keeps the real bytes. - const result = - `export API_KEY=sk-${'abcdef0123456789abcdef'} and Authorization: Bearer tok${'_live_9f8e7d6c5b4a'}`; + const result = `export API_KEY=sk-${'abcdef0123456789abcdef'} and Authorization: Bearer tok${'_live_9f8e7d6c5b4a'}`; const bounded = await boundForModel(result, BIG, host()); expect(bounded.summary).not.toContain('sk-' + 'abcdef0123456789abcdef'); expect(bounded.summary).not.toContain('tok_live_9f8e7d6c5b4a'); @@ -258,7 +257,9 @@ describe('redactSecretShapedText', () => { expect(apiKey).not.toContain('sk-' + 'XYZ12345abcdef'); expect(apiKey).toContain('[redacted]'); expect(redactSecretShapedText('token AKIA' + 'IOSFODNN7EXAMPLE here')).toContain('[redacted]'); - expect(redactSecretShapedText('ghp' + '_0123456789abcdef0123456789abcdefABCD')).toBe('[redacted]'); + expect(redactSecretShapedText('ghp' + '_0123456789abcdef0123456789abcdefABCD')).toBe( + '[redacted]', + ); }); it('redacts the JSON `"key":"value"` shape — an OAuth / egress response body', () => { From d9f31b474baea3a3d4131128a76011246afcc6c2 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Thu, 2 Jul 2026 16:50:33 +0300 Subject: [PATCH 41/45] fix(settings): update permissions to allow git push command --- .claude/settings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.claude/settings.json b/.claude/settings.json index 99d6b834..a36006a8 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -31,12 +31,12 @@ "Bash(head:*)", "Bash(tail:*)", "Bash(wc:*)", + "Bash(git push:*)", "Bash(tree:*)" ], "deny": [ "Bash(sudo:*)", "Bash(rm -rf:*)", - "Bash(git push:*)", "Read(.env)", "Read(.env.*)", "Read(**/*.pem)" From e762f8b68f8af9134da58bece5fe515a3a954e4c Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Thu, 2 Jul 2026 17:53:29 +0300 Subject: [PATCH 42/45] =?UTF-8?q?fix:=202.5.E=20=E2=80=94=20address=20PR?= =?UTF-8?q?=20#63=20follow-up=20review=20(sonar=20+=20inline=20nitpicks)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/@/node_modules//…` (require the `@` + 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 extends unknown ? Omit : 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) --- .claude/settings.json | 2 +- apps/cli/src/chat/chat-mode.ts | 2 +- apps/cli/src/commands/chat.ts | 102 +++++++++--------- apps/cli/src/engine/tool-host/fs.test.ts | 15 +++ apps/cli/src/engine/tool-host/fs.ts | 15 ++- packages/core/src/engine/agent-session.ts | 37 ++++--- packages/core/src/tools/bounding.test.ts | 37 +++++-- packages/core/src/tools/bounding.ts | 39 +++++-- packages/shared/src/run-event.test.ts | 21 ++++ packages/shared/src/run-event.ts | 121 ++++++++++++++-------- 10 files changed, 268 insertions(+), 123 deletions(-) diff --git a/.claude/settings.json b/.claude/settings.json index a36006a8..c04436e8 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -31,7 +31,7 @@ "Bash(head:*)", "Bash(tail:*)", "Bash(wc:*)", - "Bash(git push:*)", + "Bash(git push origin development)", "Bash(tree:*)" ], "deny": [ diff --git a/apps/cli/src/chat/chat-mode.ts b/apps/cli/src/chat/chat-mode.ts index 8f7a0c00..b137107a 100644 --- a/apps/cli/src/chat/chat-mode.ts +++ b/apps/cli/src/chat/chat-mode.ts @@ -66,7 +66,7 @@ export function parseMode(input: string): ChatMode | undefined { /** A type guard that narrows a string to {@link ChatMode} via the {@link CHAT_MODES} tuple — no `as` cast. */ function isChatMode(value: string): value is ChatMode { - return (CHAT_MODES as readonly string[]).includes(value); + return CHAT_MODES.some((mode) => mode === value); } /** diff --git a/apps/cli/src/commands/chat.ts b/apps/cli/src/commands/chat.ts index 9a703221..e724e5fe 100644 --- a/apps/cli/src/commands/chat.ts +++ b/apps/cli/src/commands/chat.ts @@ -15,6 +15,7 @@ import { formatReplHelp, replCommandList, REPL_COMMANDS_BY_NAME, + type ReplCommand, type ReplCommandContext, } from './repl-commands.js'; import { @@ -474,6 +475,34 @@ export interface ChatLineHandler extends ChatModeControl { readonly shouldStop: () => boolean; } +/** + * Validate the arg tokens of a resolved REPL command against what it declares, returning a ready-to-emit + * rejection message or `undefined` when the tokens are acceptable. Two rules: (1) every token must be a + * declared flag or a declared positional VALUE (a zero-arg command rejects ANY token — so `/exit now` fails); + * (2) a `{ name, values }` positional is a SINGLE value, so more than one positional-value token + * (`/mode plan accept-edits`) is rejected rather than silently dropping the extras downstream. When the command + * declares a positional, the rejection lists its valid values (so `/mode aggressive` teaches the names). Bad + * tokens are sanitized (non-printable → `?`, truncated) so a crafted arg can't smuggle a control sequence. + */ +function validateSlashTokens(command: ReplCommand, tokens: readonly string[]): string | undefined { + const positionalValues = command.positional?.values ?? []; + const allowed = new Set([...(command.args ?? []).map((arg) => arg.flag), ...positionalValues]); + const validHint = + command.positional === undefined ? '' : ` Valid: ${positionalValues.join(', ')}.`; + const bad = tokens.find((token) => !allowed.has(token)); + if (bad !== undefined) { + return `/${command.name}: unknown argument '${bad.replace(/[^\x20-\x7e]/g, '?').slice(0, 32)}'.${validHint}`; + } + if (command.positional !== undefined) { + const positionalSet = new Set(positionalValues); + const positionalCount = tokens.filter((token) => positionalSet.has(token)).length; + if (positionalCount > 1) { + return `/${command.name}: takes a single ${command.positional.name} value (got ${positionalCount}).${validHint}`; + } + } + return undefined; +} + /** * Build the slash-aware line handler shared by the chat REPL loop (`runReplLoop`) and the 2.5.B Home's in-tree * chat driver: `/exit` stops; `/cancel` ends the (resumable) session AND stops; `/export` scaffolds a workflow @@ -624,57 +653,36 @@ export function createChatLineHandler( }, }; + // Parse + dispatch a `/name [args]` REPL line (extracted from processLine so each stays under the Sonar + // cognitive-complexity ceiling). Returns after emitting any error/echo through the notice channel — an + // interactive error belongs in-view (ink), not on stderr behind the live view. + const handleSlashCommand = async (line: string): Promise => { + // Split the post-slash string into a command name + arg tokens, so `/doctor --deep` dispatches `doctor` + // with `['--deep']`. A zero-arg command takes no tokens (so `/exit now` is rejected), preserving the prior + // exact-match strictness while admitting declared flags. + const [name, ...tokens] = line.slice(1).split(/\s+/); + const command = + name !== undefined && name.length > 0 ? REPL_COMMANDS_BY_NAME.get(name) : undefined; + if (command === undefined) { + // Echo a SANITIZED form — strip non-printable bytes + truncate — so a crafted slash can't smuggle a + // terminal control sequence (or a flood). + const safe = line.replace(/[^\x20-\x7e]/g, '?').slice(0, 64); + emitOutput(`unknown command '${safe}'. Available: ${replCommandList()}.`); + return; + } + const rejection = validateSlashTokens(command, tokens); + if (rejection !== undefined) { + emitOutput(rejection); + return; + } + await command.run(replCtx, tokens); // may be async (/cost, /doctor); never fire-and-forget + }; + const processLine = async (raw: string): Promise => { const line = raw.trim(); if (line.length === 0) return; if (line.startsWith('/')) { - // Parse `/name [args]` (S5): split the post-slash string into a command name + arg tokens, so `/doctor - // --deep` dispatches `doctor` with `['--deep']`. A zero-arg command takes no tokens (so `/exit now` is - // rejected below), preserving the prior exact-match strictness while admitting declared flags. - const [name, ...tokens] = line.slice(1).split(/\s+/); - const command = - name !== undefined && name.length > 0 ? REPL_COMMANDS_BY_NAME.get(name) : undefined; - if (command !== undefined) { - // Reject a token the command does not declare (a zero-arg command rejects ANY token). A command may also - // declare a single POSITIONAL value set (e.g. `/mode plan`), whose values join the allowed set — so an - // invalid mode is rejected HERE, before run. Echo a bad token SANITIZED through the notice channel — - // interactive errors belong in-view (ink), not on stderr behind the live view. - const positionalValues = command.positional?.values ?? []; - const allowed = new Set([ - ...(command.args ?? []).map((arg) => arg.flag), - ...positionalValues, - ]); - // When the command declares a positional, list its valid values in the rejection (so `/mode aggressive` - // teaches the four names, not just "unknown argument"). - const validHint = - command.positional === undefined ? '' : ` Valid: ${positionalValues.join(', ')}.`; - const bad = tokens.find((token) => !allowed.has(token)); - if (bad !== undefined) { - emitOutput( - `/${command.name}: unknown argument '${bad.replace(/[^\x20-\x7e]/g, '?').slice(0, 32)}'.${validHint}`, - ); - return; - } - // Enforce the declared positional ARITY: a `{ name, values }` positional is a SINGLE value, so more than - // one positional-value token (`/mode plan accept-edits`) is rejected here rather than silently dropping - // the extras downstream. (Flag tokens are not positional values, so they don't count against the arity.) - if (command.positional !== undefined) { - const positionalSet = new Set(positionalValues); - const positionalCount = tokens.filter((token) => positionalSet.has(token)).length; - if (positionalCount > 1) { - emitOutput( - `/${command.name}: takes a single ${command.positional.name} value (got ${positionalCount}).${validHint}`, - ); - return; - } - } - await command.run(replCtx, tokens); // may be async (/cost, /doctor); never fire-and-forget - return; - } - // Echo a SANITIZED form — strip non-printable bytes + truncate — so a crafted slash can't smuggle a terminal - // control sequence (or a flood). Routed through the notice channel (in-view on a TTY, stderr otherwise). - const safe = line.replace(/[^\x20-\x7e]/g, '?').slice(0, 64); - emitOutput(`unknown command '${safe}'. Available: ${replCommandList()}.`); + await handleSlashCommand(line); return; } store.appendUser(line); diff --git a/apps/cli/src/engine/tool-host/fs.test.ts b/apps/cli/src/engine/tool-host/fs.test.ts index 00d10c43..acd376d7 100644 --- a/apps/cli/src/engine/tool-host/fs.test.ts +++ b/apps/cli/src/engine/tool-host/fs.test.ts @@ -219,6 +219,21 @@ describe('createNodeFsCapability — read (jailed)', () => { }, ); + itPosix( + 'STILL refuses a hard link planted DIRECTLY under .pnpm/ (not the @/node_modules/ shape)', + async () => { + // The exemption trusts ONLY the real store layout `.pnpm/@/node_modules//…`. A hard + // link an attacker drops straight under `.pnpm/` (no nested `@/node_modules`) is NOT exempt — + // real store package files never live there, so this closes the "any .pnpm subtree" over-broad exemption. + await writeFile(join(outside, 'secret.txt'), 'TOP SECRET'); + await mkdir(join(workspace, 'node_modules', '.pnpm'), { recursive: true }); + await link(join(outside, 'secret.txt'), join(workspace, 'node_modules', '.pnpm', 'evil.txt')); + await expect(sandboxed().readFile('node_modules/.pnpm/evil.txt', {})).rejects.toBeInstanceOf( + FsScopeDeniedError, + ); + }, + ); + itPosix( 'EXEMPTS a pnpm-store hard link through the GLOB read path too (readGlob recomputes the exemption)', async () => { diff --git a/apps/cli/src/engine/tool-host/fs.ts b/apps/cli/src/engine/tool-host/fs.ts index 84bac31b..2266f04d 100644 --- a/apps/cli/src/engine/tool-host/fs.ts +++ b/apps/cli/src/engine/tool-host/fs.ts @@ -155,8 +155,19 @@ async function guarded(op: () => Promise): Promise { */ function isPnpmStorePath(absolutePath: string): boolean { const folded = absolutePath.split(sep).map(foldPathComponent); - for (let i = 0; i + 1 < folded.length; i += 1) { - if (folded[i] === 'node_modules' && folded[i + 1] === '.pnpm') return true; + // Match the REAL virtual-store layout ONLY: `…/node_modules/.pnpm/@/node_modules//…`. + // Requiring the `@` segment (it always contains `@`) followed by a nested `node_modules` + // rejects a hard link planted DIRECTLY under `.pnpm/` (e.g. `.pnpm/evil`, or `.pnpm/x/y`), which the looser + // two-segment adjacency check exempted — the store's package files never live outside that nested shape. + for (let i = 0; i + 3 < folded.length; i += 1) { + if ( + folded[i] === 'node_modules' && + folded[i + 1] === '.pnpm' && + (folded[i + 2]?.includes('@') ?? false) && + folded[i + 3] === 'node_modules' + ) { + return true; + } } return false; } diff --git a/packages/core/src/engine/agent-session.ts b/packages/core/src/engine/agent-session.ts index 4a461744..7e75faac 100644 --- a/packages/core/src/engine/agent-session.ts +++ b/packages/core/src/engine/agent-session.ts @@ -354,20 +354,7 @@ export class AgentSession { // Hard turn cap — checked AFTER turn_started (the turn was attempted) but BEFORE any egress: the // blocked turn completes loudly with turn_limit and never calls a provider. - 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, - }, - ); - return; - } + if (this.#completeIfTurnCapReached()) return; this.#messages.push({ role: 'user', content: [{ type: 'text', text }] }); // Snapshot the reseat-less mode policy for the whole turn (ADR-0057): a mid-turn setTurnPolicy applies @@ -432,6 +419,28 @@ export class AgentSession { } } + /** + * The hard turn-cap gate, factored out of {@link sendMessage}. When the session has already spent its + * `#maxTurns`, complete the just-armed turn LOUDLY with `turn_limit` and no egress, release the armed abort + * controller (this path is an early return that skips `sendMessage`'s `finally`), and return `true` so the + * caller bails before touching a provider. Returns `false` (turn may proceed) when under the cap. + */ + #completeIfTurnCapReached(): boolean { + if (this.#turnCount < this.#maxTurns) return false; + 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, + }, + ); + return true; + } + /** * Settle a turn that ended in a throw onto a terminal `session:turn_completed`, by error class. The caller has * already rolled the user message back and ruled out a cancel-during-turn. diff --git a/packages/core/src/tools/bounding.test.ts b/packages/core/src/tools/bounding.test.ts index 62084440..ed9be1aa 100644 --- a/packages/core/src/tools/bounding.test.ts +++ b/packages/core/src/tools/bounding.test.ts @@ -316,6 +316,20 @@ describe('redactSecretShapedText', () => { } }); + it('fully covers ADJACENT / EMBEDDED multi-credential runs (no raw token survives — regression pin)', () => { + // The single-alternation matcher must cover a run of back-to-back / nested credential shapes as ONE + // leftmost-longest span. A per-family MULTI-PASS split leaves a trailing shape EXPOSED here: an earlier + // pass inserts `[redacted]` (a `[`), truncating a later family's greedy match. These two inputs are the + // exact witnesses that split form leaked — they must redact whole (no raw substring left behind). + const jwtWithInnerSk = + 'eyJ' + 'hbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.sig-sk-ABCDEFGHIJKLMNOPqr'; + expect(redactSecretShapedText(jwtWithInnerSk)).toBe('[redacted]'); + const slackWithInnerGlpat = 'xoxb' + '-0000000000-glpat-' + 'A'.repeat(16); + const out = redactSecretShapedText(slackWithInnerGlpat); + expect(out).toBe('[redacted]'); + expect(out).not.toContain('glpat-'); // no fragment of the embedded token survives + }); + it('leaves ordinary text (and short non-secret values) intact', () => { expect(redactSecretShapedText('the quick brown fox')).toBe('the quick brown fox'); expect(redactSecretShapedText('count = 42')).toBe('count = 42'); // not a secret-ish key @@ -341,18 +355,27 @@ describe('redactSecretShapedText', () => { }); describe('redactSecretShapedValue', () => { - it('scrubs string values at any nesting, keeping object keys (header names) intact', () => { + it('scrubs string values at any nesting, keeping normal object keys (header names) intact', () => { const input = { url: 'https://api.example.com/x?api_key=sk-' + 'secret9876543210', headers: { Authorization: 'Bearer tok_abcdef123456', 'X-Trace': 'keep-me' }, body: 'client_secret=shhhhhhhhhh', }; - 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'); + // `redactSecretShapedValue` returns `unknown`; assert the whole shape with `toEqual` — no unsafe cast. This + // also pins that a non-secret header NAME (`Authorization`, `X-Trace`) survives the key scrub untouched. + expect(redactSecretShapedValue(input)).toEqual({ + url: 'https://api.example.com/x?[redacted]', + headers: { Authorization: 'Bearer [redacted]', 'X-Trace': 'keep-me' }, + body: '[redacted]', + }); + }); + + it('scrubs a secret-SHAPED object key too (not just values)', () => { + const secretKey = 'glpat-' + 'A'.repeat(20); // a GitLab-PAT-shaped KEY, built split (Leakwatch policy) + expect(redactSecretShapedValue({ [secretKey]: 'v', normalKey: 'keep' })).toEqual({ + '[redacted]': 'v', + normalKey: 'keep', + }); }); it('is cycle-safe', () => { diff --git a/packages/core/src/tools/bounding.ts b/packages/core/src/tools/bounding.ts index 0c52f2a3..6e5bc424 100644 --- a/packages/core/src/tools/bounding.ts +++ b/packages/core/src/tools/bounding.ts @@ -149,22 +149,35 @@ export function redactSecretShapedText(text: string): string { /-----BEGIN [A-Z0-9 ]{0,40}PRIVATE KEY-----[\s\S]{0,20000}?-----END [A-Z0-9 ]{0,40}PRIVATE KEY-----/g, '[redacted]', ) - // An `Authorization`-style scheme + its token: `Bearer ` / `Basic ` / `Token `. - .replace(/\b(bearer|basic|token)\s+[A-Za-z0-9._~+/-]{8,}={0,2}/gi, '$1 [redacted]') + // An `Authorization`-style scheme + its token: `Bearer ` / `Basic ` / `Token `. The token class + // `[\w.~+/-]` is base64url + padding (`\w` folds `[A-Za-z0-9_]`). + .replace(/\b(bearer|basic|token)\s+[\w.~+/-]{8,}={0,2}/gi, '$1 [redacted]') // A secret-ish key (bounded wrappers keep this ReDoS-safe) + `=`/`:` + its value. The optional `["']?` // BEFORE the separator catches the JSON `"access_token":"…"` shape (an OAuth/egress response body). The // value has two branches: a QUOTED value consumes lazily through the FIRST matching closing quote (so a // passphrase with interior spaces — `"hunter2 dragon"` — is redacted whole, however long, not just up to // the first space), bounded by the line (`[^\r\n]`); an UNQUOTED value runs to the next whitespace/ - // delimiter. Lazy `*?` with a single-class body + a fixed backref is linear (finds the first close) ⇒ ReDoS-safe. + // delimiter. Lazy `*?` with a single-class body + a fixed backref is linear (finds the first close) ⇒ + // ReDoS-safe. Kept a single analyzable LITERAL (not composed via `new RegExp`) so Sonar's static + // super-linear-runtime (S5852) check still covers this security-critical pattern; its keyword-alternation + // breadth is deliberate (and `apikey` is dropped — `api[_-]?key` already subsumes it). .replace( - /\b[\w-]{0,32}(?:password|passwd|secret|token|api[_-]?key|apikey|authorization|access[_-]?key|private[_-]?key|client[_-]?secret)[\w-]{0,16}["']?\s*[=:]\s*(?:(["'])[^\r\n]*?\1|[^\s"',;&]{6,})/gi, + /\b[\w-]{0,32}(?:password|passwd|secret|token|api[_-]?key|authorization|access[_-]?key|private[_-]?key|client[_-]?secret)[\w-]{0,16}["']?\s*[=:]\s*(?:(["'])[^\r\n]*?\1|[^\s"',;&]{6,})/gi, '[redacted]', ) - // Well-known standalone credential shapes (OpenAI, Stripe, AWS incl. STS ASIA/ABIA, GitHub tokens + PAT, - // GitLab PAT, Slack, Google API + OAuth, HuggingFace, npm, JWT). + // 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_[A-Za-z0-9_]{20,}|glpat-[A-Za-z0-9_-]{16,}|xox[baprs]-[A-Za-z0-9-]{10,}|AIza[0-9A-Za-z_-]{30,}|ya29\.[A-Za-z0-9_-]{20,}|hf_[A-Za-z0-9]{20,}|npm_[A-Za-z0-9]{20,}|eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{6,})/g, + /\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, '[redacted]', ) ); @@ -173,8 +186,10 @@ export function redactSecretShapedText(text: string): string { /** * Walk an arbitrary value applying {@link redactSecretShapedText} to every string, leaving structure intact — * the toolInput twin of the summary scrub. Applied to the `agent:tool_call.toolInput` event field so a - * model-set credential in a header VALUE / body / url-query never rides the observability stream (the object - * KEYS — e.g. a header name — are preserved; only string values are scrubbed). Cycle-safe; display-only. + * model-set credential in a header VALUE / body / url-query never rides the observability stream. Object KEYS + * are scrubbed with the SAME detector as values (a normal header name — `Authorization`, `X-Trace` — is not + * secret-SHAPED, so it passes through unchanged; only a model that placed a live-token-shaped string in a KEY + * position is redacted, closing that leak path too). Cycle-safe; display-only. */ export function redactSecretShapedValue(value: unknown): unknown { return redactSecretShapedWalk(value, new WeakSet()); @@ -188,7 +203,11 @@ function redactSecretShapedWalk(value: unknown, seen: WeakSet): unknown if (Array.isArray(value)) return value.map((item) => redactSecretShapedWalk(item, seen)); if (!isPlainObject(value)) return value; // Date/RegExp/Map/… — leave for native handling const out: Record = {}; - for (const [key, item] of Object.entries(value)) out[key] = redactSecretShapedWalk(item, seen); + // Scrub the KEY too (a secret-shaped key is redacted; a normal name is unchanged — see the doc above). A + // display-only field, so a rare collision of two keys both redacting to `[redacted]` losing one is acceptable. + for (const [key, item] of Object.entries(value)) { + out[redactSecretShapedText(key)] = redactSecretShapedWalk(item, seen); + } return out; } diff --git a/packages/shared/src/run-event.test.ts b/packages/shared/src/run-event.test.ts index 14a5a508..bd9885cc 100644 --- a/packages/shared/src/run-event.test.ts +++ b/packages/shared/src/run-event.test.ts @@ -464,6 +464,27 @@ describe('RunEvent union — every variant', () => { } }); + it('REJECTS a blank approval preview for fs_write and process (their target is always resolved)', () => { + // The mirror of the accept test above: fs_write / process ALWAYS resolve their path / command before the + // gate (previewFor sets it from a mandatory policy target), so a blank preview is a host-wiring bug the + // union-level refine rejects — while os / egress (above) legitimately stay blank. + for (const [toolId, action] of [ + ['write_file', 'fs_write'], + ['run_command', 'process'], + ] as const) { + expect( + RunEventSchema.safeParse({ + type: 'agent:approval_requested', + ...env, + nodeId: 'n', + toolId, + action, + preview: {}, + }).success, + ).toBe(false); + } + }); + it('covers exactly the 22 canonical colon-namespaced names, pinned to a literal list', () => { // A hardcoded contract list — independent of RUN_EVENT_TYPES — so the union and the // constant cannot silently drift together. diff --git a/packages/shared/src/run-event.ts b/packages/shared/src/run-event.ts index 8653bfdc..1e451203 100644 --- a/packages/shared/src/run-event.ts +++ b/packages/shared/src/run-event.ts @@ -489,16 +489,17 @@ const RunEventUnionSchema = z.discriminatedUnion('type', [ BudgetPausedEventSchema, ]); +/** The pre-refinement union value — the input every cross-field refinement helper below receives. */ +type RunEventUnion = z.infer; + /** - * The full run-event schema every surface consumes: the discriminated union plus the - * **exactly one of `runId` / `sessionId`** correlation-key invariant (sse-event-schema.md - * §"Correlation key"). Run-only / session-only events satisfy it by construction — a stray - * opposite key is stripped by their `z.object` before this refine runs, so the parsed output - * stays compliant (the deliberate non-strict, forward-compatible posture). The `dualBase` events (the - * four reused agent/cost events plus `agent:approval_requested`) declare both keys as optional, so this - * is where neither/both is rejected. + * The **exactly one of `runId` / `sessionId`** correlation-key invariant (sse-event-schema.md §"Correlation + * key"). Run-only / session-only events satisfy it by construction — a stray opposite key is stripped by their + * `z.object` before this refine runs (the deliberate non-strict, forward-compatible posture). The `dualBase` + * events (the four reused agent/cost events plus `agent:approval_requested`) declare both keys as optional, so + * this is where neither/both is rejected. */ -export const RunEventSchema = RunEventUnionSchema.superRefine((event, ctx) => { +function refineCorrelationKey(event: RunEventUnion, ctx: z.RefinementCtx): void { const hasRunId = 'runId' in event && event.runId !== undefined; const hasSessionId = 'sessionId' in event && event.sessionId !== undefined; if (hasRunId === hasSessionId) { @@ -508,8 +509,10 @@ export const RunEventSchema = RunEventUnionSchema.superRefine((event, ctx) => { path: [hasRunId ? 'sessionId' : 'runId'], }); } - // A gate's on-timeout policy only has meaning when a timeout is configured — refused at the union level - // because a discriminatedUnion member can't carry its own cross-field refinement (see note above). +} + +/** A gate's on-timeout policy only has meaning when a timeout is configured. */ +function refineHumanGateTimeout(event: RunEventUnion, ctx: z.RefinementCtx): void { if ( event.type === 'human_gate:paused' && event.timeoutAction !== undefined && @@ -521,35 +524,38 @@ export const RunEventSchema = RunEventUnionSchema.superRefine((event, ctx) => { path: ['timeoutAction'], }); } - // A pause always has a reason: ≥1 gate OR ≥1 media job (1.AG Section D). The member's field constraints were - // relaxed (a media-only park has 0 gates), so the disjunction is enforced here at the union level (a - // discriminatedUnion member can't carry its own cross-field refinement). The engine never emits a zero-reason - // run:paused; this rejects a malformed one. - if ( - event.type === 'run:paused' && - event.gateIds.length === 0 && - (event.pendingMediaJobNodeIds?.length ?? 0) === 0 - ) { +} + +/** + * The two `run:paused` structural invariants the relaxed member constraints (a media-only park has 0 gates) + * dropped: a pause carries ≥1 reason (a gate OR a media job — 1.AG Section D), and `pendingGateCount` agrees + * with `gateIds.length` (a consumer that reads the aggregate count must not diverge from the list it pairs + * with). The engine never emits a malformed one; this rejects it. + */ +function refineRunPaused(event: RunEventUnion, ctx: z.RefinementCtx): void { + if (event.type !== 'run:paused') return; + if (event.gateIds.length === 0 && (event.pendingMediaJobNodeIds?.length ?? 0) === 0) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'run:paused must carry at least one suspension reason (a gate or a media job)', path: ['gateIds'], }); } - // `pendingGateCount` is the aggregate count of `gateIds` — relaxing both fields to `min(0)` (for a media-only - // park) dropped the structural guarantee that they agree, so re-assert it here. A consumer that reads - // `pendingGateCount` as the authoritative gate count must not diverge from the `gateIds` list it pairs with. - if (event.type === 'run:paused' && event.pendingGateCount !== event.gateIds.length) { + if (event.pendingGateCount !== event.gateIds.length) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'run:paused pendingGateCount must equal gateIds.length', path: ['pendingGateCount'], }); } - // `deadlineAt = startedAt + media_job_deadline_ms` by construction, so deadlineAt < startedAt is a malformed - // durable event that would invert the resume `now > deadlineAt` short-circuit. Compare via Date.parse (an - // offset is allowed, so never lexically). Enforced at the union level — a member-level `.refine()` would make - // a ZodEffects and break the discriminatedUnion (same reason the run:paused cross-field checks live here). +} + +/** + * `deadlineAt = startedAt + media_job_deadline_ms` by construction, so deadlineAt < startedAt is a malformed + * durable event that would invert the resume `now > deadlineAt` short-circuit. Compare via Date.parse (an + * offset is allowed, so never lexically). + */ +function refineMediaJobDeadline(event: RunEventUnion, ctx: z.RefinementCtx): void { if ( event.type === 'media_job:submitted' && Date.parse(event.deadlineAt) < Date.parse(event.startedAt) @@ -560,22 +566,55 @@ export const RunEventSchema = RunEventUnionSchema.superRefine((event, ctx) => { path: ['deadlineAt'], }); } - // Bind an approval preview to its action class (ADR-0057 EA5): a preview may carry ONLY the field its action - // produces (see {@link APPROVAL_PREVIEW_FIELD}) — an `egress` approval must never surface a `path`, etc. This - // rejects an action drift a host-wiring bug could add. Enforced here (not on the member) because the member is - // a discriminatedUnion option and a member-level `.refine()` would make it a ZodEffects. - if (event.type === 'agent:approval_requested') { - const allowed = APPROVAL_PREVIEW_FIELD[event.action]; - for (const key of ['path', 'command', 'host'] as const) { - if (key !== allowed && event.preview[key] !== undefined) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `preview.${key} is not valid for a ${event.action} approval`, - path: ['preview', key], - }); - } +} + +/** + * Bind an approval preview to its action class (ADR-0057 EA5). Two rules: + * 1. DRIFT — a preview may carry ONLY the field its action produces (see {@link APPROVAL_PREVIEW_FIELD}); an + * `egress` approval must never surface a `path`, etc. (`.strict()` on the preview already bars an UNKNOWN + * key; this bars a KNOWN-but-wrong-for-the-action one.) + * 2. MISSING — `fs_write` and `process` ALWAYS resolve their target before the gate (the registry's + * `previewFor` sets `path`/`command` from a mandatory policy target), so a BLANK preview there is a + * host-wiring bug — reject it. `egress` is exempt (its `host` is legitimately absent for `mcp_call` / + * `web_search`, a valid blank preview), and `os` carries no target field at all. + */ +function refineApprovalPreview(event: RunEventUnion, ctx: z.RefinementCtx): void { + if (event.type !== 'agent:approval_requested') return; + const allowed = APPROVAL_PREVIEW_FIELD[event.action]; + for (const key of ['path', 'command', 'host'] as const) { + if (key !== allowed && event.preview[key] !== undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `preview.${key} is not valid for a ${event.action} approval`, + path: ['preview', key], + }); } } + if ( + (event.action === 'fs_write' || event.action === 'process') && + allowed !== undefined && + event.preview[allowed] === undefined + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `a ${event.action} approval requires preview.${allowed}`, + path: ['preview', allowed], + }); + } +} + +/** + * The full run-event schema every surface consumes: the discriminated union plus the cross-field invariants a + * `discriminatedUnion` member cannot carry (a member-level `.refine()` would make it a ZodEffects and break the + * union). Each concern lives in its own named helper above; this composes them so the schema stays a thin, + * readable pipeline. + */ +export const RunEventSchema = RunEventUnionSchema.superRefine((event, ctx) => { + refineCorrelationKey(event, ctx); + refineHumanGateTimeout(event, ctx); + refineRunPaused(event, ctx); + refineMediaJobDeadline(event, ctx); + refineApprovalPreview(event, ctx); }); export type RunEvent = z.infer; From 520eb1781eee11883a4914ab7b5cc58c19da7146 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Thu, 2 Jul 2026 19:30:26 +0300 Subject: [PATCH 43/45] feat(cli): recover from host tool failures in chat + surface a tool_failed hint (A+B) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/cli/src/chat/session-host.ts | 6 +++ .../src/render/tui/chat-projection.test.ts | 15 ++++++ apps/cli/src/render/tui/chat-projection.ts | 9 ++++ packages/core/src/engine/agent-turn.test.ts | 47 ++++++++++++++++++- packages/core/src/engine/agent-turn.ts | 31 +++++++++--- 5 files changed, 101 insertions(+), 7 deletions(-) diff --git a/apps/cli/src/chat/session-host.ts b/apps/cli/src/chat/session-host.ts index 0cd6343e..df6e1439 100644 --- a/apps/cli/src/chat/session-host.ts +++ b/apps/cli/src/chat/session-host.ts @@ -2,6 +2,7 @@ import { AgentSession, BUILTIN_TOOLS, BudgetGovernor, + DEFAULT_AGENT_TURN_LIMITS, RunEventBus, createSessionEventSink, createSessionHandle, @@ -206,6 +207,11 @@ function buildSessionRuntime( // "empty/absent ⇒ run_command disabled"). Wiring it now means a 2.5.E/ADR-0057 per-mode allowlist flows // through automatically rather than being silently dropped by reading only the factory's `host`. toolPolicy: factoryEnv.policy, + // Interactive-surface turn bounds: recover from a host tool EXECUTION failure (a file-not-found read, a + // transient egress error) by feeding it back to the model so it can adapt / explain, instead of ending the + // turn with a bare `tool_failed` (ADR-0057 UX). A WORKFLOW node keeps the default (fail-fast) — this opt-in + // rides ONLY the AgentSession chat/Home/one-shot surfaces, never the run-engine's AgentRunner. + limits: { ...DEFAULT_AGENT_TURN_LIMITS, recoverToolFailures: true }, ...(opts.chat.maxTurns === undefined ? {} : { maxTurns: opts.chat.maxTurns }), ...(governor === undefined ? {} diff --git a/apps/cli/src/render/tui/chat-projection.test.ts b/apps/cli/src/render/tui/chat-projection.test.ts index 81481b9b..8b436702 100644 --- a/apps/cli/src/render/tui/chat-projection.test.ts +++ b/apps/cli/src/render/tui/chat-projection.test.ts @@ -65,6 +65,21 @@ describe('chat-projection', () => { expect(line).not.toContain('model-derived context'); }); + it('shows a STATIC actionable hint for a tool_failed turn, never echoing the (context-carrying) message', () => { + // ADR-0057 (A): a tool_failed message MAY carry model/MCP-server context (so it stays OUT of + // SAFE_MESSAGE_CODES), but a bare `error: tool_failed` is unhelpful — render a host-authored STATIC hint + // at the #1 real cause (a path outside the session workspace) WITHOUT echoing errorMessage. + const line = formatTurnSummary({ + stopReason: 'error', + tokensUsed: { input: 0, output: 0 }, + errorCode: 'tool_failed', + errorMessage: 'some model/MCP-derived context that must not be shown', + }); + expect(line).toContain('error: tool_failed —'); // an actionable hint, not the bare code + expect(line).toContain("outside this session's workspace"); + expect(line).not.toContain('model/MCP-derived context'); // the raw message is NOT echoed (F4 constraint) + }); + it('terminal-sanitizes the rendered reason (strips ANSI/OSC/control bytes) and omits an empty reason', () => { const ESC = String.fromCharCode(0x1b); const BEL = String.fromCharCode(0x07); diff --git a/apps/cli/src/render/tui/chat-projection.ts b/apps/cli/src/render/tui/chat-projection.ts index e1be9d99..cbb9169a 100644 --- a/apps/cli/src/render/tui/chat-projection.ts +++ b/apps/cli/src/render/tui/chat-projection.ts @@ -85,6 +85,15 @@ export function formatTurnSummary(summary: TurnSummary): string { // final-summary.ts (which renders errorMessage for every code), the chat path restricts it to the vetted // approval-floor codes, since a chat turn is interactive/lower-trust. head = `error: ${summary.errorCode} — ${reason}`; + } else if (summary.errorCode === 'tool_failed') { + // A tool call ended the turn (a repeated failure spent the correction budget, or a non-recoverable tool + // error). On the chat surface a file-not-found is usually fed back to the model (ADR-0057 recoverToolFailures) + // so it seldom reaches here — but when the turn DOES die on tool_failed we owe the user more than a bare code. + // We must NOT echo `errorMessage` (a tool_failed message MAY carry model/prompt/MCP-server context — the very + // reason it is outside SAFE_MESSAGE_CODES); instead a STATIC, host-authored hint at the most common real + // cause: a path outside the session workspace (the #1 launch-cwd gotcha) or an unavailable target. + head = + "error: tool_failed — a tool call failed (a path may be outside this session's workspace, or the target was unavailable)"; } else { head = `error: ${summary.errorCode}`; } diff --git a/packages/core/src/engine/agent-turn.test.ts b/packages/core/src/engine/agent-turn.test.ts index ec26685e..61f11218 100644 --- a/packages/core/src/engine/agent-turn.test.ts +++ b/packages/core/src/engine/agent-turn.test.ts @@ -600,7 +600,9 @@ describe('runAgentTurn — tool loop', () => { } }); - it('maps ToolExecutionError to tool_failed (retryable — the 1.S node-retry signal)', async () => { + it('maps ToolExecutionError to tool_failed (retryable — the 1.S node-retry signal) with recoverToolFailures ABSENT (the workflow default)', async () => { + // The default (every WORKFLOW node): a host execution_failed ends the turn loudly so node-retry / run-failure + // engages — an unattended run must NOT silently recover. The chat-surface opt-in is pinned by the next test. const registry = stubRegistry(() => { throw new ToolExecutionError('echo', 'disk full'); }); @@ -611,6 +613,49 @@ describe('runAgentTurn — tool loop', () => { }); }); + it('recovers from a host execution_failed by feeding it back when recoverToolFailures is set (ADR-0057 chat surface)', async () => { + // The interactive chat surface opts in (session-host wires recoverToolFailures:true) so a file-not-found / + // transient host failure is fed to the model as an isError result — it adapts / explains instead of the turn + // dying on a bare tool_failed. Mirrors the correctable-recovery test but for a HOST ToolExecutionError. + let dispatched = 0; + const registry = stubRegistry((call) => { + dispatched += 1; + if (dispatched === 1) throw new ToolExecutionError('echo', 'the filesystem operation failed'); + const result: ToolResultPart = { type: 'tool_result', toolCallId: call.id, result: 'OK' }; + return { + output: 'OK', + toolResult: markUntrusted(result), + truncated: false, + events: { + call: { toolId: call.name, toolInput: {} }, + result: { toolId: call.name, success: true, outputSummary: 'OK' }, + }, + }; + }); + const provider = scriptedProvider('anthropic', [ + [ + { type: 'tool_call_start', id: 'c1', name: 'echo' }, + { type: 'tool_call_end', id: 'c1' }, + STOP('tool_use'), + ], + [ + { type: 'tool_call_start', id: 'c2', name: 'echo' }, + { type: 'tool_call_end', id: 'c2' }, + STOP('tool_use'), + ], + [{ type: 'text_delta', text: 'recovered' }, STOP()], + ]); + const params = baseParams(provider, { + registry, + limits: { ...DEFAULT_AGENT_TURN_LIMITS, recoverToolFailures: true }, + }); + const result = await runAgentTurn(params); + expect(result.text).toBe('recovered'); // the turn continued past the host failure + expect( + eventsOf(params).find((e) => e.type === 'agent:tool_result' && !e.success), + ).toBeDefined(); + }); + it('attaches the turn’s REAL accumulated usage to a failed turn (EA2)', async () => { // The tool-use turn settled an attempt (STOP carries usage 10/5) BEFORE the tool throws, so the // accumulated usage is non-zero — the wrapper attaches it to the thrown AgentTurnError rather than diff --git a/packages/core/src/engine/agent-turn.ts b/packages/core/src/engine/agent-turn.ts index c62922f3..a5e7e1ed 100644 --- a/packages/core/src/engine/agent-turn.ts +++ b/packages/core/src/engine/agent-turn.ts @@ -61,8 +61,9 @@ import type { NodeStreamEvent } from './node-executor.js'; * The two bounds are **not multiplicative** — `maxToolTurns` is the worst-case **egress ceiling** (the * tool loop engages a provider at most `maxToolTurns + 1` times before the guard fails the turn with * `turn_limit`), while `maxToolCorrections` is a **monotonic sub-budget** *within* that loop: a - * model-correctable tool error (`unknown_tool` / `invalid_args`) increments it and, once exceeded, ends - * the turn EARLY with `tool_failed`. A genuine (non-correctable) tool round never resets it, so + * recoverable tool error (`unknown_tool` / `invalid_args`, plus a host `execution_failed` when + * {@link AgentTurnLimits.recoverToolFailures} is set) increments it and, once exceeded, ends the turn EARLY + * with `tool_failed`. A genuine (non-recoverable) tool round never resets it, so * corrections accumulate across interleaved genuine rounds. Net worst-case egress is `maxToolTurns + 1` * provider calls regardless of `maxToolCorrections` — the correction budget can only *shorten* a turn, * never extend its egress (so the DoS bound is the turn budget alone, not the product of the two). @@ -72,6 +73,16 @@ export interface AgentTurnLimits { readonly maxToolTurns: number; /** Max model-correctable tool-error rounds (`unknown_tool` / `invalid_args`) before escalating. */ readonly maxToolCorrections: number; + /** + * When `true`, a HOST tool EXECUTION failure (`execution_failed` — a file-not-found read, a transient egress + * error) is fed back to the model as a correctable `isError` tool result (so it can adapt — try another path, + * or tell the user) instead of ENDING the turn with `tool_failed`. It shares the `maxToolCorrections` budget, + * so a model looping on a failing tool is still bounded. **Opt-in for the INTERACTIVE chat surface only** + * (`relavium chat` / Home / one-shot `agent run`). Absent/`false` (the default, and every WORKFLOW node) keeps + * the fail-fast behavior an unattended run relies on — a genuine host failure ends the turn loudly and the + * node-retry / run-failure path engages, rather than the model silently papering over it. + */ + readonly recoverToolFailures?: boolean; } /** The run-default loop bounds (1.O). 1.V overrides these via the same `limits` param — no restructuring. */ @@ -220,9 +231,17 @@ export function codeForLlmError(error: LlmError): ErrorCode { } } -/** A tool throw the model can correct by seeing an `isError` tool result and trying again. */ -function isModelCorrectable(err: ToolDispatchError): boolean { - return err.code === 'unknown_tool' || err.code === 'invalid_args'; +/** + * A tool throw the turn recovers by feeding the model an `isError` tool result (which increments the shared + * `maxToolCorrections` budget) instead of ending the turn. Always the model's own syntactic mistakes + * (`unknown_tool` / `invalid_args`); PLUS a host `execution_failed` (a file-not-found, a transient egress + * error) ONLY when `limits.recoverToolFailures` is set (the interactive chat surface — see + * {@link AgentTurnLimits.recoverToolFailures}). A `tool_denied` / `tool_unavailable` / `cancelled` is NEVER + * recoverable here (a security/cancel boundary — it stays fatal so it never loops). + */ +function isRecoverableToolError(err: ToolDispatchError, limits: AgentTurnLimits): boolean { + if (err.code === 'unknown_tool' || err.code === 'invalid_args') return true; + return err.code === 'execution_failed' && limits.recoverToolFailures === true; } /** Map a non-correctable tool throw to the node `ErrorCode` (cancel wins; a denial is fatal). */ @@ -521,7 +540,7 @@ async function dispatchToolCalls( toolInput: {}, attemptNumber, }); - if (err instanceof ToolDispatchError && isModelCorrectable(err)) { + if (err instanceof ToolDispatchError && isRecoverableToolError(err, params.limits)) { correctable = true; results.push({ role: 'tool', From 00d8d361c512ee61b134295c45bb78ae09170299 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Thu, 2 Jul 2026 19:50:55 +0300 Subject: [PATCH 44/45] =?UTF-8?q?fix(cli):=20address=20PR=20#63=20review?= =?UTF-8?q?=20batch=20=E2=80=94=206=20verified=20findings=20(inline=20+=20?= =?UTF-8?q?nitpick)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/cli/src/chat/persister.test.ts | 3 +++ apps/cli/src/chat/persister.ts | 10 +++++++--- apps/cli/src/commands/agent-run.test.ts | 10 +++++++--- apps/cli/src/commands/chat.ts | 4 +++- apps/cli/src/engine/tool-host/fs.test.ts | 18 ++++++++++++++++++ apps/cli/src/engine/tool-host/fs.ts | 7 ++++++- apps/cli/src/render/tui/chat-ink.tsx | 24 ++++++++++++++++++------ docs/roadmap/current.md | 2 +- 8 files changed, 63 insertions(+), 15 deletions(-) diff --git a/apps/cli/src/chat/persister.test.ts b/apps/cli/src/chat/persister.test.ts index 07538059..55025e4e 100644 --- a/apps/cli/src/chat/persister.test.ts +++ b/apps/cli/src/chat/persister.test.ts @@ -256,6 +256,9 @@ describe('createSessionPersister', () => { expect(full?.messages).toHaveLength(2); // ONLY the kept turn's user + assistant expect(textOf(full?.messages[0]?.content ?? [])).toBe('kept'); // user expect(textOf(full?.messages[1]?.content ?? [])).toBe('kept-reply'); // assistant (the first script) + // The title is derived from the first COMPLETED exchange, never the aborted prompt ('abort me') whose rows + // were rolled back — so a session's label always has a transcript behind it. + expect(full?.session.title).toBe('kept'); }); it('marks the session ended on cancel (its sole terminal), leaving it resumable', async () => { diff --git a/apps/cli/src/chat/persister.ts b/apps/cli/src/chat/persister.ts index 6a91ea55..29317fd2 100644 --- a/apps/cli/src/chat/persister.ts +++ b/apps/cli/src/chat/persister.ts @@ -136,6 +136,10 @@ export function createSessionPersister(deps: SessionPersisterDeps): SessionPersi event.stopReason !== 'aborted' && pendingUserText !== undefined ) { + // Derive the title HERE (not in beginUserTurn) — from the FIRST user message of a COMPLETED exchange of + // a titleless session, so an aborted/errored earlier turn never labels the row. A blank message yields + // undefined, so the next non-blank completed message becomes the title; a resumed session keeps its own. + title ??= deriveSessionTitle(pendingUserText); appendText('user', pendingUserText); if (assistantText.length > 0) appendText('assistant', assistantText); totalInputTokens += event.tokensUsed.input; @@ -179,9 +183,9 @@ export function createSessionPersister(deps: SessionPersisterDeps): SessionPersi unsubscribe = deps.handle.subscribe(onEvent); }, 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); + // Only STAGE the user text; the title is derived when (and if) the exchange COMPLETES + persists (see + // `session:turn_completed`). Deriving it here would stamp the session row with a title from an ABORTED / + // errored first turn whose message rows are rolled back — a label with no transcript behind it. pendingUserText = text; assistantText = ''; }, diff --git a/apps/cli/src/commands/agent-run.test.ts b/apps/cli/src/commands/agent-run.test.ts index 615ab14c..b8d64407 100644 --- a/apps/cli/src/commands/agent-run.test.ts +++ b/apps/cli/src/commands/agent-run.test.ts @@ -17,6 +17,11 @@ import type { GlobalOptions } from '../process/options.js'; import { captureIo, parseNdjson } from '../test-support.js'; import { agentRunCommand, readAllStdin, type AgentRunCommandDeps } from './agent-run.js'; +/** A tiny structural guard so an NDJSON event's nested `error.code` is read without an unsafe `as` cast. */ +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + const AGENT_YAML = 'id: coder\nprovider: anthropic\nmodel: claude-sonnet-4-6\nsystem_prompt: You are a coder.\ntools:\n - read_file'; const CASSETTE = { @@ -111,9 +116,8 @@ describe('agentRunCommand (2.Q)', () => { }); await agentRunCommand({ agent: join(cwd, 'writer.agent.yaml'), input: [] }, d); const completed = parseNdjson(out()).find((e) => e['type'] === 'session:turn_completed'); - expect((completed as { error?: { code?: string } } | undefined)?.error?.code).toBe( - 'tool_denied', - ); + const error = isRecord(completed) ? completed['error'] : undefined; + expect(isRecord(error) ? error['code'] : undefined).toBe('tool_denied'); expect(existsSync(join(cwd, 'pwned.txt'))).toBe(false); // denied BEFORE any write — no file on disk }); diff --git a/apps/cli/src/commands/chat.ts b/apps/cli/src/commands/chat.ts index e724e5fe..8f5f5151 100644 --- a/apps/cli/src/commands/chat.ts +++ b/apps/cli/src/commands/chat.ts @@ -457,7 +457,9 @@ export function createChatModeControl( }); applyChatMode(modeEnv, store.getSnapshot().mode); return { - onAbort: () => built.session.abort(), + onAbort: () => { + built.session.abort(); // void-returning: block body so it never forwards abort()'s return value + }, onModeChange: (mode) => { store.setMode(mode); applyChatMode(modeEnv, mode); diff --git a/apps/cli/src/engine/tool-host/fs.test.ts b/apps/cli/src/engine/tool-host/fs.test.ts index acd376d7..8760f8c3 100644 --- a/apps/cli/src/engine/tool-host/fs.test.ts +++ b/apps/cli/src/engine/tool-host/fs.test.ts @@ -784,6 +784,24 @@ describe('createNodeFsCapability — sensitive-read floor (credential/secret sto expect(names).toContain('top.txt'); expect(names.some((n) => n.includes('.ssh'))).toBe(false); // neither the dir nor its contents are listed }); + + itPosix( + 'list_directory (recursive) SKIPS a symlink whose REALPATH is a sensitive store (resolved, not lexical)', + async () => { + // A symlink with an INNOCUOUS name (`notes`) pointing into a credential store: its lexical path is harmless, + // so a bare-lexical sensitive check would LIST it. The realpath-before-floor (mirroring collectGlobFiles) + // resolves it to `.ssh/` and skips it, so a recursive listing never surfaces a symlinked path to a secret. + await mkdir(join(workspace, '.ssh'), { recursive: true }); + await writeFile(join(workspace, '.ssh', 'id_rsa'), 'KEY'); + await symlink(join(workspace, '.ssh'), join(workspace, 'notes'), 'dir'); // harmless name → sensitive target + await writeFile(join(workspace, 'keep.txt'), 'x'); + const { entries } = await sandboxed().listDirectory('.', { recursive: true }); + const names = entries.map((e) => e.name); + expect(names).toContain('keep.txt'); + expect(names).not.toContain('notes'); // the symlink-to-.ssh is filtered by the RESOLVED sensitive check + expect(names.some((n) => n.includes('.ssh'))).toBe(false); + }, + ); }); describe('createNodeFsCapability — read-only profile (2.5.A chat)', () => { diff --git a/apps/cli/src/engine/tool-host/fs.ts b/apps/cli/src/engine/tool-host/fs.ts index 2266f04d..117af347 100644 --- a/apps/cli/src/engine/tool-host/fs.ts +++ b/apps/cli/src/engine/tool-host/fs.ts @@ -673,8 +673,13 @@ async function listOne( const entries: DirEntry[] = []; 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 if (matcher !== undefined && !matcher(rel)) return true; + // Sensitive-read floor on the RESOLVED target (mirrors collectGlobFiles): `walk` realpaths the parent DIR + // but not the entry, so a bare lexical check would let a symlink named innocuously slip a nested + // .ssh/.relavium/credential store into the listing. Fall back to the lexical path when realpath fails (a + // broken/vanished link) so a sensitively-NAMED entry is still filtered. The DISPLAYED name (`rel`) is unchanged. + const realResolved = await realpath(real).catch(() => undefined); + if (isSensitiveReadPath(realResolved ?? real)) return true; const info = await lstat(real).catch(() => undefined); if (info === undefined) return true; // a vanished/inaccessible entry is skipped, never fatal entries.push({ diff --git a/apps/cli/src/render/tui/chat-ink.tsx b/apps/cli/src/render/tui/chat-ink.tsx index d60bc603..c3d509a1 100644 --- a/apps/cli/src/render/tui/chat-ink.tsx +++ b/apps/cli/src/render/tui/chat-ink.tsx @@ -79,8 +79,11 @@ interface ChatAppProps { readonly onExit: () => void; /** Called when a turn rejects UNEXPECTEDLY (a re-thrown turn-core bug) — the driver tears down + propagates. */ readonly onError: (err: unknown) => void; - /** Mid-turn abort (EA7) — Esc aborts the in-flight turn, keeping the session alive. */ - readonly onAbort: () => void; + /** Mid-turn abort (EA7) — Esc aborts the in-flight turn, keeping the session alive. OPTIONAL: a driver/test + * wired without it degrades gracefully (Esc at a pending approval rejects it directly, so it is never a dead + * key — parity with home-controller.ts), rather than a no-op that would hang the decision. `| undefined` so + * the passthrough at the createElement site (exactOptionalPropertyTypes) can forward an absent `ctx.onAbort`. */ + readonly onAbort?: (() => void) | undefined; /** Switch the chat mode (Shift+Tab cycle) — re-applies the turn policy on the same session (ADR-0057). */ readonly onModeChange: (mode: ChatMode) => void; } @@ -291,7 +294,15 @@ export function ChatApp(props: Readonly): ReactElement { props.onModeChange(nextMode(props.store.getSnapshot().mode)); return; case 'abort': - props.onAbort(); // Esc — mid-turn abort (keeps the session; distinct from Ctrl-C /cancel) + // Esc — mid-turn abort (keeps the session; distinct from Ctrl-C /cancel). `onAbort` aborts the turn, + // whose signal also rejects any in-flight approval. If `onAbort` is absent (a driver/test wired without + // it), a PENDING approval would otherwise hang — reject it directly so Esc is never a dead key at a + // decision (parity with home-controller.ts's handleChatKey). + if (props.onAbort !== undefined) { + props.onAbort(); + } else if (props.store.getSnapshot().approval !== undefined) { + props.store.answerApproval({ outcome: 'reject' }); + } return; case 'approve': props.store.answerApproval({ outcome: 'approve', scope: action.scope }); @@ -396,9 +407,10 @@ export function driveInk(ctx: ChatDriveContext): Promise { // An unexpected turn-core throw rejects `exited` → the finally tears down + the rejection propagates out // of the command (mapped to exit 1), matching the plain driver where the throw escapes the for-await loop. onError: (err) => rejectExit(err), - // ADR-0057 mode/abort wiring — the REPL loop always supplies these; default to a no-op so a driver - // wired without them (a test harness) degrades to a fixed mode rather than crashing on a keypress. - onAbort: ctx.onAbort ?? ((): void => undefined), + // ADR-0057 mode/abort wiring — the REPL loop always supplies these. onModeChange defaults to a no-op so a + // driver wired without it degrades to a fixed mode; onAbort is passed through AS-IS (optional) so the + // 'abort' handler can reject a pending approval when it is absent (never a dead Esc — see ChatApp). + onAbort: ctx.onAbort, onModeChange: ctx.onModeChange ?? ((): void => undefined), }), { diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md index 10b4bc34..ace74c41 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -2,7 +2,7 @@ > Status: Living -> Last updated: 2026-06-29 +> Last updated: 2026-06-30 - **Related**: [README.md](README.md), [phases/phase-2.5-cli-consolidation.md](phases/phase-2.5-cli-consolidation.md), [phases/phase-2-cli.md](phases/phase-2-cli.md), [deferred-tasks.md](deferred-tasks.md), [../project-structure.md](../project-structure.md), [../tech-stack.md](../tech-stack.md) From f8455c5496e62d142ed1037aa9acdd4a5bdd838e Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Thu, 2 Jul 2026 20:28:28 +0300 Subject: [PATCH 45/45] fix(core): scope tool-failure recovery to IDEMPOTENT tools via an error flag (ADR-0057) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- packages/core/src/engine/agent-turn.test.ts | 31 +++++++++++++++++---- packages/core/src/engine/agent-turn.ts | 16 +++++++---- packages/core/src/tools/errors.ts | 15 ++++++++-- packages/core/src/tools/registry.test.ts | 31 ++++++++++++++++++++- packages/core/src/tools/registry.ts | 8 +++++- 5 files changed, 86 insertions(+), 15 deletions(-) diff --git a/packages/core/src/engine/agent-turn.test.ts b/packages/core/src/engine/agent-turn.test.ts index 61f11218..24ac8625 100644 --- a/packages/core/src/engine/agent-turn.test.ts +++ b/packages/core/src/engine/agent-turn.test.ts @@ -613,14 +613,17 @@ describe('runAgentTurn — tool loop', () => { }); }); - it('recovers from a host execution_failed by feeding it back when recoverToolFailures is set (ADR-0057 chat surface)', async () => { - // The interactive chat surface opts in (session-host wires recoverToolFailures:true) so a file-not-found / - // transient host failure is fed to the model as an isError result — it adapts / explains instead of the turn - // dying on a bare tool_failed. Mirrors the correctable-recovery test but for a HOST ToolExecutionError. + it('recovers a RECOVERABLE host execution_failed (feeds it back) when recoverToolFailures is set (ADR-0057)', async () => { + // The interactive chat surface opts in (recoverToolFailures:true); a host failure the registry stamped + // `recoverable` (an IDEMPOTENT read — e.g. a file-not-found) is fed to the model as an isError result so it + // adapts / explains instead of the turn dying on a bare tool_failed. let dispatched = 0; const registry = stubRegistry((call) => { dispatched += 1; - if (dispatched === 1) throw new ToolExecutionError('echo', 'the filesystem operation failed'); + if (dispatched === 1) + throw new ToolExecutionError('echo', 'the filesystem operation failed', undefined, { + recoverable: true, + }); const result: ToolResultPart = { type: 'tool_result', toolCallId: call.id, result: 'OK' }; return { output: 'OK', @@ -656,6 +659,24 @@ describe('runAgentTurn — tool loop', () => { ).toBeDefined(); }); + it('does NOT recover a NON-recoverable execution_failed even with recoverToolFailures set (a governed/side-effecting tool)', async () => { + // The tightening: a failure the registry did NOT stamp recoverable (a governed, non-idempotent tool — a + // half-run command, a POST that may have landed) ends the turn LOUDLY even on the chat surface, so the + // model never re-attempts a side effect. `recoverable` defaults to false. + const registry = stubRegistry(() => { + throw new ToolExecutionError('echo', 'the network request failed'); // recoverable defaults to false + }); + const provider = scriptedProvider('anthropic', [toolUseTurn('c1')]); + await expect( + runAgentTurn( + baseParams(provider, { + registry, + limits: { ...DEFAULT_AGENT_TURN_LIMITS, recoverToolFailures: true }, + }), + ), + ).rejects.toMatchObject({ code: 'tool_failed', retryable: true }); + }); + it('attaches the turn’s REAL accumulated usage to a failed turn (EA2)', async () => { // The tool-use turn settled an attempt (STOP carries usage 10/5) BEFORE the tool throws, so the // accumulated usage is non-zero — the wrapper attaches it to the thrown AgentTurnError rather than diff --git a/packages/core/src/engine/agent-turn.ts b/packages/core/src/engine/agent-turn.ts index a5e7e1ed..665bb7e0 100644 --- a/packages/core/src/engine/agent-turn.ts +++ b/packages/core/src/engine/agent-turn.ts @@ -49,7 +49,7 @@ import { type ToolDef as LlmToolDef, } from '@relavium/llm'; -import { ToolDispatchError } from '../tools/errors.js'; +import { ToolDispatchError, ToolExecutionError } from '../tools/errors.js'; import type { ToolCallPart, ToolDispatchContext, ToolRegistry } from '../tools/types.js'; import { unwrapUntrusted } from '../tools/untrusted.js'; import { BudgetExceededError, BudgetPauseError } from './budget-governor.js'; @@ -234,14 +234,18 @@ export function codeForLlmError(error: LlmError): ErrorCode { /** * A tool throw the turn recovers by feeding the model an `isError` tool result (which increments the shared * `maxToolCorrections` budget) instead of ending the turn. Always the model's own syntactic mistakes - * (`unknown_tool` / `invalid_args`); PLUS a host `execution_failed` (a file-not-found, a transient egress - * error) ONLY when `limits.recoverToolFailures` is set (the interactive chat surface — see - * {@link AgentTurnLimits.recoverToolFailures}). A `tool_denied` / `tool_unavailable` / `cancelled` is NEVER - * recoverable here (a security/cancel boundary — it stays fatal so it never loops). + * (`unknown_tool` / `invalid_args`); PLUS a host execution failure ONLY when BOTH `limits.recoverToolFailures` + * is set (the interactive chat surface — see {@link AgentTurnLimits.recoverToolFailures}) AND the error is + * flagged {@link ToolExecutionError.recoverable} — i.e. an IDEMPOTENT tool (a read), stamped by the registry + * from `governedAction`. A governed / side-effecting failure (a half-run command, a POST that may have landed) + * is NOT recoverable, so it ends the turn rather than risk a re-execution. A `tool_denied` / `tool_unavailable` + * / `cancelled` is NEVER recoverable here (a security / cancel boundary — it stays fatal so it never loops). */ function isRecoverableToolError(err: ToolDispatchError, limits: AgentTurnLimits): boolean { if (err.code === 'unknown_tool' || err.code === 'invalid_args') return true; - return err.code === 'execution_failed' && limits.recoverToolFailures === true; + return ( + limits.recoverToolFailures === true && err instanceof ToolExecutionError && err.recoverable + ); } /** Map a non-correctable tool throw to the node `ErrorCode` (cancel wins; a denial is fatal). */ diff --git a/packages/core/src/tools/errors.ts b/packages/core/src/tools/errors.ts index 0f2ebd54..b8214b8a 100644 --- a/packages/core/src/tools/errors.ts +++ b/packages/core/src/tools/errors.ts @@ -149,10 +149,21 @@ export class ToolExecutionError extends ToolDispatchError { readonly code = 'execution_failed'; readonly runErrorCode: ErrorCode = 'tool_failed'; readonly retryable = true; - - constructor(toolId: ToolId, message: string, cause?: unknown) { + /** + * Whether this failure is safe to FEED BACK to the model for a within-turn retry (ADR-0057 + * `recoverToolFailures`, the interactive chat surface) — vs ending the turn. `retryable` (above) means a + * fresh node-retry may re-run the whole node; `recoverable` is the STRICTER "the model may re-attempt this + * specific call in-turn without a side-effect hazard". True ONLY for an IDEMPOTENT tool (a read — no + * `fs_write`/`egress`/`process`/`os` action), stamped by the registry from {@link governedAction}. A + * governed / side-effecting tool defaults to **false**, so a non-idempotent failure (a half-run command, a + * POST that may have reached the server) ends the turn rather than risking a re-execution. + */ + readonly recoverable: boolean; + + constructor(toolId: ToolId, message: string, cause?: unknown, opts?: { recoverable?: boolean }) { super(message, toolId, cause); this.name = 'ToolExecutionError'; + this.recoverable = opts?.recoverable ?? false; } } diff --git a/packages/core/src/tools/registry.test.ts b/packages/core/src/tools/registry.test.ts index eed68976..d67324f6 100644 --- a/packages/core/src/tools/registry.test.ts +++ b/packages/core/src/tools/registry.test.ts @@ -958,7 +958,7 @@ describe('ToolRegistry — host capability, execution, cancellation', () => { expect(err.runErrorCode).toBe('tool_unavailable'); // EA1 (ADR-0055) — actionable, never a bare `internal` }); - it('wraps a host throw as a retryable execution error', async () => { + it('wraps a host throw as a retryable execution error, flagged RECOVERABLE for an idempotent read (ADR-0057)', async () => { const host = stubHost({ fs: fsWith(() => Promise.reject(new Error('disk gone'))) }); const err = await rejectsWith( createToolRegistry({ tools: BUILTIN_TOOLS, host }).dispatch( @@ -969,6 +969,35 @@ describe('ToolRegistry — host capability, execution, cancellation', () => { expect(err).toBeInstanceOf(ToolExecutionError); expect(err.runErrorCode).toBe('tool_failed'); expect(err.retryable).toBe(true); + // read_file is NOT a governed action (an idempotent read), so the failure is safe to feed back to the + // model for a within-turn retry on the chat surface. + expect(err.recoverable).toBe(true); + }); + + it('flags a GOVERNED (side-effecting) tool failure as NOT recoverable — the ADR-0057 tightening', async () => { + // write_file is a governed `fs_write` action, so a failure is non-idempotent (a half-written file, though + // the atomic write guards it) and must NOT be auto-fed-back for a retry: recoverable is false. + const host = stubHost({ + fs: { + readFile: () => + Promise.resolve({ + content: 'x', + mimeType: 'text/plain', + sizeBytes: 1, + lastModified: 't', + }), + writeFile: () => Promise.reject(new Error('disk gone')), + listDirectory: () => Promise.resolve({ entries: [] }), + }, + }); + const err = await rejectsWith( + createToolRegistry({ tools: BUILTIN_TOOLS, host }).dispatch( + call('write_file', { path: './out.txt', content: 'hi' }), + ctx(), + ), + ); + expect(err).toBeInstanceOf(ToolExecutionError); + expect(err.recoverable).toBe(false); }); it('refuses to start when the signal is already aborted', async () => { diff --git a/packages/core/src/tools/registry.ts b/packages/core/src/tools/registry.ts index 606ad636..b694137a 100644 --- a/packages/core/src/tools/registry.ts +++ b/packages/core/src/tools/registry.ts @@ -148,7 +148,13 @@ async function dispatch( if (cause instanceof ToolDispatchError) { throw cause; // a typed error from dispatch (e.g. ToolUnavailableError) passes through } - throw new ToolExecutionError(def.id, `tool \`${def.id}\` failed`, cause); + // Stamp whether the failure is safe to feed back to the model for a within-turn retry (ADR-0057): ONLY an + // IDEMPOTENT tool — one `governedAction` does not classify as a side-effecting fs_write/process/egress/os + // action (a read: read_file / list_directory / git_status / invoke_agent / read_media). A governed tool's + // failure is non-idempotent (a half-run command, a POST that may have landed), so it is NOT recoverable. + throw new ToolExecutionError(def.id, `tool \`${def.id}\` failed`, cause, { + recoverable: governedAction(def, target) === undefined, + }); } // 8. Brand the model-facing result untrusted + shape the sanitized event payloads.