From 4f71c7ac66dba636f6c187f52a9152afc7893bb8 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Sat, 18 Jul 2026 12:10:49 +0100 Subject: [PATCH 1/7] fix(registry-moderation): enforce image-content automated-block labels hateful-imagery, explicit-imagery, and graphic-violence are declared as automated-block labels in the labeler policy fixture but were absent from AUTOMATED_BLOCKS, so the evaluator and RELEASE_BLOCK_VALUES ignored them -- a policy-blocked release stayed installable. Add the three, plus content-warning to WARNINGS (the same un-synced image-label batch), export both sets, and add a labeler-side parity test that fails whenever the fixture and package classification drift. --- .changeset/enforce-image-moderation-blocks.md | 5 ++ .../test/moderation-policy-parity.test.ts | 78 +++++++++++++++++++ packages/registry-moderation/src/index.ts | 15 +++- 3 files changed, 96 insertions(+), 2 deletions(-) create mode 100644 .changeset/enforce-image-moderation-blocks.md create mode 100644 apps/labeler/test/moderation-policy-parity.test.ts diff --git a/.changeset/enforce-image-moderation-blocks.md b/.changeset/enforce-image-moderation-blocks.md new file mode 100644 index 0000000000..8e0d0d8eba --- /dev/null +++ b/.changeset/enforce-image-moderation-blocks.md @@ -0,0 +1,5 @@ +--- +"@emdash-cms/registry-moderation": minor +--- + +Blocks releases labeled `hateful-imagery`, `explicit-imagery`, or `graphic-violence` -- these automated-block labels were issuable by the labeler but silently ignored by release evaluation and enforcement, leaving a policy-blocked release installable. Also recognizes the `content-warning` label as a non-blocking warning, and exposes the `AUTOMATED_BLOCKS` and `WARNINGS` label-value sets for consumers that classify labels directly. diff --git a/apps/labeler/test/moderation-policy-parity.test.ts b/apps/labeler/test/moderation-policy-parity.test.ts new file mode 100644 index 0000000000..c6486b9c9b --- /dev/null +++ b/apps/labeler/test/moderation-policy-parity.test.ts @@ -0,0 +1,78 @@ +import { + AUTOMATED_BLOCKS, + PACKAGE_SCOPE_BLOCK_VALUES, + RELEASE_BLOCK_VALUES, + WARNINGS, +} from "@emdash-cms/registry-moderation"; +import { describe, expect, it } from "vitest"; + +import { MODERATION_POLICY } from "../src/policy.js"; + +/** + * Dual-source parity: label classification lives on BOTH sides of the + * labeler/registry boundary -- the labeler issues labels per this policy + * fixture's `category`/`officialEffect`, and `@emdash-cms/registry-moderation` + * enforces them via its own hardcoded sets. A value the labeler can issue as a + * block but the package omits from `AUTOMATED_BLOCKS` is issued yet never + * enforced -- a policy-blocked release stays installable (the miss that let + * hateful-imagery/explicit-imagery/graphic-violence through, after the earlier + * block/warn reclassification miss). These assertions fail the moment the two + * sources drift so a third miss can't merge. + */ +describe("moderation policy <-> registry-moderation classification parity", () => { + function sorted(values: Iterable): string[] { + return [...values].toSorted(); + } + + const fixtureAutomatedBlocks = sorted( + MODERATION_POLICY.labels + .filter((label) => label.category === "automated-block") + .map((label) => label.value), + ); + const fixtureWarnEffect = sorted( + MODERATION_POLICY.labels + .filter((label) => label.officialEffect === "warn") + .map((label) => label.value), + ); + + it("enforces exactly the fixture's automated-block category as AUTOMATED_BLOCKS", () => { + expect(sorted(AUTOMATED_BLOCKS)).toEqual(fixtureAutomatedBlocks); + }); + + it("routes every automated-block value into the release hard-block set", () => { + for (const value of fixtureAutomatedBlocks) { + expect(RELEASE_BLOCK_VALUES).toContain(value); + } + }); + + // Automated blocks are not the only enforced blocks: the manual-system + // labels `security-yanked` and `publisher-compromised` also carry + // `officialEffect: "block"` but are enforced only via the hardcoded + // RELEASE_BLOCK_VALUES / PACKAGE_SCOPE_BLOCK_VALUES, which the assertions + // above never touch. Guard the whole block vocabulary so a future + // `officialEffect: "block"` fixture label can't be issued yet silently + // left unenforced. + it("enforces every fixture block-effect label at release or package/publisher scope", () => { + const enforcedBlocks = new Set([...RELEASE_BLOCK_VALUES, ...PACKAGE_SCOPE_BLOCK_VALUES]); + const fixtureBlockEffect = MODERATION_POLICY.labels + .filter((label) => label.officialEffect === "block") + .map((label) => label.value); + for (const value of fixtureBlockEffect) { + expect(enforcedBlocks.has(value)).toBe(true); + } + }); + + // The fixture's warn-effect set, not its `warning` *category*: the package's + // WARNINGS deliberately includes `package-disputed` (a `manual-system` label + // whose officialEffect is `warn`), so category-equality would never hold -- + // the officialEffect correspondence is the invariant. + it("classifies exactly the fixture's warn-effect labels as WARNINGS", () => { + expect(sorted(WARNINGS)).toEqual(fixtureWarnEffect); + }); + + it("never classifies a value as both a block and a warning", () => { + for (const value of AUTOMATED_BLOCKS) { + expect(WARNINGS.has(value)).toBe(false); + } + }); +}); diff --git a/packages/registry-moderation/src/index.ts b/packages/registry-moderation/src/index.ts index 69b74fd827..0caa5eaccd 100644 --- a/packages/registry-moderation/src/index.ts +++ b/packages/registry-moderation/src/index.ts @@ -164,7 +164,11 @@ export interface ReleaseModeration { redacted: boolean; } -const AUTOMATED_BLOCKS = new Set([ +/** Label values the labeler policy classifies as `automated-block`; each + * hard-blocks a release. MUST stay in lock-step with the labeler policy + * fixture's `automated-block` category (`apps/labeler/fixtures/moderation-policy.json`) -- + * a value the labeler can issue but this set omits is not enforced. */ +export const AUTOMATED_BLOCKS: ReadonlySet = new Set([ "malware", "data-exfiltration", "credential-harvesting", @@ -173,15 +177,22 @@ const AUTOMATED_BLOCKS = new Set([ "artifact-integrity-failure", "invalid-bundle", "impersonation", + "hateful-imagery", + "explicit-imagery", + "graphic-violence", ]); -const WARNINGS = new Set([ +/** Label values the labeler policy classifies with `officialEffect: "warn"`; + * non-blocking. MUST stay in lock-step with the labeler policy fixture's + * warn-effect labels. */ +export const WARNINGS: ReadonlySet = new Set([ "suspicious-code", "obfuscated-code", "privacy-risk", "misleading-metadata", "low-quality", "broken-release", + "content-warning", "package-disputed", "undeclared-access", ]); From ae232f3d5b8e8975cb135c1e9cd061111d7c81f3 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Sat, 18 Jul 2026 12:10:58 +0100 Subject: [PATCH 2/7] fix(registry-client): send an explicitly empty accept-labelers header DiscoveryClient treated acceptLabelers: "" as absent (`|| onResponseMeta` and `if (acceptLabelers)`), so a config meaning "accept no labelers" was dropped and the aggregator applied its trusted defaults. Switch to `!== undefined` checks so an empty string still goes on the wire while an omitted option still sends no header. --- .changeset/discovery-empty-accept-labelers.md | 5 +++++ packages/registry-client/src/discovery/index.ts | 8 ++++++-- packages/registry-client/tests/discovery.test.ts | 16 ++++++++++++++++ 3 files changed, 27 insertions(+), 2 deletions(-) create mode 100644 .changeset/discovery-empty-accept-labelers.md diff --git a/.changeset/discovery-empty-accept-labelers.md b/.changeset/discovery-empty-accept-labelers.md new file mode 100644 index 0000000000..ce9918563f --- /dev/null +++ b/.changeset/discovery-empty-accept-labelers.md @@ -0,0 +1,5 @@ +--- +"@emdash-cms/registry-client": patch +--- + +Sends an explicitly empty `atproto-accept-labelers` header when `DiscoveryClient` is configured with `acceptLabelers: ""`, so "accept no labelers" is honored instead of being dropped and letting the aggregator apply its trusted defaults. Omitting the option entirely still sends no header. diff --git a/packages/registry-client/src/discovery/index.ts b/packages/registry-client/src/discovery/index.ts index 9b7fecf37e..2b49d63f6d 100644 --- a/packages/registry-client/src/discovery/index.ts +++ b/packages/registry-client/src/discovery/index.ts @@ -188,10 +188,14 @@ export class DiscoveryClient { const acceptLabelers = this.acceptLabelers; const onResponseMeta = options.onResponseMeta; const handler: typeof baseHandler = - acceptLabelers || onResponseMeta + acceptLabelers !== undefined || onResponseMeta !== undefined ? async (pathname, init) => { const headers = new Headers(init.headers); - if (acceptLabelers) headers.set("atproto-accept-labelers", acceptLabelers); + // An explicit empty string means "accept no labelers" and must + // still go on the wire; only an omitted option (`undefined`) + // leaves the header off so the aggregator applies its defaults. + if (acceptLabelers !== undefined) + headers.set("atproto-accept-labelers", acceptLabelers); const response = await baseHandler(pathname, { ...init, headers }); onResponseMeta?.({ contentLabelers: response.headers.get("atproto-content-labelers") ?? undefined, diff --git a/packages/registry-client/tests/discovery.test.ts b/packages/registry-client/tests/discovery.test.ts index 0b9e8dbf3c..95097ef04a 100644 --- a/packages/registry-client/tests/discovery.test.ts +++ b/packages/registry-client/tests/discovery.test.ts @@ -92,6 +92,22 @@ describe("DiscoveryClient", () => { expect(headers.get("atproto-accept-labelers")).toBeNull(); }); + it("sends an explicitly empty atproto-accept-labelers header (accept no labelers)", async () => { + const { fetch, calls } = buildFetchStub({ + "/xrpc/com.emdashcms.experimental.aggregator.searchPackages": { + status: 200, + body: { packages: [] }, + }, + }); + + const client = new DiscoveryClient({ aggregatorUrl: aggregator, acceptLabelers: "", fetch }); + await client.searchPackages({ q: "x" }); + + const headers = new Headers(calls[0]!.init?.headers); + expect(headers.has("atproto-accept-labelers")).toBe(true); + expect(headers.get("atproto-accept-labelers")).toBe(""); + }); + it("reports the atproto-content-labelers response header via onResponseMeta", async () => { const { fetch } = buildFetchStub({ "/xrpc/com.emdashcms.experimental.aggregator.searchPackages": { From 6d4f7cfcce56e430f429db9c45c5d32926d40d04 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Sat, 18 Jul 2026 12:11:26 +0100 Subject: [PATCH 3/7] fix(aggregator): honor reviewer override pair in release enforcement SQL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buildReleaseEnforcementSql excluded a release whenever any live automated block row existed, but the hydrated evaluator suppresses a source's automated blocks when that same source has an exact-CID assessment-passed + assessment-overridden pair (spec §10). A post-override re-assessment that re-issues a live automated block was therefore dropped from latest-selection even though the evaluator treats it as eligible. Split the release-URI block branch so automated blocks carry the same-source exact-CID override exception while the manual security-yanked / !takedown blocks and the package/publisher cascade keep firing unconditionally. --- .../src/routes/xrpc/label-enforcement.ts | 65 ++++++++++- apps/aggregator/test/read-api.test.ts | 109 ++++++++++++++++++ 2 files changed, 170 insertions(+), 4 deletions(-) diff --git a/apps/aggregator/src/routes/xrpc/label-enforcement.ts b/apps/aggregator/src/routes/xrpc/label-enforcement.ts index f3384440b2..cd786314bf 100644 --- a/apps/aggregator/src/routes/xrpc/label-enforcement.ts +++ b/apps/aggregator/src/routes/xrpc/label-enforcement.ts @@ -19,6 +19,7 @@ import { NSID } from "@emdash-cms/registry-lexicons"; import { + AUTOMATED_BLOCKS, PACKAGE_SCOPE_BLOCK_VALUES, RELEASE_BLOCK_VALUES, type AcceptedLabelerPolicy, @@ -32,6 +33,15 @@ const HYDRATION_CHUNK_SIZE = 50; * hydration results — redaction decisions must see the full label set. */ export const LABELS_MAX_LENGTH = 64; +/** Release-scope automated-block values (`RELEASE_BLOCK_VALUES` minus the + * manual `security-yanked` / `!takedown`). The override exception below + * applies only to these — the manual values are never suppressed. */ +const AUTOMATED_BLOCK_VALUES: readonly string[] = [...AUTOMATED_BLOCKS]; +/** The manual release blocks the reviewer override pair never suppresses. */ +const MANUAL_RELEASE_BLOCK_VALUES: readonly string[] = RELEASE_BLOCK_VALUES.filter( + (value) => !AUTOMATED_BLOCKS.has(value), +); + export interface EnforcementSql { sql: string; bindings: unknown[]; @@ -117,6 +127,17 @@ export interface ReleaseEnforcementAliases { * carries a `PACKAGE_SCOPE_BLOCK_VALUES` label (cascade), from an accepted * source. Requires the `packages` row joined under `aliases.package` — the * package-scope branch's CID comparison reads its `signature_metadata`. + * + * Mirrors the hydrated evaluator's §10 override rule (see + * `evaluateReleaseModerationCore` in `@emdash-cms/registry-moderation`): a + * source's exact-CID `assessment-passed` + `assessment-overridden` pair + * suppresses that same source's automated blocks for the release. Without + * this, a post-override re-assessment that re-issues a live automated block + * would keep the release out of latest-selection even though the evaluator + * treats it as eligible. The exception is scoped exactly as the evaluator + * scopes it: automated blocks on the release URI only — the manual + * `security-yanked` / `!takedown` release blocks and the package/publisher + * cascade are never suppressed by the pair. */ export function buildReleaseEnforcementSql( accepted: AcceptedLabelerPolicy[], @@ -127,6 +148,8 @@ export function buildReleaseEnforcementSql( const releaseAlias = aliases.release ?? "r."; const packageAlias = aliases.package ?? "p."; const srcs = accepted.map((policy) => policy.did); + const releaseUriExpr = `'at://' || ${releaseAlias}did || '/${NSID.packageRelease}/' || ${releaseAlias}rkey`; + const releaseCidExpr = `json_extract(${releaseAlias}signature_metadata, '$.cid')`; const sql = ` AND NOT EXISTS ( SELECT 1 FROM label_state ls @@ -135,9 +158,35 @@ export function buildReleaseEnforcementSql( AND (ls.exp_epoch_ms IS NULL OR ls.exp_epoch_ms > ?) AND ( ( - ls.uri = 'at://' || ${releaseAlias}did || '/${NSID.packageRelease}/' || ${releaseAlias}rkey - AND ls.val IN (${inClause(RELEASE_BLOCK_VALUES)}) - AND (ls.cid IS NULL OR ls.cid = json_extract(${releaseAlias}signature_metadata, '$.cid')) + ls.uri = ${releaseUriExpr} + AND ls.val IN (${inClause(AUTOMATED_BLOCK_VALUES)}) + AND (ls.cid IS NULL OR ls.cid = ${releaseCidExpr}) + AND NOT ( + EXISTS ( + SELECT 1 FROM label_state pass + WHERE pass.src = ls.src + AND pass.uri = ${releaseUriExpr} + AND pass.val = 'assessment-passed' + AND pass.cid = ${releaseCidExpr} + AND pass.neg = 0 + AND (pass.exp_epoch_ms IS NULL OR pass.exp_epoch_ms > ?) + ) + AND EXISTS ( + SELECT 1 FROM label_state ovr + WHERE ovr.src = ls.src + AND ovr.uri = ${releaseUriExpr} + AND ovr.val = 'assessment-overridden' + AND ovr.cid = ${releaseCidExpr} + AND ovr.neg = 0 + AND (ovr.exp_epoch_ms IS NULL OR ovr.exp_epoch_ms > ?) + ) + ) + ) + OR + ( + ls.uri = ${releaseUriExpr} + AND ls.val IN (${inClause(MANUAL_RELEASE_BLOCK_VALUES)}) + AND (ls.cid IS NULL OR ls.cid = ${releaseCidExpr}) ) OR ( @@ -150,7 +199,15 @@ export function buildReleaseEnforcementSql( `; return { sql, - bindings: [...srcs, nowMs, ...RELEASE_BLOCK_VALUES, ...PACKAGE_SCOPE_BLOCK_VALUES], + bindings: [ + ...srcs, + nowMs, + ...AUTOMATED_BLOCK_VALUES, + nowMs, + nowMs, + ...MANUAL_RELEASE_BLOCK_VALUES, + ...PACKAGE_SCOPE_BLOCK_VALUES, + ], }; } diff --git a/apps/aggregator/test/read-api.test.ts b/apps/aggregator/test/read-api.test.ts index 22f8dccc6c..3d5fd93987 100644 --- a/apps/aggregator/test/read-api.test.ts +++ b/apps/aggregator/test/read-api.test.ts @@ -850,6 +850,115 @@ describe("getLatestRelease", () => { const body = (await res.json()) as Record; expect(body["version"]).toBe("1.0.0"); }); + + // §10 override rule: a source's exact-CID assessment-passed + + // assessment-overridden pair suppresses that source's automated blocks for + // the release, mirroring `evaluateReleaseModerationCore`. The enforcement + // SQL must not exclude a release whose only live block is so suppressed. + const OVERRIDE_CID = "bafoverridecid"; + + it("includes a release whose re-issued automated block is suppressed by a same-source exact-CID override pair", async () => { + await seedLabeler(LABELER_DID, true); + await seedPackage({ slug: "demo", latestVersion: "1.0.0" }); + await seedRelease({ version: "1.0.0", cid: OVERRIDE_CID }); + const uri = releaseUri("demo", "1.0.0"); + await seedLabelState({ uri, val: "assessment-passed", cid: OVERRIDE_CID }); + await seedLabelState({ uri, val: "assessment-overridden", cid: OVERRIDE_CID }); + await seedLabelState({ uri, val: "malware", cid: OVERRIDE_CID }); + + const res = await SELF.fetch( + `https://test/xrpc/${NSID.aggregatorGetLatestRelease}?did=${DID_A}&package=demo`, + ); + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body["version"]).toBe("1.0.0"); + }); + + it("includes a release carrying an override pair and no block", async () => { + await seedLabeler(LABELER_DID, true); + await seedPackage({ slug: "demo", latestVersion: "1.0.0" }); + await seedRelease({ version: "1.0.0", cid: OVERRIDE_CID }); + const uri = releaseUri("demo", "1.0.0"); + await seedLabelState({ uri, val: "assessment-passed", cid: OVERRIDE_CID }); + await seedLabelState({ uri, val: "assessment-overridden", cid: OVERRIDE_CID }); + + const res = await SELF.fetch( + `https://test/xrpc/${NSID.aggregatorGetLatestRelease}?did=${DID_A}&package=demo`, + ); + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body["version"]).toBe("1.0.0"); + }); + + it("excludes a release with an automated block and no override pair", async () => { + await seedLabeler(LABELER_DID, true); + await seedPackage({ slug: "demo", latestVersion: "1.0.0" }); + await seedRelease({ version: "1.0.0", cid: OVERRIDE_CID }); + await seedLabelState({ uri: releaseUri("demo", "1.0.0"), val: "malware", cid: OVERRIDE_CID }); + + const res = await SELF.fetch( + `https://test/xrpc/${NSID.aggregatorGetLatestRelease}?did=${DID_A}&package=demo`, + ); + expect(res.status).toBe(404); + }); + + it("still excludes when the override pair is from a different source than the block", async () => { + const OTHER_LABELER_DID = "did:web:other-labels.example"; + await seedLabeler(LABELER_DID, true); + await seedLabeler(OTHER_LABELER_DID, true); + await seedPackage({ slug: "demo", latestVersion: "1.0.0" }); + await seedRelease({ version: "1.0.0", cid: OVERRIDE_CID }); + const uri = releaseUri("demo", "1.0.0"); + await seedLabelState({ uri, val: "malware", cid: OVERRIDE_CID }); + await seedLabelState({ + uri, + val: "assessment-passed", + cid: OVERRIDE_CID, + src: OTHER_LABELER_DID, + }); + await seedLabelState({ + uri, + val: "assessment-overridden", + cid: OVERRIDE_CID, + src: OTHER_LABELER_DID, + }); + + const res = await SELF.fetch( + `https://test/xrpc/${NSID.aggregatorGetLatestRelease}?did=${DID_A}&package=demo`, + ); + expect(res.status).toBe(404); + }); + + it("still excludes when the override pair CID does not match the release", async () => { + await seedLabeler(LABELER_DID, true); + await seedPackage({ slug: "demo", latestVersion: "1.0.0" }); + await seedRelease({ version: "1.0.0", cid: OVERRIDE_CID }); + const uri = releaseUri("demo", "1.0.0"); + await seedLabelState({ uri, val: "malware", cid: OVERRIDE_CID }); + await seedLabelState({ uri, val: "assessment-passed", cid: "bafstalecid" }); + await seedLabelState({ uri, val: "assessment-overridden", cid: "bafstalecid" }); + + const res = await SELF.fetch( + `https://test/xrpc/${NSID.aggregatorGetLatestRelease}?did=${DID_A}&package=demo`, + ); + expect(res.status).toBe(404); + }); + + it("never suppresses a security-yanked release block, even with an override pair", async () => { + await seedLabeler(LABELER_DID, true); + await seedPackage({ slug: "demo", latestVersion: "1.0.0" }); + await seedRelease({ version: "1.0.0", cid: OVERRIDE_CID }); + const uri = releaseUri("demo", "1.0.0"); + await seedLabelState({ uri, val: "assessment-passed", cid: OVERRIDE_CID }); + await seedLabelState({ uri, val: "assessment-overridden", cid: OVERRIDE_CID }); + // `security-yanked` is issued CID-less (policy cidRule: forbidden). + await seedLabelState({ uri, val: "security-yanked" }); + + const res = await SELF.fetch( + `https://test/xrpc/${NSID.aggregatorGetLatestRelease}?did=${DID_A}&package=demo`, + ); + expect(res.status).toBe(404); + }); }); describe("searchPackages", () => { From d029e8a81c047cd043b5d757cbfbc0cf8802778c Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Sat, 18 Jul 2026 12:11:47 +0100 Subject: [PATCH 4/7] fix(aggregator): keep enforcement labels when capping view label arrays capLabels sliced a view's labels at LABELS_MAX_LENGTH in hydration order, so a subject carrying more than 64 labels could lose its only hard block; a client evaluating the truncated view would then treat the release as installable. Order labels by enforcement priority (hard blocks, then assessment states, then informational) before slicing so blocks survive, and log an error in the pathological case where blocks alone exceed the cap (the kept slice is still all blocks, so the release stays blocked). --- .../src/routes/xrpc/label-enforcement.ts | 26 ++++++++ apps/aggregator/src/routes/xrpc/views.ts | 43 +++++++++--- apps/aggregator/test/views.test.ts | 66 +++++++++++++++++++ 3 files changed, 127 insertions(+), 8 deletions(-) create mode 100644 apps/aggregator/test/views.test.ts diff --git a/apps/aggregator/src/routes/xrpc/label-enforcement.ts b/apps/aggregator/src/routes/xrpc/label-enforcement.ts index cd786314bf..a7b56c4c26 100644 --- a/apps/aggregator/src/routes/xrpc/label-enforcement.ts +++ b/apps/aggregator/src/routes/xrpc/label-enforcement.ts @@ -41,6 +41,32 @@ const AUTOMATED_BLOCK_VALUES: readonly string[] = [...AUTOMATED_BLOCKS]; const MANUAL_RELEASE_BLOCK_VALUES: readonly string[] = RELEASE_BLOCK_VALUES.filter( (value) => !AUTOMATED_BLOCKS.has(value), ); +/** Assessment-state label values: `assessment-pending` / `assessment-error` + * steer eligibility, and `assessment-passed` / `assessment-overridden` form + * the reviewer override pair. Enforcement-relevant for view truncation. */ +const ASSESSMENT_STATE_VALUES: readonly string[] = [ + "assessment-pending", + "assessment-passed", + "assessment-overridden", + "assessment-error", +]; + +const ENFORCEMENT_HARD_BLOCK_VALUES: ReadonlySet = new Set([ + ...RELEASE_BLOCK_VALUES, + ...PACKAGE_SCOPE_BLOCK_VALUES, +]); +const ENFORCEMENT_STATE_VALUES: ReadonlySet = new Set(ASSESSMENT_STATE_VALUES); + +/** Truncation priority for a view's `labels`: hard blocks (0) rank ahead of + * assessment states (1) ahead of informational labels (2). Blocks decide a + * client's install/serve refusal, so the lexicon `maxLength` cap must never + * drop one in favour of a display-only label; states change eligibility and + * complete the override pair. Used by `capLabels`. */ +export function labelTruncationPriority(val: string): 0 | 1 | 2 { + if (ENFORCEMENT_HARD_BLOCK_VALUES.has(val)) return 0; + if (ENFORCEMENT_STATE_VALUES.has(val)) return 1; + return 2; +} export interface EnforcementSql { sql: string; diff --git a/apps/aggregator/src/routes/xrpc/views.ts b/apps/aggregator/src/routes/xrpc/views.ts index 5704a1fa1e..87be393bad 100644 --- a/apps/aggregator/src/routes/xrpc/views.ts +++ b/apps/aggregator/src/routes/xrpc/views.ts @@ -21,7 +21,7 @@ import { type AggregatorDefs, NSID } from "@emdash-cms/registry-lexicons"; import { isPlainObject, parseSignatureMetadataCid } from "../../utils.js"; -import { LABELS_MAX_LENGTH, type LabelView } from "./label-enforcement.js"; +import { LABELS_MAX_LENGTH, labelTruncationPriority, type LabelView } from "./label-enforcement.js"; /** Subset of columns from `packages` we read for `packageView`. Selecting * exactly these columns keeps the SQL query auditable and cheap. */ @@ -166,15 +166,42 @@ export function publisherUri(row: Pick) { * labels are the union of multiple hydrated subjects (e.g. a release's own * URI plus its parent package and publisher DID). Hydration returns * untruncated per-subject sets so redaction decisions see every label; - * this boundary cap trims only the final view array. */ + * this boundary cap trims only the final view array. + * + * The trim is enforcement-preserving: a plain slice can drop the only hard + * block when a subject carries more than `LABELS_MAX_LENGTH` labels, and a + * client evaluating the truncated view would then treat the release as + * installable. Labels are ordered by `labelTruncationPriority` (blocks, then + * assessment states, then informational) before slicing, so blocks survive. + * Hydrated labels never carry negations (`hydrateLabels` returns only + * `neg = 0` rows), so ordering can't split a label from its negation. In the + * pathological case of more than `LABELS_MAX_LENGTH` hard blocks the kept + * slice is still all blocks — the release stays blocked, only the exact set + * is lossy — and we log an error. */ function capLabels(labels: LabelView[], uri: string): LabelView[] { if (labels.length <= LABELS_MAX_LENGTH) return labels; - console.warn("[aggregator] view labels truncated to maxLength", { - uri, - count: labels.length, - cap: LABELS_MAX_LENGTH, - }); - return labels.slice(0, LABELS_MAX_LENGTH); + const ordered = labels.toSorted( + (left, right) => labelTruncationPriority(left.val) - labelTruncationPriority(right.val), + ); + const kept = ordered.slice(0, LABELS_MAX_LENGTH); + const droppedBlocks = ordered + .slice(LABELS_MAX_LENGTH) + .filter((label) => labelTruncationPriority(label.val) === 0).length; + if (droppedBlocks > 0) { + console.error("[aggregator] view label cap dropped hard-block labels", { + uri, + count: labels.length, + cap: LABELS_MAX_LENGTH, + droppedBlocks, + }); + } else { + console.warn("[aggregator] view labels truncated to maxLength", { + uri, + count: labels.length, + cap: LABELS_MAX_LENGTH, + }); + } + return kept; } /** `LabelView.src` is `string`; the lexicon's `Label.src` is a branded DID diff --git a/apps/aggregator/test/views.test.ts b/apps/aggregator/test/views.test.ts new file mode 100644 index 0000000000..dcebe992bd --- /dev/null +++ b/apps/aggregator/test/views.test.ts @@ -0,0 +1,66 @@ +/** + * View mapper unit tests. + * + * `capLabels` is exercised through `releaseView` (its only caller shape): + * the lexicon caps a view's `labels` at `LABELS_MAX_LENGTH`, and the trim + * must be enforcement-preserving — a plain slice in hydration order can drop + * the only hard block, and a client evaluating the truncated view would then + * treat the release as installable. + */ + +import { describe, expect, it } from "vitest"; + +import { LABELS_MAX_LENGTH, type LabelView } from "../src/routes/xrpc/label-enforcement.js"; +import { type ReleaseRow, releaseView } from "../src/routes/xrpc/views.js"; + +const NOW = "2026-05-10T12:00:00.000Z"; + +function makeReleaseRow(): ReleaseRow { + return { + did: "did:plc:abc", + package: "demo", + version: "1.0.0", + rkey: "demo:1.0.0", + artifacts: JSON.stringify({ package: { url: "https://x.test/d.tgz", checksum: "bsha256" } }), + requires: null, + suggests: null, + emdash_extension: JSON.stringify({ declaredAccess: {} }), + repo_url: null, + signature_metadata: JSON.stringify({ cid: "bafrel" }), + verified_at: NOW, + indexed_at: NOW, + }; +} + +function label(val: string, index: number): LabelView { + return { src: "did:web:labeler.example", uri: `at://did:plc:abc/x/${index}`, val, cts: NOW }; +} + +describe("releaseView label cap (enforcement-preserving)", () => { + it("returns labels unchanged when at or under the cap", () => { + const labels = [label("low-quality", 0), label("malware", 1), label("suspicious-code", 2)]; + const view = releaseView(makeReleaseRow(), labels); + expect(view.labels).toEqual(labels); + }); + + it("keeps the sole hard block even when it sorts last in hydration order", () => { + const labels: LabelView[] = []; + for (let i = 0; i < LABELS_MAX_LENGTH; i++) labels.push(label("low-quality", i)); + labels.push(label("malware", LABELS_MAX_LENGTH)); + expect(labels).toHaveLength(LABELS_MAX_LENGTH + 1); + + const view = releaseView(makeReleaseRow(), labels); + expect(view.labels).toHaveLength(LABELS_MAX_LENGTH); + expect(view.labels?.some((l) => l.val === "malware")).toBe(true); + }); + + it("keeps an assessment-state label above informational labels when over the cap", () => { + const labels: LabelView[] = []; + for (let i = 0; i < LABELS_MAX_LENGTH; i++) labels.push(label("low-quality", i)); + labels.push(label("assessment-pending", LABELS_MAX_LENGTH)); + + const view = releaseView(makeReleaseRow(), labels); + expect(view.labels).toHaveLength(LABELS_MAX_LENGTH); + expect(view.labels?.some((l) => l.val === "assessment-pending")).toBe(true); + }); +}); From f6018c71c2d6da0893f8f6b1dcad297219e4c4f7 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Sat, 18 Jul 2026 14:18:28 +0100 Subject: [PATCH 5/7] fix(registry-moderation): add image labels to ModerationLabelValue union The exported nominal union drifted from the runtime sets when the image-content labels were added: hateful-imagery, explicit-imagery, graphic-violence, and content-warning are classified at runtime but were missing from ModerationLabelValue, so the type contract understated the values the labeler can issue. Add all four. --- packages/registry-moderation/src/index.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/registry-moderation/src/index.ts b/packages/registry-moderation/src/index.ts index 0caa5eaccd..75448e0522 100644 --- a/packages/registry-moderation/src/index.ts +++ b/packages/registry-moderation/src/index.ts @@ -18,9 +18,13 @@ export type ModerationLabelValue = | "assessment-pending" | "artifact-integrity-failure" | "broken-release" + | "content-warning" | "credential-harvesting" | "critical-vulnerability" | "data-exfiltration" + | "explicit-imagery" + | "graphic-violence" + | "hateful-imagery" | "impersonation" | "invalid-bundle" | "low-quality" From 766a8f316c1e2b3ce15837019dedfcaf10813523 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Sat, 18 Jul 2026 14:18:39 +0100 Subject: [PATCH 6/7] fix(aggregator): validate release/package SQL aliases buildReleaseEnforcementSql interpolates the caller-supplied aliases.release / aliases.package directly into raw SQL. Callers pass compile-time constants today, but the builder is exported, so validate both aliases against an identifier-plus-trailing-dot shape and throw a TypeError on mismatch before they reach the query text. --- .../src/routes/xrpc/label-enforcement.ts | 12 +++++++++ .../aggregator/test/label-enforcement.test.ts | 26 +++++++++++++++++++ 2 files changed, 38 insertions(+) create mode 100644 apps/aggregator/test/label-enforcement.test.ts diff --git a/apps/aggregator/src/routes/xrpc/label-enforcement.ts b/apps/aggregator/src/routes/xrpc/label-enforcement.ts index a7b56c4c26..457ce7503c 100644 --- a/apps/aggregator/src/routes/xrpc/label-enforcement.ts +++ b/apps/aggregator/src/routes/xrpc/label-enforcement.ts @@ -147,6 +147,16 @@ export interface ReleaseEnforcementAliases { package?: string; } +/** A SQL row alias with its trailing dot (e.g. `"r."`). */ +const ALIAS_PATTERN = /^[a-zA-Z][a-zA-Z0-9_]*\.$/; + +/** Guards a caller-supplied alias before it is interpolated into raw SQL. + * Callers pass compile-time constants today, but the builders are exported. */ +function assertAlias(alias: string): void { + if (!ALIAS_PATTERN.test(alias)) + throw new TypeError("SQL alias must be an identifier followed by '.'"); +} + /** * `NOT EXISTS` clause excluding a release whose own URI carries a * `RELEASE_BLOCK_VALUES` label, or whose parent package URI / publisher DID @@ -173,6 +183,8 @@ export function buildReleaseEnforcementSql( if (accepted.length === 0) return { sql: "", bindings: [] }; const releaseAlias = aliases.release ?? "r."; const packageAlias = aliases.package ?? "p."; + assertAlias(releaseAlias); + assertAlias(packageAlias); const srcs = accepted.map((policy) => policy.did); const releaseUriExpr = `'at://' || ${releaseAlias}did || '/${NSID.packageRelease}/' || ${releaseAlias}rkey`; const releaseCidExpr = `json_extract(${releaseAlias}signature_metadata, '$.cid')`; diff --git a/apps/aggregator/test/label-enforcement.test.ts b/apps/aggregator/test/label-enforcement.test.ts new file mode 100644 index 0000000000..9e97f4f6e9 --- /dev/null +++ b/apps/aggregator/test/label-enforcement.test.ts @@ -0,0 +1,26 @@ +/** + * Unit tests for the enforcement SQL builders' input guards. The builders are + * exported and interpolate the caller-supplied row alias into raw SQL, so a + * malformed alias must be rejected rather than reach the query text. + */ + +import { type AcceptedLabelerPolicy } from "@emdash-cms/registry-moderation"; +import { describe, expect, it } from "vitest"; + +import { buildReleaseEnforcementSql } from "../src/routes/xrpc/label-enforcement.js"; + +const accepted: AcceptedLabelerPolicy[] = [{ did: "did:web:labels.example", redact: false }]; + +describe("buildReleaseEnforcementSql alias validation", () => { + it("rejects an alias that is not an identifier followed by a dot", () => { + expect(() => + buildReleaseEnforcementSql(accepted, 0, { release: "r; DROP TABLE releases;--" }), + ).toThrow(TypeError); + expect(() => buildReleaseEnforcementSql(accepted, 0, { package: "p" })).toThrow(TypeError); + }); + + it("accepts the default aliases", () => { + const { sql } = buildReleaseEnforcementSql(accepted, 0); + expect(sql).toContain("NOT EXISTS"); + }); +}); From fb3ff1a2feb8cf921ace37c1eb9a735ff8088ac9 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Sat, 18 Jul 2026 16:03:00 +0100 Subject: [PATCH 7/7] fix(aggregator): validate the package enforcement builder's SQL alias buildPackageEnforcementSql interpolates its caller-supplied alias into raw SQL the same way the release builder does, but the release-builder guard added earlier did not cover it. Apply the same assertAlias check right after the alias default so the exported package primitive rejects a malformed alias too. --- .../src/routes/xrpc/label-enforcement.ts | 1 + .../aggregator/test/label-enforcement.test.ts | 19 ++++++++++++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/apps/aggregator/src/routes/xrpc/label-enforcement.ts b/apps/aggregator/src/routes/xrpc/label-enforcement.ts index 457ce7503c..be1b870dc1 100644 --- a/apps/aggregator/src/routes/xrpc/label-enforcement.ts +++ b/apps/aggregator/src/routes/xrpc/label-enforcement.ts @@ -124,6 +124,7 @@ export function buildPackageEnforcementSql( alias = "p.", ): EnforcementSql { if (accepted.length === 0) return { sql: "", bindings: [] }; + assertAlias(alias); const srcs = accepted.map((policy) => policy.did); const sql = ` AND NOT EXISTS ( diff --git a/apps/aggregator/test/label-enforcement.test.ts b/apps/aggregator/test/label-enforcement.test.ts index 9e97f4f6e9..1d92a748f4 100644 --- a/apps/aggregator/test/label-enforcement.test.ts +++ b/apps/aggregator/test/label-enforcement.test.ts @@ -7,7 +7,10 @@ import { type AcceptedLabelerPolicy } from "@emdash-cms/registry-moderation"; import { describe, expect, it } from "vitest"; -import { buildReleaseEnforcementSql } from "../src/routes/xrpc/label-enforcement.js"; +import { + buildPackageEnforcementSql, + buildReleaseEnforcementSql, +} from "../src/routes/xrpc/label-enforcement.js"; const accepted: AcceptedLabelerPolicy[] = [{ did: "did:web:labels.example", redact: false }]; @@ -24,3 +27,17 @@ describe("buildReleaseEnforcementSql alias validation", () => { expect(sql).toContain("NOT EXISTS"); }); }); + +describe("buildPackageEnforcementSql alias validation", () => { + it("rejects an alias that is not an identifier followed by a dot", () => { + expect(() => buildPackageEnforcementSql(accepted, 0, "p; DROP TABLE packages;--")).toThrow( + TypeError, + ); + expect(() => buildPackageEnforcementSql(accepted, 0, "p")).toThrow(TypeError); + }); + + it("accepts the default alias", () => { + const { sql } = buildPackageEnforcementSql(accepted, 0); + expect(sql).toContain("NOT EXISTS"); + }); +});