From 6ab0bf7e04a6ea9c0edfb461a92174b9ff75e123 Mon Sep 17 00:00:00 2001 From: Cuarroc <47299143+Cuarroc@users.noreply.github.com> Date: Mon, 5 Oct 2026 00:12:51 +0200 Subject: [PATCH 1/5] Add fail-closed app release attestation to continuous audit --attestation loads the user-written v1.5.0 attestation; any refusal yields appReleaseAttested=false with a named reason. Exit 0 iff appReleaseEligible. HQ stays all-false. Test-First: scripts/lib/release-attestation.test.mjs::attestation: missing file is refused with a named reason Test-First: scripts/lib/release-attestation.test.mjs::attestation: invalid JSON is refused Test-First: scripts/lib/release-attestation.test.mjs::attestation: non-object JSON is refused Test-First: scripts/lib/release-attestation.test.mjs::attestation: extra key is refused Test-First: scripts/lib/release-attestation.test.mjs::attestation: missing key is refused Test-First: scripts/lib/release-attestation.test.mjs::attestation: wrong release is refused Test-First: scripts/lib/release-attestation.test.mjs::attestation: rows must be exactly 1..26 Test-First: scripts/lib/release-attestation.test.mjs::attestation: bad decidedAt is refused Test-First: scripts/lib/release-attestation.test.mjs::attestation: empty decidedBy is refused Test-First: scripts/lib/release-attestation.test.mjs::attestation: continuousEnabled true is refused Test-First: scripts/lib/release-attestation.test.mjs::attestation: commit mismatch is refused Test-First: scripts/lib/release-attestation.test.mjs::attestation: dirty work tree is refused Test-First: scripts/lib/release-attestation.test.mjs::attestation: a non-repository is refused Test-First: scripts/lib/release-attestation.test.mjs::attestation: happy path with the attestation itself committed on top Test-First: scripts/lib/release-attestation.test.mjs::attestation: a later code commit invalidates it Co-Authored-By: Claude Sonnet 5.5 --- docs/setup/release-attestation.md | 14 +++ scripts/continuous-audit.mjs | 29 +++++-- scripts/lib/continuous-readiness.test.mjs | 6 +- scripts/lib/release-attestation.mjs | 58 +++++++++++++ scripts/lib/release-attestation.test.mjs | 101 ++++++++++++++++++++++ 5 files changed, 198 insertions(+), 10 deletions(-) create mode 100644 docs/setup/release-attestation.md create mode 100644 scripts/lib/release-attestation.mjs create mode 100644 scripts/lib/release-attestation.test.mjs diff --git a/docs/setup/release-attestation.md b/docs/setup/release-attestation.md new file mode 100644 index 00000000..cd9e8e88 --- /dev/null +++ b/docs/setup/release-attestation.md @@ -0,0 +1,14 @@ +# Release-Bestätigung für v1.5.0 + +Die App-Freigabe v1.5.0 gilt nur, wenn **du selbst** deine Abnahme bestätigst +(Entscheidung E20). Kein Agent legt die Datei `.pa/release_attestation_v1.5.0.json` +an oder ändert sie, und DevHQ bestätigt nie etwas. + +1. Prüfe alle 26 Zeilen der Abnahme und entscheide selbst. +2. Lege die Datei mit genau diesen Feldern an: `release` (`"v1.5.0"`), `commit` + (Ausgabe von `git rev-parse HEAD`), `rows` (`[1,2,…,26]`), `decidedAt` + (ISO 8601, z. B. `2026-10-05T10:00:00Z`), `decidedBy` (dein Kürzel) und + `continuousEnabled` (`false`). Weitere oder fehlende Felder lehnt die Prüfung ab. +3. Committe nur diese Datei; der Arbeitsbaum muss sonst sauber sein. +4. Prüfe mit `npm run dev:continuous-audit -- --attestation .pa/release_attestation_v1.5.0.json`. + Exit 0 heißt: freigabefähig. Jede spätere Code-Änderung macht die Bestätigung ungültig. diff --git a/scripts/continuous-audit.mjs b/scripts/continuous-audit.mjs index f3ead1f1..67113101 100644 --- a/scripts/continuous-audit.mjs +++ b/scripts/continuous-audit.mjs @@ -1,23 +1,31 @@ #!/usr/bin/env node import { evaluateContinuousReadiness } from './lib/continuous-readiness.mjs'; +import { loadReleaseAttestation } from './lib/release-attestation.mjs'; import { doctor, inspectRuntime } from './dev-setup.mjs'; import { fileURLToPath } from 'node:url'; export function parseArgs(argv = []) { - const options = { json: false, help: false }; - for (const arg of argv) { + const options = { json: false, help: false, attestation: null }; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; if (arg === '--json') options.json = true; - else if (arg === '--help' || arg === '-h') options.help = true; + else if (arg === '--attestation') { + options.attestation = argv[++i]; + if (!options.attestation) throw new Error('--attestation needs a path'); + } else if (arg === '--help' || arg === '-h') options.help = true; else throw new Error(`unknown option: ${arg}`); } return options; } export function usage() { - return 'usage: npm run dev:continuous-audit [-- --json|--help]'; + return 'usage: npm run dev:continuous-audit [-- --json|--help|--attestation ]'; } -export async function buildReport(root = process.cwd()) { +export async function buildReport(root = process.cwd(), { attestation = null } = {}) { + const appRelease = attestation + ? loadReleaseAttestation(attestation, root) + : { attested: false, reason: 'no --attestation given' }; const setup = doctor(root); const runtime = await inspectRuntime(); const readiness = evaluateContinuousReadiness({ @@ -38,9 +46,14 @@ export async function buildReport(root = process.cwd()) { continuousExecutionEnabled: false, stablePromotionAuthorized: false, // Written only by the user in .pa/release_attestation_v1.5.0.json (E20). - appReleaseAttested: false, + appReleaseAttested: appRelease.attested, }, }); + if (!appRelease.attested) { + const blocker = readiness.blockers.find(item => item.id === 'attestation:appRelease'); + if (blocker) blocker.reason = appRelease.reason; + readiness.attestations.appRelease.reason = appRelease.reason; + } return { generatedAt: new Date().toISOString(), command: 'dev:continuous-audit', @@ -56,9 +69,9 @@ export async function main(argv = process.argv.slice(2)) { console.log(usage()); return 0; } - const report = await buildReport(); + const report = await buildReport(process.cwd(), options); console.log(JSON.stringify(report, null, options.json ? 2 : 0)); - return report.continuousEligible && report.continuousReleaseEligible ? 0 : 1; + return report.appReleaseEligible ? 0 : 1; } if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { diff --git a/scripts/lib/continuous-readiness.test.mjs b/scripts/lib/continuous-readiness.test.mjs index 2c8fb0c0..24dba8f8 100644 --- a/scripts/lib/continuous-readiness.test.mjs +++ b/scripts/lib/continuous-readiness.test.mjs @@ -92,8 +92,10 @@ test('readiness report is schema version 2', () => { }); test('continuous audit accepts explicit output flags and rejects accidental execution flags', () => { - assert.deepEqual(parseArgs(['--json']), { json: true, help: false }); - assert.deepEqual(parseArgs(['--help']), { json: false, help: true }); + assert.deepEqual(parseArgs(['--json']), { json: true, help: false, attestation: null }); + assert.deepEqual(parseArgs(['--help']), { json: false, help: true, attestation: null }); assert.match(usage(), /dev:continuous-audit/); assert.throws(() => parseArgs(['--evidence', 'observations.json']), /unknown option/); + assert.equal(parseArgs(['--attestation', 'a.json']).attestation, 'a.json'); + assert.throws(() => parseArgs(['--attestation']), /needs a path/); }); diff --git a/scripts/lib/release-attestation.mjs b/scripts/lib/release-attestation.mjs new file mode 100644 index 00000000..029d9b1d --- /dev/null +++ b/scripts/lib/release-attestation.mjs @@ -0,0 +1,58 @@ +// Fail-closed loader for the user's app release attestation (E20). +// Only the user writes .pa/release_attestation_v1.5.0.json; no agent does. +import { execFileSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; + +export const ATTESTATION_RELEASE = 'v1.5.0'; +const KEYS = ['commit', 'continuousEnabled', 'decidedAt', 'decidedBy', 'release', 'rows']; +const ROWS = Array.from({ length: 26 }, (_, i) => i + 1); +const ISO_8601 = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/; + +const git = (root, args) => execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }); +const no = reason => ({ attested: false, reason }); + +/** Returns `{ attested, reason? }`; never throws. */ +export function loadReleaseAttestation(file, root = process.cwd()) { + try { + let data; + try { + data = JSON.parse(readFileSync(path.resolve(root, file), 'utf8')); + } catch (error) { + return no(`attestation file unreadable or not JSON: ${error.code ?? 'parse error'}`); + } + if (!data || typeof data !== 'object' || Array.isArray(data)) return no('attestation must be a JSON object'); + const keys = Object.keys(data).sort(); + if (keys.join() !== KEYS.join()) return no(`attestation keys must be exactly ${KEYS.join(', ')}`); + if (data.release !== ATTESTATION_RELEASE) return no(`release must be ${ATTESTATION_RELEASE}`); + if (JSON.stringify(data.rows) !== JSON.stringify(ROWS)) return no('rows must be exactly 1..26'); + if (typeof data.decidedAt !== 'string' || !ISO_8601.test(data.decidedAt) || Number.isNaN(Date.parse(data.decidedAt))) { + return no('decidedAt must be an ISO 8601 timestamp'); + } + if (typeof data.decidedBy !== 'string' || data.decidedBy.trim() === '') return no('decidedBy must be non-empty'); + if (data.continuousEnabled !== false) return no('continuousEnabled must be false'); + if (typeof data.commit !== 'string' || data.commit === '') return no('commit must be a non-empty string'); + let head; + try { + head = git(root, ['rev-parse', 'HEAD']).trim(); + if (git(root, ['status', '--porcelain']).trim() !== '') return no('work tree is not clean'); + } catch { + return no('git state unavailable (not a repository or no commit)'); + } + if (data.commit !== head) { + // Committing the attestation itself moves HEAD by one commit; accept + // exactly the commits that touch nothing but the attestation file. + let changed = null; + try { + changed = git(root, ['diff', '--name-only', `${data.commit}..${head}`]).split('\n').filter(Boolean); + } catch { /* unknown commit: falls through to the mismatch */ } + const rel = path.relative(root, path.resolve(root, file)).split(path.sep).join('/'); + if (!changed || changed.length === 0 || changed.some(name => name !== rel)) { + return no('commit does not match HEAD'); + } + } + return { attested: true }; + } catch (error) { + return no(`attestation check failed: ${error.message}`); + } +} diff --git a/scripts/lib/release-attestation.test.mjs b/scripts/lib/release-attestation.test.mjs new file mode 100644 index 00000000..ef5e60f4 --- /dev/null +++ b/scripts/lib/release-attestation.test.mjs @@ -0,0 +1,101 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { loadReleaseAttestation } from './release-attestation.mjs'; + +const FILE = '.pa/release_attestation_v1.5.0.json'; +const git = (cwd, ...args) => execFileSync('git', args, { cwd, encoding: 'utf8' }).trim(); + +function fixture() { + const root = mkdtempSync(path.join(tmpdir(), 'attest-')); + git(root, 'init', '-q'); + git(root, 'config', 'user.email', 'test@example.invalid'); + git(root, 'config', 'user.name', 'test'); + writeFileSync(path.join(root, 'a.txt'), 'a'); + git(root, 'add', '.'); + git(root, 'commit', '-q', '-m', 'init'); + mkdirSync(path.join(root, '.pa')); + return root; +} +const valid = root => ({ + release: 'v1.5.0', + commit: git(root, 'rev-parse', 'HEAD'), + rows: Array.from({ length: 26 }, (_, i) => i + 1), + decidedAt: '2026-10-05T10:00:00Z', + decidedBy: 'user', + continuousEnabled: false, +}); +const write = (root, value) => writeFileSync(path.join(root, FILE), typeof value === 'string' ? value : JSON.stringify(value)); +function refusal(mutate, pattern, { commit = false } = {}) { + const root = fixture(); + try { + const data = valid(root); + const out = mutate(data, root) ?? data; + write(root, out); + if (commit) { git(root, 'add', '.'); git(root, 'commit', '-q', '-m', 'att'); } + const result = loadReleaseAttestation(FILE, root); + assert.equal(result.attested, false); + assert.match(result.reason, pattern); + } finally { rmSync(root, { recursive: true, force: true }); } +} + +test('attestation: missing file is refused with a named reason', () => { + const root = fixture(); + try { + const result = loadReleaseAttestation(FILE, root); + assert.equal(result.attested, false); + assert.match(result.reason, /unreadable/); + } finally { rmSync(root, { recursive: true, force: true }); } +}); +test('attestation: invalid JSON is refused', () => refusal(() => '{nope', /not JSON/)); +test('attestation: non-object JSON is refused', () => refusal(() => '[1]', /JSON object/)); +test('attestation: extra key is refused', () => refusal(d => ({ ...d, extra: 1 }), /keys must be exactly/)); +test('attestation: missing key is refused', () => refusal(d => { delete d.decidedBy; }, /keys must be exactly/)); +test('attestation: wrong release is refused', () => refusal(d => { d.release = 'v1.4.0'; }, /release must be/)); +test('attestation: rows must be exactly 1..26', () => refusal(d => { d.rows = d.rows.slice(1); }, /rows must be/)); +test('attestation: bad decidedAt is refused', () => refusal(d => { d.decidedAt = 'yesterday'; }, /decidedAt/)); +test('attestation: empty decidedBy is refused', () => refusal(d => { d.decidedBy = ' '; }, /decidedBy/)); +test('attestation: continuousEnabled true is refused', () => refusal(d => { d.continuousEnabled = true; }, /continuousEnabled/)); +test('attestation: commit mismatch is refused', () => refusal(d => { d.commit = 'a'.repeat(40); }, /does not match HEAD/, { commit: true })); +test('attestation: dirty work tree is refused', () => { + const root = fixture(); + try { + write(root, valid(root)); + git(root, 'add', '.'); + git(root, 'commit', '-q', '-m', 'attest'); + writeFileSync(path.join(root, 'a.txt'), 'changed'); + const result = loadReleaseAttestation(FILE, root); + assert.equal(result.attested, false); + assert.match(result.reason, /not clean/); + } finally { rmSync(root, { recursive: true, force: true }); } +}); +test('attestation: a non-repository is refused', () => { + const root = mkdtempSync(path.join(tmpdir(), 'attest-nogit-')); + try { + mkdirSync(path.join(root, '.pa')); + write(root, { ...valid(fixture()), commit: 'a'.repeat(40) }); + const result = loadReleaseAttestation(FILE, root); + assert.equal(result.attested, false); + assert.match(result.reason, /git state unavailable/); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +test('attestation: happy path with the attestation itself committed on top', () => { + const root = fixture(); + try { + write(root, valid(root)); + git(root, 'add', '.'); + git(root, 'commit', '-q', '-m', 'attest'); + assert.deepEqual(loadReleaseAttestation(FILE, root), { attested: true }); + } finally { rmSync(root, { recursive: true, force: true }); } +}); +test('attestation: a later code commit invalidates it', () => refusal((d, root) => { + write(root, d); + git(root, 'add', '.'); + git(root, 'commit', '-q', '-m', 'attest'); + writeFileSync(path.join(root, 'a.txt'), 'later'); + git(root, 'commit', '-q', '-am', 'code'); +}, /does not match HEAD/)); From af119526bc95068101444726b92c4c267ffa20a4 Mon Sep 17 00:00:00 2001 From: Cuarroc <47299143+Cuarroc@users.noreply.github.com> Date: Mon, 5 Oct 2026 00:54:17 +0200 Subject: [PATCH 2/5] Add failing release attestation provenance tests Test-First: scripts/lib/release-attestation.test.mjs::attestation: an ignored untracked file is refused Test-First: scripts/lib/release-attestation.test.mjs::attestation: a non-ancestor commit is refused --- scripts/lib/release-attestation.test.mjs | 29 ++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/scripts/lib/release-attestation.test.mjs b/scripts/lib/release-attestation.test.mjs index ef5e60f4..41876c34 100644 --- a/scripts/lib/release-attestation.test.mjs +++ b/scripts/lib/release-attestation.test.mjs @@ -60,6 +60,35 @@ test('attestation: bad decidedAt is refused', () => refusal(d => { d.decidedAt = test('attestation: empty decidedBy is refused', () => refusal(d => { d.decidedBy = ' '; }, /decidedBy/)); test('attestation: continuousEnabled true is refused', () => refusal(d => { d.continuousEnabled = true; }, /continuousEnabled/)); test('attestation: commit mismatch is refused', () => refusal(d => { d.commit = 'a'.repeat(40); }, /does not match HEAD/, { commit: true })); +test('attestation: an ignored untracked file is refused', () => { + const root = fixture(); + try { + writeFileSync(path.join(root, '.gitignore'), '.pa/*\n'); + git(root, 'add', '.gitignore'); + git(root, 'commit', '-q', '-m', 'ignore attestations'); + write(root, valid(root)); + assert.equal(git(root, 'status', '--porcelain'), ''); + const result = loadReleaseAttestation(FILE, root); + assert.equal(result.attested, false); + assert.match(result.reason, /committed regular file/); + } finally { rmSync(root, { recursive: true, force: true }); } +}); +test('attestation: a non-ancestor commit is refused', () => { + const root = fixture(); + try { + const base = git(root, 'rev-parse', 'HEAD'); + git(root, 'checkout', '-q', '-b', 'sibling'); + git(root, 'commit', '-q', '--allow-empty', '-m', 'sibling'); + const sibling = git(root, 'rev-parse', 'HEAD'); + git(root, 'checkout', '-q', '--detach', base); + write(root, { ...valid(root), commit: sibling }); + git(root, 'add', '.'); + git(root, 'commit', '-q', '-m', 'attest'); + const result = loadReleaseAttestation(FILE, root); + assert.equal(result.attested, false); + assert.match(result.reason, /ancestor/); + } finally { rmSync(root, { recursive: true, force: true }); } +}); test('attestation: dirty work tree is refused', () => { const root = fixture(); try { From 994b23c10d89b0b30ccfff62bc948be908bfd185 Mon Sep 17 00:00:00 2001 From: Cuarroc <47299143+Cuarroc@users.noreply.github.com> Date: Mon, 5 Oct 2026 00:56:01 +0200 Subject: [PATCH 3/5] Require committed release attestation provenance Regression-For: af11952 --- .gitignore | 1 + scripts/lib/release-attestation.mjs | 12 +++++++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 404c6df0..baf477b8 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,7 @@ !.pa/patches/*.diff !.pa/audit_*.md !.pa/plan_*.md +!.pa/release_attestation_v1.5.0.json # Archiv alter Plan-, Stand-, Spec- und Review-Prompt-Dateien (PLAN-01, 25.09.). !.pa/archiv/ .omc/ diff --git a/scripts/lib/release-attestation.mjs b/scripts/lib/release-attestation.mjs index 029d9b1d..5ac9694d 100644 --- a/scripts/lib/release-attestation.mjs +++ b/scripts/lib/release-attestation.mjs @@ -15,6 +15,8 @@ const no = reason => ({ attested: false, reason }); /** Returns `{ attested, reason? }`; never throws. */ export function loadReleaseAttestation(file, root = process.cwd()) { try { + const rel = path.relative(root, path.resolve(root, file)).split(path.sep).join('/'); + if (!rel || rel === '..' || rel.startsWith('../')) return no('attestation path must be inside the repository'); let data; try { data = JSON.parse(readFileSync(path.resolve(root, file), 'utf8')); @@ -35,18 +37,26 @@ export function loadReleaseAttestation(file, root = process.cwd()) { let head; try { head = git(root, ['rev-parse', 'HEAD']).trim(); + const tracked = git(root, ['ls-files', '--stage', '--', rel]).trim(); + if (!/^100(?:644|755) [0-9a-f]+ 0\t/.test(tracked)) { + return no('attestation file unreadable: it must be a committed regular file'); + } if (git(root, ['status', '--porcelain']).trim() !== '') return no('work tree is not clean'); } catch { return no('git state unavailable (not a repository or no commit)'); } if (data.commit !== head) { + try { + git(root, ['merge-base', '--is-ancestor', data.commit, head]); + } catch { + return no('commit does not match HEAD: it must be an ancestor'); + } // Committing the attestation itself moves HEAD by one commit; accept // exactly the commits that touch nothing but the attestation file. let changed = null; try { changed = git(root, ['diff', '--name-only', `${data.commit}..${head}`]).split('\n').filter(Boolean); } catch { /* unknown commit: falls through to the mismatch */ } - const rel = path.relative(root, path.resolve(root, file)).split(path.sep).join('/'); if (!changed || changed.length === 0 || changed.some(name => name !== rel)) { return no('commit does not match HEAD'); } From 241835da83b441cf720a5169e88a9e2813aba64d Mon Sep 17 00:00:00 2001 From: Cuarroc <47299143+Cuarroc@users.noreply.github.com> Date: Mon, 5 Oct 2026 00:57:43 +0200 Subject: [PATCH 4/5] Add failing attestation blob binding test Test-First: scripts/lib/release-attestation.test.mjs::attestation: hidden working-copy content cannot replace the committed attestation --- scripts/lib/release-attestation.test.mjs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/scripts/lib/release-attestation.test.mjs b/scripts/lib/release-attestation.test.mjs index 41876c34..52819e32 100644 --- a/scripts/lib/release-attestation.test.mjs +++ b/scripts/lib/release-attestation.test.mjs @@ -73,6 +73,20 @@ test('attestation: an ignored untracked file is refused', () => { assert.match(result.reason, /committed regular file/); } finally { rmSync(root, { recursive: true, force: true }); } }); +test('attestation: hidden working-copy content cannot replace the committed attestation', () => { + const root = fixture(); + try { + write(root, valid(root)); + git(root, 'add', '.'); + git(root, 'commit', '-q', '-m', 'attest'); + git(root, 'update-index', '--assume-unchanged', FILE); + write(root, { ...valid(root), commit: git(root, 'rev-parse', 'HEAD^'), decidedBy: 'replacement' }); + assert.equal(git(root, 'status', '--porcelain'), ''); + const result = loadReleaseAttestation(FILE, root); + assert.equal(result.attested, false); + assert.match(result.reason, /committed content/); + } finally { rmSync(root, { recursive: true, force: true }); } +}); test('attestation: a non-ancestor commit is refused', () => { const root = fixture(); try { From 541b81109a9669704ba6e943ca7bc0b36b8fa9e5 Mon Sep 17 00:00:00 2001 From: Cuarroc <47299143+Cuarroc@users.noreply.github.com> Date: Mon, 5 Oct 2026 00:58:37 +0200 Subject: [PATCH 5/5] Bind attestation data to the committed blob Regression-For: 241835d --- scripts/lib/release-attestation.mjs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/scripts/lib/release-attestation.mjs b/scripts/lib/release-attestation.mjs index 5ac9694d..4d8354ba 100644 --- a/scripts/lib/release-attestation.mjs +++ b/scripts/lib/release-attestation.mjs @@ -42,6 +42,10 @@ export function loadReleaseAttestation(file, root = process.cwd()) { return no('attestation file unreadable: it must be a committed regular file'); } if (git(root, ['status', '--porcelain']).trim() !== '') return no('work tree is not clean'); + const committed = JSON.parse(git(root, ['show', `HEAD:${rel}`])); + if (JSON.stringify(data) !== JSON.stringify(committed)) { + return no('attestation file does not match its committed content'); + } } catch { return no('git state unavailable (not a repository or no commit)'); }