Repository navigation
fix(plugin-security): a capability name collision now reaches the author - #18088
Conversation
`bootstrapDeclaredCapabilities` refuses to write into a `sys_capability` row another package owns (ADR-0086 D4). That skip is correct and is unchanged. What was wrong is that it was invisible: `logger?.warn?.(...)` is optionally chained twice, so a caller passing no logger produced no output at all and an entire declared capability vanished with one internal counter moved. Measured on the pre-fix tree, no logger passed: `skippedForeign = 1`, author-visible console lines = 0 across all five channels. Adds `capability-name-collision.ts` — its own token, record type and wording, because the consequence on this axis differs from the permission-set axis (the name still resolves through the owning package's row; what is lost is the declaring package's authored label/description/scope and its provenance claim). Reuses, rather than re-derives, the axis-independent halves: the `permissionSetNameIsForeign` predicate and the `CollisionReportSink` type. Claude-Session: https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj Co-authored-by: Claude <noreply@anthropic.com>
Two layers. `capability-name-collision.test.ts` pins the pure half — the token, the record's capability-axis wording, and the report channel, including the two trap shapes (`logger?.warn?.(...)` printing nothing, and `(logger?.warn ?? console.warn)(...)` losing the receiver on a class-based sink). The seeder-side pins live in the existing `bootstrap-declared-capabilities` suite and reuse its engine double rather than standing up a second one — this card names no engine seam, so it adds no row to the engine-double ledger. They assert the 0 -> 1 reading with no logger injected, the skip UNCHANGED (ADR-0086 D4: the foreign row is never mutated and the name is still reported materialized), one line per pass rather than one per dropped declaration, and a no-collision control that is silent on all five console channels over a pass that really seeds, updates, claims and leaves a row unchanged. Claude-Session: https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj Co-authored-by: Claude <noreply@anthropic.com>
…ken, not its prose Two `bootstrap-seed-round-trips` cases asserted loudness by matching the old sentence "owned by another package". The wording moved when the refusal grew a diagnostic record, so they now match the stable `capability_name_collision` grep token and additionally assert the record on the pass outcome. Claude-Session: https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 5 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 — 15 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 2d6efa6fa32bfc85809754d0e7e3a4f5afae9929 && git checkout 2d6efa6fa32bfc85809754d0e7e3a4f5afae9929
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin b3d6918b86abcc31f82e963d2fabc37308bc4bb7 9cbca2136fde1f610cd7d1f28f77ae5afe9b0852 && git checkout -B drift-repro b3d6918b86abcc31f82e963d2fabc37308bc4bb7 && git merge --no-ff 9cbca2136fde1f610cd7d1f28f77ae5afe9b0852
node scripts/docs-audit/affected-docs.mjs --json b3d6918b86abcc31f82e963d2fabc37308bc4bb7
|
Contract reviewServed-tier: Reviewed head: ① Derived judgments — acceptance set and public surface, item by item
One precision remark that does not change the reading: the PR's Head-state facts: full package suite on this head is 113 files / 2181 tests passing in my run; ② Semver gradingDeclared ③ Boundary flags
Implemented-by: Verdict: PASS Generated by Claude Code |
|
Record of governing verdict: comment 5657757282 on this PR, judging head
The declaration
Generated by Claude Code |
…thor (objectstack-ai#18564) Fixes objectstack-ai#18091 The two declared-metadata seeders refuse to write in five more places, and every one of those refusals reported through the doubly-optional `logger?.warn?.(…)`, so a caller that injected no logger got no output at all. Each site now words its own consequence, stamps its own grep token and builds its own record, over ONE shared delivery rule. ## Caliber declared: the FULL SPELLING SWEEP — and the two readings reconcile The dispatch ruled the full sweep (every occurrence of the doubly-optional spelling in the two seeders) over the card's five refusal sites. Re-measured on current `origin/main` (`79a046f8`), ⛔ not carried forward: | file | raw `grep -c` | prose (in comments) | CALL SITES | |---|---|---|---| | `bootstrap-declared-capabilities.ts` | 4 | 1 | **3** | | `bootstrap-declared-permissions.ts` | 3 | 1 | **2** | | total | **7** | **2** | **5** | ⭐ The triage seat's 7 reproduces exactly. **The delta of 2 is not two more call sites — it is prose**: `bootstrap-declared-capabilities.ts:409` and `bootstrap-declared-permissions.ts:313` are comment lines in which objectstack-ai#18023 and objectstack-ai#17516 each QUOTE the spelling while explaining the defect they repaired. Once comments are excluded, the full-sweep caliber and the refusal-site caliber name the SAME five call sites on this tree. So this PR is the full sweep, and the full sweep is 5. After the change: **0 call sites in either file**, and the 2 prose lines are deliberately kept — they are the historical record of why the shape exists — and are now pinned as comments-only by a class pin that reds the moment a sixth call site appears. Control that the instrument was reading: bare `logger` occurrences in the two files, 18 and 16. ## The reading this card moves (P2), measured before anything was touched Each site driven with **no logger passed**, all five console channels spied, beside the two already-repaired axes as lit controls **in the same harness**: ``` counter author-visible lines site 1 curated platform capability skippedPlatform = 1 0 site 2 capability declaration unowned skippedUnowned = 1 0 site 3 capability rows unreadable unreadable = 1 0 site 4 permission set decl. unowned (no counter at all) 0 site 4b same, via the ADR-0086 P2 door (no counter at all) 0 site 5 permission set rows unreadable unreadable = 1 0 LIT capability_name_collision skippedForeign = 1 1 LIT permission_set_name_collision skippedForeign = 1 1 ``` Every zero is now a 1, with every counter unchanged.⚠️ **Site 4 has a second door.** `upsertPackagePermissionSet` is also the ADR-0086 P2 publish materializer, which passes no collector — a repair that only lit the boot loop would have left that caller exactly as mute. It is pinned separately. ## ⛔ No skip changed Every skip is correct under ADR-0086 D4 (a package never writes into a foreign record) and ADR-0086 D3 (a package-managed row with no `package_id` makes uninstall undefined). Each test asserts the unchanged outcome beside the new line. ## Why five diagnostics and not a mechanical copy Applied, ⛔ not re-argued: the token, the record, the wording and the CONSEQUENCE are site-specific. The five consequences are genuinely different facts — - a curated-platform-name hijack still **resolves** (the curated pass owns the row), so nothing is denied and only the authored metadata and the ADR-0086 D3 provenance claim are lost; the remedy is rename-only, because unlike a package-to-package collision (ADR-0130 D1) a curated name is not co-ownable; - an unowned **capability** has three outcomes — an existing row, the back-compat derived placeholder, or nowhere at all — decided by what already stands in `sys_capability`. That three-way wording is preserved verbatim from objectstack-ai#4967 Part 3; - an unowned **permission set** keeps every grant working, because the evaluator resolves declared sets through the metadata registry; only the RECORD is lost (Setup surface, provenance axis, uninstall); - an unreadable read compared nothing and wrote nothing, so nothing is lost and nothing arrived either — and what that costs differs again between the two tables. One generic "declaration skipped" sentence would send the first author hunting for a broken grant that is not broken. ## What IS shared, and why that is the point of the card Exactly one thing: WHERE the line goes. This shape had been repaired instance-by-instance twice, each repair restating the same two lines at its own call site. `reportThroughSink()` is now the single derivation, so a sixth site cannot re-earn the card. It also improves on both spellings it replaces: - `logger?.warn?.(…)` bought safety against a host sink that lies about its shape **with silence** — the defect; - `logger.warn(…)` would buy noise **with a throw** inside a seeding pass, the hazard `ProjectionLogger.warn` records; - the `typeof` guard buys neither, and keeps the receiver so a class-based host logger does not throw. Pinned on both axes. Reused, ⛔ not re-derived, per the objectstack-ai#18088 precedent: `CollisionReportSink` is imported from the sibling module rather than re-declared. ⛔ `reportPermissionSetNameCollisions` is not threaded with an axis parameter — PR objectstack-ai#18088 measured that it does not generalise.⚠️ **The INFO channel keeps its outer `?.` deliberately.** A pass that did its work and refused nothing must stay silent on every console channel with no sink injected — that is the discriminating control objectstack-ai#18023 landed, and both axes now carry one. The refusal channel is the one where silence was the defect. Asserted explicitly in the class pins. ## New published surface On the criterion the two existing collision diagnostics state and no wider: a refusal an **author** can cause has a second door by construction (`@objectstack/lint`, `os build` / `os validate`), and each of these is decidable from the declaration alone with no database. - `CAPABILITY_PLATFORM_NAME_REFUSED` + `capabilityPlatformNameRefusedDiagnostic()` + `reportCapabilityPlatformNameRefused()` + the record type - `CAPABILITY_DECLARATION_UNOWNED` + `capabilityDeclarationUnownedDiagnostic()` + `reportCapabilityDeclarationUnowned()` + the record type - `PERMISSION_SET_DECLARATION_UNOWNED` + `permissionSetDeclarationUnownedDiagnostic()` + `reportPermissionSetDeclarationUnowned()` + the record type ⛔ `reportThroughSink()` is **not** published either. It was on this list in the first revision and the contract review was right to refuse it: it is the delivery primitive, not a refusal an author can cause, and it has no second-door consumer — the cli door records that it deliberately does not consume a sink printer. This package's own `logSeedDurabilityFailure` states the rule for its class verbatim — *"Deliberately absent from the package's `index.ts`: this is an intra-package helper, not public API"*. And the shape is about 390 raw hits across 29 package directories, so a shared delivery rule's home would be a core package, not this plugin's entry. The function did not move: `seed-refusal-diagnostics.ts` imports it relatively and all five report sites keep working. Verified off the built surface — `require(dist/index.js).reportThroughSink` is `undefined` and it appears in no `export` statement in `dist/index.d.ts`, while the controls `reportCapabilityDeclarationUnowned` (a function) and `CAPABILITY_DECLARATION_UNOWNED` (its token) both read live. ⛔ The two unreadable-rows summaries are **not** published: an unreadable database is a runtime condition no compile-time door can raise, so they stay package-private for the reason `position_name_fold_grant` does. Clause-②: yes — re-derived from the delivered diff (9 value + 3 type exports reachable from the published entry). ## Verification All at `cec185ad3`. **Tests.** `pnpm --filter @objectstack/plugin-security test` — **113 files / 2196 tests passed**. `pnpm --filter @objectstack/plugin-security typecheck` — clean (`tsc --noEmit`, `tsconfig.scripts.json`, and `check:test-typecheck`: 0 files / 0 errors / 0 pinned signatures). **ABLATION A1 — delivery.** `reportThroughSink`'s body mutated back to the doubly-optional spelling. On-disk proof before reading any result: guard line count 1 → 0, mute line count 0 → 1, `git diff --numstat` 1/2. Result — **10 failed | 46 passed**, and the ten are exactly the no-logger and lying-host pins at all five sites plus the publish door: ``` × UNOWNED DECLARATION: prints with NO LOGGER INJECTED, and names the RECORD as what is lost × UNOWNED DECLARATION reaches the author through the ADR-0086 P2 PUBLISH door too × UNREADABLE ROWS: prints with NO LOGGER INJECTED, with the count and the consequence × each site keeps its OWN sentence × a HOST SINK THAT LIES about its shape is reported to the console, never thrown at × CURATED PLATFORM NAME: prints with NO LOGGER INJECTED, and names what is lost × UNOWNED DECLARATION: prints with NO LOGGER INJECTED, keeping its three-way consequence × UNREADABLE ROWS: prints with NO LOGGER INJECTED, with the count and the consequence × each site keeps its OWN sentence × a HOST SINK THAT LIES about its shape is reported to the console, never thrown at ``` **ABLATION A2 — wording.** Site 1's distinguishing clauses replaced with site 2's, simulating exactly the generic-sentence regression that is forbidden here. On-disk proof: `CURATED PLATFORM capability` 1 → 0, `The name still resolves` 1 → 0, the borrowed clause 0 → 1. Result — **2 failed | 35 passed**, and the two are the per-site wording pins while every delivery pin stays green: ``` AssertionError: expected 'warn: [security] [capability_platform…' to contain 'CURATED PLATFORM capability' AssertionError: expected 'warn: [security] [capability_platform…' to contain 'The name still resolves' ``` Both legs restored from `HEAD` under a `trap … EXIT INT TERM` with absolute paths, verified by blob-hash equality and an empty `git status --porcelain` — ⛔ not by a step's exit code. **Gates.** Derived with `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` over the tool's own change set (8 paths vs merge base `79a046f8c`), each exit code captured to disk before any pipe, then reconciled with `--ran`: **63 derived, 60 run green, 3 NOT MEASURED, 0 UNRUN**. The 3 are `check:dual-build-cjs-loads`, `check:i18n` and `check:type-check-debt`, each exiting **3 = PREREQUISITE NOT MET** — all three read whole-repo build output, and the closure build (`turbo run build --filter='./packages/*' --filter='./packages/*/*'`) was killed at 520s by this container's foreground ceiling. ⛔ Not a pass and not a finding. CI builds that closure before the same steps.⚠️ `check:cross-package-test-inputs` exited 1 locally and is **green for this diff** — measured, not argued. Its single finding is `packages/cli/test/init-created-files-summary.e2e.test.ts` descending `packages/spec/dist/`, neither of which this diff touches. Control: with this full diff in the tree and `packages/spec/dist` moved aside, the same gate exits **0**; moved back, **1**. The finding is a function of a locally-built spec `dist`, and CI's `Lint & Repo Gates` job runs `pnpm install --frozen-lockfile` with no build, so that directory does not exist where the gate runs. **Lint.** ⛔ Not narrowed — the whole population was measured: `eslint . --no-inline-config --format json`, **6804 files** (the count eslint itself reported, not an estimate), **0 errors / 0 warnings**. ## Acceptance notes ⛔ Out of scope, filed nowhere by this PR, recorded here: - **The same doubly-optional spelling is live on a sixth site outside the two seeders.** `packages/plugins/plugin-security/src/seed-name-lookup.ts:452` reports the batched existence read's own failure through `logger?.warn?.(…)`. Measured mute here: the site-3 and site-5 cases in this PR drive an unreadable database with no logger and see exactly ONE author-visible line — this card's summary — while that one stays silent; with a logger injected the same pass produces both. It is the same class, but it is outside this card's ruled caliber (the two seeders), so it is reported for the triage seat rather than ridden in. A pin in this PR asserts the count by filtering on this card's tokens rather than counting lines, precisely so that line's removal stays visible. - **The permission-set unowned refusal increments no counter at all.** Every other refusal path on both axes moves one (`skippedPlatform`, `skippedUnowned`, `skippedForeign`, `unreadable`); this branch returns an all-zero outcome, so a caller reading no log still cannot tell it happened. Widening it means editing `PermissionSeedOutcome` in `permission-set-projection.ts`, outside this card's declared file surface. Noted, ⛔ not filed. - **The two already-landed reporters still hand-spell the delivery rule.** `reportPermissionSetNameCollisions` and `reportCapabilityNameCollisions` each carry their own `if (logger) … else console.warn(…)`, which `reportThroughSink` now supersedes and which would additionally gain the lying-host guard. Routing them through it is behaviour-preserving, but both files are outside this card's declared file surface. Noted, ⛔ not filed — it is the remaining half of closing the class. - **Cardinality is preserved, ⛔ not reconsidered.** Sites 1, 2 and 4 keep one line per refused declaration, as they had; sites 3 and 5 keep one line per pass with the count, as they had. Making the per-declaration sites summarise once per pass (the shape objectstack-ai#17516 and objectstack-ai#18023 chose for their collisions) would change what an injected-logger host sees and is not what this card measured. --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #18023
Clause-②: yes
A package's declared capability was dropped on a capability-name collision and the drop reached nobody. The branch reported through
logger?.warn?.(…)— optionally chained twice — so a caller that passed no logger produced no output at all, and the declaration vanished with one internal counter incremented. This module's own header called such a row "skipped loudly"; nothing about it was loud.⛔ The skip is unchanged. Refusing to write into a
sys_capabilityrow another package owns is correct under ADR-0086 D4, and every case below asserts it still happens, still counts, still leaves the foreign row untouched, and still reports the name as materialized so the back-compat derivation does not clobber that row (#4967 Part 1). What changed is that the refusal is now legible to the author who caused it.Reproduced first, then measured again
Both runs drive a real collision through
bootstrapDeclaredCapabilitieswith no logger passed, spying on all five console channels. The permission-set axis runs beside it in the same harness as the lit control.57343f761, pre-change)9cbca2136)skippedForeignundefinedThe after line, verbatim:
The design choice: a parallel module, not an extension, and not a copy
The card asked for "its own diagnostic record type, its own key on the capability seeder's outcome, and its own pins — a second design rather than a mechanical copy". Measured against the shared machinery on
main, four of the six things a collision report is made of are axis-specific here — the token, the record type, the wording and the consequence — so they live in a newcapability-name-collision.ts. Folding them into a file namedpermission-set-name-collision.tswould make that name lie, and renaming that file churns pins that landed one card ago.The consequence really is different, and that is the reason this is not a copy. On the permission-set axis the entire declared set is not materialized and none of its permissions are in effect. Here the name still resolves — the owning package's row answers for it. What is lost is narrower, and the diagnostic says so precisely: the declaring package's authored
label/description/scopeare not applied, andsys_capability.package_idattributes the capability to the other package, so the declaring package has no provenance claim over it. A message borrowing the sibling's wording would send the author hunting a broken grant that is not broken; a pin asserts that it does not (expect(d.message).not.toContain('none of its object, field, tab')).⛔ What is axis-independent is reused, never re-derived — that is the drift this card exists to avoid:
permissionSetNameIsForeign— THE owner-comparison predicate. The seeder's branch now routes through it instead of its ownexisting.package_id === packageId, so a nullish owner reads FOREIGN on both axes by construction (ADR-0086 D3), and a pin exercises exactly that row shape.CollisionReportSink— the minimal sink type, imported from the sibling and re-exported rather than structurally re-declared.reportPermissionSetNameCollisionsis deliberately not reused: its message states a permission-set consequence. Its sink discipline is restated, because that discipline is the contract this card buys rather than a derivation that can drift — and both axes pin it independently, including the(logger?.warn ?? console.warn)(…)receiver-detach trap (a class-based host sink that reaches forthisis injected and must not throw).reportPermissionSetNameCollisions's judgement call, which the dispatch left to this lane: it does not generalise. It takesreadonly PermissionSetNameCollisionDiagnostic[]and words a permission-set sentence, and an axis parameter threaded through it would produce one function that words two different consequences — the shape that makes both wrong.Reverse verification
Fix committed first (
aafc3ce26), then the branch mutated back to the oldlogger?.warn?.form and the pass-end report deleted, on disk, from the committed state:The direction is turns red, and the four that redden are exactly the four that assert output. The fifth — the no-collision silence control — stays green under the ablation, which is correct and is the point: a mute seeder also prints nothing on a non-colliding pass, so that case is discriminating in the other direction only.
Clause-② re-derivation —
yes, with a discriminating controlRe-derived from the delivered diff, not carried from the claim. The new symbols are re-exported from
src/index.ts, andpackage.jsonpublishesexports["."] → ./dist/index.{js,mjs,d.ts}withfiles: ["dist", …], so the test is reachability from the built entry:Both controls behave, so the probe discriminates. New exported symbols on a published entry ⇒
Clause-②: yes, and the changeset is gradedminoraccordingly (apatchwithyesis refused, #16361).needs:contract-reviewis carried on this PR and on the card.CapabilitySeedOutcome.collisionsis the other limb the claim predicted; it is not what decidesyeshere, becausebootstrapDeclaredCapabilitiesis not itself re-exported fromindex.ts— measured, not assumed. Theindex.tsre-exports are what carry it.Verification
pnpm --filter @objectstack/plugin-security test— 113 files / 2181 tests, all passing.pnpm --filter @objectstack/plugin-security typecheck— clean (tsc --noEmit, scripts project, andcheck:test-typecheck: 0 files / 0 errors).pnpm --filter '@objectstack/plugin-security...' build— closure green; the dist probe above is taken from that build.Two existing
bootstrap-seed-round-tripscases pinned this refusal's loudness by matching the prose"owned by another package". They now match the stablecapability_name_collisiongrep token and additionally assert the record on the pass outcome — a strictly stronger observable, and one that does not re-red on every rewording. ⛔ No test was skipped, quarantined or weakened.Acceptance notes
permissionSetNameIsForeigncarries the axis it was first written for in its name, and the capability seeder now imports it. Renaming it to something axis-neutral is a breaking change to a published export and buys only readability — a naming nit, excluded from the file-a-card classes. The import site documents why one name for one derivation beats two.scope. Measured: the only non-test readers ofsys_capabilityrows in this tree are the two seeders, the object definition,security-plugin.tsandpackages/lint, and none was found reading a row'sscopecolumn for an authorization decision (capabilityscopeis read off the spec-levelPLATFORM_CAPABILITIESdeclaration instead). That is an observation about today's readers, ⛔ not a proof of absence, which is exactly why the record's severity note routes a future over-grant path to its own card rather than pre-judging it here.cleanupPackagePermissionscoverssys_permission_setand does not touchsys_capability, so uninstalling the owning package does not currently remove the row. The wording avoids claiming a disappearing capability for exactly this reason.Generated by Claude Code