Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

练习 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_fileedit_file:管不住,三种语言都 实测过——开着沙箱让模型用 write_file 往家目录写文件,成功了, 文件真的躺在那里。原因很直白:那两个工具是 harness 进程里的代码, 直接调用文件系统 API,而受约束的是 bash 起的子进程,不是 harness 自己。这不是这一章的疏漏,是这一章边界的准确形状:沙箱 保护的是“任意命令“这个面,那才是不可预测的部分;write_file 能做什么是你自己写的代码说了算,该在那里加限制就在那里加。
  • 那 MCP 工具呢:同样管不住,实验二演示得很清楚,三种语言结果 一致。MCP 服务器是我们启动的另一个子进程,走的不是 shellCommand 那扇门。练习 22 结尾说过“MCP 工具绕过了权限系统“,现在可以把话说 完整:它也在沙箱之外。
  • 沙箱之外的写入被拦了,我的文件还在吗:在。被拦的是写入动作 本身,Operation not permitted 意味着这次打开文件就没成功,不存在 “写了一半”——这和练习 10 的误删保护是两件事。
  • 可以只放开某个目录吗:可以,default_sandbox_policy/ defaultSandboxPolicy 里的两个 roots 就是给你改的。
  • 沙箱能挡住内核漏洞吗:不能。这是纵深防御的一层,不是绝对屏障。 它的目标是“一条被放行的命令不该能读走你的密钥“,不是“抵御一个专门 针对内核的攻击“。

加分练习

  1. 给沙箱策略加命令行参数:-sandbox-write <目录>(可重复)、 -sandbox-allow-net。加完用 -sandbox 不带 -sandbox-allow-net 跑一次联网命令,再带上跑一次,亲眼看看那一刀切在哪。
  2. 让 MCP 服务器也进笼子:改启动 MCP 服务器的那个函数,把子进程也 包一层 sandbox-exec。做之前先预测哪些服务器会因此坏掉(提示: 练习 22 里那个 filesystem 服务器如果指向工作目录之外,还能工作吗), 跑完对照你的预测。
  3. write_file/edit_file 加一道路径检查:目标路径不在写入白名单 里就拒绝。写完想一想,为什么这道检查和沙箱不是重复劳动——提示: 一个防的是你自己的代码,一个防的是别人的代码。
  4. 把生成的那份规则打印出来,读一遍那几行 SBPL,然后手动删掉 (deny network*) 那行重新跑 curl——用最小的改动确认每一条规则 确实各自在起作用。