Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ All notable project changes are recorded here. Dates use ISO 8601 (`YYYY-MM-DD`)
## [Unreleased]

### Added
- A contract whose canonical bytes differ from its base is refused at pre-commit, verify, and gate unless the approval marker names the new bytes. The field-level weakening list stays the explanation, and a tightening is still not a weakening.
- The repository commits package-lock.json at lockfile version 3, made with npm install --package-lock-only --ignore-scripts, so a gate can install this revision from the revision itself.
- `verify` and `gate` stay on the host unless `--isolated` is passed. That mode runs command, red-green and metric checks with no network, a fresh HOME, and the repository read-only except paths the check declares. The image digest is `env.image` on the contract under test. Without it, `--isolated` refuses and names `env.image`. The receipt records `isolated`, `env_digest` and `heuristic`. grep, wiring and the forbidden scan are one layer, not a boundary.
- Every subcommand refuses a flag it does not accept, exits 2, and names the unknown flag and the accepted ones. Nothing runs after that refusal (#33).
Expand Down
10 changes: 9 additions & 1 deletion bin/proofline.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,15 @@ try {
}
const c = P.C.load(root);
const ids = flags.all ? c.tasks.map((t) => t.id) : flags.ci ? (c.ci?.tasks || []) : [requested || P.activeTask(root)].filter(Boolean);
if (!ids.length) { if (flags.ci) { const e = P.C.validate(c); print({ decision: e.length ? 'FAIL' : 'PASS', problems: e }, e.length ? e.join('\n') : 'contract valid; no ci.tasks'); process.exitCode = e.length ? 1 : 0; break; } throw new Error('which task? (proofline next)'); }
if (!ids.length) {
if (flags.ci) {
// Clearing ci.tasks is still a contract change. Refuse it before the empty-list short-circuit.
const refusal = P.E.unapprovedContractChange(root, base());
if (refusal) { print({ decision: 'BLOCKED', blocked: [refusal] }, `${refusal}\n\nDECISION: BLOCKED`); process.exitCode = 2; break; }
const e = P.C.validate(c); print({ decision: e.length ? 'FAIL' : 'PASS', problems: e }, e.length ? e.join('\n') : 'contract valid; no ci.tasks'); process.exitCode = e.length ? 1 : 0; break;
}
throw new Error('which task? (proofline next)');
}
const outs = ids.map((id) => P.E.gate(root, id, { claims: claims(), base: base(), fresh: flags.fresh === true, skipTypes: flags['skip-pushed'] || flags.ci ? ['pushed'] : [] }));
const worst = outs.some((o) => o.decision === 'BLOCKED') ? 'BLOCKED' : outs.some((o) => o.decision === 'FAIL') ? 'FAIL' : 'PASS';
const note = settled ? `${settled.message}\n\n` : '';
Expand Down
31 changes: 31 additions & 0 deletions src/contract.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,37 @@ export function weakenings(base, cur) {
return out;
}

/** Fields whose canonical bytes differ. This is the refusal. weakenings() stays the explanation. */
export function contractFieldChanges(base, cur) {
if (!base || !cur) return [];
if (contractHash(base) === contractHash(cur)) return [];
const out = [];
const walk = (a, b, path) => {
if (Object.is(a, b)) return;
const aArr = Array.isArray(a);
const bArr = Array.isArray(b);
if (aArr || bArr) {
const left = aArr ? a : [];
const right = bArr ? b : [];
const n = Math.max(left.length, right.length);
for (let i = 0; i < n; i++) {
const id = (right[i] && right[i].id) || (left[i] && left[i].id) || String(i);
walk(left[i], right[i], path ? `${path}/${id}` : String(id));
}
return;
}
if (a && b && typeof a === "object" && typeof b === "object") {
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
for (const k of keys) walk(a[k], b[k], path ? `${path}/${k}` : k);
return;
}
const shown = (v) => (v === undefined ? "absent" : JSON.stringify(v));
out.push(`${path}: ${shown(a)} -> ${shown(b)}`);
};
walk(base, cur, "");
return out;
}

export function loadFile(f) { return JSON.parse(fs.readFileSync(f, 'utf8')); }

/** End index of the JSON array named `key`, so an append can keep every byte before it. */
Expand Down
36 changes: 32 additions & 4 deletions src/engine.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,26 @@ export function taskBase(root, c, t, explicit) {
return { ref, source, base, problems };
}

/** One line when the working contract differs from `explicit` (or the default base) and no marker approves it. */
export function unapprovedContractChange(root, explicit) {
const c = C.load(root);
const ref = explicit || C.defaultBase(c);
const base = resolveBase(root, ref);
if (!base) return null;
const s = git(['show', `${base}:proofline.json`], root);
if (!s.ok) return null;
let baseContract;
try { baseContract = JSON.parse(s.out); } catch { return null; }
if (C.contractHash(baseContract) === C.contractHash(c)) return null;
let raw;
try { raw = fs.readFileSync(path.join(root, 'proofline.json')); } catch { return null; }
if (approvalMarker(root, raw).approved) return null;
const weak = C.weakenings(baseContract, c);
return weak.length
? `contract weakened vs base (needs owner approval): ${weak.join('; ')}`
: 'contract bytes changed vs base (needs owner approval)';
}

const BASE_NEXT = 'gate against the revision the task started from: pass --base <rev> (for one commit, its parent), or start the task with `proofline next`, which pins the base. Do not pick a base that hides part of the work.';

/** The "## Base" section of a brief: which base verify and gate will use, and whether they can. */
Expand Down Expand Up @@ -135,9 +155,11 @@ export function verifyTask(root, taskId, opts = {}) {
const weak = C.weakenings(baseContract, c);
// The forbidden scan skips proofline.json (it declares the deny-list); a contract change is reported here instead.
let contractChange = !base ? 'unknown' : !baseContract ? 'added' : C.contractHash(baseContract) === C.contractHash(c) ? 'unchanged' : 'changed';
const fields = baseContract ? C.contractFieldChanges(baseContract, c) : [];
let marker = null;
const approveWeakening = () => {
if (!weak.length) return false;
const hashChanged = contractChange === 'changed';
const approveChange = () => {
if (!hashChanged) return false;
let raw = null;
try { raw = fs.readFileSync(path.join(root, 'proofline.json')); } catch { raw = null; }
const mark = approvalMarker(root, raw);
Expand All @@ -152,7 +174,13 @@ export function verifyTask(root, taskId, opts = {}) {
if (errs.length) blocked.push(...errs.map((e) => `contract invalid: ${e}`));
blocked.push(...baseProblems);
if (!chain.ok) blocked.push(`evidence log tampered at entry ${chain.at}: ${chain.reason}`);
if (!approveWeakening()) for (const w of weak) blocked.push(`contract weakened vs base (needs owner approval): ${w}`);
if (hashChanged && !approveChange()) {
// One blocked line. weakenings() is the explanation, so a real weakening stays length 1.
if (weak.length) blocked.push(`contract weakened vs base (needs owner approval): ${weak.join('; ')}`);
else blocked.push('contract bytes changed vs base (needs owner approval)');
} else if (!hashChanged) {
for (const w of weak) blocked.push(`contract weakened vs base (needs owner approval): ${w}`);
}
for (const r of results) {
if (r.status === 'error') blocked.push(`${r.id}: could not run: ${r.detail}`);
else if (r.status === 'blocked') blocked.push(`${r.id}: ${r.detail}`);
Expand All @@ -179,7 +207,7 @@ export function verifyTask(root, taskId, opts = {}) {
const pushed = pr ? pr.status === 'pass'
: !!hi.head && !git(['status', '--porcelain'], root).out.split('\n').some((l) => l && !l.includes('.proofline/')) && git(['rev-parse', '--verify', '--quiet', `refs/remotes/${tgt.remote}/${tgt.branch}^{commit}`], root).out === hi.head;
const reused = results.filter((r) => r.reused).map((r) => ({ id: r.id, type: r.type, ts: r.reused.ts, entry: r.reused.entry }));
return { isolated: iso.isolated, env_digest: iso.env_digest, heuristic: true, coverage_limits: COVERAGE_LIMITS, pushed, task: t.id, title: t.title, phase: t.phase, decision, base, base_ref: baseRef, base_source: baseSource, ...hi, contract_hash: C.contractHash(c), results, reuse: !reuse.on ? 'off' : fresh ? 'fresh' : 'on', reused, forbidden, weakenings: weak, contract_change: contractChange, marker, blocked, failing, measured: ctx.measured, claims: claimResults, changed: [...ctx.changed.keys()], ts: new Date().toISOString() };
return { isolated: iso.isolated, env_digest: iso.env_digest, heuristic: true, coverage_limits: COVERAGE_LIMITS, pushed, task: t.id, title: t.title, phase: t.phase, decision, base, base_ref: baseRef, base_source: baseSource, ...hi, contract_hash: C.contractHash(c), results, reuse: !reuse.on ? 'off' : fresh ? 'fresh' : 'on', reused, forbidden, weakenings: weak, contract_fields: fields, contract_change: contractChange, marker, blocked, failing, measured: ctx.measured, claims: claimResults, changed: [...ctx.changed.keys()], ts: new Date().toISOString() };
}

/** Corrective prompt tied to each failed criterion. */
Expand Down
9 changes: 7 additions & 2 deletions src/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -299,11 +299,16 @@ export function hookPreCommit(root) {
let cur; try { cur = JSON.parse(staged.toString('utf8')); } catch { return { ok: false, msg: 'staged proofline.json is not valid JSON' }; }
let base = null; const s = git(['show', 'HEAD:proofline.json'], root); if (s.ok) try { base = JSON.parse(s.out); } catch {}
w = C.weakenings(base, cur);
if (w.length) {
const fields = base && cur ? C.contractFieldChanges(base, cur) : [];
if (w.length || fields.length) {
// Same check as verify and gate: a regular file named with the sha256 of these bytes.
const mark = approvalMarker(root, staged);
if (mark.approved) w = [];
else note = [`sha256 ${mark.hex}`, mark.marker ? `marker ${mark.marker}` : 'marker (no approvals directory configured)'].join('\n');
else {
note = [`sha256 ${mark.hex}`, mark.marker ? `marker ${mark.marker}` : 'marker (no approvals directory configured)'].join('\n');
// One explanation line. A non-weakening edit is still the hash refusal.
w = [w.length ? w.join('; ') : 'canonical bytes differ from HEAD'];
}
}
} else if (git(['cat-file', '-e', 'HEAD:proofline.json'], root).ok) {
// Removed from the index. There are no staged bytes, so no marker name can approve the removal.
Expand Down
8 changes: 4 additions & 4 deletions test/cache.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { spawnSync } from 'node:child_process';
import * as P from '../src/index.mjs';
import { readLog, logPath } from '../src/evidence.mjs';
import { handle } from '../src/mcp.mjs';
import { repo, write, addTask, commitAll, sh, BIN } from './helpers.mjs';
import { repo, write, addTask, commitAll, commitBeforeBase, sh, BIN } from './helpers.mjs';

/**
* A repo with one task T whose command criterion appends a line to a counter file outside the repo on every run.
Expand All @@ -19,7 +19,7 @@ function counterRepo({ reuse = true, cmd = (count) => `echo run >> '${count}'`,
if (reuse !== null) c.defaults.reuse_results = reuse;
Object.assign(c.defaults, defaults); P.C.save(r, c);
addTask(r, { id: 'T', title: 't', criteria: [{ statement: 'suite', verifier: { type: 'command', cmd: cmd(count) } }] });
commitAll(r, 'task');
commitBeforeBase(r);
P.taskNext(r);
const runs = () => (fs.existsSync(count) ? fs.readFileSync(count, 'utf8').split('\n').filter(Boolean).length : 0);
return { r, runs, dir: path.dirname(r) };
Expand Down Expand Up @@ -104,7 +104,7 @@ test('only command and metric results are reused: a docs check on the same commi
addTask(r, { id: 'T', title: 't', criteria: [
{ verifier: { type: 'command', cmd: `echo run >> '${at('cmd')}'` } },
{ verifier: { type: 'docs', cmd: `echo run >> '${at('docs')}'`, paths: ['package.json'] } }] });
commitAll(r, 'task'); P.taskNext(r);
commitBeforeBase(r); P.taskNext(r);
const runs = (n) => fs.readFileSync(at(n), 'utf8').split('\n').filter(Boolean).length;
ok(cli(r, ['verify', 'T'])); ok(cli(r, ['gate', 'T']));
assert.equal(runs('cmd'), 1); assert.equal(runs('docs'), 2);
Expand Down Expand Up @@ -180,7 +180,7 @@ test('a metric PASS is reused with its measured value, and only within max_load_
write(r, { 'bench/b.mjs': `import fs from 'node:fs'; fs.appendFileSync(${JSON.stringify(count)}, 'run\\n'); console.log(JSON.stringify({ flushMs: 1200 }));\n` });
const c = P.C.load(r); c.defaults.reuse_results = true; P.C.save(r, c);
addTask(r, { id: 'M', title: 'm', criteria: [{ verifier: { type: 'metric', cmd: 'node bench/b.mjs', key: 'flushMs', op: '<', value: 2000 } }] });
commitAll(r, 'task'); P.taskNext(r);
commitBeforeBase(r); P.taskNext(r);
const runs = () => fs.readFileSync(count, 'utf8').split('\n').filter(Boolean).length;
const machine = () => ({ load1: 3, cpus: 4 });
assert.equal(P.E.verifyTask(r, 'M', { machine }).decision, 'PASS'); assert.equal(runs(), 1);
Expand Down
5 changes: 3 additions & 2 deletions test/claims.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import * as P from '../src/index.mjs';
import { parseClaims } from '../src/claims.mjs';
import { repo, write, addTask, commitAll, sh } from './helpers.mjs';
import { repo, write, addTask, commitBeforeBase, sh } from './helpers.mjs';

test('parses status lines and ignores other tables', () => {
const c = parseClaims('| X-1 | done | abc1234 | t.test.mjs | p50 12 ms | - |\nfoo | bar\nY | partial | - | - | - | seal', ['X-1', 'Y']);
Expand All @@ -12,8 +12,8 @@ test('parses status lines and ignores other tables', () => {
test('claimed numbers must match measured numbers', () => {
const r = repo({ files: { 'bench/b.mjs': "console.log(JSON.stringify({ p50: 128 }))\n", 'test/t.test.mjs': "import test from 'node:test'; test('t', () => {});\n" } });
addTask(r, { id: 'P', title: 'p', criteria: [{ verifier: { type: 'metric', cmd: 'node bench/b.mjs', key: 'p50', op: '<', value: 200 } }] });
commitBeforeBase(r);
P.taskNext(r);
commitAll(r); sh(r, 'push', '-q', 'origin', 'main');
const head = sh(r, 'rev-parse', '--short', 'HEAD');
const g = P.E.verifyTask(r, 'P', { claims: `P | done | ${head} | t.test.mjs | p50 15 ms | -` });
assert.equal(g.decision, 'FAIL'); assert.match(g.failing.join(), /claimed p50 15 but measured 128/);
Expand All @@ -24,6 +24,7 @@ test('claimed numbers must match measured numbers', () => {
test('done with no measurements, unknown commit, missing test, deferral are all rejected', () => {
const r = repo({ files: { 'bench/b.mjs': "console.log(JSON.stringify({ p50: 5 }))\n" } });
addTask(r, { id: 'P', title: 'p', criteria: [{ verifier: { type: 'metric', cmd: 'node bench/b.mjs', key: 'p50', op: '<', value: 20 } }] });
commitBeforeBase(r);
const g = P.E.verifyTask(r, 'P', { claims: 'P | done | deadbeef1 | ghost_test.mjs | fast | rest is a follow-up' });
const f = g.failing.join('\n');
for (const re of [/does not exist/, /ghost_test.mjs" not found/, /no measured numbers/, /follow-up/]) assert.match(f, re);
Expand Down
67 changes: 67 additions & 0 deletions test/contract_any_change.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import * as P from '../src/index.mjs';
import { repo, write, sh, commitBeforeBase, BIN } from './helpers.mjs';

function sample() {
const c = P.C.skeleton('t');
const t = P.C.makeTask({
id: 'T',
title: 't',
criteria: [{ verifier: { type: 'metric', cmd: 'node -e "console.log(JSON.stringify({p50:1}))"', key: 'p50', op: '<', value: 20 } }],
scope: ['src/**'],
});
const v = t.criteria[0].verifier;
v.pattern = 'alpha';
v.expect_exit = 0;
v.test_file = 'test/t.test.mjs';
v.env = { MODE: 'a' };
v.timeout_s = 30;
v.min_tests = 4;
c.tasks.push(t);
c.forbidden = { ...c.forbidden, ignore_paths: ['vendor/**'], protected_values: [{ file: 'pkg.json', pattern: '(\\d+)', direction: 'no_decrease' }] };
c.ci = { tasks: ['T'] };
return c;
}

const edits = [
['min_tests', (c) => { c.tasks[0].criteria[0].verifier.min_tests = 1; }],
['grep pattern', (c) => { c.tasks[0].criteria[0].verifier.pattern = 'beta'; }],
['scope.allow', (c) => { c.tasks[0].scope.allow = ['docs/**']; }],
['forbidden.ignore_paths', (c) => { c.forbidden.ignore_paths = ['vendor/**', 'tmp/**']; }],
['protected_values', (c) => { c.forbidden.protected_values[0].direction = 'no_increase'; }],
['ci.tasks', (c) => { c.ci.tasks = []; }],
['expect_exit', (c) => { c.tasks[0].criteria[0].verifier.expect_exit = 1; }],
['test_file', (c) => { c.tasks[0].criteria[0].verifier.test_file = 'test/other.test.mjs'; }],
['env', (c) => { c.tasks[0].criteria[0].verifier.env = { MODE: 'b' }; }],
['timeout_s', (c) => { c.tasks[0].criteria[0].verifier.timeout_s = 90; }],
['tightening', (c) => { c.tasks[0].criteria[0].verifier.value = 10; }],
];

const cli = (r, args) => spawnSync(process.execPath, [BIN, ...args, '--root', r], { encoding: 'utf8' });

function refused(name, edit) {
const r = repo();
write(r, { 'proofline.json': JSON.stringify(sample(), null, 2) + '\n' });
commitBeforeBase(r);
const cur = sample();
edit(cur);
write(r, { 'proofline.json': JSON.stringify(cur, null, 2) + '\n' });
sh(r, 'add', '--', 'proofline.json');
const hook = P.hookPreCommit(r);
assert.equal(hook.ok, false, `${name} pre-commit ${hook.msg}`);
const v = P.E.verifyTask(r, 'T');
assert.equal(v.decision, 'BLOCKED', `${name} verify ${[...v.blocked, ...v.failing].join('\n')}`);
assert.equal(v.blocked.filter((b) => /needs owner approval/.test(b)).length, 1, v.blocked.join('\n'));
const g = P.E.gate(r, 'T');
assert.equal(g.decision, 'BLOCKED', `${name} gate ${g.blocked.join('\n')}`);
const ci = cli(r, ['gate', '--ci']);
const text = `${ci.stdout}\n${ci.stderr}`;
assert.equal(ci.status, 2, `${name} gate --ci ${text}`);
assert.match(text, /needs owner approval/);
}

test('each field edit, including a tightening, is refused at pre-commit, verify, gate and gate --ci', () => {
for (const [name, edit] of edits) refused(name, edit);
});
4 changes: 2 additions & 2 deletions test/contract_sig.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { spawnSync } from 'node:child_process';
import test from 'node:test';
import assert from 'node:assert/strict';
import * as P from '../src/index.mjs';
import { repo, commitAll, BIN } from './helpers.mjs';
import { repo, commitBeforeBase, BIN } from './helpers.mjs';

const AGENTKEY = '/Users/imac27/programme-b/agentkey/bin/agentkey.mjs';

Expand Down Expand Up @@ -59,7 +59,7 @@ test('a pinned contract is refused until the owner signature matches, and the pu
fs.writeFileSync(path.join(root, 'guard', 'owner.pub'), pub(box.env));
const signed = run(root, ['contract', 'sign'], box.env);
assert.equal(signed.status, 0, signed.stderr);
commitAll(root, 'signed contract');
commitBeforeBase(root);
for (const args of COMMANDS) {
const r = run(root, args, box.env);
const text = `${r.stdout || ''}${r.stderr || ''}`;
Expand Down
5 changes: 4 additions & 1 deletion test/cycle.test.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as P from '../src/index.mjs';
import { repo, addTask } from './helpers.mjs';
import { repo, addTask, commitBeforeBase, sh } from './helpers.mjs';

const ok = [{ verifier: { type: 'command', cmd: 'true' } }];
const bad = [{ verifier: { type: 'command', cmd: 'false' } }];
Expand All @@ -11,10 +11,13 @@ test('next returns one task at a time, gated by phase and dependencies', () => {
addTask(r, { id: 'IMP-2', title: 'b', phase: 'implement', depends_on: ['IMP-1'], criteria: ok });
addTask(r, { id: 'IMP-1', title: 'a', phase: 'implement', criteria: ok });
addTask(r, { id: 'DES-1', title: 'd', phase: 'design', criteria: bad });
commitBeforeBase(r);
let n = P.taskNext(r); assert.equal(n.task, 'DES-1'); assert.match(n.brief, /# TASK DES-1/);
assert.equal(P.E.gate(r, 'DES-1').decision, 'FAIL');
assert.equal(P.taskNext(r).task, 'DES-1', 'design phase not verified: implement must wait');
const c = P.C.load(r); c.tasks.find((t) => t.id === 'DES-1').criteria = ok; P.C.save(r, c);
commitBeforeBase(r);
P.taskNext(r, { base: sh(r, 'rev-parse', 'HEAD') });
P.E.gate(r, 'DES-1');
n = P.taskNext(r); assert.equal(n.task, 'IMP-1');
P.E.gate(r, 'IMP-1');
Expand Down
Loading
Loading