Skip to content

fix(plugin-security): 被拒收的 capability 声明不再压掉派生占位,拒收诊断点名授权者 (#4967 Part 1/3) - #5875

Merged
baozhoutao merged 1 commit into
mainfrom
claude/issue-4967-refused-cap-accounting
Aug 6, 2026
Merged

fix(plugin-security): 被拒收的 capability 声明不再压掉派生占位,拒收诊断点名授权者 (#4967 Part 1/3)#5875
baozhoutao merged 1 commit into
mainfrom
claude/issue-4967-refused-cap-accounting

Conversation

@baozhoutao

Copy link
Copy Markdown
Contributor

Fixes 部分:#4967 Part 1/3(Part 2 → #5870)

本 PR 只做 #4967 的 Part 1(拒收的声明不再压掉派生)与 Part 3(拒收诊断点名授权者)。
Part 2(stack.capabilities 到达 registry、_packageId 打戳)已拆到 #5870 的 engine-core
车道,本 PR 不碰 packages/objectql / packages/runtime / packages/lint。两半都合并后
才应关闭 #4967,所以正文用 "Part of" 语义而非裸 Fixes #4967

前提复核(origin/main @ e2bfa6c)

issue 描述的机制仍然成立,行号未漂:

$ git grep -n "declaredNames.push" origin/main -- packages/plugins/plugin-security/
origin/main:packages/plugins/plugin-security/src/bootstrap-declared-capabilities.ts:174:    out.declaredNames.push(cap.name);

upsertPackageCapability 的三条拒收路径(curated 平台名 / 无归属包 / 他包所有)都在这行
之后才决定,一行都不写;而 security-plugin.ts 把这份名单交给
bootstrapSystemCapabilities 当"别派生这些"的跳过表。于是「无归属包」这条既没写行、
又占住了名字 —— capability 在任何一行里都不存在。写下这条声明比不写还糟

Part 1:上报「确认有行的名字」,不是「读到的名字」

判据统一成一句(PM 方向的「派生后是否会 clobber 既有 authored 行」,逐名核实):
一个名字压掉派生,当且仅当本遍结束时 sys_capability 里确实有它的行。 保护 authored
行不被 humanize 占位刷掉,本来就是这份名单的全部用途;而一条没有行的拒收,不存在任何
需要保护的 authored 行。

三条拒收路径按此各自处置,理由都写进了代码:

拒收路径 是否上报(压掉派生) 理由(实测)
curated 平台名 curated 那一遍无条件种这些名字,行必然存在;且派生路径根本够不到 curated 名(它已在 byName 里),所以这一票对门禁是 no-op,但答案是真的
他包所有 / admin 自建 行存在,且 label/description 是作者写的;派生会把它们刷成 humanize 占位 —— 正是这份名单要防的事
无归属包 仅当已有行时 没有行 = #4967 的洞,回落派生占位,和「从未写过这条声明」时一样

顺带修了两处对账缺口:

  • 新增 skippedUnowned 计数器 —— 这条路径此前一个计数器都没有,这正是名单与计数器
    对不上的原因。现在每条具名声明恰好落进一个计数器。
  • 「无归属包」的判断移到 tryFind 之后。不这么做的话,一个无归属声明撞上 admin
    自建行时会被放去派生,而派生会覆盖该行的 label/description —— 修一个洞、开一个新洞。
    这条有独立的反向测试钉住。

declaredNames 同时更名为 materializedNames(消费侧 declaredCapabilityNames
materializedCapabilityNames)。两个符号都不在包的 index.ts 导出面上,改名不影响外部
契约;而这次的 bug 恰恰是「名字承诺的比值兑现的多」造成的,名字说实话是最便宜的防复发。

Part 3:级别不动,消息点名授权者

#4632 的规则,这是功能性降级而非持久性失败,级别维持 warn。改的是内容:
seeder 现在从 bootstrapPermissionSets(与派生所读的同一个数组,因此「谁授权」与
「会不会被派生」出自一个来源)建 capability → permission set 索引,以参数逐次传入,
⛔ 不引入任何模块级状态。三种真实后果各有措辞:

[security] declared capability "showcase.export_data" has no owning package
  (granted by showcase_ops): falls back to the back-compat derived placeholder —
  the grant resolves, but with no package provenance (ADR-0086 D3: uninstall undefined)

  …(granted by no bootstrap permission set): nothing derives it either —
  the capability is materialized nowhere

  …(granted by showcase_ops): an existing sys_capability row already resolves it
  and is left as-is — the declaration adds no package provenance

meta 里另带 grantedBy: ['showcase_ops', …] 供结构化消费。

一处必须说清的实测偏差(issue 措辞略强)

issue 说「every systemPermissions grant naming it is inert」。实测运行时判定并不查
sys_capability
:permission-evaluator.ts:267 直接对 ps.systemPermissions 里的字符串
取并集。所以修复恢复的是注册表一侧的 declared = enforced —— 能力有定义记录、在 Setup
可见可管理、带 provenance、能被 ADR-0066 ⑨ lint 的第 4 类来源解析 —— 而不是「把一个原本
不生效的授权变成生效」。issue 描述的核心缺陷(拒收 ⇒ 压掉派生 ⇒ 任何一行里都不存在 ⇒
比不声明还糟)完全属实,只是后果的射程比原文窄一档。changeset 按实测写,没有跟着原文放大。

测试(先红后绿,方向先声明后运行)

新增 12 条,复用文件内既有的 makeQl 替身(未新建 fake engine —— 该文件在
engine-double-contract.baseline.json 里是 unguarded: 1 的 shrink-only DEBT 条目,新增
一个带 update 的替身会把它顶成 2 而门禁变红)。

反向验证 —— 事先预测的方向是「红」:把删掉的肢体装回去(恢复无条件 push),预期
3 条依赖修复的 Part 1 钉子转红,其余保持绿。实测完全吻合:

 × NO OWNING PACKAGE + no row: falls through, so the derivation materializes it
 × is stable across boots: the refusal re-derives nothing and duplicates nothing
 × materializedNames reconciles with the outcome counters
 Tests  3 failed | 19 passed (22)

AssertionError: expected [ 'showcase.export_data' ] to deeply equal []

两条 REVERSE: 开头的测试按设计保持绿:它们本来就硬编码旧名单/空名单,钉的是
「旧行为会掉进洞」和「不检查既有行就会覆盖 admin 行」这两个反面事实,不随实现摆动。

绿:

pnpm --workspace-concurrency=2 --filter @objectstack/plugin-security test
  Test Files  35 passed (35)      Tests  761 passed (761)

pnpm --workspace-concurrency=2 --filter @objectstack/plugin-security typecheck
  tsc --noEmit  (clean)

pnpm --workspace-concurrency=2 --filter @objectstack/lint test        # 能力引用解析的消费侧
  Test Files  61 passed (61)      Tests  1416 passed (1416)

node scripts/check-engine-double-contract.mjs  → OK (38 pinned, 165 DEBT, 2 exempt)
node scripts/check-nul-bytes.mjs               → OK (5697 files)
pnpm check:durability-log-level                → OK (24 seams, 级别未回归)
pnpm check:role-word / check:error-code-casing / check:authz-resolver → OK
eslint(4 个改动文件)                          → clean

文件面

packages/plugins/plugin-security/**(实现 + 测试)+ 一个 changeset。未触
packages/objectql / packages/runtime / packages/lint —— lint 对 systemPermissions
的豁免依赖派生后门的存在,而本 PR 正是把这个后门修通,所以不需要动它。


Generated by Claude Code

… refused declaration falls back to the derived placeholder (#4967 Part 1/3)

bootstrapDeclaredCapabilities filled its returned name list BEFORE the upsert
decided anything, so all three refusal paths reported a name they never wrote a
row for. The caller uses that list to tell bootstrapSystemCapabilities to skip
deriving the back-compat placeholder, so a declaration refused for want of an
owning package suppressed the placeholder too and the capability then existed in
no sys_capability row at all -- writing the declaration was strictly worse than
omitting it.

The list (renamed materializedNames) now reports only names this pass CONFIRMED
have a row: written here (seeded/updated/claimed), or an existing row that must
not be clobbered (admin-authored, another package's, or a curated platform name
the curated pass owns). The unowned path reports its name only when a row
already exists, and otherwise falls through to the derivation. Adds the
skippedUnowned counter that path never had, so every named declaration lands in
exactly one counter and the list reconciles with them.

Part 3: the unowned-refusal diagnostic stays a warn (#4632 -- functional
degradation, not durability) and now names the permission set(s) that GRANT the
capability plus the actual consequence, threaded in as an argument from the
bootstrap permission sets rather than any new global state.

Part 2 of #4967 (stack.capabilities -> registry _packageId) is out of scope here
and tracked as #5870.

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

vercel Bot commented Aug 6, 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 6, 2026 10:41am

Request Review

@github-actions github-actions Bot added the size/m label Aug 6, 2026
@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-security.

13 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/deployment/cli.mdx (via @objectstack/plugin-security)
  • content/docs/kernel/runtime-services/sharing-service.mdx (via @objectstack/plugin-security)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/plugin-security)
  • content/docs/permissions/access-recipes.mdx (via packages/plugins/plugin-security)
  • content/docs/permissions/authorization.mdx (via @objectstack/plugin-security)
  • content/docs/permissions/explain.mdx (via @objectstack/plugin-security)
  • content/docs/permissions/permissions-matrix.mdx (via packages/plugins/plugin-security)
  • content/docs/permissions/sharing-rules.mdx (via @objectstack/plugin-security)
  • content/docs/plugins/index.mdx (via @objectstack/plugin-security)
  • content/docs/plugins/packages.mdx (via @objectstack/plugin-security)
  • content/docs/releases/implementation-status.mdx (via @objectstack/plugin-security)
  • content/docs/ui/audience-based-interfaces.mdx (via packages/plugins/plugin-security)
  • content/docs/ui/dashboards.mdx (via @objectstack/plugin-security)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Aug 6, 2026
@baozhoutao
baozhoutao marked this pull request as ready for review August 6, 2026 10:45
@baozhoutao
baozhoutao enabled auto-merge August 6, 2026 10:45
@baozhoutao
baozhoutao added this pull request to the merge queue Aug 6, 2026
Merged via the queue into main with commit dc6abfd Aug 6, 2026
24 checks passed
@baozhoutao
baozhoutao deleted the claude/issue-4967-refused-cap-accounting branch August 6, 2026 11:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants