diff --git a/.gitignore b/.gitignore
index df36ca9214..9c432a5273 100644
--- a/.gitignore
+++ b/.gitignore
@@ -19,3 +19,4 @@ tmp/
.DS_Store
.idea
mise.toml
+
diff --git a/docs/capability-seams.md b/docs/capability-seams.md
index 1c24a2916e..c71c74003e 100644
--- a/docs/capability-seams.md
+++ b/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
Approval seam"]
pkg_code_runtime["code-runtime"]
svc_codeRuntime["ctx.codeRuntime
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. |
diff --git a/docs/config-catalog.md b/docs/config-catalog.md
index 3e06916f6b..7866a123f2 100644
--- a/docs/config-catalog.md
+++ b/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))
diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md
index a9dfb7e05c..ee35a7a586 100644
--- a/docs/cordis-catalog/events.md
+++ b/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): Promise
+```
+
+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): Promise
@@ -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/*`
diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md
index 79902ba2fc..eb0fe8ffd1 100644
--- a/docs/cordis-catalog/services.md
+++ b/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
+```
+
+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
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`
diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md
index acbe700965..5b50b30cc2 100644
--- a/docs/event-producer-consumer.md
+++ b/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`) | - |
diff --git a/docs/module-graph.md b/docs/module-graph.md
index 39678e16eb..6237fac953 100644
--- a/docs/module-graph.md
+++ b/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) |
diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md
index 917386af9b..5d8555e752 100644
--- a/docs/persistence-catalog.md
+++ b/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
diff --git a/docs/tool-execution-pipeline.md b/docs/tool-execution-pipeline.md
index 09a863badc..28eee043c4 100644
--- a/docs/tool-execution-pipeline.md
+++ b/docs/tool-execution-pipeline.md
@@ -11,7 +11,8 @@ flowchart TD
toolCall["Session event: tool/call
logged before execution"]
presentCall["UI pending card
presentCall(args)"]
pre["tools/pre-execute waterfall
hooks, permission, sandbox"]
- denied["deny or ask
tool body skipped"]
+ denied["denied
tool body skipped"]
+ approval["ctx.approval one-shot prompt
absent or unanswerable: deny"]
around["tools/execute waterfall
timeout, retry, metrics (around dispatch)"]
toolBody["Registered tool execute() body"]
fsGate["fs/write-intent or fs/edit-intent
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.
diff --git a/eslint.config.mjs b/eslint.config.mjs
index cbb696bccb..500f42f4de 100644
--- a/eslint.config.mjs
+++ b/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',
diff --git a/packages/README.md b/packages/README.md
index a656812253..8d1580b291 100644
--- a/packages/README.md
+++ b/packages/README.md
@@ -12,6 +12,7 @@ Packages are grouped by modular role at `packages///`. 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 |
diff --git a/packages/approval/README.md b/packages/approval/README.md
new file mode 100644
index 0000000000..3778e2012e
--- /dev/null
+++ b/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).
diff --git a/packages/approval/approval/README.md b/packages/approval/approval/README.md
new file mode 100644
index 0000000000..0aaf245f2b
--- /dev/null
+++ b/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.
diff --git a/packages/approval/approval/package.json b/packages/approval/approval/package.json
new file mode 100644
index 0000000000..6120e4d4e9
--- /dev/null
+++ b/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"
+ }
+}
diff --git a/packages/approval/approval/src/index.ts b/packages/approval/approval/src/index.ts
new file mode 100644
index 0000000000..92b2a0b763
--- /dev/null
+++ b/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): Promise
+ }
+}
+
+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 {
+ 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 {
+ 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 = Promise.resolve().then(
+ () => this.ctx.waterfall(this, 'approval/request', req, () => Promise.resolve('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((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
diff --git a/packages/approval/approval/tests/approval.spec.ts b/packages/approval/approval/tests/approval.spec.ts
new file mode 100644
index 0000000000..c356723438
--- /dev/null
+++ b/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 }> } {
+ const appended: Array<{ type: string; data: Record }> = []
+ const agent = {
+ session: {
+ events: seed,
+ append: (type: string, data: Record) => {
+ appended.push({ type, data })
+ return { type, data } as unknown as SessionEvent
+ },
+ },
+ } as unknown as Agent
+ return { agent, appended }
+}
+
+async function mounted(): Promise {
+ const ctx = new Context()
+ await ctx.plugin(ApprovalService)
+ return ctx
+}
+
+function requestOf(agent: Agent, overrides: Partial = {}): 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('allowed-once'))
+ ctx.on('approval/request', () => {
+ secondRan = true
+ return Promise.resolve('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('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((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((_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('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('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')
+ })
+})
+
diff --git a/packages/approval/approval/tsconfig.json b/packages/approval/approval/tsconfig.json
new file mode 100644
index 0000000000..2d834f075d
--- /dev/null
+++ b/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"
+ }
+ ]
+}
diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md
index 7a383c5064..dde232a626 100644
--- a/packages/core/tools/README.md
+++ b/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` (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
diff --git a/packages/core/tools/package.json b/packages/core/tools/package.json
index f6425538b1..aafd87385f 100644
--- a/packages/core/tools/package.json
+++ b/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:^",
diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts
index e113a0c77c..20f32ec97d 100644
--- a/packages/core/tools/src/index.ts
+++ b/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 {
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({ 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,
+ ): Promise> {
+ 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
diff --git a/packages/core/tools/tests/tools.spec.ts b/packages/core/tools/tests/tools.spec.ts
index cad484640b..6b59d96eb7 100644
--- a/packages/core/tools/tests/tools.spec.ts
+++ b/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('allowed-once')
+ })
+ ctx.on('tools/pre-execute', async (_exec, _next): Promise =>
+ ({ 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('rejected'))
+ ctx.on('tools/pre-execute', async (_exec, _next): Promise => ({ 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('cancelled'))
+ ctx.on('tools/pre-execute', async (_exec, _next): Promise => ({ 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 => ({ 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('allowed-once')
+ })
+ ctx.on('tools/pre-execute', async (_exec, _next): Promise => ({ 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 => ({ 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)
diff --git a/packages/core/tools/tsconfig.json b/packages/core/tools/tsconfig.json
index 68edd3b003..19e17e950f 100644
--- a/packages/core/tools/tsconfig.json
+++ b/packages/core/tools/tsconfig.json
@@ -28,6 +28,9 @@
},
{
"path": "../../core/agent"
+ },
+ {
+ "path": "../../approval/approval"
}
]
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 1f607b7d9d..4b6542bbe0 100644
--- a/pnpm-lock.yaml
+++ b/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
diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts
index 610ff3f1ea..5061b12ddb 100644
--- a/scripts/gen-doc-graphs.ts
+++ b/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')}
logged before execution"]`,
' presentCall["UI pending card
presentCall(args)"]',
` pre["${mermaidCode('tools/pre-execute')} waterfall
hooks, permission, sandbox"]`,
- ' denied["deny or ask
tool body skipped"]',
+ ' denied["denied
tool body skipped"]',
+ ` approval["${mermaidCode('ctx.approval')} one-shot prompt
absent or unanswerable: deny"]`,
` around["${mermaidCode('tools/execute')} waterfall
timeout, retry, metrics (around dispatch)"]`,
' toolBody["Registered tool execute() body"]',
` fsGate["${mermaidCode('fs/write-intent')} or ${mermaidCode('fs/edit-intent')}
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')
diff --git a/tsconfig.base.json b/tsconfig.base.json
index ddba51c53f..b6d0281f81 100644
--- a/tsconfig.base.json
+++ b/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",
diff --git a/tsconfig.build.json b/tsconfig.build.json
index feff283866..2033218d01 100644
--- a/tsconfig.build.json
+++ b/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" },
diff --git a/tsconfig.json b/tsconfig.json
index feb2ca5b07..af8badd19f 100644
--- a/tsconfig.json
+++ b/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" },