Coding Agent Harness 拆解 · 权限与审批门:给副作用装上闸门(六)
本文是《Coding Agent Harness 拆解》系列的一篇。整个系列逐个拆开一个真实在用的终端 AI 编程 agent(codebot),以及它底下的执行内核 agentcore,看清一个"能每天用的 coding agent"到底由哪些子系统拼成。本篇聚焦一条边界上的一道门:模型想改盘、想跑命令、想联网时,那道决定"放行 / 询问 / 拒绝"的审批门是怎么设计、又怎么决策的。
副作用为什么必须有边界
一个 coding agent 最危险的能力,就是它最有用的能力:改文件、跑命令、连网络。模型自己并不能直接做这些,只能提出一个 tool call,副作用发生在本地工具的 Execute 里。这条"模型提议、本地执行"的缝隙,就是副作用边界。
既然所有副作用都收口在这一步,就有了插一道闸门的天然位置:在工具执行前,问一句"这个动作,该不该让它发生?"rm -rf、改动 .git/hooks、写到工作区之外,这些 tool call 参数完全合法、逻辑也没错,问题只在于要不要授权。这属于策略,不属于机制。
::: tip 审批门只是管线的一个前置关卡
一次工具调用要走完 Validator(参数自查)→ Preview(算 diff)→ ToolGate(审批) → Execute(执行)。本篇只放大 ToolGate 这一关;并发调度、中间件、事件时序等管线全貌见《工具系统》。
:::
内核只给机制:一个可插拔的 ToolGate
agentcore 把这道门做成了一个可插拔的钩子,策略不写进内核。看 agentcore/tool.go:
type ToolGate func(ctx, req GateRequest) (*GateDecision, error)
type GateDecision struct {
Allowed bool // true → execute; false → return Reason as an error result
Reason string
}
三个设计要点,全都体现"内核给机制、harness 定策略":
- 内核不做任何权限推理。
ToolGate 的文档白纸黑字:"The agent core does not perform any permission reasoning of its own." 不装门(nil)就等于全部放行。
- 门看得到工具本身。
GateRequest.Tool 直接暴露工具实例,门可以对它做类型断言、读取任何标记接口,而内核完全不需要认识这些接口。
- 拒绝是喂回,不是异常。 被拒的 tool call 会把
Reason 当作错误结果回给模型,模型能读懂并改道,循环不中断。
agentcore 甚至自带了一台现成的策略引擎 permission.Engine(agentcore/permission/),但它的包注释把话说死了:这是可选的引擎,不会被自动接线:"adapt its decisions to an agentcore.ToolGate yourself"。出厂时它就躺着,等 harness 亲手把它接上。codebot 的接法,就是本篇的主角 approval.Engine。
codebot 的四种权限模式
门装上了,但"批不批"的策略是 codebot 定的。启动时用 --mode 选(默认 balanced):
codebot --mode balanced # strict | balanced | accept-edits | trust
四种模式的差别在于按能力(capability)分档:只读(Read)、内部(Internal)、写(Write)、执行/网络(Exec / Network)。同一次 tool call 落在哪一档,就决定了放行、询问还是拒绝:
| 模式 | read / internal | write(edit / write) | exec(bash) | network |
| --- | --- | --- | --- | --- |
| strict | 放行 | 询问 | 拒绝 | 拒绝 |
| balanced(默认) | 放行 | 询问 | 询问 | 询问 |
| accept-edits → ModeAuto | 放行 | 放行 | 询问 | 询问 |
| trust | 放行 | 放行 | 放行 | 放行 |
每种模式的性格:
strict:最保守。读随便读,但凡要改盘都得你点头,跑命令 / 联网直接拒(连问都不问)。适合审阅陌生仓库。
balanced:默认档。读放行,任何有副作用的动作都停下来问你。日常最稳的档位。
accept-edits:放开写。文件编辑不再打断你,但执行命令和联网仍要确认。适合你已信任模型的编辑、只想盯住 bash 的连续改代码场景。
trust:全放行。适合一次性、可回滚、低风险的自动化跑批。
::: tip 有一个命名上的坑
CLI 写的是 accept-edits,但源码里根本没有 ModeAcceptEdits 这个常量:ParseMode(agentcore/permission/profile.go)把 "accept-edits" / "accept_edits" / "auto" 三者都映射到 ModeAuto。ModeAuto 的判定是"Read / Internal / Write 全放行,其余(Exec / Network / Unknown / Hook)询问",正好就是"放开写、盯住跑命令和联网"。别在代码里找 ModeAcceptEdits,它不存在。
:::
一层薄壳:approval.Engine 包住 permission.Engine
NewEngine(internal/approval/engine.go)做的第一件事,就是把一台内核引擎塞进自己肚子里:
e.tool = permission.NewEngine(permission.EngineConfig{
Workspace: cwd,
Mode: permission.Mode(mode),
Rules: (*permission.RuleSet)(rules),
Store: store,
OnAudit: onAudit,
Classifier: classify, // codebot's tool → capability mapping
PlanModeAllowedTools: planModeAllowedTools,
PlanModeExecAllowed: planModeAllowExec,
})
e.tool 的静态类型是一个私有接口 decisionEngine,只暴露 Decide / SetMode / PlanMode / SetApprover 等方法。approval.Engine 的绝大多数方法(Mode() / SetPlanMode() / SetSkillAllows() / SetFilesystemRoots())都是一行转发给 e.tool。长出自己血肉的只有一个:Decide。
::: tip 为什么 codebot 几乎全是类型别名
approval.go 里 Mode、Capability、Choice、Prompt、RuleSet、ApproverFunc 全是 = permission.XXX 的类型别名。这是刻意的:策略引擎的“词汇表”属于内核,harness 不另造一套,只在需要加料的地方(Decide 的两处拦截、危险路径、危险命令告警)写自己的代码。边界卡得很干净。
:::
Decide 的三条岔路
approval.Engine.Decide 不重写内核的决策,它只在委托之前插两道 harness 专属的拦截,其余一律交给 e.tool.Decide:
func (e *Engine) Decide(ctx context.Context, req permission.Request) (*permission.Decision, error) {
workspace := e.cwd
if live := agentcoretools.CwdFromContext(ctx); live != "" {
workspace = live // a worktree entered mid-turn wins over boot cwd
req.Workspace = live
}
if req.ToolName == "exit_plan_mode" && e.PlanMode() {
return e.decidePlanExit(ctx, req) // branch 1
}
if !e.PlanMode() {
if reason := CheckDangerousPath(workspace, req); reason != "" {
return e.askDangerousPath(ctx, req, reason) // branch 2
}
}
return e.tool.Decide(ctx, req) // trunk: delegate to kernel
}
flowchart TB
A["Decide(req)"] --> W["解析 workspace<br/>CwdFromContext 优先于 boot cwd"]
W --> B{"exit_plan_mode<br/>且处于 plan 模式?"}
B -->|是| PE["decidePlanExit<br/>把 plan 当预览让用户批"]
B -->|否| C{"非 plan 模式<br/>且命中危险路径?"}
C -->|是| DP["askDangerousPath<br/>强制询问, 天花板 allow-once"]
C -->|否| K["e.tool.Decide<br/>内核通用策略引擎"]
第一步先解决"在哪判":如果会话中途进了 worktree,运行 context 会带上活的 cwd(CwdFromContext),它优先于启动时的 e.cwd,这样路径检查、审计、CheckDangerousPath 都对齐到工具执行的目录。两处拦截后面单说,先把主干讲透。
主干:模式 × 能力判定矩阵
内核引擎不认识工具名,它只认能力。工具名到能力的翻译由 harness 的 classify(internal/approval/classifier.go)负责,分五桶:
| 桶 | 工具 | 能力 |
| --- | --- | --- |
| 只读文件 | read / glob / grep / ls | Read |
| 写文件 | write / edit | Write |
| 执行 | bash | Exec(只读命令降级为 Read,见后文) |
| 网络 | web_fetch / web_search | Read(GET-only,无本地副作用) |
| 内部 | Skill / ask_user / task_* / subagent / exit_plan_mode … | Internal |
拿到能力后,内核 permission.Engine.Decide 跑一条短路优先级链,命中一条就出决策,不再往下:
- 越界硬拒:写到只读根、或写进只读内部目录。
- deny 规则:用户配的
deny: Bash(rm *) 之类命中。
- plan 模式闸门:非白名单能力直接
deny "plan mode is read-only";白名单内的 Exec/Write/Network 则直接放行(harness 自己担保了安全契约)。
- 内部路径:harness 声明的自管目录(memory / plan / scratch),静默放行。
- 越界询问:目标在用户根之外 → 弹询问,且 AllowAlways/Session 一律降级为 AllowOnce。
- skill 放行 → 已存批准 → allow 规则:依次命中即放行。
- 落到模式 × 能力:以上都没命中,才由
mode 决定。
最后这一步,才是四种模式性格的来源,也是本篇的决策核心。把内核 Decide 尾部那个 switch mode 铺开:
flowchart TB
C{"capability?"}
C -->|"read / internal"| RE["放行(四模式一致)"]
C -->|"write"| W{"mode?"}
C -->|"exec / network"| X{"mode?"}
W -->|"strict / balanced"| WA["询问"]
W -->|"accept-edits / trust"| WP["放行"]
X -->|"strict"| XD["拒绝"]
X -->|"balanced / accept-edits"| XA["询问"]
X -->|"trust"| XP["放行"]
::: tip 只读 bash 为什么不打扰你
balanced 下 bash 是 Exec,本该每次都问。但 classify 会先用 isReadonlyBash(internal/approval/bash_classify.go)探一下:git status、ls、cat foo.go、grep 这类无本地副作用的命令被降级为 Read(key 前缀 exec:readonly:),于是静默放行。判定极度保守:任何重定向 > >> < 一票否决,复合命令每段都得在只读白名单里,sed -i / find -delete / 前置 VAR= 赋值全部踢出。宁可多问(false negative 无害),绝不误放(false positive 会绕过监督)。
:::
exit_plan_mode:把 plan 当预览展示
exit_plan_mode 是个 Internal 能力的工具,但它故意不在 planModeAllowedTools 白名单里(那张名单只有 ask_user 和 tool_search)。原因写在岔路 1:plan 退出不能像普通内部工具那样静默放行,它得让用户先看到计划再点头。
decidePlanExit 把这件事包装成一次标准的 ask:从 planContentProvider(plan.Manager 进入 plan 模式时注册、退出时清空)读出当前计划全文,再把模型在 allowed_prompts 里预告的后续动作拼进去,一起作为 Prompt.Preview 交给 approver。用户点 Deny,就返回一条给模型的反馈 "user denied plan; refine and call exit_plan_mode again",模型据此改计划重来;点任意 Allow,则放行、退出 plan 模式、开始执行。没有 approver(headless / 测试)时兜底放行。
危险路径:强制询问,天花板 allow-once
岔路 2 是 codebot 对内核策略的一道加固。有些路径无论什么模式都得单独确认一次,CheckDangerousPath(internal/approval/dangerous_paths.go)把它们分两类:
| 类别 | 触发 | 例子 |
| --- | --- | --- |
| leak-class(读或写) | 读到会把密钥泄进对话、写到会顶掉凭证 | ~/.ssh/id_*、~/.aws/credentials、.netrc、.pgpass、gcloud 凭证 |
| implant-class(仅写) | 一次写入 → 未来每次都自动执行 | shell rc、.git/hooks/、.gitconfig、.mcp.json、.claude.json、.vscode / .idea 加载配置、.codebot/settings.json(hook 就住这) |
命中后走 askDangerousPath:它绕过模式自动放行、绕过已存批准,直接弹询问,而且复用了内核 OutsideRoots 那套"受限选项"UI,只给 Allow Once / Deny。任何 allow 选择都返回 DecisionAllowOnce,永远不给 AllowSession / AllowAlways。目的是防止一次 "Allow Always" 变成持久后门。没有 approver 时降级为 deny(安全默认)。
两个刻意的取舍:一是从不硬拒,模型是合作的、用户在场,一次性放行是对"看下我的 ~/.ssh/config 帮我调认证"的合理回答;二是 plan 模式下跳过这道检查,因为内核引擎在 plan 模式本就会把写操作直接拒掉,再强制询问只会造出一张用户无从操作的废卡。
bash 要单独说:它的路径藏在命令字符串里,不是结构化参数。所以 CheckDangerousPath 对 bash 特判,用 scanBashForSensitiveRead 扫命令里的路径 token,不然 cat ~/.ssh/id_rsa 会因为 cat 在只读白名单里而整个溜过去。这也是 isReadonlyBash 末尾要再扫一遍敏感读、把这类命令打回 Exec 的原因:两处共用同一个扫描器,堵死同一个洞。
危险 bash 命令:判定之外的告警
上面讲的是判定(放不放行)。还有一层是提醒(放行前让你多看一眼),两者完全解耦。DestructiveCommandWarning(internal/approval/destructive_warning.go)用一组正则识别高危命令形态,返回一句名词短语,不改变任何 allow/deny 决策:
{regexp.MustCompile(`\bgit\s+reset\s+--hard\b`), "may discard uncommitted changes"},
{regexp.MustCompile(`(?:^|[;&|\n]\s*)sudo\b`), "runs with elevated privileges"},
{regexp.MustCompile(`\bterraform\s+destroy\b`), "may destroy Terraform infrastructure"},
// rm -rf / git push --force / DROP TABLE / kubectl delete ...
它挂在 approver 回调里:TUI 弹审批卡前,若工具是 bash 就调一次,把警告塞进卡片。目的正如注释所说:在 trust / always-allow 这些本会静默放行的模式下,给用户按 Y 之前留一个认知检查点。Go 的 RE2 没有 lookahead,所以 git clean -nf(dry-run)也会误报,但权衡很清楚:git clean -nf 多报一次可以接受,git clean -f 漏报不行。
AsToolGate:把 Decision 翻译回 GateDecision
最后一环,是把这台引擎接回内核的 ToolGate 形状。AsToolGate(internal/approval/gate.go)返回一个闭包:进来是内核的 GateRequest,出去是 GateDecision。
func (e *Engine) AsToolGate() agentcore.ToolGate {
return func(ctx context.Context, req agentcore.GateRequest) (*agentcore.GateDecision, error) {
permReq := permission.Request{
ToolID: req.Call.ID,
ToolName: req.Call.Name,
Args: req.Call.Args,
Preview: req.Preview,
Metadata: extractMetadata(req.Tool), // pull capability hints off the tool
}
decision, err := e.Decide(ctx, permReq)
if err != nil || decision == nil {
return nil, err
}
gd := &agentcore.GateDecision{Allowed: decision.Allowed()}
if !gd.Allowed {
gd.Reason = decision.Reason // feed the deny reason back to the model
}
return gd, nil
}
}
Decision.Allowed() 把内核那五种 DecisionKind(allow / allow_once / allow_session / allow_always 都算允许,deny 算拒绝)压成一个布尔;拒绝时把 Reason 带出去,由管线当作错误结果喂回模型。
extractMetadata 是这段适配里最精巧的一手。工具可以自报能力提示(capability / key / summary),但内核不认识 permission 包,不能在 Tool 接口上开一个 PermissionMetadata() 方法。于是 codebot 用一个匿名接口断言去问:
func extractMetadata(tool agentcore.Tool) permission.Metadata {
if mp, ok := tool.(interface {
PermissionMetadata() permission.Metadata
}); ok {
return mp.PermissionMetadata()
}
return permission.Metadata{}
}
注意:这个 PermissionMetadata() 是 harness 侧的约定,不是内核命名的接口,内核对 permission.Metadata 一无所知。工具愿意实现就多一层能力提示,不实现就退回空 Metadata 走 classify 的默认分类,两不耽误。这条匿名接口边界,就是"内核不做权限判断"落到实处的样子。
::: tip 工具不是唯一入口
Decide / AsToolGate 管的是模型发起的工具调用。approval.Engine 还有两个兄弟入口:ApproveHook(PreToolUse 等 hook 命令)和 ApproveCommand(斜杠命令),它们复用同一个 Store 和审计,但走各自的 Capability 与规则。
:::
三问回顾
- 这个机制解决了什么痛点? 让副作用有边界、可审计、可自纠:内核给一个可插拔的
ToolGate 钩子(自己不做权限推理),harness 用一层薄壳把可选的 permission.Engine 接上,classify 补上工具→能力翻译,四模式 × 能力矩阵给出默认判定,Decide 再叠两道拦截(plan 退出预览、危险路径强制询问),AsToolGate 把 Decision 压回 GateDecision。
- 如果没有它,会在哪里崩? 内核出厂时门是空的(
nil = 全放行);没有这层壳,accept-edits 会连 .git/hooks 也照写、cat ~/.ssh/id_rsa 会因为 cat 只读而泄密、exit_plan_mode 会不给用户看计划就单方面退出。危险路径的 allow-once 天花板、只读 bash 降级、危险命令告警,全是这层壳补的洞。
- 它和整个系统怎么接上? 审批门是工具执行管线里 Validator → Preview → ToolGate → Execute 的一关;引擎实例在装配阶段由
WithToolGate(approvalEngine.AsToolGate()) 注入内核;plan 模式的开关则由 Session 编排层驱动。
延伸阅读