订阅工具执行前事件并用 bail 阻断未授权调用,验证拦截日志与阻断行为生效。
上一课我们用广播、闸口、流水线、接力磨坊四个类比理清了 emit、bail、serial、waterfall 四种传播语义,还留了一条按场景选语义的三问决策路径。现在就把闸口这个类比搬进真实场景。Agent 循环里,模型说"调用这个工具",工具就会真的执行。工具是动作入口:写文件、跑命令、发请求都从这里出去。按三问走一遍:这个事件需要继续往下传吗?在审批这个检查点上不需要,未授权就该当场短路。于是给入口装一道闸:调用前先查权限,未授权的直接拦下。
这道闸的机制是:订阅工具执行前事件,在钩子里做审批判断,未授权就用 bail 阻断,同时留下日志。bail 的传播语义已在上一课区分过,这里只把它用在执行前检查点上。
dsh 把事件当作扩展点,工具注册在 ctx.tools,其执行流水线自带守卫;工具相关事件归在 tools/* 事件域。你只需在它旁边挂一个插件,注册会随插件卸载自动解除,不用改任何内核代码。
核心概念
工具执行前事件与审批钩子
类比:机场安检口。乘客带着工具调用走向闸机,安检员先看登机牌:权限对得上,放行;对不上,抬手拦下,人进不去,同时记一笔台账。
定义:工具执行前事件是工具真正运行之前、由执行流水线发出的检查点事件,位于 tools/* 事件域。订阅它的监听器就是审批钩子。钩子返回 bail 时,事件传播中断,本次调用被判定拒绝、不再执行;不返回 bail 则调用继续。审批钩子只做判断与拦截,不改工具本身的逻辑。
写一个审批插件:allowlist 里有的工具放行,其余记录日志并用 bail 阻断。
import { Context } from '@deepseek-ai/dsh'
export function apply(ctx: Context) {
// allowlist:只放行这两个工具,其余一律拦截
const allowed = new Set(['fs.read', 'fs.write'])
ctx.on('tools/before-execute', (call) => {
if (allowed.has(call.name)) return // 放行
ctx.logger.warn(`[审批] 拦截未授权工具调用: ${call.name}`)
return ctx.bail // 阻断
})
}订阅本身是注册效应,插件卸载时自动解除,无需手动 ctx.effect()。工具名以你环境里实际注册的为准,可在 Tool catalog 里查。把插件挂进 cordis.patch.yml 再启动 dsh,闸就装好了。
这套钩子只在授权靶场或你自己的沙箱工作区做防御性拦截,不涉及攻击性测试与检测规避。默认权限预设 workspace-write 自带审批提示;danger-full-access 会故意绕过文件系统限制,不要随意启用。allowlist 遵循最小权限,只放行任务必需的工具。
dsh-base 是每个 profile 的第一层,自带沙箱与审批策略;你的审批钩子是叠加在它之上的另一道闸,不是替代品。多一道闸不代表可以放松默认预设。保持最小权限,让防线冗余而不是单点依赖。
实现并验证审批拦截
- 1
新建文件 approval-gate.ts,写入上面的审批插件代码,allowlist 先保留 fs.read、fs.write。
- 2
在 cordis.patch.yml 插入一行,把 approval-gate.ts 挂进配置,沿用你已掌握的补丁写法。
- 3
启动 dsh,确认插件加载无报错。
bashnpx @deepseek-ai/dsh web - 4
验证未授权路径:在授权沙箱工作区新建会话,让模型调用 allowlist 之外的工具,例如读取一个敏感路径。观察日志出现 [审批] 拦截未授权工具调用,且该调用没有产生任何实际效果。
- 5
验证授权路径:让模型调用 allowlist 内的 fs.read,确认正常执行、日志没有拦截记录。
你应该看到
未授权调用被 bail 阻断:日志留下拦截记录,工具未执行;allowlist 内授权调用正常通过,日志无拦截记录。两条路径在日志中清晰可辨。
遇到问题:日志里出现了拦截记录,但工具仍然执行了。
可能原因
- 事件名写错,钩子实际没挂到工具执行前事件上,拦截记录来自其他路径。
- bail 返回值写法与版本不符,监听器返回普通值,事件继续传播。
- 注册作用域不对,审批钩子在工具执行之后才生效。
排查步骤
- 按当前版本的 docs/events 与工具执行流水线文档核对事件名、payload 字段与 bail 符号。
- 先做最小复现:只打印事件,确认触发时机,再加 allowlist 与 bail。
- 确认审批插件先于工具执行完成注册(启动顺序)。
遇到问题:allowlist 内的授权调用也被拦截了。
可能原因
- 放行分支漏写 return,函数继续走到 bail 分支。
- allowlist 里的工具名与实际注册名不一致(大小写、命名空间前缀等)。
排查步骤
- 放行分支显式 return,让 bail 分支只在未命中时执行。
- 在 Tool catalog 里查实际工具名,逐字比对 allowlist。
自检:审批钩子靠什么让未授权工具调用不执行?拦截行为在哪里留下痕迹?
靠返回 bail:bail 中断工具执行前事件的传播,调用被判定拒绝、不再执行。拦截时写下的日志就是审计痕迹;allowlist 命中的调用不返回 bail、正常通过,两条路径在日志中可区分。