练习 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

你应该看到什么

冒烟测试:边界先在代码层面立住

--- PASS: TestSandboxAllowsCwdWrite (0.03s)
--- PASS: TestSandboxAllowsTmpWrite (0.03s)
--- PASS: TestSandboxBlocksHomeWrite (0.02s)
    被拦时的输出: "/bin/sh: /Users/you/smoke-should-fail.txt: Operation not permitted"
--- PASS: TestSandboxBlocksSecretRead (0.02s)
    被拦时的输出: "cat: /Users/you/.zshrc: Operation not permitted"
--- PASS: TestSandboxBlocksNetwork (0.03s)
    被拦时的输出: "curl: (6) Could not resolve host: example.com"
--- PASS: TestNoSandboxControl (0.01s)

三条禁令都是操作系统给的回答,不是我们的代码在演。特别看网络那条:报错 不是"连接被拒绝",是域名解析不了——DNS 查询本身就要走网络,deny network* 一刀切在更靠前的地方。

顺带撞出一个真实的坑:/tmp 不在可写名单里。os.TempDir() 在 macOS 上返回的是 $TMPDIR/var/folders/… 底下的一个随机目录),不是 /tmp ——习惯性往 /tmp 写东西的命令,在沙箱里会失败。

实验一:正常干活不受影响,越界当场被按住

[沙箱开启:可写 [/…/ex23-test /var/folders/…/T/],家目录不可读(工作目录和临时目录除外),网络关闭——OS 强制,批准了也越不出去]
[round 2] write_file({"path": "notes.txt", "content": "沙箱内正常工作\n"})
[round 3] bash({"command": "cat notes.txt"})
[round 4] bash({"command": "echo '沙箱内正常工作' > ~/ex23-should-fail.txt"})

**1. 工作目录里建 notes.txt —— 成功**
**2. 写入家目录 ~/ex23-should-fail.txt —— 失败**
/bin/sh: /Users/…/ex23-should-fail.txt: Operation not permitted
[exit status 1]

模型在工作目录里读写自如,一伸手到家目录就被按住,而且它读得懂这个 错误、如实汇报了失败——沙箱不需要模型配合,但拦下之后的报错对模型是 可读的情报,它会自己调整而不是无限重试。

本机 Ollama 跑同样的边界测试(qwen3:4b-instruct):工作目录内 echo 写入和 cat 读取都成功,写家目录拿到同一句 Operation not permitted,然后如实汇报"第三条因权限问题失败"。它对失败原因的猜测 ("macOS 的 /Users 目录下某些文件不可写")不准确,但它不知道为什么 被拦,不影响它确实被拦住了——这正是 OS 级边界和字符串规则的区别: 前者不需要被理解就能生效。

实验二:同一个文件,两条路径两种命运

[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: /Users/…/ex23-secret-demo.txt: Operation not permitted
**(2) home MCP 服务路径:成功**
EX23-FAKE-SECRET-VALUE

这条 cat 是权限系统放行的(练习 9 的 allow 名单里有 cat ,命令里 也没有 shell 拼接符号),它照样没读到东西——沙箱在权限系统之后,是独立的 第二道关。这正是这一章想证明的:允许 ≠ 做得到。

同一轮里,MCP 服务器读同一个文件,成功。这不是漏洞被"发现",是这一章的 边界本来就画在那里——下一节讲清楚为什么。

发生了什么

这一章画的是"执行边界",而 tool 的执行边界本身就是 tool 设计的一部分。 练习 9 讲过一次同样的话:什么时候能执行,和能执行什么,是一体两面。 现在多了一层——同一个 bash 工具,装在笼子里和不装,是两个不同的工具, 尽管它的 schema 一个字都没变。模型看到的声明一样,能做成的事不一样。

权限系统和沙箱是两道关,不是一道关的两种实现。 权限系统在策略层 (要不要允许这个字符串)判断,它的优势是可以问人、可以按意图区分—— rm -rf /rm -rf ./build 在它眼里不一样。沙箱在执行层(这个进程 碰得到什么)判断,它不理解意图,但它不会被绕过:命令怎么套引号、调什么 解释器、fork 出几层子进程,都逃不出内核给的那副镣铐。两者互补:策略层 问"该不该",执行层保证"能不能"。实验二那条被放行却读不到东西的 cat, 就是两道关各司其职的样子。

边界只有装在唯一的门上才算数。 shellCommand 这个函数存在的全部 意义,就是让"把字符串变成进程"这件事在整个 harness 里只有一个出口。 现在 bash 工具从这里走;后面的章节要加后台任务,它也必须从这里走—— 如果后台任务自己另写一行 exec.CommandContext,沙箱对它就是不存在的, 而使用者不会知道。这类边界最常见的失效方式不是被攻破,是被绕过:多一个 调用点,就多一个洞。

沙箱是显式开启的,这不是偷懒,是网络这一刀太钝。 断网是全有全无: deny network* 之后 git fetchgo mod downloadnpm install 全废。 想做"只允许连这几个域名"做不到——macOS 的规则语言按 IP 和端口过滤, 不认域名;Linux 那套过滤系统调用的机制,看得到"你要建一个网络连接", 看不到"你要连哪里"(目的地址藏在指针后面)。所以只有一个开关,默认关。 常开的那道闸仍然是权限系统加人工确认。

要不到就明确拒绝,不要降级。 机器给不了沙箱时(Windows、旧内核、 sandbox-exec 不在),程序直接退出,不是"打个警告然后照常跑"。理由: 用户开 -sandbox 是要一个保证,给不了保证还继续跑,等于让他带着一个 不存在的安全感干活——那比一开始就没有沙箱更危险。

Linux 是另一套机制,同一个概念。 macOS 这条路是"拿一份规则文件把 命令包起来交给系统工具执行";Linux 上没有这样的外壳程序,边界必须由 进程给自己戴上,而且必须在 fork 之后、exec 之前那个夹缝里完成—— Go 的标准库没有给这个夹缝留钩子。octo 的做法是让程序重新执行自己一次: 用一个隐藏的子命令启动自身,在那个新进程里先给自己套上文件访问的限制 (Landlock,内核 5.13 起的能力,按路径授权,不需要 root)和一层系统调用 过滤(seccomp,用来挡住建立网络连接的那个调用),然后才真正 exec 用户 的命令。机制完全不同,Policy 那三个字段一模一样——这是好的抽象该有的 样子:换平台换的是实现,不是概念。

常见问题

  • 沙箱管得住 write_fileedit_file:管不住,实测过——开着 沙箱让模型用 write_file 往家目录写文件,成功了,文件真的躺在那里。 原因很直白:那两个工具是 harness 进程里的 Go 代码,直接调 os.WriteFile,而受约束的是 bash 起的子进程,不是 harness 自己。 这不是这一章的疏漏,是这一章边界的准确形状:沙箱保护的是"任意命令" 这个面,那才是不可预测的部分;write_file 能做什么是你自己写的 Go 代码说了算,该在那里加限制(比如拒绝工作目录之外的路径)就在那里加。
  • 那 MCP 工具呢:同样管不住,实验二演示得很清楚。MCP 服务器是我们 启动的另一个子进程,走的不是 shellCommand 那扇门。练习 22 结尾说过 "MCP 工具绕过了权限系统",现在可以把话说完整:它也在沙箱之外。要补 这个洞,得让 startMCPServer 也从沙箱走一遍——这是加分练习 2,做之前 先想清楚:把一个文件服务器关进只能读工作目录的笼子,它还是不是原来 那个服务器。
  • 沙箱之外的写入被拦了,我的文件还在吗:在。被拦的是写入动作本身, Operation not permitted 意味着这次打开文件就没成功,不存在"写了一半" ——这和练习 10 的误删保护是两件事,那个防的是"确实执行了但你后悔了"。
  • 可以只放开某个目录吗:可以,defaultSandboxPolicy 的两个 roots 切片就是给你改的。octo 把它做成了命令行参数(加一个可写目录、加一个 可读目录、放开网络),本章没做——加参数是十分钟的事,理解边界画在哪 才是这一章的内容。
  • 沙箱能挡住内核漏洞吗:不能。这是纵深防御的一层,不是绝对屏障。 它的目标是"一条被放行的命令不该能读走你的密钥",不是"抵御一个专门 针对内核的攻击"。

加分练习

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