Prechádzať zdrojové kódy

feat(approval): the approval seam — one-shot permission decisions over a waterfall of answerers

ctx.approval (dsh-approval): request() dispatches the approval/request
waterfall and always resolves a closed outcome — allowed-once / rejected /
cancelled / unavailable — never rejects; zero listeners fall through to
fail-closed unavailable; abort settles cancelled and discards late answers;
throwing or rogue answerers are contained as unavailable; every ask lands
the log-only approval/asked / approval/decided audit pair. dsh-tools routes
a pre-execute ask through the seam opportunistically (ctx.get) with three
distinct deny reasons, keeping the historical ask→deny degrade when the
seam is absent.

The per-session policy tier, the ACP bridge answerer, and the sandbox
escalation asker are staged follow-ups of the approval-seam RFC.
kingwl 2 mesiacov pred
rodič
commit
ef35007d75

+ 1 - 0
.gitignore

@@ -19,3 +19,4 @@ tmp/
 .DS_Store
 .idea
 mise.toml
+

+ 5 - 0
docs/capability-seams.md

@@ -46,6 +46,8 @@ flowchart LR
   pkg_bash_local["bash-local"]
   pkg_hooks_claude["hooks-claude"]
   pkg_hooks_codex["hooks-codex"]
+  pkg_approval["approval"]
+  svc_approval["ctx.approval<br/>Approval seam"]
   pkg_code_runtime["code-runtime"]
   svc_codeRuntime["ctx.codeRuntime<br/>Code-execution seam"]
   pkg_code_runtime_worker["code-runtime-worker"]
@@ -74,6 +76,7 @@ flowchart LR
   pkg_acp --> svc_userInteraction
   pkg_agent --> svc_agents
   pkg_agent_loop --> svc_agentLoop
+  pkg_approval --> svc_approval
   pkg_bash --> svc_bash
   pkg_bash_local --> svc_bash
   pkg_code_runtime --> svc_codeRuntime
@@ -112,6 +115,7 @@ flowchart LR
   svc_agents --> pkg_invariants
   svc_agents --> pkg_stdio_agent
   svc_agents --> pkg_subagent_inprocess
+  svc_approval --> pkg_tools
   svc_bash --> pkg_hooks_claude
   svc_bash --> pkg_hooks_codex
   svc_bash --> pkg_tool_bash
@@ -160,6 +164,7 @@ flowchart LR
 | `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/ui/acp), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`stdio-agent`](../packages/ui/stdio-agent), [`invariants`](../packages/support/invariants) | - | Owns live Agent handles and the create/resume factory seam. |
 | `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-core`](../packages/core/agent-core) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. |
 | `ctx.bash` | `seam` | [`bash`](../packages/bash/bash) | [`bash-local`](../packages/bash/bash-local) | [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | - | The model-facing bash tools and hook bridges consume this seam; sandboxed or remote executors can replace bash-local. |
+| `ctx.approval` | `seam` | [`approval`](../packages/approval/approval) | - | [`tools`](../packages/core/tools) | - | One-shot permission decisions dispatched over the `approval/request` waterfall; answerers are listeners (the ACP bridge for its own agents), absence fails closed to `unavailable`. |
 | `ctx.codeRuntime` | `seam` | [`code-runtime`](../packages/code-runtime/code-runtime) | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | [`tools`](../packages/core/tools) | - | Runs one model-written program against host-provided async bindings; backends differ by substrate and language (the tool registry consumes it for Code Mode). |
 | `ctx.fs` | `seam` | [`fs`](../packages/fs/fs) | [`fs-local`](../packages/fs/fs-local) | [`tool-fs`](../packages/fs/tool-fs) | [`fs-policy`](../packages/fs/fs-policy) | tool-fs executes read/write/edit through ctx.fs; fs-policy contributes observed-state checks through the fs/* event gate. |
 | `ctx.compact` | `seam` | [`compact`](../packages/compact/compact) | [`compact-basic`](../packages/compact/compact-basic) | [`compact-basic`](../packages/compact/compact-basic) | - | The basic backend currently consumes the pre-step event directly; a model-facing compact tool remains deferred. |

+ 2 - 1
docs/config-catalog.md

@@ -818,7 +818,7 @@ export interface Config {
 export type ToolPresentationMode = 'native' | 'code' | 'both'
 ```
 
-Source: [`packages/core/tools/src/index.ts:319`](../packages/core/tools/src/index.ts)
+Source: [`packages/core/tools/src/index.ts:323`](../packages/core/tools/src/index.ts)
 
 ## `@deepseek-ai/dsh-web`
 
@@ -966,6 +966,7 @@ Source: [`packages/workflow/workflow-workerthread/src/index.ts:69`](../packages/
 These load from a `cordis.yml` entry with no `config:` block; they declare no config surface.
 
 - `@deepseek-ai/dsh-agent` ([`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts))
+- `@deepseek-ai/dsh-approval` ([`packages/approval/approval/src/index.ts`](../packages/approval/approval/src/index.ts))
 - `@deepseek-ai/dsh-fs-policy` ([`packages/fs/fs-policy/src/index.ts`](../packages/fs/fs-policy/src/index.ts))
 - `@deepseek-ai/dsh-llm` ([`packages/llm/llm/src/index.ts`](../packages/llm/llm/src/index.ts))
 - `@deepseek-ai/dsh-session` ([`packages/core/session/src/index.ts`](../packages/core/session/src/index.ts))

+ 17 - 5
docs/cordis-catalog/events.md

@@ -163,6 +163,18 @@ Types: [Agent](../core-data-structures/core.md)
 
 Source: [`packages/core/agent/src/types.ts:464`](../../packages/core/agent/src/types.ts)
 
+## `approval/*`
+
+### `approval/request` — waterfall
+
+Waterfall asking the composed answerers to decide one approval request. Dispatched only from ApprovalService.request — callers go through the service (which owns cancellation and the audit events), never through `ctx.waterfall` directly. A listener that can answer for this request's agent returns an outcome WITHOUT calling `next()` (the decision slot is single-occupancy, first listener to answer wins); a listener that does not recognize the agent MUST call `next()` so another answerer — or the fail-closed default `'unavailable'` — gets the question. Throwing is contained by the service and yields `'unavailable'`.
+
+```ts cordis-catalog
+'approval/request'(this: ApprovalService, req: ApprovalRequest, next: () => Promise<ApprovalOutcome>): Promise<ApprovalOutcome>
+```
+
+Source: [`packages/approval/approval/src/index.ts:52`](../../packages/approval/approval/src/index.ts)
+
 ## `fs/*`
 
 ### `fs/edit-intent` — waterfall
@@ -323,7 +335,7 @@ A tool was registered or unregistered (the available tool set changed).
 'tools/change'(): void
 ```
 
-Source: [`packages/core/tools/src/index.ts:132`](../../packages/core/tools/src/index.ts)
+Source: [`packages/core/tools/src/index.ts:135`](../../packages/core/tools/src/index.ts)
 
 ### `tools/execute` — waterfall
 
@@ -335,7 +347,7 @@ Around-dispatch waterfall wrapping the registry's core tool dispatch, between th
 
 Types: [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md)
 
-Source: [`packages/core/tools/src/index.ts:111`](../../packages/core/tools/src/index.ts)
+Source: [`packages/core/tools/src/index.ts:114`](../../packages/core/tools/src/index.ts)
 
 ### `tools/post-execute` — waterfall
 
@@ -347,11 +359,11 @@ Waterfall AFTER a tool runs — where hook plugins inspect the result and accept
 
 Types: [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md)
 
-Source: [`packages/core/tools/src/index.ts:127`](../../packages/core/tools/src/index.ts)
+Source: [`packages/core/tools/src/index.ts:130`](../../packages/core/tools/src/index.ts)
 
 ### `tools/pre-execute` — waterfall
 
-Waterfall BEFORE a tool runs — the gate where sandbox, permission, and hook plugins allow or deny a call (Claude Code's `PreToolUse`). Listeners receive `(exec, next)`: call `next()` to delegate to the default (allow), or return a PreToolDecision without calling `next()` to short-circuit. A `deny` skips dispatch and yields an `isError` result; the tool body never runs. Input rewrite is deliberately NOT offered here (see PreToolDecision); `ask` degrades to deny until the permission system lands (`FIXME(permissions)`).
+Waterfall BEFORE a tool runs — the gate where sandbox, permission, and hook plugins allow or deny a call (Claude Code's `PreToolUse`). Listeners receive `(exec, next)`: call `next()` to delegate to the default (allow), or return a PreToolDecision without calling `next()` to short-circuit. A `deny` skips dispatch and yields an `isError` result; the tool body never runs. Input rewrite is deliberately NOT offered here (see PreToolDecision); `ask` is serviced by the `ctx.approval` seam when one is mounted, and degrades to deny otherwise.
 
 ```ts cordis-catalog
 'tools/pre-execute'(this: ToolRegistry, exec: ToolExecution, next: () => Promise<PreToolDecision>): Promise<PreToolDecision>
@@ -359,7 +371,7 @@ Waterfall BEFORE a tool runs — the gate where sandbox, permission, and hook pl
 
 Types: [ToolExecution](../core-data-structures/tools.md)
 
-Source: [`packages/core/tools/src/index.ts:91`](../../packages/core/tools/src/index.ts)
+Source: [`packages/core/tools/src/index.ts:94`](../../packages/core/tools/src/index.ts)
 
 ## `workflow/*`
 

+ 11 - 1
docs/cordis-catalog/services.md

@@ -40,6 +40,16 @@ Types: [Agent](../core-data-structures/core.md)
 
 Source: [`packages/core/agent/src/index.ts:117`](../../packages/core/agent/src/index.ts)
 
+## `ctx.approval` — `ApprovalService`
+
+The `ctx.approval` service: dispatches ApprovalRequests to the `approval/request` waterfall and audits every ask/outcome pair to the requesting agent's session log. Stateless between requests — grants are returned to the caller, never stored here.
+
+```ts cordis-catalog
+async request(req: ApprovalRequest): Promise<ApprovalOutcome>
+```
+
+Source: [`packages/approval/approval/src/index.ts:168`](../../packages/approval/approval/src/index.ts)
+
 ## `ctx.bash` — `BashExecutor` (abstract seam)
 
 Abstract bash execution service. Subclass, implement the abstract methods, and load the subclass as a plugin — it registers as `ctx.bash` (one implementation per context; loading a second throws, which is cordis' standard duplicate-service behavior).
@@ -226,7 +236,7 @@ async execute(exec: ToolExecution): Promise<ToolExecutionResult>
 
 Types: [ToolDefinition](../core-data-structures/tools.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md)
 
-Source: [`packages/core/tools/src/index.ts:345`](../../packages/core/tools/src/index.ts)
+Source: [`packages/core/tools/src/index.ts:349`](../../packages/core/tools/src/index.ts)
 
 ## `ctx.userInteraction` — `UserInteractionService`
 

+ 5 - 4
docs/event-producer-consumer.md

@@ -19,6 +19,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `agent/status` | `emit` | [`packages/core/agent/src/types.ts:281`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`stdio-agent`](../packages/ui/stdio-agent) |
 | `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:451`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
 | `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:464`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
+| `approval/request` | `waterfall` | [`packages/approval/approval/src/index.ts:52`](../packages/approval/approval/src/index.ts) | [`approval`](../packages/approval/approval) (`waterfall`) | - |
 | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:123`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
 | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:138`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) |
 | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:109`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
@@ -32,10 +33,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:91`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) |
 | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:38`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | - |
 | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:44`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - |
-| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:132`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - |
-| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:111`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`timeout-policy`](../packages/timeout/timeout-policy) |
-| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:127`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) |
-| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:91`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
+| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:135`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - |
+| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:114`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`timeout-policy`](../packages/timeout/timeout-policy) |
+| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:130`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) |
+| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:94`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
 | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:96`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - |
 | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:85`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - |
 | `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:106`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - |

+ 15 - 6
docs/module-graph.md

@@ -88,6 +88,9 @@ flowchart TD
     pkg_tool_ask_user["tool-ask-user"]
     pkg_user_interaction["user-interaction"]
   end
+  subgraph group_approval["packages/approval"]
+    pkg_approval["approval"]
+  end
   subgraph group_code_runtime["packages/code-runtime"]
     pkg_code_runtime["code-runtime"]
     pkg_code_runtime_worker["code-runtime-worker"]
@@ -131,11 +134,6 @@ flowchart TD
   pkg_session_persistence --> pkg_session
   pkg_llm_replay --> pkg_llm
   pkg_llm_replay --> pkg_session
-  pkg_tools --> pkg_agent
-  pkg_tools --> pkg_code_runtime
-  pkg_tools --> pkg_llm
-  pkg_tools --> pkg_session
-  pkg_tools --> pkg_system_prompt
   pkg_compact_basic --> pkg_agent
   pkg_compact_basic --> pkg_compact
   pkg_compact_basic --> pkg_llm
@@ -149,9 +147,19 @@ flowchart TD
   pkg_invariants --> pkg_session
   pkg_user_interaction --> pkg_agent
   pkg_user_interaction --> pkg_llm
+  pkg_approval --> pkg_agent
+  pkg_approval --> pkg_brand
+  pkg_approval --> pkg_llm
+  pkg_approval --> pkg_session
   pkg_workflow --> pkg_agent
   pkg_workflow --> pkg_brand
   pkg_workflow --> pkg_llm
+  pkg_tools --> pkg_agent
+  pkg_tools --> pkg_approval
+  pkg_tools --> pkg_code_runtime
+  pkg_tools --> pkg_llm
+  pkg_tools --> pkg_session
+  pkg_tools --> pkg_system_prompt
   pkg_agent_loop --> pkg_agent
   pkg_agent_loop --> pkg_llm
   pkg_agent_loop --> pkg_session
@@ -290,13 +298,14 @@ flowchart TD
 | [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`bash`](../packages/bash/bash), [`session`](../packages/core/session) |
 | [`session-persistence`](../packages/session-persistence/session-persistence) | `session-persistence` | [`session`](../packages/core/session) |
 | [`llm-replay`](../packages/support/llm-replay) | `support` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
-| [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) |
 | [`compact-basic`](../packages/compact/compact-basic) | `compact` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
 | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl) | `session-persistence` | [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) |
 | [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | `session-persistence` | [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) |
 | [`invariants`](../packages/support/invariants) | `support` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
 | [`user-interaction`](../packages/ui/user-interaction) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm) |
+| [`approval`](../packages/approval/approval) | `approval` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
 | [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) |
+| [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`approval`](../packages/approval/approval), [`code-runtime`](../packages/code-runtime/code-runtime), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) |
 | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
 | [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
 | [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |

+ 24 - 0
docs/persistence-catalog.md

@@ -11,6 +11,30 @@ The on-disk envelope around every payload is `SessionEvent` — `type`, monotoni
 
 ## Events
 
+### `approval/*`
+
+#### `approval/asked` — log-only
+
+An approval question was put to the answerer chain — log-only audit (like `hook/*`; NOT a surface event, carries no `surfaceOp`). `id` pairs it with the `approval/decided` that always follows; `toolName` is the tool the question is about, `callId` the exact tool call when the asker had one, `reason` the asker's human-readable explanation (e.g. a hook's permission-decision reason).
+
+```ts persistence-catalog
+'approval/asked': { id: ApprovalRequestId; toolName: string; callId?: CallId; reason?: string }
+```
+
+Types: [CallId](core-data-structures/core.md)
+
+Source: [`packages/approval/approval/src/index.ts:66`](../packages/approval/approval/src/index.ts)
+
+#### `approval/decided` — log-only
+
+The outcome of a prior `approval/asked` (same `id`) — log-only audit. Exactly one per ask, appended when the outcome is known: a decision, a cancellation, or the fail-closed `'unavailable'`.
+
+```ts persistence-catalog
+'approval/decided': { id: ApprovalRequestId; outcome: ApprovalOutcome }
+```
+
+Source: [`packages/approval/approval/src/index.ts:77`](../packages/approval/approval/src/index.ts)
+
 ### `assistant/*`
 
 #### `assistant/chunk` — log-only

+ 7 - 3
docs/tool-execution-pipeline.md

@@ -11,7 +11,8 @@ flowchart TD
   toolCall["Session event: <code>tool/call</code><br/>logged before execution"]
   presentCall["UI pending card<br/>presentCall(args)"]
   pre["<code>tools/pre-execute</code> waterfall<br/>hooks, permission, sandbox"]
-  denied["deny or ask<br/>tool body skipped"]
+  denied["denied<br/>tool body skipped"]
+  approval["<code>ctx.approval</code> one-shot prompt<br/>absent or unanswerable: deny"]
   around["<code>tools/execute</code> waterfall<br/>timeout, retry, metrics (around dispatch)"]
   toolBody["Registered tool execute() body"]
   fsGate["<code>fs/write-intent</code> or <code>fs/edit-intent</code><br/>tool-fs mutations only"]
@@ -25,7 +26,10 @@ flowchart TD
   toolCall --> pre
   pre -->|allow| around
   around --> toolBody
-  pre -->|deny or ask| denied
+  pre -->|deny| denied
+  pre -->|ask| approval
+  approval -->|allowed-once| around
+  approval -->|rejected, cancelled, unavailable| denied
   denied --> post
   toolBody --> fsGate
   fsGate --> toolBody
@@ -37,6 +41,6 @@ flowchart TD
   toolResult --> presentResult
 ```
 
-Filesystem read-before-edit checks live below `tool-fs` on the `fs/*` event gate; hook bridges and future permission prompts live on the generic pre/post tool waterfalls; and around-dispatch concerns like the tool-call timeout policy (`@deepseek-ai/dsh-timeout-policy`) wrap core dispatch on `tools/execute`. That split lets the same hooks observe bash, fs, web, todo, and subagent calls without coupling those tools to one policy service. Code Mode rides the same pipeline twice over: `run_code` is itself a registered tool body, and each tool call its program makes re-enters `ctx.tools.execute()` through BOTH waterfalls — serialized one at a time, logged as a `tool/code-dispatch` session event, with a deny surfacing to the program as a binding rejection (a sub-call's `additionalContext` is deliberately dropped — no safe outlet mid-run preserves call/result adjacency).
+Filesystem read-before-edit checks live below `tool-fs` on the `fs/*` event gate; hook bridges and the approval seam's permission prompts live on the generic pre/post tool waterfalls; and around-dispatch concerns like the tool-call timeout policy (`@deepseek-ai/dsh-timeout-policy`) wrap core dispatch on `tools/execute`. That split lets the same hooks observe bash, fs, web, todo, and subagent calls without coupling those tools to one policy service.
 
 Maintenance mode: curated Mermaid flow; exact tool schemas and event signatures live in generated catalogs.

+ 1 - 0
eslint.config.mjs

@@ -22,6 +22,7 @@ export default tseslint.config(
       '**/lib/**',
       '**/node_modules/**',
       '**/.sessions/**',
+      '.claude/**', // harness-local state (worktrees, skills) — other checkouts, not this one's sources
       '**/.doc-typecheck-*/**',
       'vendor/**', // vendored source keeps upstream style and idioms
       '**/*.js',

+ 1 - 0
packages/README.md

@@ -12,6 +12,7 @@ Packages are grouped by modular role at `packages/<group>/<pkg>/`. The group dir
 | [`llm/`](llm/README.md) | LLM capability family: the abstract service + provider adapters | Product — stable surface |
 | [`bash/`](bash/README.md) | Bash capability family: the executor seam, a local impl, and the model-facing tool | Product — stable surface |
 | [`code-runtime/`](code-runtime/README.md) | Code-execution capability family: the abstract runtime seam for model-written programs + a worker-thread backend | Product — stable surface |
+| [`approval/`](approval/README.md) | One-shot permission decisions | Product — stable surface |
 | [`fs/`](fs/README.md) | Filesystem capability family: the abstract seam, a local impl, and the model-facing file tools | Product — stable surface |
 | [`compact/`](compact/README.md) | Compaction capability family: the abstract seam + a basic backend (tool deferred) | Product — stable surface |
 | [`subagent/`](subagent/README.md) | Subagent capability family: the provider-registry seam and the model-facing delegation tool | Product — stable surface |

+ 9 - 0
packages/approval/README.md

@@ -0,0 +1,9 @@
+# approval/ — approval family
+
+The asking half of permission handling: one seam through which the harness puts a one-shot question — "may this specific action proceed?" — to whatever answerers a deployment composes, with a closed outcome vocabulary and a fail-closed default. The full design: [the approval-seam RFC](../../docs/rfc/proposed/feature/2026-07-06-approval-seam.md). All **product** packages.
+
+| Package | Role | ctx key |
+|---|---|---|
+| `approval/` | The `ApprovalService` mechanism (waterfall dispatch, cancellation, audit events) + the vocabulary (`ApprovalRequest`, `ApprovalOutcome`, `ApprovalRequestId`) | `ctx.approval` |
+
+Answerers live with their owners, not here: tests answer with inline scripted listeners, and the ACP bridge answerer is the staged first real one. Consumer today: [`core/tools`](../core/tools/) routes `tools/pre-execute`'s `ask` through the seam (degrading to deny when it is not mounted).

+ 11 - 0
packages/approval/approval/README.md

@@ -0,0 +1,11 @@
+# @deepseek-ai/dsh-approval
+
+Approval seam. Owns the `ctx.approval` service ([`ApprovalService`](src/index.ts)) and the one-shot permission vocabulary the harness shares: `ApprovalRequest` (agent + tool identity + reason + abort signal), the closed `ApprovalOutcome` union (`allowed-once` / `rejected` / `cancelled` / `unavailable`), the `ApprovalRequestId` brand pairing the two log-only audit events (`approval/asked` / `approval/decided`), and the `approval/request` waterfall the answerers listen on. Depends only on cordis and the core vocabulary packages (agent, session, llm brand), never on any UI.
+
+The contract in one line: `ctx.approval.request(req)` puts exactly one question — "may this specific action proceed?" — to whatever answerers the deployment composed, and always resolves to an outcome, never rejects: an aborted signal yields `cancelled`, a throwing or missing answerer yields `unavailable`, and `allowed-once` is a grant for the single asked-about action, never a class of future ones. The one precondition: ask from inside an open turn — the audit pair is turn-enclosed by contract (the turn is the durable log's commit/replay boundary; a bare event between turns is crash-tail garbage on reload), so an idle ask throws before appending anything.
+
+The service is the mechanism, answerers are the policy. Answerers are `approval/request` waterfall listeners occupying a single decision slot: answer for an agent you own by returning an outcome without calling `next()`, or delegate an agent you don't recognize by calling `next()` — the chain's built-in default is `unavailable`, so a deployment with no answerer (headless, CI) fails closed with zero configuration. Registration order across sibling plugins is not load-order deterministic; compose one terminal answerer per deployment and use `prepend` listeners only for decide-or-delegate gates.
+
+One seam serves both ask paths of [the sandbox RFC](../../../docs/rfc/proposed/feature/2026-07-06-sandbox.md): the `tools/pre-execute` `ask` decision (routed by [`@deepseek-ai/dsh-tools`](../../core/tools/) when this service is mounted; degrading to deny when it is not), and the sandbox post-denial escalated retry that RFC stages (the asker will live in the bash tool layer). The full design: [the approval-seam RFC](../../../docs/rfc/proposed/feature/2026-07-06-approval-seam.md).
+
+No answerer ships in this change — every ask fails closed to `unavailable` until one is composed (the ACP bridge answerer is the staged first one). The audit events are log-only session records — the model only ever sees the tool result the asker derives from the outcome.

+ 38 - 0
packages/approval/approval/package.json

@@ -0,0 +1,38 @@
+{
+  "name": "@deepseek-ai/dsh-approval",
+  "description": "Approval seam (ctx.approval) for the DeepSeek Harness: one-shot permission decisions dispatched to composed answerers over the approval/request waterfall, fail-closed by default",
+  "version": "0.0.1",
+  "private": true,
+  "type": "module",
+  "main": "lib/index.js",
+  "types": "lib/types/index.d.ts",
+  "exports": {
+    ".": {
+      "types": "./lib/types/index.d.ts",
+      "default": "./lib/index.js"
+    },
+    "./src/*": "./src/*",
+    "./package.json": "./package.json"
+  },
+  "files": [
+    "lib/index.js",
+    "lib/types/**/*.d.ts",
+    "lib/types/**/*.d.ts.map",
+    "src"
+  ],
+  "license": "BSD-3-Clause",
+  "peerDependencies": {
+    "@deepseek-ai/dsh-agent": "^0.0.1",
+    "@deepseek-ai/dsh-brand": "^0.0.1",
+    "@deepseek-ai/dsh-llm": "^0.0.1",
+    "@deepseek-ai/dsh-session": "^0.0.1",
+    "cordis": "^4.0.0-rc.6"
+  },
+  "devDependencies": {
+    "@deepseek-ai/dsh-agent": "workspace:^",
+    "@deepseek-ai/dsh-brand": "workspace:^",
+    "@deepseek-ai/dsh-llm": "workspace:^",
+    "@deepseek-ai/dsh-session": "workspace:^",
+    "cordis": "^4.0.0-rc.6"
+  }
+}

+ 240 - 0
packages/approval/approval/src/index.ts

@@ -0,0 +1,240 @@
+/**
+ * Approval seam: `ctx.approval` answers exactly one question — "may this
+ * specific action proceed?" — by dispatching the `approval/request` waterfall
+ * to whatever answerers the deployment composed (an ACP editor prompt, an
+ * auto-decide policy, a scripted test listener) and returning a closed
+ * {@link ApprovalOutcome}. With no answerer the waterfall falls through to the
+ * built-in default `'unavailable'`: absence of a UI can never grant anything.
+ *
+ * The service is the MECHANISM (dispatch, cancellation, audit); answerers are
+ * the POLICY. It serves both ask paths the sandbox RFC names — the
+ * `tools/pre-execute` `ask` decision today, and the sandbox post-denial
+ * escalation when that phase lands — so every asker shares one outcome
+ * vocabulary and one audit trail. Grants are one-shot by design: an
+ * `'allowed-once'` outcome authorizes the single action it was asked about,
+ * never a class of future actions.
+ *
+ * Every request lands two log-only session events on the requesting agent's
+ * log (`approval/asked` / `approval/decided`, paired by
+ * {@link ApprovalRequestId}) — an audit trail, deliberately NOT part of the
+ * model-visible transcript: the model only ever sees the tool result the
+ * caller derives from the outcome.
+ *
+ * @module @deepseek-ai/dsh-approval
+ */
+
+import { randomUUID } from 'node:crypto'
+import { Context, Service } from 'cordis'
+import type { Branded } from '@deepseek-ai/dsh-brand'
+import type { Agent } from '@deepseek-ai/dsh-agent'
+import type { CallId } from '@deepseek-ai/dsh-llm'
+import type {} from '@deepseek-ai/dsh-session'
+
+declare module 'cordis' {
+  interface Context {
+    approval: ApprovalService
+  }
+
+  interface Events {
+    /**
+     * Waterfall asking the composed answerers to decide one approval request.
+     * Dispatched only from {@link ApprovalService.request} — callers go through
+     * the service (which owns cancellation and the audit events), never through
+     * `ctx.waterfall` directly. A listener that can answer for this request's
+     * agent returns an outcome WITHOUT calling `next()` (the decision slot is
+     * single-occupancy, first listener to answer wins); a listener that does
+     * not recognize the agent MUST call `next()` so another answerer — or the
+     * fail-closed default `'unavailable'` — gets the question. Throwing is
+     * contained by the service and yields `'unavailable'`.
+     * @param req - the pending decision (agent, tool identity, reason, signal).
+     * @mode waterfall
+     */
+    'approval/request'(this: ApprovalService, req: ApprovalRequest, next: () => Promise<ApprovalOutcome>): Promise<ApprovalOutcome>
+  }
+}
+
+declare module '@deepseek-ai/dsh-session' {
+  interface SessionEventMap {
+    /**
+     * An approval question was put to the answerer chain — log-only audit
+     * (like `hook/*`; NOT a surface event, carries no `surfaceOp`). `id` pairs
+     * it with the `approval/decided` that always follows; `toolName` is the
+     * tool the question is about, `callId` the exact tool call when the asker
+     * had one, `reason` the asker's human-readable explanation (e.g. a hook's
+     * permission-decision reason).
+     */
+    'approval/asked': {
+      id: ApprovalRequestId
+      toolName: string
+      callId?: CallId
+      reason?: string
+    }
+    /**
+     * The outcome of a prior `approval/asked` (same `id`) — log-only audit.
+     * Exactly one per ask, appended when the outcome is known: a decision, a
+     * cancellation, or the fail-closed `'unavailable'`.
+     */
+    'approval/decided': {
+      id: ApprovalRequestId
+      outcome: ApprovalOutcome
+    }
+  }
+}
+
+/**
+ * Pairs one `approval/asked` audit event with its `approval/decided`.
+ * Service-issued (one fresh id per {@link ApprovalService.request} call).
+ */
+export type ApprovalRequestId = Branded<'ApprovalRequestId'>
+
+/**
+ * Brand a string as an {@link ApprovalRequestId}.
+ * @param id - the raw id string to brand.
+ * @returns the same string carrying the brand.
+ */
+export function ApprovalRequestId(id: string): ApprovalRequestId {
+  return id as ApprovalRequestId
+}
+
+/**
+ * The closed outcome vocabulary of one approval request.
+ *
+ * - `'allowed-once'` — a one-shot grant for exactly the asked-about action;
+ *   consumed by proceeding, never a durable authorization.
+ * - `'rejected'` — an answerer (human or policy) said no.
+ * - `'cancelled'` — the question was withdrawn: the prompt was dismissed, or
+ *   the requesting execution aborted while the question was pending.
+ * - `'unavailable'` — nobody composed could answer (no listener, none that
+ *   recognizes the agent, or an answerer failed). Callers MUST fail closed on
+ *   it, exactly like `'rejected'` — the two differ only for audit and wording.
+ */
+export type ApprovalOutcome = 'allowed-once' | 'rejected' | 'cancelled' | 'unavailable'
+
+/** Every {@link ApprovalOutcome}, for runtime normalization of answerer returns. */
+const OUTCOMES: readonly ApprovalOutcome[] = ['allowed-once', 'rejected', 'cancelled', 'unavailable']
+
+/**
+ * Whether the log currently sits inside an open turn (a `turn/start` not yet
+ * closed by a `turn/end`) — the {@link ApprovalService.request} precondition.
+ * The audit pair must be turn-enclosed: the turn is the durable log's
+ * commit/replay boundary, so a bare event appended between turns is
+ * indistinguishable from a crash tail and silently dropped on reload.
+ */
+function hasOpenTurn(events: readonly SessionEvent[]): boolean {
+  for (let index = events.length - 1; index >= 0; index -= 1) {
+    const type = (events[index] as SessionEvent).type
+    if (type === 'turn/start') return true
+    if (type === 'turn/end') return false
+  }
+  return false
+}
+
+/**
+ * One concrete permission question. Identifies the action precisely enough
+ * for an answerer to present it and for the audit events to reconstruct what
+ * was asked — it deliberately does NOT carry tool arguments: a UI answerer
+ * attaches the prompt to the already-streamed tool call via `callId` instead
+ * of re-rendering the call.
+ */
+export interface ApprovalRequest {
+  /**
+   * The agent on whose behalf the question is asked. Routes the question (a
+   * UI answerer only answers for agents it owns) and receives the audit
+   * events on its session log.
+   */
+  agent: Agent
+  /** The tool the question is about (presentation and audit). */
+  toolName: string
+  /**
+   * The exact tool call being decided, when the asker has one — lets a UI
+   * attach the prompt to the tool call it already streamed.
+   */
+  callId?: CallId
+  /** The asker's human-readable explanation of WHY it is asking. */
+  reason?: string
+  /**
+   * Aborting withdraws the question: the request settles `'cancelled'`
+   * immediately and a late answer from a still-pending answerer is discarded.
+   */
+  signal?: AbortSignal
+}
+
+/**
+ * The `ctx.approval` service: dispatches {@link ApprovalRequest}s to the
+ * `approval/request` waterfall and audits every ask/outcome pair to the
+ * requesting agent's session log. Stateless between requests — grants are
+ * returned to the caller, never stored here.
+ */
+export class ApprovalService extends Service {
+  constructor(ctx: Context) {
+    super(ctx, 'approval')
+  }
+
+  /**
+   * Ask the composed answerers to decide one request. Requires an open turn
+   * on the requesting agent's session — the audit pair below is turn-enclosed
+   * by contract (the turn is the log's commit/replay boundary; an idle append
+   * would be dropped as crash tail on reload) — and throws before appending
+   * anything when called idle; asking outside a turn is a deferred design.
+   * Within that precondition it always resolves to an outcome, never rejects:
+   * an aborted signal yields `'cancelled'`, a missing or throwing answerer
+   * yields `'unavailable'` (fail closed), and a rogue non-vocabulary return
+   * value is normalized to `'unavailable'`. Appends the
+   * `approval/asked`/`approval/decided` audit pair (log-only) around the
+   * decision regardless of outcome.
+   * @param req - the pending decision (agent, tool identity, reason, signal).
+   * @returns the closed outcome; `'allowed-once'` is the only grant.
+   */
+  async request(req: ApprovalRequest): Promise<ApprovalOutcome> {
+    if (!hasOpenTurn(req.agent.session.events)) {
+      throw new Error(
+        'approval.request() outside an open turn: the approval/asked + approval/decided audit pair '
+        + 'must be turn-enclosed (a bare event between turns is crash-tail garbage on reload). '
+        + 'Ask from inside the turn that needs the decision.',
+      )
+    }
+    const id = ApprovalRequestId(randomUUID())
+    req.agent.session.append('approval/asked', {
+      id,
+      toolName: req.toolName,
+      ...req.callId !== undefined ? { callId: req.callId } : {},
+      ...req.reason !== undefined ? { reason: req.reason } : {},
+    })
+    const outcome = await this.decide(req)
+    req.agent.session.append('approval/decided', { id, outcome })
+    return outcome
+  }
+
+  /** Dispatch the waterfall, contained and raced against `req.signal`. */
+  private async decide(req: ApprovalRequest): Promise<ApprovalOutcome> {
+    if (req.signal?.aborted) return 'cancelled'
+    // Enter the promise chain BEFORE dispatching: a listener that throws
+    // SYNCHRONOUSLY (before its first await) must land in the same rejection
+    // path as an async one — `Promise.resolve(call())` would let it escape
+    // the containment into the caller.
+    const answer: Promise<ApprovalOutcome> = Promise.resolve().then(
+      () => this.ctx.waterfall(this, 'approval/request', req, () => Promise.resolve<ApprovalOutcome>('unavailable')),
+    ).then(
+      // Normalize a rogue (non-vocabulary) answerer return to the fail-closed
+      // outcome instead of leaking it into callers' closed-union switches.
+      outcome => OUTCOMES.includes(outcome) ? outcome : 'unavailable',
+      // A throwing answerer must fail the QUESTION closed, not the caller's
+      // tool call open — the seam contains its callbacks.
+      () => 'unavailable',
+    )
+    const signal = req.signal
+    if (signal === undefined) return answer
+    return await new Promise<ApprovalOutcome>((resolve) => {
+      const onAbort = () => { resolve('cancelled') }
+      signal.addEventListener('abort', onAbort, { once: true })
+      void answer.then((outcome) => {
+        signal.removeEventListener('abort', onAbort)
+        // After an abort won the race this resolve is a settled-promise no-op:
+        // the late answer is discarded by construction.
+        resolve(outcome)
+      })
+    })
+  }
+}
+
+export default ApprovalService

+ 203 - 0
packages/approval/approval/tests/approval.spec.ts

@@ -0,0 +1,203 @@
+import { describe, expect, it } from 'vitest'
+import { Context } from 'cordis'
+import type { Agent } from '@deepseek-ai/dsh-agent'
+import { CallId } from '@deepseek-ai/dsh-llm'
+import type { SessionEvent } from '@deepseek-ai/dsh-session'
+import ApprovalService, { ApprovalOutcome, ApprovalRequest } from '@deepseek-ai/dsh-approval'
+
+/**
+ * A minimal Agent stand-in — the service only reaches `agent.session.append`
+ * and folds `.events`. Seeded inside an open turn by default (request()'s
+ * turn-enclosure precondition); pass `seed` to stage idle/closed logs.
+ * Returns the recorded audit appends alongside the fake.
+ */
+function fakeAgent(seed: Array<{ type: string }> = [{ type: 'turn/start' }, { type: 'user/message' }]): { agent: Agent; appended: Array<{ type: string; data: Record<string, unknown> }> } {
+  const appended: Array<{ type: string; data: Record<string, unknown> }> = []
+  const agent = {
+    session: {
+      events: seed,
+      append: (type: string, data: Record<string, unknown>) => {
+        appended.push({ type, data })
+        return { type, data } as unknown as SessionEvent
+      },
+    },
+  } as unknown as Agent
+  return { agent, appended }
+}
+
+async function mounted(): Promise<Context> {
+  const ctx = new Context()
+  await ctx.plugin(ApprovalService)
+  return ctx
+}
+
+function requestOf(agent: Agent, overrides: Partial<ApprovalRequest> = {}): ApprovalRequest {
+  return { agent, toolName: 'echo', ...overrides }
+}
+
+describe('ApprovalService.request', () => {
+  it('throws before appending anything when no turn has ever opened (idle ask)', async () => {
+    const ctx = await mounted()
+    const { agent, appended } = fakeAgent([])
+
+    await expect(ctx.approval.request(requestOf(agent))).rejects.toThrow(/outside an open turn/)
+    expect(appended).toHaveLength(0)
+  })
+
+  it('throws between turns — a closed turn does not satisfy the enclosure precondition', async () => {
+    const ctx = await mounted()
+    const { agent, appended } = fakeAgent([{ type: 'turn/start' }, { type: 'turn/end' }])
+
+    await expect(ctx.approval.request(requestOf(agent))).rejects.toThrow(/outside an open turn/)
+    expect(appended).toHaveLength(0)
+  })
+
+  it('fails closed to unavailable when nobody listens, auditing the asked/decided pair', async () => {
+    const ctx = await mounted()
+    const { agent, appended } = fakeAgent()
+
+    const outcome = await ctx.approval.request(requestOf(agent, { callId: CallId('call-1'), reason: 'hook says ask' }))
+
+    expect(outcome).toBe('unavailable')
+    expect(appended.map(e => e.type)).toEqual(['approval/asked', 'approval/decided'])
+    const [asked, decided] = appended
+    expect(asked?.data).toMatchObject({ toolName: 'echo', callId: 'call-1', reason: 'hook says ask' })
+    expect(decided?.data).toMatchObject({ outcome: 'unavailable' })
+    expect(decided?.data['id']).toBe(asked?.data['id'])
+  })
+
+  it('omits absent optional fields from the asked audit event', async () => {
+    const ctx = await mounted()
+    const { agent, appended } = fakeAgent()
+
+    await ctx.approval.request(requestOf(agent))
+
+    expect(Object.keys(appended[0]?.data ?? {}).sort()).toEqual(['id', 'toolName'])
+  })
+
+  it('returns the first answering listener outcome (single decision slot)', async () => {
+    const ctx = await mounted()
+    const { agent } = fakeAgent()
+    let secondRan = false
+    ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('allowed-once'))
+    ctx.on('approval/request', () => {
+      secondRan = true
+      return Promise.resolve<ApprovalOutcome>('rejected')
+    })
+
+    await expect(ctx.approval.request(requestOf(agent))).resolves.toBe('allowed-once')
+    expect(secondRan).toBe(false)
+  })
+
+  it('lets a non-owning listener delegate via next() down to the fail-closed default', async () => {
+    const ctx = await mounted()
+    const { agent } = fakeAgent()
+    ctx.on('approval/request', (_req, next) => next())
+
+    await expect(ctx.approval.request(requestOf(agent))).resolves.toBe('unavailable')
+  })
+
+  it('contains a throwing answerer as unavailable', async () => {
+    const ctx = await mounted()
+    const { agent, appended } = fakeAgent()
+    ctx.on('approval/request', () => Promise.reject(new Error('transport died')))
+
+    await expect(ctx.approval.request(requestOf(agent))).resolves.toBe('unavailable')
+    expect(appended[1]?.data).toMatchObject({ outcome: 'unavailable' })
+  })
+
+  it('normalizes a rogue non-vocabulary answer to unavailable', async () => {
+    const ctx = await mounted()
+    const { agent } = fakeAgent()
+    // A JS answerer can return anything; the seam must not leak it into
+    // callers' closed-union switches.
+    ctx.on('approval/request', () => Promise.resolve('yolo' as ApprovalOutcome))
+
+    await expect(ctx.approval.request(requestOf(agent))).resolves.toBe('unavailable')
+  })
+
+  it('settles cancelled immediately on an already-aborted signal without asking anyone', async () => {
+    const ctx = await mounted()
+    const { agent, appended } = fakeAgent()
+    let asked = false
+    ctx.on('approval/request', () => {
+      asked = true
+      return Promise.resolve<ApprovalOutcome>('allowed-once')
+    })
+
+    const outcome = await ctx.approval.request(requestOf(agent, { signal: AbortSignal.abort() }))
+
+    expect(outcome).toBe('cancelled')
+    expect(asked).toBe(false)
+    expect(appended.map(e => e.type)).toEqual(['approval/asked', 'approval/decided'])
+    expect(appended[1]?.data).toMatchObject({ outcome: 'cancelled' })
+  })
+
+  it('resolves cancelled when the signal aborts mid-question and discards the late answer', async () => {
+    const ctx = await mounted()
+    const { agent, appended } = fakeAgent()
+    let settleLate: ((outcome: ApprovalOutcome) => void) | undefined
+    ctx.on('approval/request', () => new Promise<ApprovalOutcome>((resolve) => { settleLate = resolve }))
+    const controller = new AbortController()
+
+    const pending = ctx.approval.request(requestOf(agent, { signal: controller.signal }))
+    controller.abort()
+    await expect(pending).resolves.toBe('cancelled')
+
+    // The answerer settles after the fact: no second decided event appears.
+    settleLate?.('allowed-once')
+    await Promise.resolve()
+    expect(appended.filter(e => e.type === 'approval/decided')).toHaveLength(1)
+    expect(appended[1]?.data).toMatchObject({ outcome: 'cancelled' })
+  })
+
+  it('discards a late REJECTION after abort without an unhandled rejection', async () => {
+    const ctx = await mounted()
+    const { agent } = fakeAgent()
+    let rejectLate: ((error: Error) => void) | undefined
+    ctx.on('approval/request', () => new Promise<ApprovalOutcome>((_resolve, reject) => { rejectLate = reject }))
+    const controller = new AbortController()
+
+    const pending = ctx.approval.request(requestOf(agent, { signal: controller.signal }))
+    controller.abort()
+    await expect(pending).resolves.toBe('cancelled')
+
+    rejectLate?.(new Error('answered too late'))
+    // Drain microtasks: the contained rejection must not escape the seam.
+    await new Promise((resolve) => { setTimeout(resolve, 0) })
+  })
+
+  it('resolves the answer when the signal never aborts', async () => {
+    const ctx = await mounted()
+    const { agent } = fakeAgent()
+    ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('rejected'))
+    const controller = new AbortController()
+
+    await expect(ctx.approval.request(requestOf(agent, { signal: controller.signal }))).resolves.toBe('rejected')
+  })
+
+  it('issues a fresh id per request', async () => {
+    const ctx = await mounted()
+    const { agent, appended } = fakeAgent()
+
+    await ctx.approval.request(requestOf(agent))
+    await ctx.approval.request(requestOf(agent))
+
+    const ids = appended.filter(e => e.type === 'approval/asked').map(e => e.data['id'])
+    expect(ids).toHaveLength(2)
+    expect(ids[0]).not.toBe(ids[1])
+  })
+
+  it('drops a disposed plugin listener from the chain (HMR safety)', async () => {
+    const ctx = await mounted()
+    const { agent } = fakeAgent()
+    const fiber = await ctx.plugin((inner: Context) => {
+      inner.on('approval/request', () => Promise.resolve<ApprovalOutcome>('allowed-once'))
+    })
+    await expect(ctx.approval.request(requestOf(agent))).resolves.toBe('allowed-once')
+
+    await fiber.dispose()
+    await expect(ctx.approval.request(requestOf(agent))).resolves.toBe('unavailable')
+  })
+})
+

+ 36 - 0
packages/approval/approval/tsconfig.json

@@ -0,0 +1,36 @@
+{
+  "extends": "../../../tsconfig.base.json",
+  "compilerOptions": {
+    "rootDir": "src",
+    "outDir": "lib/types"
+  },
+  "include": [
+    "src"
+  ],
+  "references": [
+    {
+      "path": "../../../vendor/cosmokit"
+    },
+    {
+      "path": "../../../vendor/cordis"
+    },
+    {
+      "path": "../../../vendor/schemastery"
+    },
+    {
+      "path": "../../util/brand"
+    },
+    {
+      "path": "../../llm/llm"
+    },
+    {
+      "path": "../../core/session"
+    },
+    {
+      "path": "../../core/agent"
+    },
+    {
+      "path": "../../core/system-prompt"
+    }
+  ]
+}

+ 3 - 3
packages/core/tools/README.md

@@ -22,7 +22,7 @@ tools:
 
 ### Injected services
 
-`SystemPrompt` — the registry automatically feeds its tool schemas into the system-prompt assembly via `ctx.systemPrompt.tools()`.
+`SystemPrompt` — the registry automatically feeds its tool schemas into the system-prompt assembly via `ctx.systemPrompt.tools()`. The approval seam is consumed opportunistically instead (`ctx.get('approval')`, no static inject): a deployment without it keeps the ask→deny degrade, and the registry stays active either way.
 
 ### Events
 
@@ -38,14 +38,14 @@ tools:
 - `ToolDefinition` — `ToolSchema` + `execute(args, exec): Promise<ContentBlock[] | { content: ContentBlock[]; meta? }>` (the bare array is the model-facing content; the object form additionally attaches an opaque, JSON-serializable `meta` presentation payload persisted on the `tool/result` event and handed back to `presentResult`), plus optional `presentCall(args)` / `presentResult(args, result)` for tool-owned UI presentation (see below). It also carries an optional cooperative timeout budget `timeoutMs?: number` (ms) enforced by `@deepseek-ai/dsh-timeout-policy`, never sent to the model.
 - `ToolExecution` — one pending tool call: `{ callId, name, arguments, agent?, signal? }`.
 - `ToolExecutionResult` — outcome: `{ callId, content, isError, error?, additionalContext?, meta? }`. On failure with a `HarnessError`, `error: { name, code }` carries the structured failure class alongside the model-facing text (the loop forwards it onto the `tool/result` session event for retry/sandbox plugins and replay). `additionalContext` (a `HookContext`) ferries any `tools/post-execute` context up to the loop, which buffers it and appends it as a `context/message` after all `tool/result`s in the step. `meta` is the tool's opaque presentation payload from a successful `execute` (the object return form); the loop forwards it onto the `tool/result` session event for result-card rendering.
-- `PreToolDecision` — `{kind:'allow'}` | `{kind:'deny', reason}` | `{kind:'ask', reason?}`. Input rewrite (changing `arguments`) is deliberately NOT offered (it would desync the pre-execution audit/history/UI from what ran — its own proposed RFC); `ask` degrades to `deny` until the permission system lands.
+- `PreToolDecision` — `{kind:'allow'}` | `{kind:'deny', reason}` | `{kind:'ask', reason?}`. Input rewrite (changing `arguments`) is deliberately NOT offered (it would desync the pre-execution audit/history/UI from what ran — its own proposed RFC); `ask` is serviced by [`ctx.approval`](../../approval/approval/README.md) when a deployment mounts it (`allowed-once` proceeds to dispatch; `rejected`/`cancelled`/`unavailable` deny with distinct reasons) and degrades to `deny` when none is mounted or the execution carries no agent.
 - `PostToolDecision` — `{kind:'accept', content?, additionalContext?}` (keep the call successful, optionally replacing the model-facing content) | `{kind:'block', feedback, additionalContext?}` (turn it into an `isError` whose content is the corrective feedback). Output replacement is clean because `tool/result` is logged AFTER `execute()` returns.
 - `ToolCallView` / `ToolResultView` — provider-neutral `card`-tagged render intents a tool returns from `presentCall` / `presentResult` to own how a UI renders ITS calls (see "Tool-owned UI presentation").
 
 ### Extension points
 
 - Tool plugins call `ctx.tools.register()` — schemas flow into the assembly automatically.
-- `tools/pre-execute` is the allow/deny gate (sandbox, permission, hooks): listeners receive `(exec, next)` and call `next()` to delegate to the default (allow) or return a `PreToolDecision` to short-circuit; a `deny`/`ask` skips dispatch and yields an `isError` result. `tools/execute` is the around-dispatch seam (timeout, retry, metrics): listeners receive `(exec, next)` and call `next()` to delegate to core dispatch (returning its `ToolExecutionResult`, optionally wrapped), or return a replacement result to short-circuit dispatch; the base `next()` IS dispatch-with-normalization, so `await next()` already yields an `isError` result for a thrown/unknown tool (never a raw throw). A wrapper mutates `exec` in place before `next()` — e.g. replacing `exec.signal` with a per-call deadline — because cordis `next()` ignores passed arguments. `tools/post-execute` is the inspect/transform seam: `(exec, result, next)` → a `PostToolDecision` that can replace content, block with feedback, or attach `additionalContext`. Core dispatch is the base of the `tools/execute` waterfall; the tool body keeps its own try/catch so a thrown tool still reaches `post-execute` as an `isError`. All follow the typed-Decision idiom shared with the `agent/*` interception seams (see [`dsh-agent`](../agent/README.md)); `@deepseek-ai/dsh-timeout-policy` is the reference `tools/execute` wrapper.
+- `tools/pre-execute` is the allow/deny gate (sandbox, permission, hooks): listeners receive `(exec, next)` and call `next()` to delegate to the default (allow) or return a `PreToolDecision` to short-circuit; a `deny` skips dispatch and yields an `isError` result, and an `ask` resolves through the approval seam first — only a grant dispatches (see `PreToolDecision` above). `tools/execute` is the around-dispatch seam (timeout, retry, metrics): listeners receive `(exec, next)` and call `next()` to delegate to core dispatch (returning its `ToolExecutionResult`, optionally wrapped), or return a replacement result to short-circuit dispatch; the base `next()` IS dispatch-with-normalization, so `await next()` already yields an `isError` result for a thrown/unknown tool (never a raw throw). A wrapper mutates `exec` in place before `next()` — e.g. replacing `exec.signal` with a per-call deadline — because cordis `next()` ignores passed arguments. `tools/post-execute` is the inspect/transform seam: `(exec, result, next)` → a `PostToolDecision` that can replace content, block with feedback, or attach `additionalContext`. Core dispatch is the base of the `tools/execute` waterfall; the tool body keeps its own try/catch so a thrown tool still reaches `post-execute` as an `isError`. All follow the typed-Decision idiom shared with the `agent/*` interception seams (see [`dsh-agent`](../agent/README.md)); `@deepseek-ai/dsh-timeout-policy` is the reference `tools/execute` wrapper.
 - MCP servers: one plugin per server, discover tools, call `ctx.tools.register()` with the server's schemas.
 
 ### Typed tool parameter schemas

+ 2 - 0
packages/core/tools/package.json

@@ -23,6 +23,7 @@
   "license": "BSD-3-Clause",
   "peerDependencies": {
     "@deepseek-ai/dsh-agent": "^0.0.1",
+    "@deepseek-ai/dsh-approval": "^0.0.1",
     "@deepseek-ai/dsh-code-runtime": "^0.0.1",
     "@deepseek-ai/dsh-llm": "^0.0.1",
     "@deepseek-ai/dsh-session": "^0.0.1",
@@ -34,6 +35,7 @@
   },
   "devDependencies": {
     "@deepseek-ai/dsh-agent": "workspace:^",
+    "@deepseek-ai/dsh-approval": "workspace:^",
     "@deepseek-ai/dsh-code-runtime": "workspace:^",
     "@deepseek-ai/dsh-llm": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",

+ 52 - 15
packages/core/tools/src/index.ts

@@ -19,10 +19,13 @@
 import { Context, Service } from 'cordis'
 import z from 'schemastery'
 import type { CallId, ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm'
-import { HarnessError } from '@deepseek-ai/dsh-llm'
+import { assertNever, HarnessError } from '@deepseek-ai/dsh-llm'
 import type { Agent, HookContext } from '@deepseek-ai/dsh-agent'
 import type {} from '@deepseek-ai/dsh-system-prompt'
 import type { CodeRuntime } from '@deepseek-ai/dsh-code-runtime'
+// Type-only: makes `ctx.get('approval')` resolve to the ApprovalService
+// augmentation. The seam stays optional at runtime — see `serviceAsk`.
+import type {} from '@deepseek-ai/dsh-approval'
 import type { ToolCallView, ToolResultView } from './presentation.ts'
 import { createRunCodeTool, RUN_CODE_NAME, SDK_SECTION_ORDER } from './code-mode.ts'
 import { renderToolsSdk } from './ts-types.ts'
@@ -83,8 +86,8 @@ declare module 'cordis' {
      * or return a {@link PreToolDecision} without calling `next()` to
      * short-circuit. A `deny` skips dispatch and yields an `isError` result; the
      * tool body never runs. Input rewrite is deliberately NOT offered here (see
-     * {@link PreToolDecision}); `ask` degrades to deny until the permission
-     * system lands (`FIXME(permissions)`).
+     * {@link PreToolDecision}); `ask` is serviced by the `ctx.approval` seam
+     * when one is mounted, and degrades to deny otherwise.
      * @param exec - the pending call (name, parsed arguments, caller agent).
      * @mode waterfall
      */
@@ -268,8 +271,9 @@ export interface ToolExecutionResult {
  *   would desync the UI from what RAN. That consistency redesign is its own
  *   `proposed` RFC; `TODO(pre-tool-input-rewrite)` anchors it at the call site.)
  * - `deny` skips dispatch; the loop records an `isError` result carrying `reason`.
- * - `ask` is the permission-prompt intent; until the permission system exists it
- *   degrades to `deny` (`FIXME(permissions)`).
+ * - `ask` is the permission-prompt intent: serviced as a one-shot decision by
+ *   the `ctx.approval` seam when one is mounted (`allowed-once` proceeds to
+ *   dispatch; every other outcome denies), degrading to `deny` when none is.
  */
 export type PreToolDecision =
   | { kind: 'allow' }
@@ -486,22 +490,17 @@ export class ToolRegistry extends Service {
    */
   async execute(exec: ToolExecution): Promise<ToolExecutionResult> {
     try {
-      // --- Gate: tools/pre-execute. A deny (or an ask, which degrades to deny
-      // until the permission system lands) skips dispatch entirely. ---
-      const decision = await this.ctx.waterfall(
+      // --- Gate: tools/pre-execute. An `ask` resolves through the approval
+      // seam (or degrades) to allow/deny before the shared deny path. ---
+      const gate = await this.ctx.waterfall(
         this, 'tools/pre-execute', exec,
         () => Promise.resolve<PreToolDecision>({ kind: 'allow' }),
       )
+      const decision = gate.kind === 'ask' ? await this.serviceAsk(exec, gate) : gate
       if (decision.kind !== 'allow') {
-        // deny → isError. ask has no permission UI yet, so degrade to deny
-        // (FIXME(permissions)): a forthcoming permission system turns `ask` into
-        // a real prompt; today it is the conservative "not allowed".
-        const reason = decision.kind === 'deny'
-          ? decision.reason
-          : decision.reason ?? `tool "${exec.name}" requires approval (not yet supported)`
         const denied: ToolExecutionResult = {
           callId: exec.callId,
-          content: [{ type: 'text', text: `Error: ${reason}` }],
+          content: [{ type: 'text', text: `Error: ${decision.reason}` }],
           isError: true,
         }
         return await this.postExecute(exec, denied)
@@ -540,6 +539,44 @@ export class ToolRegistry extends Service {
     }
   }
 
+  /**
+   * Resolve an `ask` decision to allow/deny through the approval seam. The
+   * seam is consumed opportunistically with `ctx.get('approval')` — a
+   * deployment that composes no ApprovalService keeps the historical degrade
+   * to deny, and an unmount mid-session degrades the same way on the next ask.
+   * An agent-less execution also degrades: without an agent there is no
+   * session to audit to and no UI to route to. Otherwise the outcome maps
+   * one-to-one — `allowed-once` proceeds; the three non-grants deny with
+   * distinct reasons so the model can tell a human "no" from an absent
+   * approval channel.
+   */
+  private async serviceAsk(
+    exec: ToolExecution,
+    ask: Extract<PreToolDecision, { kind: 'ask' }>,
+  ): Promise<Extract<PreToolDecision, { kind: 'allow' | 'deny' }>> {
+    const approval = this.ctx.get('approval')
+    if (approval === undefined) {
+      return { kind: 'deny', reason: ask.reason ?? `tool "${exec.name}" requires approval (not yet supported)` }
+    }
+    if (exec.agent === undefined) {
+      return { kind: 'deny', reason: `tool "${exec.name}" requires approval, but the call has no agent to route it through` }
+    }
+    const outcome = await approval.request({
+      agent: exec.agent,
+      toolName: exec.name,
+      callId: exec.callId,
+      ...ask.reason !== undefined ? { reason: ask.reason } : {},
+      ...exec.signal !== undefined ? { signal: exec.signal } : {},
+    })
+    switch (outcome) {
+      case 'allowed-once': return { kind: 'allow' }
+      case 'rejected': return { kind: 'deny', reason: `the user rejected tool "${exec.name}"` }
+      case 'cancelled': return { kind: 'deny', reason: `approval for tool "${exec.name}" was cancelled` }
+      case 'unavailable': return { kind: 'deny', reason: `tool "${exec.name}" requires approval, but no approval channel is available` }
+      default: return assertNever(outcome, 'ApprovalOutcome')
+    }
+  }
+
   /**
    * Run the `tools/post-execute` waterfall over a dispatched `result` and apply
    * its {@link PostToolDecision}: `accept` keeps the call successful (replacing

+ 104 - 1
packages/core/tools/tests/tools.spec.ts

@@ -2,6 +2,8 @@ import { describe, expect, expectTypeOf, it } from 'vitest'
 import { Context } from 'cordis'
 import { CallId, HarnessError } from '@deepseek-ai/dsh-llm'
 import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
+import type { Agent } from '@deepseek-ai/dsh-agent'
+import ApprovalService, { type ApprovalOutcome, type ApprovalRequest } from '@deepseek-ai/dsh-approval'
 import ToolRegistry, {
   defineTool, schemaSpecToJsonSchema, validateArgs, ToolArgsError, ToolNotFoundError,
   type InferArgs, type SchemaSpec, type PreToolDecision, type PostToolDecision,
@@ -158,7 +160,7 @@ describe('ToolRegistry', () => {
     expect(result.content[0]).toMatchObject({ text: 'Error: denied by policy' })
   })
 
-  it('an ask decision degrades to deny until the permission system lands', async () => {
+  it('an ask decision degrades to deny when no approval seam is mounted', async () => {
     const ctx = await setup()
     ctx.tools.register(echoTool)
 
@@ -181,6 +183,107 @@ describe('ToolRegistry', () => {
     expect(result.content[0]).toMatchObject({ text: 'Error: tool "echo" requires approval (not yet supported)' })
   })
 
+  describe('ask routing through ctx.approval', () => {
+    /**
+     * A minimal Agent stand-in — the approval seam reaches
+     * `agent.session.append` and folds `.events`; the seeded open turn
+     * satisfies request()'s enclosure precondition.
+     */
+    function fakeAgent(): Agent {
+      return {
+        session: { events: [{ type: 'turn/start' }], append: () => ({}) },
+      } as unknown as Agent
+    }
+
+    async function approvalSetup() {
+      const ctx = await setup()
+      await ctx.plugin(ApprovalService)
+      ctx.tools.register(echoTool)
+      return ctx
+    }
+
+    it('dispatches the tool when the answerer grants allowed-once, forwarding the ask fields', async () => {
+      const ctx = await approvalSetup()
+      const agent = fakeAgent()
+      const controller = new AbortController()
+      const seen: ApprovalRequest[] = []
+      ctx.on('approval/request', (req) => {
+        seen.push(req)
+        return Promise.resolve<ApprovalOutcome>('allowed-once')
+      })
+      ctx.on('tools/pre-execute', async (_exec, _next): Promise<PreToolDecision> =>
+        ({ kind: 'ask', reason: 'hook wants a human' }))
+
+      const result = await ctx.tools.execute({
+        callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' }, agent, signal: controller.signal,
+      })
+
+      expect(result).toMatchObject({ isError: false, content: [{ type: 'text', text: 'hi' }] })
+      expect(seen).toHaveLength(1)
+      expect(seen[0]).toMatchObject({ agent, toolName: 'echo', callId: 'c1', reason: 'hook wants a human' })
+      expect(seen[0]?.signal).toBe(controller.signal)
+    })
+
+    it('denies with the user-rejection reason on rejected', async () => {
+      const ctx = await approvalSetup()
+      ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('rejected'))
+      ctx.on('tools/pre-execute', async (_exec, _next): Promise<PreToolDecision> => ({ kind: 'ask' }))
+
+      const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: {}, agent: fakeAgent() })
+      expect(result.isError).toBe(true)
+      expect(result.content[0]).toMatchObject({ text: 'Error: the user rejected tool "echo"' })
+    })
+
+    it('denies with the cancellation reason on cancelled', async () => {
+      const ctx = await approvalSetup()
+      ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('cancelled'))
+      ctx.on('tools/pre-execute', async (_exec, _next): Promise<PreToolDecision> => ({ kind: 'ask' }))
+
+      const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: {}, agent: fakeAgent() })
+      expect(result.isError).toBe(true)
+      expect(result.content[0]).toMatchObject({ text: 'Error: approval for tool "echo" was cancelled' })
+    })
+
+    it('denies with the no-channel reason when the seam is mounted but nobody answers', async () => {
+      const ctx = await approvalSetup()
+      ctx.on('tools/pre-execute', async (_exec, _next): Promise<PreToolDecision> => ({ kind: 'ask' }))
+
+      const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: {}, agent: fakeAgent() })
+      expect(result.isError).toBe(true)
+      expect(result.content[0]).toMatchObject({ text: 'Error: tool "echo" requires approval, but no approval channel is available' })
+    })
+
+    it('denies an agent-less execution without asking — nothing to route or audit through', async () => {
+      const ctx = await approvalSetup()
+      let asked = false
+      ctx.on('approval/request', () => {
+        asked = true
+        return Promise.resolve<ApprovalOutcome>('allowed-once')
+      })
+      ctx.on('tools/pre-execute', async (_exec, _next): Promise<PreToolDecision> => ({ kind: 'ask' }))
+
+      const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: {} })
+      expect(asked).toBe(false)
+      expect(result.isError).toBe(true)
+      expect(result.content[0]).toMatchObject({ text: 'Error: tool "echo" requires approval, but the call has no agent to route it through' })
+    })
+
+    it('turns a rogue outcome from a NON-conforming approval stand-in into an isError result', async () => {
+      // ApprovalService normalizes rogue answers itself; this pins the
+      // registry's own exhaustiveness backstop by shadowing the service with a
+      // stand-in that violates the outcome contract.
+      const ctx = await setup()
+      ctx.tools.register(echoTool)
+      ctx.provide('approval', { request: () => Promise.resolve('yolo') } as unknown as ApprovalService)
+      ctx.on('tools/pre-execute', async (_exec, _next): Promise<PreToolDecision> => ({ kind: 'ask' }))
+
+      const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: {}, agent: fakeAgent() })
+      expect(result.isError).toBe(true)
+      const text = result.content[0]?.type === 'text' ? result.content[0].text : ''
+      expect(text).toContain('unreachable')
+    })
+  })
+
   it('a tools/post-execute listener can replace the result content (accept) ', async () => {
     const ctx = await setup()
     ctx.tools.register(echoTool)

+ 3 - 0
packages/core/tools/tsconfig.json

@@ -28,6 +28,9 @@
     },
     {
       "path": "../../core/agent"
+    },
+    {
+      "path": "../../approval/approval"
     }
   ]
 }

+ 21 - 0
pnpm-lock.yaml

@@ -75,6 +75,24 @@ importers:
         specifier: ^4.1.8
         version: 4.1.8(@types/node@22.20.0)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))
 
+  packages/approval/approval:
+    devDependencies:
+      '@deepseek-ai/dsh-agent':
+        specifier: workspace:^
+        version: link:../../core/agent
+      '@deepseek-ai/dsh-brand':
+        specifier: workspace:^
+        version: link:../../util/brand
+      '@deepseek-ai/dsh-llm':
+        specifier: workspace:^
+        version: link:../../llm/llm
+      '@deepseek-ai/dsh-session':
+        specifier: workspace:^
+        version: link:../../core/session
+      cordis:
+        specifier: ^4.0.0-rc.6
+        version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4)
+
   packages/bash/bash:
     devDependencies:
       '@deepseek-ai/dsh-brand':
@@ -348,6 +366,9 @@ importers:
       '@deepseek-ai/dsh-agent':
         specifier: workspace:^
         version: link:../agent
+      '@deepseek-ai/dsh-approval':
+        specifier: workspace:^
+        version: link:../../approval/approval
       '@deepseek-ai/dsh-code-runtime':
         specifier: workspace:^
         version: link:../../code-runtime/code-runtime

+ 16 - 3
scripts/gen-doc-graphs.ts

@@ -159,6 +159,15 @@ const SERVICE_ROLES: ServiceRole[] = [
     consumers: ['tool-bash', 'hooks-claude', 'hooks-codex'],
     note: 'The model-facing bash tools and hook bridges consume this seam; sandboxed or remote executors can replace bash-local.',
   },
+  {
+    key: 'approval',
+    pkg: 'approval',
+    title: 'Approval seam',
+    mode: 'seam',
+    implementations: [],
+    consumers: ['tools'],
+    note: 'One-shot permission decisions dispatched over the `approval/request` waterfall; answerers are listeners (the ACP bridge for its own agents), absence fails closed to `unavailable`.',
+  },
   {
     key: 'codeRuntime',
     pkg: 'code-runtime',
@@ -673,7 +682,8 @@ function renderToolPipeline(): string {
     `  toolCall["Session event: ${mermaidCode('tool/call')}<br/>logged before execution"]`,
     '  presentCall["UI pending card<br/>presentCall(args)"]',
     `  pre["${mermaidCode('tools/pre-execute')} waterfall<br/>hooks, permission, sandbox"]`,
-    '  denied["deny or ask<br/>tool body skipped"]',
+    '  denied["denied<br/>tool body skipped"]',
+    `  approval["${mermaidCode('ctx.approval')} one-shot prompt<br/>absent or unanswerable: deny"]`,
     `  around["${mermaidCode('tools/execute')} waterfall<br/>timeout, retry, metrics (around dispatch)"]`,
     '  toolBody["Registered tool execute() body"]',
     `  fsGate["${mermaidCode('fs/write-intent')} or ${mermaidCode('fs/edit-intent')}<br/>tool-fs mutations only"]`,
@@ -687,7 +697,10 @@ function renderToolPipeline(): string {
     '  toolCall --> pre',
     '  pre -->|allow| around',
     '  around --> toolBody',
-    '  pre -->|deny or ask| denied',
+    '  pre -->|deny| denied',
+    '  pre -->|ask| approval',
+    '  approval -->|allowed-once| around',
+    '  approval -->|rejected, cancelled, unavailable| denied',
     '  denied --> post',
     '  toolBody --> fsGate',
     '  fsGate --> toolBody',
@@ -699,7 +712,7 @@ function renderToolPipeline(): string {
     '  toolResult --> presentResult',
     '```',
     '',
-    'Filesystem read-before-edit checks live below `tool-fs` on the `fs/*` event gate; hook bridges and future permission prompts live on the generic pre/post tool waterfalls; and around-dispatch concerns like the tool-call timeout policy (`@deepseek-ai/dsh-timeout-policy`) wrap core dispatch on `tools/execute`. That split lets the same hooks observe bash, fs, web, todo, and subagent calls without coupling those tools to one policy service. Code Mode rides the same pipeline twice over: `run_code` is itself a registered tool body, and each tool call its program makes re-enters `ctx.tools.execute()` through BOTH waterfalls — serialized one at a time, logged as a `tool/code-dispatch` session event, with a deny surfacing to the program as a binding rejection (a sub-call\'s `additionalContext` is deliberately dropped — no safe outlet mid-run preserves call/result adjacency).',
+    'Filesystem read-before-edit checks live below `tool-fs` on the `fs/*` event gate; hook bridges and the approval seam\'s permission prompts live on the generic pre/post tool waterfalls; and around-dispatch concerns like the tool-call timeout policy (`@deepseek-ai/dsh-timeout-policy`) wrap core dispatch on `tools/execute`. That split lets the same hooks observe bash, fs, web, todo, and subagent calls without coupling those tools to one policy service.',
     '',
     ...maintenanceFooter(maintenance),
   ].join('\n')

+ 1 - 0
tsconfig.base.json

@@ -40,6 +40,7 @@
       // here. The build graph's project references (tsconfig.build.json) stay
       // explicit — TS project references have no wildcard form.
       "@deepseek-ai/dsh-*": [
+        "./packages/approval/*/src",
         "./packages/core/*/src",
         "./packages/llm/*/src",
         "./packages/bash/*/src",

+ 1 - 0
tsconfig.build.json

@@ -18,6 +18,7 @@
     { "path": "./packages/session-persistence/session-persistence-jsonl" },
     { "path": "./packages/session-persistence/session-persistence-sqlite" },
     { "path": "./packages/core/system-prompt" },
+    { "path": "./packages/approval/approval" },
     { "path": "./packages/core/agent" },
     { "path": "./packages/ui/user-interaction" },
     { "path": "./packages/core/tools" },

+ 1 - 0
tsconfig.json

@@ -29,6 +29,7 @@
     { "path": "./packages/session-persistence/session-persistence-jsonl" },
     { "path": "./packages/session-persistence/session-persistence-sqlite" },
     { "path": "./packages/core/system-prompt" },
+    { "path": "./packages/approval/approval" },
     { "path": "./packages/core/agent" },
     { "path": "./packages/ui/user-interaction" },
     { "path": "./packages/core/tools" },