工具执行流水线:三段瀑布与单调 Guard
pre-execute 到 post-execute 的三段管线,Guard 只能收紧不能放行。核心源码:packages/core/tools/src/index.ts。
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 行)。
先看边界问题:两个 pre-execute 监听器,一个想 allow 一个想 ask,最终听谁的?答案是排在前面的那个。瀑布是短路的,第一个不调 next() 直接返回决定的监听器就定了案。所以 pre-execute 天然顺序敏感,插件加载顺序一变,安全结论就可能跟着变。
DSH 的解法是在 pre-execute 后面加一层顺序不敏感的终审。Guard 的返回类型只有两种:一个字符串(拒绝理由),或者 undefined(弃权)。没有任何返回值能表达同意。这样一来,注册十个 Guard 还是一百个,随便怎么排,结论只可能更严不可能更松。类型定义就是证据:
/**
* 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
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 的瀑布上跑,桥接文档顺手暴露了两个协议差异:
「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 行),而且模块注释直接写明了失败语义:
「-pre_tool_usehooks 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 提供默认只读语义 一课有完整拆解。
手推一次 rm -rf 的完整路径
部署里注册了两个 pre-execute 监听器(先 CC hooks 桥接,配置了一条 ask 规则;后一个白名单插件,对 rm 直接返回 allow)和一个沙箱 Guard(对写出工作区的命令返回理由)。模型发起 bash: rm -rf /tmp/x。第一问:审批弹窗会不会出现?第二问:把两个 pre-execute 监听器对调注册顺序,答案变不变?第三问:沙箱 Guard 的结论受这个顺序影响吗?为什么?(提示:瀑布短路 + 第 1486 行的 denialReason 只在 allow 之后才问 Guard。)