MewCode 权限系统深度分析笔记
分析对象:
mewcode/internal/permission/(blacklist.go, engine.go, matcher.go, mode.go, persist.go, rule.go, sandbox.go, settings.go, matcher_test.go)与mewcode/internal/agent/permission_upgrade.go阅读方式:逐文件全量精读,所有结论均标注文件:行号。凡代码中确实没有的,明确写「源码未体现」。
模块职责
一句话:permission 包用「黑名单 → 沙箱 → 规则引擎 → 模式兜底」四层纯函数式判定(Engine.Check)加上由 agent 包编排的第五层「人在回路」审批,把一次 llm.ToolCall 收敛为 Allow / Deny / Ask 三值裁决,并把用户的「永久允许」沉淀为 YAML 规则。
- 分层清晰、职责单一:
Check只做前四层且无副作用(不写文件、不弹窗),第五层由 agent 编排(mode.go:1-5包注释明确写「前四层由 Engine.Check 实现,第五层由 agent 包编排驱动」)。 - 安全默认(fail-closed):配置文件缺失/格式错误只降级跳过(
engine.go:27注释 N5、settings.go:31-36),非法规则跳过并写 stderr(settings.go:53,60),未知工具归最严的CategoryExec(settings.go:98-101,注释 N7),路径解析失败直接Deny(engine.go:113-115)。 - 黑名单不可配置:
blacklist是包级var引用(engine.go:32),用户无法增删或关闭;注释明写「bypassPermissions 也拦」(blacklist.go:9)。 - 判定结果只三值:
Decision只有Allow / Deny / Ask(mode.go:54-58),modeFallback只产 Allow/Ask,绝不产 Deny(engine.go:142注释),Deny 只能来自黑名单、沙箱或显式 deny 规则——这条不变式是整个设计的骨架。 - 第五层可编排、可升级、可降级:子 Agent 通过
ApprovalUpgrader把审批上抛父 TUI(permission_upgrade.go:12),dontAsk可整体绕过 Ask(agent.go:762),ok=false时降级回默认审批路径(agent.go:830)。 - 测试覆盖严重不足:
permission/目录下只有matcher_test.go(171 行,纯CompileMatcher单测),没有 engine_test.go / sandbox_test.go / blacklist_test.go / rule_test.go / persist_test.go——前四层主流水线全部无单测(源码事实,非推断)。
五层权限模型的真实实现
第 1 层:黑名单(Blacklist)—— 仅拦 Exec,not configurable
10 条内置正则,命中即 Deny,先于一切规则和模式:
// mewcode/internal/permission/blacklist.go:10-40
var blacklist = []*regexp.Regexp{
// rm -rf 针对根目录、家目录、所有文件的递归强删
regexp.MustCompile(`rm\s+(-[a-zA-Z]*[rf][a-zA-Z]*\s+)+(/|~|\$HOME|\$HOME/|/\*)`),
// dd 写入块设备
regexp.MustCompile(`dd\s+.*of=/dev/(sd|hd|nvme|disk|xvd|vd|mmcblk|loop|ram|pmem)`),
// fork 炸弹(经典 :(){ :|:& };: 模式)
regexp.MustCompile(`:\(\)\s*\{[^}]*\|[^}]*&\s*\}`),
// mkfs 系列(mkfs.ext4, mkfs.xfs, mkfs.btrfs, mkfs.fat, mkfs.ntfs 等)
regexp.MustCompile(`\bmkfs\.`),
// ... 共 10 条
}挂载点在 Check 的第一段,且有两个额外前置条件(cat == CategoryExec 且 target != ""):
// mewcode/internal/permission/engine.go:106-109
// ① 黑名单:仅对命令执行类生效(N1 最高优先级,bypass 也拦)
if cat == CategoryExec && target != "" && hitsBlacklist(target) {
return Deny, "命中危险命令黑名单:" + summarize(target, 60)
}// mewcode/internal/permission/blacklist.go:43-49
func hitsBlacklist(command string) bool {
for _, re := range blacklist {
if re.MatchString(command) {
return true
}
}
return false
}要点:
- 用
MatchString(子串搜索,非全串Match),因此sudo rm -rf /也会命中。 target是未经 shell 解析的原始命令串,所以bash -c 'rm -rf /'因为字面包含rm -rf /而命中;反之任何拼接/编码混淆都能绕过(见「边界与容错」)。- 沙箱层对 Exec 类完全不生效(
engine.go:112的if isFile分支),因此bash的任意路径访问只能靠模式兜底(Ask)或黑名单挡住个别命令。
第 2 层:沙箱(Sandbox)—— 仅拦文件类,纯字符串前缀比对
// mewcode/internal/permission/sandbox.go:54-73
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)
}// mewcode/internal/permission/engine.go:111-119
// ② 沙箱:仅对文件类工具生效(N2)
if isFile {
if !ok {
return Deny, "无法解析文件路径参数,安全拒绝"
}
if !e.sandboxOK(target) {
return Deny, "路径在项目目录之外:" + target
}
}关键性质:
e.root在NewEngine里已filepath.Abs+filepath.EvalSymlinks解析(sandbox.go:10-16),即 root 本身是「真实路径」。isFile来自extractTarget:read_file/write_file/edit_file/glob/grep为 true,bash与未知工具为 false(settings.go:111-164)。- 所以
read_file(/etc/passwd)在任何模式下都会被 Deny——包括bypassPermissions,因为沙箱在modeFallback之前短路返回。
第 3 层:规则引擎(Rule Engine)—— 三层配置,就近命中
// mewcode/internal/permission/engine.go:121-136
// ③ 规则引擎:本地 > 项目 > 用户,就近命中即返回
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 规则命中,直接放行
}
}配置来源与优先级在 NewEngine 里固定(engine.go:44-67):用户级 ~/.mewcode/settings.yaml(settings.go:172-178)< 项目级 <root>/.mewcode/settings.yaml(engine.go:58)< 本地级 <root>/.mewcode/settings.local.yaml(engine.go:45-48)。
第 4 层:模式兜底(Mode Fallback)—— 只产 Allow/Ask
// mewcode/internal/permission/engine.go:142-157
// modeFallback F5 模式兜底矩阵:只产 Allow/Ask,绝不产 Deny。
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
}模式定义与名字映射(含大小写不敏感解析):
// mewcode/internal/permission/mode.go:12-17
const (
ModeDefault Mode = iota // 只读 Allow / 文件写 Ask / 命令执行 Ask
ModeAcceptEdits // 文件写 Allow / 命令执行 Ask
ModePlan // 仅只读工具可见(沿用 ch04);矩阵同 default 作防御兜底
ModeBypass // 全 Allow(黑名单/沙箱仍拦)
)// mewcode/internal/permission/mode.go:36-49
func ParseMode(s string) (Mode, bool) {
switch strings.ToLower(s) {
case "default":
return ModeDefault, true
case "acceptedits":
return ModeAcceptEdits, true
case "plan":
return ModePlan, true
case "bypasspermissions":
return ModeBypass, true
default:
return ModeDefault, false
}
}Plan 模式的真相:modeFallback 里根本没有 ModePlan 分支(engine.go:143-157),它在矩阵上等价于 ModeDefault。Plan 的真实约束在工具可见性上,不在判定矩阵上:
// mewcode/internal/agent/agent.go:205-211
// 按 mode 取工具集
var defs []llm.ToolDefinition
if mode == permission.ModePlan {
defs = a.registry.ReadOnlyDefinitions()
} else {
defs = a.registry.Definitions()
}// mewcode/internal/tool/registry.go:58-72
// ReadOnlyDefinitions 按注册顺序仅导出只读工具的定义(Plan Mode 使用)。
func (r *Registry) ReadOnlyDefinitions() []llm.ToolDefinition {
defs := make([]llm.ToolDefinition, 0, len(r.order))
for _, name := range r.order {
t := r.tools[name]
if t.ReadOnly() {
...即 Plan 模式是「不给模型看到写工具」+「兜底矩阵同样会 Ask」的双保险。run_to_completion.go:80-88 里也是同一套(并叠加 allowedTools 白名单过滤)。
第 5 层:人在回路(Human-in-the-loop)—— agent 包编排
Ask 的消费者在 agent.Run 的串行分支,先判 dontAsk,再判 approvalUpgrader,最后落到 requestApproval:
// mewcode/internal/agent/agent.go:760-833(节选)
case permission.Ask:
// 子 Agent dontAsk 模式:直接 Allow(spec F12.2)
if a.dontAsk {
results[i] = a.runTool(ctx, call)
...
continue
}
// 子 Agent 升级到父 TUI 审批(spec F12.3)
if a.approvalUpgrader != nil {
req := &ApprovalRequest{
Name: call.Name,
Args: argsPreview,
Reason: reason,
Respond: make(chan permission.Outcome, 1),
}
outcome, okUp := a.approvalUpgrader(ctx, req)
if okUp {
switch outcome {
case permission.OutcomeDenyOnce:
results[i] = llm.ToolResult{
ToolCallID: call.ID,
Content: "用户拒绝执行:" + reason,
IsError: true,
}
case permission.OutcomeAllowOnce:
results[i] = a.runTool(ctx, call)
case permission.OutcomeAllowForever:
_ = a.eng.PersistLocalAllow(call)
results[i] = a.runTool(ctx, call)
}
...
}
// okUp=false: 降级走默认 Approval 路径
}
outcome, ok2 := a.requestApproval(ctx, call, reason, ch)请求/应答数据结构:
// mewcode/internal/agent/agent.go:70-76
// ApprovalRequest 人在回路待批准请求(F8)。
type ApprovalRequest struct {
Name string // 工具内部名(用于展示 ● name(args))
Args string // 参数预览
Reason string // 触发 Ask 的原因(模式 + 类别)
Respond chan permission.Outcome // 缓冲=1:TUI 回传用户选择,agent 单次接收
}// mewcode/internal/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
}
}三选一的结果枚举:
// mewcode/internal/permission/mode.go:69-76
// Outcome 人在回路三选一结果。
type Outcome int
const (
OutcomeDenyOnce Outcome = iota // 拒绝本次
OutcomeAllowOnce // 允许本次(不留规则)
OutcomeAllowForever // 永久允许(+写本地层精确匹配规则)
)TUI 侧渲染与按键映射(↑↓/1/2/3/回车;Esc、Ctrl+C 兜底回灌 OutcomeDenyOnce 以解除 agent 阻塞):
// mewcode/internal/tui/view.go:262-270
options := []struct {
label string
desc string
outcome permission.Outcome
}{
{"1. 允许本次", "仅本次放行,不记录规则", permission.OutcomeAllowOnce},
{"2. 永久允许(写入本地配置)", "记录精确 allow 规则,跨会话生效", permission.OutcomeAllowForever},
{"3. 拒绝本次", "回灌错误给模型,让其调整策略", permission.OutcomeDenyOnce},
}// mewcode/internal/tui/tui.go:341-349
if msg.Code == tea.KeyEscape {
if m.state == stateStreaming || m.state == stateApproving {
if m.state == stateApproving && m.pending != nil {
// 兜底解除 agent 阻塞
select {
case m.pending.Respond <- permission.OutcomeDenyOnce:
default:
}
}commitApproval 用 m.pending.Respond <- outcome 单次回传后切回 streaming(tui.go:544-553)。
规则匹配引擎
数据结构
// mewcode/internal/permission/rule.go:8-28
// Rule 单条权限规则:工具名(模式) → allow 或 deny。
//
// 匹配语法升级(v2):
// - "=value" → 精确匹配(整串相等)
// - "~regex" → 正则匹配
// - "!inner" → 反向匹配(对内层 Matcher 取反,支持 !=value、!~regex、!glob)
// - "value" → glob 通配(缺省类型,向后兼容)
//
// Matcher 为 nil 表示匹配该工具全部调用(pattern 为空串)。
type Rule struct {
Tool string // 友好名:Bash/Read/Write/Edit/Glob/Grep
Matcher Matcher // 编译后的匹配器;nil 表示全匹配
Allow bool // true=allow,false=deny
raw string // 原始模式串,供错误日志与调试
}
// RuleSet 单层规则集(一个配置文件或会话内存)。
type RuleSet struct {
allow []Rule
deny []Rule
}四个 Matcher 实现(matcher.go):
// mewcode/internal/permission/matcher.go:11-16
type Matcher interface {
// Match 判断目标串是否匹配当前模式。
Match(s string) bool
// String 返回模式的可读描述,供调试与 /hooks 输出使用。
String() string
}matcherExact{value}:s == m.value(matcher.go:19-29)。matcherGlob{pattern, isCommand}:转发到matchPattern(pattern, s, !m.isCommand)(matcher.go:34-46)。matcherRegex{re, src}:re.MatchString(s),子串语义(matcher.go:49-60)。matcherNot{inner}:!inner.Match(s),可递归嵌套(matcher.go:64-74)。
前缀解析算法
// mewcode/internal/permission/matcher.go:86-117
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:
// 缺省 glob 通配
return &matcherGlob{pattern: pattern, isCommand: isCommand}, nil
}
}只有第一个字符被当作前缀解释,因此 !=foo、!~^rm、!!git * 都能正确嵌套(matcher_test.go:42-85,135-148)。注意区分 ~ 前缀走正则、= 走后缀字面,漏写前缀即退化为 glob。
pattern 语法总表
| 写法 | 解析结果 | 匹配语义 |
|---|---|---|
Bash / Bash() | Matcher == nil | 全匹配该工具所有调用(rule.go:64-67) |
Bash(git status) | matcherGlob{isCommand:true} | 命令串 glob,* 匹配任意字符含空格 |
Bash(=git status) | matcherExact | 命令串整串相等 |
Bash(~^git (push|pull)) | matcherRegex | Go regexp 子串搜索 |
Bash(!~^rm) | matcherNot{matcherRegex} | 不以 rm 开头的命令 |
Write(*.go) | matcherGlob{isCommand:false} | 按 / 分段,只匹配单段路径 |
Write(**/*.go) | 同上,含 ** | ** 跨任意层级(含 0 层) |
Read(src/**) | 同上 | 相对路径前缀式匹配 |
mcp__github__create_issue | Matcher == nil(无括号) | 全匹配该 MCP 工具 |
mcp__github__create_issue(...) | 任意非 nil Matcher | 永远匹配不上(见下方「已知缺陷」) |
isCommand 的判定只有一个条件——工具名是不是 Bash:
// mewcode/internal/permission/rule.go:69-76
// 编译 Matcher:Bash 工具走命令串 glob,其它走文件路径 glob
isCommand := tool == "Bash"
m, err := CompileMatcher(pattern, isCommand)
if err != nil {
return Rule{}, fmt.Errorf("rule %q parse failed: %w", s, err)
}Tool(pattern) 的解析
// mewcode/internal/permission/rule.go:41-58
// 查找括号位置
parenOpen := strings.IndexByte(s, '(')
parenClose := strings.LastIndexByte(s, ')')
var tool, pattern string
if parenOpen == -1 && parenClose == -1 {
// 不带模式:Tool → 匹配该工具全部调用
tool = s
pattern = ""
} else if parenOpen > 0 && parenClose > parenOpen && parenClose == len(s)-1 {
// 带模式:Tool(pattern)
tool = s[:parenOpen]
pattern = s[parenOpen+1 : parenClose]
} else {
// 括号不配对
return Rule{}, fmt.Errorf("invalid rule format: %s", s)
}用 IndexByte('(') 取第一个左括号、LastIndexByte(')') 取最后一个右括号,因此 Bash(~^git (push|pull)$) 里的正则分组括号不会被误当作规则边界——这是这套简版 parser 的关键技巧。tool 必须非空(rule.go:60-62),且不做大小写归一:bash(...) 与 Bash(...) 是两条不同规则,只有 Bash 会被 friendlyName("bash") 产出(settings.go:68-85)。
匹配算法
层内:deny 优先于 allow
// mewcode/internal/permission/rule.go:233-251
// 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
}注意:r.Tool == friendly 是精确字符串比较,没有 glob/前缀。而且形参 isFile 在函数体内完全未被使用——glob 类型已经在 parseRule 时烘进 Matcher 了(rule.go:70-71),这里的 isFile 是遗留的死参数(Go 不报未使用形参)。
层间:就近短路,不合并
engine.go:122-129 的三层循环是第一个命中即返回,不做「deny 覆盖 allow」的跨层合并。后果:一条本地 allow 可以压过项目 deny(local 在循环最前)。同时注释 mewcode/internal/permission/engine.go:19 把 localPath 称作「永久放行写入目标」,说明本地层被刻意设计成「用户自己说了算」。
命令串 glob(matchCommandPattern)
// mewcode/internal/permission/rule.go:124-160
// matchCommandPattern 命令串 glob 匹配:* 匹配任意字符序列(含空格),其余字面。
func matchCommandPattern(pattern, target string) bool {
// 将 pattern 中的 ** 替换为 *(命令串中二者等价)
pattern = strings.ReplaceAll(pattern, "**", "*")
pi, ti := 0, 0
for pi < len(pattern) && ti < len(target) {
switch pattern[pi] {
case '*':
// * 匹配剩余全部
if pi == len(pattern)-1 {
return true
}
// * 贪婪向前找到下一字符
next := pattern[pi+1]
for ti < len(target) && target[ti] != next {
ti++
}
pi++ // 跳过 *
if ti >= len(target) {
return false
}
case target[ti]:
pi++
ti++
default:
return false
}
}
// 剩余 pattern 全部是 * 则匹配
for pi < len(pattern) && pattern[pi] == '*' {
pi++
}
return pi == len(pattern) && ti == len(target)
}这是字节级、无回溯的单趟贪婪扫描:* 后面的下一个字面字符在 target 中第一次出现的位置就被当作匹配点,不回溯。? 在命令串里没有特殊含义(落到 default: return false),与文件路径 glob 不一致。
文件路径 glob(matchFilePattern → matchSegments → matchSegment)
// mewcode/internal/permission/rule.go:117-122
// matchFilePattern 文件路径 glob 匹配:* 段内、** 跨段。
func matchFilePattern(pattern, target string) bool {
patParts := strings.Split(pattern, "/")
targetParts := strings.Split(target, "/")
return matchSegments(patParts, targetParts)
}// mewcode/internal/permission/rule.go:164-195(节选)
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 {
// path 已耗尽,pat 是否只剩 **
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:])
}
// 段内匹配(使用 filepath.Match 语义兼容的简单 glob)
matched, _ := matchSegment(pat[0], path[0])
if !matched {
return false
}
return matchSegments(pat[1:], path[1:])
}** 用递归双分支实现(跳过 ** 或吃掉一段路径),是唯一带真实回溯的部分。段内用自写的 matchSegment(rule.go:198-231),支持 * 和 ?,但同样是无回溯贪婪,且注释声称「使用 filepath.Match 语义兼容的简单 glob」——实际与 filepath.Match 不等价:filepath.Match 的 * 不跨 / 且带完整回溯,这里是自己重写的字节扫描。
权限判定完整数据流
从 ToolCall 到三值裁决:逐函数链路
llm.ToolCall{Name, Input(json.RawMessage), ID}
│
├─ tool/registry.go:74-78 IsReadOnly(name) ──► bool(未知工具 false)
│
├─ permission.Engine.Check(mode, call, readOnly) engine.go:101
│ │
│ ├─ ① categorize(name, readOnly) → Category settings.go:89-102
│ ├─ ② friendlyName(name) → "Bash"/"Read"/... settings.go:68-85
│ ├─ ③ extractTarget(call) → (target, isFile, ok) settings.go:111-164
│ │
│ ├─ L1 黑名单 cat==Exec && target!="" && hitsBlacklist engine.go:107-109 → Deny
│ ├─ L2 沙箱 isFile: !ok→Deny;!sandboxOK→Deny engine.go:112-119 → Deny
│ ├─ L3 规则 local → project → user,deny 先于 allow engine.go:122-136 → Allow/Deny
│ └─ L4 兜底 modeFallback(mode, cat) engine.go:139/143-157 → Allow/Ask
│
└─ agent 编排(第五层)
├─ 只读批(agent.go:537-565):Check(...,true),只取 Deny 做 preDenied,Ask 路径不存在
└─ 串行单调用(agent.go:713):Check(...,false)
├─ Deny → ToolResult{IsError:true, Content:reason},i++ agent.go:716-758
├─ Ask → dontAsk? 直接 runTool agent.go:762-784
│ approvalUpgrader? 上抛父 TUI agent.go:787-831
│ 否则 requestApproval 阻塞等 Respond agent.go:833-1000
└─ Allow → PhaseStart → registry.Execute → PhaseEnd agent.go:912+Category 提取
// mewcode/internal/permission/settings.go:87-102
// categorize 根据内部工具名和只读标记判定工具类别。
// readOnly 优先;未知工具(readOnly==false)归 CategoryExec(N7 最严)。
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
}
}readOnly 优先于名字:MCP 工具若注册时声明 ReadOnly()==true 会被归为 CategoryRead 进而在任何模式下 Allow(registry.go:75-78 实现是 t.ReadOnly())。未知/未注册工具一律 CategoryExec。
Target 提取
// mewcode/internal/permission/settings.go:111-164(关键分支)
func extractTarget(call llm.ToolCall) (target string, isFile bool, ok bool) {
if len(call.Input) == 0 {
return "", false, false
}
var m map[string]any
if err := json.Unmarshal(call.Input, &m); err != nil {
return "", false, false
}
switch call.Name {
case "read_file", "write_file", "edit_file":
v, exists := m["path"]
if !exists {
return "", true, false
}
s, ok2 := v.(string)
if !ok2 || s == "" {
return "", true, false // path 存在但为空 → 判为不可解析
}
return s, true, true
case "glob", "grep":
v, exists := m["path"]
if !exists {
return ".", true, true // 默认搜索根为 "."
}
...
return s, true, true
case "bash":
v, exists := m["command"]
if !exists {
// 缺 command → 视为空串,不命中黑名单,落 Ask
return "", false, false
}
s, ok2 := v.(string)
if !ok2 {
return "", false, false
}
return s, false, true
default:
// 未知工具
return "", false, false
}
}三个值得记住的边界:
- 文件类:
path缺失或空 →("", true, false)→ok=false→ 沙箱层Deny(fail-closed)。 - bash 缺 command →
("", false, false),target==""使黑名单跳过(engine.go:107的target != ""前置条件),最终落到modeFallback→Ask。注释明写这是有意的。 - 未知工具(含所有
mcp__*) → 恒为("", false, false):黑名单跳过、沙箱跳过、规则只有 nil-Matcher 能命中、最终恒Ask(bypass 下恒 Allow)。
Outcome 结构
Outcome 只有三值(mode.go:70-76),语义直接映射到「是否落规则」:
| Outcome | agent 行为 | 副作用 |
|---|---|---|
OutcomeDenyOnce | 构造 ToolResult{Content:"用户拒绝执行:"+reason, IsError:true},不执行工具,错误回灌模型 | 无(agent.go:848-853) |
OutcomeAllowOnce | 直接 registry.Execute | 无(agent.go:855,879-881) |
OutcomeAllowForever | 先 PersistLocalAllow(call) 再执行;写失败仅发 Notice 不中断 | 写 <root>/.mewcode/settings.local.yaml + 同步内存(agent.go:855-860) |
模式兜底矩阵(engine.go:143-157 的真实结果)
| Category \ Mode | Default | AcceptEdits | Plan | Bypass |
|---|---|---|---|---|
CategoryRead | Allow | Allow | Allow | Allow |
CategoryWrite | Ask | Allow | Ask(真护栏在「工具不可见」) | Allow |
CategoryExec | Ask | Ask | Ask | Allow |
补充三条矩阵解释不了的:
- 黑名单 Deny 与沙箱 Deny 在矩阵之前,
Bypass也逃不掉(engine.go:106-119)。 - L3 规则命中的 Allow/Deny 同样先于矩阵,所以规则可以让 Bypass 下的东西被 Deny、让 Default 下的东西免 Ask。
mode.String()用于 reason 文案,Bypass 显示为bypassPermissions(mode.go:20-33),因此catName(cat)的中文只出现在 Ask 分支(engine.go:160-171)。
只读批处理路径的差异
// mewcode/internal/agent/agent.go:536-565(节选)
if a.registry.IsReadOnly(calls[i].Name) {
j := i
for j < len(calls) && a.registry.IsReadOnly(calls[j].Name) {
j++
}
...
d, reason := a.eng.Check(mode, calls[k], true)
if d == permission.Deny {
preDenied[k] = reason
}只读批只关心 Deny,忽略 Ask;这是安全的,因为 readOnly==true → CategoryRead → modeFallback 恒 Allow(engine.go:145-147),Ask 在只读批上不可达。被拒项在并发执行前先写结果并跳过(agent.go:589-604),未被拒的才并发跑(agent.go:600-618)。串行分支则硬编码 readOnly=false(agent.go:713),由 categorize 按名字重判。
边界与容错
沙箱路径逃逸的防护与缺口
已防护:
..穿越 ——filepath.Clean(abs)(sandbox.go:66)在解析前规整,/root/sub/../../etc→/etc,随后前缀比对失败。- 绝对路径 —— 直接走
filepath.IsAbs的 else 分支不做 root 拼接(sandbox.go:61-63),再统一比对,所以read_file(/etc/passwd)被 Deny。 - 符号链接 —— 两层处理:
root本身在resolveRoot里EvalSymlinks(sandbox.go:11-15);目标路径走evalSymlinksOrAncestor(sandbox.go:24-49),对不存在的新建文件逐级回退到最近已存在祖先再解析:
// mewcode/internal/permission/sandbox.go:24-49
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
}这说明作者明确考虑了「写一个还不存在的 a/b/c/new.go」这一场景。
- 前缀比对的边界 —— 用
resolved == e.root || strings.HasPrefix(resolved, e.root+sep)(sandbox.go:72),即/proj不会误放行/proj-evil(因为比的是/proj/)。 - 解析失败 fail-closed ——
ok=false直接 Deny(engine.go:113-115);evalSymlinksOrAncestor最终兜底返回的是被逐级上推后的abs(注意:注释写「返回原始输入」,实际返回的是循环中被改写的abs,通常已退化为/或某个祖先),拼接结果几乎不可能等于 root,因此仍收敛到 Deny。
缺口(源码可验证):
bash完全不在沙箱内。isFile=false(settings.go:148-158),engine.go:112的if isFile不进入。因此bash: cat /etc/passwd在 Default 下只被Ask拦;一旦用户点「永久允许」,Bash(=cat /etc/passwd)落盘,之后永久放行且能读任意路径。同理Write类工具也没有任何 shell 层面的约束(比如bash -c 'echo x > /etc/hosts')。- TOCTOU:
sandboxOK是「检查时求值」,resolved只是字符串;从检查到registry.Execute之间存在真实时间窗。攻击模型下(不可信仓库里的 hook/MCP 工具并发创建 symlink)可实现逃逸。源码中没有任何 fd-based / openat2 / RESOLVE_BENEATH 的二次校验。 - 硬链接 / bind mount /
/proc/self/cwd:纯字符串前缀比对无法防御,源码未体现任何应对。 - 未知工具(MCP)绕过沙箱:
extractTarget对未知工具返回("", false, false),isFile=false→ 沙箱层不执行。一个能写文件的 MCP 工具(mcp__fs__write)的写入路径完全不被sandboxOK检查,只能靠 Default 模式下的Ask拦一次。这是这套五层模型里最实质的洞。 root解析失败时 root 未规整:NewEngine里resolveRoot出错时e.root = root(原始、可能是相对路径,engine.go:37-41),而调用方main.go:84-88只打印「权限引擎降级」并继续运行(cmd/mewcode/main.go:85-88)。此后sandboxOK的e.root可能是相对路径,与resolved(绝对)比对必然失败——结果是 fail-closed(全部文件工具被 Deny),安全但可用性塌陷。属于「降级方向正确、错误语义含糊」。- 规则匹配与沙箱归一化不一致:沙箱层对
target做Clean+EvalSymlinks(两者都基于target的原始字符串),但 L3 规则匹配用的是同一个原始target未归一化的字符串(engine.go:104,130)。后果:Deny(Write(src/secret.go))可被Write(./src/secret.go)或绝对路径写法绕过;Allow(Write(**/*.go))在相对/绝对两种写法下都能命中(**跨段),但Allow(Write(src/**))只在模型恰好传相对路径时命中。规则匹配是基于字符串拼写的,不是基于文件身份的。
黑名单正则清单与绕过风险
10 条正则原文见 blacklist.go:12-39。逐条风险:
| # | 行号 | 正则 | 已知绕过 |
|---|---|---|---|
| 1 | blacklist.go:12 | rm\s+(-[a-zA-Z]*[rf][a-zA-Z]*\s+)+(/|~|$HOME|$HOME/|/*) | 长选项绕过:rm --recursive --force /(- 后第二个 - 不被 [a-zA-Z]* 匹配);引号绕过:rm -rf "$HOME"、rm -rf ${HOME};变量绕过:D=/; rm -rf $D;find / -delete、rm -rf $(echo /) |
| 2 | blacklist.go:15 | dd\s+.*of=/dev/(sd|hd|nvme|...) | 只覆盖 /dev/ 下这些前缀;of=/dev/mapper/...、of=/dev/disk/by-id/... 中 disk 命中(/dev/disk 可匹配),但 of=/dev/sda1 命中,of=/dev/fd0 不命中 |
| 3 | blacklist.go:18 | :\(\)\s*\{[^}]*|[^}]*&\s*\} | 只覆盖经典 :(){ :|:& };: 字面;改名函数 bomb(){ bomb|bomb& };bomb 不命中 |
| 4 | blacklist.go:21 | \bmkfs\. | 覆盖 mkfs.ext4 等,但裸 mkfs(走默认参数)不命中;且子串匹配会误伤 echo mkfs.x |
| 5 | blacklist.go:24 | >\s*/dev/(sd|hd|nvme|...) | 只匹配 >,不匹配 >>(其实 >> 包含 > 子串,会命中);tee /dev/sda、cp x /dev/sda 不命中 |
| 6 | blacklist.go:27 | chmod\s+-R\s+0?777\s+(/|/etc|/bin|/sbin|/usr|/var) | 只覆盖固定几个绝对目录,且要求 -R 在 777 前、顺序固定;chmod 777 -R /、chmod -R 777 .、chmod -R a+rwx / 不命中 |
| 7 | blacklist.go:30 | \bdd\s+if=.*\s+of=/dev/(sd|hd|nvme) | 与 #2 重叠且更窄,属冗余规则 |
| 8 | blacklist.go:33 | rm\s+.*--no-preserve-root\s+(/|/*) | 只覆盖该长选项拼法 |
| 9 | blacklist.go:36 | \bmv\s+.*\s+(/etc/passwd|/etc/shadow|/etc/sudoers|/boot/) | 只覆盖「写入目标」侧;cat /etc/shadow、cp x /etc/passwd、>> /etc/sudoers 不命中 |
| 10 | blacklist.go:39 | (wipefs|dd)\s+.*/dev/(sd[a-z]|hd[a-z]|nvme\dn\d)\b | 只覆盖 sd/hd/nvme 三种命名,/dev/vda、/dev/mmcblk0 不命中 |
两类系统性缺陷:
- 过度拦截(false positive):
hitsBlacklist用MatchString做子串搜索,而 #1 的备选分支里有裸/和裸~。于是任何rm -rf /绝对路径都会命中——包括本应合法的rm -rf /tmp/junk和「同项目内绝对路径删目录」。同理rm -rf ~/Documents也会被拦。这是 fail-closed 方向,但体验上会让人困惑:rm -rf build/(相对)能过,rm -rf /Users/x/proj/build被黑名单 Deny,且此时用户无法通过「永久允许」解决(黑名单在规则之前,engine.go:107)。 - 本质是启发式:单条正则跑在未解析的原始命令串上,任何 shell 语义(变量展开、命令替换、
sh -c、base64、/bin/rm全路径、python -c)都能轻易绕过。项目注释(blacklist.go:5)也自陈「启发式、非完备」。它的定位是最低成本的最后一道减速带,不是安全边界——真正的边界必须是沙箱层,而沙箱层偏偏不覆盖 Exec。
permission_upgrade.go 的降级/升级逻辑
该文件只有 12 行,仅定义了一个函数类型,没有任何逻辑:
// mewcode/internal/agent/permission_upgrade.go:9-12
// ApprovalUpgrader 是子 Agent 把审批请求升级到父 TUI 的回调(spec F12)。
// 实现方:TaskManager 把请求转发到主 TUI 的事件流;前台 inline 模式直接复用现有 Approval 路径。
// 返回 (outcome, ok)——ok=false 时调用方应走默认 emit Approval 路径。
type ApprovalUpgrader func(ctx context.Context, req *ApprovalRequest) (permission.Outcome, bool)真正的升级/降级逻辑全在 agent.go:760-845,形成三级瀑布:
dontAsk短路(agent.go:762-784):Ask直接被当作 Allow,runTool立即执行,不产生任何 Approval 事件。dontAsk由 subagent 定义文件的permissionMode: dontAsk触发,且被显式翻译成pMode = ModeDefault; dontAsk = true(subagent/parser.go:83-86)——即「矩阵仍按 default,但 Ask 被吃掉」。它不绕过黑名单/沙箱/deny 规则(那些在Check内已 Deny 返回,走不到 Ask 分支)。approvalUpgrader升级(agent.go:787-831):子 Agent 把请求上抛父 TUI。okUp==true时按 outcome 执行;特别地OutcomeAllowForever走_ = a.eng.PersistLocalAllow(call)——错误被显式丢弃(子 Agent 里写规则失败是静默的,与主路径agent.go:857-859会 emit Notice 不同)。- 降级回默认路径(
agent.go:830-831):okUp==false时不 return,继续往下走requestApproval(agent.go:833),即「升级失败就本地问」。若requestApproval返回ok2==false(emit 失败/ctx 取消),则把本项及其后所有未完成调用填为noticeCancelled并结束本轮(agent.go:834-845)。
另外 ApprovalRequest.Respond 是 buffer=1 的 channel(agent.go:75),TUI 侧 commitApproval 用阻塞发送(tui.go:548),而 Esc/Ctrl+C 兜底用 select { case ...: default: } 非阻塞发送(tui.go:324-327,345-348)——避免 TUI 已交出控制权时再次阻塞。这是「agent 单次接收 + TUI 至多回传一次」的契约。
面试官可能追问(15 条)
Q1:为什么要分五层?每层的唯一职责是什么? A:分层解决的是「确定性 vs 可能性」的分离。第 1 层黑名单是绝对禁止(不可配置、不可绕过,blacklist.go:9),第 2 层沙箱是空间约束(sandboxOK,sandbox.go:54),第 3 层规则是用户意图(三层 YAML,engine.go:121),第 4 层模式是兜底默认(modeFallback 只产 Allow/Ask,engine.go:142),第 5 层人在回路是最终裁决(agent 包编排,mode.go:1-5 注释)。关键不变式:前四层在 Engine.Check 内无副作用、可单测、按顺序短路,Deny 只能由前三层产生,Ask 只能由第 4 层产生——modeFallback 被注释明确约束为「绝不产 Deny」。
Q2:bypassPermissions 到底绕过了什么? A:绕过的是第 4 层模式兜底(engine.go:145-147 的 mode == ModeBypass 直接 Allow)。不绕过:黑名单(engine.go:107)、沙箱(engine.go:112-119)、显式 deny 规则(engine.go:131-133),因为这三层都在 modeFallback 之前 return。可以引用 mode.go:16 的注释「全 Allow(黑名单/沙箱仍拦)」。但仍然绕过了一个真实风险:MCP/未知工具因为 isFile=false 根本不进沙箱,在 Bypass 下会无提示执行。
Q3:Write(*.go) 为什么匹配不到 internal/x/main.go? A:因为文件路径 glob 是按 / 分段的(matchFilePattern,rule.go:118-122)。*.go 只有一段,而目标有三段,matchSegments 在 matchSegment("*.go","internal") 上就返回 false(rule.go:189-193)。必须写 Write(**/*.go)——** 会走 matchSegments 的双分支递归(rule.go:184-187),能跨任意层乃至 0 层,所以 matcher_test.go:110 断言它能匹配裸 main.go。顺带一说,命令串 glob 里 ** 被 strings.ReplaceAll 降级成 *(rule.go:127),与路径语义不同。
Q4:三层配置 local > project > user 的优先级是怎么实现的?有没有坑? A:engine.go:122-129 的 []struct{rs RuleSet; name string} 切片按 {e.local, e.project, e.user} 顺序遍历,第一个命中即 return(不是合并所有层再取最严)。坑有两个:(1) 一条本地 allow 可以覆盖项目 deny——这在团队共享仓库场景下很危险,因为 .mewcode/settings.yaml 的项目 deny 是「团队约定」,而 .mewcode/settings.local.yaml 是个人文件;(2) 层内是 deny 优先(rule.go:236-248),层间却是先到先得,两套优先级规则不一致,容易记混。
Q5:CompileMatcher 的前缀分派有什么设计权衡? A:只查 pattern[0] 一个字节(matcher.go:91)就分派 = / ~ / ! / 缺省 glob,代价是不能以 =、~、! 开头的字面模式——想精确匹配 !foo 这个命令得写 =!foo。收益是递归实现极简:! 分支直接 CompileMatcher(pattern[1:], isCommand),所以 !=foo、!~^rm、!!git * 全部自然可嵌套(matcher_test.go:135-148 验证了双重取反)。matcherNot 的作用域是整个内层匹配器,所以 !Bash(~^rm) 的语义是「不匹配该正则」,注意这里的 ~ 用的是 MatchString 子串语义,~^rm 才能锚定开头。
Q6:matchCommandPattern 是真 glob 吗?举一个它行为的反例。 A:不是完整的 glob,是无回溯贪婪扫描(rule.go:130-152):* 后面的下一个字面字符在目标串中第一次出现的位置就被固定为匹配点,之后不再回退。反例:pattern a*b vs target aXbYb——按标准 glob 语义(* 匹配任意序列、b 只要求结尾)应当匹配,但实现里 * 先吞到第一个 b(rule.go:138-141),随后字面 b 对上了中间的 b,循环结束时 ti=3 != len(target)=5,rule.go:159 返回 false。文件路径那边的 matchSegment(rule.go:198-231)有同样的缺陷,例如 *.go 匹配不到 a.go.go。方向上是安全的(allow 规则漏匹配 → 退回 Ask,fail-closed),但会让用户写的 allow 规则时灵时不灵。
Q7:RuleSet.match 里那个 isFile 参数是干什么的? A:它没用。rule.go:235 的签名收了 friendly, target, isFile,但函数体(rule.go:236-250)只用到 friendly 和 target;glob 的命令/路径语义在 parseRule 时就通过 isCommand := tool == "Bash"(rule.go:70)烘进了 matcherGlob,运行期不再需要。这是重构残留的死参数。Go 编译器不报未使用函数形参,所以能一直留着。面试时可以主动指出来,体现「读代码读到位」。
Q8:MCP 工具(mcp__server__tool)在这套模型里怎么走? A:三步都特殊。friendlyName 走 default 分支原样返回 mcp__demo__echo(settings.go:82-83,mcp/tool.go:31 定义命名);extractTarget 走 default 返回 ("", false, false)(settings.go:160-162);于是 categorize 因为 readOnly 为 false 落到 CategoryExec(settings.go:98-101)。结果:黑名单因 target == "" 跳过(engine.go:107)、沙箱因 isFile == false 跳过(engine.go:112)、规则只有不带括号的 mcp__demo__echo 或 mcp__demo__echo()(Matcher==nil,rule.go:64-67)能命中、最终恒 Ask(Default)或恒 Allow(Bypass)。想只靠通配符允许某台 server 的全部工具是做不到的:Allow(mcp__demo__*) 编译出来是 matcherGlob{isCommand:false},匹配目标恒为空串,matchSegments(["mcp__demo__*"], [""]) 在 matchSegment 里 pi=0 != 9 直接 false。要给每台 server 加白名单,必须逐工具写名,或(源码未体现)在配置层做前缀展开。
Q9:Deny(Write(src/secret.go)) 能不能被绕过? A:能。规则匹配用的 target 是 extractTarget 从 model JSON 里抠出的原始路径字符串(engine.go:104 → settings.go:123-132),不做 filepath.Clean、不做相对/绝对归一、不做 Rel(root, ...)。所以模型传 ./src/secret.go 或 <root>/src/secret.go 时,字符串比较 r.Matcher.Match(target)(rule.go:91-96)失败,deny 规则形同虚设——沙箱层过了(因为都在 root 内),规则层却漏了。正确做法是在 Check 里对文件类 target 先做一次与 sandboxOK 相同的归一化(Clean + EvalSymlinks + Rel),把归一化后的相对路径同时用于沙箱判定和规则匹配。这一点源码未体现。
Q10:OutcomeAllowForever 落盘的规则,会不会把通配符带进去? A:不会,ruleFor 刻意只生成精确规则(persist.go:14-15 注释「不含通配」)。文件类直接用 target 原文当 pattern(persist.go:26-28),命令类先过 escapeGlob(persist.go:29-32)把 * ? [ ] 逐个前面加反斜杠(persist.go:47-55)。但这里有个真 bug:escapeGlob 的转义是给「带转义语义的匹配器」用的,而实际匹配器是 matchCommandPattern,它对反斜杠毫无转义处理(rule.go:146-150 的 case target[ti] 是逐字节比较,反斜杠只是普通字符)。于是 Bash(echo a*) 落盘的规则是 echo a\*,运行期拿它匹配命令 echo a* 时,pattern 的 \ 对不上 target 的 *,直接 default: return false。结论:含 */?/[/] 的命令,选「永久允许」后规则不生效,下次还会重新问。 文件类路径如果含 [(Java 包名、模板文件名)也会同样失灵,只不过文件类没走 escapeGlob,反而「侥幸」是对的。
Q11:PersistLocalAllow 的幂等性和一致性怎么保证? A:三层:(1) 写前读——先 loadSettings(e.localPath) 拿到现有 YAML(persist.go:74),(2) 去重——逐条 strings.TrimSpace(a) == yamlStr 比较,命中就 return nil(persist.go:77-81),(3) 内存同步——写盘成功后 parseRuleWithAllow(yamlStr, true) 再 append 到 e.local.allow(persist.go:97-99),保证同会话内后续调用立即生效。不足:全量 yaml.Marshal 覆写(persist.go:87-94)保留不了注释和字段顺序(Settings 结构体只声明了 defaultMode 和 permissions.{allow,deny},settings.go:15-21,其它字段会被 yaml.Unmarshal 丢弃后再写回——即用户 YAML 里的自定义字段会在一次「永久允许」后消失);无文件锁,多进程并发写会互相覆盖;os.WriteFile 非原子(无 temp+rename),崩溃可能留下截断文件;写失败时内存与磁盘不一致(persist.go:92-99 的 append 在 WriteFile 之后,这点是对的)。
Q12:沙箱是怎么处理 symlink 和「尚未存在的文件」的? A:sandboxOK(sandbox.go:54-73)先按 filepath.IsAbs 决定是否拼 e.root,再 filepath.Clean 消 ..,最后调 evalSymlinksOrAncestor(sandbox.go:24-49)。后者先试直接 EvalSymlinks;失败(ENOENT,说明目标或中间目录还没建)就逐级上升到最近一个能解析成功的祖先,解析它,再用 filepath.Rel + filepath.Join 把剩余段拼回去。这正好覆盖「写 a/b/c/new.go 而 b/c 都不存在」的场景。前缀比对用 resolved == e.root || strings.HasPrefix(resolved, e.root+sep)(sandbox.go:72),/proj 不会误放行 /proj-evil。缺口是 TOCTOU:这一切都是检查瞬间的字符串快照,从 sandboxOK 返回到 registry.Execute 真正 open() 之间存在时间窗,攻击者(或一个恶意 hook)可以在这期间把中间目录替换成指向 /etc 的 symlink。防这个需要 openat2(RESOLVE_BENEATH) 或先在 fd 上 fstat 校验,源码未体现。
Q13:dontAsk 是不是一个后门? A:它是一个受控后门,边界比看起来紧:dontAsk 只在 case permission.Ask: 分支里生效(agent.go:762),而 Check 内的黑名单、沙箱、deny 规则都在 Ask 之前就 Deny 返回了,所以 dontAsk 绕过不了这三层。它绕过的只是「模式兜底矩阵给出的 Ask」。风险在于组合:如果子 Agent 定义同时给了 permissionMode: bypassPermissions(subagent/parser.go:88-89)那 Check 根本不产 Ask,dontAsk 就无从作用;真正危险的是 dontAsk + MCP 写文件类工具——MCP 工具 isFile=false(settings.go:160-162)不走沙箱,dontAsk 又把唯一的 Ask 吃掉,等于无审查执行。合理的加固是:dontAsk 仍应对 CategoryExec 和未知工具保留 Ask(或强制走 approvalUpgrader),源码未体现。
Q14:审批的并发模型是什么样的?会不会死锁? A:agent 是串行的:requestApproval 在 goroutine 里阻塞 select { case o := <-respond: ...; case <-ctx.Done(): return 0,false }(agent.go:994-999),TUI 收到 Event{Approval: req} 后进入 stateApproving,回车/数字键调 commitApproval,向 Respond(buffer=1)发送后切回 stateStreaming(tui.go:544-553)。三点防死锁:(1) buffer=1 让 TUI 不必等 agent 就在 <-respond 上 rendezvous;(2) ctx.Done() 兜底,Esc/Ctrl+C 会先 turnCancel() 取消 ctx;(3) TUI 侧 Cancel 时用 select{case Respond <- DenyOnce: default:} 非阻塞发送(tui.go:324-327),即使 buffer 已满也不卡 UI 线程。另外只读批与串行批之间用 sync.WaitGroup 收敛(agent.go:600-618),审批只在串行批发生,所以同一时刻最多一个待批准请求——m.pending 是单指针(tui.go:91)也印证了这个不变量。
Q15:如果让你给这套系统补一个「审计日志 + 规则热加载」,你会动哪里? A:审计日志的最小侵入点在 Engine.Check 的单一出口:现在是三条 return(engine.go:108/117/132/139 共 4 条),应重构为 defer/名义返回点统一记录 (mode, call.Name, cat, target, isFile, layer, decision, reason),这样五层归因一目了然(reason 里已经带了命中层的名字:engine.go:132 的「匹配 %s deny 规则」)。热加载则要动 Engine 的读取路径:user/project/local 三个 RuleSet 是 NewEngine 时一次性构建的(engine.go:51-64),之后只在 PersistLocalAllow 里增量 append(persist.go:97-99)——外部编辑 .mewcode/settings.yaml 不会生效。建议加 fsnotify + atomic.Value 持有 *RuleSet 三件套(Check 是热路径,必须无锁读),并在 reload 时做「新规则集不允许比旧的更宽松」的可选保守策略(例如只在 Bypass 之外的模式下接受放宽),防止有人在会话中途把 Deny(Bash) 悄悄删掉。这两项源码均未体现。
企业级对应方案
1. 与 Claude Code permissions 的对照
Claude Code 的权限体系同样是「settings 文件里的 allow/deny 规则 + 交互式审批 + 模式(default/acceptEdits/plan/bypassPermissions)」,MewCode 的模式名(mode.go:12-17,含 acceptEdits、bypassPermissions 的拼写)与之一一对应,Tool(pattern) 语法也被继承。但 MewCode 有两处实质性简化:一是没有 ask 规则类型(Claude Code 支持显式 ask 让某条规则强制回到人工确认),MewCode 的 Rule.Allow 只有 bool(rule.go:20),无法表达「这条命令永远要问」;二是**没有 session 级的「本次会话内记住」**层级,OutcomeAllowForever 直接落盘到本地层(persist.go:61),中间缺失「仅本会话有效」这一档——企业场景里这恰恰是最常用的一档(避免把一次性授权永久写进配置文件污染仓库)。要补:Rule 增加 Effect 三值枚举(allow/deny/ask),RuleSet 增加 session 层并置于 local 之前(内存态,进程退出即失效)。
2. 与 Cursor / VS Code 系 Agent 的对照
Cursor 类产品把权限收敛在「IDE 侧 diff 预览 + 逐次 Accept / Accept All / Run without asking」上,编辑权限与执行权限不分离。MewCode 的分离做得更细(CategoryRead/Write/Exec 三类 × 四模式),但缺一个关键能力:写入前的 diff 预审。目前写类工具走到 Ask 时 TUI 只渲染 Name(Args) 预览(view.go:243,Args 来自 argPreview(call.Input)),用户看到的是 write_file({"path":...,"content":"..."}) 的截断字符串,看不到 diff,也没有「本次编辑仅限这些文件」的范围授权。企业落地时这是合规审计的硬需求。要补:为 write_file/edit_file 在 Ask 事件里附带结构化 diff(ApprovalRequest 增加 Diff string / Paths []string 字段,agent.go:71-76 目前只有 Name/Args/Reason/Respond),并在 TUI 提供一个「本次会话内允许写 <root>/src/**」的批量授权项。
3. 与 OpenAI Codex CLI 的对照(外部产品行为,非本仓库事实)
Codex CLI 走的是另一条路:默认在操作系统级沙箱里跑命令,配 --sandbox read-only|workspace-write|danger-full-access 与 --ask-for-approval 两个正交维度,且明确把「网络访问」独立成开关。MewCode 的模式矩阵(engine.go:143-157)在语义上接近 workspace-write + 逐次审批,但有两处硬缺:(a) 没有网络维度——CategoryExec 不区分是否触网,curl https://x | sh 与 go test ./... 在 Default 下都只是一个 Ask;企业里 DNS 级出网控制通常是第一道要求。(b) 沙箱是应用层的字符串比对,不是内核强制的。要补:在 Category 之外引入正交的 Capability 集合(fs.read / fs.write / net / proc.spawn),并把 net 的授权单独走一次人在回路/独立规则;把 sandboxOK 的字符串比对替换为真正的内核沙箱调用。
4. 沙箱技术选型:从 seatbelt 到 microVM
MewCode 的沙箱(sandbox.go)是应用层路径白名单,与任何内核原语都无关。企业生产需要的是「即使 agent 被 prompt injection 完全控制也逃不出去」的边界,可选路径按强度递增:
- macOS
sandbox-exec/ seatbelt profile:本项目是 Go 单二进制、当前工作目录在 macOS 上(/Users/binhy/...)。最小改动是把bash工具的执行从exec.Command改为sandbox-exec -p '(deny default)(allow file-read* (subpath "<root>"))...'。成本低、无需 root,但 profile 语言已 deprecated。 - Linux
landlock+seccomp:landlock提供 unprivileged 的路径级 fs 限制(正是sandboxOK想做但做不牢的事),seccomp砍掉ptrace/mount/socket。适合作为容器内二次加固;Go 侧可用landlock-lsm/go-landlock。缺点是需要内核 ≥5.13,且对已打开 fd 的既有权限无能为力,必须在 exec 之前施加。 - 容器(docker/podman)+ gVisor:把每个 agent 会话放进一个只挂载
<root>的容器,gVisor(runsc)用用户态内核拦截 syscall,防容器逃逸能力远强于 runc。代价是 syscall 兼容性(对go build一般够用,对io_uring/特殊 ioctl 可能不兼容)与启动延迟(~100-300ms)。 - microVM(Firecracker / Cloud Hypervisor):硬件虚拟化隔离,冷启动 ~125ms,适合「一次工具调用一个 VM」的强隔离模型。适合多租户 SaaS 形态的编码 agent(每个用户一个 microVM,跑完即销毁)。
- 托管沙箱(E2B、Daytona):把上述能力打成 API,SDK 层就能
sandbox.commands.run()。对企业内平台而言,这是最现实的起点——不必自建内核能力,先拿到「进程/文件/网络三重隔离 + 可观测 + 可录制」的基线,再逐步下沉。Daytona 更偏「为 agent 准备的开发环境快照/镜像」语义,E2B 更偏「短生命周期执行沙箱」,选型看是否需要长驻 workspace。
对本项目而言的最小可用落地顺序建议:先给 bash 加 cwd 约束 + 在容器内运行(覆盖 90% 的越界风险),再上 landlock 做路径硬约束,最后视多租户需求升级到 gVisor / microVM。
5. 缺失的工程能力清单(生产化 Gap)
除沙箱外,MewCode 权限系统上生产还需要补:
- 审计与不可抵赖:每次
Check的(时间, 会话ID, 调用方AgentID, mode, tool, category, target, decision, 命中层, reason)落成结构化事件(JSONL / OTLP),且审批决定本身也要记录操作者身份。当前ApprovalRequest不带任何身份/时间字段(agent.go:71-76),PersistLocalAllow写文件时也不记录来源(persist.go:61)——事后无法回答「谁在什么时候永久放行了Bash(=curl ...)」。 - 规则集的完整性保护:
.mewcode/settings.yaml直接躺在 agent 的可写工作目录里,而 agent 自己就能write_file/edit_file。也就是说 agent 可以改写自己的权限策略(write_file到<root>/.mewcode/settings.local.yaml会先过沙箱——在 root 内,通过;再走规则——默认 Ask,用户可能点「永久允许」)。正确做法是把项目级策略放到 agent 不可写的路径(或由服务端下发、签名校验),并禁止本地层覆盖 deny 类规则。当前设计里 local > project(engine.go:122-129)恰好把这条逆向放开了。 - 多租户与并发:
Engine是每进程单实例,localPath是固定文件(engine.go:46-48),无锁、无租户隔离。SaaS 化必须「一租户一 sandbox 一策略存储」,并把PersistLocalAllow的读-改-写改成带乐观锁/CAS 的原子操作。 - 策略即代码与集中分发:企业平台需要策略版本化、灰度、回滚,以及 OPA/Rego 或 Cedar 这类可测试的策略语言来替代 10 条手写正则。黑名单本身应改为「从共享的 IOC/危险命令库热加载 + 版本化」,而不是硬编码在
blacklist.go:10-40(当前改一条要发版)。 - 可测试性:
permission/只有matcher_test.go,前四层主流水线零单测。生产前必须补齐:Check的四层归因表驱动测试(含每层短路)、sandboxOK的 symlink/../绝对路径/不存在路径用例、blacklist的正反例(当前连「rm -rf /tmp/x被误拦」这种明显行为都没有测试锁定)、PersistLocalAllow的幂等/并发/YAML 保真测试。特别地,Q10 里指出的escapeGlob与matchCommandPattern语义不匹配,只要写一条「永久允许含*的命令 → 再次调用应命中规则」的端到端测试就会立刻暴露。
附:不确定 / 源码未体现的点(诚实清单)
- MewCode 的 spec/checklist 文档不在本次阅读范围内,所有「F4/F5/F8/F10/F12/N1/N2/N5/N7」引用均为代码注释里的编号,未核对原始 spec。
ApprovalUpgrader的具体实现方(注释提到TaskManager)在internal/agent/permission_upgrade.go中未体现,只在runtime.go:163-168有WithApprovalUpgradersetter;本次未追踪到TaskManager的注入点。- MCP 工具的
ReadOnly()返回值是否可配置(影响categorize是否把它归为CategoryRead)未在 permission 包内体现,需看internal/mcp/tool.go。 - 是否有 Windows 兼容处理:
sandboxOK用os.PathSeparator(sandbox.go:71)而 glob 硬编码/(rule.go:119-120),跨平台行为不一致,但源码未体现平台分支。 - 黑名单/沙箱均无 fuzzing 测试与 benchmark,性能特征未知(
Check是一次json.Unmarshal+ 最多 10 条正则 + 若干层规则线性扫描,热路径上正则MatchString是主要开销)。