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

隔离与约束

四种运行形态,以及它们各自真正挡住了什么

Pi 不内置沙箱。它不会在每次工具调用前征求批准,扩展、包安装器、语言服务器和其他子进程都以启动 Pi 的那个账户的权限运行。官方安全文档的第一句就是这个事实,第二句才是解法:

安全来自限制 Pi 在生成动作出错或恶意时能访问和影响哪些文件、凭据、进程和网络服务。盯住 transcript、用项目信任、审查改动,都不构成安全边界。

这句话里最关键的是「限制」的宾语。四种隔离形态挡的东西不一样,挡不住的东西也不一样。本文的目标不是让你选一个,是让你知道自己选了之后还剩什么。

16.1 四种形态与关键区别

先看官方的总表(docs/containerization.md:11-14):

形态Pi 跑在哪隔离了什么凭据怎么处理适合
纯 Docker
容器
Pi、内置工具、! 命令、扩展
凭据传进容器
一个直白的本地容器边界
Docker Sandboxes
托管沙箱
同上
供应商凭据留在主机,由代理替换
托管的本地隔离,且不暴露真实供应商 key
OpenShell
本地或远程沙箱
同上
策略控制的凭据与推理路由
文件系统、进程、网络、凭据四类策略
Gondolin 扩展
主机
内置工具和 ! 命令
已存的 Pi 凭据留在主机,但命令继承主机环境变量
保留主机界面的同时给工具执行一个本地 micro-VM

这张表看着信息量很大,但真正把它拆开的是第一列。四种形态里,只有 Gondolin 的 Pi 跑在主机上。

唯一需要判断的问题

把形态按这个轴切开:

text
Pi 进程在边界内 ──  纯 Docker · Docker Sandboxes · OpenShell
        │
        └── Pi 进程在主机上 · 只有工具执行在边界内 ── Gondolin

为什么这个区别是决定性的?因为 Pi 的每一个动作最终都是它的某个子进程做的事,而子进程继承 Pi 进程的权限。所以:

  • Pi 在边界内,意味着模型、扩展、包安装器、语言服务器——所有东西——都已经在边界里了。你不需要逐个配置,边界是自动全覆盖的。
  • Pi 在边界外,意味着只有你显式路由过去的那部分在边界里。Pi 自己、模型请求、扩展代码、以及扩展提供的其他工具,全都在边界外。

官方文档把这句话写在表格正下方(docs/containerization.md:16):

完整 Pi 进程跑在隔离环境里时,它的扩展也跑在里面。主机上的 Pi 通过 Gondolin 委托内置工具时,其他扩展工具仍然跑在主机上,除非它们也显式委托自己的工作。

安全文档的措辞更重一点(docs/security.md:17):

主机资源会受到「经由这些工具执行的动作」的保护。Pi 自身和其他扩展仍在边界之外,所以这是一种更窄形式的隔离。

「更窄」不是修辞。Gondolin 挡住的和没挡住的,可以逐条列出来(见 16.5)。

每种形态真正挡住了什么

这是本文最有用的表。它把「官方总表」翻译成「攻击面对照表」。

纯 DockerDocker SandboxesOpenShellGondolin
工具对主机的读写
挡住(除非挂载)
挡住(除非挂载)
挡住(策略控制)
挡住(/workspace 之外)
工具起的后台进程
挡住
挡住
挡住
挡住(VM 内)
工具装包、写 ~/.ssh
挡住
挡住
策略决定
挡住
工具联网外传数据
挡住(不加网络则无出口)
由代理决定
由策略决定
不挡,见 16.6
扩展代码
挡住(随 Pi 进程一起进容器)
挡住(随 Pi 进程)
挡住(随 Pi 进程)
挡不住(扩展在主机上,除非它也委托自己的活)
Pi 进程本身被攻击
挡住
挡住
挡住
不挡
~/.pi/agent 里的凭据
挂载了就没挡住
代理可替换真实 key
策略可控制
主机上,但会经环境变量泄漏进 VM
提示注入改变 Pi 自身行为
挡不住
挡不住
挡不住
挡不住

最后三行是全文的落点。四种形态没有一种能防提示注入改变 Pi 自身的行为——这是设计上的取舍,不是遗漏。安全文档把这一点写进了它自己的安全策略里(docs/security.md:99):预期的本地 Agent 行为、来自不可信内容的提示注入、没有内置沙箱、用户自行安装的扩展或 Skill 的行为,通常都在安全边界之外,除非报告能证明存在权限边界绕过。

换句话说:隔离解决的是「生成的动作错了会怎样」,不是「模型被说服做了什么」。

为什么隔离比盯 transcript 有效

docs/security.md:5 把这层关系点得很准:文件、注释、指令、命令输出和模型响应都能通过提示注入影响模型。项目信任控制的是启动时加载哪些项目资源,但它不让这些内容或由此产生的动作变安全。

于是就有了这个推论链:

text
盯 transcript      只能在你恰好在看的时候生效
审查改动           只能在改动已经落到磁盘之后生效
项目信任           只管启动期的资源加载,不管运行期的工具行为
─────────────────────────────────────────────
运行环境边界       不依赖你注意力的那一刻,从机制上生效

前三种都是人的动作,第四种是机器的约束。这就是为什么官方文档把「限制可访问的资源」列为唯一真正的安全边界。

「边界」这个词具体指什么

四种形态里出现了三种不同的边界强度,值得说清楚,因为它们决定了「绕过去要付出什么」:

边界谁能越过它绕过的代价
容器(Docker、Pi kit)
内核
需要容器逃逸漏洞,实践上极难
micro-VM(Gondolin、OpenShell)
虚拟机监控器
需要 hypervisor 逃逸漏洞,比容器还难
策略层(OpenShell 的进程/网络/凭据策略)
策略引擎本身
不需要逃逸——绕过策略就是绕过策略,不是打洞

前两种是硬件辅助的隔离,安全性有内核和 hypervisor 支撑。第三种完全不同:它的强度取决于策略写得对不对。

这个区别在实践里很重要。容器和 micro-VM 挡的是「进程」,进程无论怎么写都在边界内。策略挡的是「行为」——一条命令只要符合策略就能跑,哪怕它正在做一件你其实不想让它做的事。

所以 OpenShell 的五类策略(文件系统、进程、网络、凭据、推理)不是「比容器更强的隔离」,而是另一种隔离:它能表达「允许访问 /workspace 但禁止写入 node_modules」这种容器很难表达的规则。两者是补充关系,不是替代关系。

一个必须先知道的例外:工作目录不是边界

docs/security.md:19 这句话很容易被误读:

工作目录控制资源发现和工具的默认位置,但它不阻止命令访问 Pi 进程可用的其他路径。

也就是说:cd 到一个干净的目录,既不会限制 read 能读什么,也不会限制 bash 能跑什么。工具拿到的是相对路径,但进程拥有的是启动它的账户的全部权限。工作目录是发现范围,不是权限范围。

在纯 Docker 里这一点体现为:-v "$PWD:/workspace" 只挂载了一个目录,但容器进程依然能访问镜像里的所有东西;反过来,你不挂载的目录它就真的看不到——这才是挂载在隔离里的真正作用。

怎么选:三个问题

四种形态可以按三个连续的问题收敛,不需要记住四张配置表。

text
问题 1:Pi 进程本身需不需要被保护?
        │
        ├─ 需要(你在跑不可信的仓库,或者你装了第三方扩展)
        │     └─► 纯 Docker / Docker Sandboxes / OpenShell
        │         进入问题 2
        │
        └─ 不需要(自用,扩展都是自己写的)
              └─► Gondolin —— 保留主机界面的同时拿到文件隔离

问题 2:真实的供应商 key 能不能进边界?
        │
        ├─ 不能(合规要求,或者 key 权限很大)
        │     └─► Docker Sandboxes 或 OpenShell
        │         再问问题 3
        │
        └─ 能(个人 key / 短时 token / project-scoped key)
              └─► 纯 Docker 就够

问题 3:要不要远程执行,或者需要细粒度行为策略?
        │
        ├─ 要 ──► OpenShell
        └─ 不要 ──► Docker Sandboxes

三个问题里,问题 1 的答案几乎总是「需要」。理由在 16.1 那张表里:扩展代码、语言服务器、包安装器全都继承 Pi 进程的权限,它们跑在任何地方就在哪受到约束。既然你用了扩展(几乎所有人都用了),Gondolin 的「只保护工具」就够不着你最需要担心的那一部分。

问题 2 才是真正的分水岭。 它把纯 Docker 和另外两个分开。默认答案往往是「能」——一个范围狭窄、时效短的 key 落在本地容器里,风险可控。但如果你用的是主账号 key、或者组织有明确要求,就必须往代理那一层走。

问题 3 决定 OpenShell 何时胜出。 它的五类策略是独有的能力,远程执行也是。所以「需要策略或需要远程」直接指向 OpenShell,其余情况 Sandboxes 足够。

16.2 纯 Docker

最简单的整进程容器边界。Pi、内置工具、! 命令和扩展全部在容器里,因为它们是同一个进程。

建镜像

bash
FROM node:24-bookworm-slim

RUN apt-get update \
  && apt-get install -y --no-install-recommends bash ca-certificates git ripgrep \
  && rm -rf /var/lib/apt/lists/*
RUN npm install -g --ignore-scripts @earendil-works/pi-coding-agent

WORKDIR /workspace
ENTRYPOINT ["pi"]

有一行值得解释:ripgrep 不是可选项。 grep 工具的后端是 rg,find 的后端是 fd(见 工具与信任边界)。宿主上有这两个二进制时 Pi 直接用;没有的话它会下载到 ~/.pi/agent/bin/。在离线容器里那条下载路径走不通,所以把 ripgrep 装进镜像是最省事的做法。

--ignore-scripts 阻止 npm 在安装时跑生命周期脚本。这不是洁癖:在一个你正要用来跑 Agent 的镜像里,省掉安装期的任意代码执行。

跑起来

从你想让 Pi 访问的工作目录执行:

bash
docker run --rm -it \
  -e ANTHROPIC_API_KEY \
  -v "$PWD:/workspace" \
  -v pi-agent-home:/root/.pi/agent \
  pi-sandbox

逐段拆开:

参数作用漏了会怎样
--rm -it
退出即删,分配 TTY
-t 少了交互式 TUI 起不来
-e ANTHROPIC_API_KEY
把凭据从宿主传进去
换成本文 16.6 里说的任何一种其他方案
-v "$PWD:/workspace"
挂载工作目录
Pi 看不到你的代码
-v pi-agent-home:/root/.pi/agent
命名卷,保留容器内的设置、凭据和会话
每次重启都重新登录

命名卷 pi-agent-home 让容器本地的设置、凭据和会话在多次运行之间保留。这是个容易忽略但很重要的细节:不用命名卷的话,你每次 docker run 都要重新走一遍 OAuth。

关于挂载 ~/.pi/agent 的一条硬规则

官方文档的原话(docs/containerization.md:70):

除非你确实希望容器能访问你主机的 Pi 配置和凭据,否则不要挂载宿主机的 ~/.pi/agent。

这句话值得展开。容器里已经有一个 /root/.pi/agent(由命名卷提供)。你在宿主机上还有一个。挂载宿主机那个,等于把所有项目的凭据、所有扩展、所有会话记录一起送进容器。

docs/containerization.md:23 把它列为隔离环境里仍然暴露的东西之一:

挂载 ~/.pi/agent 会把你的 Pi 凭据、设置、扩展和会话一并暴露。

所以纯 Docker 的默认姿势是:工作目录挂进去,agent 目录不要挂。凭据通过 -e 单个传入,需要保留的那部分用命名卷。

验证你确实在容器里

进 Pi 之后跑:

text
!pwd

应该报 /workspace。/workspace 下的改动会写穿到宿主的挂载目录。

如果不接受写穿,去掉 bind mount,或者改成只读挂载(:ro)。这两条是官方给的选项。

纯 Docker 挡住了什么

挡住因为
工具访问宿主文件系统(未挂载部分)
容器有自己的文件系统
工具起的后台进程活过会话
进程随容器一起销毁(--rm)
工具污染宿主环境(装的包、写的配置)
全在容器层
工具启动的网络出口
容器网络由 docker run 决定;不加网络就没有出口
没挡住因为
挂载进去的目录被改坏
挂载就是授权
通过 -e 传进去的凭据
容器里的任何进程都能读
挂载目录里的 AGENTS.md 里的指令
项目信任不管提示注入(见 16.1)

三条便宜的加固

上面那张「没挡住」的表里,前两行都能用很低成本堵掉。

不给网络。 Agent 不一定需要出网——本地模型 + 只读代码分析完全可以在 --network none 下跑。这是把「工具输出可以离开这个环境」(docs/containerization.md:25)从「可能」变成「不可能」的最短路径。

只读挂载工作目录。 -v "$PWD:/workspace:ro" 让 Pi 能读不能写,需要写的地方让它输出到别处。文档给了这个选项(docs/containerization.md:80)。代价是 edit 和 write 工具会失败,所以只在「只需要分析」的任务里用。

用完即删 vs 保留状态。 --rm 让容器每次都是干净的,代价是 OAuth 要重登。镜像层面可以靠 docker commit 或者干脆把状态烘进镜像来折中。命名卷是你在「干净」和「免重登」之间唯一的中间选项——所以它默认就存在,不是可选项。

16.3 Docker Sandboxes

同样是 Pi 整个进程在边界内,区别在凭据怎么过关。

核心机制:代理替身

Docker Sandboxes 把完整的 Pi 进程放进一个托管沙箱。它的关键能力是:代理可以把真实的供应商凭据留在主机上,只在请求离开沙箱时替换进去。

text
Pi 进程(沙箱内)
   │  持有的是 sk-ant-oat01-{rand} 这样的占位符
   ↓
沙箱代理
   │  替换成真实的 ANTHROPIC_OAUTH_TOKEN
   ↓
api.anthropic.com

沙箱里的任何东西——包括模型、扩展、任意子进程——读到的都只是那个占位符。真实令牌只在代理这个位置出现,而代理在沙箱外。

这是四种形态里唯一一个凭据默认不进入边界的方案,也是它相对纯 Docker 的全部增量价值。

配置顺序不能反

文档在标题下第一句就写死了(docs/containerization.md:86):

在创建沙箱之前配置好凭据。不要在沙箱内运行 /login,因为那会把一个真实凭据写进沙箱。

这不是建议,是操作顺序。/login 会把凭据写进沙箱内的 auth.json,那一刻起凭据就已经在边界里了,代理的替换价值全部消失。

Claude 订阅 token 的配法

要在沙箱里用 Claude Pro/Max 订阅,先生成一个 token。用 claude setup-token 在一台装了 Claude Code 的机器上生成。

如果已经配了一个 anthropic secret,先删掉——否则代理会在 bearer token 旁边再加一个 API key 头:

bash
sbx secret rm anthropic

sbx secret set-custom \
  --host api.anthropic.com \
  --env ANTHROPIC_OAUTH_TOKEN \
  --placeholder 'sk-ant-oat01-{rand}'

sbx secret set-custom 从标准输入读真实 token。沙箱拿到的是一个形状相同但无效的 OAuth 占位符,代理只对配置的那个 host 替换它。

用 Anthropic API key 的话是 sbx secret set anthropic,不需要这层手工。

启动

bash
sbx run --kit "docker.io/sbx/pi-kit:latest" pi

对一个已存在的沙箱,非交互式跑一次:

bash
sbx exec <sandbox-name> -- pi -p "list the failing tests"

Pi kit 的文档(docker/sbx-kits-contrib 仓库)里有其他供应商的配置、排障和镜像固定方式。

和纯 Docker 怎么选

纯 DockerDocker Sandboxes
谁管理容器
你
Docker
真实凭据位置
容器内
主机上,由代理注入
凭据怎么配
-e VAR 或容器内 /login
建沙箱前用 sbx secret set
镜像
你自己 build
Pi kit
网络
你自己配
走代理
学习成本
低(就是 Docker)
中

判断标准很简单:你能不能接受真实供应商 key 出现在容器里。 能,纯 Docker 就够。不接受,Sandboxes 是唯一为此设计的形态。

代价是引入了一个代理——请求路径上多一跳,而且供应商特有行为(比如某模型拒绝某个参数)可能在代理那层就变了。这是真实成本,但相比凭据落在边界内的风险,通常是划算的。

16.4 OpenShell

NVIDIA OpenShell 的定位比前两个都宽:它管文件系统、进程、网络、凭据、推理五类策略,而且沙箱可以是远程的。

Pi 在这里的角色和其他三者一样——整个进程在沙箱内。

先有网关

每个沙箱都需要一个活跃的网关:

bash
openshell gateway add <gateway-url> --name <name>
openshell gateway select <name>

网关是 OpenShell 的策略执行点。没有网关就没有沙箱——这是 OpenShell 和 Docker 最本质的架构差异。

建沙箱

bash
openshell sandbox create --name pi-sandbox --from pi -- pi

Pi、它的内置工具、! 命令和扩展工具全部跑在 OpenShell 边界内(docs/containerization.md:140)。

远程网关不挂载宿主目录

这是远程形态最容易踩的一步。远程网关不会把你的宿主工作目录 bind mount 进去。 文件得显式传:

bash
openshell sandbox upload pi-sandbox ./working-folder /workspace
openshell sandbox download pi-sandbox /workspace/working-folder ./working-folder-out

或者干脆在沙箱里 clone 一份。

这条限制看起来烦,但它是远程沙箱的安全基础:宿主目录在另一台机器上,物理上就没法直接挂。

凭据:推理路由

OpenShell 的凭据处理是五类策略里的一类。真正值得说的是推理路由:

OpenShell 推理路由可以让原始模型凭据留在沙箱外。配置之后,把 Pi 指向网关暴露的对应的 OpenAI 兼容或 Anthropic 兼容端点。

也就是说它的模型和 Docker Sandboxes 的代理是同一个思路:凭据留在外面,Pi 拿到的是网关地址而不是密钥。

而且因为网关自己暴露的是「OpenAI 兼容」或「Anthropic 兼容」端点,Pi 这边只需要一条 models.json 配置改 baseUrl(见 模型与供应商):

json
{
  "providers": {
    "anthropic": { "baseUrl": "https://<gateway-exposure>/..." }
  }
}

模型列表和认证逻辑都不变。

关于策略配置的一个澄清

OpenShell 的策略是通过 CLI 和网关管理的,Pi 文档里给出的全部是命令(openshell gateway add / gateway select / sandbox create / sandbox upload / sandbox download)。Pi 的文档里没有出现任何 OpenShell 策略配置文件。

如果旧资料里出现过一个 openshell.yaml 之类的策略文件,那是编造的。OpenShell 策略是 NVIDIA 侧的产物,写在网关那边,不在 Pi 的工作目录里。找它要去 OpenShell 官方文档。

三种「Pi 在里面」的形态怎么选

纯 DockerDocker SandboxesOpenShell
隔离强度
容器
托管沙箱 + 代理
沙箱 + 策略执行点
凭据留在外面
手动(很别扭)
内建
内建(推理路由)
策略粒度
挂载 + 网络
由代理和 kit 决定
五类策略
远程执行
要自己搞
不主打
原生
额外依赖
无
Docker Sandboxes 服务
一个活跃网关
适合
本地容器边界就够
不想让 key 进沙箱
需要网络/凭据/进程策略,或要远程跑

一个经验判断:先试纯 Docker。 它的隔离强度和另外两个在同一量级(内核边界),只有当凭据留在外面成为硬需求时才升级。升级的信号通常来自合规要求,而不是技术故障。

16.5 Gondolin 扩展

Gondolin 换了个思路。它不把 Pi 装进虚拟机,而是留在你主机上,用一个本地 Linux micro-VM 承接工具执行。

于是它成了四种里唯一的异类,也是最容易被误解的一个。

它到底做了什么

Gondolin 是一个 micro-VM 库(earendil-works/gondolin),Pi 官方提供了一个示例扩展。装上之后:

  • Pi 进程留在主机上。
  • 它把主机工作目录挂载到 VM 的 /workspace,文件改动写穿回主机。
  • 它接管七个内置工具:read、write、edit、bash、grep、find、ls。
plaintext
主机                                micro-VM
┌─────────────────────────┐        ┌──────────────────────────┐
│ pi 进程(Node)           │        │ /workspace  ← 主机工作目录 │
│   ├ 模型请求 ─────────────┼── 网络 ─┤                          │
│   ├ 扩展代码(未委托的)    │        │ shell: /bin/sh 或 bash   │
│   └ 被接管的 7 个工具 ─────┼───────►│   read/write/edit/bash   │
│       │                    │        │   grep/find/ls           │
│       │                    │        │                          │
└───────┼────────────────────┘        └──────────────────────────┘
        │
        └── ⚠️ process.env 复制进 VM 的每个 bash 命令

注意 七个,不是八个。powershell 没有被接管——它在 Windows 上有意义,而 Gondolin 是 Linux micro-VM。

装与跑

要求 Node.js 23.6 或更新,以及通过系统包管理器装的 QEMU。

bash
mkdir -p ~/.pi/agent/extensions
cp -R packages/coding-agent/examples/extensions/gondolin ~/.pi/agent/extensions/gondolin
cd ~/.pi/agent/extensions/gondolin
npm install --ignore-scripts

然后从你想挂载的工作目录启动:

bash
cd /path/to/working-folder
pi -e ~/.pi/agent/extensions/gondolin

-e 加载的是这一个扩展,不是整个目录。

它改的不只是工具

示例扩展里还有三个容易忽略的行为:

VM 的生命周期挂在会话上。 session_start 时创建 VM,session_shutdown 时关闭。所以 VM 的存活期和你的会话一致,不会变成一个一直跑着的孤儿进程。

系统提示里的工作目录会被改写。 扩展在 before_agent_start 里把提示词中那行 Current working directory: <主机路径> 换成 Current working directory: /workspace (Gondolin VM; host workspace mounted from <主机路径>)。所以模型看到的工作目录是 VM 里的路径,不会以为自己能直接写主机路径。

! 命令也被路由进 VM。 通过扩展的 user_bash 事件接管。你在输入框里敲 !ls -la,跑的是 VM 里的 ls。

还有一个 /gondolin 命令,报告 VM ID、主机工作目录、VM 内工作目录和实际使用的 shell 路径。shell 是启动时探测的:跑一次 command -v bash,找到就用 bash,找不到退回 /bin/sh。

路径是怎么翻译的

七个被接管的工具里,有六个只是把「执行」换了个位置,剩下一个细节决定了隔离实际成不成立:路径翻译。

示例扩展里有一个函数专门干这事。它的规则是:

text
工具给的路径                        →  VM 里实际访问的路径
────────────────────────────────────────────────────────
工作目录内的相对路径  src/a.ts       →  /workspace/src/a.ts
工作目录内的绝对路径  <cwd>/src/a.ts →  /workspace/src/a.ts
工作目录外的绝对路径  /etc/passwd    →  VM 自己的 /etc/passwd
空路径                              →  /workspace

最后两行是关键。模型仍然可以请求 /etc/passwd 或 /root/.ssh/id_rsa,但请求会被送到 VM 里的那些路径——VM 里没有你的 SSH 密钥,因为那些文件从来没被挂进去。

这就是隔离真正起作用的地方:不是拒绝越界路径,而是让越界路径落到一个没有你主机文件的文件系统上。

对照宿主的行为就很清楚。docs/security.md:19 说工作目录「不阻止命令访问 Pi 进程可用的其他路径」——在纯 Docker 里,bash 跑 cat /root/.ssh/id_rsa 会读到你主机的(如果挂载了)。在 Gondolin 里,同一条命令读到的是 VM 的 /root/.ssh/id_rsa,那是个空目录。

bash 工具还多一层:cwd 也被翻译成 guest 路径,命令在 VM 里的那个目录下跑,而不是宿主的工作目录。

一个实现差异:find 和 grep 是重写的

七个工具里,六个是「把底层操作换成 vm.fs.* / vm.exec」,实现很直接。但 find 和 grep 是完全重写的——因为宿主的 fd 和 rg 是本地二进制,VM 里没有。

这带来两个可观察的行为差异。

遍历时硬编码跳过 .git 和 node_modules。 重写的遍历逻辑里就一行:if (entry === ".git" || entry === "node_modules") continue;(gondolin/index.ts:151)。这不是配置项,也没有写进 Pi 的任何文档——我在 docs/llama-cpp.md 和 docs/containerization.md 里 grep node_modules 零命中。所以它是一个你需要知道、但官方没打算让你知道的行为。

如果你依赖 find 或 grep 遍历到 node_modules 里的东西(比如查某个只存在于 node_modules/.cache 的产物),换到 Gondolin 形态后结果会不一样。

grep 把整个文件读进内存再匹配,而不是流式扫描。大文件上这会比宿主的 rg 慢。

另外 grep 的默认 limit 是 100(示例扩展里的 DEFAULT_GREP_LIMIT),和 grep 工具本身的默认值一致。

⚠️ 凭据警告:这不是凭据边界

这一节是本文最需要你记住的部分。

官方文档的原话(docs/containerization.md:157):

VM 内的命令继承主机进程环境。通过环境变量提供的供应商 key 因此在 VM 内是可见的。除非你移除敏感变量或修改扩展的环境处理,否则不要把这个模式当作凭据边界。

这句话的语气比文档里其他地方都重。原因在源码里一目了然。

bash 工具在执行命令时的环境变量来自 getShellEnv():

text
getShellEnv()  →  { ...process.env, PATH: 更新后的 }

也就是说,除非显式传了别的 env,否则整个 process.env 都会交给子进程。工具确实会删掉几个 PI_* 会话变量(PI_SESSION_ID、PI_SESSION_FILE、PI_PROVIDER、PI_MODEL、PI_REASONING_LEVEL),但 ANTHROPIC_API_KEY、OPENAI_API_KEY、AWS_SECRET_ACCESS_KEY 这些一个都不会被删。

Gondolin 的 bash 实现把这份环境原样转发进 VM:

typescript
exec: async (command, cwd, { onData, signal, timeout, env }) => {
  // ...
  const proc = vm.exec([shellPath, "-lc", command], {
    cwd: guestCwd,
    env: sanitizeEnv(env),      // ← 只是把每个 string 值拷一遍
    // ...
  });
}

env: sanitizeEnv(env) 里的 env 就是 env ?? getShellEnv() 的结果,sanitizeEnv 唯一的动作是过滤出值为 string 的项——不做任何过滤意义上的脱敏。

所以完整的泄漏路径是:

text
宿主 shell 里的 ANTHROPIC_API_KEY
        ↓  启动 pi
pi 进程的 process.env
        ↓  getShellEnv() 返回整个 process.env
bash 工具的 options.env
        ↓  sanitizeEnv() 原样保留
Gondolin 的 vm.exec({ env })
        ↓
VM 里的 bash 进程环境
        ↓
模型让 bash 跑 `env` → 密钥出现在工具输出里

最后一步是最要紧的:VM 隔离的是文件系统,不是环境变量。 模型完全可以在 VM 里跑一条 env 把密钥打印出来,然后这段输出就进了 transcript。

那凭据到底被挡住了吗

这里有一个容易搞混的区分:

凭据形式在 Gondolin 形态下
auth.json 里的已存凭据(/login 存的那份)
留在主机上,模型碰不到
models.json 里的 apiKey
留在主机上,模型碰不到
环境变量里的 *_API_KEY
会进 VM,模型能读到

总表里那一格写的就是这件事:已存的 Pi 凭据留在主机上,但命令继承主机环境变量。

换句话说,Gondolin 挡住的是文件形式的凭据,挡不住环境变量形式的凭据。这不是实现疏忽——它只是「Pi 在主机上」这个前提的必然推论:Pi 进程要拿环境变量去认证,环境变量就在 Pi 的地址空间里,而 VM 里跑的命令和 Pi 是同一个用户的同一个执行上下文。

真要当凭据边界用怎么办

文档给的处方是二选一:移除敏感变量,或改扩展的环境处理。

移除变量最直接:

bash
# 不这样起
ANTHROPIC_API_KEY=sk-ant-... pi -e ~/.pi/agent/extensions/gondolin

# 而这样起
pi -e ~/.pi/agent/extensions/gondolin

然后在主机上用 auth.json(/login)而不是环境变量来认证。因为 auth.json 是文件,Pi 进程能读,但 VM 里的 bash 读不到——它只能看到挂载过去的 /workspace。

改扩展更彻底:示例扩展里 sanitizeEnv 是一个普通的函数,你可以往里加一个拒绝名单,让敏感变量不进 VM:

text
sanitizeEnv(env)  →  复制时跳过 ANTHROPIC_*、OPENAI_*、AWS_* 等

这不是官方提供的选项,是一个你可以做的改动。代价是你要自己维护这份名单,而名单漏一个就等于没做。

这是你要自己做的决定,示例代码不替你做。 我把示例扩展整个目录 grep 过一遍(BLOCKED_ENV、DENY_ENV、SENSITIVE、REDACT、allowlist 一类标识符零命中),确认 sanitizeEnv 是唯一的 env 处理点,且它只做「值是 string 就留」这一件事。默认行为就是把全部供应商 key 送进 VM。

Gondolin 挡住了什么,没挡住什么

挡住因为
工具访问 /workspace 之外的主机文件
VM 自己的文件系统
工具起的进程活过会话
VM 在 session_shutdown 被关闭
工具在 VM 里装的包污染主机
装在 VM 里
read/write/edit/grep/find/ls 走主机路径
路径先被翻译成 guest 路径,超出工作目录的路径指向 VM 内部路径
没挡住因为
Pi 进程本身
它就在主机上
扩展代码
扩展跑在 Pi 进程里,也在主机上
扩展提供的其他工具
除非它们也显式委托,文档在两处都强调了这点
环境变量里的凭据
见上面的完整路径
提示注入
环境变量控制不了模型被说服
powershell 工具
没被接管,仍在主机上

最后两行是「为什么 Gondolin 不是完整方案」的完整答案。

什么时候选 Gondolin

它适合的场景很具体:

  • 你想在本机用 micro-VM 的文件隔离强度,但不想为每次运行起一个容器;
  • 你要保持完整的主机界面(IDE 集成、tmux、终端复用);
  • 你能接受凭据走 auth.json 而不是环境变量;
  • 你的扩展工具本来就不碰主机敏感资源。

最后一条是扩展开发者要自己确认的。文档在两处都写了同一句警告:审查 Gondolin 示例,在添加可能绕过 VM 边界的工具之前。

16.6 凭据与网络

前五节各自讲了各自的机制,这一节把它们放到一张图上,因为凭据和网络是隔离方案里最容易出事的两个维度——它们都穿过边界,而边界本身对它们没有内置防护。

隔离环境仍然会暴露的五样东西

docs/containerization.md:20-26 列了一份清单。这份清单的措辞是「一个被隔离的进程仍然能影响你暴露给它的资源」:

暴露了什么后果
读写挂载的主机目录
Pi 能改那些主机文件
挂载 ~/.pi/agent
你的 Pi 凭据、设置、扩展和会话全部暴露
传进容器的环境变量
容器内的进程都能读
网络访问
代码或工具输出可以离开这个环境
只隔离了工具
不约束主机上的 Pi 进程,也不约束不用隔离后端的扩展工具

最后一行是给 Gondolin 形态的警告,也是本文 16.1 那张表最后几行的来源。

官方给的处置原则是一句话:只暴露任务需要的文件、凭据和网络目的地。 不希望写入影响主机时,用只读挂载,或者把文件拷进拷出。

凭据:四条路,风险递增

按「真实密钥离边界有多远」排序:

方案真实密钥在哪谁在用适用形态
推理路由 / 代理替换
网关或代理(边界外)
Pi 只拿到形状相同的占位符
Docker Sandboxes、OpenShell
auth.json 文件凭据
主机文件,VM/容器碰不到
Pi 进程
Gondolin
环境变量
进程环境,会被复制
Pi 进程 + VM 内的每个命令
所有形态,包括 Gondolin
容器内 /login
边界内
沙箱内任何进程
纯 Docker、OpenShell(不推荐)

文档给的两条通用建议(docs/security.md:21):

尽可能把凭据放在环境之外,或者使用范围狭窄、时效短的凭据。

「范围狭窄、时效短」在 OpenAI 那类供应商上就是 project-scoped key 和短期 token;在 AWS 上就是 IRSA 或短时 STS 凭据,而不是长期 IAM key。

「放在环境之外」在纯 Docker 里做得最别扭——容器里总得有某种凭据。它在 Sandboxes 和 OpenShell 里是一等公民,在 Gondolin 里靠用 auth.json 代替环境变量也能做到。

网络:默认假设它开着

容器默认有网络。所以「跑了 Docker 就安全了」在网络维度上是错的。

docs/containerization.md:25 的措辞是「网络访问可能允许代码或工具输出离开这个环境」——注意是可能允许。你的凭据、你的源码、你 prompt 里的内容,都可能顺着出站请求走。

三条处置办法,按侵入性从低到高:

  1. 不需要网络就不给。 docker run --network none,或者不开端口映射。这是最彻底也最便宜的一条。
  2. 只给需要的出口。 OpenShell 的网络策略、代理的白名单,都属于这一类。
  3. 接受它,靠其他边界兜。 如果 Agent 必须联网抓文档,那就明确知道它在抓什么。

还有一个容易被忽略的出口:llama.cpp 的 /llama 命令能下载模型(模型与供应商)。这是一条通向 Hugging Face 的出站路径,也是一条往你本机模型缓存写东西的路径。它在边界内还是边界外,取决于你用哪种形态。

一个 Pi 特有的凭据陷阱:会话文件

docs/security.md:93 提醒了一件和隔离无关但同源的事:

导出或分享会话之前先审查它们。它们可能包含提示词、工具参数、命令输出、文件内容和对话过程中暴露的凭据。

会话是纯文本的。它会记录模型看到的一切,包括工具输出里出现的密钥。容器隔离不保护会话文件——会话文件在你选的地方写。

同理,auth.json 和 models.json 都应当保持私有,不要提交到版本库。

回到原点:三种「不构成边界」的做法

在结束之前,值得把 16.1 开头那句话再具体化一遍,因为它是全文的判断基准:

做法为什么不是边界
盯 transcript
只在你恰好在看的时候生效
审查改动
只在改动落到磁盘之后生效
项目信任
只管启动期的资源加载(docs/security.md:31:项目的 sessionDir 甚至在信任判定之前就被读取了)

这三样都值得做——docs/security.md:86-93 把它们列成「降低影响或改善恢复」的最佳实践,文档没有否定它们。文档否定的只是把它们当成边界。

一句话版本:模型可能被说服做任何事,你能控制的是它做完之后能碰到什么。

上线前对照表

选完形态之后,下面这张表是实际配置时最容易漏的几项。它把全文的结论压成可勾选的形式。

检查项纯 DockerSandboxesOpenShellGondolin
Pi 进程在边界内?
是
是
是
否
扩展在边界内?
是
是
是
否(除非扩展自己委托)
真实 key 在边界内?
是
否(代理替换)
否(推理路由)
视形式而定,见下
宿主 ~/.pi/agent 有被挂进去吗?
不应
不应
不应
天然不在
工作目录是读写还是只读?
按需
按需
由策略定
写穿(/workspace)
出站网络是关的、白名单、还是敞开?
由你决定
走代理
由策略定
敞开(继承宿主)
! 命令在哪执行?
容器内
沙箱内
沙箱内
VM 内
会话文件写在哪儿、谁能读?
容器内命名卷
沙箱
沙箱
主机

三行值得单独强调。

Gondolin 的 key 那一行永远不是简单的「是」或「否」——它取决于你用 auth.json 还是环境变量。用 auth.json 是「否」,用环境变量是「是」,而示例扩展不做任何区分。这是选 Gondolin 时你必须自己做的一个决定,不是默认行为。

出站网络那一行,Gondolin 是「敞开」。 VM 继承宿主环境,通常就有网络。

会话文件那一行,Gondolin 在主机上。 会话记录了模型看到的一切,包括任何泄漏到工具输出里的凭据。它的保护级别取决于你自己的文件权限,不取决于 VM。

16.7 事实来源

断言来源
四种隔离形态
docs/containerization.md:11-14
四形态总表(Pi 位置 / 隔离范围 / 凭据 / 适用)
同上
只有 Gondolin 的 Pi 在主机上;其他形态的扩展也跑在里面
docs/containerization.md:16;docs/security.md:17("a narrower form of isolation")
被隔离的进程仍能影响暴露给它的资源(五条)
docs/containerization.md:20-26
只暴露任务需要的文件、凭据、网络目的地
docs/containerization.md:28;docs/security.md:21
Pi 不逐次审批;子进程继承同一权限
docs/security.md:3
盯 transcript / 项目信任 / 审查改动不构成边界
docs/security.md:7
三种运行方式各自保护什么
docs/security.md:13-17
工作目录不是权限边界
docs/security.md:19
提示注入可经文件/注释/指令/输出/响应影响模型
docs/security.md:5
预期行为、提示注入、无内置沙箱、用户扩展在安全边界外
docs/security.md:99
项目 sessionDir 在信任判定前被读取
docs/security.md:31
纯 Docker 镜像内容(node:24-bookworm-slim、ripgrep、--ignore-scripts)
docs/containerization.md:36-54
docker run 参数与命名卷 pi-agent-home
docs/containerization.md:58-68
不挂载宿主 ~/.pi/agent;挂载即暴露凭据/设置/扩展/会话
docs/containerization.md:70、:23
!pwd 应报 /workspace,改动写穿;只读挂载选项
docs/containerization.md:74-80
grep 后端是 rg、find 后端是 fd
packages/coding-agent/src/core/tools/grep.ts:119、find.ts:172(F-015)
Docker Sandboxes 的代理保留真实凭据并替换
docs/containerization.md:84
必须在建沙箱前配置凭据,不要在沙箱内 /login
docs/containerization.md:86
claude setup-token、sbx secret rm/set/set-custom、占位符
docs/containerization.md:88-103
sbx run --kit、sbx exec
docs/containerization.md:105-117
OpenShell 管五类策略;沙箱可远程
docs/containerization.md:123
网关是必需前提
docs/containerization.md:126-132
openshell sandbox create --from pi -- pi;Pi 与扩展全在边界内
docs/containerization.md:136-140
远程网关不 bind mount;upload/download
docs/containerization.md:142-149
OpenShell 推理路由把凭据留在沙箱外
docs/containerization.md:151
Pi 文档中 OpenShell 只有 CLI、无策略配置文件
docs/containerization.md:121-151 全文
Gondolin 是本地 Linux micro-VM;Pi 与文件凭据留在主机
docs/containerization.md:153-155
凭据警告:VM 内命令继承主机环境变量,不要当凭据边界
docs/containerization.md:157
Gondolin 需 Node.js ≥ 23.6 与 QEMU
docs/containerization.md:159;examples/extensions/gondolin/index.ts:18-19
扩展安装与 pi -e 启动方式
docs/containerization.md:161-179
挂载 /workspace,接管 read/write/edit/bash/grep/find/ls 七个
docs/containerization.md:181;gondolin/index.ts:443-515(powershell 未接管)
路径翻译规则(工作目录外路径落到 VM 自身)
gondolin/index.ts:63-82 isInsideHostPath / hostPathToGuest / toGuestPath
Gondolin 的 find/grep 是重写的,硬编码跳过 .git 与 node_modules
gondolin/index.ts:151(walkGuestFiles);:183-204、:239-313
该跳过行为未写进 Pi 文档
grep -rn "node_modules" docs/llama-cpp.md docs/containerization.md 零命中
Gondolin 无内置敏感变量拒绝名单
gondolin/index.ts:315-322;目录内 BLOCKED_ENV/DENY_ENV/SENSITIVE 类标识符零命中
Gondolin 的 grep 默认 limit 100
gondolin/index.ts:48 DEFAULT_GREP_LIMIT
其他扩展工具仍跑在主机上
docs/containerization.md:16、:183
getShellEnv() 返回整个 process.env
packages/coding-agent/src/utils/shell.ts:138-150
bash 工具默认 env: env ?? getShellEnv()
packages/coding-agent/src/core/tools/bash.ts:115
仅删除 PI_* 会话变量,不删供应商 key
packages/coding-agent/src/core/tools/bash.ts:193-197
Gondolin 原样转发 env 进 VM
gondolin/index.ts:315-322,343-349
VM 生命周期挂 session_start / session_shutdown
gondolin/index.ts:410-425
! 命令经 user_bash 事件路由
gondolin/index.ts:517-520;src/modes/interactive/interactive-mode.ts:7008
系统提示中的工作目录行被改写
gondolin/index.ts:522-530
/gondolin 命令与 shell 探测
gondolin/index.ts:389-390,427-441
宿主目录以 RealFSProvider 挂到 /workspace
gondolin/index.ts:47,381-388
会话文件可能含提示词、命令输出、暴露的凭据
docs/security.md:93
降低影响与改善恢复的实践清单
docs/security.md:86-93
上下文文件不受项目信任管辖
docs/security.md:57

完整事实表(含每条断言的推导命令)见专栏仓库的 research/pi-agent/FACTS.md。