@yandy0725/pi-sandbox1.7.1
pi coding-agent extension: process-level sandbox (bwrap / landlock / seatbelt / windows-acl) — workspace writable, everything else readable, fail-closed
Untested on omp
This is a pi package. No one has reported whether it works on omp yet. If you try it, add your result below.
Reports 0
No reports yet.
Readme
pi-sandbox
pi coding-agent 扩展:进程级沙箱(bwrap / landlock / seatbelt / windows-acl)——默认工作目录可写、其余宿主文件可读,fail-closed。
安装
# 从 npm 安装
pi install npm:@yandy0725/pi-sandbox
# 或从本地仓库安装
pi install .
工作原理
bash 命令被包装进平台沙箱 runner 后在本地 spawn(路径透明:宿主路径原样有效);write/edit 工具在执行前做进程内写围栏;read 不受限。
适用范围仅限模型发起的动作。 你自己输入的 !命令 / !!命令(以及 RPC 的 bash 命令)由 pi 经 user_bash 事件执行,本扩展不处理该事件 —— 它由你自己的 shell 裸跑,不受上面三档模式约束。你在 pi 之外启动的进程同理。
| 平台 | Runner | 机制 |
|---|---|---|
| Linux | bwrap(首选) |
--ro-bind / / 全盘只读 + 工作区与宿主 /tmp 的 rw bind |
| Linux | landlock-run(回退,随包分发预编译二进制) |
Landlock LSM 允许清单:/ 只读,工作区 + /tmp 可写 |
| macOS | sandbox-exec(系统内置) |
Seatbelt SBPL:deny file-write* + 工作区/临时区例外 |
| Windows | windows-acl(内置,partial 强制) |
受限令牌沙箱:WRITE_RESTRICTED 令牌 + 对工作区与 %TEMP% 的能力 SID ACL 授权 + Low 强制完整性标签;仅 powershell 工具(受限模式下 bash 被拒绝) |
| 其他 | 无 | fail-closed:受约束命令一律拒绝执行,绝不静默裸跑 |
Windows
windows-acl runner 以 WRITE_RESTRICTED 受限令牌启动每条受限命令,并把令牌完整性级别降到 Low。workspace-write 下令牌还携带能力 SID,获得工作区与宿主 %TEMP% 的写授权,并在两个授权根上打可继承的 NO_WRITE_UP 标签;read-only 不授予任何写能力。强制是 partial 的——与参考实现相同,存在三处结构性缺口:
- NTFS 硬链接是文件对象别名:工作区内被授权文件的硬链接在外部同样可写
- 读不受限:与其他 runner 一致,受约束进程能读调用者可读的一切
- 被其他 AppContainer 工具以包 SID 打标过的文件对 Low 完整性子进程不可读(移除外来 ACE 或重装目录树可恢复)
受限 shell 只有 powershell 工具。在 Windows(pi >= 1.0.0)上,pi-sandbox 把受限 bash 以 exposure: "hidden" 注册——pi 的语义是 registered but unreachable:模型看不到它,显式点名(defaultTools、--tools、setActiveTools())也无法激活它。保留这份注册是有意的:扩展工具按名覆盖内建定义,bash 这个名字被本包的拒绝壳占据,Windows 上不存在任何通往未受限内建 bash 的路径;若未来宿主改变 hidden 的兑现方式,该定义仍会拒绝受限模式下的每次调用(绝不 spawn,fail-closed)。除非显式切到 danger-full-access(唯一逃生门,与其他平台一样 bash 照常裸跑),沙箱绝不在 Windows 上非受限地运行 bash。
已知限制:defaultTools 去不掉扩展注册的工具(-name 条目只能移除内置工具;扩展工具除非在定义里声明退出,否则会被自动激活)。pi-sandbox 不要求用户为此配置:Windows 上 bash 这个名字在定义层就已从模型侧撤下(exposure: "hidden"),没有可配置掉的余地。若 powershell 未激活(低于 1.0.0 的老宿主——该版本没有 powershell 工具,或选择列表把它排除在外),/permission 状态行显示 shell: powershell only (not activated),并在 bash 仍在活动列表时(老宿主)于激活期提示一次;{ "defaultTools": ["+powershell"] }(需要 pi >= 1.0.0)可把它加回来。
**支持范围。**与所移植的上游设计(deepseek-harness)一致:不设新 OS 下限——机制是 WRITE_RESTRICTED 受限令牌 + Low 强制标签,两者都是老 API(当初选它而不是 mxc,正是因为 mxc 要 Windows 11 24H2+)。本仓端到端验证在 Windows 10 Enterprise LTSC 2019(build 17763.316)上完成:自动化 e2e 与人工验收清单均绿。更新版本预期一致(后端不依赖新版本特性),但本仓尚未验证。注意:上游的测试跑在 Windows Server 2025 上,它的测试夹具不可移植到老 build;本包的 Windows 夹具刻意只用与版本无关的原语。
PowerShell 语言模式取决于启动约束,不是 ACL 边界的一部分:read-only 下 %TEMP% 不可写,pwsh 可能退化为 ConstrainedLanguage(Add-Type/COM/反射失败);workspace-write 保持 FullLanguage。
NUL 在两种模式下都可写——这是设备自身的环境属性(设备 DACL 授予 Everyone 读/写/执行),不是沙箱的能力授予,因此与令牌拿到哪些授权根无关。它只能通过设备拼法到达:cmd 的 > NUL,以及 Node 的 \\.\NUL。相对路径 NUL 不是设备:libuv 构造 NT 路径时不做 Win32 设备名映射,因此 writeFileSync('NUL', …) 是子进程 cwd 下一个名为 NUL 的普通文件——在工作区内允许、在工作区外被拒,与其他文件名完全同等。
常驻的安全描述符改动。 授权幂等但不回收:pi 退出后,工作区与 %TEMP% 上的 ACE、world FILE_DELETE_CHILD 拒绝项与 Low 强制标签仍然保留。workspace-write 下每个获得授权的 extraWritableDirs 目录同样留下常驻改动(按目录派生的 capability ACE、world FILE_DELETE_CHILD 拒绝项与 Low 标签);若目录不可授予(你无法改 ACL——没有 WRITE_DAC/WRITE_OWNER),受限命令 fail-closed,绝不降级裸跑。这会向任何以同一用户身份运行在 Low 完整性的进程放宽该目录树;事后再清除可继承标签也不会回退已传播到子对象的标签。切回 read-only 会让能力 ACE 失效(该令牌不携带能力 SID),但不会移除它们。首次授权会在整棵 %TEMP% 树上做急切传播(大树可能耗时数秒),之后每次调用命中精确匹配的快路径。%TEMP% 与 TMP 本身不被重写——沙箱的可写临时根就是宿主 %TEMP%。
%TEMP% 的代价。 %TEMP% 是用户共享树:其子目录会继承拒绝项,因此第三方程序用 GENERIC_ALL/FullControl 打开自己的 temp 子目录会被拒。基于 DELETE 的删除、MAXIMUM_ALLOWED 与常规读写打开不受影响。
拒绝分类只认英文消息文本。 工具用来注入 [sandbox: …] 拒绝标记、提权提示与 denial 记账的方言,都是英文的 Win32 / cmd / PowerShell 消息文本,且只匹配子进程的 stderr。在本地化(如 zh-CN)的 Windows 上这些消息会被本地化:沙箱仍然拒绝访问(强制层与语言无关),但工具可能不会标注该拒绝,denial-first 提权也不会记账。看不到拒绝标记时按“分类缺口”而非“强制失效”处理。
koffi(Win32 调用的 FFI 层)是常规依赖,懒加载且仅在 Windows 上加载——Win32 绑定表在其他平台上永不物化。
随包提供的 diagnose-windows-sandbox-acl 技能(诊断并修复“Windows 文件权限挡住 pi-sandbox 授权”的场景)仅在 Windows 上贡献给 pi。它的修复会修改安全描述符,必须由不受限的调用者执行——请通过一次已批准的 danger-full-access(或手工)运行。
三档权限模式
| 模式 | 文件效果 |
|---|---|
read-only |
仅 /dev/null 可写 |
workspace-write(默认) |
工作目录 + /tmp + os.tmpdir() + extraWritableDirs 可写,其余只读 |
danger-full-access |
完全绕过沙箱(显式逃生门) |
网络始终放行(不做网络隔离)。
/permission 命令
/permission—— 显示当前状态(模式及来源、选中 runner 与 enforcement、工作区;配置了extraWritableDirs时还显示extra writable:行,当前模式不适用时后缀(inactive: <模式>))/permission <read-only|workspace-write|danger-full-access>—— 切换模式,进程级生效:父会话与所有 subagent 子会话的下一次工具调用立即采用
提权审批(模型发起,denial-first)
bash/write/edit 带两个可选参数:sandbox_permissions(workspace-write 或 danger-full-access)+ justification(一句话理由)。审批是 denial-first 的:
- 严格更宽的请求只在本会话真实发生过同类沙箱拒绝后(bash ↔
command,write/edit ↔operation)才会弹审批;没有前置拒绝时提权参数会被忽略,调用按当前档位正常执行,结果附一行[sandbox: escalation fields were ignored …]告诉模型参数没生效——这消除了模型"先发制人"带提权参数造成的弹窗轰炸; - 拒绝记录一次性消费:一次拒绝只放行一笔提权重试(批准仍只对那一次调用生效);
- 占位符参数按字段归一化:省略或传 JSON
null都表示"不提权",参数 schema 也显式声明了null——因此 strict schema 提供商(pi 会把这两个字段标成必填)下的模型有一个合法的"不提权"取值可传;justification上的"null"/ 空白字符串同样归一化为"未提供"。字符串占位符("null"/ 空串 / 纯空白)另有一层兜底:工具的prepareArguments在参数校验前把它们剥掉——strict 提供商下模型常把必填的可选字段写成字符串"null",这层兜底让普通调用照常执行,不再产生与沙箱无关的硬错误。
弹窗提供 Allow once / Deny;选 Deny 后可再填一句可选理由(回车跳过),理由会随拒绝错误回传给模型,让它明白为什么被拒、不要换写法反复试探。subagent 子会话(前台与后台都算)的提权会转发到父会话弹窗(同进程 pi-subagents,且父会话需有 UI);无父通道时(headless、跨进程子代理)提权一律拒绝(fail-closed),此时用 /permission 放宽进程档位解救。
缓存/数据目录搬迁属于越界行为。 工具的常驻规则要求模型不得为了绕开沙箱把全局缓存/数据目录搬进 workspace——无论是环境变量覆盖(XDG_CACHE_HOME、HOME、UV_CACHE_DIR、npm_config_cache、PNPM_HOME 等)、改缓存的 flag(--cache-dir、--store-dir 等)还是配置文件——正确做法是原样运行工具,包括提权重试也不得夹带搬迁:搬迁是无声的,它会消掉本可上膛提权的那次拒绝。这条政策目前仅停留在提示词层:pi-sandbox 不检测、也不拒绝这类命令,用户显式要求搬迁时仍然成立。在 workspace-write 模式下,让工具在工作区外写入的合法途径是用户把该目录加入 extraWritableDirs;模型仍不得自行迁移缓存/数据目录。
配置
只读一个文件:~/.pi/agent/sandbox.json(全局);每个 pi 进程只读取一次(解析结果被缓存),改配置需重启 pi。项目级 <项目>/.pi/sandbox.json 被静默忽略——不解析、不校验、不告警(项目目录对受限模型可写,配置层留在那里等于把改写自己 mode / runnerCommand 的能力交给模型)。文件缺失/损坏与非法取值的回落行为不变。
{
"mode": "workspace-write",
"runnerCommand": null,
"runnerFailureSignatures": null,
"probeTimeoutMs": 5000,
"extraWritableDirs": []
}
| 字段 | 默认 | 说明 |
|---|---|---|
mode |
workspace-write |
默认权限模式;非法值回落默认并告警 |
runnerCommand |
null |
自定义 bwrap 兼容 runner argv(须与下一项成对) |
runnerFailureSignatures |
null |
自定义 runner 的失败诊断签名(非空单行) |
probeTimeoutMs |
5000 |
runner 功能探测超时(正数) |
extraWritableDirs |
[] |
仅 workspace-write 下、工作区与 tmp 之外追加的可写根;仅接受绝对路径,~ / ~/(Win32 ~\)展开到家目录,不支持 ~user;非绝对路径或不存在(非目录)的条目在加载时告警并忽略;取值不是字符串数组时整份配置回落默认值 |
示例:为常见工具开放缓存目录
把全局缓存/包存储放在工作区外的工具,在 workspace-write 下会失败或反复重新下载。把这些目录列进 extraWritableDirs 正是为此准备的:用户一次性授权存储目录,模型随后原样运行工具——不需要 UV_CACHE_DIR / PNPM_HOME / npm_config_cache 之类的搬迁(见上文「缓存/数据目录搬迁属于越界行为」)。
条目必须是已存在目录(缺失目录会在加载时告警并忽略),先创建:
mkdir -p ~/.cache/uv ~/.local/share/pnpm/store ~/.cache/pnpm ~/.npm ~/.cache/pip ~/.cache/huggingface
{
"mode": "workspace-write",
"extraWritableDirs": [
"~/.cache/uv",
"~/.local/share/pnpm/store",
"~/.cache/pnpm",
"~/.npm",
"~/.cache/pip",
"~/.cache/huggingface"
]
}
| 工具 | 目录(Linux 默认) | 覆盖变量 | 查询本机真实路径 |
|---|---|---|---|
uv 缓存(uv sync、uv pip install) |
~/.cache/uv |
UV_CACHE_DIR |
uv cache dir |
| pnpm store——包内容实体;安装会写它 | ~/.local/share/pnpm/store |
storeDir |
pnpm store path |
pnpm 元数据 / pnpm dlx 缓存 |
~/.cache/pnpm |
cacheDir |
pnpm cache path |
| npm 缓存 | ~/.npm |
npm_config_cache |
npm config get cache |
| pip 缓存 | ~/.cache/pip |
PIP_CACHE_DIR |
pip cache dir |
| Hugging Face 缓存 | ~/.cache/huggingface |
HF_HOME |
— |
pnpm store 与 cache 的区别。 store 存放 pnpm install 下载的包内容实体——项目从它 hard link,因此安装必须能写它(上面的授权是父目录 store/;pnpm store path 打印的是它内部的版本子目录,如 store/v11);cache 存放 registry 元数据与 pnpm dlx 缓存,只影响速度。二者都不包含全局安装的包或可执行文件——那些在 ~/.local/share/pnpm/global 与 ~/.local/share/pnpm/bin,不在上面的授权范围内。
全局安装仍会被拦截。 只授予存储/缓存目录后,uv tool install(写 ~/.local/share/uv/tools 与 ~/.local/bin)与 pnpm add -g(写 ~/.local/share/pnpm/global 与 bin)依旧会被沙箱拒绝,只能走显式提权。不要授予安装前缀或 PATH 上的 bin 目录(~/.local/bin、~/.cargo/bin、~/go/bin 等):那等于让受限模型留下持久可执行文件,供未来不受限运行使用。
平台差异:表中是 Linux 默认值。macOS:pnpm store ~/Library/pnpm/store、pnpm cache ~/Library/Caches/pnpm、pip ~/Library/Caches/pip。Windows:uv cache %LOCALAPPDATA%\uv\cache、pnpm store %LOCALAPPDATA%\pnpm\store、pnpm cache %LOCALAPPDATA%\pnpm-cache、npm cache %LocalAppData%\npm-cache、pip %LocalAppData%\pip\Cache。精确路径请用各工具的查询命令。
可选授权及权衡:
- Go:授予
~/go/pkg与~/.cache/go-build(macOS~/Library/Caches/go-build、Windows%LocalAppData%\go-build),go build可跑,而go install(写~/go/bin)仍被拒。 - Cargo:
cargo build会写~/.cargo/.package-cache与 registry/git 缓存,因此必须整个授予~/.cargo;代价是cargo install(写PATH上的~/.cargo/bin)也会放行。接受这一点才授予,否则不给 Cargo 授权、按需提权。 - uv 管理的解释器:项目指定的 Python 未安装时,
uv sync会下载到~/.local/share/uv/python(并可能在~/.local/bin建可执行链接)。建议先在沙箱外uv python install <版本>装好;若确需在沙箱内下载,可单独授予~/.local/share/uv/python(uv tool install仍被拦)。 - pnpm 机器级状态:
~/.local/state/pnpm不在上面的授予集内;若遇到来自它的拒绝并确认是状态写入,可单独加入——它不含任何全局安装内容。
安全说明
- 受约束进程可读取宿主上你有权读的一切(包括
~/.ssh等)——这是本沙箱的设计语义(与 deepseek harness 一致);root 专属文件受文件权限保护 - 不要把家目录、
~/.pi或 pi agent 目录的任何祖先目录放进extraWritableDirs:否则受限模型可改写全局沙箱配置、AGENTS.md或扩展——它们会在下次启动时不受限运行;此类条目会在加载时告警,但仍会保留(warn-only) - bwrap 下 bash 的
/tmp就是宿主 /tmp(rw bind,与 write/edit 围栏及 landlock/macOS 一致):沙箱内命令可以直接修改/删除宿主的临时文件(含会话 socket 与 pi 自己的临时文件),宿主/tmp的权限原样生效;宿主/tmp不可写时沙箱内也随之不可写 - Windows 下沙箱的临时根就是宿主
%TEMP%(windows-aclrunner 授权真实路径、不重写TMP/TEMP):受限命令可以修改/删除宿主临时文件,与上面 bwrap 的/tmp同属“宿主 tmp 就是宿主 tmp”的语义 - 受限子进程强制
LC_MESSAGES=C(保证拒绝诊断可分类),不改动你的LANG/LC_CTYPE - 受限 bash 在独立进程组中运行(detached):timeout/abort 会杀掉整个进程组;但若 pi 自身被硬杀(如 SIGKILL),命令派生的后台孙进程可能存活(pi 内部的子进程追踪 API 不对扩展开放)
- landlock 回退在旧内核 ABI 上为 partial enforcement(状态里会标注)
从 pi-container-sandbox 1.x 迁移
- 配置只认
~/.pi/agent/sandbox.json:项目级<项目>/.pi/sandbox.json被静默忽略(原本依赖的项目设置请移到全局文件);旧image/runtime/host段会被忽略并告警,按需改写为上面的新字段 - 容器运行时(docker/podman)、镜像构建、
runtime.mounts、/sandbox命令、--container*flags、外部路径审批流不属于本包 - 需要容器级强隔离(独立文件系统/网络命名空间)请安装
@yandy0725/pi-container-sandbox(保留容器实现) pi-sandbox与@yandy0725/pi-container-sandbox互斥:两者都接管bash/write/edit,且共用同一个全局~/.pi/agent/sandbox.json(schema 不兼容)——同一时刻只启用一个,切换前先卸载或停用另一个。pi-sandbox 忽略项目级文件;pi-container-sandbox 仍读它自己的项目 scope
开发
npm test # 单元 + 集成(无 runner 环境集成自动 skip)
npm run typecheck
./tests/e2e.sh
用本地构建验证提权转发
父会话可以用 pi -e <path> 直接加载本地 pi-sandbox,但 -e 只影响父会话:pi-subagents 为子会话另建资源加载器,子会话按 agentDir 与项目 .pi/ 重新发现扩展。若 ~/.pi/agent/settings.json 里仍声明 npm:@yandy0725/pi-sandbox,子会话会加载发布版,转发会静默失效(子会话只报 requires approval, but no approval channel is available)。让父子两侧发现同一份构建:
AG=$(mktemp -d); cp ~/.pi/agent/auth.json "$AG/" 2>/dev/null || true
cat > "$AG/settings.json" <<EOF
{ "packages": ["<repo>/pi-sandbox", "<repo>/pi-subagents"] }
EOF
cd <可写项目目录> && PI_CODING_AGENT_DIR="$AG" pi
观察点与验证记录见 docs/superpowers/specs/2026-09-30-escalation-approval-forwarding-design.md §11。
License
MIT