@optitransfer/proofline 0.2.0-next.0 → 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.
- package/CHANGELOG.md +21 -1
- package/README.md +95 -2
- package/bin/proofline.mjs +206 -11
- package/docs/receipt.md +25 -0
- package/package.json +4 -1
- package/src/checks.mjs +12 -7
- package/src/contract.mjs +242 -1
- package/src/engine.mjs +77 -28
- package/src/guided.mjs +240 -0
- package/src/hook-stop.mjs +105 -0
- package/src/index.mjs +243 -15
- package/src/infer.mjs +173 -0
- package/src/inference.json +20 -0
- package/src/isolate.mjs +123 -0
- package/src/mcp.mjs +20 -5
- package/src/receipt.mjs +17 -0
- package/src/signature.mjs +110 -0
- package/src/vault.mjs +109 -0
- package/src/verify-receipt.mjs +109 -0
package/src/infer.mjs
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// Zero-config suite inference. Commands come from the base revision, never from the change under test.
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { spawnSync } from 'node:child_process';
|
|
6
|
+
import { git, run } from './util.mjs';
|
|
7
|
+
|
|
8
|
+
const RULES = JSON.parse(fs.readFileSync(new URL('./inference.json', import.meta.url), 'utf8'));
|
|
9
|
+
const CHANGED = RULES.changed_tests.map((s) => new RegExp(s));
|
|
10
|
+
|
|
11
|
+
export function inferenceRules() { return RULES; }
|
|
12
|
+
|
|
13
|
+
export function isChangedTest(file) {
|
|
14
|
+
return CHANGED.some((re) => re.test(file));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function rel(dir, name) { return dir ? `${dir}/${name}` : name; }
|
|
18
|
+
|
|
19
|
+
function hasAt(root, base, file) {
|
|
20
|
+
return git(['cat-file', '-e', `${base}:${file}`], root).ok;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function show(root, base, file) {
|
|
24
|
+
const s = git(['show', `${base}:${file}`], root);
|
|
25
|
+
return s.ok ? s.out : null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function readWorktree(root, file) {
|
|
29
|
+
try { return fs.readFileSync(path.join(root, file), 'utf8'); } catch { return null; }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function nodeCommand(pkg) {
|
|
33
|
+
const script = pkg?.scripts?.test;
|
|
34
|
+
if (typeof script === 'string' && script.trim()) return script.trim();
|
|
35
|
+
const dev = pkg?.devDependencies || {};
|
|
36
|
+
if (dev.vitest) return RULES.node.fallback_commands.vitest;
|
|
37
|
+
if (dev.jest) return RULES.node.fallback_commands.jest;
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function toolchainOk(bin) {
|
|
42
|
+
if (!bin) return true;
|
|
43
|
+
const r = spawnSync('bash', ['-lc', `command -v ${bin}`], { encoding: 'utf8' });
|
|
44
|
+
return r.status === 0;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function parseJson(text) {
|
|
48
|
+
if (!text) return null;
|
|
49
|
+
try { return JSON.parse(text); } catch { return null; }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Suites declared by the manifests at `base`. `dir` is a repo-relative directory. */
|
|
53
|
+
export function suitesFromBase(root, base, dir = '') {
|
|
54
|
+
const out = [];
|
|
55
|
+
const pkgPath = rel(dir, 'package.json');
|
|
56
|
+
if (hasAt(root, base, pkgPath)) {
|
|
57
|
+
const command = nodeCommand(parseJson(show(root, base, pkgPath)));
|
|
58
|
+
if (command) out.push({ id: 'node', manifest: pkgPath, command, toolchain: null });
|
|
59
|
+
}
|
|
60
|
+
const cargo = rel(dir, 'Cargo.toml');
|
|
61
|
+
if (hasAt(root, base, cargo)) out.push({ id: 'rust', manifest: cargo, command: RULES.rust.command, toolchain: RULES.rust.toolchain });
|
|
62
|
+
const pyIni = rel(dir, 'pytest.ini');
|
|
63
|
+
const pyProj = rel(dir, 'pyproject.toml');
|
|
64
|
+
const pyText = hasAt(root, base, pyProj) ? show(root, base, pyProj) : null;
|
|
65
|
+
if (hasAt(root, base, pyIni) || (pyText && /\[tool\.pytest/.test(pyText))) {
|
|
66
|
+
out.push({ id: 'python', manifest: hasAt(root, base, pyIni) ? pyIni : pyProj, command: RULES.python.command, toolchain: RULES.python.toolchain });
|
|
67
|
+
}
|
|
68
|
+
const gomod = rel(dir, 'go.mod');
|
|
69
|
+
if (hasAt(root, base, gomod)) out.push({ id: 'go', manifest: gomod, command: RULES.go.command, toolchain: RULES.go.toolchain });
|
|
70
|
+
const gradle = rel(dir, 'build.gradle');
|
|
71
|
+
if (hasAt(root, base, gradle)) out.push({ id: 'gradle', manifest: gradle, command: null, toolchain: null });
|
|
72
|
+
const tree = git(['ls-tree', '--name-only', base, dir || '.'], root);
|
|
73
|
+
if (tree.ok && tree.out.split('\n').some((n) => n.endsWith('.sln'))) {
|
|
74
|
+
const file = tree.out.split('\n').find((n) => n.endsWith('.sln'));
|
|
75
|
+
out.push({ id: 'dotnet', manifest: file, command: null, toolchain: null });
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function changedTests(root, base) {
|
|
81
|
+
const s = git(['diff', '--name-only', base], root);
|
|
82
|
+
const names = s.ok ? s.out.split('\n').filter(Boolean) : [];
|
|
83
|
+
return names.filter(isChangedTest);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function scriptOf(text) {
|
|
87
|
+
const v = parseJson(text)?.scripts?.test;
|
|
88
|
+
return typeof v === 'string' ? v : null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** A test-command change against `base`. The command that runs stays the base command. */
|
|
92
|
+
export function testCommandFindings(root, base) {
|
|
93
|
+
const findings = [];
|
|
94
|
+
const push = (file, line) => findings.push({
|
|
95
|
+
rule: 'test_command_changed', file, line,
|
|
96
|
+
why: 'the test command is taken from the base revision',
|
|
97
|
+
});
|
|
98
|
+
const baseScript = scriptOf(show(root, base, 'package.json'));
|
|
99
|
+
const headScript = scriptOf(readWorktree(root, 'package.json'));
|
|
100
|
+
if (baseScript !== headScript && (baseScript !== null || headScript !== null)) {
|
|
101
|
+
push('package.json', `${baseScript ?? ''} -> ${headScript ?? ''}`);
|
|
102
|
+
}
|
|
103
|
+
for (const file of ['Cargo.toml', 'pytest.ini']) {
|
|
104
|
+
const old = show(root, base, file);
|
|
105
|
+
const now = readWorktree(root, file);
|
|
106
|
+
if (old === null && now === null) continue;
|
|
107
|
+
if ((old ?? '').trim() !== (now ?? '').trim()) push(file, 'manifest changed');
|
|
108
|
+
}
|
|
109
|
+
const diff = git(['diff', '--name-only', base, '--', '.github/workflows'], root);
|
|
110
|
+
for (const file of (diff.ok ? diff.out.split('\n').filter(Boolean) : [])) push(file, 'workflow changed');
|
|
111
|
+
return findings;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function runCmd(cwd, command) {
|
|
115
|
+
const r = run(command, { cwd, timeoutS: 180 });
|
|
116
|
+
return { exitCode: r.exit, ran: true };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function runAtBase(root, base, command) {
|
|
120
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pl-infer-'));
|
|
121
|
+
const add = git(['worktree', 'add', '--detach', '--quiet', dir, base], root);
|
|
122
|
+
if (!add.ok) { fs.rmSync(dir, { recursive: true, force: true }); return null; }
|
|
123
|
+
try { return runCmd(dir, command); }
|
|
124
|
+
finally {
|
|
125
|
+
git(['worktree', 'remove', '--force', dir], root);
|
|
126
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Infer a receipt with no contract. Nothing inferable returns L0 and exit 2, and does not throw.
|
|
132
|
+
* A missing toolchain is `blocked`, not `pass`.
|
|
133
|
+
*/
|
|
134
|
+
export function receiptZeroConfig(root, { base } = {}) {
|
|
135
|
+
const l0 = (reason, extra = {}) => ({ level: 'L0', exitCode: 2, reason, suites: [], changed: [], findings: [], ...extra });
|
|
136
|
+
if (!base) return l0('no base revision');
|
|
137
|
+
let suites, changed, findings;
|
|
138
|
+
try {
|
|
139
|
+
suites = suitesFromBase(root, base);
|
|
140
|
+
changed = changedTests(root, base);
|
|
141
|
+
findings = testCommandFindings(root, base);
|
|
142
|
+
} catch (e) {
|
|
143
|
+
return l0(String(e?.message || e));
|
|
144
|
+
}
|
|
145
|
+
if (!suites.length) return { ...l0('nothing inferable'), findings };
|
|
146
|
+
const ran = [];
|
|
147
|
+
for (const s of suites) {
|
|
148
|
+
if (!s.command || (s.toolchain && !toolchainOk(s.toolchain))) {
|
|
149
|
+
ran.push({ ...s, status: 'blocked', ran: false, exitCode: null });
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
const head = runCmd(root, s.command);
|
|
153
|
+
const baseRun = runAtBase(root, base, s.command);
|
|
154
|
+
ran.push({
|
|
155
|
+
...s,
|
|
156
|
+
status: head.exitCode === 0 ? 'pass' : 'fail',
|
|
157
|
+
ran: true,
|
|
158
|
+
exitCode: head.exitCode,
|
|
159
|
+
baseExitCode: baseRun ? baseRun.exitCode : null,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
if (!ran.some((s) => s.ran)) {
|
|
163
|
+
return { level: 'L0', exitCode: 0, reason: 'nothing ran', suites: ran, changed, findings };
|
|
164
|
+
}
|
|
165
|
+
return {
|
|
166
|
+
level: 'L1',
|
|
167
|
+
exitCode: ran.some((s) => s.status === 'fail') ? 1 : 0,
|
|
168
|
+
reason: '',
|
|
169
|
+
suites: ran,
|
|
170
|
+
changed,
|
|
171
|
+
findings,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"changed_tests": [
|
|
3
|
+
"(^|/)(test|tests)/",
|
|
4
|
+
"_test\\.[^/]+$",
|
|
5
|
+
"\\.test\\.[^/]+$",
|
|
6
|
+
"\\.spec\\.[^/]+$",
|
|
7
|
+
"(^|/)test_[^/]+\\.py$"
|
|
8
|
+
],
|
|
9
|
+
"node": {
|
|
10
|
+
"manifest": "package.json",
|
|
11
|
+
"script": "scripts.test",
|
|
12
|
+
"fallback_dev_dependencies": ["vitest", "jest"],
|
|
13
|
+
"fallback_commands": { "vitest": "vitest run", "jest": "jest" }
|
|
14
|
+
},
|
|
15
|
+
"rust": { "manifest": "Cargo.toml", "command": "cargo test", "toolchain": "cargo" },
|
|
16
|
+
"python": { "manifests": ["pytest.ini", "pyproject.toml"], "command": "pytest", "toolchain": "pytest" },
|
|
17
|
+
"go": { "manifest": "go.mod", "command": "go test ./...", "toolchain": "go" },
|
|
18
|
+
"gradle": { "manifest": "build.gradle" },
|
|
19
|
+
"dotnet": { "glob": "*.sln" }
|
|
20
|
+
}
|
package/src/isolate.mjs
ADDED
|
@@ -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
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// Minimal MCP server (JSON-RPC 2.0 over newline-delimited stdio). Zero dependencies.
|
|
2
2
|
import readline from 'node:readline';
|
|
3
3
|
import * as P from './index.mjs';
|
|
4
|
+
import { verifyReceipt } from './verify-receipt.mjs';
|
|
5
|
+
import { buildReceipt } from './receipt.mjs';
|
|
4
6
|
|
|
5
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.' };
|
|
6
8
|
const FRESH = { type: 'boolean', description: 'Run every check again, ignoring results reused under defaults.reuse_results.' };
|
|
@@ -13,8 +15,12 @@ const TOOLS = [
|
|
|
13
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'] } },
|
|
14
16
|
{ name: 'report', description: 'Latest gate report for a task (Markdown).', inputSchema: { type: 'object', properties: { task: { type: 'string' } }, required: ['task'] } },
|
|
15
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'] } },
|
|
16
20
|
];
|
|
17
21
|
|
|
22
|
+
|
|
23
|
+
|
|
18
24
|
export function handle(root, msg) {
|
|
19
25
|
const { id, method, params = {} } = msg;
|
|
20
26
|
const ok = (result) => ({ jsonrpc: '2.0', id, result });
|
|
@@ -33,6 +39,10 @@ export function handle(root, msg) {
|
|
|
33
39
|
case 'gate': r = P.E.gate(root, a.task, { claims: a.claims, base: a.base, fresh: a.fresh === true }); break;
|
|
34
40
|
case 'report': r = P.report(root, a.task); break;
|
|
35
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 }));
|
|
36
46
|
default: return { jsonrpc: '2.0', id, error: { code: -32601, message: `unknown tool ${params.name}` } };
|
|
37
47
|
}
|
|
38
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);
|
|
@@ -45,11 +55,16 @@ export function handle(root, msg) {
|
|
|
45
55
|
|
|
46
56
|
export function serve(root) {
|
|
47
57
|
const rl = readline.createInterface({ input: process.stdin });
|
|
58
|
+
let chain = Promise.resolve();
|
|
48
59
|
rl.on('line', (line) => {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
+
});
|
|
53
68
|
});
|
|
54
|
-
return new Promise((res) => rl.on('close', res));
|
|
69
|
+
return new Promise((res) => rl.on('close', () => chain.then(res)));
|
|
55
70
|
}
|
package/src/receipt.mjs
ADDED
|
@@ -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/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
|
+
}
|