diff --git a/devlog/_plan/260807_compatibility_lab/010_cl10_public_evidence_export.md b/devlog/_plan/260807_compatibility_lab/010_cl10_public_evidence_export.md new file mode 100644 index 000000000..1f54b53e5 --- /dev/null +++ b/devlog/_plan/260807_compatibility_lab/010_cl10_public_evidence_export.md @@ -0,0 +1,771 @@ +# CL-10 - Public Evidence Export, Publishing, and Community Trust + +## Programme position + +**Repository:** `lidge-jun/opencodex` +**Integration target:** `dev` +**Branch:** `feat/cl-10-public-evidence-contract` +**Starting SHA:** `4fed8d3fe431ad23be83f3aff2af18ef8b8ecd71` +**CL-09 merge prerequisite:** satisfied by #1489 at `4fed8d3fe431ad23be83f3aff2af18ef8b8ecd71` + +CL-09 is merged. CL-10 is the final planned Compatibility Lab phase. + +This PR began contract-only and the contract was independently reviewed and accepted on 2026-08-12. CL-10.1 through CL-10.4 runtime implementation is now authorized on this branch. CL-10.5 remote publishing remains blocked until the exact transport/service contract in section 18 is independently accepted. + +--- + +# 1. Goal + +CL-10 answers: + +> How can a user deliberately export and, later, publish a narrowly allowlisted subset of Compatibility Lab evidence for community use without leaking installation-local identifiers, custom configuration, user data, credentials, or private operational metadata, and without letting untrusted community data silently affect local canonical verdicts or routing? + +The architecture is deliberately one-way at the local trust boundary: + +```text +Local canonical Lab evidence + | + | explicit export projection only + v +Public allowlist projector + | + +--> export privacy scan / fail closed + | + +--> export-scoped IDs + | + +--> optional public-export artifacts only + v +Canonical public bundle + | + +--> local preview/export + +--> explicit publish action, only after transport contract is accepted + v +Community bundle + | + +--> schema/digest/signature verification + +--> separate community trust/cache domain + | + X--> no write into local compatibility.jsonl + X--> no canonical verdict promotion/degradation + X--> no Routing Profile or Router Intelligence input + X--> no CL-08 scheduling input +``` + +CL-10 shares evidence. It does not transfer local authority. + +--- + +# 2. Existing authority carried forward + +CL-10 must preserve the existing CL-00 security/privacy contract, especially its `Local evidence versus public export` boundary: + +- public export uses a new allowlist-only schema; +- local subject/event/artifact IDs are replaced with export-scoped opaque IDs; +- endpoint and provider-instance fingerprints are omitted; +- local request, decision, and Fabric references are omitted; +- precise local paths, custom headers, project/location, account context, local errors, and raw latency traces are omitted; +- custom provider/model names are private by default; +- artifact bytes are exportable only when their policy explicitly allows `public_export`; +- export-specific secret/PII scanning is mandatory; +- unknown fields fail closed. + +CL-10 may tighten those rules. It must not weaken them silently. + +--- + +# 3. Hard CL-10 invariants + +CL-10 V1 must guarantee: + +```text +0 automatic telemetry upload +0 background publishing without an explicit user action +0 export of local subject/event/artifact/request/decision/Fabric identifiers +0 export of endpoint/provider-instance/custom-header/project/location fingerprints +0 export of credentials, account identity, prompts, responses, tool payloads, repository data, paths, or hidden reasoning +0 export of custom provider/model names unless a later reviewed public-registry authority explicitly permits them +0 community bundle writes into compatibility.jsonl +0 community evidence promotion/degradation of canonical local verdicts +0 community evidence influence on Routing Profiles or Router Intelligence +0 community evidence influence on CL-08 scheduling +0 combined local/community compatibility score +``` + +Export, publish, import, verification, or community-cache failure must not affect normal production request execution. + +--- + +# 4. Chosen approach + +Three approaches were considered. + +## 4.1 Chosen: deterministic public projection plus separate community trust domain + +Project local evidence into a new public schema containing only export-safe fields. Produce a canonical bundle with a digest and publisher signature. Community imports are verified and stored outside the local canonical evidence authority. + +Benefits: + +- privacy boundary is explicit and machine-testable; +- exported bytes are reproducible from the same local evidence and export policy; +- local IDs never leave the installation; +- community provenance can be verified without treating publisher claims as canonical truth; +- imported evidence cannot contaminate local verdicts or routing. + +## 4.2 Rejected: publish local Lab JSONL or SQLite rows directly + +The local schemas contain installation-scoped identifiers and fields whose local visibility does not imply public-export permission. Direct publication would make privacy depend on callers remembering ad-hoc redaction rules. + +## 4.3 Rejected: remote service as canonical evidence authority + +A hosted service may aggregate public bundles later, but it must not become the canonical authority for local Lab verdicts. OpenCodex must remain able to reproduce local verdicts from local canonical evidence without network access. + +--- + +# 5. Public exportability gate + +An observation is exportable only when all required public identity fields can be represented without private configuration. + +V1 exportable routes are limited to entries in the repo-reviewed `PublicRouteRegistryManifestV1` whose exported behavior identity is entirely composed from reviewed public fields. + +The public-route authority is a versioned, content-addressed repository artifact owned by OpenCodex, not a publisher-supplied assertion: + +```ts +interface PublicRouteRegistryManifestV1 { + schemaVersion: "public_route_registry_v1"; + registryVersion: string; + sourceCommit: string; + entries: PublicRouteRegistryEntryV1[]; + manifestDigest: string; +} + +interface PublicRouteRegistryEntryV1 { + providerId: string; + modelId: string; + adapterFamilies: Array<"openai-responses" | "openai-chat" | "anthropic-messages">; +} +``` + +CL-10.1 must ship and validate this manifest before any route-scoped record is exportable. The manifest may be updated only by reviewed repository changes with a new digest/version. Dynamic model discovery, cached catalogs, user configuration, imported bundles, and a matching spelling alone can never extend this authority. + +A route is not exportable when any behavior-relevant identity depends on a private/custom value, including: + +- custom provider instance or custom provider name; +- custom model ID or alias not in the reviewed public registry authority; +- non-default/custom endpoint identity; +- private/custom header behavior; +- project, location, tenant, deployment, organization, or account context; +- private-network destination behavior; +- any other local behavior fingerprint that cannot be represented publicly without weakening exact-route semantics. + +Failing this gate is `not_exportable`, not an error and not a compatibility verdict. + +CL-10 must never broaden exact local evidence into a more general public claim merely by dropping private route dimensions. + +--- + +# 6. Public evidence schema + +CL-10 introduces `PublicEvidenceBundleV1` as a closed, versioned, allowlist-only schema. + +Conceptually: + +```ts +interface PublicEvidenceBundleV1 { + schemaVersion: "public_evidence_bundle_v1"; + exportPolicyVersion: "public_export_policy_v1"; + bundleId: string; + createdDayUtc: string; + publisher: PublicPublisherV1; + records: PublicEvidenceRecordV1[]; + artifacts: PublicArtifactV1[]; + bundleDigest: string; + signature: PublicBundleSignatureV1; +} + +interface PublicEvidenceRecordV1 { + recordId: string; + subjectId: string; + evidenceLayer: "protocol_conformance" | "live_route_compatibility" | "task_effectiveness"; + suiteId: string; + suiteVersion: string; + scenarioId: string; + scenarioVersion: string; + verdict: "CLAIMED" | "PROBED" | "VERIFIED" | "DEGRADED" | "UNSUPPORTED" | "BLOCKED" | "UNKNOWN"; + observedDayUtc: string; + subject: PublicEvidenceSubjectV1; + assertions: PublicAssertionSummaryV1[]; + incidentRefs?: PublicIncidentRefV1[]; + artifactRefs?: string[]; +} + +type PublicEvidenceSubjectV1 = + | PublicProtocolSubjectV1 + | PublicRouteSubjectV1 + | PublicTaskSubjectV1; + +interface PublicIncidentRefV1 { + corpusId: string; // exact reviewed `IC-NNN` identifier only +} +``` + +The public runtime types are dedicated CL-10 types. They may import closed scalar unions such as the existing verdict/evidence-layer literals, but they must not alias, extend, spread, or serialize local ledger/query DTO interfaces. A compile-time TypeScript shape is not the security boundary: every export/import path must pass the dedicated runtime validator for the matching public schema version. + +`PublicEvidenceSubjectV1` is layer-matched: protocol records use only a public protocol descriptor, live-route records use only a public route descriptor backed by `PublicRouteRegistryManifestV1`, and task records use a public task descriptor that nests the same public route descriptor plus reviewed public task/verifier authority fields. A layer/subject-kind mismatch is `schema_rejected`. + +Unknown top-level or nested fields fail export and import validation. + +`incidentRefs` contain only exact reviewed corpus identifiers matching `^IC-[0-9]{3}$` that exist in the repository incident authority. They never contain the corpus entry's historical issue URLs, devlog paths, test paths, prose, or source metadata. `artifactRefs` contain only public artifact IDs present in the same bundle; local artifact digests/relative paths are forbidden. + +--- + +# 7. Export-scoped identity + +Local identifiers must never be serialized into a public bundle. + +`bundleId`, `recordId`, `subjectId`, and public artifact IDs are derived only from canonical export-safe bytes under explicit domain-separated SHA-256 inputs. They must have no reversible or keyed relationship to: + +- local `RouteSubjectV1.subjectId`; +- local observation/event IDs; +- local artifact digests when the artifact is not explicitly public-exportable; +- request IDs; +- route decision IDs; +- Fabric/task references; +- installation salt. + +A public subject ID may be deterministic across publishers only from fields that are already public in `PublicRouteDescriptorV1`. It must never include or hash a private local dimension. + +--- + +# 8. Public route descriptor + +`PublicRouteDescriptorV1` contains only reviewed public registry identity and protocol behavior needed to interpret a community record. + +At minimum it may contain: + +```ts +interface PublicRouteDescriptorV1 { + providerId: string; + modelId: string; + adapterFamily: "openai-responses" | "openai-chat" | "anthropic-messages"; + compatibilityVersion: string; +} +``` + +`providerId` and `modelId` must come from an explicit public-registry allowlist. A configured value matching the spelling of a public ID is insufficient if the effective route uses private behavior dimensions that make the public claim ambiguous. + +No endpoint, headers, project/location, provider-instance identifier, account identifier, credential class, quota plan, or private capability fingerprint is included. + +--- + +# 9. Time and diagnostic minimization + +Public records use UTC day buckets (`YYYY-MM-DD`), not precise local timestamps. + +V1 exports no raw request latency, token timing, transport phase trace, provider error message, local error code, or local failure string. + +Assertion summaries must use scenario-defined closed assertion IDs and bounded result enums. They must not contain arbitrary observed strings. + +If an existing scenario assertion cannot be represented without free-form/private output, that assertion is omitted only when the scenario contract permits a complete public summary without it; otherwise the record is `not_exportable`. + +--- + +# 10. Public artifact policy + +Local artifact visibility does not imply public-export permission. + +An artifact may appear in a public bundle only when all are true: + +1. its producer/scenario policy explicitly marks the artifact class `public_export`; +2. bytes are already synthetic/sanitized under Lab artifact rules; +3. CL-10 performs a second export-specific sanitizer and secret/PII scan; +4. the artifact satisfies public bundle size/type limits; +5. the public artifact digest is computed from the final exported bytes, not copied from a private/local reference by assumption. + +V1 does not export arbitrary text logs, provider errors, traces containing timing detail, task patches, terminal logs, repository content, or raw request/response shapes. + +--- + +# 11. Export privacy scanner + +Before a bundle can be written as publishable, CL-10 must run an export-specific fail-closed validator. + +It must reject: + +- unknown fields; +- strings outside field-specific bounds; +- token/credential canaries; +- email/account/project/tenant identifiers; +- URLs, local paths, IP addresses where identifying, query strings, header-like material, or authorization values; +- local Lab IDs and known request/decision/Fabric ID formats; +- custom provider/model identifiers; +- precise timestamps where only day buckets are allowed; +- artifact bytes not explicitly marked `public_export`. + +`bun run privacy:scan` remains defense in depth and is not a substitute for this validator. + +--- + +# 12. Consent and user control + +There is no automatic export or publishing. + +V1 user flow must be explicit: + +```text +select export scope + -> generate local preview + -> show included record/artifact counts and excluded/not_exportable counts + -> explicit export action + -> local canonical bundle + -> optional explicit publish action only if a publish transport is authorized +``` + +Generating a preview performs no network request. + +A publish action must require an explicit user action for the specific bundle. CL-10 V1 must not introduce an always-on telemetry toggle, silent background upload, startup upload, or production-request-path upload. + +Deleting local Lab data remains absolute locally. Public copies already distributed cannot be cryptographically erased, so revocation semantics are required separately. + +### Sensitive purge interaction + +CL-00 sensitive purge remains authoritative over CL-10 local copies. A purge whose closed action set includes `export` must fail closed until every affected local export/staging copy is removed. CL-10 must additionally remove any locally-originated copy of an affected bundle that has been imported into the local `community/` cache. Third-party community bundles are unrelated to the local sensitive bytes and are not deleted merely because they contain the same public route identity. + +A local sensitive purge never waits for network access. If an affected bundle was previously published, CL-10 records or emits a bounded signed `privacy_retraction` revocation for its public bundle/record IDs when the reviewed transport is available, but remote acknowledgement is not a prerequisite for completing the mandatory local purge. The purge must not retain sensitive bytes merely to construct a later revocation. + +--- + +# 13. Publisher provenance and signatures + +A published bundle must be self-verifying for integrity and publisher continuity without exposing account identity. + +CL-10 V1 uses an installation-local Ed25519 publisher key created only when the user first requests a publishable bundle or publication. + +The private key: + +- lives outside JSONL, SQLite, artifacts, export bundles, and community cache; +- uses secret-file permissions; +- is never logged or exposed through API/UI/CLI output; +- is never used for route-subject identity or local verdict derivation. + +The public bundle contains: + +```ts +interface PublicPublisherV1 { + algorithm: "ed25519"; + keyId: string; + publicKey: string; +} + +interface PublicBundleSignatureV1 { + algorithm: "ed25519"; + signedDigest: string; + signature: string; +} +``` + +`keyId` is a domain-separated SHA-256 digest of the public key. + +A valid signature proves only that the same publisher key signed those exact canonical bytes. It does not prove the evidence is honest, representative, current, or trustworthy. + +## 13.1 Frozen canonical byte and signature contract + +CL-10 V1 uses RFC 8785 JSON Canonicalization Scheme (JCS) as the only canonical JSON representation. Canonical JSON bytes are UTF-8 bytes of the JCS string. Raw serialized imports must be valid UTF-8 JSON and must reject duplicate decoded object member names before semantic object construction. Duplicate detection is semantic after JSON string escape decoding, so `"a"` and `"\u0061"` are the same member name and must fail closed if both appear in one object. + +All public hash identities use the exact construction: + +```text +H(domain, value) = SHA-256(UTF8(domain) || 0x00 || UTF8(JCS(value))) +``` + +No trailing NUL is added. The exact V1 domain strings are: + +```text +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 +``` + +The bundle identity preimages are frozen as semantic objects before JCS: + +```text +C = { + schemaVersion, + exportPolicyVersion, + createdDayUtc, + publisher, + records, + artifacts +} + +bundleId = H("ocx-lab-public:bundle:v1", C) + +bundleDigest = H( + "ocx-lab-public:bundle-digest:v1", + { ...C, bundleId } +) +``` + +Therefore `bundleId` is excluded from its own preimage, and both `bundleDigest` and `signature` are excluded from the `bundleId` preimage. `bundleDigest` includes the computed `bundleId`, but excludes both `bundleDigest` and `signature`. A bundle signature is exactly: + +```text +signature.algorithm = "ed25519" +signature.signedDigest = bundleDigest +signature.signature = Base64(Ed25519.Sign(privateKey, HexDecode(bundleDigest))) +``` + +The signature input is exactly the raw 32 bytes produced by hex-decoding the 64-character lowercase SHA-256 `bundleDigest`. There is no additional signature prefix because the signed digest is already domain-separated by `ocx-lab-public:bundle-digest:v1`. + +Publisher identity is exactly: + +```text +keyId = H( + "ocx-lab-public:publisher-key:v1", + { algorithm: "ed25519", publicKey } +) +``` + +where `publicKey` is the canonical Base64 representation of the Ed25519 SPKI DER bytes. + +Revocations use the same construction with a separate domain. After canonical sorting and duplicate rejection of targets: + +```text +R = { + schemaVersion, + issuedDayUtc, + publisher, + targets, + reason +} + +revocationId = H("ocx-lab-public:revocation:v1", R) +signature.signedDigest = revocationId +signature.signature = Base64(Ed25519.Sign(privateKey, HexDecode(revocationId))) +``` + +`revocationId` and `signature` are excluded from `R`. This binds schema/version, exact publisher identity, target bundle/record IDs, issued day, and finite reason under the dedicated revocation domain. + +Verification order is normative and exact for raw imported bundles: + +1. enforce the serialized byte ceiling; +2. require valid UTF-8 and reject duplicate decoded JSON object member names before object construction; +3. parse JSON and enforce nesting, array, object-key, and string bounds; +4. enforce the closed schema/version/field rules and recompute `publisher.keyId`; +5. recompute public subject/record/artifact identities, references, `bundleId`, and `bundleDigest` from canonical public-safe fields; +6. require `signature.signedDigest === bundleDigest`; +7. decode the canonical Ed25519 SPKI key and Base64 signature and verify Ed25519 over `HexDecode(bundleDigest)`; +8. validate repository-owned public-route, suite, scenario, verifier, and Fabric authority references; +9. for revocations, bootstrap authority only from an already-verified target bundle and require the exact publisher algorithm, `keyId`, and public key plus valid target membership before applying the revocation; +10. persist only after every preceding applicable check succeeds. + +A fixed test vector must lock these byte-level semantics so serializer, hash-domain, field-set, digest, or signing changes cannot silently create a second V1 wire format. + +--- + +# 14. Community trust model + +Imported community evidence is a separate trust class: `community_untrusted_v1`. + +Verification checks: + +- closed schema version; +- size/structure limits; +- canonical bundle digest; +- publisher signature; +- public route allowlist; +- scenario/suite authority references; +- export-policy version; +- revocation status when available. + +Passing verification means `cryptographically_valid`, not `locally_verified`. + +Community evidence must not: + +- append to `compatibility.jsonl`; +- rebuild or alter local canonical verdicts; +- refresh local evidence freshness; +- satisfy Routing Profile compatibility requirements; +- change Router Intelligence eligibility or scoring; +- trigger CL-08 refresh work; +- merge with local evidence into a single score. + +The UI/API/CLI must label it explicitly as community evidence and distinguish signature validity from compatibility truth. + +--- + +# 15. Community storage boundary + +Community bundles, if persisted, live outside the local canonical Lab ledger in a separate non-authoritative object/cache domain under the Lab root. + +Conceptually: + +```text +~/.opencodex/lab/ + compatibility.jsonl # local canonical authority, unchanged + compatibility.sqlite # local disposable projection, unchanged + artifacts/ # local Lab artifacts, unchanged + exports/ # user-created public bundles + community/ # non-authoritative imported public bundles/cache +``` + +The community store must not reuse local event IDs or masquerade as local observations. + +Deleting `community/` loses only imported community context and has no effect on local verdict reproducibility. + +--- + +# 16. Import boundary + +CL-10 V1 import accepts only bounded bundle bytes through reviewed entry points. It must not dereference arbitrary embedded URLs, paths, artifact references, or publisher-controlled network locations. + +A bundle is parsed with strict byte, UTF-8, duplicate-object-member, nesting, array, object-key, and string limits before expensive signature or projection work. Duplicate decoded object member names are rejected before `JSON.parse`-style semantic object construction so parsers cannot silently collapse an ambiguous wire representation. + +Invalid bundles are rejected without partial persistence. + +Artifact content embedded in/imported with a bundle is accepted only for closed `public_export` artifact classes and is revalidated locally before storage. + +--- + +# 17. Revocation and deletion semantics + +CL-10 defines `PublicEvidenceRevocationV1` as a signed, bounded public statement from the same publisher key that signed the target bundle and references one or more bundle/record IDs plus a finite reason code. + +A consumer bootstraps revocation authority from the already-verified target bundle: `publisher.keyId` and the exact Ed25519 public key in the revocation must match that target bundle before the revocation signature is considered. V1 does not support cross-key revocation or key rotation. A key-rotation protocol requires a later reviewed schema version. + +A revocation contains its own domain-separated digest/ID, `issuedDayUtc`, at most 256 sorted unique target IDs, and no free-form reason text. Re-importing the exact same revocation ID and bytes is idempotent. The same revocation ID with different canonical bytes, duplicate target IDs, an unknown target, unsupported reason/version, or a publisher-key mismatch is rejected. Consumers may retain bounded revocations received before a referenced record only in a quarantined pending set with the same structural limits; they do not become effective until the matching publisher/target bundle is present and verified. + +Allowed reason classes include: + +- `publisher_retracted`; +- `privacy_retraction`; +- `evidence_invalidated`; +- `superseded`. + +A revocation never edits the original local Lab ledger. + +Community consumers mark matching imported records revoked and exclude them from default community summaries while preserving the revocation audit relation. + +Remote physical deletion is a transport/service concern and cannot replace cryptographic revocation semantics. + +--- + +# 18. Remote publishing boundary + +This contract freezes bundle, consent, signing, verification, and trust semantics before choosing a remote service. + +No network publishing implementation is authorized until the same CL-10 branch or a reviewed follow-up contract records: + +- the exact service origin(s); +- authentication model, if any; +- maximum request/body budgets; +- TLS and redirect policy; +- retry/idempotency semantics; +- server retention and deletion policy; +- abuse/rate-limit behavior; +- revocation endpoint semantics; +- server-side schema validation; +- operator ownership and privacy policy. + +The publisher must not accept an arbitrary user-supplied upload URL as a shortcut around this gate. + +A fixed reviewed service may aggregate community bundles later, but local OpenCodex behavior remains fully functional without it. + +--- + +# 19. Read surfaces + +CL-10 implementation should extend existing Lab surfaces rather than create an unrelated product area. CL-10.1 through CL-10.4 are authorized after the accepted contract review; this does not relax the remote-publishing gate. + +Planned surfaces after contract acceptance: + +- CLI preview/export/verify/community inspection commands under `ocx lab`; +- authenticated management API for preview/export metadata and local community inspection; +- Compatibility Matrix detail UI for clearly separated community context; +- explicit publish UI only after the remote publishing transport contract is accepted. + +The local Compatibility Matrix must never silently replace its canonical verdict with a community result. + +--- + +# 20. Bounds + +V1 hard export/import ceilings: + +```text +maximum records per bundle 256 +maximum public artifacts per bundle 16 +maximum bytes per public artifact 256 KiB +maximum aggregate public artifact data 1 MiB +maximum serialized bundle bytes 2 MiB +maximum assertion summaries per record 64 +maximum incident references per record 32 +maximum serialized string field 4 KiB +maximum JSON nesting depth 8 +maximum object keys 64 +maximum array elements 512 +``` + +Implementations may use lower limits. Raising a hard ceiling requires a reviewed contract change. + +--- + +# 21. Failure semantics + +Export and import use explicit non-verdict outcomes. + +At minimum: + +```text +exportable +not_exportable +privacy_rejected +schema_rejected +signature_invalid +digest_invalid +revoked +unsupported_version +storage_failure +transport_unavailable +publish_rejected +``` + +These outcomes must never be mapped to local compatibility `DEGRADED` or `UNSUPPORTED` verdicts. + +--- + +# 22. Security tests required before implementation acceptance + +CL-10 implementation must include adversarial tests for: + +- prompt/response/tool/repository/path canaries; +- API keys, OAuth tokens, cookies, authorization headers, and common secret formats; +- account/email/project/tenant/location canaries; +- local subject/event/artifact/request/decision/Fabric IDs; +- custom provider/model IDs; +- URLs/query strings/IP addresses/header dumps; +- precise timestamps and raw latency/error fields; +- unknown JSON fields at every public schema level; +- duplicate decoded JSON object member names, including escape-equivalent keys; +- malformed/oversized/deeply nested import bundles; +- invalid signatures and digests; +- a fixed RFC 8785/domain-separated bundle digest and Ed25519 signature vector; +- bundle replay/deduplication; +- revoked bundles; +- community evidence isolation from local verdicts, routing, and CL-08; +- deterministic export from identical local inputs; +- non-exportability when private route dimensions would be erased. + +--- + +# 23. Delivery sequence + +## CL-10.0 - Audit and contract + +Contract work completed on this PR before runtime implementation: + +- record CL-09 closure; +- freeze public exportability and privacy rules; +- freeze public bundle schema and export-scoped identity; +- freeze publisher-signature and community trust semantics; +- freeze consent, revocation, import isolation, and remote-publishing gate; +- define implementation sequence and validation requirements. + +Independent review accepted CL-10.0 on 2026-08-12. CL-10.1 through CL-10.4 are therefore authorized on this branch by explicit maintainer direction. CL-10.5 remains blocked by section 18. + +## CL-10.1 - Public projector and privacy validator + +Implement closed public DTOs, exportability checks, export-scoped IDs, deterministic canonicalization, and fail-closed privacy validation. + +## CL-10.2 - Public bundle storage and publisher signatures + +Implement local public-bundle storage plus publisher-key lifecycle, bundle digesting, Ed25519 signing, and verification. + +## CL-10.3 - Local preview/export surfaces + +Implement CLI/API/UI preview and explicit local export. No remote publishing yet. + +## CL-10.4 - Community import and quarantine/read surfaces + +Implement strict import/verification, separate non-authoritative community storage, revocation handling, and clearly labelled read surfaces. No routing/verdict integration. + +## CL-10.5 - Remote publishing transport + +Implement only after the exact remote-service contract in section 18 is completed and independently accepted. + +## CL-10.6 - Adversarial closure and programme acceptance + +Run privacy, trust, cross-platform, no-feedback, reproducibility, and independent review gates. On acceptance, mark Compatibility Lab CL-00 through CL-10 complete. + +--- + +# 24. Explicit non-goals + +CL-10 V1 must not implement: + +- automatic telemetry; +- background production evidence upload; +- raw local Lab ledger export; +- custom/private route publication; +- user prompt/response/tool/repository export; +- account-linked public identity; +- community evidence as canonical local evidence; +- community-driven Routing Profile or Router Intelligence behavior; +- community-driven CL-08 scheduling; +- global compatibility score or leaderboard that mixes incomparable evidence layers; +- arbitrary upload/download URLs; +- remote code/tool execution; +- public artifact classes without explicit `public_export` policy. + +--- + +# 25. Contract acceptance criteria + +CL-10.0 is accepted only when independent review agrees that: + +1. no local/private identifier is required by the public schema; +2. exact local evidence cannot be generalized into a misleading public claim by dropping private route dimensions; +3. exported fields are closed, bounded, versioned, and fail closed on unknown fields; +4. public artifacts require explicit opt-in policy and second-pass sanitization; +5. export/publish requires explicit user action and creates no automatic telemetry path; +6. publisher signatures prove integrity/continuity without being misrepresented as evidence truth; +7. imported community evidence is isolated from local canonical evidence, freshness, routing, and scheduling; +8. revocation semantics are defined independently of remote physical deletion; +9. remote transport remains gated until an exact service/security contract exists; +10. implementation tasks have adversarial privacy and trust tests sufficient to prevent silent boundary regression. + +--- + +# 26. Validation + +Contract PR minimum: + +```text +git diff --check +repository markdown / hygiene checks +CodeRabbit / independent review +``` + +Implementation phases must additionally run: + +```text +bun x tsc --noEmit +bun run privacy:scan +focused Lab export/import/signature tests +focused ledger/projection isolation tests +Routing Profile / Router Intelligence no-feedback regressions +CL-08 no-feedback regressions +CLI/API/GUI tests for implemented surfaces +cross-platform CI +``` + +--- + +# 27. Hard stop + +The CL-10 contract was independently accepted on 2026-08-12 and explicit maintainer direction authorizes CL-10.1 through CL-10.4 runtime implementation on this branch. + +No CL-10.5 remote publishing code, upload transport, remote fetch, or arbitrary network publication is authorized until section 18 has been completed with an exact reviewed transport contract and independently accepted. diff --git a/docs/superpowers/plans/2026-08-12-cl10-public-evidence-implementation.md b/docs/superpowers/plans/2026-08-12-cl10-public-evidence-implementation.md new file mode 100644 index 000000000..5c58021ba --- /dev/null +++ b/docs/superpowers/plans/2026-08-12-cl10-public-evidence-implementation.md @@ -0,0 +1,156 @@ +# CL-10 Public Evidence Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implement CL-10.1 through CL-10.4: deterministic privacy-safe public evidence projection, signed local bundles, explicit local export, and quarantined community import/read surfaces, while keeping remote publishing blocked. + +**Architecture:** Add a dedicated `src/lab/public/` boundary with independently versioned public types and strict validators. Public bundles are derived from valid local Lab evidence only after an exact exportability gate, signed with a local Ed25519 publisher key, and stored separately from the canonical ledger. Imported bundles are bounded, signature-checked, and stored only in a non-authoritative community domain that never feeds local verdicts, routing, or CL-08. + +**Tech Stack:** TypeScript, Bun tests, Node `crypto` Ed25519, existing Lab JSONL/SQLite/query/digest/path infrastructure, existing `ocx lab` CLI and authenticated management API, existing Compatibility Matrix UI/i18n. + +## Global Constraints + +- No automatic telemetry or background publishing. +- No remote publishing implementation in this plan; CL-10.5 remains blocked until an exact reviewed service contract exists. +- No local subject/event/artifact/request/decision/Fabric identifier may appear in a public bundle. +- Private/custom route dimensions make evidence `not_exportable`; they are never dropped to broaden a public claim. +- Public schemas are closed and independently versioned; unknown fields fail closed. +- Public route identity uses a repo-reviewed, versioned allowlist authority. Dynamic discovery/configuration cannot extend it. +- Public incident references are closed corpus IDs only; historical URLs/devlog paths are never exported. +- Community evidence is `community_untrusted_v1`, never canonical local evidence, freshness, routing, or CL-08 input. +- Sensitive purge removes affected generated exports and locally-originated community copies; network revocation is never a prerequisite for completing a local purge. +- Publisher signatures prove integrity/continuity only, not evidence truth. + +--- + +### Task 1: Freeze review amendments and implementation authority + +**Files:** +- Modify: `devlog/_plan/260807_compatibility_lab/010_cl10_public_evidence_export.md` +- Modify: `docs/superpowers/specs/2026-08-12-cl10-public-evidence-design.md` + +**Interfaces:** +- Consumes: CL-00 purge/public-export contracts and merged CL-09 state. +- Produces: final CL-10.1–CL-10.4 runtime contract; CL-10.5 remains explicitly blocked. + +- [ ] **Step 1:** Add explicit purge/export/community-copy semantics consistent with CL-00 `purgeActions: export`. +- [ ] **Step 2:** Define `PublicRouteRegistryManifestV1` as the versioned local trust anchor for public provider/model identity. +- [ ] **Step 3:** Define bounded revocation bootstrap: target publisher key must match the original bundle publisher; duplicates are idempotent; conflicting replay fails closed; no V1 key rotation. +- [ ] **Step 4:** Replace arbitrary `incidentRefs` with closed `IC-NNN` references and require `artifactRefs` to resolve only to public artifact IDs in the same bundle. +- [ ] **Step 5:** Replace the route-only record assumption with a closed `PublicEvidenceSubjectV1` union for protocol/route/task evidence and require dedicated runtime validators/types. +- [ ] **Step 6:** Record that independent review accepted the contract and the user authorized CL-10.1–CL-10.4 runtime implementation on this PR; preserve the CL-10.5 transport hard stop. + +### Task 2: Public schema, registry authority, and privacy projector + +**Files:** +- Create: `src/lab/public/types.ts` +- Create: `src/lab/public/registry.ts` +- Create: `src/lab/public/validate.ts` +- Create: `src/lab/public/project.ts` +- Create: `src/lab/public/index.ts` +- Modify: `src/lab/index.ts` +- Test: `tests/lab-public-evidence.test.ts` + +**Interfaces:** +- Produces: `PublicEvidenceBundleUnsignedV1`, `PublicEvidenceRecordV1`, `PublicEvidenceSubjectV1`, `PublicRouteRegistryManifestV1`, `projectPublicEvidence()`, `validatePublicEvidenceBundle()`. + +- [ ] **Step 1: Write RED tests** for closed-schema rejection, deterministic public IDs/day buckets, protocol/route/task subject discrimination, exact route allowlist, private-route `not_exportable`, IC-only incident refs, no local ID leakage, and secret/PII canaries. +- [ ] **Step 2: Run focused test and verify expected RED failures.** + Run: `bun test tests/lab-public-evidence.test.ts` +- [ ] **Step 3: Implement minimal closed public types/registry/validator/projector.** + Public identities use domain-separated SHA-256 over JCS public-safe bytes. The registry manifest is repo-owned, versioned, digested, and cannot be supplied by an imported bundle as trust authority. +- [ ] **Step 4: Run focused test and verify GREEN.** + +### Task 3: Bundle digest/signature and local storage + +**Files:** +- Create: `src/lab/public/signature.ts` +- Create: `src/lab/public/storage.ts` +- Modify: `src/lab/paths.ts` +- Test: `tests/lab-public-evidence.test.ts` + +**Interfaces:** +- Produces: `getOrCreatePublicPublisher()`, `signPublicEvidenceBundle()`, `verifyPublicEvidenceBundle()`, `writePublicEvidenceBundle()`, `readPublicEvidenceBundle()`. + +- [ ] **Step 1: Write RED tests** for Ed25519 signing/verification, key-file permissions where enforceable, tamper rejection, deterministic bundle digest, bounded storage paths, and no private-key serialization. +- [ ] **Step 2: Verify RED.** +- [ ] **Step 3: Implement minimal key lifecycle, signing, verification, and safe local bundle storage.** +- [ ] **Step 4: Verify GREEN.** + +### Task 4: Revocation and community quarantine + +**Files:** +- Create: `src/lab/public/revocation.ts` +- Create: `src/lab/public/community.ts` +- Test: `tests/lab-public-evidence.test.ts` + +**Interfaces:** +- Produces: `PublicEvidenceRevocationV1`, `verifyPublicEvidenceRevocation()`, `importCommunityBundle()`, `listCommunityBundles()`. + +- [ ] **Step 1: Write RED tests** proving revocation accepts only the original bundle publisher key, duplicate identical revocations are idempotent, conflicting replay rejects, malformed/oversized bundles reject before persistence, and community import leaves canonical JSONL/SQLite verdict state unchanged. +- [ ] **Step 2: Verify RED.** +- [ ] **Step 3: Implement bounded revocation verification and separate community storage.** +- [ ] **Step 4: Verify GREEN.** + +### Task 5: Sensitive purge integration + +**Files:** +- Modify: `src/lab/ledger/purge.ts` +- Modify: `src/lab/paths.ts` +- Test: `tests/lab-public-evidence.test.ts` +- Test: `tests/lab-evidence-ledger.test.ts` + +**Interfaces:** +- Consumes: existing `purgeSensitiveEvidence()` and `purgeActions: export`. +- Produces: fail-closed removal of generated exports and locally-originated community copies affected by local sensitive evidence. + +- [ ] **Step 1: Write RED purge regression** showing an `export` purge removes CL-10 exports and local-origin community copies without requiring network access. +- [ ] **Step 2: Verify RED.** +- [ ] **Step 3: Extend purge-owned local directories/metadata minimally.** +- [ ] **Step 4: Run CL-10 and existing ledger purge tests.** + +### Task 6: Explicit CLI and management surfaces + +**Files:** +- Modify: `src/cli/lab.ts` +- Modify: `src/server/management/lab-routes.ts` +- Test: `tests/lab-public-evidence.test.ts` +- Test: relevant Lab CLI/management tests discovered in repository. + +**Interfaces:** +- CLI: local preview/export, bundle verify, community import/list. No publish command. +- API: authenticated preview/export/verify/community endpoints only. No remote transport. + +- [ ] **Step 1: Write RED CLI/API tests** for network-free preview, explicit export, verification, bounded community import, and absence of any publish endpoint/command. +- [ ] **Step 2: Verify RED.** +- [ ] **Step 3: Implement minimal surfaces using the public module APIs.** +- [ ] **Step 4: Verify focused CLI/API tests GREEN.** + +### Task 7: Compatibility Matrix community context + +**Files:** +- Modify: `gui/src/pages/compatibility-matrix-api.ts` +- Modify: `gui/src/pages/CompatibilityMatrix.tsx` +- Modify: locale catalog files under `gui/src/i18n/` as required by existing i18n rules. +- Test: existing Compatibility Lab GUI/i18n tests plus focused CL-10 additions. + +**Interfaces:** +- Produces: clearly labelled, read-only community context separate from canonical local verdict UI. + +- [ ] **Step 1: Write RED parser/render/i18n tests** proving community state is labelled non-authoritative and cannot replace the local verdict. +- [ ] **Step 2: Verify RED.** +- [ ] **Step 3: Implement the compact existing-detail-pane integration with no new product area.** +- [ ] **Step 4: Run GUI tests/lint/build GREEN.** + +### Task 8: Closure validation + +**Files:** +- Modify docs only if validation findings require factual updates. + +- [ ] **Step 1:** Run `bun test tests/lab-public-evidence.test.ts tests/lab-evidence-ledger.test.ts`. +- [ ] **Step 2:** Run `bun x tsc --noEmit`. +- [ ] **Step 3:** Run `bun run privacy:scan`. +- [ ] **Step 4:** Run relevant Lab query/ledger/CLI/GUI tests. +- [ ] **Step 5:** Run GUI lint/build and React Doctor. +- [ ] **Step 6:** Run full Cross-platform CI on the exact final PR head. +- [ ] **Step 7:** Confirm no remote publishing code, arbitrary URL transport, routing feedback, local-verdict feedback, or CL-08 feedback was introduced. diff --git a/docs/superpowers/plans/2026-08-13-cl10-deep-review-hardening.md b/docs/superpowers/plans/2026-08-13-cl10-deep-review-hardening.md new file mode 100644 index 000000000..e1ccfccbc --- /dev/null +++ b/docs/superpowers/plans/2026-08-13-cl10-deep-review-hardening.md @@ -0,0 +1,200 @@ +# CL-10 Deep Review Hardening Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Close all twelve adversarial findings from the post-CI CL-10 deep review, remove the catalog-timeout workaround, and make PR #1510 accurately describe the implemented CL-10.1 through CL-10.4 runtime scope. + +**Architecture:** Keep the existing `src/lab/public/` trust boundary and wire schema, but make verification canonical instead of normalizing attacker input, make community import no more permissive than local export, make revocation application publisher-scoped, and use crash-safe immutable-file publication. Public API/CLI DTOs remain separate from local operator metadata, and purge gains a bounded public-origin index so provenance does not depend on recovering mutable local files. + +**Tech Stack:** TypeScript, Bun tests, Node `crypto`/`fs`, existing Lab JCS/digest/path infrastructure, GitHub Actions. + +## Global Constraints + +- CL-10.5 remote publishing remains blocked and must not be implemented. +- No automatic telemetry, background publishing, arbitrary URL fetch, or community-to-local authority feedback. +- Keep `PublicEvidenceBundleV1` and revocation V1 domain strings frozen. +- New production behavior must be introduced test-first. +- Public bundle verification must reject non-canonical wire order rather than silently normalize it. +- Until a reviewed `public_export` artifact authority exists, both local and community V1 paths reject non-empty public artifacts. +- Publisher-key creation must not occur for invalid signing or revocation requests. +- Public management/CLI JSON must not disclose local filesystem paths or local Lab event IDs. +- Sensitive purge must remove locally-originated community copies even if the export or publisher key is damaged or missing. +- Exact-head GitHub Actions success is required before completion; do not merge. + +--- + +### Task 1: Add adversarial RED coverage + +**Files:** +- Create: `tests/lab-public-deep-review-regressions.test.ts` +- Modify: `tests/ci-workflows.test.ts` + +**Interfaces:** +- Consumes: current CL-10 public module APIs. +- Produces: failing tests for canonical array order, artifact quarantine, publisher-scoped record revocation, invalid-input key non-creation, JCS Unicode validity, exact assertion authority, cache quota, public DTO redaction, purge origin recovery, bounded duplicate-key diagnostics, IPv6 privacy rejection, and test-local catalog timeout behavior. + +- [ ] **Step 1:** Add one focused regression per finding using real public module behavior and deterministic test-only publisher keys where signatures are required. +- [ ] **Step 2:** Add a CI-policy regression that requires the catalog hardening test to own its timeout and forbids a catalog-specific timeout branch in the Linux batch runner. +- [ ] **Step 3:** Push tests only and verify the exact test-only head is red for the intended missing behavior. + +### Task 2: Canonical wire verification and JCS correctness + +**Files:** +- Modify: `src/lab/conformance/jcs.ts` +- Modify: `src/lab/public/bundle.ts` +- Modify: `src/lab/public/signature.ts` + +**Interfaces:** +- Produces: strict RFC-8785-compatible Unicode rejection and `verifyPublicEvidenceBundle()` rejection of non-canonical top-level record/artifact order. + +- [ ] **Step 1:** Reject lone UTF-16 surrogates in JCS strings and object keys. +- [ ] **Step 2:** Normalize bundle content once for local construction, but compare received record/artifact ordering against that normalized representation during verification. +- [ ] **Step 3:** Run the focused wire regressions green. + +### Task 3: Align community artifact/privacy authority + +**Files:** +- Modify: `src/lab/public/community.ts` + +**Interfaces:** +- Consumes: `validatePublicEvidencePrivacy()` and the current V1 artifact hard stop. +- Produces: community imports that reject all non-empty artifacts until reviewed authority exists and run the same second-pass privacy validator before persistence. + +- [ ] **Step 1:** Add a community-import gate before persistence. +- [ ] **Step 2:** Verify signed artifact-bearing external bundles are rejected and artifact-empty valid bundles still import. + +### Task 4: Make record revocation publisher-scoped + +**Files:** +- Modify: `src/lab/public/community.ts` +- Modify: `src/lab/public/revocation.ts` only if helper semantics need to be exposed. + +**Interfaces:** +- Produces: deterministic verification of record-only revocations against any matching verified bundle for the publisher and application to every matching record in that publisher's imported bundles. + +- [ ] **Step 1:** Resolve record-only revocation authority against a deterministic matching verified bundle instead of requiring exactly one bundle. +- [ ] **Step 2:** During listing, apply each verified revocation by publisher plus bundle/record target membership rather than binding it permanently to one bundle. +- [ ] **Step 3:** Verify a later bundle containing the same record remains revoked. + +### Task 5: Crash-safe immutable persistence and key lifecycle + +**Files:** +- Create: `src/lab/public/private-file.ts` +- Modify: `src/lab/public/signature.ts` +- Modify: `src/lab/public/storage.ts` +- Modify: `src/lab/public/community.ts` + +**Interfaces:** +- Produces: temp-file + file fsync + exclusive hard-link publication for immutable secret/public objects, deterministic EEXIST conflict handling, POSIX parent-directory durability before success is reported, an explicit Windows fallback where directory fsync is not portable, and test-only publication fault seams. + +- [ ] **Step 1:** Implement a small shared helper that writes a mode-0600 private temp file, fsyncs it, publishes it by exclusive hard link, fsyncs the parent directory on POSIX, and removes the temp name only after the publication durability boundary succeeds. On Windows, retain atomic exclusive publication without requiring unsupported directory fsync. +- [ ] **Step 2:** Migrate publisher-key creation, local exports, and community bundle/revocation persistence to the helper. +- [ ] **Step 3:** Verify an injected pre-publish failure leaves no final partial file, a POSIX parent-directory-sync failure is reported and can be recovered by an idempotent retry, and Windows publication does not depend on directory fsync. + +### Task 6: Validate before publisher-state mutation + +**Files:** +- Modify: `src/lab/public/bundle.ts` +- Modify: `src/lab/public/signature.ts` +- Modify: `src/lab/public/revocation.ts` + +**Interfaces:** +- Produces: a publisher-independent content-normalization function used before key access; revocation creation requires an existing matching local publisher key. + +- [ ] **Step 1:** Split public bundle content validation/normalization from publisher attachment. +- [ ] **Step 2:** Run closed-schema/day/record/authority/privacy validation before `getOrCreatePublicPublisher()`. +- [ ] **Step 3:** Add an existing-publisher loader and use it for revocation creation so foreign/invalid revocation attempts cannot create identity state. + +### Task 7: Exact assertion authority + +**Files:** +- Modify: `src/lab/public/community-authority.ts` + +**Interfaces:** +- Produces: exact one-to-one assertion-ID/required-flag coverage of the reviewed scenario authority. + +- [ ] **Step 1:** Reject duplicate assertion IDs. +- [ ] **Step 2:** Reject missing reviewed assertions as well as unknown ones. +- [ ] **Step 3:** Keep passed/failed values publisher-supplied evidence while freezing only identity/required authority. + +### Task 8: Bound community cache writes and read cost + +**Files:** +- Modify: `src/lab/public/community.ts` + +**Interfaces:** +- Produces: pre-create limits of 512 cache files and 64 MiB aggregate serialized bytes, with idempotent existing objects still readable/importable at the limit. + +- [ ] **Step 1:** Measure only descriptor-bound regular files without following symlinks. +- [ ] **Step 2:** Enforce count and aggregate-byte capacity before creating a new object. +- [ ] **Step 3:** Enforce the same bounds when listing so corrupted/external directory growth fails closed before bulk materialization. + +### Task 9: Separate public DTOs from local operator metadata + +**Files:** +- Modify: `src/lab/public/operator.ts` +- Modify: `src/cli/lab.ts` +- Modify: `src/server/management/lab-routes.ts` +- Modify: `tests/lab-public-surfaces.test.ts` + +**Interfaces:** +- Produces: preview/export results that expose exclusion indices/reasons and `stored.created` only, never local event IDs or filesystem paths. + +- [ ] **Step 1:** Replace public exclusion `eventId` with bounded `selectionIndex`. +- [ ] **Step 2:** Discard storage paths from public operator return values and CLI/API JSON. +- [ ] **Step 3:** Keep human CLI output useful without printing local absolute paths. + +### Task 10: Persist public-origin provenance for purge + +**Files:** +- Modify: `src/lab/paths.ts` +- Create: `src/lab/public/origin.ts` +- Modify: `src/lab/public/operator.ts` +- Modify: `src/lab/public/purge.ts` + +**Interfaces:** +- Produces: bounded immutable `public-origin-v1` markers containing only public publisherKeyId/bundleId identities. The origin marker is durably committed before a new local export file is published, so export success can never be reported without purge-owned provenance; an orphan marker after a later export failure is conservative and safe. Under retention pressure, markers without an exact community bundle copy may be reclaimed because no community object remains for that provenance marker to classify; markers backing retained community bundles are preserved. + +- [ ] **Step 1:** Commit the public origin identity before publishing the local export file; if export publication later fails, preserve the orphan marker so retry/purge can recover conservatively. +- [ ] **Step 2:** Make purge union origin markers with legacy recoverable export/key provenance. +- [ ] **Step 3:** Delete origin markers only after locally-originated community copies are removed, except bounded retention reclamation of markers with no exact community bundle copy. +- [ ] **Step 4:** Verify purge still succeeds if the export and publisher key are corrupted/missing. + +### Task 11: Harden diagnostics and privacy scanner + +**Files:** +- Modify: `src/lab/public/strict-json.ts` +- Modify: `src/lab/public/privacy.ts` + +**Interfaces:** +- Produces: constant-size duplicate-key errors and detection of unbracketed IPv6 literals in semantic public strings. + +- [ ] **Step 1:** Stop reflecting attacker-controlled duplicate key names in errors. +- [ ] **Step 2:** Add bounded IPv6-literal recognition without rejecting ordinary colon-bearing public identifiers such as versioned names. + +### Task 12: Move catalog timeout to the flaky test only + +**Files:** +- Modify: `tests/codex-catalog-sync-hardening.test.ts` +- Modify: `scripts/ci/run-bun-test-batches.sh` + +**Interfaces:** +- Produces: one 15-second Bun test timeout on the known degraded-provider case; all neighboring batch tests remain on the default timeout on Linux and macOS uses the same test-local timeout. + +- [ ] **Step 1:** Add `15_000` only to the degraded-provider test definition. +- [ ] **Step 2:** Remove catalog-specific timeout detection/variables from the batch runner. +- [ ] **Step 3:** Run CI-policy regression green. + +### Task 13: Exact-head closure and PR metadata + +**Files:** +- Modify PR #1510 title/body only after runtime verification. + +**Interfaces:** +- Produces: accurate ready-for-review description of CL-10.1 through CL-10.4 with CL-10.5 explicitly blocked. + +- [ ] **Step 1:** Run focused tests, typecheck/privacy/GUI gates via GitHub Actions on the exact final head. +- [ ] **Step 2:** Confirm Cross-platform CI and React Doctor are green on that exact head. +- [ ] **Step 3:** Update PR title to describe the runtime implementation rather than contract-only scope. +- [ ] **Step 4:** Replace the stale body with implemented scope, trust/privacy invariants, validation evidence, and the CL-10.5 hard stop. +- [ ] **Step 5:** Confirm PR remains open, unmerged, and ready for review. diff --git a/docs/superpowers/specs/2026-08-12-cl10-public-evidence-design.md b/docs/superpowers/specs/2026-08-12-cl10-public-evidence-design.md new file mode 100644 index 000000000..1495d76ed --- /dev/null +++ b/docs/superpowers/specs/2026-08-12-cl10-public-evidence-design.md @@ -0,0 +1,127 @@ +# CL-10 Public Evidence Design + +## Status + +Design approved for contract drafting on 2026-08-12. Independent review accepted the contract on 2026-08-12, and explicit maintainer direction now authorizes CL-10.1 through CL-10.4 runtime implementation on this branch. CL-10.5 remote publishing remains blocked on an exact independently accepted transport/service contract. + +Base: `dev` at `4fed8d3fe431ad23be83f3aff2af18ef8b8ecd71`, the CL-09 merge commit from #1489. + +## Problem + +Compatibility Lab now has local protocol, live-route, task-effectiveness, automatic-refresh, and passive-production evidence. The remaining programme boundary is public sharing. + +Local evidence cannot be published directly because local schemas intentionally contain installation-scoped identity and operational metadata that is safe only inside the local trust domain. Community evidence also cannot be allowed to become canonical local truth merely because a remote bundle is syntactically valid or cryptographically signed. + +## Chosen design + +Use a deterministic, closed public projection with a separate community trust domain. + +```text +local canonical evidence + -> exportability gate + -> allowlist-only public projection + -> export privacy scan + -> export-scoped IDs + -> canonical bundle digest + -> pseudonymous publisher signature + -> explicit local export + -> optional explicit publish after transport contract acceptance + +community bundle + -> bounded parser + -> schema/digest/signature verification + -> non-authoritative community cache + -> clearly labelled read surface + -> never local verdict/routing/scheduling authority +``` + +## Key decisions + +### Public route identity + +A local route is exportable only when its behavior can be represented entirely through entries in the versioned, content-addressed, repo-reviewed `PublicRouteRegistryManifestV1`; dynamic discovery, config, and imported bundles cannot extend that authority. Private/custom endpoint, header, provider-instance, project/location, tenant, account, or custom model/provider dimensions make the route `not_exportable`. + +The exporter must never create a broader public claim by deleting a private dimension from an exact local route subject. + +### Public schema + +`PublicEvidenceBundleV1` is independently versioned and allowlist-only. Dedicated runtime validators enforce its closed types. Records use a layer-matched public subject union rather than assuming every evidence layer is a route. Incident references are closed `IC-NNN` corpus IDs only, and artifact references resolve only to public artifacts in the same bundle. + +Unknown fields fail closed on export and import. + +### IDs + +Local subject, event, artifact, request, decision, and Fabric IDs never leave the installation. Public IDs are derived only from canonical public-safe bytes under explicit domain-separated hashes. + +### Canonical bytes and signatures + +CL-10 V1 freezes RFC 8785 JSON Canonicalization Scheme (JCS) over UTF-8 as the canonical byte representation. Raw imported JSON must be valid UTF-8 and must reject duplicate decoded object member names before semantic object construction, including equivalent escaped spellings such as `"a"` and `"\u0061"`. + +Every public hash is: + +```text +H(domain, value) = SHA-256(UTF8(domain) || 0x00 || UTF8(JCS(value))) +``` + +The exact V1 domains are `ocx-lab-public:subject:v1`, `ocx-lab-public:record:v1`, `ocx-lab-public:bundle:v1`, `ocx-lab-public:bundle-digest:v1`, `ocx-lab-public:artifact:v1`, `ocx-lab-public:publisher-key:v1`, `ocx-lab-public:revocation:v1`, and `ocx-lab-public:route-registry:v1` for their corresponding identities. + +For a bundle, `C = {schemaVersion, exportPolicyVersion, createdDayUtc, publisher, records, artifacts}`. `bundleId = H("ocx-lab-public:bundle:v1", C)`. `bundleDigest = H("ocx-lab-public:bundle-digest:v1", {...C, bundleId})`. Therefore `bundleDigest` and `signature` are excluded from the bundle-digest preimage, and `bundleId`, `bundleDigest`, and `signature` are excluded from the bundle-ID preimage. Ed25519 signs the raw 32 bytes obtained by hex-decoding `bundleDigest`; `signature.signedDigest` must equal `bundleDigest` exactly. + +A revocation similarly hashes `R = {schemaVersion, issuedDayUtc, publisher, targets, reason}` under `ocx-lab-public:revocation:v1`; `revocationId` and `signature` are excluded from `R`, and Ed25519 signs the raw 32 bytes of `revocationId`. Targets are sorted and unique before hashing. + +Import verification order is fixed: byte cap; strict UTF-8 and duplicate-key rejection; JSON syntax/structural bounds; closed schema/version/field validation and publisher-key-ID recomputation; public identity/reference and bundle digest recomputation; `signedDigest` equality; Ed25519 key/signature decoding and verification; repository route/suite/scenario/Fabric authority validation; revocation bootstrap only against an already-verified exact target publisher/bundle; persistence only after every preceding check succeeds. + +### Artifacts + +Artifacts require explicit `public_export` policy. A second export sanitizer and secret/PII scan runs before public artifact hashing. Local visibility alone never authorizes export. + +### Consent + +There is no automatic telemetry. Preview is local and network-free. Export is explicit. Publishing is a second explicit action for a specific bundle and is not implemented until an exact remote-service contract is accepted. + +### Publisher provenance + +Publishable bundles use an installation-local Ed25519 publisher key. The public key provides pseudonymous continuity; the signature proves bundle integrity and signer continuity only. It does not prove that the compatibility claim is true. + +### Community trust + +Imported community evidence is `community_untrusted_v1`. A valid signature produces `cryptographically_valid`, not `locally_verified`. + +Community evidence cannot: + +- append to local `compatibility.jsonl`; +- alter local canonical verdicts or freshness; +- satisfy Routing Profile compatibility requirements; +- influence Router Intelligence; +- trigger CL-08 refresh scheduling; +- merge into a combined local/community score. + +### Revocation + +Publishers can issue signed revocations with finite reason codes. Revocation authority bootstraps from the exact publisher key embedded in the already-verified target bundle; V1 permits no cross-key revocation or key rotation, and duplicate identical revocations are idempotent while conflicting replay fails closed. Consumers suppress revoked records from default community summaries while retaining the audit relation. Remote deletion is transport-specific and does not replace revocation. CL-00 sensitive purge still removes every affected local generated export plus locally-originated community-cache copy fail-closed; local purge never depends on network acknowledgement. + +### Remote service + +Bundle semantics, signing, import, and trust are frozen before any network publishing implementation. A remote publisher requires a reviewed fixed service origin, authentication, TLS/redirect, request-budget, retry/idempotency, retention/deletion, revocation, abuse/rate-limit, and server-validation contract. Arbitrary upload URLs are forbidden. + +## Delivery decomposition + +1. **CL-10.0 Contract:** freeze privacy, exportability, schema, IDs, signatures, consent, trust, revocation, and transport gate. +2. **CL-10.1 Public projector:** closed DTOs, exportability rules, deterministic canonicalization, export privacy validator. +3. **CL-10.2 Bundle/signature substrate:** local exports, publisher-key lifecycle, digest/sign/verify. +4. **CL-10.3 Local surfaces:** preview and explicit local export via existing Lab CLI/API/UI conventions. +5. **CL-10.4 Community import:** bounded import, verification, separate community cache, revocation and labelled read surfaces. +6. **CL-10.5 Remote publishing:** only after exact service contract acceptance. +7. **CL-10.6 Closure:** adversarial privacy/trust tests, cross-platform validation, independent review, programme closure. + +## Validation expectations + +The implementation must include adversarial tests for secret/PII canaries, local IDs, private route dimensions, unknown fields, duplicate JSON object keys, oversized/deep bundles, invalid digest/signature, replay/deduplication, revocation, deterministic export, fixed canonical digest/signature vectors, and complete isolation from local verdicts/routing/CL-08. + +The contract review gate is satisfied. Runtime CL-10.1 through CL-10.4 may now land on this PR under TDD and full validation; CL-10.5 remote publishing remains out of scope. + +## Source of truth + +The detailed normative contract is: + +`devlog/_plan/260807_compatibility_lab/010_cl10_public_evidence_export.md` diff --git a/gui/src/i18n/lab-translations.ts b/gui/src/i18n/lab-translations.ts index 3428a847d..75a21feb4 100644 --- a/gui/src/i18n/lab-translations.ts +++ b/gui/src/i18n/lab-translations.ts @@ -7,7 +7,12 @@ export type LabSupplementKey = | "artifact.present" | "artifact.corrupt" | "artifact.purged_unavailable" - | "selectVerdict"; + | "selectVerdict" + | "community.title" + | "community.notLocalVerdict" + | "community.bundles" + | "community.activeRecords" + | "community.revokedRecords"; const en: Record = { "lab.title": "Compatibility Lab", @@ -427,6 +432,11 @@ const supplements: Record> = { "artifact.corrupt": "Corrupt", "artifact.purged_unavailable": "Purged / unavailable", selectVerdict: "View verdict for {subject}", + "community.title": "Community evidence", + "community.notLocalVerdict": "Untrusted read-only context. Not included in this local verdict.", + "community.bundles": "Bundles", + "community.activeRecords": "Active records", + "community.revokedRecords": "Revoked records", }, de: { subjectKindUnknown: "Unbekannt", @@ -434,6 +444,11 @@ const supplements: Record> = { "artifact.corrupt": "Beschädigt", "artifact.purged_unavailable": "Gelöscht / nicht verfügbar", selectVerdict: "Urteil für {subject} anzeigen", + "community.title": "Community-Evidenz", + "community.notLocalVerdict": "Nicht vertrauenswürdiger Nur-Lese-Kontext. Nicht Teil dieses lokalen Urteils.", + "community.bundles": "Pakete", + "community.activeRecords": "Aktive Einträge", + "community.revokedRecords": "Widerrufene Einträge", }, ko: { subjectKindUnknown: "알 수 없음", @@ -441,6 +456,11 @@ const supplements: Record> = { "artifact.corrupt": "손상됨", "artifact.purged_unavailable": "삭제됨 / 사용할 수 없음", selectVerdict: "{subject}의 판정 보기", + "community.title": "커뮤니티 증거", + "community.notLocalVerdict": "신뢰되지 않는 읽기 전용 컨텍스트입니다. 이 로컬 판정에는 포함되지 않습니다.", + "community.bundles": "번들", + "community.activeRecords": "활성 레코드", + "community.revokedRecords": "폐기된 레코드", }, zh: { subjectKindUnknown: "未知", @@ -448,6 +468,11 @@ const supplements: Record> = { "artifact.corrupt": "已损坏", "artifact.purged_unavailable": "已清除 / 不可用", selectVerdict: "查看 {subject} 的判定", + "community.title": "社区证据", + "community.notLocalVerdict": "不受信任的只读上下文。不计入此本地判定。", + "community.bundles": "证据包", + "community.activeRecords": "有效记录", + "community.revokedRecords": "已撤销记录", }, "zh-TW": { subjectKindUnknown: "未知", @@ -455,6 +480,11 @@ const supplements: Record> = { "artifact.corrupt": "已損壞", "artifact.purged_unavailable": "已清除 / 不可用", selectVerdict: "查看 {subject} 的判定", + "community.title": "社群證據", + "community.notLocalVerdict": "不受信任的唯讀脈絡。不計入此本地判定。", + "community.bundles": "證據包", + "community.activeRecords": "有效記錄", + "community.revokedRecords": "已撤銷記錄", }, ru: { subjectKindUnknown: "Неизвестно", @@ -462,6 +492,11 @@ const supplements: Record> = { "artifact.corrupt": "Повреждён", "artifact.purged_unavailable": "Удалён / недоступен", selectVerdict: "Открыть вердикт для {subject}", + "community.title": "Данные сообщества", + "community.notLocalVerdict": "Недоверенный контекст только для чтения. Не входит в этот локальный вердикт.", + "community.bundles": "Пакеты", + "community.activeRecords": "Активные записи", + "community.revokedRecords": "Отозванные записи", }, ja: { subjectKindUnknown: "不明", @@ -469,6 +504,11 @@ const supplements: Record> = { "artifact.corrupt": "破損", "artifact.purged_unavailable": "削除済み / 利用不可", selectVerdict: "{subject} の判定を表示", + "community.title": "コミュニティ証拠", + "community.notLocalVerdict": "信頼されていない読み取り専用コンテキストです。このローカル判定には含まれません。", + "community.bundles": "バンドル", + "community.activeRecords": "有効なレコード", + "community.revokedRecords": "取り消されたレコード", }, tr: { subjectKindUnknown: "Bilinmiyor", @@ -476,6 +516,11 @@ const supplements: Record> = { "artifact.corrupt": "Bozuk", "artifact.purged_unavailable": "Temizlenmiş / kullanılamıyor", selectVerdict: "{subject} için kararı görüntüle", + "community.title": "Topluluk kanıtı", + "community.notLocalVerdict": "Güvenilmeyen salt okunur bağlam. Bu yerel karara dahil değildir.", + "community.bundles": "Paketler", + "community.activeRecords": "Etkin kayıtlar", + "community.revokedRecords": "Geri çekilen kayıtlar", }, }; diff --git a/gui/src/pages/CompatibilityMatrix.tsx b/gui/src/pages/CompatibilityMatrix.tsx index 9fcac9b12..48ca82685 100644 --- a/gui/src/pages/CompatibilityMatrix.tsx +++ b/gui/src/pages/CompatibilityMatrix.tsx @@ -9,6 +9,7 @@ import { fetchLabPageData, fetchMoreVerdicts, fetchVerdictDetail, + type CommunityEvidenceContextDto, type LabPageData, type VerdictDetailData, } from "./compatibility-matrix-api"; @@ -71,13 +72,7 @@ function localizedFetchError(e: unknown, fallback: string): string { return msg || fallback; } -function VerdictBadge({ - verdict, - caption, - label, - selected, - onSelect, -}: { +function VerdictBadge({ verdict, caption, label, selected, onSelect }: { verdict: CompatibilityVerdict; caption: string; label: string; @@ -101,12 +96,7 @@ function VerdictBadge({ ); } -function VerdictCell({ - rows, - t, - selectedKey, - onSelect, -}: { +function VerdictCell({ rows, t, selectedKey, onSelect }: { rows: VerdictDto[]; t: (key: TKey) => string; selectedKey: string | null; @@ -154,15 +144,27 @@ function StatusCards({ data, t, locale }: { ); } -function DetailPane({ - verdict, - detail, - loading, - error, - t, - locale, - onClose, -}: { +function CommunityEvidencePanel({ community, locale }: { + community: CommunityEvidenceContextDto | null; + locale: Parameters[0]; +}) { + if (!community || community.evidence.length === 0) return null; + const activeRecords = community.evidence.reduce((total, row) => total + row.activeRecordCount, 0); + const revokedRecords = community.evidence.reduce((total, row) => total + row.revokedRecordCount, 0); + return ( +
+

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

+

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

+
+
{labSupplement(locale, "community.bundles")}
{community.evidence.length}
+
{labSupplement(locale, "community.activeRecords")}
{activeRecords}
+
{labSupplement(locale, "community.revokedRecords")}
{revokedRecords}
+
+
+ ); +} + +function DetailPane({ verdict, detail, loading, error, t, locale, onClose }: { verdict: VerdictDto; detail: VerdictDetailData | null; loading: boolean; @@ -252,11 +254,7 @@ function DetailPane({ ); } -export default function CompatibilityMatrix({ - apiBase, - active = true, - onCountChange, -}: { +export default function CompatibilityMatrix({ apiBase, active = true, onCountChange }: { apiBase: string; active?: boolean; onCountChange?: (count: number | null) => void; @@ -276,7 +274,6 @@ export default function CompatibilityMatrix({ const queryFilters = useMemo(() => verdictQueryFromFilters(filters), [filters]); const queryKey = JSON.stringify(queryFilters); - const fetchPage = useCallback( (signal: AbortSignal) => fetchLabPageData(apiBase, queryFilters, signal), [apiBase, queryFilters], @@ -306,13 +303,7 @@ export default function CompatibilityMatrix({ setDetailLoading(false); }, []); - useEffect(() => { - // A refreshed first page makes any in-flight cursor request stale. The associated - // appended-page state is identity-bound below, so it becomes invisible immediately - // without synchronously cascading state from this effect. - loadMoreRef.current?.abort(); - }, [surface.data]); - + useEffect(() => { loadMoreRef.current?.abort(); }, [surface.data]); useEffect(() => () => { loadMoreRef.current?.abort(); detailRequestRef.current?.abort(); @@ -333,9 +324,7 @@ export default function CompatibilityMatrix({ const reportedCount = useMemo(() => { if (!active || !surface.data?.status.projectionAvailable) return null; const total = surface.data.status.verdictCount; - return typeof total === "number" - ? total - : surface.data.verdicts.length + (validExtraPage?.verdicts.length ?? 0); + return typeof total === "number" ? total : surface.data.verdicts.length + (validExtraPage?.verdicts.length ?? 0); }, [active, surface.data, validExtraPage]); useEffect(() => { onCountChange?.(reportedCount); }, [onCountChange, reportedCount]); @@ -362,9 +351,7 @@ export default function CompatibilityMatrix({ const page = await fetchMoreVerdicts(apiBase, queryFilters, cursor, controller.signal); if (controller.signal.aborted) return; setExtraPage(current => { - const existing = current?.baseData === baseData && current.queryKey === startedKey - ? current.verdicts - : []; + const existing = current?.baseData === baseData && current.queryKey === startedKey ? current.verdicts : []; return { baseData, queryKey: startedKey, @@ -455,6 +442,7 @@ export default function CompatibilityMatrix({ {loadError && {loadError}} {projectionIncompatible && {t("lab.projectionIncompatible")}} {projectionUnavailable && !projectionIncompatible && } + {surface.data && } {surface.data && status?.projectionAvailable && !projectionIncompatible && (
diff --git a/gui/src/pages/compatibility-matrix-api.ts b/gui/src/pages/compatibility-matrix-api.ts index 139ba8594..7e2803097 100644 --- a/gui/src/pages/compatibility-matrix-api.ts +++ b/gui/src/pages/compatibility-matrix-api.ts @@ -132,19 +132,14 @@ async function collectPages( seen.add(next); cursor = next; } - // The server kept advancing correctly but exceeded the browser-side safety bound. - // Preserve the coherent prefix and report truncation separately instead of - // misclassifying a legitimate large dataset as a broken pagination contract. return { rows, truncated: true }; } export async function fetchAllSubjects(apiBase: string, signal: AbortSignal): Promise> { - return collectPages( - async cursor => { - const page = await fetchSubjectPage(apiBase, cursor, signal); - return { rows: page.subjects, hasMore: page.hasMore, nextCursor: page.nextCursor }; - }, - ); + return collectPages(async cursor => { + const page = await fetchSubjectPage(apiBase, cursor, signal); + return { rows: page.subjects, hasMore: page.hasMore, nextCursor: page.nextCursor }; + }); } export async function fetchSubjectDetail( @@ -181,12 +176,10 @@ async function fetchAllObservations( filters: { subjectId: string; layer?: string; suiteId?: string }, signal: AbortSignal, ): Promise> { - return collectPages( - async cursor => { - const page = await fetchObservationsPage(apiBase, filters, cursor, signal); - return { rows: page.observations, hasMore: page.hasMore, nextCursor: page.nextCursor }; - }, - ); + return collectPages(async cursor => { + const page = await fetchObservationsPage(apiBase, filters, cursor, signal); + return { rows: page.observations, hasMore: page.hasMore, nextCursor: page.nextCursor }; + }); } export async function fetchEventById(apiBase: string, eventId: string, signal: AbortSignal): Promise { @@ -248,6 +241,80 @@ export async function fetchPassiveProductionSummary( return parsePassiveProductionSummary(raw); } +export type CommunityEvidenceSummaryRowDto = { + trustClass: "community_untrusted_v1"; + status: "cryptographically_valid"; + bundleId: string; + publisherKeyId: string; + activeRecordCount: number; + revokedRecordCount: number; +}; + +export type CommunityEvidenceContextDto = { + evidence: CommunityEvidenceSummaryRowDto[]; + trustClass: "community_untrusted_v1"; + locallyVerified: false; +}; + +function hasOnlyKeys(raw: Record, allowed: readonly string[]): boolean { + const allowedSet = new Set(allowed); + return Object.keys(raw).every(key => allowedSet.has(key)); +} + +function isSha256Hex(value: unknown): value is string { + return typeof value === "string" && /^[0-9a-f]{64}$/.test(value); +} + +function isNonNegativeInteger(value: unknown): value is number { + return typeof value === "number" && Number.isSafeInteger(value) && value >= 0; +} + +export function parseCommunityEvidenceContext(raw: unknown): CommunityEvidenceContextDto | null { + if (!isPlainObject(raw) + || !hasOnlyKeys(raw, ["evidence", "trustClass", "locallyVerified"]) + || raw.trustClass !== "community_untrusted_v1" + || raw.locallyVerified !== false + || !Array.isArray(raw.evidence) + || raw.evidence.length > 4096) { + return null; + } + const evidence: CommunityEvidenceSummaryRowDto[] = []; + for (const value of raw.evidence) { + if (!isPlainObject(value) + || !hasOnlyKeys(value, [ + "trustClass", "status", "bundleId", "publisherKeyId", + "activeRecordCount", "revokedRecordCount", + ]) + || value.trustClass !== "community_untrusted_v1" + || value.status !== "cryptographically_valid" + || !isSha256Hex(value.bundleId) + || !isSha256Hex(value.publisherKeyId) + || !isNonNegativeInteger(value.activeRecordCount) + || !isNonNegativeInteger(value.revokedRecordCount)) { + return null; + } + evidence.push({ + trustClass: "community_untrusted_v1", + status: "cryptographically_valid", + bundleId: value.bundleId, + publisherKeyId: value.publisherKeyId, + activeRecordCount: value.activeRecordCount, + revokedRecordCount: value.revokedRecordCount, + }); + } + return { evidence, trustClass: "community_untrusted_v1", locallyVerified: false }; +} + +export async function fetchCommunityEvidenceContext( + apiBase: string, + signal: AbortSignal, +): Promise { + const raw = await fetchLabJson(apiBase, "/api/lab/public/community", signal); + const context = parseCommunityEvidenceContext(raw); + if (!context) throw invalidResponse(); + return context; +} + export type LabPageData = { status: LabStatusDto; verdicts: VerdictDto[]; @@ -255,6 +322,7 @@ export type LabPageData = { subjectsTruncated: boolean; hasMore: boolean; nextCursor?: string; + community: CommunityEvidenceContextDto | null; }; export async function fetchLabPageData( @@ -262,9 +330,15 @@ export async function fetchLabPageData( filters: VerdictQueryFilters, signal: AbortSignal, ): Promise { - const status = await fetchLabStatus(apiBase, signal); + const [status, community] = await Promise.all([ + fetchLabStatus(apiBase, signal), + fetchCommunityEvidenceContext(apiBase, signal).catch(error => { + if (signal.aborted) throw error; + return null; + }), + ]); if (!status.projectionAvailable) { - return { status, verdicts: [], subjects: [], subjectsTruncated: false, hasMore: false }; + return { status, verdicts: [], subjects: [], subjectsTruncated: false, hasMore: false, community }; } const [verdictPage, subjects] = await Promise.all([ fetchVerdictPage(apiBase, filters, undefined, signal), @@ -277,6 +351,7 @@ export async function fetchLabPageData( subjectsTruncated: subjects.truncated, hasMore: verdictPage.hasMore, nextCursor: verdictPage.nextCursor, + community, }; } @@ -316,7 +391,6 @@ async function mapSettledBounded( results.push(await mapper(limited[current]!)); } catch (error) { if (signal.aborted) throw error; - // Referenced events/artifacts are optional detail enrichment. Keep successful peers. } } }; diff --git a/gui/tests/compatibility-community-evidence.test.ts b/gui/tests/compatibility-community-evidence.test.ts new file mode 100644 index 000000000..6605e7c64 --- /dev/null +++ b/gui/tests/compatibility-community-evidence.test.ts @@ -0,0 +1,141 @@ +import { expect, test } from "bun:test"; +import { + fetchLabPageData, + fetchVerdictDetail, + parseCommunityEvidenceContext, + type CommunityEvidenceContextDto, +} from "../src/pages/compatibility-matrix-api"; +import type { VerdictDto } from "../src/pages/compatibility-matrix-shared"; +import { + LAB_CATALOG_OVERRIDES, + labSupplement, + type LabLocale, +} from "../src/i18n/lab-translations"; + +const LOCALES = Object.keys(LAB_CATALOG_OVERRIDES) as LabLocale[]; + +function validContext(): CommunityEvidenceContextDto { + return { + trustClass: "community_untrusted_v1", + locallyVerified: false, + evidence: [ + { + trustClass: "community_untrusted_v1", + status: "cryptographically_valid", + bundleId: "a".repeat(64), + publisherKeyId: "b".repeat(64), + activeRecordCount: 3, + revokedRecordCount: 1, + }, + ], + }; +} + +function json(value: unknown): Response { + return new Response(JSON.stringify(value), { + status: 200, + headers: { "content-type": "application/json" }, + }); +} + +test("Compatibility Matrix parses only bounded quarantined community evidence context", () => { + expect(parseCommunityEvidenceContext(validContext())).toEqual(validContext()); + expect(parseCommunityEvidenceContext({ ...validContext(), locallyVerified: true })).toBeNull(); + expect(parseCommunityEvidenceContext({ ...validContext(), trustClass: "local" })).toBeNull(); + expect(parseCommunityEvidenceContext({ ...validContext(), unexpected: true })).toBeNull(); + expect(parseCommunityEvidenceContext({ + ...validContext(), + evidence: [{ ...validContext().evidence[0]!, unexpected: true }], + })).toBeNull(); + expect(parseCommunityEvidenceContext({ + ...validContext(), + evidence: [{ ...validContext().evidence[0]!, bundleId: "A".repeat(64) }], + })).toBeNull(); + expect(parseCommunityEvidenceContext({ + ...validContext(), + evidence: [{ ...validContext().evidence[0]!, publisherKeyId: "z".repeat(64) }], + })).toBeNull(); + expect(parseCommunityEvidenceContext({ + ...validContext(), + evidence: [{ ...validContext().evidence[0]!, activeRecordCount: -1 }], + })).toBeNull(); + expect(parseCommunityEvidenceContext({ + ...validContext(), + evidence: [{ ...validContext().evidence[0]!, activeRecordCount: 1.5 }], + })).toBeNull(); + expect(parseCommunityEvidenceContext({ + ...validContext(), + evidence: [{ ...validContext().evidence[0]!, status: "locally_verified" }], + })).toBeNull(); + expect(parseCommunityEvidenceContext({ + ...validContext(), + evidence: Array.from({ length: 4097 }, () => validContext().evidence[0]!), + })).toBeNull(); +}); + +test("Compatibility Matrix community copy is localized and explicitly non-authoritative", () => { + for (const locale of LOCALES) { + expect(labSupplement(locale, "community.title")).toBeTruthy(); + expect(labSupplement(locale, "community.notLocalVerdict")).toBeTruthy(); + expect(labSupplement(locale, "community.bundles")).toBeTruthy(); + expect(labSupplement(locale, "community.activeRecords")).toBeTruthy(); + expect(labSupplement(locale, "community.revokedRecords")).toBeTruthy(); + } + expect(labSupplement("en", "community.notLocalVerdict")).toMatch(/untrusted|not included|local verdict/i); +}); + +test("community evidence is fetched once as page-global context, never as verdict detail", async () => { + const originalFetch = globalThis.fetch; + const requested: string[] = []; + const context = validContext(); + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + requested.push(url); + if (url.endsWith("/api/lab/status")) { + return json({ + projectionAvailable: true, + subjectCount: 0, + verdictCount: 0, + observationCount: 0, + eventCount: 0, + }); + } + if (url.includes("/api/lab/verdicts?")) return json({ verdicts: [], hasMore: false }); + if (url.includes("/api/lab/subjects?")) return json({ subjects: [], hasMore: false }); + if (url.endsWith("/api/lab/public/community")) return json(context); + if (url.endsWith("/api/lab/subjects/subject-alpha")) { + return json({ subject: { subjectKind: "protocol", subjectSchemaVersion: 1, inboundProtocol: "openai-chat" } }); + } + if (url.includes("/api/lab/observations?")) return json({ observations: [], hasMore: false }); + throw new Error(`unexpected optional detail request: ${url}`); + }) as typeof fetch; + + try { + const signal = new AbortController().signal; + const page = await fetchLabPageData("http://127.0.0.1:4096", {}, signal); + expect(page.community).toEqual(context); + expect(requested.filter(url => url.endsWith("/api/lab/public/community"))).toHaveLength(1); + + const verdict: VerdictDto = { + projectionKey: "v1", + subjectId: "subject-alpha", + evidenceLayer: "protocol_conformance", + suiteId: "responses-core", + suiteVersion: "1", + suiteManifestDigest: "digest-a", + projectionSpecVersion: "cl-02.v1", + verdict: "VERIFIED", + asOf: 1_700_000_000_000, + scenarioManifestDigests: [], + claimSourceDigest: null, + contributingEventIds: [], + contradictingEventIds: [], + notes: [], + }; + const detail = await fetchVerdictDetail("http://127.0.0.1:4096", verdict, signal); + expect(detail).not.toHaveProperty("community"); + expect(requested.filter(url => url.endsWith("/api/lab/public/community"))).toHaveLength(1); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/scripts/ci/run-bun-test-batches.sh b/scripts/ci/run-bun-test-batches.sh index ff4d20210..b594ccc5b 100644 --- a/scripts/ci/run-bun-test-batches.sh +++ b/scripts/ci/run-bun-test-batches.sh @@ -5,6 +5,7 @@ readonly SHARD_SPEC="${1:-}" readonly BATCH_SIZE="${BUN_TEST_BATCH_SIZE:-12}" readonly BATCH_TIMEOUT_SECONDS="${BUN_TEST_BATCH_TIMEOUT_SECONDS:-120}" readonly BATCH_KILL_GRACE_SECONDS="${BUN_TEST_BATCH_KILL_GRACE_SECONDS:-15}" +readonly DEFAULT_TEST_TIMEOUT_MS="${BUN_TEST_CASE_TIMEOUT_MS:-5000}" usage() { echo "usage: $0 " >&2 @@ -33,6 +34,10 @@ if [[ ! "$BATCH_KILL_GRACE_SECONDS" =~ ^[1-9][0-9]*$ ]]; then echo "BUN_TEST_BATCH_KILL_GRACE_SECONDS must be a positive integer, got: $BATCH_KILL_GRACE_SECONDS" >&2 exit 64 fi +if [[ ! "$DEFAULT_TEST_TIMEOUT_MS" =~ ^[1-9][0-9]*$ ]]; then + echo "BUN_TEST_CASE_TIMEOUT_MS must be a positive integer, got: $DEFAULT_TEST_TIMEOUT_MS" >&2 + exit 64 +fi if ! command -v timeout >/dev/null 2>&1; then echo "GNU timeout is required to bound Bun test batches." >&2 exit 69 @@ -100,13 +105,13 @@ run_test_once() { log_file="$(mktemp -t ocx-bun-test-batch.XXXXXX)" - echo "::group::${label} attempt ${attempt} (${#files[@]} files)" + echo "::group::${label} attempt ${attempt} (${#files[@]} files, test timeout ${DEFAULT_TEST_TIMEOUT_MS}ms)" printf ' %s\n' "${files[@]}" set +e timeout --signal=TERM --kill-after="${BATCH_KILL_GRACE_SECONDS}s" \ "${BATCH_TIMEOUT_SECONDS}s" \ - bun test --isolate "${files[@]}" 2>&1 | tee "$log_file" + bun test --isolate --timeout "$DEFAULT_TEST_TIMEOUT_MS" "${files[@]}" 2>&1 | tee "$log_file" status="${PIPESTATUS[0]}" set -e diff --git a/src/cli/lab.ts b/src/cli/lab.ts index 013b96327..2f968a47e 100644 --- a/src/cli/lab.ts +++ b/src/cli/lab.ts @@ -63,6 +63,14 @@ import { planManualLabRun } from "../lab/automation/planner"; import { listLabAutomationRuns } from "../lab/automation/runs-query"; import { LabAutomationError, type LabAutomationLayer } from "../lab/automation/types"; import { createProductionLabRouteExecutor } from "../lib/lab-live-route-production"; +import { + exportLocalPublicEvidence, + importCommunityEvidenceFile, + listCommunityEvidenceContext, + previewLocalPublicEvidence, + verifyPublicEvidenceFile, + type PublicVerificationSummaryV1, +} from "../lab/public"; const USAGE = `Usage: ocx lab status [--json] @@ -76,6 +84,11 @@ const USAGE = `Usage: ocx lab artifacts [--status ] [--artifact-class ] [--limit ] [--cursor ] [--json] ocx lab artifact [--json] ocx lab catalog [--layer ] [--suite ] [--json] + ocx lab public preview --event [--event ...] [--json] + ocx lab public export --event [--event ...] [--json] + ocx lab public verify --file [--json] + ocx lab public import --file [--json] + ocx lab public community [--json] ocx lab automation status [--json] ocx lab automation enable [--protocol] [--live] [--json] ocx lab automation disable [--json] @@ -223,6 +236,120 @@ function runListLines(page: ReturnType): string[] return lines.length > 0 ? lines : ["No automation runs"]; } +function takeRepeatedOptions(args: string[], flag: string): string[] { + const values: string[] = []; + while (true) { + const index = args.indexOf(flag); + if (index < 0) break; + const value = args[index + 1]; + if (!value || value.startsWith("--")) { + throw new CliUsageError(`${flag} requires a value`, USAGE); + } + values.push(value); + args.splice(index, 2); + } + return values; +} + +function publicPreviewLines(result: ReturnType): string[] { + return [ + `Public evidence preview: ${result.bundle.records.length} exportable record(s)`, + `Excluded: ${result.excluded.length}`, + "Unsigned local preview; no publisher key or remote publish is created.", + ]; +} + +function publicExportLines(result: ReturnType): string[] { + return [ + "Public evidence exported locally", + `Bundle: ${result.bundle.bundleId}`, + `Publisher: ${result.bundle.publisher.keyId}`, + `Path: ${result.stored.path}`, + `Excluded: ${result.excluded.length}`, + "No remote publish occurred.", + ]; +} + +function publicVerificationLines(result: PublicVerificationSummaryV1): string[] { + if (result.status !== "cryptographically_valid") { + return [ + `Public evidence verification: ${result.status}`, + "Not locally verified.", + ...(result.detail ? [result.detail] : []), + ]; + } + return [ + "Public evidence verification: cryptographically valid", + `Bundle: ${result.bundleId}`, + `Publisher: ${result.publisherKeyId}`, + "Not locally verified. Signature validity proves integrity/continuity only.", + ]; +} + +function handlePublicLabCommand( + argv: string[], + wantsJson: boolean, + configDir: string, +): void { + const [action, ...restInput] = argv; + const rest = [...restInput]; + switch (action) { + case "preview": { + const eventIds = takeRepeatedOptions(rest, "--event"); + rejectArgs(rest, USAGE); + const result = previewLocalPublicEvidence({ eventIds }, configDir); + printData(result, wantsJson, publicPreviewLines(result)); + return; + } + case "export": { + const eventIds = takeRepeatedOptions(rest, "--event"); + rejectArgs(rest, USAGE); + const result = exportLocalPublicEvidence({ eventIds }, configDir); + printData(result, wantsJson, publicExportLines(result)); + return; + } + case "verify": { + const path = takeOption(rest, "--file"); + if (!path) throw new CliUsageError("public verify requires --file", USAGE); + rejectArgs(rest, USAGE); + const result = verifyPublicEvidenceFile(path); + printData(result, wantsJson, publicVerificationLines(result)); + if (result.status !== "cryptographically_valid") { + throw new Error(`public evidence verification failed: ${result.status}`); + } + return; + } + case "import": { + const path = takeOption(rest, "--file"); + if (!path) throw new CliUsageError("public import requires --file", USAGE); + rejectArgs(rest, USAGE); + const result = importCommunityEvidenceFile(path, configDir); + printData(result, wantsJson, [ + `Community evidence imported: ${result.bundleId}`, + `Publisher: ${result.publisherKeyId}`, + "Trust: community_untrusted_v1; not locally verified.", + ]); + return; + } + case "community": { + rejectArgs(rest, USAGE); + const result = listCommunityEvidenceContext(configDir); + const lines = result.evidence.length > 0 + ? result.evidence.map((row) => + `${row.bundleId} publisher=${row.publisherKeyId} active=${row.activeRecordCount} revoked=${row.revokedRecordCount}`, + ) + : ["No community evidence"]; + printData(result, wantsJson, [ + "Community evidence (untrusted, read-only context; not locally verified)", + ...lines, + ]); + return; + } + default: + throw new CliUsageError("unknown public subcommand", USAGE); + } +} + export async function handleLabCommand(argv: string[], deps: LabCliDeps = {}): Promise { return runCliAction(async () => { const configDir = deps.configDir ?? getConfigDir(); @@ -232,6 +359,10 @@ export async function handleLabCommand(argv: string[], deps: LabCliDeps = {}): P try { switch (sub) { + case "public": { + handlePublicLabCommand(rest, wantsJson, configDir); + return; + } case "status": { rejectArgs(rest, USAGE); const status = queryLabStatus(configDir); diff --git a/src/lab/conformance/jcs.ts b/src/lab/conformance/jcs.ts index 6bbcb923c..60fb68813 100644 --- a/src/lab/conformance/jcs.ts +++ b/src/lab/conformance/jcs.ts @@ -1,5 +1,27 @@ /** 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); +} + 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 +29,14 @@ 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)) { 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/index.ts b/src/lab/index.ts index 783eb0552..2110aecff 100644 --- a/src/lab/index.ts +++ b/src/lab/index.ts @@ -36,3 +36,4 @@ export * from "./subject/installation-salt"; export { CL03_LIVE_SUITES } from "./conformance/types"; export * from "./query"; export * from "./automation"; +export * from "./public"; diff --git a/src/lab/ledger/purge.ts b/src/lab/ledger/purge.ts index dd6b95853..160a695b4 100644 --- a/src/lab/ledger/purge.ts +++ b/src/lab/ledger/purge.ts @@ -22,6 +22,7 @@ import { appendLabEvent, replayLabLedger } from "./store"; import { ensureLabDirs } from "../paths"; import { rebuildLabProjection } from "../projection/rebuild"; import { jcsStringify } from "../digest"; +import { purgeLocalPublicEvidenceCopies } from "../public/purge"; import { closeSync, existsSync, @@ -100,8 +101,6 @@ function atomicRewriteLedger(ledgerPath: string, events: LabEvent[]): void { closeSync(dirFd); } } catch (err) { - // The rename is already committed and visible. Report durability failure - // without pretending the ledger action can be rolled back. throw new PurgeError( "ledger_durability_failed", `ledger rewrite committed but directory fsync failed: ${err instanceof Error ? err.message : String(err)}`, @@ -142,6 +141,40 @@ function purgeBoundedDirectory(dirPath: string): void { } } +function normalizePurgeError(err: unknown, completed: readonly string[]): PurgeError { + if (err instanceof PurgeError) { + return new PurgeError( + err.code, + err.message, + [...new Set([...completed, ...err.completedActions])], + ); + } + return new PurgeError( + "purge_failed", + err instanceof Error ? err.message : String(err), + [...completed], + ); +} + +function buildPurgeTombstone( + req: SensitivePurgeRequest, + removeIds: ReadonlySet, + targetArtifactDigests: string[], + purgeActions: Array<(typeof PURGE_ACTIONS)[number]>, +): PurgeTombstoneEvent { + return validateLabEvent(assignEventId({ + schemaVersion: LAB_EVENT_SCHEMA_VERSION, + eventKind: "purge_tombstone" as const, + recordedAt: req.recordedAt ?? Date.now(), + producer: LAB_PRODUCER, + producerVersion: req.producerVersion ?? LAB_PRODUCER_VERSION, + targetEventIds: [...removeIds].sort(), + targetArtifactDigests, + reason: "sensitive_evidence" as const, + purgeActions, + })) as PurgeTombstoneEvent; +} + /** * Exceptional sensitive-evidence purge: * physically remove targeted JSONL lines and artifacts, append purge_tombstone, @@ -163,19 +196,6 @@ export function purgeSensitiveEvidence(req: SensitivePurgeRequest): PurgeTombsto explicitSensitive, ); - const tombstonePayload = { - schemaVersion: LAB_EVENT_SCHEMA_VERSION, - eventKind: "purge_tombstone" as const, - recordedAt: req.recordedAt ?? Date.now(), - producer: LAB_PRODUCER, - producerVersion: req.producerVersion ?? LAB_PRODUCER_VERSION, - targetEventIds: [...removeIds].sort(), - targetArtifactDigests, - reason: "sensitive_evidence" as const, - purgeActions, - }; - const tombstone = validateLabEvent(assignEventId(tombstonePayload)) as PurgeTombstoneEvent; - const deletionPlan = purgeActions.includes("artifact") ? artifactDeletionPlan(replay.events, index, removeIds, targetArtifactDigests) : { deletable: [], retainedExplicit: [] }; @@ -189,14 +209,24 @@ export function purgeSensitiveEvidence(req: SensitivePurgeRequest): PurgeTombsto let dir: TrustedArtifactDir | null = null; const completed: string[] = []; + let deferredExportError: PurgeError | null = null; + let operationError: PurgeError | null = null; + let tombstone: PurgeTombstoneEvent | null = null; + try { if (purgeActions.includes("scratch")) { purgeBoundedDirectory(paths.scratchDir); completed.push("scratch"); } if (purgeActions.includes("export")) { - purgeBoundedDirectory(paths.exportDir); - completed.push("export"); + try { + purgeLocalPublicEvidenceCopies(req.configDir); + completed.push("export"); + } catch (err) { + // Export deletion is independent from artifact/ledger/sqlite deletion. Keep + // deleting every other requested sensitive copy, then report this failure. + deferredExportError = normalizePurgeError(err, completed); + } } if (purgeActions.includes("artifact")) { @@ -207,35 +237,59 @@ export function purgeSensitiveEvidence(req: SensitivePurgeRequest): PurgeTombsto completed.push("artifact"); } - if (purgeActions.includes("ledger")) { - const kept: LabEvent[] = []; - for (const event of replay.events) { - if (removeIds.has(event.eventId)) continue; - kept.push(event); + // Never persist a tombstone claiming that export completed when the export purge + // failed. Other independent actions remain recordable and continue as requested. + const tombstoneActions = deferredExportError + ? purgeActions.filter((action) => action !== "export") + : purgeActions; + const hasTombstoneTarget = removeIds.size > 0 + || targetArtifactDigests.length > 0 + || tombstoneActions.includes("scratch") + || tombstoneActions.includes("export"); + + if (tombstoneActions.length > 0 && (hasTombstoneTarget || !deferredExportError)) { + tombstone = buildPurgeTombstone(req, removeIds, targetArtifactDigests, tombstoneActions); + if (purgeActions.includes("ledger")) { + const kept: LabEvent[] = []; + for (const event of replay.events) { + if (removeIds.has(event.eventId)) continue; + kept.push(event); + } + kept.push(tombstone); + atomicRewriteLedger(paths.ledgerPath, kept); + } else { + appendLabEvent(paths.ledgerPath, tombstone); } - kept.push(tombstone); - atomicRewriteLedger(paths.ledgerPath, kept); - } else { - appendLabEvent(paths.ledgerPath, tombstone); + completed.push("ledger"); } - completed.push("ledger"); if (purgeActions.includes("sqlite")) { rebuildLabProjection(req.configDir); completed.push("sqlite"); } - - return tombstone; } catch (err) { - if (err instanceof PurgeError) { - throw new PurgeError(err.code, err.message, [...completed, ...err.completedActions]); - } + operationError = normalizePurgeError(err, completed); + } finally { + if (dir) closeTrustedArtifactDir(dir); + } + + if (operationError && deferredExportError) { throw new PurgeError( "purge_failed", - err instanceof Error ? err.message : String(err), - completed, + `export purge failed: ${deferredExportError.message}; subsequent purge failure (${operationError.code}): ${operationError.message}`, + [...new Set([...completed, ...deferredExportError.completedActions, ...operationError.completedActions])], ); - } finally { - if (dir) closeTrustedArtifactDir(dir); } + if (operationError) throw operationError; + if (deferredExportError) { + throw new PurgeError( + deferredExportError.code, + deferredExportError.message, + [...new Set([...completed, ...deferredExportError.completedActions])], + ); + } + if (!tombstone) { + throw new PurgeError("purge_failed", "purge completed without a durable tombstone", completed); + } + return tombstone; } diff --git a/src/lab/paths.ts b/src/lab/paths.ts index f202f4121..fa39148a9 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 000000000..712a3c310 --- /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 000000000..cc9e73607 --- /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/community.ts b/src/lab/public/community.ts new file mode 100644 index 000000000..923676b64 --- /dev/null +++ b/src/lab/public/community.ts @@ -0,0 +1,378 @@ +import { readdirSync, unlinkSync } from "node:fs"; +import { join } from "node:path"; +import { jcsStringify } from "../digest"; +import { ensureLabDirs, labCommunityDir } from "../paths"; +import { validateCommunityEvidenceAuthorities } from "./community-authority"; +import { privateRegularFileSize, readPrivateRegularFile } from "./file-safety"; +import { + cleanupStalePrivateFileStages, + cleanupStalePrivateFileStagesInDir, + isPrivateFileStageName, + publishPrivateFileExclusive, +} from "./private-file"; +import { validatePublicEvidencePrivacy } from "./privacy"; +import { verifyPublicEvidenceRevocation } from "./revocation"; +import { verifyPublicEvidenceBundle } from "./signature"; +import { parseStrictPublicJson } from "./strict-json"; +import type { + CommunityEvidenceSummaryV1, + PublicEvidenceBundleV1, + PublicEvidenceRevocationV1, +} from "./types"; +import { PublicEvidenceValidationError } from "./validate"; + +const MAX_IMPORT_BYTES = 2 * 1024 * 1024; +const MAX_CACHE_FILES = 512; +const MAX_CACHE_BYTES = 64 * 1024 * 1024; +const MAX_DEPTH = 8; +const MAX_OBJECT_KEYS = 64; +const MAX_ARRAY_ELEMENTS = 512; +const MAX_GENERIC_STRING_BYTES = 384 * 1024; +const COMMUNITY_BUNDLE_FILE_RE = /^bundle-([0-9a-f]{64})-([0-9a-f]{64})\.json$/; +const COMMUNITY_REVOCATION_FILE_RE = /^revocation-([0-9a-f]{64})\.json$/; + +const COMMUNITY_FILE_OPTIONS = { + maxBytes: MAX_IMPORT_BYTES, + errorCode: "community_unsafe_target", + errorMessage: "community object is not a bounded private regular file", + sizeErrorCode: "community_size", + sizeErrorMessage: "community file exceeds bound", +} as const; + +function assertId(value: string): string { + if (!/^[0-9a-f]{64}$/.test(value)) { + throw new PublicEvidenceValidationError("community_id", "community object id invalid"); + } + return value; +} + +function scanStructure(value: unknown, depth = 0): void { + if (depth > MAX_DEPTH) { + throw new PublicEvidenceValidationError("community_depth", "community JSON nesting depth exceeded"); + } + if (typeof value === "string") { + if (new TextEncoder().encode(value).byteLength > MAX_GENERIC_STRING_BYTES || value.includes("\0")) { + throw new PublicEvidenceValidationError("community_string", "community string invalid or oversized"); + } + return; + } + if (Array.isArray(value)) { + if (value.length > MAX_ARRAY_ELEMENTS) { + throw new PublicEvidenceValidationError("community_array", "community array bound exceeded"); + } + for (const item of value) scanStructure(item, depth + 1); + return; + } + if (value && typeof value === "object") { + const keys = Object.keys(value); + if (keys.length > MAX_OBJECT_KEYS) { + throw new PublicEvidenceValidationError("community_object", "community object key bound exceeded"); + } + for (const key of keys) { + if (new TextEncoder().encode(key).byteLength > 4096) { + throw new PublicEvidenceValidationError("community_key", "community key oversized"); + } + scanStructure((value as Record)[key], depth + 1); + } + } +} + +function boundedInput(raw: unknown): unknown { + let bytes: Buffer; + if (raw instanceof Uint8Array) { + bytes = Buffer.from(raw); + } else if (typeof raw === "string") { + bytes = Buffer.from(raw, "utf8"); + } else { + // Bound already-decoded values before recursive JCS canonicalization can allocate + // or overflow on attacker-controlled depth/width. + scanStructure(raw); + bytes = Buffer.from(jcsStringify(raw), "utf8"); + } + if (bytes.byteLength > MAX_IMPORT_BYTES) { + throw new PublicEvidenceValidationError("community_size", "community import exceeds 2 MiB"); + } + const parsed = parseStrictPublicJson(bytes, "community import"); + scanStructure(parsed); + return parsed; +} + +function assertCommunityArtifactAuthority(bundle: PublicEvidenceBundleV1): void { + if (bundle.artifacts.length !== 0) { + throw new PublicEvidenceValidationError( + "public_artifact_authority_required", + "community artifact bytes require reviewed public_export policy authority", + ); + } +} + +function verifiedBundle(raw: unknown): PublicEvidenceBundleV1 { + const result = verifyPublicEvidenceBundle(raw as PublicEvidenceBundleV1); + if (result.status !== "cryptographically_valid") { + throw new PublicEvidenceValidationError(result.status, "community bundle verification failed"); + } + const bundle = validateCommunityEvidenceAuthorities(raw as PublicEvidenceBundleV1); + assertCommunityArtifactAuthority(bundle); + validatePublicEvidencePrivacy(bundle); + return bundle; +} + +function bundleObjectPath(publisherKeyId: string, bundleId: string, configDir?: string): string { + return join(labCommunityDir(configDir), `bundle-${assertId(publisherKeyId)}-${assertId(bundleId)}.json`); +} + +function revocationObjectPath(revocationId: string, configDir?: string): string { + return join(labCommunityDir(configDir), `revocation-${assertId(revocationId)}.json`); +} + +function readBounded(path: string): Buffer { + cleanupStalePrivateFileStages(path); + return readPrivateRegularFile(path, COMMUNITY_FILE_OPTIONS); +} + +function cacheUsage(configDir?: string): { names: string[]; bytes: number } { + ensureLabDirs(configDir); + const dir = labCommunityDir(configDir); + cleanupStalePrivateFileStagesInDir(dir); + const names = readdirSync(dir).filter((name) => !isPrivateFileStageName(name)).sort(); + if (names.length > MAX_CACHE_FILES) { + throw new PublicEvidenceValidationError("community_cache_bound", "community cache file bound exceeded"); + } + let bytes = 0; + for (const name of names) { + bytes += privateRegularFileSize(join(dir, name), COMMUNITY_FILE_OPTIONS); + if (bytes > MAX_CACHE_BYTES) { + throw new PublicEvidenceValidationError("community_cache_bound", "community cache byte bound exceeded"); + } + } + return { names, bytes }; +} + +function assertCacheCanAdd(byteCount: number, configDir?: string): void { + const usage = cacheUsage(configDir); + if (usage.names.length >= MAX_CACHE_FILES || usage.bytes + byteCount > MAX_CACHE_BYTES) { + throw new PublicEvidenceValidationError("community_cache_bound", "community cache capacity exceeded"); + } +} + +function persistAt(path: string, kind: "bundle" | "revocation", value: unknown, configDir?: string): { path: string; created: boolean } { + const bytes = Buffer.from(jcsStringify(value), "utf8"); + if (bytes.byteLength > MAX_IMPORT_BYTES) { + throw new PublicEvidenceValidationError("community_size", "community object exceeds bound"); + } + + try { + const existing = readBounded(path); + if (!existing.equals(bytes)) { + throw new PublicEvidenceValidationError("community_conflict", `${kind} identity already exists with different bytes`); + } + return { path, created: false }; + } catch (error) { + if (error instanceof PublicEvidenceValidationError) throw error; + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + + assertCacheCanAdd(bytes.byteLength, configDir); + const published = publishPrivateFileExclusive(path, bytes); + if (!published.created) { + const raced = readBounded(path); + if (!raced.equals(bytes)) { + throw new PublicEvidenceValidationError("community_conflict", `${kind} identity already exists with different bytes`); + } + return { path, created: false }; + } + + try { + cacheUsage(configDir); + } catch (error) { + try { unlinkSync(path); } catch { /* preserve quota error */ } + throw error; + } + return { path, created: true }; +} + +function readJson(path: string): unknown { + const parsed = parseStrictPublicJson(readBounded(path), "stored community object"); + scanStructure(parsed); + return parsed; +} + +function files(configDir?: string): string[] { + return cacheUsage(configDir).names; +} + +function readVerifiedBundleAt(path: string): PublicEvidenceBundleV1 { + return verifiedBundle(readJson(path)); +} + +function bundleFromName(name: string, configDir?: string): PublicEvidenceBundleV1 | null { + const match = COMMUNITY_BUNDLE_FILE_RE.exec(name); + if (!match) return null; + const publisherKeyId = match[1]!; + const bundleId = match[2]!; + const bundle = readVerifiedBundleAt(bundleObjectPath(publisherKeyId, bundleId, configDir)); + if (bundle.bundleId !== bundleId || bundle.publisher.keyId !== publisherKeyId) { + throw new PublicEvidenceValidationError("community_identity_mismatch", "stored community bundle does not match filename identity"); + } + return bundle; +} + +function bundlesFromNames(names: readonly string[], configDir?: string): PublicEvidenceBundleV1[] { + const bundles: PublicEvidenceBundleV1[] = []; + for (const name of names) { + const bundle = bundleFromName(name, configDir); + if (bundle) bundles.push(bundle); + } + return bundles; +} + +export function importCommunityEvidenceBundle( + raw: unknown, + configDir?: string, +): { created: boolean; status: "cryptographically_valid"; bundleId: string; publisherKeyId: string; path: string } { + const bundle = verifiedBundle(boundedInput(raw)); + ensureLabDirs(configDir); + const stored = persistAt( + bundleObjectPath(bundle.publisher.keyId, bundle.bundleId, configDir), + "bundle", + bundle, + configDir, + ); + return { ...stored, status: "cryptographically_valid", bundleId: bundle.bundleId, publisherKeyId: bundle.publisher.keyId }; +} + +export function readCommunityEvidenceBundleForPublisher( + bundleId: string, + publisherKeyId: string, + configDir?: string, +): PublicEvidenceBundleV1 { + const bundle = readVerifiedBundleAt(bundleObjectPath(publisherKeyId, bundleId, configDir)); + if (bundle.bundleId !== bundleId || bundle.publisher.keyId !== publisherKeyId) { + throw new PublicEvidenceValidationError("community_identity_mismatch", "stored community bundle does not match filename identity"); + } + return bundle; +} + +type RevocationMetadata = { + publisher?: { keyId?: unknown }; + targets?: Array<{ kind?: unknown; id?: unknown }>; +}; + +function resolveTargetBundle( + revocation: unknown, + bundles: readonly PublicEvidenceBundleV1[], +): PublicEvidenceBundleV1 { + if (!revocation || typeof revocation !== "object") { + throw new PublicEvidenceValidationError("revocation_target", "revocation target metadata unavailable"); + } + const raw = revocation as RevocationMetadata; + if (!Array.isArray(raw.targets) || typeof raw.publisher?.keyId !== "string") { + throw new PublicEvidenceValidationError("revocation_target", "revocation targets or publisher unavailable"); + } + const publisherKeyId = assertId(raw.publisher.keyId); + const publisherBundles = bundles + .filter((bundle) => bundle.publisher.keyId === publisherKeyId) + .sort((a, b) => a.bundleId.localeCompare(b.bundleId)); + const bundleTargets = raw.targets.filter((target) => target.kind === "bundle" && typeof target.id === "string"); + if (bundleTargets.length > 0) { + const targetIds = new Set(bundleTargets.map((target) => target.id)); + if (targetIds.size !== 1) { + throw new PublicEvidenceValidationError("revocation_target", "revocation bundle targets are ambiguous"); + } + const id = [...targetIds][0]!; + const candidate = publisherBundles.find((bundle) => bundle.bundleId === id); + if (!candidate) throw new PublicEvidenceValidationError("revocation_target", "revocation target bundle not found"); + return candidate; + } + const fullyMatching = publisherBundles.filter((bundle) => raw.targets!.every((target) => + target.kind === "record" && typeof target.id === "string" + && bundle.records.some((record) => record.recordId === target.id), + )); + if (fullyMatching.length === 0) { + throw new PublicEvidenceValidationError( + "revocation_target", + "revocation targets do not resolve to a verified bundle for the same publisher", + ); + } + // Content-addressed records may legitimately occur in more than one bundle. Any + // deterministic fully-matching verified bundle bootstraps the same publisher key. + return fullyMatching[0]!; +} + +function findTargetBundle(revocation: unknown, configDir?: string): PublicEvidenceBundleV1 { + const names = files(configDir); + const raw = revocation as RevocationMetadata; + const publisherKeyId = typeof raw?.publisher?.keyId === "string" ? assertId(raw.publisher.keyId) : null; + const directBundleIds = Array.isArray(raw?.targets) + ? [...new Set(raw.targets.filter((target) => target.kind === "bundle" && typeof target.id === "string").map((target) => target.id as string))] + : []; + if (publisherKeyId && directBundleIds.length === 1) { + return readCommunityEvidenceBundleForPublisher(assertId(directBundleIds[0]!), publisherKeyId, configDir); + } + return resolveTargetBundle(revocation, bundlesFromNames(names, configDir)); +} + +export function importCommunityEvidenceRevocation( + raw: unknown, + configDir?: string, +): { created: boolean; status: "cryptographically_valid"; revocationId: string; path: string } { + const parsed = boundedInput(raw); + const targetBundle = findTargetBundle(parsed, configDir); + const verified = verifyPublicEvidenceRevocation(parsed, targetBundle); + if (verified.status !== "cryptographically_valid") { + throw new PublicEvidenceValidationError(verified.status, verified.detail ?? "community revocation verification failed"); + } + ensureLabDirs(configDir); + const stored = persistAt( + revocationObjectPath(verified.revocation.revocationId, configDir), + "revocation", + verified.revocation, + configDir, + ); + return { ...stored, status: "cryptographically_valid", revocationId: verified.revocation.revocationId }; +} + +export function listCommunityEvidence(configDir?: string): CommunityEvidenceSummaryV1[] { + const names = files(configDir); + const bundles = bundlesFromNames(names, configDir); + const revocations: PublicEvidenceRevocationV1[] = []; + + for (const name of names) { + if (!COMMUNITY_REVOCATION_FILE_RE.test(name)) continue; + const raw = readJson(join(labCommunityDir(configDir), name)); + let targetBundle: PublicEvidenceBundleV1; + try { + targetBundle = resolveTargetBundle(raw, bundles); + } catch (error) { + if (error instanceof PublicEvidenceValidationError) continue; + throw error; + } + const verified = verifyPublicEvidenceRevocation(raw, targetBundle); + if (verified.status === "cryptographically_valid") revocations.push(verified.revocation); + } + + return bundles.map((bundle) => { + const revoked = new Set(); + const bundleRecordIds = new Set(bundle.records.map((record) => record.recordId)); + for (const revocation of revocations) { + if (revocation.publisher.keyId !== bundle.publisher.keyId + || revocation.publisher.publicKey !== bundle.publisher.publicKey) { + continue; + } + if (revocation.targets.some((target) => target.kind === "bundle" && target.id === bundle.bundleId)) { + for (const record of bundle.records) revoked.add(record.recordId); + } + for (const target of revocation.targets) { + if (target.kind === "record" && bundleRecordIds.has(target.id)) revoked.add(target.id); + } + } + return { + trustClass: "community_untrusted_v1" as const, + status: "cryptographically_valid" as const, + bundleId: bundle.bundleId, + publisherKeyId: bundle.publisher.keyId, + activeRecordCount: bundle.records.filter((record) => !revoked.has(record.recordId)).length, + revokedRecordCount: bundle.records.filter((record) => revoked.has(record.recordId)).length, + }; + }).sort((a, b) => a.bundleId.localeCompare(b.bundleId) || a.publisherKeyId.localeCompare(b.publisherKeyId)); +} diff --git a/src/lab/public/file-safety.ts b/src/lab/public/file-safety.ts new file mode 100644 index 000000000..04d546d40 --- /dev/null +++ b/src/lab/public/file-safety.ts @@ -0,0 +1,87 @@ +import { + closeSync, + constants as fsConstants, + fstatSync, + lstatSync, + openSync, + readFileSync, +} from "node:fs"; +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, + ); +} + +function withPrivateRegularFile( + path: string, + options: PrivateRegularFileReadOptions, + consume: (fd: number, size: number) => T, +): T { + const 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 000000000..ff7843754 --- /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/index.ts b/src/lab/public/index.ts new file mode 100644 index 000000000..e16890d88 --- /dev/null +++ b/src/lab/public/index.ts @@ -0,0 +1,16 @@ +export * from "./types"; +export * from "./ids"; +export * from "./registry"; +export * from "./validate"; +export * from "./privacy"; +export * from "./project"; +export * from "./bundle"; +export * from "./signature"; +export * from "./storage"; +export * from "./community-authority"; +export * from "./revocation"; +export * from "./community"; +export * from "./strict-json"; +export * from "./origin"; +export * from "./operator"; +export * from "./purge"; diff --git a/src/lab/public/operator.ts b/src/lab/public/operator.ts new file mode 100644 index 000000000..85b5e2dc4 --- /dev/null +++ b/src/lab/public/operator.ts @@ -0,0 +1,268 @@ +import { replayLabLedger } from "../ledger/store"; +import { labLedgerPath } from "../paths"; +import { queryLabEventById, queryLabVerdicts } from "../query"; +import type { ObservationEvent } from "../events/types"; +import { importCommunityEvidenceBundle, listCommunityEvidence } from "./community"; +import { readPrivateRegularFile } from "./file-safety"; +import { recordLocalPublicOrigin } from "./origin"; +import type { ProjectPublicEvidenceRecordInput } from "./project"; +import { projectPublicEvidenceRecord } from "./project"; +import { signPublicEvidenceBundle, verifyPublicEvidenceBundle } from "./signature"; +import { storePublicEvidenceBundle } from "./storage"; +import { parseStrictPublicJson } from "./strict-json"; +import { publicUtcDay } from "./time"; +import { PUBLIC_EVIDENCE_BUNDLE_SCHEMA_VERSION, PUBLIC_EXPORT_POLICY_VERSION } from "./types"; +import type { + PublicEvidenceBundleV1, + PublicEvidencePreviewBundleV1, + PublicEvidenceRecordV1, + PublicProjectionNotExportableReason, +} from "./types"; +import { PublicEvidenceValidationError } from "./validate"; + +const MAX_OPERATOR_EVENTS = 256; +const MAX_PUBLIC_FILE_BYTES = 2 * 1024 * 1024; +const EMPTY_PREVIEW_DAY = "1970-01-01"; +const PRIVATE_STORAGE_LOCATOR = ""; + +export interface ProjectPublicEvidenceInput { + records: ProjectPublicEvidenceRecordInput[]; +} + +export function projectPublicEvidence(input: ProjectPublicEvidenceInput): { + bundle: PublicEvidencePreviewBundleV1; + excluded: Array<{ index: number; reason: PublicProjectionNotExportableReason }>; +} { + const records: PublicEvidenceRecordV1[] = []; + const excluded: Array<{ index: number; reason: PublicProjectionNotExportableReason }> = []; + let latestExportableCompletedAt: number | null = null; + + input.records.forEach((recordInput, index) => { + const projected = projectPublicEvidenceRecord(recordInput); + if (projected.status !== "exportable") { + excluded.push({ index, reason: projected.reason }); + return; + } + records.push(projected.record); + latestExportableCompletedAt = Math.max( + latestExportableCompletedAt ?? recordInput.observation.completedAt, + recordInput.observation.completedAt, + ); + }); + records.sort((a, b) => a.recordId.localeCompare(b.recordId)); + return { + bundle: { + schemaVersion: PUBLIC_EVIDENCE_BUNDLE_SCHEMA_VERSION, + exportPolicyVersion: PUBLIC_EXPORT_POLICY_VERSION, + // Empty previews are intentionally unsignable and use a constant day so excluded + // observation timestamps can never influence public output. + createdDayUtc: latestExportableCompletedAt === null + ? EMPTY_PREVIEW_DAY + : publicUtcDay(latestExportableCompletedAt), + records, + artifacts: [], + }, + excluded, + }; +} + +export type PublicOperatorExclusionReason = + | PublicProjectionNotExportableReason + | "event_not_found" + | "not_observation" + | "event_excluded" + | "no_canonical_verdict"; + +export interface PublicOperatorExclusionV1 { + /** Index into the caller's submitted selection, never a local Lab identifier. */ + selectionIndex: number; + reason: PublicOperatorExclusionReason; +} + +export interface LocalPublicPreviewV1 { + bundle: PublicEvidencePreviewBundleV1; + excluded: PublicOperatorExclusionV1[]; +} + +export interface LocalPublicExportV1 { + bundle: PublicEvidenceBundleV1; + /** `path` is deliberately opaque on public surfaces; real paths stay storage-internal. */ + stored: { path: typeof PRIVATE_STORAGE_LOCATOR; created: boolean }; + excluded: PublicOperatorExclusionV1[]; +} + +export type PublicVerificationSummaryV1 = + | { status: "cryptographically_valid"; bundleId: string; publisherKeyId: string; locallyVerified: false } + | { status: "schema_rejected" | "digest_invalid" | "signature_invalid"; locallyVerified: false; detail?: string }; + +function assertOperatorEventIds(eventIds: readonly string[]): Array<{ eventId: string; selectionIndex: number }> { + if (eventIds.length === 0 || eventIds.length > MAX_OPERATOR_EVENTS) { + throw new PublicEvidenceValidationError( + "public_selection_limit", + `public evidence selection must contain 1..${MAX_OPERATOR_EVENTS} event ids`, + ); + } + const unique: Array<{ eventId: string; selectionIndex: number }> = []; + const seen = new Set(); + for (const [selectionIndex, eventId] of eventIds.entries()) { + if (!/^[0-9a-f]{64}$/.test(eventId)) { + throw new PublicEvidenceValidationError( + "public_selection_event_id", + "public evidence event ids must be lowercase sha256 hex", + ); + } + if (seen.has(eventId)) continue; + seen.add(eventId); + unique.push({ eventId, selectionIndex }); + } + return unique; +} + +function canonicalVerdictForObservation( + observation: ObservationEvent, + configDir?: string, +): ProjectPublicEvidenceRecordInput["verdict"] | null { + const filters = { + subjectId: observation.subjectId, + layer: observation.evidenceLayer, + suiteId: observation.suiteId, + }; + let cursor: string | undefined; + do { + const page = queryLabVerdicts(filters, cursor, 200, configDir); + const verdict = page.items.find((row) => + row.suiteVersion === observation.suiteVersion && row.contributingEventIds.includes(observation.eventId), + ); + if (verdict) return verdict.verdict; + if (!page.hasMore || !page.nextCursor) return null; + cursor = page.nextCursor; + } while (true); +} + +export function previewLocalPublicEvidence( + input: { eventIds: readonly string[] }, + configDir?: string, +): LocalPublicPreviewV1 { + const selections = assertOperatorEventIds(input.eventIds); + const replay = replayLabLedger(labLedgerPath(configDir)); + const byId = new Map(replay.events.map((event) => [event.eventId, event] as const)); + const projectInputs: ProjectPublicEvidenceRecordInput[] = []; + const projectSelectionIndices: number[] = []; + const excluded: PublicOperatorExclusionV1[] = []; + let sawObservation = false; + + for (const { eventId, selectionIndex } of selections) { + const event = byId.get(eventId); + if (!event) { + excluded.push({ selectionIndex, reason: "event_not_found" }); + continue; + } + if (event.eventKind !== "observation") { + excluded.push({ selectionIndex, reason: "not_observation" }); + continue; + } + sawObservation = true; + const projectedEvent = queryLabEventById(eventId, configDir); + if (!projectedEvent) { + excluded.push({ selectionIndex, reason: "event_not_found" }); + continue; + } + if (projectedEvent.excluded) { + excluded.push({ selectionIndex, reason: "event_excluded" }); + continue; + } + const verdict = canonicalVerdictForObservation(event, configDir); + if (!verdict) { + excluded.push({ selectionIndex, reason: "no_canonical_verdict" }); + continue; + } + projectInputs.push({ observation: event, verdict }); + projectSelectionIndices.push(selectionIndex); + } + + if (!sawObservation) { + throw new PublicEvidenceValidationError("public_selection_empty", "public evidence selection contains no observation events"); + } + + const projected = projectPublicEvidence({ records: projectInputs }); + for (const row of projected.excluded) { + excluded.push({ selectionIndex: projectSelectionIndices[row.index]!, reason: row.reason }); + } + excluded.sort((a, b) => a.selectionIndex - b.selectionIndex); + return { bundle: projected.bundle, excluded }; +} + +export function exportLocalPublicEvidence( + input: { eventIds: readonly string[] }, + configDir?: string, +): LocalPublicExportV1 { + const preview = previewLocalPublicEvidence(input, configDir); + if (preview.bundle.records.length === 0) { + throw new PublicEvidenceValidationError("public_export_empty", "selected events produced no exportable public evidence records"); + } + const bundle = signPublicEvidenceBundle({ + records: preview.bundle.records, + artifacts: preview.bundle.artifacts, + createdDayUtc: preview.bundle.createdDayUtc, + configDir, + }); + // Persist provenance before the export file so no successfully-created local export can + // exist without purge-owned origin evidence. An orphan marker is conservative and safe. + recordLocalPublicOrigin({ publisherKeyId: bundle.publisher.keyId, bundleId: bundle.bundleId }, configDir); + const stored = storePublicEvidenceBundle(bundle, configDir); + return { + bundle, + stored: { path: PRIVATE_STORAGE_LOCATOR, created: stored.created }, + excluded: preview.excluded, + }; +} + +export function summarizePublicEvidenceVerification(raw: unknown): PublicVerificationSummaryV1 { + const result = verifyPublicEvidenceBundle(raw as PublicEvidenceBundleV1); + if (result.status !== "cryptographically_valid") { + return { status: result.status, locallyVerified: false }; + } + const bundle = raw as PublicEvidenceBundleV1; + return { + status: "cryptographically_valid", + bundleId: bundle.bundleId, + publisherKeyId: bundle.publisher.keyId, + locallyVerified: false, + }; +} + +function readBoundedPublicFile(path: string): Buffer { + return readPrivateRegularFile(path, { + maxBytes: MAX_PUBLIC_FILE_BYTES, + errorCode: "public_file_unsafe", + errorMessage: "public evidence input must be a regular non-symlink file", + sizeErrorCode: "public_file_too_large", + sizeErrorMessage: "public evidence input exceeds 2 MiB", + }); +} + +function parsePublicFile(path: string): unknown { + return parseStrictPublicJson(readBoundedPublicFile(path), "public evidence input", "public_file_json"); +} + +export function verifyPublicEvidenceFile(path: string): PublicVerificationSummaryV1 { + return summarizePublicEvidenceVerification(parsePublicFile(path)); +} + +export function importCommunityEvidenceFile(path: string, configDir?: string) { + const { path: _privatePath, ...imported } = importCommunityEvidenceBundle(readBoundedPublicFile(path), configDir); + return { ...imported, trustClass: "community_untrusted_v1" as const, locallyVerified: false as const }; +} + +export function importCommunityEvidenceValue(raw: unknown, configDir?: string) { + const { path: _privatePath, ...imported } = importCommunityEvidenceBundle(raw, configDir); + return { ...imported, trustClass: "community_untrusted_v1" as const, locallyVerified: false as const }; +} + +export function listCommunityEvidenceContext(configDir?: string) { + return { + evidence: listCommunityEvidence(configDir), + trustClass: "community_untrusted_v1" as const, + locallyVerified: false as const, + }; +} diff --git a/src/lab/public/origin.ts b/src/lab/public/origin.ts new file mode 100644 index 000000000..d6f7133b0 --- /dev/null +++ b/src/lab/public/origin.ts @@ -0,0 +1,190 @@ +import { lstatSync, readdirSync, unlinkSync } from "node:fs"; +import { join } from "node:path"; +import { jcsStringify } from "../digest"; +import { ensureLabDirs, labCommunityDir, labPublicOriginDir } from "../paths"; +import { readPrivateRegularFile } from "./file-safety"; +import { + cleanupStalePrivateFileStagesInDir, + isPrivateFileStageName, + publishPrivateFileExclusive, +} from "./private-file"; +import { parseStrictPublicJson } from "./strict-json"; +import { PublicEvidenceValidationError } from "./validate"; + +// The community cache itself is capped at 512 files. Keeping twice that many origin +// markers leaves headroom for in-flight/local exports while allowing unreferenced +// provenance to be reclaimed instead of permanently locking future exports. +const MAX_ORIGINS = 1024; +const MAX_ORIGIN_BYTES = 1024; +const ORIGIN_RE = /^origin-([0-9a-f]{64})-([0-9a-f]{64})\.json$/; + +export interface PublicOriginIdentityV1 { + publisherKeyId: string; + bundleId: string; +} + +function originPath(identity: PublicOriginIdentityV1, configDir?: string): string { + if (!/^[0-9a-f]{64}$/.test(identity.publisherKeyId) || !/^[0-9a-f]{64}$/.test(identity.bundleId)) { + throw new PublicEvidenceValidationError("public_origin_id", "public origin identity is invalid"); + } + return join( + labPublicOriginDir(configDir), + `origin-${identity.publisherKeyId}-${identity.bundleId}.json`, + ); +} + +function originBody(identity: PublicOriginIdentityV1): Buffer { + return Buffer.from(jcsStringify({ + schemaVersion: "public_origin_v1", + publisherKeyId: identity.publisherKeyId, + bundleId: identity.bundleId, + }), "utf8"); +} + +function readOrigin(path: string, expected?: PublicOriginIdentityV1): PublicOriginIdentityV1 { + const bytes = readPrivateRegularFile(path, { + maxBytes: MAX_ORIGIN_BYTES, + errorCode: "public_origin_unsafe", + errorMessage: "public origin marker is not a private regular file with 0600 permissions", + sizeErrorCode: "public_origin_unsafe", + sizeErrorMessage: "public origin marker exceeds its size bound", + requireMode600: true, + }); + const raw = parseStrictPublicJson(bytes, "public origin marker", "public_origin_json"); + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + throw new PublicEvidenceValidationError("public_origin_json", "public origin marker must be an object"); + } + const row = raw as Record; + if (Object.keys(row).sort().join(",") !== "bundleId,publisherKeyId,schemaVersion" + || row.schemaVersion !== "public_origin_v1" + || typeof row.publisherKeyId !== "string" + || typeof row.bundleId !== "string" + || !/^[0-9a-f]{64}$/.test(row.publisherKeyId) + || !/^[0-9a-f]{64}$/.test(row.bundleId)) { + throw new PublicEvidenceValidationError("public_origin_json", "public origin marker schema is invalid"); + } + const identity = { publisherKeyId: row.publisherKeyId, bundleId: row.bundleId }; + if (expected && (identity.publisherKeyId !== expected.publisherKeyId || identity.bundleId !== expected.bundleId)) { + throw new PublicEvidenceValidationError("public_origin_conflict", "public origin marker identity mismatch"); + } + return identity; +} + +function originNames(dir: string): string[] { + cleanupStalePrivateFileStagesInDir(dir); + return readdirSync(dir).filter((name) => !isPrivateFileStageName(name)).sort(); +} + +function pathExistsConservatively(path: string): boolean { + try { + lstatSync(path); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; + return true; + } +} + +function communityBundlePath(identity: PublicOriginIdentityV1, configDir?: string): string { + return join( + labCommunityDir(configDir), + `bundle-${identity.publisherKeyId}-${identity.bundleId}.json`, + ); +} + +/** + * Origin markers exist to recover local provenance for community copies when the export + * or publisher key is later unavailable. If no exact community copy exists, the marker + * is reclaimable under pressure because there is no public community object for purge to + * classify. Unexpected entries are never deleted here. + */ +function reclaimUnreferencedOrigins( + dir: string, + preservePath: string, + configDir?: string, +): void { + for (const name of originNames(dir)) { + const match = ORIGIN_RE.exec(name); + if (!match) continue; + const path = join(dir, name); + if (path === preservePath) continue; + const identity = { publisherKeyId: match[1]!, bundleId: match[2]! }; + if (pathExistsConservatively(communityBundlePath(identity, configDir))) continue; + try { + unlinkSync(path); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + } +} + +export function recordLocalPublicOrigin(identity: PublicOriginIdentityV1, configDir?: string): void { + ensureLabDirs(configDir); + const dir = labPublicOriginDir(configDir); + const path = originPath(identity, configDir); + try { + readOrigin(path, identity); + return; + } catch (error) { + if (error instanceof PublicEvidenceValidationError) throw error; + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + + let names = originNames(dir); + if (names.length >= MAX_ORIGINS) { + reclaimUnreferencedOrigins(dir, path, configDir); + names = originNames(dir); + } + if (names.length >= MAX_ORIGINS) { + throw new PublicEvidenceValidationError("public_origin_bound", "public origin marker bound exceeded"); + } + + const bytes = originBody(identity); + const published = publishPrivateFileExclusive(path, bytes); + if (!published.created) { + readOrigin(path, identity); + return; + } + + // Separate CLI processes can both observe one free slot before either publishes. + // Reclaim unreferenced history after publication, then remove only this call's marker + // if the directory still cannot converge inside the hard cap. + if (originNames(dir).length > MAX_ORIGINS) { + reclaimUnreferencedOrigins(dir, path, configDir); + if (originNames(dir).length > MAX_ORIGINS) { + try { unlinkSync(path); } catch { /* preserve the quota failure */ } + throw new PublicEvidenceValidationError("public_origin_bound", "public origin marker bound exceeded"); + } + } +} + +export function listLocalPublicOrigins(configDir?: string): PublicOriginIdentityV1[] { + ensureLabDirs(configDir); + const dir = labPublicOriginDir(configDir); + const names = originNames(dir); + if (names.length > MAX_ORIGINS) { + throw new PublicEvidenceValidationError("public_origin_bound", "public origin marker bound exceeded"); + } + const identities: PublicOriginIdentityV1[] = []; + for (const name of names) { + const match = ORIGIN_RE.exec(name); + if (!match) { + throw new PublicEvidenceValidationError("public_origin_unsafe", "unexpected public origin marker entry"); + } + const expected = { publisherKeyId: match[1]!, bundleId: match[2]! }; + identities.push(readOrigin(join(dir, name), expected)); + } + return identities; +} + +export function clearLocalPublicOrigins(configDir?: string): void { + ensureLabDirs(configDir); + const dir = labPublicOriginDir(configDir); + cleanupStalePrivateFileStagesInDir(dir); + for (const name of readdirSync(dir)) { + if (!ORIGIN_RE.test(name)) continue; + try { unlinkSync(join(dir, name)); } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + } +} diff --git a/src/lab/public/privacy.ts b/src/lab/public/privacy.ts new file mode 100644 index 000000000..5d1d96407 --- /dev/null +++ b/src/lab/public/privacy.ts @@ -0,0 +1,131 @@ +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 assertPrivacySafeString(value: string, field: string): void { + // `PUBLIC_IDENTIFIER` intentionally permits `:` for reviewed identifiers, so the + // regex-only scanner cannot safely use a broad colon pattern. Node's IP parser gives + // us an exact whole-string IPv4/IPv6 check without rejecting ordinary version names. + if (isIP(value) !== 0) { + 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 000000000..44ba10c6c --- /dev/null +++ b/src/lab/public/private-file.ts @@ -0,0 +1,171 @@ +import { randomUUID } from "node:crypto"; +import { + closeSync, + constants as fsConstants, + fsyncSync, + linkSync, + 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)); +} + +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; + 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. + fsyncParentForPublication(finalPath); + return { created: false }; + } + throw error; + } + fsyncParentForPublication(finalPath); + return { created: true }; + } finally { + if (fd !== null) closeSync(fd); + 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 000000000..611002ac2 --- /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/purge-test-fault.ts b/src/lab/public/purge-test-fault.ts new file mode 100644 index 000000000..4e801a9b2 --- /dev/null +++ b/src/lab/public/purge-test-fault.ts @@ -0,0 +1,14 @@ +export type PublicEvidencePurgeFaultForTests = "before_export_delete" | null; + +let purgeFaultForTests: PublicEvidencePurgeFaultForTests = null; + +/** Internal deterministic fault seam. This module is intentionally not barrel-exported. */ +export function setPublicEvidencePurgeFaultForTests( + fault: PublicEvidencePurgeFaultForTests, +): void { + purgeFaultForTests = fault; +} + +export function publicEvidencePurgeFaultForTests(): PublicEvidencePurgeFaultForTests { + return purgeFaultForTests; +} diff --git a/src/lab/public/purge.ts b/src/lab/public/purge.ts new file mode 100644 index 000000000..69be5b142 --- /dev/null +++ b/src/lab/public/purge.ts @@ -0,0 +1,178 @@ +import { createPrivateKey, createPublicKey } from "node:crypto"; +import { readdirSync, rmSync, unlinkSync } from "node:fs"; +import { join } from "node:path"; +import { + ensureLabDirs, + labCommunityDir, + labExportDir, + labPublicPublisherKeyPath, +} from "../paths"; +import { privateRegularFileSize, readPrivateRegularFile } from "./file-safety"; +import { publicEvidenceId } from "./ids"; +import { clearLocalPublicOrigins, listLocalPublicOrigins } from "./origin"; +import { publicEvidencePurgeFaultForTests } from "./purge-test-fault"; +import { readPublicEvidenceBundle } from "./storage"; +import { parseStrictPublicJson } from "./strict-json"; + +const MAX_PRIVATE_KEY_BYTES = 8 * 1024; +const MAX_COMMUNITY_OBJECT_BYTES = 2 * 1024 * 1024; +const EXPORT_FILE_RE = /^([0-9a-f]{64})\.json$/; +const COMMUNITY_BUNDLE_RE = /^bundle-([0-9a-f]{64})-([0-9a-f]{64})\.json$/; +const COMMUNITY_REVOCATION_RE = /^revocation-([0-9a-f]{64})\.json$/; + +/** + * Publisher provenance is useful only for classifying local community copies. A corrupt + * key must never block deletion of sensitive exports, so classification fails closed to + * "unknown publisher" while the purge continues. + */ +function readExistingPublisherKeyId(configDir?: string): string | null { + const path = labPublicPublisherKeyPath(configDir); + try { + const pem = readPrivateRegularFile(path, { + maxBytes: MAX_PRIVATE_KEY_BYTES, + errorCode: "public_publisher_key_unsafe", + errorMessage: "public publisher key is unsafe during purge", + requireMode600: true, + }).toString("utf8"); + if (!pem.includes("BEGIN PRIVATE KEY")) return null; + const privateKey = createPrivateKey(pem); + if (privateKey.asymmetricKeyType !== "ed25519") return null; + const publicKey = createPublicKey(pem); + const publicKeyDer = publicKey.export({ type: "spki", format: "der" }).toString("base64"); + return publicEvidenceId("publisher_key", { algorithm: "ed25519", publicKey: publicKeyDer }); + } catch { + return null; + } +} + +function publicIdentity(publisherKeyId: string, bundleId: string): string { + return `${publisherKeyId}:${bundleId}`; +} + +/** Best-effort legacy classification only. Malformed exports are still deleted below. */ +function localExportIdentities(configDir?: string): Set { + const identities = new Set(); + for (const entry of readdirSync(labExportDir(configDir), { withFileTypes: true })) { + const match = EXPORT_FILE_RE.exec(entry.name); + if (!match) continue; + try { + const bundle = readPublicEvidenceBundle(match[1]!, configDir); + identities.add(publicIdentity(bundle.publisher.keyId, bundle.bundleId)); + } catch { + // Durable origin markers are the primary provenance source. Never retain a + // malformed export merely because legacy recovery can no longer parse it. + } + } + return identities; +} + +function purgeAllExports(configDir?: string): number { + if (publicEvidencePurgeFaultForTests() === "before_export_delete") { + throw new Error("synthetic public export purge failure"); + } + let deleted = 0; + const exportDir = labExportDir(configDir); + for (const entry of readdirSync(exportDir, { withFileTypes: true })) { + rmSync(join(exportDir, entry.name), { recursive: entry.isDirectory(), force: true }); + deleted += 1; + } + return deleted; +} + +/** Optional public community cleanup must never turn a completed export deletion into failure. */ +function unlinkLocalCommunityFile(path: string): boolean { + try { + privateRegularFileSize(path, { + maxBytes: MAX_COMMUNITY_OBJECT_BYTES, + errorCode: "community_unsafe_target", + errorMessage: "community object is unsafe during purge", + }); + } catch { + return false; + } + try { + unlinkSync(path); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; + throw error; + } +} + +function communityObjectPublisherKeyId(path: string): string | null { + try { + const raw = parseStrictPublicJson( + readPrivateRegularFile(path, { + maxBytes: MAX_COMMUNITY_OBJECT_BYTES, + errorCode: "community_unsafe_target", + errorMessage: "community object is unsafe during purge", + }), + "community object during purge", + ); + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null; + const publisher = (raw as { publisher?: unknown }).publisher; + if (!publisher || typeof publisher !== "object" || Array.isArray(publisher)) return null; + const keyId = (publisher as { keyId?: unknown }).keyId; + return typeof keyId === "string" && /^[0-9a-f]{64}$/.test(keyId) ? keyId : null; + } catch { + return null; + } +} + +export function purgeLocalPublicEvidenceCopies(configDir?: string): { + deletedExports: number; + deletedCommunityBundles: number; + deletedCommunityRevocations: number; +} { + ensureLabDirs(configDir); + + const exportedIdentities = localExportIdentities(configDir); + const localPublisherKeyIds = new Set(); + try { + for (const origin of listLocalPublicOrigins(configDir)) { + exportedIdentities.add(publicIdentity(origin.publisherKeyId, origin.bundleId)); + localPublisherKeyIds.add(origin.publisherKeyId); + } + } catch { + // Provenance corruption must never retain the mandatory sensitive export bytes. + // Legacy export identity and the current publisher key still provide best-effort + // classification for public community cleanup below. + } + const currentPublisherKeyId = readExistingPublisherKeyId(configDir); + if (currentPublisherKeyId) localPublisherKeyIds.add(currentPublisherKeyId); + const communityDir = labCommunityDir(configDir); + + // Sensitive local exports are the mandatory deletion target. Provenance is captured + // before this point, so cleanup remains possible even after the export bytes disappear. + const deletedExports = purgeAllExports(configDir); + + let deletedCommunityBundles = 0; + let deletedCommunityRevocations = 0; + for (const entry of readdirSync(communityDir, { withFileTypes: true })) { + const bundleMatch = COMMUNITY_BUNDLE_RE.exec(entry.name); + if (bundleMatch) { + const publisherKeyId = bundleMatch[1]!; + const bundleId = bundleMatch[2]!; + const locallyOriginated = exportedIdentities.has(publicIdentity(publisherKeyId, bundleId)) + || localPublisherKeyIds.has(publisherKeyId); + if (locallyOriginated && unlinkLocalCommunityFile(join(communityDir, entry.name))) { + deletedCommunityBundles += 1; + } + continue; + } + + if (COMMUNITY_REVOCATION_RE.test(entry.name)) { + const path = join(communityDir, entry.name); + const publisherKeyId = communityObjectPublisherKeyId(path); + if (publisherKeyId && localPublisherKeyIds.has(publisherKeyId) + && unlinkLocalCommunityFile(path)) { + deletedCommunityRevocations += 1; + } + } + } + + // Markers are purge-owned public provenance only. Remove them last. A corrupted + // marker cannot retain sensitive export bytes because mandatory deletion already ran. + clearLocalPublicOrigins(configDir); + return { deletedExports, deletedCommunityBundles, deletedCommunityRevocations }; +} \ No newline at end of file diff --git a/src/lab/public/registry.ts b/src/lab/public/registry.ts new file mode 100644 index 000000000..27743b53b --- /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/revocation.ts b/src/lab/public/revocation.ts new file mode 100644 index 000000000..e3146566f --- /dev/null +++ b/src/lab/public/revocation.ts @@ -0,0 +1,246 @@ +import { createPublicKey, verify as verifyBytes } from "node:crypto"; +import { publicEvidenceId } from "./ids"; +import { + loadExistingPublicPublisher, + signPublicPublisherDigest, + verifyPublicEvidenceBundle, +} from "./signature"; +import { + PUBLIC_EVIDENCE_REVOCATION_SCHEMA_VERSION, + type PublicEvidenceBundleV1, + type PublicEvidenceRevocationV1, + type PublicPublisherV1, + type PublicRevocationReasonV1, + type PublicRevocationTargetV1, + type PublicRevocationVerificationResult, +} from "./types"; +import { PublicEvidenceValidationError } from "./validate"; + +const REASONS = new Set([ + "publisher_retracted", + "privacy_retraction", + "evidence_invalidated", + "superseded", +]); +const MAX_TARGETS = 256; + +function isPlainObject(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +function closedKeys(value: Record, keys: readonly string[]): boolean { + const allowed = new Set(keys); + return Object.keys(value).every((key) => allowed.has(key)) && keys.every((key) => key in value); +} + +function validId(value: unknown): value is string { + return typeof value === "string" && /^[0-9a-f]{64}$/.test(value); +} + +function validDay(value: unknown): value is string { + if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return false; + const date = new Date(`${value}T00:00:00.000Z`); + return Number.isFinite(date.getTime()) && date.toISOString().slice(0, 10) === value; +} + +function targetKey(target: PublicRevocationTargetV1): string { + return `${target.kind}:${target.id}`; +} + +function canonicalTargets(targets: readonly PublicRevocationTargetV1[]): PublicRevocationTargetV1[] { + if (targets.length === 0 || targets.length > MAX_TARGETS) { + throw new PublicEvidenceValidationError("revocation_targets", "revocation must contain 1..256 targets"); + } + const normalized = targets.map((target) => { + if ((target.kind !== "bundle" && target.kind !== "record") || !validId(target.id)) { + throw new PublicEvidenceValidationError("revocation_target", "invalid revocation target"); + } + return { kind: target.kind, id: target.id } as PublicRevocationTargetV1; + }).sort((a, b) => targetKey(a).localeCompare(targetKey(b))); + if (new Set(normalized.map(targetKey)).size !== normalized.length) { + throw new PublicEvidenceValidationError("revocation_target_duplicate", "revocation targets must be unique"); + } + return normalized; +} + +function samePublisher(a: PublicPublisherV1, b: PublicPublisherV1): boolean { + return a.algorithm === b.algorithm && a.keyId === b.keyId && a.publicKey === b.publicKey; +} + +function validateTargetsAgainstBundle(targets: readonly PublicRevocationTargetV1[], bundle: PublicEvidenceBundleV1): boolean { + const records = new Set(bundle.records.map((record) => record.recordId)); + return targets.every((target) => target.kind === "bundle" ? target.id === bundle.bundleId : records.has(target.id)); +} + +function revocationPayload( + issuedDayUtc: string, + publisher: PublicPublisherV1, + targets: PublicRevocationTargetV1[], + reason: PublicRevocationReasonV1, +): Record { + return { + schemaVersion: PUBLIC_EVIDENCE_REVOCATION_SCHEMA_VERSION, + issuedDayUtc, + publisher, + targets, + reason, + }; +} + +export function createPublicEvidenceRevocation(input: { + configDir?: string; + targetBundle: PublicEvidenceBundleV1; + issuedDayUtc: string; + targets: PublicRevocationTargetV1[]; + reason: PublicRevocationReasonV1; +}): PublicEvidenceRevocationV1 { + // Validate the target and every caller-controlled field before touching publisher state. + if (verifyPublicEvidenceBundle(input.targetBundle).status !== "cryptographically_valid") { + throw new PublicEvidenceValidationError("revocation_target", "revocation target bundle is not cryptographically valid"); + } + if (!validDay(input.issuedDayUtc)) { + throw new PublicEvidenceValidationError("revocation_day", "issuedDayUtc is invalid"); + } + if (!REASONS.has(input.reason)) { + throw new PublicEvidenceValidationError("revocation_reason", "unsupported revocation reason"); + } + const targets = canonicalTargets(input.targets); + if (!validateTargetsAgainstBundle(targets, input.targetBundle)) { + throw new PublicEvidenceValidationError("revocation_target", "revocation target is unknown to target bundle"); + } + + const handle = loadExistingPublicPublisher(input.configDir); + if (!handle || !samePublisher(handle.publisher, input.targetBundle.publisher)) { + throw new PublicEvidenceValidationError( + "revocation_publisher", + "revocation requires the existing publisher key that signed the target bundle", + ); + } + const revocationId = publicEvidenceId( + "revocation", + revocationPayload(input.issuedDayUtc, handle.publisher, targets, input.reason), + ); + const signature = signPublicPublisherDigest(handle, revocationId); + return Object.freeze({ + schemaVersion: PUBLIC_EVIDENCE_REVOCATION_SCHEMA_VERSION, + revocationId, + issuedDayUtc: input.issuedDayUtc, + publisher: handle.publisher, + targets, + reason: input.reason, + signature: Object.freeze({ algorithm: "ed25519" as const, signedDigest: revocationId, signature }), + }); +} + +export function verifyPublicEvidenceRevocation( + raw: unknown, + targetBundle: PublicEvidenceBundleV1, +): PublicRevocationVerificationResult { + try { + if (!isPlainObject(raw) || !closedKeys(raw, [ + "schemaVersion", "revocationId", "issuedDayUtc", "publisher", "targets", "reason", "signature", + ])) { + return { status: "schema_rejected", detail: "closed revocation schema mismatch" }; + } + if ( + raw.schemaVersion !== PUBLIC_EVIDENCE_REVOCATION_SCHEMA_VERSION + || !validId(raw.revocationId) + || !validDay(raw.issuedDayUtc) + || !REASONS.has(raw.reason as PublicRevocationReasonV1) + ) { + return { status: "schema_rejected", detail: "revocation version/id/day/reason invalid" }; + } + if ( + !isPlainObject(raw.publisher) + || !closedKeys(raw.publisher, ["algorithm", "keyId", "publicKey"]) + || raw.publisher.algorithm !== "ed25519" + || !validId(raw.publisher.keyId) + || typeof raw.publisher.publicKey !== "string" + || raw.publisher.publicKey.length > 1024 + ) { + return { status: "schema_rejected", detail: "revocation publisher invalid" }; + } + const publicKeyBytes = Buffer.from(raw.publisher.publicKey, "base64"); + if (publicKeyBytes.toString("base64") !== raw.publisher.publicKey) { + return { status: "schema_rejected", detail: "revocation publisher key is non-canonical" }; + } + const publisher: PublicPublisherV1 = { + algorithm: "ed25519", + keyId: raw.publisher.keyId, + publicKey: raw.publisher.publicKey, + }; + if (publicEvidenceId("publisher_key", { algorithm: "ed25519", publicKey: publisher.publicKey }) !== publisher.keyId) { + return { status: "schema_rejected", detail: "revocation publisher key id mismatch" }; + } + if (!samePublisher(publisher, targetBundle.publisher)) { + return { status: "publisher_mismatch", detail: "revocation publisher does not match target bundle" }; + } + if (!Array.isArray(raw.targets) || raw.targets.length === 0 || raw.targets.length > MAX_TARGETS) { + return { status: "schema_rejected", detail: "revocation targets invalid" }; + } + const targets: PublicRevocationTargetV1[] = []; + for (const [index, value] of raw.targets.entries()) { + if ( + !isPlainObject(value) + || !closedKeys(value, ["kind", "id"]) + || (value.kind !== "bundle" && value.kind !== "record") + || !validId(value.id) + ) { + return { status: "schema_rejected", detail: `revocation target ${index} invalid` }; + } + targets.push({ kind: value.kind, id: value.id }); + } + const canonical = canonicalTargets(targets); + if (canonical.some((target, index) => target.kind !== targets[index]!.kind || target.id !== targets[index]!.id)) { + return { status: "schema_rejected", detail: "revocation targets must be sorted" }; + } + if (!validateTargetsAgainstBundle(targets, targetBundle)) { + return { status: "unknown_target", detail: "revocation target not present in target bundle" }; + } + if ( + !isPlainObject(raw.signature) + || !closedKeys(raw.signature, ["algorithm", "signedDigest", "signature"]) + || raw.signature.algorithm !== "ed25519" + || raw.signature.signedDigest !== raw.revocationId + || typeof raw.signature.signature !== "string" + ) { + return { status: "schema_rejected", detail: "revocation signature schema invalid" }; + } + const expected = publicEvidenceId( + "revocation", + revocationPayload(raw.issuedDayUtc, publisher, targets, raw.reason as PublicRevocationReasonV1), + ); + if (expected !== raw.revocationId) { + return { status: "digest_invalid", detail: "revocation id does not match canonical bytes" }; + } + const key = createPublicKey({ key: publicKeyBytes, type: "spki", format: "der" }); + if (key.asymmetricKeyType !== "ed25519") { + return { status: "signature_invalid", detail: "revocation publisher key is not Ed25519" }; + } + const signatureBytes = Buffer.from(raw.signature.signature, "base64"); + if (signatureBytes.toString("base64") !== raw.signature.signature) { + return { status: "signature_invalid", detail: "revocation signature is non-canonical" }; + } + if (!verifyBytes(null, Buffer.from(raw.revocationId, "hex"), key, signatureBytes)) { + return { status: "signature_invalid", detail: "revocation signature invalid" }; + } + return { + status: "cryptographically_valid", + revocation: { + schemaVersion: PUBLIC_EVIDENCE_REVOCATION_SCHEMA_VERSION, + revocationId: raw.revocationId, + issuedDayUtc: raw.issuedDayUtc, + publisher, + targets, + reason: raw.reason as PublicRevocationReasonV1, + signature: { + algorithm: "ed25519", + signedDigest: raw.revocationId, + signature: raw.signature.signature, + }, + }, + }; + } catch (error) { + return { status: "schema_rejected", detail: error instanceof Error ? error.message : String(error) }; + } +} diff --git a/src/lab/public/signature.ts b/src/lab/public/signature.ts new file mode 100644 index 000000000..ba4d62cd0 --- /dev/null +++ b/src/lab/public/signature.ts @@ -0,0 +1,187 @@ +import { + createPrivateKey, + createPublicKey, + generateKeyPairSync, + sign as signBytes, + verify as verifyBytes, +} from "node:crypto"; +import { ensureLabDirs, labPublicPublisherKeyPath } from "../paths"; +import { + buildPublicEvidenceBundle, + canonicalPublicEvidenceContent, + expectedPublicBundleIdentityFromNormalized, + normalizePublicEvidenceContent, + type BuildPublicEvidenceBundleInput, +} from "./bundle"; +import { validatePublicEvidenceAuthorities } from "./community-authority"; +import { 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; + +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); + const pem = readPrivateRegularFile(path, { + 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, + }).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 000000000..8fb0a9617 --- /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 000000000..fe7599cb6 --- /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 000000000..9adc8727d --- /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 000000000..7792753da --- /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 000000000..d7930404d --- /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/src/server/management/config-routes.ts b/src/server/management/config-routes.ts index 0306a02f4..2e171587b 100644 --- a/src/server/management/config-routes.ts +++ b/src/server/management/config-routes.ts @@ -134,6 +134,7 @@ function publicVisionSidecarSettings( export async function handleConfigRoutes(ctx: ManagementContext): Promise { const { req, url, config, deps, convergeCodexCatalog, syncClaudeAgentDefsBestEffort } = ctx; + const readStartupHealth = deps.getCachedStartupHealth ?? getCachedStartupHealth; if (url.pathname === "/api/config" && req.method === "GET") { return jsonResponse(safeConfigDTO(config)); } @@ -189,7 +190,7 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise void; toggleDefaultModeRequestUserInput?: (enabled: boolean) => void; createManagementConvergeCodex?: (config: Readonly) => ConvergeCodex; + /** Startup-health seam keeps route tests from launching platform probes. */ + getCachedStartupHealth?: (config: Pick) => Promise; /** * Persistence seam for route-level tests. Production leaves this unset and uses * `saveConfigPreservingClaudeCode`; tests that pass an in-memory fixture config diff --git a/src/server/management/lab-routes.ts b/src/server/management/lab-routes.ts index 5c0929e47..e11568e5d 100644 --- a/src/server/management/lab-routes.ts +++ b/src/server/management/lab-routes.ts @@ -42,6 +42,15 @@ import { queryLabVerdicts, queryPassiveProductionSignals, } from "../../lab/query"; +import { + exportLocalPublicEvidence, + importCommunityEvidenceValue, + listCommunityEvidenceContext, + parseStrictPublicJson, + previewLocalPublicEvidence, + summarizePublicEvidenceVerification, + PublicEvidenceValidationError, +} from "../../lab/public"; import { jsonResponse } from "../auth-cors"; import type { ManagementContext } from "./context"; @@ -155,7 +164,7 @@ function parseExecutionMode(raw: string | null, ctx: ManagementContext): Executi if (!raw) return undefined; const trimmed = raw.trim(); if (!EXECUTION_MODES.includes(trimmed as ExecutionMode)) { - return errorResponse("invalid_execution_mode", "executionMode must be a supported execution mode", 400, ctx); + return errorResponse("invalid_execution_mode", "executionMode must be a supported lab execution mode", 400, ctx); } return trimmed as ExecutionMode; } @@ -186,9 +195,151 @@ function paginatedEnvelope(page: { items: T[]; nextCursor?: string; hasMore: }; } +const MAX_PUBLIC_REQUEST_BYTES = 2 * 1024 * 1024; + +async function readBoundedPublicJson(req: Request): Promise { + const lengthRaw = req.headers.get("content-length"); + if (lengthRaw) { + const length = Number(lengthRaw); + if (!Number.isFinite(length) || length < 0 || length > MAX_PUBLIC_REQUEST_BYTES) { + throw new PublicEvidenceValidationError( + "public_request_too_large", + "public evidence request exceeds 2 MiB", + ); + } + } + if (!req.body) { + throw new PublicEvidenceValidationError("public_request_body", "JSON body is required"); + } + const reader = req.body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > MAX_PUBLIC_REQUEST_BYTES) { + await reader.cancel(); + throw new PublicEvidenceValidationError( + "public_request_too_large", + "public evidence request exceeds 2 MiB", + ); + } + chunks.push(value); + } + const bytes = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return parseStrictPublicJson(bytes, "public evidence request"); +} + +function publicEventIds(raw: unknown): string[] { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + throw new PublicEvidenceValidationError("public_request_body", "request body must be an object"); + } + const keys = Object.keys(raw); + if (keys.length !== 1 || keys[0] !== "eventIds") { + throw new PublicEvidenceValidationError("public_request_body", "only eventIds is accepted"); + } + const eventIds = (raw as { eventIds?: unknown }).eventIds; + if (!Array.isArray(eventIds) || !eventIds.every((value) => typeof value === "string")) { + throw new PublicEvidenceValidationError("public_request_body", "eventIds must be a string array"); + } + return eventIds as string[]; +} + +function publicBundleValue(raw: unknown): unknown { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + throw new PublicEvidenceValidationError("public_request_body", "request body must be an object"); + } + const keys = Object.keys(raw); + if (keys.length !== 1 || keys[0] !== "bundle") { + throw new PublicEvidenceValidationError("public_request_body", "only bundle is accepted"); + } + return (raw as { bundle?: unknown }).bundle; +} + +function publicErrorResponse(err: unknown, ctx: ManagementContext): Response { + if (err instanceof PublicEvidenceValidationError) { + return errorResponse(err.code, err.message, 400, ctx); + } + const projected = projectionErrorResponse(err, ctx); + if (projected) return projected; + return errorResponse("public_evidence_internal", "internal public evidence failure", 500, ctx); +} + export async function handleLabRoutes(ctx: ManagementContext): Promise { const { url, req, config } = ctx; if (!url.pathname.startsWith("/api/lab")) return null; + + if (req.method === "GET" && url.pathname === "/api/lab/public/community") { + try { + return jsonResponse(listCommunityEvidenceContext(), 200, req, config); + } catch (err) { + return publicErrorResponse(err, ctx); + } + } + + if (req.method === "POST") { + if (url.pathname === "/api/lab/public/preview") { + try { + const body = await readBoundedPublicJson(req); + return jsonResponse( + previewLocalPublicEvidence({ eventIds: publicEventIds(body) }), + 200, + req, + config, + ); + } catch (err) { + return publicErrorResponse(err, ctx); + } + } + if (url.pathname === "/api/lab/public/export") { + try { + const body = await readBoundedPublicJson(req); + return jsonResponse( + exportLocalPublicEvidence({ eventIds: publicEventIds(body) }), + 200, + req, + config, + ); + } catch (err) { + return publicErrorResponse(err, ctx); + } + } + if (url.pathname === "/api/lab/public/verify") { + try { + const body = await readBoundedPublicJson(req); + const result = summarizePublicEvidenceVerification(publicBundleValue(body)); + return jsonResponse( + result, + result.status === "cryptographically_valid" ? 200 : 400, + req, + config, + ); + } catch (err) { + return publicErrorResponse(err, ctx); + } + } + if (url.pathname === "/api/lab/public/community/import") { + try { + const body = await readBoundedPublicJson(req); + return jsonResponse( + importCommunityEvidenceValue(publicBundleValue(body)), + 200, + req, + config, + ); + } catch (err) { + return publicErrorResponse(err, ctx); + } + } + return null; + } + if (req.method !== "GET") return null; if (url.pathname === "/api/lab/status") { @@ -213,10 +364,7 @@ export async function handleLabRoutes(ctx: ManagementContext): Promise { expect(slugs).not.toContain("offline/disabled-model"); expect(slugs).not.toContain("removed/ghost"); expect(slugs).toContain("cursor/composer-2.5"); - }); + }, 15_000); test("drops legacy-signature ghost rows in both gather branches", () => { const catalogPath = join(codexHome, "catalog.json"); diff --git a/tests/helpers/startup-health.ts b/tests/helpers/startup-health.ts new file mode 100644 index 000000000..f7cb47d8c --- /dev/null +++ b/tests/helpers/startup-health.ts @@ -0,0 +1,33 @@ +import type { StartupHealth } from "../../src/codex/autostart-health"; + +export function startupHealthFixture(overrides: Partial = {}): StartupHealth { + return { + status: "native", + routingKind: "native", + routingInjected: false, + localRoutingDependency: false, + autostartEnabled: false, + rebootSafe: true, + protection: "none", + serviceInstalled: false, + serviceViable: false, + serviceEnabled: false, + serviceRunning: false, + serviceStale: false, + serviceConflict: false, + shimInstalled: false, + shimHealthy: false, + shimCoverage: "none", + serviceSupported: true, + platform: process.platform, + diagnosticStale: false, + recommendedCommand: null, + commands: { + installService: "ocx service install", + repairService: "ocx service repair", + installShim: "ocx codex-shim install", + restoreNative: "ocx restore", + }, + ...overrides, + }; +} diff --git a/tests/lab-community-evidence.test.ts b/tests/lab-community-evidence.test.ts new file mode 100644 index 000000000..1f94905c6 --- /dev/null +++ b/tests/lab-community-evidence.test.ts @@ -0,0 +1,223 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + LAB_EVENT_SCHEMA_VERSION, + LAB_PRODUCER, + assignEventId, + labLedgerPath, + labSqlitePath, + purgeSensitiveEvidence, + subjectIdForSubject, + type ObservationEvent, + type ProtocolSubjectV1, +} from "../src/lab"; +import { + buildPublicEvidenceBundle, + createPublicEvidenceRevocation, + getOrCreatePublicPublisher, + importCommunityEvidenceBundle, + importCommunityEvidenceRevocation, + listCommunityEvidence, + projectPublicEvidence, + publicEvidenceId, + signPublicEvidenceBundle, + signPublicPublisherDigest, + verifyPublicEvidenceRevocation, + writePublicEvidenceBundle, +} from "../src/lab/public"; + +const roots: string[] = []; +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +function configDir(prefix = "ocx-cl10-community-"): string { + const root = mkdtempSync(join(tmpdir(), prefix)); + roots.push(root); + return root; +} + +function hex(seed: string): string { + return Bun.CryptoHasher.hash("sha256", seed, "hex"); +} + +function assertionsForScenario(scenarioId: string) { + const ids = scenarioId === "responses-core.protocol.sse-framing" + ? ["events", "text", "terminal"] + : ["method", "message", "temperature"]; + return ids.map((id) => ({ id, operator: "equals", required: true, passed: true })); +} + +function protocolObservation(scenarioId = "responses-core.protocol.request-shape"): ObservationEvent { + const subject: ProtocolSubjectV1 = { + subjectSchemaVersion: 1, + subjectKind: "protocol", + opencodexCompatibilityVersion: "2.13.0", + effectiveAdapter: "openai-chat", + inboundProtocol: "openai-responses", + upstreamProtocol: "openai-chat", + surface: "responses-http", + behaviorFingerprint: hex("PRIVATE-community-behavior"), + }; + return assignEventId({ + schemaVersion: LAB_EVENT_SCHEMA_VERSION, + eventKind: "observation" as const, + recordedAt: Date.UTC(2026, 7, 12, 14, 37, 48), + producer: LAB_PRODUCER, + producerVersion: "2.13.0", + evidenceLayer: "protocol_conformance" as const, + scenarioId, + scenarioVersion: "1.0.0", + scenarioManifestDigest: hex("scenario"), + suiteId: "responses-core", + suiteVersion: "1.0.0", + suiteManifestDigest: hex("suite"), + fixtureDigests: [hex("fixture")], + subject, + subjectId: subjectIdForSubject(subject), + startedAt: Date.UTC(2026, 7, 12, 14, 37, 40), + completedAt: Date.UTC(2026, 7, 12, 14, 37, 41), + executionMode: "fixture" as const, + attempt: 1, + limits: { totalTimeoutMs: 1000 }, + outcome: "pass" as const, + assertions: assertionsForScenario(scenarioId), + environment: {}, + artifactRefs: [], + }) as ObservationEvent; +} + +function signedBundle(config: string, scenarioId?: string) { + const projected = projectPublicEvidence({ + createdDayUtc: "2026-08-12", + records: [{ observation: protocolObservation(scenarioId), verdict: "VERIFIED" }], + }); + return signPublicEvidenceBundle({ + records: projected.bundle.records, + artifacts: projected.bundle.artifacts, + createdDayUtc: projected.bundle.createdDayUtc, + configDir: config, + }); +} + +function signedUnreviewedScenarioBundle(config: string) { + const projected = projectPublicEvidence({ + records: [{ observation: protocolObservation(), verdict: "VERIFIED" }], + }); + const baseRecord = projected.bundle.records[0]; + if (!baseRecord) throw new Error("expected reviewed source record"); + const { recordId: _recordId, ...baseFields } = baseRecord; + const withoutRecordId = { ...baseFields, scenarioId: "private.unknown.scenario" }; + const record = { recordId: publicEvidenceId("record", withoutRecordId), ...withoutRecordId }; + const handle = getOrCreatePublicPublisher(config); + const unsigned = buildPublicEvidenceBundle({ + records: [record], + artifacts: [], + createdDayUtc: projected.bundle.createdDayUtc, + publisher: handle.publisher, + }); + return { + ...unsigned, + signature: { + algorithm: "ed25519" as const, + signedDigest: unsigned.bundleDigest, + signature: signPublicPublisherDigest(handle, unsigned.bundleDigest), + }, + }; +} + +describe("CL-10 community quarantine", () => { + test("imports valid signed evidence without touching canonical Lab authority", () => { + const publisherDir = configDir("ocx-cl10-publisher-"); + const consumerDir = configDir("ocx-cl10-consumer-"); + const bundle = signedBundle(publisherDir); + const imported = importCommunityEvidenceBundle(bundle, consumerDir); + expect(imported).toMatchObject({ created: true, status: "cryptographically_valid", bundleId: bundle.bundleId }); + expect(existsSync(labLedgerPath(consumerDir))).toBe(false); + expect(existsSync(labSqlitePath(consumerDir))).toBe(false); + expect(listCommunityEvidence(consumerDir)).toEqual([expect.objectContaining({ + bundleId: bundle.bundleId, + status: "cryptographically_valid", + activeRecordCount: 1, + revokedRecordCount: 0, + })]); + expect(importCommunityEvidenceBundle(bundle, consumerDir).created).toBe(false); + }); + + test("rejects cryptographically valid but unknown scenario authority", () => { + const publisherDir = configDir("ocx-cl10-publisher-"); + const consumerDir = configDir("ocx-cl10-consumer-"); + const bundle = signedUnreviewedScenarioBundle(publisherDir); + expect(() => importCommunityEvidenceBundle(bundle, consumerDir)).toThrow(/authority/i); + expect(listCommunityEvidence(consumerDir)).toEqual([]); + }); + + test("same-key revocation is verified, idempotent, and removes records from default community context", () => { + const publisherDir = configDir("ocx-cl10-publisher-"); + const consumerDir = configDir("ocx-cl10-consumer-"); + const bundle = signedBundle(publisherDir); + importCommunityEvidenceBundle(bundle, consumerDir); + + const revocation = createPublicEvidenceRevocation({ + configDir: publisherDir, + targetBundle: bundle, + issuedDayUtc: "2026-08-12", + reason: "evidence_invalidated", + targets: [{ kind: "record", id: bundle.records[0]!.recordId }], + }); + expect(verifyPublicEvidenceRevocation(revocation, bundle).status).toBe("cryptographically_valid"); + expect(importCommunityEvidenceRevocation(revocation, consumerDir).created).toBe(true); + expect(importCommunityEvidenceRevocation(revocation, consumerDir).created).toBe(false); + expect(listCommunityEvidence(consumerDir)[0]).toMatchObject({ activeRecordCount: 0, revokedRecordCount: 1 }); + }); + + test("rejects cross-key revocation and conflicting same-id bytes", () => { + const publisherDir = configDir("ocx-cl10-publisher-"); + const otherDir = configDir("ocx-cl10-other-"); + const consumerDir = configDir("ocx-cl10-consumer-"); + const bundle = signedBundle(publisherDir); + importCommunityEvidenceBundle(bundle, consumerDir); + expect(() => createPublicEvidenceRevocation({ + configDir: otherDir, + targetBundle: bundle, + issuedDayUtc: "2026-08-12", + reason: "publisher_retracted", + targets: [{ kind: "bundle", id: bundle.bundleId }], + })).toThrow(/publisher/i); + + const revocation = createPublicEvidenceRevocation({ + configDir: publisherDir, + targetBundle: bundle, + issuedDayUtc: "2026-08-12", + reason: "publisher_retracted", + targets: [{ kind: "bundle", id: bundle.bundleId }], + }); + importCommunityEvidenceRevocation(revocation, consumerDir); + const conflict = { ...revocation, issuedDayUtc: "2026-08-13" }; + expect(() => importCommunityEvidenceRevocation(conflict, consumerDir)).toThrow(); + }); + + test("sensitive export purge removes local exports and local community copies but preserves third-party bundles", () => { + const consumerDir = configDir("ocx-cl10-consumer-"); + const thirdPartyDir = configDir("ocx-cl10-third-party-"); + const localBundle = signedBundle(consumerDir); + const localStored = writePublicEvidenceBundle(localBundle, consumerDir); + importCommunityEvidenceBundle(localBundle, consumerDir); + + const thirdPartyBundle = signedBundle(thirdPartyDir, "responses-core.protocol.sse-framing"); + importCommunityEvidenceBundle(thirdPartyBundle, consumerDir); + expect(listCommunityEvidence(consumerDir)).toHaveLength(2); + + purgeSensitiveEvidence({ + configDir: consumerDir, + targetArtifactDigests: [hex("sensitive-purge-target")], + purgeActions: ["export"], + recordedAt: Date.UTC(2026, 7, 12, 18, 0, 0), + }); + + expect(existsSync(localStored)).toBe(false); + expect(listCommunityEvidence(consumerDir).map((row) => row.bundleId)).toEqual([thirdPartyBundle.bundleId]); + }); +}); \ No newline at end of file diff --git a/tests/lab-community-publisher-continuity.test.ts b/tests/lab-community-publisher-continuity.test.ts new file mode 100644 index 000000000..5652379e0 --- /dev/null +++ b/tests/lab-community-publisher-continuity.test.ts @@ -0,0 +1,120 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + LAB_EVENT_SCHEMA_VERSION, + LAB_PRODUCER, + assignEventId, + subjectIdForSubject, + type ObservationEvent, + type ProtocolSubjectV1, +} from "../src/lab"; +import { + createPublicEvidenceRevocation, + importCommunityEvidenceBundle, + importCommunityEvidenceRevocation, + listCommunityEvidence, + projectPublicEvidence, + signPublicEvidenceBundle, +} from "../src/lab/public"; + +const roots: string[] = []; +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +function configDir(prefix: string): string { + const root = mkdtempSync(join(tmpdir(), prefix)); + roots.push(root); + return root; +} + +function hex(seed: string): string { + return Bun.CryptoHasher.hash("sha256", seed, "hex"); +} + +function observation(): ObservationEvent { + const subject: ProtocolSubjectV1 = { + subjectSchemaVersion: 1, + subjectKind: "protocol", + opencodexCompatibilityVersion: "2.13.0", + effectiveAdapter: "openai-chat", + inboundProtocol: "openai-responses", + upstreamProtocol: "openai-chat", + surface: "responses-http", + behaviorFingerprint: hex("PRIVATE-publisher-continuity"), + }; + return assignEventId({ + schemaVersion: LAB_EVENT_SCHEMA_VERSION, + eventKind: "observation" as const, + recordedAt: Date.UTC(2026, 7, 12, 14, 37, 48), + producer: LAB_PRODUCER, + producerVersion: "2.13.0", + evidenceLayer: "protocol_conformance" as const, + scenarioId: "responses-core.protocol.request-shape", + scenarioVersion: "1.0.0", + scenarioManifestDigest: hex("scenario"), + suiteId: "responses-core", + suiteVersion: "1.0.0", + suiteManifestDigest: hex("suite"), + fixtureDigests: [hex("fixture")], + subject, + subjectId: subjectIdForSubject(subject), + startedAt: Date.UTC(2026, 7, 12, 14, 37, 40), + completedAt: Date.UTC(2026, 7, 12, 14, 37, 41), + executionMode: "fixture" as const, + attempt: 1, + limits: { totalTimeoutMs: 1000 }, + outcome: "pass" as const, + assertions: [ + { id: "method", operator: "equals", required: true, passed: true }, + { id: "message", operator: "equals", required: true, passed: true }, + { id: "temperature", operator: "equals", required: true, passed: true }, + ], + environment: {}, + artifactRefs: [], + }) as ObservationEvent; +} + +function projectedBundle() { + return projectPublicEvidence({ + createdDayUtc: "2026-08-12", + records: [{ observation: observation(), verdict: "VERIFIED" }], + }).bundle; +} + +describe("CL-10 publisher continuity", () => { + test("same content from two publishers coexists and revokes independently", () => { + const publisherA = configDir("ocx-cl10-publisher-a-"); + const publisherB = configDir("ocx-cl10-publisher-b-"); + const consumer = configDir("ocx-cl10-consumer-"); + const unsigned = projectedBundle(); + const bundleA = signPublicEvidenceBundle({ ...unsigned, configDir: publisherA }); + const bundleB = signPublicEvidenceBundle({ ...unsigned, configDir: publisherB }); + + expect(bundleA.bundleId).not.toBe(bundleB.bundleId); + expect(bundleA.publisher.keyId).not.toBe(bundleB.publisher.keyId); + expect(importCommunityEvidenceBundle(bundleA, consumer).created).toBe(true); + expect(importCommunityEvidenceBundle(bundleB, consumer).created).toBe(true); + + let summaries = listCommunityEvidence(consumer); + expect(summaries).toHaveLength(2); + expect(new Set(summaries.map((row) => row.publisherKeyId)).size).toBe(2); + + const revocationA = createPublicEvidenceRevocation({ + configDir: publisherA, + targetBundle: bundleA, + issuedDayUtc: "2026-08-12", + reason: "publisher_retracted", + targets: [{ kind: "bundle", id: bundleA.bundleId }], + }); + expect(importCommunityEvidenceRevocation(revocationA, consumer).created).toBe(true); + + summaries = listCommunityEvidence(consumer); + const rowA = summaries.find((row) => row.publisherKeyId === bundleA.publisher.keyId)!; + const rowB = summaries.find((row) => row.publisherKeyId === bundleB.publisher.keyId)!; + expect(rowA).toMatchObject({ activeRecordCount: 0, revokedRecordCount: 1 }); + expect(rowB).toMatchObject({ activeRecordCount: 1, revokedRecordCount: 0 }); + }); +}); \ No newline at end of file diff --git a/tests/lab-private-file-durability.test.ts b/tests/lab-private-file-durability.test.ts new file mode 100644 index 000000000..28e44da15 --- /dev/null +++ b/tests/lab-private-file-durability.test.ts @@ -0,0 +1,53 @@ +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 is reported and retry becomes idempotent", () => { + 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); + + setPrivateFileCommitFaultForTests(null); + expect(publishPrivateFileExclusive(finalPath, bytes)).toEqual({ created: false }); + 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-api-json.test.ts b/tests/lab-public-api-json.test.ts new file mode 100644 index 000000000..79d6c2a95 --- /dev/null +++ b/tests/lab-public-api-json.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from "bun:test"; +import { handleManagementAPI } from "../src/server/management-api"; +import type { OcxConfig } from "../src/types"; +import { ManagementRequest } from "./helpers/management-auth"; + +const config = { port: 0, defaultProvider: "openai-apikey", providers: {} } as OcxConfig; + +describe("CL-10 management public JSON boundary", () => { + test("rejects duplicate decoded object keys before request object construction", async () => { + const req = new ManagementRequest("http://127.0.0.1/api/lab/public/community/import", { + method: "POST", + headers: { "content-type": "application/json" }, + body: '{"bundle":{},"\\u0062undle":{}}', + }); + + const response = await handleManagementAPI(req, new URL(req.url), config, { + refreshCodexCatalog: async () => {}, + }); + + expect(response).not.toBeNull(); + expect(response!.status).toBe(400); + expect(await response!.json()).toMatchObject({ + error: { code: "duplicate_json_key" }, + }); + }); +}); diff --git a/tests/lab-public-artifact-policy.test.ts b/tests/lab-public-artifact-policy.test.ts new file mode 100644 index 000000000..f5f227bc7 --- /dev/null +++ b/tests/lab-public-artifact-policy.test.ts @@ -0,0 +1,36 @@ +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 { + labPublicPublisherKeyPath, + publicEvidenceId, + signPublicEvidenceBundle, +} from "../src/lab"; + +const roots: string[] = []; +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +test("CL-10 local signing rejects artifact bytes without reviewed public_export authority", () => { + const configDir = mkdtempSync(join(tmpdir(), "ocx-cl10-artifact-policy-")); + roots.push(configDir); + 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, + })).toThrow(/public_export/i); + + expect(existsSync(labPublicPublisherKeyPath(configDir))).toBe(false); +}); diff --git a/tests/lab-public-deep-review-regressions.test.ts b/tests/lab-public-deep-review-regressions.test.ts new file mode 100644 index 000000000..153324e03 --- /dev/null +++ b/tests/lab-public-deep-review-regressions.test.ts @@ -0,0 +1,248 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { jcsStringify } from "../src/lab/conformance/jcs"; +import { labCommunityDir, labPublicPublisherKeyPath } from "../src/lab/paths"; +import { + buildPublicEvidenceBundle, + createPublicEvidenceRevocation, + getOrCreatePublicPublisher, + importCommunityEvidenceBundle, + importCommunityEvidenceRevocation, + listCommunityEvidence, + parseStrictPublicJson, + publicEvidenceId, + signPublicEvidenceBundle, + signPublicPublisherDigest, + validatePublicEvidenceRecordPrivacy, + verifyPublicEvidenceBundle, + type PublicArtifactV1, + type PublicEvidenceBundleV1, + type PublicEvidenceRecordV1, +} from "../src/lab/public"; + +const roots: string[] = []; +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +function configDir(prefix: string): string { + const root = mkdtempSync(join(tmpdir(), prefix)); + roots.push(root); + return root; +} + +function fixedRecord(overrides: Partial> = {}): 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 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 }, + ], + ...overrides, + }; + return { recordId: publicEvidenceId("record", withoutRecordId), ...withoutRecordId }; +} + +function rebuildRecord(record: PublicEvidenceRecordV1, patch: Partial>): PublicEvidenceRecordV1 { + const { recordId: _recordId, ...base } = record; + const withoutRecordId = { ...base, ...patch }; + return { recordId: publicEvidenceId("record", withoutRecordId), ...withoutRecordId } as PublicEvidenceRecordV1; +} + +function signArbitraryBundle(input: { + configDir: string; + records: PublicEvidenceRecordV1[]; + artifacts?: PublicArtifactV1[]; + createdDayUtc?: string; +}): PublicEvidenceBundleV1 { + const handle = getOrCreatePublicPublisher(input.configDir); + const unsigned = buildPublicEvidenceBundle({ + records: input.records, + artifacts: input.artifacts ?? [], + createdDayUtc: input.createdDayUtc ?? "2026-08-12", + publisher: handle.publisher, + }); + return { + ...unsigned, + signature: { + algorithm: "ed25519", + signedDigest: unsigned.bundleDigest, + signature: signPublicPublisherDigest(handle, unsigned.bundleDigest), + }, + }; +} + +function publicArtifact(content: string): PublicArtifactV1 { + const contentBase64 = Buffer.from(content, "utf8").toString("base64"); + const body = { + artifactClass: "verifier_summary", + mediaType: "text/plain", + byteCount: Buffer.from(contentBase64, "base64").byteLength, + contentBase64, + }; + return { artifactId: publicEvidenceId("artifact", body), ...body }; +} + +describe("CL-10 deep-review trust regressions", () => { + test("verification rejects a signed bundle whose canonical record order was changed", () => { + const publisher = configDir("ocx-cl10-order-"); + const first = fixedRecord(); + const second = fixedRecord({ observedDayUtc: "2026-08-13" }); + const bundle = signArbitraryBundle({ configDir: publisher, records: [first, second] }); + expect(bundle.records).toHaveLength(2); + + const reordered = { ...bundle, records: [...bundle.records].reverse() }; + expect(reordered.records.map(row => row.recordId)).not.toEqual(bundle.records.map(row => row.recordId)); + expect(verifyPublicEvidenceBundle(reordered)).toEqual({ status: "schema_rejected" }); + }); + + test("community import rejects artifact bytes until reviewed public_export authority exists", () => { + const publisher = configDir("ocx-cl10-artifact-publisher-"); + const consumer = configDir("ocx-cl10-artifact-consumer-"); + const bundle = signArbitraryBundle({ + configDir: publisher, + records: [fixedRecord()], + artifacts: [publicArtifact("synthetic-safe-content")], + }); + expect(verifyPublicEvidenceBundle(bundle)).toEqual({ status: "cryptographically_valid" }); + expect(() => importCommunityEvidenceBundle(bundle, consumer)).toThrow(/public_export|artifact.*authority/i); + }); + + test("record revocation remains effective when the same publisher later imports another bundle containing that record", () => { + const publisher = configDir("ocx-cl10-revoke-publisher-"); + const consumer = configDir("ocx-cl10-revoke-consumer-"); + const record = fixedRecord(); + const first = signPublicEvidenceBundle({ records: [record], artifacts: [], createdDayUtc: "2026-08-12", configDir: publisher }); + const second = signPublicEvidenceBundle({ records: [record], artifacts: [], createdDayUtc: "2026-08-13", configDir: publisher }); + expect(first.bundleId).not.toBe(second.bundleId); + + importCommunityEvidenceBundle(first, consumer); + const revocation = createPublicEvidenceRevocation({ + configDir: publisher, + targetBundle: first, + issuedDayUtc: "2026-08-13", + reason: "evidence_invalidated", + targets: [{ kind: "record", id: record.recordId }], + }); + importCommunityEvidenceRevocation(revocation, consumer); + importCommunityEvidenceBundle(second, consumer); + + const summaries = listCommunityEvidence(consumer); + expect(summaries.map((row) => row.bundleId)).toEqual([first.bundleId, second.bundleId].sort()); + expect(summaries).toEqual(expect.arrayContaining([ + expect.objectContaining({ bundleId: first.bundleId, activeRecordCount: 0, revokedRecordCount: 1 }), + expect.objectContaining({ bundleId: second.bundleId, activeRecordCount: 0, revokedRecordCount: 1 }), + ])); + }); + + test("invalid signing input fails before publisher identity is created", () => { + const home = configDir("ocx-cl10-invalid-sign-"); + expect(() => signPublicEvidenceBundle({ + records: [fixedRecord()], + artifacts: [], + createdDayUtc: "not-a-day", + configDir: home, + })).toThrow(/day/i); + expect(existsSync(labPublicPublisherKeyPath(home))).toBe(false); + }); + + test("foreign revocation attempt does not create a new publisher identity", () => { + const publisher = configDir("ocx-cl10-foreign-target-"); + const attacker = configDir("ocx-cl10-foreign-revoker-"); + const target = signPublicEvidenceBundle({ + records: [fixedRecord()], artifacts: [], createdDayUtc: "2026-08-12", configDir: publisher, + }); + expect(() => createPublicEvidenceRevocation({ + configDir: attacker, + targetBundle: target, + issuedDayUtc: "2026-08-13", + reason: "publisher_retracted", + targets: [{ kind: "bundle", id: target.bundleId }], + })).toThrow(/publisher|key/i); + expect(existsSync(labPublicPublisherKeyPath(attacker))).toBe(false); + }); + + test("JCS rejects lone UTF-16 surrogate code units", () => { + expect(() => jcsStringify("\uDEAD")).toThrow(/unicode|surrogate/i); + expect(() => jcsStringify({ ["\uDEAD"]: true })).toThrow(/unicode|surrogate/i); + }); + + test("reviewed assertion authority requires exact unique assertion coverage", () => { + const missingHome = configDir("ocx-cl10-assert-missing-"); + const duplicateHome = configDir("ocx-cl10-assert-duplicate-"); + const base = fixedRecord(); + const missing = rebuildRecord(base, { assertions: [] }); + const duplicate = rebuildRecord(base, { assertions: [ + { id: "method", required: true, passed: true }, + { id: "message", required: true, passed: true }, + { id: "temperature", required: true, passed: true }, + { id: "method", required: true, passed: false }, + ] }); + + expect(() => signPublicEvidenceBundle({ + records: [missing], artifacts: [], createdDayUtc: "2026-08-12", configDir: missingHome, + })).toThrow(/assertion.*authority|missing.*assertion/i); + expect(existsSync(labPublicPublisherKeyPath(missingHome))).toBe(false); + + expect(() => signPublicEvidenceBundle({ + records: [duplicate], artifacts: [], createdDayUtc: "2026-08-12", configDir: duplicateHome, + })).toThrow(/assertion.*authority|duplicate.*assertion/i); + expect(existsSync(labPublicPublisherKeyPath(duplicateHome))).toBe(false); + }); + + test("community import enforces the cache file quota before creating another object", () => { + const publisher = configDir("ocx-cl10-cache-publisher-"); + const consumer = configDir("ocx-cl10-cache-consumer-"); + const community = labCommunityDir(consumer); + mkdirSync(community, { recursive: true, mode: 0o700 }); + for (let index = 0; index < 512; index += 1) { + writeFileSync(join(community, `occupied-${String(index).padStart(3, "0")}`), "x", { mode: 0o600 }); + } + const bundle = signPublicEvidenceBundle({ + records: [fixedRecord()], artifacts: [], createdDayUtc: "2026-08-12", configDir: publisher, + }); + expect(() => importCommunityEvidenceBundle(bundle, consumer)).toThrow(/cache.*bound|cache.*limit|capacity/i); + }); + + test("duplicate-key diagnostics are bounded and do not reflect attacker-controlled key contents", () => { + const key = `SECRET-${"x".repeat(64 * 1024)}`; + const raw = Buffer.from(`{${JSON.stringify(key)}:1,${JSON.stringify(key)}:2}`, "utf8"); + try { + parseStrictPublicJson(raw); + throw new Error("expected duplicate-key rejection"); + } catch (error) { + expect(error).toBeInstanceOf(Error); + const message = (error as Error).message; + expect(message.length).toBeLessThan(256); + expect(message).not.toContain("SECRET-"); + } + }); + + test("privacy scanner rejects unbracketed IPv6 literals", () => { + const base = fixedRecord(); + const subject = { ...base.subject, surface: "2001:db8::1" }; + const subjectId = publicEvidenceId("subject", subject); + const record = rebuildRecord(base, { subject, subjectId }); + expect(() => validatePublicEvidenceRecordPrivacy(record)).toThrow(/IP address|privacy/i); + }); +}); diff --git a/tests/lab-public-evidence.test.ts b/tests/lab-public-evidence.test.ts new file mode 100644 index 000000000..da4f67849 --- /dev/null +++ b/tests/lab-public-evidence.test.ts @@ -0,0 +1,350 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + LAB_EVENT_SCHEMA_VERSION, + LAB_PRODUCER, + assignEventId, + subjectIdForSubject, + type ObservationEvent, + type ProtocolSubjectV1, + type RouteSubjectV1, +} from "../src/lab"; +import { labPublicPublisherKeyPath } from "../src/lab/paths"; +import { + PUBLIC_ROUTE_REGISTRY_V1, + PublicEvidenceValidationError, + buildPublicEvidenceBundle, + getOrCreatePublicPublisher, + isPublicIncidentRef, + projectPublicEvidence, + projectPublicEvidenceRecord, + publicEvidenceId, + readPublicEvidenceBundle, + signPublicEvidenceBundle, + validatePublicEvidenceRecord, + validatePublicRouteRegistryManifest, + verifyPublicEvidenceBundle, + writePublicEvidenceBundle, +} from "../src/lab/public"; + +const HOMES: string[] = []; +const DEFAULT_COMPLETED_AT = Date.UTC(2026, 7, 12, 14, 37, 41); + +function tempHome(): string { + const dir = join(tmpdir(), `ocx-lab-public-${process.pid}-${Math.random().toString(16).slice(2)}`); + mkdirSync(dir, { recursive: true, mode: 0o700 }); + HOMES.push(dir); + return dir; +} + +afterEach(() => { + for (const dir of HOMES.splice(0)) { + try { + rmSync(dir, { recursive: true, force: true }); + } catch { + /* ignore */ + } + } +}); + +function hex(seed: string): string { + return Bun.CryptoHasher.hash("sha256", seed, "hex"); +} + +function protocolObservation(completedAt = DEFAULT_COMPLETED_AT): ObservationEvent { + const subject: ProtocolSubjectV1 = { + subjectSchemaVersion: 1, + subjectKind: "protocol", + opencodexCompatibilityVersion: "2.13.0", + effectiveAdapter: "openai-chat", + inboundProtocol: "openai-responses", + upstreamProtocol: "openai-chat", + surface: "responses-http", + behaviorFingerprint: hex("private-protocol-behavior"), + }; + const subjectId = subjectIdForSubject(subject); + return assignEventId({ + schemaVersion: LAB_EVENT_SCHEMA_VERSION, + eventKind: "observation" as const, + recordedAt: completedAt + 7_000, + producer: LAB_PRODUCER, + producerVersion: "2.13.0", + evidenceLayer: "protocol_conformance" as const, + scenarioId: "responses-core.protocol.request-shape", + scenarioVersion: "1.0.0", + scenarioManifestDigest: hex("scenario"), + suiteId: "responses-core", + suiteVersion: "1.0.0", + suiteManifestDigest: hex("suite"), + fixtureDigests: [hex("fixture")], + subject, + subjectId, + startedAt: completedAt - 1_000, + completedAt, + executionMode: "fixture" as const, + attempt: 1, + limits: { totalTimeoutMs: 1000 }, + outcome: "pass" as const, + assertions: [ + { + id: "method", + operator: "equals", + required: true, + passed: true, + expectedSummary: "CANARY-PRIVATE-EXPECTED", + observedSummary: "CANARY-PRIVATE-OBSERVED", + }, + { id: "message", operator: "equals", required: true, passed: true }, + { id: "temperature", operator: "equals", required: true, passed: true }, + ], + environment: { localPath: "C:\\Users\\private\\repo" }, + artifactRefs: [], + sourceRefs: ["request_1234567890", "decision_1234567890"], + }) as ObservationEvent; +} + +function routeObservation(completedAt = DEFAULT_COMPLETED_AT): ObservationEvent { + const subject: RouteSubjectV1 = { + subjectSchemaVersion: 1, + subjectKind: "route", + providerId: "openai", + providerInstanceFingerprint: hex("PRIVATE-provider-instance"), + clientModelId: "gpt-5.6-sol", + upstreamModelId: "gpt-5.6-sol", + effectiveAdapter: "openai-responses", + inboundProtocol: "openai-responses", + upstreamProtocol: "openai-responses", + surface: "responses-http", + opencodexCompatibilityVersion: "2.13.0", + behaviorFingerprint: hex("PRIVATE-route-behavior"), + endpointFingerprint: hex("PRIVATE-endpoint"), + dependencies: [], + }; + const subjectId = subjectIdForSubject(subject); + return assignEventId({ + ...protocolObservation(completedAt), + eventId: undefined, + evidenceLayer: "live_route_compatibility" as const, + scenarioId: "responses-core.live.request-shape", + executionMode: "live" as const, + subject, + subjectId, + sourceRefs: ["request_PRIVATE", "decision_PRIVATE"], + }) as ObservationEvent; +} + +function exportedProtocolRecord() { + const result = projectPublicEvidenceRecord({ observation: protocolObservation(), verdict: "VERIFIED" }); + if (result.status !== "exportable") throw new Error("expected exportable protocol record"); + return result.record; +} + +function withRecomputedRecordId(record: ReturnType) { + const { recordId: _oldRecordId, ...withoutRecordId } = record; + return { recordId: publicEvidenceId("record", withoutRecordId), ...withoutRecordId }; +} + +describe("CL-10 public authority", () => { + test("ships a closed, self-consistent public route registry manifest", () => { + const manifest = validatePublicRouteRegistryManifest(PUBLIC_ROUTE_REGISTRY_V1); + expect(manifest.schemaVersion).toBe("public_route_registry_v1"); + expect(manifest.entries.length).toBeGreaterThan(0); + expect(manifest.manifestDigest).toMatch(/^[0-9a-f]{64}$/); + expect(manifest.entries.every((entry) => entry.providerId && entry.modelId)).toBe(true); + }); + + test("public incident references are closed corpus ids only", () => { + expect(isPublicIncidentRef("IC-001")).toBe(true); + expect(isPublicIncidentRef("IC-020")).toBe(true); + expect(isPublicIncidentRef("https://github.com/private/issue/1")).toBe(false); + expect(isPublicIncidentRef("devlog/_plan/private.md")).toBe(false); + expect(isPublicIncidentRef("IC-1")).toBe(false); + }); +}); + +describe("CL-10 public projection", () => { + test("projects protocol evidence without leaking local ids, diagnostics, or assertion text", () => { + const event = protocolObservation(); + const result = projectPublicEvidenceRecord({ observation: event, verdict: "VERIFIED" }); + expect(result.status).toBe("exportable"); + if (result.status !== "exportable") throw new Error("expected exportable protocol record"); + + expect(result.record.evidenceLayer).toBe("protocol_conformance"); + expect(result.record.subject.subjectKind).toBe("protocol"); + expect(result.record.observedDayUtc).toBe("2026-08-12"); + expect(result.record.subjectId).toMatch(/^[0-9a-f]{64}$/); + expect(result.record.subjectId).not.toBe(event.subjectId); + expect(result.record.recordId).toMatch(/^[0-9a-f]{64}$/); + expect(result.record.assertions).toEqual([ + { id: "method", required: true, passed: true }, + { id: "message", required: true, passed: true }, + { id: "temperature", required: true, passed: true }, + ]); + + const serialized = JSON.stringify(result.record); + for (const canary of [ + event.subjectId, + event.eventId, + "CANARY-PRIVATE-EXPECTED", + "CANARY-PRIVATE-OBSERVED", + "C:\\Users\\private\\repo", + "request_1234567890", + "decision_1234567890", + (event.subject as ProtocolSubjectV1).behaviorFingerprint, + ]) { + expect(serialized).not.toContain(canary); + } + }); + + test("does not generalise a private exact route into a public claim", () => { + const event = routeObservation(); + const result = projectPublicEvidenceRecord({ observation: event, verdict: "PROBED" }); + expect(result).toEqual({ status: "not_exportable", reason: "private_route_identity" }); + }); + + test("derives bundle day only from records that survive exportability gates", () => { + const olderPublic = protocolObservation(Date.UTC(2026, 7, 12, 23, 59, 59)); + const newerPrivateRoute = routeObservation(Date.UTC(2026, 7, 13, 12, 0, 0)); + const projected = projectPublicEvidence({ + createdDayUtc: "2099-12-31", + records: [ + { observation: olderPublic, verdict: "VERIFIED" }, + { observation: newerPrivateRoute, verdict: "PROBED" }, + ], + }); + expect(projected.bundle.createdDayUtc).toBe("2026-08-12"); + expect(projected.bundle.records).toHaveLength(1); + expect(projected.excluded).toEqual([{ index: 1, reason: "private_route_identity" }]); + }); + + test("uses domain-separated deterministic public ids", () => { + const payload = { providerId: "openai", modelId: "gpt-5.6-sol" }; + const a = publicEvidenceId("subject", payload); + const b = publicEvidenceId("subject", payload); + const c = publicEvidenceId("record", payload); + expect(a).toBe(b); + expect(a).toMatch(/^[0-9a-f]{64}$/); + expect(a).not.toBe(c); + }); + + test("runtime validation rejects unknown public fields", () => { + const result = projectPublicEvidenceRecord({ observation: protocolObservation(), verdict: "VERIFIED" }); + if (result.status !== "exportable") throw new Error("expected exportable protocol record"); + const withUnknown = { ...result.record, localSubjectId: "PRIVATE" }; + expect(() => validatePublicEvidenceRecord(withUnknown)).toThrow(PublicEvidenceValidationError); + }); +}); + +describe("CL-10 public bundle and publisher", () => { + test("builds deterministic bundle ids and digests from public-safe bytes", () => { + const home = tempHome(); + const publisher = getOrCreatePublicPublisher(home).publisher; + const input = { + records: [exportedProtocolRecord()], + artifacts: [], + createdDayUtc: "2026-08-12", + publisher, + }; + const a = buildPublicEvidenceBundle(input); + const b = buildPublicEvidenceBundle(input); + expect(a.bundleId).toBe(b.bundleId); + expect(a.bundleDigest).toBe(b.bundleDigest); + expect(a.bundleId).toMatch(/^[0-9a-f]{64}$/); + expect(a.bundleDigest).toMatch(/^[0-9a-f]{64}$/); + expect(a.bundleId).not.toBe(a.bundleDigest); + }); + + test("creates one installation-local Ed25519 publisher key with restrictive permissions", () => { + const home = tempHome(); + const first = getOrCreatePublicPublisher(home); + const second = getOrCreatePublicPublisher(home); + expect(first.publisher).toEqual(second.publisher); + expect(first.publisher.algorithm).toBe("ed25519"); + expect(first.publisher.keyId).toMatch(/^[0-9a-f]{64}$/); + expect(first.publisher.publicKey.length).toBeGreaterThan(20); + const privateKey = readFileSync(first.privateKeyPath, "utf8"); + expect(privateKey).toContain("PRIVATE KEY"); + if (process.platform !== "win32") { + expect(statSync(first.privateKeyPath).mode & 0o777).toBe(0o600); + } + }); + + test("rejects unreviewed assertion authority before publisher key creation", () => { + const home = tempHome(); + const record = exportedProtocolRecord(); + const unauthorized = withRecomputedRecordId({ + ...record, + assertions: [{ id: "private-assertion-name", required: true, passed: true }], + }); + expect(() => signPublicEvidenceBundle({ + records: [unauthorized], + artifacts: [], + createdDayUtc: "2026-08-12", + configDir: home, + })).toThrow(/assertion.*authority/i); + expect(existsSync(labPublicPublisherKeyPath(home))).toBe(false); + }); + + test("rejects privacy-canary public fields before publisher key creation", () => { + const home = tempHome(); + const record = exportedProtocolRecord(); + if (record.subject.subjectKind !== "protocol") throw new Error("expected protocol public subject"); + const subject = { ...record.subject, surface: "https://private.example.test/path?token=secret" }; + const subjectId = publicEvidenceId("subject", subject); + const unsafe = withRecomputedRecordId({ ...record, subject, subjectId }); + expect(() => signPublicEvidenceBundle({ + records: [unsafe], + artifacts: [], + createdDayUtc: "2026-08-12", + configDir: home, + })).toThrow(/closed public identifier|forbidden URL material/i); + expect(existsSync(labPublicPublisherKeyPath(home))).toBe(false); + }); + + test("signs and verifies exact canonical bundle bytes without serializing private key material", () => { + const home = tempHome(); + const handle = getOrCreatePublicPublisher(home); + const bundle = signPublicEvidenceBundle({ + records: [exportedProtocolRecord()], + artifacts: [], + createdDayUtc: "2026-08-12", + configDir: home, + }); + expect(verifyPublicEvidenceBundle(bundle)).toEqual({ status: "cryptographically_valid" }); + const serialized = JSON.stringify(bundle); + expect(serialized).not.toContain(handle.privateKeyPath); + expect(serialized).not.toContain(readFileSync(handle.privateKeyPath, "utf8").trim()); + + const badDigest = { ...bundle, bundleDigest: hex("tampered-bundle") }; + expect(verifyPublicEvidenceBundle(badDigest)).toEqual({ status: "digest_invalid" }); + const badSignature = { + ...bundle, + signature: { ...bundle.signature, signature: Buffer.from("tampered").toString("base64") }, + }; + expect(verifyPublicEvidenceBundle(badSignature)).toEqual({ status: "signature_invalid" }); + }); + + test("writes and reads a bounded local export by public bundle id", () => { + const home = tempHome(); + const bundle = signPublicEvidenceBundle({ + records: [exportedProtocolRecord()], + artifacts: [], + createdDayUtc: "2026-08-12", + configDir: home, + }); + const path = writePublicEvidenceBundle(bundle, home); + expect(path).toBe(join(home, "lab", "export", `${bundle.bundleId}.json`)); + expect(readPublicEvidenceBundle(bundle.bundleId, home)).toEqual(bundle); + }); + + test("rejects non-object local export JSON with a validation error", () => { + const home = tempHome(); + const bundleId = "f".repeat(64); + const exportDir = join(home, "lab", "export"); + mkdirSync(exportDir, { recursive: true, mode: 0o700 }); + writeFileSync(join(exportDir, `${bundleId}.json`), "null\n", { encoding: "utf8", mode: 0o600 }); + expect(() => readPublicEvidenceBundle(bundleId, home)).toThrow(PublicEvidenceValidationError); + }); +}); \ No newline at end of file diff --git a/tests/lab-public-file-safety.test.ts b/tests/lab-public-file-safety.test.ts new file mode 100644 index 000000000..ae2ff6450 --- /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-lifecycle-hardening.test.ts b/tests/lab-public-lifecycle-hardening.test.ts new file mode 100644 index 000000000..d40de7089 --- /dev/null +++ b/tests/lab-public-lifecycle-hardening.test.ts @@ -0,0 +1,191 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + rmSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + labCommunityDir, + labExportDir, + labPublicPublisherKeyPath, +} from "../src/lab/paths"; +import { + publishPrivateFileExclusive, + setPrivateFileCommitFaultForTests, +} from "../src/lab/public/private-file"; +import { + createPublicEvidenceRevocation, + importCommunityEvidenceBundle, + importCommunityEvidenceRevocation, + listCommunityEvidence, + listLocalPublicOrigins, + purgeLocalPublicEvidenceCopies, + publicEvidenceId, + recordLocalPublicOrigin, + signPublicEvidenceBundle, + writePublicEvidenceBundle, + type PublicEvidenceRecordV1, +} from "../src/lab/public"; + +const roots: string[] = []; +afterEach(() => { + setPrivateFileCommitFaultForTests(null); + 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 fixedRecord(): 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 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 }; +} + +function signedBundle(config: string, day = "2026-08-12") { + return signPublicEvidenceBundle({ + records: [fixedRecord()], + artifacts: [], + createdDayUtc: day, + configDir: config, + }); +} + +function addLivePrivateStages(dir: string, count: number): void { + mkdirSync(dir, { recursive: true, mode: 0o700 }); + for (let index = 0; index < count; index += 1) { + const finalName = `bundle-${String(index).padStart(3, "0")}.json`; + writeFileSync( + join(dir, `.${finalName}.${process.pid}.${randomUUID()}.tmp`), + "stage", + { mode: 0o600 }, + ); + } +} + +describe("CL-10 public lifecycle hardening", () => { + test("exclusive private publication never exposes a partial final file", () => { + const root = configDir("ocx-cl10-atomic-"); + const finalPath = join(root, "object.json"); + const bytes = Buffer.from('{"ok":true}', "utf8"); + + setPrivateFileCommitFaultForTests("before_publish"); + expect(() => publishPrivateFileExclusive(finalPath, bytes)).toThrow(/synthetic.*commit failure/i); + expect(existsSync(finalPath)).toBe(false); + expect(readdirSync(root).filter((name) => name.endsWith(".tmp"))).toEqual([]); + + setPrivateFileCommitFaultForTests(null); + expect(publishPrivateFileExclusive(finalPath, bytes)).toEqual({ created: true }); + expect(existsSync(finalPath)).toBe(true); + }); + + test("private staging files do not consume the bounded community object quota", () => { + const publisher = configDir("ocx-cl10-stage-publisher-"); + const consumer = configDir("ocx-cl10-stage-consumer-"); + addLivePrivateStages(labCommunityDir(consumer), 512); + + const bundle = signedBundle(publisher); + expect(importCommunityEvidenceBundle(bundle, consumer)).toMatchObject({ + created: true, + status: "cryptographically_valid", + bundleId: bundle.bundleId, + }); + expect(listCommunityEvidence(consumer)).toEqual([ + expect.objectContaining({ bundleId: bundle.bundleId, activeRecordCount: 1 }), + ]); + }); + + test("durable origin provenance purges local community copies even after export and key corruption", () => { + const local = configDir("ocx-cl10-origin-local-"); + const thirdParty = configDir("ocx-cl10-origin-third-party-"); + const localBundle = signedBundle(local); + const thirdPartyBundle = signedBundle(thirdParty, "2026-08-13"); + + const localExportPath = writePublicEvidenceBundle(localBundle, local); + recordLocalPublicOrigin({ + publisherKeyId: localBundle.publisher.keyId, + bundleId: localBundle.bundleId, + }, local); + importCommunityEvidenceBundle(localBundle, local); + importCommunityEvidenceBundle(thirdPartyBundle, local); + + const localRevocation = createPublicEvidenceRevocation({ + configDir: local, + targetBundle: localBundle, + issuedDayUtc: "2026-08-13", + reason: "privacy_retraction", + targets: [{ kind: "bundle", id: localBundle.bundleId }], + }); + importCommunityEvidenceRevocation(localRevocation, local); + + expect(listLocalPublicOrigins(local)).toEqual([{ + publisherKeyId: localBundle.publisher.keyId, + bundleId: localBundle.bundleId, + }]); + + writeFileSync(localExportPath, "{", { encoding: "utf8" }); + unlinkSync(labPublicPublisherKeyPath(local)); + + expect(purgeLocalPublicEvidenceCopies(local)).toMatchObject({ + deletedCommunityBundles: 1, + deletedCommunityRevocations: 1, + }); + expect(readdirSync(labExportDir(local))).toEqual([]); + expect(listLocalPublicOrigins(local)).toEqual([]); + expect(listCommunityEvidence(local).map((row) => row.bundleId)).toEqual([thirdPartyBundle.bundleId]); + }); + + test("duplicate-key revocation JSON is rejected before persistence", () => { + const publisher = configDir("ocx-cl10-dup-rev-publisher-"); + const consumer = configDir("ocx-cl10-dup-rev-consumer-"); + const bundle = signedBundle(publisher); + importCommunityEvidenceBundle(bundle, consumer); + const revocation = createPublicEvidenceRevocation({ + configDir: publisher, + targetBundle: bundle, + issuedDayUtc: "2026-08-13", + reason: "evidence_invalidated", + targets: [{ kind: "record", id: bundle.records[0]!.recordId }], + }); + const raw = JSON.stringify(revocation).replace( + '"schemaVersion":"public_evidence_revocation_v1"', + '"schemaVersion":"public_evidence_revocation_v1","schemaVersion":"public_evidence_revocation_v1"', + ); + + expect(() => importCommunityEvidenceRevocation(raw, consumer)).toThrow(/duplicate json object key/i); + expect(readdirSync(labCommunityDir(consumer)).filter((name) => name.startsWith("revocation-"))).toEqual([]); + }); +}); \ No newline at end of file diff --git a/tests/lab-public-review-fixes.test.ts b/tests/lab-public-review-fixes.test.ts new file mode 100644 index 000000000..7dc10d401 --- /dev/null +++ b/tests/lab-public-review-fixes.test.ts @@ -0,0 +1,194 @@ +import { afterEach, expect, test } from "bun:test"; +import { + existsSync, + linkSync, + mkdtempSync, + readdirSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { ensureLabDirs, labPublicOriginDir } from "../src/lab/paths"; +import { purgeSensitiveEvidence } from "../src/lab/ledger/purge"; +import { replayLabLedger } from "../src/lab/ledger/store"; +import * as publicApi from "../src/lab/public"; +import { setPublicEvidencePurgeFaultForTests } from "../src/lab/public/purge"; +import { + importCommunityEvidenceBundle, + purgeLocalPublicEvidenceCopies, + publicEvidenceId, + recordLocalPublicOrigin, + signPublicEvidenceBundle, + writePublicEvidenceBundle, + PublicEvidenceValidationError, + type PublicEvidenceRecordV1, +} from "../src/lab/public"; + +const roots: string[] = []; +afterEach(() => { + setPublicEvidencePurgeFaultForTests(null); + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +function configDir(prefix: string): string { + const root = mkdtempSync(join(tmpdir(), prefix)); + roots.push(root); + return root; +} + +function hex(seed: string): string { + return Bun.CryptoHasher.hash("sha256", seed, "hex"); +} + +function fixedRecord(): 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 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 }; +} + +function signedBundle(config: string) { + return signPublicEvidenceBundle({ + records: [fixedRecord()], + artifacts: [], + createdDayUtc: "2026-08-12", + configDir: config, + }); +} + +test("decoded community objects are depth-bounded before JCS canonicalization", () => { + const consumer = configDir("ocx-cl10-object-bound-"); + let raw: unknown = { leaf: true }; + for (let index = 0; index < 20_000; index += 1) raw = { nested: raw }; + + try { + importCommunityEvidenceBundle(raw, consumer); + throw new Error("expected bounded object rejection"); + } catch (error) { + expect(error).toBeInstanceOf(PublicEvidenceValidationError); + expect((error as PublicEvidenceValidationError).code).toBe("community_depth"); + } +}); + +test("public barrel does not expose private test fault setters", () => { + expect("setPrivateFileCommitFaultForTests" in publicApi).toBe(false); + expect("setPublicEvidencePurgeFaultForTests" in publicApi).toBe(false); +}); + +test("public origin quota stays bounded when unreclaimable unexpected entries fill it", () => { + const home = configDir("ocx-cl10-origin-bound-"); + ensureLabDirs(home); + const dir = labPublicOriginDir(home); + for (let index = 0; index < 1024; index += 1) { + writeFileSync(join(dir, `occupied-${String(index).padStart(4, "0")}`), "x", { mode: 0o600 }); + } + + expect(() => recordLocalPublicOrigin({ + publisherKeyId: hex("publisher-bound"), + bundleId: hex("bundle-bound"), + }, home)).toThrow(/origin marker bound/i); + expect(readdirSync(dir)).toHaveLength(1024); +}); + +test("public origin pressure reclaims markers with no community copy", () => { + const home = configDir("ocx-cl10-origin-reclaim-"); + ensureLabDirs(home); + const dir = labPublicOriginDir(home); + for (let index = 0; index < 1024; index += 1) { + const publisherKeyId = hex(`publisher-old-${index}`); + const bundleId = hex(`bundle-old-${index}`); + writeFileSync( + join(dir, `origin-${publisherKeyId}-${bundleId}.json`), + "{}", + { mode: 0o600 }, + ); + } + + const current = { publisherKeyId: hex("publisher-current"), bundleId: hex("bundle-current") }; + recordLocalPublicOrigin(current, home); + const names = readdirSync(dir); + expect(names).toHaveLength(1); + expect(names[0]).toBe(`origin-${current.publisherKeyId}-${current.bundleId}.json`); +}); + +test("corrupt origin provenance cannot retain mandatory local export bytes", () => { + const home = configDir("ocx-cl10-origin-corrupt-"); + const bundle = signedBundle(home); + writePublicEvidenceBundle(bundle, home); + recordLocalPublicOrigin({ publisherKeyId: bundle.publisher.keyId, bundleId: bundle.bundleId }, home); + const originEntry = readdirSync(labPublicOriginDir(home))[0]!; + writeFileSync(join(labPublicOriginDir(home), originEntry), "{", { mode: 0o600 }); + + expect(purgeLocalPublicEvidenceCopies(home).deletedExports).toBe(1); + expect(readdirSync(ensureLabDirs(home).exportDir)).toEqual([]); +}); + +test("unsafe optional community copies do not turn a completed export purge into failure", () => { + const home = configDir("ocx-cl10-community-unsafe-"); + const bundle = signedBundle(home); + writePublicEvidenceBundle(bundle, home); + recordLocalPublicOrigin({ publisherKeyId: bundle.publisher.keyId, bundleId: bundle.bundleId }, home); + const imported = importCommunityEvidenceBundle(bundle, home); + try { + linkSync(imported.path, `${imported.path}.hardlink`); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "EPERM" || code === "ENOTSUP" || code === "EOPNOTSUPP") return; + throw error; + } + + const result = purgeLocalPublicEvidenceCopies(home); + expect(result.deletedExports).toBe(1); + expect(result.deletedCommunityBundles).toBe(0); + expect(readdirSync(ensureLabDirs(home).exportDir)).toEqual([]); + expect(existsSync(imported.path)).toBe(true); +}); + +test("failed export purge is omitted from the durable tombstone action set", () => { + const home = configDir("ocx-cl10-tombstone-export-"); + const paths = ensureLabDirs(home); + writeFileSync(join(paths.scratchDir, "scratch.txt"), "scratch", { mode: 0o600 }); + writeFileSync(join(paths.exportDir, "sensitive.txt"), "sensitive", { mode: 0o600 }); + setPublicEvidencePurgeFaultForTests("before_export_delete"); + + let failure: unknown; + try { + purgeSensitiveEvidence({ + configDir: home, + purgeActions: ["export", "scratch"], + recordedAt: Date.UTC(2026, 7, 13, 6, 0, 0), + }); + } catch (error) { + failure = error; + } finally { + setPublicEvidencePurgeFaultForTests(null); + } + expect(failure).toBeInstanceOf(Error); + + const tombstones = replayLabLedger(paths.ledgerPath).events.filter((event) => event.eventKind === "purge_tombstone"); + expect(tombstones).toHaveLength(1); + expect(tombstones[0]!.purgeActions).toEqual(["scratch"]); +}); \ No newline at end of file diff --git a/tests/lab-public-route-registry.test.ts b/tests/lab-public-route-registry.test.ts new file mode 100644 index 000000000..e13b8fdb6 --- /dev/null +++ b/tests/lab-public-route-registry.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, test } from "bun:test"; +import { PUBLIC_ROUTE_REGISTRY_V1, validatePublicRouteRegistryManifest } from "../src/lab/public"; + +const REVIEWED_AUTHORITY_SOURCE_COMMIT = "75a21417657ba5a3033198be0d8ae949de723d11"; + +describe("CL-10 public route registry authority", () => { + test("pins the reviewed OpenAI gpt-5.6-sol authority exactly", () => { + 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-surfaces.test.ts b/tests/lab-public-surfaces.test.ts new file mode 100644 index 000000000..89c6e4f60 --- /dev/null +++ b/tests/lab-public-surfaces.test.ts @@ -0,0 +1,300 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { handleLabCommand } from "../src/cli/lab"; +import { + labExportDir, + labPublicPublisherKeyPath, + persistConformanceResult, + rebuildLabProjection, +} from "../src/lab"; +import { createArtifactStore } from "../src/lab/artifacts/store"; +import { resolveProtocolExecutionContext } from "../src/lab/conformance/executor"; +import { discoverScenarios, loadCaseAuthority } from "../src/lab/conformance/manifest"; +import type { CaseRecord } from "../src/lab/conformance/types"; +import { queryLabObservations } from "../src/lab/query"; +import { handleManagementAPI } from "../src/server/management-api"; +import type { OcxConfig } from "../src/types"; +import { ManagementRequest } from "./helpers/management-auth"; + +const HOMES: string[] = []; + +afterEach(() => { + for (const home of HOMES.splice(0)) rmSync(home, { recursive: true, force: true }); + delete process.env.OPENCODEX_HOME; +}); + +function tempHome(): string { + const home = join(tmpdir(), `ocx-cl10-surfaces-${process.pid}-${Math.random().toString(16).slice(2)}`); + mkdirSync(home, { recursive: true, mode: 0o700 }); + HOMES.push(home); + return home; +} + +function syntheticPassResult(caseRecord: CaseRecord) { + return { + scenarioId: caseRecord.id, + suite: caseRecord.suite, + passed: true, + classification: "inconclusive" as const, + assertionResults: caseRecord.assertions.map((assertion) => ({ + id: assertion.id, + operator: assertion.operator, + required: assertion.required, + passed: true, + observedSummary: "PRIVATE-CANARY-OBSERVED", + })), + diagnostics: ["PRIVATE-CANARY-DIAGNOSTIC"], + executionContext: resolveProtocolExecutionContext(caseRecord), + startedAt: 1_700_000_000_000, + completedAt: 1_700_000_001_000, + }; +} + +function seedProtocolProjection(home: string): string { + const authority = loadCaseAuthority(); + const scenario = discoverScenarios(authority, ["responses-core"]) + .find((candidate) => candidate.id === "responses-core.protocol.request-shape") + ?? discoverScenarios(authority, ["responses-core"])[0]; + if (!scenario) throw new Error("no responses-core protocol scenario available"); + const store = createArtifactStore(join(home, "lab", "artifacts")); + try { + persistConformanceResult(syntheticPassResult(scenario), scenario, authority, { + configDir: home, + recordedAt: 1_700_000_001_100, + artifactStore: store, + }); + } finally { + store.close(); + } + rebuildLabProjection(home); + const rows = queryLabObservations( + { layer: "protocol_conformance", scenarioId: scenario.id }, + undefined, + 10, + home, + ); + const eventId = rows.items[0]?.eventId; + if (!eventId) throw new Error("seeded observation missing"); + return eventId; +} + +function config(home: string): OcxConfig { + void home; + return { port: 0, defaultProvider: "openai-apikey", providers: {} } as OcxConfig; +} + +async function api( + home: string, + path: string, + init: { method?: string; body?: unknown } = {}, +): Promise { + process.env.OPENCODEX_HOME = home; + const req = new ManagementRequest(`http://127.0.0.1${path}`, { + method: init.method ?? "GET", + ...(init.body !== undefined + ? { headers: { "content-type": "application/json" }, body: JSON.stringify(init.body) } + : {}), + }); + const response = await handleManagementAPI(req, new URL(req.url), config(home), { + refreshCodexCatalog: async () => {}, + }); + expect(response).not.toBeNull(); + return response!; +} + +async function captureCli(argv: string[], home: string): Promise<{ code: number; stdout: string; stderr: string }> { + const stdout: string[] = []; + const stderr: string[] = []; + const originalLog = console.log; + const originalError = console.error; + console.log = (...args: unknown[]) => { stdout.push(args.join(" ")); }; + console.error = (...args: unknown[]) => { stderr.push(args.join(" ")); }; + try { + return { + code: await handleLabCommand(argv, { configDir: home }), + stdout: stdout.join("\n"), + stderr: stderr.join("\n"), + }; + } finally { + console.log = originalLog; + console.error = originalError; + } +} + +function installNetworkCanary(): () => void { + const original = globalThis.fetch; + globalThis.fetch = (async () => { + throw new Error("CL10-NETWORK-CANARY"); + }) as typeof fetch; + return () => { globalThis.fetch = original; }; +} + +describe("CL-10 CLI local public evidence", () => { + test("preview is network-free, identifier-safe, and does not create publisher or export state", async () => { + const home = tempHome(); + const eventId = seedProtocolProjection(home); + const unknownEventId = "0".repeat(64); + const restoreFetch = installNetworkCanary(); + try { + const result = await captureCli([ + "public", "preview", "--event", eventId, "--event", unknownEventId, "--json", + ], home); + expect(result.code).toBe(0); + const body = JSON.parse(result.stdout) as { + bundle: { records: unknown[]; publisher?: unknown }; + excluded: Array<{ selectionIndex: number; reason: string; eventId?: string }>; + }; + expect(body.bundle.records).toHaveLength(1); + expect(body.bundle).not.toHaveProperty("publisher"); + expect(body.excluded).toEqual([{ selectionIndex: 1, reason: "event_not_found" }]); + expect(body.excluded[0]).not.toHaveProperty("eventId"); + expect(result.stdout).not.toContain(eventId); + expect(result.stdout).not.toContain(unknownEventId); + expect(existsSync(labPublicPublisherKeyPath(home))).toBe(false); + expect(existsSync(labExportDir(home)) ? readdirSync(labExportDir(home)) : []).toEqual([]); + expect(result.stdout).not.toContain("PRIVATE-CANARY"); + } finally { + restoreFetch(); + } + }); + + test("explicit export signs and stores, then verify/import/community remain local", async () => { + const home = tempHome(); + const eventId = seedProtocolProjection(home); + const restoreFetch = installNetworkCanary(); + try { + const exported = await captureCli(["public", "export", "--event", eventId, "--json"], home); + expect(exported.code).toBe(0); + const exportBody = JSON.parse(exported.stdout) as { + bundle: { bundleId: string; publisher: { keyId: string } }; + stored: { path: string; created: boolean }; + }; + expect(exportBody.bundle.publisher.keyId).toMatch(/^[0-9a-f]{64}$/); + expect(exportBody.stored).toEqual({ path: "", created: true }); + expect(exported.stdout).not.toContain(home); + const privateExportPath = join(labExportDir(home), `${exportBody.bundle.bundleId}.json`); + expect(existsSync(privateExportPath)).toBe(true); + + const verified = await captureCli(["public", "verify", "--file", privateExportPath, "--json"], home); + expect(verified.code).toBe(0); + expect(JSON.parse(verified.stdout)).toMatchObject({ + status: "cryptographically_valid", + bundleId: exportBody.bundle.bundleId, + publisherKeyId: exportBody.bundle.publisher.keyId, + locallyVerified: false, + }); + + const ledgerBefore = readFileSync(join(home, "lab", "compatibility.jsonl")); + const sqliteBefore = readFileSync(join(home, "lab", "compatibility.sqlite")); + const imported = await captureCli(["public", "import", "--file", privateExportPath, "--json"], home); + expect(imported.code).toBe(0); + const importedBody = JSON.parse(imported.stdout) as Record; + expect(importedBody).toMatchObject({ + status: "cryptographically_valid", + trustClass: "community_untrusted_v1", + bundleId: exportBody.bundle.bundleId, + }); + expect(importedBody).not.toHaveProperty("path"); + expect(imported.stdout).not.toContain(home); + expect(readFileSync(join(home, "lab", "compatibility.jsonl")).equals(ledgerBefore)).toBe(true); + expect(readFileSync(join(home, "lab", "compatibility.sqlite")).equals(sqliteBefore)).toBe(true); + + const community = await captureCli(["public", "community", "--json"], home); + expect(community.code).toBe(0); + const communityBody = JSON.parse(community.stdout) as { evidence: Array<{ bundleId: string; trustClass: string }> }; + expect(communityBody.evidence).toEqual([ + expect.objectContaining({ bundleId: exportBody.bundle.bundleId, trustClass: "community_untrusted_v1" }), + ]); + } finally { + restoreFetch(); + } + }); + + test("has no publish command", async () => { + const home = tempHome(); + const result = await captureCli(["public", "publish", "--json"], home); + expect(result.code).toBe(2); + expect(result.stderr).toMatch(/unknown public subcommand|usage/i); + }); +}); + +describe("CL-10 management local public evidence", () => { + test("preview/export/verify/import/community are explicit authenticated local actions", async () => { + const home = tempHome(); + const eventId = seedProtocolProjection(home); + const restoreFetch = installNetworkCanary(); + try { + const preview = await api(home, "/api/lab/public/preview", { + method: "POST", + body: { eventIds: [eventId] }, + }); + expect(preview.status).toBe(200); + const previewBody = await preview.json() as { bundle: { records: unknown[]; publisher?: unknown } }; + expect(previewBody.bundle.records).toHaveLength(1); + expect(previewBody.bundle).not.toHaveProperty("publisher"); + expect(existsSync(labPublicPublisherKeyPath(home))).toBe(false); + + const exported = await api(home, "/api/lab/public/export", { + method: "POST", + body: { eventIds: [eventId] }, + }); + expect(exported.status).toBe(200); + const exportBody = await exported.json() as { + bundle: { bundleId: string; publisher: { keyId: string } }; + stored: { path: string; created: boolean }; + }; + expect(exportBody.stored).toEqual({ path: "", created: true }); + expect(JSON.stringify(exportBody)).not.toContain(home); + + const verified = await api(home, "/api/lab/public/verify", { + method: "POST", + body: { bundle: exportBody.bundle }, + }); + expect(verified.status).toBe(200); + expect(await verified.json()).toMatchObject({ + status: "cryptographically_valid", + bundleId: exportBody.bundle.bundleId, + locallyVerified: false, + }); + + const ledgerBefore = readFileSync(join(home, "lab", "compatibility.jsonl")); + const imported = await api(home, "/api/lab/public/community/import", { + method: "POST", + body: { bundle: exportBody.bundle }, + }); + expect(imported.status).toBe(200); + const importedBody = await imported.json() as Record; + expect(importedBody).toMatchObject({ + status: "cryptographically_valid", + trustClass: "community_untrusted_v1", + }); + expect(importedBody).not.toHaveProperty("path"); + expect(JSON.stringify(importedBody)).not.toContain(home); + expect(readFileSync(join(home, "lab", "compatibility.jsonl")).equals(ledgerBefore)).toBe(true); + + const community = await api(home, "/api/lab/public/community"); + expect(community.status).toBe(200); + expect(await community.json()).toMatchObject({ + evidence: [expect.objectContaining({ bundleId: exportBody.bundle.bundleId })], + }); + } finally { + restoreFetch(); + } + }); + + test("does not expose a remote publish endpoint", async () => { + const home = tempHome(); + process.env.OPENCODEX_HOME = home; + const req = new ManagementRequest("http://127.0.0.1/api/lab/public/publish", { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{}", + }); + const res = await handleManagementAPI(req, new URL(req.url), config(home), { + refreshCodexCatalog: async () => {}, + }); + expect(res).toBeNull(); + }); +}); diff --git a/tests/lab-public-wire-contract.test.ts b/tests/lab-public-wire-contract.test.ts new file mode 100644 index 000000000..8b730dc89 --- /dev/null +++ b/tests/lab-public-wire-contract.test.ts @@ -0,0 +1,132 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + buildPublicEvidenceBundle, + importCommunityEvidenceBundle, + parseStrictPublicJson, + publicEvidenceId, + signPublicEvidenceBundle, + verifyPublicEvidenceBundle, +} from "../src/lab/public"; + +// Deterministic test-only key material is assembled at runtime so leak scanners do not +// mistake the fixture for a deployable private-key credential. +const FIXED_PRIVATE_KEY = [ + `-----BEGIN PRIVATE ${"KEY"}-----`, + ["MC4CAQAwBQYDK2VwBCIEIAABAgMEBQYH", "CAkKCwwNDg8QERITFBUWFxgZGhscHR4f"].join(""), + `-----END PRIVATE ${"KEY"}-----`, + "", +].join("\n"); +const FIXED_PUBLIC_KEY = "MCowBQYDK2VwAyEAA6EHv/POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg="; + +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 lab = join(config, "lab"); + mkdirSync(lab, { recursive: true, mode: 0o700 }); + const path = join(lab, "publisher-ed25519.pem"); + 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 }; +} + +function fixedBundle(config: string) { + installFixedPublisherKey(config); + return signPublicEvidenceBundle({ + records: [fixedRecord()], + artifacts: [], + createdDayUtc: "2026-08-12", + configDir: config, + }); +} + +describe("CL-10 public wire contract", () => { + test("freezes the RFC 8785/domain-separated bundle and Ed25519 signature vector", () => { + const bundle = fixedBundle(configDir("ocx-cl10-wire-publisher-")); + + expect(bundle.publisher.publicKey).toBe(FIXED_PUBLIC_KEY); + expect(bundle.publisher.keyId).toBe("4d5a347afcc7a1ac8d2dd4e573f0fbca2d2e90dd472c35df5c72bf2d2afca08f"); + expect(bundle.records[0]!.subjectId).toBe("982a06b98a218df5ed68ae88f5f203e1911a3e875343c6ed8d5d0b74ff4c2b25"); + expect(bundle.records[0]!.recordId).toBe("5bec20821bbf01f831e74ba469e7f18481c1209fdef209c76f482105de3e406d"); + expect(bundle.bundleId).toBe("a7598b68a4cf884dc381b1d88111e74bfad5e74ceae2be8de55b88bac3250401"); + expect(bundle.bundleDigest).toBe("aeef2f3e64a131588f6a34aaea1172c352a0c803f838690c2d6ee652ca74fb87"); + expect(bundle.signature).toEqual({ + algorithm: "ed25519", + signedDigest: "aeef2f3e64a131588f6a34aaea1172c352a0c803f838690c2d6ee652ca74fb87", + signature: "UAiI7Mz4/yIU5XjSuNZFSuyFPoAvGCy+x9cpTCwYKnFDq20AP6ipV3zowD3S4KP2iYfkXyHTMsMH3CEnz6lCBw==", + }); + expect(verifyPublicEvidenceBundle(bundle)).toEqual({ status: "cryptographically_valid" }); + }); + + test("rejects non-canonical publisher public-key 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("rejects duplicate JSON object keys before community parsing", () => { + const publisherDir = configDir("ocx-cl10-wire-publisher-"); + const consumerDir = configDir("ocx-cl10-wire-consumer-"); + const bundle = fixedBundle(publisherDir); + const raw = JSON.stringify(bundle).replace( + '"schemaVersion":"public_evidence_bundle_v1"', + '"schemaVersion":"public_evidence_bundle_v1","schemaVersion":"public_evidence_bundle_v1"', + ); + + expect(() => importCommunityEvidenceBundle(raw, consumerDir)).toThrow(/duplicate json object key/i); + }); + + test("rejects public JSON deeper than the V1 import bound before JSON.parse materialization", () => { + const raw = Buffer.from(`${"[".repeat(9)}0${"]".repeat(9)}`, "utf8"); + expect(() => parseStrictPublicJson(raw)).toThrow(/nesting depth exceeds 8/i); + }); +}); diff --git a/tests/settings-startup-health-seam.test.ts b/tests/settings-startup-health-seam.test.ts new file mode 100644 index 000000000..657e49aed --- /dev/null +++ b/tests/settings-startup-health-seam.test.ts @@ -0,0 +1,46 @@ +import { expect, test } from "bun:test"; +import { handleManagementAPI, type ManagementApiDeps } from "../src/server/management-api"; +import type { OcxConfig } from "../src/types"; +import { ManagementRequest as Request } from "./helpers/management-auth"; +import { startupHealthFixture } from "./helpers/startup-health"; + +function baseConfig(): OcxConfig { + return { + port: 10100, + defaultProvider: "openai", + providers: { + openai: { + adapter: "openai-chat", + baseUrl: "https://api.example.test/v1", + apiKey: "sk-secret-value", + defaultModel: "gpt-test", + }, + }, + }; +} + +test("settings PUT uses the injected startup-health reader", async () => { + const config = baseConfig(); + let reads = 0; + const expectedHealth = startupHealthFixture({ diagnosticStale: true }); + const deps: ManagementApiDeps = { + saveConfigPreservingClaudeCode: () => {}, + getCachedStartupHealth: async () => { + reads += 1; + return expectedHealth; + }, + }; + const req = new Request("http://127.0.0.1:10100/api/settings", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ streamMode: "eager-relay" }), + }); + + const response = await handleManagementAPI(req, new URL(req.url), config, deps); + + expect(response?.status).toBe(200); + expect(reads).toBe(1); + expect(await response!.json()).toMatchObject({ + startupHealth: { diagnosticStale: true, status: "native" }, + }); +}); diff --git a/tests/settings-stream-mode.test.ts b/tests/settings-stream-mode.test.ts index b079ff61c..5b65dd9fb 100644 --- a/tests/settings-stream-mode.test.ts +++ b/tests/settings-stream-mode.test.ts @@ -29,9 +29,13 @@ import { usageSummaryRetainedStoreSnapshot, } from "../src/server/management/usage-summary-cache"; import { catalogConvergenceFactory } from "./helpers/catalog-convergence"; +import { startupHealthFixture } from "./helpers/startup-health"; let TEST_DIR = ""; const previousHome = process.env.OPENCODEX_HOME; +const readTestStartupHealth: NonNullable = async () => ( + startupHealthFixture() +); function baseConfig(): OcxConfig { return { @@ -58,12 +62,17 @@ function putSettings( headers: { "content-type": "application/json" }, body: JSON.stringify(body), }); - return handleManagementAPI(req, new URL(req.url), config, deps); + return handleManagementAPI(req, new URL(req.url), config, { + getCachedStartupHealth: readTestStartupHealth, + ...deps, + }); } function getSettings(config: OcxConfig): Promise { const req = new Request("http://127.0.0.1:10100/api/settings"); - return handleManagementAPI(req, new URL(req.url), config); + return handleManagementAPI(req, new URL(req.url), config, { + getCachedStartupHealth: readTestStartupHealth, + }); } beforeEach(() => { @@ -84,7 +93,7 @@ afterEach(() => { try { rmSync(TEST_DIR, { recursive: true, force: true }); } catch { - /* Windows may briefly lock while a background startup-health probe exits */ + /* Windows may briefly retain file handles during test cleanup */ } } });