2563 字
约 8 分钟
1
工具执行流水线:三段瀑布与单调 Guard
无标签

DeepSeek Harness · 工具系统 工具执行流水线:三段瀑布与单调 Guard pre-execute 到 post-execute 的三段管线,Guard 只能收紧不能放行。核心源码: packages/core/tools/src/index.ts 。 一句话速览 pre-execute 到 post-execute 的三段管线,Guard 只能收紧不能放行

课程目标 读完你能说清三件事:一次工具调用在 DSH 里要过哪三段瀑布,每段各管什么;Guard 为什么在类型上就没有放行这个选项,插件顺序怎么排都翻不了案;被拒绝的调用不会消失,它会物化成一条模型看得见的错误结果,继续走完流水线。

交互演示 · 流水线闯关

情景 A · 一路放行 情景 B · Guard 拦下 情景 C · 恶意放行插件

播放 单步 重置

bash: rm -rf build/

第 1 段 · pre-execute 瀑布 进门前表态:allow / deny / ask,监听器可重排

Guard 层 · 单调守卫 只能给拒绝理由或弃权,类型上没有放行

第 2 段 · execute 瀑布 围着执行包一层:超时、重试、指标

第 3 段 · post-execute 瀑布 出门前改写:accept / block,换内容或换值

选择情景后点「播放」,或滚动到此处自动播放情景 A。 演示为教学化模拟:监听器与守卫的名字是课程化举例,段与段的顺序、Guard 的单调语义对应 packages/core/tools/src/index.ts 与 docs/tool-execution-pipeline.zh.md 。玩的时候盯住一件事:只要有一个环节给出拒绝理由,后面谁也翻不了案。

机制拆解 · 三段各管什么 先说清问题。权限检查、人工审批、超时、结果改写、UI 渲染,全都想挂进工具执行这一个动作里。如果让每个工具自己处理,40 个工具就有 40 份权限代码。DSH 的做法是把工具执行做成一条流水线,策略全部住在流水线的固定工位上,工具本体只做一件事:执行并返回值。 流水线的顺序写在 docs/tool-execution-pipeline.zh.md 第 8 行: tools/pre-execute 先跑,随后是单调守卫,然后是 tools/execute 和 tools/post-execute 。瀑布(waterfall)是 DSH 的监听器排队模式:每个监听器拿到 (exec, next) ,可以调 next() 把决定权交给下一位,也可以直接返回一个决定当场定案。 三段的分工很清楚。第 1 段 pre-execute 在工具跑之前表态,返回值只有三种:allow 放行、deny 拒绝、ask 转人工审批。ask 只有拿到审批服务的 allowed-once 才继续,没接审批通道就当 deny 处理。第 2 段 execute 是环绕式包装,超时策略、重试、指标都在这里给真正的执行包一层,它能替换取消信号但动不了调用身份。第 3 段 post-execute 在结果出来之后检查:原样接受、换掉内容、换掉值,或者 block 把结果改写成一条纠正性错误。

拒绝不是沉默 被 deny 的调用会物化成 Error: 理由 的 isError 结果,而且照样走 post-execute 和 tools/result 。模型能看到自己为什么被拒,循环不会因为一次拒绝卡死。 参数改不了 pre-execute 可以否决但不能改写参数。因为 tool/call 事件在执行前就落了日志,UI 的待执行卡片也已经按原参数渲染,改参数会让历史、界面、执行三方对不上( index.ts 第 583 至 586 行的类型注释写明了这条排除)。 Guard 是同步终审 Guard 在 pre-execute 全部表态之后、工具本体之前跑,签名是同步函数:返回字符串就是拒绝理由,返回 undefined 就是弃权。全局 Guard 先问,再沿 agent 的作用域链从远到近问( index.ts 第 1118 至 1127 行)。

核心视觉 · 一次调用的完整路径

tool/call 落日志 UI 同步渲染待执行卡

pre-execute 瀑布 allow / deny / ask

ctx.approval 审批 仅 allowed-once 继续

单调 Guard deny 或弃权,无 allow

execute 瀑布 超时 / 重试 / 工具本体

post-execute accept / block

deny 物化为 Error 结果 跳过工具本体,仍走 post-execute

finalizeContent 后 tools/result 冻结定稿

教学化结构图:路径对应 docs/tool-execution-pipeline.zh.md 的官方流程图,节点文案经过课程化整理。

Guard 的单调性 · 为什么类型里没有 allow 先看边界问题:两个 pre-execute 监听器,一个想 allow 一个想 ask,最终听谁的?答案是排在前面的那个。瀑布是短路的,第一个不调 next() 直接返回决定的监听器就定了案。所以 pre-execute 天然顺序敏感,插件加载顺序一变,安全结论就可能跟着变。 DSH 的解法是在 pre-execute 后面加一层顺序不敏感的终审。Guard 的返回类型只有两种:一个字符串(拒绝理由),或者 undefined (弃权)。没有任何返回值能表达同意。这样一来,注册十个 Guard 还是一百个,随便怎么排,结论只可能更严不可能更松。类型定义就是证据:

packages/core/tools/src/index.ts 第 703 至 711 行

/**
 * A monotonic execution guard evaluated after every `tools/pre-execute`
 * listener and before the tool body. Returning a reason denies the call;
 * returning `undefined` leaves it unchanged. Because guards have no allow
 * result, listener ordering cannot turn a denial back into permission.
 * @param execution - the identity-protected call after extensible pre-execute policy completed.
 * @returns a final denial reason, or `undefined` to leave the call allowed.
 */
export type ToolGuard = (execution: Readonly<ToolExecution>) => string | undefined

源码快照说明: 依据本地仓库 deepseek-harness-master,核对文件 packages/core/tools/src/index.ts ,核对日期 2026-08-13。代码块保留源码原文。 注释里那句原话值得抄下来:「guards have no allow result, listener ordering cannot turn a denial back into permission」。恶意插件想放行一个被拒的调用,不需要防,因为它在类型系统里就写不出这个动作。这比在运行时检查放行权限干净得多,问题类别直接被消灭了。 再看拒绝之后发生什么。调度器的定案逻辑分两步:只有 pre-execute 的决定是 allow(含审批通过的 ask),才轮到 Guard 逐个表态;pre-execute 的拒绝理由和 Guard 的拒绝理由汇到同一个变量里,任何一方给出理由,调用就地物化成一条 Error: 理由 的错误结果。工具本体连碰都不碰,但这个结果带着 post-result 标记继续交给 post-execute 和最终观察者。 所以审计插件、上下文注入插件在拒绝场景下照常工作,拒绝对流水线的其余部分只是一种普通结果。工具抛异常、找不到工具(UNKNOWN_TOOL)也走同样的归一化路径。 出处:定案与物化在 packages/core/tools/src/index.ts 第 1486 至 1499 行,异常与 UNKNOWN_TOOL 的归一化在第 1546 至 1555 行,核对日期 2026-08-13。

横向对比 · 同一个位置,三家三种答案 Claude Code 把权限判断分散在 Tool 接口的方法上:每个工具自带 checkPermissions 、 validateInput 、 isReadOnly ,BashTool 还要再串白名单和 ML 分类器(书稿 study/chapters/02-tool-system.md 第 350 至 376 行)。外挂扩展走 PreToolUse / PostToolUse hooks。有意思的是 DSH 自己实现了一个 CC hooks 桥接插件 packages/hooks/hooks-claude-code ,把 CC 的 hook 挂到 DSH 的瀑布上跑,桥接文档顺手暴露了两个协议差异:

packages/hooks/hooks-claude-code/README.zh.md · 第 92 行(已知限制) 「 PreToolUse 只支持部分功能: deny 与 ask 决策可用; allow 不会预审批,不支持 defer , additionalContext 会被忽略, updatedInput 会被记录 + 警告但不应用」

这两条限制另有原因:流水线的不变式在挡路。CC 原生 hook 可以 allow 预审批、可以用 updatedInput 改写工具参数;DSH 的桥接把前者降级、把后者只记日志不执行,因为放行权在 DSH 里不外借,参数在 tool/call 落日志之后不可变。同一份 CC hook 配置,换个宿主,能做的事就变少了,这正好量出了两套协议的表达力边界。另外多个 CC hook 在桥接里按最严格方式折叠(deny 优先于 ask 优先于 allow),折叠结果与顺序无关(README 第 49 行),和 Guard 的单调思路一脉相承。 Grok Build 的 hooks 系统( crates/codegen/xai-grok-hooks )只有 pre_tool_use 一个点能拦截,决策类型是 Allow 或 Deny 两个值( src/result.rs 第 5 至 10 行),而且模块注释直接写明了失败语义:

grok-build-main/crates/codegen/xai-grok-hooks/src/lib.rs · 第 16 至 17 行 「- pre_tool_use hooks can deny/allow (blocking); all others are non-blocking - Fail-open by default: hook failures do not block normal operation」

Fail-open 的意思是 hook 自己崩了、超时了,调用照常放行。DSH 反过来:pre-execute 监听器抛异常,这次调用直接归一化成错误结果,宁可错杀。两种取向都讲得通,Grok 把 hooks 当外挂增强,不让用户脚本拖垮主流程;DSH 把策略当流水线的正式工位,工位塌了调用就不该过。Grok 工具系统的注册表与只读语义,站内 ToolKind 提供默认只读语义 一课有完整拆解。

课堂练习

01 手推一次 rm -rf 的完整路径 部署里注册了两个 pre-execute 监听器(先 CC hooks 桥接,配置了一条 ask 规则;后一个白名单插件,对 rm 直接返回 allow)和一个沙箱 Guard(对写出工作区的命令返回理由)。模型发起 bash: rm -rf /tmp/x 。第一问:审批弹窗会不会出现?第二问:把两个 pre-execute 监听器对调注册顺序,答案变不变?第三问:沙箱 Guard 的结论受这个顺序影响吗?为什么?(提示:瀑布短路 + 第 1486 行的 denialReason 只在 allow 之后才问 Guard。)

Takeaway: 三段瀑布各管一段:进门前表态、围着执行包一层、出门前改写结果。顺序敏感的扩展放瀑布里,顺序不敏感的否决权交给 Guard,Guard 的类型里没有 allow,拒绝一旦成立谁也翻不了案。拒绝也是一等结果:物化成 Error 文本给模型,流水线照常走完。

工具执行流水线:三段瀑布与单调 Guard
http://www.clxhxhhr.top/posts/4025/
作者
clxstart
发布于
2026-09-25
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。