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
- 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 <rev>` 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/<sha256>.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.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<hook>.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 `<hook>` and `<hook>.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 <dir>` (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.

Expand Down
3 changes: 2 additions & 1 deletion bin/proofline.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
142 changes: 142 additions & 0 deletions proofline.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 <proposal>` 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.",
Expand Down
34 changes: 27 additions & 7 deletions src/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down Expand Up @@ -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 `<local ref> <local sha> <remote ref> <remote sha>`. The gated revision is that sha.
if (lines == null) {
let raw = '';
Expand All @@ -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;
Expand Down
Loading
Loading