diff --git a/.claude/settings.json b/.claude/settings.json index 99d6b834..c04436e8 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -31,12 +31,12 @@ "Bash(head:*)", "Bash(tail:*)", "Bash(wc:*)", + "Bash(git push origin development)", "Bash(tree:*)" ], "deny": [ "Bash(sudo:*)", "Bash(rm -rf:*)", - "Bash(git push:*)", "Read(.env)", "Read(.env.*)", "Read(**/*.pem)" diff --git a/CLAUDE.md b/CLAUDE.md index bd99a614..e4683b1c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -79,9 +79,21 @@ 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 -obligations, see the canonical home [docs/roadmap/current.md](docs/roadmap/current.md); [README.md](README.md) -is the public overview. +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); +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/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..a1fe8ac6 --- /dev/null +++ b/apps/cli/src/chat/chat-mode-host.ts @@ -0,0 +1,81 @@ +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 `[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; +} + +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/chat/chat-mode.test.ts b/apps/cli/src/chat/chat-mode.test.ts new file mode 100644 index 00000000..f11be212 --- /dev/null +++ b/apps/cli/src/chat/chat-mode.test.ts @@ -0,0 +1,327 @@ +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), 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 + }); + + 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'])); + }); + + 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', + 'read_clipboard', + 'notify', + ]), + ); + // Explicit negatives for the read-only tools the positive cases above don't name. + for (const id of ['read_media', 'invoke_agent']) { + expect(isGovernedTool(builtin(id))).toBe(false); + } + }); +}); + +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 (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); + // 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 (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); + 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 () => { + 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 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 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' }), + ); + 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' }), + ); + 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); + // 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 () => { + 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 + // 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!( + 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' }), + ); + 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 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, 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 () => { + 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, 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'); + }); + + 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 new file mode 100644 index 00000000..b137107a --- /dev/null +++ b/apps/cli/src/chat/chat-mode.ts @@ -0,0 +1,254 @@ +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 (`[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). + */ + +/** 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 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', + auto: 'auto', +}; + +/** 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', + '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 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 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.some((mode) => mode === value); +} + +/** + * 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). `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; + +/** + * 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), 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; +} + +/** 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 +} + +/** + * 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 { + // 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. */ +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' }; + // 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); + } + 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) { + 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; + } + } + }; +} + +/** + * 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 (allowAlwaysCache && answer.scope === 'always') cache.rememberAlways(toolId); + return { outcome: 'approve' }; +} diff --git a/apps/cli/src/chat/persister.test.ts b/apps/cli/src/chat/persister.test.ts index 0a7ffdc3..55025e4e 100644 --- a/apps/cli/src/chat/persister.test.ts +++ b/apps/cli/src/chat/persister.test.ts @@ -236,6 +236,31 @@ 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) + // 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 () => { 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..29317fd2 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,28 @@ 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 + ) { + // 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; @@ -170,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/chat/session-host.test.ts b/apps/cli/src/chat/session-host.test.ts index 74a0a7e2..ee5c3777 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,10 @@ 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 { buildChatSession, @@ -465,6 +468,64 @@ 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('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 @@ -764,7 +825,41 @@ 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, + agentRef: writeAgent(['write_file']), + providers: scriptedResolver([ + 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(); + 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', // the ask-mode confirm floor denied the governed write (ADR-0057 EA3) + ); + 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, @@ -773,24 +868,167 @@ describe('buildChatSession + 2.5.A tool-host wiring (ADR-0055)', () => { 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 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 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({ + 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(); + 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 () => { + // 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('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)); + } + // 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(); + 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_unavailable', // the read-only fs writeFile fail-closed (EA1) + 'tool_denied', ); - expect(existsSync(join(workspace, 'x.txt'))).toBe(false); // it fail-closed BEFORE any write — no file on disk + expect(existsSync(join(workspace, '.git', 'config'))).toBe(false); // never written despite the approval }); - 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 +1038,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..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, @@ -58,7 +59,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 @@ -117,6 +118,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 +172,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, }); @@ -199,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 ? {} @@ -216,7 +229,7 @@ export async function buildChatSession(opts: BuildChatSessionOptions): Promise mcp.close() }), @@ -324,7 +338,7 @@ export interface BuildResumedChatSessionOptions { readonly now: () => 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; @@ -355,7 +369,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 = { @@ -400,6 +414,7 @@ export async function buildResumedChatSession( sessionId: record.id, agent, context, + tools: deps.tools, emitSessionEvent: emit, resumeState, nextSequenceNumber, diff --git a/apps/cli/src/commands/agent-run.test.ts b/apps/cli/src/commands/agent-run.test.ts index a4119331..b8d64407 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'; @@ -16,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 = { @@ -87,6 +93,34 @@ 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'); + 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 + }); + 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.test.ts b/apps/cli/src/commands/chat.test.ts index da40eeeb..ccc384de 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, @@ -271,6 +272,33 @@ 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, 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); + 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 () => { 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 @@ -1019,6 +1047,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 480ba502..8f5f5151 100644 --- a/apps/cli/src/commands/chat.ts +++ b/apps/cli/src/commands/chat.ts @@ -15,8 +15,18 @@ import { formatReplHelp, replCommandList, REPL_COMMANDS_BY_NAME, + type ReplCommand, type ReplCommandContext, } from './repl-commands.js'; +import { + CHAT_MODES, + MODE_DESCRIPTION, + MODE_LABEL, + parseMode, + type ApprovalPrompt, + 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 +118,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,8 +402,73 @@ 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 slash-aware line handler + the session's cancel/stop state. */ -export interface ChatLineHandler { +/** 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`. + */ +/** + * 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, + 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, + }); + applyChatMode(modeEnv, store.getSnapshot().mode); + return { + onAbort: () => { + built.session.abort(); // void-returning: block body so it never forwards abort()'s return value + }, + onModeChange: (mode) => { + store.setMode(mode); + applyChatMode(modeEnv, mode); + }, + }; +} + +/** 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. */ @@ -391,6 +477,34 @@ export interface ChatLineHandler { 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 @@ -410,13 +524,16 @@ export function createChatLineHandler( built.session.cancel(); // the session's sole terminal (session:cancelled) — persister marks it 'ended' } }; - - // 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; + // 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 + // full-capability host is never live without the fail-closed approval regime (default `ask` denies governed). + const modeControl = createChatModeControl(built, store, { interactive }); const emitOutput = (text: string): void => { if (interactive) { store.notice(text); @@ -432,8 +549,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; }, @@ -511,36 +629,62 @@ 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) { + // 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); + if (mode === undefined) { + emitOutput(`/mode: unknown mode '${requested.replace(/[^\x20-\x7e]/g, '?').slice(0, 16)}'`); + return; + } + modeControl.onModeChange(mode); + emitOutput(`mode: ${MODE_LABEL[mode]}`); + }, + }; + + // 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). 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)); - const bad = tokens.find((token) => !allowed.has(token)); - if (bad !== undefined) { - emitOutput( - `/${command.name}: unknown argument '${bad.replace(/[^\x20-\x7e]/g, '?').slice(0, 32)}'.`, - ); - 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); @@ -548,12 +692,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); + // 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) @@ -575,6 +730,8 @@ async function runReplLoop(wiring: ReplWiring, deps: ChatReplDeps): Promise 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/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/assemble.test.ts b/apps/cli/src/engine/tool-host/assemble.test.ts index 71448f91..672772d8 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']); @@ -134,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 9c648a83..97d9f02f 100644 --- a/apps/cli/src/engine/tool-host/assemble.ts +++ b/apps/cli/src/engine/tool-host/assemble.ts @@ -1,14 +1,22 @@ 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 // 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'; @@ -21,15 +29,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 +51,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 +86,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 +107,22 @@ 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). 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( + 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 — 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..19205624 --- /dev/null +++ b/apps/cli/src/engine/tool-host/egress.test.ts @@ -0,0 +1,272 @@ +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('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('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('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({ + 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('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 }); + 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); + }); + + 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 new file mode 100644 index 00000000..244f91ec --- /dev/null +++ b/apps/cli/src/engine/tool-host/egress.ts @@ -0,0 +1,162 @@ +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 => { + 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 = mergeEgressHeaders(request.headers, credential); + 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); + } + }, + }; +} + +/** + * 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 { + // 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 ${trimmed}`; + } + 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 + * 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; + } +} + +/** + * 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') { + return new EgressDeniedError(error.message); + } + return new EgressCapabilityError(error.message); + } + return error instanceof Error ? error : new EgressCapabilityError('egress request 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..8760f8c3 100644 --- a/apps/cli/src/engine/tool-host/fs.test.ts +++ b/apps/cli/src/engine/tool-host/fs.test.ts @@ -1,7 +1,10 @@ import { execFileSync } from 'node:child_process'; +import { constants } from 'node:fs'; import { + link, mkdir, mkdtemp, + open, readdir, readFile, realpath, @@ -10,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; @@ -156,6 +159,100 @@ 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( + '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( + '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 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 () => { + 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'); + await expect(sandboxed().readFile('node_modules/evil/.npmrc', {})).rejects.toBeInstanceOf( + FsScopeDeniedError, + ); + }); }); /** @@ -210,6 +307,31 @@ 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', () => { @@ -244,6 +366,20 @@ 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])); @@ -337,6 +473,72 @@ 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( + '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 () => { + // 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, + ); + + 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 @@ -351,6 +553,257 @@ 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 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, + ); + } + }); + + 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 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). + // 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, + ); + } + // 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` + // 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('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('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); + 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 — 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 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 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); + } + 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('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). + 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'); + }); + + 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 + }); + + 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)', () => { 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..117af347 100644 --- a/apps/cli/src/engine/tool-host/fs.ts +++ b/apps/cli/src/engine/tool-host/fs.ts @@ -136,6 +136,48 @@ async function guarded(op: () => Promise): Promise { * read_file * ------------------------------------------------------------------------------------------------ */ +/** + * 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 isPnpmStorePath(absolutePath: string): boolean { + const folded = absolutePath.split(sep).map(foldPathComponent); + // 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; +} + +/** Whether the hard-link aliasing read guard applies to `real`: ON for the jailed tiers, OFF for the unjailed + * `full` tier and for pnpm's virtual store ({@link isPnpmStorePath}). */ +function rejectAliasedRead(config: NodeFsCapabilityConfig, real: string): boolean { + return config.tier !== 'full' && !isPnpmStorePath(real); +} + async function readOne( config: NodeFsCapabilityConfig, maxReadBytes: number, @@ -150,13 +192,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, rejectAliasedRead(config, real)); 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 (only pnpm virtual-store links under node_modules/.pnpm are exempt)', + ); + } 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 +248,21 @@ 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, + 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' || + result.kind === 'aliased' || + result.kind === 'binary' + ) continue; if (result.kind === 'oversize') { throw new FsCapabilityError( @@ -224,10 +287,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 +309,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 +355,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); @@ -302,6 +385,152 @@ export async function readJailedFile(real: string, sizeLimit: number): Promise = new Set(['.git', '.relavium', '.ssh']); +const PROTECTED_RC_BASENAMES: ReadonlySet = new Set([ + '.bashrc', + '.bash_profile', + '.bash_login', + '.bash_logout', + '.bash_aliases', // sourced by the default Debian/Ubuntu .bashrc + '.profile', + '.zshrc', + '.zprofile', + '.zshenv', + '.zlogin', + '.zlogout', + '.cshrc', + '.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 +]); + +/** 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 { + const beforeStream = name.split(':', 1)[0] ?? name; // NTFS Alternate-Data-Stream / drive qualifier + 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); +} + +/** + * 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( + 'write_file: refusing to write inside a protected directory (.git / .relavium / .ssh)', + ); + } + } + 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. + * + * 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 (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', + '.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 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 ( + base === 'config' && + folded.some((segment) => segment === '.git' || segment.endsWith('.git')) + ) { + return true; + } + // 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. */ +function assertNotSensitiveReadPath(absoluteTarget: string): void { + if (isSensitiveReadPath(absoluteTarget)) { + throw new FsScopeDeniedError( + 'refusing to read a credential/secret store (.ssh / .relavium / a git config / a credential dotfile) — ask the user to share any needed content instead', + ); + } +} + async function writeOne( config: NodeFsCapabilityConfig, path: string, @@ -315,6 +544,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 (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( config, @@ -323,6 +556,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 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) { @@ -331,19 +571,54 @@ 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. `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' || 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)', + ); + } 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(); @@ -358,8 +633,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, }); @@ -385,6 +661,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'); @@ -395,6 +674,12 @@ async function listOne( 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 (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({ @@ -461,9 +746,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(() => { @@ -589,6 +879,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); 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..59bae6ff --- /dev/null +++ b/apps/cli/src/engine/tool-host/os.test.ts @@ -0,0 +1,264 @@ +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 }; +} + +/** + * 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; + + 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 after `--` (flag-injection safe, no env overlay)', () => { + const plan = notifyPlan('linux', input); + expect(plan.executable).toBe('notify-send'); + // 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', () => { + 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('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 }); + 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('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); + 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..a9304a91 --- /dev/null +++ b/apps/cli/src/engine/tool-host/os.ts @@ -0,0 +1,279 @@ +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. + */ + +/** 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; + +/** 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, 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 (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; + 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: + // `--` 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] }; + } +} + +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 OsTimeoutError('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'); +} diff --git a/apps/cli/src/home/drive-home.tsx b/apps/cli/src/home/drive-home.tsx index 94717317..d84dfd94 100644 --- a/apps/cli/src/home/drive-home.tsx +++ b/apps/cli/src/home/drive-home.tsx @@ -177,10 +177,14 @@ export async function driveHome(deps: HomeDeps): Promise { now, uuid, }); - const { processLine, cancelOnce, shouldStop } = createChatLineHandler( - { built, opened, store, persister, doctorProbes: chatDoctorProbes }, - deps, - ); + // 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); @@ -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..c3d509a1 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,13 @@ 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. 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; } interface ChatViewProps { @@ -86,6 +95,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 +111,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 +169,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 +266,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 +289,27 @@ 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': + // 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 }); + return; + case 'reject': + props.store.answerApproval({ outcome: 'reject' }); + return; case 'none': return; } @@ -266,6 +323,8 @@ export function ChatApp(props: Readonly): ReactElement { color={color} input={input} running={running} + mode={mode} + approval={approval} paletteOpen={palette !== undefined} /> {palette !== undefined && ( @@ -348,6 +407,11 @@ 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. 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), }), { // OUR /cancel (Ctrl-C) handler drives the cooperative cancel — never ink's process.exit. 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..7ef284b6 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,52 @@ 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. + * 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' }; +} + +/** + * 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, input: string, running: boolean, + approvalPending = false, ): ChatKeyAction { + 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) 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' }; diff --git a/apps/cli/src/render/tui/chat-projection.test.ts b/apps/cli/src/render/tui/chat-projection.test.ts index 6468e85f..8b436702 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, @@ -33,6 +37,82 @@ 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('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); + 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', + 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(' · '); @@ -99,6 +179,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..cbb9169a 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'; @@ -48,11 +51,52 @@ 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 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']); + +/** + * 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}`; + // 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 (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)`). 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 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}`; + } const parts = [ head, formatTokens(summary.tokensUsed), @@ -82,3 +126,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/chat-store.test.ts b/apps/cli/src/render/tui/chat-store.test.ts index 8d3611b9..bcd4f7dc 100644 --- a/apps/cli/src/render/tui/chat-store.test.ts +++ b/apps/cli/src/render/tui/chat-store.test.ts @@ -256,3 +256,63 @@ 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); + // 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(); + }); + + 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).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/chat-store.ts b/apps/cli/src/render/tui/chat-store.ts index 753eaaec..8b7a8520 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,21 @@ import { * rate is capped). */ +/** 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. */ + 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 +72,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 `[y]/[a]/[n]` decision) — a no-op if none is pending. */ + answerApproval: (answer: ApprovalAnswer) => void; } /** @@ -71,12 +101,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 +155,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 [y]/[a]/[n] 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; +} 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.test.ts b/apps/cli/src/render/tui/home-controller.test.ts index 80846632..5c29f52a 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,88 @@ 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('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('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 })); + 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/home-controller.ts b/apps/cli/src/render/tui/home-controller.ts index a1e28111..1256f006 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 { 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'; @@ -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; } @@ -260,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 @@ -315,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) { @@ -336,6 +345,26 @@ 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': + // 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 }); + return; + case 'reject': + active.store.answerApproval({ outcome: 'reject' }); + return; case 'none': return; } 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..f3e5951d 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,38 @@ 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('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 6fb68512..e352910e 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,13 +30,16 @@ 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. */ 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; } @@ -60,7 +63,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 @@ -311,7 +318,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; 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..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 -- **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) +- **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.** +> **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 @@ -19,6 +32,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 +62,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,13 +85,22 @@ 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 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** @@ -63,11 +108,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 +`[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 +[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 +154,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 +167,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/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/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/cli/home.md b/docs/reference/cli/home.md index 399c2268..684efe04 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 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 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) 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) @@ -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/docs/reference/contracts/agent-session-spec.md b/docs/reference/contracts/agent-session-spec.md index 92a4dccc..c84ca223 100644 --- a/docs/reference/contracts/agent-session-spec.md +++ b/docs/reference/contracts/agent-session-spec.md @@ -38,6 +38,9 @@ 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) + 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 @@ -47,7 +50,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. 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 c9b86df9..f898fc2c 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 @@ -73,6 +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 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) | @@ -261,16 +263,16 @@ 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? } - | SessionCancelledEvent // 'session:cancelled' — the in-flight turn was aborted + | 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' — 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) ``` -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` 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/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 a341cf00..369941b3 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 @@ -72,10 +72,22 @@ 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. */ 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; } @@ -158,12 +170,13 @@ 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); 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`. 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 { @@ -173,6 +186,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). @@ -189,6 +207,55 @@ 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' | 'os' (@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`, 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) 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). @@ -258,6 +325,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/docs/roadmap/current.md b/docs/roadmap/current.md index b3a29ffc..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) @@ -141,8 +141,26 @@ 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). **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 items up as it first touches each file. Notable inheritances: 1.AH's host-wiring half diff --git a/docs/roadmap/deferred-tasks.md b/docs/roadmap/deferred-tasks.md index 8af33585..695482b5 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,54 @@ 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)* +- [ ] **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)* +- [ ] **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 + `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 > **2026-06-22 2.D (`relavium run`) implementation.** The CLI was wired to `@relavium/core` — the first @@ -560,27 +608,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 @@ -592,10 +632,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/docs/roadmap/phases/phase-2.5-cli-consolidation.md b/docs/roadmap/phases/phase-2.5-cli-consolidation.md index c9763760..6a00c4f3 100644 --- a/docs/roadmap/phases/phase-2.5-cli-consolidation.md +++ b/docs/roadmap/phases/phase-2.5-cli-consolidation.md @@ -3,9 +3,10 @@ > 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 -> lanes (no dependency chain): 2.5.H / I / J. +> + palette + `/help`/`/doctor`/`/workflows`/`/cost` + footer hint-bar) ✅ **Done (PR #62, 2026-06-30)** +> → 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) @@ -188,14 +189,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` @@ -258,6 +260,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) @@ -269,9 +288,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:** @@ -280,7 +310,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 @@ -292,7 +323,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 diff --git a/packages/core/src/engine/agent-session.test.ts b/packages/core/src/engine/agent-session.test.ts index b9af213b..ea558632 100644 --- a/packages/core/src/engine/agent-session.test.ts +++ b/packages/core/src/engine/agent-session.test.ts @@ -7,14 +7,16 @@ import type { } from '@relavium/llm'; import { AgentSchema, + RunEventSchema, SessionContextSchema, type Agent, type SessionContext, } 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 +637,375 @@ 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 () => { + // 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(); + 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('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' }, + }); + // 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 () => { + 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(); + }); + + 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'); + // 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; + + 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'); + }); + + 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 898b1ea8..7e75faac 100644 --- a/packages/core/src/engine/agent-session.ts +++ b/packages/core/src/engine/agent-session.ts @@ -26,10 +26,11 @@ import type { Agent, AbortSignalLike, + AgentApprovalRequestedEvent, ErrorCode, SessionContext, SessionEvent, - StopReason, + SessionStopReason, ToolPolicy, } from '@relavium/shared'; import { @@ -41,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, @@ -73,15 +80,60 @@ 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 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, + '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 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 | NodeStreamEvent; +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; +/** + * 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` / @@ -183,6 +235,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; @@ -236,13 +300,19 @@ 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`). + * 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). 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). */ - async sendMessage(text: string): Promise { + setTurnPolicy(policy: SessionTurnPolicy | undefined): void { + this.#turnPolicy = policy; + } + + /** 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`); } @@ -252,36 +322,46 @@ export class AgentSession { `session ${this.sessionId} is ${this.#status}; cannot send a message`, ); } + } + + /** + * 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(); + // 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'; + // 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.#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 }] }); - 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). @@ -289,6 +369,11 @@ export class AgentSession { this.#messages.pop(); 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. 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 @@ -315,13 +400,47 @@ 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'; } } + /** + * 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. @@ -390,8 +509,27 @@ 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 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 + 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. @@ -405,8 +543,37 @@ 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 + ? {} + : { + // 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, + }); + }, + }, + }), }; - 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, @@ -449,7 +616,7 @@ export class AgentSession { } #emitTurnCompleted( - stopReason: StopReason, + stopReason: SessionStopReason, tokensUsed: { input: number; output: number; model?: string }, error?: { code: ErrorCode; message: string; retryable: boolean }, ): void { @@ -505,11 +672,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/engine/agent-turn.test.ts b/packages/core/src/engine/agent-turn.test.ts index ec26685e..24ac8625 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,70 @@ describe('runAgentTurn — tool loop', () => { }); }); + 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', undefined, { + recoverable: true, + }); + 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('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 c62922f3..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'; @@ -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,21 @@ 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 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 ( + limits.recoverToolFailures === true && err instanceof ToolExecutionError && err.recoverable + ); } /** Map a non-correctable tool throw to the node `ErrorCode` (cancel wins; a denial is fatal). */ @@ -521,7 +544,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', 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/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/index.ts b/packages/core/src/index.ts index 18b9ee3d..53f1685a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -191,6 +191,11 @@ export type { SessionEventSink, SessionStreamEvent, SessionLifecycleEvent, + // 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). + 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 @@ -247,8 +252,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 +270,11 @@ export type { EgressKind, ToolPolicyClass, PolicyTarget, + ConfirmActionHook, + ToolApprovalContext, + ToolApprovalRequest, + ToolApprovalDecision, + ToolActionPreview, ToolHost, FsCapability, ProcessCapability, diff --git a/packages/core/src/tools/bounding.test.ts b/packages/core/src/tools/bounding.test.ts index 198e57a9..ed9be1aa 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,167 @@ 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 + }); + + 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', () => { + 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('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('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('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 + }); + + 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(); + 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 + }); +}); + +describe('redactSecretShapedValue', () => { + 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', + }; + // `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', () => { + 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..6e5bc424 100644 --- a/packages/core/src/tools/bounding.ts +++ b/packages/core/src/tools/bounding.ts @@ -129,6 +129,88 @@ 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 + // 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 `. 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. 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|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 token + PAT, + // GitLab PAT, Slack, Google API + OAuth, HuggingFace, npm, JWT). Kept as ONE alternation ON PURPOSE: a + // single leftmost-longest scan is required so two ADJACENT / EMBEDDED credential shapes (e.g. a JWT whose + // signature segment contains an `sk-` run, or a `xox…-glpat-…` run) are covered by the outer greedy match + // as a single span. A per-family MULTI-PASS split is NOT equivalent — an earlier pass inserting `[redacted]` + // (a non-word `[`) truncates a later family's greedy match and can leave a trailing secret-shaped substring + // EXPOSED (a real redaction regression, pinned in bounding.test.ts). So this stays a single analyzable + // LITERAL and its Sonar per-regex complexity is a deliberate, documented exception — the same trade-off as + // the key=value pattern above: correctness of a security redactor + Sonar's static ReDoS (S5852) coverage + // outrank the metric. `\w` / `[\w-]` fold only the classes that are EXACTLY `[A-Za-z0-9_]` / `[A-Za-z0-9_-]`; + // the tighter `[A-Za-z0-9]` / `[A-Za-z0-9-]` families keep their narrower class (no `_`). + .replace( + /\b(?:sk-[A-Za-z0-9]{16,}|sk_(?:live|test)_[A-Za-z0-9]{16,}|A[KSB]IA[0-9A-Z]{16}|gh[pousr]_[A-Za-z0-9]{20,}|github_pat_\w{20,}|glpat-[\w-]{16,}|xox[baprs]-[A-Za-z0-9-]{10,}|AIza[\w-]{30,}|ya29\.[\w-]{20,}|hf_[A-Za-z0-9]{20,}|npm_[A-Za-z0-9]{20,}|eyJ[\w-]{10,}\.[\w-]{10,}\.[\w-]{6,})/g, + '[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. 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()); +} + +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 '[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 + const out: Record = {}; + // 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; +} + /** 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 +236,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/builtins.ts b/packages/core/src/tools/builtins.ts index 9ed5f118..41104e01 100644 --- a/packages/core/src/tools/builtins.ts +++ b/packages/core/src/tools/builtins.ts @@ -127,14 +127,28 @@ 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, +}; +// 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, @@ -182,7 +196,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, @@ -538,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/errors.ts b/packages/core/src/tools/errors.ts index b46e9b5b..b8214b8a 100644 --- a/packages/core/src/tools/errors.ts +++ b/packages/core/src/tools/errors.ts @@ -82,6 +82,32 @@ 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 + | '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 { readonly code = 'invalid_args'; @@ -123,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 238866e3..d67324f6 100644 --- a/packages/core/src/tools/registry.test.ts +++ b/packages/core/src/tools/registry.test.ts @@ -5,17 +5,20 @@ import { BUILTIN_TOOLS, BUILTIN_TOOL_IDS } from './builtins.js'; import { ToolArgsInvalidError, ToolCancelledError, + ToolDeniedByUserError, ToolExecutionError, ToolPolicyError, 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, EgressResponse, FsCapability, + ToolApprovalDecision, + ToolApprovalRequest, ToolCallPart, ToolDispatchContext, ToolHost, @@ -339,6 +342,498 @@ 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 + * 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 + // (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(() => + 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('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('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(); + 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(); + 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: {} }); + }); + + 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 () => { + 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 (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( + 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 = mutableSignal(); + 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 }), + ), + ); + 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 () => { + // 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 = mutableSignal(); + 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 }), + ), + ); + 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 = mutableSignal(); + const confirm = approving(); + await registry().dispatch( + call('write_file', { path: './out.txt', content: 'hi' }), + ctx({ approval: { confirm }, signal: signal }), + ); + 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: mutableSignal(true) }), + ), + ); + expect(err).toBeInstanceOf(ToolCancelledError); + expect(writeFile).not.toHaveBeenCalled(); + }); +}); + /* --- config-only params + I/O mapping --- */ describe('ToolRegistry — config-only params and I/O mapping', () => { @@ -415,6 +910,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 --- */ @@ -437,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( @@ -448,14 +969,40 @@ 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 () => { const err = await rejectsWith( - registry().dispatch( - call('read_file', { path: 'a' }), - ctx({ signal: { aborted: true } as never }), - ), + registry().dispatch(call('read_file', { path: 'a' }), ctx({ signal: mutableSignal(true) })), ); expect(err).toBeInstanceOf(ToolCancelledError); expect(err.runErrorCode).toBe('cancelled'); @@ -741,7 +1288,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 @@ -751,7 +1298,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); @@ -777,7 +1324,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) @@ -792,14 +1339,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(() => @@ -820,7 +1367,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 35b5c92f..b694137a 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 { boundForModel, redactInlineMedia, redactSecretShapedValue } from './bounding.js'; import { ToolArgsInvalidError, ToolCancelledError, + ToolDeniedByUserError, ToolDispatchError, ToolExecutionError, ToolPolicyError, @@ -22,6 +23,10 @@ import { markUntrusted } from './untrusted.js'; import { DEFAULT_TOOL_RESULT_LIMITS, type CreateToolRegistryOptions, + type PolicyTarget, + type ToolActionPreview, + type ToolApprovalDecision, + type ToolApprovalRequest, type ToolCallPart, type ToolDef, type ToolDispatchContext, @@ -96,16 +101,25 @@ 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); - - // 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. + // 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); + + // 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 + // — 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 { 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); @@ -134,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. @@ -269,7 +289,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 +298,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 +328,137 @@ 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, 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 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 + // 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 { + /* an observability emit must never break the approval decision */ + } + + let decision: ToolApprovalDecision; + try { + 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 + // 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 = + decision.reason !== undefined && decision.reason.length > 0 + ? `: ${decision.reason}` + : ' 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); +} + +/** + * 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). + * 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'; + } + if (def.policy.egress !== undefined) { + return 'egress'; + } + if (def.policy.spawnsProcess && target.command !== undefined) { + return 'process'; + } + if (def.policy.os === true) { + return 'os'; + } + 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 }; + } + 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 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; + } + } +} + function enforceHttpEgress(toolId: ToolId, url: string, ctx: ToolDispatchContext): void { const parsed = extractHttpsHost(url); if (parsed === null || parsed.hasCredentials) { @@ -425,12 +574,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/core/src/tools/types.ts b/packages/core/src/tools/types.ts index ff9ded6b..00760828 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,10 +48,25 @@ 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`. */ 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; } @@ -252,6 +268,66 @@ 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 / 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. + * ------------------------------------------------------------------------------------------------ */ + +/** 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; + /** + * 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 { readonly nodeId: string; /** The node's narrowed grant (ADR-0029(b)); a dispatch outside it is refused (registered ≠ authorized). */ @@ -264,6 +340,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 / 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. */ readonly secretArgKeys?: ReadonlySet; /** Engine delegate for `invoke_agent` — pure orchestration, not a ToolHost I/O capability. */ @@ -315,6 +397,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/db/src/index.ts b/packages/db/src/index.ts index 7d506d88..81b99584 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -111,9 +111,27 @@ export { type FetchMediaBytesOptions, type MediaEgressDeps, type MediaEgressErrorCode, + // `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) + +// `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, + // `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 './media-egress.js'; +} 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` 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.test.ts b/packages/db/src/safe-egress.test.ts new file mode 100644 index 00000000..d0a1884e --- /dev/null +++ b/packages/db/src/safe-egress.test.ts @@ -0,0 +1,489 @@ +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('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('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', + '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', + }, + body: 'a much longer body than three bytes', + }, + deps, + sig(), + ); + for (const stripped of [ + 'Content-Length', + 'Transfer-Encoding', + 'Connection', + 'Keep-Alive', + 'Proxy-Connection', + 'Proxy-Authorization', + 'Proxy-Authenticate', + 'TE', + 'Trailer', + 'Upgrade', + 'Expect', + ]) { + expect(captured?.headers?.[stripped]).toBeUndefined(); + } + 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-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', + }, + }, + deps, + sig(), + ); + 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'); + }); + + 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' }); + }); + + 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 --- */ + +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 & { + 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()`, 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(), destroy: 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('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); + 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(); + }); +}); diff --git a/packages/db/src/safe-egress.ts b/packages/db/src/safe-egress.ts new file mode 100644 index 00000000..3e457d3f --- /dev/null +++ b/packages/db/src/safe-egress.ts @@ -0,0 +1,389 @@ +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: sanitizeHopHeaders(opts.headers), + body: opts.body, + }, + signal, + ); +} + +/** + * 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). + * - `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', + ':authority', + 'content-length', + 'transfer-encoding', + 'connection', + 'keep-alive', + 'proxy-connection', + 'te', + 'trailer', + 'upgrade', + 'proxy-authenticate', + 'proxy-authorization', + '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 { + if (headers === undefined) return undefined; + 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; +} + +/** 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`, 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) 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, + 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 + 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 Promise.race([fn(controller.signal), deadline]); + } catch (error) { + if (error instanceof SafeEgressError) { + 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 { + if (timer !== undefined) 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; +} diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index c015719d..eb6ec0c0 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,21 @@ 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), `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', 'os'] 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 @@ -125,6 +144,16 @@ export type RetryableErrorCode = (typeof RETRYABLE_ERROR_CODES)[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 5e5f66ef..bd9885cc 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'; @@ -44,6 +45,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 +220,95 @@ 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 | os) + preview: { path: './out.txt' }, + }, + 'agent:approval_requested (empty toolId)': { + type: 'agent:approval_requested', + ...env, + nodeId: 'n', + toolId: '', + 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/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 (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, + 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', + ...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', @@ -345,7 +443,49 @@ describe('RunEvent union — every variant', () => { ).toBe(true); }); - it('covers exactly the 21 canonical colon-namespaced names, pinned to a literal list', () => { + 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('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. const CONTRACT_NAMES = [ @@ -354,6 +494,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 +518,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)', () => { @@ -543,10 +684,16 @@ 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). 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', () => { @@ -573,7 +720,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', @@ -624,6 +771,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 +781,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..1e451203 100644 --- a/packages/shared/src/run-event.ts +++ b/packages/shared/src/run-event.ts @@ -8,7 +8,9 @@ import { FS_SCOPE_TIERS, LLM_PROVIDERS, MEDIA_BILLED_MODALITIES, + SESSION_STOP_REASONS, STOP_REASONS, + TOOL_ACTION_CLASSES, } from './constants.js'; import { GateTypeSchema, TimeoutActionSchema } from './node.js'; @@ -36,12 +38,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(), @@ -88,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 @@ -176,6 +183,55 @@ 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 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). `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'), + ...dualBase, + nodeId: nonEmptyString, + toolId: nonEmptyString, + action: ToolActionClassSchema, + // `.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; + +/** + * 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, @@ -414,6 +470,7 @@ const RunEventUnionSchema = z.discriminatedUnion('type', [ AgentTokenEventSchema, AgentToolCallEventSchema, AgentToolResultEventSchema, + AgentApprovalRequestedEventSchema, AgentFilePatchProposedEventSchema, CostUpdatedEventSchema, NodeCompletedEventSchema, @@ -432,15 +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 four - * dual-envelope events 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) { @@ -450,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 && @@ -463,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) @@ -502,6 +566,55 @@ export const RunEventSchema = RunEventUnionSchema.superRefine((event, ctx) => { path: ['deadlineAt'], }); } +} + +/** + * 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; @@ -523,7 +636,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(), @@ -541,10 +657,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, @@ -559,9 +675,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 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;