Agentpath
动手15 / 31 节 · 预计 40 分钟

权限门与路径保护:让它别删库

Pi 不内置权限弹窗 —— 这不是缺陷,是让你自己定义什么叫危险。

学完这节你能做到

  • 实现一个按工具与参数分级的确认门
  • 用路径白名单/黑名单挡住写操作越界
  • 为无人值守场景设计「不弹窗也安全」的策略

Pi 不内置权限弹窗。文档说得很明白:它「不包含内置沙箱」, project trust「不是沙箱,也不限制模型能让工具做什么」。

这不是偷懒。理由是:一个进程内的半沙箱很容易被误认为安全边界, 而它底下仍然靠着宿主的 shell、文件系统、包管理器和凭据。 真正的隔离必须来自操作系统或虚拟化(那是下一阶段的内容)。

所以这一节做的是另一件事:在你自己定义的危险边界上建一道门。

先定义什么叫危险

别一上手就写正则拦 rm。先按后果分类:

判据例子策略
安全无副作用、可重复read grep ls git status放行
局部可逆只动工作目录,且有版本控制edit write git commit放行,事后可 diff
不可逆删数据、改历史rm -rf git push --force DROP TABLE拦,必须确认
对外可见别人会看到git push curl -X POST 外部 API、发消息拦,必须确认
越界碰工作目录以外~/.ssh/etc、别的仓库拦,默认拒绝

最后两档是重点。「删了本地一个文件」通常能救回来,「往生产发了个请求」救不回来。

落点只有一个:tool_call

要拦就拦在「已决定执行、尚未执行」这个唯一时点。Pi 的 tool_call 事件是阻塞的:

import { isToolCallEventType } from "@earendil-works/pi-coding-agent"

export default function (pi: ExtensionAPI) {
  pi.on("tool_call", async (event, ctx) => {
    if (!isToolCallEventType("bash", event)) return

    const risk = classify(event.input.command)
    if (risk === "safe") return

    // 没有界面(print / 无人值守)时不能弹框 —— 默认拒绝,别默认放行
    if (!ctx.hasUI) {
      return { block: true, reason: `无人值守模式拒绝执行 ${risk} 级命令:${event.input.command}` }
    }

    const ok = await ctx.ui.confirm(
      `确认执行?(${risk})`,
      event.input.command,
    )
    if (!ok) {
      return { block: true, reason: "用户拒绝了这条命令。换个方式,或者说明为什么必须这么做。" }
    }
  })
}

三个关键决定藏在这段代码里:

  1. ctx.hasUI 为假时默认拒绝。 无人值守场景下没法问人, 而「问不了就放行」是所有事故的共同起点。
  2. reason 是写给模型的。 说清「被拒了 + 可以怎么办」, 否则它会原地重试同一条命令。
  3. terminate: true 要慎用。 它会在这一批工具结束后停下整轮 —— 适合「这次任务方向不对」,不适合「这条不行换一条」。

路径保护

路径判断比命令判断更值得做,因为它的判据清晰:在工作目录内 or 不在

import { resolve, relative, isAbsolute } from "node:path"

function outsideWorkspace(target: string, cwd: string) {
  const abs = resolve(cwd, target)
  const rel = relative(cwd, abs)
  return rel.startsWith("..") || isAbsolute(rel)
}

要拦的路径清单(官方 protected-paths 示例的思路):

const NEVER_WRITE = [
  /(^|\/)\.git\/(config|hooks)\//,   // 改 hooks 等于任意代码执行
  /(^|\/)\.ssh\//,
  /(^|\/)\.aws\//, /(^|\/)\.kube\//,
  /(^|\/)\.env(\.|$)/,               // 凭据
  /^\/etc\//, /^\/usr\//,
  /(^|\/)node_modules\//,            // 改了也会被覆盖,纯浪费
]
×路径匹配的四个坑

../ 穿越、符号链接(resolve 之后再 realpath)、大小写不敏感的文件系统 (macOS 上 .SSH 也能命中真实目录)、还有 shell 展开(~$HOME、glob)—— 拿字符串匹配 bash 命令里的路径,永远漏得比拦得多。

上面那句提醒很重要:命令行审查天生是不完备的

rm -rf /important                  # 拦得住
r''m -rf /important                # 引号拼接
eval "$(echo cm0gLXJm | base64 -d)" # 编码
python -c "import shutil; shutil.rmtree('/important')"  # 换语言
git config core.pager 'rm -rf /'   # 改配置后由别的命令触发

不是说别做 —— 拦下 90% 的误操作已经很有价值。但要清楚它防的是「模型判断失误」, 不是「有人主动绕过」。后者只能靠隔离。

参数改写:比拦截更温和的一手

event.input 是可变的,改了就是真的改了执行参数。这给了第三条路 —— 不拦,而是改成安全的版本:

pi.on("tool_call", async (event) => {
  if (!isToolCallEventType("bash", event)) return

  // 强制 dry-run
  if (/^kubectl\s+delete/.test(event.input.command)
      && !event.input.command.includes("--dry-run")) {
    event.input.command += " --dry-run=client"
  }

  // 给所有命令加超时,避免挂死
  if (!event.input.command.startsWith("timeout ")) {
    event.input.command = `timeout 300 ${event.input.command}`
  }
})
!改写之后不会再做 schema 校验

文档明确说明:mutation 会到达真实执行、后续 handler 看到改过的值、 之后不再重新校验。所以改写代码本身就是安全边界的一部分,写错了没人兜。

确认疲劳:门太多等于没有门

一个每三步就弹框的 agent,用户三分钟后就会开始无脑点「允许」。降低频率的四招:

  1. 只拦最后两档(不可逆 / 对外可见),前面全放行。
  2. 同类记住选择:这次批准了 git push origin feature/*,本会话内同模式不再问。
  3. 批量确认:一次列出接下来要跑的 5 条命令,一起批。
  4. 给「阅读模式」开关:--no-approve 之类,明确进入只读,就完全不用问。

无人值守的四道防线

弹框在无人值守时没有意义。这时候靠的是四层叠加,缺一层就有事故:

防线挡什么
白名单工具集(--tools read,grep,find,ls从根上没有写入能力
预算与轮次上限烧钱、死循环
路径 / 命令拦截,无 UI 时默认拒绝误判造成的破坏
容器 / micro-VM 隔离上面三层全失效时的兜底

L4 的「失控的 agent」那节会让你逐条复查这四道防线为什么会同时失效。

检查点单选

你的权限门在 print 模式(无人值守)下拿不到 UI。这时最合理的默认行为是?

交作业

给 L1 写的 mini-agent 加一道门:

  1. bash 工具执行前分类,不可逆/对外可见的两档要确认。
  2. write_file 只允许写工作目录内,路径先 resolverealpath
  3. 无交互环境下一律拒绝,并把拒绝原因回灌给模型。
  4. 试着绕过你自己的门(引号、编码、换语言),把绕过成功的样本记下来 —— 这份清单就是你需要沙箱的理由。

延伸资料