Skip to content

Claude Code Sandbox 深度调研

本文聚焦 Claude Code 的「沙箱(Sandbox)」机制,从设计哲学、整体架构、平台落地、关键源码到执行流程,逐层拆解 Anthropic 的实现思路。

调研依据:

  • Anthropic 官方博客《Making Claude Code more secure and autonomous with sandboxing》
  • 官方文档 docs.anthropic.com/en/docs/claude-code/sandboxing
  • 开源项目 anthropic-experimental/sandbox-runtime(npm 包 @anthropic-ai/sandbox-runtime,CLI 名为 srt
  • Claude Code 反编译源码(claude-code-sourcemap)中的 sandbox-runtimesandbox-adapter.ts

一、为什么需要沙箱:从「权限疲劳」到「OS 级边界」

1.1 Claude Code 早期的安全模型痛点

在没有沙箱前,Claude Code 走的是「Permission Prompt(权限提示)」路线:每个 Bash 命令、每次写文件、每次访问网络都可能要弹一次确认框。这套机制的问题官方在博客里说得很直白:

  • Approval fatigue(审批疲劳):用户重复点 "approve",最终对每个提示视而不见。
  • 生产力下降:被频繁打断,节奏被打乱。
  • 代理自主性受限:必须等待用户审批,无法长时间自主运行。
  • 信任前提脆弱:Prompt Injection 攻击可以诱导模型输出绕过 prompt 层防御的命令;纯应用层校验抵不过聪明的 exploit。

1.2 沙箱的设计哲学(Design Principle)

Anthropic 给出的核心思路是 Defined Boundaries(先画好边界)+ OS-Level Enforcement(操作系统级强制)。两条关键设计原则:

  1. Secure by default(默认安全):进程拿到的能力是最小的,需要的"权限洞"必须显式打开。
  2. Dual Isolation Model(双重隔离)文件系统隔离 + 网络隔离 必须成对出现
    • 只锁文件不锁网:恶意代码可以把 ~/.ssh/id_rsa POST 出去。
    • 只锁网不锁文:恶意代码可以改 ~/.bashrc 在你下次登录时 reverse shell。

Anthropic 在内部数据上声称:开启 Sandbox 后权限提示量减少 84%,且在 Prompt Injection 测试中能扛住典型逃逸尝试。

1.3 在 Claude Code 中的定位

  • 沙箱只作用于 Bash 工具及其子进程树(Bash → npm → curl → ...),不覆盖 Read / Edit / Write 这些走应用层 Permission 的工具。
  • 与 Permission System 互补而非替代:
    • Permission 控制 "Claude 能不能调这个 tool"(应用层)
    • Sandbox 控制 "Bash 跑出去后能不能访问这个文件/域名"(OS 层)
  • 沙箱被打包成独立的 npm 包 @anthropic-ai/sandbox-runtime ,对外开源(Apache 2.0),CLI 名为 srt。Claude Code 内部就是 vendor 这个包。

二、整体架构:分层与职责

┌─────────────────────────────────────────────────────────────────┐
│  Claude Code 主进程(Node.js)                                    │
│  ┌─────────────────────────────────────────────────────────────┐ │
│  │ BashTool  →  shouldUseSandbox(input) → SandboxManager       │ │
│  │                                              │              │ │
│  │                                              ▼              │ │
│  │  Shell.exec()  →  SandboxManager.wrapWithSandbox(cmd)       │ │
│  │                                              │              │ │
│  │                              ┌───────────────┴───────────┐  │ │
│  │                              │ sandbox-adapter.ts        │  │ │
│  │                              │  (Claude CLI 的 settings  │  │ │
│  │                              │   → SandboxRuntimeConfig) │  │ │
│  │                              └───────────────┬───────────┘  │ │
│  │                                              ▼              │ │
│  │  HTTP Proxy (127.0.0.1:port)  ◄──────  SandboxManager       │ │
│  │  SOCKS5 Proxy (127.0.0.1:port) ◄──────  (sandbox-runtime)   │ │
│  └─────────────────────────────────────────────────────────────┘ │
│                            │ spawn 子进程                        │
└────────────────────────────┼────────────────────────────────────┘

                ┌─────────────────────────────┐
                │  外层包装命令:              │
                │  • macOS: sandbox-exec -p   │
                │           '<profile>' bash  │
                │  • Linux: bwrap [args] bash │
                └────────────┬────────────────┘

                ┌─────────────────────────────┐
                │  受限子进程(用户的 bash    │
                │  命令 + 全部子进程树)      │
                │                             │
                │  网络 → Proxy(域名过滤)   │
                │  文件 → Seatbelt/bwrap 拦截 │
                └─────────────────────────────┘

2.1 关键模块(sandbox-runtime 源码结构)

sandbox-runtime/
├── index.ts                       # 对外导出 SandboxManager 等
├── cli.ts                         # srt 命令入口
├── sandbox/
│   ├── sandbox-manager.ts         # 总调度器:init / wrapWithSandbox / reset
│   ├── sandbox-config.ts          # Zod Schema:网络/文件/seccomp/...
│   ├── sandbox-utils.ts           # 通用工具:路径归一化、glob、危险文件清单
│   ├── sandbox-violation-store.ts # 违规事件存储
│   ├── http-proxy.ts              # HTTP/HTTPS CONNECT 代理(域名过滤)
│   ├── socks-proxy.ts             # SOCKS5 代理(任意 TCP 流量)
│   ├── linux-sandbox-utils.ts     # bwrap 包装 + 网络桥接 + seccomp
│   ├── macos-sandbox-utils.ts     # 动态生成 Seatbelt profile
│   └── generate-seccomp-filter.js # 加载预编译 BPF 过滤器
└── vendor/seccomp/
    ├── x64/{unix-block.bpf, apply-seccomp}
    └── arm64/{unix-block.bpf, apply-seccomp}

2.2 Claude Code 的适配层(sandbox-adapter.ts

SandboxManager(基础库)只负责"给我一段命令、给我配置,我返回一段被包裹的命令字符串"。Claude Code 在外面套了一层 sandbox-adapter.ts,负责:

  • Settings ↔ SandboxRuntimeConfig 转换:把 Claude 自己的 permissions.allow/denyEdit(...)Read(...)WebFetch(domain:...))和 sandbox.filesystem.*sandbox.network.* 翻译成 sandbox-runtime 的格式。
  • 路径前缀语义:Claude Code 引入了三种路径写法:
    • //path — 文件系统绝对路径(去掉一个 /
    • /path相对设置文件目录的路径(permission rule 约定,与一般理解相反)
    • ~/path./pathpath — 透传给 sandbox-runtime
  • Worktree 检测:在 git worktree 里 .git 是文件而不是目录,必须把主仓库路径加到 allowWrite,否则 git index.lock 会报错。
  • Bare-repo 防御(CVE-style):阻止恶意命令在 cwd 下植入 HEADobjects/refs/ 把 cwd 伪装成裸仓库(一旦伪装成功,宿主未沙盒的 git 就会读 core.fsmonitor 触发任意命令执行)。
  • 设置变更订阅settingsChangeDetector.subscribe → 实时 updateConfig,让运行中的代理也能感知新规则。

三、配置模型:双向不对称的精妙

SandboxRuntimeConfigSchema(Zod)的核心字段,读写两侧的「allow / deny 优先级」是反着的

维度默认模式优先级
read(读)允许全部deny-then-allowallowRead 覆盖 denyRead
write(写)拒绝全部allow-onlydenyWrite 覆盖 allowWrite
network拒绝全部allow-onlydeniedDomains 覆盖 allowedDomains

这种不对称的语义是精心设计的

  • 读:你想"封一个区域,但留一个洞"——例如禁 /Users 但开 cwd。
  • 写:你想"开一个区域,但留一个保护带"——例如开 .,但禁 .env.git/hooks/

3.1 强制 Deny 路径(Mandatory Deny Paths,源码 DANGEROUS_FILES

无论用户怎么配置 allowWrite,下列路径永远被拒写——这是「沙箱逃逸防线」:

ts
// sandbox-utils.ts
export const DANGEROUS_FILES = [
  '.gitconfig', '.gitmodules',
  '.bashrc', '.bash_profile', '.zshrc', '.zprofile', '.profile',
  '.ripgreprc',
  '.mcp.json',
];
export const DANGEROUS_DIRECTORIES = ['.git', '.vscode', '.idea'];
// + 始终阻止 .git/hooks(任意提交触发执行)
// + 始终阻止 .git/config(除非显式 allowGitConfig: true)
// + 始终阻止 .claude/commands、.claude/agents、.claude/skills
// + 始终阻止 settings.json / settings.local.json(防止改沙箱自身)

阻止 mv/rename 绕过的设计也很细:在 macOS profile 里同时下发 (deny file-write-unlink ...) 来挡掉 "把目标删掉再造一个同名文件" 的攻击。

3.2 Default Write Paths(生存所需)

某些路径是「让命令能正常跑完」的最低保障,所以 sandbox-runtime 默认就打开:

ts
function getDefaultWritePaths() {
  return [
    '/dev/stdout', '/dev/stderr', '/dev/null', '/dev/tty',
    '/dev/dtracehelper', '/dev/autofs_nowait',
    '/tmp/claude', '/private/tmp/claude',
    `${homedir}/.npm/_logs`,
    `${homedir}/.claude/debug`,
  ];
}

四、macOS 实现:Seatbelt + sandbox-exec

4.1 Seatbelt 是什么

Seatbelt 是 Apple TrustedBSD MAC 框架(与 iOS 的 App Sandbox、App Store 沙箱、Chrome Renderer 沙箱同源)。命令行接口是 sandbox-exec(1) + 一种**类 Lisp 的 SBPL(Sandbox Profile Language)**配置:

scheme
(version 1)
(deny default (with message "<logTag>"))
(allow process-exec)
(allow file-read*)
(deny file-read* (subpath "/Users/me/.ssh") (with message "<logTag>"))
(allow network-bind (local ip "*:*"))
...

Apple 早就用 sandbox-exec 隔离自家系统服务,但它官方未文档化,签名时还会警告 "deprecated"——好在它一直没真被废弃。

4.2 Profile 动态生成(macos-sandbox-utils.ts

每次 wrapWithSandbox 都会为这条命令现场生成一个 SBPL profile,关键骨架:

scheme
(version 1)
(deny default (with message "<logTag>"))   ; 默认全拒

; ── 进程相关
(allow process-exec)
(allow process-fork)
(allow signal (target same-sandbox))

; ── Mach IPC(仅放行白名单服务)
(allow mach-lookup
  (global-name "com.apple.audio.systemsoundserver")
  (global-name "com.apple.lsd.mapdb")
  ...)

; ── sysctl-read(精挑细选了 50+ 个,避免泄漏过多硬件信息)
(allow sysctl-read
  (sysctl-name "hw.ncpu")
  (sysctl-name-prefix "kern.proc.pid.")
  ...)

; ── 网络
(allow network-bind (local ip "localhost:<httpProxyPort>"))
(allow network-outbound (remote ip "localhost:<httpProxyPort>"))
(allow network-bind (local ip "localhost:<socksProxyPort>"))
; 其他网络操作 → 落入 (deny default) 被拒

; ── 文件读:先 allow 全部,再 deny 区域,再 allow back
(allow file-read*)
(deny file-read* (subpath "/Users/me/.ssh") (with message "<logTag>"))
(allow file-read* (subpath "<cwd>") (with message "<logTag>"))

; ── 文件写:默认拒,逐条 allow,再 deny 危险文件
(allow file-write* (subpath "<cwd>") (with message "<logTag>"))
(deny file-write* (regex "<glob-converted-regex>") (with message "<logTag>"))
(deny file-write-unlink (literal "/Users/me/.bashrc") (with message "<logTag>"))

Glob 支持:macOS 端原生支持 glob——sandbox-utils.ts 里 globToRegex()**/*.ts 类的 gitignore 风格写法编译为 SBPL 的 (regex ...)。Linux 不行(见下文)。

4.3 命令包装最终形态

bash
env SANDBOX_RUNTIME=1 TMPDIR=/tmp/claude \
    HTTP_PROXY=http://localhost:53201 \
    HTTPS_PROXY=http://localhost:53201 \
    ALL_PROXY=socks5h://localhost:53202 \
    GIT_SSH_COMMAND="ssh -o ProxyCommand='nc -X 5 -x localhost:53202 %h %p'" \
    NO_PROXY=localhost,127.0.0.1,::1,*.local,... \
    sandbox-exec -p '<上面那大段 profile>' /usr/bin/zsh -c '<用户的命令>'

注意:

  • env VAR=val ... sandbox-exec ... 而不是 VAR=val sandbox-exec,因为后者用 shell quote 会很难对付,前者每个 KV 都是独立 argv,靠 shell-quote 包安全转义。
  • binShell 默认走用户的 $SHELL(zsh/bash),让 alias / shell snapshot 在沙箱内仍生效。

4.4 违规事件流式监听(macOS 独有)

macOS 提供了一个非常香的能力:内核 sandbox 拒绝事件会实时打到 system logstartMacOSSandboxLogMonitor() 就 spawn 了一个:

bash
log stream --predicate '(eventMessage ENDSWITH "<sessionSuffix>")' --style compact

每条 deny 都会被解析、关联到引发它的命令(每条命令的 profile 里都嵌了一个 CMD64_<base64-encoded-cmd>_END_<sessionSuffix> 作为 logTag),然后回填到 stderr:

<original stderr>
<sandbox_violations>
deny file-write* /Users/me/.bashrc ...
deny network-outbound to evil.com:443 ...
</sandbox_violations>

这个增强后的 stderr 会反喂给模型,让 Claude 知道哪条规则把它拦了,然后理性地选择放弃或申请 escape hatch。


五、Linux 实现:bubblewrap + 网络命名空间 + seccomp

Linux 比 macOS 复杂得多。Seatbelt 是「描述式策略」,bwrap 是「命令式构造命名空间」,所以要把同样的语义在 bwrap 里搭出来要费一番力气。

5.1 bubblewrap 是什么

bwrap 是 Flatpak 团队做的轻量沙箱,原理是 unprivileged user namespace + mount/PID/net namespace + bind mount。它不依赖 root,不需要内核 LSM,是目前 Linux 上做"非容器但又强隔离"的事实标准。

5.2 Linux 沙箱的三层:bwrap / 代理桥接 / seccomp

┌────────── 宿主机 host ──────────┐
│                                 │
│  Node 主进程                    │
│  ├── HTTP Proxy   (127.0.0.1:Px)│
│  └── SOCKS5 Proxy (127.0.0.1:Py)│
│                                 │
│  socat UNIX-LISTEN:/tmp/claude- │
│        http-XXX.sock,fork ──────┼──→ TCP localhost:Px
│  socat UNIX-LISTEN:/tmp/claude- │
│        socks-XXX.sock,fork ─────┼──→ TCP localhost:Py
└────────────────┬────────────────┘
                 │  (这两个 .sock bind-mount 到沙箱里)

┌────────── bwrap 沙箱 ──────────┐
│  --unshare-net                 │   ← 完全切掉网络命名空间
│  --unshare-pid --proc /proc    │   ← 切掉 PID 命名空间,挂全新 /proc
│  --ro-bind / /                 │   ← 整个根文件系统只读
│  --bind  <cwd> <cwd>           │   ← 仅 cwd 可写
│  --ro-bind /dev/null .bashrc   │   ← 危险文件挂 /dev/null 拒写
│  --tmpfs <denyRead 目录>        │   ← 拒读区域用 tmpfs 遮蔽
│  --bind /tmp/claude-http.sock ...│
│                                │
│  内部 bash -c "                │
│    socat TCP-LISTEN:3128,fork ─┼──→ UNIX-CONNECT:/tmp/claude-http.sock
│    socat TCP-LISTEN:1080,fork ─┼──→ UNIX-CONNECT:/tmp/claude-socks.sock
│    apply-seccomp <bpf-file> sh -c '<user cmd>'
│  "                             │   ← seccomp BPF 拦 socket(AF_UNIX,...)
└────────────────────────────────┘

为什么这么绕?因为 Linux 的 --unshare-net 是「全有或全无」——一旦切了网络命名空间就没有任何网卡,你必须自己造一条「沙箱内 → host 代理」的通道。Anthropic 选的是 Unix Domain Socket bind-mount:把宿主上 socat 监听的 .sock 挂进沙箱,然后再在沙箱内用一个 socat 把 localhost:3128 翻译到这个 .sock。这样沙箱里的 curl 看到的就是普通的 HTTP_PROXY。

5.3 文件系统隔离(generateFilesystemArgs

精彩的几个细节(直接来自源码注释):

  1. 写白名单--ro-bind / / 让根目录只读;然后 --bind <p> <p> 把允许写的路径再改回来。
  2. 不存在路径的拒绝处理(防 mkdir + write 绕过):
    • 找出 deny 路径中第一个不存在的组件
    • 在该位置 --ro-bind /dev/null <component> —— 后续任何 mkdir 都会失败
    • bwrap 退出后会留下 0 字节的 ghost 文件,cleanupBwrapMountPoints() 兜底删掉
  3. Symlink 替换攻击防御
    • findSymlinkInPath() 检查 deny 路径中每个组件是否为 symlink
    • 如果是,挂 /dev/null 占位,攻击者就没法删了 symlink 再重建一个真目录
  4. 拒读用 tmpfs--tmpfs /Users 把 /Users "盖" 成空目录,再 --ro-bindallowRead 里的子目录贴回来。
  5. /etc/ssh/ssh_config.d 强制隐藏:OrbStack 等环境下文件权限会让 SSH 报 "Bad owner",Anthropic 默认遮蔽。
  6. Glob 限制:bwrap 完全不支持 glob(参数必须是真路径)。sandbox-runtime 在 Linux 端要么静默丢弃 glob、要么 expandGlobPattern()fs.readdirSync + 自己实现的 globToRegex 一次性展开成具体路径。这是 Linux 端能力受限的主要短板(UI 上会有 ⚠️ 警告:"Glob patterns not fully supported on Linux")。

5.4 强制 Deny 用 ripgrep 扫描(linuxGetMandatoryDenyPaths

macOS 可以靠 glob 一发命中所有嵌套 .bashrc,Linux 不行——只能实时扫描。所以每条命令前都会跑:

bash
rg --files --hidden --max-depth 3 \
   --iglob .bashrc --iglob .zshrc ... \
   --iglob '**/.git/hooks/**' \
   -g '!**/node_modules/**'

把扫到的路径全部加进 deny 列表,然后逐一 --ro-bind /dev/null <path>。这就是 Linux 端依赖 ripgrep 的原因。mandatoryDenySearchDepth 默认 3,可以调到 1~10——值越大越安全但越慢。

5.5 网络代理桥接(initializeLinuxNetworkBridge

宿主侧两条 socat:

bash
socat UNIX-LISTEN:/tmp/claude-http-<rand>.sock,fork,reuseaddr \
      TCP:localhost:<httpProxyPort>,keepalive

socat UNIX-LISTEN:/tmp/claude-socks-<rand>.sock,fork,reuseaddr \
      TCP:localhost:<socksProxyPort>,keepalive

bwrap 启动时 --bind 把这两个 sock 挂进沙箱;沙箱里的 init 脚本再起两条 socat 把内部 127.0.0.1:3128 / 1080 转过去。环境变量统一注入:

bash
HTTP_PROXY=http://localhost:3128
HTTPS_PROXY=http://localhost:3128
ALL_PROXY=socks5h://localhost:1080
GIT_SSH_COMMAND="ssh -o ProxyCommand='socat - PROXY:localhost:%h:%p,proxyport=3128'"
NO_PROXY=localhost,127.0.0.1,::1,...

域名过滤发生在哪? 在宿主机的 HTTP/SOCKS 代理里。bwrap 本身不懂域名,它只保证「除了这两个 .sock,没有任何出网通道」。

5.6 Unix Socket 阻断 + seccomp BPF

bwrap 的网络隔离是「网络命名空间级别」的——但 AF_UNIX socket 不走网络命名空间,进了同 mnt namespace 的 sock 文件就能通信。如果不堵这一层,恶意命令只要能打到 /var/run/docker.sock/tmp/claude-http.sock 就能逃逸。

Anthropic 的解法是 预编译 seccomp BPF + 自定义 apply-seccomp 启动器

  1. 预编译 BPFvendor/seccomp/{x64,arm64}/unix-block.bpf —— 拦 socket(AF_UNIX, ...)io_uring_setup/enter/register(5.19+ 有 IORING_OP_SOCKET 旁路风险)。
  2. apply-seccomp 二进制(C 源码同包,预编译):
    • unshare(CLONE_NEWUSER|CLONE_NEWPID|CLONE_NEWNS) 嵌套一层命名空间
    • 在新命名空间内做 PID 1(init/reaper),PR_SET_DUMPABLE=0 防 ptrace
    • prctl(PR_SET_NO_NEW_PRIVS) + prctl(PR_SET_SECCOMP, ..., bpf-file)
    • execve(user_command)
  3. 两阶段加载的精妙之处:
    • 先让 socat 在沙箱里启动(它需要 socket(AF_UNIX,...) 才能桥接)
    • 然后 apply-seccomp 把 BPF 装上,再 exec 用户命令——这时候用户命令既不能新建 AF_UNIX socket,也看不到 socat 进程(被嵌套 PID namespace 隔开)
  4. 架构限制:x86_64 / arm64 有预编译;i386 上 socket()socketcall(SYS_SOCKET, ...) 多路复用,BPF 拿不到子调用号——所以官方明确不支持 32 位 x86(避免假装安全)。

5.7 为什么不用 Landlock / SELinux / AppArmor

  • Landlock:内核 5.13+ 才有,且语义偏文件——不能做网络隔离。
  • SELinux/AppArmor:需要 root 写 policy,企业 IT 友好但不适合 CLI 工具默认开启。
  • bwrap:unprivileged user namespace(5.13 之前就稳定)+ mount namespace 已经够强,且零配置。

唯一例外:Ubuntu 24.04+ 默认开启了 kernel.apparmor_restrict_unprivileged_userns=1,会剥夺 user namespace 的 capability——这种情况下 bwrap 和 seccomp 嵌套都会失败,需要用户:

bash
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0

六、网络代理:HTTP/HTTPS + SOCKS5 双通道

6.1 为什么要两个代理?

  • HTTP 代理:处理 HTTP_PROXY/HTTPS_PROXY 协议——CONNECT 隧道走 HTTPS,普通 GET/POST 走透明代理。绝大多数 CLI 工具(curl/wget/git/npm/pip/cargo/...)默认认这两个变量
  • SOCKS5 代理:处理「不认 HTTP_PROXY 的 TCP 协议」——SSH / 数据库连接 / 二进制 RPC。socks5h:// 让 DNS 也走代理,避免 DNS 泄露。

源码里的 generateProxyEnvVars() 一口气下了 20+ 个环境变量,对各种工具都做了适配:

ts
HTTP_PROXY / HTTPS_PROXY / http_proxy / https_proxy / NO_PROXY / no_proxy
ALL_PROXY / all_proxy / FTP_PROXY / ftp_proxy / RSYNC_PROXY
DOCKER_HTTP_PROXY / DOCKER_HTTPS_PROXY
CLOUDSDK_PROXY_TYPE / CLOUDSDK_PROXY_ADDRESS / CLOUDSDK_PROXY_PORT  // gcloud
GRPC_PROXY / grpc_proxy
GIT_SSH_COMMAND      // git over ssh 通过 SOCKS5
SANDBOX_RUNTIME=1    // 给沙箱内进程一个识别标志
TMPDIR=/tmp/claude   // 让所有临时文件落到允许写的目录

6.2 代理层的请求过滤逻辑(http-proxy.ts

ts
server.on('connect', async (req, socket) => {
  const [hostname, portStr] = req.url.split(':');
  const allowed = await options.filter(port, hostname, socket);
  if (!allowed) {
    socket.end('HTTP/1.1 403 Forbidden\r\n'
              + 'X-Proxy-Error: blocked-by-allowlist\r\n\r\n'
              + 'Connection blocked by network allowlist');
    return;
  }
  // 检查 MITM 域名 → 走 Unix socket 上的 mitmproxy
  // 否则直连
  ...
});

filter() 实现:

  1. 先匹配 deniedDomains(黑名单优先)→ 拒
  2. 再匹配 allowedDomains(支持 *.example.com 通配,但 *.com 这种过宽的 Schema 校验时就被拒了)→ 准
  3. 都不命中 → 调用 sandboxAskCallback({ host, port }) 弹 UI 让用户选 Yes / Yes-and-remember / No

6.3 BYO Proxy / MITM 模式

network.mitmProxy.{ socketPath, domains } 让用户接入自己的 mitmproxy(通过 Unix socket 与 sandbox-runtime 通信)。这条通道允许:

  • 解密并审计 HTTPS 流量
  • 实施 path 级别的允许("github.com 但只准访问 /api/v3/repos/own/...")
  • 配合自签 CA + macOS 上的 enableWeakerNetworkIsolation 让 Go 程序认证书

6.4 MITM / 内部 NAT / 企业 SSO 适配

  • httpProxyPort / socksProxyPort:跳过自带代理,让流量走企业 forward proxy。
  • enableWeakerNetworkIsolation(macOS):放开 com.apple.trustd.agent,让 Go 工具(gh、gcloud、terraform、kubectl)能调用系统 Security 框架做 TLS 验证。安全代价:trustd 自身可能被滥用做数据外传,仅在确实需要时打开。

七、执行流程:一次 Bash("npm install") 的完整旅行

模型决策


BashTool.tsx
   │ 1) shouldUseSandbox(input)
   │    ├─ SandboxManager.isSandboxingEnabled()?
   │    ├─ input.dangerouslyDisableSandbox && allowUnsandboxed?
   │    └─ containsExcludedCommand(input.command)? (docker / npm test:* / ...)

Shell.exec(...)
   │ 2) const wrapped = await SandboxManager.wrapWithSandbox(cmd, binShell, undefined, abortSignal)

sandbox-adapter.ts → BaseSandboxManager (sandbox-runtime)

   │ 3) 等待 initializationPromise(HTTP/SOCKS 代理 + Linux 桥接早就起好了)

   │ 4) 平台分流:
   │    ├─ macOS:动态生成 Seatbelt profile → 拼出 'env ... sandbox-exec -p ... zsh -c ...'
   │    └─ Linux:构造 bwrap 参数 + 内层 socat 启动脚本 + apply-seccomp 包装


spawn(/bin/sh, ['-c', wrapped], { cwd, env, stdio })


   ┌─────────── 受限子进程 ───────────┐
   │  npm install 的子进程树         │
   │  ├─ 网络请求 → HTTP_PROXY      │ → host proxy 检查域名 → registry.npmjs.org ✓
   │  ├─ git clone via ssh → SOCKS5 │ → 黑名单/白名单
   │  ├─ 写 ./node_modules/         │ ✓ 落到 cwd 内
   │  ├─ 写 ~/.bashrc (恶意脚本)    │ ✗ EPERM
   │  └─ curl evil.com              │ ✗ 403 by proxy
   └─────────────────────────────────┘


Shell.ts.then(result => {
  SandboxManager.cleanupAfterCommand()    // 清理 bwrap 留下的 ghost 文件 + scrubBareGitRepoFiles
  // ↓ 把违规事件回填到 stderr
  result.stdout = SandboxManager.annotateStderrWithSandboxFailures(cmd, result.stdout)
})


模型看到的输出:
   <最后几行 stdout>
   <sandbox_violations>
   deny file-write* /Users/me/.bashrc
   deny network-outbound to evil.com:443
   </sandbox_violations>

八、Escape Hatch(逃生口):dangerouslyDisableSandbox

不是所有命令都能在沙箱里跑:

  • docker:本身就是命名空间魔法,跟 bwrap 互斥
  • watchman:要监控沙箱外的目录
  • 某些 IDE 集成:要写 ~/.vscode/

Anthropic 给了三档解法:

  1. excludedCommands(用户白名单):在 settings.json["docker:*"],匹配上就完全跳过沙箱。注意官方原话:"这不是安全边界,只是用户体验"。
  2. 模型主动申请 dangerouslyDisableSandbox: true:BashTool 的 input schema 暴露这个字段,模型遇到沙箱失败可以重试时打开它——但会重新走 Permission Prompt 让用户审批
  3. allowUnsandboxedCommands: false:硬关 escape hatch。dangerouslyDisableSandbox 字段会被无视,命令必须沙盒化或在 excludedCommands 里。

源码里很有趣的细节:BashTool 的 inputSchemalazySchema() 动态生成的,并且 _simulatedSedEdit 这个内部字段被刻意从 model-facing schema 里删掉,防止模型 "用 sed 包装写文件" 偷渡过沙盒检查(注释原文:"Exposing it in the schema would let the model bypass permission checks and the sandbox by pairing an innocuous command with an arbitrary file write.")。


九、与 Permission System 的关系

                  ┌──────────────── Tool Layer ───────────────┐
                  │ Read / Edit / Write / WebFetch / MCP / ... │ ← Permission Rules
                  │ Bash                                       │   - allow/deny/ask
                  └──────────────────┬─────────────────────────┘
                                     │ Bash 工具单独再包一层

                  ┌──────────────── Sandbox Layer ────────────┐
                  │  OS-level enforcement (Seatbelt / bwrap)  │ ← Sandbox Config
                  │  作用范围:bash 进程及其全部子进程         │   - filesystem
                  │                                           │   - network (proxy)
                  └────────────────────────────────────────────┘

重要陷阱(来自第三方分析 claudecodecamp):

  • sandbox.filesystem.denyRead 只挡 Bash 走 catvim 等工具读文件。
  • Claude 自己的 Read 工具走 Permission Layer——必须同时permissions.denyRead(...) 才能完整拒读。
  • 同理:WebFetch(domain:...) deny 与 sandbox.network.deniedDomains 是 Read 工具与 Bash 工具两条管线的对应配置。

官方的 Settings 合并语义是「跨多 scope 数组合并而不是覆盖」——管理员通过 policy 设置的 allowWrite: ["/opt/company-tools"] 会和用户 settings 的 allowWrite: ["~/.kube"] 合并出最终允许列表。


十、安全边界清单(What sandbox does / doesn't)

✅ 沙箱能挡住

  • 在 cwd 外写文件(包括所有子进程衍生的写)
  • 访问未列入白名单的域名(HTTP/HTTPS/任意 TCP)
  • 修改 .bashrc.zshrc.gitconfig.git/hooks 等触发执行的敏感文件
  • 在 Linux 上创建任意 AF_UNIX socket 做 IPC
  • 域名拼写绕过(Seatbelt/bwrap 的 deny 是 OS 层硬阻断)

⚠️ 沙箱不能保护的

  • Read/Edit/Write 工具走应用层:只有 Bash 子进程被沙盒。要拒读 SSH key,必须两层都配。
  • 域名 fronting / 共享 CDN:允许 github.com 也意味着 attacker 能往任意 GitHub 仓库 push 数据。
  • allowUnixSockets: ["/var/run/docker.sock"]:Docker socket 等同于 root,等于完全绕过沙箱。
  • allowWrite 包含 $PATH 目录:可以放后门可执行文件。
  • enableWeakerNestedSandbox(Linux):在非特权 Docker 里跑沙箱必须打开,但安全性显著降低。
  • MCP server 不天然在沙箱内:要靠 srt npx -y server-foo 这种方式手动包装。
  • Computer Use(让 Claude 控屏幕)完全不在 sandbox 内:那是另一条赛道。

十一、与同类方案对比

维度Claude Code SandboxCodex CLI SandboxDevcontainerDockergVisor / Firecracker
隔离强度中-强(OS Primitive)中(macOS sandbox-exec / Linux Landlock)强(完整 VM/容器)极强
启动延迟~10ms~10ms秒级秒级秒~分钟
配置粒度域名/路径路径为主全镜像全镜像全镜像
跨平台macOS / Linux / WSL2macOS / Linux跨 OS跨 OSLinux
用法心智修改 settings.jsonCLI flag 或 config整套环境配置Dockerfile复杂
网络过滤域名级(HTTP+SOCKS)通常关闭自定义自定义自定义
场景AI 代码代理(轻量、按命令)AI 代理(轻量)长开发会话服务部署多租户 / 不可信代码

Anthropic 选 Sandbox 而不是默认 "永远跑在 Docker 里",是出于 延迟(每条 bash 都包一次容器太慢)+ 兼容性(Mac 用户用得上)+ 透明度(沙箱内能看到宿主真实文件结构) 的综合考虑。


十二、关键启示与设计模式

如果要在自己的 Agent 上做同样的事情,从 Claude Code 这套实现里能抽出几个值得借鉴的模式:

  1. 双向不对称的 allow/deny 优先级:读「允许全部、按需拒绝、再放回来」;写「拒绝全部、按需放行、再封死要害」。是 90% 的现实需求最简单的表达。
  2. Mandatory deny 永远生效:不管用户怎么配,.bashrc / .git/hooks / 沙箱自身的 settings 必须封死。这是「用户不能配置出一个不安全的状态」的兜底。
  3. OS 原语优先:能用 Seatbelt 的不要在 Node 层做检查;能用 namespace 的不要拦 syscall——尽量把策略下沉到内核,让 ASLR/MAC 做事。
  4. Network proxy 放在沙箱外:只允许出站走代理,比写 firewall 规则简单得多——还能复用熟悉的 mitmproxy 生态。
  5. Violation log 反喂模型:当沙箱拒了一个动作,要让 Agent 看见原因("deny file-write* /Users/me/.bashrc"),它才能聪明地选择 escape hatch、改方案或放弃,而不是盲目重试。
  6. Escape hatch 但要走人工审批:把"逃生口"暴露给模型很重要(否则它会被 false negative 卡死),但每次逃生必须经过用户。
  7. 拥抱「漏一定会有」的现实:明确写死 docker/watchman 不兼容;通过 BYO proxy 让企业用户能自己加 MITM;把局限性写到文档第一段——这种诚实是企业落地的关键。
  8. Settings 合并而不是覆盖:让 Admin 和 User 各自加规则,最终是并集——既方便企业策略,又不剥夺用户自由。

十三、参考资料