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/.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/aggregator/src/routes/xrpc/label-enforcement.ts b/apps/aggregator/src/routes/xrpc/label-enforcement.ts index f3384440b2..be1b870dc1 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,41 @@ 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), +); +/** 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; bindings: unknown[]; @@ -88,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 ( @@ -111,12 +148,33 @@ 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 * 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[], @@ -126,7 +184,11 @@ 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')`; const sql = ` AND NOT EXISTS ( SELECT 1 FROM label_state ls @@ -135,9 +197,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 +238,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/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/label-enforcement.test.ts b/apps/aggregator/test/label-enforcement.test.ts new file mode 100644 index 0000000000..1d92a748f4 --- /dev/null +++ b/apps/aggregator/test/label-enforcement.test.ts @@ -0,0 +1,43 @@ +/** + * 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 { + buildPackageEnforcementSql, + 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"); + }); +}); + +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"); + }); +}); 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", () => { 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); + }); +}); 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-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": { diff --git a/packages/registry-moderation/src/index.ts b/packages/registry-moderation/src/index.ts index 69b74fd827..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" @@ -164,7 +168,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 +181,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", ]);