From ffdee61fcf9ce5edd49e598335dda2c4ca75a639 Mon Sep 17 00:00:00 2001 From: tuzuminami Date: Mon, 13 Jul 2026 22:22:33 +0900 Subject: [PATCH] feat!: harden compiled bundle integrity contract --- CHANGELOG.md | 6 + README.md | 20 +- SECURITY.md | 5 +- package.json | 2 +- .../fixtures/compiled-bundle.v1.json | 50 ++++- packages/contracts/openapi/openapi.yaml | 12 +- .../schemas/compiled-bundle.schema.json | 107 +++++++-- packages/core/src/canonical.ts | 5 +- packages/core/src/compiler.ts | 207 +++++++++++++++++- packages/core/src/service.ts | 74 +++++-- packages/core/src/types.ts | 29 ++- scripts/check-release-contract.mjs | 9 + scripts/check-release-docs.mjs | 6 +- tests/aster-flow.test.ts | 80 +++++++ tests/canonical.test.ts | 16 ++ tests/compiled-bundle-contract.test.ts | 74 ++++++- tests/release-contract.test.ts | 19 +- 17 files changed, 659 insertions(+), 62 deletions(-) create mode 100644 tests/canonical.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9093f17..2555abd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 2.0.0 - 2026-07-13 + +- Publish CompiledBundle contract `1.1.0` with closed nested schemas for provenance, context, policy references, and plugin references. +- Add `integrity.canonicalInput` so an independent consumer can recompute the content hash and reject a context-mutated bundle before composition. +- Make the breaking strict-parser and compiler-output change explicit as a package major release. The immutable v1.0.1 package and contract remain available at their existing release tag; new integrations should select the CompiledBundle `1.1.0` schema identifier and ASTER package v2. + ## 1.0.1 - 2026-07-13 - Release the versioned CompiledBundle schema and compiler-generated public fixture introduced during the v1.0 stabilization work. diff --git a/README.md b/README.md index e9c70b1..88b037d 100644 --- a/README.md +++ b/README.md @@ -130,8 +130,22 @@ node node_modules/@aster/persona-contract-compiler/dist/apps/api/src/runtime.js `@aster/persona-contract-compiler/contracts/schemas/compiled-bundle.schema.json` and a deterministic compiler-produced fixture at `@aster/persona-contract-compiler/contracts/fixtures/compiled-bundle.v1.json`. -The schema identifier is `https://tuzuminami.github.io/aster/contracts/compiled-bundle/1.0.0/schema.json`. -Consumers must treat `contentHash` as the SHA-256 identity of the canonical compiled context. Contract +The current schema identifier is `https://tuzuminami.github.io/aster/contracts/compiled-bundle/1.1.0/schema.json`. +`integrity.canonicalInput` is the complete public hash input: consumers recompute `contentHash` as SHA-256 +of UTF-8 encoded ASTER Canonical JSON v1, then verify its persona ID/version against the outer bundle fields, +its full provenance against `provenance`, and its execution fields against `context`. ASTER Canonical JSON v1 +recursively sorts object keys by JavaScript string code-unit order, preserves array order, omits undefined +object values, and serializes with `JSON.stringify`. That makes a bundle with a changed runtime context, +identity, provenance, or retained digest fail verification before it composes +with systems such as [DRIFT](https://github.com/tuzuminami/drift). + +This is an internal integrity contract, not a transport-authenticity signature. A consumer receiving a bundle +from an untrusted transport must verify it with `parseVerifiedCompiledBundle` and pin or authenticate its +distribution channel separately. + +The immutable v1.0.1 package and artifact remain available at their existing release tag for compatibility. +ASTER package v2 makes the stricter parser and compiler output explicit as a breaking API change. New independent +or integrated consumers should select the CompiledBundle v1.1.0 schema identifier and ASTER package v2. Contract changes require a new schema identifier and an explicit compatibility policy; ASTER never creates a runtime dependency on consumers such as DRIFT. @@ -162,7 +176,7 @@ Primary flow: 4. `POST /v1/personas/{personaId}/versions/{version}/compile` 5. `GET /v1/personas/{personaId}/versions/{version}/diff/{otherVersion}` -The package and OpenAPI `info.version` are one release contract: ASTER `1.x.y` ships OpenAPI `1.x.y`. +The package and OpenAPI `info.version` are one release contract: ASTER `x.y.z` ships OpenAPI `x.y.z`. The `/v1` HTTP path remains the separate API-major compatibility axis. `pnpm run check:release-contract` rejects version drift before release. See `packages/contracts/openapi/openapi.yaml` and `packages/contracts/schemas/persona-contract.schema.json`. diff --git a/SECURITY.md b/SECURITY.md index 8845a46..0cb96fd 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,8 +2,9 @@ ## Supported Versions -ASTER v1.x receives security fixes for the latest supported v1 release. Security fixes also -target the default branch while the next release is in development. +ASTER v2.x receives security fixes for the latest supported v2 release. ASTER v1.0.1 remains +available as the immutable prior contract release; security fixes also target the default branch +while the next release is in development. ## Reporting a Vulnerability diff --git a/package.json b/package.json index 660fc3e..efbc303 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@aster/persona-contract-compiler", - "version": "1.0.1", + "version": "2.0.0", "private": false, "license": "Apache-2.0", "type": "module", diff --git a/packages/contracts/fixtures/compiled-bundle.v1.json b/packages/contracts/fixtures/compiled-bundle.v1.json index d98e02a..e0543ad 100644 --- a/packages/contracts/fixtures/compiled-bundle.v1.json +++ b/packages/contracts/fixtures/compiled-bundle.v1.json @@ -1,8 +1,54 @@ { "personaId": "persona_contract_fixture", "version": 1, - "compilerVersion": "aster-compiler/0.1.0", - "contentHash": "528a4ed2da51e3ca105b704c442178ce6ac430f163cb4c425315f387b8efedc6", + "compilerVersion": "aster-compiler/0.2.0", + "contentHash": "c73be96db01b2a3eb918f43d7a6068e8eb3685e4255e078a884c6eb18a22d9c7", + "integrity": { + "algorithm": "sha256", + "canonicalization": "aster-canonical-json/1", + "encoding": "utf-8", + "canonicalInput": { + "personaId": "persona_contract_fixture", + "version": 1, + "compilerVersion": "aster-compiler/0.2.0", + "sourceContractHash": "b950347e15b00657e20c32ef1f4706fd3099ffc0118d5cf14629799ce80dcc31", + "compiledAt": "2026-07-13T00:00:00.000Z", + "componentIds": [ + "base", + "boundary", + "context" + ], + "policyReferenceIds": [ + "default-safety@2026-01" + ], + "pluginReferenceIds": [], + "persona": { + "displayName": "Aster Tutor", + "purpose": "Help a learner practice concise explanations.", + "voice": [ + "calm", + "precise" + ] + }, + "instructions": [ + "Answer with concrete examples." + ], + "boundaries": [ + "Do not claim real-world authority." + ], + "contextBlocks": [ + "Prefer short practice loops." + ], + "policyReferences": [ + { + "id": "default-safety", + "version": "2026-01", + "required": true + } + ], + "pluginReferences": [] + } + }, "provenance": { "sourceContractHash": "b950347e15b00657e20c32ef1f4706fd3099ffc0118d5cf14629799ce80dcc31", "compiledAt": "2026-07-13T00:00:00.000Z", diff --git a/packages/contracts/openapi/openapi.yaml b/packages/contracts/openapi/openapi.yaml index 3c03a8d..13b9af4 100644 --- a/packages/contracts/openapi/openapi.yaml +++ b/packages/contracts/openapi/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: ASTER API - version: 1.0.1 + version: 2.0.0 paths: /health: get: @@ -107,6 +107,14 @@ paths: responses: "200": description: Compiled bundle. + content: + application/json: + schema: + type: object + required: [data] + properties: + data: + $ref: "#/components/schemas/CompiledBundle" "409": description: Idempotency key was reused for a different request. /v1/personas/{personaId}/versions/{version}/diff/{otherVersion}: @@ -187,6 +195,8 @@ components: schema: type: string schemas: + CompiledBundle: + $ref: "../schemas/compiled-bundle.schema.json" PluginManifest: type: object required: [name, version, capabilities, coreApiVersion, enabled] diff --git a/packages/contracts/schemas/compiled-bundle.schema.json b/packages/contracts/schemas/compiled-bundle.schema.json index 5fbb84d..be9da4d 100644 --- a/packages/contracts/schemas/compiled-bundle.schema.json +++ b/packages/contracts/schemas/compiled-bundle.schema.json @@ -1,40 +1,109 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://tuzuminami.github.io/aster/contracts/compiled-bundle/1.0.0/schema.json", + "$id": "https://tuzuminami.github.io/aster/contracts/compiled-bundle/1.1.0/schema.json", "title": "ASTER CompiledBundle", "type": "object", - "required": ["personaId", "version", "compilerVersion", "contentHash", "provenance", "context"], + "required": ["personaId", "version", "compilerVersion", "contentHash", "integrity", "provenance", "context"], "properties": { "personaId": { "type": "string", "minLength": 1 }, - "version": { "type": "integer", "minimum": 1 }, - "compilerVersion": { "type": "string", "const": "aster-compiler/0.1.0" }, + "version": { "type": "integer", "minimum": 1, "maximum": 9007199254740991 }, + "compilerVersion": { "type": "string", "const": "aster-compiler/0.2.0" }, "contentHash": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, + "integrity": { + "type": "object", + "required": ["algorithm", "canonicalization", "encoding", "canonicalInput"], + "properties": { + "algorithm": { "type": "string", "const": "sha256" }, + "canonicalization": { "type": "string", "const": "aster-canonical-json/1" }, + "encoding": { "type": "string", "const": "utf-8" }, + "canonicalInput": { "$ref": "#/$defs/canonicalInput" } + }, + "additionalProperties": false + }, "provenance": { "type": "object", "required": ["sourceContractHash", "compiledAt", "componentIds", "policyReferenceIds", "pluginReferenceIds"], "properties": { "sourceContractHash": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, - "compiledAt": { "type": "string", "format": "date-time" }, - "componentIds": { "type": "array", "items": { "type": "string" } }, - "policyReferenceIds": { "type": "array", "items": { "type": "string" } }, - "pluginReferenceIds": { "type": "array", "items": { "type": "string" } } - } + "compiledAt": { "type": "string", "format": "date-time", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\\.[0-9]+)?(?:Z|[+-][0-9]{2}:[0-9]{2})$" }, + "componentIds": { "$ref": "#/$defs/nonEmptyStringArray" }, + "policyReferenceIds": { "$ref": "#/$defs/nonEmptyStringArray" }, + "pluginReferenceIds": { "$ref": "#/$defs/nonEmptyStringArray" } + }, + "additionalProperties": false }, "context": { "type": "object", "required": ["displayName", "purpose", "instructions", "boundaries", "contextBlocks", "policyReferences", "pluginReferences"], "properties": { - "displayName": { "type": "string" }, - "purpose": { "type": "string" }, - "instructions": { "type": "array", "items": { "type": "string" } }, - "boundaries": { "type": "array", "items": { "type": "string" } }, - "contextBlocks": { "type": "array", "items": { "type": "string" } }, - "policyReferences": { "type": "array" }, - "pluginReferences": { "type": "array" } - } + "displayName": { "$ref": "#/$defs/nonEmptyString" }, + "purpose": { "$ref": "#/$defs/nonEmptyString" }, + "instructions": { "$ref": "#/$defs/nonEmptyStringArray" }, + "boundaries": { "$ref": "#/$defs/nonEmptyStringArray" }, + "contextBlocks": { "$ref": "#/$defs/nonEmptyStringArray" }, + "policyReferences": { "type": "array", "items": { "$ref": "#/$defs/policyReference" } }, + "pluginReferences": { "type": "array", "items": { "$ref": "#/$defs/pluginReference" } } + }, + "additionalProperties": false } }, "additionalProperties": false, - "x-aster-contract-version": "1.0.0", - "x-aster-compatibility": "Consumers may accept additive fields only after upgrading this schema identifier; contentHash is SHA-256 of ASTER canonical compiled context." + "$defs": { + "nonEmptyString": { "type": "string", "minLength": 1 }, + "nonEmptyStringArray": { "type": "array", "items": { "$ref": "#/$defs/nonEmptyString" } }, + "persona": { + "type": "object", + "required": ["displayName", "purpose", "voice"], + "properties": { + "displayName": { "$ref": "#/$defs/nonEmptyString" }, + "purpose": { "$ref": "#/$defs/nonEmptyString" }, + "voice": { "$ref": "#/$defs/nonEmptyStringArray" } + }, + "additionalProperties": false + }, + "policyReference": { + "type": "object", + "required": ["id", "version", "required"], + "properties": { + "id": { "$ref": "#/$defs/nonEmptyString" }, + "version": { "$ref": "#/$defs/nonEmptyString" }, + "required": { "type": "boolean" } + }, + "additionalProperties": false + }, + "pluginReference": { + "type": "object", + "required": ["name", "version", "capability"], + "properties": { + "name": { "$ref": "#/$defs/nonEmptyString" }, + "version": { "$ref": "#/$defs/nonEmptyString" }, + "capability": { "type": "string", "enum": ["context_injector", "renderer"] } + }, + "additionalProperties": false + }, + "canonicalInput": { + "type": "object", + "required": ["personaId", "version", "compilerVersion", "sourceContractHash", "compiledAt", "componentIds", "policyReferenceIds", "pluginReferenceIds", "persona", "instructions", "boundaries", "contextBlocks", "policyReferences", "pluginReferences"], + "properties": { + "personaId": { "$ref": "#/$defs/nonEmptyString" }, + "version": { "type": "integer", "minimum": 1, "maximum": 9007199254740991 }, + "compilerVersion": { "type": "string", "const": "aster-compiler/0.2.0" }, + "sourceContractHash": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, + "compiledAt": { "type": "string", "format": "date-time", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\\.[0-9]+)?(?:Z|[+-][0-9]{2}:[0-9]{2})$" }, + "componentIds": { "$ref": "#/$defs/nonEmptyStringArray" }, + "policyReferenceIds": { "$ref": "#/$defs/nonEmptyStringArray" }, + "pluginReferenceIds": { "$ref": "#/$defs/nonEmptyStringArray" }, + "persona": { "$ref": "#/$defs/persona" }, + "instructions": { "$ref": "#/$defs/nonEmptyStringArray" }, + "boundaries": { "$ref": "#/$defs/nonEmptyStringArray" }, + "contextBlocks": { "$ref": "#/$defs/nonEmptyStringArray" }, + "policyReferences": { "type": "array", "items": { "$ref": "#/$defs/policyReference" } }, + "pluginReferences": { "type": "array", "items": { "$ref": "#/$defs/pluginReference" } } + }, + "additionalProperties": false + } + }, + "x-aster-contract-version": "1.1.0", + "x-aster-compatibility": "CompiledBundle 1.1.0 supersedes 1.0.0 for new integrations. Consumers must select an explicit schema identifier; v1.1 adds a closed integrity.canonicalInput that can reproduce contentHash without ASTER's source-contract store.", + "x-aster-content-hash": "contentHash is SHA-256 of UTF-8 encoded ASTER Canonical JSON v1 for integrity.canonicalInput. Canonical JSON v1 recursively sorts object keys by JavaScript string code-unit order, preserves array order, omits undefined object values, and serializes with JSON.stringify. Consumers must verify canonicalInput.personaId/version against the outer bundle fields, canonicalInput's provenance fields against provenance, and canonicalInput's execution fields against context. This is an integrity check, not a transport-authenticity signature." } diff --git a/packages/core/src/canonical.ts b/packages/core/src/canonical.ts index 868e1df..74c781e 100644 --- a/packages/core/src/canonical.ts +++ b/packages/core/src/canonical.ts @@ -1,9 +1,12 @@ import { createHash } from "node:crypto"; +export const ASTER_CANONICALIZATION = "aster-canonical-json/1"; +export const ASTER_INTEGRITY_ENCODING = "utf-8"; + export const canonicalJson = (value: unknown): string => JSON.stringify(canonicalize(value)); export const sha256Hex = (value: unknown): string => - createHash("sha256").update(typeof value === "string" ? value : canonicalJson(value)).digest("hex"); + createHash("sha256").update(typeof value === "string" ? value : canonicalJson(value), "utf8").digest("hex"); const canonicalize = (value: unknown): unknown => { if (Array.isArray(value)) { diff --git a/packages/core/src/compiler.ts b/packages/core/src/compiler.ts index 130223d..1e3e320 100644 --- a/packages/core/src/compiler.ts +++ b/packages/core/src/compiler.ts @@ -1,7 +1,8 @@ -import { canonicalJson, sha256Hex } from "./canonical.ts"; -import type { CompiledBundle, PersonaContract } from "./types.ts"; +import { ASTER_CANONICALIZATION, ASTER_INTEGRITY_ENCODING, canonicalJson, sha256Hex } from "./canonical.ts"; +import { validationError } from "./errors.ts"; +import type { CompiledBundle, CompiledBundleCanonicalInput, PersonaContract } from "./types.ts"; -export const COMPILER_VERSION = "aster-compiler/0.1.0"; +export const COMPILER_VERSION = "aster-compiler/0.2.0"; export const compilePersonaContract = ( personaId: string, @@ -19,9 +20,15 @@ export const compilePersonaContract = ( .filter((component) => component.type === "context") .map((component) => component.body); const sourceContractHash = sha256Hex(contract); - const body = { + const canonicalInput: CompiledBundleCanonicalInput = { + personaId, + version, compilerVersion: COMPILER_VERSION, sourceContractHash, + compiledAt, + componentIds: contract.components.map((component) => component.id), + policyReferenceIds: contract.policyReferences.map((policy) => `${policy.id}@${policy.version}`), + pluginReferenceIds: (contract.plugins ?? []).map((plugin) => `${plugin.name}@${plugin.version}:${plugin.capability}`), persona: contract.persona, instructions, boundaries, @@ -29,18 +36,24 @@ export const compilePersonaContract = ( policyReferences: contract.policyReferences, pluginReferences: contract.plugins ?? [] }; - const contentHash = sha256Hex(canonicalJson(body)); + const contentHash = sha256Hex(canonicalJson(canonicalInput)); return { personaId, version, compilerVersion: COMPILER_VERSION, contentHash, + integrity: { + algorithm: "sha256", + canonicalization: ASTER_CANONICALIZATION, + encoding: ASTER_INTEGRITY_ENCODING, + canonicalInput + }, provenance: { sourceContractHash, compiledAt, - componentIds: contract.components.map((component) => component.id), - policyReferenceIds: contract.policyReferences.map((policy) => `${policy.id}@${policy.version}`), - pluginReferenceIds: (contract.plugins ?? []).map((plugin) => `${plugin.name}@${plugin.version}:${plugin.capability}`) + componentIds: canonicalInput.componentIds, + policyReferenceIds: canonicalInput.policyReferenceIds, + pluginReferenceIds: canonicalInput.pluginReferenceIds }, context: { displayName: contract.persona.displayName, @@ -53,3 +66,181 @@ export const compilePersonaContract = ( } }; }; + +const hasValidCompiledBundleIntegrity = (bundle: CompiledBundle): boolean => + bundle.compilerVersion === COMPILER_VERSION && + bundle.integrity.algorithm === "sha256" && + bundle.integrity.canonicalization === ASTER_CANONICALIZATION && + bundle.integrity.encoding === ASTER_INTEGRITY_ENCODING && + bundle.integrity.canonicalInput.personaId === bundle.personaId && + bundle.integrity.canonicalInput.version === bundle.version && + bundle.integrity.canonicalInput.compilerVersion === bundle.compilerVersion && + bundle.integrity.canonicalInput.sourceContractHash === bundle.provenance.sourceContractHash && + bundle.integrity.canonicalInput.compiledAt === bundle.provenance.compiledAt && + canonicalJson(bundle.integrity.canonicalInput.componentIds) === canonicalJson(bundle.provenance.componentIds) && + canonicalJson(bundle.integrity.canonicalInput.policyReferenceIds) === canonicalJson(bundle.provenance.policyReferenceIds) && + canonicalJson(bundle.integrity.canonicalInput.pluginReferenceIds) === canonicalJson(bundle.provenance.pluginReferenceIds) && + bundle.integrity.canonicalInput.persona.displayName === bundle.context.displayName && + bundle.integrity.canonicalInput.persona.purpose === bundle.context.purpose && + canonicalJson(bundle.integrity.canonicalInput.instructions) === canonicalJson(bundle.context.instructions) && + canonicalJson(bundle.integrity.canonicalInput.boundaries) === canonicalJson(bundle.context.boundaries) && + canonicalJson(bundle.integrity.canonicalInput.contextBlocks) === canonicalJson(bundle.context.contextBlocks) && + canonicalJson(bundle.integrity.canonicalInput.policyReferences) === canonicalJson(bundle.context.policyReferences) && + canonicalJson(bundle.integrity.canonicalInput.pluginReferences) === canonicalJson(bundle.context.pluginReferences) && + sha256Hex(canonicalJson(bundle.integrity.canonicalInput)) === bundle.contentHash; + +/** + * Validates untrusted CompiledBundle JSON against the closed v1.1 contract, + * checks its projections, and recomputes its hash before returning it. + */ +export const parseVerifiedCompiledBundle = (input: unknown): CompiledBundle => { + const errors: string[] = []; + const bundle = record(input, "bundle", errors); + exactKeys(bundle, ["personaId", "version", "compilerVersion", "contentHash", "integrity", "provenance", "context"], "bundle", errors); + nonEmptyString(bundle.personaId, "bundle.personaId", errors); + positiveInteger(bundle.version, "bundle.version", errors); + literal(bundle.compilerVersion, COMPILER_VERSION, "bundle.compilerVersion", errors); + hash(bundle.contentHash, "bundle.contentHash", errors); + + const integrity = record(bundle.integrity, "bundle.integrity", errors); + exactKeys(integrity, ["algorithm", "canonicalization", "encoding", "canonicalInput"], "bundle.integrity", errors); + literal(integrity.algorithm, "sha256", "bundle.integrity.algorithm", errors); + literal(integrity.canonicalization, ASTER_CANONICALIZATION, "bundle.integrity.canonicalization", errors); + literal(integrity.encoding, ASTER_INTEGRITY_ENCODING, "bundle.integrity.encoding", errors); + validateCanonicalInput(record(integrity.canonicalInput, "bundle.integrity.canonicalInput", errors), errors); + + validateProvenance(record(bundle.provenance, "bundle.provenance", errors), errors); + validateContext(record(bundle.context, "bundle.context", errors), errors); + if (errors.length > 0) throw validationError(errors); + + const parsed = input as CompiledBundle; + if (!hasValidCompiledBundleIntegrity(parsed)) { + throw validationError(["bundle integrity projection or contentHash is invalid"]); + } + return parsed; +}; + +const validateCanonicalInput = (value: Record, errors: string[]): void => { + exactKeys(value, ["personaId", "version", "compilerVersion", "sourceContractHash", "compiledAt", "componentIds", "policyReferenceIds", "pluginReferenceIds", "persona", "instructions", "boundaries", "contextBlocks", "policyReferences", "pluginReferences"], "bundle.integrity.canonicalInput", errors); + nonEmptyString(value.personaId, "bundle.integrity.canonicalInput.personaId", errors); + positiveInteger(value.version, "bundle.integrity.canonicalInput.version", errors); + literal(value.compilerVersion, COMPILER_VERSION, "bundle.integrity.canonicalInput.compilerVersion", errors); + hash(value.sourceContractHash, "bundle.integrity.canonicalInput.sourceContractHash", errors); + isoTimestamp(value.compiledAt, "bundle.integrity.canonicalInput.compiledAt", errors); + nonEmptyStringArray(value.componentIds, "bundle.integrity.canonicalInput.componentIds", errors); + nonEmptyStringArray(value.policyReferenceIds, "bundle.integrity.canonicalInput.policyReferenceIds", errors); + nonEmptyStringArray(value.pluginReferenceIds, "bundle.integrity.canonicalInput.pluginReferenceIds", errors); + validatePersona(record(value.persona, "bundle.integrity.canonicalInput.persona", errors), "bundle.integrity.canonicalInput.persona", errors); + nonEmptyStringArray(value.instructions, "bundle.integrity.canonicalInput.instructions", errors); + nonEmptyStringArray(value.boundaries, "bundle.integrity.canonicalInput.boundaries", errors); + nonEmptyStringArray(value.contextBlocks, "bundle.integrity.canonicalInput.contextBlocks", errors); + validatePolicyReferences(value.policyReferences, "bundle.integrity.canonicalInput.policyReferences", errors); + validatePluginReferences(value.pluginReferences, "bundle.integrity.canonicalInput.pluginReferences", errors); +}; + +const validateProvenance = (value: Record, errors: string[]): void => { + exactKeys(value, ["sourceContractHash", "compiledAt", "componentIds", "policyReferenceIds", "pluginReferenceIds"], "bundle.provenance", errors); + hash(value.sourceContractHash, "bundle.provenance.sourceContractHash", errors); + isoTimestamp(value.compiledAt, "bundle.provenance.compiledAt", errors); + nonEmptyStringArray(value.componentIds, "bundle.provenance.componentIds", errors); + nonEmptyStringArray(value.policyReferenceIds, "bundle.provenance.policyReferenceIds", errors); + nonEmptyStringArray(value.pluginReferenceIds, "bundle.provenance.pluginReferenceIds", errors); +}; + +const validateContext = (value: Record, errors: string[]): void => { + exactKeys(value, ["displayName", "purpose", "instructions", "boundaries", "contextBlocks", "policyReferences", "pluginReferences"], "bundle.context", errors); + nonEmptyString(value.displayName, "bundle.context.displayName", errors); + nonEmptyString(value.purpose, "bundle.context.purpose", errors); + nonEmptyStringArray(value.instructions, "bundle.context.instructions", errors); + nonEmptyStringArray(value.boundaries, "bundle.context.boundaries", errors); + nonEmptyStringArray(value.contextBlocks, "bundle.context.contextBlocks", errors); + validatePolicyReferences(value.policyReferences, "bundle.context.policyReferences", errors); + validatePluginReferences(value.pluginReferences, "bundle.context.pluginReferences", errors); +}; + +const validatePersona = (value: Record, path: string, errors: string[]): void => { + exactKeys(value, ["displayName", "purpose", "voice"], path, errors); + nonEmptyString(value.displayName, `${path}.displayName`, errors); + nonEmptyString(value.purpose, `${path}.purpose`, errors); + nonEmptyStringArray(value.voice, `${path}.voice`, errors); +}; + +const validatePolicyReferences = (value: unknown, path: string, errors: string[]): void => { + for (const [index, item] of array(value, path, errors).entries()) { + const referencePath = `${path}[${index}]`; + const reference = record(item, referencePath, errors); + exactKeys(reference, ["id", "version", "required"], referencePath, errors); + nonEmptyString(reference.id, `${referencePath}.id`, errors); + nonEmptyString(reference.version, `${referencePath}.version`, errors); + if (typeof reference.required !== "boolean") errors.push(`${referencePath}.required must be boolean`); + } +}; + +const validatePluginReferences = (value: unknown, path: string, errors: string[]): void => { + for (const [index, item] of array(value, path, errors).entries()) { + const referencePath = `${path}[${index}]`; + const reference = record(item, referencePath, errors); + exactKeys(reference, ["name", "version", "capability"], referencePath, errors); + nonEmptyString(reference.name, `${referencePath}.name`, errors); + nonEmptyString(reference.version, `${referencePath}.version`, errors); + if (reference.capability !== "context_injector" && reference.capability !== "renderer") { + errors.push(`${referencePath}.capability is invalid`); + } + } +}; + +const record = (value: unknown, path: string, errors: string[]): Record => { + if (value !== null && typeof value === "object" && !Array.isArray(value)) return value as Record; + errors.push(`${path} must be an object`); + return {}; +}; +const array = (value: unknown, path: string, errors: string[]): readonly unknown[] => { + if (Array.isArray(value)) return value; + errors.push(`${path} must be an array`); + return []; +}; +const exactKeys = (value: Record, allowed: readonly string[], path: string, errors: string[]): void => { + for (const key of Object.keys(value)) if (!allowed.includes(key)) errors.push(`${path}.${key} is not allowed`); + for (const key of allowed) if (!(key in value)) errors.push(`${path}.${key} is required`); +}; +const nonEmptyString = (value: unknown, path: string, errors: string[]): void => { + if (typeof value !== "string" || value.length === 0) errors.push(`${path} must be a non-empty string`); +}; +const nonEmptyStringArray = (value: unknown, path: string, errors: string[]): void => { + for (const [index, item] of array(value, path, errors).entries()) nonEmptyString(item, `${path}[${index}]`, errors); +}; +const positiveInteger = (value: unknown, path: string, errors: string[]): void => { + if (!Number.isSafeInteger(value) || (value as number) < 1) errors.push(`${path} must be a positive safe integer`); +}; +const literal = (value: unknown, expected: string, path: string, errors: string[]): void => { + if (value !== expected) errors.push(`${path} must be ${expected}`); +}; +const hash = (value: unknown, path: string, errors: string[]): void => { + if (typeof value !== "string" || !/^[a-f0-9]{64}$/.test(value)) errors.push(`${path} must be a SHA-256 hash`); +}; +const isoTimestamp = (value: unknown, path: string, errors: string[]): void => { + if (typeof value !== "string") { + errors.push(`${path} must be an RFC 3339 timestamp`); + return; + } + const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(Z|[+-]\d{2}:\d{2})$/.exec(value); + if (!match) { + errors.push(`${path} must be an RFC 3339 timestamp`); + return; + } + const yearValue = Number(match[1]!); + const monthValue = Number(match[2]!); + const dayValue = Number(match[3]!); + const hourValue = Number(match[4]!); + const minuteValue = Number(match[5]!); + const secondValue = Number(match[6]!); + const zone = match[7]!; + const validDate = + yearValue >= 0 && monthValue >= 1 && monthValue <= 12 && + dayValue >= 1 && dayValue <= new Date(Date.UTC(yearValue, monthValue, 0)).getUTCDate() && + hourValue <= 23 && minuteValue <= 59 && secondValue <= 59 && + (zone === "Z" || (Number(zone.slice(1, 3)) <= 23 && Number(zone.slice(4, 6)) <= 59)); + if (!validDate || Number.isNaN(Date.parse(value))) { + errors.push(`${path} must be an RFC 3339 timestamp`); + } +}; diff --git a/packages/core/src/service.ts b/packages/core/src/service.ts index c11759c..1f87c6a 100644 --- a/packages/core/src/service.ts +++ b/packages/core/src/service.ts @@ -1,5 +1,5 @@ -import { sha256Hex } from "./canonical.ts"; -import { COMPILER_VERSION, compilePersonaContract } from "./compiler.ts"; +import { canonicalJson, sha256Hex } from "./canonical.ts"; +import { COMPILER_VERSION, compilePersonaContract, parseVerifiedCompiledBundle } from "./compiler.ts"; import { AsterError } from "./errors.ts"; import type { AtomicMutationPorts, AtomicMutationStore, AuditLog, Clock, IdGenerator, IdempotencyStore, PersonaRepository, PluginRegistry } from "./ports.ts"; import type { CompiledBundle, Persona, PersonaContract, PersonaDiff, PersonaVersion, PluginManifest, RequestContext } from "./types.ts"; @@ -131,25 +131,48 @@ export class AsterService { input: { readonly personaId: string; readonly version: number } ): Promise { this.requireTenant(context); - return this.idempotent(context, "compileVersion", { actorId: context.actorId, input }, async (mutation) => { - const personaVersion = await mutation.repository.getVersion(context.tenantId, input.personaId, input.version); - if (!personaVersion) throw new AsterError("RESOURCE_NOT_FOUND", 404, "Persona version was not found."); - assertPublished(personaVersion.status); - await this.ports.plugins.validateReferences(context.tenantId, personaVersion.contract.plugins ?? []); + const personaVersion = await this.ports.repository.getVersion(context.tenantId, input.personaId, input.version); + if (!personaVersion) throw new AsterError("RESOURCE_NOT_FOUND", 404, "Persona version was not found."); + assertPublished(personaVersion.status); + if (sha256Hex(personaVersion.contract) !== personaVersion.contentHash) { + throw new AsterError("DEPENDENCY_UNAVAILABLE", 503, "Stored persona contract failed content-hash verification."); + } + await this.ports.plugins.validateReferences(context.tenantId, personaVersion.contract.plugins ?? []); + const expectedBundle = compilePersonaContract( + input.personaId, + input.version, + personaVersion.contract, + personaVersion.updatedAt + ); + const operation = `compileVersion:${COMPILER_VERSION}`; + return this.idempotent( + context, + operation, + { actorId: context.actorId, input, compilerVersion: COMPILER_VERSION, contractVersion: "1.1.0", expectedContentHash: expectedBundle.contentHash }, + async (mutation) => { const existingBundle = await mutation.repository.getBundle( context.tenantId, input.personaId, input.version, COMPILER_VERSION ); - if (existingBundle) return existingBundle; - const bundle = compilePersonaContract( - input.personaId, - input.version, - personaVersion.contract, - personaVersion.updatedAt - ); + if (existingBundle) { + return this.verifyExpectedCompiledBundle(existingBundle, expectedBundle); + } + const bundle = expectedBundle; const saveResult = await mutation.repository.saveBundle(bundle, context.tenantId, context.actorId); + if (saveResult === "existing") { + const persistedBundle = await mutation.repository.getBundle( + context.tenantId, + input.personaId, + input.version, + COMPILER_VERSION + ); + if (!persistedBundle) { + throw new AsterError("DEPENDENCY_UNAVAILABLE", 503, "Stored compiled bundle was unavailable after a concurrent write."); + } + return this.verifyExpectedCompiledBundle(persistedBundle, expectedBundle); + } if (saveResult === "created") { await this.audit( mutation.audit, @@ -162,7 +185,9 @@ export class AsterService { ); } return bundle; - }); + }, + (replayed) => this.verifyExpectedCompiledBundle(replayed, expectedBundle) + ); } public async diffVersions( @@ -203,7 +228,8 @@ export class AsterService { context: RequestContext, operation: string, request: unknown, - create: (mutation: AtomicMutationPorts) => Promise + create: (mutation: AtomicMutationPorts) => Promise, + replayValidator?: (replayed: unknown) => T ): Promise { const idempotencyKey = context.idempotencyKey; if (!idempotencyKey) { @@ -211,14 +237,26 @@ export class AsterService { } const requestHash = sha256Hex(request); return this.ports.transactions.runAtomically({ tenantId: context.tenantId, idempotencyKey, operation }, async (mutation) => { - const replayed = await mutation.idempotency.replay(context.tenantId, idempotencyKey, operation, requestHash); - if (replayed) return replayed; + const replayed = await mutation.idempotency.replay(context.tenantId, idempotencyKey, operation, requestHash); + if (replayed !== undefined) return replayValidator ? replayValidator(replayed) : replayed as T; const response = await create(mutation); await mutation.idempotency.record(context.tenantId, idempotencyKey, operation, requestHash, response); return response; }); } + private verifyExpectedCompiledBundle(candidate: unknown, expected: CompiledBundle): CompiledBundle { + try { + const verified = parseVerifiedCompiledBundle(candidate); + if (canonicalJson(verified) !== canonicalJson(expected)) { + throw new Error("Stored compiled bundle differs from the published persona version."); + } + return verified; + } catch { + throw new AsterError("DEPENDENCY_UNAVAILABLE", 503, "Stored compiled bundle failed integrity verification."); + } + } + private requireTenant(context: RequestContext): void { if (context.tenantId.length === 0 || context.actorId.length === 0) { throw new AsterError("TENANT_SCOPE_DENIED", 403, "Request cannot access this resource."); diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index e244e76..a406d20 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -71,8 +71,18 @@ export interface PersonaVersion { export interface CompiledBundle { readonly personaId: string; readonly version: number; - readonly compilerVersion: string; + readonly compilerVersion: "aster-compiler/0.2.0"; readonly contentHash: string; + /** + * Public, hash-addressed compiler input. Consumers can recompute + * `contentHash` without access to ASTER's private source contract store. + */ + readonly integrity: { + readonly algorithm: "sha256"; + readonly canonicalization: "aster-canonical-json/1"; + readonly encoding: "utf-8"; + readonly canonicalInput: CompiledBundleCanonicalInput; + }; readonly provenance: { readonly sourceContractHash: string; readonly compiledAt: string; @@ -91,6 +101,23 @@ export interface CompiledBundle { }; } +export interface CompiledBundleCanonicalInput { + readonly personaId: string; + readonly version: number; + readonly compilerVersion: "aster-compiler/0.2.0"; + readonly sourceContractHash: string; + readonly compiledAt: string; + readonly componentIds: readonly string[]; + readonly policyReferenceIds: readonly string[]; + readonly pluginReferenceIds: readonly string[]; + readonly persona: PersonaContract["persona"]; + readonly instructions: readonly string[]; + readonly boundaries: readonly string[]; + readonly contextBlocks: readonly string[]; + readonly policyReferences: readonly PolicyReference[]; + readonly pluginReferences: readonly PluginReference[]; +} + export interface AuditEvent { readonly id: string; readonly tenantId: string; diff --git a/scripts/check-release-contract.mjs b/scripts/check-release-contract.mjs index f239046..440cc4c 100644 --- a/scripts/check-release-contract.mjs +++ b/scripts/check-release-contract.mjs @@ -8,6 +8,15 @@ export function validateReleaseContract({ packageVersion, openApi }) { if (openApiVersion !== packageVersion) { throw new Error(`release-contract: OpenAPI info.version must equal package version ${packageVersion}`); } + const compilePath = openApi.indexOf("/v1/personas/{personaId}/versions/{version}/compile:"); + const nextPath = openApi.indexOf("\n /", compilePath + 1); + const compileOperation = compilePath === -1 ? "" : openApi.slice(compilePath, nextPath === -1 ? undefined : nextPath); + if (!compileOperation.includes('$ref: "#/components/schemas/CompiledBundle"')) { + throw new Error("release-contract: compile response must expose CompiledBundle"); + } + if (!openApi.includes('CompiledBundle:\n $ref: "../schemas/compiled-bundle.schema.json"')) { + throw new Error("release-contract: CompiledBundle must reference the versioned public schema"); + } } if (process.argv[1]?.endsWith("check-release-contract.mjs")) { diff --git a/scripts/check-release-docs.mjs b/scripts/check-release-docs.mjs index 017add7..f0072d0 100644 --- a/scripts/check-release-docs.mjs +++ b/scripts/check-release-docs.mjs @@ -5,6 +5,7 @@ export function validateReleaseDocs({ version, docs }) { const firstStableVersion = "1.0.0"; const readme = getDocument(docs, "README.md"); const security = getDocument(docs, "SECURITY.md"); + const supportedMajor = version.split(".")[0]; const v1Scope = section(readme, "## v1 Scope"); check(/^\d+\.\d+\.\d+$/.test(version), "package version must be stable semver"); @@ -18,7 +19,10 @@ export function validateReleaseDocs({ version, docs }) { readme.includes("optional transport-level composition"), "README must describe optional DRIFT composition without a runtime dependency" ); - check(security.includes("ASTER v1.x receives security fixes"), "SECURITY.md must identify the supported v1 release series"); + check( + security.includes(`ASTER v${supportedMajor}.x receives security fixes`), + `SECURITY.md must identify the supported v${supportedMajor} release series` + ); check(!/\bpre[- ]1\.0\b/i.test(security), "SECURITY.md must not describe ASTER as pre-1.0"); for (const doc of docs) { diff --git a/tests/aster-flow.test.ts b/tests/aster-flow.test.ts index 8aded07..5769735 100644 --- a/tests/aster-flow.test.ts +++ b/tests/aster-flow.test.ts @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { AsterError, AsterService, type PersonaContract } from "../packages/core/src/index.ts"; import { CryptoIdGenerator, DeterministicClock, InMemoryAsterStore, SequentialIdGenerator } from "../packages/adapters/src/memory-store.ts"; +import { compilePersonaContract } from "../packages/core/src/compiler.ts"; const contract: PersonaContract = { schemaVersion: "1.0", @@ -282,6 +283,85 @@ test("AT-AST-019 a failing in-memory mutation cannot roll back a concurrent succ assert.deepEqual(await store.getPersona("tenant_a", persona.id), persona); }); +test("AT-AST-028 stored compiled bundles fail closed when integrity verification fails", async () => { + const { service, store } = makeService(); + const persona = await service.createPersona(baseContext("create-persona-28"), { name: "Tutor" }); + await service.createVersion(baseContext("create-version-28"), { personaId: persona.id, contract }); + await service.publishVersion(baseContext("publish-28"), { personaId: persona.id, version: 1 }); + const bundle = await service.compileVersion(baseContext("compile-28"), { personaId: persona.id, version: 1 }); + const mutableStore = store as unknown as { bundles: Map }; + mutableStore.bundles.set( + `tenant_a:${persona.id}:1:${bundle.compilerVersion}`, + { ...bundle, context: { ...bundle.context, instructions: ["tampered after persistence"] } } + ); + + await assert.rejects( + service.compileVersion(baseContext("compile-corrupt-28"), { personaId: persona.id, version: 1 }), + (error: unknown) => error instanceof AsterError && error.code === "DEPENDENCY_UNAVAILABLE" + ); +}); + +test("AT-AST-029 stored bundles must match deterministic compilation from the published contract", async () => { + const { service, store } = makeService(); + const persona = await service.createPersona(baseContext("create-persona-29"), { name: "Tutor" }); + await service.createVersion(baseContext("create-version-29"), { personaId: persona.id, contract }); + await service.publishVersion(baseContext("publish-29"), { personaId: persona.id, version: 1 }); + const bundle = await service.compileVersion(baseContext("compile-29"), { personaId: persona.id, version: 1 }); + const forged = compilePersonaContract( + persona.id, + 1, + { ...contract, components: [{ id: "base", type: "instruction", body: "Substituted instruction." }] }, + "2026-01-01T00:00:00.000Z" + ); + const mutableStore = store as unknown as { bundles: Map }; + mutableStore.bundles.set(`tenant_a:${persona.id}:1:${bundle.compilerVersion}`, forged); + + await assert.rejects( + service.compileVersion(baseContext("compile-forged-29"), { personaId: persona.id, version: 1 }), + (error: unknown) => error instanceof AsterError && error.code === "DEPENDENCY_UNAVAILABLE" + ); +}); + +test("AT-AST-030 compiled bundle idempotency replays fail closed before returning", async () => { + const { service, store } = makeService(); + const persona = await service.createPersona(baseContext("create-persona-30"), { name: "Tutor" }); + await service.createVersion(baseContext("create-version-30"), { personaId: persona.id, contract }); + await service.publishVersion(baseContext("publish-30"), { personaId: persona.id, version: 1 }); + const replayContext = baseContext("compile-replay-30"); + const bundle = await service.compileVersion(replayContext, { personaId: persona.id, version: 1 }); + const mutableStore = store as unknown as { + idempotency: Map; + }; + const key = `tenant_a:compileVersion:${bundle.compilerVersion}:compile-replay-30`; + const record = mutableStore.idempotency.get(key); + assert.ok(record); + mutableStore.idempotency.set(key, { ...record, response: { ...bundle, compilerVersion: "aster-compiler/0.1.0" } }); + + await assert.rejects( + service.compileVersion(replayContext, { personaId: persona.id, version: 1 }), + (error: unknown) => error instanceof AsterError && error.code === "DEPENDENCY_UNAVAILABLE" + ); +}); + +test("AT-AST-032 stored persona contracts fail closed when their content hash drifts", async () => { + const { service, store } = makeService(); + const persona = await service.createPersona(baseContext("create-persona-32"), { name: "Tutor" }); + await service.createVersion(baseContext("create-version-32"), { personaId: persona.id, contract }); + await service.publishVersion(baseContext("publish-32"), { personaId: persona.id, version: 1 }); + const mutableStore = store as unknown as { versions: Map }; + const key = `tenant_a:${persona.id}:1`; + const stored = mutableStore.versions.get(key) as { readonly contract: PersonaContract }; + mutableStore.versions.set(key, { + ...stored, + contract: { ...stored.contract, components: [{ id: "base", type: "instruction", body: "Tampered stored contract." }] } + }); + + await assert.rejects( + service.compileVersion(baseContext("compile-tampered-32"), { personaId: persona.id, version: 1 }), + (error: unknown) => error instanceof AsterError && error.code === "DEPENDENCY_UNAVAILABLE" + ); +}); + class FailingAuditStore extends InMemoryAsterStore { public override async append(): Promise { throw new Error("injected audit failure"); diff --git a/tests/canonical.test.ts b/tests/canonical.test.ts new file mode 100644 index 0000000..7d2d482 --- /dev/null +++ b/tests/canonical.test.ts @@ -0,0 +1,16 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { ASTER_CANONICALIZATION, ASTER_INTEGRITY_ENCODING, canonicalJson, sha256Hex } from "../packages/core/src/canonical.ts"; + +test("AT-AST-027 publishes stable ASTER Canonical JSON v1 vectors", () => { + assert.equal(ASTER_CANONICALIZATION, "aster-canonical-json/1"); + assert.equal(ASTER_INTEGRITY_ENCODING, "utf-8"); + assert.equal( + canonicalJson({ z: true, a: [false, { y: "snow", x: "雪" }], omitted: undefined }), + '{"a":[false,{"x":"雪","y":"snow"}],"z":true}' + ); + assert.equal( + sha256Hex(canonicalJson({ z: true, a: [false, { y: "snow", x: "雪" }], omitted: undefined })), + "907dfec09191c6a5d61cb48086165b2680253b46749d813f89f23a192233458a" + ); +}); diff --git a/tests/compiled-bundle-contract.test.ts b/tests/compiled-bundle-contract.test.ts index 2a9a16c..c24b9c7 100644 --- a/tests/compiled-bundle-contract.test.ts +++ b/tests/compiled-bundle-contract.test.ts @@ -1,7 +1,8 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; -import { compilePersonaContract } from "../packages/core/src/compiler.ts"; +import { compilePersonaContract, parseVerifiedCompiledBundle } from "../packages/core/src/compiler.ts"; +import { canonicalJson, sha256Hex } from "../packages/core/src/canonical.ts"; import { parsePersonaContract } from "../packages/core/src/validation.ts"; test("AT-AST-022 compiled bundle schema and fixture are versioned compiler output", () => { @@ -10,8 +11,75 @@ test("AT-AST-022 compiled bundle schema and fixture are versioned compiler outpu const source = JSON.parse(readFileSync("examples/persona-contract.json", "utf8")); const compiled = compilePersonaContract("persona_contract_fixture", 1, parsePersonaContract(source), "2026-07-13T00:00:00.000Z"); - assert.equal(schema.$id, "https://tuzuminami.github.io/aster/contracts/compiled-bundle/1.0.0/schema.json"); - assert.equal(schema["x-aster-contract-version"], "1.0.0"); + assert.equal(schema.$id, "https://tuzuminami.github.io/aster/contracts/compiled-bundle/1.1.0/schema.json"); + assert.equal(schema["x-aster-contract-version"], "1.1.0"); assert.equal(schema.properties.contentHash.pattern, "^[a-f0-9]{64}$"); + assert.equal(schema.properties.integrity.additionalProperties, false); + assert.equal(schema.properties.provenance.additionalProperties, false); + assert.equal(schema.properties.context.additionalProperties, false); + assert.equal(schema.$defs.policyReference.additionalProperties, false); + assert.equal(schema.$defs.pluginReference.additionalProperties, false); + const openApi = readFileSync("packages/contracts/openapi/openapi.yaml", "utf8"); + assert.ok(openApi.includes('$ref: "#/components/schemas/CompiledBundle"')); + assert.ok(openApi.includes('CompiledBundle:\n $ref: "../schemas/compiled-bundle.schema.json"')); assert.deepEqual(fixture, compiled); + assert.deepEqual(parseVerifiedCompiledBundle(fixture), compiled); +}); + +test("AT-AST-025 compiled bundle integrity rejects context mutation with a retained hash", () => { + const source = JSON.parse(readFileSync("examples/persona-contract.json", "utf8")); + const bundle = compilePersonaContract("persona_contract_fixture", 1, parsePersonaContract(source), "2026-07-13T00:00:00.000Z"); + const tampered = { + ...bundle, + context: { ...bundle.context, instructions: ["Ignore the published persona contract."] } + }; + + assert.throws(() => parseVerifiedCompiledBundle(tampered)); +}); + +test("AT-AST-026 compiled bundle verification rejects unknown fields at every closed boundary", () => { + const fixture = JSON.parse(readFileSync("packages/contracts/fixtures/compiled-bundle.v1.json", "utf8")); + const cases = [ + { ...fixture, unexpected: true }, + { ...fixture, integrity: { ...fixture.integrity, unexpected: true } }, + { ...fixture, integrity: { ...fixture.integrity, canonicalInput: { ...fixture.integrity.canonicalInput, unexpected: true } } }, + { ...fixture, provenance: { ...fixture.provenance, unexpected: true } }, + { ...fixture, context: { ...fixture.context, unexpected: true } }, + { ...fixture, context: { ...fixture.context, policyReferences: [{ ...fixture.context.policyReferences[0], unexpected: true }] } } + ]; + + for (const value of cases) { + assert.throws(() => parseVerifiedCompiledBundle(value)); + } +}); + +test("AT-AST-031 compiled bundle verification enforces RFC 3339 timestamps", () => { + const fixture = JSON.parse(readFileSync("packages/contracts/fixtures/compiled-bundle.v1.json", "utf8")); + const withTimestamp = (compiledAt: string) => { + const canonicalInput = { ...fixture.integrity.canonicalInput, compiledAt }; + return { + ...fixture, + contentHash: sha256Hex(canonicalJson(canonicalInput)), + integrity: { ...fixture.integrity, canonicalInput }, + provenance: { ...fixture.provenance, compiledAt } + }; + }; + + assert.deepEqual(parseVerifiedCompiledBundle(withTimestamp("2026-07-13T09:00:00.123+09:00")), withTimestamp("2026-07-13T09:00:00.123+09:00")); + for (const value of ["2026-07-13", "July 13, 2026", "2026-07-13T00:00:00", "2026-02-30T00:00:00Z"]) { + assert.throws(() => parseVerifiedCompiledBundle(withTimestamp(value))); + } +}); + +test("AT-AST-033 compiled bundle verification rejects JSON integer values outside the safe range", () => { + const fixture = JSON.parse(readFileSync("packages/contracts/fixtures/compiled-bundle.v1.json", "utf8")); + const canonicalInput = { ...fixture.integrity.canonicalInput, version: 9_007_199_254_740_992 }; + const overRange = { + ...fixture, + version: 9_007_199_254_740_992, + contentHash: sha256Hex(canonicalJson(canonicalInput)), + integrity: { ...fixture.integrity, canonicalInput } + }; + + assert.throws(() => parseVerifiedCompiledBundle(overRange)); }); diff --git a/tests/release-contract.test.ts b/tests/release-contract.test.ts index d7fc944..c1f4fc2 100644 --- a/tests/release-contract.test.ts +++ b/tests/release-contract.test.ts @@ -7,6 +7,21 @@ const scriptPath = import.meta.url.includes("/dist/tests/") const { validateReleaseContract } = await import(scriptPath.href); test("AT-AST-023 release contract aligns the package and OpenAPI versions", () => { - assert.doesNotThrow(() => validateReleaseContract({ packageVersion: "1.2.3", openApi: "openapi: 3.1.0\ninfo:\n version: 1.2.3\n" })); - assert.throws(() => validateReleaseContract({ packageVersion: "1.2.3", openApi: "openapi: 3.1.0\ninfo:\n version: 1.2.2\n" }), /must equal package version/); + const compileContract = ` +paths: + /v1/personas/{personaId}/versions/{version}/compile: + post: + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/CompiledBundle" +components: + schemas: + CompiledBundle: + $ref: "../schemas/compiled-bundle.schema.json" +`; + assert.doesNotThrow(() => validateReleaseContract({ packageVersion: "1.2.3", openApi: `openapi: 3.1.0\ninfo:\n version: 1.2.3\n${compileContract}` })); + assert.throws(() => validateReleaseContract({ packageVersion: "1.2.3", openApi: `openapi: 3.1.0\ninfo:\n version: 1.2.2\n${compileContract}` }), /must equal package version/); });