From 830c49a676b423ccb785839cd32224b2ca5b2368 Mon Sep 17 00:00:00 2001 From: TheCryptoDonkey Date: Tue, 22 Sep 2026 10:11:27 +0100 Subject: [PATCH 1/2] feat: preserve verified task handovers for continuation --- docs/TASK-HANDOVERS.md | 101 +++++++++++ docs/WORKER-PACKETS.md | 3 + package.json | 2 +- scripts/task-handover.mjs | 132 ++++++++++++++ scripts/worker-packet.mjs | 3 +- test/task-handover.test.mjs | 347 ++++++++++++++++++++++++++++++++++++ 6 files changed, 586 insertions(+), 2 deletions(-) create mode 100644 docs/TASK-HANDOVERS.md create mode 100644 scripts/task-handover.mjs create mode 100644 test/task-handover.test.mjs diff --git a/docs/TASK-HANDOVERS.md b/docs/TASK-HANDOVERS.md new file mode 100644 index 0000000..d736437 --- /dev/null +++ b/docs/TASK-HANDOVERS.md @@ -0,0 +1,101 @@ +# Resume a task without repeating discovery + +`scripts/task-handover.mjs` saves a compact progress record with an existing +[source packet](WORKER-PACKETS.md). It runs locally, makes no model calls and +does not depend on Oathrun. Use Node from `.nvmrc` and `npm run build` first. + +This is a checkout helper, not a published CLI, automatic client hook or an +Oathrun runtime integration. The person or agent doing the work supplies the +state. A fresh session explicitly runs `resume` before using it. + +## Save at a useful stopping point + +After edits, build a **fresh** source packet containing the evidence required +to continue, including changed files and relevant tests. Its allowed-file +snapshots bind present and absent files; an earlier pre-edit packet will fail. +Keep the same reviewed task boundaries unless a scope change is authorised. +Select sufficient evidence; hashing an incomplete selection cannot make it +complete. Do not include credentials or unrelated project data. + +Write a private state JSON file. Each acceptance check in the packet must have +exactly one result, using its zero-based index. For a packet with one check: + +```json +{ + "version": 1, + "status": "in_progress", + "completed": ["Implemented the selected change"], + "decisions": ["Retained the existing public interface"], + "checks": [{"index": 0, "outcome": "not_run", "evidence": null}], + "unresolvedQuestions": [], + "pendingEffects": [], + "nextAction": "Run the focused regression tests and review the diff" +} +``` + +Record actual outcomes as `passed`, `failed`, `not_run` or `unknown`. A passed +or failed check requires an evidence description: exact command, result and a +private log reference where available. The helper does not open that reference, +execute the command or authenticate the result. Claims supplied by a model are +still claims; review the evidence before accepting work. + +Use `blocked` when progress needs a decision or reconciliation. Put uncertain +external operations in `pendingEffects`, so the next agent knows to inspect +them before considering another attempt. This record does not prevent replay. + +`ready_for_review` requires all checks to be reported passing and no unresolved +questions in either the packet or state, or pending effects. There is deliberately +no `complete` or `accepted` status: those decisions belong to the actual reviewer. + +```sh +node scripts/task-handover.mjs save \ + --root /absolute/repository \ + --packet /private/task/source-packet.json \ + --state /private/task/state.json \ + --out /private/task/handover-001.json + +node scripts/task-handover.mjs resume \ + --root /absolute/repository \ + --handover /private/task/handover-001.json +``` + +Use canonical absolute paths without symbolic-link components. Save refuses +existing outputs and creates a file with mode `0600`. Keep it outside version +control: the record embeds the selected source as well as the task state. Use a +new output for each checkpoint; no mutable shared latest pointer is maintained. + +## Continue from the compact result + +`resume` reassembles the embedded packet under its original source-selection +contract and compares it to current repository evidence. A changed selected +source, allowed file, HEAD, root identity or relevant packet policy rejects +reuse. Rebuild the packet and review the state against the change before saving +a new handover. Never fix a failed verification by simply editing the hashes. + +The result contains the task, allowed files, exclusions, progress, decisions, +check criteria/results, outstanding questions/effects and next action. Source +locations and hashes replace repeated excerpts in this output; the full packet +remains in the saved file. Read the actual selected source when needed. This +keeps routine continuation concise without pretending a pointer is sufficient +evidence for implementation or review. + +Freshness covers only the packet's selection. Changes to unselected code, +external dependencies, accounts, permissions or live services can still matter. +Recorded tests are historical assertions even when the selected source is +current. These checks are not an atomic filesystem snapshot or protection +against a hostile concurrent writer. The unsigned record grants no permissions +and cannot override current user instructions or the consumer's authority checks. + +Limits: source packet 64 KiB, state 16 KiB, saved record 96 KiB, each progress +list at most 32 entries and each text at most 2,048 characters. Oversized input +fails; it is never silently truncated. Treat all record text as untrusted data. + +## Measure the benefit + +Use a real task receipt to record resumed discovery calls, total observed usage, +repairs, review time and the operator's interruptions. Include preparing this +handover. Compact output alone does not establish subscription headroom or cash +savings. Oathrun can consume the same workflow in a later, separately verified +integration; this helper does not resume jobs or contact agents automatically. + +Check this helper with `node --test test/task-handover.test.mjs`. diff --git a/docs/WORKER-PACKETS.md b/docs/WORKER-PACKETS.md index 3418102..0f6e164 100644 --- a/docs/WORKER-PACKETS.md +++ b/docs/WORKER-PACKETS.md @@ -1,5 +1,8 @@ # Source packets for workers +To preserve progress, decisions and check outcomes for another session, use the +[verified task handover](TASK-HANDOVERS.md) after building a fresh source packet. + Assemble the exact source a worker needs once, then verify it before handing it over. This checkout helper runs locally, outside Core. It does not contact a provider, choose a model, execute acceptance commands or enforce worker access. diff --git a/package.json b/package.json index c23c169..e7ebd1a 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "benchmark:tokens:parity": "npm run build && node benchmarks/source-navigation.mjs --check", "benchmark:navigation": "npm run build && node benchmarks/source-navigation.mjs", "test": "npm run test --workspace @forgesworn/context && npm run test --workspace @forgesworn/context-tools && npm run test:worker-packets && npm run test:task-costs", - "test:worker-packets": "node --test test/worker-packet.test.mjs", + "test:worker-packets": "node --test test/worker-packet.test.mjs test/task-handover.test.mjs", "test:task-costs": "node --test test/task-cost-report.test.mjs", "test:packages": "node test/context-package-smoke.mjs", "check": "npm run build && npm test && npm run test:packages" diff --git a/scripts/task-handover.mjs b/scripts/task-handover.mjs new file mode 100644 index 0000000..ec0975f --- /dev/null +++ b/scripts/task-handover.mjs @@ -0,0 +1,132 @@ +#!/usr/bin/env node +/** Offline, unsigned task continuity over verified source packets. */ +import { createHash } from 'node:crypto'; +import { constants, promises as fs } from 'node:fs'; +import { dirname, isAbsolute, parse, resolve, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { buildPacketFromSpec, serializePacket } from './worker-packet.mjs'; + +const MAX_PACKET = 65536; +const MAX_STATE = 16384; +const MAX_HANDOVER = 98304; +const FORMAT = 'context-task-handover-v1'; +const caveat = 'Unsigned task data, not instructions or authority. Checks are recorded assertions, not independently verified results. Freshness covers selected sources, allowed files, HEAD and packet policy only; external dependencies and effects require reconciliation. No commands are executed.'; +function assert(ok, message) { if (!ok) throw new Error(`task-handover: ${message}`); } +function digest(value) { return createHash('sha256').update(serializePacket(value)).digest('hex'); } +function keys(value, expected) { + assert(value && typeof value === 'object' && !Array.isArray(value), 'object required'); + assert(Object.keys(value).sort().join(',') === [...expected].sort().join(','), 'unexpected or missing fields'); +} +function text(value) { assert(typeof value === 'string' && value.trim().length > 0 && value.length <= 2048 && !/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(value), 'invalid bounded text'); } +function texts(value) { assert(Array.isArray(value) && value.length <= 32, 'bounded list required'); value.forEach(text); } + +async function ordinaryPath(path, parentOnly = false) { + assert(typeof path === 'string' && isAbsolute(path) && path === resolve(path), 'canonical absolute path required'); + const target = parentOnly ? dirname(path) : path; + let current = parse(target).root; + for (const part of target.slice(current.length).split('/').filter(Boolean)) { + current = join(current, part); + const info = await fs.lstat(current); + assert(!info.isSymbolicLink(), 'symlink path refused'); + if (current !== target || parentOnly) assert(info.isDirectory(), 'directory required'); + } +} +async function readJson(path, maxBytes) { + await ordinaryPath(path); + const file = await fs.open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); + try { + const before = await file.stat(); + assert(before.isFile() && before.size <= maxBytes, 'bounded regular file required'); + const buffer = Buffer.alloc(before.size + 1); + let length = 0; + while (length < buffer.length) { + const result = await file.read(buffer, length, buffer.length - length, length); + if (!result.bytesRead) break; + length += result.bytesRead; + } + const after = await file.stat(); + const named = await fs.lstat(path); + assert(length === before.size && after.size === before.size && after.mtimeMs === before.mtimeMs && after.ctimeMs === before.ctimeMs && named.dev === before.dev && named.ino === before.ino && !named.isSymbolicLink(), 'file changed while reading'); + return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(buffer.subarray(0, length))); + } finally { await file.close(); } +} +function validateState(state, packet) { + keys(state, ['version', 'status', 'completed', 'decisions', 'checks', 'unresolvedQuestions', 'pendingEffects', 'nextAction']); + assert(state.version === 1, 'unsupported state version'); + assert(['in_progress', 'blocked', 'ready_for_review'].includes(state.status), 'invalid status'); + for (const key of ['completed', 'decisions', 'unresolvedQuestions', 'pendingEffects']) texts(state[key]); + text(state.nextAction); + const acceptance = packet.originalSpec.acceptanceChecks; + assert(Array.isArray(state.checks) && state.checks.length === acceptance.length, 'record every acceptance check'); + const seen = new Set(); + for (const check of state.checks) { + keys(check, ['index', 'outcome', 'evidence']); + assert(Number.isInteger(check.index) && check.index >= 0 && check.index < acceptance.length && !seen.has(check.index), 'invalid or duplicate check index'); + seen.add(check.index); + assert(['passed', 'failed', 'not_run', 'unknown'].includes(check.outcome), 'invalid check outcome'); + if (check.evidence !== null) text(check.evidence); + if (['passed', 'failed'].includes(check.outcome)) assert(check.evidence !== null, 'check evidence required'); + } + if (state.status === 'ready_for_review') { + assert(state.checks.every(check => check.outcome === 'passed') && !state.unresolvedQuestions.length && !state.pendingEffects.length && !packet.originalSpec.unresolvedQuestions.length, 'review readiness requires reported passing checks and no unresolved questions or effects'); + } + assert(Buffer.byteLength(serializePacket(state)) <= MAX_STATE, 'state too large'); +} +async function verifyCurrent(root, packet) { + assert(packet && typeof packet === 'object' && packet.originalSpec, 'source packet required'); + assert(Buffer.byteLength(serializePacket(packet)) <= MAX_PACKET, 'packet too large'); + const rebuilt = await buildPacketFromSpec({ root, spec: packet.originalSpec }); + assert(serializePacket(rebuilt) === serializePacket(packet), 'source packet is stale or changed; rebuild before continuing'); +} + +export async function saveHandover({ root, packet: packetPath, state: statePath, out }) { + const packet = await readJson(packetPath, MAX_PACKET); + const state = await readJson(statePath, MAX_STATE); + await verifyCurrent(root, packet); + validateState(state, packet); + const record = { format: FORMAT, savedAt: new Date().toISOString(), packetSha256: digest(packet), packet, state }; + const bytes = serializePacket(record) + '\n'; + assert(Buffer.byteLength(bytes) <= MAX_HANDOVER, 'handover too large'); + await ordinaryPath(out, true); + const file = await fs.open(out, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600); + try { await file.writeFile(bytes, 'utf8'); await file.sync(); } finally { await file.close(); } + return { format: FORMAT, status: state.status, bytes: Buffer.byteLength(bytes), packetSha256: record.packetSha256 }; +} + +export async function resumeHandover({ root, handover }) { + const record = await readJson(handover, MAX_HANDOVER); + keys(record, ['format', 'savedAt', 'packetSha256', 'packet', 'state']); + assert(record.format === FORMAT && typeof record.savedAt === 'string' && Number.isFinite(Date.parse(record.savedAt)), 'invalid handover format or timestamp'); + assert(record.packetSha256 === digest(record.packet), 'packet digest differs'); + await verifyCurrent(root, record.packet); + validateState(record.state, record.packet); + const spec = record.packet.originalSpec; + return { + format: FORMAT, trust: 'unsigned', freshness: 'current', savedAt: record.savedAt, + task: spec.task, gitHEAD: record.packet.gitHEAD, packetSha256: record.packetSha256, + allowedFiles: spec.allowedFiles, exclusions: spec.exclusions, taskUnresolvedQuestions: spec.unresolvedQuestions, + state: { ...record.state, checks: record.state.checks.map(check => ({ ...check, criterion: spec.acceptanceChecks[check.index] })) }, + sources: record.packet.sources.map(({ path, startLine, endLine, sha256 }) => ({ path, startLine, endLine, sha256 })), + caveat, + }; +} + +const usage = 'Usage: task-handover.mjs save --root ABS --packet ABS --state ABS --out ABS\n task-handover.mjs resume --root ABS --handover ABS\n'; +async function main(args) { + if (args.length === 1 && ['--help', '-h'].includes(args[0])) return process.stdout.write(usage); + let result; + if (args[0] === 'save') { + assert(args.length === 9 && args[1] === '--root' && args[3] === '--packet' && args[5] === '--state' && args[7] === '--out', usage); + result = await saveHandover({ root: args[2], packet: args[4], state: args[6], out: args[8] }); + } else { + assert(args[0] === 'resume' && args.length === 5 && args[1] === '--root' && args[3] === '--handover', usage); + result = await resumeHandover({ root: args[2], handover: args[4] }); + } + process.stdout.write(JSON.stringify(result) + '\n'); +} +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main(process.argv.slice(2)).catch(error => { + // Only our fixed validation messages are safe to print; parser/filesystem + // errors can echo private paths or input text. + process.stderr.write((error.message?.startsWith('task-handover:') ? error.message : 'task-handover: validation failed; check input schema, source freshness and file paths') + '\n'); + process.exitCode = 1; +}); diff --git a/scripts/worker-packet.mjs b/scripts/worker-packet.mjs index 8bc7fab..fff1e1b 100644 --- a/scripts/worker-packet.mjs +++ b/scripts/worker-packet.mjs @@ -28,6 +28,7 @@ function stable(value) { return value; } function serialize(value) { return JSON.stringify(stable(value)); } +export { serialize as serializePacket }; function strictText(bytes, label) { try { return new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes); } catch { throw new Error(`worker-packet: ${label} is not valid UTF-8`); } @@ -219,7 +220,7 @@ async function validateAllowedSnapshots(root, allowedFiles) { } } -async function buildPacketFromSpec({ root: rootInput, spec: specInput }) { +export async function buildPacketFromSpec({ root: rootInput, spec: specInput }) { const rootInfo = await canonicalRoot(rootInput); const root = rootInfo.root; const spec = parseSpec(serialize(specInput)); diff --git a/test/task-handover.test.mjs b/test/task-handover.test.mjs new file mode 100644 index 0000000..bb15aa5 --- /dev/null +++ b/test/task-handover.test.mjs @@ -0,0 +1,347 @@ +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { realpath, mkdtemp, rm, writeFile, readFile, stat, symlink } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { promisify } from 'node:util'; +import test, { after } from 'node:test'; +import { saveHandover, resumeHandover } from '../scripts/task-handover.mjs'; +import { buildPacket } from '../scripts/worker-packet.mjs'; + +const exec = promisify(execFile); +const fixtures = []; + +after(async () => { + await Promise.all(fixtures.map((root) => rm(root, { recursive: true, force: true }))); +}); + +async function fixture() { + const temp = await mkdtemp(join(tmpdir(), 'task-handover-')); + fixtures.push(temp); + const root = await realpath(temp); + await exec('git', ['init', '-q', root]); + await exec('git', ['-C', root, 'config', 'user.email', 'test@example.test']); + await exec('git', ['-C', root, 'config', 'user.name', 'Test']); + await writeFile(join(root, 'src.ts'), 'one\ntwo\nthree\n'); + await exec('git', ['-C', root, 'add', '.']); + await exec('git', ['-C', root, 'commit', '-qm', 'fixture']); + return root; +} + +async function makePacket(root, specData) { + const specPath = join(root, 'spec.json'); + await writeFile(specPath, JSON.stringify(specData)); + const packet = await buildPacket({ root, spec: specPath }); + const packetPath = join(root, 'packet.json'); + await writeFile(packetPath, JSON.stringify(packet)); + return { packetPath, packet, spec: specData }; +} + +function validState(spec, overrides = {}) { + const checks = spec.acceptanceChecks.map((c, index) => ({ + index, + outcome: 'passed', + evidence: 'evidence for ' + c + })); + return { + version: 1, + status: 'in_progress', + completed: ['task done'], + decisions: [], + checks, + unresolvedQuestions: [], + pendingEffects: [], + nextAction: 'continue work', + ...overrides + }; +} + +async function writeState(root, state, name = 'state.json') { + const path = join(root, name); + await writeFile(path, JSON.stringify(state)); + return path; +} + +test('current resume preserves nextAction and no source excerpts', async () => { + const root = await fixture(); + const spec = { + version: 1, + task: 'Test task', + acceptanceChecks: ['check 1', 'check 2'], + allowedFiles: ['src.ts'], + sources: [{ path: 'src.ts', startLine: 1, endLine: 3 }], + exclusions: [], + unresolvedQuestions: [] + }; + + const { packetPath } = await makePacket(root, spec); + const state = validState(spec); + const statePath = await writeState(root, state); + const outPath = join(root, 'handover.json'); + + const saved = await saveHandover({ root, packet: packetPath, state: statePath, out: outPath }); + assert.equal(saved.status, 'in_progress'); + + const resumed = await resumeHandover({ root, handover: outPath }); + + assert.equal(resumed.state.nextAction, 'continue work'); + assert.ok(Array.isArray(resumed.sources)); + for (const src of resumed.sources) { + assert.deepEqual(Object.keys(src).sort(), ['endLine', 'path', 'sha256', 'startLine']); + } + assert.equal(resumed.state.checks[1].criterion, 'check 2'); + assert.equal(resumed.trust, 'unsigned'); + assert.match(resumed.caveat, /recorded assertions/); + assert.equal((await stat(outPath)).mode & 0o777, 0o600); +}); + +test('source edit rejects stale resume', async () => { + const root = await fixture(); + const spec = { + version: 1, + task: 'Test task', + acceptanceChecks: ['check 1'], + allowedFiles: ['src.ts'], + sources: [{ path: 'src.ts', startLine: 1, endLine: 3 }], + exclusions: [], + unresolvedQuestions: [] + }; + + const { packetPath } = await makePacket(root, spec); + const state = validState(spec); + const statePath = await writeState(root, state); + const outPath = join(root, 'handover.json'); + + await saveHandover({ root, packet: packetPath, state: statePath, out: outPath }); + + await writeFile(join(root, 'src.ts'), 'changed\ncontent\nhere\n'); + + await assert.rejects( + async () => resumeHandover({ root, handover: outPath }), + /stale or changed/ + ); +}); + +test('ready_for_review rejects not_run check', async () => { + const root = await fixture(); + const spec = { + version: 1, + task: 'Test task', + acceptanceChecks: ['check 1', 'check 2'], + allowedFiles: ['src.ts'], + sources: [{ path: 'src.ts', startLine: 1, endLine: 3 }], + exclusions: [], + unresolvedQuestions: [] + }; + + const { packetPath } = await makePacket(root, spec); + const state = validState(spec, { + status: 'ready_for_review', + checks: [ + { index: 0, outcome: 'not_run', evidence: null }, + { index: 1, outcome: 'passed', evidence: 'ok' } + ] + }); + const statePath = await writeState(root, state); + const outPath = join(root, 'handover1.json'); + + await assert.rejects( + async () => saveHandover({ root, packet: packetPath, state: statePath, out: outPath }), + /review readiness/ + ); +}); + +test('ready_for_review rejects pendingEffects', async () => { + const root = await fixture(); + const spec = { + version: 1, + task: 'Test task', + acceptanceChecks: ['check 1', 'check 2'], + allowedFiles: ['src.ts'], + sources: [{ path: 'src.ts', startLine: 1, endLine: 3 }], + exclusions: [], + unresolvedQuestions: [] + }; + + const { packetPath } = await makePacket(root, spec); + const state = validState(spec, { + status: 'ready_for_review', + pendingEffects: ['some effect'] + }); + const statePath = await writeState(root, state); + const outPath = join(root, 'handover2.json'); + + await assert.rejects( + async () => saveHandover({ root, packet: packetPath, state: statePath, out: outPath }), + /review readiness/ + ); +}); + +test('passed check requires evidence', async () => { + const root = await fixture(); + const spec = { + version: 1, + task: 'Test task', + acceptanceChecks: ['check 1'], + allowedFiles: ['src.ts'], + sources: [{ path: 'src.ts', startLine: 1, endLine: 3 }], + exclusions: [], + unresolvedQuestions: [] + }; + + const { packetPath } = await makePacket(root, spec); + const state = validState(spec, { + checks: [ + { index: 0, outcome: 'passed', evidence: null } + ] + }); + const statePath = await writeState(root, state); + const outPath = join(root, 'handover.json'); + + await assert.rejects( + async () => saveHandover({ root, packet: packetPath, state: statePath, out: outPath }), + /evidence required/ + ); +}); + +test('save refuses overwrite', async () => { + const root = await fixture(); + const spec = { + version: 1, + task: 'Test task', + acceptanceChecks: ['check 1'], + allowedFiles: ['src.ts'], + sources: [{ path: 'src.ts', startLine: 1, endLine: 3 }], + exclusions: [], + unresolvedQuestions: [] + }; + + const { packetPath } = await makePacket(root, spec); + const state = validState(spec); + const statePath = await writeState(root, state); + const outPath = join(root, 'handover.json'); + + await saveHandover({ root, packet: packetPath, state: statePath, out: outPath }); + + await assert.rejects( + async () => saveHandover({ root, packet: packetPath, state: statePath, out: outPath }), + /EEXIST/ + ); +}); + +test('missing acceptance checks rejects', async () => { + const root = await fixture(); + const spec = { + version: 1, + task: 'Test task', + acceptanceChecks: ['check 1', 'check 2', 'check 3'], + allowedFiles: ['src.ts'], + sources: [{ path: 'src.ts', startLine: 1, endLine: 3 }], + exclusions: [], + unresolvedQuestions: [] + }; + + const { packetPath } = await makePacket(root, spec); + const state = validState(spec, { + checks: [ + { index: 0, outcome: 'passed', evidence: 'ok' }, + { index: 1, outcome: 'passed', evidence: 'ok' } + ] + }); + const statePath = await writeState(root, state); + const outPath = join(root, 'handover.json'); + + await assert.rejects( + async () => saveHandover({ root, packet: packetPath, state: statePath, out: outPath }), + /record every acceptance check/ + ); +}); + +async function savedCase(specOverrides = {}, stateOverrides = {}) { + const root = await fixture(); + const spec = { + version: 1, task: 'Continue the reviewed fixture task', + acceptanceChecks: ['Run the focused tests'], allowedFiles: ['src.ts', 'new.ts'], + sources: [{ path: 'src.ts', startLine: 1, endLine: 3 }], + exclusions: ['No publication'], unresolvedQuestions: [], ...specOverrides, + }; + const { packetPath } = await makePacket(root, spec); + const statePath = await writeState(root, validState(spec, stateOverrides)); + const handover = join(root, 'handover.json'); + await saveHandover({ root, packet: packetPath, state: statePath, out: handover }); + return { root, handover, packetPath, statePath }; +} + +test('review readiness preserves scope but never claims acceptance', async () => { + const { root, handover } = await savedCase({}, { status: 'ready_for_review' }); + const result = await resumeHandover({ root, handover }); + assert.equal(result.state.status, 'ready_for_review'); + assert.deepEqual(result.allowedFiles, ['src.ts', 'new.ts']); + assert.deepEqual(result.exclusions, ['No publication']); + assert.equal(result.accepted, undefined); +}); + +test('new allowed files, HEAD changes and policy edits invalidate continuation', async () => { + for (const mutate of [ + root => writeFile(join(root, 'new.ts'), 'new source\n'), + root => exec('git', ['-C', root, 'commit', '--allow-empty', '-qm', 'new revision']), + root => writeFile(join(root, '.gitignore'), 'src.ts\n'), + ]) { + const { root, handover } = await savedCase(); + await mutate(root); + await assert.rejects(resumeHandover({ root, handover })); + } +}); + +test('tampered source and cross-repository handovers are refused', async () => { + const { root, handover } = await savedCase(); + const other = await fixture(); + await assert.rejects(resumeHandover({ root: other, handover })); + const record = JSON.parse(await readFile(handover, 'utf8')); + record.packet.sources[0].lines = 'fabricated evidence'; + await writeFile(handover, JSON.stringify(record)); + await assert.rejects(resumeHandover({ root, handover }), /digest differs/); +}); + +test('resume revalidates state, duplicate checks and pending questions', async () => { + const { root, handover } = await savedCase({ acceptanceChecks: ['first', 'second'] }); + const original = JSON.parse(await readFile(handover, 'utf8')); + for (const change of [ + state => { state.status = 'complete'; }, + state => { state.checks[1].index = 0; }, + state => { state.unexpected = true; }, + state => { state.status = 'ready_for_review'; state.unresolvedQuestions = ['Needs owner decision']; }, + ]) { + const record = structuredClone(original); + change(record.state); + await writeFile(handover, JSON.stringify(record)); + await assert.rejects(resumeHandover({ root, handover })); + } + await assert.rejects(savedCase({ unresolvedQuestions: ['Scope still undecided'] }, { status: 'ready_for_review' }), /review readiness/); +}); + +test('bounded regular files and non-symlink paths are required', async () => { + const { root, handover, packetPath, statePath } = await savedCase(); + const alias = join(root, 'alias.json'); + await symlink(handover, alias); + await assert.rejects(resumeHandover({ root, handover: alias }), /symlink/); + const dirAlias = join(root, 'alias-dir'); + await symlink(root, dirAlias); + await assert.rejects(resumeHandover({ root, handover: join(dirAlias, 'handover.json') }), /symlink/); + await assert.rejects(saveHandover({ root, packet: packetPath, state: statePath, out: join(dirAlias, 'new.json') }), /symlink/); + await assert.rejects(resumeHandover({ root, handover: root }), /regular file/); + await writeFile(handover, Buffer.alloc(98305, 32)); + await assert.rejects(resumeHandover({ root, handover }), /bounded/); + await writeFile(handover, Buffer.from([0xff])); + await assert.rejects(resumeHandover({ root, handover })); +}); + +test('oversized state cannot be saved and blocked state keeps uncertain effects', async () => { + const pendingEffects = ['Inspect the existing remote operation before retrying']; + const { root, handover, packetPath, statePath } = await savedCase({}, { status: 'blocked', pendingEffects }); + assert.deepEqual((await resumeHandover({ root, handover })).state.pendingEffects, pendingEffects); + const state = JSON.parse(await readFile(statePath, 'utf8')); + state.completed = Array(10).fill('x'.repeat(2000)); + await writeFile(statePath, JSON.stringify(state)); + await assert.rejects(saveHandover({ root, packet: packetPath, state: statePath, out: join(root, 'too-large.json') }), /bounded/); +}); From a6184a3548efceb4b61197c2bef3e1ddcee2557a Mon Sep 17 00:00:00 2001 From: TheCryptoDonkey Date: Tue, 22 Sep 2026 10:14:13 +0100 Subject: [PATCH 2/2] fix: isolate fixture repositories from Git hook environment --- test/git-fixture.mjs | 12 ++++++++++++ test/task-handover.test.mjs | 4 +--- test/worker-packet.test.mjs | 23 ++++++++++++++++++++--- 3 files changed, 33 insertions(+), 6 deletions(-) create mode 100644 test/git-fixture.mjs diff --git a/test/git-fixture.mjs b/test/git-fixture.mjs new file mode 100644 index 0000000..a192cc1 --- /dev/null +++ b/test/git-fixture.mjs @@ -0,0 +1,12 @@ +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; + +const exec = promisify(execFile); + +// Git hooks export repository/index settings. A fixture's `git -C` alone does +// not override them: clear them before any fixture init/config/add/commit. +export function fixtureExec(file, args, options = {}) { + const env = Object.fromEntries(Object.entries(options.env ?? process.env) + .filter(([key]) => !key.startsWith('GIT_'))); + return exec(file, args, { ...options, env }); +} diff --git a/test/task-handover.test.mjs b/test/task-handover.test.mjs index bb15aa5..6192a5a 100644 --- a/test/task-handover.test.mjs +++ b/test/task-handover.test.mjs @@ -1,14 +1,12 @@ import assert from 'node:assert/strict'; -import { execFile } from 'node:child_process'; import { realpath, mkdtemp, rm, writeFile, readFile, stat, symlink } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { promisify } from 'node:util'; import test, { after } from 'node:test'; import { saveHandover, resumeHandover } from '../scripts/task-handover.mjs'; import { buildPacket } from '../scripts/worker-packet.mjs'; -const exec = promisify(execFile); +import { fixtureExec as exec } from './git-fixture.mjs'; const fixtures = []; after(async () => { diff --git a/test/worker-packet.test.mjs b/test/worker-packet.test.mjs index d37ae0c..81c10f7 100644 --- a/test/worker-packet.test.mjs +++ b/test/worker-packet.test.mjs @@ -1,15 +1,13 @@ import assert from 'node:assert/strict'; -import { execFile } from 'node:child_process'; import { createHash } from 'node:crypto'; import { mkdtemp, mkdir, readFile, rm, stat, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { basename, dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { promisify } from 'node:util'; import test, { after } from 'node:test'; import { buildPacket, verifyPacket } from '../scripts/worker-packet.mjs'; -const exec = promisify(execFile); +import { fixtureExec as exec } from './git-fixture.mjs'; const fixtures = []; const SCRIPT = fileURLToPath(new URL('../scripts/worker-packet.mjs', import.meta.url)); @@ -252,3 +250,22 @@ test('rejects tampered, invalid UTF-8 and oversized packets', async () => { await writeFile(oversized, 'x'.repeat(64 * 1024 + 1)); await assert.rejects(verifyPacket({ root, packet: oversized }), /exceeds/); }); + +test('fixture Git operations cannot inherit another repository from a hook', async () => { + const root = await fixture(); + const decoy = await fixture(); + const head = async path => (await exec('git', ['-C', path, 'rev-parse', 'HEAD'])).stdout; + const beforeRoot = await head(root); + const beforeDecoy = await head(decoy); + await exec('git', ['-C', root, 'commit', '--allow-empty', '-qm', 'fixture isolation'], { + env: { + ...process.env, + GIT_DIR: join(decoy, '.git'), GIT_WORK_TREE: decoy, + GIT_INDEX_FILE: join(decoy, '.git', 'index'), GIT_COMMON_DIR: join(decoy, '.git'), + GIT_CONFIG_COUNT: '1', GIT_CONFIG_KEY_0: 'user.name', GIT_CONFIG_VALUE_0: 'Wrong identity', + }, + }); + assert.notEqual(await head(root), beforeRoot); + assert.equal(await head(decoy), beforeDecoy); + assert.equal((await exec('git', ['-C', root, 'show', '-s', '--format=%an'])).stdout.trim(), 'Test'); +});