Skip to content

fix(scripts): check-single-authz-resolver 判据改判「同时查询两张表」+ 真实仓库阳性对照 (#6286) - #6353

Merged
hotlong merged 4 commits into
mainfrom
claude/issue-6286-authz-heuristic-vocabulary
Aug 7, 2026
Merged

fix(scripts): check-single-authz-resolver 判据改判「同时查询两张表」+ 真实仓库阳性对照 (#6286)#6353
hotlong merged 4 commits into
mainfrom
claude/issue-6286-authz-heuristic-vocabulary

Conversation

@hotlong

@hotlong hotlong commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Fixes #6286

ADR-0090 D3 的表改名把检查 (1) 的判据词表废掉之后,这道门禁结构上无法变红。本 PR 修判据、重新策展 ALLOW,并补上这一族门禁普遍缺失的那一半:判据侧的阳性对照

叠在 PR #6282(#6070,语料侧扩展名过滤)之后,两者正交 —— 那条改 walk() 与排除列表,这条改 :237 一带的判据。

1. 前提复现(自行在 origin/main @ 7e1b480 复测)

判据现行形状(audit() 内):

if (src.includes('sys_user_role') && src.includes('sys_user_permission_set')) { /* 报重复解析器 */ }
语料文件数 1495
sys_user_role 的文件 1(仅 sys-user-position.object.ts:9 的 "ADR-0090 D3; formerly sys_user_role" 注释)
sys_user_permission_set 的文件 32
同时命中两词(= 启发式能抓到的) 0
同时含 sys_user_position + sys_user_permission_set 20

三个实测数字(0 / 32 / 20)与立单人一致。阳性对照(证明扫描器没坏):32 与 20 都非零。

规范解析器自己都不触发自己的启发式 —— resolve-authz-context.tssys_user_position(:317)与 sys_user_permission_set(:341),没有 sys_user_role

2. 各候选形状的命中面实测

同一语料(1495 文件)上逐个跑出来的,不是估计:

形状 命中 命中面成分
S0 现状 sys_user_role && sys_user_permission_set 0 门禁已死
S1 一词替换 sys_user_position && sys_user_permission_set 20 规范解析器 1 + explain-engine 1 + 噪声 18
S2 S1 + 落点限 security/ 4 resolve-authz-context.tsgrant-validity.ts(纯注释)、explain.zod.ts(纯注释)、permission.zod.ts(纯注释)
S3 剥注释后仍同时提到 12 仍含 4 个生成翻译包、常量表、testkit
S4 两词都以引号字面量出现 6 仍含 platform-object-names.ts、lint 的 Set 字面量、页面元数据
S5 两词都出现在任意调用实参位 4 仍含 lint 的 new Set([...])delegated-admin-gate.ts
S6 ✅ 采用 两词都是数据读取调用的引号实参 2 resolve-authz-context.ts(规范)+ explain-engine.ts(explain 镜像)

S1 的 18 个噪声具体是:4 个 generated translations、object.zod.ts / permission.zod.ts / component.zod.ts / explain.zod.ts 的注释散文、platform-object-names.ts 常量表、validate-security-posture.tsSet 字面量、sys-user.page.ts 页面元数据、exec-context-seam.testkit.ts fixture、grant-validity.ts / security-plugin.ts / auth-manager.ts / auth-plugin.ts / delegated-admin-gate.ts 的注释或非查询用法。

选择理由

(a)「查询」而非「提到」。 重复解析器不是一个说出这些表名的文件,而是一个从它们读行的文件。判据要求表名作为数据读取调用(find / query / select / count / aggregate,含 tryFind 这类 helper 拼法)的引号实参,把 20 收到 2,且收窄完全由判据完成,没有一条靠豁免名单

(b)⛔ 不采用按落点收窄(S2),这是量证的否决而非偏好。 原始 bug 就在 packages/rest/src/rest-server.ts —— 不在任何 security/ 目录下。S2 这个形状根本抓不到该门禁立案要防的那个缺陷,而且会让 self-test 自己的重复解析器 fixture(packages/rest/src/my-own-resolver.ts)一路放行。把守卫收窄到「正确代码所在之处」,就看不见种在别处的代码。

(c)⛔ 刻意不做剥注释预处理。 实测过:剥与不剥都是同样的 2 个命中(S6 行),而一个词法剥离器只要错解一个正则字面量,就会悄悄缩小守卫所审视的内容 —— 正是这个文件整族失败模式(#4930 / #5916 / #6070)在裁决步骤上的翻版。少一个会撒谎的活动件。

3. ALLOW 重新策展(逐条理由)

旧的两条是对着死判据写的,按新判据逐条重判:

条目 处置 理由
CANONICAL(resolve-authz-context.ts) 保留 唯一合法解析器。同时是阳性对照的对象:它必须被启发式命中,只豁免「被上报」这一步。
default-permission-sets.ts 移除 新判据不再触及它 —— 它以散文和无引号对象键(sys_user_permission_set: { allowRead: true, ... })提到两表,从不查询任何一张,实测 0 命中。豁免一个不再需要豁免的文件是死重量,而豁免名单里的死重量,在别人读到它的那天与一条活的压制无法区分。
explain-engine.ts 新增 explain 诊断镜像,不是 request-context 解析器(见下)。

ALLOWSet 改为 Map(路径 → 理由),self-test 断言每条都带非空理由。旧名单烂掉正是因为没有任何东西要求一条豁免为自己辩护。

explain-engine 为什么是豁免而不是「真发现」

⚠️ 这是本 PR 唯一的判断题,按分诊闸「真发现不许为了让门变绿而豁免」逐条查过:

  • 不在请求强制路径上buildContextForUser()任意 userId 重建授权,唯一调用者是 security-plugin.ts:2221 explainAccessForCaller();调用者自身的授权(manage_users 或 ADR-0090 D6/D12 的 delegated adminScope)走正常路径,与这份聚合无关。它不解析任何人的强制上下文。
  • 因此它不是本门禁 header 声称要防的那类缺陷(「REST 自带一份漂移的 request-context 解析器」)。判它在内会改掉门禁对外声称的语义,那属于升级闸 ②,不能顺手做。
  • 门禁 header 从第一天起就写了 "or an explicitly allow-listed non-resolver" —— 这正是该机制的设计用途。

但它确实是一份手工镜像(其自身注释:"mirroring the runtime resolver's semantics"、"IDENTICAL rule"),与规范解析器之间没有任何 parity 断言。这是一条真实但不同的不变量,已按 Prime Directive #10 单独立观察单 #6352(finding,未认领),并在 ALLOW 理由里写明「不要靠放宽本门禁 remit 来守它」。

这次判据修复是它第一次被任何门禁看见 —— 旧判据一个文件都匹配不到。

4. 阳性对照的设计,以及它与 ALLOW 如何分层

assertCanonicalStillMatches()每次真实运行(不只 --self-test)跑:规范解析器必须被检查 (1) 的启发式命中,否则按名变红,点出哪几张表不再匹配。CI 的 check:authz-resolver = --self-test && 真实运行,两侧都覆盖。

分层是这条断言得以成立的前提。 「被启发式匹配」与「被上报为错误」是两步:

  • queriesAllGrantTables → 决定匹配(与 ALLOW 完全无关)
  • ALLOW → 决定上报

规范解析器必须通过第一步(这就是对照),并在第二步被豁免(它是合法的那一份)。两步在代码里是两个函数,所以对照可以只断言第一步,而不必断言「它是坏的」。

同样的分层在 self-test 里对 explain-engine 也断言了一遍:肯定式断言它被启发式命中,再断言它不被上报。

self-test 的 fixture body 全部改为GRANT_TABLES 生成。这不是整洁癖:写死的 fixture 正是判据烂掉却无人察觉的原因 —— 写死的 fixture 只证明启发式对它自己包含的那些词有效,而这些词与判据同步漂移、与真实仓库一起走开,self-test 恰好在门禁失去意义的那段时间里保持全绿。

5. 反向验证(先申报,后执行)

⚠️ 断言极性已按要求分开申报。

方向 申报预期 实测
D1a 判据活过来(肯定式,会红) 在真实非豁免落点 packages/rest/src/reverse-verify-duplicate-resolver.ts 种一份重复解析器 ⇒ 新门禁按名抓出 EXIT=1,输出 Possible duplicate authorization resolver: packages/rest/src/reverse-verify-duplicate-resolver.ts
D1b 红确由判据引起(⚠️ 反向,预期绿) 同一份种植物 + origin/main 的门禁 ⇒ 预期绿,而这个绿就是 bug 本身 EXIT=0,✓ check:authz-resolver: single shared authorization resolver intact
D2 阳性对照会咬(肯定式,会红) 模拟下一次改名(GRANT_TABLESsys_user_position_v2)⇒ 对照转红并点名 EXIT=1,resolve-authz-context.ts does not query sys_user_position_v2
D2' 旁证(⚠️ 反向,预期绿) origin/main--self-test 在其门禁已死的情况下 ⇒ 预期绿 EXIT=0 —— 合成 fixture 结构上看不见这个缺陷

D1b 值得单独读一遍:origin/main 的门禁对着一份真实种下的、拼写正确的、会漂移的重复解析器打印绿灯。这是 phantom check 的现场演示,不是推论。

断言极性(按要求逐条标明)

  • 肯定式(判据被删空/退回时会转红):D1a 抓取并点名、D2 全量改名点名、D2 部分改名只点漏掉那张表、D2 解析器移走、真实仓库阳性对照(3 条)、explain 镜像被启发式命中、ALLOW 条目带理由。
  • 否定式(⚠️ 结构上不会转红,判据被删空时平凡成立):「mention-only 文件不被判为解析器」、「它不被上报(虽未豁免)」、「只查询单张表不算解析器」。这三类仅在上面那批肯定式断言旁边才有承载力 —— 单独存在时,一个匹配不到任何东西的判据同样能让它们全绿。这正是本 issue 的失败模式,故在代码注释里也标了极性。

6. 门禁 EXIT 表(均在 git add 之后跑)

命令 EXIT
node scripts/check-single-authz-resolver.mjs --self-test 0
node scripts/check-single-authz-resolver.mjs(真实运行) 0
pnpm exec eslint scripts/check-single-authz-resolver.mjs --no-inline-config 0
pnpm check:nul-bytes 0(6002 tracked text files,无裸控制字节)
控制字节自扫 grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]' 1(无命中 = 干净)

真实运行为绿,且不是靠放宽判据换来的:新判据在真实仓库上抓出的 2 个文件里,1 个是规范解析器本身,另 1 个经逐调用者核对确认不在强制路径上,豁免理由写进代码,风险另立 #6352

7. 不在本 PR 里

)

ADR-0090 D3 把 sys_user_role 改名为 sys_user_position 之后,检查 (1) 的判据
`src.includes('sys_user_role') && src.includes('sys_user_permission_set')`
在全仓命中 0 个文件 —— 门禁结构上无法变红,两条 ALLOW 豁免的是一条永不触发
的启发式。规范解析器自己都不触发自己的启发式。

三处改动:

1. 词表收敛到 GRANT_TABLES 单一出处,判据从「同时提到两个字符串」改为
   「同时查询两张表」(表名作为 find/query/select/count/aggregate 调用的引号
   实参)。实测:一词替换命中 20 个文件、其中 18 个是噪声(生成的翻译包、
   zod schema 注释、常量表、lint Set 字面量、页面元数据、testkit fixture);
   查询形状把同一语料收到 2 个,且不靠豁免名单做收窄。
   按落点收窄(限 security/)已量证不可取:原始 bug 就在 packages/rest/
   src/rest-server.ts,不在任何 security/ 目录下。

2. ALLOW 按新判据重新策展,每条豁免带理由:
   - CANONICAL 保留(唯一合法解析器,同时是阳性对照的对象);
   - default-permission-sets.ts 移除(新判据不再触及它,实测 0 命中);
   - explain-engine.ts 新增(explain 诊断镜像,非 request-context 解析器)。

3. assertCanonicalStillMatches:真实仓库上的阳性对照,每次真实运行都跑 ——
   规范解析器必须被检查 (1) 的启发式命中,否则按名变红。这是本次改名能
   悄无声息废掉判据的直接原因:此前全部断言都跑在合成 fixture 上,而合成
   fixture 与判据同步漂移,永远绿。匹配与上报在代码里分成两步
   (queriesAllGrantTables 决定匹配,ALLOW 决定上报),阳性对照才可断言。

self-test 的 fixture body 改为从 GRANT_TABLES 生成,并补上改名的反向证明
(整体改名 / 部分改名 / 解析器移走,三种都必须红且点名)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BDmDsu2575gDxeMCxXhDE3
@vercel

vercel Bot commented Aug 7, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 7, 2026 2:57pm

Request Review

@github-actions github-actions Bot added the size/m label Aug 7, 2026
@hotlong hotlong added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 7, 2026 — with Claude
claude added 3 commits August 7, 2026 14:54
原先 default-permission-sets.ts 的「已移除」说明写在 explain-engine 条目的
数组内部,读起来像是在解释 explain-engine 的豁免。移到 ALLOW 的文档块里,
与两条现存豁免分开;explain-engine 的理由补上 #6352 单号,读者能直接找到
那条 parity 风险的观察单。

纯注释改动,判据与 ALLOW 成员未变。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BDmDsu2575gDxeMCxXhDE3
`[^)'"`]{0,80}` 排除了右括号却没排除左括号,于是 `find(wrap('sys_user_position'))`
会被判为「read call 读取该表」—— 表名其实是内层调用的实参,不是这次读取的。
与该正则自己注释里的声明(「是这次调用的靠前实参,而非嵌套在其中之物」)不符。

改为 `[^()'"`]{0,80}`。真实语料命中面不变(仍是规范解析器 + explain-engine
两个,门禁真实运行仍绿),这是精度修正而非收窄结果。

self-test 补上 11 条读取调用拼法断言,把召回侧钉死:
- 肯定式(会红):member 调用、helper 调用带前置实参(规范解析器用的正是
  tryFind 这种拼法,丢了它阳性对照本身就会塌)、双引号、模板字面量、实参换行;
- 否定式(平凡成立,仅备案):注释、常量表、无引号对象键、Set 字面量、
  页面元数据,以及本次修的嵌套调用。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BDmDsu2575gDxeMCxXhDE3
上一提交把 `[^)'"`]` 改成 `[^()'"`]`,注释仍写「不跨越另一个引号或右括号」。
补上左括号并给出具体例子(`find(wrap('sys_user_position'))` 不算读取该表),
让注释与正则一致。纯注释。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BDmDsu2575gDxeMCxXhDE3
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/m skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants