DeepSeek Harness 的审批顺序:为什么排在 monotonic guards 之前
先把限定条件放前面:deepseek-ai/deepseek-harness 这个仓库建立于 2026-08-13,我们采集与核对的日期是 2026-08-16,前后只差三天;根 package.json 里的版本是 0.1.0-rc.5,GitHub 上一个 Release 都没有,README 自述处于开发者预览阶段,并明确写了未来会出现破坏兼容性的变更。所以下面提到的任何文件路径、行号、类型定义、字符串文案,都可能随时变动,以仓库当前内容为准。
那句话原文在哪一行
docs/tool-execution-pipeline.md 全文只有 62 行,页首那句是这样写的(:6):
The
tools/pre-executewaterfall runs first, monotonic guards run next, and thetools/executeandtools/post-executewaterfalls follow; the three waterfalls may transform a call.
这句只说了三个 waterfall 与 guards 的相对位置,没提审批。真正把审批钉进顺序的是同一页的 :60:
ctx.approvalresolves asks before monotonic guards, and owner policy that must not be reordered remains a registered guard.
顺带说一句这页本身的性质:它和 docs/tool-catalog.md 一样是生成物,文件头标了由 scripts/gen-doc-graphs.ts 生成、用 pnpm run gen-doc-graphs 重新生成,正文主体(:9-58)就是一张 Mermaid 流程图。:62 还写明了它的维护模式是 curated Mermaid flow,精确的工具 schema 与事件签名放在生成的 catalog 里。也就是说,这页是「顺序」的权威描述位置,不是「参数」的权威描述位置。
图上的边,比文字更直白
图里的节点按 :10-28 的原文顺序,和这件事相关的是这几个:pre 是 tools/pre-execute waterfall(注释里写着 hooks, permission, sandbox),guards 是 registered monotonic guards(注释写 deny or abstain;identity protected),approval 是 ctx.approval one-shot prompt(注释写 absent or unanswerable: deny),denied 是 denied or approval refused,tool body skipped。
分支边在 :32-41,照抄与本文相关的七条(同段还有三条指向 normalized 的虚线 throw 边,这里不展开):
pre -->|allow| guards
pre -->|deny| denied
pre -->|ask| approval
approval -->|allowed-once| guards
approval -->|rejected, cancelled, unavailable| denied
guards -->|allow| around
guards -->|deny| denied
把这几条边连起来读:ask 这条路走完审批以后,allowed-once 并不是直接进入 tools/execute,而是回到 guards。换句话说,按图上的边,人点过同意之后,调用还要再过一遍 monotonic guards,而 guards 这一步仍然可以走 deny 到 denied。docs/subsystems/tools.md:172 用文字重述过同一条顺序:tools/pre-execute →(registered monotonic guards)→ tools/execute → tools/post-execute → 可选的 finalizeContent → tools/result,并写明只有 tools/execute 这一视图可以替换必需信号。
guard 的类型里没有 allow,这是理解顺序的关键
docs/subsystems/tools.md:324 给出的 guard 类型只有一行:
type ToolGuard = (execution: Readonly<ToolExecution>) => string | undefined
同页 :313 的原文说明是:返回类型故意没有 allow,undefined 表示保留 waterfall 已经做出的决定,返回字符串则只能降低权限,因此 listener 的注册顺序没法把一次拒绝翻回许可。
对照着看 tools/pre-execute 的决策类型(docs/subsystems/tools.md:385-389,源码同型在 packages/core/tools/src/index.ts:584-591):
type PreToolDecision =
| { kind: 'allow' }
| { kind: 'deny'; reason: string }
| { kind: 'ask'; reason?: string }
这里有 allow,也有 ask。两个类型放在一起,顺序的含义就清楚了:能发起「问人」的是前一段,能做最终单调拒绝的是后一段,而后一段在类型上根本表达不出「放行」。docs/cookbook/adding-a-tool.md:59 那句策略选择规则也是这么分工的——tools/pre-execute 做可扩展的 allow/deny/ask,ctx.tools.guard() 做最终单调 deny,tools/execute 加 deadline/retry/metrics,tools/post-execute 换内容或换值、block、附加上下文,tools/result 只观察不可变结果。
需要注意的是,这里说的都是类型与文档给出的语义,不是运行时表现。我们没有安装、没有运行过这个项目,也没有触发过一次审批弹窗。
代码里的第二份证据
顺序不只写在文档里。packages/core/tools/src/index.ts:1463-1507 的 prepareExecution() 是这条链的落地位置,代码的先后是:先跑 tools/pre-execute waterfall(默认值 { kind: 'allow' },见 :1475-1478),若结果是 ask 就调 serviceAsk(:1479-1481),之后才去算 guard 的拒绝理由(:1486-1488)。文档那句「resolves asks before monotonic guards」与这段代码的行序是对得上的。
serviceAsk 本身在 packages/core/tools/src/index.ts:1689-1729,它把审批服务的四种结果映射成管线里的决策,各分支的拒绝理由原文如下:
| 情况 | 结果 | reason 原文 |
|---|---|---|
ctx.get('approval') 为 undefined | deny | tool "<name>" requires approval (not yet supported)(:1696 的兜底文案) |
exec.agent 为 undefined | deny | tool "<name>" requires approval, but the call has no agent to route it through(:1702) |
outcome allowed-once | allow | —(:1714) |
outcome rejected | deny | the user rejected tool "<name>"(:1716) |
outcome cancelled | deny,并置 approvalCancelled: true | approval for tool "<name>" was cancelled(:1720-1721) |
outcome unavailable | deny | tool "<name>" requires approval, but no approval channel is available(:1724) |
审批请求带过去的字段只有五个(:1706-1712):agent、toolName: exec.name、callId: exec.callId、可选 reason、signal: exec.signal。注意里面没有工具参数——docs/subsystems/approval.md:53 写明 ApprovalRequest 是故意不带参数的,answerer 靠 callId 把提示挂到那次已经流式呈现过的 tool call 上,而不是再渲染一份可能漂移的副本。
allowed-once 只有一次,词表是封闭的
审批结果的词表在 docs/subsystems/approval.md:28 与源码 packages/interaction/user-approval/src/index.ts:82 两处一致,是封闭的四个值:allowed-once、rejected、cancelled、unavailable。文档 :21 的原文说 allowed-once 只授权被问到的那一次动作,并且缺席的、不拥有该请求的、抛异常的、返回值不符合词表的 answerer 一律变成 unavailable,而不是把门打开。源码侧对应得很直白:没有 answerer 兜底 'unavailable'(:320),返回值不在词表内归一化成 'unavailable'(:325),抛错同样归一化(:328),signal abort 则 resolve 'cancelled'(:334)。
packages/interaction/user-approval/README.md 的 Known Limitations 段把这条边界写得更明确:只存在一次性授权,词表里有 allowed-once,没有 allow-always、没有记住的规则、没有撤销、没有授权存储;会话策略也只有 ask / never 两档;并且这个服务自带的 answerer 是没有的,headless 或组合不完整的部署会解析成 unavailable 并 fail closed,服务本身从不向人发问。
会话策略的类型是 type ApprovalPolicy = 'ask' | 'never'(docs/subsystems/approval.md:46,源码 user-approval/src/index.ts:94,常量表在 :97),服务 Config 的 schema 默认值是 'ask'(:194),有效策略的读取顺序写在 :286:this.overrideOf(session) ?? this.config.policy ?? 'ask'。
这里还有另一处「更早」:never 的判定放在 waterfall 派发之前(user-approval/src/index.ts:306-312),先判 signal?.aborted 返回 'cancelled',再判有效策略为 never 就返回 'rejected'。源码注释的原话是,后注册并且用 prepend 的 listener 也绕不过它(对应 docs/subsystems/approval.md:86)。
谁会发起 ask,我们只核到两处
顺序讲清楚了,还得说一句我们没能核到的部分:在这个快照里,我们没有在非测试源码里找到一个默认就会对普通工具调用发起 ask 的独立策略插件。用 grep -rln "'tools/pre-execute'" packages/ --include=*.ts | grep -v "/tests/" 扫出来的监听方只有注册表自身、packages/core/tools/src/invariant.ts、生成的 scope 表、packages/extensions/tool-cordis/src/api-catalog.ts 这类 API 目录数据,以及两个 hooks 桥和 packages/jobs/tool-jobs(后者那个监听器记录输出上限后直接 next(),见 packages/jobs/tool-jobs/src/index.ts:233-237)。
我们核到的 ask 来源有两处:一是 Claude Code 桥把 PreToolUse 的 ask 映射成 PreToolDecision.ask(packages/hooks/hooks-claude-code/README.md:41),二是沙箱升级路径(packages/sandbox/sandbox/src/escalation.ts:173)。
沙箱那条链里有个同型的顺序值得一并记住。approveEscalation()(escalation.ts:157-189)是先做便宜的检查再惊动人:第一步查请求的模式是否严格加宽,不加宽直接抛 sandbox escalation to "X" is not strictly wider than this call's current "Y" mode(:162-163),注释原文是「A non-widening request never prompts a human.」(:150);第二步无审批服务抛错,第三步无 agent 抛错,第四步才发出审批请求,reason 拼成 escalate sandbox to ${mode}: ${justification}(:177)。另外 sandbox_permissions 与 justification 这两个参数必须成对出现、且 justification 去空格后不能为空,三种违规各有固定文案(validateEscalationArgs,escalation.ts:51-61)。
想自己核这一句,看这四处就够
- 顺序的权威表述:
docs/tool-execution-pipeline.md的:6与:60,以及:32-41的分支边。 - 文字重述:
docs/subsystems/tools.md:172。 - 类型层面的解释:
docs/subsystems/tools.md:324(guard 类型)与:385-389(PreToolDecision)。 - 代码行序:
packages/core/tools/src/index.ts:1463-1507,重点看:1479-1481与:1486-1488的先后。
再补一个数:非测试源码里真正调用 ctx.tools.guard( 的地方只有 1 处,在 packages/subagent/subagent-in-process-driver/src/structured.ts:109(packages/core/tools/src/index.ts:1114 那处是注册表内部的 { label: 'tools.guard()' } 标签,不是调用点)。命令是 grep -rn "tools\.guard(" packages/ --include=*.ts | grep -v "/tests/"。这说明「guard」在这个快照里是一条被慎用的机制,而不是遍地都是的钩子——但它具体拦住了什么、在什么条件下返回字符串,属于那一个调用点的实现细节,本文没有展开。
最后提醒一句审计面:审批的两个事件 approval/asked 与 approval/decided 是成对的,且都是 log-only,不进模型 transcript(docs/subsystems/approval.md:11、:88)。它们和 permission/preset、tool/call、tool/result 一样登记在 packages/core/session/src/known-event-types.ts 里。也就是说,「有没有问过人、人怎么答的」留在会话日志里,模型看到的只有工具结果那一份。这一层留痕在什么条件下可用、日志放在哪里,我们没有核实,不做展开。
关于工具目录本身怎么生成、里面 52 个工具小节与 21 个 packages/*/tool-* 目录怎么对上,我们另有一篇专门讲,这里不重复。
延伸阅读
- 从头读起:DeepSeek Harness 是什么:建仓三天、13 万 star 的 Agent 框架
- 本专题共 45 篇,完整分组目录见专题页
- DeepSeek Harness 的工具调用管线:从 pre-execute 到结果落盘
- DeepSeek Harness 的权限预设有几档:源码两档、出厂组合三档
本文依据 DeepSeek Harness 官方仓库(github.com/deepseek-ai/deepseek-harness)的 README、docs/ 下的
架构与子系统文档、以及 packages/ 下的源码整理,核对日 2026-08-16,对应仓库快照 47f9438(版本 0.1.0-rc.5)。
本文内容为仓库源码与文档口径,我们没有安装、也没有运行过这个项目,
因此不涉及界面外观、操作手感与运行速度的任何描述。
该仓库建立于 2026-08-13,README 自述处于开发者预览阶段并明确说明未来会有破坏兼容性的变更,
文中出现的命令、配置与默认值随时可能变动,请以仓库最新内容为准。
安全与合规相关做法请结合自身环境评估,本文不构成安全方案建议。