fix(security): pass the stack's declared capabilities at every audience-anchor predicate consumer - #18602
Conversation
… predicate consumer WIP: the four consumer sites of describeHighPrivilegeBits / describeAnchorForbiddenBits now hand over AnchorBindingContext. Claude-Session: https://claude.ai/code/session_01Gqi43smmqjJ5sUrhfoPeKu Co-authored-by: Claude <noreply@anthropic.com>
…-predicate consumer ADR-0090 D5's `everyone`-anchor excusal for app-declared capability tokens landed as a spec predicate in PR #17811 and no consumer passed it a context, so a declared token still made an `isDefault` set unbindable at boot, at the engine write gate, at the suggestion confirm path and in the lint rule. All four now hand over `AnchorBindingContext.declaredCapabilities`, read from the stack's `capabilities:` declarations; the platform floor and the undeclared-name refusal are unchanged, and the `guest` tier is untouched. Claude-Session: https://claude.ai/code/session_01Gqi43smmqjJ5sUrhfoPeKu Co-authored-by: Claude <noreply@anthropic.com>
The `find` double added for the #18535 anchor pins refuses a WHERE combinator it does not implement instead of comparing it as a field name, applies the caller's bound by presence after the filter, and its grown seam counts are ratcheted into the engine-double ledger. Claude-Session: https://claude.ai/code/session_01Gqi43smmqjJ5sUrhfoPeKu Co-authored-by: Claude <noreply@anthropic.com>
…clared-capabilities-consumers # Conflicts: # packages/lint/src/validate-security-posture.ts
📓 Docs Drift CheckThis PR changes 2 package(s): 3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 18 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 46575539988c9e11bebd9c978eb568b335967589 && git checkout 46575539988c9e11bebd9c978eb568b335967589
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ad067addec3731c4da61fe1de75d2212029fa8ef 1fcf14513ca02114214c315e9c59e1f8693e6cfa && git checkout -B drift-repro ad067addec3731c4da61fe1de75d2212029fa8ef && git merge --no-ff 1fcf14513ca02114214c315e9c59e1f8693e6cfa
node scripts/docs-audit/affected-docs.mjs --json ad067addec3731c4da61fe1de75d2212029fa8ef
|
Contract reviewServed-tier: In-seat review by the dispatching ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
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?), whereAnchorBindingContext.declaredCapabilitiesexcuses asystemPermissionsname, 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/AnchorBindingContextinpackages/plugins/plugin-security/src+packages/lint/src→ 0 hits (control: 3 inhigh-privilege.ts).plugin-security/src/security-plugin.ts(boot bind,bindBaselineToEveryone)const offending = boot ? describeHighPrivilegeBits(boot) : null;:3595const offending = boot ? describeHighPrivilegeBits(boot, anchorContext) : null;— context read once per pass at:3592plugin-security/src/security-plugin.ts(engine write gate)const offending = describeAnchorForbiddenBits(boot ?? setDef, positionName as 'everyone' | 'guest');:5503–:5508the same call withawait declaredCapabilityContext()as the third argument, memoised at:5469plugin-security/src/suggested-audience-bindings.ts(confirm path)const offending = describeAnchorForbiddenBits(setRow, row.anchor as 'everyone' | 'guest');:968–:972the same call withawait readDeclaredCapabilityContext(ql, deps.metadata)lint/src/validate-security-posture.ts(security-anchor-high-privilege)const offending = describeAnchorForbiddenBits(ps, 'everyone');:795describeAnchorForbiddenBits(ps, 'everyone', anchorContext), built at:440–:443fromrecordsOf(stack.capabilities)New module:
packages/plugins/plugin-security/src/declared-capability-context.ts—readDeclaredCapabilityContext(ql, metadataService), the registry-first / metadata-service-fallback read thesys_capabilityseeder itself uses, returningundefinedwhen 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_capabilityrows. 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'srunBootstrap::3878for (const organizationId of catalogPasses) await bindBaselineToEveryone(organizationId);:3917const capOutcome = await bootstrapDeclaredCapabilities(ql, this.metadata, …);:3926await 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 followbootstrapBuiltinRoles(which seeds theeveryoneanchor) and MUST precedereconcileAudienceBindingSuggestions. Nothing in the boot sequence was reordered.The same reader serves the engine write gate and
confirmAudienceBindingSuggestionon 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 throughrecordsOf(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)
packages/plugins/plugin-security/src/security-plugin.test.ts:4372packages/plugins/plugin-security/src/security-plugin.test.ts:4381packages/plugins/plugin-security/src/security-plugin.test.ts:4392packages/plugins/plugin-security/src/security-plugin.test.ts:4410packages/plugins/plugin-security/src/security-plugin.test.ts:4415code: PERMISSION_DENIED,statusCode: 403(ADR-0112 envelope), message names the classpackages/plugins/plugin-security/src/security-plugin.test.ts:4426packages/plugins/plugin-security/src/suggested-audience-bindings.test.ts:347packages/plugins/plugin-security/src/suggested-audience-bindings.test.ts:365pendingpackages/plugins/plugin-security/src/suggested-audience-bindings.test.ts:378packages/lint/src/validate-security-posture.test.ts:457packages/lint/src/validate-security-posture.test.ts:473packages/lint/src/validate-security-posture.test.ts:492The platform-floor cases reuse
high-privilege.ts's own vocabulary (manage_usersfromPLATFORM_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 judgingsystemPermissionsaltogether.The lint meta-pins (#5017) were visited deliberately rather than silenced:
stack.capabilitiesjoined thestackread surface and acapreceiver entry was added againstObjectStackSchema.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: minorminor, notpatch: the PR declaresClause-②: yes (widening)andcheck:changeset-no-majorrequires at least one moved package atminoror 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 atrap … EXIT INT TERMwith absolute paths. The subjects resolve throughsrc(same-package relative imports), so nodistleg applies.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 …)lint:Tests 1 failed | 125 passed— exactly the accepting pingit hash-objectof each of the three files equals itsHEADblob (3a8fd520…,30c2ad7c…,f16fb00e…),git statusclean,git diff HEADemptySuites (merged tree,
1fcf14513):pnpm --filter @objectstack/lint --filter @objectstack/plugin-security test→ exit 0 — lint103 files / 3868 tests, plugin-security113 files / 2190 testspnpm --filter @objectstack/lint --filter @objectstack/plugin-security typecheck→ exit 0, 0error TSpnpm lint(repo-wideeslint . --no-inline-config) → exit 0 — the whole population, no narrowing claimedeslint --format jsonover the 7 changed source files → 7 files, 0 errors, 0 warningsDerived gates —
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, re-derived after the merge: 71 families, all run, reconciled with--rancarrying 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 commite0d05538cin a separate worktree the gate exits 0 with nopackages/spec/dist/on disk, and exits 1 with the identical finding the moment one emptypackages/spec/dist/securitydirectory exists. The finding namespackages/cli/test/init-created-files-summary.e2e.test.tsdescending intopackages/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 nodist/). 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(thefinddouble now applies the caller's bound by presence, after the filter) andcheck: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/mainmoved frome0d05538ctob79fae8fbduring the work and PR #18503 landed invalidate-security-posture.ts. The one conflict was the@objectstack/specimport line; BOTH sides were kept (referenceCarrierOffrom/dataanddescribeAnchorForbiddenBits, type AnchorBindingContextfrom/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
everyoneanchor:systemPermissionstoken THIS stack declares undercapabilities:no longer counts as a platform system permission;security-anchor-high-privilege's accept set forisDefault: truesets — the same names, at authoring time.What did NOT move: the platform floor (
PLATFORM_CAPABILITY_NAMESis applied inside the predicate, so declaringmanage_userslaunders nothing); undeclared names (still refused everywhere); theguesttier (the predicate drops the context forguestby 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 yieldundefined, which is the pre-#17811 verdict verbatim.Generated by Claude Code