DeepSeek Harness 的用户审批是怎么弹出来的:谁发起、谁应答、拒绝之后发生什么

2026-08-17

先说清楚这篇的边界:我们读的是仓库源码和 docs/ 下的子系统文档,没有安装过 dsh、也没有运行过它,所以下面不会有任何关于弹窗长什么样、响应快不快的描述。另外,该仓库 README 的「Developer preview」一节自述项目处于开发者预览阶段,并明写会有破坏兼容性的变更,下文提到的字段名、默认值、错误文案随时可能变动。

从一个具体问题开始

假设模型想跑一条会写到工作区外的命令,于是在工具参数里带上了 sandbox_permissionsjustification。这时候「问一下用户」这个动作是谁触发的?如果没人来回答,结果是放行还是拒绝?拒绝之后模型看到的是什么?

这三个问题的答案散在好几个包里,主线是 packages/interaction/user-approval/src/index.ts。子系统文档 docs/subsystems/approval.md 把它称作一个 seam,回答的就是一句话:这个具体操作是否可以继续。

第一步:谁把请求发起来

审批不是工具自己弹的。工具执行前会先跑一次 tools/pre-execute 的 waterfall,默认值是 { kind: 'allow' },见 packages/core/tools/src/index.ts。这个 waterfall 返回的 PreToolDecision 只有三种形态:

export type PreToolDecision =
  | { kind: 'allow' }
  | { kind: 'deny'; reason: string }
  | { kind: 'ask'; reason?: string }

只有拿到 ask,执行器才会走进 serviceAsk(),把它换成一次真正的审批请求。所以「谁发起」的答案是:任何往 tools/pre-execute 里挂监听器的东西。

仓库里现成的发起方有两类。一类是 Hooks:packages/hooks/hook-protocol/src/merge.ts 把同一个 hook 点上所有命中的 hook 折叠成一个最严格的结果,优先级注释写得很直白,deny > ask > allowrank() 里 deny/block 是 3、ask 是 2、allow/approve 是 1。折叠出 ask 之后,由 packages/hooks/hooks-claude-code/src/index.ts 转成 { kind: 'ask' }

另一类是沙箱提权,路径完全不同。packages/sandbox/sandbox/src/escalation.ts 导出的 approveEscalation() 被三个工具族共用:packages/shell/tool-bash/src/index.tspackages/shell/tool-pwsh/src/index.ts(Windows 侧的 PowerShell 工具走的就是这一条,提权路径和 bash 完全一致)、以及 packages/fs/tool-fs/src/sandbox.ts。顺带说明一处口径差异:该文件顶部的模块注释只点名了 bash 和 fs 两个族,而我们在源码里查到 tool-pwsh 同样 import 并调用了它——这里只陈述两处不一致,以我们实读的源码为准。它先做「严格更宽」的检查——WIDER_MODES 表里 read-only 只能升到 workspace-writedanger-full-accessworkspace-write 只能升到 danger-full-access——不满足就直接抛错,源码注释明确写了这一条:非严格加宽的请求不会去打扰人。通过之后才把请求交给审批通道,reason 的拼法是固定的 escalate sandbox to ${mode}: ${justification},这一整句会进审计事件。

顺带一提,validateEscalationArgs() 要求 sandbox_permissionsjustification 必须成对出现,且理由 trim 之后不能为空,缺一个就是「malformed ask」。这是 schema 表达不了、只能在执行期查的约束。

第二步:服务本体做了什么

ApprovalService.request() 的第一件事不是弹窗,是检查会话是否处在一个未结束的轮次里。hasOpenTurn() 从日志尾部往前扫,遇到 turn/start 返回 true,遇到 turn/end 返回 false。不在轮次里就直接抛错,错误文案里自带理由:审计对必须被轮次包住,否则「a bare event between turns is crash-tail garbage on reload」——重载时分不清它和崩溃残尾,会被静默丢掉。

通过之后,服务用 randomUUID() 生成一个 ApprovalRequestIdsrc/types.ts 里是个 branded 类型,专门防止它和工具调用 id、会话 id 互相串用),追加 approval/asked,拿结果,再追加 approval/decided。这两条事件是 log-only 的审计,不进模型 transcript。

值得记住的是 ApprovalRequest 这个结构故意不带工具参数:只有 agenttoolName、可选的 callIdreasonsignal。文档自述的理由是应答者靠 callId 把提示挂到已经流式输出过的那次工具调用上,而不是再渲染一份可能漂移的副本。

第三步:never 是在哪里被判掉的

会话策略只有两个值,ApprovalPolicy = 'ask' | 'never'。默认值来自 static Config 里那行 z.union(['ask', 'never'] as const).default('ask')。会话级覆盖靠 effectiveApprovalPolicy() 从日志里往回折叠最后一条 approval/policy 事件,没有就落到插件配置。

关键在私有方法 decide()never 是在 waterfall 分发之前、在服务内部判掉的,返回 'rejected'。源码注释解释了为什么不做成一个监听器——用 prepend: true 注册的监听器会排在任何「闸门监听器」前面,那样就守不住「无论注册顺序如何 never 都确定性拒绝」这个承诺。这段理由是源码注释自述的,不是我们的推测。

同一个方法里还有另一处顺序讲究:应答者的调用被包在 Promise.resolve().then(...) 里,注释写明是为了让同步抛异常的监听器和异步抛的落进同一条 rejection 路径,不至于逃逸到调用方。

策略也会说给模型听。构造函数往 systemPrompt 注册了一段 name: 'approval:policy'order: 115 的运行时上下文。never 下那句原文是「Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set sandbox_permissions)」。而 setPolicy() 在切换时还会额外 inject 一条来源为 { kind: 'plugin', plugin: 'user-approval' } 的用户消息,把「策略从 X 变成 Y(由用户变更)」告诉模型。

第四步:谁来应答

approval/request 是一个 waterfall 事件,签名带 next():应答者要么返回一个 outcome 认领这次请求,要么调 next() 往下让。链走到底的兜底值写死在 decide() 里,是 'unavailable'

仓库里有两个内置应答者。一个在 packages/acp/acp/src/index.ts:它只接自己拥有的 agent,且 callId 为 undefined 时直接 next();给出的选项只有 allow-oncereject-once 两个,注释写明这是机器策略通道,绝不从未知的客户端响应里推断出持久授权。

另一个在 packages/host/apiproxy/src/api-proxy.ts,把一次 ask 变成 mux 流上一个带稳定 rpcId 的服务端请求。这里有段容易被忽略的逻辑:因为分发要过一个 microtask,并行工具调用可能已经追加了好几条 approval/asked,所以它得反向扫日志找「最新的、尚未 decided、未被其它 pending 项认领、并且 callId 对称匹配」的那一条——带 callId 的 ask 只能认领带同一 callId 的记录,不带的只能认领不带的。扫不到就 next(),因为这说明请求根本没走服务的审计路径。它还在最前面同步处理了已 abort 的信号,注释说明如果晚一个 microtask 才注册 abort 监听器,那条记录会永远挂着,每次 mux 重放都冒出一个僵尸帧。

第五步:拒绝之后发生什么

ApprovalOutcome 是闭合的四值,且只有一个是放行

outcome什么时候出现调用方(core/tools)给模型的 reason
allowed-once应答者明确同意—(放行,且只授权问过的这一次)
rejected人明确拒绝,或 never 策略the user rejected tool "<name>"
cancelled请求带的 signal 被 abortapproval for tool "<name>" was cancelled
unavailable没有应答者 / 应答者抛异常 / 返回值不在词表里tool "<name>" requires approval, but no approval channel is available

这四条文案在 serviceAsk() 里逐条写死,注释也点明了目的:让模型能分清「人说了不」和「压根没有审批通道」。沙箱提权那条路径同理,approveEscalation() 对四种 outcome 分别抛不同的原文,比如 the user rejected escalating this ${subject} to "${mode}"

unavailable 的三个来源里,第三个最容易被漏掉:应答者返回了词表外的值,会被 OUTCOMES.includes(outcome) ? outcome : 'unavailable' 归一化掉,而不是漏进调用方的 switch。抛异常的那条也一样,() => 'unavailable' 兜住,注释的说法是要让问题失败关闭,而不是让调用方的工具调用失败敞开。

还有两种更早的短路,都在 serviceAsk() 里、根本不会走到 request():没有组合 ApprovalService 时 ctx.get('approval') 是 undefined,直接拒绝,默认文案是 tool "<name>" requires approval (not yet supported);执行没有 agent 时也拒绝,理由是没有会话可审计、也没有 UI 可路由。

两处放在一起读的地方

packages/interaction/permission-presets/src/index.ts 的默认预设表里有两条:workspace-writeapproval: 'ask'danger-full-accessapproval: 'never',后者的 description 原文是「Full file access without approval prompts」。

把它和 ApprovalPolicynever 的定义放在一起读:never 的语义是「每次 ask 都确定性地 resolve 成 rejected」,不是「自动同意」。也就是说在这个预设下,文件访问本身是放开的,而任何仍然需要走审批的动作会被自动拒掉。这两处都是白纸黑字,只是 without approval prompts 这句英文很容易被读成「不用批就过」。我们只指出这两处的关系,不做别的引申。

顺手提一句不变量

packages/interaction/user-approval/src/invariant.ts 里注册了一组审计流检查:approval/askedapproval/decided 都必须落在打开的轮次内,toolName 不能是空串,同一个 id 不能重复 asked,decided 必须能找到配对的 asked,outcome 和 policy 都必须在闭合词表里。如果你打算自己写一个应答者,这几条就是你的验收线——不用去猜规范写在哪,它已经是可执行的代码了。


本文依据 DeepSeek Harness 官方仓库(github.com/deepseek-ai/deepseek-harness)的 README、docs/ 下的 架构与子系统文档、以及 packages/ 下的源码整理,核对日 2026-08-17,对应仓库快照 47f9438(版本 0.1.0-rc.5)。 本文内容为仓库源码与文档口径,我们没有安装、也没有运行过这个项目, 因此不涉及界面外观、操作手感与运行速度的任何描述。 该仓库 README 自述处于开发者预览阶段并明确说明未来会有破坏兼容性的变更, 文中出现的命令、配置与默认值随时可能变动,请以仓库最新内容为准。 安全与合规相关做法请结合自身环境评估,本文不构成安全方案建议。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。