# AGENTS.md 给在本仓库开发的 agent。先读 [README.md](README.md) / [README.zh.md](README.zh.md),再改代码。 ## 这是什么 DeepSeek Harness 的 Cordis 插件:审核模型自动审批 + 设置页。范围仅限审批。 对 DSH 审批栈是一个 `approval/request` answerer:允许 / 拒绝直接返回 outcome;转人工 `await next()` 交给原网页框。 Host API 以 DeepSeek Harness 源码为准(审批 / 预设 / LLM)。 ## 硬约束 - **改名必须四同步**:`package.json` name、`cordis.patch.yml` 的 name、`src/util.mjs` 的 `NAME`(→ index.mjs `export const name`)、`client.js` 的 `__ModuleLoader__.load({ id })` + `exports.name`。只改 package.json 重装会炸 `loaded without registering "..."`(client bundle 找不到注册)。RPC 路径 `/api/<名>` 与 client.js 的 `rpc.call('/api', '<名>')` 需一致(可独立于插件名)。 - `permissionPresets.current(session)`,禁止 `session.events`。 - Host Session 工作目录是 `session.header.cwd`,没有 `session.cwd`。 - `danger-full-access` 进入同一条判定管道,不因模式名短路。 - **出厂关键词表只保留三类「零上下文就确定灾难、不该让模型有发言权」的红线**:① 清根 `rm -rf /`(写法同时覆盖 `rm -rf /*` 与 `sudo rm -rf /`,因为 `/` 后跟非字母数字就算词尾);② 裸设备覆写/格式化(`of=/dev/`、`mkfs`、`wipefs`、`Format-Volume`、`Clear-Disk`、`diskutil eraseDisk`);③ 门控自身配置(`DEFAULT_APPROVAL_CONFIG_KEYWORDS`)与私钥/云端凭据(`DEFAULT_SECRET_PATH_KEYWORDS`,**不含** `.env`/`.npmrc`/`docker/config.json`——这些工具会自己改写,交给 `credential` 行判)。**拼写变形要匹配得到**:`rm -rf "/"`、`m'k'fs.ext4`、`rm -rf ${IFS}/`、`rm -rf -- /`、`rm -rf --no-preserve-root /`、`rm -rf $'\x2f'` 在 shell 眼里与红线完全一样,所以匹配前先过 `shellNormalizeForKeywords`(行续接、`${IFS}`/`$IFS`→空格、ANSI-C 引用解码、反斜杠转义、去引号、折叠空白、吃掉独立的 `--` 与 `--no-preserve-root`),**只在原文本没命中时额外试一次**——只多拦、不少拦(实测六种拼写此前全部 allowed-once)。**`--no-preserve-root` 只归一化、不进词表**:归一化把它吃掉之后 `rm -rf --no-preserve-root /` 回到出厂词 `rm -rf /` 的形状,而把它写成词表项会让**只是提到**这串字样的命令硬拒(`git log --no-preserve-root`、`man rm --no-preserve-root`)——那是「需要上下文」的词,违反出厂表定义;归一化对所有用户(含升级)都生效,所以词表项是多余且有害的。已知边界(不覆盖,别再扩):`${var}` 变量展开、`$'\uXXXX'` 之外的 shell 分词差异。**需要上下文才能判危险的词一律不加回词表**(递归删除家族、`chmod -R 777`、`git push --force`、破坏性 SQL、`terraform destroy`、`docker prune`/`volume rm`、关机重启),由审核表按说明判;新增/删除出厂词时同步 `RETIRED_DEFAULT_KEYWORDS` 留档(已有用户文件里的同名词不会被迁移删除,用户可自行挪桶)。 - **管道:关键词拒绝 → 「参数没采集到」无条件直接拒绝(并提示模型重发)→ 关键词人工 → 「看得见吗」闸门(撞收集护栏 / 超过送审上限按 `truncatedAction`)→ 关键词允许 → 审核模型(类别 + 风险等级 + 理由)→ 按 (行, 等级) 查三格动作**。**拒绝与人工两个关键词桶都在闸门之前**(它们是用户显式写的意图,不能被 `truncatedAction` 静默盖过;`tests/pipeline.test.mjs` 的「闸门顺序」用例两头都覆盖);**唯一的例外是「参数没采集到」**——那一态连参数都没有,弹框等于让人盲批,所以人工桶也不例外。**没有任何「内容多少」的闸门**:只要没超上限,卡片上有什么就原样交模型判(含空参数、只有 description 的调用)——用户明确要求取消 `missingPayloadAction`,那个开关已从配置、设置页、rule-op 里整套删除。(顺序的理由与回归用例见下面那条。)一条命令有多段(管道 / `&&` / `;`)时,提示词要求**同时**按最不可回补的一段给出类别与等级,关键词层本来就看整条命令。出厂三格**每一行都是同一套**:low 允许 / medium 人工 / high 拒绝——等级就是默认风险刻度(`DEFAULT_ROW_ACTIONS` / `defaultRowActions()`)。提示词里有两条兜底面:① 一条命令多段时按最不可回补的一段;② 多行都像时选后果更不可回补的一行、等级也按那一行给——关键词表缩小后这两条是主要安全网,改框架时不要删。类别判定要求**表内结果唯一**(见下面「分类解析」那条):两条互不相同的类别行——无论带什么行首装饰、谁先谁后——都按歧义失败关闭(`src=none` → 兜底行),不再「一律取最后一个」;卡片回显既可能排在结论前面也可能在后面,last-wins 就等于让回显决定放不放行。 - **非表内结果分两类**(`resolveFallbackAction` / `JUDGE_FAILURE_SRCS`):① **「判定压根没跑成」——空输出 `empty`、超时 `timeout`、调用失败 `call`、无可用路由 `route`、插件异常 `plugin`——固定转人工,不查 `other` 的三格**(这些不是模型的结论:按 high 格执行会变成「没有任何人参与的硬拒绝」,一次网络抖动就成了阻断;而 low 格的自动放行更不该由故障触发);② **模型答了但类别认不出(`src=none`)仍按 (other, 等级) 查格**——那是真实回答,只是无法归类,等级认不出才走 `levels.fallback`。插件里依旧没有硬编码动作:「失败转人工」是 `resolveFallbackAction` 里那一条规则——所以 `plugin-error` 那段**只留转人工一条路**(曾经还留着 `action === 'reject'/'allow'` 两条分支,恒不可执行却让人读成「插件异常可以自动放行」;现在真出现别的动作就 `console.error` 留痕并失败关闭)。请求被取消(`judged.aborted`)不产生 verdict,直接返回 `cancelled`。判定来源写进审计行与事件顶层 `src`(`clipJudgeForEvent` 还带 `level` / `levelSrc`),它是「模型答了 other」与「判定压根没跑成」的唯一区分手段——不要顺手删掉(判定失败的事件没有 `level`:空字符串字段会被 `put()` 整条丢掉,判据用 `src`)。 - **审核调用的输出预算按路由能力给,不按「配没配档位」给**:`judgeMaxTokens(effort, modelInfo)` —— `off` 与不配档位在适配层是**同一个请求**(DSH 适配层 `reasoning === 'off' ? undefined : reasoning` 先删一次、pi-ai 收到 `off` 再折一次,所以 `off` 的语义就是「不表态」;真关要看路由的 `compat.thinkingFormat`(用户侧就是 `settings.yaml` 里那个模型的 `reasoningEfforts.off`,适配层再折成 `thinkingLevelMap.off`),插件碰不到也不该碰)。**`off` 是历史拼写,不许再出现在 UI 或落盘配置里**:`normalizeJudgeEffort`(在 `mergePluginConfig` 收口,读盘/迁移/恢复默认/保存四条路都过它)归一成空,设置页只留「模型默认」。留着它会走「档位必须在路由档位表里」那条校验:路由不列 `off` 时**每次判定都以路由失败告终**(全量转人工),而它在适配层不可能"不受支持"。预算档位 2026-09-16 起:会推理的路由(有 `off` 之外的档位**或**路由报告有推理能力)首轮 **8192**(`JUDGE_MAX_TOKENS_REASONING`,设置页可配 `judge.maxTokens` = 256..32768,越界 clamp + `warnClampedSettings` 留痕),不推理仍 256。1024 会被推理吃光 → 空正文 → 重试(见 README);`maxTokens` 是**上限不是预扣**。**空输出重试必须严格大于首轮**:`max(8192, 首轮×2)`;其余翻倍且 ≥1024。分类认不出 / 请求已取消都不重试。放宽预算的前提是 `callJudge` 仍只累加 `text-delta`:不要把 `reasoning-delta` 拼进正文,只统计字符数。**这条失败链必须对用户可见**(否则「每次判定都转人工」在用户眼里就是插件坏了,而现场只剩 audit.log):`withRetry` 的每个终态记一次 `noteJudgeOutcome`(**取消**与**超预算**不记——前者没有结局、后者压根没问模型),`snapshot.judgeHealth` 把进程内计数交给设置页(重启清零),`judge-selftest` 拿固定小卡片真跑一次(`{ track:false }` 不计健康度,审计留一行 `SELFTEST`)。 - **判定失败的现场必须落审计与事件**:审计行是判定结局(失败固定转人工 → `HUMAN`;类别认不出 → 按 `other` 的格子),`judgeFailureNote(judged)` 拼在 `|` 之后(`src=` 标明原因),事件带 `emptyOutput` / `emptyRetry` / `finishKind` / `reasoningChars` / `maxTokens`——正文为空时 `error.raw` 是空串而 `put()` 会丢空字符串字段,没有这几项就无法区分「模型一个字没吐」与「原始输出没记上」。 - **本地/临时开发库要写进三行**:`deletion`、`remote`、`safe` 各有一句「能确认是本地/临时开发库(`sqlite3 dev.db`、一次性测试库)的常规改动不算」——只写 `remote` 不够(本地库 `drop table` 也命中 `deletion`,`safe` 不列出来模型不敢选);`remote` 必须保留「连接目标不明确时仍按本行判」,不透明的 `$PROD_URL` 不许当本地库。 - **审核表一行 = 英文 `id` + `description`(什么情况下选这个 id)+ `actions` 三格(low/medium/high),没有 label**。id 的取法是**三级**:`slug(id)` → `slug(旧 label)` → **原样保留 `id`**(手写的 `id: "中文类别"` 不许让整行静默消失、更不许在下一次写盘时从磁盘上被抹掉;只有既无 id 又无 label 的空行才丢)。id 由 `slugCriterionId` 归一(中文/非法字符会变空 → 拒绝新增),模型只输出 `类别: ` + `风险等级: ` + `理由:`,动作由程序按 **(行, 等级)** 查格执行(`resolveCriterionAction`);送审文本只有 `- id:说明`(`formatCriteriaLines`)与 `- low:说明`(`formatLevelLines`),不得出现 label 或动作词。审批历史、设置页、决策事件一律显示 id(客户端不再有 `criterion.*` 文案)。`normalizeCriterion` 保证每行有说明与三格:旧文件的 `label` 只作兜底来源(先当 id、再当说明),**旧 `action` 播种到三格**,两者规范化后都不再保留,下次写盘即消失;某一格写坏(不是 allow/reject/human 也不是空)→ 该格失败关闭 `human`,不影响其它格。 - **出厂三格:所有行统一 low 允许 / medium 人工 / high 拒绝**(`DEFAULT_ROW_ACTIONS`,v22 起)。`sameActions(action)` 是**旧形状**(三格同值),只用于迁移判断与「没有三格时用旧 `action` 播种」;新代码要出场三格用 `defaultRowActions()`。`cloneAllowlist` 必须深拷三格与 `levels.descriptions`,草稿改动不能提前改到活对象。**迁移步骤改默认动作必须走 `seedRowActions` / `seedDefaultRowActions`**:`normalizeCriteria` 之后行上不再有 `action`,`prevVersion < 7 / 11 / 12` 那三步直接写 `row.action` 就是静默空操作;判断「这行是不是出厂默认」用 `allRowActions(row, 'reject')` 这类整体比较。**v22 迁移只改「仍是该行旧出厂形状」的行**(`LEGACY_SHIPPED_SHAPES`:风险行三格 reject、`safe` 三格 allow、`other` 三格 human → 新刻度);用户自己拉开过的格子(哪怕只差一格)一动不动。**v23 只刷仍是旧出厂原文的 `system` 行**(补临时产物消歧)。 - **`other` 不可删除(`err.criterionOtherLocked`),除此之外没有任何特殊**:说明与三格都可改,出厂说明是「以上条目全部不符合或无法确认」(allowlist 版本 21 迁移只刷仍是上一版原文的行)。提示词**不点名 id**,靠通用句子「没有任何一行能确认符合时选那一行」把它指给模型(没有占位符),所以行说明改到认不出来时兜底会失锚——这是用户的选择,设置页只提示「这行不能删除」(`set.criterionOtherNote`)。程序侧的「非表内结果」是**另一件事**:类别认不出(`src=none`)仍走 `other` 的三格,而判定压根没跑成(`empty`/`timeout`/`call`/`route`/`plugin`)固定转人工,都与模型怎么选行无关。 - **归类顺序:严格 → 整段裸 id → 模糊兜底(全表,含 `other` 与 allow 行)→ `other`(`src=none`)**。模糊兜底不再排除 `other` 行(用户明确要求「不属于表中的其它内容都落 other」);它**会**跳过「在**生效等级**那一格会放行」的行(`autoAllowsOnLevel`,生效等级 = 解析出的等级、否则 `levels.fallback`;等级解析因此必须排在模糊扫描**之前**——旧判据只看 fallback 那一格,出厂 `high` 下没有一行会放行,模型顺手写一句 `风险等级: low` 就能让护栏再次空转),所以 `this looks safe to me` 这类散文命中的行最终会不会放行,取决于那一行在兜底等级下的格子(出厂三格下是 reject)——`src=fuzzy` 是唯一的量化手段。只有**正文为空**才抛 `err.judgeEmpty`(那是「没有输出」不是分类问题,会换更大预算重试一次);认不出类别**不再抛错**(走 `src=none` 落兜底行),`err.judgeParse` 这个档位已整套删除。 - **等级:`JUDGE_LEVELS` 固定三档**(id 不可配、说明可配,非空校验 `err.levelNeedDesc`),`levels.fallback` 默认 `high`(`err.levelFallback`)决定「等级认不出」查哪一格。等级与类别同样是**唯一性**语义:同一趟里认得出的等级互相矛盾就整条作废(交给 `levels.fallback`),不是「取最后一个认得出的值」——否则卡片回显里的伪等级能压低风险等级(`normalizeJudgeLevel` 是闭集,`critical`/`高危` 一律算认不出)。**`fallback` 绝不写进提示词**:那是程序侧行为,告诉模型只会让它偷懒不判。 - 审核提示词语言 `judgePromptLang`(`zh`|`en`,默认 zh)**设置页没有开关**:只在「恢复中文/英文默认审核表」「恢复中文/英文默认提示词」时选,选中即写入 config,并同时决定框架、卡片文案与理由语言。恢复默认审核表走 `rule-op {op:'reset',kind:'criteria',value:{lang}}`、恢复默认等级说明走 `{op:'reset',kind:'levels',value:{lang}}`,两者**各有各的恢复按钮**(并进一个会让用户修表时顺手冲掉自定义的等级说明),Host 在 `applyRuleOp` 成功后同步落 `pluginCfg.judgePromptLang` 并写 config(写盘失败要回滚内存值);恢复默认提示词走 `save-plugin {judgePromptLang: lang, judgePrompts: {[lang]: ''}}`;**保存审核模型/超时不得携带 `judgePromptLang`**(否则会把语言写回旧值);**改 `judgeTimeoutMs` 时 config 写不动要连 allowlist 一起回滚**(超时的权威来源是 allowlist,只回滚 config 会让用户看到「保存失败」而新超时已生效——与 rule-op 分支同一条规则)。恢复默认审核表只换表,不动自定义提示词;恢复默认审核表 / 等级说明会把语言切成所选语言,Host 之后调 `syncShippedLevels()`:等级说明仍是**任一语言**出厂原文的换成当前语言原文,用户改过的一个字不动(语言在 config.json、说明在 allowlist.json,读盘时拿不到彼此,所以每次重载后都要补这一步);出厂中英包 id/action 相同,**只有说明不同**。设置页只编辑当前语言那一份提示词 + 一行只读的「当前语言」。理由与框架同语言(模板里写死「用中文」/「in English」)。出厂提示词必须与审核表解耦:只讲通用归类规则,不点名出厂 id,也不得出现任何出厂说明文案(含意译——用户改名后就是悬空引用);兜底只讲通用角色(「不符合其它行 / 拿不准的那一行」),表相关特例写在各行说明里。**末尾那段转人工追加说明是写给审核模型的**(`withEscalationNote`,仅开启时):审核模型是只做归类的纯补全,**没有工具**,所以这段不许点转人工工具名、不许说「已拒绝 X」、不许教它「调用工具 / 批准后重试」——工具名属于**执行模型**,由拒绝通知告诉它。卡片字段会随工具变化,提示词不得写死字段清单,只讲通用两句:**卡片的字段一视同仁都是操作本身**(含 `description` 与 MCP 的参数),**唯一例外是「模型理由」**(请求调用的模型自己的说辞,不能代替操作);外加一句**「卡片只有工具名/沙箱/cwd 时说明没提供可审内容,不要据此认为无害」**——新流程下这种极简卡片真的会送到模型面前,缺了这句等于默许它把"看不见"读成"没事"。「允许/拒绝/人工」是动作词而非保留 id(设置页可自建同名 id)。输出格式只在模板里规定一次,必须要求纯文本(不要加粗/引号/代码块/JSON);**卡片(`formatJudgeCard` 的尾巴)里也不许规定输出格式**——它曾经写着「只输出两行:类别/理由」而模板要求三行,模型照办就让等级行整条消失、每个判定都落 `levels.fallback`。卡片只留一句「请归类这次调用。」,`tests/rules.test.mjs` 有断言盯着「卡片里不得出现 `类别:`」。`judgePrompts.zh` / `judgePrompts.en` 可覆盖出厂模板,空则用 `shippedJudgePromptTemplate`;模板用 `{{criteria}}` 插入当前审核表、`{{levels}}` 插入风险等级说明(`buildJudgePrompt(criteria, levels, lang, template)`)。**占位符缺失时只追加定义,绝不追加输出格式**(格式只在模板里规定一次,两处规定会打架);自定义模板没要求输出等级 → 全部落兜底档。设置页可改、可恢复默认。 - 关键词匹配工具名 + command + 路径 + workdir(workdir 含 `session.header.cwd`;相对 `file_path`/`path` 会拼到 cwd/workdir 上)**与自定义工具的未知参数值**。**它覆盖不到的东西要心里有数(实测)**:`description`/`content`/`body` 这类"内容型"字段与**数字/布尔标量**都不进干草,只能靠审核模型判——这是分工(红线总出现在命令或路径字段里;把内容型字段塞进干草会让模型用一句描述把调用**推向**转人工),但**不要在文档/设置页里承诺"关键词能拦住一切"**。允许桶不匹配工具名,也不匹配会话目录名。审核模型看网页卡片同款字段,不以模型理由为准。禁止 JSON.stringify live exec。点文件凭据词(`.env` / `.netrc`)在命令干草里要求前置分隔(不误伤 `process.env`),在**路径干草**(`formatPathKeywordHay`,只有路径字段)里放宽,所以 `prod.env` 也命中;放宽对**拒绝桶与人工桶一视同仁**(两个桶都是用户显式意图,只放宽拒绝桶会让写进人工桶的 `.env` 在路径形态下静默失效)。 - **自定义工具(MCP 等)的参数名不在 `TOOL_ARG_KEYS` 里不是缺参**:`pickToolArgs` 除已知字段外还收**未知键与嵌套叶子**(键带路径 `params.command`,**深度与键数都没有上限**,取值走 `safeEntry`:只取自有键、getter 抛错不算数;只有 `RAW_COLLECT_GUARD_BYTES` 一个字节护栏,撞到即 `over=true`)。**取消深度/键数上限是安全修复,不要加回来**:实测「批量 250 个文件路径、门控配置在末位」正是被旧的 200 键上限丢掉的——被丢的字段既不进参数、也不进干草,红线整条失效而卡片上看不出。这些叶子进关键词干草(**允许桶不进**:放行只能靠已知命令/路径字段),也上卡片、进事件。**数字/布尔标量也收**(`scalars` 标记):上卡片、进事件,但**绝不进干草**。**已知键的非字符串值也必须收**(`{query:{match:{…}}}`、`{file_path:42}` 曾整条被跳过,既不上卡片也不进干草)。**点号是拍平路径的分隔符,字面键会撞车**(`{'a.b':'LITERAL', a:{b:'NESTED'}}`):撞了就带 `#2` 后缀另起一格,**绝不覆盖**(`keep()` 返回真正落到的键名,标量标记也跟着走,否则标量会漏进干草)。**卡片去重四条规则(每条都对应一个踩过的坑,改之前先读)**:① 每个键恰好一行,一个字段都不丢;② 只在「同一个参数的两个位置」上合并——被印过的键本身与它自己的嵌套变体,**且两者值必须相同**(`file_path` 与 `params.file_path` 都是 `/a` 才是一个参数;值不同就是两个参数,合并会让其中一个消失);③ **不按值去重**:`{url:'https://x', body:'https://x'}` 是两个不同的参数,按值去重会让 `body` 整条消失;④ **键尾相同也不合并**:`{args:{file_path:'x'}, extra:{file_path:'y'}}` 是两个不同参数(不同父键),按键尾丢会让 `y` 消失。实现见 `formatJudgeCard` 的 `take`/`show`/`labeled`:**标记必须发生在真的印出那一行之后**(`workdir` 等于 cwd 时那行不印,早标记会让另一个位置的 `args.workdir` 被当成「同一参数的第二个位置」丢掉),与 cwd 同值的键记进 `redundant` 以免重复单列。契约测试在 `tests/rules.test.mjs`(「每个键恰好一行」+ 上面三条 review 修复)。`justification` 顶层与嵌套都不收(那是模型理由,卡片另有「模型理由」一行)。`description` **不再有任何特殊身份**:它就是一个参数,有值就上卡片、照常送审(此前按工具类型区分「算不算内容」的逻辑已随闸门一起删除)。**送审内容要么完整、要么不问模型**:卡片 `formatJudgeCard` 不切任何字段、不设条数上限;闸门量的是**整条请求**(系统提示词 + 卡片)与 `pluginCfg.judgeRequestBudget`(默认 20000,区间 8192..1000000——**下限必须覆盖英文出厂框架(~5.8k 字符,中文 ~2.2k)**,否则切到英文提示词后每次判定都送不进模型),超了就按 `truncatedAction` 处理并写日志 + 审计 `err.judgePayloadOversize request=<实际>>预算`。唯一的例外是**事件存档**:`clipToolArgsForEvent` 按 `EVENT_ARG_LIMITS` / `EVENT_ARGS_BUDGET` 裁剪(只为压 jsonl 体积),略过的键记进 `argsOmitted`——那是「当时完整送审过、只是存档短」的证据,别拿它当送审依据。禁止任何「少送一点再让模型判」的中间态。 - 关键词三条特殊规则,改词表时必须一起考虑:① `KEYWORD_EXCEPTIONS`——命中后紧跟该后缀就不算(`id_rsa`/`id_ed25519`/`id_ecdsa`/`id_dsa` 后跟 `.pub` 是公钥;`of=/dev/` 后跟 `null`/`zero`/`full`/`random`/`urandom`/`stdout`/`stderr` 是伪设备);② `PREFIX_MATCH_KEYWORDS`——只要求词首边界,**不要求词尾**(`of=/dev/nvme0n1p2` 后面还是字母数字,套普通词尾边界会整条漏判);③ **人工桶**(`DEFAULT_HUMAN_KEYWORDS` / `shippedHumanKeywords()`,默认空):拒绝桶是直接拒掉工具调用、人工桶是弹网页框,拿不准的默认由审核表兜底行转人工;首次初始化和「恢复默认关键词」都要同时写回两个桶。 - 空字符串参数要保留(`write` 的 `content=''` 是截断文件);审核卡片用 `(空)` / `(empty)` 展示。**判定前只剩一个开关 `truncatedAction`**(默认 `human`、可配 `reject`),它承接三件**都属于「插件看不见这次操作」**的事:① 参数没采集到(缓存未命中 / 已消费 / 被挤出,`src=uncaptured`)**——这一条不走 `truncatedAction`,永远直接拒绝**:它是插件侧瞬时故障,让人为它拍板没有意义(人也看不到内容),也没有参数可做一次性凭证的键;拒绝会带 `denyReason=payload-uncaptured` 的 notice 回给模型,写明「这是插件侧采集故障、重新发起同一次调用即可」——**必须与超预算那句分开**(后者是「操作太大别再发」,混起来模型会朝错方向重试);② 撞收集护栏(连插件自己都没收全,`src=oversize`);③ 整条请求超送审上限。②③ 走**同一条 path** `truncated-payload`、同一个开关——语义都是「看不见就不判」,且每条都要写审计行(`HUMAN/REJECT ... truncated-payload | <证据>`)。**「参数没采集到」的事件带 `argsCaptured: false`**(`args` 缺失分不清「真没有参数」与「插件没拿到」);审批 tab 据此显示 `detail.argsUncaptured` 告警而不是 `(空)`。**`missingPayloadAction` 与「算不算内容」的判据(`hasToolPayload` 等)已整套删除**(配置、设置页、rule-op、测试都清了):空参数、只有 `description`/`workdir`、纯标量开关一律照常送审,由模型按卡片判。顺序有实测依据,别改回去:关键词是用户**显式**意图,必须对「看不见的调用」同样生效(闸门曾在关键词之前 `return`,危险工具名写进拒绝桶时静默无效,有「闸门顺序」用例);但关键词**允许**必须在闸门**之后**(参数没采集到/撞护栏/超预算的调用禁止被放行)。审计行必须记清证据:超预算记 `request=<实际>>预算`、撞收集护栏记 `oversize=collect>8388608`、没采集到记 `err.missingPayloadUncaptured`(`formatJudgeRequestNote` / `formatOversizeNote`)——三条都必须同时打 `console.warn`(用户明确要求:不能出现「不知道为什么转人工了」)。判定路径上超预算走 `judgeOperation` 里的 `oversize` 分支(`src=truncated`,动作直接取 `truncatedAction`),与闸门是**同一条规则**,不是"判定失败"。**这条规则有三处必须同时成立,少一处就退化**:① `withRetry` 的**两个** catch 都要认 `error.noRetry`(共用 `oversizeTerminal`)——只认第一个时,判定进行中预算被改小(设置页保存 / 并发重载)就会把「超上限 → 拒绝」变成弹人工框、证据退化成「调用失败」;② verdict 必须带 `oversize: true`(`judgeOperation` 的 oversize 分支漏过它,事件因此变成 `criteria-reject`);③ 判定路径的这条事件也要记 `path: 'truncated-payload'`(用 `pathOf()`),尺寸证据顶到 `judgeReason`(超预算没有「模型理由」,`reason` 是空的,证据在 `errorDetail` 里)。**事件文件的上限是字节**(条数只是同时生效的第二个上限):`trimEventsFile` 从最新一条往前累加字节、超 `EVENTS_MAX_BYTES` 就停(至少留一条——最后一条是「刚才发生了什么」的唯一现场);只按条数裁时 2000 条 ≈ 13MB,`appendEvent` 每次都调裁剪 → 每写一条事件就全量重写一次(实测 ~68ms/条)。 **事件侧排障契约**(`tests/pipeline.test.mjs` 的「排障契约」用例兜底):超预算写 `judgeReason: err.judgePayloadOversize request=<实际>>预算`、撞护栏写 `err.payloadOversize oversize=collect>8388608` + `src=oversize`、没采集到写 `err.missingPayloadUncaptured` + `src=uncaptured`——三者同走 `truncated-payload`,靠 `src` + `judgeReason` 区分,别合回一句没有证据的话。护栏标记在 pre-execute 采集时产生,所以缓存必须存 `{args, over}`(只存 args 会让闸门永远看不到 `over`)。三条 truncated-payload 的**决策叶子也必须带 `src`**(`uncaptured`/`oversize`/`truncated`):`decisionLeaf` 只在有 src 时才写、`denyReasonKey` 按 src 派生文案,丢了它就都变成 `payload-truncated`,而「参数没采集到(重发即可)」与「太大别再发」必须能区分——叶子是文档化的只读面。`denyReason` 只写在**非放行**事件上,判据是 `ev.outcome !== 'allowed-once'` + `NON_DENY_PATHS`——**`outcome` 是显式落盘的**(`rejected` / `allowed-once`),别退回「按 verdict 后缀猜」:`truncated-payload`(超预算/撞护栏)与 `plugin-error` 既可能拒绝也可能放行,客户端 `isAutoReject` 就是靠 `outcome` → `denyReason` → 后缀三级判据把一次真拒绝渲染成「已拒绝」而不是绿色的「自动放行」。人工结论(`applyHumanOutcome`)也要带 `outcome`,否则一次 `criteria-human` 的**人工批准**会带上「为什么被拒」。**`denyReason` 可以是空**:转人工本身不是机器拒绝(`keyword-human` / `criteria-human` / `human-review` 在 `denyReasonFor` 里返回空串),此时事件与决策叶子**都不写**这个字段,通知里也不许出现「原因:」——只有判定压根没跑成(`src` ∈ `none`/`empty`/`timeout`/`call`/`route`/`plugin`)才归因失败,且**不许兜底成 `judge-call`**(那会把一次正常转人工显示成「审核模型调用失败」)。`src=none`(模型答了、类别认不出,程序落兜底行)归 `judge-unparsed`:那不是模型选了兜底行,而是输出无法归类。 - **启动写 patch 沙箱要同时满足「配置里明确有 `presetSandbox`」与「值认得出」**(或刚从旧路径迁移、或 patch 里还没有 auto-approve 要安装):文件**存在**不等于有意见——缺键时 `pluginCfg.presetSandbox` 是默认 workspace-write,写盘会把手改成 `read-only` 的 patch 加宽;值先 `trim().toLowerCase()` 再判(`"READ-ONLY"` 就是 `read-only`),**认不出的值按「没有有效意见」处理**(不写 patch + warn);`save-plugin` 收到认不出的 `presetSandbox` 时**明确报 `err.presetSandbox`**(RPC 外部可调,静默按默认值写就是放宽沙箱)。`version` 用 `Math.max(23, prevVersion)`:未来版本号不降级(回退再升级不该重跑迁移)。 - **语言同步要在两个文件都重载之后**(`reloadBoth()`):`syncLevelLanguage()` 依赖 config 的语言 + allowlist 的说明,写在 `reloadAllowlist()` 内部时用的是**上一次请求**的 `pluginCfg`(外部改语言后第一次送审是「新框架 + 旧说明」,第二次才收敛)。 - allowlist / 插件配置读失败不写盘;**「合法 JSON 但不是对象」(`null` / `[]` / `"text"` / `42`)也算损坏**(`tryLoadJson` 判 ok:false):否则读盘侧会把它归一成默认值再写回磁盘,用户手写坏的内容被静默覆盖,沙箱还会被默认值**放宽**(实测 read-only → workspace-write)。设置页除明确覆盖外不得覆盖损坏文件。**`setup` RPC 也要过同一道守卫**(`pluginCfgCorrupt` → `err.pluginCorrupt`):否则一次点击就会按默认 workspace-write 改写 patch,把用户写好的 read-only 加宽(启动路径一直是拒绝的)。判定失败事件里**只留 `errorCode`**(`error` 与它同值且没有生产者)。恢复默认关键词必须含路径拒绝词(`shippedRejectKeywords()`),不能只用 `DEFAULT_DENY_KEYWORDS`。 - 设置页审核表说明若用非受控 `defaultValue`,必须随 snapshot 换 `key` 重挂(`c.id + ':desc:' + 说明`、等级说明用 `level + ':desc:' + 说明`),否则恢复默认后 blur 会把旧文案写回;`other` 行与其它行一样挂 textarea(只是不能删除),`set.criterionOtherNote` 作为它上方的一行提示。新增/修改审核项必须过 `err.criterionNeedId` / `err.criterionNeedDesc`:空说明的行模型认不出,绝不允许写进表。三格补丁走 `{ id, actions: { : } }`,未知档位报 `err.criterionLevel`;`levels` 走 `{ op:'set'|'reset', kind:'levels' }`。 - **设置页文案是纯文本**(`t()` 直接当文本节点,禁止 markdown/反引号),只描述**当前**管道(`missingPayloadAction` 时代的说法一律删掉)。数量统计两个键:审核表 `set.counts`、**关键词 `set.kwCounts`**(关键词不是三格)。总览芯片=关键词 → 审核表 → 审核模型 → 模型转人工且都要能跳转;**闸门没有独立卡片/芯片**,`truncatedAction` 就在审核模型卡片里紧挨「送审内容上限」,管道顺序是 **拒绝关键词 → 参数没采集到(无条件拒绝)→ 人工关键词 → 闸门 → 允许关键词**。「提示词语言」属审核模型卡片。**风险等级不本地化**(`levelLabel()` 原样返回 id,字典里没有 `level.*`):等级 id 是模型契约(提示词、审计、`actions.low`),显示「低」会逼用户自己映射;**动作 id `allow`/`reject`/`human` 相反,保持本地化**(它不进提示词、不出现在模型输出里)。判据就是「这串 id 会不会出现在提示词或持久化数据里」。 - **设置页只留一行提示,长说明一律写 README**(`README.zh.md` / `README.md` 的「设置」一节,条目与卡片一一对应):卡片副标题、展开区的 `*Sub`/`*Hint` 只讲开关**本身怎么用**;「为什么这么设计、盲区在哪、和其它卡片什么关系」全进 README。**删卡片时同步删文案键**(`set.unjudgeable*` / `set.judgeSubTail` 就是这么没的),别留没有渲染点的死键;**删控件要同步删只服务它的 CSS 与局部变量**。总览卡片里**只放步骤芯片**(v22 统一刻度后,每行「兜底等级 → 动作」芯片成了同一个值的重复投影,已删)。 - **设置页的本地草稿与快照刷新分开**:`load({ reseed })` 只有 `'all'`(首次加载、覆盖损坏配置)、`'judge'`(保存审核设置、恢复默认提示词)、`'human'`(模型转人工写入)才从快照播种本地编辑态,其余一律用默认的 `'none'`——**任何写入都不得顺手冲掉审核模型卡片里没保存的提示词草稿**(旧代码用 `keepEdits=false` 表达「重载」,于是点一下「模型转人工」下拉就把刚写的提示词丢了)。`judgePromptLang` 是服务端状态,每次刷新都跟随,中英草稿分开存所以切语言不丢另一份。 - **设置页的可访问性与结构约定**:`` 里只能放 phrasing content——用 ``,别用 `
`/`

`(无效 HTML);折叠卡片的长说明放**展开区**,summary 只留标题 + 计数。每个表单控件都要有可访问名:能关联的用 `