练习 9:权限系统——拦下 rm 的那一刻

练习 8 的结论有点让人不安:同一条"改文件前必须先读"的规矩,DeepSeek 守了, 本机的小模型没守。软约束问的是模型愿不愿意,答案因模型而异—— 如果这就是唯一的防线,那防线的强度取决于你恰好用了哪个模型。

这一章加一道不问模型意见的闸门。模型想干什么是它的事, 干不干得成,从这一章起由别的东西说了算。

敲进去

在练习 8 的代码上继续写。新增一个决策类型、一张规则表、一个分类函数、 一个问人的函数,外加在 registry.execute 里插一段检查。完整文件在 exercises/ex09/

先是三档决策和规则表:

// ---- 权限层:拦下危险命令 ----

// decision 是权限检查的结论,档位从低到高:allow < ask < deny。
type decision int

const (
	decisionAllow decision = iota
	decisionAsk
	decisionDeny
)

// permRule 是一条模式规则:命令里出现了 pattern,就归到 decide 那一档。
type permRule struct {
	pattern string
	decide  decision
}

// bashRules 蒸馏自 octo 的 internal/permission/defaults.yml——声明顺序不重要,
// 重要的是档位:deny 赢 ask,ask 赢 allow。一条规则都没命中时,隐式默认是
// ask——宁可多问一句,不要放过一个没见过的命令。
var bashRules = []permRule{
	{"rm -rf /", decisionDeny},
	{"rm -rf ~", decisionDeny},
	{"rm -rf", decisionAsk},
	{"sudo ", decisionAsk},
	{"git push --force", decisionAsk},
	{"curl ", decisionAsk},
	{"ls", decisionAllow},
	{"cat ", decisionAllow},
	{"pwd", decisionAllow},
	{"echo ", decisionAllow},
	{"git status", decisionAllow},
}

再是分类函数——三遍独立扫描,不是碰到第一条规则就返回:

// classifyBash 给一条 shell 命令分档。分三遍独立扫描,而不是一遍碰到就返回,
// 就是为了让"deny 赢 ask 赢 allow"这件事跟规则声明的先后顺序无关。
func classifyBash(cmd string) decision {
	for _, r := range bashRules {
		if r.decide == decisionDeny && strings.Contains(cmd, r.pattern) {
			return decisionDeny
		}
	}
	for _, r := range bashRules {
		if r.decide == decisionAsk && strings.Contains(cmd, r.pattern) {
			return decisionAsk
		}
	}
	for _, r := range bashRules {
		if r.decide != decisionAllow || !strings.Contains(cmd, r.pattern) {
			continue
		}
		// allow 比 deny/ask 挑剔:命令必须以这个词开头,且整条命令里不能有
		// shell 的链接符号——否则 "ls && rm -rf /" 会被 "ls" 这条规则放行。
		trimmed := strings.TrimLeft(cmd, " \t")
		if strings.HasPrefix(trimmed, r.pattern) && !containsShellChain(cmd) {
			return decisionAllow
		}
	}
	return decisionAsk
}

// containsShellChain 检查命令里有没有把一条命令接到另一条上的符号。
func containsShellChain(cmd string) bool {
	return strings.ContainsAny(cmd, ";|&$()`\n")
}

问人的函数——这一次问的是终端前的你,不是模型:

// askApproval 停下来问人,不是问模型——危险命令要过这一关,
// 模型自己怎么想不算数。读不到回答(比如脚本化调用、没有终端)一律按拒绝处理,
// 安全边界宁可保守,不能因为读不到输入就放行。
func askApproval(cmd string) bool {
	fmt.Fprintf(os.Stderr, "\n⚠️  模型想执行: %s\n允许吗?(y/N) ", cmd)
	line, err := bufio.NewReader(os.Stdin).ReadString('\n')
	if err != nil {
		return false
	}
	answer := strings.ToLower(strings.TrimSpace(line))
	return answer == "y" || answer == "yes"
}

func commandOf(args string) string {
	var in struct {
		Command string `json:"command"`
	}
	_ = json.Unmarshal([]byte(args), &in)
	return in.Command
}

最后接进 registry.execute,插在 read-before-write 检查之后、 真正调用工具之前:

	if name == "bash" {
		cmd := commandOf(args)
		switch classifyBash(cmd) {
		case decisionDeny:
			return "错误: 权限拒绝——这条命令匹配了硬性禁止规则,不会执行,也不会询问。"
		case decisionAsk:
			if !askApproval(cmd) {
				return "错误: 权限拒绝——用户没有批准这条命令。"
			}
		}
	}

别忘了 import "bufio"。先别问为什么。敲完,跑起来,我们再回头讲。

跑起来

go build -o ex09 . && printf '购物清单\n- 牛奶\n- 面包\n' > list.txt

这一章要做三个实验,分别对应三档决策。第一个,一条安全命令:

./ex09 "用 shell 命令 cat 把 list.txt 的内容打印出来"

第二个,一条没有任何规则覆盖、落进隐式默认的命令:

./ex09 "用 shell 命令把 list.txt 这个文件删掉"

看到 ⚠️ 模型想执行: ... 时,敲 y 回车批准。第三个,先建一个可以放心删的目录:

mkdir -p scratch_ok_to_delete && echo 占位 > scratch_ok_to_delete/note.txt
./ex09 "用 shell 命令彻底删掉这个绝对路径下的目录:$(pwd)/scratch_ok_to_delete,命令里请用绝对路径"

你应该看到什么

第一个实验,cat 直接放行,没有任何提示——本机 Ollama:

[round 1 输入 795 tokens,命中缓存 0]
[round 1] bash({"command":"cat list.txt"})
已成功将 list.txt 的内容打印出来:

购物清单
- 牛奶
- 面包

[共 2 轮 · 最后一轮输入 842 tokens(命中缓存 0)· finish_reason=stop]

第二个实验,DeepSeek 这次把删除、确认串成了一条命令:

[round 1 输入 934 tokens,命中缓存 896]
[round 1] bash({"command": "rm -f list.txt && echo \"已删除\" && ls list.txt 2>&1"})

⚠️  模型想执行: rm -f list.txt && echo "已删除" && ls list.txt 2>&1
允许吗?(y/N) 已用 rm -f list.txt 删除该文件,ls 确认它已不存在。

[共 2 轮 · 最后一轮输入 1044 tokens(命中缓存 896)· finish_reason=stop]

rm -f list.txt && echo ... && ls ... 不含 rm -rf,也因为带了 && 过不了 allow 的严格检查——落进隐式默认,停下来问了我。我敲了 y,文件是真的被删了。

第三个实验,本机 Ollama 想删掉那个绝对路径下的目录:

[round 1 输入 869 tokens,命中缓存 0]
[round 1] bash({"command":"rm -rf /private/tmp/.../scratch_ok_to_delete"})
我无法执行删除操作,因为系统禁止了权限拒绝的命令……

[共 2 轮 · 最后一轮输入 991 tokens(命中缓存 0)· finish_reason=stop]

没有任何确认提示,scratch_ok_to_delete/note.txt 还在。这条命令从没被问过, 是直接被拒绝的——因为它命中了 deny: rm -rf / 这条规则。

发生了什么

三档,不是两档。 allow 静默放行,不打扰任何人;deny 静默拒绝,也不打扰任何人, 但方向相反;中间的 ask 才会真的停下来,把决定权交给终端前的人。 练习 8 的软约束问的是模型愿不愿意;这一章的 ask 问的是愿不愿意, 模型的意见从这里开始不再算数——它连问都问不上。

为什么要分三遍扫描,而不是一遍碰到规则就返回。 如果规则列表是从上到下 第一条命中就生效,规则的作者就得操心"这条 deny 有没有排在某条 allow 前面", 新增一条规则时一不小心就会被前面的宽松规则截胡。分成三遍独立扫描、 按档位取胜负,规则声明的先后顺序就彻底不重要了——deny 永远最先被看到。

allow 为什么比 deny/ask 挑剔。 deny 和 ask 只要求"命令里出现了这个词", 因为它们的心态是宁可错杀:漏判的代价更大。allow 反过来,心态是宁可少放: 如果只检查子串,"ls && rm -rf /" 也会被 "ls" 这条规则放行, 危险命令搭一个安全词的顺风车就溜过去了。所以 allow 多两个条件——命令必须 以这个词开头,而且整条命令里不能有 ;&&| 这类能把两条命令接起来 的符号。这句话不是我编的,是 octo 源码里的原话: "Allow rules on terminal must never match a command containing shell chaining characters."

这一层和练习 6 的 read-before-write 不是一回事。 那一层管的是"工作流对不对" ——改文件前有没有先读过;这一层管的是"这个动作本身安全不安全"——不管你读没读过, rm -rf / 就是不该被无声无息地放行。两把锁装在同一个 registry.execute 里, 分工不同,谁都不能替代谁。

这一层和练习 8 的 base prompt 也不是一回事。 base prompt 是讲道理, 讲不讲得通取决于模型愿不愿意听;这一层是设卡,模型愿不愿意跟结果没关系—— 它连"愿不愿意"的机会都没有。练习 8 结尾那句话,这一章算是兑现了。

一个意外,也是好事:两个模型都没让我看到"deny 真的拦下了一条它自己想执行的 危险命令"。 我原本打算直接让模型跑 rm -rf ~,结果 DeepSeek 和本机的 qwen3:4b-instruct 都在自己的对齐层面直接拒绝了,工具调用根本没发生—— 它们比我们的闸门先一步说"不"。这说明真正的安全从来不是一层:模型自己的 训练是第一道,我们这道基于规则的闸门是第二道,存在的理由恰恰是你不能 假设所有场景下第一道都同样牢靠——一个没那么谨慎的模型、一次精心措辞的 诱导,都可能让第一道失效,这时候还得靠第二道兜底。为了看到第二道真的单独 起作用,这一章换了个不那么"看起来邪恶"的任务——删一个绝对路径下的目录, 这是模型会正常配合执行的日常操作。

这也牵出了这一章闸门自己的短板。 上面第三个实验里,那条命令被拦下, 不是因为它精确瞄准了根目录,而是因为 rm -rf /private/tmp/.../scratch_ok_to_delete 这个绝对路径恰好包含字符串 "rm -rf /"——deny 规则用的是最朴素的子串匹配, 没有边界判断。octo 的真实引擎会检查这个标记后面是不是紧跟着参数边界, 所以 rm -rf /path/under 会正确落进 ask 而不是被 deny 误伤, 只有真正裸露的 rm -rf /(后面接空格、结尾或另一个命令)才会被拒绝。 这一章的版本没实现这个边界判断——够用就好,代价是"宁可错杀": 任何以 / 开头的删除都会被这条规则拦住,哪怕目标只是一个安全的临时目录。

octo 真实系统还有两样这一章没做的东西: 一是 Mode——我们这里只实现了 "有人在终端等着回答"这一种;octo 还有 auto(把 ask 直接放行,适合完全 信任的自动化场景)和 strict(把 ask 直接拒绝,适合没人值守、答不了 y/N 的场景,比如定时任务)。二是"记住这次决定":octo 答一次"总是允许", 同一个文件这个会话里就不再问了。两样都不难加,但不影响这一章的道理, 略过。

常见问题

  • 我跑出来的绝对路径删除,会不会真的把我的目录删了:不会。registry.executedecisionDeny 那个分支直接 return,下面调用 t.execute(也就是 真正跑 exec.CommandContext 的地方)根本没被执行到。不放心的话, 自己写一个只调用 registry.execute("bash", ...)、绕开模型和网络请求的 小测试,亲眼确认这条路径。
  • ask 的确认提示卡住不动:程序在等你在终端里敲 y 或者别的什么再回车, 这是设计成同步阻塞的——闸门存在的意义就是让执行停下来等人。
  • 为什么这套规则会漏判 rm -rf $HOME(而不是 rm -rf ~)之类的变体: 会漏。子串匹配防的是"常见、已知"的写法,不是穷举所有等价表达。 这正是隐式默认设成 ask 而不是 allow 的原因——没被特别列出的危险命令, 至少会先经过人工确认这一关,不会因为规则没写到就直接放行。

加分练习

  1. 把 allow 分支的检查换成一句 strings.Contains(cmd, r.pattern)(去掉前缀 和链接符号检查),然后让模型执行"先 ls 一下当前目录,再删掉 scratch_ok_to_delete"——观察它会不会被 "ls" 这条规则误判成安全命令, 一步执行,完全没有询问。改回来,确认恢复正常。
  2. bashRules 加一条你自己在意的规则——比如把 git commit 也设成 ask, 看它在真实对话里怎么生效。
  3. 用不同措辞让模型删除同一个绝对路径下的目录(比如让它先 cd 进去、 用相对路径删、或者拼一条不含 / 开头参数的命令),看它落进哪一档。 借着这个实验感受一下"精确的 deny 名单"和"宽松的 ask 兜底"分别防住了什么、 又分别漏掉了什么。
  4. (选做)加一个环境变量 STRICT=1,读到它就让 classifyBashdecisionAsk 直接转成 decisionDeny——这就是 octo Modestrict 权限模式的最小实现,没人在终端等着回答时,答案从"问"变成"不行"。