diff --git a/.github/workflows/cl10-focus.yml b/.github/workflows/cl10-focus.yml new file mode 100644 index 000000000..63b24c207 --- /dev/null +++ b/.github/workflows/cl10-focus.yml @@ -0,0 +1,24 @@ +name: CL-10 focused validation + +on: + push: + branches: + - feat/cl-10-public-evidence-runtime + +permissions: + contents: read + +jobs: + focused: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 + with: + bun-version: 1.3.14 + - run: bun install --frozen-lockfile + - run: bun test tests/lab-public-evidence.test.ts tests/lab-public-evidence-signature.test.ts tests/lab-community-evidence.test.ts tests/lab-community-publisher-continuity.test.ts tests/lab-public-surfaces.test.ts + - run: bun x tsc --noEmit + - run: cd gui && bun install --frozen-lockfile + - run: cd gui && bun test tests/compatibility-community-evidence.test.ts tests/compatibility-lab-i18n.test.ts + - run: cd gui && bun x tsc -b --pretty false diff --git a/docs/superpowers/plans/2026-08-12-cl10-public-evidence-validation.md b/docs/superpowers/plans/2026-08-12-cl10-public-evidence-validation.md new file mode 100644 index 000000000..562580e8b --- /dev/null +++ b/docs/superpowers/plans/2026-08-12-cl10-public-evidence-validation.md @@ -0,0 +1,47 @@ +# CL-10 Public Evidence Validation State + +Validated implementation scope: CL-10.1 through CL-10.4 only. CL-10.5 remote publishing remains blocked by contract. + +## Implemented + +- Closed, independently versioned public evidence DTOs and runtime validators. +- Domain-separated public subject, record, bundle, artifact, publisher, and revocation identities. +- Repo-owned, versioned, content-addressed public route registry authority. +- Fail-closed protocol/route/task privacy projection. Private route dimensions are never dropped to broaden a public claim. +- Ed25519 publisher continuity with an installation-local restricted private key and public-only bundle identity. +- Deterministic signed local bundles and content-addressed local export storage. +- Same-publisher revocation with bounded targets and idempotent identical replay. +- Quarantined `community_untrusted_v1` import/cache with cryptographic validation followed by repository authority validation. +- Publisher-scoped community identity so identical content signed by different publishers can coexist and revoke independently. +- Sensitive `export` purge integration for generated exports and provably locally-originated community copies while preserving third-party evidence. +- Explicit local CLI/API preview, export, verify, import, and community-list surfaces. +- Compatibility Matrix read-only community context, labelled non-authoritative and kept separate from the canonical local verdict. + +## Hard stops preserved + +- No remote publish command or management endpoint. +- No arbitrary upload URL or remote transport implementation. +- No automatic telemetry or background public-evidence upload. +- No imported community write to `compatibility.jsonl` or `compatibility.sqlite`. +- No community evidence input to canonical local verdicts, routing, Router Intelligence, or CL-08. +- A valid signature yields `cryptographically_valid`, never `locally_verified`. + +## Focused TDD evidence before closure run + +- Public projection/registry/privacy tests: GREEN. +- Signing/local export tests: GREEN. +- Community/revocation/publisher-continuity tests: GREEN. +- Purge interaction tests: GREEN. +- Local CLI/API surface tests with network canaries: GREEN. +- Compatibility Matrix community parser/render/i18n tests: GREEN. +- Root TypeScript: GREEN on the implemented core/operator slices. +- GUI TypeScript and GUI lint: GREEN on the Matrix slice. + +## Closure still required on exact final head + +- Focused CL-10 tests including existing Lab purge regressions. +- Root TypeScript and privacy scan. +- Relevant Lab query/ledger/CLI/management tests. +- GUI targeted tests, lint, build, and React Doctor. +- Full Cross-platform CI. +- Final changed-file/static audit confirming the CL-10.5 remote-publish hard stop and zero feedback into routing/local verdict/CL-08. diff --git a/gui/src/i18n/lab-translations.ts b/gui/src/i18n/lab-translations.ts index 3428a847d..75a21feb4 100644 --- a/gui/src/i18n/lab-translations.ts +++ b/gui/src/i18n/lab-translations.ts @@ -7,7 +7,12 @@ export type LabSupplementKey = | "artifact.present" | "artifact.corrupt" | "artifact.purged_unavailable" - | "selectVerdict"; + | "selectVerdict" + | "community.title" + | "community.notLocalVerdict" + | "community.bundles" + | "community.activeRecords" + | "community.revokedRecords"; const en: Record = { "lab.title": "Compatibility Lab", @@ -427,6 +432,11 @@ const supplements: Record> = { "artifact.corrupt": "Corrupt", "artifact.purged_unavailable": "Purged / unavailable", selectVerdict: "View verdict for {subject}", + "community.title": "Community evidence", + "community.notLocalVerdict": "Untrusted read-only context. Not included in this local verdict.", + "community.bundles": "Bundles", + "community.activeRecords": "Active records", + "community.revokedRecords": "Revoked records", }, de: { subjectKindUnknown: "Unbekannt", @@ -434,6 +444,11 @@ const supplements: Record> = { "artifact.corrupt": "Beschädigt", "artifact.purged_unavailable": "Gelöscht / nicht verfügbar", selectVerdict: "Urteil für {subject} anzeigen", + "community.title": "Community-Evidenz", + "community.notLocalVerdict": "Nicht vertrauenswürdiger Nur-Lese-Kontext. Nicht Teil dieses lokalen Urteils.", + "community.bundles": "Pakete", + "community.activeRecords": "Aktive Einträge", + "community.revokedRecords": "Widerrufene Einträge", }, ko: { subjectKindUnknown: "알 수 없음", @@ -441,6 +456,11 @@ const supplements: Record> = { "artifact.corrupt": "손상됨", "artifact.purged_unavailable": "삭제됨 / 사용할 수 없음", selectVerdict: "{subject}의 판정 보기", + "community.title": "커뮤니티 증거", + "community.notLocalVerdict": "신뢰되지 않는 읽기 전용 컨텍스트입니다. 이 로컬 판정에는 포함되지 않습니다.", + "community.bundles": "번들", + "community.activeRecords": "활성 레코드", + "community.revokedRecords": "폐기된 레코드", }, zh: { subjectKindUnknown: "未知", @@ -448,6 +468,11 @@ const supplements: Record> = { "artifact.corrupt": "已损坏", "artifact.purged_unavailable": "已清除 / 不可用", selectVerdict: "查看 {subject} 的判定", + "community.title": "社区证据", + "community.notLocalVerdict": "不受信任的只读上下文。不计入此本地判定。", + "community.bundles": "证据包", + "community.activeRecords": "有效记录", + "community.revokedRecords": "已撤销记录", }, "zh-TW": { subjectKindUnknown: "未知", @@ -455,6 +480,11 @@ const supplements: Record> = { "artifact.corrupt": "已損壞", "artifact.purged_unavailable": "已清除 / 不可用", selectVerdict: "查看 {subject} 的判定", + "community.title": "社群證據", + "community.notLocalVerdict": "不受信任的唯讀脈絡。不計入此本地判定。", + "community.bundles": "證據包", + "community.activeRecords": "有效記錄", + "community.revokedRecords": "已撤銷記錄", }, ru: { subjectKindUnknown: "Неизвестно", @@ -462,6 +492,11 @@ const supplements: Record> = { "artifact.corrupt": "Повреждён", "artifact.purged_unavailable": "Удалён / недоступен", selectVerdict: "Открыть вердикт для {subject}", + "community.title": "Данные сообщества", + "community.notLocalVerdict": "Недоверенный контекст только для чтения. Не входит в этот локальный вердикт.", + "community.bundles": "Пакеты", + "community.activeRecords": "Активные записи", + "community.revokedRecords": "Отозванные записи", }, ja: { subjectKindUnknown: "不明", @@ -469,6 +504,11 @@ const supplements: Record> = { "artifact.corrupt": "破損", "artifact.purged_unavailable": "削除済み / 利用不可", selectVerdict: "{subject} の判定を表示", + "community.title": "コミュニティ証拠", + "community.notLocalVerdict": "信頼されていない読み取り専用コンテキストです。このローカル判定には含まれません。", + "community.bundles": "バンドル", + "community.activeRecords": "有効なレコード", + "community.revokedRecords": "取り消されたレコード", }, tr: { subjectKindUnknown: "Bilinmiyor", @@ -476,6 +516,11 @@ const supplements: Record> = { "artifact.corrupt": "Bozuk", "artifact.purged_unavailable": "Temizlenmiş / kullanılamıyor", selectVerdict: "{subject} için kararı görüntüle", + "community.title": "Topluluk kanıtı", + "community.notLocalVerdict": "Güvenilmeyen salt okunur bağlam. Bu yerel karara dahil değildir.", + "community.bundles": "Paketler", + "community.activeRecords": "Etkin kayıtlar", + "community.revokedRecords": "Geri çekilen kayıtlar", }, }; diff --git a/gui/src/pages/CompatibilityMatrix.tsx b/gui/src/pages/CompatibilityMatrix.tsx index 9fcac9b12..632d30fe0 100644 --- a/gui/src/pages/CompatibilityMatrix.tsx +++ b/gui/src/pages/CompatibilityMatrix.tsx @@ -205,6 +205,17 @@ function DetailPane({ )} + {detail.community && detail.community.evidence.length > 0 && ( +
+

{labSupplement(locale, "community.title")}

+

{labSupplement(locale, "community.notLocalVerdict")}

+
+
{labSupplement(locale, "community.bundles")}
{detail.community.evidence.length}
+
{labSupplement(locale, "community.activeRecords")}
{detail.community.evidence.reduce((total, row) => total + row.activeRecordCount, 0)}
+
{labSupplement(locale, "community.revokedRecords")}
{detail.community.evidence.reduce((total, row) => total + row.revokedRecordCount, 0)}
+
+
+ )} {detail.observations.length > 0 && (

{t("lab.detailObservations")}

diff --git a/gui/src/pages/compatibility-matrix-api.ts b/gui/src/pages/compatibility-matrix-api.ts index 139ba8594..de3452299 100644 --- a/gui/src/pages/compatibility-matrix-api.ts +++ b/gui/src/pages/compatibility-matrix-api.ts @@ -248,6 +248,80 @@ export async function fetchPassiveProductionSummary( return parsePassiveProductionSummary(raw); } +export type CommunityEvidenceSummaryRowDto = { + trustClass: "community_untrusted_v1"; + status: "cryptographically_valid"; + bundleId: string; + publisherKeyId: string; + activeRecordCount: number; + revokedRecordCount: number; +}; + +export type CommunityEvidenceContextDto = { + evidence: CommunityEvidenceSummaryRowDto[]; + trustClass: "community_untrusted_v1"; + locallyVerified: false; +}; + +function hasOnlyKeys(raw: Record, allowed: readonly string[]): boolean { + const allowedSet = new Set(allowed); + return Object.keys(raw).every(key => allowedSet.has(key)); +} + +function isSha256Hex(value: unknown): value is string { + return typeof value === "string" && /^[0-9a-f]{64}$/.test(value); +} + +function isNonNegativeInteger(value: unknown): value is number { + return typeof value === "number" && Number.isSafeInteger(value) && value >= 0; +} + +export function parseCommunityEvidenceContext(raw: unknown): CommunityEvidenceContextDto | null { + if (!isPlainObject(raw) + || !hasOnlyKeys(raw, ["evidence", "trustClass", "locallyVerified"]) + || raw.trustClass !== "community_untrusted_v1" + || raw.locallyVerified !== false + || !Array.isArray(raw.evidence) + || raw.evidence.length > 4096) { + return null; + } + const evidence: CommunityEvidenceSummaryRowDto[] = []; + for (const value of raw.evidence) { + if (!isPlainObject(value) + || !hasOnlyKeys(value, [ + "trustClass", "status", "bundleId", "publisherKeyId", + "activeRecordCount", "revokedRecordCount", + ]) + || value.trustClass !== "community_untrusted_v1" + || value.status !== "cryptographically_valid" + || !isSha256Hex(value.bundleId) + || !isSha256Hex(value.publisherKeyId) + || !isNonNegativeInteger(value.activeRecordCount) + || !isNonNegativeInteger(value.revokedRecordCount)) { + return null; + } + evidence.push({ + trustClass: "community_untrusted_v1", + status: "cryptographically_valid", + bundleId: value.bundleId, + publisherKeyId: value.publisherKeyId, + activeRecordCount: value.activeRecordCount, + revokedRecordCount: value.revokedRecordCount, + }); + } + return { evidence, trustClass: "community_untrusted_v1", locallyVerified: false }; +} + +export async function fetchCommunityEvidenceContext( + apiBase: string, + signal: AbortSignal, +): Promise { + const raw = await fetchLabJson(apiBase, "/api/lab/public/community", signal); + const context = parseCommunityEvidenceContext(raw); + if (!context) throw invalidResponse(); + return context; +} + export type LabPageData = { status: LabStatusDto; verdicts: VerdictDto[]; @@ -296,6 +370,7 @@ export type VerdictDetailData = { events: LabEventDto[]; artifacts: ArtifactMetadataDto[]; production: PassiveProductionSummaryDto | null; + community: CommunityEvidenceContextDto | null; }; async function mapSettledBounded( @@ -337,7 +412,7 @@ export async function fetchVerdictDetail( layer: verdict.evidenceLayer, suiteId: verdict.suiteId, }; - const [subject, observations, events, artifacts, production] = await Promise.all([ + const [subject, observations, events, artifacts, production, community] = await Promise.all([ fetchSubjectDetail(apiBase, verdict.subjectId, signal), fetchAllObservations(apiBase, observationFilters, signal), mapSettledBounded(eventIds, DETAIL_CONCURRENCY, signal, id => fetchEventById(apiBase, id, signal)), @@ -346,6 +421,10 @@ export async function fetchVerdictDetail( if (signal.aborted) throw error; return null; }), + fetchCommunityEvidenceContext(apiBase, signal).catch(error => { + if (signal.aborted) throw error; + return null; + }), ]); return { subject, @@ -354,5 +433,6 @@ export async function fetchVerdictDetail( events, artifacts, production, + community, }; } diff --git a/gui/tests/compatibility-community-evidence.test.ts b/gui/tests/compatibility-community-evidence.test.ts new file mode 100644 index 000000000..cf76d149a --- /dev/null +++ b/gui/tests/compatibility-community-evidence.test.ts @@ -0,0 +1,58 @@ +import { expect, test } from "bun:test"; +import { + parseCommunityEvidenceContext, + type CommunityEvidenceContextDto, +} from "../src/pages/compatibility-matrix-api"; +import { labSupplement, type LabLocale } from "../src/i18n/lab-translations"; + +const LOCALES: LabLocale[] = ["en", "de", "ja", "ko", "ru", "tr", "zh", "zh-TW"]; + +function validContext(): CommunityEvidenceContextDto { + return { + trustClass: "community_untrusted_v1", + locallyVerified: false, + evidence: [ + { + trustClass: "community_untrusted_v1", + status: "cryptographically_valid", + bundleId: "a".repeat(64), + publisherKeyId: "b".repeat(64), + activeRecordCount: 3, + revokedRecordCount: 1, + }, + ], + }; +} + +test("Compatibility Matrix parses only quarantined community evidence context", () => { + expect(parseCommunityEvidenceContext(validContext())).toEqual(validContext()); + expect(parseCommunityEvidenceContext({ ...validContext(), locallyVerified: true })).toBeNull(); + expect(parseCommunityEvidenceContext({ ...validContext(), trustClass: "local" })).toBeNull(); + expect(parseCommunityEvidenceContext({ + ...validContext(), + evidence: [{ ...validContext().evidence[0]!, activeRecordCount: -1 }], + })).toBeNull(); + expect(parseCommunityEvidenceContext({ + ...validContext(), + evidence: [{ ...validContext().evidence[0]!, status: "locally_verified" }], + })).toBeNull(); +}); + +test("Compatibility Matrix community copy is localized and explicitly non-authoritative", () => { + for (const locale of LOCALES) { + expect(labSupplement(locale, "community.title")).toBeTruthy(); + expect(labSupplement(locale, "community.notLocalVerdict")).toBeTruthy(); + expect(labSupplement(locale, "community.bundles")).toBeTruthy(); + expect(labSupplement(locale, "community.activeRecords")).toBeTruthy(); + expect(labSupplement(locale, "community.revokedRecords")).toBeTruthy(); + } + expect(labSupplement("en", "community.notLocalVerdict")).toMatch(/untrusted|not included|local verdict/i); +}); + +test("Compatibility Matrix renders community evidence as separate context, never a combined score", async () => { + const source = await Bun.file(new URL("../src/pages/CompatibilityMatrix.tsx", import.meta.url)).text(); + expect(source).toContain('data-testid="lab-community-evidence"'); + expect(source).toContain('labSupplement(locale, "community.notLocalVerdict")'); + expect(source).not.toMatch(/combined.?score/i); + expect(source).not.toMatch(/community.*verdict\s*=|verdict\s*=.*community/i); +}); diff --git a/src/cli/lab.ts b/src/cli/lab.ts index 013b96327..deac35b94 100644 --- a/src/cli/lab.ts +++ b/src/cli/lab.ts @@ -63,6 +63,14 @@ import { planManualLabRun } from "../lab/automation/planner"; import { listLabAutomationRuns } from "../lab/automation/runs-query"; import { LabAutomationError, type LabAutomationLayer } from "../lab/automation/types"; import { createProductionLabRouteExecutor } from "../lib/lab-live-route-production"; +import { + exportLocalPublicEvidence, + importCommunityEvidenceFile, + listCommunityEvidenceContext, + previewLocalPublicEvidence, + verifyPublicEvidenceFile, + type PublicVerificationSummaryV1, +} from "../lab/public"; const USAGE = `Usage: ocx lab status [--json] @@ -76,6 +84,11 @@ const USAGE = `Usage: ocx lab artifacts [--status ] [--artifact-class ] [--limit ] [--cursor ] [--json] ocx lab artifact [--json] ocx lab catalog [--layer ] [--suite ] [--json] + ocx lab public preview --event [--event ...] [--json] + ocx lab public export --event [--event ...] [--json] + ocx lab public verify --file [--json] + ocx lab public import --file [--json] + ocx lab public community [--json] ocx lab automation status [--json] ocx lab automation enable [--protocol] [--live] [--json] ocx lab automation disable [--json] @@ -223,6 +236,117 @@ function runListLines(page: ReturnType): string[] return lines.length > 0 ? lines : ["No automation runs"]; } +function takeRepeatedOptions(args: string[], flag: string): string[] { + const values: string[] = []; + while (true) { + const index = args.indexOf(flag); + if (index < 0) break; + const value = args[index + 1]; + if (!value || value.startsWith("--")) { + throw new CliUsageError(`${flag} requires a value`, USAGE); + } + values.push(value); + args.splice(index, 2); + } + return values; +} + +function publicPreviewLines(result: ReturnType): string[] { + return [ + `Public evidence preview: ${result.bundle.records.length} exportable record(s)`, + `Excluded: ${result.excluded.length}`, + "Unsigned local preview; no publisher key or remote publish is created.", + ]; +} + +function publicExportLines(result: ReturnType): string[] { + return [ + "Public evidence exported locally", + `Bundle: ${result.bundle.bundleId}`, + `Publisher: ${result.bundle.publisher.keyId}`, + `Path: ${result.stored.path}`, + `Excluded: ${result.excluded.length}`, + "No remote publish occurred.", + ]; +} + +function publicVerificationLines(result: PublicVerificationSummaryV1): string[] { + if (result.status !== "cryptographically_valid") { + return [ + `Public evidence verification: ${result.status}`, + "Not locally verified.", + ...(result.detail ? [result.detail] : []), + ]; + } + return [ + "Public evidence verification: cryptographically valid", + `Bundle: ${result.bundleId}`, + `Publisher: ${result.publisherKeyId}`, + "Not locally verified. Signature validity proves integrity/continuity only.", + ]; +} + +function handlePublicLabCommand( + argv: string[], + wantsJson: boolean, + configDir: string, +): void { + const [action, ...restInput] = argv; + const rest = [...restInput]; + switch (action) { + case "preview": { + const eventIds = takeRepeatedOptions(rest, "--event"); + rejectArgs(rest, USAGE); + const result = previewLocalPublicEvidence({ eventIds }, configDir); + printData(result, wantsJson, publicPreviewLines(result)); + return; + } + case "export": { + const eventIds = takeRepeatedOptions(rest, "--event"); + rejectArgs(rest, USAGE); + const result = exportLocalPublicEvidence({ eventIds }, configDir); + printData(result, wantsJson, publicExportLines(result)); + return; + } + case "verify": { + const path = takeOption(rest, "--file"); + if (!path) throw new CliUsageError("public verify requires --file", USAGE); + rejectArgs(rest, USAGE); + const result = verifyPublicEvidenceFile(path); + printData(result, wantsJson, publicVerificationLines(result)); + return; + } + case "import": { + const path = takeOption(rest, "--file"); + if (!path) throw new CliUsageError("public import requires --file", USAGE); + rejectArgs(rest, USAGE); + const result = importCommunityEvidenceFile(path, configDir); + printData(result, wantsJson, [ + `Community evidence imported: ${result.bundleId}`, + `Publisher: ${result.publisherKeyId}`, + "Trust: community_untrusted_v1; not locally verified.", + ]); + return; + } + case "community": { + rejectArgs(rest, USAGE); + const result = listCommunityEvidenceContext(configDir); + const lines = result.evidence.length > 0 + ? result.evidence.map((row) => + `${row.bundleId} publisher=${row.publisherKeyId} active=${row.activeRecordCount} revoked=${row.revokedRecordCount}`, + ) + : ["No community evidence"]; + printData(result, wantsJson, [ + "Community evidence (untrusted, read-only context; not locally verified)", + ...lines, + ]); + return; + } + default: + throw new CliUsageError("unknown public subcommand", USAGE); + } +} + export async function handleLabCommand(argv: string[], deps: LabCliDeps = {}): Promise { return runCliAction(async () => { const configDir = deps.configDir ?? getConfigDir(); @@ -232,6 +356,10 @@ export async function handleLabCommand(argv: string[], deps: LabCliDeps = {}): P try { switch (sub) { + case "public": { + handlePublicLabCommand(rest, wantsJson, configDir); + return; + } case "status": { rejectArgs(rest, USAGE); const status = queryLabStatus(configDir); diff --git a/src/lab/index.ts b/src/lab/index.ts index 783eb0552..2110aecff 100644 --- a/src/lab/index.ts +++ b/src/lab/index.ts @@ -36,3 +36,4 @@ export * from "./subject/installation-salt"; export { CL03_LIVE_SUITES } from "./conformance/types"; export * from "./query"; export * from "./automation"; +export * from "./public"; diff --git a/src/lab/ledger/purge.ts b/src/lab/ledger/purge.ts index dd6b95853..0f5d89bc9 100644 --- a/src/lab/ledger/purge.ts +++ b/src/lab/ledger/purge.ts @@ -13,6 +13,7 @@ import { } from "../constants"; import type { LabEvent, PurgeTombstoneEvent } from "../events/types"; import { assignEventId, validateLabEvent } from "../events/validate"; +import { purgeLocalPublicEvidenceCopies } from "../public/purge"; import { artifactDeletionPlan, expandSensitiveArtifactEventTargets, @@ -195,7 +196,7 @@ export function purgeSensitiveEvidence(req: SensitivePurgeRequest): PurgeTombsto completed.push("scratch"); } if (purgeActions.includes("export")) { - purgeBoundedDirectory(paths.exportDir); + purgeLocalPublicEvidenceCopies(req.configDir); completed.push("export"); } diff --git a/src/lab/paths.ts b/src/lab/paths.ts index f202f4121..581411097 100644 --- a/src/lab/paths.ts +++ b/src/lab/paths.ts @@ -81,8 +81,24 @@ export function labScratchDir(configDir = getConfigDir()): string { return join(labRoot(configDir), "scratch"); } +/** CL-10 content-addressed local public export store. */ +export function labPublicExportsDir(configDir = getConfigDir()): string { + return join(labRoot(configDir), "exports"); +} + +/** Backwards-compatible Lab export path helper; CL-10 canonicalizes the directory to `exports/`. */ export function labExportDir(configDir = getConfigDir()): string { - return join(labRoot(configDir), "export"); + return labPublicExportsDir(configDir); +} + +/** CL-10 quarantined imported community evidence cache. */ +export function labCommunityDir(configDir = getConfigDir()): string { + return join(labRoot(configDir), "community"); +} + +/** Installation-local Ed25519 private publisher key. Never exported. */ +export function labPublicPublisherKeyPath(configDir = getConfigDir()): string { + return join(labRoot(configDir), "publisher-ed25519-private.pem"); } /** Opaque per-installation salt for local fingerprinting (never exported as evidence). */ @@ -110,15 +126,18 @@ export function ensureLabDirs(configDir = getConfigDir()): { artifactsDir: string; scratchDir: string; exportDir: string; + communityDir: string; } { const root = labRoot(configDir); const artifactsDir = labArtifactsDir(configDir); const scratchDir = labScratchDir(configDir); - const exportDir = labExportDir(configDir); + const exportDir = labPublicExportsDir(configDir); + const communityDir = labCommunityDir(configDir); ensureRestrictedDir(root, root); ensureRestrictedDir(artifactsDir, root); ensureRestrictedDir(scratchDir, root); ensureRestrictedDir(exportDir, root); + ensureRestrictedDir(communityDir, root); return { root, ledgerPath: labLedgerPath(configDir), @@ -126,5 +145,6 @@ export function ensureLabDirs(configDir = getConfigDir()): { artifactsDir, scratchDir, exportDir, + communityDir, }; } diff --git a/src/lab/public/authority.ts b/src/lab/public/authority.ts new file mode 100644 index 000000000..273e24b6f --- /dev/null +++ b/src/lab/public/authority.ts @@ -0,0 +1,129 @@ +import { + FABRIC_COMPATIBILITY_VERSION, + FABRIC_TASK_CLASS_ID, + FABRIC_TASK_CLASS_VERSION, + FABRIC_VERIFIER_ID, +} from "../fabric/constants"; +import { sandboxProfileDigest, taskFixtureDigest, verifierManifestDigest } from "../fabric/subject"; +import { subjectIdForSubject } from "../digest"; +import type { RouteSubjectV1, TaskSubjectV1 } from "../events/types"; +import { PUBLIC_ROUTE_REGISTRY_V1, findPublicRouteRegistryEntry } from "./registry"; +import type { + PublicAdapterFamilyV1, + PublicRouteAuthorityV1, + PublicRouteSubjectV1, + PublicTaskAuthorityV1, + PublicTaskSubjectV1, +} from "./types"; + +const routeAuthorities = new WeakSet(); +const taskAuthorities = new WeakSet(); + +export function toPublicAdapterFamily(value: string): PublicAdapterFamilyV1 | null { + switch (value) { + case "openai-responses": + case "responses": + return "openai-responses"; + case "openai-chat": + case "chat": + return "openai-chat"; + case "anthropic": + case "anthropic-messages": + return "anthropic-messages"; + default: + return null; + } +} + +function canonicalHttpsBaseUrl(value: string): string | null { + try { + const url = new URL(value); + if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash) return null; + url.pathname = url.pathname.replace(/\/+$/, "") || "/"; + return url.toString().replace(/\/$/, url.pathname === "/" ? "/" : ""); + } catch { + return null; + } +} + +export function authorizePublicRouteSubject(input: { + subject: RouteSubjectV1; + localSubjectId: string; + effectiveBaseUrl: string; + /** Closed names derived by the trusted export caller from effective config. Must be empty. */ + privateBehaviorDimensions: readonly string[]; +}): PublicRouteAuthorityV1 | null { + const { subject } = input; + if (subjectIdForSubject(subject) !== input.localSubjectId) return null; + if (subject.dependencies.length !== 0 || subject.clientModelId !== subject.upstreamModelId) return null; + if (input.privateBehaviorDimensions.length !== 0) return null; + + const adapterFamily = toPublicAdapterFamily(subject.effectiveAdapter); + const inboundProtocol = toPublicAdapterFamily(subject.inboundProtocol); + const upstreamProtocol = toPublicAdapterFamily(subject.upstreamProtocol); + if (!adapterFamily || !inboundProtocol || !upstreamProtocol) return null; + + const entry = findPublicRouteRegistryEntry(subject.providerId, subject.upstreamModelId, adapterFamily); + if (!entry) return null; + const effectiveBaseUrl = canonicalHttpsBaseUrl(input.effectiveBaseUrl); + const canonicalBaseUrl = canonicalHttpsBaseUrl(entry.canonicalBaseUrl); + if (!effectiveBaseUrl || !canonicalBaseUrl || effectiveBaseUrl !== canonicalBaseUrl) return null; + + const descriptor: PublicRouteSubjectV1 = Object.freeze({ + subjectKind: "route", + providerId: subject.providerId, + modelId: subject.upstreamModelId, + adapterFamily, + inboundProtocol, + upstreamProtocol, + surface: subject.surface, + compatibilityVersion: subject.opencodexCompatibilityVersion, + registryVersion: PUBLIC_ROUTE_REGISTRY_V1.registryVersion, + registryDigest: PUBLIC_ROUTE_REGISTRY_V1.manifestDigest, + }); + const authority: PublicRouteAuthorityV1 = Object.freeze({ + localSubjectId: input.localSubjectId, + descriptor, + }); + routeAuthorities.add(authority); + return authority; +} + +export function isTrustedPublicRouteAuthority(value: unknown): value is PublicRouteAuthorityV1 { + return !!value && typeof value === "object" && routeAuthorities.has(value); +} + +export function authorizePublicTaskSubject(input: { + subject: TaskSubjectV1; + localSubjectId: string; + routeAuthority: PublicRouteAuthorityV1; +}): PublicTaskAuthorityV1 | null { + const { subject } = input; + if (!isTrustedPublicRouteAuthority(input.routeAuthority)) return null; + if (subjectIdForSubject(subject) !== input.localSubjectId) return null; + if (subjectIdForSubject(subject.routeSubject) !== input.routeAuthority.localSubjectId) return null; + if (subject.taskClassId !== FABRIC_TASK_CLASS_ID + || subject.taskClassVersion !== FABRIC_TASK_CLASS_VERSION + || subject.taskFixtureDigest !== taskFixtureDigest() + || subject.verifierManifestDigest !== verifierManifestDigest() + || subject.fabricCompatibilityVersion !== FABRIC_COMPATIBILITY_VERSION + || subject.sandboxProfileDigest !== sandboxProfileDigest()) return null; + + const descriptor: PublicTaskSubjectV1 = Object.freeze({ + subjectKind: "task", + route: input.routeAuthority.descriptor, + taskClassId: FABRIC_TASK_CLASS_ID, + taskClassVersion: FABRIC_TASK_CLASS_VERSION, + verifierAuthorityId: FABRIC_VERIFIER_ID, + }); + const authority: PublicTaskAuthorityV1 = Object.freeze({ + localSubjectId: input.localSubjectId, + descriptor, + }); + taskAuthorities.add(authority); + return authority; +} + +export function isTrustedPublicTaskAuthority(value: unknown): value is PublicTaskAuthorityV1 { + return !!value && typeof value === "object" && taskAuthorities.has(value); +} diff --git a/src/lab/public/bundle.ts b/src/lab/public/bundle.ts new file mode 100644 index 000000000..262cbc522 --- /dev/null +++ b/src/lab/public/bundle.ts @@ -0,0 +1,160 @@ +import { jcsStringify } from "../digest"; +import { publicArtifactId, publicEvidenceId } from "./ids"; +import { projectPublicEvidenceRecord, type ProjectPublicEvidenceRecordInput } from "./project"; +import { + PUBLIC_EVIDENCE_BUNDLE_SCHEMA_VERSION, + PUBLIC_EXPORT_POLICY_VERSION, + type PublicArtifactV1, + type PublicEvidenceBundleUnsignedV1, + type PublicProjectionNotExportableReason, +} from "./types"; +import { PublicEvidenceValidationError, validatePublicEvidenceRecord } from "./validate"; + +const MAX_RECORDS = 256; +const MAX_ARTIFACTS = 16; +const MAX_ARTIFACT_BYTES = 256 * 1024; +const MAX_AGGREGATE_ARTIFACT_BYTES = 1024 * 1024; +const MAX_BUNDLE_BYTES = 2 * 1024 * 1024; +const MAX_STRING_BYTES = 4 * 1024; + +function isPlainObject(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +function assertClosedKeys(raw: Record, allowed: readonly string[], field: string): void { + const set = new Set(allowed); + for (const key of Object.keys(raw)) { + if (!set.has(key)) throw new PublicEvidenceValidationError("unknown_field", `${field}.${key}`); + } +} + +function assertDay(value: unknown, field: string): string { + if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value)) { + throw new PublicEvidenceValidationError("invalid_day", `${field} must be YYYY-MM-DD`); + } + const date = new Date(`${value}T00:00:00.000Z`); + if (!Number.isFinite(date.getTime()) || date.toISOString().slice(0, 10) !== value) { + throw new PublicEvidenceValidationError("invalid_day", `${field} is invalid`); + } + return value; +} + +function assertHex(value: unknown, field: string): string { + if (typeof value !== "string" || !/^[0-9a-f]{64}$/.test(value)) { + throw new PublicEvidenceValidationError("invalid_id", `${field} must be lowercase sha256 hex`); + } + return value; +} + +function decodeCanonicalBase64(value: unknown, field: string): Uint8Array { + if (typeof value !== "string" || new TextEncoder().encode(value).byteLength > Math.ceil(MAX_ARTIFACT_BYTES * 4 / 3) + 8) { + throw new PublicEvidenceValidationError("artifact_encoding", `${field} is invalid or oversized`); + } + if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) { + throw new PublicEvidenceValidationError("artifact_encoding", `${field} is not canonical base64`); + } + const bytes = Uint8Array.from(Buffer.from(value, "base64")); + if (Buffer.from(bytes).toString("base64") !== value) { + throw new PublicEvidenceValidationError("artifact_encoding", `${field} is not canonical base64`); + } + return bytes; +} + +function validateArtifact(raw: unknown, index: number): PublicArtifactV1 { + if (!isPlainObject(raw)) throw new PublicEvidenceValidationError("invalid_artifact", `artifacts[${index}]`); + assertClosedKeys(raw, ["artifactId", "mediaType", "byteCount", "contentBase64"], `artifacts[${index}]`); + const artifactId = assertHex(raw.artifactId, `artifacts[${index}].artifactId`); + if (raw.mediaType !== "application/json" && raw.mediaType !== "text/plain; charset=utf-8") { + throw new PublicEvidenceValidationError("artifact_media_type", `artifacts[${index}].mediaType`); + } + if (!Number.isSafeInteger(raw.byteCount) || (raw.byteCount as number) < 0 || (raw.byteCount as number) > MAX_ARTIFACT_BYTES) { + throw new PublicEvidenceValidationError("artifact_size", `artifacts[${index}].byteCount`); + } + const contentBase64 = typeof raw.contentBase64 === "string" ? raw.contentBase64 : ""; + const bytes = decodeCanonicalBase64(contentBase64, `artifacts[${index}].contentBase64`); + if (bytes.byteLength !== raw.byteCount) throw new PublicEvidenceValidationError("artifact_size", `artifacts[${index}] byteCount mismatch`); + if (publicArtifactId(bytes) !== artifactId) throw new PublicEvidenceValidationError("artifact_digest", `artifacts[${index}] id mismatch`); + return { artifactId, mediaType: raw.mediaType, byteCount: raw.byteCount as number, contentBase64 }; +} + +function bundlePayload(bundle: Omit): Omit { + return bundle; +} + +export function validatePublicEvidenceBundleUnsigned(raw: unknown): PublicEvidenceBundleUnsignedV1 { + if (!isPlainObject(raw)) throw new PublicEvidenceValidationError("invalid_bundle", "bundle must be object"); + assertClosedKeys(raw, ["schemaVersion", "exportPolicyVersion", "bundleId", "createdDayUtc", "records", "artifacts"], "bundle"); + if (raw.schemaVersion !== PUBLIC_EVIDENCE_BUNDLE_SCHEMA_VERSION) throw new PublicEvidenceValidationError("unsupported_version", "bundle schemaVersion"); + if (raw.exportPolicyVersion !== PUBLIC_EXPORT_POLICY_VERSION) throw new PublicEvidenceValidationError("unsupported_version", "bundle exportPolicyVersion"); + const bundleId = assertHex(raw.bundleId, "bundleId"); + const createdDayUtc = assertDay(raw.createdDayUtc, "createdDayUtc"); + if (!Array.isArray(raw.records) || raw.records.length > MAX_RECORDS) throw new PublicEvidenceValidationError("record_limit", "bundle records invalid or oversized"); + if (!Array.isArray(raw.artifacts) || raw.artifacts.length > MAX_ARTIFACTS) throw new PublicEvidenceValidationError("artifact_limit", "bundle artifacts invalid or oversized"); + + const records = raw.records.map(validatePublicEvidenceRecord); + const artifacts = raw.artifacts.map(validateArtifact); + if (new Set(records.map((record) => record.recordId)).size !== records.length) throw new PublicEvidenceValidationError("duplicate_record", "bundle record ids must be unique"); + if (new Set(artifacts.map((artifact) => artifact.artifactId)).size !== artifacts.length) throw new PublicEvidenceValidationError("duplicate_artifact", "bundle artifact ids must be unique"); + const artifactIds = new Set(artifacts.map((artifact) => artifact.artifactId)); + for (const record of records) { + for (const ref of record.artifactRefs ?? []) { + if (!artifactIds.has(ref)) throw new PublicEvidenceValidationError("unknown_artifact_ref", `${record.recordId} references absent artifact`); + } + } + const aggregateArtifactBytes = artifacts.reduce((sum, artifact) => sum + artifact.byteCount, 0); + if (aggregateArtifactBytes > MAX_AGGREGATE_ARTIFACT_BYTES) throw new PublicEvidenceValidationError("artifact_limit", "aggregate public artifact bytes exceeded"); + + const normalized: PublicEvidenceBundleUnsignedV1 = { + schemaVersion: PUBLIC_EVIDENCE_BUNDLE_SCHEMA_VERSION, + exportPolicyVersion: PUBLIC_EXPORT_POLICY_VERSION, + bundleId, + createdDayUtc, + records, + artifacts, + }; + const expected = publicEvidenceId("bundle", bundlePayload({ + schemaVersion: normalized.schemaVersion, + exportPolicyVersion: normalized.exportPolicyVersion, + createdDayUtc, + records, + artifacts, + })); + if (expected !== bundleId) throw new PublicEvidenceValidationError("bundle_digest", "bundleId does not match canonical public bytes"); + if (new TextEncoder().encode(jcsStringify(normalized)).byteLength > MAX_BUNDLE_BYTES) throw new PublicEvidenceValidationError("bundle_limit", "serialized public bundle exceeds limit"); + for (const value of [createdDayUtc, ...records.flatMap((record) => [record.suiteId, record.suiteVersion, record.scenarioId, record.scenarioVersion])]) { + if (new TextEncoder().encode(value).byteLength > MAX_STRING_BYTES) throw new PublicEvidenceValidationError("field_too_large", "public bundle string exceeds limit"); + } + return normalized; +} + +export function projectPublicEvidence(input: { + createdDayUtc: string; + records: ProjectPublicEvidenceRecordInput[]; + artifacts?: PublicArtifactV1[]; +}): { + bundle: PublicEvidenceBundleUnsignedV1; + excluded: Array<{ index: number; reason: PublicProjectionNotExportableReason }>; +} { + if (input.records.length > MAX_RECORDS) throw new PublicEvidenceValidationError("record_limit", "export scope exceeds public record limit"); + const records = [] as PublicEvidenceBundleUnsignedV1["records"]; + const excluded: Array<{ index: number; reason: PublicProjectionNotExportableReason }> = []; + input.records.forEach((recordInput, index) => { + const result = projectPublicEvidenceRecord(recordInput); + if (result.status === "exportable") records.push(result.record); + else excluded.push({ index, reason: result.reason }); + }); + records.sort((a, b) => a.recordId.localeCompare(b.recordId)); + const artifacts = [...(input.artifacts ?? [])].sort((a, b) => a.artifactId.localeCompare(b.artifactId)); + const withoutId: Omit = { + schemaVersion: PUBLIC_EVIDENCE_BUNDLE_SCHEMA_VERSION, + exportPolicyVersion: PUBLIC_EXPORT_POLICY_VERSION, + createdDayUtc: assertDay(input.createdDayUtc, "createdDayUtc"), + records, + artifacts, + }; + const bundle: PublicEvidenceBundleUnsignedV1 = { + bundleId: publicEvidenceId("bundle", bundlePayload(withoutId)), + ...withoutId, + }; + return { bundle: validatePublicEvidenceBundleUnsigned(bundle), excluded }; +} diff --git a/src/lab/public/community-authority.ts b/src/lab/public/community-authority.ts new file mode 100644 index 000000000..0755de7cc --- /dev/null +++ b/src/lab/public/community-authority.ts @@ -0,0 +1,34 @@ +import { loadCaseAuthority } from "../conformance/manifest"; +import { + FABRIC_SCENARIO_ID, + FABRIC_SCENARIO_VERSION, + FABRIC_SUITE_ID, + FABRIC_SUITE_VERSION, + FABRIC_VERIFIER_ID, +} from "../fabric/constants"; +import { PUBLIC_ROUTE_REGISTRY_V1, findPublicRouteRegistryEntry } from "./registry"; +import type { PublicEvidenceBundleV1, PublicEvidenceRecordV1, PublicRouteSubjectV1 } from "./types"; +import { PublicEvidenceValidationError } from "./validate"; + +function validateRouteAuthority(subject: PublicRouteSubjectV1): void { + if (subject.registryVersion !== PUBLIC_ROUTE_REGISTRY_V1.registryVersion || subject.registryDigest !== PUBLIC_ROUTE_REGISTRY_V1.manifestDigest) throw new PublicEvidenceValidationError("community_authority", "public route registry authority mismatch"); + if (!findPublicRouteRegistryEntry(subject.providerId, subject.modelId, subject.adapterFamily)) throw new PublicEvidenceValidationError("community_authority", "public route is not in reviewed registry authority"); +} +function validateScenarioAuthority(record: PublicEvidenceRecordV1): void { + if (record.evidenceLayer === "task_effectiveness") { + if (record.suiteId !== FABRIC_SUITE_ID || record.suiteVersion !== FABRIC_SUITE_VERSION || record.scenarioId !== FABRIC_SCENARIO_ID || record.scenarioVersion !== FABRIC_SCENARIO_VERSION || record.subject.subjectKind !== "task" || record.subject.taskClassId !== FABRIC_SCENARIO_ID || record.subject.taskClassVersion !== FABRIC_SCENARIO_VERSION || record.subject.verifierAuthorityId !== FABRIC_VERIFIER_ID) throw new PublicEvidenceValidationError("community_authority", "task scenario/verifier authority mismatch"); + validateRouteAuthority(record.subject.route); + return; + } + const authority = loadCaseAuthority(); + const caseRecord = authority.cases.find((candidate) => candidate.id === record.scenarioId); + if (!caseRecord || caseRecord.suite !== record.suiteId || record.scenarioVersion !== String(authority.manifestDefaults.version) || record.suiteVersion !== String(authority.manifestDefaults.suiteVersion)) throw new PublicEvidenceValidationError("community_authority", "scenario/suite authority mismatch"); + if (record.evidenceLayer === "live_route_compatibility") { + if (record.subject.subjectKind !== "route") throw new PublicEvidenceValidationError("community_authority", "live route subject mismatch"); + validateRouteAuthority(record.subject); + } +} +export function validateCommunityEvidenceAuthorities(bundle: PublicEvidenceBundleV1): PublicEvidenceBundleV1 { + for (const record of bundle.records) validateScenarioAuthority(record); + return bundle; +} diff --git a/src/lab/public/community.ts b/src/lab/public/community.ts new file mode 100644 index 000000000..cccee2433 --- /dev/null +++ b/src/lab/public/community.ts @@ -0,0 +1,367 @@ +import { + closeSync, + constants as fsConstants, + fstatSync, + fsyncSync, + lstatSync, + openSync, + readdirSync, + readFileSync, + writeSync, +} from "node:fs"; +import { join } from "node:path"; +import { jcsStringify } from "../digest"; +import { ensureLabDirs, labCommunityDir } from "../paths"; +import { validateCommunityEvidenceAuthorities } from "./community-authority"; +import { verifyPublicEvidenceRevocation } from "./revocation"; +import { verifyPublicEvidenceBundle } from "./signing"; +import type { + CommunityEvidenceSummaryV1, + PublicEvidenceBundleV1, + PublicEvidenceRevocationV1, +} from "./types"; +import { PublicEvidenceValidationError } from "./validate"; + +const MAX_IMPORT_BYTES = 2 * 1024 * 1024; +const MAX_CACHE_FILES = 4096; +const MAX_DEPTH = 8; +const MAX_OBJECT_KEYS = 64; +const MAX_ARRAY_ELEMENTS = 512; +const MAX_GENERIC_STRING_BYTES = 384 * 1024; +const O_NOFOLLOW = (fsConstants as { O_NOFOLLOW?: number }).O_NOFOLLOW ?? 0; +const COMMUNITY_BUNDLE_FILE_RE = /^bundle-([0-9a-f]{64})-([0-9a-f]{64})\.json$/; +const COMMUNITY_REVOCATION_FILE_RE = /^revocation-([0-9a-f]{64})\.json$/; + +function assertId(value: string): string { + if (!/^[0-9a-f]{64}$/.test(value)) { + throw new PublicEvidenceValidationError("community_id", "community object id invalid"); + } + return value; +} + +function scanStructure(value: unknown, depth = 0): void { + if (depth > MAX_DEPTH) { + throw new PublicEvidenceValidationError("community_depth", "community JSON nesting depth exceeded"); + } + if (typeof value === "string") { + if (new TextEncoder().encode(value).byteLength > MAX_GENERIC_STRING_BYTES || value.includes("\0")) { + throw new PublicEvidenceValidationError("community_string", "community string invalid or oversized"); + } + return; + } + if (Array.isArray(value)) { + if (value.length > MAX_ARRAY_ELEMENTS) { + throw new PublicEvidenceValidationError("community_array", "community array bound exceeded"); + } + for (const item of value) scanStructure(item, depth + 1); + return; + } + if (value && typeof value === "object") { + const keys = Object.keys(value); + if (keys.length > MAX_OBJECT_KEYS) { + throw new PublicEvidenceValidationError("community_object", "community object key bound exceeded"); + } + for (const key of keys) { + if (new TextEncoder().encode(key).byteLength > 4096) { + throw new PublicEvidenceValidationError("community_key", "community key oversized"); + } + scanStructure((value as Record)[key], depth + 1); + } + } +} + +function boundedInput(raw: unknown): unknown { + if (raw instanceof Uint8Array || typeof raw === "string") { + const bytes = typeof raw === "string" ? Buffer.from(raw, "utf8") : Buffer.from(raw); + if (bytes.byteLength > MAX_IMPORT_BYTES) { + throw new PublicEvidenceValidationError("community_size", "community import exceeds 2 MiB"); + } + let parsed: unknown; + try { + parsed = JSON.parse(bytes.toString("utf8")); + } catch { + throw new PublicEvidenceValidationError("community_json", "community import is not valid JSON"); + } + scanStructure(parsed); + return parsed; + } + scanStructure(raw); + const bytes = Buffer.from(jcsStringify(raw), "utf8"); + if (bytes.byteLength > MAX_IMPORT_BYTES) { + throw new PublicEvidenceValidationError("community_size", "community import exceeds 2 MiB"); + } + return raw; +} + +function bundleObjectPath(publisherKeyId: string, bundleId: string, configDir?: string): string { + return join( + labCommunityDir(configDir), + `bundle-${assertId(publisherKeyId)}-${assertId(bundleId)}.json`, + ); +} + +function revocationObjectPath(revocationId: string, configDir?: string): string { + return join(labCommunityDir(configDir), `revocation-${assertId(revocationId)}.json`); +} + +function assertRegular(path: string, fd: number): void { + const stats = fstatSync(fd); + if (!stats.isFile() || stats.isSymbolicLink() || stats.nlink !== 1 || stats.size > MAX_IMPORT_BYTES) { + throw new PublicEvidenceValidationError("community_unsafe_target", `unsafe community file: ${path}`); + } +} + +function readBounded(path: string): Buffer { + const before = lstatSync(path); + if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1 || before.size > MAX_IMPORT_BYTES) { + throw new PublicEvidenceValidationError("community_unsafe_target", "unsafe community path"); + } + const fd = openSync(path, fsConstants.O_RDONLY | O_NOFOLLOW); + try { + assertRegular(path, fd); + const bytes = readFileSync(fd); + if (bytes.byteLength > MAX_IMPORT_BYTES) { + throw new PublicEvidenceValidationError("community_size", "community file exceeds bound"); + } + return bytes; + } finally { + closeSync(fd); + } +} + +function writeAll(fd: number, bytes: Uint8Array): void { + let offset = 0; + while (offset < bytes.byteLength) { + const count = writeSync(fd, bytes, offset, bytes.byteLength - offset); + if (count <= 0) { + throw new PublicEvidenceValidationError("community_write", "community write made no progress"); + } + offset += count; + } +} + +function persistAt(path: string, kind: "bundle" | "revocation", value: unknown): { path: string; created: boolean } { + const bytes = Buffer.from(jcsStringify(value), "utf8"); + if (bytes.byteLength > MAX_IMPORT_BYTES) { + throw new PublicEvidenceValidationError("community_size", "community object exceeds bound"); + } + let fd: number | null = null; + try { + fd = openSync(path, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | O_NOFOLLOW, 0o600); + writeAll(fd, bytes); + fsyncSync(fd); + assertRegular(path, fd); + closeSync(fd); + fd = null; + return { path, created: true }; + } catch (error) { + if (fd !== null) closeSync(fd); + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + if (!readBounded(path).equals(bytes)) { + throw new PublicEvidenceValidationError( + "community_conflict", + `${kind} identity already exists with different bytes`, + ); + } + return { path, created: false }; + } +} + +function readJson(path: string): unknown { + try { + return JSON.parse(readBounded(path).toString("utf8")); + } catch (error) { + if (error instanceof PublicEvidenceValidationError) throw error; + throw new PublicEvidenceValidationError("community_json", "stored community object is invalid JSON"); + } +} + +function files(configDir?: string): string[] { + ensureLabDirs(configDir); + const names = readdirSync(labCommunityDir(configDir)); + if (names.length > MAX_CACHE_FILES) { + throw new PublicEvidenceValidationError("community_cache_bound", "community cache file bound exceeded"); + } + return names.sort(); +} + +function readVerifiedBundleAt(path: string): PublicEvidenceBundleV1 { + const raw = readJson(path); + const verified = verifyPublicEvidenceBundle(raw); + if (verified.status !== "cryptographically_valid") { + throw new PublicEvidenceValidationError( + verified.status, + verified.detail ?? "stored community bundle invalid", + ); + } + return validateCommunityEvidenceAuthorities(verified.bundle); +} + +export function importCommunityEvidenceBundle( + raw: unknown, + configDir?: string, +): { + created: boolean; + status: "cryptographically_valid"; + bundleId: string; + publisherKeyId: string; + path: string; +} { + const parsed = boundedInput(raw); + const verified = verifyPublicEvidenceBundle(parsed); + if (verified.status !== "cryptographically_valid") { + throw new PublicEvidenceValidationError( + verified.status, + verified.detail ?? "community bundle verification failed", + ); + } + validateCommunityEvidenceAuthorities(verified.bundle); + ensureLabDirs(configDir); + const stored = persistAt( + bundleObjectPath(verified.bundle.publisher.keyId, verified.bundle.bundleId, configDir), + "bundle", + verified.bundle, + ); + return { + ...stored, + status: "cryptographically_valid", + bundleId: verified.bundle.bundleId, + publisherKeyId: verified.bundle.publisher.keyId, + }; +} + +export function readCommunityEvidenceBundleForPublisher( + bundleId: string, + publisherKeyId: string, + configDir?: string, +): PublicEvidenceBundleV1 { + const bundle = readVerifiedBundleAt(bundleObjectPath(publisherKeyId, bundleId, configDir)); + if (bundle.bundleId !== bundleId || bundle.publisher.keyId !== publisherKeyId) { + throw new PublicEvidenceValidationError( + "community_identity_mismatch", + "stored community bundle does not match filename identity", + ); + } + return bundle; +} + +export function readCommunityEvidenceBundle(bundleId: string, configDir?: string): PublicEvidenceBundleV1 { + assertId(bundleId); + const matches = files(configDir).flatMap((name) => { + const match = COMMUNITY_BUNDLE_FILE_RE.exec(name); + return match && match[2] === bundleId ? [{ name, publisherKeyId: match[1]! }] : []; + }); + if (matches.length === 0) { + throw new PublicEvidenceValidationError("community_missing", "community bundle not found"); + } + if (matches.length !== 1) { + throw new PublicEvidenceValidationError( + "community_ambiguous_bundle", + "bundleId is shared by multiple publishers; publisherKeyId is required", + ); + } + return readCommunityEvidenceBundleForPublisher(bundleId, matches[0]!.publisherKeyId, configDir); +} + +function allCommunityBundles(configDir?: string): PublicEvidenceBundleV1[] { + return files(configDir).flatMap((name) => { + const match = COMMUNITY_BUNDLE_FILE_RE.exec(name); + if (!match) return []; + return [readCommunityEvidenceBundleForPublisher(match[2]!, match[1]!, configDir)]; + }); +} + +function findTargetBundle(revocation: unknown, configDir?: string): PublicEvidenceBundleV1 { + if (!revocation || typeof revocation !== "object") { + throw new PublicEvidenceValidationError("revocation_target", "revocation target metadata unavailable"); + } + const raw = revocation as { + publisher?: { keyId?: unknown }; + targets?: Array<{ kind?: unknown; id?: unknown }>; + }; + if (!Array.isArray(raw.targets) || typeof raw.publisher?.keyId !== "string") { + throw new PublicEvidenceValidationError("revocation_target", "revocation targets or publisher unavailable"); + } + const publisherKeyId = assertId(raw.publisher.keyId); + const candidates = allCommunityBundles(configDir).filter( + (bundle) => bundle.publisher.keyId === publisherKeyId, + ); + const fullyMatching = candidates.filter((bundle) => raw.targets!.every((target) => + target.kind === "bundle" + ? target.id === bundle.bundleId + : target.kind === "record" + && bundle.records.some((record) => record.recordId === target.id), + )); + if (fullyMatching.length !== 1) { + throw new PublicEvidenceValidationError( + "revocation_target", + "revocation targets must resolve to one verified bundle for the same publisher", + ); + } + return fullyMatching[0]!; +} + +export function importCommunityEvidenceRevocation( + raw: unknown, + configDir?: string, +): { created: boolean; status: "cryptographically_valid"; revocationId: string; path: string } { + const parsed = boundedInput(raw); + const targetBundle = findTargetBundle(parsed, configDir); + const verified = verifyPublicEvidenceRevocation(parsed, targetBundle); + if (verified.status !== "cryptographically_valid") { + throw new PublicEvidenceValidationError( + verified.status, + verified.detail ?? "community revocation verification failed", + ); + } + ensureLabDirs(configDir); + const stored = persistAt( + revocationObjectPath(verified.revocation.revocationId, configDir), + "revocation", + verified.revocation, + ); + return { + ...stored, + status: "cryptographically_valid", + revocationId: verified.revocation.revocationId, + }; +} + +function verifiedRevocationsForBundle( + bundle: PublicEvidenceBundleV1, + configDir?: string, +): PublicEvidenceRevocationV1[] { + const result: PublicEvidenceRevocationV1[] = []; + for (const name of files(configDir)) { + if (!COMMUNITY_REVOCATION_FILE_RE.test(name)) continue; + const raw = readJson(join(labCommunityDir(configDir), name)); + const verified = verifyPublicEvidenceRevocation(raw, bundle); + if (verified.status === "cryptographically_valid") result.push(verified.revocation); + } + return result; +} + +export function listCommunityEvidence(configDir?: string): CommunityEvidenceSummaryV1[] { + return allCommunityBundles(configDir).map((bundle) => { + const revoked = new Set(); + for (const revocation of verifiedRevocationsForBundle(bundle, configDir)) { + if (revocation.targets.some( + (target) => target.kind === "bundle" && target.id === bundle.bundleId, + )) { + for (const record of bundle.records) revoked.add(record.recordId); + } + for (const target of revocation.targets) { + if (target.kind === "record") revoked.add(target.id); + } + } + return { + trustClass: "community_untrusted_v1" as const, + status: "cryptographically_valid" as const, + bundleId: bundle.bundleId, + publisherKeyId: bundle.publisher.keyId, + activeRecordCount: bundle.records.filter((record) => !revoked.has(record.recordId)).length, + revokedRecordCount: bundle.records.filter((record) => revoked.has(record.recordId)).length, + }; + }).sort((a, b) => + a.bundleId.localeCompare(b.bundleId) || a.publisherKeyId.localeCompare(b.publisherKeyId)); +} diff --git a/src/lab/public/ids.ts b/src/lab/public/ids.ts new file mode 100644 index 000000000..9c34ff4ea --- /dev/null +++ b/src/lab/public/ids.ts @@ -0,0 +1,25 @@ +import { domainHash, jcsStringify } from "../digest"; + +const PUBLIC_ID_DOMAINS = { + subject: "ocx-lab:public-subject:v1", + record: "ocx-lab:public-record:v1", + bundle: "ocx-lab:public-bundle:v1", + artifact: "ocx-lab:public-artifact:v1", + publisher: "ocx-lab:public-publisher:v1", + revocation: "ocx-lab:public-revocation:v1", + bundleDigest: "ocx-lab:public-bundle-digest:v1", +} as const; + +export type PublicEvidenceIdKind = "subject" | "record" | "bundle" | "publisher" | "revocation"; +export function publicEvidenceId(kind: PublicEvidenceIdKind, payload: unknown): string { + return domainHash(PUBLIC_ID_DOMAINS[kind], jcsStringify(payload)); +} +export function publicArtifactId(bytes: Uint8Array): string { + return domainHash(PUBLIC_ID_DOMAINS.artifact, bytes); +} +export function publicPublisherKeyId(publicKeyDer: Uint8Array): string { + return domainHash(PUBLIC_ID_DOMAINS.publisher, publicKeyDer); +} +export function publicBundleDigest(payload: unknown): string { + return domainHash(PUBLIC_ID_DOMAINS.bundleDigest, jcsStringify(payload)); +} diff --git a/src/lab/public/index.ts b/src/lab/public/index.ts new file mode 100644 index 000000000..3033898f0 --- /dev/null +++ b/src/lab/public/index.ts @@ -0,0 +1,17 @@ +export * from "./types"; +export * from "./registry"; +export * from "./ids"; +export * from "./authority"; +export * from "./validate"; +export * from "./project"; +export * from "./bundle"; +export { + getOrCreatePublicPublisher, + signPublicEvidenceBundle, + verifyPublicEvidenceBundle, +} from "./signing"; +export * from "./store"; +export * from "./revocation"; +export * from "./community-authority"; +export * from "./community"; +export * from "./operator"; diff --git a/src/lab/public/operator.ts b/src/lab/public/operator.ts new file mode 100644 index 000000000..8cefd9049 --- /dev/null +++ b/src/lab/public/operator.ts @@ -0,0 +1,308 @@ +import { lstatSync, readFileSync } from "node:fs"; +import { replayLabLedger } from "../ledger/store"; +import { labLedgerPath } from "../paths"; +import { + queryLabEventById, + queryLabVerdicts, +} from "../query"; +import type { ObservationEvent } from "../events/types"; +import { + projectPublicEvidence, +} from "./bundle"; +import type { ProjectPublicEvidenceRecordInput } from "./project"; +import { + getOrCreatePublicPublisher, + signPublicEvidenceBundle, + verifyPublicEvidenceBundle, +} from "./signing"; +import { writePublicEvidenceBundle } from "./store"; +import { + importCommunityEvidenceBundle, + listCommunityEvidence, +} from "./community"; +import type { + PublicBundleVerificationResult, + PublicEvidenceBundleUnsignedV1, + PublicEvidenceBundleV1, + PublicProjectionNotExportableReason, +} from "./types"; +import { PublicEvidenceValidationError } from "./validate"; + +const MAX_OPERATOR_EVENTS = 256; +const MAX_PUBLIC_FILE_BYTES = 2 * 1024 * 1024; + +export type PublicOperatorExclusionReason = + | PublicProjectionNotExportableReason + | "event_not_found" + | "not_observation" + | "event_excluded" + | "no_canonical_verdict"; + +export interface PublicOperatorExclusionV1 { + eventId: string; + reason: PublicOperatorExclusionReason; +} + +export interface LocalPublicPreviewV1 { + bundle: PublicEvidenceBundleUnsignedV1; + excluded: PublicOperatorExclusionV1[]; +} + +export interface LocalPublicExportV1 { + bundle: PublicEvidenceBundleV1; + stored: { + path: string; + created: boolean; + }; + excluded: PublicOperatorExclusionV1[]; +} + +export type PublicVerificationSummaryV1 = + | { + status: "cryptographically_valid"; + bundleId: string; + publisherKeyId: string; + locallyVerified: false; + } + | { + status: Exclude; + locallyVerified: false; + detail?: string; + }; + +function assertOperatorEventIds(eventIds: readonly string[]): string[] { + if (eventIds.length === 0 || eventIds.length > MAX_OPERATOR_EVENTS) { + throw new PublicEvidenceValidationError( + "public_selection_limit", + `public evidence selection must contain 1..${MAX_OPERATOR_EVENTS} event ids`, + ); + } + const unique: string[] = []; + const seen = new Set(); + for (const eventId of eventIds) { + if (!/^[0-9a-f]{64}$/.test(eventId)) { + throw new PublicEvidenceValidationError( + "public_selection_event_id", + "public evidence event ids must be lowercase sha256 hex", + ); + } + if (seen.has(eventId)) continue; + seen.add(eventId); + unique.push(eventId); + } + return unique; +} + +function utcDay(timestamp: number): string { + const date = new Date(timestamp); + if (!Number.isFinite(date.getTime())) { + throw new PublicEvidenceValidationError( + "public_selection_time", + "selected observation has an invalid completion timestamp", + ); + } + return date.toISOString().slice(0, 10); +} + +function canonicalVerdictForObservation( + observation: ObservationEvent, + configDir?: string, +): ProjectPublicEvidenceRecordInput["verdict"] | null { + const page = queryLabVerdicts( + { + subjectId: observation.subjectId, + layer: observation.evidenceLayer, + suiteId: observation.suiteId, + }, + undefined, + 200, + configDir, + ); + const verdict = page.items.find((row) => + row.suiteVersion === observation.suiteVersion + && row.contributingEventIds.includes(observation.eventId), + ); + return verdict?.verdict ?? null; +} + +/** + * Build a public-safe unsigned bundle from explicitly selected local Lab events. + * + * This function is read-only. It does not create a publisher key, write an + * export, import community evidence, contact a provider, or perform network IO. + * Live-route/task records remain fail-closed unless a future trusted caller can + * provide an internally-derived public authority; this operator currently never + * accepts authority assertions from user input. + */ +export function previewLocalPublicEvidence( + input: { eventIds: readonly string[] }, + configDir?: string, +): LocalPublicPreviewV1 { + const eventIds = assertOperatorEventIds(input.eventIds); + const replay = replayLabLedger(labLedgerPath(configDir)); + const byId = new Map(replay.events.map((event) => [event.eventId, event] as const)); + const projectInputs: ProjectPublicEvidenceRecordInput[] = []; + const projectEventIds: string[] = []; + const excluded: PublicOperatorExclusionV1[] = []; + let latestObservationCompletedAt: number | null = null; + + for (const eventId of eventIds) { + const event = byId.get(eventId); + if (!event) { + excluded.push({ eventId, reason: "event_not_found" }); + continue; + } + if (event.eventKind !== "observation") { + excluded.push({ eventId, reason: "not_observation" }); + continue; + } + + latestObservationCompletedAt = Math.max( + latestObservationCompletedAt ?? event.completedAt, + event.completedAt, + ); + + const projectedEvent = queryLabEventById(eventId, configDir); + if (!projectedEvent) { + excluded.push({ eventId, reason: "event_not_found" }); + continue; + } + if (projectedEvent.excluded) { + excluded.push({ eventId, reason: "event_excluded" }); + continue; + } + + const verdict = canonicalVerdictForObservation(event, configDir); + if (!verdict) { + excluded.push({ eventId, reason: "no_canonical_verdict" }); + continue; + } + + projectInputs.push({ observation: event, verdict }); + projectEventIds.push(eventId); + } + + if (latestObservationCompletedAt === null) { + throw new PublicEvidenceValidationError( + "public_selection_empty", + "public evidence selection contains no observation events", + ); + } + + const projected = projectPublicEvidence({ + createdDayUtc: utcDay(latestObservationCompletedAt), + records: projectInputs, + }); + for (const row of projected.excluded) { + excluded.push({ + eventId: projectEventIds[row.index]!, + reason: row.reason, + }); + } + + return { + bundle: projected.bundle, + excluded, + }; +} + +/** Explicit local export. No remote publish transport exists in CL-10.1..10.4. */ +export function exportLocalPublicEvidence( + input: { eventIds: readonly string[] }, + configDir?: string, +): LocalPublicExportV1 { + const preview = previewLocalPublicEvidence(input, configDir); + if (preview.bundle.records.length === 0) { + throw new PublicEvidenceValidationError( + "public_export_empty", + "selected events produced no exportable public evidence records", + ); + } + const signer = getOrCreatePublicPublisher(configDir); + const bundle = signPublicEvidenceBundle(preview.bundle, signer); + const stored = writePublicEvidenceBundle(bundle, configDir); + return { bundle, stored, excluded: preview.excluded }; +} + +export function summarizePublicEvidenceVerification(raw: unknown): PublicVerificationSummaryV1 { + const verified = verifyPublicEvidenceBundle(raw); + if (verified.status !== "cryptographically_valid") { + return { + status: verified.status, + locallyVerified: false, + ...(verified.detail ? { detail: verified.detail } : {}), + }; + } + return { + status: "cryptographically_valid", + bundleId: verified.bundle.bundleId, + publisherKeyId: verified.bundle.publisher.keyId, + locallyVerified: false, + }; +} + +function readBoundedPublicFile(path: string): Buffer { + const stats = lstatSync(path); + if (!stats.isFile() || stats.isSymbolicLink() || stats.nlink !== 1) { + throw new PublicEvidenceValidationError( + "public_file_unsafe", + "public evidence input must be a regular non-symlink file", + ); + } + if (stats.size > MAX_PUBLIC_FILE_BYTES) { + throw new PublicEvidenceValidationError( + "public_file_too_large", + "public evidence input exceeds 2 MiB", + ); + } + const bytes = readFileSync(path); + if (bytes.byteLength > MAX_PUBLIC_FILE_BYTES) { + throw new PublicEvidenceValidationError( + "public_file_too_large", + "public evidence input exceeds 2 MiB", + ); + } + return bytes; +} + +function parsePublicFile(path: string): unknown { + const bytes = readBoundedPublicFile(path); + try { + return JSON.parse(bytes.toString("utf8")); + } catch { + throw new PublicEvidenceValidationError( + "public_file_json", + "public evidence input is not valid JSON", + ); + } +} + +export function verifyPublicEvidenceFile(path: string): PublicVerificationSummaryV1 { + return summarizePublicEvidenceVerification(parsePublicFile(path)); +} + +export function importCommunityEvidenceFile(path: string, configDir?: string) { + const imported = importCommunityEvidenceBundle(readBoundedPublicFile(path), configDir); + return { + ...imported, + trustClass: "community_untrusted_v1" as const, + locallyVerified: false as const, + }; +} + +export function importCommunityEvidenceValue(raw: unknown, configDir?: string) { + const imported = importCommunityEvidenceBundle(raw, configDir); + return { + ...imported, + trustClass: "community_untrusted_v1" as const, + locallyVerified: false as const, + }; +} + +export function listCommunityEvidenceContext(configDir?: string) { + return { + evidence: listCommunityEvidence(configDir), + trustClass: "community_untrusted_v1" as const, + locallyVerified: false as const, + }; +} diff --git a/src/lab/public/project.ts b/src/lab/public/project.ts new file mode 100644 index 000000000..30c696d0e --- /dev/null +++ b/src/lab/public/project.ts @@ -0,0 +1,106 @@ +import type { ObservationEvent, ProtocolSubjectV1 } from "../events/types"; +import { + isTrustedPublicRouteAuthority, + isTrustedPublicTaskAuthority, + toPublicAdapterFamily, +} from "./authority"; +import { publicEvidenceId } from "./ids"; +import type { + PublicEvidenceRecordV1, + PublicEvidenceSubjectV1, + PublicProjectionResult, + PublicProtocolSubjectV1, + PublicRouteAuthorityV1, + PublicTaskAuthorityV1, +} from "./types"; +import { isPublicIncidentRef, validatePublicEvidenceRecord } from "./validate"; + +function publicProtocolSubject(subject: ProtocolSubjectV1): PublicProtocolSubjectV1 | null { + const adapterFamily = toPublicAdapterFamily(subject.effectiveAdapter); + const inboundProtocol = toPublicAdapterFamily(subject.inboundProtocol); + const upstreamProtocol = toPublicAdapterFamily(subject.upstreamProtocol); + if (!adapterFamily || !inboundProtocol || !upstreamProtocol) return null; + return { + subjectKind: "protocol", + adapterFamily, + inboundProtocol, + upstreamProtocol, + surface: subject.surface, + compatibilityVersion: subject.opencodexCompatibilityVersion, + }; +} + +function observedDayUtc(observation: ObservationEvent): string { + const value = Number.isFinite(observation.completedAt) ? observation.completedAt : observation.recordedAt; + const date = new Date(value); + if (!Number.isFinite(date.getTime())) throw new Error("invalid observation timestamp"); + return date.toISOString().slice(0, 10); +} + +export interface ProjectPublicEvidenceRecordInput { + observation: ObservationEvent; + verdict: PublicEvidenceRecordV1["verdict"]; + incidentRefs?: string[]; + routeAuthority?: PublicRouteAuthorityV1; + taskAuthority?: PublicTaskAuthorityV1; +} + +export function projectPublicEvidenceRecord(input: ProjectPublicEvidenceRecordInput): PublicProjectionResult { + const observation = input.observation; + let subject: PublicEvidenceSubjectV1; + + if (observation.evidenceLayer === "protocol_conformance") { + if (observation.subject.subjectKind !== "protocol") { + return { status: "not_exportable", reason: "unsupported_public_adapter" }; + } + const projected = publicProtocolSubject(observation.subject); + if (!projected) return { status: "not_exportable", reason: "unsupported_public_adapter" }; + subject = projected; + } else if (observation.evidenceLayer === "live_route_compatibility") { + if (observation.subject.subjectKind !== "route" + || !isTrustedPublicRouteAuthority(input.routeAuthority) + || input.routeAuthority.localSubjectId !== observation.subjectId) { + return { status: "not_exportable", reason: "private_route_identity" }; + } + subject = input.routeAuthority.descriptor; + } else { + if (observation.subject.subjectKind !== "task" + || !isTrustedPublicTaskAuthority(input.taskAuthority) + || input.taskAuthority.localSubjectId !== observation.subjectId) { + return { status: "not_exportable", reason: "task_authority_unavailable" }; + } + subject = input.taskAuthority.descriptor; + } + + let incidentRefs: Array<{ corpusId: string }> | undefined; + if (input.incidentRefs !== undefined) { + if (input.incidentRefs.some((value) => !isPublicIncidentRef(value))) { + return { status: "not_exportable", reason: "invalid_public_incident_ref" }; + } + incidentRefs = [...new Set(input.incidentRefs)].sort().map((corpusId) => ({ corpusId })); + } + + const subjectId = publicEvidenceId("subject", subject); + const recordWithoutId: Omit = { + subjectId, + evidenceLayer: observation.evidenceLayer, + suiteId: observation.suiteId, + suiteVersion: observation.suiteVersion, + scenarioId: observation.scenarioId, + scenarioVersion: observation.scenarioVersion, + verdict: input.verdict, + observedDayUtc: observedDayUtc(observation), + subject, + assertions: observation.assertions.slice(0, 64).map((assertion) => ({ + id: assertion.id, + required: assertion.required, + passed: assertion.passed, + })), + ...(incidentRefs && incidentRefs.length > 0 ? { incidentRefs } : {}), + }; + const record: PublicEvidenceRecordV1 = { + recordId: publicEvidenceId("record", recordWithoutId), + ...recordWithoutId, + }; + return { status: "exportable", record: validatePublicEvidenceRecord(record) }; +} diff --git a/src/lab/public/purge.ts b/src/lab/public/purge.ts new file mode 100644 index 000000000..e4d845a58 --- /dev/null +++ b/src/lab/public/purge.ts @@ -0,0 +1,158 @@ +import { createPrivateKey, createPublicKey } from "node:crypto"; +import { + closeSync, + constants as fsConstants, + existsSync, + fstatSync, + lstatSync, + openSync, + readFileSync, + readdirSync, + rmSync, + unlinkSync, +} from "node:fs"; +import { join } from "node:path"; +import { + ensureLabDirs, + labCommunityDir, + labPublicExportsDir, + labPublicPublisherKeyPath, +} from "../paths"; +import { readCommunityEvidenceBundleForPublisher } from "./community"; +import { publicPublisherKeyId } from "./ids"; +import { readPublicEvidenceBundle } from "./store"; +import { PublicEvidenceValidationError } from "./validate"; + +const O_NOFOLLOW = (fsConstants as { O_NOFOLLOW?: number }).O_NOFOLLOW ?? 0; +const MAX_PRIVATE_KEY_BYTES = 8 * 1024; +const EXPORT_FILE_RE = /^([0-9a-f]{64})\.json$/; +const COMMUNITY_BUNDLE_RE = /^bundle-([0-9a-f]{64})-([0-9a-f]{64})\.json$/; + +function readExistingPublisherKeyId(configDir?: string): string | null { + const path = labPublicPublisherKeyPath(configDir); + if (!existsSync(path)) return null; + + const before = lstatSync(path); + if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1 || before.size > MAX_PRIVATE_KEY_BYTES) { + throw new PublicEvidenceValidationError( + "publisher_key_unsafe", + "cannot establish local publisher provenance from an unsafe publisher key file", + ); + } + if (process.platform !== "win32" && (before.mode & 0o777) !== 0o600) { + throw new PublicEvidenceValidationError( + "publisher_key_unsafe", + "cannot establish local publisher provenance from an incorrectly-permissioned publisher key file", + ); + } + + const fd = openSync(path, fsConstants.O_RDONLY | O_NOFOLLOW); + try { + const stats = fstatSync(fd); + if (!stats.isFile() || stats.isSymbolicLink() || stats.nlink !== 1 || stats.size > MAX_PRIVATE_KEY_BYTES) { + throw new PublicEvidenceValidationError( + "publisher_key_unsafe", + "publisher key changed while establishing local public-evidence provenance", + ); + } + const pem = readFileSync(fd, { encoding: "utf8" }); + if (Buffer.byteLength(pem) > MAX_PRIVATE_KEY_BYTES || !pem.includes("BEGIN PRIVATE KEY")) { + throw new PublicEvidenceValidationError("publisher_key_invalid", "local publisher key encoding is invalid"); + } + const privateKey = createPrivateKey(pem); + if (privateKey.asymmetricKeyType !== "ed25519") { + throw new PublicEvidenceValidationError("publisher_key_invalid", "local publisher key is not Ed25519"); + } + const privatePem = privateKey.export({ type: "pkcs8", format: "pem" }); + const publicKey = createPublicKey(privatePem); + const publicKeyDer = publicKey.export({ type: "spki", format: "der" }); + return publicPublisherKeyId(publicKeyDer); + } finally { + closeSync(fd); + } +} + +function publicIdentity(publisherKeyId: string, bundleId: string): string { + return `${publisherKeyId}:${bundleId}`; +} + +function localExportIdentities(configDir?: string): Set { + const identities = new Set(); + for (const entry of readdirSync(labPublicExportsDir(configDir), { withFileTypes: true })) { + const match = EXPORT_FILE_RE.exec(entry.name); + if (!match) continue; + const bundleId = match[1]!; + const bundle = readPublicEvidenceBundle(bundleId, configDir); + identities.add(publicIdentity(bundle.publisher.keyId, bundle.bundleId)); + } + return identities; +} + +function purgeAllExports(configDir?: string): number { + const exportsDir = labPublicExportsDir(configDir); + let deleted = 0; + for (const entry of readdirSync(exportsDir, { withFileTypes: true })) { + rmSync(join(exportsDir, entry.name), { recursive: entry.isDirectory(), force: true }); + deleted++; + } + return deleted; +} + +/** + * CL-10 sensitive-purge bridge. + * + * The pre-CL-10 purge contract already removes every local export when the + * `export` action is selected. CL-10 additionally removes quarantined community + * bundle copies that can be proven locally-originated by the exact + * `(publisherKeyId, bundleId)` pair or by this installation's existing + * publisher key. + * + * Third-party community evidence is deliberately preserved, including an + * independently signed copy with the same content `bundleId`. When local + * provenance cannot be inspected safely, this function fails closed rather than + * pretending the purge completed. + */ +export function purgeLocalPublicEvidenceCopies(configDir?: string): { + deletedExports: number; + deletedCommunityBundles: number; +} { + ensureLabDirs(configDir); + const exportedIdentities = localExportIdentities(configDir); + const localPublisherKeyId = readExistingPublisherKeyId(configDir); + const communityDir = labCommunityDir(configDir); + + let deletedCommunityBundles = 0; + for (const entry of readdirSync(communityDir, { withFileTypes: true })) { + const match = COMMUNITY_BUNDLE_RE.exec(entry.name); + if (!match) continue; + const publisherKeyId = match[1]!; + const bundleId = match[2]!; + const locallyOriginated = exportedIdentities.has(publicIdentity(publisherKeyId, bundleId)) + || publisherKeyId === localPublisherKeyId; + if (!locallyOriginated) continue; + + const bundle = readCommunityEvidenceBundleForPublisher(bundleId, publisherKeyId, configDir); + if (bundle.publisher.keyId !== publisherKeyId || bundle.bundleId !== bundleId) { + throw new PublicEvidenceValidationError( + "community_identity_mismatch", + `community bundle identity changed while purging: ${entry.name}`, + ); + } + + const path = join(communityDir, entry.name); + const before = lstatSync(path); + if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1) { + throw new PublicEvidenceValidationError( + "community_unsafe_target", + `refusing to purge unsafe locally-originated community bundle path: ${entry.name}`, + ); + } + unlinkSync(path); + deletedCommunityBundles++; + } + + return { + deletedExports: purgeAllExports(configDir), + deletedCommunityBundles, + }; +} diff --git a/src/lab/public/registry.ts b/src/lab/public/registry.ts new file mode 100644 index 000000000..ced6d82dc --- /dev/null +++ b/src/lab/public/registry.ts @@ -0,0 +1,51 @@ +import { domainHash, jcsStringify } from "../digest"; +import type { + PublicAdapterFamilyV1, + PublicRouteRegistryEntryV1, + PublicRouteRegistryManifestV1, +} from "./types"; +import { PUBLIC_ROUTE_REGISTRY_SCHEMA_VERSION } from "./types"; + +const REGISTRY_DOMAIN = "ocx-lab:public-route-registry:v1"; +const OPENAI_API_BASE_URL = "https://api.openai.com/v1"; + +const entries: PublicRouteRegistryEntryV1[] = [ + { providerId: "openai-apikey", modelId: "gpt-5.5", adapterFamilies: ["openai-responses"], canonicalBaseUrl: OPENAI_API_BASE_URL }, + { providerId: "openai-apikey", modelId: "gpt-5.6-luna", adapterFamilies: ["openai-responses"], canonicalBaseUrl: OPENAI_API_BASE_URL }, + { providerId: "openai-apikey", modelId: "gpt-5.6-sol", adapterFamilies: ["openai-responses"], canonicalBaseUrl: OPENAI_API_BASE_URL }, + { providerId: "openai-apikey", modelId: "gpt-5.6-terra", adapterFamilies: ["openai-responses"], canonicalBaseUrl: OPENAI_API_BASE_URL }, +]; + +const manifestPayload = { + schemaVersion: PUBLIC_ROUTE_REGISTRY_SCHEMA_VERSION, + registryVersion: "2026-08-12.2", + sourceCommit: "4fed8d3fe431ad23be83f3aff2af18ef8b8ecd71", + entries, +}; + +export const PUBLIC_ROUTE_REGISTRY_V1: PublicRouteRegistryManifestV1 = Object.freeze({ + ...manifestPayload, + entries: Object.freeze(entries.map((entry) => Object.freeze({ + ...entry, + adapterFamilies: Object.freeze([...entry.adapterFamilies]) as unknown as PublicAdapterFamilyV1[], + }))) as unknown as PublicRouteRegistryEntryV1[], + manifestDigest: domainHash(REGISTRY_DOMAIN, jcsStringify(manifestPayload)), +}); + +export function publicRouteRegistryDigest( + manifest: Omit, +): string { + return domainHash(REGISTRY_DOMAIN, jcsStringify(manifest)); +} + +export function findPublicRouteRegistryEntry( + providerId: string, + modelId: string, + adapterFamily: PublicAdapterFamilyV1, + manifest: PublicRouteRegistryManifestV1 = PUBLIC_ROUTE_REGISTRY_V1, +): PublicRouteRegistryEntryV1 | null { + return manifest.entries.find((entry) => + entry.providerId === providerId + && entry.modelId === modelId + && entry.adapterFamilies.includes(adapterFamily)) ?? null; +} diff --git a/src/lab/public/revocation.ts b/src/lab/public/revocation.ts new file mode 100644 index 000000000..ea846cf14 --- /dev/null +++ b/src/lab/public/revocation.ts @@ -0,0 +1,81 @@ +import { publicEvidenceId } from "./ids"; +import { decodeCanonicalPublicBase64, signDigestWithPublicPublisherCapability, verifyPublicPublisherSignature } from "./signing"; +import { + PUBLIC_EVIDENCE_REVOCATION_SCHEMA_VERSION, + type PublicEvidenceBundleV1, + type PublicEvidenceRevocationV1, + type PublicPublisherSignerV1, + type PublicPublisherV1, + type PublicRevocationReasonV1, + type PublicRevocationTargetV1, + type PublicRevocationVerificationResult, +} from "./types"; +import { PublicEvidenceValidationError } from "./validate"; + +const REASONS = new Set(["publisher_retracted", "privacy_retraction", "evidence_invalidated", "superseded"]); +const MAX_TARGETS = 256; +function isPlainObject(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value); } +function closedKeys(value: Record, keys: readonly string[]): boolean { const allowed = new Set(keys); return Object.keys(value).every((key) => allowed.has(key)) && keys.every((key) => key in value); } +function validId(value: unknown): value is string { return typeof value === "string" && /^[0-9a-f]{64}$/.test(value); } +function validDay(value: unknown): value is string { + if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return false; + const date = new Date(`${value}T00:00:00.000Z`); + return Number.isFinite(date.getTime()) && date.toISOString().slice(0, 10) === value; +} +function targetKey(target: PublicRevocationTargetV1): string { return `${target.kind}:${target.id}`; } +function canonicalTargets(targets: readonly PublicRevocationTargetV1[]): PublicRevocationTargetV1[] { + if (targets.length === 0 || targets.length > MAX_TARGETS) throw new PublicEvidenceValidationError("revocation_targets", "revocation must contain 1..256 targets"); + const normalized = targets.map((target) => { + if ((target.kind !== "bundle" && target.kind !== "record") || !validId(target.id)) throw new PublicEvidenceValidationError("revocation_target", "invalid revocation target"); + return { kind: target.kind, id: target.id } as PublicRevocationTargetV1; + }).sort((a, b) => targetKey(a).localeCompare(targetKey(b))); + if (new Set(normalized.map(targetKey)).size !== normalized.length) throw new PublicEvidenceValidationError("revocation_target_duplicate", "revocation targets must be unique"); + return normalized; +} +function samePublisher(a: PublicPublisherV1, b: PublicPublisherV1): boolean { return a.algorithm === b.algorithm && a.keyId === b.keyId && a.publicKey === b.publicKey; } +function validateTargetsAgainstBundle(targets: readonly PublicRevocationTargetV1[], bundle: PublicEvidenceBundleV1): boolean { + const records = new Set(bundle.records.map((record) => record.recordId)); + return targets.every((target) => target.kind === "bundle" ? target.id === bundle.bundleId : records.has(target.id)); +} +function revocationPayload(issuedDayUtc: string, publisher: PublicPublisherV1, targets: PublicRevocationTargetV1[], reason: PublicRevocationReasonV1): Record { + return { schemaVersion: PUBLIC_EVIDENCE_REVOCATION_SCHEMA_VERSION, issuedDayUtc, publisher, targets, reason }; +} +export function createPublicEvidenceRevocation(input: { + signer: PublicPublisherSignerV1; + targetBundle: PublicEvidenceBundleV1; + issuedDayUtc: string; + targets: PublicRevocationTargetV1[]; + reason: PublicRevocationReasonV1; +}): PublicEvidenceRevocationV1 { + if (!samePublisher(input.signer.publisher, input.targetBundle.publisher)) throw new PublicEvidenceValidationError("revocation_publisher", "revocation publisher must exactly match target bundle publisher"); + if (!validDay(input.issuedDayUtc)) throw new PublicEvidenceValidationError("revocation_day", "issuedDayUtc is invalid"); + if (!REASONS.has(input.reason)) throw new PublicEvidenceValidationError("revocation_reason", "unsupported revocation reason"); + const targets = canonicalTargets(input.targets); + if (!validateTargetsAgainstBundle(targets, input.targetBundle)) throw new PublicEvidenceValidationError("revocation_target", "revocation target is unknown to target bundle"); + const revocationId = publicEvidenceId("revocation", revocationPayload(input.issuedDayUtc, input.signer.publisher, targets, input.reason)); + const signature = signDigestWithPublicPublisherCapability(revocationId, input.signer); + return Object.freeze({ schemaVersion: PUBLIC_EVIDENCE_REVOCATION_SCHEMA_VERSION, revocationId, issuedDayUtc: input.issuedDayUtc, publisher: input.signer.publisher, targets, reason: input.reason, signature: Object.freeze({ algorithm: "ed25519" as const, signedDigest: revocationId, signature }) }); +} +export function verifyPublicEvidenceRevocation(raw: unknown, targetBundle: PublicEvidenceBundleV1): PublicRevocationVerificationResult { + try { + if (!isPlainObject(raw) || !closedKeys(raw, ["schemaVersion", "revocationId", "issuedDayUtc", "publisher", "targets", "reason", "signature"])) return { status: "schema_rejected", detail: "closed revocation schema mismatch" }; + if (raw.schemaVersion !== PUBLIC_EVIDENCE_REVOCATION_SCHEMA_VERSION || !validId(raw.revocationId) || !validDay(raw.issuedDayUtc) || !REASONS.has(raw.reason as PublicRevocationReasonV1)) return { status: "schema_rejected", detail: "revocation version/id/day/reason invalid" }; + if (!isPlainObject(raw.publisher) || !closedKeys(raw.publisher, ["algorithm", "keyId", "publicKey"]) || raw.publisher.algorithm !== "ed25519" || !validId(raw.publisher.keyId) || typeof raw.publisher.publicKey !== "string" || !decodeCanonicalPublicBase64(raw.publisher.publicKey, 1024)) return { status: "schema_rejected", detail: "revocation publisher invalid" }; + const publisher: PublicPublisherV1 = { algorithm: "ed25519", keyId: raw.publisher.keyId, publicKey: raw.publisher.publicKey }; + if (!samePublisher(publisher, targetBundle.publisher)) return { status: "publisher_mismatch", detail: "revocation publisher does not match target bundle" }; + if (!Array.isArray(raw.targets) || raw.targets.length === 0 || raw.targets.length > MAX_TARGETS) return { status: "schema_rejected", detail: "revocation targets invalid" }; + const targets: PublicRevocationTargetV1[] = []; + for (const [index, value] of raw.targets.entries()) { + if (!isPlainObject(value) || !closedKeys(value, ["kind", "id"]) || (value.kind !== "bundle" && value.kind !== "record") || !validId(value.id)) return { status: "schema_rejected", detail: `revocation target ${index} invalid` }; + targets.push({ kind: value.kind, id: value.id }); + } + const canonical = canonicalTargets(targets); + if (canonical.some((target, index) => target.kind !== targets[index]!.kind || target.id !== targets[index]!.id)) return { status: "schema_rejected", detail: "revocation targets must be sorted" }; + if (!validateTargetsAgainstBundle(targets, targetBundle)) return { status: "unknown_target", detail: "revocation target not present in target bundle" }; + if (!isPlainObject(raw.signature) || !closedKeys(raw.signature, ["algorithm", "signedDigest", "signature"]) || raw.signature.algorithm !== "ed25519" || raw.signature.signedDigest !== raw.revocationId || typeof raw.signature.signature !== "string") return { status: "schema_rejected", detail: "revocation signature schema invalid" }; + const expected = publicEvidenceId("revocation", revocationPayload(raw.issuedDayUtc, publisher, targets, raw.reason as PublicRevocationReasonV1)); + if (expected !== raw.revocationId) return { status: "digest_invalid", detail: "revocation id does not match canonical bytes" }; + if (!verifyPublicPublisherSignature(raw.revocationId, publisher, raw.signature.signature)) return { status: "signature_invalid", detail: "revocation signature invalid" }; + return { status: "cryptographically_valid", revocation: { schemaVersion: PUBLIC_EVIDENCE_REVOCATION_SCHEMA_VERSION, revocationId: raw.revocationId, issuedDayUtc: raw.issuedDayUtc, publisher, targets, reason: raw.reason as PublicRevocationReasonV1, signature: { algorithm: "ed25519", signedDigest: raw.revocationId, signature: raw.signature.signature } } }; + } catch (error) { return { status: "schema_rejected", detail: error instanceof Error ? error.message : String(error) }; } +} diff --git a/src/lab/public/signing.ts b/src/lab/public/signing.ts new file mode 100644 index 000000000..0b17dcc0b --- /dev/null +++ b/src/lab/public/signing.ts @@ -0,0 +1,155 @@ +import { + createPrivateKey, + createPublicKey, + generateKeyPairSync, + sign as cryptoSign, + verify as cryptoVerify, + type KeyObject, +} from "node:crypto"; +import { + chmodSync, + closeSync, + constants as fsConstants, + fstatSync, + fsyncSync, + lstatSync, + openSync, + readFileSync, + writeSync, +} from "node:fs"; +import { ensureLabDirs, labPublicPublisherKeyPath } from "../paths"; +import { publicBundleDigest, publicPublisherKeyId } from "./ids"; +import type { + PublicEvidenceBundleUnsignedV1, + PublicEvidenceBundleV1, + PublicPublisherSignerV1, + PublicPublisherV1, + PublicBundleVerificationResult, +} from "./types"; +import { PublicEvidenceValidationError } from "./validate"; +import { validatePublicEvidenceBundleUnsigned } from "./bundle"; + +const signerKeys = new WeakMap(); +const O_NOFOLLOW = (fsConstants as { O_NOFOLLOW?: number }).O_NOFOLLOW ?? 0; +const MAX_PRIVATE_KEY_BYTES = 8 * 1024; + +function writeAll(fd: number, bytes: Uint8Array): void { + let offset = 0; + while (offset < bytes.byteLength) { + const count = writeSync(fd, bytes, offset, bytes.byteLength - offset); + if (count <= 0) throw new PublicEvidenceValidationError("publisher_key_write", "publisher key write made no progress"); + offset += count; + } +} +function assertPrivateKeyFile(path: string, fd: number): void { + const stats = fstatSync(fd); + if (!stats.isFile() || stats.isSymbolicLink() || stats.nlink !== 1 || stats.size > MAX_PRIVATE_KEY_BYTES) throw new PublicEvidenceValidationError("publisher_key_unsafe", "publisher key target is not a bounded regular file"); + if (process.platform !== "win32" && (stats.mode & 0o777) !== 0o600) chmodSync(path, 0o600); +} +function loadPrivateKey(path: string): KeyObject { + const before = lstatSync(path); + if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1) throw new PublicEvidenceValidationError("publisher_key_unsafe", "publisher key path is unsafe"); + const fd = openSync(path, fsConstants.O_RDONLY | O_NOFOLLOW); + try { + assertPrivateKeyFile(path, fd); + const pem = readFileSync(fd, { encoding: "utf8" }); + if (Buffer.byteLength(pem) > MAX_PRIVATE_KEY_BYTES || !pem.includes("BEGIN PRIVATE KEY")) throw new PublicEvidenceValidationError("publisher_key_invalid", "publisher key encoding invalid"); + const key = createPrivateKey(pem); + if (key.asymmetricKeyType !== "ed25519") throw new PublicEvidenceValidationError("publisher_key_invalid", "publisher key must be Ed25519"); + return key; + } finally { closeSync(fd); } +} +function createPrivateKeyFile(path: string): KeyObject { + const { privateKey } = generateKeyPairSync("ed25519"); + const pem = privateKey.export({ type: "pkcs8", format: "pem" }).toString(); + let fd: number | null = null; + try { + fd = openSync(path, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | O_NOFOLLOW, 0o600); + writeAll(fd, Buffer.from(pem, "utf8")); + fsyncSync(fd); + assertPrivateKeyFile(path, fd); + return privateKey; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EEXIST") return loadPrivateKey(path); + throw error; + } finally { if (fd !== null) closeSync(fd); } +} +function publisherForPrivateKey(privateKey: KeyObject): PublicPublisherV1 { + const privatePem = privateKey.export({ type: "pkcs8", format: "pem" }); + const publicKey = createPublicKey(privatePem); + const der = publicKey.export({ type: "spki", format: "der" }); + return Object.freeze({ algorithm: "ed25519" as const, keyId: publicPublisherKeyId(der), publicKey: der.toString("base64") }); +} +export function getOrCreatePublicPublisher(configDir?: string): PublicPublisherSignerV1 { + ensureLabDirs(configDir); + const path = labPublicPublisherKeyPath(configDir); + let key: KeyObject; + try { key = loadPrivateKey(path); } + catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + key = createPrivateKeyFile(path); + } + const signer: PublicPublisherSignerV1 = Object.freeze({ publisher: publisherForPrivateKey(key) }); + signerKeys.set(signer, key); + return signer; +} + +/** Internal CL-10 primitive. Not re-exported by the public barrel. */ +export function signDigestWithPublicPublisherCapability(digest: string, signer: PublicPublisherSignerV1): string { + if (!/^[0-9a-f]{64}$/.test(digest)) throw new PublicEvidenceValidationError("invalid_signing_digest", "signing digest must be lowercase sha256 hex"); + const privateKey = signerKeys.get(signer as object); + if (!privateKey) throw new PublicEvidenceValidationError("publisher_signer_untrusted", "publisher signer is not a live local signer capability"); + return cryptoSign(null, Buffer.from(digest, "hex"), privateKey).toString("base64"); +} + +function signedPayload(unsigned: PublicEvidenceBundleUnsignedV1, publisher: PublicPublisherV1): Record { + return { schemaVersion: unsigned.schemaVersion, exportPolicyVersion: unsigned.exportPolicyVersion, bundleId: unsigned.bundleId, createdDayUtc: unsigned.createdDayUtc, publisher, records: unsigned.records, artifacts: unsigned.artifacts }; +} +export function signPublicEvidenceBundle(rawUnsigned: PublicEvidenceBundleUnsignedV1, signer: PublicPublisherSignerV1): PublicEvidenceBundleV1 { + const unsigned = validatePublicEvidenceBundleUnsigned(rawUnsigned); + const digest = publicBundleDigest(signedPayload(unsigned, signer.publisher)); + const signature = signDigestWithPublicPublisherCapability(digest, signer); + return Object.freeze({ ...unsigned, publisher: signer.publisher, bundleDigest: digest, signature: Object.freeze({ algorithm: "ed25519" as const, signedDigest: digest, signature }) }); +} +function isPlainObject(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value); } +function closedKeys(value: Record, keys: readonly string[]): boolean { const allowed = new Set(keys); return Object.keys(value).every((key) => allowed.has(key)) && keys.every((key) => key in value); } +export function decodeCanonicalPublicBase64(value: unknown, maxBytes: number): Buffer | null { + if (typeof value !== "string" || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) return null; + const bytes = Buffer.from(value, "base64"); + if (bytes.byteLength > maxBytes || bytes.toString("base64") !== value) return null; + return bytes; +} +export function verifyPublicPublisherSignature(digest: string, publisher: PublicPublisherV1, signature: string): boolean { + if (!/^[0-9a-f]{64}$/.test(digest)) return false; + const publicKeyDer = decodeCanonicalPublicBase64(publisher.publicKey, 1024); + const signatureBytes = decodeCanonicalPublicBase64(signature, 128); + if (!publicKeyDer || publicPublisherKeyId(publicKeyDer) !== publisher.keyId || !signatureBytes || signatureBytes.byteLength !== 64) return false; + try { + const publicKey = createPublicKey({ key: publicKeyDer, format: "der", type: "spki" }); + return publicKey.asymmetricKeyType === "ed25519" && cryptoVerify(null, Buffer.from(digest, "hex"), publicKey, signatureBytes); + } catch { return false; } +} +export function verifyPublicEvidenceBundle(raw: unknown): PublicBundleVerificationResult { + try { + if (!isPlainObject(raw) || !closedKeys(raw, ["schemaVersion", "exportPolicyVersion", "bundleId", "createdDayUtc", "publisher", "records", "artifacts", "bundleDigest", "signature"])) return { status: "schema_rejected", detail: "closed bundle schema mismatch" }; + const unsignedRaw = { schemaVersion: raw.schemaVersion, exportPolicyVersion: raw.exportPolicyVersion, bundleId: raw.bundleId, createdDayUtc: raw.createdDayUtc, records: raw.records, artifacts: raw.artifacts }; + let unsigned: PublicEvidenceBundleUnsignedV1; + try { unsigned = validatePublicEvidenceBundleUnsigned(unsignedRaw); } + catch (error) { + if (error instanceof PublicEvidenceValidationError && error.code === "bundle_digest") return { status: "digest_invalid", detail: error.message }; + return { status: "schema_rejected", detail: error instanceof Error ? error.message : String(error) }; + } + if (!isPlainObject(raw.publisher) || !closedKeys(raw.publisher, ["algorithm", "keyId", "publicKey"]) || raw.publisher.algorithm !== "ed25519") return { status: "schema_rejected", detail: "publisher schema mismatch" }; + if (typeof raw.publisher.keyId !== "string" || !/^[0-9a-f]{64}$/.test(raw.publisher.keyId) || typeof raw.publisher.publicKey !== "string") return { status: "schema_rejected", detail: "publisher key invalid" }; + const publisher: PublicPublisherV1 = { algorithm: "ed25519", keyId: raw.publisher.keyId, publicKey: raw.publisher.publicKey }; + const publicKeyDer = decodeCanonicalPublicBase64(publisher.publicKey, 1024); + if (!publicKeyDer || publicPublisherKeyId(publicKeyDer) !== publisher.keyId) return { status: "schema_rejected", detail: "publisher public key invalid" }; + if (typeof raw.bundleDigest !== "string" || !/^[0-9a-f]{64}$/.test(raw.bundleDigest)) return { status: "schema_rejected", detail: "bundle digest invalid" }; + if (!isPlainObject(raw.signature) || !closedKeys(raw.signature, ["algorithm", "signedDigest", "signature"]) || raw.signature.algorithm !== "ed25519" || typeof raw.signature.signature !== "string") return { status: "schema_rejected", detail: "signature schema mismatch" }; + if (raw.signature.signedDigest !== raw.bundleDigest) return { status: "digest_invalid", detail: "signed digest does not match bundle digest" }; + const expectedDigest = publicBundleDigest(signedPayload(unsigned, publisher)); + if (expectedDigest !== raw.bundleDigest) return { status: "digest_invalid", detail: "canonical bundle digest mismatch" }; + if (!verifyPublicPublisherSignature(raw.bundleDigest, publisher, raw.signature.signature)) return { status: "signature_invalid", detail: "Ed25519 signature verification failed" }; + return { status: "cryptographically_valid", bundle: { ...unsigned, publisher, bundleDigest: raw.bundleDigest, signature: { algorithm: "ed25519", signedDigest: raw.bundleDigest, signature: raw.signature.signature } } }; + } catch (error) { return { status: "schema_rejected", detail: error instanceof Error ? error.message : String(error) }; } +} diff --git a/src/lab/public/store.ts b/src/lab/public/store.ts new file mode 100644 index 000000000..515f68b77 --- /dev/null +++ b/src/lab/public/store.ts @@ -0,0 +1,97 @@ +import { + closeSync, + constants as fsConstants, + fstatSync, + fsyncSync, + lstatSync, + openSync, + readFileSync, + writeSync, +} from "node:fs"; +import { join } from "node:path"; +import { jcsStringify } from "../digest"; +import { ensureLabDirs, labPublicExportsDir } from "../paths"; +import type { PublicEvidenceBundleV1 } from "./types"; +import { PublicEvidenceValidationError } from "./validate"; +import { verifyPublicEvidenceBundle } from "./signing"; + +const MAX_BUNDLE_FILE_BYTES = 2 * 1024 * 1024; +const O_NOFOLLOW = (fsConstants as { O_NOFOLLOW?: number }).O_NOFOLLOW ?? 0; + +function assertId(value: string): string { + if (!/^[0-9a-f]{64}$/.test(value)) throw new PublicEvidenceValidationError("invalid_bundle_id", "bundle id must be lowercase sha256 hex"); + return value; +} + +function assertRegularFile(path: string, fd: number): void { + const stats = fstatSync(fd); + if (!stats.isFile() || stats.isSymbolicLink() || stats.nlink !== 1 || stats.size > MAX_BUNDLE_FILE_BYTES) { + throw new PublicEvidenceValidationError("export_unsafe_target", `unsafe public export file: ${path}`); + } +} + +function readBounded(path: string): Buffer { + const before = lstatSync(path); + if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1 || before.size > MAX_BUNDLE_FILE_BYTES) { + throw new PublicEvidenceValidationError("export_unsafe_target", "public export path is unsafe or oversized"); + } + const fd = openSync(path, fsConstants.O_RDONLY | O_NOFOLLOW); + try { + assertRegularFile(path, fd); + const bytes = readFileSync(fd); + if (bytes.byteLength > MAX_BUNDLE_FILE_BYTES) throw new PublicEvidenceValidationError("export_too_large", "public export exceeds read bound"); + return bytes; + } finally { + closeSync(fd); + } +} + +function writeAll(fd: number, bytes: Uint8Array): void { + let offset = 0; + while (offset < bytes.byteLength) { + const count = writeSync(fd, bytes, offset, bytes.byteLength - offset); + if (count <= 0) throw new PublicEvidenceValidationError("export_write_failed", "public export write made no progress"); + offset += count; + } +} + +export function writePublicEvidenceBundle(bundle: PublicEvidenceBundleV1, configDir?: string): { path: string; created: boolean } { + const verified = verifyPublicEvidenceBundle(bundle); + if (verified.status !== "cryptographically_valid") throw new PublicEvidenceValidationError(verified.status, verified.detail ?? "public bundle is not cryptographically valid"); + ensureLabDirs(configDir); + const exportsDir = labPublicExportsDir(configDir); + const path = join(exportsDir, `${assertId(bundle.bundleId)}.json`); + const bytes = Buffer.from(jcsStringify(verified.bundle), "utf8"); + if (bytes.byteLength > MAX_BUNDLE_FILE_BYTES) throw new PublicEvidenceValidationError("export_too_large", "public export exceeds file bound"); + let fd: number | null = null; + try { + fd = openSync(path, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | O_NOFOLLOW, 0o600); + writeAll(fd, bytes); + fsyncSync(fd); + assertRegularFile(path, fd); + closeSync(fd); + fd = null; + return { path, created: true }; + } catch (error) { + if (fd !== null) closeSync(fd); + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + const existing = readBounded(path); + if (!existing.equals(bytes)) throw new PublicEvidenceValidationError("export_conflict", "bundle id already exists with different bytes"); + return { path, created: false }; + } +} + +export function readPublicEvidenceBundle(bundleId: string, configDir?: string): PublicEvidenceBundleV1 { + ensureLabDirs(configDir); + const path = join(labPublicExportsDir(configDir), `${assertId(bundleId)}.json`); + const bytes = readBounded(path); + let raw: unknown; + try { + raw = JSON.parse(bytes.toString("utf8")); + } catch { + throw new PublicEvidenceValidationError("export_invalid_json", "public export is not valid JSON"); + } + const verified = verifyPublicEvidenceBundle(raw); + if (verified.status !== "cryptographically_valid") throw new PublicEvidenceValidationError(verified.status, verified.detail ?? "public export verification failed"); + return verified.bundle; +} diff --git a/src/lab/public/types.ts b/src/lab/public/types.ts new file mode 100644 index 000000000..2b4f0c12d --- /dev/null +++ b/src/lab/public/types.ts @@ -0,0 +1,130 @@ +import type { CompatibilityVerdict, EvidenceLayer } from "../constants"; + +export const PUBLIC_ROUTE_REGISTRY_SCHEMA_VERSION = "public_route_registry_v1" as const; +export const PUBLIC_EVIDENCE_BUNDLE_SCHEMA_VERSION = "public_evidence_bundle_v1" as const; +export const PUBLIC_EVIDENCE_REVOCATION_SCHEMA_VERSION = "public_evidence_revocation_v1" as const; +export const PUBLIC_EXPORT_POLICY_VERSION = "public_export_policy_v1" as const; + +export const PUBLIC_ADAPTER_FAMILIES = ["openai-responses", "openai-chat", "anthropic-messages"] as const; +export type PublicAdapterFamilyV1 = (typeof PUBLIC_ADAPTER_FAMILIES)[number]; + +export interface PublicRouteRegistryEntryV1 { + providerId: string; + modelId: string; + adapterFamilies: PublicAdapterFamilyV1[]; + canonicalBaseUrl: string; +} +export interface PublicRouteRegistryManifestV1 { + schemaVersion: typeof PUBLIC_ROUTE_REGISTRY_SCHEMA_VERSION; + registryVersion: string; + sourceCommit: string; + entries: PublicRouteRegistryEntryV1[]; + manifestDigest: string; +} +export interface PublicProtocolSubjectV1 { + subjectKind: "protocol"; + adapterFamily: PublicAdapterFamilyV1; + inboundProtocol: PublicAdapterFamilyV1; + upstreamProtocol: PublicAdapterFamilyV1; + surface: string; + compatibilityVersion: string; +} +export interface PublicRouteSubjectV1 { + subjectKind: "route"; + providerId: string; + modelId: string; + adapterFamily: PublicAdapterFamilyV1; + inboundProtocol: PublicAdapterFamilyV1; + upstreamProtocol: PublicAdapterFamilyV1; + surface: string; + compatibilityVersion: string; + registryVersion: string; + registryDigest: string; +} +export interface PublicTaskSubjectV1 { + subjectKind: "task"; + route: PublicRouteSubjectV1; + taskClassId: string; + taskClassVersion: string; + verifierAuthorityId: string; +} +export type PublicEvidenceSubjectV1 = PublicProtocolSubjectV1 | PublicRouteSubjectV1 | PublicTaskSubjectV1; +export interface PublicAssertionSummaryV1 { id: string; required: boolean; passed: boolean; } +export interface PublicIncidentRefV1 { corpusId: string; } +export interface PublicEvidenceRecordV1 { + recordId: string; + subjectId: string; + evidenceLayer: EvidenceLayer; + suiteId: string; + suiteVersion: string; + scenarioId: string; + scenarioVersion: string; + verdict: CompatibilityVerdict; + observedDayUtc: string; + subject: PublicEvidenceSubjectV1; + assertions: PublicAssertionSummaryV1[]; + incidentRefs?: PublicIncidentRefV1[]; + artifactRefs?: string[]; +} +export interface PublicArtifactV1 { + artifactId: string; + mediaType: "application/json" | "text/plain; charset=utf-8"; + byteCount: number; + contentBase64: string; +} +export interface PublicEvidenceBundleUnsignedV1 { + schemaVersion: typeof PUBLIC_EVIDENCE_BUNDLE_SCHEMA_VERSION; + exportPolicyVersion: typeof PUBLIC_EXPORT_POLICY_VERSION; + bundleId: string; + createdDayUtc: string; + records: PublicEvidenceRecordV1[]; + artifacts: PublicArtifactV1[]; +} +export interface PublicPublisherV1 { + algorithm: "ed25519"; + keyId: string; + publicKey: string; +} +export interface PublicBundleSignatureV1 { + algorithm: "ed25519"; + signedDigest: string; + signature: string; +} +export interface PublicEvidenceBundleV1 extends PublicEvidenceBundleUnsignedV1 { + publisher: PublicPublisherV1; + bundleDigest: string; + signature: PublicBundleSignatureV1; +} +export interface PublicPublisherSignerV1 { publisher: PublicPublisherV1; } +export type PublicBundleVerificationResult = + | { status: "cryptographically_valid"; bundle: PublicEvidenceBundleV1 } + | { status: "schema_rejected" | "digest_invalid" | "signature_invalid"; detail?: string }; + +export type PublicRevocationReasonV1 = "publisher_retracted" | "privacy_retraction" | "evidence_invalidated" | "superseded"; +export interface PublicRevocationTargetV1 { kind: "bundle" | "record"; id: string; } +export interface PublicEvidenceRevocationV1 { + schemaVersion: typeof PUBLIC_EVIDENCE_REVOCATION_SCHEMA_VERSION; + revocationId: string; + issuedDayUtc: string; + publisher: PublicPublisherV1; + targets: PublicRevocationTargetV1[]; + reason: PublicRevocationReasonV1; + signature: PublicBundleSignatureV1; +} +export type PublicRevocationVerificationResult = + | { status: "cryptographically_valid"; revocation: PublicEvidenceRevocationV1 } + | { status: "schema_rejected" | "digest_invalid" | "signature_invalid" | "publisher_mismatch" | "unknown_target"; detail?: string }; + +export interface CommunityEvidenceSummaryV1 { + trustClass: "community_untrusted_v1"; + status: "cryptographically_valid"; + bundleId: string; + publisherKeyId: string; + activeRecordCount: number; + revokedRecordCount: number; +} + +export type PublicProjectionNotExportableReason = "private_route_identity" | "unsupported_public_adapter" | "task_authority_unavailable" | "invalid_public_incident_ref"; +export type PublicProjectionResult = { status: "exportable"; record: PublicEvidenceRecordV1 } | { status: "not_exportable"; reason: PublicProjectionNotExportableReason }; +export interface PublicRouteAuthorityV1 { localSubjectId: string; descriptor: PublicRouteSubjectV1; } +export interface PublicTaskAuthorityV1 { localSubjectId: string; descriptor: PublicTaskSubjectV1; } diff --git a/src/lab/public/validate.ts b/src/lab/public/validate.ts new file mode 100644 index 000000000..ad0a4d8df --- /dev/null +++ b/src/lab/public/validate.ts @@ -0,0 +1,248 @@ +import { EVIDENCE_LAYERS, VERDICTS } from "../constants"; +import { isSha256Hex, jcsStringify } from "../digest"; +import { + PUBLIC_ADAPTER_FAMILIES, + PUBLIC_ROUTE_REGISTRY_SCHEMA_VERSION, + type PublicAdapterFamilyV1, + type PublicEvidenceRecordV1, + type PublicEvidenceSubjectV1, + type PublicIncidentRefV1, + type PublicProtocolSubjectV1, + type PublicRouteRegistryEntryV1, + type PublicRouteRegistryManifestV1, + type PublicRouteSubjectV1, + type PublicTaskSubjectV1, +} from "./types"; +import { publicRouteRegistryDigest } from "./registry"; + +const MAX_STRING_BYTES = 4 * 1024; +const MAX_ASSERTIONS = 64; +const MAX_INCIDENT_REFS = 32; +const MAX_ARTIFACT_REFS = 16; +const MAX_RECORD_BYTES = 64 * 1024; +const MAX_REGISTRY_ENTRIES = 512; +const PUBLIC_INCIDENT_REFS = new Set( + Array.from({ length: 21 }, (_, index) => `IC-${String(index + 1).padStart(3, "0")}`), +); + +export class PublicEvidenceValidationError extends Error { + readonly code: string; + + constructor(code: string, message: string) { + super(message); + this.name = "PublicEvidenceValidationError"; + this.code = code; + } +} + +function isPlainObject(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +function assertClosedKeys(raw: Record, allowed: readonly string[], field: string): void { + const set = new Set(allowed); + for (const key of Object.keys(raw)) { + if (!set.has(key)) throw new PublicEvidenceValidationError("unknown_field", `${field}.${key}`); + } +} + +function assertString(value: unknown, field: string, max = MAX_STRING_BYTES): string { + if (typeof value !== "string") throw new PublicEvidenceValidationError("invalid_type", `${field} must be string`); + if (value.includes("\0")) throw new PublicEvidenceValidationError("nul_forbidden", `${field} contains NUL`); + if (new TextEncoder().encode(value).byteLength > max) throw new PublicEvidenceValidationError("field_too_large", `${field} exceeds ${max} bytes`); + return value; +} + +function assertPublicToken(value: unknown, field: string, max = 256): string { + const text = assertString(value, field, max); + if (!/^[A-Za-z0-9][A-Za-z0-9._:/+-]*$/.test(text)) throw new PublicEvidenceValidationError("unsafe_string", `${field} contains unsupported characters`); + return text; +} + +function assertHex(value: unknown, field: string): string { + const text = assertString(value, field, 64); + if (!isSha256Hex(text)) throw new PublicEvidenceValidationError("invalid_id", `${field} must be lowercase sha256 hex`); + return text; +} + +function assertGitCommit(value: unknown, field: string): string { + const text = assertString(value, field, 40); + if (!/^[0-9a-f]{40}$/.test(text)) throw new PublicEvidenceValidationError("invalid_git_commit", `${field} must be a lowercase 40-character git commit id`); + return text; +} + +function assertAdapter(value: unknown, field: string): PublicAdapterFamilyV1 { + const text = assertString(value, field, 64); + if (!(PUBLIC_ADAPTER_FAMILIES as readonly string[]).includes(text)) throw new PublicEvidenceValidationError("closed_set", `${field} is not a public adapter family`); + return text as PublicAdapterFamilyV1; +} + +function assertBoolean(value: unknown, field: string): boolean { + if (value !== true && value !== false) throw new PublicEvidenceValidationError("invalid_type", `${field} must be boolean`); + return value; +} + +function assertDay(value: unknown, field: string): string { + const text = assertString(value, field, 10); + if (!/^\d{4}-\d{2}-\d{2}$/.test(text)) throw new PublicEvidenceValidationError("invalid_day", `${field} must be YYYY-MM-DD`); + const parsed = new Date(`${text}T00:00:00.000Z`); + if (!Number.isFinite(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== text) throw new PublicEvidenceValidationError("invalid_day", `${field} is not a UTC calendar day`); + return text; +} + +function assertCanonicalHttpsBaseUrl(value: unknown, field: string): string { + const text = assertString(value, field, 512); + try { + const url = new URL(text); + if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash) throw new Error("not canonical public https endpoint"); + const normalizedPath = url.pathname.replace(/\/+$/, "") || "/"; + url.pathname = normalizedPath; + const normalized = url.toString().replace(/\/$/, normalizedPath === "/" ? "/" : ""); + if (normalized !== text) throw new Error("not canonical"); + return text; + } catch { + throw new PublicEvidenceValidationError("invalid_registry_endpoint", `${field} must be a canonical https base URL`); + } +} + +export function isPublicIncidentRef(value: unknown): value is string { + return typeof value === "string" && PUBLIC_INCIDENT_REFS.has(value); +} + +function validateIncidentRefs(raw: unknown): PublicIncidentRefV1[] { + if (!Array.isArray(raw) || raw.length > MAX_INCIDENT_REFS) throw new PublicEvidenceValidationError("invalid_incident_refs", "incidentRefs is invalid or oversized"); + const seen = new Set(); + return raw.map((value, index) => { + if (!isPlainObject(value)) throw new PublicEvidenceValidationError("invalid_incident_ref", `incidentRefs[${index}]`); + assertClosedKeys(value, ["corpusId"], `incidentRefs[${index}]`); + const corpusId = assertString(value.corpusId, `incidentRefs[${index}].corpusId`, 6); + if (!isPublicIncidentRef(corpusId) || seen.has(corpusId)) throw new PublicEvidenceValidationError("invalid_incident_ref", `incidentRefs[${index}].corpusId`); + seen.add(corpusId); + return { corpusId }; + }); +} + +function validateProtocolSubject(raw: Record): PublicProtocolSubjectV1 { + assertClosedKeys(raw, ["subjectKind", "adapterFamily", "inboundProtocol", "upstreamProtocol", "surface", "compatibilityVersion"], "subject"); + if (raw.subjectKind !== "protocol") throw new PublicEvidenceValidationError("subject_kind", "protocol subjectKind mismatch"); + return { + subjectKind: "protocol", + adapterFamily: assertAdapter(raw.adapterFamily, "subject.adapterFamily"), + inboundProtocol: assertAdapter(raw.inboundProtocol, "subject.inboundProtocol"), + upstreamProtocol: assertAdapter(raw.upstreamProtocol, "subject.upstreamProtocol"), + surface: assertPublicToken(raw.surface, "subject.surface", 128), + compatibilityVersion: assertPublicToken(raw.compatibilityVersion, "subject.compatibilityVersion", 128), + }; +} + +function validateRouteSubject(raw: Record): PublicRouteSubjectV1 { + assertClosedKeys(raw, ["subjectKind", "providerId", "modelId", "adapterFamily", "inboundProtocol", "upstreamProtocol", "surface", "compatibilityVersion", "registryVersion", "registryDigest"], "subject"); + if (raw.subjectKind !== "route") throw new PublicEvidenceValidationError("subject_kind", "route subjectKind mismatch"); + return { + subjectKind: "route", + providerId: assertPublicToken(raw.providerId, "subject.providerId", 128), + modelId: assertPublicToken(raw.modelId, "subject.modelId", 256), + adapterFamily: assertAdapter(raw.adapterFamily, "subject.adapterFamily"), + inboundProtocol: assertAdapter(raw.inboundProtocol, "subject.inboundProtocol"), + upstreamProtocol: assertAdapter(raw.upstreamProtocol, "subject.upstreamProtocol"), + surface: assertPublicToken(raw.surface, "subject.surface", 128), + compatibilityVersion: assertPublicToken(raw.compatibilityVersion, "subject.compatibilityVersion", 128), + registryVersion: assertPublicToken(raw.registryVersion, "subject.registryVersion", 128), + registryDigest: assertHex(raw.registryDigest, "subject.registryDigest"), + }; +} + +function validateTaskSubject(raw: Record): PublicTaskSubjectV1 { + assertClosedKeys(raw, ["subjectKind", "route", "taskClassId", "taskClassVersion", "verifierAuthorityId"], "subject"); + if (raw.subjectKind !== "task" || !isPlainObject(raw.route)) throw new PublicEvidenceValidationError("subject_kind", "task subject is invalid"); + return { + subjectKind: "task", + route: validateRouteSubject(raw.route), + taskClassId: assertPublicToken(raw.taskClassId, "subject.taskClassId", 256), + taskClassVersion: assertPublicToken(raw.taskClassVersion, "subject.taskClassVersion", 128), + verifierAuthorityId: assertPublicToken(raw.verifierAuthorityId, "subject.verifierAuthorityId", 256), + }; +} + +function validateSubject(raw: unknown, layer: string): PublicEvidenceSubjectV1 { + if (!isPlainObject(raw)) throw new PublicEvidenceValidationError("invalid_subject", "subject must be object"); + if (layer === "protocol_conformance") { + if (raw.subjectKind !== "protocol") throw new PublicEvidenceValidationError("layer_subject_mismatch", "protocol layer requires protocol subject"); + return validateProtocolSubject(raw); + } + if (layer === "live_route_compatibility") { + if (raw.subjectKind !== "route") throw new PublicEvidenceValidationError("layer_subject_mismatch", "live layer requires route subject"); + return validateRouteSubject(raw); + } + if (layer === "task_effectiveness") { + if (raw.subjectKind !== "task") throw new PublicEvidenceValidationError("layer_subject_mismatch", "task layer requires task subject"); + return validateTaskSubject(raw); + } + throw new PublicEvidenceValidationError("unknown_layer", layer); +} + +export function validatePublicEvidenceRecord(raw: unknown): PublicEvidenceRecordV1 { + if (!isPlainObject(raw)) throw new PublicEvidenceValidationError("invalid_record", "record must be object"); + assertClosedKeys(raw, ["recordId", "subjectId", "evidenceLayer", "suiteId", "suiteVersion", "scenarioId", "scenarioVersion", "verdict", "observedDayUtc", "subject", "assertions", "incidentRefs", "artifactRefs"], "record"); + const layer = assertString(raw.evidenceLayer, "evidenceLayer", 64); + if (!(EVIDENCE_LAYERS as readonly string[]).includes(layer)) throw new PublicEvidenceValidationError("closed_set", "evidenceLayer"); + const verdict = assertString(raw.verdict, "verdict", 32); + if (!(VERDICTS as readonly string[]).includes(verdict)) throw new PublicEvidenceValidationError("closed_set", "verdict"); + if (!Array.isArray(raw.assertions) || raw.assertions.length > MAX_ASSERTIONS) throw new PublicEvidenceValidationError("invalid_assertions", "assertions is invalid or oversized"); + const assertions = raw.assertions.map((value, index) => { + if (!isPlainObject(value)) throw new PublicEvidenceValidationError("invalid_assertion", `assertions[${index}]`); + assertClosedKeys(value, ["id", "required", "passed"], `assertions[${index}]`); + return { id: assertPublicToken(value.id, `assertions[${index}].id`, 256), required: assertBoolean(value.required, `assertions[${index}].required`), passed: assertBoolean(value.passed, `assertions[${index}].passed`) }; + }); + let artifactRefs: string[] | undefined; + if (raw.artifactRefs !== undefined) { + if (!Array.isArray(raw.artifactRefs) || raw.artifactRefs.length > MAX_ARTIFACT_REFS) throw new PublicEvidenceValidationError("invalid_artifact_refs", "artifactRefs is invalid or oversized"); + artifactRefs = raw.artifactRefs.map((value, index) => assertHex(value, `artifactRefs[${index}]`)); + if (new Set(artifactRefs).size !== artifactRefs.length) throw new PublicEvidenceValidationError("duplicate_artifact_ref", "artifactRefs contains duplicates"); + } + const record: PublicEvidenceRecordV1 = { + recordId: assertHex(raw.recordId, "recordId"), + subjectId: assertHex(raw.subjectId, "subjectId"), + evidenceLayer: layer as PublicEvidenceRecordV1["evidenceLayer"], + suiteId: assertPublicToken(raw.suiteId, "suiteId", 256), + suiteVersion: assertPublicToken(raw.suiteVersion, "suiteVersion", 128), + scenarioId: assertPublicToken(raw.scenarioId, "scenarioId", 256), + scenarioVersion: assertPublicToken(raw.scenarioVersion, "scenarioVersion", 128), + verdict: verdict as PublicEvidenceRecordV1["verdict"], + observedDayUtc: assertDay(raw.observedDayUtc, "observedDayUtc"), + subject: validateSubject(raw.subject, layer), + assertions, + ...(raw.incidentRefs !== undefined ? { incidentRefs: validateIncidentRefs(raw.incidentRefs) } : {}), + ...(artifactRefs !== undefined ? { artifactRefs } : {}), + }; + if (new TextEncoder().encode(jcsStringify(record)).byteLength > MAX_RECORD_BYTES) throw new PublicEvidenceValidationError("record_too_large", `record exceeds ${MAX_RECORD_BYTES} bytes`); + return record; +} + +export function validatePublicRouteRegistryManifest(raw: unknown): PublicRouteRegistryManifestV1 { + if (!isPlainObject(raw)) throw new PublicEvidenceValidationError("invalid_registry", "registry must be object"); + assertClosedKeys(raw, ["schemaVersion", "registryVersion", "sourceCommit", "entries", "manifestDigest"], "registry"); + if (raw.schemaVersion !== PUBLIC_ROUTE_REGISTRY_SCHEMA_VERSION) throw new PublicEvidenceValidationError("unsupported_version", "public route registry schema version"); + const registryVersion = assertPublicToken(raw.registryVersion, "registryVersion", 128); + const sourceCommit = assertGitCommit(raw.sourceCommit, "sourceCommit"); + if (!Array.isArray(raw.entries) || raw.entries.length === 0 || raw.entries.length > MAX_REGISTRY_ENTRIES) throw new PublicEvidenceValidationError("invalid_registry", "registry entries are invalid or oversized"); + const seen = new Set(); + const entries: PublicRouteRegistryEntryV1[] = raw.entries.map((value, index) => { + if (!isPlainObject(value)) throw new PublicEvidenceValidationError("invalid_registry_entry", `entries[${index}]`); + assertClosedKeys(value, ["providerId", "modelId", "adapterFamilies", "canonicalBaseUrl"], `entries[${index}]`); + const providerId = assertPublicToken(value.providerId, `entries[${index}].providerId`, 128); + const modelId = assertPublicToken(value.modelId, `entries[${index}].modelId`, 256); + if (!Array.isArray(value.adapterFamilies) || value.adapterFamilies.length === 0 || value.adapterFamilies.length > 3) throw new PublicEvidenceValidationError("invalid_registry_entry", `entries[${index}].adapterFamilies`); + const adapterFamilies = value.adapterFamilies.map((adapter, adapterIndex) => assertAdapter(adapter, `entries[${index}].adapterFamilies[${adapterIndex}]`)); + if (new Set(adapterFamilies).size !== adapterFamilies.length) throw new PublicEvidenceValidationError("invalid_registry_entry", `entries[${index}].adapterFamilies duplicates`); + const canonicalBaseUrl = assertCanonicalHttpsBaseUrl(value.canonicalBaseUrl, `entries[${index}].canonicalBaseUrl`); + const key = `${providerId}\0${modelId}`; + if (seen.has(key)) throw new PublicEvidenceValidationError("duplicate_registry_entry", `entries[${index}]`); + seen.add(key); + return { providerId, modelId, adapterFamilies, canonicalBaseUrl }; + }); + const manifestDigest = assertHex(raw.manifestDigest, "manifestDigest"); + const expected = publicRouteRegistryDigest({ schemaVersion: PUBLIC_ROUTE_REGISTRY_SCHEMA_VERSION, registryVersion, sourceCommit, entries }); + if (manifestDigest !== expected) throw new PublicEvidenceValidationError("registry_digest_mismatch", "manifestDigest does not match canonical registry bytes"); + return { schemaVersion: PUBLIC_ROUTE_REGISTRY_SCHEMA_VERSION, registryVersion, sourceCommit, entries, manifestDigest }; +} diff --git a/src/server/management/lab-routes.ts b/src/server/management/lab-routes.ts index 5c0929e47..1a7c73252 100644 --- a/src/server/management/lab-routes.ts +++ b/src/server/management/lab-routes.ts @@ -42,6 +42,14 @@ import { queryLabVerdicts, queryPassiveProductionSignals, } from "../../lab/query"; +import { + exportLocalPublicEvidence, + importCommunityEvidenceValue, + listCommunityEvidenceContext, + previewLocalPublicEvidence, + summarizePublicEvidenceVerification, + PublicEvidenceValidationError, +} from "../../lab/public"; import { jsonResponse } from "../auth-cors"; import type { ManagementContext } from "./context"; @@ -186,9 +194,154 @@ function paginatedEnvelope(page: { items: T[]; nextCursor?: string; hasMore: }; } +const MAX_PUBLIC_REQUEST_BYTES = 2 * 1024 * 1024; + +async function readBoundedPublicJson(req: Request): Promise { + const lengthRaw = req.headers.get("content-length"); + if (lengthRaw) { + const length = Number(lengthRaw); + if (!Number.isFinite(length) || length < 0 || length > MAX_PUBLIC_REQUEST_BYTES) { + throw new PublicEvidenceValidationError( + "public_request_too_large", + "public evidence request exceeds 2 MiB", + ); + } + } + if (!req.body) { + throw new PublicEvidenceValidationError("public_request_body", "JSON body is required"); + } + const reader = req.body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > MAX_PUBLIC_REQUEST_BYTES) { + await reader.cancel(); + throw new PublicEvidenceValidationError( + "public_request_too_large", + "public evidence request exceeds 2 MiB", + ); + } + chunks.push(value); + } + const bytes = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + try { + return JSON.parse(new TextDecoder().decode(bytes)); + } catch { + throw new PublicEvidenceValidationError("public_request_json", "request body is not valid JSON"); + } +} + +function publicEventIds(raw: unknown): string[] { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + throw new PublicEvidenceValidationError("public_request_body", "request body must be an object"); + } + const keys = Object.keys(raw); + if (keys.length !== 1 || keys[0] !== "eventIds") { + throw new PublicEvidenceValidationError("public_request_body", "only eventIds is accepted"); + } + const eventIds = (raw as { eventIds?: unknown }).eventIds; + if (!Array.isArray(eventIds) || !eventIds.every((value) => typeof value === "string")) { + throw new PublicEvidenceValidationError("public_request_body", "eventIds must be a string array"); + } + return eventIds as string[]; +} + +function publicBundleValue(raw: unknown): unknown { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + throw new PublicEvidenceValidationError("public_request_body", "request body must be an object"); + } + const keys = Object.keys(raw); + if (keys.length !== 1 || keys[0] !== "bundle") { + throw new PublicEvidenceValidationError("public_request_body", "only bundle is accepted"); + } + return (raw as { bundle?: unknown }).bundle; +} + +function publicErrorResponse(err: unknown, ctx: ManagementContext): Response { + const message = err instanceof Error ? err.message : "public evidence operation failed"; + const code = err instanceof PublicEvidenceValidationError + ? err.code + : "public_evidence_error"; + return errorResponse(code, message, 400, ctx); +} + export async function handleLabRoutes(ctx: ManagementContext): Promise { const { url, req, config } = ctx; if (!url.pathname.startsWith("/api/lab")) return null; + + if (req.method === "GET" && url.pathname === "/api/lab/public/community") { + try { + return jsonResponse(listCommunityEvidenceContext(), 200, req, config); + } catch (err) { + return publicErrorResponse(err, ctx); + } + } + + if (req.method === "POST") { + if (url.pathname === "/api/lab/public/preview") { + try { + const body = await readBoundedPublicJson(req); + return jsonResponse( + previewLocalPublicEvidence({ eventIds: publicEventIds(body) }), + 200, + req, + config, + ); + } catch (err) { + return publicErrorResponse(err, ctx); + } + } + if (url.pathname === "/api/lab/public/export") { + try { + const body = await readBoundedPublicJson(req); + return jsonResponse( + exportLocalPublicEvidence({ eventIds: publicEventIds(body) }), + 200, + req, + config, + ); + } catch (err) { + return publicErrorResponse(err, ctx); + } + } + if (url.pathname === "/api/lab/public/verify") { + try { + const body = await readBoundedPublicJson(req); + const result = summarizePublicEvidenceVerification(publicBundleValue(body)); + return jsonResponse( + result, + result.status === "cryptographically_valid" ? 200 : 400, + req, + config, + ); + } catch (err) { + return publicErrorResponse(err, ctx); + } + } + if (url.pathname === "/api/lab/public/community/import") { + try { + const body = await readBoundedPublicJson(req); + return jsonResponse( + importCommunityEvidenceValue(publicBundleValue(body)), + 200, + req, + config, + ); + } catch (err) { + return publicErrorResponse(err, ctx); + } + } + return null; + } + if (req.method !== "GET") return null; if (url.pathname === "/api/lab/status") { diff --git a/tests/lab-community-evidence.test.ts b/tests/lab-community-evidence.test.ts new file mode 100644 index 000000000..f237b62a5 --- /dev/null +++ b/tests/lab-community-evidence.test.ts @@ -0,0 +1,190 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + LAB_EVENT_SCHEMA_VERSION, + LAB_PRODUCER, + assignEventId, + labLedgerPath, + labSqlitePath, + purgeSensitiveEvidence, + subjectIdForSubject, + type ObservationEvent, + type ProtocolSubjectV1, +} from "../src/lab"; +import { + createPublicEvidenceRevocation, + getOrCreatePublicPublisher, + importCommunityEvidenceBundle, + importCommunityEvidenceRevocation, + listCommunityEvidence, + projectPublicEvidence, + signPublicEvidenceBundle, + verifyPublicEvidenceRevocation, + writePublicEvidenceBundle, +} from "../src/lab/public"; + +const roots: string[] = []; +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +function configDir(prefix = "ocx-cl10-community-"): string { + const root = mkdtempSync(join(tmpdir(), prefix)); + roots.push(root); + return root; +} + +function hex(seed: string): string { + return Bun.CryptoHasher.hash("sha256", seed, "hex"); +} + +function protocolObservation(scenarioId = "responses-core.protocol.request-shape"): ObservationEvent { + const subject: ProtocolSubjectV1 = { + subjectSchemaVersion: 1, + subjectKind: "protocol", + opencodexCompatibilityVersion: "2.13.0", + effectiveAdapter: "openai-chat", + inboundProtocol: "openai-responses", + upstreamProtocol: "openai-chat", + surface: "responses-http", + behaviorFingerprint: hex("PRIVATE-community-behavior"), + }; + return assignEventId({ + schemaVersion: LAB_EVENT_SCHEMA_VERSION, + eventKind: "observation" as const, + recordedAt: Date.UTC(2026, 7, 12, 14, 37, 48), + producer: LAB_PRODUCER, + producerVersion: "2.13.0", + evidenceLayer: "protocol_conformance" as const, + scenarioId, + scenarioVersion: "1.0.0", + scenarioManifestDigest: hex("scenario"), + suiteId: "responses-core", + suiteVersion: "1.0.0", + suiteManifestDigest: hex("suite"), + fixtureDigests: [hex("fixture")], + subject, + subjectId: subjectIdForSubject(subject), + startedAt: Date.UTC(2026, 7, 12, 14, 37, 40), + completedAt: Date.UTC(2026, 7, 12, 14, 37, 41), + executionMode: "fixture" as const, + attempt: 1, + limits: { totalTimeoutMs: 1000 }, + outcome: "pass" as const, + assertions: [{ id: "request-shape", operator: "equals", required: true, passed: true }], + environment: {}, + artifactRefs: [], + }) as ObservationEvent; +} + +function signedBundle(config: string, scenarioId?: string) { + const unsigned = projectPublicEvidence({ + createdDayUtc: "2026-08-12", + records: [{ observation: protocolObservation(scenarioId), verdict: "VERIFIED" }], + }).bundle; + return signPublicEvidenceBundle(unsigned, getOrCreatePublicPublisher(config)); +} + +describe("CL-10 community quarantine", () => { + test("imports valid signed evidence without touching canonical Lab authority", () => { + const publisherDir = configDir("ocx-cl10-publisher-"); + const consumerDir = configDir("ocx-cl10-consumer-"); + const bundle = signedBundle(publisherDir); + const imported = importCommunityEvidenceBundle(bundle, consumerDir); + expect(imported).toMatchObject({ created: true, status: "cryptographically_valid", bundleId: bundle.bundleId }); + expect(existsSync(labLedgerPath(consumerDir))).toBe(false); + expect(existsSync(labSqlitePath(consumerDir))).toBe(false); + expect(listCommunityEvidence(consumerDir)).toEqual([expect.objectContaining({ + bundleId: bundle.bundleId, + status: "cryptographically_valid", + activeRecordCount: 1, + revokedRecordCount: 0, + })]); + expect(importCommunityEvidenceBundle(bundle, consumerDir).created).toBe(false); + }); + + test("rejects cryptographically valid but unknown scenario authority", () => { + const publisherDir = configDir("ocx-cl10-publisher-"); + const consumerDir = configDir("ocx-cl10-consumer-"); + const bundle = signedBundle(publisherDir, "private.unknown.scenario"); + expect(() => importCommunityEvidenceBundle(bundle, consumerDir)).toThrow(/authority/i); + expect(listCommunityEvidence(consumerDir)).toEqual([]); + }); + + test("same-key revocation is verified, idempotent, and removes records from default community context", () => { + const publisherDir = configDir("ocx-cl10-publisher-"); + const consumerDir = configDir("ocx-cl10-consumer-"); + const signer = getOrCreatePublicPublisher(publisherDir); + const bundle = signPublicEvidenceBundle(projectPublicEvidence({ + createdDayUtc: "2026-08-12", + records: [{ observation: protocolObservation(), verdict: "VERIFIED" }], + }).bundle, signer); + importCommunityEvidenceBundle(bundle, consumerDir); + + const revocation = createPublicEvidenceRevocation({ + signer, + targetBundle: bundle, + issuedDayUtc: "2026-08-12", + reason: "evidence_invalidated", + targets: [{ kind: "record", id: bundle.records[0]!.recordId }], + }); + expect(verifyPublicEvidenceRevocation(revocation, bundle).status).toBe("cryptographically_valid"); + expect(importCommunityEvidenceRevocation(revocation, consumerDir).created).toBe(true); + expect(importCommunityEvidenceRevocation(revocation, consumerDir).created).toBe(false); + expect(listCommunityEvidence(consumerDir)[0]).toMatchObject({ activeRecordCount: 0, revokedRecordCount: 1 }); + }); + + test("rejects cross-key revocation and conflicting same-id bytes", () => { + const publisherDir = configDir("ocx-cl10-publisher-"); + const otherDir = configDir("ocx-cl10-other-"); + const consumerDir = configDir("ocx-cl10-consumer-"); + const signer = getOrCreatePublicPublisher(publisherDir); + const bundle = signPublicEvidenceBundle(projectPublicEvidence({ + createdDayUtc: "2026-08-12", + records: [{ observation: protocolObservation(), verdict: "VERIFIED" }], + }).bundle, signer); + importCommunityEvidenceBundle(bundle, consumerDir); + expect(() => createPublicEvidenceRevocation({ + signer: getOrCreatePublicPublisher(otherDir), + targetBundle: bundle, + issuedDayUtc: "2026-08-12", + reason: "publisher_retracted", + targets: [{ kind: "bundle", id: bundle.bundleId }], + })).toThrow(/publisher/i); + + const revocation = createPublicEvidenceRevocation({ + signer, + targetBundle: bundle, + issuedDayUtc: "2026-08-12", + reason: "publisher_retracted", + targets: [{ kind: "bundle", id: bundle.bundleId }], + }); + importCommunityEvidenceRevocation(revocation, consumerDir); + const conflict = { ...revocation, issuedDayUtc: "2026-08-13" }; + expect(() => importCommunityEvidenceRevocation(conflict, consumerDir)).toThrow(); + }); + + test("sensitive export purge removes local exports and local community copies but preserves third-party bundles", () => { + const consumerDir = configDir("ocx-cl10-consumer-"); + const thirdPartyDir = configDir("ocx-cl10-third-party-"); + const localBundle = signedBundle(consumerDir); + const localStored = writePublicEvidenceBundle(localBundle, consumerDir); + importCommunityEvidenceBundle(localBundle, consumerDir); + + const thirdPartyBundle = signedBundle(thirdPartyDir, "responses-core.protocol.sse-framing"); + importCommunityEvidenceBundle(thirdPartyBundle, consumerDir); + expect(listCommunityEvidence(consumerDir)).toHaveLength(2); + + purgeSensitiveEvidence({ + configDir: consumerDir, + targetArtifactDigests: [hex("sensitive-purge-target")], + purgeActions: ["export"], + recordedAt: Date.UTC(2026, 7, 12, 18, 0, 0), + }); + + expect(existsSync(localStored.path)).toBe(false); + expect(listCommunityEvidence(consumerDir).map((row) => row.bundleId)).toEqual([thirdPartyBundle.bundleId]); + }); +}); diff --git a/tests/lab-community-publisher-continuity.test.ts b/tests/lab-community-publisher-continuity.test.ts new file mode 100644 index 000000000..8a51937f2 --- /dev/null +++ b/tests/lab-community-publisher-continuity.test.ts @@ -0,0 +1,119 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + LAB_EVENT_SCHEMA_VERSION, + LAB_PRODUCER, + assignEventId, + subjectIdForSubject, + type ObservationEvent, + type ProtocolSubjectV1, +} from "../src/lab"; +import { + createPublicEvidenceRevocation, + getOrCreatePublicPublisher, + importCommunityEvidenceBundle, + importCommunityEvidenceRevocation, + listCommunityEvidence, + projectPublicEvidence, + signPublicEvidenceBundle, +} from "../src/lab/public"; + +const roots: string[] = []; +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +function configDir(prefix: string): string { + const root = mkdtempSync(join(tmpdir(), prefix)); + roots.push(root); + return root; +} + +function hex(seed: string): string { + return Bun.CryptoHasher.hash("sha256", seed, "hex"); +} + +function observation(): ObservationEvent { + const subject: ProtocolSubjectV1 = { + subjectSchemaVersion: 1, + subjectKind: "protocol", + opencodexCompatibilityVersion: "2.13.0", + effectiveAdapter: "openai-chat", + inboundProtocol: "openai-responses", + upstreamProtocol: "openai-chat", + surface: "responses-http", + behaviorFingerprint: hex("PRIVATE-publisher-continuity"), + }; + return assignEventId({ + schemaVersion: LAB_EVENT_SCHEMA_VERSION, + eventKind: "observation" as const, + recordedAt: Date.UTC(2026, 7, 12, 14, 37, 48), + producer: LAB_PRODUCER, + producerVersion: "2.13.0", + evidenceLayer: "protocol_conformance" as const, + scenarioId: "responses-core.protocol.request-shape", + scenarioVersion: "1.0.0", + scenarioManifestDigest: hex("scenario"), + suiteId: "responses-core", + suiteVersion: "1.0.0", + suiteManifestDigest: hex("suite"), + fixtureDigests: [hex("fixture")], + subject, + subjectId: subjectIdForSubject(subject), + startedAt: Date.UTC(2026, 7, 12, 14, 37, 40), + completedAt: Date.UTC(2026, 7, 12, 14, 37, 41), + executionMode: "fixture" as const, + attempt: 1, + limits: { totalTimeoutMs: 1000 }, + outcome: "pass" as const, + assertions: [{ id: "request-shape", operator: "equals", required: true, passed: true }], + environment: {}, + artifactRefs: [], + }) as ObservationEvent; +} + +function unsignedBundle() { + return projectPublicEvidence({ + createdDayUtc: "2026-08-12", + records: [{ observation: observation(), verdict: "VERIFIED" }], + }).bundle; +} + +describe("CL-10 publisher continuity", () => { + test("same content from two publishers coexists and revokes independently", () => { + const publisherA = configDir("ocx-cl10-publisher-a-"); + const publisherB = configDir("ocx-cl10-publisher-b-"); + const consumer = configDir("ocx-cl10-consumer-"); + const signerA = getOrCreatePublicPublisher(publisherA); + const signerB = getOrCreatePublicPublisher(publisherB); + const unsigned = unsignedBundle(); + const bundleA = signPublicEvidenceBundle(unsigned, signerA); + const bundleB = signPublicEvidenceBundle(unsigned, signerB); + + expect(bundleA.bundleId).toBe(bundleB.bundleId); + expect(bundleA.publisher.keyId).not.toBe(bundleB.publisher.keyId); + expect(importCommunityEvidenceBundle(bundleA, consumer).created).toBe(true); + expect(importCommunityEvidenceBundle(bundleB, consumer).created).toBe(true); + + let summaries = listCommunityEvidence(consumer); + expect(summaries).toHaveLength(2); + expect(new Set(summaries.map((row) => row.publisherKeyId)).size).toBe(2); + + const revocationA = createPublicEvidenceRevocation({ + signer: signerA, + targetBundle: bundleA, + issuedDayUtc: "2026-08-12", + reason: "publisher_retracted", + targets: [{ kind: "bundle", id: bundleA.bundleId }], + }); + expect(importCommunityEvidenceRevocation(revocationA, consumer).created).toBe(true); + + summaries = listCommunityEvidence(consumer); + const rowA = summaries.find((row) => row.publisherKeyId === bundleA.publisher.keyId)!; + const rowB = summaries.find((row) => row.publisherKeyId === bundleB.publisher.keyId)!; + expect(rowA).toMatchObject({ activeRecordCount: 0, revokedRecordCount: 1 }); + expect(rowB).toMatchObject({ activeRecordCount: 1, revokedRecordCount: 0 }); + }); +}); diff --git a/tests/lab-public-evidence-signature.test.ts b/tests/lab-public-evidence-signature.test.ts new file mode 100644 index 000000000..75744529b --- /dev/null +++ b/tests/lab-public-evidence-signature.test.ts @@ -0,0 +1,145 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync, statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve, sep } from "node:path"; +import { + LAB_EVENT_SCHEMA_VERSION, + LAB_PRODUCER, + assignEventId, + subjectIdForSubject, + type ObservationEvent, + type ProtocolSubjectV1, +} from "../src/lab"; +import { + getOrCreatePublicPublisher, + projectPublicEvidence, + readPublicEvidenceBundle, + signPublicEvidenceBundle, + verifyPublicEvidenceBundle, + writePublicEvidenceBundle, +} from "../src/lab/public"; +import { labPublicPublisherKeyPath, labPublicExportsDir } from "../src/lab/paths"; + +const roots: string[] = []; +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +function configDir(): string { + const root = mkdtempSync(join(tmpdir(), "ocx-cl10-sign-")); + roots.push(root); + return root; +} + +function hex(seed: string): string { + return Bun.CryptoHasher.hash("sha256", seed, "hex"); +} + +function observation(): ObservationEvent { + const subject: ProtocolSubjectV1 = { + subjectSchemaVersion: 1, + subjectKind: "protocol", + opencodexCompatibilityVersion: "2.13.0", + effectiveAdapter: "openai-chat", + inboundProtocol: "openai-responses", + upstreamProtocol: "openai-chat", + surface: "responses-http", + behaviorFingerprint: hex("PRIVATE-behavior"), + }; + return assignEventId({ + schemaVersion: LAB_EVENT_SCHEMA_VERSION, + eventKind: "observation" as const, + recordedAt: Date.UTC(2026, 7, 12, 14, 37, 48), + producer: LAB_PRODUCER, + producerVersion: "2.13.0", + evidenceLayer: "protocol_conformance" as const, + scenarioId: "responses-core.protocol.request-shape", + scenarioVersion: "1", + scenarioManifestDigest: hex("scenario"), + suiteId: "responses-core", + suiteVersion: "1", + suiteManifestDigest: hex("suite"), + fixtureDigests: [hex("fixture")], + subject, + subjectId: subjectIdForSubject(subject), + startedAt: Date.UTC(2026, 7, 12, 14, 37, 40), + completedAt: Date.UTC(2026, 7, 12, 14, 37, 41), + executionMode: "fixture" as const, + attempt: 1, + limits: { totalTimeoutMs: 1000 }, + outcome: "pass" as const, + assertions: [{ id: "request-shape", operator: "equals", required: true, passed: true, expectedSummary: "PRIVATE", observedSummary: "PRIVATE" }], + environment: {}, + artifactRefs: [], + }) as ObservationEvent; +} + +function unsignedBundle() { + return projectPublicEvidence({ + createdDayUtc: "2026-08-12", + records: [{ observation: observation(), verdict: "VERIFIED" }], + }).bundle; +} + +describe("CL-10 public publisher", () => { + test("creates a stable opaque Ed25519 publisher with a restricted private key file", () => { + const dir = configDir(); + const a = getOrCreatePublicPublisher(dir); + const b = getOrCreatePublicPublisher(dir); + expect(a.publisher).toEqual(b.publisher); + expect(a.publisher).toMatchObject({ algorithm: "ed25519" }); + expect(a.publisher.keyId).toMatch(/^[0-9a-f]{64}$/); + expect(a.publisher.publicKey.length).toBeGreaterThan(32); + expect(JSON.stringify(a)).not.toContain("PRIVATE KEY"); + expect(JSON.stringify(a)).not.toContain("privateKey"); + if (process.platform !== "win32") { + expect(statSync(labPublicPublisherKeyPath(dir)).mode & 0o777).toBe(0o600); + } + }); + + test("signs deterministic canonical bundle bytes and rejects tampering", () => { + const dir = configDir(); + const signer = getOrCreatePublicPublisher(dir); + const unsigned = unsignedBundle(); + const first = signPublicEvidenceBundle(unsigned, signer); + const second = signPublicEvidenceBundle(unsigned, signer); + expect(first.bundleDigest).toMatch(/^[0-9a-f]{64}$/); + expect(first.signature.signedDigest).toBe(first.bundleDigest); + expect(first.signature.signature).toBe(second.signature.signature); + expect(first.bundleDigest).toBe(second.bundleDigest); + expect(verifyPublicEvidenceBundle(first).status).toBe("cryptographically_valid"); + + const badSignature = { + ...first, + signature: { ...first.signature, signature: Buffer.alloc(64, 1).toString("base64") }, + }; + expect(verifyPublicEvidenceBundle(badSignature).status).toBe("signature_invalid"); + + const tampered = { + ...first, + records: first.records.map((record, index) => index === 0 ? { ...record, scenarioId: "tampered.scenario" } : record), + }; + expect(verifyPublicEvidenceBundle(tampered).status).toBe("digest_invalid"); + }); +}); + +describe("CL-10 public export storage", () => { + test("writes and reads a verified bundle only inside the restricted exports directory", () => { + const dir = configDir(); + const bundle = signPublicEvidenceBundle(unsignedBundle(), getOrCreatePublicPublisher(dir)); + const stored = writePublicEvidenceBundle(bundle, dir); + const exportsDir = resolve(labPublicExportsDir(dir)); + expect(resolve(stored.path).startsWith(exportsDir + sep)).toBe(true); + expect(stored.created).toBe(true); + expect(writePublicEvidenceBundle(bundle, dir).created).toBe(false); + const read = readPublicEvidenceBundle(bundle.bundleId, dir); + expect(read.bundleDigest).toBe(bundle.bundleDigest); + expect(verifyPublicEvidenceBundle(read).status).toBe("cryptographically_valid"); + }); + + test("rejects traversal-like bundle ids before filesystem access", () => { + const dir = configDir(); + expect(() => readPublicEvidenceBundle("../PRIVATE", dir)).toThrow(); + expect(() => readPublicEvidenceBundle("a".repeat(64) + "/PRIVATE", dir)).toThrow(); + }); +}); diff --git a/tests/lab-public-evidence.test.ts b/tests/lab-public-evidence.test.ts index e668a7965..12225ea8d 100644 --- a/tests/lab-public-evidence.test.ts +++ b/tests/lab-public-evidence.test.ts @@ -1,19 +1,32 @@ import { describe, expect, test } from "bun:test"; import { + FABRIC_SCENARIO_ID, + FABRIC_SCENARIO_VERSION, + FABRIC_SUITE_ID, + FABRIC_SUITE_VERSION, + FABRIC_VERIFIER_ID, LAB_EVENT_SCHEMA_VERSION, LAB_PRODUCER, assignEventId, + buildTaskSubjectV1, subjectIdForSubject, + taskSubjectId, type ObservationEvent, type ProtocolSubjectV1, type RouteSubjectV1, + type TaskSubjectV1, } from "../src/lab"; import { + PUBLIC_EVIDENCE_BUNDLE_SCHEMA_VERSION, PUBLIC_ROUTE_REGISTRY_V1, PublicEvidenceValidationError, + authorizePublicRouteSubject, + authorizePublicTaskSubject, isPublicIncidentRef, + projectPublicEvidence, projectPublicEvidenceRecord, publicEvidenceId, + validatePublicEvidenceBundleUnsigned, validatePublicEvidenceRecord, validatePublicRouteRegistryManifest, } from "../src/lab/public"; @@ -64,7 +77,13 @@ function protocolObservation(): ObservationEvent { expectedSummary: "CANARY-PRIVATE-EXPECTED", observedSummary: "CANARY-PRIVATE-OBSERVED", }], - environment: { localPath: "C:\\Users\\private\\repo" }, + environment: { + localPath: "C:\\Users\\private\\repo", + authorization: "Bearer sk-private-CANARY", + email: "private@example.com", + endpoint: "https://private.example.test/v1?tenant=acme", + ip: "10.23.45.67", + }, artifactRefs: [], sourceRefs: ["request_1234567890", "decision_1234567890"], }) as ObservationEvent; @@ -74,7 +93,7 @@ function routeObservation(): ObservationEvent { const subject: RouteSubjectV1 = { subjectSchemaVersion: 1, subjectKind: "route", - providerId: "openai", + providerId: "openai-apikey", providerInstanceFingerprint: hex("PRIVATE-provider-instance"), clientModelId: "gpt-5.6-sol", upstreamModelId: "gpt-5.6-sol", @@ -100,6 +119,36 @@ function routeObservation(): ObservationEvent { }) as ObservationEvent; } +function taskObservation(): ObservationEvent { + const route = routeObservation(); + const subject = buildTaskSubjectV1({ routeSubject: route.subject as RouteSubjectV1 }); + const subjectId = taskSubjectId(subject); + return assignEventId({ + ...route, + eventId: undefined, + evidenceLayer: "task_effectiveness" as const, + suiteId: FABRIC_SUITE_ID, + suiteVersion: FABRIC_SUITE_VERSION, + scenarioId: FABRIC_SCENARIO_ID, + scenarioVersion: FABRIC_SCENARIO_VERSION, + executionMode: "sandbox" as const, + subject, + subjectId, + sourceRefs: ["fabric_run_PRIVATE"], + }) as ObservationEvent; +} + +function routeAuthority(event = routeObservation()) { + const authority = authorizePublicRouteSubject({ + subject: event.subject as RouteSubjectV1, + localSubjectId: event.subjectId, + effectiveBaseUrl: "https://api.openai.com/v1", + privateBehaviorDimensions: [], + }); + if (!authority) throw new Error("expected public route authority"); + return authority; +} + describe("CL-10 public authority", () => { test("ships a closed, self-consistent public route registry manifest", () => { const manifest = validatePublicRouteRegistryManifest(PUBLIC_ROUTE_REGISTRY_V1); @@ -107,6 +156,7 @@ describe("CL-10 public authority", () => { expect(manifest.entries.length).toBeGreaterThan(0); expect(manifest.manifestDigest).toMatch(/^[0-9a-f]{64}$/); expect(manifest.entries.every((entry) => entry.providerId && entry.modelId)).toBe(true); + expect(manifest.entries.every((entry) => entry.canonicalBaseUrl.startsWith("https://"))).toBe(true); }); test("public incident references are closed corpus ids only", () => { @@ -116,10 +166,36 @@ describe("CL-10 public authority", () => { expect(isPublicIncidentRef("devlog/_plan/private.md")).toBe(false); expect(isPublicIncidentRef("IC-1")).toBe(false); }); + + test("authorises only an exact canonical route with no private behavior dimensions", () => { + const event = routeObservation(); + const authority = routeAuthority(event); + expect(authority.descriptor.providerId).toBe("openai-apikey"); + expect(authority.descriptor.modelId).toBe("gpt-5.6-sol"); + + expect(authorizePublicRouteSubject({ + subject: event.subject as RouteSubjectV1, + localSubjectId: event.subjectId, + effectiveBaseUrl: "https://proxy.private.example/v1", + privateBehaviorDimensions: [], + })).toBeNull(); + expect(authorizePublicRouteSubject({ + subject: event.subject as RouteSubjectV1, + localSubjectId: event.subjectId, + effectiveBaseUrl: "https://api.openai.com/v1", + privateBehaviorDimensions: ["headers"], + })).toBeNull(); + expect(authorizePublicRouteSubject({ + subject: event.subject as RouteSubjectV1, + localSubjectId: hex("wrong-local-subject"), + effectiveBaseUrl: "https://api.openai.com/v1", + privateBehaviorDimensions: [], + })).toBeNull(); + }); }); describe("CL-10 public projection", () => { - test("projects protocol evidence without leaking local ids, diagnostics, or assertion text", () => { + test("projects protocol evidence without leaking local ids, diagnostics, secrets, or assertion text", () => { const event = protocolObservation(); const result = projectPublicEvidenceRecord({ observation: event, verdict: "VERIFIED" }); expect(result.status).toBe("exportable"); @@ -140,6 +216,10 @@ describe("CL-10 public projection", () => { "CANARY-PRIVATE-EXPECTED", "CANARY-PRIVATE-OBSERVED", "C:\\Users\\private\\repo", + "sk-private-CANARY", + "private@example.com", + "private.example.test", + "10.23.45.67", "request_1234567890", "decision_1234567890", (event.subject as ProtocolSubjectV1).behaviorFingerprint, @@ -154,8 +234,68 @@ describe("CL-10 public projection", () => { expect(result).toEqual({ status: "not_exportable", reason: "private_route_identity" }); }); + test("projects an exact reviewed public route only with a trusted authority", () => { + const event = routeObservation(); + const authority = routeAuthority(event); + const result = projectPublicEvidenceRecord({ observation: event, verdict: "PROBED", routeAuthority: authority }); + expect(result.status).toBe("exportable"); + if (result.status !== "exportable") throw new Error("expected exportable route record"); + expect(result.record.subject).toMatchObject({ + subjectKind: "route", + providerId: "openai-apikey", + modelId: "gpt-5.6-sol", + }); + const serialized = JSON.stringify(result.record); + expect(serialized).not.toContain((event.subject as RouteSubjectV1).providerInstanceFingerprint); + expect(serialized).not.toContain((event.subject as RouteSubjectV1).endpointFingerprint); + expect(serialized).not.toContain((event.subject as RouteSubjectV1).behaviorFingerprint); + + const forged = { ...authority }; + expect(projectPublicEvidenceRecord({ observation: event, verdict: "PROBED", routeAuthority: forged })).toEqual({ + status: "not_exportable", + reason: "private_route_identity", + }); + }); + + test("projects only the frozen public Fabric task authority", () => { + const event = taskObservation(); + const routeSubject = (event.subject as TaskSubjectV1).routeSubject; + const routeEvent = routeObservation(); + const routeAuth = authorizePublicRouteSubject({ + subject: routeSubject, + localSubjectId: subjectIdForSubject(routeSubject), + effectiveBaseUrl: "https://api.openai.com/v1", + privateBehaviorDimensions: [], + }); + if (!routeAuth) throw new Error("expected route authority"); + const taskAuthority = authorizePublicTaskSubject({ + subject: event.subject as TaskSubjectV1, + localSubjectId: event.subjectId, + routeAuthority: routeAuth, + }); + expect(taskAuthority).not.toBeNull(); + const result = projectPublicEvidenceRecord({ + observation: event, + verdict: "VERIFIED", + taskAuthority: taskAuthority ?? undefined, + }); + expect(result.status).toBe("exportable"); + if (result.status !== "exportable") throw new Error("expected exportable task record"); + expect(result.record.subject).toMatchObject({ + subjectKind: "task", + taskClassId: FABRIC_SCENARIO_ID, + taskClassVersion: FABRIC_SCENARIO_VERSION, + verifierAuthorityId: FABRIC_VERIFIER_ID, + }); + const serialized = JSON.stringify(result.record); + expect(serialized).not.toContain(event.subjectId); + expect(serialized).not.toContain((event.subject as TaskSubjectV1).taskFixtureDigest); + expect(serialized).not.toContain((event.subject as TaskSubjectV1).verifierManifestDigest); + expect(routeEvent.subjectId).not.toBe(event.subjectId); + }); + test("uses domain-separated deterministic public ids", () => { - const payload = { providerId: "openai", modelId: "gpt-5.6-sol" }; + const payload = { providerId: "openai-apikey", modelId: "gpt-5.6-sol" }; const a = publicEvidenceId("subject", payload); const b = publicEvidenceId("subject", payload); const c = publicEvidenceId("record", payload); @@ -164,10 +304,33 @@ describe("CL-10 public projection", () => { expect(a).not.toBe(c); }); - test("runtime validation rejects unknown public fields", () => { + test("runtime validation rejects unknown top-level and nested public fields", () => { const result = projectPublicEvidenceRecord({ observation: protocolObservation(), verdict: "VERIFIED" }); if (result.status !== "exportable") throw new Error("expected exportable protocol record"); const withUnknown = { ...result.record, localSubjectId: "PRIVATE" }; expect(() => validatePublicEvidenceRecord(withUnknown)).toThrow(PublicEvidenceValidationError); + const nestedUnknown = { + ...result.record, + subject: { ...result.record.subject, behaviorFingerprint: hex("PRIVATE") }, + }; + expect(() => validatePublicEvidenceRecord(nestedUnknown)).toThrow(PublicEvidenceValidationError); + }); + + test("builds a closed deterministic unsigned bundle and reports exclusions", () => { + const privateRoute = routeObservation(); + const projected = projectPublicEvidence({ + createdDayUtc: "2026-08-12", + records: [ + { observation: protocolObservation(), verdict: "VERIFIED" }, + { observation: privateRoute, verdict: "PROBED" }, + ], + }); + expect(projected.bundle.schemaVersion).toBe(PUBLIC_EVIDENCE_BUNDLE_SCHEMA_VERSION); + expect(projected.bundle.records).toHaveLength(1); + expect(projected.bundle.artifacts).toEqual([]); + expect(projected.excluded).toEqual([{ index: 1, reason: "private_route_identity" }]); + expect(projected.bundle.bundleId).toMatch(/^[0-9a-f]{64}$/); + expect(validatePublicEvidenceBundleUnsigned(projected.bundle)).toEqual(projected.bundle); + expect(JSON.stringify(projected.bundle)).not.toContain(privateRoute.subjectId); }); }); diff --git a/tests/lab-public-surfaces.test.ts b/tests/lab-public-surfaces.test.ts new file mode 100644 index 000000000..e970bc276 --- /dev/null +++ b/tests/lab-public-surfaces.test.ts @@ -0,0 +1,282 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { handleLabCommand } from "../src/cli/lab"; +import { + labPublicExportsDir, + labPublicPublisherKeyPath, + persistConformanceResult, + rebuildLabProjection, +} from "../src/lab"; +import { createArtifactStore } from "../src/lab/artifacts/store"; +import { resolveProtocolExecutionContext } from "../src/lab/conformance/executor"; +import { discoverScenarios, loadCaseAuthority } from "../src/lab/conformance/manifest"; +import type { CaseRecord } from "../src/lab/conformance/types"; +import { queryLabObservations } from "../src/lab/query"; +import { handleManagementAPI } from "../src/server/management-api"; +import type { OcxConfig } from "../src/types"; +import { ManagementRequest } from "./helpers/management-auth"; + +const HOMES: string[] = []; + +afterEach(() => { + for (const home of HOMES.splice(0)) rmSync(home, { recursive: true, force: true }); + delete process.env.OPENCODEX_HOME; +}); + +function tempHome(): string { + const home = join(tmpdir(), `ocx-cl10-surfaces-${process.pid}-${Math.random().toString(16).slice(2)}`); + mkdirSync(home, { recursive: true, mode: 0o700 }); + HOMES.push(home); + return home; +} + +function syntheticPassResult(caseRecord: CaseRecord) { + return { + scenarioId: caseRecord.id, + suite: caseRecord.suite, + passed: true, + classification: "inconclusive" as const, + assertionResults: caseRecord.assertions.map((assertion) => ({ + id: assertion.id, + operator: assertion.operator, + required: assertion.required, + passed: true, + observedSummary: "PRIVATE-CANARY-OBSERVED", + })), + diagnostics: ["PRIVATE-CANARY-DIAGNOSTIC"], + executionContext: resolveProtocolExecutionContext(caseRecord), + startedAt: 1_700_000_000_000, + completedAt: 1_700_000_001_000, + }; +} + +function seedProtocolProjection(home: string): string { + const authority = loadCaseAuthority(); + const scenario = discoverScenarios(authority, ["responses-core"]) + .find((candidate) => candidate.id === "responses-core.protocol.request-shape") + ?? discoverScenarios(authority, ["responses-core"])[0]; + if (!scenario) throw new Error("no responses-core protocol scenario available"); + const store = createArtifactStore(join(home, "lab", "artifacts")); + try { + persistConformanceResult(syntheticPassResult(scenario), scenario, authority, { + configDir: home, + recordedAt: 1_700_000_001_100, + artifactStore: store, + }); + } finally { + store.close(); + } + rebuildLabProjection(home); + const rows = queryLabObservations( + { layer: "protocol_conformance", scenarioId: scenario.id }, + undefined, + 10, + home, + ); + const eventId = rows.items[0]?.eventId; + if (!eventId) throw new Error("seeded observation missing"); + return eventId; +} + +function config(home: string): OcxConfig { + void home; + return { port: 0, defaultProvider: "openai-apikey", providers: {} } as OcxConfig; +} + +async function api( + home: string, + path: string, + init: { method?: string; body?: unknown } = {}, +): Promise { + process.env.OPENCODEX_HOME = home; + const req = new ManagementRequest(`http://127.0.0.1${path}`, { + method: init.method ?? "GET", + ...(init.body !== undefined + ? { headers: { "content-type": "application/json" }, body: JSON.stringify(init.body) } + : {}), + }); + const response = await handleManagementAPI(req, new URL(req.url), config(home), { + refreshCodexCatalog: async () => {}, + }); + expect(response).not.toBeNull(); + return response!; +} + +async function captureCli(argv: string[], home: string): Promise<{ code: number; stdout: string; stderr: string }> { + const stdout: string[] = []; + const stderr: string[] = []; + const originalLog = console.log; + const originalError = console.error; + console.log = (...args: unknown[]) => { stdout.push(args.join(" ")); }; + console.error = (...args: unknown[]) => { stderr.push(args.join(" ")); }; + try { + return { + code: await handleLabCommand(argv, { configDir: home }), + stdout: stdout.join("\n"), + stderr: stderr.join("\n"), + }; + } finally { + console.log = originalLog; + console.error = originalError; + } +} + +function installNetworkCanary(): () => void { + const original = globalThis.fetch; + globalThis.fetch = (async () => { + throw new Error("CL10-NETWORK-CANARY"); + }) as typeof fetch; + return () => { globalThis.fetch = original; }; +} + +describe("CL-10 CLI local public evidence", () => { + test("preview is network-free and does not create publisher or export state", async () => { + const home = tempHome(); + const eventId = seedProtocolProjection(home); + const restoreFetch = installNetworkCanary(); + try { + const result = await captureCli(["public", "preview", "--event", eventId, "--json"], home); + expect(result.code).toBe(0); + const body = JSON.parse(result.stdout) as { bundle: { records: unknown[]; publisher?: unknown }; excluded: unknown[] }; + expect(body.bundle.records).toHaveLength(1); + expect(body.bundle).not.toHaveProperty("publisher"); + expect(body.excluded).toEqual([]); + expect(existsSync(labPublicPublisherKeyPath(home))).toBe(false); + expect(existsSync(labPublicExportsDir(home)) ? readdirSync(labPublicExportsDir(home)) : []).toEqual([]); + expect(result.stdout).not.toContain("PRIVATE-CANARY"); + } finally { + restoreFetch(); + } + }); + + test("explicit export signs and stores, then verify/import/community remain local", async () => { + const home = tempHome(); + const eventId = seedProtocolProjection(home); + const restoreFetch = installNetworkCanary(); + try { + const exported = await captureCli(["public", "export", "--event", eventId, "--json"], home); + expect(exported.code).toBe(0); + const exportBody = JSON.parse(exported.stdout) as { + bundle: { bundleId: string; publisher: { keyId: string } }; + stored: { path: string; created: boolean }; + }; + expect(exportBody.bundle.publisher.keyId).toMatch(/^[0-9a-f]{64}$/); + expect(exportBody.stored.created).toBe(true); + expect(existsSync(exportBody.stored.path)).toBe(true); + + const verified = await captureCli(["public", "verify", "--file", exportBody.stored.path, "--json"], home); + expect(verified.code).toBe(0); + expect(JSON.parse(verified.stdout)).toMatchObject({ + status: "cryptographically_valid", + bundleId: exportBody.bundle.bundleId, + publisherKeyId: exportBody.bundle.publisher.keyId, + locallyVerified: false, + }); + + const ledgerBefore = readFileSync(join(home, "lab", "compatibility.jsonl")); + const sqliteBefore = readFileSync(join(home, "lab", "compatibility.sqlite")); + const imported = await captureCli(["public", "import", "--file", exportBody.stored.path, "--json"], home); + expect(imported.code).toBe(0); + expect(JSON.parse(imported.stdout)).toMatchObject({ + status: "cryptographically_valid", + trustClass: "community_untrusted_v1", + bundleId: exportBody.bundle.bundleId, + }); + expect(readFileSync(join(home, "lab", "compatibility.jsonl")).equals(ledgerBefore)).toBe(true); + expect(readFileSync(join(home, "lab", "compatibility.sqlite")).equals(sqliteBefore)).toBe(true); + + const community = await captureCli(["public", "community", "--json"], home); + expect(community.code).toBe(0); + const communityBody = JSON.parse(community.stdout) as { evidence: Array<{ bundleId: string; trustClass: string }> }; + expect(communityBody.evidence).toEqual([ + expect.objectContaining({ bundleId: exportBody.bundle.bundleId, trustClass: "community_untrusted_v1" }), + ]); + } finally { + restoreFetch(); + } + }); + + test("has no publish command", async () => { + const home = tempHome(); + const result = await captureCli(["public", "publish", "--json"], home); + expect(result.code).toBe(2); + expect(result.stderr).toMatch(/unknown public subcommand|usage/i); + }); +}); + +describe("CL-10 management local public evidence", () => { + test("preview/export/verify/import/community are explicit authenticated local actions", async () => { + const home = tempHome(); + const eventId = seedProtocolProjection(home); + const restoreFetch = installNetworkCanary(); + try { + const preview = await api(home, "/api/lab/public/preview", { + method: "POST", + body: { eventIds: [eventId] }, + }); + expect(preview.status).toBe(200); + const previewBody = await preview.json() as { bundle: { records: unknown[]; publisher?: unknown } }; + expect(previewBody.bundle.records).toHaveLength(1); + expect(previewBody.bundle).not.toHaveProperty("publisher"); + expect(existsSync(labPublicPublisherKeyPath(home))).toBe(false); + + const exported = await api(home, "/api/lab/public/export", { + method: "POST", + body: { eventIds: [eventId] }, + }); + expect(exported.status).toBe(200); + const exportBody = await exported.json() as { + bundle: { bundleId: string; publisher: { keyId: string } }; + stored: { path: string; created: boolean }; + }; + expect(exportBody.stored.created).toBe(true); + + const verified = await api(home, "/api/lab/public/verify", { + method: "POST", + body: { bundle: exportBody.bundle }, + }); + expect(verified.status).toBe(200); + expect(await verified.json()).toMatchObject({ + status: "cryptographically_valid", + bundleId: exportBody.bundle.bundleId, + locallyVerified: false, + }); + + const ledgerBefore = readFileSync(join(home, "lab", "compatibility.jsonl")); + const imported = await api(home, "/api/lab/public/community/import", { + method: "POST", + body: { bundle: exportBody.bundle }, + }); + expect(imported.status).toBe(200); + expect(await imported.json()).toMatchObject({ + status: "cryptographically_valid", + trustClass: "community_untrusted_v1", + }); + expect(readFileSync(join(home, "lab", "compatibility.jsonl")).equals(ledgerBefore)).toBe(true); + + const community = await api(home, "/api/lab/public/community"); + expect(community.status).toBe(200); + expect(await community.json()).toMatchObject({ + evidence: [expect.objectContaining({ bundleId: exportBody.bundle.bundleId })], + }); + } finally { + restoreFetch(); + } + }); + + test("does not expose a remote publish endpoint", async () => { + const home = tempHome(); + process.env.OPENCODEX_HOME = home; + const req = new ManagementRequest("http://127.0.0.1/api/lab/public/publish", { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{}", + }); + const res = await handleManagementAPI(req, new URL(req.url), config(home), { + refreshCodexCatalog: async () => {}, + }); + expect(res).toBeNull(); + }); +});