前置:第 03、04、05 课 | 预计时长:60–90 分钟 | 动手环节:必须完成
你将学会
- 工具执行的四道关卡:pre-execute → 单调守卫 → execute(around + 信号熔断)→ post-execute;
- 审批闭集
allow | deny | ask与 ApprovalService 的日志先行纪律; - 安全模型的三个正交旋钮:审批策略 × 沙箱模式 × 逐次批准,以及 fail-closed 姿态;
- landlock 的 self-restrict-then-exec 顺序。
问题引入
第 04 课的工具调用事件流跳过了执行本身。这一课补上最敏感的一段:模型说「帮我删这个文件」,从决策到落地之间有几道闸门?谁有权拦?拦了之后怎么审计? dsh 的答案:四道关卡 + 三个正交旋钮 + 一条铁律——fail-closed。
正文
9.1 模型看到什么:只有投影
工具对模型只投影 name / description / parameters 三个字段(packages/core/tools/src/index.ts:1255-1266 的 schemaOf);执行回调、超时等绝不发给模型。schema 由 ToolRuntime 构造时挂接 ctx.systemPrompt.tools(...) 自动进入提示词组装(tools/src/index.ts:833)——你在第 10 课注册工具时不需要手动同步提示词。
9.2 四道关卡
对应 prepareExecution(packages/core/tools/src/index.ts:1341-1506):
execute(exec) {
gate = await waterfall('tools/pre-execute', exec, () => ({kind:'allow'})) // :1474-1477 关卡①
if (gate.kind === 'ask') gate = await serviceAsk(exec, gate) // 无审批通道即拒绝
if (gate.kind === 'deny') return errorResult(gate.reason)
denial = guardReason(exec) // 关卡②:单调守卫再过一遍 :1485-1487
result = await waterfall('tools/execute', exec, runBody) // 关卡③:around 包裹 :1572-1575
// 包装器只能替换 signal;体内把原始信号重新熔断(fuseToolSignals :1888-1915)
result = await waterfall('tools/post-execute', ...) // 关卡④:可替换/阻断 :1741-1780
emit('tools/result', finalize(result)) // 失败一律物化为错误结果,不抛出
}
| 关卡 | 语义 | 谁在用 |
|---|---|---|
① tools/pre-execute | allow / deny / ask 闭集决策 | 审批策略、外部 hooks 桥、UI |
| ② 单调守卫 | guard() 注册的守卫只能拒绝、不能放行,且在可扩展瀑布之后运行 | 循环卫生、安全兜底 |
③ tools/execute | around 包裹:包装器只能替换 signal(如上截止时间);体内重新熔断原始信号防甩脱 | guard 包的 timeout-policy |
④ tools/post-execute | 可替换 / 阻断结果 | spill-policy 把超大输出换成引用(第 06 课) |
最后一条纪律:失败一律物化为错误结果写进日志,不向调用方抛出——工具失败是领域事实,不是基础设施崩溃。
9.3 审批:闭集 + 日志先行
对应 core/tools/src/index.ts:1474-1498 与 packages/interaction/user-approval/:
// ApprovalService.request(user-approval/src/index.ts:222-241)
require(处于打开的 turn)
session.append('approval/asked', { id, toolName, ... }) // 先记日志
outcome = await decide(req, session)
// 'never' 策略在服务自身路径确定性拒绝(防监听器抢注绕过)
// 否则走 approval/request scoped waterfall,兜底 'unavailable'(fail-closed)
session.append('approval/decided', { id, outcome }) // 再记结果
三个要点:
- 决策是闭集:
PreToolDecision = allow | deny | ask(core/tools/src/index.ts:589-592),ask走一次性人工批准;客户端(ui-approval)与 ACP 自动化都通过监听approval/request瀑布来回答。 - 审计可回溯:
approval/asked→ 裁决 →approval/decided都在会话日志里(第 05 课的事件溯源在这里兑现安全价值)。 - fail-closed 全程:无审批服务或无 agent → 拒绝;审批瀑布异常 → 归一为
unavailable;never策略不依赖监听器自觉,在服务自身路径确定性拒绝。
9.4 三个正交旋钮
dsh 的权限模型没有 allow/deny 规则表,核心是两个旋钮的组合,第三个是逐次批准:
ApprovalPolicy = 'ask' | 'never' ← 每个工具调用要不要人点头
× SandboxMode = 'read-only' | 'workspace-write' | 'danger-full-access'
× 逐次人工批准(ask 的一次性裁决)
预设把两旋钮打包(如 read-only + ask)。沙箱模式解析优先级:批准过的显式模式 > 会话 sandbox/mode 事件 > 部署默认(默认 read-only,fail-safe)。
9.5 sandbox 抽象与 landlock
Service Definition 只有一个方法:SandboxProvider.confine(argv, policy) → 包装后 argv(packages/sandbox/sandbox/src/index.ts:158-176)。禁止静默直通——没有沙箱就明说 SANDBOX_UNAVAILABLE,绝不假装执行。
本地后端选择(sandbox-local/src/index.ts:159-166):平台链优先、探测其次——linux: ['bwrap','landlock'],darwin: ['seatbelt'],win32: ['windows-acl'];唯一候选直接选中,多候选按链序功能探测(真跑一次 read-only profile),全部不可用 → unavailable(fail-closed)。
landlock 启动器(native/landlock-run/main.c)是 self-restrict-then-exec:
restrict_self() { // main.c:230-262
abi = 查询内核 ABI;按 abi 收缩 handled mask
for (path of 规则) add_rule(...) // --ro 只给读+执行位;--rw 给全部访问位
prctl(PR_SET_NO_NEW_PRIVS, 1) // :254 封死 setuid 提权
landlock_restrict_self(ruleset_fd) // 失败即退出,绝不 exec
execvp(wrapped_command) // :295 最后才 exec
}
规则集随 exec 继承,调用者不受限;不接受任何环境变量覆盖(哪个二进制约束进程不能被环境决定);无安装期构建回退——没有匹配平台包就探测失败、消费方 fail closed。
9.6 guard 与 hooks 的准确分工
ctx.tools.guard():单调守卫——只能拒绝,不能把别人的拒绝翻成允许(关卡②的语义来源)。guard包的两个插件不是权限判定:timeout-policy(给声明timeoutMs的工具上截止时间,走关卡③)、repeat-tool-reminder(重复调用提醒,无否决权)。hooks包把 Claude Code / Codex 外部钩子桥到tools/pre-execute:钩子输出的deny/ask直接变成PreToolDecision,汇入同一条审批路径(hooks-claude-code/src/index.ts:238-244);每次调用以hook/invoked+hook/result事件对持久化审计。
动手环节
- 画关卡图:照 9.2 画一张从
tool/call到tool/result的序列图,标注四个 waterfall 的名字与每关的「否决权」来源;对照源码行号核对。 - 在快照里找审计:翻一个含工具调用的录制会话,找出
approval/asked/approval/decided事件对(若没有,说明该用例走的是 allow 直通;解释为什么直通也安全——提示:旋钮组合)。 - 推演题:某插件想在
tools/pre-execute里把别人的deny改成allow。为什么做不到?至少给出两层拦截(提示:单调守卫的位置与语义)。
自检清单
- 四道关卡各自能改变什么?哪一关不能「放行」只能「拒绝」?
-
ask在没有审批通道时落在什么结果?这条规则叫什么? - 三个正交旋钮分别是什么?默认值是什么?为什么默认
read-only? - landlock 为什么必须先
NO_NEW_PRIVS再 restrict 再 exec?顺序错了会怎样? - 工具抛异常为什么被物化而不是向上抛?
常见误解
- 「安全靠一张规则表」——没有规则表;是旋钮组合 + 关卡管线 + fail-closed。
- 「guard 包负责权限」——不负责;它是循环卫生与超时,权限判定在审批管线。
- 「沙箱不可用时静默降级为直跑」——绝不。fail-closed:宁可不可用,不降级为无防护。
延伸阅读
- 维基:《sandbox-and-permissions》、《core-spine》(tools 一节)
- 仓库:
docs/tool-execution-pipeline.md、docs/defensive-patterns.md、SAFETY.md - 下一课:第 10 课 · 毕业项目:写一个完整插件