Learn Pi Agent
s03

权限闸门

循环与行动

先划边界,再给自由

30 LOC0 个工具tool_call 权限闸门
先划边界,再给自由。
机制定位
用户输入组装上下文模型推理tool_call 闸门执行工具tool_result 回传会话 / 持久化

在 tool_call 处拦截,副作用发生之前。

s02 → s03 → s04 → ...

"先划边界,再给自由" — 在工具执行前判断:能不能做、要不要问用户。

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几乎必然是灾难直接拒绝,可终止整个 turnrm -rf /、sudo、mkfs、curl … | sh
ask可能合法,需要人判断交互模式弹窗确认;非交互模式默认拒绝rm -rf ./dist、git reset --hard、npm publish
allow日常操作放行ls、git status、npm test

三个关键设计决策,都是 harness 工程师的判断:

  1. fail-closed(默认拒绝)。 ctx.hasUI === false 时(print / JSON / 无人值守),ask 一律拒绝。安全系统的默认值必须是「不」,而不是「猜用户会同意」。
  2. terminate: true。 当一次调用被判定为危险时,terminate 让 agent 提前停下,而不是「换个姿势再试一次」。
  3. tool_call 可以改参数。 event.input 是可变的,你也可以不拦截而是给它套一层沙箱或补全路径。拦截只是最粗的用法。

边界顺序很重要:tool_call 在 tool_execution_start 之后、真正执行之前触发。所以它看得见工具名和参数,却还没有产生副作用。这就是「先划边界,再给自由」。

与 pi 项目信任的关系

这是运行时的细粒度边界。pi 还有一层项目级信任(ctx.isProjectTrusted()):未受信项目里的 .pi/extensions 不会加载。两层配合:项目信任决定「谁能扩展 pi」,tool_call 决定「模型能做什么」。

变更内容

组件s02s03
工具执行直接执行先过 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" —— 全部 allow
  • npm 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:事件与钩子 系统梳理观察与干预的全部插口。