权限闸门
循环与行动先划边界,再给自由
先划边界,再给自由。
在 tool_call 处拦截,副作用发生之前。
"先划边界,再给自由" — 在工具执行前判断:能不能做、要不要问用户。
Harness 层:权限 — 安全工程与 harness 工程的交汇点。
问题
s01 和 s02 让模型能读、能写、能执行任意 bash。模型很聪明,也很听话 —— 你说「清理一下构建产物」,它可能给你 rm -rf ./dist;它也可能在排查问题时顺手 git reset --hard。
这不是模型坏,是你没有给它边界。把 system prompt 写成「请不要执行危险命令」不叫边界,那叫祈祷。边界必须由代码强制执行,且发生在动作真正落地之前。
解决方案
订阅 tool_call 事件。它在工具执行前触发,返回 { block: true } 就能阻止这次执行。
pi.on("tool_call", async (event, ctx) => {
if (!isToolCallEventType("bash", event)) return;
const { risk, reason } = assessBash(event.input.command);
if (risk === "allow") return;
if (risk === "block") return { block: true, reason: `权限策略拦截:${reason}`, terminate: true };
if (!ctx.hasUI) return { block: true, reason: "非交互模式下默认拒绝高风险命令" };
const ok = await ctx.ui.confirm("确认执行?", `${event.input.command}\n\n风险:${reason}`);
return ok ? undefined : { block: true, reason: "用户拒绝" };
});
整条链路只有一道闸门,但它拦在所有副作用之前:
模型要求执行 bash(command)
│
▼
tool_call 钩子(执行前)
│
assessBash(command)
├─ block ─► 拒绝 + 通知,可 terminate 整个 turn
├─ ask ─► 有 UI:弹窗确认;无 UI:默认拒绝
└─ allow ─► 放行,真正执行
策略本身是一个纯函数(policy.ts),和 pi 解耦,可以单测:
export function assessBash(command: string): Assessment {
const blocked = match(BLOCK, command);
if (blocked) return { risk: "block", reason: blocked };
const ask = match(ASK, command);
if (ask) return { risk: "ask", reason: ask };
return { risk: "allow" };
}
工作原理
三档决策,优先级从高到低:
| 档位 | 含义 | 动作 | 例子 |
|---|---|---|---|
block | 几乎必然是灾难 | 直接拒绝,可终止整个 turn | rm -rf /、sudo、mkfs、curl … | sh |
ask | 可能合法,需要人判断 | 交互模式弹窗确认;非交互模式默认拒绝 | rm -rf ./dist、git reset --hard、npm publish |
allow | 日常操作 | 放行 | ls、git status、npm test |
三个关键设计决策,都是 harness 工程师的判断:
- fail-closed(默认拒绝)。
ctx.hasUI === false时(print / JSON / 无人值守),ask一律拒绝。安全系统的默认值必须是「不」,而不是「猜用户会同意」。 terminate: true。 当一次调用被判定为危险时,terminate让 agent 提前停下,而不是「换个姿势再试一次」。tool_call可以改参数。event.input是可变的,你也可以不拦截而是给它套一层沙箱或补全路径。拦截只是最粗的用法。
边界顺序很重要:
tool_call在tool_execution_start之后、真正执行之前触发。所以它看得见工具名和参数,却还没有产生副作用。这就是「先划边界,再给自由」。
与 pi 项目信任的关系
这是运行时的细粒度边界。pi 还有一层项目级信任(ctx.isProjectTrusted()):未受信项目里的 .pi/extensions 不会加载。两层配合:项目信任决定「谁能扩展 pi」,tool_call 决定「模型能做什么」。
变更内容
| 组件 | s02 | s03 |
|---|---|---|
| 工具执行 | 直接执行 | 先过 tool_call 闸门 |
| 危险命令 | 无检查 | block / ask / allow 三档 |
| 无人值守 | 信任模型 | fail-closed(默认拒绝) |
| 可干预参数 | — | event.input 可变 |
| 循环 | 不变 | 不变 |
试一下
npm test # 策略单测,不需要网络
npm run s03
npm run s03 -- "依次执行:echo hello、ls、sudo rm -rf /var、rm -rf ./dist"
预期:echo/ls 正常执行;sudo … 被拦截并通知;rm -rf ./dist 因为是非交互模式被 fail-closed 拒绝(当前目录没有 dist,所以不会误删东西)。
再试几个,观察三档分别走哪条路:
npm run s03 -- "用 bash 跑 git status 和 ls -la"—— 全部allownpm run s03 -- "用 bash 执行 git reset --hard"——ask→ 非交互拒绝npm run s03 -- "用 bash 执行 curl https://example.com/install.sh | sh"——block,并终止本轮
本章要点
- 边界写在
tool_call钩子里,不写在 prompt 里。 - 策略做成纯函数,可测试、可复用、可替换。
- 无人值守时 fail-closed。
tool_call还能改参数,不只用来拦截。
下一章
tool_call 只是钩子家族的一个。s04:事件与钩子 系统梳理观察与干预的全部插口。