青戈AI小栈
会员限时8折

拆解 pi:Agent 要不要在每次调用工具前请求确认?

阅读设置

字号
行距
代码

假设你让一个编程 Agent 清理项目、修改代码,再运行测试。它读一个文件问一次,改一处代码问一次,执行一条命令再问一次。任务还没完成,你可能已经开始不看内容就点“同意”。

可如果一次也不问呢?同样是执行命令,运行测试和删除项目目录,后果显然不同。真正难的,是把需要人判断的动作挑出来,同时让其他工作继续进行。

pi 的选择很直接:默认不在每次工具调用前请求确认,在执行前留下一个可拦截的钩子,由扩展决定放行、询问还是拒绝。但它的官方示例还有一个值得追问的现象:rm -rf build/ 会触发确认,换成效果相同的 rm -fr build/,却不会。

为什么留下了拦截点,仍然拦不住?沿着这个问题读源码,可以看清确认机制能保证什么、不能保证什么,再把这些判断用到自己的 Agent 里。

为什么拆 pi,读完能带走什么

pi 是 Mario Zechner 开源的 TypeScript Agent 框架,采用 MIT 许可证,包含模型接入、Agent 循环和一个编程助手产品。它的取向是内核尽量小:确认、权限这类带有产品策略的功能,不写死在循环里,而是通过扩展接入。代码量小、层次清楚,适合用来了解“一个 Agent 框架到底把哪些事放在执行路径上”。

本文只拆工具调用前的权限设计,不要求先熟悉 pi 的其他模块。读完可以回答三个问题:

  • 拦截点放在调用链的哪一步,拦住的是什么,拦不住什么;
  • 被拒绝的调用,模型下一轮会看到什么,为什么拒绝一次不等于任务结束;
  • 自己做企业 Agent 时,授权、确认和隔离各自该放在哪里,怎样让用户确认的内容就是最终执行的内容。

先看一个漏拦:只换参数顺序,就不用确认了

官方扩展示例 permission-gate.ts 检查几类危险命令,命中后才询问用户。下面用示例中的三条正则,对一组人工挑选的命令做本机 Node 匹配测试。这里只运行字符串匹配,命令本身没有被执行。

命令 是否命中示例规则
rm -rf build/ 命中,需要确认;无界面时拦截
rm -r -f build/ 命中,需要确认;无界面时拦截
sudo apt install curl 命中,需要确认;无界面时拦截
chmod -R 777 /srv 命中,需要确认;无界面时拦截
rm -fr build/ 未命中,示例放行
rm --force --recursive build/ 未命中,示例放行
find . -name '*.log' -delete 未命中,示例放行
git clean -fdx 未命中,示例放行
python -c "import shutil; shutil.rmtree('build')" 未命中,示例放行

rm -fr 和 rm -rf 效果相同,却只有后者命中。find -delete、git clean 和脚本里的删除调用,也都能造成文件删除。这组结果先说明一件事:规则判断的是命令的写法,还没有识别出“删除哪些文件”这个动作。

不过,漏拦究竟是示例规则的问题,还是框架没有把关?先看示例怎样接入,再沿调用链找执行边界。

一个钩子,怎样决定放行、询问和拒绝

下面是官方 permission-gate.ts 的主体,省略文件头和导入。代码摘自 pi 提交 676af1d,MIT 许可证,Copyright (c) 2025 Mario Zechner;文中源码链接均固定到这个版本。

阅读时抓住三个出口:其他工具不管,命中规则后询问,没有可用界面或用户未同意时拒绝。

permission-gate.ts 第 10–35 行

扩展通过 pi.on("tool_call", ...) 注册处理器。返回 undefined 表示这个处理器不拦截;返回 { block: true, reason: ... } 才会阻止调用。await ctx.ui.select(...) 则让处理器等待用户选择。

因此,这个钩子同时支持三种处理:无需确认就放行、暂停等待确认、直接拒绝。 同目录的 protected-paths.ts 就对部分 write、edit 路径直接拦截,不询问用户。

这里的“默认拒绝”有严格范围:示例只在命中那三条危险模式后,才检查有没有界面。未命中的调用依旧放行。开头的 rm -fr,根本到不了询问这一步。

那么,只要处理器返回了 block,工具就一定不会执行吗?这个保证要到循环里确认。

拦截点为什么放在参数校验之后、工具执行之前

pi 把 Agent 循环放在 agent 包,扩展机制放在 coding-agent 产品层。循环不认识“权限扩展”,它只在预检函数 prepareToolCall 里调用 beforeToolCall。产品层再把它接到扩展的 tool_call 事件上。

先看一次工具调用经过哪些关口。图依据 agent-loop.ts 第 718–787 行:

flowchart TD
  A["模型输出工具调用"] --> B{"工具存在且参数校验通过?"}
  B -->|否| X["不执行,生成错误结果"]
  B -->|是| C["等待 beforeToolCall 钩子"]
  C --> D{"钩子拦截、抛异常<br/>或运行已中止?"}
  D -->|是| X
  D -->|否| E["prepared:准备执行"]
  E --> F["执行工具"]
  X --> G["结果写入对话历史"]
  F --> G

prepareToolCall 先找工具,再用 validateToolArguments 校验参数。通过后,才把校验后的 args 交给 beforeToolCall。钩子被 await,所以可以等待用户;等待期间,这次工具调用还没有执行。

这一步的意义是:提示词里写“删除前请先询问”依赖模型照做;执行路径中的钩子可以直接阻止调用。它拿到的还是经过结构校验的参数,便于检查路径、金额、对象等字段。但字段类型正确,不代表当前用户有权操作这个对象。

真正阻止执行的是钩子返回后的分支。下面只看中止和拦截部分,后续放行与异常处理用文字说明:

typescript
if (signal?.aborted) {  // 先看是否已中止
  return {
    kind: "immediate",
    result: createErrorToolResult("Operation aborted"),
    isError: true,
  };
}
if (beforeResult?.block) {  // 只有 block 为真才拦截
  const result = createErrorToolResult(beforeResult.reason || "Tool execution was blocked");  // reason 就是模型下一轮看到的文字
  if (beforeResult.terminate === true) {
    result.terminate = true;  // terminate 只在拦截时才会记录
  }
  return {
    kind: "immediate",
    result,
    isError: true,
  };
}

agent-loop.ts 第 748–765 行

block 为真时返回 immediate,意思是直接产出结果,不进入工具执行。reason 变成错误结果的正文;terminate 是可选的“本批之后停止运行”提示,下一节再看它的限制。

如果没有拦截,函数会返回带有工具和参数的 prepared,交给执行路径处理。校验或钩子抛异常也会被 catch 转成错误结果,这次工具调用不会执行;运行已中止时同样不执行。依据见 agent-loop.ts 第 767–786 行。

至此可以确认:钩子能阻止这一次工具调用执行。接下来要区分的是:阻止一次调用,是否也阻止了 Agent 继续完成同一个动作。

拒绝会交还模型,但拒绝一次不等于任务结束

被拦截后,pi 会把原因组成一条 role: "toolResult"、isError: true 的消息,写入对话历史。正常继续循环时,模型在下一轮能读到这条原因。对应路径见 预检结果的处理和 createToolResultMessage。

这让 Agent 有机会恢复。例如某个命令不可用,可以换一种允许的实现;某个对象没有权限,可以停下来请求用户补充授权。原因写得清楚,模型才知道接下来允许做什么。

但这里也有另一面。假设用户拒绝的是“删除 build 目录”,模型改用 Python 删除,用户的拒绝仍然应该有效。只拦 rm -rf 这段文本,就表达不了这条业务约束。

下面是两种不同含义的反馈,均为说明用的示意文本,不是 pi 的实际运行记录:

拒绝原因 传达的约束
不允许使用这条命令,请换一种方式 可能允许换实现完成原动作
用户未授权删除 build 目录;保留目录,只列出待清理文件 拒绝删除,给出允许的下一步

反馈负责让模型理解约束,执行层仍要在每次调用时落实约束。 不能仅靠把拒绝原因写得更严厉,防止模型换工具绕过。

除了文字原因,拦截结果还可以带 terminate: true,提示循环在这一批工具结束后停止,不再让模型继续。但它不等于立即终止整批调用,批次结束时的判断是:

typescript
function shouldTerminateToolBatch(finalizedCalls: FinalizedToolCallOutcome[]): boolean {
  return finalizedCalls.length > 0 && finalizedCalls.every((finalized) => finalized.result.terminate === true);
}

agent-loop.ts 第 700–702 行

只有非空批次里的每个最终结果都带 terminate: true,这项判断才成立。同批还有正常结果时,单个拦截结果的 terminate 不足以结束循环。因此,不能把“返回一次拒绝”当成“撤销同批其他动作”。

调用的结果反馈解决了恢复问题,而持续有效的授权规则,必须覆盖同一业务动作的其他执行入口。

多个扩展接力检查,检查过的还是执行的参数吗

再往产品层走一步。agent-session.ts 第 668–697 行 把 beforeToolCall 接到扩展运行器。没有 tool_call 处理器时返回 undefined;有处理器时,把校验后的 args 作为事件的 input 传下去。

扩展运行器的核心如下。这里关注的不是返回类型,而是处理器的执行顺序和提前退出条件:

typescript
async emitToolCall(event: ToolCallEvent): Promise<ToolCallEventResult | undefined> {
  const ctx = this.createContext();
  let result: ToolCallEventResult | undefined;

  for (const { handlers } of snapshotEventHandlers(this.extensions, "tool_call")) {
    for (const handler of handlers) {
      const handlerResult = await handler(event, ctx);

      if (handlerResult) {
        result = handlerResult as ToolCallEventResult;
        if (result.block) {
          return result;
        }
      }
    }
  }

  return result;
}

runner.ts 第 1256–1274 行

处理器逐个 await,只有出现 block 才提前结束。一个处理器放行,不代表后面的处理器也必须放行;处理器抛出的异常会向上传递,最终成为这次调用的错误结果。

更容易忽略的是:input 与工具执行时使用的参数指向同一个对象。处理器原地修改它,会改变后续处理器看到的值,也会改变工具最终收到的值,修改后不会自动重新校验。可对照 input 的传递位置和 执行参数的来源。

用一个假设的采购单说明影响:前一个处理器检查金额 8000 元并放行,后一个处理器又把金额改成 18000 元。如果最终执行前不重新核对,前面的许可就不再覆盖实际执行的内容。

这不是说扩展必然会这样修改,而是揭示了一个设计前提:许可应当对应最终执行的参数。 在自己的系统里,可以先完成参数补全与转换,再基于确定的参数做最终授权和人工确认;若确认后的关键字段发生变化,就重新确认。业务服务也要在执行时核对权限,不能把钩子里的检查当成唯一依据。

确认除了要对准最终参数,还要基于已经确定的业务状态。pi 的并行路径先逐个预检所有调用,再并发执行通过的调用,所以钩子看不到同批其他调用的执行结果。源码见 executeToolCallsParallel 第 606–664 行。

例如“先创建采购草稿,再确认提交”依赖前一步的真实结果,就不能在预检提交时假定草稿已经创建。pi 支持把工具标为 executionMode: "sequential",让整批按顺序走完预检、执行和收尾;如果后一步还需要使用前一步刚生成的编号,应等结果交还模型后再发起调用。串行执行本身不会替模型补出未知参数。

回到漏拦:确认、授权与隔离分别管什么

现在可以回答开头的疑问了。rm -fr 漏过,是因为示例正则没有覆盖这种写法。示例的目的是演示怎样使用钩子,只检查了这三类写法。框架提供了拒绝入口,却没有替使用者定义完整的权限规则。

给正则补上 -fr 只能修复这一种漏匹配。只要仍然提供通用命令工具,还能通过其他程序、脚本和等价写法做同样的事。对于这类工具,很难仅靠列举危险文本表达完整的业务权限。

下面是根据这些行为作出的工程分析。设计自己的 Agent 时,可以把三个问题分开考虑,再组合使用:

问题 要决定什么 采购场景中的例子
授权 当前身份能否对这个对象执行这个动作 只能处理本公司的采购单,不能跨公司提交
确认 已允许范围内的这次动作,是否还需要人作决定 向供应商正式提交前,确认对象、金额和内容
隔离 生成的代码或命令出错时,最多能影响什么 编程环境不挂载生产采购凭据,限制文件和网络访问

每次都弹确认,会增加打断,也可能让用户习惯性同意;选择性确认能减少打断,但要求规则真正覆盖需要介入的动作。两者都不能替代服务端授权。环境隔离又是在限制可接触的资源,可以与确认同时使用。

pi 的官方安全文档强调:模型生成的命令和代码应视为不可信,观察对话与审查改动并不构成安全边界;把整个 pi 放进受限环境通常是最强的实际选择。但暴露给该环境的凭据和网络服务仍然可以被访问。

这也解释了为什么“放进容器”之后仍可能需要业务授权和确认:容器限制了宿主机资源,却不会自动判断一笔采购是否该提交。

可借用的做法

把前面读到的行为整理成可以直接用到自己 Agent 里的几条:

  • 把拦截点放在参数校验之后、执行之前。 提示词只能请求模型照做,执行路径上的钩子才能阻止调用,并且拿到结构正确的参数。
  • 拒绝要写清允许的下一步。 拒绝原因会成为模型下一轮看到的内容,写“保留目录,只列出待清理文件”比写“不允许”更有用。
  • 许可要对应最终执行的参数。 多个处理器共用同一个参数对象时,前面的检查可能被后面的修改改变,确认前要先把参数补全定稿。
  • 规则围绕动作和对象,不围绕命令文本。 通用命令工具无法靠列举写法穷尽,专用工具才能表达“谁、对哪个对象、做什么”。
  • 确认、授权、隔离分开设计。 确认不能替代服务端授权,容器也不能替代业务确认。
  • 依赖前一步结果的确认,等结果出来再发起。 并行预检看不到同批其他调用的执行结果。

应用示例:把一笔采购单走完

以下是一个设想中的供应链询价 Agent 的设计推演,用于展示怎样应用上面的做法,不是 pi 已有功能,也不是运行实测。

先限定任务:查询本公司供应商报价、保存采购草稿;正式提交必须由有权限的人确认。工具按影响分档,但每一档都先经过身份和对象权限检查:

工具 放行前提 是否询问
查询报价 当前用户可查看该供应商及报价字段,结果不越权暴露 在已授权范围内直接执行
保存草稿 只保存本公司草稿,不触发通知或正式采购流程 可以直接执行,保留修改记录
提交采购单 用户有提交权限,供应商、金额、单据状态均符合业务规则 展示本次提交内容,确认后才执行;无人值守时拒绝

假设模型申请提交采购单 PO-1042,版本为 7,供应商为“甲供应商”,金额为 800000 分,即 8000 元。执行过程分成四步。

第一步,检查这件事是否允许做。 结构校验确认金额是整数、编号格式正确;业务服务根据登录身份检查单据属于本公司、用户有提交权限,并从可信数据源读取供应商、金额和当前版本。不能仅凭模型传来的公司编号或金额授权。

第二步,让人确认具体后果。 确认界面展示“向甲供应商提交 PO-1042,第 7 版,金额 8000 元,提交后进入正式采购流程”。用户同意的是这份内容,而不是以后所有叫作“提交采购单”的调用。

第三步,在执行时核对确认仍然有效。 用户等待期间,其他人可能修改草稿。执行服务应校验确认所对应的单据版本:仍是第 7 版才提交;变成第 8 版就拒绝,并展示新内容重新确认。同一份提交还应防止重试造成重复单据。

第四步,把可继续做的事交还模型。 用户拒绝时,返回“用户未授权提交 PO-1042;保留草稿,不发送邮件,可继续整理报价对比”。无论模型接下来调用提交工具还是邮件工具,都要遵守同一条对外发送规则。

专用工具让规则能围绕“谁、对哪张单据、提交哪个版本”来表达,比识别一段 shell 文本容易。但可信身份、对象归属和最终参数检查仍要自己实现,工具名本身不提供这些保证。

回到标题:不必把每次工具调用都变成一次人工确认;应当先限定允许的动作和资源,再让人在需要承担后果的位置作决定。 pi 值得借用的是执行前可等待、可拒绝、可反馈的钩子。把它用到企业 Agent 里,还要保证用户确认的内容,就是最终被执行的内容;拒绝的业务动作,也不会换个工具继续发生。

依据与版本

  • 本文所读版本:pi 提交 676af1d(2026-11-03)。pi 处于快速迭代中,本文行为和行号以此提交为准。
  • 核心调用链:packages/agent/src/agent-loop.ts、packages/agent/src/types.ts、packages/coding-agent/src/core/agent-session.ts、packages/coding-agent/src/core/extensions/runner.ts。
  • 官方说明与示例:packages/coding-agent/docs/security.md、packages/coding-agent/examples/extensions/permission-gate.ts、protected-paths.ts。源码代码块为节选,缩进已调整;企业采购案例与设计建议为本文推演。

访问课程源码

此链接指向 GitHub 私有仓库。请登录已获授权的 GitHub 账号,并接受仓库邀请后访问;否则可能显示 404。

尚未获得权限?有效 VIP 会员可联系站长,提供 GitHub 用户名申请加入。

有帮助

设置你的昵称

在讨论中,使用你自己的名字。

2–12 个字符0 / 12