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 官方文档 与 官方仓库 编写。
一、三件常被混为一谈的事
官方安全文档对第一件事说得非常直接:
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 目录不需要信任。
信任决策的顺序是固定的:
- 命令行的
--approve或--no-approve优先。 - 用户级和命令行扩展可以处理
project_trust事件,第一个返回 yes 或 no 的扩展拥有这个决定。 - 没有扩展决定时,Pi 查
~/.pi/agent/trust.json里保存的决策,当前目录或祖先目录里最近的那个生效。 - 还没有保存决策,就看全局
defaultProjectTrust,默认"ask"。
会话内用 /trust 保存决策。
信任不是完整启动边界
官方文档里有一处细节值得单独拎出来:
Pi reads the project
sessionDirsetting 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 可以修改输入或直接拦截。
第三方包尤其要小心。官方包文档的原话是「Review third-party package source before installing it. Review project package declarations before granting project trust.」
扩展能补上 Pi 缺的那一层
Pi 不内置权限系统,但扩展可以自己实现。下面是一个最小权限门,在危险命令执行前拦下来:
// 最小权限门: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_callandtool_resulthandlers, including permission gates, therefore apply to MCP tools. Calls made from codemode scripts carry the codemode call ID asparentToolCallId.
MCP 工具和 codemode 脚本里的调用,都在同一个门里。 parentToolCallId 正是审计日志里区分"模型直接调"和"脚本里调"的依据。
扩展还能用 annotations 做更细的判断。MCP 服务器声明的 readOnlyHint、destructiveHint、idempotentHint、openWorldHint,Pi 原样带过来。官方示例照着这些提示决定要不要确认:
// 按 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 进程在隔离环境里时,扩展也在里面;宿主 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 的官方命令:
# 在容器里跑 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。
五、三类能力的清单
把前面的事实合成一张表。这是本篇的交付物。
最后一行值得单独说明。官方 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 有没有通过环境变量进容器。 - 断言"项目信任"与"隔离"是两件事:为同一个不可信仓库分别测一次"信任后的工具能访问什么"和"容器内的工具能访问什么",把两份结果写进你的威胁模型。