From a7b1ea6c406fe710e2d1496bfc6b93ed38f6af1d Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 14 Aug 2026 00:39:39 +0200 Subject: [PATCH 01/12] feat(lab): CL-10 public evidence trust core Rebased onto current dev with the reviewed public evidence trust core, consumer durability recovery, sparse-array JCS hardening, and required Windows publisher-key ACL hardening. --- src/lab/conformance/jcs.ts | 40 +- src/lab/paths.ts | 23 ++ src/lab/public/bundle.ts | 207 ++++++++++ src/lab/public/community-authority.ts | 133 ++++++ src/lab/public/file-safety.ts | 155 +++++++ src/lab/public/ids.ts | 26 ++ src/lab/public/privacy.ts | 139 +++++++ src/lab/public/private-file.ts | 214 ++++++++++ src/lab/public/project.ts | 122 ++++++ src/lab/public/registry.ts | 43 ++ src/lab/public/signature.ts | 205 +++++++++ src/lab/public/storage.ts | 105 +++++ src/lab/public/strict-json.ts | 198 +++++++++ src/lab/public/time.ts | 19 + src/lab/public/types.ts | 171 ++++++++ src/lab/public/validate.ts | 390 ++++++++++++++++++ ...lab-private-file-consumer-recovery.test.ts | 70 ++++ tests/lab-private-file-durability.test.ts | 70 ++++ tests/lab-public-core-contract.test.ts | 162 ++++++++ tests/lab-public-file-safety.test.ts | 36 ++ tests/lab-public-security-regressions.test.ts | 52 +++ 21 files changed, 2578 insertions(+), 2 deletions(-) create mode 100644 src/lab/public/bundle.ts create mode 100644 src/lab/public/community-authority.ts create mode 100644 src/lab/public/file-safety.ts create mode 100644 src/lab/public/ids.ts create mode 100644 src/lab/public/privacy.ts create mode 100644 src/lab/public/private-file.ts create mode 100644 src/lab/public/project.ts create mode 100644 src/lab/public/registry.ts create mode 100644 src/lab/public/signature.ts create mode 100644 src/lab/public/storage.ts create mode 100644 src/lab/public/strict-json.ts create mode 100644 src/lab/public/time.ts create mode 100644 src/lab/public/types.ts create mode 100644 src/lab/public/validate.ts create mode 100644 tests/lab-private-file-consumer-recovery.test.ts create mode 100644 tests/lab-private-file-durability.test.ts create mode 100644 tests/lab-public-core-contract.test.ts create mode 100644 tests/lab-public-file-safety.test.ts create mode 100644 tests/lab-public-security-regressions.test.ts diff --git a/src/lab/conformance/jcs.ts b/src/lab/conformance/jcs.ts index 6bbcb923c7..6ef064ee00 100644 --- a/src/lab/conformance/jcs.ts +++ b/src/lab/conformance/jcs.ts @@ -1,5 +1,40 @@ /** RFC 8785 JSON Canonicalization Scheme (JCS) for deterministic equality. */ +function assertValidUnicodeScalarString(value: string): void { + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if (code >= 0xd800 && code <= 0xdbff) { + const next = value.charCodeAt(index + 1); + if (!(next >= 0xdc00 && next <= 0xdfff)) { + throw new TypeError("jcsStringify: lone UTF-16 surrogate is not valid Unicode"); + } + index += 1; + continue; + } + if (code >= 0xdc00 && code <= 0xdfff) { + throw new TypeError("jcsStringify: lone UTF-16 surrogate is not valid Unicode"); + } + } +} + +function stringifyJcsString(value: string): string { + assertValidUnicodeScalarString(value); + return JSON.stringify(value); +} + +function assertDenseJsonArray(value: readonly unknown[]): void { + for (let index = 0; index < value.length; index += 1) { + if (!Object.prototype.hasOwnProperty.call(value, index)) { + throw new TypeError("jcsStringify: sparse arrays / array holes are not representable in JCS"); + } + } + for (const key of Object.keys(value)) { + if (!/^(?:0|[1-9]\d*)$/.test(key) || Number(key) >= value.length) { + throw new TypeError("jcsStringify: arrays with extra enumerable properties are not representable in JCS"); + } + } +} + export function jcsStringify(value: unknown): string { if (value === undefined) throw new TypeError("jcsStringify: undefined is not representable in JCS"); if (value === null || typeof value === "boolean") return JSON.stringify(value); @@ -7,14 +42,15 @@ export function jcsStringify(value: unknown): string { if (!Number.isFinite(value)) throw new TypeError("jcsStringify: non-finite numbers are not representable in JCS"); return JSON.stringify(value); } - if (typeof value === "string") return JSON.stringify(value); + if (typeof value === "string") return stringifyJcsString(value); if (Array.isArray(value)) { + assertDenseJsonArray(value); return `[${value.map(jcsStringify).join(",")}]`; } if (typeof value === "object") { const obj = value as Record; const keys = Object.keys(obj).sort(); - return `{${keys.map((k) => `${JSON.stringify(k)}:${jcsStringify(obj[k])}`).join(",")}}`; + return `{${keys.map((key) => `${stringifyJcsString(key)}:${jcsStringify(obj[key])}`).join(",")}}`; } throw new TypeError(`jcsStringify: unsupported value type ${typeof value}`); } diff --git a/src/lab/paths.ts b/src/lab/paths.ts index f202f4121c..fa39148a9c 100644 --- a/src/lab/paths.ts +++ b/src/lab/paths.ts @@ -81,10 +81,25 @@ export function labScratchDir(configDir = getConfigDir()): string { return join(labRoot(configDir), "scratch"); } +/** Shared Lab export directory. Public evidence bundles intentionally live here too. */ export function labExportDir(configDir = getConfigDir()): string { return join(labRoot(configDir), "export"); } +export function labCommunityDir(configDir = getConfigDir()): string { + return join(labRoot(configDir), "community"); +} + +export function labPublicOriginDir(configDir = getConfigDir()): string { + return join(labRoot(configDir), "public-origin-v1"); +} + +export const LAB_PUBLIC_PUBLISHER_KEY_FILE = "publisher-ed25519.pem"; + +export function labPublicPublisherKeyPath(configDir = getConfigDir()): string { + return join(labRoot(configDir), LAB_PUBLIC_PUBLISHER_KEY_FILE); +} + /** Opaque per-installation salt for local fingerprinting (never exported as evidence). */ export function labInstallationSaltPath(configDir = getConfigDir()): string { return join(labRoot(configDir), "installation-salt.bin"); @@ -110,15 +125,21 @@ export function ensureLabDirs(configDir = getConfigDir()): { artifactsDir: string; scratchDir: string; exportDir: string; + communityDir: string; + publicOriginDir: string; } { const root = labRoot(configDir); const artifactsDir = labArtifactsDir(configDir); const scratchDir = labScratchDir(configDir); const exportDir = labExportDir(configDir); + const communityDir = labCommunityDir(configDir); + const publicOriginDir = labPublicOriginDir(configDir); ensureRestrictedDir(root, root); ensureRestrictedDir(artifactsDir, root); ensureRestrictedDir(scratchDir, root); ensureRestrictedDir(exportDir, root); + ensureRestrictedDir(communityDir, root); + ensureRestrictedDir(publicOriginDir, root); return { root, ledgerPath: labLedgerPath(configDir), @@ -126,5 +147,7 @@ export function ensureLabDirs(configDir = getConfigDir()): { artifactsDir, scratchDir, exportDir, + communityDir, + publicOriginDir, }; } diff --git a/src/lab/public/bundle.ts b/src/lab/public/bundle.ts new file mode 100644 index 0000000000..712a3c3102 --- /dev/null +++ b/src/lab/public/bundle.ts @@ -0,0 +1,207 @@ +import { jcsStringify } from "../digest"; +import { publicEvidenceId } from "./ids"; +import { + PUBLIC_EVIDENCE_BUNDLE_SCHEMA_VERSION, + PUBLIC_EXPORT_POLICY_VERSION, + type PublicArtifactV1, + type PublicEvidenceBundleUnsignedV1, + type PublicEvidenceRecordV1, + type PublicPublisherV1, +} from "./types"; +import { PublicEvidenceValidationError, validatePublicEvidenceRecord } from "./validate"; + +export const MAX_PUBLIC_BUNDLE_BYTES = 2 * 1024 * 1024; +export const MAX_PUBLIC_BUNDLE_RECORDS = 256; +export const MAX_PUBLIC_BUNDLE_ARTIFACTS = 16; +export const MAX_PUBLIC_ARTIFACT_BYTES = 256 * 1024; +export const MAX_PUBLIC_ARTIFACT_BYTES_TOTAL = 1024 * 1024; + +export interface PublicEvidenceContentInput { + records: PublicEvidenceRecordV1[]; + artifacts: PublicArtifactV1[]; + createdDayUtc: string; +} + +export interface BuildPublicEvidenceBundleInput extends PublicEvidenceContentInput { + publisher: PublicPublisherV1; +} + +function utcDay(value: string): string { + if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) { + throw new PublicEvidenceValidationError("invalid_day", "createdDayUtc must be YYYY-MM-DD"); + } + const parsed = new Date(`${value}T00:00:00.000Z`); + if (Number.isNaN(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== value) { + throw new PublicEvidenceValidationError("invalid_day", "createdDayUtc must be a real UTC day"); + } + return value; +} + +function validatePublisher(publisher: PublicPublisherV1): PublicPublisherV1 { + const raw = publisher as unknown as Record; + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + throw new PublicEvidenceValidationError("invalid_publisher", "publisher must be an object"); + } + const keys = Object.keys(raw); + if (keys.some((key) => !["algorithm", "keyId", "publicKey"].includes(key))) { + throw new PublicEvidenceValidationError("unknown_field", "publisher contains unknown fields"); + } + if (publisher.algorithm !== "ed25519") { + throw new PublicEvidenceValidationError("unsupported_algorithm", "publisher must use ed25519"); + } + if (!/^[0-9a-f]{64}$/.test(publisher.keyId)) { + throw new PublicEvidenceValidationError("invalid_publisher", "publisher.keyId must be sha256 hex"); + } + if (typeof publisher.publicKey !== "string" || publisher.publicKey.length === 0 || publisher.publicKey.length > 1024) { + throw new PublicEvidenceValidationError("invalid_publisher", "publisher.publicKey is invalid"); + } + const publicKeyBytes = Buffer.from(publisher.publicKey, "base64"); + if (publicKeyBytes.byteLength === 0 || publicKeyBytes.toString("base64") !== publisher.publicKey) { + throw new PublicEvidenceValidationError("invalid_publisher", "publisher.publicKey must use canonical base64"); + } + const expectedKeyId = publicEvidenceId("publisher_key", { + algorithm: publisher.algorithm, + publicKey: publisher.publicKey, + }); + if (publisher.keyId !== expectedKeyId) { + throw new PublicEvidenceValidationError("publisher_key_id_mismatch", "publisher.keyId does not match public key"); + } + return { algorithm: "ed25519", keyId: publisher.keyId, publicKey: publisher.publicKey }; +} + +function validateArtifacts(artifacts: PublicArtifactV1[]): PublicArtifactV1[] { + if (!Array.isArray(artifacts) || artifacts.length > MAX_PUBLIC_BUNDLE_ARTIFACTS) { + throw new PublicEvidenceValidationError("array_too_large", `artifacts exceeds ${MAX_PUBLIC_BUNDLE_ARTIFACTS}`); + } + let aggregate = 0; + const ids = new Set(); + return artifacts.map((artifact, index) => { + const raw = artifact as unknown as Record; + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + throw new PublicEvidenceValidationError("invalid_artifact", `artifacts[${index}] must be an object`); + } + if (Object.keys(raw).some((key) => !["artifactId", "artifactClass", "mediaType", "byteCount", "contentBase64"].includes(key))) { + throw new PublicEvidenceValidationError("unknown_field", `artifacts[${index}] contains unknown fields`); + } + if (!/^[0-9a-f]{64}$/.test(artifact.artifactId)) { + throw new PublicEvidenceValidationError("invalid_artifact", `artifacts[${index}].artifactId is invalid`); + } + if (typeof artifact.artifactClass !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:+-]{0,255}$/.test(artifact.artifactClass)) { + throw new PublicEvidenceValidationError("invalid_artifact", `artifacts[${index}].artifactClass is invalid`); + } + if (typeof artifact.mediaType !== "string" || artifact.mediaType.length === 0 || artifact.mediaType.length > 256) { + throw new PublicEvidenceValidationError("invalid_artifact", `artifacts[${index}].mediaType is invalid`); + } + if (!Number.isInteger(artifact.byteCount) || artifact.byteCount < 0 || artifact.byteCount > MAX_PUBLIC_ARTIFACT_BYTES) { + throw new PublicEvidenceValidationError("artifact_too_large", `artifacts[${index}].byteCount is invalid`); + } + let bytes: Buffer; + try { + bytes = Buffer.from(artifact.contentBase64, "base64"); + } catch { + throw new PublicEvidenceValidationError("invalid_artifact", `artifacts[${index}].contentBase64 is invalid`); + } + if (bytes.byteLength !== artifact.byteCount || bytes.toString("base64") !== artifact.contentBase64) { + throw new PublicEvidenceValidationError("invalid_artifact", `artifacts[${index}] byte count or base64 is non-canonical`); + } + const expectedArtifactId = publicEvidenceId("artifact", { + artifactClass: artifact.artifactClass, + mediaType: artifact.mediaType, + byteCount: artifact.byteCount, + contentBase64: artifact.contentBase64, + }); + if (artifact.artifactId !== expectedArtifactId) { + throw new PublicEvidenceValidationError("artifact_id_mismatch", `artifacts[${index}].artifactId mismatch`); + } + aggregate += artifact.byteCount; + if (aggregate > MAX_PUBLIC_ARTIFACT_BYTES_TOTAL) { + throw new PublicEvidenceValidationError("artifact_aggregate_too_large", "artifact aggregate exceeds 1 MiB"); + } + if (ids.has(artifact.artifactId)) { + throw new PublicEvidenceValidationError("duplicate_id", "artifacts contains duplicate ids"); + } + ids.add(artifact.artifactId); + return { ...artifact }; + }); +} + +/** Validate all publisher-independent bundle content before any signing-key state is touched. */ +export function normalizePublicEvidenceContent(input: PublicEvidenceContentInput): PublicEvidenceContentInput { + if (!Array.isArray(input.records) || input.records.length > MAX_PUBLIC_BUNDLE_RECORDS) { + throw new PublicEvidenceValidationError("array_too_large", `records exceeds ${MAX_PUBLIC_BUNDLE_RECORDS}`); + } + const records = input.records.map(validatePublicEvidenceRecord).sort((a, b) => a.recordId.localeCompare(b.recordId)); + if (new Set(records.map((record) => record.recordId)).size !== records.length) { + throw new PublicEvidenceValidationError("duplicate_id", "records contains duplicate ids"); + } + const artifacts = validateArtifacts(input.artifacts).sort((a, b) => a.artifactId.localeCompare(b.artifactId)); + const artifactIds = new Set(artifacts.map((artifact) => artifact.artifactId)); + for (const record of records) { + for (const artifactId of record.artifactRefs ?? []) { + if (!artifactIds.has(artifactId)) { + throw new PublicEvidenceValidationError("artifact_ref_missing", `record ${record.recordId} references a missing public artifact`); + } + } + } + return { records, artifacts, createdDayUtc: utcDay(input.createdDayUtc) }; +} + +export function canonicalPublicEvidenceContent( + input: PublicEvidenceContentInput, +): { canonical: boolean; normalized: PublicEvidenceContentInput } { + const normalized = normalizePublicEvidenceContent(input); + const canonical = input.records.length === normalized.records.length + && input.artifacts.length === normalized.artifacts.length + && input.records.every((record, index) => record.recordId === normalized.records[index]!.recordId) + && input.artifacts.every((artifact, index) => artifact.artifactId === normalized.artifacts[index]!.artifactId); + return { canonical, normalized }; +} + +export function hasCanonicalPublicEvidenceOrder(input: PublicEvidenceContentInput): boolean { + return canonicalPublicEvidenceContent(input).canonical; +} + +function buildFromNormalizedContent( + normalized: PublicEvidenceContentInput, + publisherInput: PublicPublisherV1, +): PublicEvidenceBundleUnsignedV1 { + const publisher = validatePublisher(publisherInput); + const content = { + schemaVersion: PUBLIC_EVIDENCE_BUNDLE_SCHEMA_VERSION, + exportPolicyVersion: PUBLIC_EXPORT_POLICY_VERSION, + createdDayUtc: normalized.createdDayUtc, + publisher, + records: normalized.records, + artifacts: normalized.artifacts, + }; + const bundleId = publicEvidenceId("bundle", content); + const bundleDigest = publicEvidenceId("bundle_digest", { ...content, bundleId }); + const bundle: PublicEvidenceBundleUnsignedV1 = { ...content, bundleId, bundleDigest }; + if (new TextEncoder().encode(jcsStringify(bundle)).byteLength > MAX_PUBLIC_BUNDLE_BYTES) { + throw new PublicEvidenceValidationError("bundle_too_large", "public bundle exceeds 2 MiB"); + } + return bundle; +} + +export function buildPublicEvidenceBundle(input: BuildPublicEvidenceBundleInput): PublicEvidenceBundleUnsignedV1 { + return buildFromNormalizedContent(normalizePublicEvidenceContent(input), input.publisher); +} + +export function expectedPublicBundleIdentityFromNormalized( + normalized: PublicEvidenceContentInput, + publisher: PublicPublisherV1, +): { bundleId: string; bundleDigest: string } { + const rebuilt = buildFromNormalizedContent(normalized, publisher); + return { bundleId: rebuilt.bundleId, bundleDigest: rebuilt.bundleDigest }; +} + +export function expectedPublicBundleIdentity(bundle: PublicEvidenceBundleUnsignedV1): { bundleId: string; bundleDigest: string } { + return expectedPublicBundleIdentityFromNormalized( + normalizePublicEvidenceContent({ + records: bundle.records, + artifacts: bundle.artifacts, + createdDayUtc: bundle.createdDayUtc, + }), + bundle.publisher, + ); +} diff --git a/src/lab/public/community-authority.ts b/src/lab/public/community-authority.ts new file mode 100644 index 0000000000..cc9e736075 --- /dev/null +++ b/src/lab/public/community-authority.ts @@ -0,0 +1,133 @@ +import { loadCaseAuthority } from "../conformance/manifest"; +import { + FABRIC_COMPATIBILITY_VERSION, + FABRIC_SCENARIO_ID, + FABRIC_SCENARIO_VERSION, + FABRIC_SUITE_ID, + FABRIC_SUITE_VERSION, + FABRIC_TASK_CLASS_ID, + FABRIC_TASK_CLASS_VERSION, +} from "../fabric/constants"; +import { loadFabricCaseAuthority } from "../fabric/manifest"; +import { verifierManifestDigest } from "../fabric/subject"; +import { findPublicRouteRegistryEntry } from "./registry"; +import type { PublicEvidenceBundleV1, PublicEvidenceRecordV1, PublicRouteSubjectV1 } from "./types"; +import { PublicEvidenceValidationError } from "./validate"; + +let cachedCaseAuthority: ReturnType | null = null; +let cachedFabricCaseAuthority: ReturnType | null = null; +let cachedVerifierManifestDigest: string | null = null; + +function caseAuthority(): ReturnType { + cachedCaseAuthority ??= loadCaseAuthority(); + return cachedCaseAuthority; +} + +function fabricCaseAuthority(): ReturnType { + cachedFabricCaseAuthority ??= loadFabricCaseAuthority(); + return cachedFabricCaseAuthority; +} + +function reviewedVerifierManifestDigest(): string { + cachedVerifierManifestDigest ??= verifierManifestDigest(); + return cachedVerifierManifestDigest; +} + +function validateRouteAuthority(subject: PublicRouteSubjectV1): void { + const entry = findPublicRouteRegistryEntry(subject.providerId, subject.modelId); + if (!entry || !entry.adapterFamilies.includes(subject.adapterFamily)) { + throw new PublicEvidenceValidationError("public_authority", "public route is not in reviewed registry authority"); + } +} + +function validateAssertionAuthority( + record: PublicEvidenceRecordV1, + assertions: readonly { id: string; required: boolean }[], +): void { + const allowed = new Map(assertions.map((assertion) => [assertion.id, assertion.required] as const)); + if (allowed.size !== assertions.length) { + throw new PublicEvidenceValidationError("public_authority", "reviewed scenario assertion authority contains duplicates"); + } + if (record.assertions.length !== allowed.size) { + throw new PublicEvidenceValidationError( + "public_authority", + "public assertion set does not exactly match reviewed scenario authority", + ); + } + const seen = new Set(); + for (const assertion of record.assertions) { + if (seen.has(assertion.id)) { + throw new PublicEvidenceValidationError("public_authority", "public assertion set contains duplicate assertion ids"); + } + seen.add(assertion.id); + if (!allowed.has(assertion.id) || allowed.get(assertion.id) !== assertion.required) { + throw new PublicEvidenceValidationError( + "public_authority", + "public assertion id/required flag is not in reviewed scenario authority", + ); + } + } + for (const assertionId of allowed.keys()) { + if (!seen.has(assertionId)) { + throw new PublicEvidenceValidationError("public_authority", "public assertion set is missing reviewed scenario authority"); + } + } +} + +function validateTaskAuthority(record: PublicEvidenceRecordV1): void { + const fabricAuthority = fabricCaseAuthority(); + const caseRecord = fabricAuthority.cases.find((candidate) => candidate.id === FABRIC_SCENARIO_ID); + if ( + !caseRecord + || 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_TASK_CLASS_ID + || record.subject.taskClassVersion !== FABRIC_TASK_CLASS_VERSION + || record.subject.taskFixtureDigest !== caseRecord.fixture.digest + || record.subject.verifierManifestDigest !== reviewedVerifierManifestDigest() + || record.subject.fabricCompatibilityVersion !== FABRIC_COMPATIBILITY_VERSION + ) { + throw new PublicEvidenceValidationError("public_authority", "task scenario/verifier authority mismatch"); + } + validateAssertionAuthority(record, caseRecord.assertions); + validateRouteAuthority(record.subject.route); +} + +function validateScenarioAuthority(record: PublicEvidenceRecordV1): void { + if (record.evidenceLayer === "task_effectiveness") { + validateTaskAuthority(record); + return; + } + + const authority = caseAuthority(); + 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("public_authority", "scenario/suite authority mismatch"); + } + validateAssertionAuthority(record, caseRecord.assertions); + + if (record.evidenceLayer === "live_route_compatibility") { + if (record.subject.subjectKind !== "route") { + throw new PublicEvidenceValidationError("public_authority", "live route subject mismatch"); + } + validateRouteAuthority(record.subject); + } +} + +/** Repository-owned authority gate used by both local signing and community imports. */ +export function validatePublicEvidenceAuthorities(records: readonly PublicEvidenceRecordV1[]): void { + for (const record of records) validateScenarioAuthority(record); +} + +export function validateCommunityEvidenceAuthorities(bundle: PublicEvidenceBundleV1): PublicEvidenceBundleV1 { + validatePublicEvidenceAuthorities(bundle.records); + return bundle; +} diff --git a/src/lab/public/file-safety.ts b/src/lab/public/file-safety.ts new file mode 100644 index 0000000000..78134929cf --- /dev/null +++ b/src/lab/public/file-safety.ts @@ -0,0 +1,155 @@ +import { + closeSync, + constants as fsConstants, + fstatSync, + fsyncSync, + lstatSync, + openSync, + readFileSync, + readdirSync, + unlinkSync, +} from "node:fs"; +import { basename, dirname, join } from "node:path"; +import { isPrivateFileStageName } from "./private-file"; +import { PublicEvidenceValidationError } from "./validate"; + +const O_NOFOLLOW = (fsConstants as { O_NOFOLLOW?: number }).O_NOFOLLOW ?? 0; + +export interface PrivateRegularFileReadOptions { + maxBytes: number; + errorCode: string; + errorMessage: string; + sizeErrorCode?: string; + sizeErrorMessage?: string; + requireMode600?: boolean; +} + +function sizeError(options: PrivateRegularFileReadOptions): PublicEvidenceValidationError { + return new PublicEvidenceValidationError( + options.sizeErrorCode ?? options.errorCode, + options.sizeErrorMessage ?? options.errorMessage, + ); +} + +/** + * Heal only the publication-specific hard link left behind when the final name was linked + * but the parent-directory durability check failed. The stage must be target-scoped and + * inode-identical to the final file; unrelated hard links remain and are rejected below. + */ +function recoverPublishedPrivateFileStage(path: string): void { + if (process.platform === "win32") return; + const finalStats = lstatSync(path); + if (finalStats.isSymbolicLink() || !finalStats.isFile() || finalStats.nlink <= 1) return; + + const dir = dirname(path); + const prefix = `.${basename(path)}.`; + const candidates: string[] = []; + for (const name of readdirSync(dir)) { + if (!name.startsWith(prefix) || !isPrivateFileStageName(name)) continue; + const stagePath = join(dir, name); + try { + const stageStats = lstatSync(stagePath); + if ( + stageStats.isFile() + && !stageStats.isSymbolicLink() + && stageStats.dev === finalStats.dev + && stageStats.ino === finalStats.ino + ) { + candidates.push(stagePath); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + } + if (candidates.length === 0) return; + + // The final directory entry already exists. Make that entry durable before deleting + // the recovery witness. A real fsync failure propagates and the strict read stays closed. + let dirFd: number | null = null; + try { + dirFd = openSync(dir, fsConstants.O_RDONLY); + fsyncSync(dirFd); + } finally { + if (dirFd !== null) closeSync(dirFd); + } + + for (const stagePath of candidates) { + try { + const stageStats = lstatSync(stagePath); + if ( + stageStats.isFile() + && !stageStats.isSymbolicLink() + && stageStats.dev === finalStats.dev + && stageStats.ino === finalStats.ino + ) { + unlinkSync(stagePath); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + } +} + +function withPrivateRegularFile( + path: string, + options: PrivateRegularFileReadOptions, + consume: (fd: number, size: number) => T, +): T { + let pathStats = lstatSync(path); + if (!pathStats.isSymbolicLink() && pathStats.isFile() && pathStats.nlink > 1) { + recoverPublishedPrivateFileStage(path); + pathStats = lstatSync(path); + } + if (pathStats.isSymbolicLink() || !pathStats.isFile() || pathStats.nlink !== 1) { + throw new PublicEvidenceValidationError(options.errorCode, options.errorMessage); + } + if (pathStats.size > options.maxBytes) throw sizeError(options); + + const fd = openSync(path, fsConstants.O_RDONLY | O_NOFOLLOW); + try { + const stats = fstatSync(fd); + if ( + !stats.isFile() + || stats.nlink !== 1 + || stats.dev !== pathStats.dev + || stats.ino !== pathStats.ino + ) { + throw new PublicEvidenceValidationError(options.errorCode, options.errorMessage); + } + if (stats.size > options.maxBytes) throw sizeError(options); + if (options.requireMode600 && process.platform !== "win32" && (stats.mode & 0o777) !== 0o600) { + throw new PublicEvidenceValidationError(options.errorCode, options.errorMessage); + } + return consume(fd, stats.size); + } finally { + closeSync(fd); + } +} + +/** + * Inspect a file only after proving that the pathname and checked descriptor refer to + * the same private regular file. This keeps quota scans descriptor-bound without + * reading every cached object into memory. + */ +export function privateRegularFileSize( + path: string, + options: PrivateRegularFileReadOptions, +): number { + return withPrivateRegularFile(path, options, (_fd, size) => size); +} + +/** + * Read bytes only after proving that the pathname and the consumed descriptor refer to + * the same private regular file. The lstat/dev+ino comparison keeps the protection on + * platforms where O_NOFOLLOW is unavailable instead of silently following a symlink. + */ +export function readPrivateRegularFile( + path: string, + options: PrivateRegularFileReadOptions, +): Buffer { + return withPrivateRegularFile(path, options, (fd) => { + const bytes = readFileSync(fd); + if (bytes.byteLength > options.maxBytes) throw sizeError(options); + return bytes; + }); +} diff --git a/src/lab/public/ids.ts b/src/lab/public/ids.ts new file mode 100644 index 0000000000..ff78437545 --- /dev/null +++ b/src/lab/public/ids.ts @@ -0,0 +1,26 @@ +import { domainHash, jcsStringify } from "../digest"; + +export type PublicEvidenceIdKind = + | "subject" + | "record" + | "bundle" + | "bundle_digest" + | "artifact" + | "publisher_key" + | "revocation" + | "route_registry"; + +const PUBLIC_EVIDENCE_DOMAIN: Record = { + subject: "ocx-lab-public:subject:v1", + record: "ocx-lab-public:record:v1", + bundle: "ocx-lab-public:bundle:v1", + bundle_digest: "ocx-lab-public:bundle-digest:v1", + artifact: "ocx-lab-public:artifact:v1", + publisher_key: "ocx-lab-public:publisher-key:v1", + revocation: "ocx-lab-public:revocation:v1", + route_registry: "ocx-lab-public:route-registry:v1", +}; + +export function publicEvidenceId(kind: PublicEvidenceIdKind, payload: unknown): string { + return domainHash(PUBLIC_EVIDENCE_DOMAIN[kind], jcsStringify(payload)); +} diff --git a/src/lab/public/privacy.ts b/src/lab/public/privacy.ts new file mode 100644 index 0000000000..7c963cf795 --- /dev/null +++ b/src/lab/public/privacy.ts @@ -0,0 +1,139 @@ +import { isIP } from "node:net"; +import type { + PublicArtifactV1, + PublicEvidenceBundleUnsignedV1, + PublicEvidenceBundleV1, + PublicEvidenceRecordV1, + PublicEvidenceSubjectV1, +} from "./types"; +import { PublicEvidenceValidationError } from "./validate"; + +const FORBIDDEN_PUBLIC_STRING_PATTERNS: ReadonlyArray<{ label: string; pattern: RegExp }> = [ + { label: "URL", pattern: /(?:https?|file):\/\//i }, + { label: "local path", pattern: /(?:[A-Za-z]:[\\/]|(?:^|[\\/])(?:Users|home)[\\/])/i }, + { label: "email", pattern: /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/i }, + { label: "IP address", pattern: /\b(?:\d{1,3}\.){3}\d{1,3}\b|\[[0-9a-f:]{2,}\]/i }, + { label: "query string", pattern: /[?&][A-Za-z0-9_.~-]+=/ }, + { label: "authorization/header material", pattern: /\b(?:authorization|proxy-authorization|cookie|set-cookie|x-api-key|api[_-]?key|bearer)\b/i }, + { label: "credential", pattern: /\b(?:sk-[A-Za-z0-9_-]{8,}|gh[opusr]_[A-Za-z0-9]{8,}|github_pat_[A-Za-z0-9_]{8,}|AKIA[0-9A-Z]{12,})\b/ }, + { label: "private key", pattern: /-----BEGIN [^-]*PRIVATE KEY-----/i }, + { label: "local request/decision/Fabric id", pattern: /\b(?:request|decision|fabric)_[A-Za-z0-9_-]{6,}\b/i }, + { label: "account/project/tenant context", pattern: /\b(?:account|tenant|project|organization|deployment)[=:][^\s]+/i }, + { label: "precise timestamp", pattern: /\b\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/ }, +]; + +const PUBLIC_TEXT_ARTIFACT_MEDIA_TYPES = new Set([ + "application/json", + "application/json; charset=utf-8", + "text/markdown", + "text/markdown; charset=utf-8", + "text/plain", + "text/plain; charset=utf-8", +]); + +function containsIpLiteral(value: string): boolean { + if (isIP(value) !== 0) return true; + for (const candidate of value.match(/[0-9A-Fa-f:]{2,}/g) ?? []) { + if (candidate.includes(":") && isIP(candidate) === 6) return true; + } + return false; +} + +function assertPrivacySafeString(value: string, field: string): void { + // `PUBLIC_IDENTIFIER` intentionally permits `:` for reviewed identifiers, so use + // Node's IP parser to validate colon-bearing candidates instead of rejecting them + // with a broad regex. This catches both whole-string and embedded IPv6 literals. + if (containsIpLiteral(value)) { + throw new PublicEvidenceValidationError( + "privacy_rejected", + `${field} contains forbidden IP address material`, + ); + } + for (const { label, pattern } of FORBIDDEN_PUBLIC_STRING_PATTERNS) { + if (pattern.test(value)) { + throw new PublicEvidenceValidationError( + "privacy_rejected", + `${field} contains forbidden ${label} material`, + ); + } + } +} + +function scanSubject(subject: PublicEvidenceSubjectV1, field: string): void { + if (subject.subjectKind === "protocol") { + assertPrivacySafeString(subject.compatibilityVersion, `${field}.compatibilityVersion`); + assertPrivacySafeString(subject.adapterFamily, `${field}.adapterFamily`); + assertPrivacySafeString(subject.inboundProtocol, `${field}.inboundProtocol`); + assertPrivacySafeString(subject.upstreamProtocol, `${field}.upstreamProtocol`); + assertPrivacySafeString(subject.surface, `${field}.surface`); + return; + } + if (subject.subjectKind === "route") { + assertPrivacySafeString(subject.providerId, `${field}.providerId`); + assertPrivacySafeString(subject.modelId, `${field}.modelId`); + assertPrivacySafeString(subject.adapterFamily, `${field}.adapterFamily`); + assertPrivacySafeString(subject.compatibilityVersion, `${field}.compatibilityVersion`); + return; + } + scanSubject(subject.route, `${field}.route`); + assertPrivacySafeString(subject.taskClassId, `${field}.taskClassId`); + assertPrivacySafeString(subject.taskClassVersion, `${field}.taskClassVersion`); + assertPrivacySafeString(subject.fabricCompatibilityVersion, `${field}.fabricCompatibilityVersion`); +} + +function scanArtifact(artifact: PublicArtifactV1, index: number): void { + const field = `bundle.artifacts[${index}]`; + assertPrivacySafeString(artifact.artifactClass, `${field}.artifactClass`); + assertPrivacySafeString(artifact.mediaType, `${field}.mediaType`); + + if (!PUBLIC_TEXT_ARTIFACT_MEDIA_TYPES.has(artifact.mediaType.toLowerCase())) { + throw new PublicEvidenceValidationError( + "privacy_rejected", + `${field}.mediaType is not in the closed public text-artifact set`, + ); + } + if (typeof artifact.contentBase64 !== "string") { + throw new PublicEvidenceValidationError("privacy_rejected", `${field}.contentBase64 is invalid`); + } + const bytes = Buffer.from(artifact.contentBase64, "base64"); + if (bytes.toString("base64") !== artifact.contentBase64 || bytes.byteLength !== artifact.byteCount) { + throw new PublicEvidenceValidationError( + "privacy_rejected", + `${field}.contentBase64 is non-canonical or does not match byteCount`, + ); + } + let text: string; + try { + text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch { + throw new PublicEvidenceValidationError("privacy_rejected", `${field} is not valid UTF-8 text`); + } + assertPrivacySafeString(text, `${field}.content`); +} + +export function validatePublicEvidenceRecordPrivacy(record: PublicEvidenceRecordV1): void { + assertPrivacySafeString(record.suiteId, "record.suiteId"); + assertPrivacySafeString(record.suiteVersion, "record.suiteVersion"); + assertPrivacySafeString(record.scenarioId, "record.scenarioId"); + assertPrivacySafeString(record.scenarioVersion, "record.scenarioVersion"); + scanSubject(record.subject, "record.subject"); + for (const [index, assertion] of record.assertions.entries()) { + assertPrivacySafeString(assertion.id, `record.assertions[${index}].id`); + } + for (const [index, incident] of (record.incidentRefs ?? []).entries()) { + assertPrivacySafeString(incident.corpusId, `record.incidentRefs[${index}].corpusId`); + } +} + +/** + * Second-pass CL-10 export privacy boundary. Hashes, signatures and publisher public-key + * bytes are intentionally not pattern-scanned; every human-semantic public string and + * every final text artifact byte is scanned before local signing/storage or import. + */ +export function validatePublicEvidencePrivacy( + bundle: PublicEvidenceBundleUnsignedV1 | PublicEvidenceBundleV1, +): void { + assertPrivacySafeString(bundle.createdDayUtc, "bundle.createdDayUtc"); + for (const record of bundle.records) validatePublicEvidenceRecordPrivacy(record); + for (const [index, artifact] of bundle.artifacts.entries()) scanArtifact(artifact, index); +} diff --git a/src/lab/public/private-file.ts b/src/lab/public/private-file.ts new file mode 100644 index 0000000000..190f9a497e --- /dev/null +++ b/src/lab/public/private-file.ts @@ -0,0 +1,214 @@ +import { randomUUID } from "node:crypto"; +import { + closeSync, + constants as fsConstants, + fsyncSync, + linkSync, + lstatSync, + openSync, + readFileSync, + readdirSync, + unlinkSync, + writeSync, +} from "node:fs"; +import { basename, dirname, join } from "node:path"; + +export type PrivateFileCommitFault = "before_publish" | "parent_directory_sync" | null; +let privateFileCommitFaultForTests: PrivateFileCommitFault = null; +const PRIVATE_STAGE_RE = /^\..+\.(\d+)\.[0-9a-f-]{36}\.tmp$/; + +function cleanup(path: string): void { + try { unlinkSync(path); } catch { /* absent/already removed */ } +} + +function pidDefinitelyDead(pid: number): boolean { + try { + process.kill(pid, 0); + return false; + } catch (error) { + return (error as NodeJS.ErrnoException).code === "ESRCH"; + } +} + +function staleTempPrefix(finalPath: string): string { + return `.${basename(finalPath)}.`; +} + +function fsyncParentBestEffort(path: string): void { + if (process.platform === "win32") return; + let fd: number | null = null; + try { + fd = openSync(dirname(path), fsConstants.O_RDONLY); + fsyncSync(fd); + } catch { + // Cleanup durability is best-effort. Publication durability uses the strict + // fsyncParentForPublication path below and never swallows POSIX failures. + } finally { + if (fd !== null) closeSync(fd); + } +} + +function fsyncParentForPublication(path: string): void { + // Node does not provide a portable directory-fsync contract on Windows. The + // exclusive hard-link publication remains atomic there, while POSIX requires + // the parent directory sync before publication is reported as durable. + if (process.platform === "win32") return; + if (privateFileCommitFaultForTests === "parent_directory_sync") { + throw new Error("synthetic private-file parent directory sync failure"); + } + let fd: number | null = null; + try { + fd = openSync(dirname(path), fsConstants.O_RDONLY); + fsyncSync(fd); + } catch (error) { + if (error instanceof Error && error.message.includes("synthetic private-file")) throw error; + const code = (error as NodeJS.ErrnoException).code ?? "unknown"; + const wrapped = new Error(`private-file parent directory sync failed (${code})`); + (wrapped as Error & { cause?: unknown }).cause = error; + throw wrapped; + } finally { + if (fd !== null) closeSync(fd); + } +} + +export function isPrivateFileStageName(name: string): boolean { + return PRIVATE_STAGE_RE.test(name); +} + +/** Reclaim all private-file stages in a directory whose writer is definitely dead. */ +export function cleanupStalePrivateFileStagesInDir(dir: string): void { + let names: string[]; + try { + names = readdirSync(dir); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return; + throw error; + } + let changed = false; + for (const name of names) { + const match = PRIVATE_STAGE_RE.exec(name); + if (!match) continue; + const pid = Number(match[1]); + if (!Number.isSafeInteger(pid) || pid <= 0 || pid === process.pid || !pidDefinitelyDead(pid)) continue; + try { + unlinkSync(join(dir, name)); + changed = true; + } catch { + // Another cleanup or writer may have removed it after enumeration. + } + } + if (changed) fsyncParentBestEffort(join(dir, ".")); +} + +/** Reclaim staging links from writers that are definitely no longer alive. */ +export function cleanupStalePrivateFileStages(finalPath: string): void { + cleanupStalePrivateFileStagesInDir(dirname(finalPath)); +} + +/** Remove only stage links that already reference the durable final inode. */ +function cleanupPublishedPrivateFileStages(finalPath: string): void { + let finalStats; + try { + finalStats = lstatSync(finalPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return; + throw error; + } + if (!finalStats.isFile() || finalStats.isSymbolicLink()) return; + + const dir = dirname(finalPath); + const prefix = staleTempPrefix(finalPath); + let changed = false; + for (const name of readdirSync(dir)) { + if (!name.startsWith(prefix) || !PRIVATE_STAGE_RE.test(name)) continue; + const stagePath = join(dir, name); + try { + const stageStats = lstatSync(stagePath); + if (!stageStats.isFile() || stageStats.isSymbolicLink()) continue; + if (stageStats.dev !== finalStats.dev || stageStats.ino !== finalStats.ino) continue; + unlinkSync(stagePath); + changed = true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + } + if (changed) fsyncParentBestEffort(finalPath); +} + +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 Error("private file write made no progress"); + offset += count; + } +} + +/** + * Publish immutable mode-0600 bytes without ever exposing a partially-written final path. + * The caller owns EEXIST comparison semantics because some objects are idempotent and + * others are identity conflicts. Staging files are target-scoped and stale stages from + * definitely-dead writers are reclaimed on the next read or publication attempt. + */ +export function publishPrivateFileExclusive( + finalPath: string, + bytes: Uint8Array, +): { created: boolean } { + cleanupStalePrivateFileStages(finalPath); + const tempPath = join( + dirname(finalPath), + `${staleTempPrefix(finalPath)}${process.pid}.${randomUUID()}.tmp`, + ); + let fd: number | null = null; + let preservePublishedStage = false; + try { + fd = openSync(tempPath, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL, 0o600); + writeAll(fd, bytes); + fsyncSync(fd); + closeSync(fd); + fd = null; + + if (privateFileCommitFaultForTests === "before_publish") { + throw new Error("synthetic private-file commit failure before publish"); + } + + try { + linkSync(tempPath, finalPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EEXIST") { + // A prior publication may have linked the final entry but failed while + // syncing the parent directory. Re-sync before reporting idempotent success, + // then remove only stages that are hard links to that durable final inode. + fsyncParentForPublication(finalPath); + cleanupPublishedPrivateFileStages(finalPath); + return { created: false }; + } + throw error; + } + try { + fsyncParentForPublication(finalPath); + } catch (error) { + // The final name exists, but POSIX durability was not established. Keep this + // exact hard-link stage so a retry can re-sync and then identify it by inode. + preservePublishedStage = true; + throw error; + } + return { created: true }; + } finally { + if (fd !== null) closeSync(fd); + if (!preservePublishedStage) { + cleanup(tempPath); + fsyncParentBestEffort(finalPath); + } + } +} + +export function readPublishedPrivateFile(path: string): Buffer { + cleanupStalePrivateFileStages(path); + return readFileSync(path); +} + +/** Test-only fault seam at the atomic publication point. Import this module directly in tests. */ +export function setPrivateFileCommitFaultForTests(fault: PrivateFileCommitFault): void { + privateFileCommitFaultForTests = fault; +} diff --git a/src/lab/public/project.ts b/src/lab/public/project.ts new file mode 100644 index 0000000000..611002ac29 --- /dev/null +++ b/src/lab/public/project.ts @@ -0,0 +1,122 @@ +import type { CompatibilityVerdict } from "../constants"; +import type { ObservationEvent, ProtocolSubjectV1 } from "../events/types"; +import { validatePublicEvidenceAuthorities } from "./community-authority"; +import { publicEvidenceId } from "./ids"; +import { validatePublicEvidenceRecordPrivacy } from "./privacy"; +import { publicUtcDay } from "./time"; +import { + PUBLIC_ADAPTER_FAMILIES, + type PublicAdapterFamily, + type PublicEvidenceProjectionResult, + type PublicEvidenceRecordV1, + type PublicIncidentRefV1, + type PublicProtocolSubjectV1, +} from "./types"; +import { + isPublicIncidentRef, + PublicEvidenceValidationError, + validatePublicEvidenceRecord, +} from "./validate"; + +const PROJECTOR_INVARIANT_ERROR_CODES = new Set([ + "subject_id_mismatch", + "record_id_mismatch", + "public_selection_time", +]); + +export interface ProjectPublicEvidenceRecordInput { + observation: ObservationEvent; + verdict: CompatibilityVerdict; + incidentRefs?: string[]; + publicArtifactRefs?: string[]; +} + +function asPublicAdapterFamily(value: string): PublicAdapterFamily | undefined { + return (PUBLIC_ADAPTER_FAMILIES as readonly string[]).includes(value) + ? value as PublicAdapterFamily + : undefined; +} + +function projectProtocolSubject(subject: ProtocolSubjectV1): PublicProtocolSubjectV1 | undefined { + const adapterFamily = asPublicAdapterFamily(subject.effectiveAdapter); + if (!adapterFamily) return undefined; + return { + subjectKind: "protocol", + compatibilityVersion: subject.opencodexCompatibilityVersion, + adapterFamily, + inboundProtocol: subject.inboundProtocol, + upstreamProtocol: subject.upstreamProtocol, + surface: subject.surface, + }; +} + +function projectIncidentRefs(values: string[] | undefined): PublicIncidentRefV1[] | undefined { + if (values === undefined) return undefined; + if (values.some((value) => !isPublicIncidentRef(value))) return undefined; + return values.map((corpusId) => ({ corpusId })); +} + +/** + * Project one local observation into the closed public V1 record shape and apply the + * complete reviewed authority/privacy boundary before exposing it as exportable. + * + * Route and task observations deliberately fail closed here. Persisted RouteSubjectV1 + * contains installation-salted provider-instance and endpoint identity, so the exact + * public/default route cannot be proven from ledger bytes alone. Dropping those fields + * would broaden a private exact route into a misleading public claim. + */ +export function projectPublicEvidenceRecord( + input: ProjectPublicEvidenceRecordInput, +): PublicEvidenceProjectionResult { + const { observation } = input; + + if (observation.evidenceLayer === "live_route_compatibility" || observation.evidenceLayer === "task_effectiveness") { + return { status: "not_exportable", reason: "private_route_identity" }; + } + if (observation.evidenceLayer !== "protocol_conformance" || observation.subject.subjectKind !== "protocol") { + return { status: "not_exportable", reason: "unsupported_subject" }; + } + + const subject = projectProtocolSubject(observation.subject); + if (!subject) return { status: "not_exportable", reason: "unsupported_adapter_family" }; + + const incidentRefs = projectIncidentRefs(input.incidentRefs); + if (input.incidentRefs !== undefined && incidentRefs === undefined) { + return { status: "not_exportable", reason: "unsafe_public_field" }; + } + + try { + const subjectId = publicEvidenceId("subject", subject); + const withoutRecordId: Omit = { + subjectId, + evidenceLayer: "protocol_conformance", + suiteId: observation.suiteId, + suiteVersion: observation.suiteVersion, + scenarioId: observation.scenarioId, + scenarioVersion: observation.scenarioVersion, + verdict: input.verdict, + observedDayUtc: publicUtcDay(observation.completedAt), + subject, + assertions: observation.assertions.map((assertion) => ({ + id: assertion.id, + required: assertion.required, + passed: assertion.passed, + })), + ...(incidentRefs !== undefined ? { incidentRefs } : {}), + ...(input.publicArtifactRefs !== undefined ? { artifactRefs: [...input.publicArtifactRefs] } : {}), + }; + const record = validatePublicEvidenceRecord({ + recordId: publicEvidenceId("record", withoutRecordId), + ...withoutRecordId, + }); + validatePublicEvidenceAuthorities([record]); + validatePublicEvidenceRecordPrivacy(record); + return { status: "exportable", record }; + } catch (error) { + if (error instanceof PublicEvidenceValidationError) { + if (PROJECTOR_INVARIANT_ERROR_CODES.has(error.code)) throw error; + return { status: "not_exportable", reason: "unsafe_public_field" }; + } + throw error; + } +} diff --git a/src/lab/public/registry.ts b/src/lab/public/registry.ts new file mode 100644 index 0000000000..27743b53b0 --- /dev/null +++ b/src/lab/public/registry.ts @@ -0,0 +1,43 @@ +import { publicEvidenceId } from "./ids"; +import type { + PublicAdapterFamily, + PublicRouteRegistryEntryV1, + PublicRouteRegistryManifestV1, +} from "./types"; + +// Repository-authoritative provider/model/adapter snapshot. The public manifest +// itself is independently content-addressed by manifestDigest below. +const PUBLIC_ROUTE_REGISTRY_SOURCE_COMMIT = "75a21417657ba5a3033198be0d8ae949de723d11"; + +const entries: PublicRouteRegistryEntryV1[] = [ + { + providerId: "openai", + modelId: "gpt-5.6-sol", + adapterFamilies: ["openai-responses"], + }, +]; + +const manifestWithoutDigest = { + schemaVersion: "public_route_registry_v1" as const, + registryVersion: "2026-08-13.v2", + sourceCommit: PUBLIC_ROUTE_REGISTRY_SOURCE_COMMIT, + entries, +}; + +export const PUBLIC_ROUTE_REGISTRY_V1: PublicRouteRegistryManifestV1 = Object.freeze({ + ...manifestWithoutDigest, + entries: Object.freeze(entries.map((entry) => Object.freeze({ + ...entry, + adapterFamilies: Object.freeze([...entry.adapterFamilies]) as unknown as PublicAdapterFamily[], + }))) as unknown as PublicRouteRegistryEntryV1[], + manifestDigest: publicEvidenceId("route_registry", manifestWithoutDigest), +}); + +export function findPublicRouteRegistryEntry( + providerId: string, + modelId: string, +): PublicRouteRegistryEntryV1 | undefined { + return PUBLIC_ROUTE_REGISTRY_V1.entries.find( + (entry) => entry.providerId === providerId && entry.modelId === modelId, + ); +} diff --git a/src/lab/public/signature.ts b/src/lab/public/signature.ts new file mode 100644 index 0000000000..6429445a28 --- /dev/null +++ b/src/lab/public/signature.ts @@ -0,0 +1,205 @@ +import { + createPrivateKey, + createPublicKey, + generateKeyPairSync, + sign as signBytes, + verify as verifyBytes, +} from "node:crypto"; +import { ensureLabDirs, labPublicPublisherKeyPath } from "../paths"; +import { hardenSecretPath } from "../../lib/windows-secret-acl"; +import { + buildPublicEvidenceBundle, + canonicalPublicEvidenceContent, + expectedPublicBundleIdentityFromNormalized, + normalizePublicEvidenceContent, + type BuildPublicEvidenceBundleInput, +} from "./bundle"; +import { validatePublicEvidenceAuthorities } from "./community-authority"; +import { privateRegularFileSize, readPrivateRegularFile } from "./file-safety"; +import { publicEvidenceId } from "./ids"; +import { cleanupStalePrivateFileStages, publishPrivateFileExclusive } from "./private-file"; +import { validatePublicEvidencePrivacy, validatePublicEvidenceRecordPrivacy } from "./privacy"; +import type { + PublicEvidenceBundleV1, + PublicPublisherV1, +} from "./types"; +import { PublicEvidenceValidationError } from "./validate"; + +const MAX_PRIVATE_KEY_BYTES = 8 * 1024; +const PRIVATE_KEY_FILE_OPTIONS = { + maxBytes: MAX_PRIVATE_KEY_BYTES, + errorCode: "public_publisher_key_unsafe", + errorMessage: "public publisher key path is not a bounded private regular file with 0600 permissions", + requireMode600: true, +} as const; + +export interface PublicPublisherHandle { + publisher: PublicPublisherV1; + privateKeyPath: string; +} + +function publicKeyBase64(privateKeyPem: string): string { + const publicKey = createPublicKey(privateKeyPem); + return publicKey.export({ type: "spki", format: "der" }).toString("base64"); +} + +function publisherForPrivateKey(privateKeyPem: string): PublicPublisherV1 { + const publicKey = publicKeyBase64(privateKeyPem); + return { + algorithm: "ed25519", + keyId: publicEvidenceId("publisher_key", { algorithm: "ed25519", publicKey }), + publicKey, + }; +} + +function readRestrictedPrivateKey(path: string): string { + cleanupStalePrivateFileStages(path); + // Prove the pathname is the expected private regular file before applying any + // platform ACL operation, then fail closed if Windows per-user ACL hardening + // cannot be established. The helper is a no-op success on non-Windows. + privateRegularFileSize(path, PRIVATE_KEY_FILE_OPTIONS); + let hardened: { ok: boolean }; + try { + hardened = hardenSecretPath(path, { required: true }); + } catch { + hardened = { ok: false }; + } + if (!hardened.ok) { + throw new PublicEvidenceValidationError( + "public_publisher_key_unsafe", + "public publisher key ACL hardening did not complete", + ); + } + const pem = readPrivateRegularFile(path, PRIVATE_KEY_FILE_OPTIONS).toString("utf8"); + const key = createPrivateKey(pem); + if (key.asymmetricKeyType !== "ed25519") { + throw new Error("public publisher key must be Ed25519"); + } + return pem; +} + +function createPrivateKeyFile(path: string): string { + const { privateKey } = generateKeyPairSync("ed25519", { + privateKeyEncoding: { type: "pkcs8", format: "pem" }, + publicKeyEncoding: { type: "spki", format: "pem" }, + }); + publishPrivateFileExclusive(path, Buffer.from(privateKey, "utf8")); + return readRestrictedPrivateKey(path); +} + +export function loadExistingPublicPublisher(configDir?: string): PublicPublisherHandle | null { + const privateKeyPath = labPublicPublisherKeyPath(configDir); + try { + const privateKeyPem = readRestrictedPrivateKey(privateKeyPath); + return { publisher: publisherForPrivateKey(privateKeyPem), privateKeyPath }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + throw error; + } +} + +export function getOrCreatePublicPublisher(configDir?: string): PublicPublisherHandle { + ensureLabDirs(configDir); + const existing = loadExistingPublicPublisher(configDir); + if (existing) return existing; + const privateKeyPath = labPublicPublisherKeyPath(configDir); + const privateKeyPem = createPrivateKeyFile(privateKeyPath); + return { publisher: publisherForPrivateKey(privateKeyPem), privateKeyPath }; +} + +/** Centralized descriptor-bound signing primitive for the installation publisher key. */ +export function signPublicPublisherDigest(handle: PublicPublisherHandle, digestHex: string): string { + if (!/^[0-9a-f]{64}$/.test(digestHex)) { + throw new PublicEvidenceValidationError("invalid_digest", "publisher signing digest must be lowercase sha256 hex"); + } + const privateKeyPem = readRestrictedPrivateKey(handle.privateKeyPath); + return signBytes(null, Buffer.from(digestHex, "hex"), createPrivateKey(privateKeyPem)).toString("base64"); +} + +export interface SignPublicEvidenceBundleInput extends Omit { + configDir?: string; +} + +function assertLocalArtifactExportAuthority(input: SignPublicEvidenceBundleInput): void { + if (input.artifacts.length !== 0) { + throw new PublicEvidenceValidationError( + "public_artifact_authority_required", + "artifact bytes require reviewed public_export policy authority before local signing", + ); + } +} + +export function signPublicEvidenceBundle(input: SignPublicEvidenceBundleInput): PublicEvidenceBundleV1 { + // Validate every caller-controlled invariant before publisher identity state is touched. + assertLocalArtifactExportAuthority(input); + const normalized = normalizePublicEvidenceContent({ + records: input.records, + artifacts: input.artifacts, + createdDayUtc: input.createdDayUtc, + }); + validatePublicEvidenceAuthorities(normalized.records); + for (const record of normalized.records) validatePublicEvidenceRecordPrivacy(record); + + const handle = getOrCreatePublicPublisher(input.configDir); + const unsigned = buildPublicEvidenceBundle({ ...normalized, publisher: handle.publisher }); + validatePublicEvidencePrivacy(unsigned); + return { + ...unsigned, + signature: { + algorithm: "ed25519", + signedDigest: unsigned.bundleDigest, + signature: signPublicPublisherDigest(handle, unsigned.bundleDigest), + }, + }; +} + +export type PublicBundleVerificationResult = + | { status: "cryptographically_valid" } + | { status: "digest_invalid" } + | { status: "signature_invalid" } + | { status: "schema_rejected" }; + +export function verifyPublicEvidenceBundle(bundle: PublicEvidenceBundleV1): PublicBundleVerificationResult { + try { + const raw = bundle as unknown as Record; + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return { status: "schema_rejected" }; + const allowed = new Set([ + "schemaVersion", + "exportPolicyVersion", + "bundleId", + "createdDayUtc", + "publisher", + "records", + "artifacts", + "bundleDigest", + "signature", + ]); + if (Object.keys(raw).some((key) => !allowed.has(key))) return { status: "schema_rejected" }; + if (bundle.schemaVersion !== "public_evidence_bundle_v1" || bundle.exportPolicyVersion !== "public_export_policy_v1") { + return { status: "schema_rejected" }; + } + if (!bundle.signature || bundle.signature.algorithm !== "ed25519") return { status: "schema_rejected" }; + if (Object.keys(bundle.signature).some((key) => !["algorithm", "signedDigest", "signature"].includes(key))) { + return { status: "schema_rejected" }; + } + const canonical = canonicalPublicEvidenceContent(bundle); + if (!canonical.canonical) return { status: "schema_rejected" }; + const expected = expectedPublicBundleIdentityFromNormalized(canonical.normalized, bundle.publisher); + if (bundle.bundleId !== expected.bundleId || bundle.bundleDigest !== expected.bundleDigest) { + return { status: "digest_invalid" }; + } + if (bundle.signature.signedDigest !== bundle.bundleDigest) return { status: "signature_invalid" }; + const key = createPublicKey({ + key: Buffer.from(bundle.publisher.publicKey, "base64"), + type: "spki", + format: "der", + }); + if (key.asymmetricKeyType !== "ed25519") return { status: "signature_invalid" }; + const signature = Buffer.from(bundle.signature.signature, "base64"); + if (signature.toString("base64") !== bundle.signature.signature) return { status: "signature_invalid" }; + const valid = verifyBytes(null, Buffer.from(bundle.bundleDigest, "hex"), key, signature); + return valid ? { status: "cryptographically_valid" } : { status: "signature_invalid" }; + } catch { + return { status: "schema_rejected" }; + } +} diff --git a/src/lab/public/storage.ts b/src/lab/public/storage.ts new file mode 100644 index 0000000000..8fb0a96179 --- /dev/null +++ b/src/lab/public/storage.ts @@ -0,0 +1,105 @@ +import { join } from "node:path"; +import { isSha256Hex, jcsStringify } from "../digest"; +import { ensureLabDirs } from "../paths"; +import { MAX_PUBLIC_BUNDLE_BYTES } from "./bundle"; +import { validatePublicEvidenceAuthorities } from "./community-authority"; +import { readPrivateRegularFile } from "./file-safety"; +import { cleanupStalePrivateFileStages, publishPrivateFileExclusive } from "./private-file"; +import { validatePublicEvidencePrivacy } from "./privacy"; +import { parseStrictPublicJson } from "./strict-json"; +import type { PublicEvidenceBundleV1 } from "./types"; +import { verifyPublicEvidenceBundle } from "./signature"; +import { PublicEvidenceValidationError } from "./validate"; + +function encodedBytes(value: string): number { + return new TextEncoder().encode(value).byteLength; +} + +function bundlePath(bundleId: string, configDir?: string): string { + if (!isSha256Hex(bundleId)) throw new Error("public bundle id must be lowercase sha256 hex"); + return join(ensureLabDirs(configDir).exportDir, `${bundleId}.json`); +} + +function assertLocalArtifactExportAuthority(bundle: PublicEvidenceBundleV1): void { + if (bundle.artifacts.length !== 0) { + throw new PublicEvidenceValidationError( + "public_artifact_authority_required", + "artifact bytes require reviewed public_export policy authority before local export storage", + ); + } +} + +function readLocalExport(path: string): Buffer { + cleanupStalePrivateFileStages(path); + return readPrivateRegularFile(path, { + maxBytes: MAX_PUBLIC_BUNDLE_BYTES, + errorCode: "public_file_unsafe", + errorMessage: "public export is not a private regular file with 0600 permissions", + sizeErrorCode: "public_file_too_large", + sizeErrorMessage: "public bundle exceeds 2 MiB", + requireMode600: true, + }); +} + +function existingBody(path: string): string | null { + try { + return readLocalExport(path).toString("utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + throw error; + } +} + +function validateLocalBundle(bundle: PublicEvidenceBundleV1): void { + const verification = verifyPublicEvidenceBundle(bundle); + if (verification.status !== "cryptographically_valid") { + throw new PublicEvidenceValidationError(verification.status, `public bundle verification failed: ${verification.status}`); + } + assertLocalArtifactExportAuthority(bundle); + validatePublicEvidenceAuthorities(bundle.records); + validatePublicEvidencePrivacy(bundle); +} + +export function storePublicEvidenceBundle( + bundle: PublicEvidenceBundleV1, + configDir?: string, +): { path: string; created: boolean } { + validateLocalBundle(bundle); + const body = jcsStringify(bundle) + "\n"; + if (encodedBytes(body) > MAX_PUBLIC_BUNDLE_BYTES) { + throw new PublicEvidenceValidationError("public_file_too_large", "public bundle exceeds 2 MiB"); + } + const path = bundlePath(bundle.bundleId, configDir); + const existing = existingBody(path); + if (existing !== null) { + if (existing === body) return { path, created: false }; + throw new PublicEvidenceValidationError("public_export_conflict", "public export id collision with different bytes"); + } + + const published = publishPrivateFileExclusive(path, Buffer.from(body, "utf8")); + if (!published.created) { + const raced = existingBody(path); + if (raced === body) return { path, created: false }; + throw new PublicEvidenceValidationError("public_export_conflict", "public export id collision with different bytes"); + } + return { path, created: true }; +} + +/** Backward-compatible local storage helper for callers that need the private path. */ +export function writePublicEvidenceBundle(bundle: PublicEvidenceBundleV1, configDir?: string): string { + return storePublicEvidenceBundle(bundle, configDir).path; +} + +export function readPublicEvidenceBundle(bundleId: string, configDir?: string): PublicEvidenceBundleV1 { + const bytes = readLocalExport(bundlePath(bundleId, configDir)); + const raw = parseStrictPublicJson(bytes, "public export", "public_file_json"); + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + throw new PublicEvidenceValidationError("public_file_json", "public export must contain a bundle object"); + } + const parsed = raw as PublicEvidenceBundleV1; + if (parsed.bundleId !== bundleId) { + throw new PublicEvidenceValidationError("public_file_identity", "public export filename does not match bundle id"); + } + validateLocalBundle(parsed); + return parsed; +} diff --git a/src/lab/public/strict-json.ts b/src/lab/public/strict-json.ts new file mode 100644 index 0000000000..fe7599cb68 --- /dev/null +++ b/src/lab/public/strict-json.ts @@ -0,0 +1,198 @@ +import { PublicEvidenceValidationError } from "./validate"; + +const MAX_PUBLIC_JSON_DEPTH = 8; +const MAX_PUBLIC_JSON_OBJECT_KEYS = 64; +const MAX_PUBLIC_JSON_ARRAY_ELEMENTS = 512; +const MAX_PUBLIC_JSON_STRING_BYTES = 384 * 1024; + +function isJsonWhitespace(value: string | undefined): boolean { + return value === " " || value === "\n" || value === "\r" || value === "\t"; +} + +function malformedJson(code: string, message: string): never { + throw new PublicEvidenceValidationError(code, message); +} + +function assertStrictPublicJsonShape(text: string, invalidCode: string): void { + let index = 0; + let depth = 0; + + function invalid(message: string): never { + return malformedJson(invalidCode, message); + } + + function skipWhitespace(): void { + while (isJsonWhitespace(text[index])) index += 1; + } + + function parseStringToken(): string { + if (text[index] !== '"') invalid("public JSON contains an invalid string token"); + const start = index; + index += 1; + let escaped = false; + while (index < text.length) { + const ch = text[index++]!; + if (escaped) { + escaped = false; + continue; + } + if (ch === "\\") { + escaped = true; + continue; + } + if (ch === '"') { + if (Buffer.byteLength(text.slice(start + 1, index - 1), "utf8") > MAX_PUBLIC_JSON_STRING_BYTES) { + invalid(`public JSON string exceeds ${MAX_PUBLIC_JSON_STRING_BYTES} bytes`); + } + try { + const decoded = JSON.parse(text.slice(start, index)); + if (typeof decoded !== "string") invalid("public JSON contains an invalid string token"); + return decoded; + } catch (error) { + if (error instanceof PublicEvidenceValidationError) throw error; + invalid("public JSON contains an invalid string token"); + } + } + if (ch.charCodeAt(0) < 0x20) invalid("public JSON contains an invalid control character"); + } + invalid("public JSON contains an unterminated string token"); + } + + function parseScalar(): void { + const start = index; + while (index < text.length) { + const ch = text[index]; + if (ch === "," || ch === "]" || ch === "}" || isJsonWhitespace(ch)) break; + index += 1; + } + if (start === index) invalid("public JSON contains an invalid value"); + try { + const parsed = JSON.parse(text.slice(start, index)); + if (parsed !== null && typeof parsed === "object") invalid("public JSON contains an invalid scalar value"); + } catch (error) { + if (error instanceof PublicEvidenceValidationError) throw error; + invalid("public JSON contains an invalid scalar value"); + } + } + + function enterContainer(): void { + depth += 1; + if (depth > MAX_PUBLIC_JSON_DEPTH) { + invalid(`public JSON nesting depth exceeds ${MAX_PUBLIC_JSON_DEPTH}`); + } + } + + function parseArray(): void { + enterContainer(); + try { + index += 1; + skipWhitespace(); + if (text[index] === "]") { + index += 1; + return; + } + let elementCount = 0; + while (index < text.length) { + elementCount += 1; + if (elementCount > MAX_PUBLIC_JSON_ARRAY_ELEMENTS) { + invalid(`public JSON array exceeds ${MAX_PUBLIC_JSON_ARRAY_ELEMENTS} elements`); + } + parseValue(); + skipWhitespace(); + if (text[index] === "]") { + index += 1; + return; + } + if (text[index] !== ",") invalid("public JSON array is malformed"); + index += 1; + skipWhitespace(); + if (text[index] === "]") invalid("public JSON array contains a trailing comma"); + } + invalid("public JSON array is unterminated"); + } finally { + depth -= 1; + } + } + + function parseObject(): void { + enterContainer(); + try { + index += 1; + skipWhitespace(); + if (text[index] === "}") { + index += 1; + return; + } + const keys = new Set(); + while (index < text.length) { + if (text[index] !== '"') invalid("public JSON object key must be a string"); + const key = parseStringToken(); + if (keys.has(key)) { + throw new PublicEvidenceValidationError("duplicate_json_key", "duplicate JSON object key"); + } + keys.add(key); + if (keys.size > MAX_PUBLIC_JSON_OBJECT_KEYS) { + invalid(`public JSON object exceeds ${MAX_PUBLIC_JSON_OBJECT_KEYS} keys`); + } + skipWhitespace(); + if (text[index] !== ":") invalid("public JSON object is missing a colon"); + index += 1; + parseValue(); + skipWhitespace(); + if (text[index] === "}") { + index += 1; + return; + } + if (text[index] !== ",") invalid("public JSON object is malformed"); + index += 1; + skipWhitespace(); + if (text[index] === "}") invalid("public JSON object contains a trailing comma"); + } + invalid("public JSON object is unterminated"); + } finally { + depth -= 1; + } + } + + function parseValue(): void { + skipWhitespace(); + const ch = text[index]; + if (ch === "{") { + parseObject(); + return; + } + if (ch === "[") { + parseArray(); + return; + } + if (ch === '"') { + parseStringToken(); + return; + } + parseScalar(); + } + + skipWhitespace(); + if (index === text.length) invalid("public JSON is empty"); + parseValue(); + skipWhitespace(); + if (index !== text.length) invalid("public JSON contains trailing data"); +} + +export function parseStrictPublicJson( + bytes: Uint8Array, + label = "public JSON", + invalidCode = "public_json", +): unknown { + const buffer = Buffer.from(bytes); + const text = buffer.toString("utf8"); + if (!Buffer.from(text, "utf8").equals(buffer)) { + throw new PublicEvidenceValidationError(invalidCode, `${label} is not valid UTF-8 JSON`); + } + assertStrictPublicJsonShape(text, invalidCode); + try { + return JSON.parse(text); + } catch { + throw new PublicEvidenceValidationError(invalidCode, `${label} is not valid JSON`); + } +} diff --git a/src/lab/public/time.ts b/src/lab/public/time.ts new file mode 100644 index 0000000000..9adc8727d4 --- /dev/null +++ b/src/lab/public/time.ts @@ -0,0 +1,19 @@ +import { PublicEvidenceValidationError } from "./validate"; + +/** Convert a bounded JavaScript timestamp into the public UTC day bucket. */ +export function publicUtcDay(timestampMs: number): string { + if (!Number.isInteger(timestampMs) || timestampMs < 0) { + throw new PublicEvidenceValidationError( + "public_selection_time", + "invalid observation completion timestamp", + ); + } + const date = new Date(timestampMs); + if (!Number.isFinite(date.getTime())) { + throw new PublicEvidenceValidationError( + "public_selection_time", + "invalid observation completion timestamp", + ); + } + return date.toISOString().slice(0, 10); +} diff --git a/src/lab/public/types.ts b/src/lab/public/types.ts new file mode 100644 index 0000000000..7792753daa --- /dev/null +++ b/src/lab/public/types.ts @@ -0,0 +1,171 @@ +import type { CompatibilityVerdict, EvidenceLayer } from "../constants"; + +export const PUBLIC_EVIDENCE_BUNDLE_SCHEMA_VERSION = "public_evidence_bundle_v1" as const; +export const PUBLIC_EXPORT_POLICY_VERSION = "public_export_policy_v1" as const; +export const PUBLIC_EVIDENCE_REVOCATION_SCHEMA_VERSION = "public_evidence_revocation_v1" as const; + +export const PUBLIC_ADAPTER_FAMILIES = [ + "openai-responses", + "openai-chat", + "anthropic-messages", +] as const; +export type PublicAdapterFamily = (typeof PUBLIC_ADAPTER_FAMILIES)[number]; + +export interface PublicRouteRegistryEntryV1 { + providerId: string; + modelId: string; + adapterFamilies: PublicAdapterFamily[]; +} + +export interface PublicRouteRegistryManifestV1 { + schemaVersion: "public_route_registry_v1"; + registryVersion: string; + sourceCommit: string; + entries: PublicRouteRegistryEntryV1[]; + manifestDigest: string; +} + +export interface PublicProtocolSubjectV1 { + subjectKind: "protocol"; + compatibilityVersion: string; + adapterFamily: PublicAdapterFamily; + inboundProtocol: string; + upstreamProtocol: string; + surface: string; +} + +export interface PublicRouteSubjectV1 { + subjectKind: "route"; + providerId: string; + modelId: string; + adapterFamily: PublicAdapterFamily; + compatibilityVersion: string; +} + +export interface PublicTaskSubjectV1 { + subjectKind: "task"; + route: PublicRouteSubjectV1; + taskClassId: string; + taskClassVersion: string; + taskFixtureDigest: string; + verifierManifestDigest: string; + fabricCompatibilityVersion: 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; + artifactClass: string; + mediaType: string; + byteCount: number; + contentBase64: string; +} + +export interface PublicPublisherV1 { + algorithm: "ed25519"; + keyId: string; + publicKey: string; +} + +export interface PublicBundleSignatureV1 { + algorithm: "ed25519"; + signedDigest: string; + signature: string; +} + +export interface PublicEvidenceBundleUnsignedV1 { + schemaVersion: typeof PUBLIC_EVIDENCE_BUNDLE_SCHEMA_VERSION; + exportPolicyVersion: typeof PUBLIC_EXPORT_POLICY_VERSION; + bundleId: string; + createdDayUtc: string; + publisher: PublicPublisherV1; + records: PublicEvidenceRecordV1[]; + artifacts: PublicArtifactV1[]; + bundleDigest: string; +} + +export interface PublicEvidenceBundleV1 extends PublicEvidenceBundleUnsignedV1 { + signature: PublicBundleSignatureV1; +} + +export interface PublicEvidencePreviewBundleV1 { + schemaVersion: typeof PUBLIC_EVIDENCE_BUNDLE_SCHEMA_VERSION; + exportPolicyVersion: typeof PUBLIC_EXPORT_POLICY_VERSION; + createdDayUtc: string; + records: PublicEvidenceRecordV1[]; + artifacts: PublicArtifactV1[]; +} + +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_subject" + | "unsafe_public_field" + | "unsupported_adapter_family"; + +export type PublicEvidenceProjectionResult = + | { status: "exportable"; record: PublicEvidenceRecordV1 } + | { status: "not_exportable"; reason: PublicProjectionNotExportableReason }; diff --git a/src/lab/public/validate.ts b/src/lab/public/validate.ts new file mode 100644 index 0000000000..d7930404d2 --- /dev/null +++ b/src/lab/public/validate.ts @@ -0,0 +1,390 @@ +import { EVIDENCE_LAYERS, VERDICTS, type EvidenceLayer } from "../constants"; +import { isSha256Hex } from "../digest"; +import { publicEvidenceId } from "./ids"; +import { findPublicRouteRegistryEntry } from "./registry"; +import { + PUBLIC_ADAPTER_FAMILIES, + type PublicAdapterFamily, + type PublicAssertionSummaryV1, + type PublicEvidenceRecordV1, + type PublicEvidenceSubjectV1, + type PublicIncidentRefV1, + type PublicProtocolSubjectV1, + type PublicRouteRegistryEntryV1, + type PublicRouteRegistryManifestV1, + type PublicRouteSubjectV1, + type PublicTaskSubjectV1, +} from "./types"; + +const MAX_PUBLIC_STRING_BYTES = 4 * 1024; +const MAX_PUBLIC_ASSERTIONS = 64; +const MAX_PUBLIC_INCIDENT_REFS = 32; +const MAX_PUBLIC_ARTIFACT_REFS = 16; +const PUBLIC_IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:+-]{0,255}$/; +const UTC_DAY = /^\d{4}-\d{2}-\d{2}$/; +const SOURCE_COMMIT = /^[0-9a-f]{40}$/; + +const PUBLIC_INCIDENT_CORPUS_IDS = new Set( + Array.from({ length: 21 }, (_, index) => `IC-${String(index + 1).padStart(3, "0")}`), +); + +export class PublicEvidenceValidationError extends Error { + override readonly name = "PublicEvidenceValidationError"; + + constructor(readonly code: string, message: string) { + super(message); + } +} + +function isPlainObject(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function assertObject(value: unknown, field: string): Record { + if (!isPlainObject(value)) { + throw new PublicEvidenceValidationError("invalid_type", `${field} must be an object`); + } + return value; +} + +function assertKnownKeys( + raw: Record, + field: string, + allowed: readonly string[], +): void { + const allow = new Set(allowed); + for (const key of Object.keys(raw)) { + if (!allow.has(key)) { + throw new PublicEvidenceValidationError("unknown_field", `${field}.${key} is not public schema`); + } + } +} + +function assertString(value: unknown, field: string, max = MAX_PUBLIC_STRING_BYTES): string { + if (typeof value !== "string") { + throw new PublicEvidenceValidationError("invalid_type", `${field} must be a string`); + } + if (value.includes("\0")) { + throw new PublicEvidenceValidationError("unsafe_public_field", `${field} contains NUL`); + } + if (new TextEncoder().encode(value).byteLength > max) { + throw new PublicEvidenceValidationError("field_too_large", `${field} exceeds ${max} bytes`); + } + return value; +} + +function assertPublicIdentifier(value: unknown, field: string): string { + const result = assertString(value, field, 256); + if (!PUBLIC_IDENTIFIER.test(result)) { + throw new PublicEvidenceValidationError("unsafe_public_field", `${field} is not a closed public identifier`); + } + return result; +} + +function assertBoolean(value: unknown, field: string): boolean { + if (value !== true && value !== false) { + throw new PublicEvidenceValidationError("invalid_type", `${field} must be boolean`); + } + return value; +} + +function assertSha256(value: unknown, field: string): string { + const result = assertString(value, field, 64); + if (!isSha256Hex(result)) { + throw new PublicEvidenceValidationError("invalid_digest", `${field} must be lowercase sha256 hex`); + } + return result; +} + +function assertClosed( + value: unknown, + field: string, + allowed: readonly T[], +): T { + if (typeof value !== "string" || !(allowed as readonly string[]).includes(value)) { + throw new PublicEvidenceValidationError("closed_set", `${field} is not in the public closed set`); + } + return value as T; +} + +function assertUtcDay(value: unknown, field: string): string { + const result = assertString(value, field, 10); + if (!UTC_DAY.test(result)) { + throw new PublicEvidenceValidationError("invalid_day", `${field} must be YYYY-MM-DD`); + } + const parsed = new Date(`${result}T00:00:00.000Z`); + if (Number.isNaN(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== result) { + throw new PublicEvidenceValidationError("invalid_day", `${field} must be a real UTC day`); + } + return result; +} + +function validateAdapterFamily(value: unknown, field: string): PublicAdapterFamily { + return assertClosed(value, field, PUBLIC_ADAPTER_FAMILIES); +} + +function validateProtocolSubject(rawValue: unknown): PublicProtocolSubjectV1 { + const raw = assertObject(rawValue, "subject"); + assertKnownKeys(raw, "subject", [ + "subjectKind", + "compatibilityVersion", + "adapterFamily", + "inboundProtocol", + "upstreamProtocol", + "surface", + ]); + if (raw.subjectKind !== "protocol") { + throw new PublicEvidenceValidationError("layer_subject_mismatch", "protocol layer requires protocol subject"); + } + return { + subjectKind: "protocol", + compatibilityVersion: assertPublicIdentifier(raw.compatibilityVersion, "subject.compatibilityVersion"), + adapterFamily: validateAdapterFamily(raw.adapterFamily, "subject.adapterFamily"), + inboundProtocol: assertPublicIdentifier(raw.inboundProtocol, "subject.inboundProtocol"), + upstreamProtocol: assertPublicIdentifier(raw.upstreamProtocol, "subject.upstreamProtocol"), + surface: assertPublicIdentifier(raw.surface, "subject.surface"), + }; +} + +function validateRouteSubject(rawValue: unknown): PublicRouteSubjectV1 { + const raw = assertObject(rawValue, "subject"); + assertKnownKeys(raw, "subject", [ + "subjectKind", + "providerId", + "modelId", + "adapterFamily", + "compatibilityVersion", + ]); + if (raw.subjectKind !== "route") { + throw new PublicEvidenceValidationError("layer_subject_mismatch", "route layer requires route subject"); + } + const providerId = assertPublicIdentifier(raw.providerId, "subject.providerId"); + const modelId = assertPublicIdentifier(raw.modelId, "subject.modelId"); + const adapterFamily = validateAdapterFamily(raw.adapterFamily, "subject.adapterFamily"); + const entry = findPublicRouteRegistryEntry(providerId, modelId); + if (!entry || !entry.adapterFamilies.includes(adapterFamily)) { + throw new PublicEvidenceValidationError("public_registry_rejected", "route is not in the reviewed public registry"); + } + return { + subjectKind: "route", + providerId, + modelId, + adapterFamily, + compatibilityVersion: assertPublicIdentifier(raw.compatibilityVersion, "subject.compatibilityVersion"), + }; +} + +function validateTaskSubject(rawValue: unknown): PublicTaskSubjectV1 { + const raw = assertObject(rawValue, "subject"); + assertKnownKeys(raw, "subject", [ + "subjectKind", + "route", + "taskClassId", + "taskClassVersion", + "taskFixtureDigest", + "verifierManifestDigest", + "fabricCompatibilityVersion", + ]); + if (raw.subjectKind !== "task") { + throw new PublicEvidenceValidationError("layer_subject_mismatch", "task layer requires task subject"); + } + return { + subjectKind: "task", + route: validateRouteSubject(raw.route), + taskClassId: assertPublicIdentifier(raw.taskClassId, "subject.taskClassId"), + taskClassVersion: assertPublicIdentifier(raw.taskClassVersion, "subject.taskClassVersion"), + taskFixtureDigest: assertSha256(raw.taskFixtureDigest, "subject.taskFixtureDigest"), + verifierManifestDigest: assertSha256(raw.verifierManifestDigest, "subject.verifierManifestDigest"), + fabricCompatibilityVersion: assertPublicIdentifier( + raw.fabricCompatibilityVersion, + "subject.fabricCompatibilityVersion", + ), + }; +} + +function validateSubject(raw: unknown, layer: EvidenceLayer): PublicEvidenceSubjectV1 { + if (layer === "protocol_conformance") return validateProtocolSubject(raw); + if (layer === "live_route_compatibility") return validateRouteSubject(raw); + if (layer === "task_effectiveness") return validateTaskSubject(raw); + const _exhaustive: never = layer; + throw new PublicEvidenceValidationError("unsupported_layer", String(_exhaustive)); +} + +function validateAssertion(rawValue: unknown, index: number): PublicAssertionSummaryV1 { + const raw = assertObject(rawValue, `assertions[${index}]`); + assertKnownKeys(raw, `assertions[${index}]`, ["id", "required", "passed"]); + return { + id: assertPublicIdentifier(raw.id, `assertions[${index}].id`), + required: assertBoolean(raw.required, `assertions[${index}].required`), + passed: assertBoolean(raw.passed, `assertions[${index}].passed`), + }; +} + +export function isPublicIncidentRef(value: unknown): value is string { + return typeof value === "string" && PUBLIC_INCIDENT_CORPUS_IDS.has(value); +} + +function validateIncidentRef(rawValue: unknown, index: number): PublicIncidentRefV1 { + const raw = assertObject(rawValue, `incidentRefs[${index}]`); + assertKnownKeys(raw, `incidentRefs[${index}]`, ["corpusId"]); + const corpusId = assertString(raw.corpusId, `incidentRefs[${index}].corpusId`, 6); + if (!isPublicIncidentRef(corpusId)) { + throw new PublicEvidenceValidationError("incident_ref_rejected", `${corpusId} is not in the reviewed corpus`); + } + return { corpusId }; +} + +function validateUniqueIds(rawValue: unknown, field: string, max: number): string[] { + if (!Array.isArray(rawValue)) { + throw new PublicEvidenceValidationError("invalid_type", `${field} must be an array`); + } + if (rawValue.length > max) { + throw new PublicEvidenceValidationError("array_too_large", `${field} exceeds ${max}`); + } + const values = rawValue.map((value, index) => assertSha256(value, `${field}[${index}]`)); + if (new Set(values).size !== values.length) { + throw new PublicEvidenceValidationError("duplicate_id", `${field} contains duplicates`); + } + return values; +} + +export function validatePublicEvidenceRecord(rawValue: unknown): PublicEvidenceRecordV1 { + const raw = assertObject(rawValue, "record"); + assertKnownKeys(raw, "record", [ + "recordId", + "subjectId", + "evidenceLayer", + "suiteId", + "suiteVersion", + "scenarioId", + "scenarioVersion", + "verdict", + "observedDayUtc", + "subject", + "assertions", + "incidentRefs", + "artifactRefs", + ]); + + const evidenceLayer = assertClosed(raw.evidenceLayer, "record.evidenceLayer", EVIDENCE_LAYERS); + const subject = validateSubject(raw.subject, evidenceLayer); + const subjectId = assertSha256(raw.subjectId, "record.subjectId"); + const expectedSubjectId = publicEvidenceId("subject", subject); + if (subjectId !== expectedSubjectId) { + throw new PublicEvidenceValidationError("subject_id_mismatch", "record.subjectId does not match public subject"); + } + + if (!Array.isArray(raw.assertions)) { + throw new PublicEvidenceValidationError("invalid_type", "record.assertions must be an array"); + } + if (raw.assertions.length > MAX_PUBLIC_ASSERTIONS) { + throw new PublicEvidenceValidationError("array_too_large", `record.assertions exceeds ${MAX_PUBLIC_ASSERTIONS}`); + } + const assertions = raw.assertions.map(validateAssertion); + + let incidentRefs: PublicIncidentRefV1[] | undefined; + if (raw.incidentRefs !== undefined) { + if (!Array.isArray(raw.incidentRefs)) { + throw new PublicEvidenceValidationError("invalid_type", "record.incidentRefs must be an array"); + } + if (raw.incidentRefs.length > MAX_PUBLIC_INCIDENT_REFS) { + throw new PublicEvidenceValidationError( + "array_too_large", + `record.incidentRefs exceeds ${MAX_PUBLIC_INCIDENT_REFS}`, + ); + } + incidentRefs = raw.incidentRefs.map(validateIncidentRef); + const ids = incidentRefs.map((ref) => ref.corpusId); + if (new Set(ids).size !== ids.length) { + throw new PublicEvidenceValidationError("duplicate_id", "record.incidentRefs contains duplicates"); + } + } + + const artifactRefs = raw.artifactRefs === undefined + ? undefined + : validateUniqueIds(raw.artifactRefs, "record.artifactRefs", MAX_PUBLIC_ARTIFACT_REFS); + + const withoutRecordId: Omit = { + subjectId, + evidenceLayer, + suiteId: assertPublicIdentifier(raw.suiteId, "record.suiteId"), + suiteVersion: assertPublicIdentifier(raw.suiteVersion, "record.suiteVersion"), + scenarioId: assertPublicIdentifier(raw.scenarioId, "record.scenarioId"), + scenarioVersion: assertPublicIdentifier(raw.scenarioVersion, "record.scenarioVersion"), + verdict: assertClosed(raw.verdict, "record.verdict", VERDICTS), + observedDayUtc: assertUtcDay(raw.observedDayUtc, "record.observedDayUtc"), + subject, + assertions, + ...(incidentRefs !== undefined ? { incidentRefs } : {}), + ...(artifactRefs !== undefined ? { artifactRefs } : {}), + }; + const recordId = assertSha256(raw.recordId, "record.recordId"); + const expectedRecordId = publicEvidenceId("record", withoutRecordId); + if (recordId !== expectedRecordId) { + throw new PublicEvidenceValidationError("record_id_mismatch", "record.recordId does not match public record"); + } + return { recordId, ...withoutRecordId }; +} + +function validateRegistryEntry(rawValue: unknown, index: number): PublicRouteRegistryEntryV1 { + const raw = assertObject(rawValue, `entries[${index}]`); + assertKnownKeys(raw, `entries[${index}]`, ["providerId", "modelId", "adapterFamilies"]); + if (!Array.isArray(raw.adapterFamilies) || raw.adapterFamilies.length === 0) { + throw new PublicEvidenceValidationError("invalid_registry", `entries[${index}].adapterFamilies must be non-empty`); + } + const adapterFamilies = raw.adapterFamilies.map((value, adapterIndex) => + validateAdapterFamily(value, `entries[${index}].adapterFamilies[${adapterIndex}]`) + ); + if (new Set(adapterFamilies).size !== adapterFamilies.length) { + throw new PublicEvidenceValidationError("duplicate_id", `entries[${index}].adapterFamilies contains duplicates`); + } + return { + providerId: assertPublicIdentifier(raw.providerId, `entries[${index}].providerId`), + modelId: assertPublicIdentifier(raw.modelId, `entries[${index}].modelId`), + adapterFamilies, + }; +} + +export function validatePublicRouteRegistryManifest(rawValue: unknown): PublicRouteRegistryManifestV1 { + const raw = assertObject(rawValue, "publicRouteRegistry"); + assertKnownKeys(raw, "publicRouteRegistry", [ + "schemaVersion", + "registryVersion", + "sourceCommit", + "entries", + "manifestDigest", + ]); + if (raw.schemaVersion !== "public_route_registry_v1") { + throw new PublicEvidenceValidationError("unsupported_version", "unsupported public route registry schema"); + } + const registryVersion = assertPublicIdentifier(raw.registryVersion, "publicRouteRegistry.registryVersion"); + const sourceCommit = assertString(raw.sourceCommit, "publicRouteRegistry.sourceCommit", 40); + if (!SOURCE_COMMIT.test(sourceCommit)) { + throw new PublicEvidenceValidationError("invalid_registry", "publicRouteRegistry.sourceCommit must be a commit SHA"); + } + if (!Array.isArray(raw.entries) || raw.entries.length === 0 || raw.entries.length > 512) { + throw new PublicEvidenceValidationError("invalid_registry", "publicRouteRegistry.entries must contain 1..512 entries"); + } + const entries = raw.entries.map(validateRegistryEntry); + const identities = entries.map((entry) => `${entry.providerId}\0${entry.modelId}`); + if (new Set(identities).size !== identities.length) { + throw new PublicEvidenceValidationError("duplicate_id", "publicRouteRegistry.entries contains duplicates"); + } + const manifestDigest = assertSha256(raw.manifestDigest, "publicRouteRegistry.manifestDigest"); + const expectedDigest = publicEvidenceId("route_registry", { + schemaVersion: "public_route_registry_v1", + registryVersion, + sourceCommit, + entries, + }); + if (manifestDigest !== expectedDigest) { + throw new PublicEvidenceValidationError("digest_invalid", "publicRouteRegistry.manifestDigest mismatch"); + } + return { + schemaVersion: "public_route_registry_v1", + registryVersion, + sourceCommit, + entries, + manifestDigest, + }; +} diff --git a/tests/lab-private-file-consumer-recovery.test.ts b/tests/lab-private-file-consumer-recovery.test.ts new file mode 100644 index 0000000000..b427795976 --- /dev/null +++ b/tests/lab-private-file-consumer-recovery.test.ts @@ -0,0 +1,70 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdtempSync, readdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { publicEvidenceId } from "../src/lab/public/ids"; +import { isPrivateFileStageName, setPrivateFileCommitFaultForTests } from "../src/lab/public/private-file"; +import { getOrCreatePublicPublisher, signPublicEvidenceBundle } from "../src/lab/public/signature"; +import { storePublicEvidenceBundle } from "../src/lab/public/storage"; +import type { PublicEvidenceRecordV1 } from "../src/lab/public/types"; + +const roots: string[] = []; +afterEach(() => { + setPrivateFileCommitFaultForTests(null); + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +function root(): string { + const value = mkdtempSync(join(tmpdir(), "ocx-cl10-recovery-")); + roots.push(value); + return value; +} + +function record(): PublicEvidenceRecordV1 { + const subject = { + subjectKind: "protocol" as const, + compatibilityVersion: "2.13.0", + adapterFamily: "openai-chat" as const, + inboundProtocol: "openai-responses", + upstreamProtocol: "openai-chat", + surface: "responses-http", + }; + const subjectId = publicEvidenceId("subject", subject); + const body = { + subjectId, + evidenceLayer: "protocol_conformance" as const, + suiteId: "responses-core", + suiteVersion: "1.0.0", + scenarioId: "responses-core.protocol.request-shape", + scenarioVersion: "1.0.0", + verdict: "VERIFIED" as const, + observedDayUtc: "2026-08-12", + subject, + assertions: [ + { id: "method", required: true, passed: true }, + { id: "message", required: true, passed: true }, + { id: "temperature", required: true, passed: true }, + ], + }; + return { recordId: publicEvidenceId("record", body), ...body }; +} + +test("publisher key recovers after a same-process parent-directory sync failure", () => { + if (process.platform === "win32") return; + const configDir = root(); + setPrivateFileCommitFaultForTests("parent_directory_sync"); + expect(() => getOrCreatePublicPublisher(configDir)).toThrow(); + setPrivateFileCommitFaultForTests(null); + expect(getOrCreatePublicPublisher(configDir).publisher.algorithm).toBe("ed25519"); + expect(readdirSync(join(configDir, "lab")).filter(isPrivateFileStageName)).toEqual([]); +}); + +test("public bundle storage recovers after a same-process parent-directory sync failure", () => { + if (process.platform === "win32") return; + const configDir = root(); + const bundle = signPublicEvidenceBundle({ records: [record()], artifacts: [], createdDayUtc: "2026-08-12", configDir }); + setPrivateFileCommitFaultForTests("parent_directory_sync"); + expect(() => storePublicEvidenceBundle(bundle, configDir)).toThrow(); + setPrivateFileCommitFaultForTests(null); + expect(storePublicEvidenceBundle(bundle, configDir).created).toBe(false); +}); diff --git a/tests/lab-private-file-durability.test.ts b/tests/lab-private-file-durability.test.ts new file mode 100644 index 0000000000..69ff1dcf68 --- /dev/null +++ b/tests/lab-private-file-durability.test.ts @@ -0,0 +1,70 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + isPrivateFileStageName, + publishPrivateFileExclusive, + setPrivateFileCommitFaultForTests, +} from "../src/lab/public/private-file"; + +const roots: string[] = []; +const setCommitFault = setPrivateFileCommitFaultForTests as unknown as (fault: string | null) => void; + +afterEach(() => { + setPrivateFileCommitFaultForTests(null); + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +function tempRoot(): string { + const root = mkdtempSync(join(tmpdir(), "ocx-cl10-private-file-")); + roots.push(root); + return root; +} + +describe("CL-10 private-file durability", () => { + test("POSIX parent-directory sync failure preserves the published stage until retry", () => { + if (process.platform === "win32") return; + const root = tempRoot(); + const finalPath = join(root, "bundle.json"); + const bytes = Buffer.from("durable-public-evidence", "utf8"); + + setCommitFault("parent_directory_sync"); + expect(() => publishPrivateFileExclusive(finalPath, bytes)).toThrow(/directory.*sync|durab/i); + expect(existsSync(finalPath)).toBe(true); + expect(readdirSync(root).filter(isPrivateFileStageName)).toHaveLength(1); + + setPrivateFileCommitFaultForTests(null); + expect(publishPrivateFileExclusive(finalPath, bytes)).toEqual({ created: false }); + expect(readFileSync(finalPath).equals(bytes)).toBe(true); + expect(readdirSync(root).filter(isPrivateFileStageName)).toEqual([]); + }); + + test("before-publish failure leaves no final path or staging entry and retry creates cleanly", () => { + const root = tempRoot(); + const finalPath = join(root, "bundle.json"); + const bytes = Buffer.from("durable-public-evidence", "utf8"); + + setCommitFault("before_publish"); + expect(() => publishPrivateFileExclusive(finalPath, bytes)).toThrow(/before publish/i); + expect(existsSync(finalPath)).toBe(false); + expect(readdirSync(root).filter(isPrivateFileStageName)).toEqual([]); + + setPrivateFileCommitFaultForTests(null); + expect(publishPrivateFileExclusive(finalPath, bytes)).toEqual({ created: true }); + expect(readFileSync(finalPath).equals(bytes)).toBe(true); + expect(readdirSync(root).filter(isPrivateFileStageName)).toEqual([]); + }); + + test("Windows publication does not require parent-directory fsync", () => { + if (process.platform !== "win32") return; + const root = tempRoot(); + const finalPath = join(root, "bundle.json"); + const bytes = Buffer.from("durable-public-evidence", "utf8"); + + setCommitFault("parent_directory_sync"); + expect(publishPrivateFileExclusive(finalPath, bytes)).toEqual({ created: true }); + expect(readFileSync(finalPath).equals(bytes)).toBe(true); + expect(readdirSync(root).filter(isPrivateFileStageName)).toEqual([]); + }); +}); diff --git a/tests/lab-public-core-contract.test.ts b/tests/lab-public-core-contract.test.ts new file mode 100644 index 0000000000..168f2c8fbf --- /dev/null +++ b/tests/lab-public-core-contract.test.ts @@ -0,0 +1,162 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { labPublicPublisherKeyPath } from "../src/lab/paths"; +import { buildPublicEvidenceBundle } from "../src/lab/public/bundle"; +import { publicEvidenceId } from "../src/lab/public/ids"; +import { validatePublicEvidencePrivacy } from "../src/lab/public/privacy"; +import { PUBLIC_ROUTE_REGISTRY_V1 } from "../src/lab/public/registry"; +import { signPublicEvidenceBundle, verifyPublicEvidenceBundle } from "../src/lab/public/signature"; +import { parseStrictPublicJson } from "../src/lab/public/strict-json"; +import type { PublicEvidenceBundleUnsignedV1 } from "../src/lab/public/types"; +import { validatePublicRouteRegistryManifest } from "../src/lab/public/validate"; + +const FIXED_PRIVATE_KEY = [ + `-----BEGIN PRIVATE ${"KEY"}-----`, + ["MC4CAQAwBQYDK2VwBCIEIAABAgMEBQYH", "CAkKCwwNDg8QERITFBUWFxgZGhscHR4f"].join(""), + `-----END PRIVATE ${"KEY"}-----`, + "", +].join("\n"); +const FIXED_PUBLIC_KEY = "MCowBQYDK2VwAyEAA6EHv/POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg="; +const REVIEWED_AUTHORITY_SOURCE_COMMIT = "75a21417657ba5a3033198be0d8ae949de723d11"; + +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 installFixedPublisherKey(config: string): void { + const path = labPublicPublisherKeyPath(config); + mkdirSync(join(path, ".."), { recursive: true, mode: 0o700 }); + writeFileSync(path, FIXED_PRIVATE_KEY, { encoding: "utf8", mode: 0o600 }); + if (process.platform !== "win32") chmodSync(path, 0o600); +} + +function fixedRecord() { + const subject = { + subjectKind: "protocol" as const, + compatibilityVersion: "2.13.0", + adapterFamily: "openai-chat" as const, + inboundProtocol: "openai-responses", + upstreamProtocol: "openai-chat", + surface: "responses-http", + }; + const subjectId = publicEvidenceId("subject", subject); + const withoutRecordId = { + subjectId, + evidenceLayer: "protocol_conformance" as const, + suiteId: "responses-core", + suiteVersion: "1.0.0", + scenarioId: "responses-core.protocol.request-shape", + scenarioVersion: "1.0.0", + verdict: "VERIFIED" as const, + observedDayUtc: "2026-08-12", + subject, + assertions: [ + { id: "method", required: true, passed: true }, + { id: "message", required: true, passed: true }, + { id: "temperature", required: true, passed: true }, + ], + }; + return { recordId: publicEvidenceId("record", withoutRecordId), ...withoutRecordId }; +} + +describe("CL-10 public evidence core contract", () => { + test("freezes the RFC 8785/domain-separated bundle and Ed25519 vector", () => { + const config = configDir("ocx-cl10-core-wire-"); + installFixedPublisherKey(config); + const bundle = signPublicEvidenceBundle({ + records: [fixedRecord()], + artifacts: [], + createdDayUtc: "2026-08-12", + configDir: config, + }); + + expect(bundle.publisher.publicKey).toBe(FIXED_PUBLIC_KEY); + expect(bundle.publisher.keyId).toBe("4d5a347afcc7a1ac8d2dd4e573f0fbca2d2e90dd472c35df5c72bf2d2afca08f"); + expect(bundle.records[0]!.recordId).toBe("5bec20821bbf01f831e74ba469e7f18481c1209fdef209c76f482105de3e406d"); + expect(bundle.bundleId).toBe("a7598b68a4cf884dc381b1d88111e74bfad5e74ceae2be8de55b88bac3250401"); + expect(bundle.bundleDigest).toBe("aeef2f3e64a131588f6a34aaea1172c352a0c803f838690c2d6ee652ca74fb87"); + expect(bundle.signature.signature).toBe("UAiI7Mz4/yIU5XjSuNZFSuyFPoAvGCy+x9cpTCwYKnFDq20AP6ipV3zowD3S4KP2iYfkXyHTMsMH3CEnz6lCBw=="); + expect(verifyPublicEvidenceBundle(bundle)).toEqual({ status: "cryptographically_valid" }); + }); + + test("rejects non-canonical publisher Base64", () => { + const publicKey = `${FIXED_PUBLIC_KEY}\n`; + const publisher = { + algorithm: "ed25519" as const, + publicKey, + keyId: publicEvidenceId("publisher_key", { algorithm: "ed25519", publicKey }), + }; + expect(() => buildPublicEvidenceBundle({ + records: [fixedRecord()], + artifacts: [], + createdDayUtc: "2026-08-12", + publisher, + })).toThrow(/canonical base64/i); + }); + + test("strict JSON rejects duplicate decoded keys and bound violations before materialization", () => { + expect(() => parseStrictPublicJson(Buffer.from('{"a":1,"\\u0061":2}', "utf8"))) + .toThrow(/duplicate json object key/i); + expect(() => parseStrictPublicJson(Buffer.from(`${"[".repeat(9)}0${"]".repeat(9)}`, "utf8"))) + .toThrow(/nesting depth exceeds 8/i); + expect(() => parseStrictPublicJson(Buffer.from(`[${Array.from({ length: 513 }, () => "0").join(",")}]`, "utf8"))) + .toThrow(/array exceeds 512/i); + const wide = `{${Array.from({ length: 65 }, (_, index) => `"k${index}":0`).join(",")}}`; + expect(() => parseStrictPublicJson(Buffer.from(wide, "utf8"))).toThrow(/object exceeds 64/i); + }); + + test("local signing rejects artifact bytes before creating publisher state", () => { + const config = configDir("ocx-cl10-core-artifact-"); + const contentBase64 = Buffer.from("credential-canary-1234567890", "utf8").toString("base64"); + const artifact = { + artifactClass: "verifier_summary", + mediaType: "text/plain", + byteCount: Buffer.from(contentBase64, "base64").byteLength, + contentBase64, + }; + const artifactId = publicEvidenceId("artifact", artifact); + expect(() => signPublicEvidenceBundle({ + records: [], + artifacts: [{ artifactId, ...artifact }], + createdDayUtc: "2026-08-12", + configDir: config, + })).toThrow(/public_export/i); + expect(existsSync(labPublicPublisherKeyPath(config))).toBe(false); + }); + + test("privacy rejects embedded unbracketed IPv6 in artifact text", () => { + const bytes = Buffer.from("artifact 2001:db8::1 content", "utf8"); + const bundle = { + createdDayUtc: "2026-08-13", + records: [], + artifacts: [{ + artifactId: "0".repeat(64), + artifactClass: "verifier_summary", + mediaType: "text/plain", + byteCount: bytes.byteLength, + contentBase64: bytes.toString("base64"), + }], + } as unknown as PublicEvidenceBundleUnsignedV1; + expect(() => validatePublicEvidencePrivacy(bundle)).toThrow(/IP address|privacy/i); + }); + + test("pins the reviewed public route registry authority", () => { + const manifest = validatePublicRouteRegistryManifest(PUBLIC_ROUTE_REGISTRY_V1); + expect(manifest.registryVersion).toBe("2026-08-13.v2"); + expect(manifest.sourceCommit).toBe(REVIEWED_AUTHORITY_SOURCE_COMMIT); + expect(manifest.entries).toEqual([{ + providerId: "openai", + modelId: "gpt-5.6-sol", + adapterFamilies: ["openai-responses"], + }]); + }); +}); diff --git a/tests/lab-public-file-safety.test.ts b/tests/lab-public-file-safety.test.ts new file mode 100644 index 0000000000..ae2ff64502 --- /dev/null +++ b/tests/lab-public-file-safety.test.ts @@ -0,0 +1,36 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { readPrivateRegularFile } from "../src/lab/public/file-safety"; +import { PublicEvidenceValidationError } from "../src/lab/public/validate"; + +const roots: string[] = []; +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +function tempRoot(): string { + const root = mkdtempSync(join(tmpdir(), "ocx-cl10-file-safety-")); + roots.push(root); + return root; +} + +test("descriptor-bound private reads reject a symlink even when O_NOFOLLOW is unavailable", () => { + const root = tempRoot(); + const target = join(root, "target.txt"); + const link = join(root, "link.txt"); + writeFileSync(target, "safe-bytes", { mode: 0o600 }); + try { + symlinkSync(target, link, "file"); + } catch (error) { + if (process.platform === "win32" && (error as NodeJS.ErrnoException).code === "EPERM") return; + throw error; + } + + expect(() => readPrivateRegularFile(link, { + maxBytes: 1024, + errorCode: "unsafe_test_file", + errorMessage: "unsafe test file", + })).toThrow(PublicEvidenceValidationError); +}); diff --git a/tests/lab-public-security-regressions.test.ts b/tests/lab-public-security-regressions.test.ts new file mode 100644 index 0000000000..eaa4a0da25 --- /dev/null +++ b/tests/lab-public-security-regressions.test.ts @@ -0,0 +1,52 @@ +import { afterEach, expect, test } from "bun:test"; +import { existsSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { jcsStringify } from "../src/lab/conformance/jcs"; +import { labPublicPublisherKeyPath } from "../src/lab/paths"; +import { getOrCreatePublicPublisher } from "../src/lab/public/signature"; +import { + resetHardenedStateForTests, + setIcaclsRunnerForTests, + setPlatformForTests, +} from "../src/lib/windows-secret-acl"; + +const roots: string[] = []; + +afterEach(() => { + setIcaclsRunnerForTests(null); + setPlatformForTests(null); + resetHardenedStateForTests(); + for (const root of roots.splice(0)) { + if (existsSync(root)) rmSync(root, { recursive: true, force: true }); + } +}); + +function configDir(prefix: string): string { + const root = mkdtempSync(join(tmpdir(), prefix)); + roots.push(root); + return root; +} + +test("JCS rejects sparse JavaScript arrays instead of collapsing holes", () => { + const sparse = new Array(1); + expect(() => jcsStringify(sparse)).toThrow(/sparse|array hole/i); +}); + +test("publisher key creation applies required Windows secret ACL hardening to the final key path", () => { + const home = configDir("ocx-cl10-windows-publisher-acl-"); + const keyPath = labPublicPublisherKeyPath(home); + const calls: string[][] = []; + + resetHardenedStateForTests(); + setPlatformForTests("win32"); + setIcaclsRunnerForTests((args) => { + calls.push(args); + return { success: true, exitCode: 0, timedOut: false, stdout: "" }; + }); + + expect(getOrCreatePublicPublisher(home).publisher.algorithm).toBe("ed25519"); + expect(existsSync(keyPath)).toBe(true); + expect(calls.some((args) => args[0] === keyPath && args.includes("/grant:r"))).toBe(true); + expect(calls.some((args) => args[0] === keyPath && args.includes("/inheritance:r"))).toBe(true); +}); From 45a030a00ed3edc5f95bd276cc20ad4c85278742 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 14 Aug 2026 01:06:23 +0200 Subject: [PATCH 02/12] fix(lab): bound private file stage cleanup --- src/lab/public/private-file.ts | 42 ++++++++++++++++++++++++---------- 1 file changed, 30 insertions(+), 12 deletions(-) diff --git a/src/lab/public/private-file.ts b/src/lab/public/private-file.ts index 190f9a497e..b623071886 100644 --- a/src/lab/public/private-file.ts +++ b/src/lab/public/private-file.ts @@ -6,16 +6,17 @@ import { linkSync, lstatSync, openSync, - readFileSync, readdirSync, unlinkSync, writeSync, } from "node:fs"; +import type { Stats } from "node:fs"; import { basename, dirname, join } from "node:path"; export type PrivateFileCommitFault = "before_publish" | "parent_directory_sync" | null; let privateFileCommitFaultForTests: PrivateFileCommitFault = null; const PRIVATE_STAGE_RE = /^\..+\.(\d+)\.[0-9a-f-]{36}\.tmp$/; +export const PRIVATE_FILE_STAGE_RETENTION_MS = 24 * 60 * 60 * 1000; function cleanup(path: string): void { try { unlinkSync(path); } catch { /* absent/already removed */ } @@ -75,7 +76,31 @@ export function isPrivateFileStageName(name: string): boolean { return PRIVATE_STAGE_RE.test(name); } -/** Reclaim all private-file stages in a directory whose writer is definitely dead. */ +function isPrivateRegularStage(stats: Stats): boolean { + return stats.isFile() && !stats.isSymbolicLink(); +} + +function shouldReclaimPrivateFileStage(dir: string, name: string, nowMs: number): boolean { + const match = PRIVATE_STAGE_RE.exec(name); + if (!match) return false; + const pid = Number(match[1]); + if (!Number.isSafeInteger(pid) || pid <= 0) return false; + + let stats: Stats; + try { + stats = lstatSync(join(dir, name)); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; + throw error; + } + if (!isPrivateRegularStage(stats)) return false; + + const expired = nowMs - stats.mtimeMs > PRIVATE_FILE_STAGE_RETENTION_MS; + const dead = pid !== process.pid && pidDefinitelyDead(pid); + return expired || dead; +} + +/** Reclaim private-file stages whose writer is dead or whose crash witness is past the retention window. */ export function cleanupStalePrivateFileStagesInDir(dir: string): void { let names: string[]; try { @@ -84,12 +109,10 @@ export function cleanupStalePrivateFileStagesInDir(dir: string): void { if ((error as NodeJS.ErrnoException).code === "ENOENT") return; throw error; } + const nowMs = Date.now(); let changed = false; for (const name of names) { - const match = PRIVATE_STAGE_RE.exec(name); - if (!match) continue; - const pid = Number(match[1]); - if (!Number.isSafeInteger(pid) || pid <= 0 || pid === process.pid || !pidDefinitelyDead(pid)) continue; + if (!shouldReclaimPrivateFileStage(dir, name, nowMs)) continue; try { unlinkSync(join(dir, name)); changed = true; @@ -100,7 +123,7 @@ export function cleanupStalePrivateFileStagesInDir(dir: string): void { if (changed) fsyncParentBestEffort(join(dir, ".")); } -/** Reclaim staging links from writers that are definitely no longer alive. */ +/** Reclaim staging links from dead writers or expired crash witnesses. */ export function cleanupStalePrivateFileStages(finalPath: string): void { cleanupStalePrivateFileStagesInDir(dirname(finalPath)); } @@ -203,11 +226,6 @@ export function publishPrivateFileExclusive( } } -export function readPublishedPrivateFile(path: string): Buffer { - cleanupStalePrivateFileStages(path); - return readFileSync(path); -} - /** Test-only fault seam at the atomic publication point. Import this module directly in tests. */ export function setPrivateFileCommitFaultForTests(fault: PrivateFileCommitFault): void { privateFileCommitFaultForTests = fault; From 9c37da185ad1b094242ab06887d798d4f991aa56 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 14 Aug 2026 01:07:17 +0200 Subject: [PATCH 03/12] test(lab): cover expired private file stages --- tests/lab-private-file-durability.test.ts | 26 ++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/tests/lab-private-file-durability.test.ts b/tests/lab-private-file-durability.test.ts index 69ff1dcf68..781b31e75d 100644 --- a/tests/lab-private-file-durability.test.ts +++ b/tests/lab-private-file-durability.test.ts @@ -1,9 +1,11 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs"; +import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, utimesSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { + cleanupStalePrivateFileStages, isPrivateFileStageName, + PRIVATE_FILE_STAGE_RETENTION_MS, publishPrivateFileExclusive, setPrivateFileCommitFaultForTests, } from "../src/lab/public/private-file"; @@ -56,6 +58,28 @@ describe("CL-10 private-file durability", () => { expect(readdirSync(root).filter(isPrivateFileStageName)).toEqual([]); }); + test("expired published crash witnesses are reclaimed without requiring a retry", () => { + if (process.platform === "win32") return; + const root = tempRoot(); + const finalPath = join(root, "bundle.json"); + const bytes = Buffer.from("durable-public-evidence", "utf8"); + + setCommitFault("parent_directory_sync"); + expect(() => publishPrivateFileExclusive(finalPath, bytes)).toThrow(/directory.*sync|durab/i); + setPrivateFileCommitFaultForTests(null); + + const stages = readdirSync(root).filter(isPrivateFileStageName); + expect(stages).toHaveLength(1); + const stagePath = join(root, stages[0]!); + const old = new Date(Date.now() - PRIVATE_FILE_STAGE_RETENTION_MS - 60_000); + utimesSync(stagePath, old, old); + + cleanupStalePrivateFileStages(finalPath); + + expect(readFileSync(finalPath).equals(bytes)).toBe(true); + expect(readdirSync(root).filter(isPrivateFileStageName)).toEqual([]); + }); + test("Windows publication does not require parent-directory fsync", () => { if (process.platform !== "win32") return; const root = tempRoot(); From bd0763b16e316910347e5ad78e31efaa477c98d3 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 14 Aug 2026 01:35:42 +0200 Subject: [PATCH 04/12] fix(lab): bound strict public JSON input (#1641) --- src/lab/public/strict-json.ts | 8 ++++++++ tests/lab-public-core-contract.test.ts | 4 +++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/lab/public/strict-json.ts b/src/lab/public/strict-json.ts index fe7599cb68..339c41797c 100644 --- a/src/lab/public/strict-json.ts +++ b/src/lab/public/strict-json.ts @@ -1,5 +1,6 @@ import { PublicEvidenceValidationError } from "./validate"; +const MAX_PUBLIC_JSON_BYTES = 2 * 1024 * 1024; const MAX_PUBLIC_JSON_DEPTH = 8; const MAX_PUBLIC_JSON_OBJECT_KEYS = 64; const MAX_PUBLIC_JSON_ARRAY_ELEMENTS = 512; @@ -183,7 +184,14 @@ export function parseStrictPublicJson( bytes: Uint8Array, label = "public JSON", invalidCode = "public_json", + maxBytes = MAX_PUBLIC_JSON_BYTES, ): unknown { + if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) { + throw new PublicEvidenceValidationError(invalidCode, `${label} byte limit is invalid`); + } + if (bytes.byteLength > maxBytes) { + throw new PublicEvidenceValidationError(invalidCode, `${label} exceeds ${maxBytes} bytes`); + } const buffer = Buffer.from(bytes); const text = buffer.toString("utf8"); if (!Buffer.from(text, "utf8").equals(buffer)) { diff --git a/tests/lab-public-core-contract.test.ts b/tests/lab-public-core-contract.test.ts index 168f2c8fbf..08827f01cc 100644 --- a/tests/lab-public-core-contract.test.ts +++ b/tests/lab-public-core-contract.test.ts @@ -112,6 +112,8 @@ describe("CL-10 public evidence core contract", () => { .toThrow(/array exceeds 512/i); const wide = `{${Array.from({ length: 65 }, (_, index) => `"k${index}":0`).join(",")}}`; expect(() => parseStrictPublicJson(Buffer.from(wide, "utf8"))).toThrow(/object exceeds 64/i); + expect(() => parseStrictPublicJson(Buffer.alloc((2 * 1024 * 1024) + 1, 0x20))) + .toThrow(/exceeds 2097152 bytes/i); }); test("local signing rejects artifact bytes before creating publisher state", () => { @@ -159,4 +161,4 @@ describe("CL-10 public evidence core contract", () => { adapterFamilies: ["openai-responses"], }]); }); -}); +}); \ No newline at end of file From bf2c551620c26e9a4ba0512695a8003ea080231c Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 14 Aug 2026 04:26:35 +0200 Subject: [PATCH 05/12] fix(lab): harden private stages before publication --- src/lab/public/private-file.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/lab/public/private-file.ts b/src/lab/public/private-file.ts index b623071886..b648ffaf6f 100644 --- a/src/lab/public/private-file.ts +++ b/src/lab/public/private-file.ts @@ -18,6 +18,11 @@ let privateFileCommitFaultForTests: PrivateFileCommitFault = null; const PRIVATE_STAGE_RE = /^\..+\.(\d+)\.[0-9a-f-]{36}\.tmp$/; export const PRIVATE_FILE_STAGE_RETENTION_MS = 24 * 60 * 60 * 1000; +export interface PrivateFilePublishOptions { + /** Validate or harden the fully-written stage before the final pathname becomes visible. */ + beforePublish?: (stagePath: string) => void; +} + function cleanup(path: string): void { try { unlinkSync(path); } catch { /* absent/already removed */ } } @@ -176,6 +181,7 @@ function writeAll(fd: number, bytes: Uint8Array): void { export function publishPrivateFileExclusive( finalPath: string, bytes: Uint8Array, + options: PrivateFilePublishOptions = {}, ): { created: boolean } { cleanupStalePrivateFileStages(finalPath); const tempPath = join( @@ -195,6 +201,10 @@ export function publishPrivateFileExclusive( throw new Error("synthetic private-file commit failure before publish"); } + // Secret callers can harden the stage while it is still unreachable through + // the final pathname. A failure here leaves no published object behind. + options.beforePublish?.(tempPath); + try { linkSync(tempPath, finalPath); } catch (error) { From ab92c90b64830a5de2c0fa5f23d68c0fa729f73c Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 14 Aug 2026 04:27:02 +0200 Subject: [PATCH 06/12] fix(lab): protect publisher key before final link --- src/lab/public/signature.ts | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/src/lab/public/signature.ts b/src/lab/public/signature.ts index 6429445a28..698a5f5c24 100644 --- a/src/lab/public/signature.ts +++ b/src/lab/public/signature.ts @@ -52,15 +52,11 @@ function publisherForPrivateKey(privateKeyPem: string): PublicPublisherV1 { }; } -function readRestrictedPrivateKey(path: string): string { - cleanupStalePrivateFileStages(path); - // Prove the pathname is the expected private regular file before applying any - // platform ACL operation, then fail closed if Windows per-user ACL hardening - // cannot be established. The helper is a no-op success on non-Windows. +function requirePublisherKeyAcl(path: string, timeoutMemoKey = path): void { privateRegularFileSize(path, PRIVATE_KEY_FILE_OPTIONS); let hardened: { ok: boolean }; try { - hardened = hardenSecretPath(path, { required: true }); + hardened = hardenSecretPath(path, { required: true, timeoutMemoKey }); } catch { hardened = { ok: false }; } @@ -70,6 +66,14 @@ function readRestrictedPrivateKey(path: string): string { "public publisher key ACL hardening did not complete", ); } +} + +function readRestrictedPrivateKey(path: string): string { + cleanupStalePrivateFileStages(path); + // Prove the pathname is the expected private regular file before applying any + // platform ACL operation, then fail closed if Windows per-user ACL hardening + // cannot be established. The helper is a no-op success on non-Windows. + requirePublisherKeyAcl(path); const pem = readPrivateRegularFile(path, PRIVATE_KEY_FILE_OPTIONS).toString("utf8"); const key = createPrivateKey(pem); if (key.asymmetricKeyType !== "ed25519") { @@ -83,7 +87,11 @@ function createPrivateKeyFile(path: string): string { privateKeyEncoding: { type: "pkcs8", format: "pem" }, publicKeyEncoding: { type: "spki", format: "pem" }, }); - publishPrivateFileExclusive(path, Buffer.from(privateKey, "utf8")); + publishPrivateFileExclusive(path, Buffer.from(privateKey, "utf8"), { + // On Windows, harden the stage before the final key pathname is visible. A + // required ACL failure therefore cannot leave a newly-published key exposed. + beforePublish: stagePath => requirePublisherKeyAcl(stagePath, path), + }); return readRestrictedPrivateKey(path); } From 41a8fd995fa8e6f027b2ebfc4dcfeca537ab3972 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 14 Aug 2026 04:27:14 +0200 Subject: [PATCH 07/12] test(lab): reject publisher key publication on ACL failure --- tests/lab-public-security-regressions.test.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/lab-public-security-regressions.test.ts b/tests/lab-public-security-regressions.test.ts index eaa4a0da25..4202a7a593 100644 --- a/tests/lab-public-security-regressions.test.ts +++ b/tests/lab-public-security-regressions.test.ts @@ -50,3 +50,20 @@ test("publisher key creation applies required Windows secret ACL hardening to th expect(calls.some((args) => args[0] === keyPath && args.includes("/grant:r"))).toBe(true); expect(calls.some((args) => args[0] === keyPath && args.includes("/inheritance:r"))).toBe(true); }); + +test("publisher key creation never publishes the final path when required Windows ACL hardening fails", () => { + const home = configDir("ocx-cl10-windows-publisher-acl-fail-"); + const keyPath = labPublicPublisherKeyPath(home); + + resetHardenedStateForTests(); + setPlatformForTests("win32"); + setIcaclsRunnerForTests(() => ({ + success: false, + exitCode: 5, + timedOut: false, + stdout: "", + })); + + expect(() => getOrCreatePublicPublisher(home)).toThrow(/ACL hardening/i); + expect(existsSync(keyPath)).toBe(false); +}); From 15d57520f257a614825a9898f097e9854fefa277 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 14 Aug 2026 04:39:53 +0200 Subject: [PATCH 08/12] test(lab): prepare private stages before secret writes --- tests/lab-public-security-regressions.test.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/tests/lab-public-security-regressions.test.ts b/tests/lab-public-security-regressions.test.ts index 4202a7a593..9328e4f90c 100644 --- a/tests/lab-public-security-regressions.test.ts +++ b/tests/lab-public-security-regressions.test.ts @@ -1,9 +1,10 @@ import { afterEach, expect, test } from "bun:test"; -import { existsSync, mkdtempSync, rmSync } from "node:fs"; +import { existsSync, mkdtempSync, rmSync, statSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { jcsStringify } from "../src/lab/conformance/jcs"; import { labPublicPublisherKeyPath } from "../src/lab/paths"; +import { publishPrivateFileExclusive } from "../src/lab/public/private-file"; import { getOrCreatePublicPublisher } from "../src/lab/public/signature"; import { resetHardenedStateForTests, @@ -33,6 +34,17 @@ test("JCS rejects sparse JavaScript arrays instead of collapsing holes", () => { expect(() => jcsStringify(sparse)).toThrow(/sparse|array hole/i); }); +test("private publication prepares an empty stage before writing secret bytes", () => { + const root = configDir("ocx-cl10-private-stage-prepare-"); + const finalPath = join(root, "secret.bin"); + const observedSizes: number[] = []; + + expect(publishPrivateFileExclusive(finalPath, Buffer.from("secret", "utf8"), { + prepareStage: stagePath => observedSizes.push(statSync(stagePath).size), + })).toEqual({ created: true }); + expect(observedSizes).toEqual([0]); +}); + test("publisher key creation applies required Windows secret ACL hardening to the final key path", () => { const home = configDir("ocx-cl10-windows-publisher-acl-"); const keyPath = labPublicPublisherKeyPath(home); From 2b8eafea47a8dccfc12da221e7f8c89571bc4a21 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 14 Aug 2026 04:40:21 +0200 Subject: [PATCH 09/12] fix(lab): harden private stages before writing secrets --- src/lab/public/private-file.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/lab/public/private-file.ts b/src/lab/public/private-file.ts index b648ffaf6f..ac21aac87d 100644 --- a/src/lab/public/private-file.ts +++ b/src/lab/public/private-file.ts @@ -19,8 +19,8 @@ const PRIVATE_STAGE_RE = /^\..+\.(\d+)\.[0-9a-f-]{36}\.tmp$/; export const PRIVATE_FILE_STAGE_RETENTION_MS = 24 * 60 * 60 * 1000; export interface PrivateFilePublishOptions { - /** Validate or harden the fully-written stage before the final pathname becomes visible. */ - beforePublish?: (stagePath: string) => void; + /** Validate or harden the empty stage before caller-controlled bytes are written. */ + prepareStage?: (stagePath: string) => void; } function cleanup(path: string): void { @@ -192,6 +192,9 @@ export function publishPrivateFileExclusive( let preservePublishedStage = false; try { fd = openSync(tempPath, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL, 0o600); + // Secret callers can harden the empty stage before any sensitive bytes exist. + // A failure here can therefore leave at most an empty cleanup witness. + options.prepareStage?.(tempPath); writeAll(fd, bytes); fsyncSync(fd); closeSync(fd); @@ -201,10 +204,6 @@ export function publishPrivateFileExclusive( throw new Error("synthetic private-file commit failure before publish"); } - // Secret callers can harden the stage while it is still unreachable through - // the final pathname. A failure here leaves no published object behind. - options.beforePublish?.(tempPath); - try { linkSync(tempPath, finalPath); } catch (error) { From b5f9b7543dcf464910e1ba62b17e8d14a70ba311 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 14 Aug 2026 04:40:45 +0200 Subject: [PATCH 10/12] fix(lab): harden publisher stage before key bytes --- src/lab/public/signature.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/lab/public/signature.ts b/src/lab/public/signature.ts index 698a5f5c24..dc5712bf2e 100644 --- a/src/lab/public/signature.ts +++ b/src/lab/public/signature.ts @@ -88,9 +88,9 @@ function createPrivateKeyFile(path: string): string { publicKeyEncoding: { type: "spki", format: "pem" }, }); publishPrivateFileExclusive(path, Buffer.from(privateKey, "utf8"), { - // On Windows, harden the stage before the final key pathname is visible. A - // required ACL failure therefore cannot leave a newly-published key exposed. - beforePublish: stagePath => requirePublisherKeyAcl(stagePath, path), + // On Windows, harden the empty stage before private key bytes are written. + // A required ACL failure therefore cannot strand secret bytes in a stage. + prepareStage: stagePath => requirePublisherKeyAcl(stagePath, path), }); return readRestrictedPrivateKey(path); } From 8374c6d5b62b2b58f321efe72af34d679ce910e0 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 14 Aug 2026 08:13:50 +0200 Subject: [PATCH 11/12] fix(lab): address public evidence review findings --- src/lab/public/bundle.ts | 14 ++++++-- src/lab/public/project.ts | 3 ++ src/lab/public/time.ts | 9 ++++- ...lab-private-file-consumer-recovery.test.ts | 4 +-- tests/lab-private-file-durability.test.ts | 9 +++-- tests/lab-public-core-contract.test.ts | 34 ++++++++++++++++++- tests/lab-public-file-safety.test.ts | 17 +++++++++- tests/lab-public-security-regressions.test.ts | 21 ++++++++++++ 8 files changed, 99 insertions(+), 12 deletions(-) diff --git a/src/lab/public/bundle.ts b/src/lab/public/bundle.ts index 712a3c3102..9bedd41120 100644 --- a/src/lab/public/bundle.ts +++ b/src/lab/public/bundle.ts @@ -26,6 +26,13 @@ export interface BuildPublicEvidenceBundleInput extends PublicEvidenceContentInp publisher: PublicPublisherV1; } +/** Deterministic, locale-independent code-unit ordering for canonical identity. */ +function compareCanonicalId(a: string, b: string): number { + if (a < b) return -1; + if (a > b) return 1; + return 0; +} + function utcDay(value: string): string { if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) { throw new PublicEvidenceValidationError("invalid_day", "createdDayUtc must be YYYY-MM-DD"); @@ -130,11 +137,14 @@ export function normalizePublicEvidenceContent(input: PublicEvidenceContentInput if (!Array.isArray(input.records) || input.records.length > MAX_PUBLIC_BUNDLE_RECORDS) { throw new PublicEvidenceValidationError("array_too_large", `records exceeds ${MAX_PUBLIC_BUNDLE_RECORDS}`); } - const records = input.records.map(validatePublicEvidenceRecord).sort((a, b) => a.recordId.localeCompare(b.recordId)); + const records = input.records + .map(validatePublicEvidenceRecord) + .sort((a, b) => compareCanonicalId(a.recordId, b.recordId)); if (new Set(records.map((record) => record.recordId)).size !== records.length) { throw new PublicEvidenceValidationError("duplicate_id", "records contains duplicate ids"); } - const artifacts = validateArtifacts(input.artifacts).sort((a, b) => a.artifactId.localeCompare(b.artifactId)); + const artifacts = validateArtifacts(input.artifacts) + .sort((a, b) => compareCanonicalId(a.artifactId, b.artifactId)); const artifactIds = new Set(artifacts.map((artifact) => artifact.artifactId)); for (const record of records) { for (const artifactId of record.artifactRefs ?? []) { diff --git a/src/lab/public/project.ts b/src/lab/public/project.ts index 611002ac29..7a86dacaa8 100644 --- a/src/lab/public/project.ts +++ b/src/lab/public/project.ts @@ -117,6 +117,9 @@ export function projectPublicEvidenceRecord( if (PROJECTOR_INVARIANT_ERROR_CODES.has(error.code)) throw error; return { status: "not_exportable", reason: "unsafe_public_field" }; } + if (error instanceof TypeError && error.message.startsWith("jcsStringify:")) { + return { status: "not_exportable", reason: "unsafe_public_field" }; + } throw error; } } diff --git a/src/lab/public/time.ts b/src/lab/public/time.ts index 9adc8727d4..3bc3fe284f 100644 --- a/src/lab/public/time.ts +++ b/src/lab/public/time.ts @@ -1,8 +1,15 @@ import { PublicEvidenceValidationError } from "./validate"; +/** Largest timestamp whose ISO-8601 year still fits the four-digit YYYY form. */ +const MAX_PUBLIC_DAY_TIMESTAMP_MS = Date.UTC(9999, 11, 31, 23, 59, 59, 999); + /** Convert a bounded JavaScript timestamp into the public UTC day bucket. */ export function publicUtcDay(timestampMs: number): string { - if (!Number.isInteger(timestampMs) || timestampMs < 0) { + if ( + !Number.isInteger(timestampMs) + || timestampMs < 0 + || timestampMs > MAX_PUBLIC_DAY_TIMESTAMP_MS + ) { throw new PublicEvidenceValidationError( "public_selection_time", "invalid observation completion timestamp", diff --git a/tests/lab-private-file-consumer-recovery.test.ts b/tests/lab-private-file-consumer-recovery.test.ts index b427795976..83e6db5a7e 100644 --- a/tests/lab-private-file-consumer-recovery.test.ts +++ b/tests/lab-private-file-consumer-recovery.test.ts @@ -53,7 +53,7 @@ test("publisher key recovers after a same-process parent-directory sync failure" if (process.platform === "win32") return; const configDir = root(); setPrivateFileCommitFaultForTests("parent_directory_sync"); - expect(() => getOrCreatePublicPublisher(configDir)).toThrow(); + expect(() => getOrCreatePublicPublisher(configDir)).toThrow(/directory.*sync|durab/i); setPrivateFileCommitFaultForTests(null); expect(getOrCreatePublicPublisher(configDir).publisher.algorithm).toBe("ed25519"); expect(readdirSync(join(configDir, "lab")).filter(isPrivateFileStageName)).toEqual([]); @@ -64,7 +64,7 @@ test("public bundle storage recovers after a same-process parent-directory sync const configDir = root(); const bundle = signPublicEvidenceBundle({ records: [record()], artifacts: [], createdDayUtc: "2026-08-12", configDir }); setPrivateFileCommitFaultForTests("parent_directory_sync"); - expect(() => storePublicEvidenceBundle(bundle, configDir)).toThrow(); + expect(() => storePublicEvidenceBundle(bundle, configDir)).toThrow(/directory.*sync|durab/i); setPrivateFileCommitFaultForTests(null); expect(storePublicEvidenceBundle(bundle, configDir).created).toBe(false); }); diff --git a/tests/lab-private-file-durability.test.ts b/tests/lab-private-file-durability.test.ts index 781b31e75d..01497a3e1f 100644 --- a/tests/lab-private-file-durability.test.ts +++ b/tests/lab-private-file-durability.test.ts @@ -11,7 +11,6 @@ import { } from "../src/lab/public/private-file"; const roots: string[] = []; -const setCommitFault = setPrivateFileCommitFaultForTests as unknown as (fault: string | null) => void; afterEach(() => { setPrivateFileCommitFaultForTests(null); @@ -31,7 +30,7 @@ describe("CL-10 private-file durability", () => { const finalPath = join(root, "bundle.json"); const bytes = Buffer.from("durable-public-evidence", "utf8"); - setCommitFault("parent_directory_sync"); + setPrivateFileCommitFaultForTests("parent_directory_sync"); expect(() => publishPrivateFileExclusive(finalPath, bytes)).toThrow(/directory.*sync|durab/i); expect(existsSync(finalPath)).toBe(true); expect(readdirSync(root).filter(isPrivateFileStageName)).toHaveLength(1); @@ -47,7 +46,7 @@ describe("CL-10 private-file durability", () => { const finalPath = join(root, "bundle.json"); const bytes = Buffer.from("durable-public-evidence", "utf8"); - setCommitFault("before_publish"); + setPrivateFileCommitFaultForTests("before_publish"); expect(() => publishPrivateFileExclusive(finalPath, bytes)).toThrow(/before publish/i); expect(existsSync(finalPath)).toBe(false); expect(readdirSync(root).filter(isPrivateFileStageName)).toEqual([]); @@ -64,7 +63,7 @@ describe("CL-10 private-file durability", () => { const finalPath = join(root, "bundle.json"); const bytes = Buffer.from("durable-public-evidence", "utf8"); - setCommitFault("parent_directory_sync"); + setPrivateFileCommitFaultForTests("parent_directory_sync"); expect(() => publishPrivateFileExclusive(finalPath, bytes)).toThrow(/directory.*sync|durab/i); setPrivateFileCommitFaultForTests(null); @@ -86,7 +85,7 @@ describe("CL-10 private-file durability", () => { const finalPath = join(root, "bundle.json"); const bytes = Buffer.from("durable-public-evidence", "utf8"); - setCommitFault("parent_directory_sync"); + setPrivateFileCommitFaultForTests("parent_directory_sync"); expect(publishPrivateFileExclusive(finalPath, bytes)).toEqual({ created: true }); expect(readFileSync(finalPath).equals(bytes)).toBe(true); expect(readdirSync(root).filter(isPrivateFileStageName)).toEqual([]); diff --git a/tests/lab-public-core-contract.test.ts b/tests/lab-public-core-contract.test.ts index 08827f01cc..8d28ccc862 100644 --- a/tests/lab-public-core-contract.test.ts +++ b/tests/lab-public-core-contract.test.ts @@ -9,6 +9,7 @@ import { validatePublicEvidencePrivacy } from "../src/lab/public/privacy"; import { PUBLIC_ROUTE_REGISTRY_V1 } from "../src/lab/public/registry"; import { signPublicEvidenceBundle, verifyPublicEvidenceBundle } from "../src/lab/public/signature"; import { parseStrictPublicJson } from "../src/lab/public/strict-json"; +import { publicUtcDay } from "../src/lab/public/time"; import type { PublicEvidenceBundleUnsignedV1 } from "../src/lab/public/types"; import { validatePublicRouteRegistryManifest } from "../src/lab/public/validate"; @@ -88,6 +89,33 @@ describe("CL-10 public evidence core contract", () => { expect(verifyPublicEvidenceBundle(bundle)).toEqual({ status: "cryptographically_valid" }); }); + test("pins canonical multi-record ordering into the bundle digest", () => { + const first = fixedRecord(); + const { recordId: _ignored, ...secondBody } = { + ...first, + scenarioId: "responses-core.protocol.response-shape", + }; + const second = { recordId: publicEvidenceId("record", secondBody), ...secondBody }; + const publisher = { + algorithm: "ed25519" as const, + publicKey: FIXED_PUBLIC_KEY, + keyId: publicEvidenceId("publisher_key", { algorithm: "ed25519", publicKey: FIXED_PUBLIC_KEY }), + }; + + const bundle = buildPublicEvidenceBundle({ + records: [first, second], + artifacts: [], + createdDayUtc: "2026-08-12", + publisher, + }); + + expect(bundle.records.map((record) => record.recordId)).toEqual([ + "2a2a2e8406e6ccac915b21e96558a7b89e49e52effe474bd2c861ad2f7459437", + "5bec20821bbf01f831e74ba469e7f18481c1209fdef209c76f482105de3e406d", + ]); + expect(bundle.bundleDigest).toBe("63fef67418ec196b480bba3865fba287cc92aa94a760e2e3648b0759c0be046e"); + }); + test("rejects non-canonical publisher Base64", () => { const publicKey = `${FIXED_PUBLIC_KEY}\n`; const publisher = { @@ -116,6 +144,10 @@ describe("CL-10 public evidence core contract", () => { .toThrow(/exceeds 2097152 bytes/i); }); + test("public UTC day rejects expanded-year timestamps", () => { + expect(() => publicUtcDay(Date.UTC(10_000, 0, 1))).toThrow(/completion timestamp/i); + }); + test("local signing rejects artifact bytes before creating publisher state", () => { const config = configDir("ocx-cl10-core-artifact-"); const contentBase64 = Buffer.from("credential-canary-1234567890", "utf8").toString("base64"); @@ -161,4 +193,4 @@ describe("CL-10 public evidence core contract", () => { adapterFamilies: ["openai-responses"], }]); }); -}); \ No newline at end of file +}); diff --git a/tests/lab-public-file-safety.test.ts b/tests/lab-public-file-safety.test.ts index ae2ff64502..6693e54812 100644 --- a/tests/lab-public-file-safety.test.ts +++ b/tests/lab-public-file-safety.test.ts @@ -1,5 +1,5 @@ import { afterEach, expect, test } from "bun:test"; -import { mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { linkSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { readPrivateRegularFile } from "../src/lab/public/file-safety"; @@ -34,3 +34,18 @@ test("descriptor-bound private reads reject a symlink even when O_NOFOLLOW is un errorMessage: "unsafe test file", })).toThrow(PublicEvidenceValidationError); }); + +test("descriptor-bound private reads reject a file with an unrelated hard link", () => { + if (process.platform === "win32") return; + const root = tempRoot(); + const target = join(root, "target.txt"); + const alias = join(root, "alias.txt"); + writeFileSync(target, "safe-bytes", { mode: 0o600 }); + linkSync(target, alias); + + expect(() => readPrivateRegularFile(target, { + maxBytes: 1024, + errorCode: "unsafe_test_file", + errorMessage: "unsafe test file", + })).toThrow(PublicEvidenceValidationError); +}); diff --git a/tests/lab-public-security-regressions.test.ts b/tests/lab-public-security-regressions.test.ts index 9328e4f90c..f94d6ace68 100644 --- a/tests/lab-public-security-regressions.test.ts +++ b/tests/lab-public-security-regressions.test.ts @@ -3,8 +3,10 @@ import { existsSync, mkdtempSync, rmSync, statSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { jcsStringify } from "../src/lab/conformance/jcs"; +import type { ObservationEvent } from "../src/lab/events/types"; import { labPublicPublisherKeyPath } from "../src/lab/paths"; import { publishPrivateFileExclusive } from "../src/lab/public/private-file"; +import { projectPublicEvidenceRecord } from "../src/lab/public/project"; import { getOrCreatePublicPublisher } from "../src/lab/public/signature"; import { resetHardenedStateForTests, @@ -34,6 +36,25 @@ test("JCS rejects sparse JavaScript arrays instead of collapsing holes", () => { expect(() => jcsStringify(sparse)).toThrow(/sparse|array hole/i); }); +test("public projection maps JCS-invalid public fields to not_exportable", () => { + const observation = { + evidenceLayer: "protocol_conformance", + subject: { + subjectKind: "protocol", + effectiveAdapter: "openai-chat", + opencodexCompatibilityVersion: "2.13.0", + inboundProtocol: "openai-responses", + upstreamProtocol: "openai-chat", + surface: "responses-\uD800", + }, + } as ObservationEvent; + + expect(projectPublicEvidenceRecord({ observation, verdict: "VERIFIED" })).toEqual({ + status: "not_exportable", + reason: "unsafe_public_field", + }); +}); + test("private publication prepares an empty stage before writing secret bytes", () => { const root = configDir("ocx-cl10-private-stage-prepare-"); const finalPath = join(root, "secret.bin"); From b6808ca4c6f5d8738345e12df25b9169c8c05b57 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 14 Aug 2026 08:14:55 +0200 Subject: [PATCH 12/12] test(lab): tighten review regressions --- tests/lab-public-core-contract.test.ts | 10 +++------- tests/lab-public-security-regressions.test.ts | 2 +- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/tests/lab-public-core-contract.test.ts b/tests/lab-public-core-contract.test.ts index 8d28ccc862..c965227a0c 100644 --- a/tests/lab-public-core-contract.test.ts +++ b/tests/lab-public-core-contract.test.ts @@ -40,7 +40,7 @@ function installFixedPublisherKey(config: string): void { if (process.platform !== "win32") chmodSync(path, 0o600); } -function fixedRecord() { +function fixedRecord(scenarioId = "responses-core.protocol.request-shape") { const subject = { subjectKind: "protocol" as const, compatibilityVersion: "2.13.0", @@ -55,7 +55,7 @@ function fixedRecord() { evidenceLayer: "protocol_conformance" as const, suiteId: "responses-core", suiteVersion: "1.0.0", - scenarioId: "responses-core.protocol.request-shape", + scenarioId, scenarioVersion: "1.0.0", verdict: "VERIFIED" as const, observedDayUtc: "2026-08-12", @@ -91,11 +91,7 @@ describe("CL-10 public evidence core contract", () => { test("pins canonical multi-record ordering into the bundle digest", () => { const first = fixedRecord(); - const { recordId: _ignored, ...secondBody } = { - ...first, - scenarioId: "responses-core.protocol.response-shape", - }; - const second = { recordId: publicEvidenceId("record", secondBody), ...secondBody }; + const second = fixedRecord("responses-core.protocol.response-shape"); const publisher = { algorithm: "ed25519" as const, publicKey: FIXED_PUBLIC_KEY, diff --git a/tests/lab-public-security-regressions.test.ts b/tests/lab-public-security-regressions.test.ts index f94d6ace68..2e7cc6a986 100644 --- a/tests/lab-public-security-regressions.test.ts +++ b/tests/lab-public-security-regressions.test.ts @@ -47,7 +47,7 @@ test("public projection maps JCS-invalid public fields to not_exportable", () => upstreamProtocol: "openai-chat", surface: "responses-\uD800", }, - } as ObservationEvent; + } as unknown as ObservationEvent; expect(projectPublicEvidenceRecord({ observation, verdict: "VERIFIED" })).toEqual({ status: "not_exportable",