跳到主内容
创建时间:最后更新:作者:白川

MCP 与执行边界:内置能力和宿主责任

用 Pi 官方默认能力划清执行边界:项目信任不等于隔离,MCP 接入、权限扩展与容器隔离分别由谁承担。

MCP 与执行边界:内置能力和宿主责任

接了 MCP 之后,一个自然的问题浮上来:Pi 到底管什么,不管什么?

官方 README 的回答很直接:

Pi does not include a built-in permission system for restricting filesystem, process, network, or credential access. By default, it runs with the permissions of the user and process that launched it.

Pi 不内置权限系统。 它以启动它的用户权限运行,工具调用前不逐个要批准。扩展在 Pi 进程里执行,MCP 服务器是 Pi 启动的子进程,它们全都继承同一套权限。

这一篇要做三件事:分清"项目信任"、"容器隔离"、"扩展代码信任"这三件常被混为一谈的事;给出一张默认能力 / 扩展能力 / 宿主责任的对照表;写出一个最小权限扩展,能在危险命令执行前拦下来。

读它需要了解 Pi 的工具调用流程(工具请求如何进入、执行、结果如何回填),以及 MCP 的基本概念。Pi 迭代很快,动手前先记录 pi --version;本文依据 Pi 官方文档 与 官方仓库 编写。

一、三件常被混为一谈的事

概念它保证什么它不保证什么
项目信任
未信任项目的设置、MCP 服务器、扩展、Skills 不被加载
不限制工具调用能访问什么
扩展 / MCP 代码信任
你审查过这些代码,知道它们会做什么
不提供运行时隔离——它们就在 Pi 进程里
操作系统 / 容器隔离
越界的操作在技术上做不到
不判断操作该不该做

官方安全文档对第一件事说得非常直接:

Project trust does not limit what tool calls can access or affect. After Pi starts, enabled tools still use the operating-system permissions of the Pi process.

以及更前面一句:

Watching the transcript, using project trust, and reviewing changes do not create a security boundary.

所以"这个项目我信得过"和"这个项目跑在沙箱里"是两回事。 前者决定加载什么代码,后者决定代码能碰到什么。

二、项目信任管哪些资源

Pi 在当前工作目录里发现下列资源时,会要求一次信任决策:

资源信任后加载
.pi/settings.json
项目设置
.pi/mcp.json
项目 MCP 服务器
.pi/extensions、.pi/skills、.pi/prompts、.pi/themes
项目扩展与资源
.pi/SYSTEM.md、.pi/APPEND_SYSTEM.md
系统提示词文件
项目 .agents/skills
项目技能

光有一个空的 .pi 目录不需要信任。

信任决策的顺序是固定的:

  1. 命令行的 --approve 或 --no-approve 优先。
  2. 用户级和命令行扩展可以处理 project_trust 事件,第一个返回 yes 或 no 的扩展拥有这个决定。
  3. 没有扩展决定时,Pi 查 ~/.pi/agent/trust.json 里保存的决策,当前目录或祖先目录里最近的那个生效。
  4. 还没有保存决策,就看全局 defaultProjectTrust,默认 "ask"。

会话内用 /trust 保存决策。

信任不是完整启动边界

官方文档里有一处细节值得单独拎出来:

Pi reads the project sessionDir setting while selecting or creating a session, before it resolves project trust. Declining trust prevents the remaining project settings and protected resources from loading, but it cannot undo that initial session-directory lookup.

拒绝信任之后,项目 sessionDir 已经被读过一次了,撤不回来。这不是漏洞,是文档承认的既成事实。

还有一条更容易被忽略:AGENTS.override.md、AGENTS.md 和 CLAUDE.md 这些上下文文件 load regardless of project trust,除非你用 --no-context-files 关闭上下文加载。项目里的 AGENTS.md 无论信不信这个目录都会被加载。

非交互模式下没有提问机会

Print、JSON 和 RPC 模式都显示不了内置的信任提示。没有命令行覆盖、没有扩展决定、没有保存决策时,defaultProjectTrust: "always" 加载受保护的项目资源,"ask"(默认)和 "never" 都跳过。自动化运行要用 --approve 或 --no-approve 做一次性决定。

三、扩展代码的信任代价

官方扩展文档第一段就写明:

An extension runs inside the Pi process with the same operating-system permissions. It can inspect prompts, tool calls, files, credentials, and session history, so load extensions only from sources you trust.

扩展能读到你对话里的任何东西:提示词、工具调用参数、命令输出、文件内容、凭据、会话历史。它还能改这些——message_end 可以替换已定稿的消息,tool_call 可以修改输入或直接拦截。

装了什么它能做的事审查看什么
自定义工具
在你的进程里跑任意代码
有没有网络请求、文件写入、进程启动
MCP 服务器
Pi 启动的子进程,能读写文件、访问网络
服务器是什么、命令从哪来
Pi 包
可能带扩展、技能、提示模板、主题
package.json 的 pi 清单和依赖
Skill
正文进上下文,可能指示模型跑程序
技能文件里的指令

第三方包尤其要小心。官方包文档的原话是「Review third-party package source before installing it. Review project package declarations before granting project trust.」

扩展能补上 Pi 缺的那一层

Pi 不内置权限系统,但扩展可以自己实现。下面是一个最小权限门,在危险命令执行前拦下来:

typescript
// 最小权限门:Pi 不内置权限系统,这道门是扩展自己补上的
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";

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) => {
		// 只管 bash:其它工具不经过这道门,避免误伤
		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) {
				// 非交互模式:默认拦截
				// fail-closed:没有 UI 就没人能批准,不能默认放行
				return { block: true, reason: "Dangerous command blocked (no UI for confirmation)" };
			}

			const choice = await ctx.ui.select(`危险命令:\n\n  ${command}\n\n允许吗?`, ["允许", "拒绝"]);

			if (choice !== "允许") {
				return { block: true, reason: "Blocked by user" };
			}
		}

		// 返回 undefined 表示放行:tool_call 是门控型事件,只有 { block: true } 才是拦截
		return undefined;
	});
}

三个值得学的设计:不匹配就返回 undefined(tool_call 是门控型事件,返回 { block: true, reason } 才是拦截);无 UI 时默认拦截(fail-closed);只针对 bash。

MCP 工具同样受这个门管。官方 MCP 文档的「Permissions」一节:

Every MCP call passes through Pi's tool pipeline. Extension tool_call and tool_result handlers, including permission gates, therefore apply to MCP tools. Calls made from codemode scripts carry the codemode call ID as parentToolCallId.

MCP 工具和 codemode 脚本里的调用,都在同一个门里。 parentToolCallId 正是审计日志里区分"模型直接调"和"脚本里调"的依据。

扩展还能用 annotations 做更细的判断。MCP 服务器声明的 readOnlyHint、destructiveHint、idempotentHint、openWorldHint,Pi 原样带过来。官方示例照着这些提示决定要不要确认:

typescript
// 按 MCP 服务器声明的 annotations 决定要不要确认
// 注意这些只是提示,不是验证过的事实:缺失的提示按 MCP 默认值取
pi.on("tool_call", async (event, ctx) => {
	// annotations 从工具表里读:MCP 服务器声明什么,Pi 就原样带过来
	const hints = pi.getAllTools().find((tool) => tool.name === event.toolName)?.annotations;
	const needsApproval =
		hints?.destructiveHint === true ||
		(!hints?.readOnlyHint && ((hints?.destructiveHint ?? true) || (hints?.openWorldHint ?? true)));
	// 确认框走 ctx.ui:没有 UI 的场景这里会失败,所以别指望它能兜底
	if (needsApproval && !(await ctx.ui.confirm("Allow tool call?", event.toolName))) {
		return { block: true, reason: `${event.toolName} was not approved` };
	}
});

注意 annotations 是提示,不是验证过的事实——文档原话「The hints are not verified」。缺失的提示取 MCP 默认值:不是只读,可能破坏性,可能触达开放世界。

官方扩展文档还有一条容易忽略的规则:tool_call 处理器的失败按 fail-safe 处理——处理函数抛错时,该工具被拦截而不是放行。

四、容器化:四种方案的实际差别

关键差别不在容器技术,而在Pi 进程本身在哪里。

方案Pi 跑在哪隔离了什么凭据怎么处理
普通 Docker
容器里
Pi、内置工具、! 命令、扩展
凭据传进容器
Docker Sandboxes
托管沙箱里
同上
真实 provider 凭据留在宿主,代理出网时替换
OpenShell
本地或远程沙箱
同上
策略控制的凭据与推理路由
Gondolin 扩展
宿主上
只有内置工具和 ! 命令
Pi 凭据留在宿主,命令继承宿主环境变量

隔离方式会改变扩展跑在哪。 整个 Pi 进程在隔离环境里时,扩展也在里面;宿主 Pi 通过 Gondolin 只把内置工具路由进 VM 时,其它扩展工具仍跑在宿主上——官方原文:「Other extension tools still run on the host unless they explicitly delegate their operations.」

还有一个凭据上的坑。官方原文:

Commands inside the VM inherit the host process environment. Provider keys supplied through environment variables can therefore be visible inside the VM. Do not use this pattern as a credential boundary unless you remove sensitive variables or change the extension's environment handling.

通过环境变量传的 provider key,在 Gondolin 模式下会进 VM 可见。 想用它当凭据边界,必须先清掉敏感变量。

隔离了不等于安全了

隔离环境里仍然能碰到的东西:读写挂载让 Pi 能改宿主文件;挂载 ~/.pi/agent 会暴露你的凭据、设置、扩展和会话;网络访问可能让代码或工具输出离开环境。对照普通 Docker 的官方命令:

bash
# 在容器里跑 Pi:这是"整个 Pi 进程进容器"的方案,扩展和 MCP 子进程都在里面
# -e 把 provider 的 API key 通过环境变量传进容器(Pi 靠它调模型)
# -v "$PWD:/workspace" 把当前目录挂到容器:容器里的工具以此为路径基准
# 最后一个挂载用命名卷而不是宿主目录:挂宿主 ~/.pi/agent 会把凭据、设置、会话一起暴露给容器
docker run --rm -it \
  -e ANTHROPIC_API_KEY \
  -v "$PWD:/workspace" \
  -v pi-agent-home:/root/.pi/agent \
  pi-sandbox

用命名卷 pi-agent-home 而不是挂宿主目录,是刻意的:除非确实想让容器访问宿主 Pi 配置和凭据,否则不要挂 ~/.pi/agent。

五、三类能力的清单

把前面的事实合成一张表。这是本篇的交付物。

约束Pi 默认能力扩展能力宿主责任
MCP 服务器接入
内置 builtin:mcp 扩展读 mcp.json、连接、暴露工具
pi.registerMcpServer(name, config) 加会话内服务器
提供 mcp.json 和凭据;决定哪些服务器可用
危险命令拦截
无。工具调用前不逐个要批准
tool_call 返回 { block: true, reason }
无人值守场景补上 fail-closed 的门
MCP 工具权限判定
服务器声明 annotations,Pi 原样带出
用 annotations + ctx.ui.confirm 决定是否确认
审查服务器声明的提示是否可信
项目资源加载
信任决策;.pi/ 下的设置、服务器、扩展受保护
project_trust 事件由第一个返回 yes/no 的扩展决定
审阅要加载的扩展和包
上下文文件
AGENTS.md、CLAUDE.md 等不受信任保护
context 事件可变换消息
把仓库里的指令当作不可信输入
文件系统范围
无限制,用宿主进程权限
工具可以自己限制路径
容器 / micro-VM / 专用用户账号
网络访问
无限制
工具可自行拒绝
容器网络策略;只暴露必需的服务
凭据可见性
auth.json 在 ~/.pi/agent/,进程能读
扩展可读全部凭据
短时效窄范围凭据;别挂 ~/.pi/agent
子任务 / 委派
无内置。Pi 明确不提供 sub-agent 和 plan mode
自建,或装一个提供它的包
要不要引入;给子任务划定边界
运行隔离
无
Gondolin 扩展把内置工具路由进 micro-VM
选 Docker / OpenShell / Gondolin 之一

最后一行值得单独说明。官方 README 里有一句:

Pi ships with powerful defaults but skips features like sub-agents and plan mode. Ask Pi to build what you want, or install a package that does it your way.

子任务机制不是 Pi 内置能力。 它属于"扩展能力"或"宿主责任"这一列——要么装一个提供它的包,要么自己实现。

六、官方已定义、源码事实与教学设计

官方已定义:Pi 不含内置权限系统,默认以启动它的用户权限运行;项目信任只管资源加载,不管工具访问范围;上下文文件不受信任保护;扩展在 Pi 进程内以同样权限运行;MCP 调用走 Pi 的工具管线,扩展的 tool_call / tool_result 处理器同样适用;annotations 是提示不是验证;Pi 不提供内置 sub-agent;tool_call 处理器失败按 fail-safe 拦截。

源码事实但文档没单独成段:MCP 服务器也是 Pi 启动的子进程,继承同一套权限;tool_call 处理器的失败会让该工具被拦截而不是放行;ctx.hasUI 决定能不能弹确认框。

教学设计:三类能力对照表这张具体形式;三列划法本身;场景到行动的翻译——官方没发布过这样的划分。

你现在能做什么

  • 在一个陌生仓库里启动 Pi,观察它是否因为发现 .pi/ 下的资源而要求信任,然后决定 /trust 保存还是 --no-approve 拒绝。
  • 写一个 tool_call 权限扩展,拦住 rm -rf 形态的命令,用一次真实的危险命令验证它确实没执行。
  • 把同一个扩展放进没有 UI 的场景(print 模式),确认它默认拦截而不是默认放行。
  • 给一个 MCP 服务器的某个工具配 toolExposure 覆盖,然后用 pi.getAllTools() 读出该工具的 annotations,写一个基于提示的确认逻辑。
  • 在容器里跑一次 Pi,检查 ~/.pi/agent 有没有被挂进去、provider key 有没有通过环境变量进容器。
  • 断言"项目信任"与"隔离"是两件事:为同一个不可信仓库分别测一次"信任后的工具能访问什么"和"容器内的工具能访问什么",把两份结果写进你的威胁模型。

排障速查

现象检查点处理
.pi/ 里的东西没生效
项目信任
发现受保护资源时会问;用 /trust 保存决定,或 --approve / --no-approve
拒绝了信任,会话却建在项目目录
sessionDir 查找
官方已定义:项目 sessionDir 在信任解析前就被读过一次,撤不回来
AGENTS.md 里的指令总是生效
上下文文件
不受项目信任保护;用 --no-context-files 关闭
MCP 工具没经过我的权限门
门的作用域
用 tool_call 事件判断,而不是看工具有没有声明给模型
危险命令被放过了
门控实现
tool_call 返回 undefined 就是放行;无 UI 时应 fail-closed 拦截
权限门在 CI 里不生效
ctx.hasUI
没有 UI 时不能弹确认框,扩展应直接 { block: true }
权限门自己抛错了
处理失败语义
tool_call 处理器失败按 fail-safe 处理,工具被拦截
容器里能看到我的凭据
挂载
不要挂 ~/.pi/agent;用命名卷隔离;环境变量里的 key 也会进容器
Gondolin 下密钥仍可见
环境变量
VM 里的命令继承宿主环境变量,清掉敏感变量或改扩展处理
以为 Pi 自带子任务
能力归属
Pi 明确不提供 sub-agent;要么装包,要么自己实现
扩展里的工具绕过 VM
隔离范围
Gondolin 只覆盖内置工具;其它扩展工具仍在宿主上

参考资料