diff --git a/.buildkite/scripts/provenance-coverage.sh b/.buildkite/scripts/provenance-coverage.sh index 0e0fbd6..b71c833 100644 --- a/.buildkite/scripts/provenance-coverage.sh +++ b/.buildkite/scripts/provenance-coverage.sh @@ -52,7 +52,7 @@ TABELLIO_REQUIRE_POSTGRES=1 TABELLIO_TEST_PG_SOCKET="$coverage_pg_socket" TABELL tests/provenance-security-scanners.test.mjs tests/provenance-review-result.test.mjs \ tests/provenance-review-publication.test.mjs \ tests/platform-config.test.mjs tests/preflight.test.mjs \ - tests/release-workflow.test.mjs tests/release-cli.test.mjs tests/checkpoint-evidence.test.mjs + tests/release-workflow.test.mjs tests/release-cli.test.mjs tests/checkpoint-evidence.test.mjs tests/checkpoint-proof.test.mjs cleanup_coverage_postgres # Preserve the same real recovery demo coverage without rerunning that suite diff --git a/docs/checkpoint-proof-handoff.md b/docs/checkpoint-proof-handoff.md new file mode 100644 index 0000000..b1d9982 --- /dev/null +++ b/docs/checkpoint-proof-handoff.md @@ -0,0 +1,40 @@ +# Private checkpoint proof preparation + +Prepare and verify a bounded metadata-only handoff from genuine local Entire 0.7.7 checkpoints. This command never captures a session, uploads evidence, changes an environment, creates credentials, or approves a gate. It reuses the canonical exporter and audits the actual serialized pack against the selected native metadata object IDs. Keep the native ref and private backup locally. + +Use Node 22, Entire 0.7.7, Git, and an existing authenticated GitHub CLI with repository read access. Fetch current public source refs first; check out the exact published candidate. Choose a new output directory outside both the source checkout and Git directory, with an existing private parent directory. + +```sh +npm run tabellio:checkpoint:proof -- prepare \ + --repo-id github.com/OWNER/REPO --event pull_request \ + --candidate FULL_COMMIT_ID --pull-request PR_NUMBER --out /private/handoff + +npm run tabellio:checkpoint:proof -- check \ + --out /private/handoff --scope-sha256 DIGEST_FROM_PREPARE +``` + +For the current landed main commit, use `--event push` and omit `--pull-request`. The tool reads GitHub's merged-PR association and preserves the original source head as the checkpoint range, while the envelope binds the landed candidate to its first parent. Source and landed trees must match. Missing source objects fail closed; fetch the public PR head into the existing checkout and retry. No history is reconstructed. + +Preparation creates a directory with mode 0700 and five files with mode 0600: `scope.json`, `metadata.bundle`, `envelope.json`, `part-1.txt`, and `part-2.txt`. It refuses existing output directories, repository/Git destinations (including symlinks), inline prompt references, unsupported metadata fields, extra packed blobs, and envelopes exceeding the existing two-secret transport limits. Failed preparation removes only its newly created directory. Successful preparation preserves the private files for review. Stdout contains scope and digests, never secret chunks or session content. + +Review `scope.json` privately. Approval must name its digest, repository, event, candidate, validation base, checkpoint range, destination, sharing scope, and cleanup plan. Keep the digest separately; verification uses it to detect changes to both scope and transport. The local check also rejects expired preparation (24 hours), moved local/remote candidate targets, changed PR merge bases, and changed merged-source associations. It does not validate a dirty worktree or grant approval. The existing hosted envelope has no expiry field: the 24-hour expiry protects local checking only, so run `check` immediately before an independently approved handoff and verify the exact hosted head again. + +After explicit approval, an operator may supply the two part files through the existing encrypted secret-input mechanism to an appropriately protected exact-run environment. Do not put these files in Git, PR comments, logs, artifacts, or a public checkpoint ref. This tool deliberately performs no remote mutation. Creating persistent access, changing protection, or sharing proof for another candidate requires separate authorization. A premerge handoff does not authorize a postmerge handoff. + +Record the real hosted validators and runner cleanup result, then remove only handoff-owned secrets/environment resources and temporary local transport copies. Missing proof leaves the hosted gate blocked; do not describe skipped validators as passed. Retain customer-owned native checkpoints and backups. Entire 0.7.7 has no authenticated commit binding, so the genuine trusted producer and its Git association remain part of the trust boundary; metadata filtering is not a substitute for that boundary. + +## What this command replaces + +One preparation command replaces manual repository/base selection, squash-source lookup, exporter argument assembly, serialized pack inspection, envelope/base64 construction, transport-size calculation, chunk splitting, private file creation, and scope digest assembly. One check command replaces manual comparisons of those files, expiry, the local/remote candidate, PR base freshness, and merged-source association. It makes no hosted gate or release-readiness claim. + +A normal future PR flow is: finish genuine capture and commit; push the exact source head; fetch current main; prepare proof for that published head and PR number; review the private scope and approve that exact handoff; run `check`; upload only the approved secret inputs; approve the protected job; wait for actual validators; clean owned transport resources; merge through protection. The current main workflow then requires a separately prepared and approved push envelope for the landed commit. Local preparation, exact-head validation, publication, evidence sharing, environment review, gate execution, merge and cleanup remain distinct boundaries. + +## Proposed reduction of the second handoff — not implemented + +The structural cause is the existing v1 envelope's exact `candidate` and `base`: a squash merge creates a new commit, so a PR envelope cannot validate main. Main runs separately after the temporary secrets have been removed. Reusing or rebinding the old PR approval implicitly would broaden authorization. + +A bounded code-level design could use an explicitly approved two-phase handoff capsule: name the repository, PR number, exact source head/tree and expected main parent before merging; authorize only that PR check and its single verified landed successor. After merge, read GitHub's merged-PR association, require the original head, identical tree and expected parent, derive the landed envelope, and run all existing validators again. Reject moved main, another PR, an unrelated commit, replay outside the capsule, expiry or an ambiguous association. Do not infer the source from commit subjects or manufacture a checkpoint. + +The encrypted proof would remain available only for that bounded lifecycle, with protected review and cleanup after postmerge validation or timeout/cancellation. Implementing the capsule, adding an authorized derived-commit scope, changing environment branch access or extending secret retention affects sharing/security policy and needs explicit approval before implementation or configuration. This proposal needs concurrency, replay, cancellation and cleanup regressions and review; it is not an enabled release process. It would not require a mandatory private control repository or persistent new credentials. + +The pack audit accepts the pinned native capture's numeric session/line-attribution counters and reference identifiers. It validates nested counter types; arbitrary text in counters and context-bearing fields such as summaries or review prompts remain unsupported and fail closed. The allowed shape follows [Entire 0.7.7 checkpoint metadata](https://github.com/entireio/cli/blob/v0.7.7/cmd/entire/cli/checkpoint/checkpoint.go). These transport fixtures are synthetic tests and never substitute for genuine captured checkpoints. diff --git a/package.json b/package.json index c41fd7d..88aef07 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,8 @@ "tabellio-vercel-deployment": "scripts/tabellio-vercel-deployment.mjs", "tabellio-local-store": "scripts/tabellio-local-store.mjs", "tabellio-provenance": "scripts/tabellio-provenance.mjs", - "tabellio-provenance-demo": "scripts/demo-provenance.mjs" + "tabellio-provenance-demo": "scripts/demo-provenance.mjs", + "tabellio-checkpoint-proof": "scripts/tabellio-checkpoint-proof.mjs" }, "bugs": { "url": "https://github.com/IntelIP/Tabellio/issues" @@ -119,7 +120,8 @@ "tabellio:provenance:demo": "node scripts/demo-provenance.mjs", "tabellio:provenance:security:check": "node scripts/check-provenance-security.mjs", "benchmark:provenance": "node scripts/benchmark-provenance.mjs", - "tabellio:checkpoint:export": "node scripts/export-checkpoint-metadata.mjs" + "tabellio:checkpoint:export": "node scripts/export-checkpoint-metadata.mjs", + "tabellio:checkpoint:proof": "node scripts/tabellio-checkpoint-proof.mjs" }, "engines": { "node": ">=20" diff --git a/scripts/lib/checkpoint-proof.mjs b/scripts/lib/checkpoint-proof.mjs new file mode 100644 index 0000000..6fb919e --- /dev/null +++ b/scripts/lib/checkpoint-proof.mjs @@ -0,0 +1,198 @@ +import { mergedPullRequestForCommit } from './merged-pull-request.mjs'; +import { createHash } from 'node:crypto'; +import { execFileSync } from 'node:child_process'; +import { mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join, relative, resolve } from 'node:path'; + +export const proofDigest = value => createHash('sha256').update(value).digest('hex'); +export const scopeDigest = scope => proofDigest(JSON.stringify(scope)); + +export function proofTransport(scope, bytes) { + const envelope = { schemaVersion: 'tabellio-private-checkpoint-proof/v1', repositoryId: scope.repositoryId, + candidate: scope.candidate, base: scope.base, nativeTip: scope.nativeTip, + sha256: proofDigest(bytes), bundle: bytes.toString('base64') }; + const text = JSON.stringify(envelope); + const midpoint = Math.floor(text.length / 2); + const parts = [text.slice(0, midpoint), text.slice(midpoint)]; + if (text.length > 98000 || parts.some(part => Buffer.byteLength(part) > 48000)) throw new Error('Proof exceeds the existing private transport limits.'); + return { text, parts }; +} + +export function verifyPreparedProof(scope, expectedDigest, bytes, text, parts, now = Date.now()) { + if (scopeDigest(scope) !== expectedDigest || scope.bundleSha256 !== proofDigest(bytes) + || !Number.isFinite(Date.parse(scope.expiresAt)) || Date.parse(scope.expiresAt) <= now) throw new Error('Proof scope changed or expired.'); + const transport = proofTransport(scope, bytes); + if (transport.text !== text || parts.length !== 2 || parts.some((part, index) => part !== transport.parts[index])) throw new Error('Prepared transport changed.'); +} + +// Inspect the serialized pack, not only Git's proposed filter traversal. Never import it. +export async function auditProofPack(repo, bytes, nativeTip, checkpointIds) { + const git = (...args) => execFileSync('git', args, { cwd: repo, encoding: 'utf8', timeout: 30000, stdio: ['pipe', 'pipe', 'pipe'] }).trim(); + const separator = bytes.indexOf(Buffer.from('\n\n')); + if (separator < 0 || bytes.subarray(separator + 2, separator + 6).toString() !== 'PACK') throw new Error('Invalid native bundle.'); + const header = bytes.subarray(0, separator).toString(); + if (!header.startsWith('# v3 git bundle\n') || header.split('\n').filter(line => /^[0-9a-f]{40} /.test(line)).join('\n') !== `${nativeTip} refs/heads/entire/checkpoints/v1` + || header.split('\n').some(line => line.startsWith('-'))) throw new Error('Unexpected bundle refs or prerequisites.'); + const expected = expectedMetadataBlobs(git, nativeTip, checkpointIds); + const temporary = await mkdtemp(join(tmpdir(), 'tabellio-proof-audit-')); + try { + const pack = join(temporary, 'native.pack'); + const index = join(temporary, 'native.idx'); + await writeFile(pack, bytes.subarray(separator + 2), { mode: 0o600, flag: 'wx' }); + git('index-pack', '--index-version=2', '-o', index, pack); + const actual = new Set(git('verify-pack', '-v', index).split('\n').filter(line => line.split(/\s+/)[1] === 'blob').map(line => line.split(' ')[0])); + if (actual.size !== expected.size || [...actual].some(oid => !expected.has(oid))) throw new Error('Unexpected packed content.'); + return { metadataBlobs: actual.size, transcriptsIncluded: false, promptsIncluded: false }; + } finally { + await rm(temporary, { recursive: true, force: true }); + } +} + +export async function readPreparedProof(out) { + const [scope, bytes, text, first, second] = await Promise.all([ + readFile(join(out, 'scope.json'), 'utf8'), readFile(join(out, 'metadata.bundle')), + readFile(join(out, 'envelope.json'), 'utf8'), readFile(join(out, 'part-1.txt'), 'utf8'), readFile(join(out, 'part-2.txt'), 'utf8'), + ]); + return { scope: JSON.parse(scope), bytes, text, parts: [first, second] }; +} + +export function assertProofTarget(candidate, head, remoteHead) { + if (candidate !== head || candidate !== remoteHead) throw new Error('Current target moved.'); +} + +export async function assertPrivateProofOutput(repo, gitDirectory, out) { + const destination = join(await realpath(dirname(resolve(out))), resolve(out).split('/').at(-1)); + for (const root of [await realpath(repo), await realpath(gitDirectory)]) { + const path = relative(root, destination); + if (path === '' || (!path.startsWith('../') && path !== '..' && !path.startsWith('/'))) throw new Error('Proof output must stay outside source and Git directories.'); + } +} + +function expectedMetadataBlobs(git, nativeTip, checkpointIds) { + const expected = new Set(); + for (const id of checkpointIds) { + if (!/^[0-9a-f]{12}$/.test(id)) throw new Error('Invalid checkpoint ID.'); + const prefix = `${id.slice(0, 2)}/${id.slice(2)}`; + const metadata = JSON.parse(git('show', `${nativeTip}:${prefix}/metadata.json`)); + if (!Array.isArray(metadata.sessions) || metadata.sessions.length === 0) throw new Error('Missing session metadata.'); + const paths = [`${prefix}/metadata.json`]; + for (const session of metadata.sessions) { + paths.push(sessionMetadataPath(prefix, session)); + } + for (const path of paths) { + const value = JSON.parse(git('show', `${nativeTip}:${path}`)); + assertMetadataFields(value); + expected.add(git('rev-parse', `${nativeTip}:${path}`)); + } + } + if (expected.size === 0) throw new Error('Missing checkpoint metadata.'); + return expected; +} + +function sessionMetadataPath(prefix, session) { + const match = typeof session.metadata === 'string' && session.metadata.match(new RegExp(`^/${prefix}/(0|[1-9][0-9]*)/metadata\\.json$`)); + if (!match) throw new Error('Unsupported session path.'); + const directory = `/${prefix}/${match[1]}`; + if ((session.prompt !== '' && session.prompt !== `${directory}/prompt.txt`) || session.transcript !== `${directory}/full.jsonl` + || Object.keys(session).some(key => !['metadata', 'prompt', 'transcript', 'content_hash'].includes(key))) throw new Error('Inline session content is unsupported.'); + return session.metadata.slice(1); +} + +function assertMetadataFields(value) { + const allowed = ['cli_version', 'checkpoint_id', 'strategy', 'checkpoints_count', 'files_touched', 'sessions', 'token_usage', 'session_id', 'created_at', 'agent', 'model', 'branch', 'save_step_count', 'turn_id', 'session_metrics', 'initial_attribution', 'combined_attribution', 'prompt_attributions', 'checkpoint_transcript_start', 'transcript_lines_at_start', 'transcript_identifier_at_start', 'is_task', 'tool_use_id']; + if (Object.keys(value).some(key => !allowed.includes(key))) throw new Error('Unsupported metadata fields require private review.'); + assertNativeCounters(value); +} + +export async function resolveProofScope(options, dependencies) { + const { repositoryId, event, candidate, pullRequest } = options; + const { repository, git, github, remote } = dependencies; + assertScopeOptions(options, repository); + const canonical = github(`repos/${repository.fullName}`); + if (`github.com/${canonical.full_name}` !== repositoryId) throw new Error('Canonical repository mismatch.'); + if (git('rev-parse', '--verify', `${candidate}^{commit}`) !== candidate) throw new Error('Candidate unavailable.'); + const targetRef = event === 'push' ? 'refs/heads/main' : `refs/pull/${pullRequest}/head`; + assertProofTarget(candidate, git('rev-parse', 'HEAD'), await remote(targetRef)); + await assertFreshMain(event, git, remote); + const base = event === 'push' ? git('rev-parse', `${candidate}^`) : git('merge-base', 'origin/main', candidate); + const checkpointHead = checkpointSource(event, candidate, repository, git, github); + return { repositoryId, event, candidate, targetRef, ...(pullRequest === undefined ? {} : { pullRequest }), + base, checkpointHead, checkpointBase: git('merge-base', base, checkpointHead) }; +} + +function assertScopeOptions({ repositoryId, event, candidate, pullRequest }, repository) { + if (!['pull_request', 'push'].includes(event) || !/^[0-9a-f]{40}$/.test(candidate)) throw new Error('Invalid candidate scope.'); + if (typeof repositoryId !== 'string' || repository.identity.toLowerCase() !== repositoryId.toLowerCase()) throw new Error('Repository mismatch.'); + if (event === 'pull_request' && !/^[1-9][0-9]*$/.test(pullRequest ?? '')) throw new Error('PR number required.'); + if (event === 'push' && pullRequest !== undefined) throw new Error('Push must not specify PR.'); +} + +async function assertFreshMain(event, git, remote) { + if (event === 'pull_request' && await remote('refs/heads/main') !== git('rev-parse', 'origin/main')) throw new Error('Fetch current main before preparing proof.'); +} + +function checkpointSource(event, candidate, repository, git, github) { + if (event !== 'push') return candidate; + const records = github(`repos/${repository.fullName}/commits/${candidate}/pulls`); + const source = mergedPullRequestForCommit(records, candidate)?.headCommit ?? candidate; + if (git('rev-parse', `${source}^{tree}`) !== git('rev-parse', `${candidate}^{tree}`)) throw new Error('Merged source tree mismatch.'); + return source; +} + +export async function resolveProofRepository(repo) { + const root = execFileSync('git', ['rev-parse', '--show-toplevel'], { + cwd: resolve(repo), encoding: 'utf8', timeout: 30000, stdio: ['pipe', 'pipe', 'pipe'], + }).trim(); + return realpath(root); +} + +// Pinned Entire 0.7.7 checkpoint.go: accept counters, never summaries/review prompts. +function assertNativeCounters(value) { + if (value.branch !== undefined && (typeof value.branch !== 'string' || /\s/.test(value.branch))) throw new Error('Invalid branch metadata.'); + assertNativeReferences(value); + assertCounterFields(value.session_metrics, ['duration_ms', 'turn_count', 'context_tokens', 'context_window_size']); + const attributionFields = ['calculated_at', 'agent_lines', 'agent_removed', 'human_added', 'human_modified', 'human_removed', 'total_committed', 'total_lines_changed', 'agent_percentage', 'metric_version']; + assertCounterFields(value.initial_attribution, attributionFields); + assertCounterFields(value.combined_attribution, attributionFields); + assertPromptCounters(value.prompt_attributions); +} + +function assertCounterFields(value, allowed) { + if (value === undefined) return; + if (value === null || typeof value !== 'object' || Array.isArray(value)) throw new Error('Invalid native counter object.'); + for (const [key, counter] of Object.entries(value)) { + if (!allowed.includes(key)) throw new Error('Unsupported native counter field.'); + if (key === 'calculated_at') { + if (typeof counter !== 'string' || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})$/.test(counter) || !Number.isFinite(Date.parse(counter))) throw new Error('Invalid attribution time.'); + } else if (typeof counter !== 'number' || !Number.isFinite(counter)) throw new Error('Native counters cannot contain text.'); + } +} + +function assertPromptCounters(value) { + if (value === undefined) return; + if (!Array.isArray(value)) throw new Error('Invalid prompt attribution counters.'); + for (const item of value) { + const { user_added_per_file, user_removed_per_file, ...counters } = item; + assertCounterFields(counters, ['checkpoint_number', 'user_lines_added', 'user_lines_removed', 'agent_lines_added', 'agent_lines_removed']); + for (const files of [user_added_per_file, user_removed_per_file]) { + assertFileCounters(files); + } + } +} + +function assertFileCounters(value) { + if (value === undefined) return; + if (value === null || typeof value !== 'object' || Array.isArray(value)) throw new Error('Invalid native file counters.'); + if (Object.values(value).some(counter => typeof counter !== 'number' || !Number.isFinite(counter))) throw new Error('Native counters cannot contain text.'); +} + +function assertNativeReferences(value) { + for (const key of ['turn_id', 'tool_use_id', 'transcript_identifier_at_start']) { + if (value[key] !== undefined && (typeof value[key] !== 'string' || !/^[A-Za-z0-9_.:-]{1,256}$/.test(value[key]))) throw new Error('Invalid native reference.'); + } + for (const key of ['save_step_count', 'checkpoint_transcript_start', 'transcript_lines_at_start']) { + if (value[key] !== undefined && (!Number.isSafeInteger(value[key]) || value[key] < 0)) throw new Error('Invalid native integer counter.'); + } + if (value.is_task !== undefined && typeof value.is_task !== 'boolean') throw new Error('Invalid native task flag.'); +} diff --git a/scripts/tabellio-checkpoint-proof.mjs b/scripts/tabellio-checkpoint-proof.mjs new file mode 100644 index 0000000..1c44431 --- /dev/null +++ b/scripts/tabellio-checkpoint-proof.mjs @@ -0,0 +1,67 @@ +#!/usr/bin/env node +import { execFileSync } from 'node:child_process'; +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parseCommandOptions, requireOptions } from './lib/cli-options.mjs'; +import { effectiveGitHubRepository, readRemoteRefOid } from './lib/github-repository.mjs'; +import { assertPrivateProofOutput, resolveProofRepository, resolveProofScope, auditProofPack, proofDigest, proofTransport, readPreparedProof, scopeDigest, verifyPreparedProof } from './lib/checkpoint-proof.mjs'; + +let ownedOutput; +let stage = 'options'; +try { + const options = parseCommandOptions(process.argv.slice(2), { + prepare: ['repo', 'repoId', 'event', 'candidate', 'pullRequest', 'out'], + check: ['repo', 'out', 'scopeSha256'], + }); + requireOptions(options, options.command === 'prepare' ? ['repoId', 'event', 'candidate', 'out'] : ['out', 'scopeSha256']); + const repo = await resolveProofRepository(options.repo ?? '.'); + const out = resolve(options.out); + const git = (...args) => execFileSync('git', args, { cwd: repo, encoding: 'utf8', timeout: 30000, stdio: ['pipe', 'pipe', 'pipe'] }).trim(); + stage = 'repository'; + const repository = await effectiveGitHubRepository({ repoPath: repo }, 'origin'); + const scopeNow = (repositoryId, event, candidate, pullRequest) => resolveProofScope({ repositoryId, event, candidate, pullRequest }, { + repository, git, + github: path => JSON.parse(execFileSync('gh', ['api', path], { encoding: 'utf8', timeout: 30000, stdio: ['pipe', 'pipe', 'pipe'] })), + remote: ref => readRemoteRefOid({ repoPath: repo, remote: 'origin', ref }), + }); + if (options.command === 'prepare') { + stage = 'scope'; + await assertPrivateProofOutput(repo, resolve(repo, git('rev-parse', '--git-common-dir')), out); + const range = await scopeNow(options.repoId, options.event, options.candidate, options.pullRequest); + await mkdir(out, { mode: 0o700 }); ownedOutput = out; + const exporter = fileURLToPath(new URL('./export-checkpoint-metadata.mjs', import.meta.url)); + stage = 'export'; + const exported = JSON.parse(execFileSync(process.execPath, [exporter, '--repo', repo, '--repo-id', range.repositoryId, + '--base', range.checkpointBase, '--commit', range.checkpointHead, '--out', join(out, 'metadata.bundle')], + { encoding: 'utf8', timeout: 120000, stdio: ['pipe', 'pipe', 'pipe'] })); + const bytes = await readFile(join(out, 'metadata.bundle')); + stage = 'pack-audit'; + const audit = await auditProofPack(repo, bytes, exported.nativeTip, exported.checkpointIds); + const scope = { schemaVersion: 'tabellio-checkpoint-handoff-scope/v1', ...range, nativeTip: exported.nativeTip, + checkpointIds: exported.checkpointIds, bundleSha256: proofDigest(bytes), bundleBytes: bytes.length, + ...audit, expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString() }; + const transport = proofTransport(scope, bytes); + for (const [name, value] of Object.entries({ 'scope.json': JSON.stringify(scope, null, 2), 'envelope.json': transport.text, + 'part-1.txt': transport.parts[0], 'part-2.txt': transport.parts[1] })) { + await writeFile(join(out, name), value, { mode: 0o600, flag: 'wx' }); + } + const current = await scopeNow(scope.repositoryId, scope.event, scope.candidate, scope.pullRequest); + if (JSON.stringify(current) !== JSON.stringify(range) || git('rev-parse', 'refs/heads/entire/checkpoints/v1') !== scope.nativeTip) throw new Error('Evidence changed during preparation.'); + ownedOutput = null; + console.log(JSON.stringify({ ok: true, ...range, scopeSha256: scopeDigest(scope), bundleSha256: scope.bundleSha256, + bundleBytes: scope.bundleBytes, metadataBlobs: audit.metadataBlobs, expiresAt: scope.expiresAt, uploaded: false })); + } else { + stage = 'check'; + const prepared = await readPreparedProof(out); + verifyPreparedProof(prepared.scope, options.scopeSha256, prepared.bytes, prepared.text, prepared.parts); + const current = await scopeNow(prepared.scope.repositoryId, prepared.scope.event, prepared.scope.candidate, prepared.scope.pullRequest); + for (const [key, value] of Object.entries(current)) if (prepared.scope[key] !== value) throw new Error('Candidate range changed.'); + await auditProofPack(repo, prepared.bytes, prepared.scope.nativeTip, prepared.scope.checkpointIds); + console.log(JSON.stringify({ ok: true, scopeSha256: options.scopeSha256, uploaded: false })); + } +} catch { + if (ownedOutput) await rm(ownedOutput, { recursive: true, force: true }); + console.error(JSON.stringify({ ok: false, stage, reason: 'Private proof preparation/check blocked. Verify exact Git scope, pinned Entire, GitHub read access, private metadata and transport limits locally.' })); + process.exitCode = 1; +} diff --git a/tests/checkpoint-proof.test.mjs b/tests/checkpoint-proof.test.mjs new file mode 100644 index 0000000..0bb7a27 --- /dev/null +++ b/tests/checkpoint-proof.test.mjs @@ -0,0 +1,168 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync, spawnSync } from 'node:child_process'; +import { access, chmod, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { assertPrivateProofOutput, assertProofTarget, auditProofPack, proofDigest, proofTransport, readPreparedProof, resolveProofRepository, resolveProofScope, scopeDigest, verifyPreparedProof } from '../scripts/lib/checkpoint-proof.mjs'; + +const bytes = Buffer.from('synthetic transport fixture, never provenance'); +const scope = { repositoryId: 'github.com/fixture/test', candidate: 'a'.repeat(40), base: 'b'.repeat(40), nativeTip: 'c'.repeat(40), + bundleSha256: proofDigest(bytes), expiresAt: '2030-01-01T00:00:00Z' }; + +test('transport binds exact scope and bytes; local expiry and every altered component fail closed', () => { + const { text, parts } = proofTransport(scope, bytes); + const digest = scopeDigest(scope); + verifyPreparedProof(scope, digest, bytes, text, parts, 0); + for (const changed of [{ ...scope, candidate: 'd'.repeat(40) }, { ...scope, base: 'd'.repeat(40) }, { ...scope, repositoryId: 'github.com/other/repo' }]) { + assert.throws(() => verifyPreparedProof(changed, digest, bytes, text, parts, 0)); + } + assert.throws(() => verifyPreparedProof(scope, digest, Buffer.from('changed'), text, parts, 0)); + assert.throws(() => verifyPreparedProof(scope, digest, bytes, text + ' ', parts, 0)); + assert.throws(() => verifyPreparedProof(scope, digest, bytes, text, [parts[1], parts[0]], 0)); + assert.throws(() => verifyPreparedProof(scope, digest, bytes, text, parts, Date.parse(scope.expiresAt))); + const invalid = { ...scope, expiresAt: 'invalid' }; + assert.throws(() => verifyPreparedProof(invalid, scopeDigest(invalid), bytes, text, parts, 0)); + assert.throws(() => proofTransport(scope, Buffer.alloc(72000))); +}); + +test('moved checkout or remote target rejects the previous handoff', () => { + assertProofTarget('a', 'a', 'a'); + assert.throws(() => assertProofTarget('a', 'b', 'a')); + assert.throws(() => assertProofTarget('a', 'a', 'b')); +}); + +test('private output rejects source, Git and symlink destinations; existing files are read exactly', async () => { + const root = await mkdtemp(join(tmpdir(), 'tabellio-proof-fixture-')); + try { + const repo = join(root, 'repo'); const gitDirectory = join(root, 'git'); + await mkdir(repo); await mkdir(gitDirectory); await symlink(repo, join(root, 'alias')); + await assert.rejects(assertPrivateProofOutput(repo, gitDirectory, join(repo, 'proof'))); + await assert.rejects(assertPrivateProofOutput(repo, gitDirectory, join(gitDirectory, 'proof'))); + await assert.rejects(assertPrivateProofOutput(repo, gitDirectory, join(root, 'alias', 'proof'))); + const out = join(root, 'safe'); await assertPrivateProofOutput(repo, gitDirectory, out); await mkdir(out); + const { text, parts } = proofTransport(scope, bytes); + for (const [name, value] of Object.entries({ 'scope.json': JSON.stringify(scope), 'metadata.bundle': bytes, 'envelope.json': text, 'part-1.txt': parts[0], 'part-2.txt': parts[1] })) await writeFile(join(out, name), value); + const prepared = await readPreparedProof(out); + verifyPreparedProof(prepared.scope, scopeDigest(scope), prepared.bytes, prepared.text, prepared.parts, 0); + } finally { await rm(root, { recursive: true, force: true }); } +}); + +test('serialized native pack preserves IDs and rejects extra blobs, wrong tips and inline content', async () => { + // Synthetic Git fixture exercises transport filtering only; it is never captured evidence. + const root = await mkdtemp(join(tmpdir(), 'tabellio-proof-pack-fixture-')); + const git = (...args) => execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim(); + try { + git('init', '-q'); git('config', 'user.name', 'Transport Fixture'); git('config', 'user.email', 'fixture@example.invalid'); + git('checkout', '-qb', 'entire/checkpoints/v1'); + const prefix = 'ab/cdef012345'; await mkdir(join(root, prefix, '0'), { recursive: true }); + const metadata = { checkpoint_id: 'abcdef012345', sessions: [{ metadata: `/${prefix}/0/metadata.json`, prompt: '', transcript: `/${prefix}/0/full.jsonl`, content_hash: 'fixture' }] }; + await writeFile(join(root, prefix, 'metadata.json'), JSON.stringify(metadata)); + await writeFile(join(root, prefix, '0/metadata.json'), JSON.stringify({ checkpoint_id: 'abcdef012345', session_id: 'synthetic', branch: 'fixture', save_step_count: 1, turn_id: 'fixture-turn', tool_use_id: 'fixture-tool', transcript_identifier_at_start: 'fixture-message', is_task: false, checkpoint_transcript_start: 0, transcript_lines_at_start: 0, session_metrics: { turn_count: 2 }, initial_attribution: { calculated_at: '2026-01-01T00:00:00Z', agent_lines: 3 }, combined_attribution: { agent_lines: 3 }, prompt_attributions: [{ checkpoint_number: 0, agent_lines_added: 3, user_added_per_file: { 'fixture.mjs': 2, calculated_at: 1 } }] })); + await writeFile(join(root, prefix, '0/full.jsonl'), 'private-transcript-marker'); + git('add', '.'); git('commit', '-qm', 'Synthetic transport fixture'); const tip = git('rev-parse', 'HEAD'); + const patterns = `/${prefix}/metadata.json\n/${prefix}/0/metadata.json\n`; + const filter = execFileSync('git', ['hash-object', '-w', '--stdin'], { cwd: root, input: patterns, encoding: 'utf8' }).trim(); + const bundle = join(root, 'filtered.bundle'); git('bundle', 'create', bundle, `--filter=sparse:oid=${filter}`, 'refs/heads/entire/checkpoints/v1'); + const filtered = await readFile(bundle); + assert.deepEqual(await auditProofPack(root, filtered, tip, ['abcdef012345']), { metadataBlobs: 2, transcriptsIncluded: false, promptsIncluded: false }); + await assert.rejects(auditProofPack(root, filtered, 'a'.repeat(40), ['abcdef012345'])); + await assert.rejects(auditProofPack(root, Buffer.from('not a pack'), tip, ['abcdef012345'])); + await assert.rejects(auditProofPack(root, filtered, tip, ['invalid'])); + await assert.rejects(auditProofPack(root, filtered, tip, [])); + const full = join(root, 'full.bundle'); git('bundle', 'create', '--version=3', full, 'refs/heads/entire/checkpoints/v1'); + await assert.rejects(auditProofPack(root, await readFile(full), tip, ['abcdef012345'])); + for (const [index, extra] of [ + { session_metrics: { turn_count: 'private-text' } }, + { initial_attribution: { agent_lines: 'private-text' } }, + { prompt_attributions: [{ agent_lines_added: 'private-text' }] }, + { session_metrics: { prompt: 'private-text' } }, + { summary: { intent: 'private-text' } }, + { initial_attribution: { calculated_at: '2026-01-01 (private prompt text)' } }, + { checkpoint_transcript_start: 'private-text' }, + { transcript_lines_at_start: 'private-text' }, + { transcript_identifier_at_start: 'private prompt text' }, + { prompt_attributions: [{ user_added_per_file: { calculated_at: '2026-01-01T00:00:00Z' } }] }, + ].entries()) { + await writeFile(join(root, prefix, '0/metadata.json'), JSON.stringify({ checkpoint_id: 'abcdef012345', ...extra })); + git('add', prefix); git('commit', '-qm', 'Rejected native text fixture'); + const unsafe = join(root, `unsafe-${index}.bundle`); const unsafeTip = git('rev-parse', 'HEAD'); + git('bundle', 'create', unsafe, `--filter=sparse:oid=${filter}`, 'refs/heads/entire/checkpoints/v1'); + await assert.rejects(auditProofPack(root, await readFile(unsafe), unsafeTip, ['abcdef012345']), /Native counters cannot contain text|Unsupported native counter field|Unsupported metadata fields|Invalid attribution time|Invalid native integer counter|Invalid native reference/); + } + metadata.sessions[0].prompt = 'inline-private-prompt'; await writeFile(join(root, prefix, 'metadata.json'), JSON.stringify(metadata)); + git('add', prefix); git('commit', '-qm', 'Unsupported inline prompt fixture'); const changed = git('rev-parse', 'HEAD'); + const inline = join(root, 'inline.bundle'); git('bundle', 'create', inline, `--filter=sparse:oid=${filter}`, 'refs/heads/entire/checkpoints/v1'); + await assert.rejects(auditProofPack(root, await readFile(inline), changed, ['abcdef012345'])); + } finally { await rm(root, { recursive: true, force: true }); } +}); + +test('push CLI accepts omitted PR number and reaches event-specific scope validation', async () => { + const root = await mkdtemp(join(tmpdir(), 'tabellio-proof-cli-fixture-')); + try { + execFileSync('git', ['init', '-q'], { cwd: root }); + execFileSync('git', ['remote', 'add', 'origin', 'https://github.com/fixture/test.git'], { cwd: root }); + const result = spawnSync(process.execPath, [resolve('scripts/tabellio-checkpoint-proof.mjs'), 'prepare', + '--repo', root, '--repo-id', 'github.com/fixture/test', '--event', 'push', '--candidate', 'invalid', '--out', join(root, 'rejected')], { encoding: 'utf8' }); + assert.equal(result.status, 1); + assert.equal(JSON.parse(result.stderr).stage, 'scope'); + assert.equal(result.stdout, ''); + const invalid = spawnSync(process.execPath, [resolve('scripts/tabellio-checkpoint-proof.mjs'), 'prepare', '--unknown', 'value'], { encoding: 'utf8' }); + assert.equal(invalid.status, 1); assert.equal(JSON.parse(invalid.stderr).stage, 'options'); + } finally { await rm(root, { recursive: true, force: true }); } +}); + +test('scope resolver separates landed and checkpoint ranges and rejects mismatched GitHub/target facts', async () => { + const candidate = 'a'.repeat(40), base = 'b'.repeat(40), source = 'c'.repeat(40); + const repository = { fullName: 'fixture/test', identity: 'github.com/fixture/test' }; + const options = { repositoryId: repository.identity, event: 'push', candidate }; + const merged = [{ number: 5, merged_at: '2026-01-01T00:00:00Z', merge_commit_sha: candidate, head: { sha: source } }]; + const dependencies = { + repository, + github: path => path.endsWith('/pulls') ? merged : { full_name: repository.fullName }, + git: (...args) => { + const ref = args.at(-1); + if (args[0] === 'merge-base' || ref === `${candidate}^` || ref === 'origin/main') return base; + if (ref.endsWith('^{tree}')) return 'tree'; + return candidate; + }, + remote: async () => candidate, + }; + const push = await resolveProofScope(options, dependencies); + assert.equal(push.checkpointHead, source); assert.equal(push.base, base); assert.equal(push.checkpointBase, base); + const prOptions = { ...options, event: 'pull_request', pullRequest: '5' }; + const prDeps = { ...dependencies, remote: async ref => ref === 'refs/heads/main' ? base : candidate }; + const pr = await resolveProofScope(prOptions, prDeps); + assert.equal(pr.checkpointHead, candidate); assert.equal(pr.targetRef, 'refs/pull/5/head'); + const direct = await resolveProofScope(options, { ...dependencies, github: path => path.endsWith('/pulls') ? [] : { full_name: repository.fullName } }); + assert.equal(direct.checkpointHead, candidate); + for (const invalid of [{ ...options, event: 'unknown' }, { ...options, candidate: 'invalid' }, { ...options, repositoryId: 'github.com/foreign/test' }, { ...options, pullRequest: '5' }, { ...prOptions, pullRequest: undefined }]) { + await assert.rejects(resolveProofScope(invalid, dependencies)); + } + await assert.rejects(resolveProofScope(options, { ...dependencies, github: () => ({ full_name: 'foreign/test' }) })); + await assert.rejects(resolveProofScope(options, { ...dependencies, git: (...args) => args.includes('--verify') ? base : dependencies.git(...args) })); + await assert.rejects(resolveProofScope(options, { ...dependencies, remote: async () => base })); + await assert.rejects(resolveProofScope(prOptions, dependencies)); + await assert.rejects(resolveProofScope(options, { ...dependencies, git: (...args) => args.at(-1) === `${source}^{tree}` ? 'different-tree' : dependencies.git(...args) })); +}); + +test('CLI resolves a checkout subdirectory before rejecting source-tree proof output', async () => { + const root = await mkdtemp(join(tmpdir(), 'tabellio-proof-subdirectory-fixture-')); + try { + execFileSync('git', ['init', '-q'], { cwd: root }); + execFileSync('git', ['remote', 'add', 'origin', 'https://github.com/fixture/test.git'], { cwd: root }); + await mkdir(join(root, 'scripts')); await mkdir(join(root, 'docs')); await mkdir(join(root, 'bin')); + const resolved = await resolveProofRepository(join(root, 'scripts')); + assert.equal(resolved, await resolveProofRepository(root)); + const marker = join(root, 'unexpected-github-call'); + // Read-access stub only: preparation must reject this location before any GitHub call. + const stub = join(root, 'bin', 'gh'); + await writeFile(stub, `#!/bin/sh\ntouch '${marker}'\nprintf '%s' '{"full_name":"fixture/test"}'\n`); await chmod(stub, 0o700); + const result = spawnSync(process.execPath, [resolve('scripts/tabellio-checkpoint-proof.mjs'), 'prepare', + '--repo', join(root, 'scripts'), '--repo-id', 'github.com/fixture/test', '--event', 'push', + '--candidate', 'a'.repeat(40), '--out', join(root, 'docs', 'handoff')], + { encoding: 'utf8', env: { ...process.env, PATH: `${join(root, 'bin')}:${process.env.PATH}` } }); + assert.equal(result.status, 1); assert.equal(JSON.parse(result.stderr).stage, 'scope'); + await assert.rejects(access(marker)); await assert.rejects(access(join(root, 'docs', 'handoff'))); + } finally { await rm(root, { recursive: true, force: true }); } +});