ruvnet-brain 4.0.7 → 4.0.8
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/README.md +1 -1
- package/bin/install.mjs +6 -2
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/hooks/codex-hooks.json +17 -17
- package/plugin/scripts/codex-hook-wrapper.mjs +72 -1
- package/plugin/skills/release-proof/scripts/release-proof.mjs +12 -5
- package/scripts/candidate-host-evidence.mjs +49 -0
- package/scripts/ci/learning-replay-codex-adapter.mjs +13 -1
- package/scripts/learning-replay-contract.mjs +14 -4
- package/scripts/learning-replay-execution.mjs +4 -3
- package/scripts/learning-replay-fixture.mjs +23 -2
- package/scripts/publication-receipt.mjs +126 -34
- package/scripts/release-evidence-aggregate.mjs +55 -0
- package/scripts/release-payload.mjs +100 -0
- package/scripts/release-transaction-provider.mjs +233 -56
- package/scripts/release-transaction.mjs +295 -105
- package/scripts/release.mjs +41 -12
- package/scripts/selfcheck.mjs +20 -5
- package/scripts/staged-host-verifier.mjs +3 -6
|
@@ -10,6 +10,7 @@ import { Readable } from 'node:stream';
|
|
|
10
10
|
import { spawn, spawnSync } from 'node:child_process';
|
|
11
11
|
import { pathToFileURL } from 'node:url';
|
|
12
12
|
import { evaluateCandidateReceipt, evaluatePublicationReceipt } from './release-proof.mjs';
|
|
13
|
+
import { verifyPayload } from './release-payload.mjs';
|
|
13
14
|
|
|
14
15
|
const REPO = 'stuinfla/ruvnet-brain';
|
|
15
16
|
const PACKAGE = 'ruvnet-brain';
|
|
@@ -28,6 +29,22 @@ function command(name, args, options = {}) {
|
|
|
28
29
|
return String(result.stdout || '').trim();
|
|
29
30
|
}
|
|
30
31
|
|
|
32
|
+
function locate(name) {
|
|
33
|
+
try { return command('which', [name]); } catch { return null; }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function isolatedPath(mode, temp) {
|
|
37
|
+
const bin = path.join(temp, `bin-${mode}`);
|
|
38
|
+
fs.mkdirSync(bin);
|
|
39
|
+
const hosts = mode === 'claudeOnly' ? ['claude'] : mode === 'codexOnly' ? ['codex'] : ['claude', 'codex'];
|
|
40
|
+
for (const name of ['node', 'npm', ...hosts]) {
|
|
41
|
+
const target = locate(name);
|
|
42
|
+
if (!target) throw new Error(`${name} CLI unavailable for ${mode} public host fixture`);
|
|
43
|
+
fs.symlinkSync(target, path.join(bin, name));
|
|
44
|
+
}
|
|
45
|
+
return `${bin}:/usr/bin:/bin`;
|
|
46
|
+
}
|
|
47
|
+
|
|
31
48
|
async function download(url, destination, headers = {}) {
|
|
32
49
|
const response = await fetch(url, {
|
|
33
50
|
redirect: 'follow',
|
|
@@ -66,6 +83,23 @@ function findManifest(root, relative, version) {
|
|
|
66
83
|
return match;
|
|
67
84
|
}
|
|
68
85
|
|
|
86
|
+
function findMcpServer(home) {
|
|
87
|
+
const matches = [];
|
|
88
|
+
const visit = (directory, depth = 0) => {
|
|
89
|
+
if (depth > 12 || !fs.existsSync(directory)) return;
|
|
90
|
+
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
91
|
+
const child = path.join(directory, entry.name);
|
|
92
|
+
if (entry.isSymbolicLink()) continue;
|
|
93
|
+
if (entry.isDirectory()) visit(child, depth + 1);
|
|
94
|
+
else if (entry.isFile() && entry.name === 'server.mjs' && child.includes(`${path.sep}mcp${path.sep}`)) matches.push(child);
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
visit(home);
|
|
98
|
+
const server = matches.find((file) => file.includes('ruvnet-brain'));
|
|
99
|
+
if (!server) throw new Error('installed host registration has no ruvnet-brain MCP server');
|
|
100
|
+
return server;
|
|
101
|
+
}
|
|
102
|
+
|
|
69
103
|
export function assertInstalledPayload(sourceRoot, installedRoot) {
|
|
70
104
|
let checked = 0;
|
|
71
105
|
const visit = (source, relative = '') => {
|
|
@@ -143,6 +177,7 @@ function rpcSearch(server, env, query, timeoutMs = DEADLINE_MS) {
|
|
|
143
177
|
|
|
144
178
|
export function livePublicationAdapter({ root = process.cwd() } = {}) {
|
|
145
179
|
let installContext = null;
|
|
180
|
+
let installTemp = null;
|
|
146
181
|
return {
|
|
147
182
|
async downloadNpm({ version, destination }) {
|
|
148
183
|
const metadata = JSON.parse(command('npm', ['view', `${PACKAGE}@${version}`, '--json']));
|
|
@@ -169,42 +204,66 @@ export function livePublicationAdapter({ root = process.cwd() } = {}) {
|
|
|
169
204
|
|
|
170
205
|
async installHosts({ artifactPath, artifactSha256, version }) {
|
|
171
206
|
const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'ruvnet-public-install-'));
|
|
207
|
+
installTemp = temp;
|
|
172
208
|
const packageRoot = path.join(temp, 'package');
|
|
173
209
|
command('tar', ['-xzf', artifactPath, '-C', temp]);
|
|
174
|
-
const home = path.join(temp, 'home');
|
|
175
|
-
const codexHome = path.join(home, '.codex');
|
|
176
|
-
const brainHome = path.join(home, '.cache', 'ruvnet-brain');
|
|
177
|
-
const kb = path.join(brainHome, 'kb');
|
|
178
|
-
fs.mkdirSync(codexHome, { recursive: true });
|
|
179
|
-
const env = {
|
|
180
|
-
...process.env,
|
|
181
|
-
HOME: home,
|
|
182
|
-
CODEX_HOME: codexHome,
|
|
183
|
-
RUVNET_BRAIN_HOME: brainHome,
|
|
184
|
-
RUVNET_BRAIN_KB: kb,
|
|
185
|
-
RUVNET_STRICT_INSTALL: '1',
|
|
186
|
-
RUVNET_BRAIN_PROFILE: 'complete',
|
|
187
|
-
CI: 'true',
|
|
188
|
-
};
|
|
189
|
-
command(process.execPath, [
|
|
190
|
-
path.join(packageRoot, 'bin', 'install.mjs'), '--yes', '--force', '--version', `v${version}`,
|
|
191
|
-
'--no-nightly-prompt', '--no-telemetry', '--no-stack', '--no-enhance', '--no-statusline',
|
|
192
|
-
], { env, cwd: packageRoot, timeout: 1_200_000, maxBuffer: 32 * 1024 * 1024 });
|
|
193
|
-
|
|
194
|
-
const claudeManifest = findManifest(path.join(home, '.claude'), path.join('.claude-plugin', 'plugin.json'), version);
|
|
195
|
-
const codexManifest = findManifest(path.join(home, '.codex'), path.join('.codex-plugin', 'plugin.json'), version);
|
|
196
210
|
const sealedPlugin = path.join(packageRoot, 'plugin');
|
|
197
|
-
const
|
|
198
|
-
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
211
|
+
const results = {};
|
|
212
|
+
let bundle = null;
|
|
213
|
+
for (const mode of ['claudeOnly', 'codexOnly', 'dual']) {
|
|
214
|
+
const home = path.join(temp, `home-${mode}`);
|
|
215
|
+
const codexHome = path.join(home, '.codex');
|
|
216
|
+
const brainHome = path.join(home, '.cache', 'ruvnet-brain');
|
|
217
|
+
const kb = path.join(brainHome, 'kb');
|
|
218
|
+
fs.mkdirSync(path.join(home, '.claude'), { recursive: true });
|
|
219
|
+
if (mode !== 'claudeOnly') fs.mkdirSync(codexHome, { recursive: true });
|
|
220
|
+
const env = {
|
|
221
|
+
...process.env,
|
|
222
|
+
HOME: home,
|
|
223
|
+
CODEX_HOME: codexHome,
|
|
224
|
+
RUVNET_BRAIN_HOME: brainHome,
|
|
225
|
+
RUVNET_BRAIN_KB: kb,
|
|
226
|
+
RUVNET_STRICT_INSTALL: '1',
|
|
227
|
+
RUVNET_BRAIN_PROFILE: 'complete',
|
|
228
|
+
CI: 'true',
|
|
229
|
+
PATH: isolatedPath(mode, temp),
|
|
230
|
+
};
|
|
231
|
+
const installer = path.join(packageRoot, 'bin', 'install.mjs');
|
|
232
|
+
command(process.execPath, [
|
|
233
|
+
installer, '--yes', '--force', '--version', `v${version}`,
|
|
234
|
+
'--no-nightly-prompt', '--no-telemetry', '--no-stack', '--no-enhance', '--no-statusline',
|
|
235
|
+
], { env, cwd: packageRoot, timeout: 1_200_000, maxBuffer: 32 * 1024 * 1024 });
|
|
236
|
+
command(process.execPath, [installer, '--doctor', '--hooks'], {
|
|
237
|
+
env, cwd: packageRoot, timeout: 300_000, maxBuffer: 32 * 1024 * 1024,
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
const verified = {};
|
|
241
|
+
if (mode !== 'codexOnly') {
|
|
242
|
+
const manifest = findManifest(path.join(home, '.claude'), path.join('.claude-plugin', 'plugin.json'), version);
|
|
243
|
+
verified.claudeFiles = assertInstalledPayload(sealedPlugin, path.dirname(path.dirname(manifest)));
|
|
244
|
+
}
|
|
245
|
+
if (mode !== 'claudeOnly') {
|
|
246
|
+
const manifest = findManifest(codexHome, path.join('.codex-plugin', 'plugin.json'), version);
|
|
247
|
+
verified.codexFiles = assertInstalledPayload(sealedPlugin, path.dirname(path.dirname(manifest)));
|
|
248
|
+
const config = fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8');
|
|
249
|
+
if (!/\[mcp_servers\.ruvnet-brain\]/.test(config)) throw new Error(`${mode} virgin Codex home is not wired to ruvnet-brain`);
|
|
250
|
+
}
|
|
251
|
+
const source = readJson(path.join(kb, 'SOURCE.json'));
|
|
252
|
+
if (source.brainVersion !== version || source.releaseTag !== `v${version}`) {
|
|
253
|
+
throw new Error(`${mode} installed public Brain bundle version mismatch`);
|
|
254
|
+
}
|
|
255
|
+
bundle ||= { brainVersion: source.brainVersion, releaseTag: source.releaseTag };
|
|
256
|
+
const searched = await rpcSearch(findMcpServer(home), env, 'How does RuvNet Brain prove a public release artifact?', DEADLINE_MS);
|
|
257
|
+
results[mode] = {
|
|
258
|
+
status: 'PASS', doctorExit: 0, version, artifactSha256,
|
|
259
|
+
functionalSearch: true, searchMs: searched.broadMs, hostsOnPath: mode,
|
|
260
|
+
...verified,
|
|
261
|
+
};
|
|
262
|
+
if (mode === 'dual') installContext = { temp, home, codexHome, brainHome, kb, env, packageRoot };
|
|
263
|
+
}
|
|
204
264
|
return {
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
bundle: { brainVersion: source.brainVersion, releaseTag: source.releaseTag },
|
|
265
|
+
...results,
|
|
266
|
+
bundle,
|
|
208
267
|
};
|
|
209
268
|
},
|
|
210
269
|
|
|
@@ -231,6 +290,12 @@ export function livePublicationAdapter({ root = process.cwd() } = {}) {
|
|
|
231
290
|
if (result.status !== 0 || parsed.verdict !== 'PASS') throw new Error(`published-surface-probe is ${parsed.verdict || `exit ${result.status}`}`);
|
|
232
291
|
return { name: 'published-surface-probe', status: 'completed', conclusion: 'success', sha };
|
|
233
292
|
},
|
|
293
|
+
|
|
294
|
+
async dispose() {
|
|
295
|
+
if (installTemp) fs.rmSync(installTemp, { recursive: true, force: true });
|
|
296
|
+
installTemp = null;
|
|
297
|
+
installContext = null;
|
|
298
|
+
},
|
|
234
299
|
};
|
|
235
300
|
}
|
|
236
301
|
|
|
@@ -246,15 +311,35 @@ export async function generatePublicationReceipt({
|
|
|
246
311
|
const candidateResult = evaluateCandidateReceipt(candidate);
|
|
247
312
|
if (candidateResult.verdict !== 'PASS') throw new Error(`candidate seal failed: ${candidateResult.failures.map(({ code }) => code).join(',')}`);
|
|
248
313
|
const digest = receiptDigest(candidate);
|
|
314
|
+
const evidenceRoot = path.dirname(path.resolve(candidatePath));
|
|
315
|
+
const payloadManifestPath = path.join(evidenceRoot, 'payload-manifest.json');
|
|
316
|
+
const payloadSignaturePath = path.join(evidenceRoot, 'payload-manifest.sig');
|
|
317
|
+
const payloadManifest = readJson(payloadManifestPath);
|
|
318
|
+
const payloadProof = verifyPayload({
|
|
319
|
+
manifest: payloadManifest,
|
|
320
|
+
signature: fs.readFileSync(payloadSignaturePath, 'utf8'),
|
|
321
|
+
publicKey: crypto.createPublicKey(fs.readFileSync(path.join(root, 'keys', 'ruvnet-brain-signing.pub.pem'), 'utf8')),
|
|
322
|
+
root: evidenceRoot,
|
|
323
|
+
});
|
|
324
|
+
if (payloadManifest.version !== candidate.version || payloadManifest.tag !== candidate.tag
|
|
325
|
+
|| payloadManifest.candidateSha !== candidate.sha) throw new Error('signed payload identity differs from candidate seal');
|
|
326
|
+
const npmMember = payloadManifest.members.find(({ role }) => role === 'npm');
|
|
327
|
+
const bundleMember = payloadManifest.members.find(({ role }) => role === 'bundle');
|
|
328
|
+
if (!npmMember || !bundleMember || npmMember.sha256 !== digest) throw new Error('signed payload member set differs from candidate artifact');
|
|
249
329
|
const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'ruvnet-public-evidence-'));
|
|
250
330
|
try {
|
|
251
331
|
const assetName = path.basename(candidate.artifact.path);
|
|
252
332
|
const npm = await adapter.downloadNpm({ version: candidate.version, destination: path.join(temp, `npm-${assetName}`) });
|
|
253
333
|
const github = await adapter.downloadGithub({ tag: candidate.tag, assetName, destination: path.join(temp, `github-${assetName}`) });
|
|
334
|
+
const githubBundle = await adapter.downloadGithub({
|
|
335
|
+
tag: candidate.tag, assetName: bundleMember.name, destination: path.join(temp, `github-${bundleMember.name}`),
|
|
336
|
+
});
|
|
254
337
|
const npmDigest = sha256(npm.path);
|
|
255
338
|
const githubDigest = sha256(github.path);
|
|
256
339
|
if (npmDigest !== digest) throw new Error(`npm public byte mismatch: ${npmDigest} != ${digest}`);
|
|
257
340
|
if (githubDigest !== digest) throw new Error(`GitHub public byte mismatch: ${githubDigest} != ${digest}`);
|
|
341
|
+
const githubBundleDigest = sha256(githubBundle.path);
|
|
342
|
+
if (githubBundleDigest !== bundleMember.sha256) throw new Error(`GitHub public bundle byte mismatch: ${githubBundleDigest} != ${bundleMember.sha256}`);
|
|
258
343
|
if (npm.version !== candidate.version || (npm.sha && npm.sha !== candidate.sha)) throw new Error('npm public identity mismatch');
|
|
259
344
|
if (github.tag !== candidate.tag || github.sha !== candidate.sha) throw new Error('GitHub public identity mismatch');
|
|
260
345
|
|
|
@@ -262,17 +347,23 @@ export async function generatePublicationReceipt({
|
|
|
262
347
|
const brain = await adapter.probeBrain({ sha: candidate.sha, artifactSha256: digest, version: candidate.version });
|
|
263
348
|
const surface = await adapter.probePublishedSurface({ sha: candidate.sha, artifactSha256: digest, version: candidate.version });
|
|
264
349
|
const publication = {
|
|
265
|
-
schemaVersion:
|
|
350
|
+
schemaVersion: 2,
|
|
266
351
|
phase: 'publication',
|
|
267
352
|
sha: candidate.sha,
|
|
268
353
|
artifactSha256: digest,
|
|
269
354
|
version: candidate.version,
|
|
355
|
+
payloadId: payloadProof.payloadId,
|
|
356
|
+
bundleArtifactSha256: githubBundleDigest,
|
|
270
357
|
// npm does not guarantee gitHead metadata. Exact public-byte equality with the candidate's
|
|
271
358
|
// SHA-bound sealed artifact is the identity proof; a present but conflicting gitHead is red.
|
|
272
359
|
npm: { version: npm.version, sha: candidate.sha, artifactSha256: npmDigest },
|
|
273
360
|
githubRelease: { tag: github.tag, sha: github.sha, artifactSha256: githubDigest },
|
|
274
361
|
bundle: installed.bundle,
|
|
275
|
-
installed: {
|
|
362
|
+
installed: {
|
|
363
|
+
claudeOnly: installed.claudeOnly,
|
|
364
|
+
codexOnly: installed.codexOnly,
|
|
365
|
+
dual: installed.dual,
|
|
366
|
+
},
|
|
276
367
|
brain,
|
|
277
368
|
postPublicationChecks: [surface],
|
|
278
369
|
};
|
|
@@ -282,6 +373,7 @@ export async function generatePublicationReceipt({
|
|
|
282
373
|
fs.writeFileSync(outPath, `${JSON.stringify(publication, null, 2)}\n`, { flag: 'wx', mode: 0o600 });
|
|
283
374
|
return result;
|
|
284
375
|
} finally {
|
|
376
|
+
await adapter.dispose?.();
|
|
285
377
|
fs.rmSync(temp, { recursive: true, force: true });
|
|
286
378
|
}
|
|
287
379
|
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import crypto from 'node:crypto';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { canonicalJson } from './release-transaction.mjs';
|
|
7
|
+
|
|
8
|
+
export const REQUIRED_RELEASE_LEAVES = Object.freeze([
|
|
9
|
+
'source-quality',
|
|
10
|
+
'ux-qe',
|
|
11
|
+
'release-qe',
|
|
12
|
+
'integration-linux',
|
|
13
|
+
'stranger-linux',
|
|
14
|
+
'stranger-macos',
|
|
15
|
+
'stranger-windows',
|
|
16
|
+
'claude-only',
|
|
17
|
+
'codex-only',
|
|
18
|
+
'dual-host',
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
export function aggregateEvidence({ sha, payloadId, leaves }) {
|
|
22
|
+
if (!/^[a-f0-9]{40}$/.test(sha || '') || !/^[a-f0-9]{64}$/.test(payloadId || '')) {
|
|
23
|
+
throw new Error('aggregate identity is malformed');
|
|
24
|
+
}
|
|
25
|
+
const byName = new Map();
|
|
26
|
+
for (const leaf of leaves || []) {
|
|
27
|
+
if (!leaf?.name || byName.has(leaf.name)) throw new Error(`duplicate or unnamed evidence leaf: ${leaf?.name || '(missing)'}`);
|
|
28
|
+
if (leaf.sha !== sha || leaf.payloadId !== payloadId) throw new Error(`evidence identity mismatch: ${leaf.name}`);
|
|
29
|
+
if (leaf.status !== 'completed' || leaf.conclusion !== 'success' || leaf.verdict !== 'PASS') {
|
|
30
|
+
throw new Error(`evidence leaf is not fail-closed PASS: ${leaf.name}`);
|
|
31
|
+
}
|
|
32
|
+
byName.set(leaf.name, leaf);
|
|
33
|
+
}
|
|
34
|
+
const missing = REQUIRED_RELEASE_LEAVES.filter((name) => !byName.has(name));
|
|
35
|
+
if (missing.length) throw new Error(`required evidence leaves missing: ${missing.join(', ')}`);
|
|
36
|
+
const normalized = [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
37
|
+
const envelope = { schemaVersion: 1, sha, payloadId, verdict: 'PASS', leaves: normalized };
|
|
38
|
+
return { ...envelope, evidenceDigest: crypto.createHash('sha256').update(canonicalJson(envelope)).digest('hex') };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const isCli = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
42
|
+
if (isCli) {
|
|
43
|
+
const arg = (name) => {
|
|
44
|
+
const index = process.argv.indexOf(name);
|
|
45
|
+
return index >= 0 ? process.argv[index + 1] : null;
|
|
46
|
+
};
|
|
47
|
+
const out = path.resolve(arg('--out') || 'release-evidence/aggregate-envelope.json');
|
|
48
|
+
const envelope = aggregateEvidence({
|
|
49
|
+
sha: arg('--sha'),
|
|
50
|
+
payloadId: arg('--payload-id'),
|
|
51
|
+
leaves: JSON.parse(fs.readFileSync(path.resolve(arg('--leaves')), 'utf8')),
|
|
52
|
+
});
|
|
53
|
+
fs.writeFileSync(out, `${canonicalJson(envelope)}\n`, { flag: 'wx', mode: 0o600 });
|
|
54
|
+
process.stdout.write(`${JSON.stringify({ verdict: 'PASS', evidenceDigest: envelope.evidenceDigest, out })}\n`);
|
|
55
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import crypto from 'node:crypto';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { canonicalJson } from './release-transaction.mjs';
|
|
7
|
+
|
|
8
|
+
const sha256 = (bytes) => crypto.createHash('sha256').update(bytes).digest('hex');
|
|
9
|
+
const digestFile = (file) => sha256(fs.readFileSync(file));
|
|
10
|
+
|
|
11
|
+
export function createPayloadManifest({ version, tag, candidateSha, producer, members }) {
|
|
12
|
+
const normalized = members.map(({ name, file, role }) => {
|
|
13
|
+
const stat = fs.statSync(file);
|
|
14
|
+
if (!stat.isFile() || stat.size === 0) throw new Error(`payload member is absent or empty: ${file}`);
|
|
15
|
+
return { name, role, size: stat.size, sha256: digestFile(file) };
|
|
16
|
+
}).sort((a, b) => a.name.localeCompare(b.name));
|
|
17
|
+
const names = new Set(normalized.map(({ name }) => name));
|
|
18
|
+
if (names.size !== normalized.length) throw new Error('payload member names must be unique');
|
|
19
|
+
return { schemaVersion: 1, version, tag, candidateSha, producer, members: normalized };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const payloadIdFor = (manifest) => sha256(Buffer.from(canonicalJson(manifest)));
|
|
23
|
+
|
|
24
|
+
export function signPayloadManifest(manifest, privateKey) {
|
|
25
|
+
return crypto.sign(null, Buffer.from(canonicalJson(manifest)), privateKey).toString('base64');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function verifyPayloadMembers({ manifest, root }) {
|
|
29
|
+
if (!manifest || manifest.schemaVersion !== 1 || !Array.isArray(manifest.members)) {
|
|
30
|
+
throw new Error('invalid payload manifest');
|
|
31
|
+
}
|
|
32
|
+
for (const member of manifest.members) {
|
|
33
|
+
if (!member.name || path.basename(member.name) !== member.name) throw new Error(`unsafe payload member name: ${member.name}`);
|
|
34
|
+
const file = path.join(root, member.name);
|
|
35
|
+
const stat = fs.statSync(file);
|
|
36
|
+
if (stat.size !== member.size || digestFile(file) !== member.sha256) {
|
|
37
|
+
throw new Error(`payload member mismatch: ${member.name}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return { payloadId: payloadIdFor(manifest), manifest, root };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function verifyPayload({ manifest, signature, publicKey, root }) {
|
|
44
|
+
if (!crypto.verify(null, Buffer.from(canonicalJson(manifest)), publicKey, Buffer.from(signature.trim(), 'base64'))) {
|
|
45
|
+
throw new Error('payload manifest signature mismatch');
|
|
46
|
+
}
|
|
47
|
+
return verifyPayloadMembers({ manifest, root });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const arg = (name) => {
|
|
51
|
+
const index = process.argv.indexOf(name);
|
|
52
|
+
return index >= 0 ? process.argv[index + 1] : null;
|
|
53
|
+
};
|
|
54
|
+
const command = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
|
|
55
|
+
? process.argv[2]
|
|
56
|
+
: null;
|
|
57
|
+
|
|
58
|
+
if (command === 'create') {
|
|
59
|
+
const out = path.resolve(arg('--out') || 'release-evidence/payload-manifest.json');
|
|
60
|
+
const memberArgs = process.argv.filter((value, index) => process.argv[index - 1] === '--member');
|
|
61
|
+
const members = memberArgs.map((entry) => {
|
|
62
|
+
const [role, file] = entry.split('=', 2);
|
|
63
|
+
if (!role || !file) throw new Error(`invalid --member ${entry}; expected role=path`);
|
|
64
|
+
return { role, file: path.resolve(file), name: path.basename(file) };
|
|
65
|
+
});
|
|
66
|
+
const manifest = createPayloadManifest({
|
|
67
|
+
version: arg('--version'),
|
|
68
|
+
tag: arg('--tag'),
|
|
69
|
+
candidateSha: arg('--sha'),
|
|
70
|
+
producer: {
|
|
71
|
+
workflow: process.env.GITHUB_WORKFLOW || 'local',
|
|
72
|
+
runId: process.env.GITHUB_RUN_ID || 'local',
|
|
73
|
+
runAttempt: process.env.GITHUB_RUN_ATTEMPT || '1',
|
|
74
|
+
},
|
|
75
|
+
members,
|
|
76
|
+
});
|
|
77
|
+
fs.writeFileSync(out, `${canonicalJson(manifest)}\n`, { flag: 'wx', mode: 0o600 });
|
|
78
|
+
process.stdout.write(`${JSON.stringify({ payloadId: payloadIdFor(manifest), manifest: out })}\n`);
|
|
79
|
+
} else if (command === 'sign') {
|
|
80
|
+
const manifestPath = path.resolve(arg('--manifest'));
|
|
81
|
+
const out = path.resolve(arg('--out'));
|
|
82
|
+
if (fs.existsSync(out)) throw new Error(`refusing to overwrite persisted payload signature: ${out}`);
|
|
83
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
84
|
+
const pem = process.env.RUVNET_SIGNING_KEY;
|
|
85
|
+
if (!pem) throw new Error('RUVNET_SIGNING_KEY is required');
|
|
86
|
+
fs.writeFileSync(out, `${signPayloadManifest(manifest, crypto.createPrivateKey(pem))}\n`, { flag: 'wx', mode: 0o600 });
|
|
87
|
+
process.stdout.write(`${JSON.stringify({ payloadId: payloadIdFor(manifest), signature: out })}\n`);
|
|
88
|
+
} else if (command === 'verify' || command === 'verify-members') {
|
|
89
|
+
const manifestPath = path.resolve(arg('--manifest'));
|
|
90
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
91
|
+
const result = command === 'verify'
|
|
92
|
+
? verifyPayload({
|
|
93
|
+
manifest,
|
|
94
|
+
signature: fs.readFileSync(path.resolve(arg('--signature')), 'utf8'),
|
|
95
|
+
publicKey: crypto.createPublicKey(fs.readFileSync(path.resolve(arg('--public-key')), 'utf8')),
|
|
96
|
+
root: path.dirname(manifestPath),
|
|
97
|
+
})
|
|
98
|
+
: verifyPayloadMembers({ manifest, root: path.dirname(manifestPath) });
|
|
99
|
+
process.stdout.write(`${JSON.stringify({ verdict: 'PASS', payloadId: result.payloadId })}\n`);
|
|
100
|
+
}
|