Commit 21b7c12
fix(security): pass the stack's declared capabilities at every audience-anchor predicate consumer (#18602)
Fixes #18535
ADR-0090 D5 rules the `everyone`-anchor offending list as 「平台系统权限;带
package provenance 的应用声明 capability 令牌不计」. PR #17811 landed the
predicate that implements it — `describeHighPrivilegeBits(def,
context?)` / `describeAnchorForbiddenBits(def, anchor, context?)`, where
`AnchorBindingContext.declaredCapabilities` excuses a
`systemPermissions` name, the platform floor stays absolute and an
omitted context refuses — and its own changeset named this follow-up:
「the plugin-security boot refusal and the lint
security-anchor-high-privilege rule pass the declared list in a
follow-up」. This is that follow-up. `packages/spec/**` is untouched.
## What changed, per site
Premise re-verified on the branch before editing: four consumer sites,
none passing a context; `declaredCapabilities` / `AnchorBindingContext`
in `packages/plugins/plugin-security/src` + `packages/lint/src` → 0 hits
(control: 3 in `high-privilege.ts`).
| site | before | after |
|---|---|---|
| `plugin-security/src/security-plugin.ts` (boot bind,
`bindBaselineToEveryone`) | `const offending = boot ?
describeHighPrivilegeBits(boot) : null;` | `:3595` `const offending =
boot ? describeHighPrivilegeBits(boot, anchorContext) : null;` — context
read once per pass at `:3592` |
| `plugin-security/src/security-plugin.ts` (engine write gate) | `const
offending = describeAnchorForbiddenBits(boot ?? setDef, positionName as
'everyone' \| 'guest');` | `:5503`–`:5508` the same call with `await
declaredCapabilityContext()` as the third argument, memoised at `:5469`
|
| `plugin-security/src/suggested-audience-bindings.ts` (confirm path) |
`const offending = describeAnchorForbiddenBits(setRow, row.anchor as
'everyone' \| 'guest');` | `:968`–`:972` the same call with `await
readDeclaredCapabilityContext(ql, deps.metadata)` |
| `lint/src/validate-security-posture.ts`
(`security-anchor-high-privilege`) | `const offending =
describeAnchorForbiddenBits(ps, 'everyone');` | `:795`
`describeAnchorForbiddenBits(ps, 'everyone', anchorContext)`, built at
`:440`–`:443` from `recordsOf(stack.capabilities)` |
New module:
`packages/plugins/plugin-security/src/declared-capability-context.ts` —
`readDeclaredCapabilityContext(ql, metadataService)`, the registry-first
/ metadata-service-fallback read the `sys_capability` seeder itself
uses, returning `undefined` when the stack declares nothing.
## Where the declared list is read, and why that moment is safe
**Boot (the three runtime doors) reads the DECLARATIONS, not the
`sys_capability` rows.** The predicate's docblock names the rows at
boot; the ordering forbids it, so the card's ruled fallback applies and
this is the "say so" half of it.
Ordering evidence, all in `security-plugin.ts`'s `runBootstrap`:
- `:3878` `for (const organizationId of catalogPasses) await
bindBaselineToEveryone(organizationId);`
- `:3917` `const capOutcome = await bootstrapDeclaredCapabilities(ql,
this.metadata, …);`
- `:3926` `await bootstrapSystemCapabilities(ql, …)`
The binding runs 39 lines and one awaited pass BEFORE the seeder that
writes `managed_by:'package'` rows, so on a first boot that table is
empty at bind time; reading it there would refuse every declared token
one layer in. The position is pinned by two other constraints stated in
the code at `:3866`–`:3868`: the bind MUST follow
`bootstrapBuiltinRoles` (which seeds the `everyone` anchor) and MUST
precede `reconcileAudienceBindingSuggestions`. Nothing in the boot
sequence was reordered.
The same reader serves the engine write gate and
`confirmAudienceBindingSuggestion` on purpose: the confirm check is the
friendly early rendition of the gate that re-enforces the predicate on
the insert it performs, so a second source there could answer
"confirmed" and then have its own write refused under it.
**Lint** reads the stack's own `capabilities:` collection through
`recordsOf(stack.capabilities)` — the authoring-time source the
predicate's docblock names, indexed by the same helper every other
collection in the rule uses. No second declaration source was invented.
## Pins (each beside the consumer it guards, three cases per door)
| file:line | case |
|---|---|
| `packages/plugins/plugin-security/src/security-plugin.test.ts:4372` |
boot: a declared token BINDS (row asserted, not just a flag) |
| `packages/plugins/plugin-security/src/security-plugin.test.ts:4381` |
boot: an UNDECLARED token still refuses (declarations present, naming a
different capability) |
| `packages/plugins/plugin-security/src/security-plugin.test.ts:4392` |
boot: a PLATFORM capability still refuses although the stack declares
that name |
| `packages/plugins/plugin-security/src/security-plugin.test.ts:4410` |
write gate: admits the declared token |
| `packages/plugins/plugin-security/src/security-plugin.test.ts:4415` |
write gate: refuses the undeclared one — `code: PERMISSION_DENIED`,
`statusCode: 403` (ADR-0112 envelope), message names the class |
| `packages/plugins/plugin-security/src/security-plugin.test.ts:4426` |
write gate: refuses the platform capability, same envelope |
|
`packages/plugins/plugin-security/src/suggested-audience-bindings.test.ts:347`
| confirm: binds, and the bound row really carries the token |
|
`packages/plugins/plugin-security/src/suggested-audience-bindings.test.ts:365`
| confirm: undeclared still refused, suggestion stays `pending` |
|
`packages/plugins/plugin-security/src/suggested-audience-bindings.test.ts:378`
| confirm: platform capability still refused |
| `packages/lint/src/validate-security-posture.test.ts:457` | lint: a
declared token lints CLEAN |
| `packages/lint/src/validate-security-posture.test.ts:473` | lint: an
undeclared token still errors |
| `packages/lint/src/validate-security-posture.test.ts:492` | lint: a
platform capability still errors |
The platform-floor cases reuse `high-privilege.ts`'s own vocabulary
(`manage_users` from `PLATFORM_CAPABILITY_NAMES`), so the two layers
cannot drift. The boot pins drive the METADATA-SERVICE door of the
reader and the confirm pins drive the REGISTRY door, so both halves of
the fallback are exercised. Three cases per door and not one: "the
declared token binds" alone is equally satisfied by a door that stopped
judging `systemPermissions` altogether.
The lint meta-pins (#5017) were visited deliberately rather than
silenced: `stack.capabilities` joined the `stack` read surface and a
`cap` receiver entry was added against
`ObjectStackSchema.capabilities[]`, so the new read is held to the same
"reads only keys the spec declares" rule as every other.
## Changesets
- `.changeset/18535-anchor-declared-capabilities-consumers.md` —
`@objectstack/plugin-security`: minor
- `.changeset/18535-lint-anchor-declared-capabilities.md` —
`@objectstack/lint`: minor
`minor`, not `patch`: the PR declares `Clause-②: yes (widening)` and
`check:changeset-no-major` requires at least one moved package at
`minor` or above under that declaration. Both bodies carry the arm and
the consumer-facing FROM → TO sentence.
## Measurements
**Red-then-green, with the control lit.** Reverse verification ran from
the COMMITTED fix, mutating the four call sites back to their pre-fix
argument lists, proving the mutation reached the disk (anchored
occurrence counts 1 → 0 for each fixed spelling, plus `git diff
--stat`), and restoring under a `trap … EXIT INT TERM` with absolute
paths. The subjects resolve through `src` (same-package relative
imports), so no `dist` leg applies.
- ablated `plugin-security` (both files): `Tests 3 failed | 293 passed`
— exactly the three accepting pins (`binds an isDefault set …`, `binds
the isDefault set …`, `write gate: admits …`)
- ablated `lint`: `Tests 1 failed | 125 passed` — exactly the accepting
pin
- the six refusal controls (undeclared + platform, at each door) stayed
GREEN under the ablation, which is what makes the four reds mean the
context and not the predicate
- restore leg proven by blob identity, not by an exit code: `git
hash-object` of each of the three files equals its `HEAD` blob
(`3a8fd520…`, `30c2ad7c…`, `f16fb00e…`), `git status` clean, `git diff
HEAD` empty
**Suites (merged tree, `1fcf14513`):**
- `pnpm --filter @objectstack/lint --filter @objectstack/plugin-security
test` → exit 0 — lint `103 files / 3868 tests`, plugin-security `113
files / 2190 tests`
- `pnpm --filter @objectstack/lint --filter @objectstack/plugin-security
typecheck` → exit 0, 0 `error TS`
- `pnpm lint` (repo-wide `eslint . --no-inline-config`) → exit 0 — the
whole population, no narrowing claimed
- targeted `eslint --format json` over the 7 changed source files → 7
files, 0 errors, 0 warnings
**Derived gates** — `node scripts/pm/dispatch-gates.mjs --commands
--repo objectstack-ai/objectstack`, re-derived after the merge: 71
families, all run, reconciled with `--ran` carrying each exit code → `71
derived, 68 run, 3 NOT-MEASURED, 0 UNRUN`. 67 green. The four non-zero:
- `pnpm check:cross-package-test-inputs` → exit 1. NOT caused by this
diff, proven with a control: at the base commit `e0d05538c` in a
separate worktree the gate exits 0 with no `packages/spec/dist/` on
disk, and exits 1 with the identical finding the moment one empty
`packages/spec/dist/security` directory exists. The finding names
`packages/cli/test/init-created-files-summary.e2e.test.ts` descending
into `packages/spec/dist/` — a file this PR does not touch, in a package
it does not touch. Reported for filing, not fixed here.
- `pnpm check:dual-build-cjs-loads`, `pnpm check:i18n`, `pnpm
check:type-check-debt` → exit 3, `PREREQUISITE NOT MET`: each refuses to
measure without a full workspace build (53 packages with no `dist/`).
NOT MEASURED locally, not a pass and not a finding; CI builds first and
runs them for real.
Three gates DID go red on this diff and were fixed, all in the new boot
double: `check:engine-double-contract` (grown seam counts ratcheted with
`--write`), `check:objectql-double-limit` (the `find` double now applies
the caller's bound by presence, after the filter) and
`check:where-matcher` (the matcher now REFUSES a `$`-prefixed combinator
instead of comparing it as a field name — the refusal had to live INSIDE
the matcher callback, since that gate probes the extracted matcher
behaviourally).
**Merge:** `origin/main` moved from `e0d05538c` to `b79fae8fb` during
the work and PR #18503 landed in `validate-security-posture.ts`. The one
conflict was the `@objectstack/spec` import line; BOTH sides were kept
(`referenceCarrierOf` from `/data` and `describeAnchorForbiddenBits,
type AnchorBindingContext` from `/security`), neither dropped, and every
measurement above was re-taken on the merged tree.
## Note for the contract-tier reviewer (Clause-② yes)
Exactly two accept sets widen, both by the same ruled rule and both only
for the `everyone` anchor:
1. the runtime anchor-binding accept set (boot bind, engine write gate,
suggestion confirm) — a `systemPermissions` token THIS stack declares
under `capabilities:` no longer counts as a platform system permission;
2. the lint rule `security-anchor-high-privilege`'s accept set for
`isDefault: true` sets — the same names, at authoring time.
What did NOT move: the platform floor (`PLATFORM_CAPABILITY_NAMES` is
applied inside the predicate, so declaring `manage_users` launders
nothing); undeclared names (still refused everywhere); the `guest` tier
(the predicate drops the context for `guest` by contract, and no call
site overrides that); the VAMA / delete / transfer / bulk-export /
wildcard arms of the predicate; the boot sequence's order; and the
failure direction when the declarations cannot be read — an unreadable
registry, an unreadable metadata service, or an empty list all yield
`undefined`, which is the pre-#17811 verdict verbatim.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01Gqi43smmqjJ5sUrhfoPeKu)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent d0b8ec2 commit 21b7c12
10 files changed
Lines changed: 511 additions & 10 deletions
File tree
- .changeset
- packages
- lint/src
- plugins/plugin-security/src
- scripts
Lines changed: 21 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
447 | 447 | | |
448 | 448 | | |
449 | 449 | | |
| 450 | + | |
| 451 | + | |
| 452 | + | |
| 453 | + | |
| 454 | + | |
| 455 | + | |
| 456 | + | |
| 457 | + | |
| 458 | + | |
| 459 | + | |
| 460 | + | |
| 461 | + | |
| 462 | + | |
| 463 | + | |
| 464 | + | |
| 465 | + | |
| 466 | + | |
| 467 | + | |
| 468 | + | |
| 469 | + | |
| 470 | + | |
| 471 | + | |
| 472 | + | |
| 473 | + | |
| 474 | + | |
| 475 | + | |
| 476 | + | |
| 477 | + | |
| 478 | + | |
| 479 | + | |
| 480 | + | |
| 481 | + | |
| 482 | + | |
| 483 | + | |
| 484 | + | |
| 485 | + | |
| 486 | + | |
| 487 | + | |
| 488 | + | |
| 489 | + | |
| 490 | + | |
| 491 | + | |
| 492 | + | |
| 493 | + | |
| 494 | + | |
| 495 | + | |
| 496 | + | |
| 497 | + | |
| 498 | + | |
| 499 | + | |
| 500 | + | |
| 501 | + | |
| 502 | + | |
| 503 | + | |
| 504 | + | |
| 505 | + | |
| 506 | + | |
| 507 | + | |
| 508 | + | |
| 509 | + | |
| 510 | + | |
450 | 511 | | |
451 | 512 | | |
452 | 513 | | |
| |||
1189 | 1250 | | |
1190 | 1251 | | |
1191 | 1252 | | |
1192 | | - | |
| 1253 | + | |
| 1254 | + | |
| 1255 | + | |
| 1256 | + | |
| 1257 | + | |
1193 | 1258 | | |
1194 | 1259 | | |
1195 | 1260 | | |
| |||
1261 | 1326 | | |
1262 | 1327 | | |
1263 | 1328 | | |
| 1329 | + | |
| 1330 | + | |
| 1331 | + | |
| 1332 | + | |
| 1333 | + | |
| 1334 | + | |
| 1335 | + | |
| 1336 | + | |
| 1337 | + | |
| 1338 | + | |
1264 | 1339 | | |
1265 | 1340 | | |
1266 | 1341 | | |
| |||
1304 | 1379 | | |
1305 | 1380 | | |
1306 | 1381 | | |
| 1382 | + | |
1307 | 1383 | | |
1308 | 1384 | | |
1309 | 1385 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
119 | 119 | | |
120 | 120 | | |
121 | 121 | | |
122 | | - | |
| 122 | + | |
123 | 123 | | |
124 | 124 | | |
125 | 125 | | |
| |||
427 | 427 | | |
428 | 428 | | |
429 | 429 | | |
| 430 | + | |
| 431 | + | |
| 432 | + | |
| 433 | + | |
| 434 | + | |
| 435 | + | |
| 436 | + | |
| 437 | + | |
| 438 | + | |
| 439 | + | |
| 440 | + | |
| 441 | + | |
| 442 | + | |
| 443 | + | |
430 | 444 | | |
431 | 445 | | |
432 | 446 | | |
| |||
778 | 792 | | |
779 | 793 | | |
780 | 794 | | |
781 | | - | |
| 795 | + | |
782 | 796 | | |
783 | 797 | | |
784 | 798 | | |
| |||
Lines changed: 81 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
0 commit comments