沙箱(Sandbox)#
沙箱是宿主原生进程隔离层:macOS使用Seatbelt,Linux使用bubblewrap,限制子进程的文件系统、进程视图、环境变量和网络。写死的owner/paired权限档常量表、权限规则与owner精确审批决定一项操作是否可以尝试;SandboxPolicy决定获准进程实际可以访问什么;hard constraints先于两者执行(见permission-model.md §1.1)。获批重试仍在OS沙箱内执行,不能取消凭证过滤或hard floor。
同一份内容的图示版在sandbox-architecture.html。
本文分三层。第一部分是当前实现与实测结果。第二部分分析references/下八个框架,包括明确不做沙箱的框架。第三部分记录采用的设计、参考来源和实施顺序。
第一部分:我们现在怎么做#
策略实现位于openprogram/sandbox/__init__.py。公开接口包括SandboxPolicy(冻结dataclass)、resolve_policy()、is_available() / unavailable_reason()、child_env(policy)、validate_write_path()和wrap_command(command, cwd, policy) -> (args, shell)。openprogram/backend/local.py::_invocation是共用命令边界,bash、process、本地execute_code、cron direct、memory写入器MCP shell和one-shot MCP启动都使用它。spawn的agentic子进程显式接收策略快照。write、edit和apply_patch不需要转成shell命令,而是直接执行相同的可写根与hard-floor检查。
本地后端确定使用宿主原生沙箱,使命令使用宿主实际安装的Git、Python、Conda、npm、编译器和项目环境。Docker不是本地沙箱实现,也不是自动回退。现有Docker与SSH执行后端分别声明容器或远端主机为边界;只有出现需要用户显式选择独立Linux环境的明确需求后,才另行设计Docker沙箱后端。
1. 边界#
四个方向,不对称:读除凭证glob之外不限,写限制在工作目录,执行不限制,网络两个平台都断。这一节全部是在已发布代码上实测的。
1.1 macOS:Seatbelt
wrap_command返回/usr/bin/sandbox-exec -p <profile> /bin/bash -c <command>,profile由_seatbelt_profile()内联生成:
| 资源 | 策略 |
|---|---|
| 兜底 | (deny default) |
| 文件读 | (allow file-read* (subpath "/")),之后每条deny-read glob各发一条deny file-read*正则 |
| 文件写 | cwd、额外的writable_roots、当前进程TMPDIR、/private/tmp、/tmp,之后每条deny-write glob各发一条deny file-write*正则 |
| 删除被禁路径 | 每条deny-read glob同时发deny file-write-unlink,被禁读的路径不能用删除操作反推存在性 |
| 进程执行 | (allow process-exec),不限制,子进程继承profile |
| fork | 允许 |
| 信号、进程信息 | 只限(target same-sandbox) |
| POSIX信号量与共享内存 | 允许,给Python multiprocessing用 |
| 字符设备 | /dev/null、/dev/zero、/dev/random、/dev/urandom、/dev/tty的读写和ioctl,每条都用require-all加vnode-type CHARACTER-DEVICE |
| sysctl | 硬件名字前缀,以及kern.hostname、kern.osrelease、kern.ostype、kern.version |
| Mach IPC | 不提供通用mach-lookup |
| 网络 | 除非sandbox.network打开,否则不发任何规则,由(deny default)兜住出入两个方向 |
工作目录拼进profile之前先转义,deny glob编译成锚定正则。Seatbelt正则方言有两条细节是关键的:(?:…)永远匹配不上,非捕获分组会让一条deny规则变成无声空转;引擎匹配的是解析软链后的真实路径,所以每条glob在静态前缀指向别处时要发两遍。
1.2 Linux:bubblewrap
bwrap --new-session --die-with-parent --unshare-pid --unshare-ipc --unshare-uts
--cap-drop ALL --ro-bind / / --proc /proc --dev /dev [--unshare-net]
--tmpfs /tmp --bind <cwd> <cwd> [屏蔽挂载] -- /bin/bash -c <command>
| 资源 | 策略 |
|---|---|
| 文件读 | --ro-bind / /,减去下面的deny-read挂载 |
| 文件写 | cwd和额外writable_roots用--bind放开,加上/tmp的一次性tmpfs |
| deny-read目录 | --perms 0000 --tmpfs <dir>;--cap-drop ALL拿掉了DAC_OVERRIDE,所以容器里子进程是root时权限位依然生效 |
| deny-read文件 | --ro-bind /dev/null <file> |
| 进程执行 | 不限制,任何位置的任何二进制 |
| 网络 | 除非sandbox.network打开,否则--unshare-net |
| PID命名空间 | 已隔离 |
| IPC、UTS命名空间 | 已隔离 |
| capability | 全部丢弃 |
| 终端 | --new-session,bubblewrap文档把它列为TIOCSTI注入防护 |
| 生命周期 | --die-with-parent |
| 系统调用 | 无seccomp过滤 |
| 环境变量 | 由调用方过滤,见§1.4 |
挂载顺序是功能性的,不是格式问题。--tmpfs /tmp必须发在cwd bind之前;反过来的话,工作目录落在/tmp下时会被tmpfs盖掉,沙箱内工作区消失而宿主侧文件完好。所有tempfile建的暂存目录都落在那里,包括memory写入器的暂存目录。屏蔽挂载会跳过宿主上不存在的路径:根是只读绑定的,bubblewrap没法在里面创建挂载点,硬来会让整条调用失败并报Can't create file at <path>: Read-only file system。
--unshare-user是故意不加的。非setuid的构建本来就自己建用户命名空间,加了没有增量;setuid的构建根本不支持这个参数。
可用性按实际执行能力判断,不只检查PATH中是否存在文件。Linux针对每个bwrap可执行文件的首次检查会用策略要求的PID、IPC、UTS、网络、挂载和capability限制执行/bin/true。因此,已经安装bwrap但禁止非特权用户命名空间的宿主会被判为不可用,并按sandbox.unavailable_policy处理;普通宿主执行不会被标记为已进入沙箱。
1.3 屏蔽清单里有什么
出厂清单不是空的:~/.ssh/**、~/.aws/**、~/.gnupg/**、~/.openprogram/auth/**、~/.claude.json、~/.claude/.credentials.json、~/.config/gh/**、~/.netrc、~/Library/Keychains/**、**/.env。启用沙箱后的实测结果是:具体凭证路径在macOS上报Operation not permitted,在Linux上报Permission denied;rm -f ~/.ssh/id_ed25519同样被拒,不会泄露文件是否存在。**/.env这种中段通配只在macOS正则profile中可强制执行。Linux上的敏感内容必须配置精确路径,或/absolute/path/to/secrets/**这类具有确定前缀的目录级deny;bubblewrap不能实现全文件系统中段通配匹配。
清单之外仍然全盘可读。这是有意选的姿态而不是疏漏:全盘读是命令了解自己所在系统的方式,收口收在携带凭证的路径上,不是收在读这个动作上。
1.4 子进程的环境变量
沙箱内的子进程拿到的是一份白名单:PATH、HOME、SHELL、USER、LOGNAME、TERM、TMPDIR、TMP、TEMP、TZ、PWD、OLDPWD、LANG、LANGUAGE、COLUMNS、LINES和LC_*,再加上sandbox.pass_env里名字本身不像凭证的条目。实测:父进程里164字符的OPENAI_API_KEY到子进程是空串,env | grep -iE '(key|token|secret|password)='没有输出。
选白名单而不是从provider注册表推导的黑名单,理由只有一条:明天新增的provider会被自动丢掉,没人需要更新任何东西,而推导出来的名单要跟着注册表一起长。凭证名字pattern留作sandbox.pass_env的底线,避免这个逃生口顺手把key发给每一条命令。
Linux上光洗环境变量不够。没有--unshare-pid时,/proc/<agent_pid>/environ会把刚从子进程里去掉的key还回来。加上之后,沙箱内的进程只看得到4个PID,cat /proc/<宿主pid>/environ报"No such file or directory",kill -9 <宿主pid>报"No such process",宿主进程照常活着。
1.5 已知行为与平台限制
- macOS上
ps和top跑不了。它们是setuid二进制,Seatbelt无论exec策略怎么写都拒绝把setuid二进制exec进沙箱,这是平台限制不是配置选择。所有用Seatbelt的参考实现都一样。 - git hooks和仓库config在工作目录里可写。owner可以将其加入
sandbox.deny_write,但默认不禁止:实测禁止.git/hooks/**会让git init和git clone失败,因为两者都要写该目录。这是已经记录的兼容性选择。 - 带平台错误文本的沙箱拒绝会产生结构化
sandbox.violation事件。pbpaste这类无错误文本的服务拒绝保持失败,不提供升级。
2. 开关#
策略在包装命令的那一刻从~/.openprogram/config.json的sandbox.*读出来。新安装默认workspace-write;已有配置中显式的danger-full-access保持不变。七个键注册在openprogram/config_schema.py::SETTINGS里,所以openprogram config、setup向导、TUI设置页和Web设置页都会渲染它们:
| 键 | 含义 | 默认 |
|---|---|---|
sandbox.mode |
danger-full-access或workspace-write |
workspace-write |
sandbox.writable_roots |
工作目录之外还可写的目录 | [] |
sandbox.deny_read |
沙箱内不可读的glob | §1.3那份凭证清单 |
sandbox.deny_write |
沙箱内不可写的glob | [],外加常开的agentics目录 |
sandbox.network |
沙箱内是否有网络 | false |
sandbox.pass_env |
额外透传的环境变量名 | [] |
sandbox.unavailable_policy |
平台后端缺失或无法创建所需隔离时refuse还是warn |
refuse |
CLI REPL和Web UI的/sandbox都通过set_setting写sandbox.mode,所以这个开关是持久的,不是单次会话的。
**开关使用持久化配置。**开关原来使用ContextVar,每一个新上下文边界都会丢失该值。其中三个边界位于实际调用路径:Web UI在websocket的asyncio任务里设置,agent轮次运行于普通threading.Thread;openprogram/agent/process_runner.py使用mp.get_context("spawn"),spawn不携带上下文变量;嵌套CLI是独立进程。在每个交接点增加copy_context()也不等价:spawn子agent可以把开关传入worker线程,但实测followup线程仍会恢复默认值。按调用链计数的执行状态继续使用上下文变量,并在每个线程入口重新绑定;安装级策略不使用上下文变量。修改后实测,Web worker线程在沙箱内执行并看到空的OPENAI_API_KEY,spawn子进程也得到相同结果。
**只有本地interactive owner可以申请一次精确重试并放宽可配置限制。**重试仍使用OS沙箱,凭证环境过滤和不可配置的agentics禁写保持生效;cron、subagent和paired渠道不能使用该路径。permission_mode="bypass"也不能取消hard floor或沙箱。
平台后端不可用时默认拒绝执行。sandbox.mode开着、平台后端缺失或所需隔离探测失败、sandbox.unavailable_policy是refuse时,_invocation抛SandboxUnavailable,LocalBackend.run把它变成失败的RunResult,文案给出原因和显式的不安全替代设置。warn恢复原来不受保护的执行行为,附一行日志。
粒度仍然是整个安装一个设置:不分agent、不分工具、不分命令。wrap_command接收显式策略,手上有策略的调用方可以传进去,但目前没有任何地方按调用点给出不同的策略。
3. 覆盖面#
修复前审计确认了25个执行面:其中2个已经使用共用沙箱策略,另外23个没有统一分类。这个数字保留为历史基线。完整U01—U23台账位于架构文档,逐项记录来源类别、强制边界、保留原因和验收条件。
当前分类如下:
| 执行类别 | 当前边界 |
|---|---|
| 本地模型命令 | bash、process、本地execute_code、cron direct、memory写入器MCP shell和one-shot MCP启动使用LocalBackend._invocation;无人值守与写入器路径强制启用沙箱 |
| spawn agentic function | SandboxPolicy与authority字段显式序列化到子进程;缺少执行能力时拒绝宿主副作用 |
| 直接文件工具 | write、edit和apply_patch在修改文件前检查规范化后的可写根与不可配置保护路径 |
| cron prompt与direct job | 创建和管理需要schedule capability;签名不可变执行spec固化principal、scope、cwd、内容、policy与hash;触发时不能请求或获得更高权限 |
| 嵌套Claude Code | 禁用内置命令与文件副作用工具;受管MCP替代工具检查暂存工作区路径、记录审计,并强制shell命令使用OS沙箱 |
| 动态program导入 | 自动导入只接受owner登记来源;模型可写目录不能成为导入来源 |
| Docker与SSH后端 | 配置的容器或远端主机是执行边界;工具规则、authority、审批和审计继续生效,但不声明使用宿主原生沙箱 |
| 固定argv与owner管理员路径 | 确定性的Git/store操作、已配置plugin/MCP/hook、显式安装或升级命令保留各自声明的边界,不计为模型shell命令 |
permission_mode="bypass"不再先于安全检查。hard constraints与capability检查先执行;subagent保持非交互,scope不能超过caller,也不能申请升级。被忽略的provider参数sandbox="read-only"不计为保护;进程隔离由显式策略快照和上表边界提供。
第二部分:别人怎么做#
references/下有八个框架,下面八个全部覆盖,包括四个完全不做沙箱的。不做隔离本身就是一种设计立场,而且各自都给出了替代方案。
计数上有两点要先说清。pi-ai不是独立框架:它是pi-mono同一上游的只读子集(references/pi-ai/README.md:1-6,两个仓库的remote都指向badlogic/pi-mono),只包含provider和协议层。另外四个系统级沙箱里有两个是同一份代码:claude-code和pi-mono的沙箱扩展都调用@anthropic-ai/sandbox-runtime,所以这一组里独立的系统调用级实现只有两份。
4. 四种立场#
| 框架 | 系统级沙箱 | 机制 | 模型的命令在哪里跑 | 默认开 |
|---|---|---|---|---|
claude-code |
有 | macOS用Seatbelt,Linux用bubblewrap加seccomp helper,实现在外部包@anthropic-ai/sandbox-runtime |
宿主,被包住 | 否(sandbox.enabled为false) |
codex-cli |
有 | Seatbelt SBPL加bubblewrap加seccomp,实现在仓库内codex-rs/ |
宿主,被包住 | 是,read-only |
openclaw |
有,但粒度更粗 | Docker容器,默认一个agent一个,走可插拔的backend注册表(docker/ssh/插件提供) |
容器内,或远端主机 | 否(sandbox.mode为"off") |
pi-mono |
只有示例扩展里有 | @anthropic-ai/sandbox-runtime,通过替换bash工具实现接入 |
宿主,不装扩展就不包 | 核心里没有这个概念,扩展自身默认启用 |
hermes-agent |
默认路径没有,可选后端有 | TERMINAL_ENV选local(默认)、ssh,或容器/远端后端(docker、singularity、modal、daytona、vercel_sandbox) |
默认宿主 | 不适用,默认就是宿主 |
opencode |
没有,且文档明确列为非目标 | 无 | 宿主 | 无 |
weclaw |
没有,而且它把被包的agent的沙箱也关掉了 | 无 | 宿主,经spawn出来的claude/codex |
无 |
pi-ai |
不适用 | 完全没有执行面 | 无 | 无 |
| OpenProgram | 有 | Seatbelt和bubblewrap,仓库内 | 宿主,被包住 | 是(sandbox.mode为workspace-write) |
claude-code:证据取自本机安装的2.1.226二进制,因为references/claude-code-leaked/src/utils/sandbox/sandbox-adapter.ts:17只是从外部包import SandboxManager。策略是(allow file-read*)加空deny列表、写默认拒绝加显式allowlist加一份硬编码的dotfile与.git deny列表、process-exec完全不限制而靠子进程继承沙箱、网络走父进程里一个逐域名弹窗的代理。
codex-cli:这一组里唯一在仓库内实现的系统调用级沙箱。macOS上三份.sbpl拼装(references/codex-cli/codex-rs/sandboxing/src/seatbelt.rs:21-24),Linux上bubblewrap带--new-session --die-with-parent --unshare-user --unshare-pid再加seccomp(linux-sandbox/src/bwrap.rs:318-332、landlock.rs:169-268)。四档沙箱模式、四档审批模式、per-command策略。
openclaw:边界是Docker容器,不是系统调用过滤。references/openclaw/src/agents/sandbox/backend.ts:43-94是一张以全局Symbol为键的注册表,内置docker和ssh,openshell由插件注册;backend没注册就是硬拒绝。容器参数在src/agents/sandbox/docker.ts:411-535拼出。src/agents/sandbox/config.ts里的默认值:readOnlyRoot: true(:108)、network: "none"(:110)、capDrop: ["ALL"](:112),另外无条件加--security-opt no-new-privileges(docker.ts:488)。但sandbox.mode本身默认"off"(config.ts:246)。
pi-mono:核心完全不设防。references/pi-mono/packages/coding-agent/src/core/tools/bash.ts:79-85就是一句spawn(shell, [...args, command]),带父进程完整环境,没有审批弹窗,文件工具也没有工作区约束。整个仓库找不到--yolo这类标志,因为没有东西需要绕过。隔离被交给用户侧,而且钩子是真的:beforeToolCall可以拦截或改写参数(src/core/agent-session.ts:397-416 → packages/agent/src/agent-loop.ts:581-604),随包附带的示例examples/extensions/sandbox/index.ts直接把bash工具整个换掉。
hermes-agent:默认路径没有系统调用级沙箱。references/hermes-agent/tools/terminal_tool.py:1013读TERMINAL_ENV,默认"local",而tools/environments/local.py:493就是以同一个OS用户跑bash -c <模型给的串>。替代方案是§6讲的三层命令守卫,外加可选的容器后端,容器后端下容器被明确宣布为边界、整个守卫层被跳过(tools/approval.py:1052-1054)。
opencode:references/opencode/SECURITY.md:15-19把立场写得很直白,权限系统是UX功能而不是安全隔离,需要真隔离就把opencode跑在容器或VM里;沙箱逃逸明确列为out of scope。替代方案是一张三效果规则表(allow/ask/deny),按工具名加资源pattern匹配,last-match-wins。
weclaw:这一组里唯一主动移除隔离的。它是一个微信侧的桥,spawn claude或codex:references/weclaw/agent/acp_agent.go:502-506发的是"sandbox": "danger-full-access"加"approvalPolicy": "never",:567-574再以sandboxPolicy: {"type": "dangerFullAccess"}重复一遍,:718-722把每个session/request_permission都自动答成allow。它唯一的真边界是对外发送附件时的符号链接解析加路径包含检查(messaging/attachment.go:51-75),而这个检查锚定的根目录可以被一条聊天消息用/cwd /扩到全盘(messaging/handler.go:646-663)。
pi-ai:14个文件,没有工具层,没有spawn,没有child_process。没有可隔离的东西。列在这里是为了覆盖完整,不作为一个数据点。
5. 四个方向#
claude-code |
codex-cli |
openclaw |
pi-mono核心 |
pi-mono加扩展 |
hermes-agent |
opencode |
weclaw |
OpenProgram | |
|---|---|---|---|---|---|---|---|---|---|
| 读 | 整盘,deny列表出厂为空 | 整盘,deny-read引擎出厂为空 | 容器内只看得到两个挂载;宿主侧read工具不受限 | 不受限 | denyRead预置~/.ssh、~/.aws、~/.gnupg |
不受限;文件工具有一份read-deny列表,代码自己注明"不是安全边界" | *.env走ask |
不受限 | 受沙箱本地命令可读宿主减去凭证deny清单;直接读取需要fs.read authority,但不属于OS沙箱进程 |
| 写 | 默认拒绝加allowlist,另有硬编码的dotfile和.git deny |
默认拒绝,.git/.codex/.agents受保护 |
容器内工作区挂载默认:ro,除非workspaceAccess: "rw" |
不受限 | allowWrite: [".", "/tmp"],denyWrite含.env、*.pem、*.key |
对/etc、/boot、docker socket等敏感路径拒写 |
只有规则 | 不受限 | 受沙箱命令与直接文件工具都执行cwd/配置可写根和保护路径检查 |
| 执行 | 不限制,子进程继承沙箱 | 不限制 | 容器内不限制;argv解包器在审批allowlist之前拦掉被混淆的调用 | 不限制 | 不限制 | 47条正则加一份掀不动的hardline列表 | 只有规则 | 不限制 | 不限制,子进程继承沙箱 |
| 网络 | 提示式代理,空白名单意味着每个域名都问 | 默认断,可配代理加域名白名单 | 默认--network none;host和container:<id>被拦 |
无 | 域名allow/deny列表,默认10个registry域名 | --network=none存在但从来没被传进去,出厂路径上不可达 |
不限制 | 不限制 | 两个平台都断 |
| 子进程环境变量 | 由runtime处理 | 可配置,默认不过滤 | 名字正则加取值启发式,出厂装弹 | 全量继承 | 全量继承(扩展根本没传env) |
剥离,且名单从provider注册表推导 | 不过滤 | 全量继承 | 白名单,没见过的名字自动被丢掉,不需要更新清单 |
有两行要强调,因为它们推翻了只看两家时得出的结论。
八家里有三家出厂就装了凭证屏蔽。openclaw拒绝把.aws、.cargo、.config、.docker、.gnupg、.netrc、.npm、.ssh以及/etc、/proc、/sys、/dev、/root、/boot和docker socket的各种别名作为bind挂载源(src/agents/sandbox/validate-sandbox-security.ts:23-49),并用兜底正则/_?(API_KEY|TOKEN|PASSWORD|PRIVATE_KEY|SECRET)$/i拦掉凭证形状的环境变量(src/agents/sandbox/sanitize-env-vars.ts:1-19)。hermes-agent的子进程环境黑名单是从provider注册表推导的而不是硬编码的(tools/environments/local.py:78-99),所以加新provider时不会漂移。pi-mono的沙箱扩展出厂就带着填好的denyRead和denyWrite,且enabled: true(examples/extensions/sandbox/index.ts:55-77)。出厂为空的那两家,正好就是最先调研的那两家。我们现在也出厂装弹,环境变量那一层用的是白名单而不是推导出来的黑名单:明天新增的provider会被自动丢掉,没人需要更新任何东西,openclaw那条兜底名字pattern留在下面,作为唯一能往回加名字的那个键的底线。
三家各自都是部分覆盖,而且都把没覆盖的部分写清楚了。openclaw的deny列表管的是挂载源,不管宿主的read工具,后者的tools.fs.workspaceOnly默认false。pi-mono的denyRead只对bash生效,不管它自己的read/grep/find。hermes-agent把这条写进了源码:agent/file_safety.py:167-171明说terminal工具以同一个用户身份运行、照样能cat出来,所以read-deny是纵深防御而不是边界。这里的结论不是部分覆盖可以接受,而是deny列表该放在每条路径都要过的那一层,并且没覆盖到的路径必须点名。
6. 粒度、审批与降级#
| 粒度 | 配置入口 | 沙箱与审批的关系 | 不可用时 | |
|---|---|---|---|---|
claude-code |
全局开关×单命令opt-out | 分层settings.json,没有CLI flag | 沙箱内的bash免审批;prompt教模型被拒时用dangerouslyDisableSandbox重试,从而触发弹窗 |
照跑但启动时明确告警;failIfUnavailable可改成硬失败 |
codex-cli |
per-command,工具可覆盖 | config.toml加--sandbox/--add-dir/--yolo |
沙箱内被拒→问用户→不带沙箱重跑,前面有五道闸 | 静默None;Windows降级成只读;WSL1硬报错 |
openclaw |
全局×per-agent×per-tool×per-session(mode: "non-main") |
zod schema加生成的JSON schema,agents.defaults.sandbox.*和agents.list[].sandbox.* |
互不相干:host === "sandbox"直接跳过审批allowlist,容器就是边界;host === "gateway"才走allowlist |
硬拒绝并给出可操作的提示,另有doctor提前预警 |
pi-mono |
整工具级allowlist(--tools),per-session |
~/.pi/agent/settings.json;扩展自带一份JSON,项目配置覆盖全局配置 |
核心没有;示例扩展也不弹窗 | 四条路径各自把sandboxEnabled置false后继续,包括"平台不支持" |
hermes-agent |
per-pattern审批,按上下文分策略(cron、subagent、gateway、CLI、oneshot) | cli-config.yaml、approvals.mode、command_allowlist、TERMINAL_ENV |
守卫就是审批系统;容器后端整体跳过 | 非交互、oneshot、batch三条路径fail-open;subagent和hardline列表fail-closed |
opencode |
per-tool×per-resource | opencode.json加agent frontmatter | 只有审批,没有沙箱 | 无 |
weclaw |
无 | ~/.weclaw/config.json,没有任何安全相关的键 |
审批被自动答成allow | 无 |
| OpenProgram | 整个安装一个设置 | config_schema.SETTINGS里七个sandbox.*键 |
不联动 | 硬拒绝并给出两条出路,另有warn可选 |
最后一列的分布是最有用的部分。openclaw在Docker缺失时直接拒绝运行,并给出两条出路(src/agents/sandbox/docker.ts:324-333),镜像缺失时也拒绝拿一个通用镜像顶替。claude-code是照跑,但它自己的源码写清了为什么必须出声:修过的那个bug是isSandboxingEnabled()在依赖缺失时静默返回false,注释写的是"This is a security footgun — users configure allowedDomains expecting enforcement, get none."我们原来的行为正是那个bug描述的状态,现在sandbox.unavailable_policy默认是refuse。
7. 前两轮没见过的招数#
有十一种机制出现在这一轮新看的五个框架里,而在claude-code和codex-cli里都不存在。
带语义透明性判定的argv解包(openclaw,src/infra/dispatch-wrapper-resolution.ts:351-383)。一张18个启动器程序的表,包括arch、caffeinate、chrt、doas、env、ionice、nice、nohup、sandbox-exec、script、setsid、stdbuf、sudo、taskset、time、timeout、xcrun,从argv前面剥掉,好让审批allowlist匹配到真正的可执行文件。没有unwrap函数的条目(sudo、doas、setsid、chrt、ionice、taskset)直接拦死。可能透明的条目,只在实际用到的flag不改变语义时才放行:nice -n 5过,env FOO=bar不过,arch -e不过,arch和xcrun只在darwin上算透明。链深度上限4,溢出即拦(:503-512)。收益在src/infra/exec-approvals-allowlist.ts:968-971:wrapper链被拦的命令没法为它保存一条"永远允许"的规则。
模式匹配之前先做反混淆归一化(hermes-agent,tools/approval.py:452-467)。完整的ECMA-48 ANSI转义剥离、空字节剥离、Unicode NFKC归一化,所以全角的rm -rf /过不了正则。配套的还有:136-146的macOS /private/*软链镜像处理,/private/etc/sudoers按/etc/sudoers匹配。
一条掀不动的底线(hermes-agent,tools/approval.py:198-220,判定在:1060-1063)。删根、mkfs、dd写裸块设备、fork炸弹、kill -1和各种关机命令,在yolo模式、approvals.mode=off和cron approve-mode被读取之前就拦掉。拒绝文案自己写着:"cannot be executed via the agent — not even with --yolo, /yolo, approvals.mode=off, or cron approve mode"。:163-165给的理由是:选择yolo意味着信任agent处理你的文件,不等于信任它抹盘。
把猜密码当攻击而不是当权限问题(hermes-agent,tools/approval.py:238-266)。没配SUDO_PASSWORD时出现sudo -S,无条件拦下,因为往sudo的stdin里灌东西只有试密码这一个用途。
自保护模式(hermes-agent,tools/approval.py:360-375)。agent不能停掉或重启自己的gateway,不能跑hermes update,不能pkill hermes,连kill $(pgrep …)和反引号形式这类结构变体也一并拦。
按上下文分审批策略(hermes-agent)。五种上下文五种策略:cron默认deny(cron_mode),subagent默认自动deny并留审计行(tools/delegate_tool.py:73-84,理由是工作线程里的input()会把父进程TUI卡死),gateway走异步队列,CLI走同步弹窗,oneshot直接开yolo因为弹窗会永远挂着(hermes_cli/oneshot.py:170-172)。
拦掉嵌套agent自带的执行面(openclaw,extensions/codex/src/app-server/sandbox-guard.ts)。openclaw驱动嵌套的Codex app-server时,把每个JSON-RPC方法分成allowed-control-plane、blocked-native-bypass、requires-openclaw-environment三类,拦掉command/、fs/、windowsSandbox/三个前缀(:62),并注入sandbox_exec和sandbox_process两个替代工具把操作绕回openclaw自己的沙箱。这是"嵌套agent自带的文件工具够不着"这个问题的通用答案。
用策略哈希驱动沙箱重建,并带epoch(openclaw,src/agents/sandbox/config-hash.ts:37-70)。把归一化后的有效策略算成SHA-256,作为label打在容器上,复用时再校验,不一致就强制重建。几个具名epoch常量(SANDBOX_DOCKER_EXPLICIT_ENV_POLICY_EPOCH、SANDBOX_MOUNT_FORMAT_VERSION)让策略语义的变化不需要改配置就能让在跑的沙箱失效。
给自己的安全配置做静态检查(openclaw,src/security/audit-extra.sync.ts)。一批具名检查项,包括sandbox.docker_config_mode_off、sandbox.dangerous_bind_mount、sandbox.dangerous_network_mode、sandbox.dangerous_seccomp_profile、sandbox.dangerous_apparmor_profile,还有专门覆盖tools.exec.host="sandbox"而sandbox.mode="off"这种情况的tools.exec.host_sandbox_no_sandbox_defaults,跑在用户配置上并给出报告。破窗用的开关是一个有类型的枚举集合(DANGEROUS_SANDBOX_DOCKER_BOOLEAN_KEYS,src/agents/sandbox/config.ts:31-35),每一个都配了一条对应的检查项。
资源限额与容器生命周期(openclaw和hermes-agent)。openclaw把pidsLimit、memory、memorySwap、cpus、gpus、ulimits都开了出来(默认全部不设),并按idle小时数加最长存活天数回收容器(src/agents/sandbox/prune.ts:24-36,24小时/7天)。hermes-agent硬编码了--pids-limit 256、给/tmp、/var/tmp、/run挂限定大小的nosuid tmpfs、--cap-drop ALL后再加回三个cap、--security-opt no-new-privileges、用--init当PID 1(tools/environments/docker.py:161-171),另有600秒前台上限和50KB输出上限。claude-code和codex-cli除了RLIMIT_CORE=0之外没有任何资源限额。
拦截器崩了就fail-closed,非交互即拒(pi-mono)。beforeToolCall处理器抛出非Error时会被转成Extension failed, blocking execution(src/core/agent-session.ts:410-415)。随包的permission-gate示例在没有UI时直接拦(examples/extensions/permission-gate.ts:20-23),这是CI下的正确默认值,而且比应该有的少见。注意它有不对称:user_bash那条路径会吞掉处理器的错误并继续执行。
同一轮还有三条反面案例值得记下来,因为我们有对应的面。
项目配置覆盖全局沙箱配置且没有信任确认(pi-mono,examples/extensions/sandbox/index.ts:79-102)。deepMerge(deepMerge(DEFAULT_CONFIG, global), project)意味着一个clone下来的仓库只要带上.pi/sandbox.json写{"enabled": false},就能关掉用户全机生效的沙箱。项目级扩展(.pi/extensions/)同样在进程内自动加载,没有信任闸。
截断之后把未截断的副本交给模型(pi-mono,src/core/tools/bash.ts:360-364)。输出按2000行/50KB截断,全文写进/tmp/pi-bash-*.log并把路径返回给模型,模型cat一下就拿回来了。
聊天桥把审批环路化掉(weclaw)。hermes-agent的ACP适配层把权限请求转发给客户端(acp_adapter/permissions.py:22-28),weclaw则自己替客户端答了。同一个协议,相反的极性。而且weclaw没有发送者allowlist:messaging/handler.go:261-409只按消息类型过滤并按message id去重,从不检查是谁发的。
8. 凭证这条#
只看两家时的结论是:出厂都不屏蔽凭证读取,两家都在外传侧收口,一家把流量走逐域名弹窗的代理,一家默认断网。八家都看过之后这个结论要收窄:出厂带空deny列表的是少数派,出厂装弹的三家分别装在不同的层(挂载源、子进程环境、只对bash生效的路径glob),而且没覆盖的部分是写出来的而不是藏起来的。
不管怎样,那条让空deny列表站得住的推理在这里不成立。出站网络本来就断,比八家中任何一家都严;但memory写入器是一条不碰网络的外传通道:它在暂存目录里跑shell命令,产出提交进记忆库,记忆库内容又会在之后的会话里回到上下文。cat ~/.openprogram/auth/*/default.json > topics/x.md全程离线就完成了外传。所以deny-read在这里是必需项,在那边是可选项。
第三部分:实施决策与记录#
9. 缺口、先例、步骤#
修复前基线中的每条缺口、第二部分提供参考的实现,以及§10中已经实施的步骤。
| 缺口(第一部分) | 谁解决了、怎么解决(第二部分) | 步骤 |
|---|---|---|
macOS上/dev/null不可写 |
codex-cli seatbelt_base_policy.sbpl:18-21;claude-code用require-all加vnode-type CHARACTER-DEVICE |
1,已完成 |
| exec白名单误伤git、python、node | 两家都放开process-exec、依靠子进程继承沙箱;openclaw限制混淆而不是限制路径 |
1,已完成 |
Linux上tmpfs遮蔽/tmp下的工作目录 |
这是挂载顺序问题 | 1,已完成 |
| 整盘可读,没有deny-read引擎 | openclaw的挂载源deny列表、hermes-agent的注册表推导环境变量剥离、pi-mono扩展的denyRead,八家中有三家预置清单 |
2,已完成 |
| 环境变量全量继承 | hermes-agent local.py:78-99从provider注册表推导黑名单;openclaw sanitize-env-vars.ts:1-19按名字pattern加取值启发式匹配 |
2,已完成,采用白名单 |
| Linux共享PID命名空间,宿主进程可读可杀 | codex-cli和claude-code都传--unshare-pid;claude-code还把signal和process-info*限定在(target same-sandbox) |
2,已完成 |
| 开关在线程、spawn、嵌套CLI三个边界上丢失 | codex-cli每次exec重建argv;openclaw在每个调用点解析策略 |
3,已完成 |
| 没有配置面 | 每个有沙箱的框架都有;openclaw从zod生成JSON schema |
3,已完成 |
| 不可用时静默放行 | openclaw强制拒绝并给出处理提示,另有doctor提前预警;claude-code源码把静默版本称为security footgun |
3,已完成 |
| 默认关且启用后不可用 | codex-cli默认启用read-only |
4,已完成 |
| 沙箱和审批不联动 | claude-code在沙箱内免审批;codex-cli失败后移除沙箱重试;openclaw把容器作为边界并跳过allowlist |
5,已完成,采用精确沙箱重试 |
permission_mode="bypass"在risky工具检查之前短路 |
hermes-agent把不可绕过规则放在所有bypass之前 |
5,已完成 |
| 按前缀匹配的命令allowlist可被wrapper和Unicode绕过 | openclaw的argv解包器、hermes-agent的NFKC加ANSI归一化 |
5,已完成 |
| cron worker触发时没有审批,subagent关闭审批 | hermes-agent的按上下文策略:cron默认deny,subagent自动deny并记录审计 |
5,已完成 |
嵌套Claude Code CLI的文件工具不受wrap_command控制 |
openclaw拦截嵌套agent的command/和fs/方法,注入受管替代工具 |
memory写入器,已完成 |
| 没有违规审计 | claude-code把内核deny行归因到具体命令并返回模型;openclaw在专用agents/tool-policy logger记录策略判定 |
与第2步一起,已完成 |
| 没有CPU、内存或进程数配额 | hermes-agent的--pids-limit 256、限定大小的tmpfs、600秒前台上限;openclaw提供pidsLimit/memory/cpus/ulimits |
不在范围内 |
| 配置文件没有写保护 | claude-code显式deny所有settings.json;codex-cli保护.codex/.git/.agents |
与第2步一起,受保护program/config根已完成;Git hooks与仓库config为兼容性保持opt-in |
| 没有对沙箱设置执行静态检查 | openclaw src/security/audit-extra.sync.ts的具名检查项 |
独立配置安全工作,不属于沙箱运行时完成条件 |
10. 修复顺序#
以下五步均已实施。这里保留原有顺序,用于记录依赖关系与验收条件。
1. 可用性,已完成。process-exec不再限制,子进程继承profile,所以git、python3、make、clang、conda python以及/sbin和/usr/sbin下的东西都能跑。/dev/null、/dev/zero、/dev/random、/dev/urandom、/dev/tty通过require-all加vnode-type CHARACTER-DEVICE可读可写,2>/dev/null正常。Linux上--tmpfs /tmp发在cwd bind之前,工作目录落在/tmp下也不会消失。macOS上仍然挡着的是ps和top,因为Seatbelt根本不允许把setuid二进制exec进沙箱。
**2. 凭证屏蔽,已完成。**deny-read清单出厂装弹(§1.3)。macOS上每条glob同时发deny file-read*和deny file-write-unlink,被禁读的路径不能用删除操作反推存在性;Linux上目录用--perms 0000 --tmpfs、文件用--ro-bind /dev/null屏蔽,宿主上不存在的路径跳过,因为只读的根让bubblewrap没地方创建挂载点。子进程环境变量用白名单而不是从provider注册表推导的黑名单:推导出来的名单要跟着注册表一起重建,白名单会自己丢掉没见过的名字,openclaw那条兜底名字pattern留作sandbox.pass_env的底线。Linux加上--unshare-pid,否则刚从子进程里去掉的key又能从/proc/<agent_pid>/environ读回来。deny-write覆盖agentics目录,这层保护按操作面分成两半。文件工具面(write、edit、apply_patch)上它无条件成立:validate_write_path()在解析任何策略之前就拒绝写入agentics目录和agentic源注册表,任何配置都够不到这道检查。命令面(bash、execute_code)上这层保护存在于沙箱策略里,因此在workspace-write和read-only下成立,在sandbox.mode=danger-full-access下不成立——该模式下shell面本就不设防,这正是这个模式的含义。git hook和git config是同一形状的逃逸,但保持opt-in,因为禁掉.git/hooks/**会让git init和git clone失败,而在第5步之前没有升级路径。
3. 开关语义,已完成。ContextVar已经删掉。策略在包装命令的那一刻从配置里的sandbox.*解析,asyncio任务到线程、spawn子进程、嵌套CLI三个边界都扛得住,因为文件不属于任何上下文。它同时坐在权限层之下,所以permission_mode="bypass"短路掉的是审批卡而不是沙箱。wrap_command接收显式策略供手上有策略的调用方使用;目前还没有按工具或按调用点给出不同策略的地方,那正是"调用点可覆盖"原本要买到的东西。平台工具不可用时默认拒绝执行,并给出两条出路。
**4. 默认开,已完成。**新安装使用workspace-write;已有配置中显式的danger-full-access保持不变。
5. 审批联动,已完成,分为三个独立决策。
正向:只读工具、显式allow规则和安全编辑路径继续免审批。不能仅因bash位于沙箱内就统一免审批,因为workspace-write仍可修改或删除仓库文件。
反向:结构化沙箱拒绝可以申请一次本地owner精确批准。重试使用放宽后的OS沙箱策略,不直接在宿主执行;agentics hard floor和凭证过滤继续生效。
向下:_hard_constraint_violation和capability检查位于规则、审批与bypass之前。cron和subagent不能建立交互审批路径。命令匹配会移除ANSI和NUL、执行NFKC、解析透明env包装,并拒绝持久化复杂shell表达式。
权限规则和SandboxPolicy保持为两个输入:前者表达owner同意,后者表达进程实际可访问的资源。
配套profile修补清单已经完成:工作目录插入profile前先转义,信号和进程信息只允许同沙箱目标,Linux传入上述命名空间与capability参数,两条路径统一使用/bin/bash,macOS收窄sysctl访问、删除通用mach-lookup,临时写入只允许当前TMPDIR。--unshare-user仍不加入:非setuid bubblewrap会自行创建用户命名空间,setuid构建不接受该参数。
CPU、内存和进程数配额明确不属于本沙箱项目。Linux PID namespace隐藏宿主进程,但不限制进程数量。通用配置静态检查属于独立配置安全工作;provider忽略的sandbox="read-only"参数已标明不提供强制能力,也不计入运行时边界。
接memory写入器#
memory写入器有两个执行面,wrap_command只够得着其中一个。
MCPshell工具(memory/management/tools.py → workspace.shell())在OpenProgram进程内执行。它以暂存目录为cwd调用LocalBackend._invocation(..., force_sandbox=True),因此即使interactive bash被显式关闭,只要宿主原生沙箱不可用,该工具仍会拒绝执行。
Claude Code CLI子进程(memory/agent_runtime/claude_code.py)是另一回事。它的Read/Write/Edit/Grep/Glob在CLI进程内部执行,wrap_command碰不到,而permission_mode="dontAsk"把它自己的审批也关了。这个CLI进程也不该被包进沙箱:它要调Anthropic API,而沙箱没有网络。
CLI进程仍不进入OS沙箱,因为它需要访问Anthropic API。它自带的Read、Write、Edit、Grep、Glob和Bash现在被显式禁用。OpenProgram通过MCP提供五个文件操作的替代工具:路径必须位于暂存工作区内,sources/下的写入会被拒绝,每次结果都记入写入器审计。MCP shell替代工具调用LocalBackend._invocation(..., force_sandbox=True);平台沙箱不可用时直接拒绝执行。
当前边界是:嵌套CLI可以访问模型API,但不能调用自带的文件或命令工具;暴露给它的文件和命令操作全部由宿主MCP执行,命令强制经过OS沙箱。
它挡住的威胁是具体的,不是假想的。只要挂了消息渠道,进入写入器prompt的文本就是攻击方可影响的:入站消息正文里带着发信人自己在平台上设的显示名。从那里被执行的命令本可以读~/.openprogram/auth/*/default.json并写进某个topic文件,而记忆库内容会回到之后会话的上下文里,这是一条不碰网络的外传路径,也正是"网络已经断了"没能覆盖的部分。
实现状态#
截至2026-08-10,修复顺序第1—5步和扩展架构第04—08步均已实现。新安装默认workspace-write;已有配置中显式的danger-full-access保持不变。
- hard constraint和固定权限档能力检查在权限规则、审批及
permission_mode="bypass"之前判定。 - cron保存带签名且固化owner权限档的不可变执行spec,以强制沙箱和禁止审批升级的方式无人值守执行。
execute_code、agentic子进程和one-shot MCP使用同一策略边界。 write、edit和apply_patch执行写入根检查。自动导入只接受owner登记来源,并为已有官方clone提供校验后迁移。- 已配对渠道发言是可信来源,并可追加source memory。未配对群组发言不进入agent,只归档为
pending;pending证据仍可检索,hold队列准入与读取过滤推迟到第二批。只有本地interactive owner可以提升。 - 沙箱拒绝采用结构化结果。只有本地interactive owner可以批准一次精确重试;重试策略仍保留hard floor和凭证过滤。持久批准保存规范化后的精确操作,复杂shell只能单次批准。
- 嵌套Claude Code内置副作用工具已禁用,改用受管MCP文件与shell工具。
最终验证记录(2026-08-10):本机完整受跟踪测试集(排除integration)为2731 passed、4 skipped、1 xfailed;GitHub Actions run 31398444213的Python 3.11、3.12、3.13、Web、文档和示例job全部通过,其中Linux Python 3.11为2723 passed、12 skipped、1 xfailed。该runner先启用Ubuntu 24.04的非特权user namespace能力,再执行真实cron bubblewrap用例,因此不会把“已安装但不能工作”的二进制计入Linux覆盖。macOS Seatbelt与Linux bubblewrap真实矩阵覆盖git、Python、npm、make、conda、凭证拒读、工作区外拒写和网络拒绝。
已知限制:
- Windows没有宿主原生沙箱后端。启用沙箱时默认拒绝命令,OpenProgram不会自动选择Docker。owner必须显式设置
sandbox.mode=danger-full-access,或显式设置不安全的sandbox.unavailable_policy=warn。 - macOS拒绝在该Seatbelt profile中执行setuid二进制,因此
ps和top不可用。 - Linux不能表达
**/.env这类中段通配deny-read;已知具体路径会被遮蔽,该glob只在macOS生效。Linux敏感内容要使用精确路径,或/absolute/path/to/secrets/**这类具有确定前缀的目录级规则。 - 沙箱策略以安装为粒度,不支持per-tool沙箱覆盖;authority与权限规则继续提供逐操作控制。
- 为保证
git init与git clone正常,工作区内git hooks和仓库config默认可写;owner可将其加入sandbox.deny_write。 - CPU、内存和进程数配额不在范围内。当前timeout和Linux namespace控制不能表述为资源配额。