沙箱与网络
agentrun 用沙箱限制 agent 能写的文件、能读的敏感文件和命令能访问的主机,用运行时参数限制它能读到的配置与账号。本页写 --sandbox、--network 与 --deny-read 三组选项、各运行时分别怎么关进沙箱,以及每种组合剩下的风险。
代码位置
| 文件 | 主要类型与函数 | 本页对应的章节 |
|---|---|---|
src/sandbox/mod.rs | Sandbox(new)、Wrapper、resolve_mode、check、PiState、Hidden、hidden、Wrapped、wrap、wrapper_failure、start_failure、exit_text | 沙箱模式、判断沙箱能不能用、读取限制 |
src/sandbox/bubblewrap.rs | probe、wrap_bwrap、ProxyForward | 判断沙箱能不能用、用 bubblewrap 包裹命令(由 wrap 调用)、读取限制 |
src/sandbox/seatbelt.rs | probe、write_seatbelt_profile、wrap_seatbelt | 判断沙箱能不能用、用 Seatbelt 包裹命令(由 wrap 调用)、读取限制 |
src/network/ | mod.rs 的 ProxyEndpoint、proxy_environment;rule.rs 的 HostRule、check_usage、Policy、SERVICE_PORT;address.rs 的 is_private、url_host;upstream.rs 的 Upstream、ProxyAddress;proxy.rs 的 FilterProxy(bind、endpoint、serve_session);http.rs 的 handle_http;socks.rs 的 handle_socks | 网络、过滤代理、不支持 IPv6 |
src/runtime/mod.rs | DENIED_HOME_ENTRIES、default_denied_reads、denied_reads、proxy_needed | 读取限制、网络 |
src/runtime/claudecode.rs、src/runtime/codex.rs、src/runtime/pi.rs、src/runtime/dsh.rs | claudecode.rs 的 ClaudeCode(launch)、SERVICE_HOST、login_file、history_paths;codex.rs 的 Codex(launch)、SERVICE_HOSTS、SERVICE_HOST、sandbox_command、history_paths;pi.rs 的 Pi(launch)、model_service;dsh.rs 的 Dsh(launch)、service_host、user_home、history_paths、ENV_FILE | 各运行时怎么关进沙箱、各运行时的做法、模型服务的主机 |
src/doctor.rs | run、connect | doctor 对沙箱与网络的检查,见运行 |
过滤代理在 agentrun 进程里的独立线程上运行,每个连接一个处理线程,建立转发后再加一个复制下行数据的线程,线程数没有上限;Policy 只做放行判断,不知道连接来自哪个运行时。理由:所有运行时共用同一份判定规则与同一种 network 事件。普通 HTTP 请求的转发、CONNECT 与 SOCKS5 的隧道都设了空闲时限:客户端关闭发送方向以后,上游从最后一次发来数据起 30 秒(proxy.rs 的 UPSTREAM_IDLE_TIMEOUT)内没有再发来数据,复制下行数据的线程就结束,所以上游一直不回复也不关闭时,处理线程与复制线程不会留到 agentrun 退出。客户端没有关闭时,上游停顿多久都不中断转发。
沙箱模式
沙箱由操作系统决定:macOS 用 Seatbelt,Linux 用 bubblewrap(bwrap);codex 在两个系统上都用它自己的沙箱,它在 Linux 上也是用 bwrap 实现的。--sandbox(环境变量 AGENTRUN_SANDBOX,选项优先)决定沙箱做不到时怎么办。
on(默认) | relax | off | |
|---|---|---|---|
| 沙箱可用 | 用沙箱 | 用沙箱 | 不用 |
| 沙箱缺失或启动失败 | 启动前报错退出(退出码 2),写明原因与安装命令 | 不用沙箱照常运行 | 不尝试 |
--network 的限制无法执行 | 启动前报错退出(退出码 2) | 照常运行,不限制网络 | 不限制网络 |
| 没有沙箱时的记录 | 不会出现 | start 事件记 sandbox: "none" 与 network.enforced: false,text 与 rich 开头一行提示 | 同 relax |
默认 on 的理由:本机 Linux 没装 bwrap 时,agent 不会在没有隔离的情况下运行而无人察觉;docker 里的结果也不会取决于镜像碰巧装了什么。名字用 on 与 relax,是有意鼓励调用方使用 on。off 给明确知道自己在容器里的启动方用。
沙箱与网络共用这一个选项,因为网络限制本身靠沙箱执行:沙箱没有运行,网络限制也一定落空。分成两个选项会出现「沙箱放宽、网络严格」这类说不清的组合。
判断沙箱能不能用
只看 bwrap 是否存在不够:docker 容器、关闭了非特权用户命名空间的系统、被 AppArmor 限制的系统里,bwrap 装了也启动不了。所以 on 与 relax 在每次运行前都实际检查一次:
- macOS:执行一次
/usr/bin/sandbox-exec -p '(version 1)(allow default)' /usr/bin/true,最多等 5 秒。与 Linux 一样按能否实际启动判断,本机耗时约 25 毫秒。sandbox-exec只用/usr/bin/sandbox-exec这个绝对路径,不在会话 PATH 里查找,因为 Seatbelt 是系统组件,PATH 里同名的程序不是它。macOS 上不查找socat:claude-code 在 macOS 上不用它,pi 与 dsh 直接连接过滤代理的端口。 - Linux:会话 PATH 里有
bwrap;claude-code、pi 与 dsh 还要有socat。然后执行一次bwrap --ro-bind / / --dev /dev --proc /proc --die-with-parent --unshare-net -- /bin/true,最多等 5 秒。耗时通常是毫秒级。检查创建的命名空间与 pi 和 dsh 的包裹相同,网络命名空间也在检查之内。 - Linux 上的 codex 同样执行这条检查,不要求
socat。查找bwrap时跳过工作目录之内的 PATH 项,找不到时报缺少bwrap。理由:codex 的沙箱启动失败时,codex exec仍以退出码 0 结束并输出turn.completed,没有失败事件,运行中发现不了(codex 0.160.0 在本机 Linux 与 docker 实测)。codex 用 PATH 上第一个不在工作目录之内的bwrap,找不到时才用 npm 包里自带的bwrap;agentrun 按同样的规则查找,检查的就是 codex 会用的那一个,并且不让 codex 退回到没有检查过的自带bwrap。运行中的附加信号见codex 的失败判定。
启动前发现的沙箱问题按 rejected 处理(退出码 2);运行中才发现的,例如 pi 或 dsh 的包裹命令启动失败,按 failed 处理(退出码 1)。--dry-run 也做这些检查,--debug 写出每项检查的结果。
这些检查由 sandbox::check 执行。检查期间它通过 checking 回调把检查进程交给信号处理,收到信号时检查进程按进程、信号与超时清理。沙箱可用时,检查的结果是 Sandbox,它的 wrapper 字段是 Wrapper,记录用哪种方式包裹命令,用 bubblewrap 时还记录找到的 bwrap 与 socat 的路径。沙箱不可用时,--sandbox on 下 check 返回错误,这次运行按 rejected 拒绝;--sandbox relax 下结果仍是 Sandbox,wrapper 为 Wrapper::None,不可用的原因记在 reason 字段。
bwrap 启动失败的常见原因
检查失败时,报错写出 bwrap 的第一行报错,并提示 In a docker container use --sandbox off. On Ubuntu 23.10 or later, allow bwrap to create user namespaces with an AppArmor profile,后面附本节的链接。bwrap 的报错文字随调用者身份变化,所以提示同时写出两种情况,不按报错文字区分。
| 环境 | bwrap 的报错 | 做法 |
|---|---|---|
docker 默认权限(不加 --privileged)的容器 | 以 root 运行时为 Creating new namespace failed: Operation not permitted;以普通用户运行时为 No permissions to create new namespace 开头的一句 | 用 --sandbox off,由容器提供隔离。能让 bwrap 在容器里启动的 docker 选项没有实测 |
| Ubuntu 23.10 及以后的默认设置,AppArmor 限制普通程序创建用户命名空间 | 普通用户运行时为 setting up uid map: Permission denied;root 不受这项限制 | 为 /usr/bin/bwrap 单独加一个 AppArmor profile(见下) |
# 这项限制由 sysctl kernel.apparmor_restrict_unprivileged_userns=1 打开
# /etc/apparmor.d/bwrap
abi <abi/4.0>,
include <tunables/global>
profile bwrap /usr/bin/bwrap flags=(unconfined) {
userns,
}
写入后运行 sudo apparmor_parser -r /etc/apparmor.d/bwrap 加载(Ubuntu 24.04 实测)。只给 bwrap 放行,不把上表里的这项 sysctl 改成 0。理由:改成 0 会让所有程序都能创建用户命名空间。
agentrun 不自动安装 bwrap 与 socat。理由:安装需要 root 和各发行版不同的包管理器,运行 agent 时修改系统,出了问题不容易查清原因。报错信息与 agentrun doctor 给出安装命令。
没有沙箱时的提示
relax 下沙箱没有运行、或者 off 时,text 与 rich 格式在第一行输出:
jsonl 格式不加额外的行,由 start 事件的 sandbox 与 network.enforced 字段记录。理由:relax 不打断运行,但事后能查出这次运行没有隔离。沙箱没有运行时,读取限制只剩 claude-code 的权限规则,见读取限制。
各运行时怎么关进沙箱
四个运行时在沙箱里都只能写工作目录(--cwd)与会话临时目录(见会话临时目录),pi 另外能写用户 pi 状态目录里的五个条目。能读的范围见读取限制。
| 运行时 | 沙箱打开时 | 沙箱没有运行时 |
|---|---|---|
| claude-code | 用 claude-code 自己的沙箱(macOS 是 Seatbelt,Linux 是 bwrap 加 socat),由 --settings 打开。只作用于 Bash 命令:命令只能写两个目录、不能联网,沙箱里的命令自动放行,不允许沙箱外执行命令。Edit 与 Write 不经过沙箱,改用权限模式 dontAsk 加按路径放行的规则,只放行写两个目录 | 权限模式 auto,每条 Bash 命令由 claude-code 的分类器判断 |
| codex | codex 自己的沙箱(macOS 是 Seatbelt,Linux 是 bwrap 加一个 seccomp 网络过滤),用一个自定义权限配置:整个文件系统可读,工作目录与会话临时目录可写,/tmp 与 $TMPDIR 不可写,网络关闭 | 内置权限配置 :danger-full-access |
| pi | pi 没有沙箱,由 agentrun 把整个 pi 进程包裹起来:macOS 用 sandbox-exec 加一份编译进 agentrun 的 Seatbelt 配置,Linux 用 bwrap。另外放行用户 pi 状态目录里的五个条目:auth.json、auth.json.lock、models-store.json、models-store.json.lock、settings.json.lock,见pi 使用用户的状态目录。网络见下一节 | 不包裹 |
| dsh | dsh 自带的沙箱只限制写文件,agentrun 用 DSH_PERMISSION_MODE=danger-full-access 关掉它,与 pi 一样把整个 dsh 进程包裹起来:macOS 用同一份 Seatbelt 配置,Linux 用 bwrap,没有另外放行的条目。dsh 的私有目录在会话临时目录里,见dsh 的私有配置目录 | 不包裹,仍为 danger-full-access,dsh 不限制写入 |
pi 状态目录的放行按平台实现,输入都是 src/sandbox/mod.rs 的 PiState:
- Linux:
bwrap把整个状态目录--bind为可写,再把目录里已有的其他条目(跟随符号链接后是目录或普通文件的)逐个--ro-bind只读覆盖;auth.json是指向别处的符号链接时,最后再--bind链接目标。bwrap 不能只绑定一个还不存在的文件,而 pi 要在目录里新建锁目录,所以整目录可写。剩余风险:沙箱里的 agent 能在状态目录里新建原本没有的条目,例如extensions/,用户下次在沙箱外运行 pi 时会加载它;已有的条目不能改写。 - macOS:Seatbelt 配置在默认放行的基础上禁止所有写入,再只放行工作目录、会话临时目录、状态目录里这五个条目的字面路径(
literal,来自PiState的writable,不存在的也写,pi 自己创建)、auth.json是符号链接时的链接目标,以及/dev/null、终端等几个设备文件;禁止所有网络,只允许连接过滤代理在本机监听的端口。pi 直接连接这个端口,不经过socat。配置写在会话临时目录的seatbelt.sb,权限 0600。状态目录里的其他条目不能新建也不能改写。
pi、dsh、doctor 的沙箱与网络检查、测试里的 FakeAdapter 都通过 sandbox::wrap 包裹,--dry-run 时不写 Seatbelt 配置。理由:包裹逻辑分开写时,改了 bwrap 参数、socat 转发或 Seatbelt 配置的一处,另外几处不会跟着变,doctor 与测试检查的就不再是运行时实际使用的包裹。
doctor 的沙箱与网络检查通过 runtime::model_service_host、runtime::service_hosts、runtime::sandbox_state、runtime::default_denied_reads、runtime::proxy_needed 与 codex::sandbox_command 取运行时的参数,沙箱检查里是否另在沙箱中启动运行时取自 runtime::starts_inside_wrapper。只属于 codex 自己沙箱的检查(需要 codex 可执行文件、不检查私有地址、fake-ip 提示)按 Wrapper::Codex 判断,不按运行时判断。理由:分开写时改了一个运行时的启动参数,doctor 检查的就不再是运行时实际使用的参数。
各运行时的完整参数见运行。几个容易被改坏的决定:
- pi 只放行五个条目,不放行整个状态目录:状态目录里有 pi 的启动脚本、
bin/与扩展,沙箱里的 agent 能改写它们时,用户下次在沙箱外运行 pi 就会执行 agent 写进去的代码。放行的五个条目是 pi 刷新令牌、读写模型缓存与读设置文件时必须写的文件与锁目录,锁必须建在用户目录里才能与用户自己的 pi 互斥,理由见pi 使用用户的状态目录。 - codex 用
default_permissions选择自定义权限配置,不传--sandbox。理由:传了--sandbox时权限配置不生效;显式选择内置的:workspace时 codex 忽略sandbox_workspace_write的各项,/tmp与$TMPDIR仍可写(codex 0.159.2 实测与源码)。 - dsh 关掉自己的沙箱,只用 agentrun 的包裹,理由见运行页的 dsh 一节:两层沙箱叠加时 bash 的
/tmp在命令结束后消失,macOS 上每条 bash 命令都被拒绝。 - pi 与 dsh 的包裹不加
--unshare-pid,让 agentrun 能在宿主的/proc里找到 bwrap 的子进程并直接给它发信号;不加--new-session,让进程组的 SIGKILL 能清理沙箱里的全部进程,见进程组与信号。代价是沙箱里的进程能看到宿主上的其他进程,写限制不受影响。 - macOS 上 Seatbelt 按真实路径匹配,
/tmp与/var指向/private,所以可写路径先解析符号链接再写进配置。 - 沙箱打开时 claude-code 用
dontAsk,不用auto。理由:Edit 与 Write 在 claude 进程里执行,不经过沙箱;auto 模式会放行写到工作目录以外的请求,即使放行规则只列了工作目录(claude 2.1.289 云端实测,Write 写成了$HOME与/tmp下的文件)。dontAsk拒绝放行规则以外的所有调用,被拒绝的调用交还模型,Bash 仍由沙箱自动放行。被拒绝的调用记为tool事件的denied。 - 被沙箱拦下的写入或联网不算
failed,也不设tool事件的denied。denied只表示运行时权限机制的拒绝。理由:命令失败的结果会交还模型,由模型决定下一步,和其他命令出错一样。
读取限制
agent 默认读不到下表的路径。--deny-read PATH 另外加一个文件或目录,可重复;--no-default-deny-read 去掉默认列表,只保留 --deny-read 给出的路径。目录的全部内容都读不到。
| 默认列表 | 路径 |
|---|---|
主目录里的密钥与账号(DENIED_HOME_ENTRIES) | ~/.ssh、~/.aws、~/.gnupg、~/.config/gh、~/.netrc、~/.kube、~/.docker/config.json、~/.config/gcloud、~/.azure |
| 各运行时的登录文件 | claude-code 的 $CLAUDE_CONFIG_DIR/.credentials.json(未设时 ~/.claude/.credentials.json)、codex 的 $CODEX_HOME/auth.json(未设时 ~/.codex/auth.json)、pi 的 $PI_CODING_AGENT_DIR/auth.json(未设时 ~/.pi/agent/auth.json)、dsh 的 $DSH_HOME/.env(未设时 ~/.dsh/.env,dsh 自己从这里读 DEEPSEEK_API_KEY)。运行 pi 时不含 pi 自己的登录文件 |
| 会话记录 | claude-code 的 $CLAUDE_CONFIG_DIR/projects(未设时 ~/.claude/projects,含每个项目的会话记录与 memory 目录)、codex 的 $CODEX_HOME/sessions 与 $CODEX_HOME/history.jsonl(未设时在 ~/.codex 下)、dsh 的 $DSH_HOME/sessions(未设时 ~/.dsh/sessions) |
~与这四个变量取会话环境里的值(--env与--env-file之后),相对路径按工作目录解析,与运行时自己查找登录文件的方式相同。运行 dsh 时会话环境里已经删除了DSH_HOME(见会话环境),这时取调用方环境里原来的DSH_HOME(Session的removed_values),没有时取~/.dsh。理由:用户自己运行 dsh 时,key 与会话记录在这个目录里。会话环境没有HOME时,主目录里的条目不加。用--env HOME=...或--env-file给 agent 另一个主目录时,默认列表只挡新主目录下的条目,调用方自己主目录里的~/.ssh、~/.aws等不在列表里,agent 能读到;需要时用--deny-read加上这些路径。- agent 的工具与命令不需要读这几个会话记录路径:codex 与 dsh 用私有目录(
CODEX_HOME、DSH_HOME);claude-code 沙箱里的 memory 目录在会话临时目录,没有--session-dir时用--no-session-persistence不写记录,给出时由 claude 进程在运行期间把记录写进projects/、继续会话时从--session-dir读取,都在沙箱外,不受读取限制,见保存与继续会话。所以四个运行时都挡住它们。 - pi 的整个进程在沙箱里,要读写自己的登录文件来登录和刷新令牌,所以运行 pi 时不挡它。claude-code 与 codex 的进程在沙箱外读写自己的登录文件,只有它们的工具与命令受读取限制,所以运行它们时自己的登录文件也挡住。dsh 的整个进程在沙箱里,但用会话环境里的
DEEPSEEK_API_KEY与私有DSH_HOME,不读用户的~/.dsh,所以运行 dsh 时它自己的.env也挡住。 --deny-read的相对路径按 agentrun 的当前目录展开成绝对路径。以~开头的路径是用法错误(退出码 2),detail为--deny-read <路径>: ~ is not expanded, give the full path,因为~/.env会被当成当前目录下名为~的目录,什么也挡不住。- 路径等于或包含运行需要的目录时是用法错误,
detail为--deny-read <路径>: contains the <名称> <目录>;默认列表里包含这些目录的项直接去掉。这些目录是工作目录(working directory)、会话临时目录所在的目录(temporary directory,调用方的TMPDIR,未设时/tmp),运行 pi 时还有 pi 的状态目录(pi state directory)。理由:工作目录读不到时 agent 无法工作;会话临时目录里有图片的副本、codex 的私有CODEX_HOME与 claude-code 的 memory 目录;pi 在沙箱里读自己的状态目录来登录。包含的判断:两个路径都存在时跟随符号链接展开后比较;有一个不存在时,按字面去掉.与..后比较,所以<工作目录>/../x不算在工作目录之内。判断在src/runtime/mod.rs的contains。 - 最终的列表按默认列表在前、
--deny-read在后的顺序去重,写进start事件的deny_read与--dry-run、--debug的deny read:一行。
| 运行时 | 沙箱运行时 | 沙箱没有运行时 |
|---|---|---|
| claude-code | --settings 的 permissions.deny 给每个路径写两条规则 Read(//<路径>) 与 Read(//<路径>/**),路径里的 \ * ? [ ] { } 前面加反斜杠,按字面匹配。Read、Grep、Glob 读这些路径时被拒绝,Grep 搜索上层目录时跳过它们;sandbox.filesystem.denyRead 列出同样的路径,Bash 命令读不到(Linux 上目录显示为空,文件读取报 Permission denied)。路径是符号链接时,链接与目标都写(claude 2.1.303 云端实测) | --settings 只有 permissions.deny。Read、Grep、Glob 照样被拒绝;Bash 命令不在沙箱里,命令里直接写出这些路径时被 claude-code 拒绝,间接的写法由分类器判断,没有保证(claude 2.1.303 云端实测) |
| codex | 自定义权限配置的 filesystem 表给每个存在的路径加一项 "<真实路径>"="deny",已经在另一个被拒绝的目录之内的路径不再单独加。用户登录文件或它所在的目录在列表里时(按真实路径比较),私有 CODEX_HOME 里指向它的符号链接 auth.json 按链接本身的路径再加一项,不依赖 codex 是否解析符号链接。codex 能看到这些拒绝项,读写被拒路径的命令它不执行,所以这些命令没有 tool 事件(codex 0.160.1 macOS 实测)。--deny-read 的真实路径含 * ? [ ] { } 时是用法错误,detail 为 --deny-read <路径>: codex cannot deny a path that contains * ? [ ] { },解析后的路径与给出的不同时,在给出的路径后加 (resolves to <真实路径>):codex 把这类表项当作模式,文档没有转义写法,"a*b"="deny" 既挡不住字面路径 a*b 也挡不住 aXb(codex 0.160.1 macOS 实测)。默认列表不检查 | 不限制 |
| pi | 存在的路径先解析符号链接,已经在另一个被拒绝的目录之内的路径不再单独处理,与输入顺序无关。理由:Linux 上父目录先换成只读的空目录后,子路径不存在也无法创建,bwrap 启动失败。Linux:目录用 --tmpfs 加 --remount-ro 换成只读的空目录,其他文件用 --ro-bind /dev/null 覆盖(读取报 Permission denied),写在所有 --bind 之后,所以工作目录与状态目录里的路径也能挡住。macOS:Seatbelt 配置末尾加 (deny file-read* (subpath "<真实路径>") ...) | 不限制 |
| dsh | 与 pi 相同。dsh 的整个进程在沙箱里,命令与 dsh 自己的 read、glob、grep 工具都读不到这些路径:read 读被覆盖的文件报 not a regular file,读被换成空目录里的文件报 not found,glob 在这样的目录里找不到文件,grep 搜索上层目录时报 Permission denied(0.2.1-rc.2 云端实测) | 不限制 |
路径在 sandbox::hidden 里解析成 Hidden(真实路径与是否目录),pi、dsh、doctor 与 codex 都用它。几个容易被改坏的决定:
- 不存在的路径在 bwrap、Seatbelt 与 codex 里跳过:bwrap 不能在只读的根目录上新建挂载点,而沙箱里的 agent 只能在可写目录里新建文件。claude-code 的规则按路径匹配,不存在的路径也写。
- claude-code 同时写权限规则与
denyRead。只写权限规则时,claude-code 会把 Read 的拒绝规则并入沙箱(claude 2.1.303 云端实测),显式写denyRead不依赖这项内部行为;Read、Grep、Glob 在 claude 进程里执行,只能靠权限规则。 - Read 被规则拒绝时 claude-code 不输出
permission_denied,只返回工具错误<tool_use_error>File is in a directory that is denied by your permission settings.</tool_use_error>;沙箱没有运行时 Write 写这些路径返回<tool_use_error>File is covered by a Read deny rule in your permission settings and cannot be written.</tool_use_error>。适配器按这两段文字把调用标为denied,与 Grep、Glob 一致,见claude-code 的输出映射。只认以<tool_use_error>开头的结果,因为 Bash 命令的输出里也可能出现同样的文字。 - doctor 的沙箱与网络检查带上默认列表,检查的就是实际运行时的挂载与配置。沙箱检查另外把检查目录里的一个文件加进列表,确认沙箱里读不到它。
网络
--network 决定 agent 的命令与网页工具能访问哪些主机。它的限制只在沙箱运行时生效;沙箱没有运行时按沙箱模式处理。
| 取值 | 命令能访问 | 网页工具 |
|---|---|---|
none(默认) | 不能联网。pi 与 dsh 只能访问模型服务的主机 | 关闭 |
full | 任何公网主机 | 打开网页搜索与网页读取 |
custom | --allow-host 列出的主机,pi 与 dsh 另加模型服务的主机 | 只打开网页读取,限制在列出的主机 |
--allow-host HOST[:PORT] 可重复。HOST 是完整主机名,或 *.example.com(匹配所有子域名,不匹配 example.com 本身)。不写端口时允许 443 与 80。以下情形是用法错误:custom 但没有 --allow-host;给了 --allow-host 但不是 custom;主机名里有 * 但不是以 *. 开头;单独一个 *;主机为空或端口不是 1 到 65535 的整数;主机是 IPv6 字面量(见不支持 IPv6)。理由:单独的 * 与 full 同义,只保留一种写法,start 事件里一眼能看出是否全开。
full 与 custom 默认拒绝解析到私有地址的主机,主机名或 IP 被逐字列在 --allow-host 里时放行;*.example.com 这类通配规则匹配到的主机仍然检查,因为允许用户自建子域名或泛解析的域名能让某个子域名解析到内网地址。私有地址是 0.0.0.0/8、10.0.0.0/8、100.64.0.0/10、127.0.0.0/8、169.254.0.0/16、172.16.0.0/12、192.168.0.0/16。198.18.0.0/15 不在其中:本机 DNS 由代理工具做 fake-ip 时,公网域名解析到这个网段,把它当作私有地址会让放行列表里的主机也被拒绝。
custom 不打开网页搜索的理由:claude-code 的网页搜索与 codex 的网页搜索在服务商的服务器上执行,无法按主机限制,而且搜索词本身可以带出文件内容。
过滤代理
沙箱打开且需要按主机放行时,agentrun 在启动运行时之前在自己的进程里启动一个过滤代理,会话结束时关闭。代理运行在沙箱外,沙箱里的进程只能连到它。
- 协议:HTTP 代理(
CONNECT隧道与普通 HTTP 转发)和 SOCKS5CONNECT。不解密 HTTPS,只按目标主机名与端口判断。 - 目标主机名只接受字母、数字、
-、_、.组成、最长 253 个字节的名字与 IPv4 字面量,其他字节或更长的目标按请求格式错误处理(HTTP 400,SOCKS5 拒绝),不输出network事件,所以事件与发给上游的请求行里不会出现别的字符。 - 请求行的版本只接受
HTTP/1.0与HTTP/1.1,CONNECT也一样。请求行或请求头格式错误时回复 400,不输出network事件,包括版本后面还有内容、以空格开头的续行头(obs-fold)、头名与冒号之间有空白、头名含有 token 以外的字符,以及请求头在第一个\r\n\r\n之前出现只用\n的空行。上游 HTTP 代理对CONNECT的回复接受头名后的空白与续行头,状态码是 2xx 时建立隧道。 - 普通 HTTP 每个连接只转发一个请求:请求体按
Content-Length转发,带Transfer-Encoding的请求回复 411,之后客户端再写入的字节丢弃。请求行与Host头按判定过的主机与端口重写,客户端自己写的Host不转发,所以不能借一个放行的主机访问同一个 IP 上按Host区分的其他站点。 - 同时监听
127.0.0.1上的随机端口与会话临时目录里的 Unix socketproxy.sock。Linux 的bwrap用--unshare-net断开网络,被包裹的 pi 与 dsh 由沙箱里的socat把同一个端口号接到这个 socket,做法与 claude-code 自己的沙箱相同;沙箱外不需要socat。macOS 的 Seatbelt 配置只允许连接这个端口。 - 代理自己解析域名,判断私有地址,直接连接时只连解析出的地址。读请求头最多等 30 秒、最多 64 KB;连接目标时依次尝试解析出的每个地址,每个地址最多等 10 秒。
- 会话环境的
HTTPS_PROXY、HTTP_PROXY、ALL_PROXY与它们的小写形式指向代理,NO_PROXY与no_proxy清空。小写也要设,因为 pi 先读小写的https_proxy,调用方环境里原有的小写变量会让 pi 绕过过滤代理(pi 1.0.1 实测)。 - 调用方环境里原有的代理变量改由过滤代理作为上游使用,所以 Cloud Managed 容器自带的出口代理仍然生效:
CONNECT与 SOCKS5 依次取https_proxy、HTTPS_PROXY、all_proxy、ALL_PROXY,普通 HTTP 依次取http_proxy、HTTP_PROXY、all_proxy、ALL_PROXY,第一个非空的值生效。只支持http://上游,地址里的用户名与密码转为Proxy-Authorization: Basic;其他协议的值当作没有上游。调用方原有的no_proxy或NO_PROXY匹配的主机直接连接。目标主机解析失败时,有上游则交给上游连接,没有上游则按连接失败处理。代理变量的值可能带凭据,不出现在任何输出里。 - 每个「主机、端口、放行或拒绝」组合第一次出现时输出一个
network事件,见事件与输出。 - HTTP 代理拒绝请求时回复 403,响应体为
agentrun: <主机>:<端口> is not allowed (<reason>)。放行后连接失败时回复 502,响应体为agentrun: <主机>:<端口> <原因>,让沙箱里的 agent 与调用方能看出连接为什么失败。原因是下面几种之一,括号里的错误是操作系统给出的错误描述:could not be resolved:本机解析不到目标主机的 IPv4 地址,又没有上游代理。connection failed (<错误>):直接连接时每个地址都连接失败,错误取最后一个地址的。upstream proxy connection failed (<错误>):连不上上游代理,或者向上游代理写请求、读回复时出错。upstream proxy did not answer in time:上游代理在 30 秒内没有回复完CONNECT的回复头。upstream proxy closed the connection:上游代理在回复头结束之前关闭了连接。upstream proxy answered <状态码>:上游代理对CONNECT回复了 200 到 299 以外的状态码。upstream proxy answered an invalid response:上游代理的回复头超过 64 KB,或者读不出状态码。
network事件只记放行或拒绝,不记连接结果。
不支持 IPv6
过滤代理与网络限制只支持 IPv4:只在 127.0.0.1 监听;解析目标主机只取 IPv4 地址,没有 IPv4 地址时按连接失败处理;目标是 IPv6 字面量时拒绝,reason 为 not_allowed;--allow-host 的主机是 IPv6 字面量时是用法错误;SOCKS5 的 IPv6 地址类型回复「不支持的地址类型」。理由:Cloud Managed 容器的内核没有 IPv6,IPv6 的路径无法在云端验收。已知后果:在 localhost 先解析为 ::1 的主机上,claude-code 在沙箱外用 TCP:localhost:<端口> 连接过滤代理可能失败,由本机 Linux 实测确认。
没有直接用各运行时自带的网络设置,因为它们能力不一致:claude-code 沙箱的 allowedDomains 不接受单独的 *,表达不了 full(claude 2.1.289);codex 按主机放行要开启它自带的实验性代理,而且这个代理拒绝解析到私有网段的主机;pi 没有任何网络控制,dsh 自带的沙箱不限制网络。各自的做法含义不同,也没有统一的连接记录可供排查。
各运行时的做法
| 运行时 | none | full 与 custom | 网页工具 |
|---|---|---|---|
| claude-code | 沙箱全禁,不启动代理 | 沙箱设置 sandbox.network.httpProxyPort 与 socksProxyPort 指向过滤代理。设了这两个端口后 claude-code 不再按 allowedDomains 过滤,沙箱里命令的流量交给过滤代理,主机由过滤代理判定(claude 2.1.289 云端实测),所以 allowedDomains 保持为空。例外:claude-code 给沙箱里的命令设置的 NO_PROXY 列出了 localhost、127.0.0.1、169.254.0.0/16、10.0.0.0/8、172.16.0.0/12、192.168.0.0/16,目标是这些网段里的 IP 字面量时命令不经代理,在没有网络的命名空间里直接连接失败,过滤代理看不到这次请求,没有 network 事件(claude 2.1.289 云端实测);解析到私有地址的主机名仍由过滤代理拒绝 | full:放行 WebFetch 与 WebSearch。custom:每个主机放行一条 WebFetch(domain:HOST),不放行 WebSearch |
| codex | 沙箱全禁,不启动代理 | full:权限配置的网络打开,直接联网,不经过代理,没有 network 事件。custom:开启 codex 自带的代理,放行 --allow-host 列出的主机;codex 进程的 HTTPS_PROXY 指向过滤代理,codex 的代理把放行的请求转给它,过滤代理按同一份列表再判断一次并输出 network 事件。codex 的代理关闭 SOCKS5,因为 SOCKS5 的连接不经过上游。codex 访问模型与刷新令牌的请求也经过过滤代理,过滤代理另外放行 codex 的服务主机,见模型服务的主机。codex 的代理配置不写 allow_local_binding:本机 DNS 由代理工具做 fake-ip 时,公网域名解析到 198.18.0.0/15,codex 的代理把这个网段当作私有地址,放行列表里的主机也被拒绝。加 allow_local_binding=true 能通过,但加上后沙箱里的命令能绕过代理直接连接本机回环地址上的端口,所以这个选项由调用方在 -- 之后用 -c permissions.agentrun.network.allow_local_binding=true 自己打开。agentrun 的过滤代理不把这个网段当作私有地址 | full:web_search="live"。none 与 custom:web_search="disabled" |
| pi | 包裹断开网络,只能连代理,代理只放行模型服务 | 同 none,代理按取值放行 | pi 没有网页工具,靠 bash 里的 curl |
| dsh | 同 pi | 同 pi。dsh 的 node 进程与它执行的命令都按代理变量经过过滤代理(实测) | 补丁关闭 web_search 与 web_fetch,靠 bash 里的 curl,理由见运行页的 dsh 一节 |
claude-code 的 WebFetch 在 claude 进程里运行,不经过沙箱也不经过代理,只能靠权限规则限制主机。沙箱打开时权限模式是 dontAsk,放行规则以外的主机被拒绝;沙箱没有运行时权限模式是 auto,放行规则以外的主机由分类器判断,云端实测分类器放行了不在列表里的主机,所以这时 custom 下的 WebFetch 不受主机限制,见剩余风险。claude-code 进程本身不设置代理变量,它访问 Anthropic 的流量不经过过滤代理。
claude-code 的沙箱运行时,三种取值下会话环境都设置 SOCAT_DEFAULT_LISTEN_IP=4。理由:claude-code 在断网的网络命名空间里用 socat 把沙箱里的端口转发到它的代理 socket,没有指定地址族;内核没有 IPv6 时(Cloud Managed 容器),socat 1.8 选了 IPv6 而启动失败,claude-code 丢弃它的输出,沙箱里的命令连不上任何代理。这个变量让 socat 改用 IPv4 监听。
模型服务的主机
pi 的进程整个在沙箱里,它访问模型服务也要经过代理。pi 不输出服务商的地址(--list-models 与 auth check 都没有,pi 1.0.1),所以 agentrun 自带一张表,按 --model 的前缀判断服务商,没给 --model 时读 pi 设置里的 defaultProvider。pi 的 launch 与 doctor 的网络检查用同一个函数 pi::model_service 得到服务商与主机。模型服务的主机只在 443 端口放行,并且跳过私有地址检查:
| pi 的服务商 | 放行的主机 |
|---|---|
deepseek | api.deepseek.com |
anthropic | api.anthropic.com |
openai | api.openai.com |
google | generativelanguage.googleapis.com |
openrouter | openrouter.ai |
服务商不在表里时,agentrun 不知道该放行哪个主机。none 下启动前报错(退出码 2),detail 为 cannot tell which host pi's model service uses (provider: <服务商或 unknown>). Use --network custom --allow-host <host of the model service>;没给 --model 且 pi 设置文件不存在、不是 JSON 或没有 defaultProvider 时服务商按 unknown 处理;custom 下只放行 --allow-host 列出的主机;full 不受影响。
dsh 的进程也整个在沙箱里,它的模型服务主机由 dsh::service_host 给出,launch 与 doctor 的网络检查都用它:会话环境里 DEEPSEEK_BASE_URL 不为空时取这个地址的主机(network::url_host::// 之后、第一个 /、? 或 # 之前的部分,去掉用户信息后按过滤代理解析请求目标的同一规则取主机,即去掉端口、转成小写、去掉末尾的点),否则为 api.deepseek.com。与 pi 相同,这个主机只在 443 端口放行并跳过私有地址检查;模型服务用其他端口时,用 --network custom --allow-host HOST:PORT 放行。DEEPSEEK_BASE_URL 读不出主机时(没有 ://、主机为空、不是合法的主机名、端口为空、不是数字或大于 65535,或者主机是 IPv6 字面量,见不支持 IPv6),none 下启动前报错(退出码 2),detail 为 cannot tell which host dsh's model service uses from DEEPSEEK_BASE_URL. Use --network custom --allow-host <host of the model service>;custom 下只放行 --allow-host 列出的主机;full 不受影响。dsh 的网页搜索也只连 api.deepseek.com,这是关闭它的理由之一。
codex 的进程不在沙箱里,none 与 full 下它直接访问模型服务。custom 下 codex 进程的 HTTPS_PROXY 指向过滤代理,它自己的请求也经过过滤代理,所以过滤代理另外放行下面四个主机,不看用户用哪种方式登录:
| codex 的请求 | 放行的主机 |
|---|---|
| ChatGPT 登录时访问模型(macOS 实测) | chatgpt.com、ab.chatgpt.com |
| 刷新 ChatGPT 登录的令牌 | auth.openai.com |
| API key 登录时访问模型 | api.openai.com |
后两个主机取自 codex 0.159.2 内置的地址,没有实测。只放行前两个时,令牌过期的那次运行刷新失败,API key 登录完全不能用。
配置与账号
agent 不应读到用户自己的运行时配置、插件、规则、MCP 服务器与会话记录,也不应把记录写进用户的会话。各运行时都用参数关闭这些来源,详见运行:
- claude-code:不加载用户、项目与本地设置,不加载 MCP 服务器,关闭 auto-memory,新会话每次传一个新生成的会话 id。沙箱打开时 auto-memory 目录改到会话临时目录,agent 不能写用户的 memory 目录。
- codex:使用私有的
CODEX_HOME,只带入用户的登录文件,见私有配置目录;不读工作目录里的AGENTS.md与.codex/,不加载技能说明;关闭插件、连接器、hooks 等会给模型增加工具的功能。 - pi:不加载扩展、技能、提示模板、主题与上下文文件,不信任工作目录里的项目级配置,启动时不联网下载资源。用户状态目录里的设置、自定义模型与
bin/照常读取,见pi 使用用户的状态目录。 - dsh:使用会话临时目录里的私有
DSH_HOME与空的DSH_AGENTS_HOME,见dsh 的私有配置目录;补丁关闭指令文件、技能、会话记录随请求上传、插件清单上报、遥测与会话标题请求。工作目录的.env无法关闭,见剩余风险。
四个运行时默认都不保存会话。调用方给出 --session-dir 时,运行结束后会话记录移到那个目录,--resume 只从那里读,不读用户自己的会话记录。运行期间 codex 的记录在私有目录里,pi 与 dsh 的记录在会话临时目录里;claude-code 的新会话写在用户配置目录的 projects/ 里,结束后才移走,带来的风险见剩余风险,做法见保存与继续会话。
账号由登录方式决定。会话环境按运行时删除会改变登录方式的变量,容器里的凭据由环境变量写入登录文件,见会话环境。
剩余风险
| 运行时 | 沙箱运行时 | 沙箱没有运行时 |
|---|---|---|
| claude-code | 内置的 Read 能读读取限制以外的文件,内容会进入模型上下文。Edit 与 Write 只能写工作目录与会话临时目录,由权限规则限制,不由沙箱限制(云端实测)。macOS 上沙箱里的命令能写 /tmp/claude-<uid>,这个目录由同一用户的所有 claude 会话共用,写在里面的文件运行结束后不删除(macOS 实测);Linux 上这个目录在会话临时目录之内,没有这项风险(本机 Linux 与云端实测) | Bash 命令只经分类器判断,能写容器内任何位置、能联网,能用间接的写法读取读取限制里的路径;Write 能写任何位置;--network custom 下 WebFetch 能读放行列表以外的主机(均为云端实测)。边界是容器 |
| codex | 能读读取限制以外、用户可读的任何文件,内容会进入模型上下文。系统级配置(/etc/codex 与 macOS 的托管配置)无法关闭,照常生效(codex 0.159.2 源码)。--network full 下命令直接联网,不经过滤代理,没有私有地址检查,169.254.169.254 这类地址可以访问(用户定) | 能读、写容器内任何位置,能联网,边界是容器 |
| pi | 能读读取限制以外、用户可读的任何文件;能改写用户 pi 状态目录里的 auth.json 与 models-store.json;Linux 上还能在状态目录里新建原本没有的条目,例如 extensions/,用户下次在沙箱外运行 pi 时会加载。agent 可以把状态目录里的 auth.json 换成指向其他文件的符号链接,之后带 AGENTRUN_PI_AUTH 运行时,agentrun 把凭据写到那个文件,这个文件可以在沙箱之外,见凭据。网络只通向模型服务,所以读到的文件只能经模型服务带出 | 能读、写容器内任何位置,能联网,边界是容器 |
| dsh | 能读读取限制以外、用户可读的任何文件。dsh 启动时把工作目录里 .env 的变量合并进它自己与命令的环境,会话环境里已有的变量优先,没有参数能关闭;agent 能写工作目录,写进 .env 的变量会进入之后每次运行的命令环境。账号不受影响,因为会话环境里总有 DEEPSEEK_API_KEY;.env 里有 dsh 拒绝读取的变量名(DSH_ 开头、DEEPSEEK_BASE_URL、代理变量、PATH 等)时 dsh 启动失败(0.2.1-rc.2 实测)。网络只通向模型服务,读到的文件只能经模型服务带出;每个模型请求带 x-deepseek-harness-user-id 与 x-deepseek-harness-session-id 两个请求头,私有 DSH_HOME 下用户 id 每次运行随机生成 | 能读、写容器内任何位置,能联网,边界是容器 |
读取限制按路径匹配。限制范围以外已有的硬链接指向同一个文件时,通过硬链接能读到内容。工作目录里被拒绝读取的目录,沙箱运行时 claude-code 的命令写进去报告成功,但写入的文件在运行结束后不存在(Linux 上这个目录在沙箱里换成了空目录,云端实测);pi 写进去时报只读错误;codex 的拒绝项优先于工作目录的写权限,文件不会写出(codex 0.160.1 macOS 实测)。
读取限制只针对文件。会话环境里的变量(例如 DEEPSEEK_API_KEY 与容器里的令牌)agent 都能读到;默认列表以外的凭据文件,需要用 --deny-read 自己加上。pi 的会话记录目录(状态目录里的 sessions/)不在默认列表里。
保存会话(--session-dir)的剩余风险:
- claude-code 的新会话在运行期间写在用户的配置目录里,运行结束后才移到
--session-dir。只有给出--session-dir时这样做,不给时仍加--no-session-persistence,不写记录。failed、interrupted、timeout与limit的运行同样在结束时移走(云端实测interrupted、timeout与limit)。运行期间用户自己的claude --resume列表能看到它;agentrun 被 SIGKILL 时它留在用户的配置目录里。运行期间用户在同一个工作目录里运行claude --continue可能接上这次运行的会话,用户写进去的内容在运行结束时随记录一起被移到--session-dir。 - pi 与 dsh 的记录在运行期间放在会话临时目录里,沙箱里的 agent 能改写这次会话的记录,改写后的内容随记录一起保存,下次继续时进入模型上下文。其他会话的记录 agent 写不到。
--session-dir放在工作目录或会话临时目录之内时,沙箱里的 agent 能改写已保存的全部会话,改写的内容在下次继续时进入模型上下文。agentrun 不检查这一点,由调用方把目录放在两者之外。
过滤代理本身的剩余风险:
- HTTPS 只按
CONNECT的主机名与端口判定,TLS 里的 SNI 与Host不检查。放行的主机如果和别的站点共用 IP 并按 SNI 选站点,隧道里可以访问那些站点。 - 有上游代理时,私有地址只看本机的解析结果,上游会自己再解析一次;本机解析失败时不做检查,直接交给上游,主机只有 IPv6 地址时同样不做检查。理由:Cloud Managed 容器的本机 DNS 常常不可用,解析不出地址时拒绝会让
full在那里几乎不能用。 - 过滤代理在本机回环地址上监听 TCP 端口,会话期间同一台机器上的其他用户也能连这个端口,经它发出的请求会带上调用方的上游代理凭据。
full下 claude-code 的 WebFetch 在 claude 进程里运行,不经过过滤代理,也就没有私有地址检查。
不走代理的程序在沙箱里连不上网。例如 git clone git@github.com:... 走 SSH,即使用了 --network full 也会失败;改用 HTTPS 地址可以成功。