第 6 章
工具执行管线与审批
场景还原
假设你在维护一个 agent harness,要加一条安全策略:模型调用 Bash 时,命令里出现 rm -rf 必须先问用户。第一版方案很直接,在 Bash 工具实现里加一个 if,命中就弹确认框。上线后问题接二连三。有人 fork 了 Bash 工具,没抄这段 if,检查被绕过。沙箱升级要问同一句话,可那一刻执行已经开始了,工具实现里没有「执行前」这个时机。还有,审批记录不在会话日志里,事后想查谁在什么时候允许了什么,只有工具结果,没有问答对。
问题不在 if 本身,在于把「决策」埋进了「执行」。这一章看 dsh 怎么把决策从工具实现里全部抽出来:一个 tool call 从模型输出到结果入日志,要过三道可拦截的水闸与一个独立审批服务。
逐行精读
先看水闸。packages/core/tools/src/index.ts 的 Events 接口,第一个瀑布事件 tools/pre-execute:
143 /**144 * Allow, deny, or ask before dispatch. `next()` delegates to allow; missing145 * approval support turns `ask` into denial. Async gates must observe146 * `exec.signal`; the registry rechecks cancellation after they settle but147 * never abandons their promise.148 * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls.149 * @param exec - the pending call (name, parsed arguments, caller agent).150 * @mode waterfall151 */152 'tools/pre-execute'(this: Scoped<ToolRuntime>, exec: ToolExecution, next: () => Promise<PreToolDecision>): Promise<PreToolDecision>@mode waterfall 是仓库的事件文档约定:监听器调用 next() 把决定权交给下一位,或返回决策短路整条链。pre-execute 返回三态(allow、deny、ask)。注释第一行写了缺省行为:next() 委托等于放行;「审批支持缺失时 ask 会变成拒绝」在边界条件里展开。
另两个水闸,execute 与 post-execute:
163 'tools/execute'(this: Scoped<ToolRuntime>, exec: ToolDispatchExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult>164 /**165 * Accept, replace, enrich, or block a normalized dispatch result. `next()`166 * accepts it unchanged; thrown tools still reach this waterfall as errors. Async167 * listeners must observe `exec.signal`; after they settle, caller168 * cancellation replaces only a successful accepted outcome with the code169 * selected by whether the tool body was invoked.170 * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls.171 * @param exec - the call that just ran (name, parsed arguments, caller agent).172 * @param result - the dispatch outcome a listener may accept, replace, or block.173 * @mode waterfall174 */175 'tools/post-execute'(this: Scoped<ToolRuntime>, exec: ToolExecution, result: Readonly<ToolExecutionResult>, next: () => Promise<PostToolDecision>): Promise<PostToolDecision>execute 是 around-dispatch 的包装层,注释说它给超时、重试、指标用;包装器只能换 exec.signal,不能改调用身份。post-execute 在结果归一化之后跑,监听器可以接受、替换、增强或 block。三个水闸各有专属决策类型:
588export type PreToolDecision =589 | { kind: 'allow' }590 | { kind: 'deny'; reason: string }591 | { kind: 'ask'; reason?: string }597export type PostToolDecision =598 | { kind: 'accept'; content?: ContentBlock[]; value?: never; additionalContexts?: UserMessage[] }599 | { kind: 'accept'; value: JsonValue; content?: never; additionalContexts?: UserMessage[] }600 | { kind: 'block'; feedback: ContentBlock[]; additionalContexts?: UserMessage[] }582-586 行注释解释了为什么没有「改写输入」这个决策:参数已经进过日志并展示给用户,改参数会让历史、审计、UI、执行四方对不上。block 把 corrective feedback 变成错误结果,语义在 postExecute 本体里展开。
看调度。执行器是四阶段调度,类型声明如下:
426/**427 * Scheduler-only result after ordered pre-execute and guards. A `post-result`428 * still receives post-execute; a `final-result` bypasses it.429 * @internal430 */431export type ScheduledToolPreparation =432 | { kind: 'dispatch'; exec: ToolRunContext }433 | { kind: 'post-result'; exec: ToolRunContext; result: ToolExecutionResult }434 | { kind: 'final-result'; exec: ToolRunContext; result: ToolExecutionResult }451export interface ToolRuntimeScheduler {452 /** Materialize input, run the ordered pre-execute/guard gate, and decide what stage follows. */453 prepare(exec: ToolExecutionInput): Promise<ScheduledToolPreparation>454 /** Run only the around-dispatch/body stage. */455 dispatch(exec: ToolRunContext): Promise<ScheduledToolDispatch>456 /** Run post-execute and definition-owned content finalization, then materialize and notify. */457 finalize(exec: ToolRunContext, result: ToolExecutionResult): Promise<ToolExecutionResult>458 /** Run definition-owned content finalization, then materialize and notify without post-execute. */459 finish(exec: ToolRunContext, result: ToolExecutionResult): ToolExecutionResult460}427-428 行是本章的关键分叉:post-result 仍然经过 post-execute,final-result 直接绕过它。finalize 是 post-execute 加内容收尾、物化、通知;finish 是没有 post-execute 的那条。
真正的分派开关在 completeScheduledExecution:
1346 private async completeScheduledExecution(prepared: ScheduledToolPreparation): Promise<ToolExecutionResult> {1347 switch (prepared.kind) {1348 case 'dispatch': {1349 const dispatched = await this.dispatchScheduledExecution(prepared.exec)1350 return dispatched.kind === 'post-result'1351 ? await this.finalizeScheduledExecution(prepared.exec, dispatched.result)1352 : this.finishScheduledExecution(prepared.exec, dispatched.result)1353 }1354 case 'post-result':1355 return await this.finalizeScheduledExecution(prepared.exec, prepared.result)1356 case 'final-result':1357 return this.finishScheduledExecution(prepared.exec, prepared.result)1358 /* v8 ignore next -- closed-union exhaustiveness guard */1359 default:1360 return assertNever(prepared, 'scheduled tool preparation')1361 }1362 }这张图把三个 kind 的分派关系画出来:
第一站,prepare 完整实现:
1463 private async prepareExecution<T>(1464 input: ToolExecutionInput,1465 next: (prepared: ScheduledToolPreparation) => T | PromiseLike<T>,1466 ): Promise<T> {1467 const created = this.createExecution(input)1468 if (created.kind !== 'ready') return next(created)1469 const exec = created.exec1470 if (this.callerCancelled(exec)) {1471 return next({ kind: 'final-result', exec, result: toolAbortedBeforeDispatchResult() })1472 }1473 try {1474 const carrier = scopeTarget(this, exec.agent)1475 const gate = await this.ctx.waterfall(1476 carrier, 'tools/pre-execute', exec,1477 () => Promise.resolve<PreToolDecision>({ kind: 'allow' }),1478 )1479 const askResolution: ToolAskResolution = gate.kind === 'ask'1480 ? await this.serviceAsk(exec, gate)1481 : { decision: gate, approvalCancelled: false }1482 const { decision } = askResolution1483 if (this.callerCancelled(exec) && askResolution.approvalCancelled) {1484 return await next({ kind: 'post-result', exec, result: toolAbortedBeforeDispatchResult() })1485 }1486 const denialReason = decision.kind === 'allow'1487 ? this.guardReason(exec)1488 : decision.reason1489 if (denialReason !== undefined) {1490 return await next({1491 kind: 'post-result',1492 exec,1493 result: this.materializeFinalResult({1494 content: [{ type: 'text', text: `Error: ${denialReason}` }],1495 isError: true,1496 error: { message: denialReason },1497 }),1498 })1499 }1500 if (this.callerCancelled(exec)) {1501 return await next({ kind: 'post-result', exec, result: toolAbortedBeforeDispatchResult() })1502 }1503 return await next({ kind: 'dispatch', exec })1504 } catch (error: unknown) {1505 return next({ kind: 'final-result', exec, result: toolErrorResult(error) })1506 }1507 }逐行过。1470-1472:策略开始前已取消,直接产出 final-result。1475-1478:pre-execute 瀑布,默认 allow。1479-1481:闸口说 ask 就调 serviceAsk;直接表态时 approvalCancelled 为 false。1483-1485:审批期间调用被取消,走 post-result 的 aborted 结果。1486-1488:allow 时再过 guard,只许拒绝;非 allow 用决策自带的 reason。1489-1499:拒绝物化成 Error: ${denialReason} 错误结果,仍走 post-result,post-execute 还能再加工。1503:全绿进 dispatch。1504-1506:任何抛错都收成 final-result。
第二站,dispatch:
1569 private async dispatchScheduledExecution(exec: ToolRunContext): Promise<ScheduledToolDispatch> {1570 try {1571 const mutableExec = exec as MutableToolRunContext1572 const carrier = scopeTarget(this, exec.agent)1573 const result = await this.ctx.waterfall(1574 carrier, 'tools/execute', mutableExec,1575 () => this.dispatchToolBody(mutableExec),1576 )1577 const normalized = this.normalizeDispatchResult(exec, result)1578 const deferredContexts = this.deferredContexts.get(exec)1579 /* v8 ignore next -- dispatch only receives executions minted by this registry's prepare stage */1580 if (deferredContexts === undefined) throw new Error('tool registry scheduler invariant violated: unprepared execution')1581 const resultWithDeferredContexts: ToolExecutionResult = deferredContexts.length === 01582 ? normalized1583 : this.markCanonical(exec, {⋯ ...normalized,· additionalContexts: [⋯ ...deferredContexts,⋯ ...normalized.additionalContexts ?? [],1588 ],1589 })1590 return {1591 kind: 'post-result',1592 result: this.callerCancelled(exec) && !resultWithDeferredContexts.isError1593 ? this.cancellationResult(exec, resultWithDeferredContexts)1594 : resultWithDeferredContexts,1595 }1596 } catch (error: unknown) {1597 return { kind: 'final-result', result: toolErrorResult(error) }1598 }1599 }1573-1576:tools/execute 瀑布包住 dispatchToolBody,后者才真正调工具体,找不到就抛 ToolNotFoundError(1546-1549 行)。1563-1564 注释:工具失败与未知工具失败仍走 post-execute,管线自身失败直接是 final。1590-1595:正常路径一律 post-result,取消只替换成功结果。1596-1598:包装器抛错收成 final-result。
第三站,finalize 与 finish:
1609 private async finalizeScheduledExecution(exec: ToolRunContext, result: ToolExecutionResult): Promise<ToolExecutionResult> {1610 try {1611 const postResult = await this.postExecute(exec, result)1612 return this.finishScheduledExecution(1613 exec,1614 this.callerCancelled(exec) && !postResult.isError1615 ? this.cancellationResult(exec, postResult)1616 : postResult,1617 )1618 } catch (error: unknown) {1619 return this.finishScheduledExecution(exec, toolErrorResult(error))1620 }1621 }1631 private finishScheduledExecution(exec: ToolRunContext, result: ToolExecutionResult): ToolExecutionResult {1632 let materializedResult: ToolExecutionResult1633 try {1634 materializedResult = this.materializeFinalResult(result)1635 } catch (error: unknown) {1636 materializedResult = this.materializeFinalResult(toolErrorResult(error))1637 }1638 let finalResult: ToolExecutionResult1639 try {1640 finalResult = this.materializeFinalResult(this.applyFinalContent(exec, materializedResult))1641 } catch (error: unknown) {1642 finalResult = this.materializeFinalResult(toolErrorResult(error))1643 }1644 this.notifyResult(exec, finalResult)1645 return finalResult1646 }finalize 先跑 post-execute,再交给 finish。finish 无论如何做三件事:物化(lossless JSON 快照加冻结)、applyFinalContent(definition 自有的收尾回调)、notifyResult(发 tools/result 事件)。两层 try 保证物化失败也走完流程,被绕过路径送进来的 final-result 在这里兜底。
postExecute 本体:
1742 private async postExecute(exec: ToolExecution, result: ToolExecutionResult): Promise<ToolExecutionResult> {1743 const decision = await this.ctx.waterfall(1744 scopeTarget(this, exec.agent), 'tools/post-execute', exec, result,1745 () => Promise.resolve<PostToolDecision>({ kind: 'accept' }),1746 )1747 const decisionContexts = decision.additionalContexts ?? []1748 if (decision.kind === 'block') {1749 const message = failureMessageFromContent(decision.feedback)1750 return this.markCanonical(exec, {1751 content: decision.feedback,1752 isError: true,1753 error: { message },⋯ ...decisionContexts.length > 0 ? { additionalContexts: decisionContexts } : {},· })· }· if (Object.hasOwn(decision, 'content') && Object.hasOwn(decision, 'value')) {· throw new TypeError('tools/post-execute accept decision cannot replace both value and content')· }· const additionalContexts = [⋯ ...result.additionalContexts ?? [],⋯ ...decisionContexts,· ]· if (Object.hasOwn(decision, 'value')) {· if (result.isError) {· throw new TypeError('tools/post-execute cannot replace the value of a failed result')· }· const tool = this.resolveExecution(exec.name, exec.agent, exec.parent !== undefined)· if (tool === undefined) throw new ToolNotFoundError(exec.name)· const replaced = this.createSuccessResult(exec, tool, decision.value)· return this.markCanonical(exec, {⋯ ...replaced,⋯ ...additionalContexts.length > 0 ? { additionalContexts } : {},· })· }· return this.markCanonical(exec, {⋯ ...result,⋯ ...decision.content !== undefined ? { content: decision.content } : {},⋯ ...additionalContexts.length > 0 ? { additionalContexts } : {},1780 })1781 }三条分支。block(1748-1756):把 feedback 变成错误结果,错误消息由 failureMessageFromContent 推导(默认文案 629 行)。value 替换(1764-1775):重新走 createSuccessResult,新 value 再过工具声明的 output schema;失败结果不许替换 value(1765-1767)。content 替换(1776-1780):只动展示用的 content 块。1757-1759 拦了 content 与 value 只能换一个的冲突。
默认文案,629 行:
624/** Derive one failure message from policy feedback without changing its rendered blocks. */625function failureMessageFromContent(content: ContentBlock[]): string {626 const text = content627 .map(block => block.type === 'text' ? block.text : `[${block.type} content]`)628 .join('\n')629 return text.length > 0 ? text : 'tool result blocked by post-execute policy'630}管线主链走完。看审批,它是 ask 决策的服务端,tools 侧先消费:
1689 private async serviceAsk(1690 exec: ToolExecution,1691 ask: Extract<PreToolDecision, { kind: 'ask' }>,1692 ): Promise<ToolAskResolution> {1693 const approval = this.ctx.get('approval')1694 if (approval === undefined) {1695 return {1696 decision: { kind: 'deny', reason: ask.reason ?? `tool "${exec.name}" requires approval (not yet supported)` },1697 approvalCancelled: false,1698 }1699 }1700 if (exec.agent === undefined) {1701 return {1702 decision: { kind: 'deny', reason: `tool "${exec.name}" requires approval, but the call has no agent to route it through` },1703 approvalCancelled: false,1704 }1705 }1706 const outcome = await approval.request({1707 agent: exec.agent,1708 toolName: exec.name,1709 callId: exec.callId,⋯ ...ask.reason !== undefined ? { reason: ask.reason } : {},1711 signal: exec.signal,1712 })1713 switch (outcome) {1714 case 'allowed-once': return { decision: { kind: 'allow' }, approvalCancelled: false }1715 case 'rejected': return {1716 decision: { kind: 'deny', reason: `the user rejected tool "${exec.name}"` },1717 approvalCancelled: false,1718 }1719 case 'cancelled': return {1720 decision: { kind: 'deny', reason: `approval for tool "${exec.name}" was cancelled` },1721 approvalCancelled: true,1722 }1723 case 'unavailable': return {1724 decision: { kind: 'deny', reason: `tool "${exec.name}" requires approval, but no approval channel is available` },1725 approvalCancelled: false,1726 }1727 default: return assertNever(outcome, 'ApprovalOutcome')1728 }1729 }1693 行是机会式消费:ctx.get('approval'),没装 ApprovalService 就降级成 deny,reason 是「not yet supported」。审批不与工具注册表耦合,缺服务也不会挂起。四种 outcome 只有 allowed-once 放行,其余各自 deny 且 reason 不同,模型能分辨「人拒绝了」「弹窗被撤了」「没有审批通道」。cancelled 额外带 approvalCancelled: true,对应 prepare 1483-1485 行的组合判定。
审批服务本体在 packages/interaction/user-approval/,先看它落在日志里的东西:
44 'approval/asked': {45 id: ApprovalRequestId46 toolName: string47 callId?: CallId48 reason?: string49 }50 /**51 * The outcome of a prior `approval/asked` (same `id`) — log-only audit.52 * Exactly one per ask, appended when the outcome is known: a decision, a53 * cancellation, or the fail-closed `'unavailable'`.54 */55 'approval/decided': {56 id: ApprovalRequestId57 outcome: ApprovalOutcome58 }这是成对的 log-only 审计事件:asked 记「问了一句」,decided 记「答了什么」,靠同一个 id 关联。outcome 词表:
10/**11 * Pairs one `approval/asked` audit event with its `approval/decided`.12 * Service-issued (one fresh id per {@link ApprovalService.request} call).13 */14export type ApprovalRequestId = Branded<'ApprovalRequestId'>1516/**17 * Brand a string as an {@link ApprovalRequestId}.18 * @param id - the raw id string to brand.19 * @returns the same string carrying the brand.20 */21export function ApprovalRequestId(id: string): ApprovalRequestId {22 return id as ApprovalRequestId23}2425/**26 * Closed approval outcomes: a one-shot grant, explicit rejection, withdrawn27 * request, or unavailable answerer. Callers fail closed on `unavailable`.28 */29export type ApprovalOutcome = 'allowed-once' | 'rejected' | 'cancelled' | 'unavailable'request 与 decide 是核心方法。request 负责审计对与边界检查:
257 async request(req: ApprovalRequest): Promise<ApprovalOutcome> {258 const session = req.agent.session259 if (!hasOpenTurn(session.events)) {260 throw new Error(261 'approval.request() outside an open turn: the approval/asked + approval/decided audit pair '262 + 'must be turn-enclosed (a bare event between turns is crash-tail garbage on reload). '263 + 'Ask from inside the turn that needs the decision.',264 )265 }266 const id = ApprovalRequestId(randomUUID())267 session.append('approval/asked', {268 id,269 toolName: req.toolName,⋯ ...req.callId !== undefined ? { callId: req.callId } : {},⋯ ...req.reason !== undefined ? { reason: req.reason } : {},272 })273 const outcome = await this.decide(req, session)274 session.append('approval/decided', { id, outcome })275 return outcome276 }259-265:审计对必须落在 turn 内,这是会话日志的 commit/replay 边界:turn 外裸事件在重载时会被当崩溃尾巴丢弃,宁可抛错也不写。266-272:先 mint 一个 id 并 append approval/asked,273 行决定,274 行无条件补上 approval/decided,成一个对。
decide 是决策发生的地方:
304 private async decide(req: ApprovalRequest, session: Session): Promise<ApprovalOutcome> {305 const signal = req.signal306 if (signal?.aborted) return 'cancelled'307 // The 'never' policy is decided HERE, before any dispatch: a listener308 // registered with `prepend: true` after this service mounts would sit309 // ahead of any gate LISTENER, so a listener-shaped gate cannot keep the310 // documented promise that 'never' rejects deterministically regardless311 // of registration order — only the service's own request path can.312 if (this.effectivePolicy(session) === 'never') return 'rejected'313 // Enter the promise chain BEFORE dispatching: a listener that throws314 // SYNCHRONOUSLY (before its first await) must land in the same rejection315 // path as an async one — `Promise.resolve(call())` would let it escape316 // the containment into the caller.317 const answer: Promise<ApprovalOutcome> = Promise.resolve().then(318 () => this.ctx.waterfall(319 scopeTarget(this, req.agent), 'approval/request', req,320 () => Promise.resolve<ApprovalOutcome>('unavailable'),321 ),322 ).then(323 // Normalize a rogue (non-vocabulary) answerer return to the fail-closed324 // outcome instead of leaking it into callers' closed-union switches.325 outcome => OUTCOMES.includes(outcome) ? outcome : 'unavailable',326 // A throwing answerer must fail the QUESTION closed, not the caller's327 // tool call open — the seam contains its callbacks.328 () => 'unavailable',329 )330 if (signal === undefined) return answer331 return await new Promise<ApprovalOutcome>((resolve) => {332 const onAbort = () => {333 signal.removeEventListener('abort', onAbort)334 resolve('cancelled')335 }336 signal.addEventListener('abort', onAbort, { once: true })337 void answer.then((outcome) => {338 signal.removeEventListener('abort', onAbort)339 // After an abort won the race this resolve is a settled-promise no-op:340 // the late answer is discarded by construction.341 resolve(outcome)342 })343 })344 }307-312 是 'never' 策略的判定点:在分发任何 answerer 之前就返回 rejected,为什么必须在 312 行判定,边界条件展开。317-321:answerer 是 approval/request 瀑布的监听器,默认值 unavailable,零监听器等于 fail closed。325-328:answerer 返回词表外的值或直接抛错,都归一化成 unavailable。331-343:请求信号与 answerer 竞速,abort 赢就 cancelled,晚到的答案作废。一次 ask 的完整时序:
会话策略本身是从日志投影出来的:
94export type ApprovalPolicy = 'ask' | 'never'9596/** Every {@link ApprovalPolicy}, for option advertisement and runtime validation of untrusted policy strings. */97export const APPROVAL_POLICIES: readonly ApprovalPolicy[] = ['ask', 'never']9899/** Model-facing statement for the deterministic `'never'` policy. */100const NEVER_SENTENCE = 'Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).'101/** Model-facing statement for an interactive policy that may still fail closed. */102const ASK_SENTENCE = 'Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.''ask' 是默认,交给 answerer;'never' 是无人值守的严格姿态,每个 ask 确定性拒绝。策略以 approval/policy 事件入日志,effectiveApprovalPolicy 从日志尾部折叠出当前值(112-118 行),重放不需要补状态机制。
管线的「只许收窄」层,guard:
703/**704 * A monotonic execution guard evaluated after every `tools/pre-execute`705 * listener and before the tool body. Returning a reason denies the call;706 * returning `undefined` leaves it unchanged. Because guards have no allow707 * result, listener ordering cannot turn a denial back into permission.708 * @param execution - the identity-protected call after extensible pre-execute policy completed.709 * @returns a final denial reason, or `undefined` to leave the call allowed.710 */711export type ToolGuard = (execution: Readonly<ToolExecution>) => string | undefinedguard 只有「拒绝」和「弃权」两个结果,没有 allow。pre-execute 是重排可逆的瀑布,监听器顺序能改变结果;guard 在全部 pre-execute 监听器之后、工具体之前求值(1486-1488),任何顺序都翻不回已拒绝的决定。
设计决策分析
为什么水闸挂在调用链上
docs/subsystems/tools.md 172 行一句话说全了管线顺序:
172`ctx.tools.execute()` accepts a caller-owned `ToolExecutionInput` with a required readonly `signal`, materializes its parsed JSON arguments once into a pipeline-owned `ToolExecution`, and runs that call through `tools/pre-execute` (the reorderable allow/deny/ask waterfall) → registered monotonic guards → `tools/execute` (around-dispatch wrappers) → `tools/post-execute` (inspect/replace the result) → optional definition-owned `finalizeContent` → `tools/result` (the immutable authoritative outcome). Only the `tools/execute` view may replace the required signal. The outcome is a `ToolExecutionResult`.docs/tool-execution-pipeline.md 第 6 行把目的说透:
6This graph shows where policy, hooks, sandboxing, filesystem guards, result rewriting, final-outcome observation, and UI rendering run without changing the loop. The `tools/pre-execute` waterfall runs first, monotonic guards run next, and the `tools/execute` and `tools/post-execute` waterfalls follow; the three waterfalls may transform a call. Definition-owned `finalizeContent` and `tools/result` run afterward.要点在「without changing the loop」:策略、钩子、沙箱、结果改写、观察全部挂在调用链上,agent-loop 与工具体都不知情。收益三条:一次注册对所有工具生效;工具实现里没有权限代码,fork 一个工具不会丢掉检查;审计路径唯一,决策事件都在会话日志里。反面代价就是场景还原里的故事。这条规则在 packages/AGENTS.md 14 行是审查红线:
14- **Enforce a decision in the operation that makes it.** Schema omission, prompt filtering, facades, wrappers, and listener order are not enforcement when direct or alternate callers can bypass them; test denial through the executor.红线点名的四种假 enforcement:schema 省略、prompt 过滤、facade、wrapper。共同问题:都挡在「模型能看到什么」这一侧,直接调用者绕得掉,因为 executor 不读 prompt、不读 schema 展示层。
post-result 与 final-result 两条路径
分界在「结果还值不值得被策略后处理」。post-result 是 body 可信执行过,可以拦;final-result 是 body 没以可信方式执行过,post-execute 无可操作对象。
产生 final-result 的路径逐个数:折叠拒绝(createExecution 1423-1444);策略开始前调用已取消(1470-1472);pre-execute 监听器抛错(1504-1506);around 包装器抛错(1596-1598)。工具自己抛错、未知工具,仍然走 post-execute(1563-1564),body 跑过了,结果值得拦;拒绝(1489-1499)也走 post-result,post-execute 可以再加工。
绕过 post-execute 会让结果失去所有加工吗?不会。finalizeContent 是 definition 自有的收尾回调,契约覆盖一切路径:
236 /**237 * Synchronous last-mile transform for model-facing content. The registry238 * snapshots this callback when execution starts and invokes it exactly once239 * for every normalized outcome, including pipeline failures that bypass240 * `tools/post-execute`, immediately before lossless materialization.241 * Returning `undefined` preserves the content; every other result field242 * remains registry-owned. The callback must be total and must not throw.243 * @param exec - immutable execution identity and arguments.244 * @param result - complete normalized outcome before materialization.245 * @returns replacement content, or `undefined` to preserve it.246 */247 finalizeContent?(exec: Readonly<ToolExecution>, result: Readonly<ToolExecutionResult>): ContentBlock[] | undefined「invoked exactly once for every normalized outcome, including pipeline failures that bypass tools/post-execute」。这个不对称是有意的:post-execute 是部署方策略,可以拦也可以被跳过;finalizeContent 是工具自己声明的契约,工具对自己的结果负最后一公里责任。
审批做成独立服务与事件
approval-seam 设计笔记(.agents/notes/implemented/feature/2026-07-06-approval-seam.md)的决策段:
13## Decision1415One package, `dsh-user-approval` (`packages/interaction/user-approval`), owns the vocabulary and the `ctx.approval` service — the mechanism. The policy — who answers, and whether a session is asked at all — lives outside it: answerers are `approval/request` waterfall listeners registered by channel-owning plugins (the ACP bridge, host adapters, and test scripts), and a per-session policy tier can decide before a channel is involved. Consumers (`dsh-tools`' ask routing and the sandbox escalation gate) resolve a question to a closed outcome and derive their own tool results from it. This is deliberately one package, not the capability-seam three (see Alternatives).机制与策略分离:词表、路由、审计、失败关闭是机制,归 dsh-user-approval 一个包;谁回答(answerer)、问不问(策略层),在服务之外。answerer 是 approval/request 瀑布的监听器,挂在通道插件上,卸载即失效。
为什么不能做进工具内部的 if?备选方案清单说得最清楚:
96- **A single registered provider instead of waterfall listeners** — rejected: a `registerProvider()` API forces every composition question — allowlist pre-filters, external hook deciders, scripted test answers, a policy gate in front of a human — inside one provider implementation. The waterfall gets composition, fail-closed absence, and HMR disposal from machinery the runtime already has; the seam's JSDoc pins the single-decision-slot convention instead of inventing a provider registry.97- **An inline `tools/pre-execute` permission gate in the ACP bridge** — rejected: prompting for every bridge-owned call hardwires the asking policy into the transport, cannot serve a second asker (sandbox escalation happens after execution starts, with no pre-execute moment), and leaves hook-produced `ask` decisions without a shared mechanism.98- **The generic user-questions seam (`ctx.userQuestions`)** — rejected as the approval mechanism: the two share a skeleton (route by agent, block for a human, handle absence), but approval's contract is narrower in every dimension that matters: a closed outcome vocabulary instead of free text, a protocol-native prompt attached to a tool call instead of a generic form, mandatory fail-closed absence, and audit events. Approval therefore does not ride the shipped `packages/interaction/user-questions` / `ask_user_question` elicitation path — an elicitation form is not a permission prompt, and a free-text answer is not a closed outcome; sharing provider plumbing stays open if the two ever converge.第二个 asker 是关键证据:sandbox 升级门在工具体已经跑起来之后才问,那时没有 pre-execute 时刻,工具内 if 根本挂不上。hook 产生的 ask(permissionDecision: ask)也要走同一套词表与审计。两个 asker 各写一套 if,词汇会分叉,审计会消失。
还有两个「做成服务才成立」的性质。fail-closed 是零监听器默认:answerer 瀑布默认 unavailable(decide 317-321),FAQ 120 行:「Fail-closed is the zero-listener default, not a configuration」。一次性授权:allowed-once 只覆盖被问的那一次调用,服务不存跨请求状态,FAQ 121 行说 allow_always 特意不提供。
不这样做会出什么事
红线的反面具象化。最典型的是 code mode 折叠:mode: 'code' 下模型只能直接调 run_code,其他工具要写进 SDK 程序。这条规则有三层表达,只有一层是 enforcement。
prompt 层:CODE_ONLY_INSTRUCTION(58 行)告诉模型只能直接调 run_code,只影响模型。展示层:其他工具 schema 不进 prompt(restriction 只移可见性,680-685 行注释)。直接调用者两层都绕得掉:另一个进程调 execute() 传别的工具名,prompt 挡不住。所以 executor 层还有真拒绝:createExecution 折叠命中就直接 final-result,且在策略管线之前(1373-1379:pre-execute、approval ask、guard 都看不到被折叠的调用),拒绝文案还给模型指路(1436-1443)。
判定谓词只有一份,同时驱动 prompt 与执行器:
855 private collapseSection(): { name: string; order: number; text: (context: { scope?: ScopeKey }) => string } {856 return {857 name: 'tools:code-only',858 order: COLLAPSE_SECTION_ORDER,859 // The SAME predicate the executor denies by, so the prompt cannot state860 // a rule the registry does not enforce (see `collapses`).861 text: context => this.modeFor(context.scope) === 'code' ? CODE_ONLY_INSTRUCTION : '',862 }863 }「The SAME predicate the executor denies by, so the prompt cannot state a rule the registry does not enforce」。prompt 里说的每句话,executor 都用同一谓词落地,prompt 与执行不会分叉。enforcement 的最小单位:决策落在做出操作的那一层,测试也从 executor 入口测拒绝(「test denial through the executor」)。
边界条件剖析
问出去了,但没有任何 answerer
部署只装了 user-approval,没有装任何通道插件。ask 照常发:approval/asked 落日志,decide 的瀑布空转落到默认值 unavailable(317-321 行),serviceAsk 映射成 deny(1723-1726 行),reason 是「no approval channel is available」,再经 prepare 物化成 Error: 结果,日志留下 asked 加 decided(unavailable) 一对。没有通道时 if 该读什么?它没有「默认值」概念,只会挂起或漏过。
'never' 策略能不能被监听器顺序绕过
会话切到 'never' 后,另一个插件用 prepend: true 注册了一个无条件放行的 answerer,能不能救回这个 ask?decide 307-312 行的注释回答了:prepend 注册的监听器会排在任何 gate 监听器前面,「监听器形态的门」无法兑现「'never' 确定性拒绝、与注册顺序无关」的承诺,只有服务自己的 request 路径能。
审批弹窗挂着,调用被取消
用户撤了整轮调用。信号 abort 后 decide 竞速分支返回 cancelled(331-343 行),serviceAsk 映射成 deny 并标记 approvalCancelled: true(1719-1722 行),prepare 在 1483-1485 行检查到「调用已取消且审批被撤」的组合,产出 post-result 的 aborted 结果。注意与 1470-1472 的对照:策略开始前就取消是 final-result(没有审批发生过),审批中的取消是 post-result(审计对已经写了 asked,结果要过 post-execute)。
管线自己抛错,绕过 post-execute
pre-execute 监听器抛错,1504-1506 行收成 final-result;around 包装器抛错,1596-1598 行同样,都跳过 post-execute 直接进 finish。原因在 dispatch 注释(1563-1564):post-execute 假定 body 已可信执行过,管线失败时前提不成立,再碰可能二次抛错。兜底在 finish:finalizeContent 对每个归一化结果执行一次(236-240 行契约),物化失败退化成结构化错误(1635-1637 行),notifyResult 无条件发(1644 行),观察者永不落空。
block 决策的自我约束
post-execute 同时给 content 和 value,1757-1759 行抛 TypeError,只能选一个。给失败结果替换 value,1765-1767 行抛 TypeError,失败结果没有 value 可换。误用会在开发期炸出来,线上不会静默。
跨平台交代:本章管线与平台无关,事件分发不感知操作系统;平台差异在工具体一侧(shell capability seam,第 7 章展开)。claude-code 的权限修复记录按平台出现(106 行 zsh 语法、269 行 PowerShell、270 行 Git Bash),推断见下节。
横向对比
组一:claude-code 的 permission 机制,权限检查挂在哪一层
对比语料 anthropics/claude-code(commit 45bdfa96)的公开仓库已经不含主产品源码,只剩 CHANGELOG、examples、plugins。检索关键词 permission、canUseTool、PermissionCallback,未找到对应实现。下面用公开材料两侧对照。
claude-code 侧能确证的是行为记录。CHANGELOG 权限条目密集,随手三条:
106- Improved Bash tool permission checking for zsh-specific syntax in shell conditionals299- Bash input redirections (`< file`) are now permission-checked like their argument spellings on all platforms435- Fixed a Bash permission bypass where a crafted command could hide parts of itself from permission checks269 行 PowerShell 参数覆盖绕过、270 行 Git Bash 符号链接绕过、436 行「命令用 tab 或不可见 Unicode 填充可藏掉部分命令」的审批弹窗修复。共同点:漏洞面都在 Bash 命令解析与权限判定层,按平台逐个修。由此推断(源码不可见,行为证据):权限检查做在工具执行路径内部,与命令解析器耦合。
claude-code 侧公开的决策注入面是 hook 协议,官方示例把 PreToolUse 挂在 Bash 工具名上:
13{14 "hooks": {15 "PreToolUse": [16 {17 "matcher": "Bash",18 "hooks": [19 {20 "type": "command",21 "command": "python3 /path/to/claude-code/examples/hooks/bash_command_validator_example.py"22 }23 ]24 }25 ]26 }27}第三方插件 hookify 在规则命中时产出 permissionDecision: deny:
72 elif hook_event in ['PreToolUse', 'PostToolUse']:73 return {74 "hookSpecificOutput": {75 "hookEventName": hook_event,76 "permissionDecision": "deny"77 },78 "systemMessage": combined_message79 }dsh 侧,同一份 hook 协议被桥进管线,hooks-claude-code 把 PreToolUse 接到 tools/pre-execute:
237 // --- PreToolUse → PreToolDecision. Matcher subject is the tool name. ---238 ctx.on('tools/pre-execute', async (exec, next): Promise<PreToolDecision> => {239 const turn = lastTurn(exec.agent)240 const merged = await runPoint('PreToolUse', exec.name, preToolPayload(ctx, exec), { ...exec.agent ? { agent: exec.agent } : {}, turn, signal: exec.signal })241 if (merged.decision === 'deny') return { kind: 'deny', reason: merged.reason ?? 'blocked by PreToolUse hook' }242 if (merged.decision === 'ask') return { kind: 'ask', ...merged.reason !== undefined ? { reason: merged.reason } : {} }243 return next()244 })permissionDecision: deny 变成 PreToolDecision deny,ask 变成 ask 再进 approval seam(PostToolUse 的 deny 映射成 block,246-253 行)。
同一个问题「权限检查挂在哪一层」,两侧的答案:claude-code 挂在工具解析层加 hook 协议注入(推断),代价是每个工具各自接,绕过漏洞反复出现在解析层,修复按平台打补丁(435/269/270 行)。dsh 把决策全部抬到管线层:pre-execute 水闸、approval seam、单调 guard,工具体内没有权限逻辑,新增工具零成本获得整套策略,代价是每次调用都付事件分发与日志投影的开销。
组二:dsh 内部,同一决策的两种表达
审批决策记录成两种事件。逐次的:approval/asked 加 approval/decided 审计对(44-58 行),回答「这一下谁批准了什么」。持续的:permission/preset、sandbox/mode、approval/policy 三个 knob 事件,回答「默认行为是什么」。permission-presets 把两者连起来:
391 set(session: Session, name: string): void {392 this.apply(session, name, (policy) =>{ setApprovalPolicy(session, policy) })393 }394395 /** Apply one preset with the caller-selected live or initialization policy writer. */396 private apply(session: Session, name: string, setApproval: (policy: ApprovalPolicy) => void): void {397 const spec = this.resolve(name)398 if (this.current(session.events) !== name) {399 session.append('permission/preset', { preset: name })400 }401 const events = session.events402 if (spec.sandbox !== (effectiveSandboxMode(events) ?? this.ctx.shell.sandboxMode)) {403 setSandboxMode(session, spec.sandbox)404 }405 if (spec.approval !== (effectiveApprovalPolicy(events) ?? this.ctx.approval.config.policy ?? 'ask')) {406 setApproval(spec.approval)407 }408 }399-407 行的顺序:先 append permission/preset 记录用户意图,再通过 setter 写 knob。preset 是 log-only 的用户意图(42-50 行注释),knob 才控制执行。为什么两种都要:审计对是证据,回答事后「谁允许的」;配置是默认值,回答事前「下一个 ask 会怎样」。二者都能从日志重放。代价:两套事件都要投影和维护;单机单通道场景只需要 decided 审计对,preset bundle(workspace-write 配 ask、danger-full-access 配 never)是给多部署组合、knob 必须一起换的场景准备的。
互动演示设计
形态:决策沙盘。演示目标是一个 tool call 在五种策略配置下走五条不同的分支,全程只有一个可操作变量:配置。
一句话结论:同一个 tool call 走哪条分支,由策略配置决定,而配置之外还有一道守卫,在任何配置下都能拒绝。
舞台。中央是一条横向泳道,一个 tool call 从右往左走:pre-execute 水闸、审批闸、guard 闸、工具体、post-execute 水闸、结果入日志。左侧控制台四个开关:hook 决策(放行 / deny / ask)、审批策略(ask / never)、answerer(有 / 无)、post-execute(accept / block)。生活化比喻:泳道是安检通道,开关是安检政策,轨迹面板是监控录像。
分步字幕。
第 1 步,默认配置(hook 放行、ask、有 answerer、accept)。call 进泳道,各闸依次放行,结果入日志。字幕:「默认配置下,全部水闸放行,守卫层也没有拒绝动作。」
第 2 步,hook 切到 deny。call 在 pre-execute 水闸直接变红,生成 Error: <reason> 结果,继续走到 post-execute。字幕:「deny 产生的错误结果走 post-result 路径,post-execute 还能替换或补充它的文案。」
第 3 步,hook 切到 ask。call 停在审批闸,弹出问答框,点 Allow once 后继续。字幕:「ask 进入 approval 服务,审计对 asked 与 decided 先后写入日志,allowed-once 只授权这一次调用。」
第 4 步,审批策略切到 never。同样的 ask,答案者根本没有被调用,直接变红拒绝。轨迹面板高亮 never 判定行。字幕:「never 在服务内部、分发答案者之前就拒绝,注册顺序改不了这行代码。」
第 5 步,关掉 answerer。策略切回 ask,问答框弹出又消失,变成「no approval channel is available」的拒绝。字幕:「没有答案者就是默认拒绝,fail-closed 是零监听器的缺省行为。」
第 6 步,post-execute 切到 block。call 正常执行完,结果却在最后一闸被翻成错误,文案变成 feedback 内容。字幕:「block 把成功结果变成带纠正反馈的错误,静默消失的结果不在此列。」
读者能操作什么:四个开关随意组合,重发同一个 call,观察它停在哪一闸、产出什么结果、日志里多哪些事件。
逻辑轨迹面板(随步进高亮,右侧为真实行号)。
| 伪代码行 | 白话 | 源码位置 |
|---|---|---|
| decision = pre-execute(call) | 水闸一:hook 可 allow、deny、ask | packages/core/tools/src/index.ts:1475 |
| if decision is ask: ask approval | 问审批服务,解析成 allow 或 deny | packages/core/tools/src/index.ts:1479 |
| denialReason = guard(call) | 单调守卫只许拒绝 | packages/core/tools/src/index.ts:1486 |
| result = execute(call) | 工具体执行,包装器围观 | packages/core/tools/src/index.ts:1573 |
| decision2 = post-execute(result) | 水闸三:accept、replace 或 block | packages/core/tools/src/index.ts:1743 |
| finalizeContent 加物化加通知 | 收尾回调与冻结结果入日志 | packages/core/tools/src/index.ts:1631 |
决策沙盘的分支关系:
可迁移结论
1. 把决策点从实现里抽到调用链的拦截层。 这条不依赖 TypeScript,是中间件模式的另一种说法。最小形态大概长这样:
def execute(call, body):
decision = allow
for hook in pre_hooks: # 每个 hook 三选一:allow / deny / ask
decision = hook(call)
if decision.kind != 'allow':
break
if decision.kind == 'deny':
return error(f'Error: {decision.reason}')
if decision.kind == 'ask':
outcome = approval.request(call) # 无服务或无答案者 -> 默认拒绝
if outcome != 'allowed-once':
return error(deny_reason(outcome))
result = body(call)
for hook in post_hooks: # 每个 hook 可替换结果,可 block 成错误
result = hook(call, result)
return result对照 dsh,这 15 行覆盖了 pre-execute、审批降级、post-execute 三个决策点。值得抄的三件事:ask 走独立服务,不埋进工具 if;deny 物化成错误结果,口头拒绝不算数;post 拦截返回决策,改结果交给调用方。
2. 兜底回调必须盖住绕过路径。 可拦截的环节会被绕过(管线失败、调用取消),所以「每个结果都必须经过一次收尾」这条不变量要挂在最外层(finalizeContent 加 finish 的物化与通知,1631-1646 行)。写中间件时,把无论如何都要跑的收尾放在最外层。
3. 机制与策略分离。 词表、路由、审计是机制,归一个服务;谁回答、问不问是策略,注册成监听器与配置。判断标准:换策略时不需要改机制代码,换机制时不需要动任何策略。
4. 哪些是过度设计。 answerer 瀑布、成对审计事件、closed outcome 词表、preset bundle,这些是为「多个 asker、多个通道、跨会话审计」准备的。单机单 hook 场景,一个 pre 回调加一个布尔开关就够。抄的时候先数一数:有没有第二个 asker?要不要事后审计?两个没有,就只抄三层水闸。
思考题
-
动手题:用 Python 实现上面「可迁移结论」第 1 条的最小管线(30 行内),验证三件事:无 answerer 时 ask 默认拒绝(对照 serviceAsk 的降级,tools/index.ts:1693-1698);
'never'策略在分发答案者之前拒绝(对照 decide 307-312 行);post hook 返回 block 时结果变成错误(对照 postExecute 1748-1756 行)。跑法:三个断言各触发一条分支,观察返回值。 -
读
completeScheduledExecution(tools/index.ts:1346-1362)与prepareExecution(1463-1507),列出所有产生final-result的路径,逐条说明为什么可以不经过 post-execute。提示:对照 1563-1564 注释与 finalizeContent 契约(236-247 行)。 -
解释为什么 prompt 里写「code mode 下只能直接调 run_code」不够,executor 必须用同一谓词拒绝(collapseSection 注释 859-861 行、collapses 1324-1326 行)。反过来想:如果只做 prompt 层,谁会绕过?
-
场景题:post-execute 的 block 为什么实现成
isError加 corrective feedback(1748-1756 行),为什么不用「静默丢弃结果」?从模型视角回答:模型收到的结果里,什么信息让它能改正下一次调用?