diff --git a/layers/artifact-delivery/server/utils/checks.ts b/layers/artifact-delivery/server/utils/checks.ts index 37181be1..d9e74702 100644 --- a/layers/artifact-delivery/server/utils/checks.ts +++ b/layers/artifact-delivery/server/utils/checks.ts @@ -1,6 +1,7 @@ import type { ArtifactFile, CheckResult, ResolvedSource } from '../schemas/contracts' import type { ArtifactSourceFile } from './github-source' import { digestHex } from './encoding' +import { ARTIFACT_POLICY_VERSION } from './state' /** * 2026-10-07: the check became advisory. It still reports every finding, but @@ -14,13 +15,39 @@ const PATH_POLICY_VERSION = '1' const CREDENTIAL_MATERIAL_VERSION = '1' const EXECUTABLE_FILES_VERSION = '1' -const CURRENT_ARTIFACT_CHECKS = new Map([ +/** The checks a statement under one policy carries, by check name. */ +export type ArtifactCheckSet = ReadonlyMap + +const CURRENT_ARTIFACT_CHECKS: ArtifactCheckSet = new Map([ ['path-policy', { version: PATH_POLICY_VERSION, required: true }], ['agent-skills-spec', { version: AGENT_SKILLS_CHECK_VERSION, required: false }], ['credential-material', { version: CREDENTIAL_MATERIAL_VERSION, required: true }], ['executable-files', { version: EXECUTABLE_FILES_VERSION, required: false }], ]) +/** + * Every policy the artifact signer signs, with the checks its statements carry. + * + * The deploy updates the signer before the site, and a failed smoke rolls the + * site back alone. In both windows the running site stages statements under + * the policy before the signer's, so the signer signs that one too. When you + * bump `ARTIFACT_POLICY_VERSION`, replace the previous entry with the policy + * you bumped from. Leave it out only when the bump closes a safety gap, so the + * signer refuses the old policy at once. + * + * The site never reuses a build under another policy, so a statement signed + * under the previous one only finishes a run already in flight. + */ +export const SIGNABLE_ARTIFACT_POLICIES: ReadonlyMap = new Map([ + [ARTIFACT_POLICY_VERSION, CURRENT_ARTIFACT_CHECKS], + ['2026-08-20.1', new Map([ + ['path-policy', { version: '1', required: true }], + ['agent-skills-spec', { version: '2026-08-20', required: true }], + ['credential-material', { version: '1', required: true }], + ['executable-files', { version: '1', required: false }], + ])], +]) + export interface CheckedArtifactSource { files: ArtifactFile[] checkResults: CheckResult[] @@ -151,11 +178,11 @@ export async function checkArtifactSource( } } -export function checksBlockArtifact(checks: CheckResult[]): boolean { +export function checksBlockArtifact(checks: CheckResult[], checkSet: ArtifactCheckSet = CURRENT_ARTIFACT_CHECKS): boolean { const byName = new Map(checks.map(check => [check.name, check])) if (byName.size !== checks.length) return true - for (const [name, expected] of CURRENT_ARTIFACT_CHECKS) { + for (const [name, expected] of checkSet) { const check = byName.get(name) if (!check || check.version !== expected.version || check.required !== expected.required) return true @@ -163,8 +190,11 @@ export function checksBlockArtifact(checks: CheckResult[]): boolean { return checks.some(check => check.required && (check.outcome === 'fail' || check.outcome === 'error')) } -export function checksPermitSigning(checks: CheckResult[]): boolean { - return !checksBlockArtifact(checks) +/** Whether checks permit signing under one policy. The current policy is the default. */ +export function checksPermitSigning(checks: CheckResult[], policyVersion: string = ARTIFACT_POLICY_VERSION): boolean { + const checkSet = SIGNABLE_ARTIFACT_POLICIES.get(policyVersion) + return checkSet !== undefined + && !checksBlockArtifact(checks, checkSet) && checks.every(check => !check.required || check.outcome === 'pass') } diff --git a/layers/artifact-delivery/server/utils/state.ts b/layers/artifact-delivery/server/utils/state.ts index 84bfba56..008b03ff 100644 --- a/layers/artifact-delivery/server/utils/state.ts +++ b/layers/artifact-delivery/server/utils/state.ts @@ -22,6 +22,9 @@ import { canonicalJson, digestHex } from './encoding' * Bump it when a change to loading, packaging, or checks changes what one * commit produces. A ready build signed under another version is never reused, * so the bump also makes every commit load from GitHub once more. + * + * The signer also signs the policy before this one, so a deploy window fails + * no run. Update `SIGNABLE_ARTIFACT_POLICIES` in `checks.ts` with each bump. */ export const ARTIFACT_POLICY_VERSION = '2026-10-07.1' diff --git a/test/unit/artifact-signer.test.ts b/test/unit/artifact-signer.test.ts index b9ba0905..7749459d 100644 --- a/test/unit/artifact-signer.test.ts +++ b/test/unit/artifact-signer.test.ts @@ -1,4 +1,4 @@ -import type { ResolvedSource } from '../../layers/artifact-delivery/server/schemas/contracts' +import type { CheckResult, ResolvedSource } from '../../layers/artifact-delivery/server/schemas/contracts' import type { ArtifactSignerBindings } from '../../workers/artifact-signer/src/handler' import { describe, expect, it, vi } from 'vitest' import { artifactR2Key } from '../../layers/artifact-delivery/server/utils/artifact-storage' @@ -7,7 +7,9 @@ import { createAttestationStatement, encodeAttestationStatement, } from '../../layers/artifact-delivery/server/utils/attestation' +import { SIGNABLE_ARTIFACT_POLICIES } from '../../layers/artifact-delivery/server/utils/checks' import { bytesToBase64Url, digestHex } from '../../layers/artifact-delivery/server/utils/encoding' +import { ARTIFACT_POLICY_VERSION } from '../../layers/artifact-delivery/server/utils/state' import { ARTIFACT_SIGNER_MAX_REQUEST_BYTES, handleArtifactSignerRequest, @@ -154,6 +156,33 @@ describe('artifact signing Worker', () => { fixture.close() }) + // The deploy updates the signer before the site, and a failed smoke rolls + // the site back alone. In both windows the running site stages statements + // under the policy before the signer's. The signer refused every one, so a + // deploy that bumped the policy failed each run in its window. + it('signs a statement the site staged under the previous policy', async () => { + const [policyVersion, checkSet] = [...SIGNABLE_ARTIFACT_POLICIES].find(([version]) => version !== ARTIFACT_POLICY_VERSION)! + const fixture = await createSignerFixture({ + policyVersion, + checks: [...checkSet].map(([name, check]) => ({ name, version: check.version, outcome: 'pass' as const, required: check.required })), + }) + + const response = await handleArtifactSignerRequest(attestRequest(fixture.artifactId), fixture.bindings, () => NOW) + + expect(response.status).toBe(200) + fixture.close() + }) + + it('rejects current check results under the previous policy', async () => { + const [policyVersion] = [...SIGNABLE_ARTIFACT_POLICIES].find(([version]) => version !== ARTIFACT_POLICY_VERSION)! + const fixture = await createSignerFixture({ policyVersion }) + + const response = await handleArtifactSignerRequest(attestRequest(fixture.artifactId), fixture.bindings, () => NOW) + + expect(await readCode(response)).toBe('CHECKS_BLOCKED') + fixture.close() + }) + it.each([ ['size', { sizeOffset: 1 }], ['stored digest', { storedDigest: 'f'.repeat(64) }], @@ -235,6 +264,8 @@ interface SignerFixtureOptions { body?: Uint8Array } privateArtifact?: boolean + policyVersion?: string + checks?: CheckResult[] } async function createSignerFixture(options: SignerFixtureOptions = {}) { @@ -255,21 +286,24 @@ async function createSignerFixture(options: SignerFixtureOptions = {}) { const r2Key = options.privateArtifact ? `v1/private/account/${RESOLUTION_ID}.bin` : artifactR2Key(contentSha256) - const checks = [ + const checks: CheckResult[] = options.checks ?? [ { name: 'path-policy', version: '1', outcome: options.checkOutcome ?? 'pass', required: true }, { name: 'agent-skills-spec', version: '2026-10-07', outcome: 'pass', required: false }, { name: 'credential-material', version: '1', outcome: 'pass', required: true }, { name: 'executable-files', version: '1', outcome: 'pass', required: false }, - ] as const - const statement = encodeAttestationStatement(createAttestationStatement({ - artifactId, - createdAt: new Date((NOW - 60) * 1000).toISOString(), - source: options.privateArtifact ? { ...source, visibility: 'private' } : source, - contentSha256, - contentBytes: artifactBytes.byteLength, - files: [{ path: 'SKILL.md', mode: 420, size: 1, sha256: 'a'.repeat(64) }], - checkResults: checks.map(check => ({ ...check })), - })) + ] + const statement = encodeAttestationStatement({ + ...createAttestationStatement({ + artifactId, + createdAt: new Date((NOW - 60) * 1000).toISOString(), + source: options.privateArtifact ? { ...source, visibility: 'private' } : source, + contentSha256, + contentBytes: artifactBytes.byteLength, + files: [{ path: 'SKILL.md', mode: 420, size: 1, sha256: 'a'.repeat(64) }], + checkResults: checks.map(check => ({ ...check })), + }), + ...(options.policyVersion ? { policyVersion: options.policyVersion } : {}), + }) sqlite.raw.prepare( `INSERT INTO artifact_resolutions ( id, request_fingerprint, state, state_version, diff --git a/workers/artifact-signer/src/handler.ts b/workers/artifact-signer/src/handler.ts index 5e928fcc..c384a0ca 100644 --- a/workers/artifact-signer/src/handler.ts +++ b/workers/artifact-signer/src/handler.ts @@ -9,7 +9,7 @@ import { } from '../../../layers/artifact-delivery/server/schemas/contracts' import { artifactR2Key } from '../../../layers/artifact-delivery/server/utils/artifact-storage' import { createAttestationSignaturePayload, encodeAttestationStatement } from '../../../layers/artifact-delivery/server/utils/attestation' -import { checksPermitSigning } from '../../../layers/artifact-delivery/server/utils/checks' +import { checksPermitSigning, SIGNABLE_ARTIFACT_POLICIES } from '../../../layers/artifact-delivery/server/utils/checks' import { base64ToBytes, bytesToBase64Url, canonicalJson, digestHex } from '../../../layers/artifact-delivery/server/utils/encoding' import { ARTIFACT_POLICY_VERSION } from '../../../layers/artifact-delivery/server/utils/state' @@ -211,9 +211,15 @@ function validateSigningRow(row: SigningRow, maximumAgeSeconds: number, now: num ) { return failures.artifactIdMismatch() } + // Checks count under the policy the statement names, when the signer signs + // it. An unknown policy counts under the current one and fails below. + const stagedPolicy = artifactAttestationStatementSchema.safeParse(parseJson(row.attestation_statement_json)) + const policyVersion = stagedPolicy.success && SIGNABLE_ARTIFACT_POLICIES.has(stagedPolicy.data.policyVersion) + ? stagedPolicy.data.policyVersion + : ARTIFACT_POLICY_VERSION const checkResultsValue = parseJson(row.check_results_json) const checkResults = z.array(checkResultSchema).min(1).max(100).safeParse(checkResultsValue) - if (!checkResults.success || !checksPermitSigning(checkResults.data)) + if (!checkResults.success || !checksPermitSigning(checkResults.data, policyVersion)) return failures.checksBlocked() const checkAge = now - row.updated_at if (checkAge < 0 || checkAge > maximumAgeSeconds) @@ -226,7 +232,7 @@ function validateSigningRow(row: SigningRow, maximumAgeSeconds: number, now: num const statement = artifactAttestationStatementSchema.safeParse(statementValue) if (!statement.success) return failures.statementChanged() - if (statement.data.policyVersion !== ARTIFACT_POLICY_VERSION) + if (!SIGNABLE_ARTIFACT_POLICIES.has(statement.data.policyVersion)) return failures.policyOutdated() if (row.attestation_statement_json !== encodeAttestationStatement(statement.data)) return failures.statementChanged()