Skip to content

feat(registry): enforce moderation labels in clients#1972

Merged
ascorbic merged 4 commits into
feat/plugin-registry-labelling-servicefrom
feat/labeler-client-enforcement
Jul 12, 2026
Merged

feat(registry): enforce moderation labels in clients#1972
ascorbic merged 4 commits into
feat/plugin-registry-labelling-servicefrom
feat/labeler-client-enforcement

Conversation

@ascorbic

@ascorbic ascorbic commented Jul 12, 2026

Copy link
Copy Markdown
Collaborator

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:

  • Hydrated moderation evaluation (@emdash-cms/registry-moderation): evaluateHydratedReleaseModeration evaluates aggregator-hydrated labels that carry no signature, sharing one evaluation body with the branded verifyLabel path 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).
  • Registry-client adapter (@emdash-cms/registry-client): evaluateReleaseViews (subject context from validated views, tolerant of structurally invalid aggregator labels), resolveAcceptedPolicy (response atproto-content-labelers header > configured acceptLabelers > empty; response-header parse failure warns and falls through, configured parse failure is a loud error), and onResponseMeta on DiscoveryClient for header access. Docs cut over to the canonical vocabulary.
  • Core + CLI enforcement: install and update gate on the shared evaluation — package and publisher cascades, CID-bound labels, negation, expiry — immediately before artifact download (fetch-tripwire tested on both paths), replacing the three raw security:yanked string 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-plugin moderation state with no extra network calls. The CLI renders evaluated eligibility instead of raw label dumps. Error codes unified: RELEASE_YANKED when security-yanked is among the blocking labels, else RELEASE_BLOCKED; malformed acceptLabelers config is a loud REGISTRY_POLICY_INVALID.

Adversarial review (opus) finding fixed pre-merge: the gate originally required eligibility === "blocked", but the evaluator ranks pending/error above automated blocks — so malware plus a co-present assessment-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 on blockingLabels/redacted, with a regression test for the co-present case.

Decisions needing maintainer ratification (deviations from the plan, reasoned in-thread):

  1. Hydrated-trust evaluation instead of per-request signature verification. A compromised aggregator attacks by omitting labels, which verifying the labels it chose to show cannot detect; the integrity roots (signed release record, artifact checksum) are already verified in core. Signature verification is reserved for the future positive-assessment gate, where fabricating a pass is the actual threat — the branded path exists and is required for it.
  2. No enforcement flag. The plan's "disabled production flag" applies to the positive-assessment gate (not shipped here). Blocking-label enforcement is behavior-preserving-or-stronger versus the string checks it replaces, and missing-assessment-pass explicitly does not block — zero production change on merge (verified: a clean release with an accepted policy installs; no labels flow in production yet regardless).
  3. Gate-0 corpus amendment: the ratified fixture's oldPackageCid was 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-service per the umbrella PR. Related to #1909 and RFC #694.

Type of change

  • Bug fix
  • Feature (requires maintainer-approved Discussion)
  • Refactor (no behavior change)
  • Translation
  • Documentation
  • Performance improvement
  • Tests
  • Chore (dependencies, CI, tooling)

Checklist

  • I have read CONTRIBUTING.md
  • pnpm typecheck passes
  • pnpm lint passes
  • pnpm test passes (or targeted tests for my change)
  • pnpm format has been run
  • I have added/updated tests for my changes (if applicable)
  • User-visible strings in the admin UI are wrapped for translation (if applicable). Do not include messages.po changes except in translation PRs — a workflow extracts catalogs on merge to main.
  • I have added a changeset (if this PR changes a published package)
  • New features link to an approved Discussion: RFC RFC: Decentralized Plugin Registry #694

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

  • This PR includes AI-generated code — model/tool: Claude Code with Claude Fable 5 (design, review, fixes) and Sonnet/Opus subagents (implementation, adversarial review)

Screenshots / test output

  • Core: full unit suite 4618 passed / 3 skipped, including 16 new moderation-gate tests (install/update/update-check, cascades, CID-stale, warning/pending/error non-blocking, the co-present fail-open regression, gate-before-fetch tripwires) and 4 new artifact-proxy tests
  • registry-moderation: 150 pass (ratified corpus now runs through BOTH the branded and hydrated entry points); registry-client: 85 pass; plugin-cli: 399 pass (6 new display tests)
  • Typecheck clean everywhere; pnpm lint:quick 0 diagnostics; type-aware lint:json 0 findings repo-wide
  • CLI rendering (verbatim): Moderation: blocked (malware) by did:plc:labeler-a on info; [blocked] marker + blocked: !takedown line on search; clean packages render identically to before

Try 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.

ascorbic added 3 commits July 12, 2026 08:46
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.
@github-actions github-actions Bot added the review/needs-review No maintainer or bot review yet label Jul 12, 2026
@changeset-bot

changeset-bot Bot commented Jul 12, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 103173a

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 20 packages
Name Type
@emdash-cms/registry-client Patch
@emdash-cms/registry-moderation Patch
emdash Patch
@emdash-cms/plugin-cli Patch
@emdash-cms/admin Patch
@emdash-cms/labeler Patch
@emdash-cms/cloudflare Patch
@emdash-cms/sandbox-workerd Patch
@emdash-cms/fixture-perf-site Patch
@emdash-cms/perf-demo-site Patch
@emdash-cms/cache-demo-site Patch
@emdash-cms/do-demo-site Patch
@emdash-cms/do-solo-demo-site Patch
@emdash-cms/auth Patch
@emdash-cms/blocks Patch
@emdash-cms/gutenberg-to-portable-text Patch
@emdash-cms/x402 Patch
create-emdash Patch
@emdash-cms/auth-atproto Patch
@emdash-cms/plugin-embeds Patch

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

@github-actions

Copy link
Copy Markdown
Contributor

Scope check

This 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.

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 12, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
emdash-playground 103173a Jul 12 2026, 09:57 AM

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 12, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
emdash-demo-do 103173a Jul 12 2026, 09:57 AM

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 12, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
emdash-demo-cache 103173a Jul 12 2026, 09:57 AM

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-722

    When a block label and its negation share the same maximum cts in the same (src, uri, val) stream, reduceLabels detects a collision and places the stream in collisions instead of active. evaluateReleaseModerationCore then sets eligibility = "error" with reason "label-state-collision" but leaves blockingLabels empty. All three enforcement consumers (install/update in packages/core/src/api/handlers/registry.ts and the artifact proxy in packages/core/src/astro/routes/api/admin/plugins/registry/artifact.ts) gate only on blockingLabels.length > 0 || redacted, so an accepted labeler can co-post malware and a same-cts neg: true label to bypass the block. This is the same fail-open class the PR fixed for the assessment-pending/assessment-error re-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:96

    evaluateReleaseViews already structurally validates each raw label with parseModerationLabel and drops invalid ones. It then passes the resulting ModerationLabel[] to evaluateHydratedReleaseModeration, which immediately re-parses every label. The second parse is redundant work and slightly doubles validation cost per install/update call. Consider making evaluateReleaseViews call the shared evaluation core directly after it has parsed and filtered the raw labels, or have evaluateHydratedReleaseModeration accept pre-parsed labels.

  • [suggestion] packages/core/src/api/handlers/registry.ts:1803-1809

    The comment says this stub makes "the package/publisher cascade ... inert here by construction", but the returned object uses the real publisherDid. appliesToContext still matches publisher-scope labels (publisher-compromised, !takedown with a DID subject) from releaseView.labels. That is actually safer, but the comment is misleading — update-check can block on publisher-scope labels without a getPackage round-trip if the aggregator attaches them to the release response. Consider updating the docstring to say only package-scope labels are excluded by the empty uri/cid, not the publisher-scope cascade.

@github-actions github-actions Bot added review/awaiting-author Reviewed; waiting on the author to respond and removed review/needs-review No maintainer or bot review yet labels Jul 12, 2026
…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.
@ascorbic

Copy link
Copy Markdown
Collaborator Author

Review findings addressed in 103173a:

Collision bypass (needs fixing) — taken, with a different mechanism than the suggestion. Surfacing colliding values into blockingLabels would break that field's ratified definition ("only active, applicable" labels — gate-0 result-fields contract). Instead the shared package now exports isModerationBlocking — the single canonical blocking predicate (applicable blocking label, redact-flagged takedown, or label-state-collision; never keyed on eligibility) — and all three consumers delegate to it. That also fixes the root cause of this whole bug class: the predicate was restated in three places, and every restatement was a fresh chance to get it wrong. Regression tests at both levels (predicate unit tests incl. the same-cts collision, and an end-to-end install test proving RELEASE_BLOCKED before any artifact fetch).

Double parse (suggestion) — declined. evaluateReleaseViews needs per-label tolerance (skip + warn) while the primitive stays strict; the re-parse preserves that split at a cost of microseconds on an operation that performs network fetches and archive extraction. Strictness-in-the-primitive is the deliberate design.

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.

@github-actions github-actions Bot added review/needs-rereview Author pushed changes since the last review and removed review/awaiting-author Reviewed; waiting on the author to respond labels Jul 12, 2026
@ascorbic ascorbic added the bot:review Trigger an emdashbot code review on this PR label Jul 12, 2026
@ascorbic ascorbic mentioned this pull request Jul 12, 2026
81 tasks
@ascorbic ascorbic merged commit c1c83c4 into feat/plugin-registry-labelling-service Jul 12, 2026
8 checks passed
@ascorbic ascorbic deleted the feat/labeler-client-enforcement branch July 12, 2026 10:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/core bot:review Trigger an emdashbot code review on this PR cla: signed review/needs-rereview Author pushed changes since the last review size/XL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant