feat(registry): enforce moderation labels in clients#1972
Conversation
evaluateHydratedReleaseModeration evaluates aggregator-hydrated labels that carry no signature, sharing one evaluation body with the branded verified path so the two cannot drift; the branded entrance keeps its runtime check and remains required for any positive-assessment gate. The registry client gains a moderation adapter that assembles subject context from validated views, an accepted-policy resolver preferring the response's atproto-content-labelers header over configured values, and response-header exposure on DiscoveryClient. Corrects a latent invalid CID in the ratified moderation corpus (recorded as a gate-0 amendment) that structural validation surfaced.
Install and update gate on the shared moderation evaluation — package and publisher cascades, CID-bound labels, negation and expiry — immediately before artifact download, replacing the raw security:yanked string comparisons. Only actual blocking labels or a redact-flagged takedown block; the missing-assessment-pass state does not, since the positive- assessment gate is a later, flag-controlled phase. Update now fetches the package view (it previously checked release labels only, missing every cascade) and cross-checks aggregator identity like install. The artifact proxy refuses media for blocked releases, update-check reports per-plugin moderation state, and the CLI renders eligibility instead of raw labels.
The evaluator ranks pending/error above automated blocks, so a malware label with a co-present assessment-pending label yields eligibility 'pending' while blockingLabels still carries the block — and the gate, keyed on eligibility, failed open. All three consumers now key solely on blockingLabels/redacted, which still admits missing-assessment-pass and pure pending/error states.
🦋 Changeset detectedLatest commit: 103173a The changes in this PR will be included in the next version bump. This PR includes changesets to release 20 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
Scope checkThis PR changes 1,908 lines across 26 files. Large PRs are harder to review and more likely to be closed without review. If this scope is intentional, no action needed. A maintainer will review it. If not, please consider splitting this into smaller PRs. See CONTRIBUTING.md for contribution guidelines. |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
emdash-playground | 103173a | Jul 12 2026, 09:57 AM |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
emdash-demo-do | 103173a | Jul 12 2026, 09:57 AM |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
emdash-demo-cache | 103173a | Jul 12 2026, 09:57 AM |
There was a problem hiding this comment.
This PR implements W5 client moderation enforcement across registry install/update/update-check, the artifact proxy, and the plugin CLI. The approach is sound and additive: it switches gating from raw security:yanked string checks and from the top-line eligibility value to the shared evaluateReleaseViews evaluator keyed on blockingLabels/redacted. That matches the gate-0 design, includes the requested changesets and tests, and correctly closes the documented fail-open where a co-present assessment-pending/assessment-error label re-ranks eligibility while the block is still present.
I checked the full diff and the relevant source files, traced the new evaluator/refactored core, the DiscoveryClient header plumbing, the three enforcement consumers (core install/update/proxy), and the CLI rendering. Type/exports and dependency wiring look consistent.
One adversarial edge remains. The same reduceLabels collision path that existed before this PR now becomes a bypass in the new enforcement gate: when a block label and its negation share the maximum timestamp in the same (src, uri, val) stream, the evaluator marks the stream as a collision, excludes the block from active, sets eligibility = "error", and leaves blockingLabels empty. Because install/update/proxy all key solely on blockingLabels.length > 0 || redacted, a malicious accepted labeler can co-post malware (or security:yanked/!takedown) and a same-cts negation to fail the gate open. This is the same class of fail-open the PR fixed for the pending/error re-ranking case, so I think it needs to be addressed before the gate is considered robust.
I also noted two smaller things: evaluateReleaseViews parses labels before handing them to evaluateHydratedReleaseModeration, which immediately re-parses them, and the comment on releaseOnlyPackageViewStub is slightly misleading about publisher-scope labels. Neither is blocking.
Verdict: comment — the main change is good, but the collision bypass should be fixed or explicitly accepted.
Findings
-
[needs fixing]
packages/registry-moderation/src/index.ts:721-722When a block label and its negation share the same maximum
ctsin the same(src, uri, val)stream,reduceLabelsdetects a collision and places the stream incollisionsinstead ofactive.evaluateReleaseModerationCorethen setseligibility = "error"with reason"label-state-collision"but leavesblockingLabelsempty. All three enforcement consumers (install/update inpackages/core/src/api/handlers/registry.tsand the artifact proxy inpackages/core/src/astro/routes/api/admin/plugins/registry/artifact.ts) gate only onblockingLabels.length > 0 || redacted, so an accepted labeler can co-postmalwareand a same-ctsneg: truelabel to bypass the block. This is the same fail-open class the PR fixed for theassessment-pending/assessment-errorre-ranking case — the gate should fail closed on colliding block labels.} else if (collisions.length > 0) { eligibility = "error"; reasonCodes.push("label-state-collision"); // Also surface colliding block labels so enforcement consumers fail closed // on ambiguous-but-dangerous state, not open. } -
[suggestion]
packages/registry-client/src/moderation.ts:96evaluateReleaseViewsalready structurally validates each raw label withparseModerationLabeland drops invalid ones. It then passes the resultingModerationLabel[]toevaluateHydratedReleaseModeration, which immediately re-parses every label. The second parse is redundant work and slightly doubles validation cost per install/update call. Consider makingevaluateReleaseViewscall the shared evaluation core directly after it has parsed and filtered the raw labels, or haveevaluateHydratedReleaseModerationaccept pre-parsed labels. -
[suggestion]
packages/core/src/api/handlers/registry.ts:1803-1809The comment says this stub makes "the package/publisher cascade ... inert here by construction", but the returned object uses the real
publisherDid.appliesToContextstill matches publisher-scope labels (publisher-compromised,!takedownwith a DID subject) fromreleaseView.labels. That is actually safer, but the comment is misleading — update-check can block on publisher-scope labels without agetPackageround-trip if the aggregator attaches them to the release response. Consider updating the docstring to say only package-scope labels are excluded by the emptyuri/cid, not the publisher-scope cascade.
…redicate A block label colliding with a same-cts negation puts the stream in the ratified fail-closed error state with empty blockingLabels, which the gate — restated separately in three consumers — read as not blocked. The shared moderation package now exports isModerationBlocking as the single blocking predicate (applicable blocking label, redact-flagged takedown, or label-state collision; never keyed on eligibility), and core install/ update, the artifact proxy, and the CLI all delegate to it.
|
Review findings addressed in 103173a: Collision bypass (needs fixing) — taken, with a different mechanism than the suggestion. Surfacing colliding values into Double parse (suggestion) — declined. Stub docstring (suggestion) — taken: it now says only the package-scope cascade is excluded by the empty uri/cid; publisher-scope labels riding on the release response still evaluate through the real publisherDid. |
c1c83c4
into
feat/plugin-registry-labelling-service
What does this PR do?
Implements W5 client eligibility enforcement (minus the admin UI, which is W5.5's own PR) — the layer that decides whether an EmDash site installs a plugin. Three commits:
@emdash-cms/registry-moderation):evaluateHydratedReleaseModerationevaluates aggregator-hydrated labels that carry no signature, sharing one evaluation body with the brandedverifyLabelpath so the two cannot drift. The branded entrance and its runtime check are untouched; nothing can mint the verified brand without cryptographic verification. Also corrects a latent invalid CID in the ratified gate-0 moderation corpus (present since ratification, inert until this path added structural CID validation — recorded in the gate-0 amendments log in this diff).@emdash-cms/registry-client):evaluateReleaseViews(subject context from validated views, tolerant of structurally invalid aggregator labels),resolveAcceptedPolicy(responseatproto-content-labelersheader > configuredacceptLabelers> empty; response-header parse failure warns and falls through, configured parse failure is a loud error), andonResponseMetaonDiscoveryClientfor header access. Docs cut over to the canonical vocabulary.security:yankedstring comparisons (zero occurrences remain in core, registry-client, or plugin-cli). Update now fetches the package view (it previously saw release labels only, missing every cascade) with the same aggregator-identity cross-check as install. The artifact proxy refuses media for blocked releases. Update-check reports additive per-pluginmoderationstate with no extra network calls. The CLI renders evaluated eligibility instead of raw label dumps. Error codes unified:RELEASE_YANKEDwhensecurity-yankedis among the blocking labels, elseRELEASE_BLOCKED; malformedacceptLabelersconfig is a loudREGISTRY_POLICY_INVALID.Adversarial review (opus) finding fixed pre-merge: the gate originally required
eligibility === "blocked", but the evaluator ranks pending/error above automated blocks — somalwareplus a co-presentassessment-pending(a realistic state once a newer assessment run opens against a flagged release) re-ranked eligibility and failed the gate open. All three consumers now key solely onblockingLabels/redacted, with a regression test for the co-present case.Decisions needing maintainer ratification (deviations from the plan, reasoned in-thread):
missing-assessment-passexplicitly does not block — zero production change on merge (verified: a clean release with an accepted policy installs; no labels flow in production yet regardless).oldPackageCidwas not a decodable CID (hard parse failure, traced to the original ratification commit); corrected to a verified round-tripping CID with no change to any case's expected outcome.Implementation PRs target
feat/plugin-registry-labelling-serviceper the umbrella PR. Related to #1909 and RFC #694.Type of change
Checklist
pnpm typecheckpassespnpm lintpassespnpm testpasses (or targeted tests for my change)pnpm formathas been runmessages.pochanges except in translation PRs — a workflow extracts catalogs on merge tomain.Admin i18n is n/a — no admin changes in this PR (W5.5 handles the UI). Changesets included for all four published packages that changed (
@emdash-cms/registry-moderation,@emdash-cms/registry-client,emdash,@emdash-cms/plugin-cli).AI-generated code disclosure
Screenshots / test output
pnpm lint:quick0 diagnostics; type-awarelint:json0 findings repo-wideModeration: blocked (malware) by did:plc:labeler-aoninfo;[blocked]marker +blocked: !takedownline onsearch; clean packages render identically to beforeTry this PR
Open a fresh playground →
A full working EmDash site, deployed from this branch. Each visit gets its own session-scoped sandbox: no login needed and no shared state. Try the admin, edit content, hit the public site.
Tracks
feat/labeler-client-enforcement. Updated automatically when the playground redeploys.