拆解 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;文中源码链接均固定到这个版本。
阅读时抓住三个出口:其他工具不管,命中规则后询问,没有可用界面或用户未同意时拒绝。
export default function (pi: ExtensionAPI) {
const dangerousPatterns = [/\brm\s+(-rf?|--recursive)/i, /\bsudo\b/i, /\b(chmod|chown)\b.*777/i];
pi.on("tool_call", async (event, ctx) => {
if (event.toolName !== "bash") return undefined;
const command = event.input.command as string;
const isDangerous = dangerousPatterns.some((p) => p.test(command));
if (isDangerous) {
if (!ctx.hasUI) {
// In non-interactive mode, block by default
return { block: true, reason: "Dangerous command blocked (no UI for confirmation)" };
}
const choice = await ctx.ui.select(`⚠️ Dangerous command:\n\n ${command}\n\nAllow?`, ["Yes", "No"]);
if (choice !== "Yes") {
return { block: true, reason: "Blocked by user" };
}
}
return undefined;
});
}扩展通过 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 --> GprepareToolCall 先找工具,再用 validateToolArguments 校验参数。通过后,才把校验后的 args 交给 beforeToolCall。钩子被 await,所以可以等待用户;等待期间,这次工具调用还没有执行。
这一步的意义是:提示词里写“删除前请先询问”依赖模型照做;执行路径中的钩子可以直接阻止调用。它拿到的还是经过结构校验的参数,便于检查路径、金额、对象等字段。但字段类型正确,不代表当前用户有权操作这个对象。
真正阻止执行的是钩子返回后的分支。下面只看中止和拦截部分,后续放行与异常处理用文字说明:
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,
};
}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,提示循环在这一批工具结束后停止,不再让模型继续。但它不等于立即终止整批调用,批次结束时的判断是:
function shouldTerminateToolBatch(finalizedCalls: FinalizedToolCallOutcome[]): boolean {
return finalizedCalls.length > 0 && finalizedCalls.every((finalized) => finalized.result.terminate === true);
}只有非空批次里的每个最终结果都带 terminate: true,这项判断才成立。同批还有正常结果时,单个拦截结果的 terminate 不足以结束循环。因此,不能把“返回一次拒绝”当成“撤销同批其他动作”。
调用的结果反馈解决了恢复问题,而持续有效的授权规则,必须覆盖同一业务动作的其他执行入口。
多个扩展接力检查,检查过的还是执行的参数吗
再往产品层走一步。agent-session.ts 第 668–697 行 把 beforeToolCall 接到扩展运行器。没有 tool_call 处理器时返回 undefined;有处理器时,把校验后的 args 作为事件的 input 传下去。
扩展运行器的核心如下。这里关注的不是返回类型,而是处理器的执行顺序和提前退出条件:
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;
}处理器逐个 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。源码代码块为节选,缩进已调整;企业采购案例与设计建议为本文推演。