Skip to content

17 · 类别开关与信任目录 ​

Tri-state category switches & trusted directories

静态引擎(§03)回答「这一次调用危不危险」,类别层回答「这一类操作要不要问」。工具与 shell 命令被归入 12 个类别,每类可配 auto / ask / deny 三态;未配置 = inherit,行为与没有这层时完全一致(HARD_LOCKED 的 delete/disk 除外,见 17.4)。全部实现是纯函数(src/auto/category.ts#,897 行),宿主在两个接线点各自从零调用。

17.1 十二个类别与优先级 category.ts:LCATEGORY_PRECEDENCE ​

优先级类别典型内容配置约束
12dynamicPlugin动态插件包激活(cordis_run):跑模型写的宿主代码三态可配;未配置 = inherit(默认与既有版本一致),显式 ask 落无倒计时的常驻人工
11privilegesudo/su、set-executionpolicy 等提权LOCKED:仅可 ask(开启 privilegeAutoReview 后三态可配)
10deleterm/del/Remove-Item 等删除LOCKED:仅可 ask
9diskformat/bcdedit/磁盘镜像写LOCKED:仅可 ask
8protected触碰受保护/关键路径的写改LOCKED:仅可 ask(开启 protectedAutoReview 后三态可配)
7networkExeccurl/wget/iwr 外联下载执行;agent 的 web_fetch 同属此类(URL 级安全边界在宿主 fetch provider,插件可用本类目或声明式规则收紧)三态可配
6gitPushgit push 及等价远端变更三态可配
5publishnpm publish/deploy 等发布动作三态可配
4gitLocal本地 git 变更(commit/branch…)三态可配
3fileEdit工作区文件写改三态可配
2build构建/测试/包管理例行命令三态可配
1readOnly只读查询三态可配

类别清单 CATEGORY_KEYS(category.ts:LCATEGORY_KEYS)、锁定名单 LOCKED_CATEGORIES = ['delete','protected','privilege','disk'](category.ts:LLOCKED_CATEGORIES)。另有 harnessInternal 与 unknown 两个非类别归宿:它们没有配置键、恒为 inherit(category.ts:L"if (category === 'unknown' || category === 'harnessInternal') return 'inherit'")——看不懂的东西不给你开自动。

17.2 三态语义 ​

值语义关键边界
auto≡ 按 LOW 档走,LLM 复审仍是最后一关只对「本来就要进语义分类器」的调用生效(ask + classifierEligible,category.ts:L"only applies to an ask-classified, classifier-eligible call");降档不越 HIGH——原判 HIGH/DENY 原地不动(category.ts:LapplyCategoryDirective)
ask无条件转人工;普通类别 = status-less 无倒计时;LOCKED 类 = 恒拒倒计时(默认 10s,超时自动拒绝,绝不因 timeoutAction 放行)pre-execute 快径直接返回,LLM 分类器永远没机会回答一次类别 ask;answerer 侧 LOCKED 类带 action:'reject' 的 countdown status(index.ts:isLockedCategory 判定),普通类别仍直达无状态人工
deny绝对拒绝,提权重试不可绕过与 denyList 同构的终端拒绝(decision.ts:L"{ kind: 'reject', source: 'denyList-deny' }");applyCategoryDirective 里 DENY 是地板,任何配置都压不住它(category.ts:LapplyCategoryDirective)

17.3 双接点机制 ​

两个接点各自调 categoryDirectiveFor 从零重算类别与指令,无任何状态跨越(函数注释明言,category.ts:LcategoryDirectiveFor);同一次调用被两层检查,但不存在「上层记住下层结论」的耦合。guard 层只做硬拒,从不参与类别判定。

17.4 LOCKED 类与三重保险(privilege 可解锁) ​

delete / protected / privilege / disk 四类在配置面上默认只能收 ask。保险有三道:

  1. schema 层:categoryPolicy 的 zod 定义只允许 auto|ask|deny 三值字典(index.ts:L"categoryPolicy: z.dict(z.union(['auto', 'ask', 'deny'] as const), z.string()).default({})");
  2. resolveConfig 层:未知键 warn+丢弃,LOCKED 类别收到非 ask 值一律钳回丢弃并告警(config-normalize.ts:LresolveConfig);
  3. 决策层常量兜底:即便有漏网配置进了运行时,categoryDirective 对 locked 类别的分支也只会给出 ask 或 inherit,绝无 auto/deny(category.ts:L"if (locked && !privilegeUnlocked && !protectedUnlocked && !provenArtifactDeletion)")。

两档锁定分层:LOCKED_CATEGORIES(delete/protected/privilege/disk,category.ts:LLOCKED_CATEGORIES)之上还有更硬的 HARD_LOCKED_CATEGORIES = ['delete','disk'](category.ts:LHARD_LOCKED_CATEGORIES)——后两者任何按名授权的通道都不得预先放行:allowlist、pre-execute 镜像、声明规则的 allow、显式配置一律无效,delete/disk 的批准只能来自人工逐次确认(带恒拒倒计时),绝不静默自动允许;protected/privilege 保留显式 operator override(分别由 protectedAutoReview / privilegeAutoReview 解锁)。理由:delete/disk 的破坏在大规模上不可逆。

哪一层决定「未配置的 LOCKED 询问」(如实写明):delete / disk(HARD_LOCKED)与档位解耦——任何档位下类别层都把未显式配置的询问接管为 ask,pre-execute 立即返回并 pin 成恒拒倒计时(action:'reject',评审器不被问到),timeoutAction 无法结算它们;protected / privilege 维持档位依赖:standard(默认)下未显式配置时类别层给出 inherit,即类别层不介入,该询问由正常评审管线(classifier 快径 / LLM 评审 / 倒计时)裁决、超时按 timeoutAction 结算;aggressive 下同一询问由类别层接管为 ask。任一模态下把 categoryPolicy.<类别> 显式设为 ask 都会得到锁定询问。因此「protected/privilege 一定需要人工」只在 aggressive 档或显式配置时成立,delete/disk 则在任何档位恒拒;protectedAutoReview 的解锁与 HARD_LOCKED 的按名通道禁令均不受该差异影响(后者覆盖两条平面共四个按名站点:规则 allow 与 allowlist 各两处)。

例外一:privilegeAutoReview(默认关,fail-closed)。开启后 privilege 类别从 LOCKED 名单中剔除(delete / protected / disk 仍锁死):配置面上 privilege 可设 auto/ask/deny,未配置时走 inherit——类别层不再强制转人,命令进入正常评审管线(classifier + LLM 评审 + 倒计时)。三层改动:schema 新键(index.ts:L"privilegeAutoReview: z.boolean().default(false)")、resolveConfig 解锁分支(config-normalize.ts:L"key === 'privilege' && raw.privilegeAutoReview === true")、categoryDirective 解锁判定(category.ts:L"const privilegeUnlocked = category === 'privilege'");client 设置卡「分类开关与信任模式」子卡新增同名开关(locale 键 settings.category.privilegeAutoReview),开启后 privilege 行的下拉才出现 自动/拒绝 选项。

例外二:protectedAutoReview(默认关,fail-closed)。解除 protected 的非凭据锁定钳制,但不改变它仍是敏感类别。先说清开关的实际效果:类别 ask 在 pre-execute 处即返回(index.ts 的 directive === 'ask' 分支,分类器快径不执行),所以评审器始终不会被问到;开启本键只是把原来那条「倒计时恒拒、无人能答」的询问换成常驻人工询问(status-less,不再自动拒绝),仍须人工作答。要自动放行必须再把 categoryPolicy.protected 显式设为 auto;直接 inherit 会让策略层的静态放行无任何评审地生效,故不采用。两档的后果要分清(否则会误判成"面板卡住"):关(默认)走 LOCKED 分支——aggressive 档或显式 ask 时倒计时 action:'reject',highRiskSeconds 后自动结算为 timeout-deny,timeoutAction 无法放行,无人盯守不挂起;standard 档且未显式配置时该类别本就 inherit(见上段),走正常评审管线、超时按 timeoutAction 结算。开且未显式配置走 status-less 分支——不发布倒计时状态、永不自动结算,人不在就会一直等(面板显示 ⏸️ Awaiting human approval — no auto-countdown. 正是这一档的标记,不是故障)。默认值为关;standard 档未显式配置的 protected 询问走正常评审管线,恒拒倒计时只出现在 aggressive 档或显式 ask。

凭据读取地板(本键不适用):protected 同时涵盖工作区敏感文件与受保护元数据(.env / .npmrc / .git/* / .vscode/* 等)以及凭据树的读取。这两半的风险不同,因此策略层把后者标成结构化字段 credentialRead(sensitiveBasenameAt 或 isCriticalPath 命中即置位),categoryDirective 与 answerer 的 isLockedCategory 都对它保持锁定——~/.npmrc、~/.ssh/…、~/.aws/credentials 这类凭据读取在本开关开启时也不解锁。启用本键真正解锁的只有非凭据的工作区元数据(.git/、.vscode/ 等)。解锁判定读 category.ts 的 protectedUnlocked,answerer 的锁定谓词(index.ts 的 isLockedCategory)同读同一字段,两平面一致;设置卡开关(settings.category.protectedAutoReview)随分类卡一起保存。

地板的三条边界(如实写明):①写头读源(cp <凭据> out、tee out < <凭据>、dd if=<凭据>)不落 write 快径(走语义评审,非静态放行),且同样置位地板;②地板按类别层视角生效——它只对类别为 protected 的调用起作用,tee out < <凭据> 这类被类别层判为 fileEdit 的命令由上面的「不落快径」保护,而非由本键的锁定谓词保护;③opaque 行(含 (/{/$(/heredoc 等无法静态分解的行)在类别层落到 unknown,因此既不受本键解锁、也不进地板——这类行的凭据读取是一次普通倒计时询问(timeoutAction=allow 下可被超时结算)。该残余面已登记 backlog,不在本键语义内。(此前同列的 shell 面 junction 逃逸已由 docs/03 §3.4 的收窄型复检覆盖。)

例外三:已证实的会话自建物删除(无需配置,始终生效)。delete 仍是 LOCKED,但策略层对「删除目标全部是本会话成功创建过的路径」有不依赖配置的出处豁免(shell.ts 的 artifact 分支 → allowed('delete exact session-created artifacts'))。该豁免以结构化字段 sessionArtifactDeletion 带出,类别层的锁定钳制与 answerer 的锁定谓词都读它——否则类别层看不到 artifact 注册表,会把这条静态放行拦成锁定询问,使豁免在 aggressive 模式下永远不可达(修复见 commit cb02a3d)。红线遵守:授权性信号走结构化通道,不从 reason 文本解析。

例外三的边界(如实写明):出处只由结构化写工具(write / edit / apply_patch)在 post-execute 的 artifacts.settle(...) 登记;shell 重定向与输出 flag 产生的文件不进注册表(例如 sort -o out.txt、> out.txt)。因此删除这类文件时 sessionArtifactDeletion 不成立,仍按 LOCKED delete 落恒拒倒计时(timeoutAction 无法放行)。这是 fail-closed 方向的已知代价而非故障:按路径推断的删除豁免只对「由结构化写工具登记的确切路径」生效,不扩到 shell 产物;需要删除时用结构化工具创建、或用结构化工具删除(亦可用面板「允许一次」)。

LOCKED 类的转人行为:LOCKED 类(delete / protected / disk;privilege 未解锁时)的类别 ask 不再是 status-less——answerer 注入硬拒倒计时(action:'reject' 恒拒、秒数取 highRiskSeconds 默认 10),无 LLM 接管 handle、无学习上下文;超时未响应自动 timeout-deny(agent 收到「no response: auto-rejected」),任何 timeoutAction 配置都无法把它变成自动放行。delete / disk 未显式配置时在任何档位都落这一形态(与档位解耦);protected / privilege 在 standard 档未显式配置时走正常评审管线(见「哪一层决定未配置的 LOCKED 询问」段)。无人值守会话不再因危险命令无限挂起;面板上拒绝按钮带 10s 倒计时可直接点击。这类询问在面板文本里带 LOCKED_ASK_MARKER(host 写 token、client 渲染本地化句子),明说「对话里给出的授权对该类询问不生效、只有在本面板点『允许一次』可放行」——此前它与普通倒计时在界面上无法区分,用户以为自己已经授权过而在等一个永远不会到来的答复(收到该提示最早在 panelDelayMs 后面板展开时)。

17.5 复合命令:类别取先、指令取严 ​

一条 bash 可能串了多段命令。categorizeCommandSegments 先做词法分解再逐段归类;读不懂的整行(opaque)退化为单个 unknown 段——不瞎判(category.ts:LcategorizeCommandSegments)。合并规则 mergeCommandDecisions(category.ts:LmergeCommandDecisions)双轨取值:

  • 类别取先:按 §17.1 优先级表,最高优先级类别的标签胜出(git push && rm x 归 gitPush?不——delete 10 > gitPush 6,归 delete);
  • directive 取严:deny > ask > auto > inherit,任一段最严的指令决定整行待遇。

17.5b 写重定向与写向量族判类 ​

  • 写重定向目标参与判类:含 >/>>/>|/&>/N> 写重定向(非 discard sink)的命令段按目标先行判类——命中敏感名/受保护路径 → protected,否则 → fileEdit,与显式写工具同语义;按 §17.1 优先级取严合并,delete/privilege 永不被拖低。
  • POSIX 写向量族五头:tee、dd of=、sed -i(含后缀/--in-place 形态)、truncate、install 以操作数目标参与全部按目标闸门——直写运行态文件(history/audit 等)无条件硬拒,敏感/受保护目标与 cp/mv 同流;sed 不带 -i、dd 无 of= 保持读语义;dd 恒属 disk 锁定类。

17.6 信任目录模式 categoryMode 与敏感名熔丝 ​

位置谓词 isEffectiveRoutine(target, roots)(category.ts:LisEffectiveRoutine)决定「工作区内的例行放行」认哪些地方:

模式判定
standard(默认)workspace ∪ trustedDirs 内才认(category.ts:L"(roots.trustedDirs ?? []).some((root) => isWithin(normalizePath(root, roots.workspace, roots.home), normalized))";category.ts:L"return isWithin(roots.workspace, normalized)")
aggressive直接 return true——位置不限(category.ts:L"if (roots.mode === 'aggressive') return true")

aggressive 下三个内置类别 ['networkExec','gitPush','publish'](AGGRESSIVE_BUILTIN,category.ts:LAGGRESSIVE_BUILTIN)在未显式配置时隐式取 auto(category.ts:L"AGGRESSIVE_BUILTIN.includes(category as CategoryKey) && mode === 'aggressive' ? 'auto' : 'inherit'")——这就是「切激进会自动放行网络读写/git push/发布」的出处;显式配置过则听你的。

危险度门全部不动:敏感名熔丝 sensitiveBasenameAt(category.ts:LsensitiveBasenameAt)对任意位置的 .gitconfig/.netrc/.npmrc/.pypirc/.mcp.json/.bash*/.env(非 example) 与 .ssh/.gnupg/.aws/.azure/.kube 目录段生效(名单 category.ts:LSENSITIVE_BASE,含 .gitmodules 与 .config/gcloud 双级标记)——换什么模式都拦着;插件运行态文件硬拒、symlink realpath 复检同样与模式无关(§3.2/§3.4)。

17.7 trustedDirs 配置面 ​

  • 校验:仅收绝对路径;凭据树(.ssh/.gnupg/.aws/.azure/.kube)、home、dshHome、critical 路径内的条目 warn+丢弃,余下归一化入库(resolveConfig,config-normalize.ts:LresolveConfig)。
  • 设置卡控件:「分类开关与信任模式」子卡内有 trustedDirs 文本框(每行一个绝对路径),属 44 员可编辑键(decision.ts:LEDITABLE_CONFIG_KEYS);写入仍经上面那条钳制,卡片只是路径的第二种写法,settings.yaml / patch 里的声明同样生效。
  • 复检扩区:symlink 守卫把 trustedDirs 并入受信复检区(workspace ∪ 插件区 ∪ trustedDirs,symlink.ts:L"const trustedZone: string[] = [...(roots.allowedDshSubpaths ?? []), ...(roots.trustedDirs ?? [])]")——文本上落进信任目录的目标照样做真实路径逃逸检查(realpath 逃逸硬拒,symlink.ts:L"const escape = realpathCriticalReason(textual, normalized, roots, roots.trustedDirs, realWsNormalized)")。

配置示例(默认零变化) ​

yaml
auto-approval-llm:
  # 什么都不写 = 全部 inherit = 行为与本层不存在时一致
  # categoryPolicy: {}
  # categoryMode: standard
  # trustedDirs: []
  # ---- 以下为主动收紧/放宽的样子 ----
  categoryPolicy:
    fileEdit: auto      # 工作区文件写改:降为 LOW 档(仍送 LLM 复审)
    networkExec: ask    # 外联下载(含 web_fetch):无条件转人工
    gitLocal: deny      # 本地 git 变更:绝对拒绝
    # delete/protected/privilege/disk 写 auto/deny 会被 warn+丢弃,仅 ask 有效
  categoryMode: aggressive   # 取消位置白名单:任意位置视为常规位置(危险度门、敏感名 fuse 不动)
  trustedDirs:
    - C:\projects\shared-lib   # 绝对路径(POSIX 如 /opt/shared-lib);落在凭据树/home/critical 内会被丢弃