From e29c31aa39e318a709c7d438d279023a41fff26a Mon Sep 17 00:00:00 2001 From: luciferlive112116 <291889058+luciferlive112116@users.noreply.github.com> Date: Fri, 17 Jul 2026 00:35:28 +0800 Subject: [PATCH 1/2] feat(orb): trust-gate inbound federated bundles per the #6477 key-trust design (#6480) Add the receiving side of federated fleet intelligence: verify a pulled peer bundle's HMAC against the operator's explicit peerKeys allowlist before it can be folded into local calibration or the peer-median benchmark, and log every rejection with the rule that stopped it. Implements #6477's decision exactly: explicit operator-configured allowlist (no auto-discovery, no PKI) plus median-not-mean aggregation, which the existing fleet analytics already provides. No reputation/decay/scoring system, which that design pass considered and deliberately turned down. Closes #6480 --- .loopover.yml.example | 9 + config/examples/loopover.full.yml | 9 + .../loopover-engine/src/focus-manifest.ts | 45 +++- src/orb/federated-import.ts | 196 ++++++++++++++++ test/unit/federated-import.test.ts | 214 ++++++++++++++++++ test/unit/focus-manifest.test.ts | 62 ++++- 6 files changed, 528 insertions(+), 7 deletions(-) create mode 100644 src/orb/federated-import.ts create mode 100644 test/unit/federated-import.test.ts 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-import.test.ts b/test/unit/federated-import.test.ts new file mode 100644 index 0000000000..b81de2a9e4 --- /dev/null +++ b/test/unit/federated-import.test.ts @@ -0,0 +1,214 @@ +import { createHmac } from "node:crypto"; +import { describe, expect, it, vi } from "vitest"; + +import { canonicalizeFederatedBundleBody, FEDERATED_BUNDLE_SCHEMA_VERSION, type FederatedSignalBundle } from "../../src/orb/federated-bundle"; +import { + importPeerBundles, + isFederatedImportEnabled, + verifyFederatedBundle, + type FederatedRejection, +} from "../../src/orb/federated-import"; +import type { FocusManifest } from "../../src/signals/focus-manifest"; + +// Fake 64-hex keys — the shape generateAnonSecret produces. Not secrets: locally-invented test fixtures. +const PEER_KEY_A = "a".repeat(64); +const PEER_KEY_B = "b".repeat(64); +const UNTRUSTED_KEY = "c".repeat(64); + +const body = (over: Partial = {}) => ({ + 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("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)", () => { From 5ae824ee558254e0ef154fed2edbd5465624e3f8 Mon Sep 17 00:00:00 2001 From: luciferlive112116 <291889058+luciferlive112116@users.noreply.github.com> Date: Fri, 17 Jul 2026 00:56:45 +0800 Subject: [PATCH 2/2] test(orb): add peerKeys to the export/transport manifest fixtures (#6480) vitest does not typecheck, so both fixtures passed at runtime while tsc failed on the new required field. Each notes why its own side never reads peerKeys. Also cover defaultRejectionLogger's unknown-handle fallback, the last partial branch in the new module. --- test/unit/federated-bundle.test.ts | 2 ++ test/unit/federated-collector.test.ts | 2 ++ test/unit/federated-import.test.ts | 7 +++++++ 3 files changed, 11 insertions(+) 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 { 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)]);