练习 23:沙箱——把 bash 关进笼子
练习 9 给了 bash 一道权限闸门,它检查的是命令字符串:rm -rf /
匹配上 deny 规则,拦下;没匹配上的,问一句人或者直接放行。这道闸门有一个
结构性的漏洞——它只认得它见过的字符串。python -c '...' 里那行 Python 会
做什么,规则看不见;一个你没见过的二进制、一段套了三层引号的命令、一个
被 prompt 注入诱导出来的“看起来无害“的命令,都可能从 allow 那一档大摇大摆
走过去。字符串匹配管的是“这条命令看起来像什么“。
这一章加第二道边界,管“这条命令实际做成了什么“:-sandbox 开启后,
bash 跑的每一条命令——权限系统放没放行、人批没批准,都不影响——在操作
系统层面就只能写工作目录和临时目录、读不到家目录下的密钥、连不上网。
敲进去
在练习 22 的代码上继续写。先是笼子的形状:
// ---- 沙箱层:OS 强制的执行边界 ----
// sandboxPolicy 描述一个笼子的形状:哪些目录能读、哪些能写、能不能上网。
// 根目录授权是"这个目录以及它下面的一切"。注意这里没有"允许读 ~/.ssh"
// 的选项——密钥目录被排除不是碰巧,是这个类型存在的理由。
type sandboxPolicy struct {
readRoots []string
writeRoots []string
allowNetwork bool
}
// activeSandbox 非 nil 时,每一条 bash 命令都在笼子里跑。默认 nil——
// 沙箱是显式开启的(-sandbox),不是默认值。原因在"网络"这一刀上:
// 断网是全有全无的开关(见 buildSandboxProfile),默认开沙箱等于默认
// 弄坏一切要联网的命令(go mod download、git fetch、brew install),
// 权限系统 + 人工确认才是常开的那道闸。
var activeSandbox *sandboxPolicy
// defaultSandboxPolicy 是标准笼子:可写的只有工作目录和临时目录;可读的
// 加上系统目录(跑普通命令要用的工具链、动态库、配置都在里面);网络
// 关闭。家目录整体不在可读名单里——~/.ssh、~/.aws、~/.config 这些密钥
// 重灾区因此碰不到,这正是要保护的东西。
func defaultSandboxPolicy() sandboxPolicy {
tmp := os.TempDir()
return sandboxPolicy{
readRoots: []string{workDir, tmp,
"/usr", "/bin", "/sbin", "/etc", "/var", "/private", "/System", "/Library", "/opt"},
writeRoots: []string{workDir, tmp},
allowNetwork: false,
}
}
// sandboxAvailable 报告这台机器能不能强制执行沙箱。本章的实现用 macOS
// 自带的 sandbox-exec;Linux 上 octo 用的是内核的 Landlock + seccomp,
// 实现要多一层自我重执行的技巧,本书不展开。
func sandboxAvailable() bool {
if runtime.GOOS != "darwin" {
return false
}
_, err := os.Stat("/usr/bin/sandbox-exec")
return err == nil
}
笼子的规则要翻译成 macOS 能执行的形式。SBPL 是一种括号风格的小语言,
sandbox-exec 读它:
// buildSandboxProfile 把 policy 翻译成 macOS 沙箱的规则语言(SBPL,一种
// 括号风格的小语言)。底座是 allow default——全默认禁止的配置会让普通
// 程序连动态库都加载不了,根本跑不起来;在放行的底座上,只收紧我们
// 关心的三个口子:
//
// - 写:先全部禁止,再放行 writeRoots(后写的、更具体的规则赢),
// 外加几个命令普遍要碰的设备文件(/dev/null 这类)
// - 读:把整个家目录禁掉,再放行 readRoots——系统路径本来就在
// allow default 里,这一刀专门保护家目录下的密钥
// - 网:一刀切断,除非 allowNetwork
//
// 路径先解析符号链接再写进规则:macOS 的 /tmp 实际是 /private/tmp 的
// 链接,内核检查的是真实路径,规则里写链接路径等于没写。
func buildSandboxProfile(p sandboxPolicy) string {
resolve := func(path string) string {
if real, err := filepath.EvalSymlinks(path); err == nil {
return real
}
return path
}
subpaths := func(roots []string) string {
var parts []string
for _, r := range roots {
parts = append(parts, fmt.Sprintf("(subpath %q)", resolve(r)))
}
return strings.Join(parts, " ")
}
var b strings.Builder
b.WriteString("(version 1)\n")
b.WriteString("(allow default)\n")
b.WriteString("(deny file-write*)\n")
b.WriteString("(allow file-write* " + subpaths(p.writeRoots) + ")\n")
b.WriteString(`(allow file-write* (literal "/dev/null") (literal "/dev/tty") (literal "/dev/stdout") (literal "/dev/stderr"))` + "\n")
if home, err := os.UserHomeDir(); err == nil && home != "" {
b.WriteString(fmt.Sprintf("(deny file-read* (subpath %q))\n", resolve(home)))
b.WriteString("(allow file-read* " + subpaths(p.readRoots) + ")\n")
}
if !p.allowNetwork {
b.WriteString("(deny network*)\n")
}
return b.String()
}
然后是这一章最重要的五行——唯一的那扇门:
// shellCommand 是全 harness 唯一一处把命令字符串变成 shell 进程的地方。
// 沙箱开着就包一层 sandbox-exec,关着就是原来那行 sh -c。以后任何新的
// 执行路径(后台任务、别的要跑命令的工具)都必须从这扇门走——笼子只有
// 装在唯一的门上才算数,多一个绕开它的调用点,边界就不成立了。
func shellCommand(ctx context.Context, command string) *exec.Cmd {
if activeSandbox != nil {
profile := buildSandboxProfile(*activeSandbox)
return exec.CommandContext(ctx, "/usr/bin/sandbox-exec", "-p", profile, "/bin/sh", "-c", command)
}
return exec.CommandContext(ctx, "sh", "-c", command)
}
bashTool.execute 里那行 exec.CommandContext(ctx, "sh", "-c", in.Command)
换成 shellCommand(ctx, in.Command)。
main() 开头认这个开关,机器给不了沙箱就拒绝启动:
if len(args) >= 1 && args[0] == "-sandbox" {
args = args[1:]
if !sandboxAvailable() {
// 要了沙箱又给不了,就明确拒绝启动——降级成"假装有沙箱"
// 比没有沙箱更危险:你以为有边界,其实没有。
fmt.Fprintln(os.Stderr, "错误: 这台机器提供不了 OS 级沙箱(本章实现只支持带 sandbox-exec 的 macOS),拒绝在没有边界的情况下假装有边界地运行")
os.Exit(1)
}
p := defaultSandboxPolicy()
activeSandbox = &p
fmt.Fprintf(os.Stderr, "[沙箱开启:可写 %v,家目录不可读(工作目录和临时目录除外),网络关闭——OS 强制,批准了也越不出去]\n", p.writeRoots)
}
别忘了 import 里加 "runtime"。
跑起来
go build -o ex23 .
先不带模型,直接验证边界。危险的操作要先用代码确认拦得住,再交给 模型——这是练习 9 就定下的规矩。
写一个 sandbox_smoke_test.go:
func runSandboxed(t *testing.T, command string) (string, error) {
p := defaultSandboxPolicy()
activeSandbox = &p
defer func() { activeSandbox = nil }()
cmd := shellCommand(context.Background(), command)
cmd.Dir = workDir
out, err := cmd.CombinedOutput()
return string(out), err
}
func TestSandboxBlocksHomeWrite(t *testing.T) {
home, _ := os.UserHomeDir()
target := filepath.Join(home, "smoke-should-fail.txt")
defer os.Remove(target) // 万一真写成功了,别留垃圾
out, err := runSandboxed(t, "echo pwned > "+target)
if err == nil {
t.Fatalf("家目录写入应当被拦: out=%q", out)
}
if _, statErr := os.Stat(target); statErr == nil {
t.Fatalf("文件不应该存在——命令报错但文件写成了?")
}
}
照这个样子再写几个:工作目录内写入应当成功、临时目录写入应当成功、
读家目录文件应当被拦、curl 应当被拦。还要写一个对照组——不开沙箱时
家目录写入畅通无阻,证明拦住那几个的是沙箱,不是别的什么碰巧失败:
func TestNoSandboxControl(t *testing.T) {
activeSandbox = nil
home, _ := os.UserHomeDir()
target := filepath.Join(home, "smoke-control.txt")
defer os.Remove(target)
cmd := shellCommand(context.Background(), "echo control > "+target)
cmd.Dir = workDir
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("不开沙箱时家目录写入应当成功: err=%v out=%q", err, out)
}
}
go test -v -run 'TestSandbox|TestNoSandbox' -count=1 .
然后带模型跑。 造一个假的“密钥“文件(真的密钥不要拿来做实验):
echo 'EX23-FAKE-SECRET-VALUE' > ~/ex23-secret-demo.txt
实验一,让模型正常干活,顺便撞一次墙:
./ex23 -sandbox "在当前工作目录建一个 notes.txt,写进去一行'沙箱内正常工作',然后用 cat 读出来确认。再试着把同样一行写到我的家目录 ~/ex23-should-fail.txt,如实汇报这次成功还是失败、报错原文是什么。"
实验二,把练习 22 接的 MCP 服务器指向家目录,两条路径读同一个文件:
cat > mcp.json << EOF
{
"mcpServers": {
"home": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "$HOME"]
}
}
}
EOF
./ex23 -sandbox "请做两件事,两件都要做,失败也如实汇报:(1) 用 bash 执行一条命令,命令原文必须严格就是 cat $HOME/ex23-secret-demo.txt ——不要加分号、不要加 echo、不要加任何其他内容,就这一条;(2) 用名叫 home 的 MCP 服务的工具读同一个文件。最后告诉我两条路径分别成功还是失败、各自看到了什么、失败的报错原文是什么。"
跑完把假密钥文件删掉:rm ~/ex23-secret-demo.txt。
你应该看到什么
冒烟测试:边界先在代码层面立住
三种语言都真机跑过全部六个用例,全部通过(Python 版):
test_allows_cwd_write ... ok
test_allows_tmp_write ... ok
test_blocks_home_write ... ok
test_blocks_secret_read ... ok
test_blocks_network ... ok
test_no_sandbox_control ... ok
Ran 6 tests in 0.168s
OK
JavaScript 版(node --test)同样六个全过:
✔ sandbox allows cwd write
✔ sandbox allows tmp write
✔ sandbox blocks home write
✔ sandbox blocks secret read
✔ sandbox blocks network
✔ no-sandbox control: home write succeeds
tests 6, pass 6, fail 0
三条禁令都是操作系统给的回答,不是我们的代码在演。
实验一:正常干活不受影响,越界当场被按住
Python 版真机结果:
[沙箱开启:可写 [...],家目录不可读(工作目录和临时目录除外),网络关闭——OS 强制,批准了也越不出去]
[round 1] bash({"command": "pwd && echo '沙箱内正常工作' > notes.txt && cat notes.txt"})
[round 4] bash({"command": "echo '沙箱内正常工作' > ~/ex23-should-fail.txt; ..."})
## 1. 工作目录内建文件 ✅ 成功
## 2. 写家目录 ~/ex23-should-fail.txt ❌ 失败
报错原文(来自 shell,exit status 1):
/bin/sh: /Users/.../ex23-should-fail.txt: Operation not permitted
这次真机测试里,模型自己还分清楚了两层拒绝的区别:拼接在一起的组合
命令先撞上了练习 9 的人工确认闸(非交互下默认拒绝,报“权限拒绝——
用户没有批准这条命令“),拆成单条命令之后 echo > ~/... 才真正执行、
被内核以 Operation not permitted 拒绝——它在汇报里如实区分了“命令
没获批准“和“命令执行后被系统拒绝“这两件不同的事。JavaScript 版跑
同一个任务,5 轮内干净完成,同样拿到 Operation not permitted。
本机 qwen3:4b-instruct 两种语言都跑过同样的边界测试:工作目录内
写入和读取都成功,写家目录也拿到同一句 Operation not permitted
或触发权限闸——但 JavaScript 那次真机测试里,qwen3:4b-instruct 把
练习 9 的人工确认闸拒绝(因为它把命令拼成了 touch && echo,落进
需要审批的那一档,非交互环境下自动拒绝)误判成了“沙箱环境可能限制了
写入权限“——它的结论方向是对的(这次写入确实没有成功),但对拒绝
原因的归因是错的:这次真正拦住它的是权限系统的审批闸,命令根本没有
执行到沙箱那一层。这正是 OS 级边界和策略层规则的区别:沙箱不需要
被理解就能生效,但模型对“为什么失败“的解释不一定可靠——如实汇报
“失败了“是它能做到的,猜对“为什么失败“是另一回事。
实验二:同一个文件,两条路径两种命运
Python 版真机结果(真实调用官方 npm 包 @modelcontextprotocol/ server-filesystem):
[round 1] bash({"command": "cat /Users/.../ex23-secret-demo.txt"})
[round 1] mcp__home__read_text_file({"path": "/Users/.../ex23-secret-demo.txt"})
**(1)bash 执行 cat ... —— 失败**
cat: /Users/.../ex23-secret-demo.txt: Operation not permitted
**(2)用 home MCP 服务读取同一文件 —— 成功**
EX23-FAKE-SECRET-VALUE
JavaScript 版同一个任务跑出了逐字对应的结果——bash 路径被 OS 拒绝,
MCP 路径成功读到假密钥。这条 cat 是权限系统放行的(命令里没有
shell 拼接符号,不在需要审批的那一档),它照样没读到内容——沙箱在
权限系统之后,是独立的第二道关。这正是这一章想证明的:允许 ≠ 做得到。
发生了什么
这一章画的是“执行边界“,而 tool 的执行边界本身就是 tool 设计的一部分。
练习 9 讲过一次同样的话:什么时候能执行,和能执行什么,是一体两面。
现在多了一层——同一个 bash 工具,装在笼子里和不装,是两个不同的工具,
尽管它的 schema 一个字都没变。模型看到的声明一样,能做成的事不一样。
三种语言的实现完全印证了这一点:shellArgv/shell_command/
shellCommand 是全 harness 唯一一处把命令字符串变成子进程的地方,
沙箱开着还是关着,从这一个函数的返回值分叉,BashTool/bashTool
本身不需要知道笼子的存在。
权限系统和沙箱是两道关,不是一道关的两种实现。 权限系统在策略层
(要不要允许这个字符串)判断,它的优势是可以问人、可以按意图区分。
沙箱在执行层(这个进程碰得到什么)判断,它不理解意图,但它不会被
绕过:命令怎么套引号、调什么解释器,都逃不出内核给的那副镣铐。两者
互补:策略层问“该不该“,执行层保证“能不能“。实验二那条被放行却读不到
内容的 cat,就是两道关各司其职的样子——三种语言的真机测试结果逐字
一致,因为这条边界完全由操作系统而不是由 harness 的语言来保证。
边界只有装在唯一的门上才算数。 这个函数存在的全部意义,就是让
“把字符串变成进程“在整个 harness 里只有一个出口。现在 bash
工具从这里走;后面的章节要加后台任务,它也必须从这里走——如果后台
任务自己另写一处进程调用,沙箱对它就是不存在的,而使用者不会知道。
这类边界最常见的失效方式不是被攻破,是被绕过:多一个调用点,就多
一个洞。三种语言各自唯一的执行路径函数(Go 的 shellCommand、
Python 的 shell_command、JavaScript 的 shellArgv)都是“唯一门”
这条纪律的直接体现,跟具体用哪个系统调用无关。
沙箱是显式开启的,这不是偷懒,是网络这一刀太钝。 断网是全有全无:
(deny network*) 之后所有要联网的命令全废。想做“只允许连这几个域名“
做不到——macOS 的规则语言按 IP 和端口过滤,不认域名。所以只有一个
开关,默认关。常开的那道闸仍然是权限系统加人工确认。
要不到就明确拒绝,不要降级。 机器给不了沙箱时,程序直接退出,
不是“打个警告然后照常跑“。理由:用户开 -sandbox 是要一个保证,
给不了保证还继续跑,等于让他带着一个不存在的安全感干活——那比一开始
就没有沙箱更危险。三种语言的 sandboxAvailable/sandbox_available
判断逻辑完全一致:非 macOS,或者 macOS 但没有 sandbox-exec,一律
拒绝启动。
模型对失败原因的解释,不是边界本身的一部分。 JavaScript 版 qwen3:4b-instruct 那次真机测试里,模型把权限闸的拒绝错误归因成了 沙箱——这说明一点:这一章验证的是“命令实际能不能做成某件事“,不是 “模型知不知道自己为什么被拦住”。哪怕模型的归因是错的,边界本身仍然 生效——这跟 OS 级边界“不需要被理解就能生效“这句话是一体两面。
常见问题
- 沙箱管得住
write_file和edit_file吗:管不住,三种语言都 实测过——开着沙箱让模型用write_file往家目录写文件,成功了, 文件真的躺在那里。原因很直白:那两个工具是 harness 进程里的代码, 直接调用文件系统 API,而受约束的是 bash 起的子进程,不是 harness 自己。这不是这一章的疏漏,是这一章边界的准确形状:沙箱 保护的是“任意命令“这个面,那才是不可预测的部分;write_file能做什么是你自己写的代码说了算,该在那里加限制就在那里加。 - 那 MCP 工具呢:同样管不住,实验二演示得很清楚,三种语言结果
一致。MCP 服务器是我们启动的另一个子进程,走的不是
shellCommand那扇门。练习 22 结尾说过“MCP 工具绕过了权限系统“,现在可以把话说 完整:它也在沙箱之外。 - 沙箱之外的写入被拦了,我的文件还在吗:在。被拦的是写入动作
本身,
Operation not permitted意味着这次打开文件就没成功,不存在 “写了一半”——这和练习 10 的误删保护是两件事。 - 可以只放开某个目录吗:可以,
default_sandbox_policy/defaultSandboxPolicy里的两个 roots 就是给你改的。 - 沙箱能挡住内核漏洞吗:不能。这是纵深防御的一层,不是绝对屏障。 它的目标是“一条被放行的命令不该能读走你的密钥“,不是“抵御一个专门 针对内核的攻击“。
加分练习
- 给沙箱策略加命令行参数:
-sandbox-write <目录>(可重复)、-sandbox-allow-net。加完用-sandbox不带-sandbox-allow-net跑一次联网命令,再带上跑一次,亲眼看看那一刀切在哪。 - 让 MCP 服务器也进笼子:改启动 MCP 服务器的那个函数,把子进程也
包一层
sandbox-exec。做之前先预测哪些服务器会因此坏掉(提示: 练习 22 里那个 filesystem 服务器如果指向工作目录之外,还能工作吗), 跑完对照你的预测。 - 给
write_file/edit_file加一道路径检查:目标路径不在写入白名单 里就拒绝。写完想一想,为什么这道检查和沙箱不是重复劳动——提示: 一个防的是你自己的代码,一个防的是别人的代码。 - 把生成的那份规则打印出来,读一遍那几行 SBPL,然后手动删掉
(deny network*)那行重新跑curl——用最小的改动确认每一条规则 确实各自在起作用。