一切皆插件:DeepSeek Harness 源码精读 6 / 14

第 6 章

工具执行管线与审批

源码核对基于 deepseek-ai/deepseek-harness commit `b150a551b8`(2026-08-21),tag `course-anchor-20260823`对比语料 anthropics/claude-code commit `45bdfa96`(2026-08-23),tag `v2.1.241`本章字数:约 9900 字(不含代码与图)

场景还原

假设你在维护一个 agent harness,要加一条安全策略:模型调用 Bash 时,命令里出现 rm -rf 必须先问用户。第一版方案很直接,在 Bash 工具实现里加一个 if,命中就弹确认框。上线后问题接二连三。有人 fork 了 Bash 工具,没抄这段 if,检查被绕过。沙箱升级要问同一句话,可那一刻执行已经开始了,工具实现里没有「执行前」这个时机。还有,审批记录不在会话日志里,事后想查谁在什么时候允许了什么,只有工具结果,没有问答对。

问题不在 if 本身,在于把「决策」埋进了「执行」。这一章看 dsh 怎么把决策从工具实现里全部抽出来:一个 tool call 从模型输出到结果入日志,要过三道可拦截的水闸与一个独立审批服务。

逐行精读

先看水闸。packages/core/tools/src/index.tsEvents 接口,第一个瀑布事件 tools/pre-execute

packages/core/tools/src/index.ts143:152
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 会变成拒绝」在边界条件里展开。

另两个水闸,executepost-execute

packages/core/tools/src/index.ts163:175
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。三个水闸各有专属决策类型:

packages/core/tools/src/index.ts588:591
588export type PreToolDecision =589  | { kind: 'allow' }590  | { kind: 'deny'; reason: string }591  | { kind: 'ask'; reason?: string }
packages/core/tools/src/index.ts597:600
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 本体里展开。

看调度。执行器是四阶段调度,类型声明如下:

packages/core/tools/src/index.ts426:434
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 }
packages/core/tools/src/index.ts451:460
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

packages/core/tools/src/index.ts1346:1362
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 的分派关系画出来:

flowchart TD prep["prepare 阶段"] prep -->|dispatch| dis["dispatch 阶段"] prep -->|post-result| fin["finalize 阶段"] prep -->|final-result| fin2["finish 阶段"] dis -->|post-result| fin dis -->|final-result| fin2 fin --> fin2 fin2 --> out["finalizeContent 加物化加通知"] prep -.->|监听器抛错、调用前取消、折叠拒绝| fin2 dis -.->|包装器抛错| fin2

第一站,prepare 完整实现:

packages/core/tools/src/index.ts1463:1507
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:

packages/core/tools/src/index.ts1569:1599
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:

packages/core/tools/src/index.ts1609:1621
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  }
packages/core/tools/src/index.ts1631:1646
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 本体:

packages/core/tools/src/index.ts1742:1781
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 行:

packages/core/tools/src/index.ts624:630
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 侧先消费:

packages/core/tools/src/index.ts1689:1729
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/,先看它落在日志里的东西:

packages/interaction/user-approval/src/index.ts44:58
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 词表:

packages/interaction/user-approval/src/types.ts10:29
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 负责审计对与边界检查:

packages/interaction/user-approval/src/index.ts257:276
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 是决策发生的地方:

packages/interaction/user-approval/src/index.ts304:344
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 的完整时序:

sequenceDiagram participant Tools as ToolRegistry participant Seam as ApprovalService participant Chan as Answerer participant Log as SessionLog Tools->>Seam: request with signal Seam->>Log: append approval/asked alt policy never Seam-->>Tools: rejected else signal aborted Seam-->>Tools: cancelled else dispatch answerers Seam->>Chan: approval/request waterfall Chan-->>Seam: allowed-once or rejected end Seam->>Log: append approval/decided Seam-->>Tools: closed outcome

会话策略本身是从日志投影出来的:

packages/interaction/user-approval/src/index.ts94:102
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:

packages/core/tools/src/index.ts703:711
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 | undefined

guard 只有「拒绝」和「弃权」两个结果,没有 allow。pre-execute 是重排可逆的瀑布,监听器顺序能改变结果;guard 在全部 pre-execute 监听器之后、工具体之前求值(1486-1488),任何顺序都翻不回已拒绝的决定。

设计决策分析

为什么水闸挂在调用链上

docs/subsystems/tools.md 172 行一句话说全了管线顺序:

docs/subsystems/tools.md172: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 行把目的说透:

docs/tool-execution-pipeline.md6: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 行是审查红线:

packages/AGENTS.md14: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 自有的收尾回调,契约覆盖一切路径:

packages/core/tools/src/index.ts236:247
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)的决策段:

.agents/notes/implemented/feature/2026-07-06-approval-seam.md13:15
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?备选方案清单说得最清楚:

.agents/notes/implemented/feature/2026-07-06-approval-seam.md96:98
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 产生的 askpermissionDecision: 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 与执行器:

packages/core/tools/src/index.ts855:863
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 权限条目密集,随手三条:

claude-code/CHANGELOG.md106:106
106- Improved Bash tool permission checking for zsh-specific syntax in shell conditionals
claude-code/CHANGELOG.md299:299
299- Bash input redirections (`< file`) are now permission-checked like their argument spellings on all platforms
claude-code/CHANGELOG.md435:435
435- Fixed a Bash permission bypass where a crafted command could hide parts of itself from permission checks

269 行 PowerShell 参数覆盖绕过、270 行 Git Bash 符号链接绕过、436 行「命令用 tab 或不可见 Unicode 填充可藏掉部分命令」的审批弹窗修复。共同点:漏洞面都在 Bash 命令解析与权限判定层,按平台逐个修。由此推断(源码不可见,行为证据):权限检查做在工具执行路径内部,与命令解析器耦合。

claude-code 侧公开的决策注入面是 hook 协议,官方示例把 PreToolUse 挂在 Bash 工具名上:

claude-code/examples/hooks/bash_command_validator_example.py13:27
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

claude-code/plugins/hookify/core/rule_engine.py72:79
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

packages/hooks/hooks-claude-code/src/index.ts237:244
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 denyask 变成 ask 再进 approval seam(PostToolUse 的 deny 映射成 block,246-253 行)。

同一个问题「权限检查挂在哪一层」,两侧的答案:claude-code 挂在工具解析层加 hook 协议注入(推断),代价是每个工具各自接,绕过漏洞反复出现在解析层,修复按平台打补丁(435/269/270 行)。dsh 把决策全部抬到管线层:pre-execute 水闸、approval seam、单调 guard,工具体内没有权限逻辑,新增工具零成本获得整套策略,代价是每次调用都付事件分发与日志投影的开销。

组二:dsh 内部,同一决策的两种表达

审批决策记录成两种事件。逐次的:approval/askedapproval/decided 审计对(44-58 行),回答「这一下谁批准了什么」。持续的:permission/presetsandbox/modeapproval/policy 三个 knob 事件,回答「默认行为是什么」。permission-presets 把两者连起来:

packages/interaction/permission-presets/src/index.ts391:408
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

决策沙盘的分支关系:

flowchart LR sw["策略配置"] sw -->|ask 且有答案者| a1["approval/request 瀑布"] sw -->|never| a2["request 内直接 rejected"] sw -->|无 approval 服务| a3["serviceAsk 降级 deny"] a1 -->|allowed-once| b["guard 仍可拒绝"] a1 -->|rejected| c["Error 结果"] b -->|deny| c b -->|allow| d["工具体执行"] d -->|block| e["isError 加 feedback"] d -->|管线抛错| f["final-result 绕过 post-execute"]

可迁移结论

1. 把决策点从实现里抽到调用链的拦截层。 这条不依赖 TypeScript,是中间件模式的另一种说法。最小形态大概长这样:

python
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?要不要事后审计?两个没有,就只抄三层水闸。

思考题

  1. 动手题:用 Python 实现上面「可迁移结论」第 1 条的最小管线(30 行内),验证三件事:无 answerer 时 ask 默认拒绝(对照 serviceAsk 的降级,tools/index.ts:1693-1698);'never' 策略在分发答案者之前拒绝(对照 decide 307-312 行);post hook 返回 block 时结果变成错误(对照 postExecute 1748-1756 行)。跑法:三个断言各触发一条分支,观察返回值。

  2. completeScheduledExecution(tools/index.ts:1346-1362)与 prepareExecution(1463-1507),列出所有产生 final-result 的路径,逐条说明为什么可以不经过 post-execute。提示:对照 1563-1564 注释与 finalizeContent 契约(236-247 行)。

  3. 解释为什么 prompt 里写「code mode 下只能直接调 run_code」不够,executor 必须用同一谓词拒绝(collapseSection 注释 859-861 行、collapses 1324-1326 行)。反过来想:如果只做 prompt 层,谁会绕过?

  4. 场景题:post-execute 的 block 为什么实现成 isError 加 corrective feedback(1748-1756 行),为什么不用「静默丢弃结果」?从模型视角回答:模型收到的结果里,什么信息让它能改正下一次调用?