From 964fdf202cc20c2ab3895085c930e27a887a8541 Mon Sep 17 00:00:00 2001 From: mgillr Date: Fri, 16 Oct 2026 23:52:45 +0200 Subject: [PATCH 1/2] I record the approved v4.3 contract bytes. The file hash is 8a58ee25f081973979bd9a78b228524b3dfbb76a06b5ec69d9e3cf075c3bd0d6. I did not sign it. Signed-off-by: mgillr --- proofline.json | 142 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 142 insertions(+) diff --git a/proofline.json b/proofline.json index a48d99d..9ccd73f 100644 --- a/proofline.json +++ b/proofline.json @@ -1979,6 +1979,148 @@ "If a criterion cannot pass without weakening a rule, widening a bypass list, or signing with a key that is not the owner's: STOP and report. Never mark a boundary criterion passed by hand.", "If the environment a criterion needs is missing (repository admin, the owner key, a container runtime, OIDC): report BLOCKED with the exact missing item." ] + }, + { + "id": "PL-14", + "title": "The pre-push hook gates only commits the server does not already hold", + "phase": "implement", + "owner": "lane-c", + "depends_on": [ + "PL-09" + ], + "requirements": [ + "TB-R02" + ], + "scope": { + "allow": [ + "src/**", + "bin/**", + "test/**", + "docs/**", + "README.md", + "CHANGELOG.md" + ] + }, + "description": "Finding of 2026-10-16 (issue 31): sending a branch that was rebased onto a newer main made the sent range every commit not on the remote branch, which included tasks already landed on main, so the hook re-gated them and refused the send. The hook counts as already sent only commits reachable from the server's own head of the default branch, read for the push URL with `git ls-remote` at push time, never from local refs/remotes/*: a remote-tracking ref is local state that an agent can write (update-ref, a stale fetch), so trusting it would let a commit skip the gate. A rebased branch then gates its own task alone, and a commit placed under a local-only remote-tracking ref is still gated, on an existing branch and on the first push of a new branch alike (main at fe670fb already trusts refs/remotes for a new branch: measured by lane-b, one update-ref lets a failing commit through). If the server cannot be read, the hook gates every commit in the push range (fail closed).", + "criteria": [ + { + "id": "PL-14-AC01", + "statement": "a branch rebased onto a newer main and sent again gates only its own commits; a failing commit placed under a local-only refs/remotes ref is still gated and refused, on an existing branch and on the first push of a new branch; with the server unreadable every commit in the range is gated", + "verifier": { + "type": "test_red_green", + "test_file": "test/hook_push_range.test.mjs", + "cmd": "node --test {test}" + } + }, + { + "id": "PL-14-AC02", + "statement": "the hook section says which commits a send gates", + "verifier": { + "type": "grep", + "file": "README.md", + "pattern": "already on a remote|on no remote", + "must": "present" + } + }, + { + "id": "PL-14-AC03", + "statement": "suite green", + "verifier": { + "type": "command", + "cmd": "npm test", + "expect_exit": 0, + "timeout_s": 1800 + } + }, + { + "id": "PL-14-AC04", + "statement": "Changes stay in scope", + "verifier": { + "type": "scope" + } + }, + { + "id": "PL-14-AC05", + "statement": "Committed and pushed to main", + "verifier": { + "type": "pushed" + } + } + ], + "stop_rules": [ + "If a criterion cannot pass without weakening a rule, widening a bypass list, or signing with a key that is not the owner's: STOP and report. Never mark a boundary criterion passed by hand.", + "If the environment a criterion needs is missing (repository admin, the owner key, a container runtime, OIDC): report BLOCKED with the exact missing item." + ] + }, + { + "id": "PL-15", + "title": "Every subcommand refuses a flag it does not know", + "phase": "implement", + "owner": "lane-c", + "depends_on": [], + "requirements": [ + "TB-R02" + ], + "scope": { + "allow": [ + "src/**", + "bin/**", + "test/**", + "docs/**", + "README.md", + "CHANGELOG.md" + ] + }, + "description": "Finding of 2026-10-16 (issue 33): `proofline contract validate --file ` ignored --file and validated the contract at the root, so a whole day of proposals were reported valid without being read. Every subcommand checks its flags against the ones it accepts and exits 2 naming the unknown flag and the accepted ones; nothing runs on a refused command.", + "criteria": [ + { + "id": "PL-15-AC01", + "statement": "contract validate --file and an unknown flag on each subcommand exit 2 with the flag named; the accepted flags still work", + "verifier": { + "type": "test_red_green", + "test_file": "test/cli_unknown_flags.test.mjs", + "cmd": "node --test {test}" + } + }, + { + "id": "PL-15-AC02", + "statement": "the refusal names the flag", + "verifier": { + "type": "grep", + "file": "bin/proofline.mjs", + "pattern": "unknown flag", + "must": "present" + } + }, + { + "id": "PL-15-AC03", + "statement": "suite green", + "verifier": { + "type": "command", + "cmd": "npm test", + "expect_exit": 0, + "timeout_s": 1800 + } + }, + { + "id": "PL-15-AC04", + "statement": "Changes stay in scope", + "verifier": { + "type": "scope" + } + }, + { + "id": "PL-15-AC05", + "statement": "Committed and pushed to main", + "verifier": { + "type": "pushed" + } + } + ], + "stop_rules": [ + "If a criterion cannot pass without weakening a rule, widening a bypass list, or signing with a key that is not the owner's: STOP and report. Never mark a boundary criterion passed by hand.", + "If the environment a criterion needs is missing (repository admin, the owner key, a container runtime, OIDC): report BLOCKED with the exact missing item." + ] } ], "notes": "Each task carries the requirement ids it covers; next --owner hands out the tasks of one owner. Cross-repository needs are written into task text as an external dependency and reported BLOCKED when absent. PL-SELF stays: proofline verifies itself with its own contract.", From cedd01d125398726bd5449a510825d8d4cfc131d Mon Sep 17 00:00:00 2001 From: mgillr Date: Fri, 16 Oct 2026 23:52:45 +0200 Subject: [PATCH 2/2] I leave out a commit already on the server default branch (PL-14) I read that head with git ls-remote of the URL at send time, for an existing branch and for a new branch. A local remote-tracking ref is not that head. If the server cannot be read, I gate every commit in the range. This one commit carries the whole change. Signed-off-by: mgillr --- CHANGELOG.md | 1 + README.md | 2 +- bin/proofline.mjs | 3 +- src/index.mjs | 34 ++++++++--- test/hook_push_range.test.mjs | 105 ++++++++++++++++++++++++++++++++++ 5 files changed, 136 insertions(+), 9 deletions(-) create mode 100644 test/hook_push_range.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 965f4ce..0360e40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable project changes are recorded here. Dates use ISO 8601 (`YYYY-MM-DD`) ## [Unreleased] ### Added +- The pre-push hook leaves out a commit already on the server default branch. It reads that head with `git ls-remote` of the push URL at send time, for an existing branch and for a new branch. A local remote-tracking ref does not count. If the server cannot be read, every commit in the range is gated (#31, #35). - The pre-push hook gates the commit being pushed, at that sha, in a temporary worktree. It reads the local sha from stdin and does not gate the checked-out HEAD. The base is the most recent `proofline next` pin for the task, or the parent of that commit, and a refusal prints the base it used. `proofline next --base ` records a newer pin. A worktree with no evidence still gates, and stale evidence in the pushing worktree cannot block a commit that passes at its own sha (#21, #22, #23). - `proofline contract sign` writes `proofline.json.sig`, a signature over the canonical contract by the agentkey owner role. `proofline contract verify-sig` checks it against `guard/owner.pub` only. Once that pin is present, `next`, `verify`, `gate`, `brief` and both hooks exit 2 with `contract not owner-signed` when the signature is missing, altered or from another key. `proofline contract propose` writes `proposals/.diff` and prints the hash. - `proofline setup` installs the hooks, creates the owner key through agentkey and sets approvals to `.proofline/approvals`. `proofline task` writes a proposal from plain words and prints its hash. `proofline contract approve` shows the diff and, on y, writes the marker and applies the bytes. `proofline watch` prints the active task until the gate is PASS. diff --git a/README.md b/README.md index f23c455..dfdbd0d 100644 --- a/README.md +++ b/README.md @@ -202,7 +202,7 @@ Two or more builders can share one contract. A task may carry an `owner`, and `d `init` installs `pre-commit` (forbidden actions and contract weakening vs HEAD) and `pre-push` (the active task must gate PASS). If the repository already has one of these hooks and it is not proofline's, `init` keeps it as `.proofline-prev` and says so. The installed hook runs it first, with the same arguments and stdin; if it fails, its exit code stands and the gate does not run. Running `init` again changes nothing. If both `` and `.proofline-prev` exist and neither is proofline's, `init` stops rather than lose one. -A push is gated on the commit being pushed. The hook reads that commit's local sha from stdin, checks the commit out in a temporary worktree, and gates it there. It does not gate the checked-out HEAD. The task is the `(TASK-ID)` suffix on the subject, or a `Proofline-Task` trailer. A commit that names no task is gated on the active task. The base is the most recent pin from `proofline next` for that task, otherwise the parent of the commit being pushed, and every refusal prints that base. A worktree with no evidence still gates. Evidence left in the worktree you push from cannot block a commit that passes at its own sha. An active task whose head is an ancestor of origin/main, or whose pushed criterion has a PASS record, has landed: `next`, `verify` and the hooks clear `.proofline/active` and say so, and a landed task is never gated. A push with no active task and no named task is refused, and the refusal prints `proofline next`. Before any task has a start record, a push that names no task passes and prints `proofline next`. +A push is gated on the commit being pushed. The hook reads that commit's local sha from stdin, checks the commit out in a temporary worktree, and gates it there. It does not gate the checked-out HEAD. A commit already on a remote default branch is left out. That head is read with `git ls-remote` of the push URL at send time, never from a local remote-tracking ref, for an existing branch and for a new branch. A commit on no remote is still gated. If the server cannot be read, every commit in the range is gated. The task is the `(TASK-ID)` suffix on the subject, or a `Proofline-Task` trailer. A commit that names no task is gated on the active task. The base is the most recent pin from `proofline next` for that task, otherwise the parent of the commit being pushed, and every refusal prints that base. A worktree with no evidence still gates. Evidence left in the worktree you push from cannot block a commit that passes at its own sha. An active task whose head is an ancestor of origin/main, or whose pushed criterion has a PASS record, has landed: `next`, `verify` and the hooks clear `.proofline/active` and say so, and a landed task is never gated. A push with no active task and no named task is refused, and the refusal prints `proofline next`. Before any task has a start record, a push that names no task passes and prints `proofline next`. A contract change is refused unless an approval marker is present. `proofline init --approvals ` (re-runnable) stores that directory in `.proofline/config.json`, which is never committed. The marker is a file in that directory named with the sha256 of the staged `proofline.json` bytes. The refusal prints the weakenings, the sha256 and the marker path. A marker named for any other bytes does not count, and what else the commit stages makes no difference. With no directory configured, every contract change is refused. There is no bypass. diff --git a/bin/proofline.mjs b/bin/proofline.mjs index d4c3b7f..b661c7b 100755 --- a/bin/proofline.mjs +++ b/bin/proofline.mjs @@ -213,7 +213,8 @@ try { } let lines; if (sub === 'pre-push' && !process.stdin.isTTY) lines = fs.readFileSync(0, 'utf8').split('\n').filter((l) => l.trim()); - const r = sub === 'pre-commit' ? P.hookPreCommit(root) : sub === 'pre-push' ? P.hookPrePush(root, { lines }) : null; + const pushUrl = typeof pos[3] === 'string' ? pos[3] : undefined; + const r = sub === 'pre-commit' ? P.hookPreCommit(root) : sub === 'pre-push' ? P.hookPrePush(root, { lines, pushUrl }) : null; if (!r) { process.exitCode = 3; break; } if (!r.ok) { console.error(`proofline ${sub}: REFUSED\n${r.msg}\n(There is no bypass. Fix the cause.)`); process.exitCode = 1; } else if (r.msg) console.error(r.msg); diff --git a/src/index.mjs b/src/index.mjs index 43d786a..40c1731 100644 --- a/src/index.mjs +++ b/src/index.mjs @@ -207,16 +207,36 @@ export function settleLanded(root) { return { task: id, message: landedMessage(id, why) }; } -function commitsOnPushLine(root, line) { +/** Server default-branch head for this push URL, from `git ls-remote` now. Never reads refs/remotes. */ +function serverDefaultHead(root, pushUrl) { + if (typeof pushUrl !== 'string' || !pushUrl.trim()) return { ok: false }; + const ls = git(['ls-remote', pushUrl, 'HEAD'], root); + if (!ls.ok) return { ok: false }; + const sha = (ls.out.split(/\s+/)[0] || ''); + if (!sha) return { ok: true, sha: '' }; + if (!/^[0-9a-f]{40}$/i.test(sha)) return { ok: false }; + return { ok: true, sha }; +} + +function commitsOnPushLine(root, line, pushUrl) { const p = line.trim().split(/\s+/); if (p.length < 4) return []; const local = p[1]; const remote = p[3]; if (!/^[0-9a-f]{40}$/i.test(local) || /^0+$/.test(local)) return []; - const spec = /^0+$/.test(remote) ? [local, '--not', '--remotes'] : [`${remote}..${local}`]; - const r = git(['rev-list', ...spec], root); - if (!r.ok || !r.out) return []; - return r.out.split('\n').filter(Boolean); + // Existing branch: remote..local. New branch: every commit reachable from local. + // Already sent means reachable from the server default-branch head. A local + // remote-tracking ref is not that head. An unreadable server gates the whole range. + const range = /^0+$/.test(remote) ? [local] : [`${remote}..${local}`]; + const listed = git(['rev-list', ...range], root); + if (!listed.ok) return []; + const all = listed.out ? listed.out.split('\n').filter(Boolean) : []; + const server = serverDefaultHead(root, pushUrl); + if (!server.ok || !server.sha) return all; + if (!git(['cat-file', '-e', `${server.sha}^{commit}`], root).ok) return all; + const filtered = git(['rev-list', ...range, '--not', server.sha], root); + if (!filtered.ok) return all; + return filtered.out ? filtered.out.split('\n').filter(Boolean) : []; } export function evidenceRecord(root, type, data, actor = 'agent') { @@ -349,7 +369,7 @@ function gateAtCommit(root, sha, id) { * that prints `proofline next`. An active task that has landed is cleared and never gated. * With no push lines (a direct call), the previous rule stands: the active task, else every in-progress or failed task. */ -export function hookPrePush(root, { lines } = {}) { +export function hookPrePush(root, { lines, pushUrl } = {}) { // stdin lines are ` `. The gated revision is that sha. if (lines == null) { let raw = ''; @@ -365,7 +385,7 @@ export function hookPrePush(root, { lines } = {}) { const haveLines = Array.isArray(lines) && lines.some((l) => l.trim()); if (haveLines) { const known = new Set(c.tasks.map((t) => t.id)); - const commits = [...new Set(lines.flatMap((l) => commitsOnPushLine(root, l)))]; + const commits = [...new Set(lines.flatMap((l) => commitsOnPushLine(root, l, pushUrl)))]; if (commits.length) { const active = activeTask(root); const activeLive = active && !landedWhy(root, active) ? active : null; diff --git a/test/hook_push_range.test.mjs b/test/hook_push_range.test.mjs new file mode 100644 index 0000000..34ae9b1 --- /dev/null +++ b/test/hook_push_range.test.mjs @@ -0,0 +1,105 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { repo, write, addTask, sh } from './helpers.mjs'; +import { hookPrePush } from '../src/index.mjs'; + +function push(r, args) { + return spawnSync('git', ['push', ...args], { cwd: r, encoding: 'utf8' }); +} + +test('a rebased branch does not gate a commit already on main', () => { + const r = repo({ hooks: true }); + addTask(r, { id: 'MAIN', title: 'm', criteria: [{ verifier: { type: 'command', cmd: 'true' } }] }); + addTask(r, { id: 'BRANCH', title: 'b', criteria: [{ verifier: { type: 'command', cmd: 'true' } }] }); + write(r, { 'src/tasks.mjs': 'export const tasks = 1;\n' }); + sh(r, 'add', '-A'); + sh(r, 'commit', '-q', '-m', 'add the two tasks'); + push(r, ['origin', 'HEAD:main']); + sh(r, 'checkout', '-q', '-b', 'feature'); + write(r, { 'src/branch.mjs': 'export const branch = 1;\n' }); + sh(r, 'add', '-A'); + sh(r, 'commit', '-q', '-m', 'branch work (BRANCH)'); + const first = push(r, ['origin', 'HEAD:feature']); + assert.equal(first.status, 0, first.stderr); + sh(r, 'checkout', '-q', 'main'); + write(r, { 'src/main.mjs': 'export const main = 1;\n' }); + sh(r, 'add', '-A'); + sh(r, 'commit', '-q', '-m', 'main work (MAIN)'); + const landed = push(r, ['origin', 'HEAD:main']); + assert.equal(landed.status, 0, landed.stderr); + sh(r, 'checkout', '-q', 'feature'); + sh(r, 'rebase', '-q', 'main'); + const again = push(r, ['--force-with-lease', 'origin', 'HEAD:feature']); + assert.equal(again.status, 0, again.stderr); + assert.match(again.stderr, /gated BRANCH at /); + assert.doesNotMatch(again.stderr, /gated MAIN at /); +}); + +test('a commit that is not on a remote yet is still gated', () => { + const r = repo({ hooks: true }); + addTask(r, { id: 'MAIN', title: 'm', criteria: [{ verifier: { type: 'command', cmd: 'false' } }] }); + addTask(r, { id: 'BRANCH', title: 'b', criteria: [{ verifier: { type: 'command', cmd: 'true' } }] }); + write(r, { 'src/tasks.mjs': 'export const tasks = 1;\n' }); + sh(r, 'add', '-A'); + sh(r, 'commit', '-q', '-m', 'add the two tasks'); + push(r, ['origin', 'HEAD:main']); + sh(r, 'checkout', '-q', '-b', 'feature'); + write(r, { 'src/branch.mjs': 'export const branch = 1;\n' }); + sh(r, 'add', '-A'); + sh(r, 'commit', '-q', '-m', 'branch work (BRANCH)'); + const first = push(r, ['origin', 'HEAD:feature']); + assert.equal(first.status, 0, first.stderr); + sh(r, 'checkout', '-q', 'main'); + write(r, { 'src/main.mjs': 'export const main = 1;\n' }); + sh(r, 'add', '-A'); + sh(r, 'commit', '-q', '-m', 'main work (MAIN)'); + sh(r, 'checkout', '-q', 'feature'); + sh(r, 'rebase', '-q', 'main'); + const again = push(r, ['--force-with-lease', 'origin', 'HEAD:feature']); + assert.notEqual(again.status, 0); + assert.match(again.stderr, /MAIN at /); +}); + +const send = (r, args) => spawnSync('git', ['push', ...args], { cwd: r, encoding: 'utf8' }); +function setup() { + const r = repo({ hooks: true }); + addTask(r, { id: 'MAIN', title: 'm', criteria: [{ verifier: { type: 'command', cmd: 'false' } }] }); + write(r, { 'src/tasks.mjs': 'export const tasks = 1;\n' }); + sh(r, 'add', '-A'); sh(r, 'commit', '-q', '-m', 'add the task'); + send(r, ['origin', 'HEAD:main']); + write(r, { 'src/main.mjs': 'export const main = 1;\n' }); + sh(r, 'add', '-A'); sh(r, 'commit', '-q', '-m', 'main work (MAIN)'); + return r; +} +const forge = (r) => sh(r, 'update-ref', 'refs/remotes/origin/elsewhere', 'HEAD'); + +test('main, control', () => { assert.notEqual(send(setup(), ['origin', 'HEAD:main']).status, 0); }); +test('main, forged ref', () => { const r = setup(); forge(r); assert.notEqual(send(r, ['origin', 'HEAD:main']).status, 0); }); +test('new branch, control', () => { assert.notEqual(send(setup(), ['origin', 'HEAD:refs/heads/fresh']).status, 0); }); +test('new branch, forged ref', () => { const r = setup(); forge(r); assert.notEqual(send(r, ['origin', 'HEAD:refs/heads/fresh']).status, 0); }); + +test('an unreadable server gates every commit in the range', () => { + const r = repo({ hooks: true }); + addTask(r, { id: 'MAIN', title: 'm', criteria: [{ verifier: { type: 'command', cmd: 'true' } }] }); + addTask(r, { id: 'BRANCH', title: 'b', criteria: [{ verifier: { type: 'command', cmd: 'true' } }] }); + write(r, { 'src/main.mjs': 'export const main = 1;\n' }); + sh(r, 'add', '-A'); + sh(r, 'commit', '-q', '-m', 'main work (MAIN)'); + const landed = push(r, ['origin', 'HEAD:main']); + assert.equal(landed.status, 0, landed.stderr); + write(r, { 'src/branch.mjs': 'export const branch = 1;\n' }); + sh(r, 'add', '-A'); + sh(r, 'commit', '-q', '-m', 'branch work (BRANCH)'); + const local = sh(r, 'rev-parse', 'HEAD'); + const line = `refs/heads/fresh ${local} refs/heads/fresh ${'0'.repeat(40)}`; + const url = sh(r, 'remote', 'get-url', 'origin'); + const open = hookPrePush(r, { lines: [line], pushUrl: url }); + assert.equal(open.ok, true, open.msg); + assert.match(open.msg, /gated BRANCH at /); + assert.doesNotMatch(open.msg, /gated MAIN at /); + const shut = hookPrePush(r, { lines: [line], pushUrl: '/tmp/proofline-no-such-remote' }); + assert.equal(shut.ok, true, shut.msg); + assert.match(shut.msg, /gated BRANCH at /); + assert.match(shut.msg, /gated MAIN at /); +});