Day 09 / 共 20 天 · 第 2 周 核心引擎 · 加深版

权限系统

Agent 能动手改你的电脑,凭什么信它?权限系统就是那道闸。这一页贴 permissions.ts 的真实判定代码,讲清 5 种模式、过闸顺序、以及"哪些操作再高的权限也拦不住"。Day 07/08 讲工具"能干什么",今天讲工具"动手前要不要先经你同意",Day 10 收官第 2 周。

📍 你在整门课的位置 · 第 2 周 核心引擎
D06 QueryEngine D07 工具接口 D08 内置工具 D09 权限 D10 上下文/Token
💡 用一个类比先兜住今天(本日世界观:施工前"签字确认") 权限系统就是装修工人动手前的签字确认制度:工人(Agent)想砸墙(改文件)、想接电(跑命令)前,得先拿单子给你签。5 种模式=你给的信任档位:plan(只准看图纸不准动)→ default(动手前都问你)→ acceptEdits(在自家范围内改可以不问)→ bypassPermissions(基本随他干)。但关键在于——哪怕你签了"随便干",动"承重墙 / 燃气总阀"(.git/.bashrc 这类危险文件)仍然必须再签一次字。今天最该记住的一句:最大的放行档位,也保留最小的安全底线。
L01

为什么需要权限

🤔 痛点:如果没有这道闸,会出什么事故? 你让 Agent"清理一下临时文件",它一时"脑补"出一句 rm -rf / 就直接执行——你的电脑就没了。模型会判断错、也可能被网页/文件里的恶意文字诱导(提示注入)。只要它能不经你同意就动手,一次失误就是灾难。所以必须在"真动手前"设一道闸。

模型会自主决定调 Bash 跑命令、调 Edit 改文件。但模型可能判断错、也可能被恶意输入诱导。权限系统就是"在工具真执行前踩一脚刹车"——高风险操作停下来问你,低风险的放行,还能按规则批量允许/禁止。

Day 07 L08 讲过:执行管线里"权限检查"排在 tool.call() 之前(第 6 关)。今天钻进那关。核心文件 src/utils/permissions/permissions.ts,文件系统相关在 filesystem.ts(Day 08 见过危险清单),Bash 在 bashSecurity.ts(Day 08 的 tree-sitter)。

L02

5 种权限模式

你用 Shift+Tab 切换的就是这些模式(src/types/permissions.ts:15):

default

正常询问:高风险操作弹窗确认

acceptEdits

工作目录内的编辑自动放行,其它照常问

plan

只读规划,不动手;ExitPlanMode 提交方案后才开工

bypassPermissions

几乎全放行——但 deny 规则、ask 规则、safetyCheck 仍免疫(L05)

dontAsk

把所有 ask 直接转成 deny(宁可不做也不问)

用一个"信任滑杆"理解 plan(只看不动)→ default(动手要问)→ acceptEdits(改文件不用问)→ bypassPermissions(基本全放)。越往右越省心但越危险。dontAsk 是另类——不是"更信任",而是"别烦我,拿不准的就别做"。实际最常在 defaultacceptEdits 之间切。另有内部模式 auto(不弹窗、用 AI 分类器判,L07)。
L03

权限规则从哪来

除了模式,还有规则——三种行为 allow/deny/ask,可精确到工具甚至参数。配在 settings.json:

{
  "permissions": {
    "allow": ["Read", "Bash(git status)"],   // 整工具 或 参数级
    "deny":  ["Bash(rm *)", "Edit(/etc/**)"],
    "ask":   ["Bash(git push*)"]
  }
}
整工具规则 vs 参数级规则 Bash = "整个 Bash 工具";Bash(git status) = "只有跑 git status 时"。参数级让你精细控制——"允许 git status,但 git push 要问"。你在弹窗里点"始终允许 Bash(git status)",就是往规则里加一条参数级 allow。
📝 举个例子:同样是 Bash,命运不同 规则:allow: ["Bash(git status)"]ask: ["Bash(git push*)"]deny: ["Bash(rm *)"]
模型想跑 git status → 命中 allow → 静默放行
模型想跑 git push origin main → 命中 ask → 弹窗问你
模型想跑 rm foo.txt → 命中 deny → 直接拒绝,连问都不问。同一个 Bash 工具,靠参数级规则精细分流。

规则来源有优先级(permissions.ts):userSettings / projectSettings / localSettings / flagSettings / policySettings / cliArg / command / session——企业策略(policy)最高。它们最终落到权限上下文的 alwaysAllowRules/alwaysDenyRules/alwaysAskRules

L04

判定顺序:一步步过闸(真实代码)

入口 hasPermissionsToUseToolpermissions.ts:473)→ 内部 hasPermissionsToUseToolInner:1179)。源码里用注释标了 step 编号,逐条过。看开头两步的真实代码:1190):

// 1a. Entire tool is denied
const denyRule = getDenyRuleForTool(appState.toolPermissionContext, tool)
if (denyRule) {
  return { behavior: 'deny', ... }        // ← 命中 deny 规则,直接拒绝(最先、最硬)
}

// 1b. Check if the entire tool should always ask for permission
const askRule = getAskRuleForTool(appState.toolPermissionContext, tool)
if (askRule) {
  // ...(sandbox 自动放行是例外)
  return { behavior: 'ask', ... }         // ← 命中 ask 规则,弹窗询问
}
读法:先查有没有"禁止规则"(deny)——有就立刻拒绝、不再往下看再查有没有"询问规则"(ask)——有就弹窗。这两步排在最前面,是刻意的。

完整过闸顺序(源码 step 编号):

1a

整工具 deny 规则 → deny(最先,最硬)

1b

整工具 ask 规则 → ask

1c

调工具自己的 checkPermissions(Day 07/08,如 Read 的"工作目录内放行"、Bash 的 AST)

1d

工具返回 deny → deny

1e-1g

需要用户交互 / 内容级 ask / safetyCheck(.git/.claude 等,Day 08)→ ask(bypass 免疫

2a

模式放行:bypassPermissions → allow

2b

整工具 allow 规则 → allow

3

passthrough 转 ask(工具没明确表态,交给用户)

顺序里藏着整个安全策略:禁止 > 询问 > 允许。 deny/ask(1a-1g)都排在"模式放行"(2a)前面——所以它们能"免疫"bypass。源码注释(:1083)明说:"bypassPermissions mode respects everything that fires before step 2a"(bypass 模式尊重 step 2a 之前触发的一切)。这个"顺序编排"就是安全的核心:把"不该做的"和"要确认的"放在"放行"之前判,且让它们凌驾于放行模式。
过闸顺序:禁止 > 询问 > 允许 一次工具调用 1a/1d deny 规则 → 直接拒绝 🚫 1b/1e-1g ask + safetyCheck → 弹窗问你 ✋ ━━ bypass 免疫线:这条线以上,再高的放行模式也拦不住 ━━ 2a bypass 模式 → 放行 ✅ 2b allow 规则 / 3 passthrough→ask 从上往下逐条过;上面先命中就短路,轮不到下面的放行
图注:deny/ask/safetyCheck 都排在"放行"之前,所以它们在虚线上方——即使开了 bypass 全放模式也照样拦得住。
L05

哪些连 bypass 都拦得住

很多人以为 bypassPermissions = "全放行"。不对。由 L04 的顺序(deny/ask/safetyCheck 都在 2a 之前)可知,即使 bypass 模式,这几类仍然拦得住

  • deny 规则(1a/1d):你明说"绝不许"的红线,永远禁止。
  • ask 规则(1b/1f):你明说"要确认"的,仍会问。
  • safetyCheck(1g):动 .git/.claude/.bashrc/.mcp.json 等危险文件仍会问(Day 08 那份清单)。
  • 需要用户交互的工具(1e):如 AskUserQuestion。
为什么 bypass 也留这几道闸?(核心设计哲学) 因为"绕过权限"是为了省去常规确认的烦扰,不是为了让 Agent 能干灾难级操作。deny 是你划的红线;safetyCheck 是"改这个可能毁掉环境或泄密"的雷区。这些无论多信任都该留一道人工闸——否则一次幻觉就能删库、改坏 shell 配置、注入恶意 MCP。"最大放行模式也保留最小安全底线"——这是整个权限系统最值得学的一条。gov-agents 教程的失败闸门、敏感度红线也是同一思想:有些底线不可绕过。
L06

审批弹窗 = await 一个 Promise(真实代码)

判定出 ask 后,怎么变成你看到的那张权限卡、并"等你点"?hasPermissionsToUseTool产出决策,真正弹 UI + 等待的是 useCanUseToolsrc/hooks/useCanUseTool.tsx:52)。真实代码:

return new Promise(resolve => {           // ← 创建一个 Promise,先不 resolve
  const ctx = createPermissionContext(tool, input, ...);
  if (ctx.resolveIfAborted(resolve)) return;

  const decisionPromise = hasPermissionsToUseTool(tool, input, ...);  // 算出 allow/deny/ask
  return decisionPromise.then(async result => {
    if (result.behavior === 'allow') { ... resolve(...) }   // 直接放行
    // 若是 ask:把一个 {tool, input, onAllow, onReject} 压进确认队列,
    //          等用户点"允许/拒绝"时才 resolve 这个 Promise
  });
});
这个模式太妙了,值得单独理解。 工具执行是异步的(await canUseTool(...))。当需要用户确认时,代码创建一个 Promise 但先不 resolve,把它的 resolve 函数塞进 UI 的确认队列。界面渲染出权限卡(Day 04/18 讲的队列渲染),你点"允许"就调 onAllow()resolve('allow'),那个 await 才继续往下。于是"等一个人类点按钮"被表达成了"await 一个 Promise"——工具执行天然地在这一步挂起,不用轮询、不用回调地狱。
Promise 是什么?resolve 又是什么? Promise 是 JS 里表示"一件未来才有结果的事"的对象。await 一个 Promise = "在这等它有结果再往下"。resolve 是"给这个 Promise 填上结果"的函数——一调用,所有 await 它的地方就继续。这里的巧思:创建 Promise 时把 resolve 存起来(塞进队列),等用户点按钮那一刻才调它。本质就是:"用户的点击"当成"未来的结果",用 Promise 把等待表达出来。

权限卡组件是 PermissionRequestcomponents/permissions/),内部按工具类型分派到 BashPermissionRequest / FileEditPermissionRequest 等具体样式。

👶 小白:程序在"等我点按钮"的时候,它到底卡在哪一行、又没卡死整个界面,怎么做到的?

👨‍🏫 老师:卡在 await canUseTool(...) 那一行——但"卡"的只是工具执行这条异步链,界面(另一条 React 渲染线)照样能动、能接收你的点击。诀窍是:创建了一个 Promise 却先不给它结果,把它的 resolve 函数存进确认队列;你点"允许"那一刻才调 resolve('allow'),那行 await 立刻续上。等一个人点按钮 = await 一个"由按钮来 resolve 的 Promise",既不轮询也不回调地狱。

L07

auto 模式:用 AI 分类器代替弹窗

auto 模式(permissions.ts:519 附近)不弹窗,改用一个 AI 分类器自动判断这次操作安不安全。判定路径:

  1. 先试 acceptEdits 快速路径(工作目录内编辑直接过)。
  2. 查"安全工具白名单"(如只读工具直接放)。
  3. 都不满足 → 调分类器(一个小模型)判"这个操作危险吗",据此 allow/deny。
auto 模式解决什么? default 太烦(每步都问),bypass 太险(全放)。auto 是折中——用一个便宜的 AI 分类器替你做"这操作要不要拦"的判断:只读/编辑这类明显安全的直接放,可疑的(删文件、改系统配置)才拦。相当于配了个"自动审批助理"。这是本 fork 的特色能力。无人值守时(headless/后台代理,没人能点弹窗)走另一条:拿不准就默认拒绝——"没人确认 = 不做",安全优先。
还有一层"精简版复核" checkRuleBasedPermissions:1088):即使 hook 或分类器说"放行",仍会用它复核一遍 deny/ask 规则——hook/分类器不能绕过你明确设的 deny 规则和 ask 规则。又一处"底线不可绕过"。
L08

今日小结 + 动手

🧠 今天你应该能回答

  • 5 种权限模式各是什么?(用"信任滑杆"记)
  • 权限规则三种行为 + 整工具 vs 参数级?
  • 判定顺序为什么"deny/ask 在 allow 前"?(禁止>询问>允许)
  • bypassPermissions 拦不住哪几类?(deny/ask 规则、safetyCheck、交互工具)为什么留这些底线?
  • 审批弹窗怎么用"await 一个 Promise"实现?(resolve 存进队列,点按钮才调)
  • auto 模式怎么工作?为什么无人值守时默认拒绝?

✋ 动手:对着真实代码读一遍

# 1. 权限模式定义
sed -n '15,45p' src/types/permissions.ts

# 2. 判定顺序 step 1a/1b(L04,真实代码)
sed -n '1179,1228p' src/utils/permissions/permissions.ts

# 3. bypass 免疫的注释(L05)
sed -n '1083,1090p' src/utils/permissions/permissions.ts

# 4. "await 一个 Promise" 的审批(L06)
sed -n '46,104p' src/hooks/useCanUseTool.tsx

# 5. 亲身体验:起 dev,Shift+Tab 切模式,让它改个文件看弹窗
bun run dev
明天预告 · Day 10:第 2 周收官。对话越来越长会撑爆上下文——Day 10 加深版会贴 query.ts 里"每轮开头分级瘦身"的真实代码:microcompact 微压缩、autocompact 大摘要、以及 Stop hook 续命 / token 预算这些"何时才算真结束"的协商机制。

← Day 08 内置工具 Day 10 · 上下文与 Token 管理 →