@optitransfer/proofline 0.0.0-stage → 0.2.0-next.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/AGENTS.md +32 -0
  2. package/CHANGELOG.md +56 -0
  3. package/CONTRIBUTING.md +29 -0
  4. package/LICENSE +154 -0
  5. package/NOTICE +10 -0
  6. package/PATENTS-NONASSERT.md +1 -0
  7. package/README.md +285 -2
  8. package/bin/proofline.mjs +292 -0
  9. package/docs/00-index.md +43 -0
  10. package/docs/01-scope.md +70 -0
  11. package/docs/02-requirements.md +64 -0
  12. package/docs/03-domain-model.md +43 -0
  13. package/docs/04-architecture.md +79 -0
  14. package/docs/05-lifecycle.md +47 -0
  15. package/docs/06-verification.md +57 -0
  16. package/docs/07-evidence.md +69 -0
  17. package/docs/08-adapters.md +61 -0
  18. package/docs/09-security.md +64 -0
  19. package/docs/10-conformance.md +59 -0
  20. package/docs/11-development-pipeline.md +50 -0
  21. package/docs/12-roadmap.md +104 -0
  22. package/docs/13-interdependencies.md +53 -0
  23. package/docs/14-metrics.md +40 -0
  24. package/docs/adr/0001-product-boundary.md +28 -0
  25. package/docs/adr/0002-evidence-and-gates.md +22 -0
  26. package/docs/adr/0003-standalone-first.md +18 -0
  27. package/docs/receipt.md +25 -0
  28. package/package.json +60 -4
  29. package/src/cache.mjs +81 -0
  30. package/src/checks.mjs +276 -0
  31. package/src/claims.mjs +60 -0
  32. package/src/contract.mjs +404 -0
  33. package/src/engine.mjs +378 -0
  34. package/src/evidence.mjs +45 -0
  35. package/src/forbidden.mjs +57 -0
  36. package/src/guided.mjs +240 -0
  37. package/src/hook-stop.mjs +105 -0
  38. package/src/index.mjs +431 -0
  39. package/src/infer.mjs +173 -0
  40. package/src/inference.json +20 -0
  41. package/src/isolate.mjs +123 -0
  42. package/src/mcp.mjs +70 -0
  43. package/src/receipt.mjs +17 -0
  44. package/src/signature.mjs +110 -0
  45. package/src/util.mjs +92 -0
  46. package/src/vault.mjs +109 -0
  47. package/src/verify-receipt.mjs +109 -0
@@ -0,0 +1,123 @@
1
+ // Opt-in isolation for command, red-green and metric checks.
2
+ // Off unless the caller passes isolated, or the process was started with --isolated.
3
+ // The image digest lives in the contract under test (a fixture in the TB-06 test), never invented here.
4
+ import { spawnSync } from 'node:child_process';
5
+ import fs from 'node:fs';
6
+ import os from 'node:os';
7
+ import path from 'node:path';
8
+
9
+ const DIGEST = /^node:22\.11\.0@sha256:[0-9a-f]{64}$/;
10
+
11
+ /** Evasions the static checks do not catch. Each fixture directory uses one of these names. */
12
+ export const COVERAGE_LIMITS = [
13
+ 'computed-dynamic-import',
14
+ 'env-dependent-test',
15
+ 'benchmark-code-edited',
16
+ 'timeout-raised-through-config',
17
+ 'test-removed-by-rename',
18
+ 'test-weakened-by-snapshot',
19
+ 'suite-command-wrapper',
20
+ 'network-dependent-pass',
21
+ 'pre-generated-cache',
22
+ 'check-reads-contract',
23
+ ];
24
+
25
+ export function imageDigest(contract) {
26
+ const image = contract?.env?.image;
27
+ return typeof image === 'string' && DIGEST.test(image) ? image : null;
28
+ }
29
+
30
+ function hasBin(bin) {
31
+ const r = spawnSync(bin, ['--version'], { encoding: 'utf8', timeout: 5000 });
32
+ return r.status === 0;
33
+ }
34
+
35
+ export function runtimeName() {
36
+ if (hasBin('docker')) return 'docker';
37
+ if (hasBin('podman')) return 'podman';
38
+ if (hasBin('bwrap')) return 'bwrap';
39
+ return null;
40
+ }
41
+
42
+ function result(cmd, cwd, started, child) {
43
+ const ms = Math.round(performance.now() - started);
44
+ const timedOut = child.error?.code === 'ETIMEDOUT';
45
+ return {
46
+ cmd, cwd, ms, timedOut,
47
+ exit: timedOut ? 124 : (child.status ?? 1),
48
+ stdout: child.stdout || '',
49
+ stderr: (child.stderr || '') + (child.error && !timedOut ? String(child.error) : ''),
50
+ };
51
+ }
52
+
53
+ function bwrapArgs(cmd, cwd, extra, writable) {
54
+ const args = ['--unshare-net', '--die-with-parent', '--ro-bind', '/', '/', '--tmpfs', '/tmp', '--clearenv', '--setenv', 'HOME', '/tmp', '--setenv', 'PATH', '/usr/local/bin:/usr/bin:/bin'];
55
+ if (cwd) {
56
+ args.push('--ro-bind', cwd, '/work', '--chdir', '/work');
57
+ for (const rel of writable) args.push('--bind', path.resolve(cwd, rel), path.posix.join('/work', rel));
58
+ } else {
59
+ args.push('--chdir', '/tmp');
60
+ }
61
+ for (const [k, val] of extra) args.push('--setenv', k, String(val));
62
+ args.push('--', 'sh', '-c', cmd);
63
+ return args;
64
+ }
65
+
66
+ function dockerArgs(cmd, cwd, image, extra, writable) {
67
+ const args = ['run', '--rm', '--network', 'none', '--read-only', '--tmpfs', '/tmp', '-e', 'HOME=/tmp'];
68
+ if (cwd) {
69
+ args.push('-v', `${cwd}:/work:ro`, '-w', '/work');
70
+ for (const rel of writable) args.push('-v', `${path.resolve(cwd, rel)}:/work/${rel}:rw`);
71
+ }
72
+ for (const [k, val] of extra) args.push('-e', `${k}=${val}`);
73
+ args.push('--entrypoint', 'sh', image, '-c', cmd);
74
+ return args;
75
+ }
76
+
77
+ /** One copy under the home directory, which a desktop runtime can mount when the work tree is not shared. */
78
+ function stageCopy(cwd) {
79
+ const parent = path.join(os.homedir(), '.cache', 'proofline-isolate');
80
+ fs.mkdirSync(parent, { recursive: true });
81
+ const dest = fs.mkdtempSync(path.join(parent, 'work-'));
82
+ fs.cpSync(cwd, dest, { recursive: true, dereference: false });
83
+ return dest;
84
+ }
85
+
86
+ /** Run cmd with no network, a fresh HOME, and the repo read-only except declared paths. */
87
+ export function runInSandbox(cmd, { cwd, timeoutS = 600, env, image, runtime, writable = [] } = {}) {
88
+ const started = performance.now();
89
+ const extra = Object.entries(env || {}).filter(([k]) => k !== 'NODE_TEST_CONTEXT');
90
+ const argsFor = (dir) => runtime === 'bwrap'
91
+ ? bwrapArgs(cmd, dir, extra, writable)
92
+ : dockerArgs(cmd, dir, image, extra, writable);
93
+ const spawn = (args) => spawnSync(runtime, args, { encoding: 'utf8', timeout: timeoutS * 1000, maxBuffer: 64 << 20 });
94
+ let child = spawn(argsFor(cwd));
95
+ if (cwd && runtime !== 'bwrap' && /Mounts denied/.test(child.stderr || '')) {
96
+ const staged = stageCopy(cwd);
97
+ try {
98
+ child = spawn(argsFor(staged));
99
+ } finally {
100
+ fs.rmSync(staged, { recursive: true, force: true });
101
+ }
102
+ }
103
+ return result(cmd, cwd, started, child);
104
+ }
105
+
106
+ /**
107
+ * Isolation for one verify. Missing env.image refuses and names that item.
108
+ * A runtime is probed only after the caller asked for isolation and the digest is present.
109
+ */
110
+ export function resolveIsolation(contract, opts = {}) {
111
+ const requested = opts.isolated === true || (opts.isolated !== false && process.argv.includes('--isolated'));
112
+ if (!requested) return { isolated: false, env_digest: null, block: null, sandbox: null };
113
+ const image = imageDigest(contract);
114
+ if (!image) return { isolated: false, env_digest: null, block: 'env.image is required for --isolated', sandbox: null };
115
+ const runtime = runtimeName();
116
+ if (!runtime) return { isolated: false, env_digest: null, block: 'no container runtime for --isolated', sandbox: null };
117
+ const sandbox = (cmd, o) => runInSandbox(cmd, { ...o, image, runtime, writable: o.writable || [] });
118
+ // The version probe does not need the work tree. Mounting it fails where the desktop runtime cannot see that path.
119
+ const ver = sandbox('node --version', { timeoutS: 60 });
120
+ const nodeVer = (ver.stdout || '').trim().split('\n')[0] || 'unknown';
121
+ const env_digest = `${image} runtime=${runtime} node=${nodeVer}`;
122
+ return { isolated: true, env_digest, block: null, sandbox };
123
+ }
package/src/mcp.mjs ADDED
@@ -0,0 +1,70 @@
1
+ // Minimal MCP server (JSON-RPC 2.0 over newline-delimited stdio). Zero dependencies.
2
+ import readline from 'node:readline';
3
+ import * as P from './index.mjs';
4
+ import { verifyReceipt } from './verify-receipt.mjs';
5
+ import { buildReceipt } from './receipt.mjs';
6
+
7
+ const BASE = { type: 'string', description: 'The revision the task started from. Needed in a fresh clone unless task_next pinned it or HEAD carries a Proofline-Base: trailer.' };
8
+ const FRESH = { type: 'boolean', description: 'Run every check again, ignoring results reused under defaults.reuse_results.' };
9
+ const OWNER = { type: 'string', description: "Only this owner's tasks and unowned ones; never another owner's." };
10
+ const TOOLS = [
11
+ { name: 'contract_create', description: 'Add a task contract (id, title, phase, depends_on, scope, criteria with deterministic verifiers). Returns validity problems; a task without verifiers cannot pass.', inputSchema: { type: 'object', properties: { task: { type: 'object' } }, required: ['task'] } },
12
+ { name: 'task_next', description: 'Return the single next task (phase- and dependency-gated) with its full brief. Call this before starting any work.', inputSchema: { type: 'object', properties: { owner: OWNER } } },
13
+ { name: 'evidence_record', description: 'Record agent-side evidence (notes, commands you ran). Stored hash-chained, but never used to pass a gate: verify re-runs everything itself.', inputSchema: { type: 'object', properties: { type: { type: 'string' }, data: { type: 'object' } }, required: ['type'] } },
14
+ { name: 'verify', description: 'Independently run every acceptance check for a task, plus forbidden-action, scope, wiring and claim checks. Returns decision and a corrective prompt.', inputSchema: { type: 'object', properties: { task: { type: 'string' }, claims: { type: 'string', description: 'Your status line(s): TASK-ID | status | commit | tests | measured vs target | missing' }, base: BASE, fresh: FRESH }, required: ['task'] } },
15
+ { name: 'gate', description: 'Release decision PASS/FAIL/BLOCKED with Markdown+JSON evidence report.', inputSchema: { type: 'object', properties: { task: { type: 'string' }, claims: { type: 'string' }, base: BASE, fresh: FRESH }, required: ['task'] } },
16
+ { name: 'report', description: 'Latest gate report for a task (Markdown).', inputSchema: { type: 'object', properties: { task: { type: 'string' } }, required: ['task'] } },
17
+ { name: 'status', description: 'Task and phase status, evidence-chain integrity.', inputSchema: { type: 'object', properties: { owner: OWNER } } },
18
+ { name: 'receipt', description: 'Infer a receipt from the base revision. Returns the same JSON the CLI prints with --json.', inputSchema: { type: 'object', properties: { task: { type: 'string' }, base: { type: 'string', description: 'The revision the receipt is inferred from.' }, role: { type: 'string' }, vault: { type: 'boolean', description: 'Attach a hivevault digest and write the note when the daemon answers.' } } } },
19
+ { name: 'verify_receipt', description: 'Check a DSSE receipt envelope offline. Returns the same JSON verify-receipt prints with --json.', inputSchema: { type: 'object', properties: { path: { type: 'string' }, recompute: { type: 'boolean' }, base: BASE }, required: ['path'] } },
20
+ ];
21
+
22
+
23
+
24
+ export function handle(root, msg) {
25
+ const { id, method, params = {} } = msg;
26
+ const ok = (result) => ({ jsonrpc: '2.0', id, result });
27
+ if (method === 'initialize') return ok({ protocolVersion: params.protocolVersion || '2024-11-05', capabilities: { tools: {} }, serverInfo: { name: 'proofline', version: '0.1.0' } });
28
+ if (method === 'ping') return ok({});
29
+ if (method === 'tools/list') return ok({ tools: TOOLS });
30
+ if (method === 'tools/call') {
31
+ const a = params.arguments || {};
32
+ try {
33
+ let r;
34
+ switch (params.name) {
35
+ case 'contract_create': r = P.contractCreate(root, a.task); break;
36
+ case 'task_next': r = P.taskNext(root, { owner: a.owner }); break;
37
+ case 'evidence_record': r = P.evidenceRecord(root, a.type, a.data || {}); break;
38
+ case 'verify': { const g = P.E.verifyTask(root, a.task, { claims: a.claims, base: a.base, fresh: a.fresh === true }); r = { ...g, corrective_prompt: P.E.steer(g, P.C.load(root)) }; break; }
39
+ case 'gate': r = P.E.gate(root, a.task, { claims: a.claims, base: a.base, fresh: a.fresh === true }); break;
40
+ case 'report': r = P.report(root, a.task); break;
41
+ case 'status': r = P.status(root, { owner: a.owner }); break;
42
+ case 'receipt':
43
+ return buildReceipt(root, a).then((got) => ok({ content: [{ type: 'text', text: JSON.stringify(got, null, 2) }], isError: false })).catch((e) => ok({ content: [{ type: 'text', text: `error: ${e.message}` }], isError: true }));
44
+ case 'verify_receipt':
45
+ return verifyReceipt(a.path, { root, recompute: a.recompute === true, base: a.base }).then((got) => ok({ content: [{ type: 'text', text: JSON.stringify(got, null, 2) }], isError: false })).catch((e) => ok({ content: [{ type: 'text', text: `error: ${e.message}` }], isError: true }));
46
+ default: return { jsonrpc: '2.0', id, error: { code: -32601, message: `unknown tool ${params.name}` } };
47
+ }
48
+ const text = r.brief ? r.brief + '\n\n' + JSON.stringify({ task: r.task, phase: r.phase }) : r.corrective_prompt ? `DECISION: ${r.decision}\n\n${r.corrective_prompt}\n\n${JSON.stringify({ decision: r.decision, failing: r.failing, blocked: r.blocked, measured: r.measured })}` : r.markdown || JSON.stringify(r, null, 2);
49
+ return ok({ content: [{ type: 'text', text }], isError: false });
50
+ } catch (e) { return ok({ content: [{ type: 'text', text: `error: ${e.message}` }], isError: true }); }
51
+ }
52
+ if (id === undefined) return null; // notification
53
+ return { jsonrpc: '2.0', id, error: { code: -32601, message: `method not found: ${method}` } };
54
+ }
55
+
56
+ export function serve(root) {
57
+ const rl = readline.createInterface({ input: process.stdin });
58
+ let chain = Promise.resolve();
59
+ rl.on('line', (line) => {
60
+ chain = chain.then(async () => {
61
+ if (!line.trim()) return;
62
+ let msg;
63
+ try { msg = JSON.parse(line); }
64
+ catch { process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'parse error' } }) + '\n'); return; }
65
+ const r = await Promise.resolve(handle(root, msg));
66
+ if (r) process.stdout.write(JSON.stringify(r) + '\n');
67
+ });
68
+ });
69
+ return new Promise((res) => rl.on('close', () => chain.then(res)));
70
+ }
@@ -0,0 +1,17 @@
1
+ // One receipt for a revision range. A missing daemon leaves the receipt unchanged.
2
+ import { receiptZeroConfig } from './infer.mjs';
3
+ import { attachMemory, probeDigest, receiptNote, writeReceiptNote } from './vault.mjs';
4
+
5
+ export async function buildReceipt(root, a = {}) {
6
+ const receipt = receiptZeroConfig(root, { base: a.base });
7
+ if (a.vault !== true) return receipt;
8
+ const digest = await probeDigest();
9
+ const attached = attachMemory(receipt, digest);
10
+ const note = receiptNote({
11
+ receipt: attached.receipt,
12
+ role: typeof a.role === 'string' && a.role ? a.role : 'agent',
13
+ subject: typeof a.task === 'string' ? a.task : '',
14
+ });
15
+ await writeReceiptNote(note, { vault: true });
16
+ return attached.receipt;
17
+ }
@@ -0,0 +1,110 @@
1
+ // Owner signature over the canonical contract. The public key is read from guard/owner.pub only.
2
+ import crypto from 'node:crypto';
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { spawnSync } from 'node:child_process';
7
+ import * as C from './contract.mjs';
8
+ import { git, sha256 } from './util.mjs';
9
+
10
+ export const PAYLOAD_TYPE = 'application/vnd.proofline.contract-sha256';
11
+
12
+ function canonical(value) {
13
+ if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
14
+ if (value && typeof value === 'object') {
15
+ return `{${Object.keys(value).sort().map((k) => `${JSON.stringify(k)}:${canonical(value[k])}`).join(',')}}`;
16
+ }
17
+ return JSON.stringify(value);
18
+ }
19
+
20
+ export function canonicalHash(root) {
21
+ return sha256(canonical(JSON.parse(fs.readFileSync(C.contractPath(root), 'utf8'))));
22
+ }
23
+
24
+ function pae(payloadType, body) {
25
+ const t = Buffer.from(payloadType);
26
+ const b = Buffer.from(body);
27
+ return Buffer.concat([Buffer.from(`DSSEv1 ${t.length} `), t, Buffer.from(` ${b.length} `), b]);
28
+ }
29
+
30
+ function agentkeyBin(env) {
31
+ for (const dir of String(env.PATH || '').split(path.delimiter)) {
32
+ if (!dir) continue;
33
+ const f = path.join(dir, 'agentkey');
34
+ try {
35
+ const st = fs.statSync(f);
36
+ if (st.isFile() && (st.mode & 0o111)) return f;
37
+ } catch { /* keep looking */ }
38
+ }
39
+ return null;
40
+ }
41
+
42
+ /** Armed only when guard/owner.pub is present. A missing pin is reported, and no other key path is opened. */
43
+ export function verifyContractSignature(root) {
44
+ const pubPath = path.join(root, 'guard', 'owner.pub');
45
+ let pem;
46
+ try { pem = fs.readFileSync(pubPath, 'utf8'); }
47
+ catch { return { ok: false, armed: false, detail: 'guard/owner.pub is missing' }; }
48
+ let envelope;
49
+ try { envelope = JSON.parse(fs.readFileSync(path.join(root, 'proofline.json.sig'), 'utf8')); }
50
+ catch { return { ok: false, armed: true, detail: 'proofline.json.sig is missing or not a signature' }; }
51
+ if (!envelope || envelope.payloadType !== PAYLOAD_TYPE || typeof envelope.payload !== 'string') {
52
+ return { ok: false, armed: true, detail: 'proofline.json.sig is not an owner signature' };
53
+ }
54
+ let payload;
55
+ try { payload = Buffer.from(envelope.payload, 'base64').toString('utf8'); }
56
+ catch { return { ok: false, armed: true, detail: 'signature payload is not base64' }; }
57
+ const hash = canonicalHash(root);
58
+ if (payload !== hash) return { ok: false, armed: true, detail: 'signature is not over the current contract' };
59
+ const sig = envelope.signatures?.[0]?.sig;
60
+ if (typeof sig !== 'string') return { ok: false, armed: true, detail: 'signature is missing' };
61
+ try {
62
+ const key = crypto.createPublicKey(pem);
63
+ const ok = crypto.verify(null, pae(PAYLOAD_TYPE, Buffer.from(payload)), key, Buffer.from(sig, 'base64'));
64
+ if (!ok) return { ok: false, armed: true, detail: 'signature does not verify against guard/owner.pub' };
65
+ } catch {
66
+ return { ok: false, armed: true, detail: 'guard/owner.pub is not a usable public key' };
67
+ }
68
+ return { ok: true, armed: true, detail: 'owner signature matches' };
69
+ }
70
+
71
+ /** Owner laptop: agentkey signs the canonical hash. AGENTKEY_HOME is required, so the default key directory is never opened. */
72
+ export function signContract(root, env = process.env) {
73
+ const home = env.AGENTKEY_HOME;
74
+ if (typeof home !== 'string' || !home.trim()) {
75
+ throw new Error('AGENTKEY_HOME is not set, so no key directory was opened\nNext step: proofline contract sign');
76
+ }
77
+ const bin = agentkeyBin(env);
78
+ if (!bin) throw new Error('agentkey is not on PATH\nNext step: proofline contract sign');
79
+ const hash = canonicalHash(root);
80
+ const r = spawnSync(bin, ['sign', 'owner', '--type', PAYLOAD_TYPE], {
81
+ input: hash, encoding: 'utf8', env: { ...env, AGENTKEY_HOME: home },
82
+ });
83
+ if (r.error || r.status !== 0) throw new Error(`agentkey sign owner failed\nNext step: proofline contract sign`);
84
+ JSON.parse(r.stdout);
85
+ fs.writeFileSync(path.join(root, 'proofline.json.sig'), r.stdout.endsWith('\n') ? r.stdout : `${r.stdout}\n`);
86
+ return `${hash}\n`;
87
+ }
88
+
89
+ function unified(before, after) {
90
+ const a = path.join(os.tmpdir(), `pl-sig-a-${process.pid}`);
91
+ const b = path.join(os.tmpdir(), `pl-sig-b-${process.pid}`);
92
+ fs.writeFileSync(a, before);
93
+ fs.writeFileSync(b, after);
94
+ const r = spawnSync('diff', ['-u', a, b], { encoding: 'utf8' });
95
+ fs.rmSync(a, { force: true });
96
+ fs.rmSync(b, { force: true });
97
+ return r.stdout || '';
98
+ }
99
+
100
+ /** The agent path: write proposals/<sha256>.diff and print the hash. It does not change proofline.json. */
101
+ export function proposeContract(root) {
102
+ const current = fs.readFileSync(C.contractPath(root));
103
+ const shown = git(['show', 'HEAD:proofline.json'], root);
104
+ const diff = unified(shown.ok ? shown.out : '', current);
105
+ const hex = sha256(diff);
106
+ const dir = path.join(root, '.proofline', 'proposals');
107
+ fs.mkdirSync(dir, { recursive: true });
108
+ fs.writeFileSync(path.join(dir, `${hex}.diff`), diff);
109
+ return `${hex}\n`;
110
+ }
package/src/util.mjs ADDED
@@ -0,0 +1,92 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import crypto from 'node:crypto';
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+
6
+ export const sha256 = (s) => crypto.createHash('sha256').update(s).digest('hex');
7
+
8
+ /** Owner marker for these exact bytes. The directory comes from `init --approvals`. A symlink is not a regular file. */
9
+ export function approvalMarker(root, bytes) {
10
+ let dir = null;
11
+ try {
12
+ const c = JSON.parse(fs.readFileSync(path.join(root, '.proofline', 'config.json'), 'utf8'));
13
+ if (typeof c.approvals === 'string' && c.approvals.trim()) dir = c.approvals;
14
+ } catch { dir = null; }
15
+ if (!bytes) return { hex: null, marker: null, approved: false };
16
+ const hex = sha256(bytes);
17
+ const marker = dir ? path.join(dir, hex) : null;
18
+ let approved = false;
19
+ if (marker) { try { approved = fs.lstatSync(marker).isFile(); } catch { approved = false; } }
20
+ return { hex, marker, approved };
21
+ }
22
+
23
+ /** Stable JSON (sorted keys) so hashes are reproducible. */
24
+ export function canon(v) {
25
+ if (Array.isArray(v)) return '[' + v.map(canon).join(',') + ']';
26
+ if (v && typeof v === 'object') return '{' + Object.keys(v).sort().filter((k) => v[k] !== undefined).map((k) => JSON.stringify(k) + ':' + canon(v[k])).join(',') + '}';
27
+ return JSON.stringify(v ?? null);
28
+ }
29
+
30
+ /** Run a shell command; never throws. */
31
+ export function run(cmd, { cwd, timeoutS = 600, env } = {}) {
32
+ const t = performance.now();
33
+ // NODE_TEST_CONTEXT leaks from an enclosing `node --test` and makes a nested `node --test` exit 0 regardless of failures.
34
+ const e = { ...process.env, ...env }; delete e.NODE_TEST_CONTEXT;
35
+ // A pre-push hook exports GIT_DIR. A test or command that inits its own repository must not use it.
36
+ for (const k of ['GIT_DIR', 'GIT_WORK_TREE', 'GIT_INDEX_FILE', 'GIT_PREFIX', 'GIT_COMMON_DIR', 'GIT_OBJECT_DIRECTORY', 'GIT_NAMESPACE', 'GIT_QUARANTINE_PATH']) delete e[k];
37
+ const r = spawnSync('bash', ['-c', cmd], { cwd, encoding: 'utf8', timeout: timeoutS * 1000, env: e, maxBuffer: 64 << 20 });
38
+ const ms = Math.round(performance.now() - t);
39
+ const timedOut = r.error?.code === 'ETIMEDOUT';
40
+ return { cmd, cwd, exit: timedOut ? 124 : (r.status ?? 1), stdout: r.stdout || '', stderr: (r.stderr || '') + (r.error && !timedOut ? String(r.error) : ''), ms, timedOut };
41
+ }
42
+
43
+ export function git(args, cwd) {
44
+ const r = spawnSync('git', args, { cwd, encoding: 'utf8', maxBuffer: 64 << 20 });
45
+ return { ok: r.status === 0, out: (r.stdout || '').trim(), err: (r.stderr || '').trim() };
46
+ }
47
+
48
+ /** Where commit `sha` stands against <remote>/<branch>. { ok: false, err } when the remote cannot be read; otherwise
49
+ * { ok: true, tip, at, contains, behind, unknown }. A tip this repository lacks is fetched by the branch's name with no ref
50
+ * written (--refmap= keeps the remote-tracking ref, --no-write-fetch-head keeps FETCH_HEAD), so a commit under the tip is
51
+ * found without a fetch beforehand. `unknown`: the tip could not be fetched, so whether it holds the commit is not known. */
52
+ export function onRemote(root, sha, remote, branch) {
53
+ const ls = git(['ls-remote', remote, `refs/heads/${branch}`], root);
54
+ if (!ls.ok) return { ok: false, err: ls.err || `git ls-remote ${remote} failed` };
55
+ const tip = ls.out.split(/\s/)[0] || '';
56
+ if (!tip) return { ok: true, tip: '', at: false, contains: false, behind: null, unknown: false };
57
+ if (tip === sha) return { ok: true, tip, at: true, contains: true, behind: 0, unknown: false };
58
+ const have = () => git(['cat-file', '-e', `${tip}^{commit}`], root).ok;
59
+ if (!have()) git(['fetch', '-q', '--no-tags', '--refmap=', '--no-write-fetch-head', remote, `refs/heads/${branch}`], root);
60
+ if (!have()) return { ok: true, tip, at: false, contains: false, behind: null, unknown: true };
61
+ const contains = git(['merge-base', '--is-ancestor', sha, tip], root).ok;
62
+ return { ok: true, tip, at: false, contains, behind: contains ? Number(git(['rev-list', '--count', `${sha}..${tip}`], root).out) : null, unknown: false };
63
+ }
64
+
65
+ export const tail = (s, n = 1500) => (s.length > n ? '…' + s.slice(-n) : s);
66
+
67
+ /** Minimal glob → RegExp: ** , * , ? */
68
+ export function globRe(g) {
69
+ let re = '';
70
+ for (let i = 0; i < g.length; i++) {
71
+ const c = g[i];
72
+ if (c === '*' && g[i + 1] === '*') { re += '.*'; i++; if (g[i + 1] === '/') i++; }
73
+ else if (c === '*') re += '[^/]*';
74
+ else if (c === '?') re += '[^/]';
75
+ else re += c.replace(/[.+^${}()|[\]\\]/g, '\\$&');
76
+ }
77
+ return new RegExp('^' + re + '$');
78
+ }
79
+ export const matchAny = (p, globs) => globs.some((g) => globRe(g).test(p));
80
+
81
+ /** Read a dotted path like "latency.p50" from an object. */
82
+ export const pick = (o, p) => p.split('.').reduce((a, k) => (a == null ? undefined : a[k]), o);
83
+
84
+ export function readJson(f, d) { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return d; } }
85
+ export function writeJson(f, v) { fs.mkdirSync(path.dirname(f), { recursive: true }); fs.writeFileSync(f, JSON.stringify(v, null, 2) + '\n'); }
86
+
87
+ /** Last JSON object found in a text (bench scripts print JSON as the last line). */
88
+ export function lastJson(text) {
89
+ const lines = text.trim().split('\n').reverse();
90
+ for (const l of lines) { const s = l.trim(); if (s.startsWith('{')) { try { return JSON.parse(s); } catch {} } }
91
+ try { return JSON.parse(text); } catch { return undefined; }
92
+ }
package/src/vault.mjs ADDED
@@ -0,0 +1,109 @@
1
+ // Attach a hivevault digest to a receipt, and write the note when asked.
2
+ // A missing daemon leaves the receipt unchanged. The probe gives up after 2 seconds.
3
+ import crypto from 'node:crypto';
4
+
5
+ const DIGEST_PATH = '/v1/state/digest';
6
+
7
+ function sha256(s) {
8
+ return crypto.createHash('sha256').update(s).digest('hex');
9
+ }
10
+
11
+ /** GET the digest. Returns null when the daemon is absent, refuses, or exceeds the timeout. */
12
+ export async function probeDigest(url, token, { timeout = 2000 } = {}) {
13
+ const base = url || process.env.HIVEVAULT_URL || 'http://127.0.0.1:7788';
14
+ const bearer = token ?? process.env.HIVEVAULT_TOKEN ?? '';
15
+ try {
16
+ const res = await fetch(new URL(DIGEST_PATH, base), {
17
+ headers: bearer ? { authorization: `Bearer ${bearer}` } : {},
18
+ signal: AbortSignal.timeout(timeout),
19
+ });
20
+ if (!res.ok) return null;
21
+ const body = await res.json();
22
+ if (!body || typeof body.digest !== 'string') return null;
23
+ return body;
24
+ } catch {
25
+ return null;
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Copy a digest onto a receipt.
31
+ * L1 gets memory.vault_digest and memory.frontier.
32
+ * mode `ci` (an L2 receipt) gets only memory_commitment, so a replica id stays inside the hash.
33
+ * No digest: the receipt is returned without a memory field, and exitCode is unchanged.
34
+ */
35
+ export function attachMemory(receipt, digest, { mode = 'local', vaultId = '' } = {}) {
36
+ const exitCode = receipt?.exitCode ?? 0;
37
+ if (!digest) {
38
+ const next = { ...receipt };
39
+ delete next.memory;
40
+ delete next.memory_commitment;
41
+ return { receipt: next, exitCode };
42
+ }
43
+ if (mode === 'ci' || receipt?.level === 'L2') {
44
+ const commitment = sha256(`${vaultId || digest.vault_id || digest.replica || ''}${digest.digest}${JSON.stringify(digest.frontier ?? null)}`);
45
+ const next = { ...receipt, memory_commitment: commitment };
46
+ delete next.memory;
47
+ return { receipt: next, exitCode };
48
+ }
49
+ const next = {
50
+ ...receipt,
51
+ memory: { vault_digest: digest.digest, frontier: digest.frontier ?? null },
52
+ };
53
+ delete next.memory_commitment;
54
+ return { receipt: next, exitCode };
55
+ }
56
+
57
+ export function receiptNotePath({ date, role = 'agent', sha }) {
58
+ const day = (date instanceof Date ? date : new Date(date || Date.now())).toISOString().slice(0, 10);
59
+ const short = String(sha || '').slice(0, 7);
60
+ return `work/receipts/${day}-${role}-${short}.md`;
61
+ }
62
+
63
+ export function receiptNote({ receipt, role = 'agent', subject = '', agent = '', summary = '', envelopeSha = '', date } = {}) {
64
+ const level = receipt?.level || 'L1';
65
+ const frontmatter = {
66
+ kind: 'proof-receipt',
67
+ level,
68
+ subject,
69
+ agent,
70
+ predicate_type: 'https://proofline.dev/receipt/v1',
71
+ summary,
72
+ envelope_sha256: envelopeSha,
73
+ origin: 'proofline',
74
+ };
75
+ const lines = ['| field | value |', '| --- | --- |', `| level | ${level} |`, `| subject | ${subject} |`];
76
+ if (receipt?.memory) lines.push(`| vault_digest | ${receipt.memory.vault_digest} |`);
77
+ if (receipt?.memory_commitment) lines.push('| memory_commitment | present |');
78
+ const body = `${lines.join('\n')}\n\n\`\`\`json\n${JSON.stringify(receipt, null, 2)}\n\`\`\`\n`;
79
+ const rel = receiptNotePath({ date, role, sha: envelopeSha });
80
+ const fm = ['---', ...Object.entries(frontmatter).map(([k, v]) => `${k}: ${v}`), '---', ''].join('\n');
81
+ return { path: rel, frontmatter, body, text: fm + body };
82
+ }
83
+
84
+ /** PUT the note. origin is proofline. Returns null when the write does not land. */
85
+ export async function writeReceiptNote(note, { url, token, timeout = 2000, vault = false } = {}) {
86
+ const base = url || process.env.HIVEVAULT_URL || 'http://127.0.0.1:7788';
87
+ const bearer = token ?? process.env.HIVEVAULT_TOKEN ?? '';
88
+ if (process.env.PROOFLINE_VAULT !== '1' && vault !== true) return null;
89
+ try {
90
+ const res = await fetch(new URL(`/v1/notes/${note.path}`, base), {
91
+ method: 'PUT',
92
+ headers: {
93
+ 'content-type': 'application/json',
94
+ ...(bearer ? { authorization: `Bearer ${bearer}` } : {}),
95
+ },
96
+ body: JSON.stringify({
97
+ frontmatter: note.frontmatter,
98
+ body: note.body,
99
+ origin: 'proofline',
100
+ commit_message: `proofline receipt ${note.path}`,
101
+ }),
102
+ signal: AbortSignal.timeout(timeout),
103
+ });
104
+ if (!res.ok) return null;
105
+ return { path: note.path, status: res.status };
106
+ } catch {
107
+ return null;
108
+ }
109
+ }
@@ -0,0 +1,109 @@
1
+ // Verify a receipt envelope. Offline unless --sigstore is asked for, and that import is lazy.
2
+ import crypto from 'node:crypto';
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { receiptZeroConfig } from './infer.mjs';
6
+
7
+ const STATEMENT = 'https://in-toto.io/Statement/v1';
8
+ const PAYLOAD_TYPE = 'application/vnd.in-toto+json';
9
+ const PREDICATE_TYPE = 'https://proofline.dev/receipt/v1';
10
+
11
+ export function pae(payloadType, payload) {
12
+ const t = Buffer.from(payloadType);
13
+ const b = Buffer.isBuffer(payload) ? payload : Buffer.from(payload);
14
+ return Buffer.concat([Buffer.from(`DSSEv1 ${t.length} `), t, Buffer.from(` ${b.length} `), b]);
15
+ }
16
+
17
+ function fromB64(s) {
18
+ if (typeof s !== 'string' || !/^[A-Za-z0-9+/_=-]+$/.test(s)) return null;
19
+ return Buffer.from(s.replace(/-/g, '+').replace(/_/g, '/'), 'base64');
20
+ }
21
+
22
+ function loadKeys(dir) {
23
+ const out = new Map();
24
+ if (!dir || !fs.existsSync(dir)) return out;
25
+ for (const name of fs.readdirSync(dir)) {
26
+ if (!name.endsWith('.pem')) continue;
27
+ const pem = fs.readFileSync(path.join(dir, name), 'utf8');
28
+ out.set(name.slice(0, -4), crypto.createPublicKey(pem));
29
+ }
30
+ return out;
31
+ }
32
+
33
+ function schemaProblem(statement) {
34
+ if (!statement || statement._type !== STATEMENT) return 'statement _type';
35
+ if (statement.predicateType !== PREDICATE_TYPE) return 'predicateType';
36
+ const p = statement.predicate;
37
+ if (!p || typeof p !== 'object') return 'predicate';
38
+ if (!['L0', 'L1', 'L2'].includes(p.level)) return 'predicate.level';
39
+ if (p.agent != null && typeof p.agent !== 'object') return 'predicate.agent';
40
+ return null;
41
+ }
42
+
43
+ function result(exitCode, message, extra = {}) {
44
+ return { exitCode, message, differences: extra.differences || [], level: extra.level || null, ...extra };
45
+ }
46
+
47
+ function verifySignatures(envelope, keys) {
48
+ if (!envelope || typeof envelope.payloadType !== 'string' || typeof envelope.payload !== 'string' || !Array.isArray(envelope.signatures) || !envelope.signatures.length) {
49
+ return { problem: 'schema', detail: 'a DSSE envelope needs payloadType, payload and one signature' };
50
+ }
51
+ const body = fromB64(envelope.payload);
52
+ if (!body) return { problem: 'schema', detail: 'payload is not base64' };
53
+ const data = pae(envelope.payloadType, body);
54
+ let verified = 0;
55
+ let unknown = 0;
56
+ let failed = 0;
57
+ for (const s of envelope.signatures) {
58
+ const id = typeof s?.keyid === 'string' ? s.keyid : '';
59
+ const sig = fromB64(s?.sig || '');
60
+ const key = keys.get(id);
61
+ if (!key || !sig) { unknown++; continue; }
62
+ const ok = crypto.verify(null, data, key, sig);
63
+ if (ok) verified++;
64
+ else failed++;
65
+ }
66
+ return { body, verified, unknown, failed };
67
+ }
68
+
69
+ /**
70
+ * @param {string} envelopePath
71
+ * @param {{ root?: string, keys?: string, sigstore?: boolean, recompute?: boolean, base?: string, suite?: boolean }} [opts]
72
+ */
73
+ export async function verifyReceipt(envelopePath, opts = {}) {
74
+ if (!envelopePath) return result(3, 'usage: verify-receipt <envelope>');
75
+ let raw;
76
+ try { raw = JSON.parse(fs.readFileSync(envelopePath, 'utf8')); }
77
+ catch { return result(1, 'envelope is not JSON'); }
78
+ if (opts.sigstore) return verifySigstore(raw);
79
+ const keys = loadKeys(opts.keys || (process.env.AGENTKEY_HOME ? path.join(process.env.AGENTKEY_HOME, 'keys') : ''));
80
+ const sig = verifySignatures(raw, keys);
81
+ if (sig.problem) return result(1, sig.detail);
82
+ if (sig.failed) return result(1, 'signature failed');
83
+ if (!sig.verified) return result(2, 'unknown key');
84
+ let statement;
85
+ try { statement = JSON.parse(sig.body.toString('utf8')); }
86
+ catch { return result(1, 'payload is not a statement'); }
87
+ if (raw.payloadType !== PAYLOAD_TYPE) return result(1, 'payloadType is not an in-toto statement');
88
+ const schema = schemaProblem(statement);
89
+ if (schema) return result(1, `schema: ${schema}`);
90
+ const level = statement.predicate.level;
91
+ if (!opts.recompute) return result(0, `verified\nlevel ${level}`, { level });
92
+ if (!opts.base) return result(3, '--recompute needs --base');
93
+ const live = receiptZeroConfig(opts.root || process.cwd(), { base: opts.base });
94
+ const recomputed = live.exitCode === 0 && live.level === 'L1' && live.suites.every((s) => s.status === 'pass');
95
+ const differences = [];
96
+ if (Boolean(statement.predicate.green_on_head) !== recomputed) {
97
+ differences.push(`green_on_head: envelope ${Boolean(statement.predicate.green_on_head)} recomputed ${recomputed}`);
98
+ }
99
+ if (differences.length) return result(1, differences.join('\n'), { level, differences });
100
+ return result(0, `verified\nlevel ${level}`, { level });
101
+ }
102
+
103
+ async function verifySigstore(bundle) {
104
+ const sigs = bundle?.verificationMaterial?.tlogEntries || bundle?.signatures || [];
105
+ if (sigs.length !== 1) return result(1, 'a sigstore bundle carries exactly one signature');
106
+ try { await import('sigstore'); }
107
+ catch { return result(2, 'sigstore verifier is not available'); }
108
+ return result(2, 'sigstore verification is not confirmed offline');
109
+ }