Skip to content

fix(security): pass the stack's declared capabilities at every audience-anchor predicate consumer - #18602

Merged
os-justin merged 4 commits into
mainfrom
claude/issue-18535-declared-capabilities-consumers
Sep 17, 2026
Merged

os-justin merged 4 commits into
mainfrom
claude/issue-18535-declared-capabilities-consumers

Conversation

@os-justin

Copy link
Copy Markdown
Collaborator

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.tsreadDeclaredCapabilityContext(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 gatesnode 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

… 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
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 17, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/lint, @objectstack/plugin-security, touching 6 documentable anchor(s).

3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/client-sdk.mdx (via security.suggestedBindings.confirm (sdk, the route ledger binds it to POST /api/v1/security/suggested-bindings/:id/confirm, selected by route anchor /security/suggested-bindings/:id/confirm; the route ledger binds it to POST /security/suggested-bindings/:id/confirm))
  • content/docs/permissions/authorization.mdx (via validateSecurityPosture (symbol, a top-level function))
  • content/docs/permissions/permission-sets.mdx (via /security/suggested-bindings/:id/confirm (route, bridged from symbol confirmAudienceBindingSuggestion — its route source's handler names it))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v13.mdx (via validateSecurityPosture (symbol, a top-level function))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 18 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json ad067addec3731c4da61fe1de75d2212029fa8efpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 46575539988c9e11bebd9c978eb568b335967589 — the merge of head 1fcf14513ca02114214c315e9c59e1f8693e6cfa into base ad067addec3731c4da61fe1de75d2212029fa8ef, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs ad067addec3731c4da61fe1de75d2212029fa8ef → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Copy link
Copy Markdown
Collaborator Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 1fcf14513ca02114214c315e9c59e1f8693e6cfa

In-seat review by the dispatching domain:skills seat (the blocked seat that took #18535 as the ownerless blocker of #17359), 2026-09-17T08:24Z, read against the PR's diff, files and head on GitHub and the fetched branch — ⛔ not against the dev report (5711251082). Clause-②: yes on both carriers; needs:contract-review hung on both at 2026-09-17T08:22Z (the seat's omission at claim time, repaired before this record) and stripped by this PASS in the same act.

① Derived judgments

  • The accept set widens exactly to the ruled set, at four doors, from one source. ADR-0090 D5 (「平台系统权限;带 package provenance 的应用声明 capability 令牌不计」) is implemented by PR feat(spec): an app-declared capability token is not a platform system permission at the everyone anchor #17811's predicate with context?.declaredCapabilities; this PR hands that context to every consumer that lacked it — bindBaselineToEveryone (describeHighPrivilegeBits(boot, anchorContext), read once per pass), the engine write gate on sys_position_permission_set (describeAnchorForbiddenBits(boot ?? setDef, positionName, await declaredCapabilityContext()), memoised, read only once an anchor row is in play), confirmAudienceBindingSuggestion (the same call with readDeclaredCapabilityContext(ql, deps.metadata)) and the lint rule security-anchor-high-privilege (describeAnchorForbiddenBits(ps, 'everyone', anchorContext) from recordsOf(stack.capabilities)). Correct: the pre-fix reading (0 hits of declaredCapabilities / AnchorBindingContext in the two packages' src) reproduced on the base.
  • What does not move, verified at the predicate, not in the PR's prose. The platform floor is applied inside describeHighPrivilegeBits (PLATFORM_CAPABILITY_NAMES), so declaring a platform name excuses nothing; describeAnchorForbiddenBits drops the context for guest (high-privilege.ts :195 「anchor === 'guest' ? undefined : context」), so the strictest tier is untouched; an undeclared name and an empty / unreadable declaration list yield undefined ⇒ the pre-feat(spec): an app-declared capability token is not a platform system permission at the everyone anchor #17811 verdict (omission refuses). The new reader declared-capability-context.ts fails closed at every step and never derives the list from the set under test.
  • The boot source is the declarations, and the ordering evidence holds on the head: bootstrapBuiltinRoles :3572 → bindBaselineToEveryone :3639 / :3888 → reconcileAudienceBindingSuggestions :3742 / :3905 → bootstrapDeclaredCapabilities :3927 → bootstrapSystemCapabilities :3936. The sys_capability rows with managed_by:'package' do not exist at bind time on a first boot; the card's ruled fallback (take the metadata declarations and say so) is taken and said in the module docblock, the call-site comment and the PR body. The reader is the seeder's own two-step (readDeclared(ql, 'capability') — the same call bootstrap-declared-capabilities.ts :460 makes — then the metadata service). No boot reordering; packages/spec/**, ADRs and skills/** untouched.
  • Pins in place, where the fork is caught: 12 cases across the three existing test files — declared binds / lints clean, undeclared still refuses, platform still refuses, at boot, at the write gate (with the ADR-0112 envelope), at confirm and at lint; the platform-floor cases reuse high-privilege.ts's vocabulary. The dev's ablation (four call sites reverted from the committed fix) turned exactly the four accepting pins red and left the six refusal controls green — the reds measure the context, not the predicate.
  • Merge with PR Retire check-reference-carrier-shape; refuse an unreadable reference carrier at the reader #18503 on validate-security-posture.ts: one conflict on the @objectstack/spec import line, both sides kept (referenceCarrierOf from /data; describeAnchorForbiddenBits + type AnchorBindingContext from /security) — read on the head, correct.
  • scripts/engine-double-contract.pinned.json +2 / −2: the seam counts of the new boot double in security-plugin.test.ts (findOne 5 → 6, update 1 → 2) re-pinned by the gate's own --write, not by hand; the two other doubles-gates that went red (check:objectql-double-limit, check:where-matcher) were fixed in the new double itself. Accepted.

② Semver level

minor for both @objectstack/plugin-security and @objectstack/lint, changesets named 18535-*.md, each carrying Clause-②: yes (widening) and the consumer-facing FROM/TO sentence — consistent with check-changeset-no-major (a PR declaring clause ② moves at least one package at minor) and with the launch-window level policy.

③ Boundary flags

Implemented-by: claude/issue-18535-declared-capabilities-consumers
Reviewed-by: session_01Gqi43smmqjJ5sUrhfoPeKu

VERDICT: PASS


Generated by Claude Code

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/l tests tooling

Projects

None yet

2 participants