练习 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.execute里decisionDeny那个分支直接return,下面调用t.execute(也就是 真正跑exec.CommandContext的地方)根本没被执行到。不放心的话, 自己写一个只调用registry.execute("bash", ...)、绕开模型和网络请求的 小测试,亲眼确认这条路径。 - ask 的确认提示卡住不动:程序在等你在终端里敲
y或者别的什么再回车, 这是设计成同步阻塞的——闸门存在的意义就是让执行停下来等人。 - 为什么这套规则会漏判
rm -rf $HOME(而不是rm -rf ~)之类的变体: 会漏。子串匹配防的是"常见、已知"的写法,不是穷举所有等价表达。 这正是隐式默认设成ask而不是allow的原因——没被特别列出的危险命令, 至少会先经过人工确认这一关,不会因为规则没写到就直接放行。
加分练习
- 把 allow 分支的检查换成一句
strings.Contains(cmd, r.pattern)(去掉前缀 和链接符号检查),然后让模型执行"先ls一下当前目录,再删掉scratch_ok_to_delete"——观察它会不会被"ls"这条规则误判成安全命令, 一步执行,完全没有询问。改回来,确认恢复正常。 - 给
bashRules加一条你自己在意的规则——比如把git commit也设成ask, 看它在真实对话里怎么生效。 - 用不同措辞让模型删除同一个绝对路径下的目录(比如让它先
cd进去、 用相对路径删、或者拼一条不含/开头参数的命令),看它落进哪一档。 借着这个实验感受一下"精确的 deny 名单"和"宽松的 ask 兜底"分别防住了什么、 又分别漏掉了什么。 - (选做)加一个环境变量
STRICT=1,读到它就让classifyBash把decisionAsk直接转成decisionDeny——这就是 octoMode里strict权限模式的最小实现,没人在终端等着回答时,答案从"问"变成"不行"。