diff --git a/.loopover.yml.example b/.loopover.yml.example index 3eaeb0f79a..b464d809c6 100644 --- a/.loopover.yml.example +++ b/.loopover.yml.example @@ -1328,3 +1328,12 @@ settings: # # uses, so an http:// or localhost/private-range endpoint is dropped with a warning. # collectorUrl: https://collector.example.org/v1/federated # collectorMode: both # push | pull | both. Default: both. +# # WHICH PEERS YOU TRUST (#6480). A pulled bundle is only ever folded into your calibration if its signature +# # verifies against one of these keys -- trust is explicit and operator-configured, exactly like +# # MCP_READ_REPO_ALLOWLIST: there is no auto-discovery, no PKI, and no default peer. Unset or empty means you +# # trust no peer, so EVERY inbound bundle is rejected; `enabled: true` on its own never starts importing. +# # Each entry is a peer's 64-char hex verification key, which that operator gives you out of band. This is +# # shared verification material, not a password: treat it the way you treat the rest of this file. +# # To stop trusting a peer, remove its key -- that is the whole revocation story, by design. +# peerKeys: +# - 0000000000000000000000000000000000000000000000000000000000000000 diff --git a/config/examples/loopover.full.yml b/config/examples/loopover.full.yml index 5dafe22c71..cca82a9275 100644 --- a/config/examples/loopover.full.yml +++ b/config/examples/loopover.full.yml @@ -1342,3 +1342,12 @@ settings: # # uses, so an http:// or localhost/private-range endpoint is dropped with a warning. # collectorUrl: https://collector.example.org/v1/federated # collectorMode: both # push | pull | both. Default: both. +# # WHICH PEERS YOU TRUST (#6480). A pulled bundle is only ever folded into your calibration if its signature +# # verifies against one of these keys -- trust is explicit and operator-configured, exactly like +# # MCP_READ_REPO_ALLOWLIST: there is no auto-discovery, no PKI, and no default peer. Unset or empty means you +# # trust no peer, so EVERY inbound bundle is rejected; `enabled: true` on its own never starts importing. +# # Each entry is a peer's 64-char hex verification key, which that operator gives you out of band. This is +# # shared verification material, not a password: treat it the way you treat the rest of this file. +# # To stop trusting a peer, remove its key -- that is the whole revocation story, by design. +# peerKeys: +# - 0000000000000000000000000000000000000000000000000000000000000000 diff --git a/packages/loopover-engine/src/focus-manifest.ts b/packages/loopover-engine/src/focus-manifest.ts index a8eee08d8f..34d48e1908 100644 --- a/packages/loopover-engine/src/focus-manifest.ts +++ b/packages/loopover-engine/src/focus-manifest.ts @@ -476,6 +476,10 @@ export type FocusManifestPrReconciliationConfig = { export const FEDERATED_COLLECTOR_MODES = ["push", "pull", "both"] as const; export type FederatedCollectorMode = (typeof FEDERATED_COLLECTOR_MODES)[number]; +/** A peer verification key: 64 hex chars — the exact shape generateAnonSecret produces and signFederatedBundle + * consumes as its HMAC key, so an operator can only allowlist something that could actually verify a bundle. */ +const FEDERATED_PEER_KEY = /^[0-9a-f]{64}$/; + export type FocusManifestFederatedIntelligenceConfig = { present: boolean; enabled: boolean; @@ -490,6 +494,14 @@ export type FocusManifestFederatedIntelligenceConfig = { collectorUrl: string | null; /** Which directions the client may use against `collectorUrl`. Null ⇒ `both`. */ collectorMode: FederatedCollectorMode | null; + /** + * The operator's explicit allowlist of peer verification keys (#6477's key-trust decision, consumed by + * #6480's import path). A pulled bundle is only ever considered if its signature verifies against one of + * these keys — trust is operator-configured, exactly like `MCP_READ_REPO_ALLOWLIST`, never auto-discovered + * and never a PKI. Empty ⇒ no peer is trusted ⇒ every inbound bundle is rejected (fail closed), which is + * also the default, so an operator who opts into the export alone never silently starts importing. + */ + peerKeys: string[]; }; /** @@ -1257,6 +1269,7 @@ const EMPTY_FEDERATED_INTELLIGENCE_CONFIG: FocusManifestFederatedIntelligenceCon enabled: false, collectorUrl: null, collectorMode: null, + peerKeys: [], }; const EMPTY_MANIFEST: FocusManifest = { @@ -2194,7 +2207,30 @@ function parseFederatedIntelligenceConfig(value: JsonValue | undefined, warnings const enabled = normalizeOptionalBoolean(record.enabled, "federatedIntelligence.enabled", warnings) ?? false; const collectorUrl = parseFederatedCollectorUrl(record.collectorUrl, warnings); const collectorMode = normalizeOptionalEnum(record.collectorMode, "federatedIntelligence.collectorMode", FEDERATED_COLLECTOR_MODES, warnings); - return { present: true, enabled, collectorUrl, collectorMode }; + const peerKeys = parseFederatedPeerKeys(record.peerKeys, warnings); + return { present: true, enabled, collectorUrl, collectorMode, peerKeys }; +} + +/** Parse `federatedIntelligence.peerKeys` (#6480) — the operator's explicit peer-trust allowlist from #6477's + * design. Each entry must be a 64-char hex key, the shape signFederatedBundle's HMAC key already has; a + * malformed entry is dropped with a warning rather than throwing, matching every sibling list field. Dropping + * rather than failing closed on the whole list is deliberate and safe in this direction: a dropped key can only + * ever REMOVE a peer's bundles from consideration, never admit an untrusted one. */ +function parseFederatedPeerKeys(value: JsonValue | undefined, warnings: string[]): string[] { + const raw = normalizeStringList(value, "federatedIntelligence.peerKeys", warnings); + const keys: string[] = []; + const seen = new Set(); + for (const entry of raw) { + const key = entry.toLowerCase(); + if (!FEDERATED_PEER_KEY.test(key)) { + warnings.push(`Manifest "federatedIntelligence.peerKeys" entry is not a 64-character hex key; ignoring it.`); + continue; + } + if (seen.has(key)) continue; // first occurrence wins, like normalizeAutoCloseExemptLogins + seen.add(key); + keys.push(key); + } + return keys; } /** Parse `federatedIntelligence.collectorUrl` (#6479) — validated at CONFIG-READ time against the same @@ -2216,7 +2252,12 @@ function parseFederatedCollectorUrl(value: JsonValue | undefined, warnings: stri * configured. */ export function federatedIntelligenceConfigToJson(config: FocusManifestFederatedIntelligenceConfig): JsonValue { if (!config.present) return null; - return { enabled: config.enabled, collectorUrl: config.collectorUrl, collectorMode: config.collectorMode }; + return { + enabled: config.enabled, + collectorUrl: config.collectorUrl, + collectorMode: config.collectorMode, + peerKeys: [...config.peerKeys], + }; } function normalizeOptionalEnum(value: JsonValue | undefined, field: string, allowed: readonly T[], warnings: string[]): T | null { diff --git a/src/orb/federated-import.ts b/src/orb/federated-import.ts new file mode 100644 index 0000000000..228f76d4e9 --- /dev/null +++ b/src/orb/federated-import.ts @@ -0,0 +1,196 @@ +// LoopOver federated fleet intelligence (#1970) — OPT-IN, peer bundle IMPORT + trust-gating (#6480). +// +// This is the RECEIVING side: it decides whether a bundle pulled by the transport client +// (src/orb/federated-collector.ts, #6479) may be folded into local calibration or the peer-median benchmark +// (#6481) at all. The export side is #6478 (src/orb/federated-bundle.ts). +// +// The trust model is #6477's DESIGN DECISION, implemented here exactly as specified and deliberately NOT +// redesigned. Its two poisoning-resistance layers, and where each one actually lives: +// 1. ALLOWLIST — only a peer whose verification key the operator explicitly added to +// `federatedIntelligence.peerKeys` is ever considered. That is enforced HERE, and it is why a Sybil +// attack is self-limiting by construction: forging peers requires the RECEIVING operator to have added +// the attacker's keys themselves. Mirrors MCP_READ_REPO_ALLOWLIST's posture: explicit operator config, +// fail closed when unset, never auto-discovery and never a PKI. +// 2. MEDIAN, NOT MEAN — a bounded number of outliers cannot drag a median arbitrarily, unlike a mean. That +// layer needs no code here: the fleet aggregation this feeds already medians (src/orb/analytics.ts:92), +// so it holds by construction. Re-implementing it in this module would fork the definition #6481's +// comparison depends on. +// +// #6477 explicitly rejected building a reputation/decay/scoring system for trust, so there is deliberately no +// per-peer score, no anomaly heuristic, and no retroactive poisoned-bundle detection here: an operator who +// discovers a bad peer removes its key from the allowlist. Adding any of those would be inventing a mechanism +// that design pass considered and turned down. +import { canonicalizeFederatedBundleBody, FEDERATED_BUNDLE_SCHEMA_VERSION, type FederatedSignalBundle, type FederatedSignalBundleBody } from "./federated-bundle"; +import { timingSafeEqualHex } from "../utils/crypto"; +import type { FocusManifest } from "../signals/focus-manifest"; +import { createHmac } from "node:crypto"; + +/** Why a bundle was not folded in. Every rejection carries one of these, so a rejection is always traceable to + * a specific rule rather than vanishing silently (#6480 requires rejections be operator-visible). */ +export type FederatedRejectionReason = + /** The operator never opted in — nothing inbound is processed at all. */ + | "not_opted_in" + /** `peerKeys` is empty: the operator trusts no peer yet, so nothing can verify. Fail closed. */ + | "no_trusted_peers" + /** Not a bundle shape this build understands — never guessed at, per FEDERATED_BUNDLE_SCHEMA_VERSION. */ + | "unsupported_schema_version" + /** Structurally malformed: a field the signature covers is missing or the wrong type. */ + | "malformed" + /** No allowlisted key reproduces the signature: either an untrusted peer or a tampered body. These are + * deliberately ONE reason — with a detached HMAC the receiver cannot distinguish them, and pretending + * otherwise would report a distinction this scheme cannot actually make. */ + | "untrusted_or_tampered"; + +/** One rejected bundle, reduced to what an operator can act on without leaking bundle contents. */ +export interface FederatedRejection { + /** The claimed instance handle, or null when the bundle was too malformed to read one. Opaque, not identity. */ + instanceId: string | null; + reason: FederatedRejectionReason; +} + +export interface FederatedImportResult { + /** Bundles that passed every gate and may be folded into calibration / the peer median. */ + accepted: FederatedSignalBundle[]; + /** Every bundle that did not, with the rule that stopped it. */ + rejected: FederatedRejection[]; +} + +/** Sink for rejection visibility. Defaults to console.warn so a rejection is never silently dropped even when + * a caller passes no logger — #6480 forbids a silent drop as explicitly as it forbids silent acceptance. */ +export type FederatedImportLogger = (rejection: FederatedRejection) => void; + +type ManifestSlice = Pick; + +/** Is peer IMPORT armed? Opt-in (`enabled`) is necessary but NOT sufficient: an operator who turned on the + * export and configured no peer keys imports nothing, because trust is explicit and there is no default peer. + * Kept separate from isFederatedIntelligenceEnabled (the export's gate) precisely so enabling the export can + * never, by itself, start admitting inbound data. */ +export function isFederatedImportEnabled(manifest: ManifestSlice | null | undefined): boolean { + const config = manifest?.federatedIntelligence; + return config?.enabled === true && config.peerKeys.length > 0; +} + +/** Does `bundle` carry every signature-covered field, with the right type? Guards the canonicalization below: + * an absent field would otherwise serialize as `undefined` and silently change the signed bytes. */ +function isBundleBodyShaped(bundle: FederatedSignalBundle): boolean { + const numeric = (value: unknown): boolean => typeof value === "number" && Number.isFinite(value); + const nullableNumeric = (value: unknown): boolean => value === null || numeric(value); + return ( + typeof bundle.instanceId === "string" && + typeof bundle.generatedAt === "string" && + typeof bundle.signature === "string" && + numeric(bundle.windowDays) && + numeric(bundle.decided) && + numeric(bundle.reversalRate) && + numeric(bundle.slopRate) && + numeric(bundle.copycatRate) && + nullableNumeric(bundle.mergePrecision) && + nullableNumeric(bundle.closePrecision) && + nullableNumeric(bundle.fpRate) && + nullableNumeric(bundle.fnRate) && + nullableNumeric(bundle.cycleP50Ms) && + nullableNumeric(bundle.cycleP95Ms) + ); +} + +/** Strip the detached signature back off, so the body is canonicalized over exactly the fields the sender + * signed. Rebuilt field-by-field rather than by deleting `signature` from a copy: the canonical form is a + * fixed key list, so an extra property a peer appended can never reach the signed bytes. */ +function toBody(bundle: FederatedSignalBundle): FederatedSignalBundleBody { + return { + schemaVersion: bundle.schemaVersion, + instanceId: bundle.instanceId, + generatedAt: bundle.generatedAt, + windowDays: bundle.windowDays, + decided: bundle.decided, + mergePrecision: bundle.mergePrecision, + closePrecision: bundle.closePrecision, + fpRate: bundle.fpRate, + fnRate: bundle.fnRate, + reversalRate: bundle.reversalRate, + cycleP50Ms: bundle.cycleP50Ms, + cycleP95Ms: bundle.cycleP95Ms, + slopRate: bundle.slopRate, + copycatRate: bundle.copycatRate, + }; +} + +/** + * Does `bundle`'s signature verify against ANY key the operator allowlisted? + * + * Every candidate key is tried because the HMAC is detached and carries no key hint — the bundle says which + * INSTANCE it claims to be from, but `instanceId` is unauthenticated until a key verifies, so selecting a key + * by it would trust the attacker-controlled field to pick its own verifier. + * + * The comparison is timing-safe (timingSafeEqualHex), and the loop deliberately does NOT early-exit on a match: + * it verifies against all keys and ORs the results, so total work does not depend on WHICH key matched. + */ +export function verifyFederatedBundle(bundle: FederatedSignalBundle, peerKeys: readonly string[]): boolean { + const canonical = canonicalizeFederatedBundleBody(toBody(bundle)); + let verified = false; + for (const key of peerKeys) { + const expected = createHmac("sha256", key).update(canonical).digest("hex"); + if (timingSafeEqualHex(bundle.signature, expected)) verified = true; + } + return verified; +} + +/** Apply every gate to a single bundle. Returns null when it may be folded in, or the reason it may not. */ +function rejectionFor(bundle: FederatedSignalBundle, peerKeys: readonly string[]): FederatedRejectionReason | null { + if (bundle?.schemaVersion !== FEDERATED_BUNDLE_SCHEMA_VERSION) return "unsupported_schema_version"; + if (!isBundleBodyShaped(bundle)) return "malformed"; + if (!verifyFederatedBundle(bundle, peerKeys)) return "untrusted_or_tampered"; + return null; +} + +/** + * Trust-gate a batch of pulled peer bundles, returning only those an operator's own config says to trust. + * + * FAIL-SAFE: this is a pure function the gate never consults — it reads no DB, makes no network call, and + * returns a value rather than mutating anything, so neither a rejected nor a malformed bundle can reach this + * instance's own review/merge behavior. That is the structural version of #6480's fail-safe requirement: there + * is no path from here to a gate decision, rather than a guard that could be forgotten. + */ +export function importPeerBundles( + manifest: ManifestSlice | null | undefined, + bundles: readonly FederatedSignalBundle[], + opts: { log?: FederatedImportLogger } = {}, +): FederatedImportResult { + const log = opts.log ?? defaultRejectionLogger; + const reject = (instanceId: string | null, reason: FederatedRejectionReason): FederatedRejection => { + const rejection: FederatedRejection = { instanceId, reason }; + log(rejection); + return rejection; + }; + + const config = manifest?.federatedIntelligence; + // Opted out and no-trusted-peers are reported per bundle rather than once: an operator watching the log for + // "why did nothing import?" needs the answer attached to the bundles that were actually dropped. + if (config?.enabled !== true) { + return { accepted: [], rejected: bundles.map((bundle) => reject(instanceIdOf(bundle), "not_opted_in")) }; + } + if (config.peerKeys.length === 0) { + return { accepted: [], rejected: bundles.map((bundle) => reject(instanceIdOf(bundle), "no_trusted_peers")) }; + } + + const accepted: FederatedSignalBundle[] = []; + const rejected: FederatedRejection[] = []; + for (const bundle of bundles) { + const reason = rejectionFor(bundle, config.peerKeys); + if (reason === null) accepted.push(bundle); + else rejected.push(reject(instanceIdOf(bundle), reason)); + } + return { accepted, rejected }; +} + +/** The claimed handle, or null when the bundle is too malformed to carry one. Unauthenticated until a + * signature verifies — only ever used to label a log line, never to select a key or a trust decision. */ +function instanceIdOf(bundle: FederatedSignalBundle): string | null { + return typeof bundle?.instanceId === "string" ? bundle.instanceId : null; +} + +/** Operator-visible by default. Logs the reason and the opaque instance handle only — never bundle contents, + * never a peer key, so a rejection is diagnosable without the log becoming a place secrets leak. */ +function defaultRejectionLogger(rejection: FederatedRejection): void { + console.warn(`[federated-import] rejected peer bundle (instance=${rejection.instanceId ?? "unknown"}): ${rejection.reason}`); +} diff --git a/test/unit/federated-bundle.test.ts b/test/unit/federated-bundle.test.ts index 00b3b4fd10..7dae9e4516 100644 --- a/test/unit/federated-bundle.test.ts +++ b/test/unit/federated-bundle.test.ts @@ -73,6 +73,8 @@ function manifest(enabled: boolean | undefined): Pick = {}) => ({ + schemaVersion: FEDERATED_BUNDLE_SCHEMA_VERSION, + instanceId: "abc123def4567890", + generatedAt: "2026-02-01T00:00:00.000Z", + windowDays: 90, + decided: 40, + mergePrecision: 0.9, + closePrecision: 0.8, + fpRate: 0.1, + fnRate: 0.2, + reversalRate: 0.05, + cycleP50Ms: 1000, + cycleP95Ms: 5000, + slopRate: 0.1, + copycatRate: 0.02, + ...over, +}); + +/** Sign a body the way the export side does, so these tests pin the real cross-module contract rather than a + * local re-statement of it: a canonicalization change on the export side must break them. */ +const signedWith = (key: string, over: Partial = {}): FederatedSignalBundle => { + const payload = body(over); + const signature = createHmac("sha256", key).update(canonicalizeFederatedBundleBody(payload)).digest("hex"); + return { ...payload, signature, ...(over.signature === undefined ? {} : { signature: over.signature }) }; +}; + +const manifest = (over: Partial = {}): Pick => ({ + federatedIntelligence: { + present: true, + enabled: true, + collectorUrl: null, + collectorMode: null, + peerKeys: [PEER_KEY_A], + ...over, + }, +}); + +describe("isFederatedImportEnabled (#6480)", () => { + it("is armed only when opted in AND at least one peer key is allowlisted", () => { + expect(isFederatedImportEnabled(manifest())).toBe(true); + }); + + it("stays off when the operator opted into the export but allowlisted no peer", () => { + // The load-bearing case: enabling the EXPORT must never, by itself, start admitting inbound data. + expect(isFederatedImportEnabled(manifest({ peerKeys: [] }))).toBe(false); + }); + + it("stays off when not opted in, even with peer keys configured", () => { + expect(isFederatedImportEnabled(manifest({ enabled: false }))).toBe(false); + }); + + it("stays off for an absent manifest or an absent federatedIntelligence block", () => { + expect(isFederatedImportEnabled(null)).toBe(false); + expect(isFederatedImportEnabled(undefined)).toBe(false); + expect(isFederatedImportEnabled({} as Pick)).toBe(false); + }); +}); + +describe("verifyFederatedBundle (#6480)", () => { + it("verifies a bundle signed by an allowlisted key", () => { + expect(verifyFederatedBundle(signedWith(PEER_KEY_A), [PEER_KEY_A])).toBe(true); + }); + + it("verifies against ANY allowlisted key, not just the first", () => { + expect(verifyFederatedBundle(signedWith(PEER_KEY_B), [PEER_KEY_A, PEER_KEY_B])).toBe(true); + }); + + it("rejects a bundle signed by a key the operator never allowlisted", () => { + expect(verifyFederatedBundle(signedWith(UNTRUSTED_KEY), [PEER_KEY_A, PEER_KEY_B])).toBe(false); + }); + + it("rejects when the allowlist is empty", () => { + expect(verifyFederatedBundle(signedWith(PEER_KEY_A), [])).toBe(false); + }); + + it("rejects a body tampered with after signing", () => { + // The signature stays valid for the ORIGINAL body; flipping a field must invalidate it. + const bundle = signedWith(PEER_KEY_A); + expect(verifyFederatedBundle({ ...bundle, mergePrecision: 0.99 }, [PEER_KEY_A])).toBe(false); + }); + + it("rejects a non-hex or truncated signature without throwing", () => { + expect(verifyFederatedBundle(signedWith(PEER_KEY_A, { signature: "not-hex" }), [PEER_KEY_A])).toBe(false); + expect(verifyFederatedBundle(signedWith(PEER_KEY_A, { signature: "abcd" }), [PEER_KEY_A])).toBe(false); + expect(verifyFederatedBundle(signedWith(PEER_KEY_A, { signature: "" }), [PEER_KEY_A])).toBe(false); + }); + + it("ignores an extra field a peer appended: it is outside the canonical key list, so it cannot alter the signed bytes", () => { + const bundle = signedWith(PEER_KEY_A); + expect(verifyFederatedBundle({ ...bundle, injected: "payload" } as FederatedSignalBundle, [PEER_KEY_A])).toBe(true); + }); +}); + +describe("importPeerBundles (#6480)", () => { + const collect = () => { + const seen: FederatedRejection[] = []; + return { log: (rejection: FederatedRejection) => seen.push(rejection), seen }; + }; + + it("accepts a valid bundle from an allowlisted peer", () => { + const bundle = signedWith(PEER_KEY_A); + const { log, seen } = collect(); + const result = importPeerBundles(manifest(), [bundle], { log }); + expect(result.accepted).toEqual([bundle]); + expect(result.rejected).toEqual([]); + expect(seen).toEqual([]); + }); + + it("rejects an invalid signature and logs it", () => { + const { log, seen } = collect(); + const result = importPeerBundles(manifest(), [signedWith(PEER_KEY_A, { signature: "f".repeat(64) })], { log }); + expect(result.accepted).toEqual([]); + expect(result.rejected).toEqual([{ instanceId: "abc123def4567890", reason: "untrusted_or_tampered" }]); + expect(seen).toHaveLength(1); + }); + + it("rejects a bundle from a peer outside the allowlist — the trust-gating rule", () => { + // #6477's layer 1: a bundle that is perfectly well-formed and authentically signed is still rejected, + // purely because the receiving operator never added this peer's key. + const result = importPeerBundles(manifest(), [signedWith(UNTRUSTED_KEY)], { log: () => undefined }); + expect(result.accepted).toEqual([]); + expect(result.rejected).toEqual([{ instanceId: "abc123def4567890", reason: "untrusted_or_tampered" }]); + }); + + it("never processes an inbound bundle for an opted-out instance", () => { + const result = importPeerBundles(manifest({ enabled: false }), [signedWith(PEER_KEY_A)], { log: () => undefined }); + expect(result.accepted).toEqual([]); + expect(result.rejected).toEqual([{ instanceId: "abc123def4567890", reason: "not_opted_in" }]); + }); + + it("rejects everything when opted in with an empty allowlist (fail closed)", () => { + const result = importPeerBundles(manifest({ peerKeys: [] }), [signedWith(PEER_KEY_A)], { log: () => undefined }); + expect(result.accepted).toEqual([]); + expect(result.rejected).toEqual([{ instanceId: "abc123def4567890", reason: "no_trusted_peers" }]); + }); + + it("rejects an unknown schema version rather than guessing at it", () => { + const result = importPeerBundles(manifest(), [signedWith(PEER_KEY_A, { schemaVersion: 999 })], { log: () => undefined }); + expect(result.rejected).toEqual([{ instanceId: "abc123def4567890", reason: "unsupported_schema_version" }]); + }); + + it("rejects a malformed bundle whose signed field is the wrong type", () => { + const bundle = { ...signedWith(PEER_KEY_A), decided: "many" } as unknown as FederatedSignalBundle; + const result = importPeerBundles(manifest(), [bundle], { log: () => undefined }); + expect(result.rejected).toEqual([{ instanceId: "abc123def4567890", reason: "malformed" }]); + }); + + it("rejects a malformed bundle with a non-numeric nullable field", () => { + const bundle = { ...signedWith(PEER_KEY_A), cycleP50Ms: "fast" } as unknown as FederatedSignalBundle; + expect(importPeerBundles(manifest(), [bundle], { log: () => undefined }).rejected[0]!.reason).toBe("malformed"); + }); + + it("accepts a bundle whose nullable fields are genuinely null (an instance under MIN_DECIDED)", () => { + const bundle = signedWith(PEER_KEY_A, { mergePrecision: null, closePrecision: null, fpRate: null, fnRate: null, cycleP50Ms: null, cycleP95Ms: null }); + expect(importPeerBundles(manifest(), [bundle], { log: () => undefined }).accepted).toEqual([bundle]); + }); + + it("reports a null instanceId when the bundle is too malformed to carry one", () => { + const bundle = { schemaVersion: FEDERATED_BUNDLE_SCHEMA_VERSION } as unknown as FederatedSignalBundle; + expect(importPeerBundles(manifest(), [bundle], { log: () => undefined }).rejected).toEqual([{ instanceId: null, reason: "malformed" }]); + }); + + it("partitions a mixed batch, keeping only the trusted bundles", () => { + const good = signedWith(PEER_KEY_A, { instanceId: "1111111111111111" }); + const alsoGood = signedWith(PEER_KEY_B, { instanceId: "2222222222222222" }); + const bad = signedWith(UNTRUSTED_KEY, { instanceId: "3333333333333333" }); + const result = importPeerBundles(manifest({ peerKeys: [PEER_KEY_A, PEER_KEY_B] }), [good, bad, alsoGood], { log: () => undefined }); + expect(result.accepted).toEqual([good, alsoGood]); + expect(result.rejected).toEqual([{ instanceId: "3333333333333333", reason: "untrusted_or_tampered" }]); + }); + + it("handles an empty batch", () => { + expect(importPeerBundles(manifest(), [])).toEqual({ accepted: [], rejected: [] }); + }); + + it("warns on the console by default, so a rejection is never silently dropped", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + importPeerBundles(manifest(), [signedWith(UNTRUSTED_KEY)]); + expect(warn).toHaveBeenCalledTimes(1); + expect(String(warn.mock.calls[0]?.[0])).toContain("untrusted_or_tampered"); + warn.mockRestore(); + }); + + it("labels an unreadable instance handle as unknown rather than logging 'null'", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + importPeerBundles(manifest(), [{ schemaVersion: FEDERATED_BUNDLE_SCHEMA_VERSION } as unknown as FederatedSignalBundle]); + expect(String(warn.mock.calls[0]?.[0])).toContain("instance=unknown"); + warn.mockRestore(); + }); + + it("never logs a peer key or bundle contents", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + importPeerBundles(manifest(), [signedWith(UNTRUSTED_KEY)]); + const line = String(warn.mock.calls[0]?.[0]); + expect(line).not.toContain(PEER_KEY_A); + expect(line).not.toContain("0.9"); + warn.mockRestore(); + }); + + it("treats an absent manifest as opted out", () => { + expect(importPeerBundles(null, [signedWith(PEER_KEY_A)], { log: () => undefined }).rejected[0]!.reason).toBe("not_opted_in"); + expect(importPeerBundles(undefined, [], { log: () => undefined })).toEqual({ accepted: [], rejected: [] }); + }); +}); diff --git a/test/unit/focus-manifest.test.ts b/test/unit/focus-manifest.test.ts index 4915f15a22..c265745c7c 100644 --- a/test/unit/focus-manifest.test.ts +++ b/test/unit/focus-manifest.test.ts @@ -947,7 +947,7 @@ describe("compileFocusManifestPolicy", () => { upstreamDriftIssues: { present: false, enabled: false }, sweepWatchdog: { present: false, enabled: false }, prReconciliation: { present: false, enabled: false }, - federatedIntelligence: { present: false, enabled: false, collectorUrl: null, collectorMode: null }, + federatedIntelligence: { present: false, enabled: false, collectorUrl: null, collectorMode: null, peerKeys: [] }, warnings: [], }); expect(policy.publicSafe.entryGuidance).toContain("Keep PRs focused."); @@ -2242,12 +2242,12 @@ describe("parseFocusManifest gate config", () => { describe("federatedIntelligence: (#1970, opt-in federated fleet intelligence export config-as-code toggle)", () => { it("defaults to fully disabled/absent when the key is omitted, and does not make the manifest present on its own", () => { const m = parseFocusManifest({}); - expect(m.federatedIntelligence).toEqual({ present: false, enabled: false, collectorUrl: null, collectorMode: null }); + expect(m.federatedIntelligence).toEqual({ present: false, enabled: false, collectorUrl: null, collectorMode: null, peerKeys: [] }); expect(m.present).toBe(false); }); it("treats an explicit null the same as an omitted key", () => { - expect(parseFocusManifest({ federatedIntelligence: null }).federatedIntelligence).toEqual({ present: false, enabled: false, collectorUrl: null, collectorMode: null }); + expect(parseFocusManifest({ federatedIntelligence: null }).federatedIntelligence).toEqual({ present: false, enabled: false, collectorUrl: null, collectorMode: null, peerKeys: [] }); }); it("warns and falls back to the default when the value is a non-mapping type (string or array)", () => { @@ -2261,13 +2261,13 @@ describe("parseFocusManifest gate config", () => { it("parses enabled: true, making the manifest present", () => { const m = parseFocusManifest({ federatedIntelligence: { enabled: true } }); - expect(m.federatedIntelligence).toEqual({ present: true, enabled: true, collectorUrl: null, collectorMode: null }); + expect(m.federatedIntelligence).toEqual({ present: true, enabled: true, collectorUrl: null, collectorMode: null, peerKeys: [] }); expect(m.present).toBe(true); }); it("parses enabled: false explicitly, still making the manifest present", () => { const m = parseFocusManifest({ federatedIntelligence: { enabled: false } }); - expect(m.federatedIntelligence).toEqual({ present: true, enabled: false, collectorUrl: null, collectorMode: null }); + expect(m.federatedIntelligence).toEqual({ present: true, enabled: false, collectorUrl: null, collectorMode: null, peerKeys: [] }); expect(m.present).toBe(true); }); @@ -2295,6 +2295,7 @@ describe("parseFocusManifest gate config", () => { enabled: true, collectorUrl: "https://collector.example.org/v1/federated", collectorMode: "push", + peerKeys: [], }); }); @@ -2334,6 +2335,57 @@ describe("parseFocusManifest gate config", () => { .federatedIntelligence, ).toEqual(m.federatedIntelligence); }); + + // peerKeys (#6480) — #6477's explicit peer-trust allowlist. A key is only useful if it could actually + // verify a bundle, so the parser validates the 64-hex shape signFederatedBundle's HMAC key has. + it("parses a valid peerKeys allowlist and lowercases it", () => { + const m = parseFocusManifest({ + federatedIntelligence: { enabled: true, peerKeys: ["a".repeat(64), "B".repeat(64)] }, + }); + expect(m.federatedIntelligence.peerKeys).toEqual(["a".repeat(64), "b".repeat(64)]); + expect(m.warnings.some((w) => /peerKeys/.test(w))).toBe(false); + }); + + it("defaults peerKeys to an empty allowlist when the key is omitted, so the export alone never imports", () => { + expect(parseFocusManifest({ federatedIntelligence: { enabled: true } }).federatedIntelligence.peerKeys).toEqual([]); + }); + + it("warns and drops a peerKeys entry that is not a 64-char hex key, keeping the valid ones", () => { + const m = parseFocusManifest({ + federatedIntelligence: { enabled: true, peerKeys: ["nope", "a".repeat(63), "z".repeat(64), "a".repeat(64)] }, + }); + expect(m.federatedIntelligence.peerKeys).toEqual(["a".repeat(64)]); + expect(m.warnings.some((w) => /federatedIntelligence\.peerKeys/.test(w))).toBe(true); + }); + + it("never echoes a dropped peerKeys entry into a warning", () => { + // A peer key is verification material; a warning that quoted it would put it in logs and PR surfaces. + const m = parseFocusManifest({ federatedIntelligence: { enabled: true, peerKeys: ["deadbeef"] } }); + expect(m.warnings.some((w) => w.includes("deadbeef"))).toBe(false); + }); + + it("de-duplicates peerKeys case-insensitively, first occurrence winning", () => { + const m = parseFocusManifest({ + federatedIntelligence: { enabled: true, peerKeys: ["a".repeat(64), "A".repeat(64)] }, + }); + expect(m.federatedIntelligence.peerKeys).toEqual(["a".repeat(64)]); + }); + + it("warns and drops a non-list peerKeys value", () => { + const m = parseFocusManifest({ + federatedIntelligence: { enabled: true, peerKeys: "a".repeat(64) as unknown as string[] }, + }); + expect(m.federatedIntelligence.peerKeys).toEqual([]); + expect(m.warnings.some((w) => /federatedIntelligence\.peerKeys/.test(w))).toBe(true); + }); + + it("round-trips peerKeys through federatedIntelligenceConfigToJson unchanged", () => { + const m = parseFocusManifest({ federatedIntelligence: { enabled: true, peerKeys: ["a".repeat(64)] } }); + expect( + parseFocusManifest({ federatedIntelligence: federatedIntelligenceConfigToJson(m.federatedIntelligence) }) + .federatedIntelligence, + ).toEqual(m.federatedIntelligence); + }); }); it("parses aiReviewAllAuthors from the settings: block (generic override)", () => {