如果你在其他平台看到这篇文章,这可能不是最终版本。为了获得更好的阅读体验(包含最新的评论讨论和勘误),欢迎移步原文

正如之前所提,我为适配 ClaudeCode 专门配置了一套沙盒体系。最近,我打算借此机会系统地梳理一下这套环境目前的搭建现状与进展。

如果从一个完整的视角来看,这套体系其实应该分为沙盒、宿主机以及本地机器。

宿主机

我的宿主机是一台 MacOS 设备。也正是如此,其实不太需要过分担心最近 Linux 内核连发的那些 CVE 漏洞,因此直接选用 Docker 作为沙盒方案。

在宿主机层面,主要做了两项核心配置:网络代理与目录挂载。

代理网络分流

为了更好地适配 Claude Code 的请求,我对网络进行了针对性的分流处理,具体策略如下:

  • 常规分流:根据预设规则,对常见域名进行基础分流。
  • 定向代理:将 Anthropic 相关域名精准路由至固定节点,保证 API 连通性。
  • 沙盒兜底:针对沙盒所在的 IP 网段设置特殊规则作为兜底,将其流量同样指向特定的节点。

目录挂载

起初,为了让沙盒重启后状态不丢失,我直接把整个 Home 目录挂载到了宿主机。但随之带来的问题就是:里面那一堆没用的缓存文件(比如 .cache 等)也会被全盘同步出来,看着非常臃肿。

所以后面我调整了一下思路:不再把“持久化”简单理解成把整个 Home 目录原样搬出来,而是按语义拆成几类目录:

  • /workspace:对应项目工作区,用来放日常开发的 Repo。
  • /home:对应用户 Home,主要保存工具配置、认证状态、会话数据等。
  • /state:保存更偏“运行状态”的内容,比如 Shell 扩展、环境变量、XDG data/state、history。
  • /tool-bin:专门保存可执行工具,并进一步区分 Host 管理的 managed 和 Agent 自行安装的 user

这样做的好处是边界会清楚很多。配置和登录态得以保留,但像 .claude/cache.codex/.tmp 这类高频变化、随时可重建的内容,就不会落到宿主机上污染环境了。

这个项目还支持一种“自举模式”,即把沙盒项目本身挂进容器里的 /self。这样 Agent 可以在沙盒内直接修改构建沙盒自己的代码(比如 Dockerfilecompose.yaml 等)。为了避免自举变成自我提权,/self 下的一些核心配置文件会被刻意遮住,防止 Agent 乱改导致安全问题。

沙盒

沙盒本身并不是一个单独的容器,而是一组容器协作完成的环境。主要包括四个容器:

  • sandbox:Agent 真正运行的地方。它默认使用只读根文件系统,关闭 Passwordless Sudo,启用 no-new-privileges,并 Drop 掉多余的容器能力。真正允许写入的地方,只有上面提到的几个挂载目录和 tmpfs。
  • proxy:负责网络与认证网关。sandbox 并不直接接外网,而是共享 proxy 的网络命名空间。我们在 proxy 里用 iptables 把 80/443 流量透明重定向到 Squid 出网。这样就可以配置黑名单,比如拦截 GitHub API 直连,迫使 Agent 乖乖走 MCP 接口。此外,proxy 还负责持有 SSH 私钥并启动 ssh-agent,为沙盒内的 Git 提供 SSH 认证 Relay。
  • mcp-gateway:负责承载更高权限的外部能力。比如 GitHub PAT 绝对不会注入到 sandbox,而是只给 mcp-gateway。沙盒内需要查代码时,必须通过 http://mcp-gateway:8080/servers/github/mcp 去调用。
  • autoheal:辅助容器,用来自动重启状态异常的组件。

预想与妥协:目前的 SSH 转发方案其实还存在一定的缺陷——SSH Key 本身权限过大,可以访问所有的仓库。更合理(也更麻烦)的方式,应该是使用 Machine User 的 Key 或者为每个项目单独配置 Deploy Key。

本地机器

我的日常本地机器是 Windows,通过 SSH 连到 Mac 宿主,再去控制跑在里面的 Docker 沙盒。这漫长的链路带来了不少奇葩的体验问题。

这里我选用了老牌的 WezTerm 作为终端,主要是因为它配置极度灵活,遇到不爽的地方直接写 Lua 脚本糊上去就完事了。

打通跨端图片剪贴板

发现用 Codex 时没法直接贴图?这其实是因为 Windows 的剪贴板无法穿透 SSH 直接送到 Docker 里的 X11 剪贴板。

既然直接贴不行,那就把链路拆解:Windows 读取剪贴板 -> 转 PNG -> SCP 传到 Mac -> 启动 Xvfb 模拟 X11 -> 注入 Codex 容器

首先,在 Windows 端的 WezTerm 绑定 F10,调用 PowerShell 脚本抓取图片并传输:

-- config/bindings.lua
table.insert(keys, {
key = 'F10',
mods = 'NONE',
action = wezterm.action_callback(function(window, _pane)
local ok, stdout, stderr = wezterm.run_child_process({
'powershell.exe', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Sta',
'-File', wezterm.config_dir .. '/scripts/sync-image-clipboard-to-mac.ps1',
'-HostAlias', 'Mac',
})
local message = trim(ok and stdout or stderr)
if message == '' then
message = ok and 'Sent Windows clipboard image to X11 clipboard.' or 'Failed to send Windows clipboard image.'
end
window:toast_notification('Clipboard image', message, nil, 4000)
end),
})

传输脚本负责把图抠出来存成 PNG,再塞到远端的 State 目录:

# scripts/sync-image-clipboard-to-mac.ps1
if (-not [System.Windows.Forms.Clipboard]::ContainsImage()) {
throw 'Windows clipboard does not contain an image.'
}
$image = [System.Windows.Forms.Clipboard]::GetImage()
$image.Save($localPath, [System.Drawing.Imaging.ImageFormat]::Png)
$stateTarget = ('{0}:{1}' -f $HostAlias, $statePath)
Invoke-NativeCommand -FilePath 'scp' -Arguments @($localPath, $stateTarget)

接着,在远端启动一个 Xvfb 容器模拟显示器,并将 DISPLAY 环境变量(如 127.0.0.1:99)注入目标沙盒。最后在容器内用 Python 脚本接管 X11 剪贴板:

# scripts/x11-image-clipboard.py
if target in (self.atoms["image/png"], self.atoms["PNG"]):
self._change_property(requestor, prop, target, 8, self.png)
return True

修复 Shift+Enter 交互吞键问题

在使用交互式工具时,Shift+Enter 经常被终端链路吞掉,导致本想换行却直接发送了未完成的指令。

本地 WezTerm 把按键映射为专门的 ANSI 转义序列(CSI u),远端 Claude Code 再把这个序列识别为换行。

本地端绑定专属模式:

-- config/bindings.lua
{
key = 'F9',
mods = 'NONE',
action = act.ActivateKeyTable({ name = 'agent', one_shot = false }),
}
-- ... 在 claude_code table 中 ...
{ key = 'Enter', mods = 'SHIFT', action = act.SendString '\u{1b}[13;2u' },

远端配置 Claude 识别该行为:

// ~/mnt/agent-sandbox/runtime/home/.claude/keybindings.json
{
"bindings": [
{
"context": "Chat",
"bindings": {
"shift+enter": "chat:newline"
}
}
]
}

穿透 SSH 的系统弹窗通知

我们在本地跑 Codex 的时候,如果出现一些 Approval Request,是会出现一个系统弹窗提醒告知我们的,但是在远程环境,这套机制失效了。

为此,我们利用 Claude Code 提供的 Hooks 机制,让它在状态变更时主动呼叫外部脚本,通过 OSC序列把状态穿透 SSH 发回给本地 WezTerm。

配置 Hook 触发:

// ~/mnt/agent-sandbox/runtime/home/.claude/settings.json
{
"hooks": {
"UserPromptSubmit": [ { "hooks": [ { "type": "command", "command": "sh /home/node/.claude/hooks/wezterm-hook.sh prompt-submit" } ] } ],
"Stop": [ { "hooks": [ { "type": "command", "command": "sh /home/node/.claude/hooks/wezterm-hook.sh stop" } ] } ],
"Notification": [ { "hooks": [ { "type": "command", "command": "sh /home/node/.claude/hooks/wezterm-hook.sh notification" } ] } ]
}
}

写个看门狗脚本,超过十分钟没动静就发 Terminal Bell 并弹窗:

# ~/mnt/agent-sandbox/runtime/home/.claude/hooks/wezterm-watchdog.sh
if [ "$elapsed" -ge 600 ] && [ ! -f "$toasted_file" ]; then
osc_progress 2 0
osc_toast "Claude 还在跑 10m+"
osc_bell
touch "$toasted_file" "$escalated_file"
elif [ "$elapsed" -ge 180 ] && [ ! -f "$escalated_file" ]; then
osc_progress 4 0
touch "$escalated_file"
fi

附录

项目地址:https://github.com/moesin-lab/agent-sandbox