ruvnet-brain 4.7.18 → 4.7.19
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/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/commands/configure.md +3 -3
- package/plugin/scripts/nightly-scheduler.mjs +29 -8
- package/scripts/codex-hook-trust-reconcile.mjs +126 -9
- package/scripts/codex-hook-trust.mjs +45 -0
package/README.md
CHANGED
|
@@ -9,7 +9,7 @@ Created: 2026-06-29 22:36:38 EDT
|
|
|
9
9
|
|
|
10
10
|
### Discover the stack. Read the source. Build with confidence.
|
|
11
11
|
|
|
12
|
-
[](plugin/.claude-plugin/plugin.json)
|
|
13
13
|
|
|
14
14
|
**A source-grounded companion for Claude Code and Codex that helps you find and use Reuven Cohen's (rUv's) RuvNet ecosystem.**
|
|
15
15
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ruvnet-brain",
|
|
3
|
-
"version": "4.7.
|
|
3
|
+
"version": "4.7.19",
|
|
4
4
|
"description": "One-command installer for RuvNet Brain — a portable, source-grounded brain over rUv's RuvNet building blocks, delivered as a Claude Code plugin so Claude uses the stack instead of fighting it.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ruvnet-brain",
|
|
3
3
|
"description": "RuvNet brain transplant for Claude Code — grounds every RuvNet decision in real source across 77 rUv repositories, prefers Ruflo / RuVector-RVF / AgentDB over training-prior defaults (pgvector, Pinecone, hand-rolled cosine), and can pull in any RuvNet repo on demand. Ships a UserPromptSubmit retrieve-and-inject grounding hook and a PreToolUse write gate that refuses ungrounded rUv-product code until search_ruvnet has been consulted (ADR-0012 / ADR-067).",
|
|
4
|
-
"version": "4.7.
|
|
4
|
+
"version": "4.7.19",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Stuart Kerr"
|
|
7
7
|
},
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: Open the RuvNet Brain Console (/rnbc) — a local web page that mirrors YOUR machine's RuvNet setup, explains it in plain English, and lets you safely configure and fix it. Read-only until you click; every machine change is explained first and is reversible.
|
|
3
|
-
updated: 2026-10-
|
|
4
|
-
version: 1.0.
|
|
2
|
+
description: "Open the RuvNet Brain Console (/rnbc) — a local web page that mirrors YOUR machine's RuvNet setup, explains it in plain English, and lets you safely configure and fix it. Read-only until you click; every machine change is explained first and is reversible."
|
|
3
|
+
updated: 2026-10-24 18:00:00 EDT
|
|
4
|
+
version: 1.0.3
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
Launch the **RuvNet Brain Console** (RNBC) for the user. `/rnbc` is its name; `/rnb`, `/rvbc`,
|
|
@@ -186,7 +186,8 @@ function verifyUpdateModules(modules) {
|
|
|
186
186
|
* the allowlisted values from env; an object is kept verbatim; null omits the field (an older registration without it).
|
|
187
187
|
*/
|
|
188
188
|
export function installNightlyRunner({ brainHome, source, nodePath = process.execPath,
|
|
189
|
-
identity = NIGHTLY_LABEL, packageTarget, bundleTarget, env = {}, environment, moduleSource, developerSuite = !NIGHTLY_PROOF_LABEL.test(identity), dryRun = false
|
|
189
|
+
identity = NIGHTLY_LABEL, packageTarget, bundleTarget, env = {}, environment, moduleSource, developerSuite = !NIGHTLY_PROOF_LABEL.test(identity), dryRun = false,
|
|
190
|
+
replacedUpdateModules = null } = {}) {
|
|
190
191
|
validateIdentity(identity);
|
|
191
192
|
if (!source || !fs.existsSync(source)) throw new Error(`nightly runner source is missing: ${source || '(unset)'}`);
|
|
192
193
|
const bytes = fs.readFileSync(source);
|
|
@@ -204,7 +205,8 @@ export function installNightlyRunner({ brainHome, source, nodePath = process.exe
|
|
|
204
205
|
const record = { updateModules: modules, ...(developerSuite ? { mode: 'developer-suite' } : {}), schemaVersion: 2, kind: 'ruvnet-brain-nightly-scheduler', identity,
|
|
205
206
|
nodePath: path.resolve(nodePath), runnerPath, runnerSha256: digest, argv: [],
|
|
206
207
|
...(environment === null ? {} : { environment: environment ?? Object.fromEntries(NIGHTLY_ENV_ALLOWLIST.filter(key => env[key] !== undefined).map(key => [key, key === 'PATH' ? automaticPath({ nodePath, home: homeOf(env), env }) : String(env[key])])) }),
|
|
207
|
-
packageTarget: normalizePackageTarget(packageTarget), bundleTarget: normalizeBundleTarget(bundleTarget, identity)
|
|
208
|
+
packageTarget: normalizePackageTarget(packageTarget), bundleTarget: normalizeBundleTarget(bundleTarget, identity),
|
|
209
|
+
...(Array.isArray(replacedUpdateModules) && replacedUpdateModules.length ? { replacedUpdateModules } : {}) };
|
|
208
210
|
const recordPath = path.join(dir, registrationName(identity));
|
|
209
211
|
if (dryRun) return { ...record, recordPath };
|
|
210
212
|
const tmp = `${recordPath}.tmp-${process.pid}`;
|
|
@@ -239,6 +241,12 @@ export function nightlyModuleCurrency({ brainHome, identity = NIGHTLY_LABEL, mod
|
|
|
239
241
|
* and the LaunchAgent command stays exactly what it was; anything else is refused and left as it was.
|
|
240
242
|
*/
|
|
241
243
|
export const SCHEDULER_VISIBLE_FIELDS = Object.freeze(['identity', 'nodePath', 'runnerPath', 'runnerSha256', 'argv', 'environment', 'packageTarget', 'bundleTarget', 'mode']);
|
|
244
|
+
/** The replaced-module history after replacing `record.updateModules` now: newest first, last 48 h, at most 20. */
|
|
245
|
+
export function nextReplacedSets(record, now = Date.now()) {
|
|
246
|
+
return [{ updateModules: record.updateModules, replacedAt: new Date(now).toISOString() },
|
|
247
|
+
...(Array.isArray(record.replacedUpdateModules) ? record.replacedUpdateModules : [])]
|
|
248
|
+
.filter((entry) => now - Date.parse(entry?.replacedAt || '') <= 48 * 3_600_000).slice(0, 20);
|
|
249
|
+
}
|
|
242
250
|
export function refreshNightlyModules(options = {}) {
|
|
243
251
|
const currency = nightlyModuleCurrency(options);
|
|
244
252
|
if (currency.state !== 'stale') return currency;
|
|
@@ -250,7 +258,11 @@ export function refreshNightlyModules(options = {}) {
|
|
|
250
258
|
const candidate = (options.build || ((p) => installNightlyRunner({ ...p, dryRun: true })))(plan);
|
|
251
259
|
const differs = SCHEDULER_VISIBLE_FIELDS.filter((key) => JSON.stringify(candidate[key] ?? null) !== JSON.stringify(record[key] ?? null));
|
|
252
260
|
if (differs.length) return { ...currency, state: 'refused', reason: `refresh would change ${differs.join(', ')}; registration left as it was`, fix: NIGHTLY_ENABLE_FIX };
|
|
253
|
-
|
|
261
|
+
// Remember the module set this replaces: the receipt of the night that ran on it stays valid evidence until the next
|
|
262
|
+
// night (2026-10-24: doctor said "nightly refresh FAILED" all day after a session-start update re-pointed the modules).
|
|
263
|
+
// Newest first, every set replaced in the last 48 h (a receipt older than the 30 h health window is stale anyway), at
|
|
264
|
+
// most 20: any number of refreshes before the next night keeps the set that night ran on (reviews 2026-10-24).
|
|
265
|
+
const refreshed = installNightlyRunner({ ...plan, replacedUpdateModules: nextReplacedSets(record) });
|
|
254
266
|
return { ...currency, state: 'refreshed', registered: registeredModuleDigest(refreshed), fix: undefined };
|
|
255
267
|
}
|
|
256
268
|
|
|
@@ -329,10 +341,19 @@ export function developerRunHealth({ brainHome, registration, now = Date.now(),
|
|
|
329
341
|
} catch (error) { return { state: 'failed', evidence: `Coordinated update receipt unreadable or refused: ${error.message}`, receipt: null }; }
|
|
330
342
|
if (!receipt) return { state: 'never-ran', evidence: 'No coordinated update receipt exists yet.', receipt: null };
|
|
331
343
|
const validDigest = value => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value);
|
|
332
|
-
const
|
|
333
|
-
&& receipt.sourceSha256 ===
|
|
334
|
-
&& Object.entries(receipt.sourceSnapshot || {}).every(([name, digest]) => validDigest(digest) && digest ===
|
|
335
|
-
&& EXECUTION_MODULES.filter(name => name !== 'plugin-artifact-proof.mjs' ||
|
|
344
|
+
const boundTo = modules => Boolean(modules) && validDigest(receipt.sourceSha256) && receipt.kind === 'nightly-suite-update' && receipt.schemaVersion === 1
|
|
345
|
+
&& receipt.sourceSha256 === modules['developer-update.mjs']?.sha256
|
|
346
|
+
&& Object.entries(receipt.sourceSnapshot || {}).every(([name, digest]) => validDigest(digest) && digest === modules[name]?.sha256)
|
|
347
|
+
&& EXECUTION_MODULES.filter(name => name !== 'plugin-artifact-proof.mjs' || modules[name]).every(name => validDigest(receipt.sourceSnapshot?.[name]) && receipt.sourceSnapshot[name] === modules[name]?.sha256);
|
|
348
|
+
// A receipt from the module set an update just replaced is still the night's proof, but only one that FINISHED before
|
|
349
|
+
// the replacement: the next scheduled run must bind to the current set.
|
|
350
|
+
// Judged by LAUNCH time: a night's own knowledge step can install a release and re-point the modules before the run
|
|
351
|
+
// records finishedAt (cross-model review 2026-10-24). A run launched after its set was replaced must bind to the new one.
|
|
352
|
+
const launched = Date.parse(receipt.startedAt || receipt.finishedAt || '');
|
|
353
|
+
const ranOnPrevious = !boundTo(registration.updateModules) && Array.isArray(registration.replacedUpdateModules)
|
|
354
|
+
&& registration.replacedUpdateModules.some((entry) => Number.isFinite(Date.parse(entry?.replacedAt || ''))
|
|
355
|
+
&& launched <= Date.parse(entry.replacedAt) && boundTo(entry.updateModules));
|
|
356
|
+
const bound = boundTo(registration.updateModules) || ranOnPrevious;
|
|
336
357
|
const ageHours = (now - Date.parse(receipt.finishedAt || receipt.startedAt || '')) / 3_600_000;
|
|
337
358
|
const times = [receipt.startedAt, receipt.finishedAt].filter(value => value !== undefined).map(value => Date.parse(value));
|
|
338
359
|
const timestampsValid = times.length > 0 && times.every(value => Number.isFinite(value) && value <= now)
|
|
@@ -349,7 +370,7 @@ export function developerRunHealth({ brainHome, registration, now = Date.now(),
|
|
|
349
370
|
if (receipt.state !== 'completed') return { state: 'failed', ageHours, receipt, evidence: 'Coordinated update terminal state is unverified.' };
|
|
350
371
|
if (ageHours > maxAgeHours) return { state: 'stale', ageHours, receipt, evidence: `Last coordinated update is ${ageHours.toFixed(1)}h old.` };
|
|
351
372
|
const verification = { ok: bound && receipt.mode === 'apply' && receipt.schedulerIdentity === registration.identity && receipt.state === 'completed' && receipt.ok === true && ageHours >= 0 && ageHours <= maxAgeHours };
|
|
352
|
-
return { state: verification.ok ? 'ok' : 'failed', verification, ageHours, receipt, evidence: verification.ok ? `Coordinated updates completed ${ageHours.toFixed(1)}h ago; ${receipt.config?.scope || 'unknown'} scope
|
|
373
|
+
return { state: verification.ok ? 'ok' : 'failed', verification, ageHours, receipt, evidence: verification.ok ? `Coordinated updates completed ${ageHours.toFixed(1)}h ago; ${receipt.config?.scope || 'unknown'} scope.${ranOnPrevious ? ' That run used the code an update replaced since; the next night runs the update.' : ''}` : 'Coordinated update timestamp or terminal evidence is invalid.' };
|
|
353
374
|
}
|
|
354
375
|
|
|
355
376
|
export function refreshRunHealth({ brainHome, identity = NIGHTLY_LABEL, now = Date.now(), maxAgeHours = 30,
|
|
@@ -5,7 +5,103 @@ import path from 'node:path';
|
|
|
5
5
|
import crypto from 'node:crypto';
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
7
|
import { spawn, spawnSync } from 'node:child_process';
|
|
8
|
-
import
|
|
8
|
+
import zlib from 'node:zlib';
|
|
9
|
+
import { codexHookIdentities, codexPluginContentHash } from './codex-hook-trust.mjs';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* sha256 of every regular file under package/plugin/ in a verified npm tarball, read in memory (ustar with pax `path`
|
|
13
|
+
* records; nothing is extracted or written). Strict: a bad header checksum, a non-octal size, a body past the end, or
|
|
14
|
+
* any entry type other than file, directory or pax is refused rather than guessed (review 2026-10-24).
|
|
15
|
+
*/
|
|
16
|
+
export function publishedPluginFiles(archive) {
|
|
17
|
+
const tar = zlib.gunzipSync(archive);
|
|
18
|
+
const files = new Map();
|
|
19
|
+
const field = (b) => { const i = b.indexOf(0); return (i < 0 ? b : b.subarray(0, i)).toString('utf8'); };
|
|
20
|
+
const octal = (b, what) => { const t = field(b).trim(); if (!/^[0-7]+$/.test(t)) throw new Error(`Published archive has a non-octal ${what}`); return parseInt(t, 8); };
|
|
21
|
+
const paxRecords = (body) => {
|
|
22
|
+
const out = {}; let i = 0;
|
|
23
|
+
while (i < body.length) {
|
|
24
|
+
const space = body.indexOf(0x20, i);
|
|
25
|
+
if (space < 0) throw new Error('Published archive has a malformed pax record');
|
|
26
|
+
const len = Number(body.subarray(i, space).toString('ascii'));
|
|
27
|
+
if (!Number.isSafeInteger(len) || len <= space - i || i + len > body.length || body[i + len - 1] !== 0x0a) throw new Error('Published archive has a malformed pax record');
|
|
28
|
+
const rec = body.subarray(space + 1, i + len - 1).toString('utf8');
|
|
29
|
+
const eq = rec.indexOf('=');
|
|
30
|
+
if (eq < 1) throw new Error('Published archive has a malformed pax record');
|
|
31
|
+
out[rec.slice(0, eq)] = rec.slice(eq + 1);
|
|
32
|
+
i += len;
|
|
33
|
+
}
|
|
34
|
+
return out;
|
|
35
|
+
};
|
|
36
|
+
let pax = null; let off = 0;
|
|
37
|
+
for (; off + 512 <= tar.length;) {
|
|
38
|
+
const header = tar.subarray(off, off + 512);
|
|
39
|
+
if (header.every((b) => b === 0)) break;
|
|
40
|
+
const sum = header.reduce((n, b, i) => n + (i >= 148 && i < 156 ? 32 : b), 0);
|
|
41
|
+
if (octal(header.subarray(148, 156), 'checksum') !== sum) throw new Error('Published archive header checksum mismatch');
|
|
42
|
+
const size = octal(header.subarray(124, 136), 'size');
|
|
43
|
+
const type = header[156] === 0 ? '0' : String.fromCharCode(header[156]);
|
|
44
|
+
const prefix = field(header.subarray(345, 500));
|
|
45
|
+
const name = pax?.path ?? (prefix ? `${prefix}/${field(header.subarray(0, 100))}` : field(header.subarray(0, 100)));
|
|
46
|
+
if (off + 512 + size > tar.length) throw new Error('Published archive entry runs past the end');
|
|
47
|
+
const body = tar.subarray(off + 512, off + 512 + size);
|
|
48
|
+
off += 512 + Math.ceil(size / 512) * 512;
|
|
49
|
+
if (type === 'x') { pax = paxRecords(body); continue; }
|
|
50
|
+
if (type === 'g') { pax = null; continue; }
|
|
51
|
+
pax = null;
|
|
52
|
+
if (type === '5') continue;
|
|
53
|
+
if (type !== '0') throw new Error(`Published archive has an unsupported entry type ${JSON.stringify(type)}`);
|
|
54
|
+
if (name.startsWith('package/plugin/')) files.set(name.slice('package/plugin/'.length), digest(body));
|
|
55
|
+
}
|
|
56
|
+
if (!files.size) throw new Error('Published archive carries no plugin files');
|
|
57
|
+
return files;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Codex writes this under the installed plugin itself: one SKILL.md per migrated slash command (openai/codex
|
|
61
|
+
// rust-v0.164.0 codex-rs/core-plugins/src/command_migration.rs render_command_skill). It is not release bytes, so it is
|
|
62
|
+
// accepted only when every byte is what that template renders from the release's own verified command file.
|
|
63
|
+
const CODEX_GENERATED = 'migrated-command-skills';
|
|
64
|
+
function commandParts(text) {
|
|
65
|
+
const m = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)([\s\S]*)$/.exec(text);
|
|
66
|
+
if (!m) return null;
|
|
67
|
+
const d = /^description:\s*"((?:[^"\\]|\\.)*)"\s*$/m.exec(m[1]);
|
|
68
|
+
return d ? { description: JSON.parse(`"${d[1]}"`).trim(), body: m[2] } : null;
|
|
69
|
+
}
|
|
70
|
+
function expectedGeneratedSkill(skillName, sourceName, commandText) {
|
|
71
|
+
const parts = commandParts(commandText);
|
|
72
|
+
if (!parts || !parts.description) return null;
|
|
73
|
+
const body = parts.body.trim() || 'No command template body was found.';
|
|
74
|
+
return `---\nname: ${JSON.stringify(skillName)}\ndescription: ${JSON.stringify(parts.description)}\n---\n\n# ${skillName}\n\n`
|
|
75
|
+
+ `Use this skill when the user asks to run the migrated source command \`${sourceName}\`.\n\n## Command Template\n\n${body}\n`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* ONE pass over the installed plugin: each file's bytes are checked against the verified release (or, for Codex's own
|
|
80
|
+
* generated skills, against the template rendered from the verified command file) and the SAME bytes feed the content
|
|
81
|
+
* fingerprint returned. Throws on any changed, extra or missing file, link, or generated file that is not exactly
|
|
82
|
+
* Codex's rendering.
|
|
83
|
+
*/
|
|
84
|
+
export function verifiedPluginContentHash(root, pluginFiles, { platform = process.platform } = {}) {
|
|
85
|
+
const seen = new Set(); const commands = new Map(); const generated = new Map();
|
|
86
|
+
const genPrefix = `.codex-plugin/${CODEX_GENERATED}/`;
|
|
87
|
+
const hash = codexPluginContentHash(root, { platform, onFile: (rel, bytes) => {
|
|
88
|
+
if (rel.startsWith(genPrefix)) { generated.set(rel.slice(genPrefix.length), bytes); return; }
|
|
89
|
+
if (pluginFiles.get(rel) !== digest(bytes)) throw new Error(`Installed plugin file differs from the verified release: ${rel}`);
|
|
90
|
+
seen.add(rel);
|
|
91
|
+
if (/^commands\/[^/]+\.md$/.test(rel)) commands.set(rel.slice('commands/'.length, -3), bytes.toString('utf8'));
|
|
92
|
+
} });
|
|
93
|
+
const missing = [...pluginFiles.keys()].filter((k) => !seen.has(k));
|
|
94
|
+
if (missing.length) throw new Error(`Installed plugin lacks ${missing.length} released file(s), e.g. ${missing[0]}`);
|
|
95
|
+
for (const [rel, bytes] of generated) {
|
|
96
|
+
const m = /^source-command-([a-z0-9-]+)\/SKILL\.md$/.exec(rel);
|
|
97
|
+
const source = m && commands.get(m[1]);
|
|
98
|
+
const expected = source && expectedGeneratedSkill(`source-command-${m[1]}`, m[1], source);
|
|
99
|
+
if (!expected || bytes.toString('utf8') !== expected) throw new Error(`Codex-generated file is not the rendering of a verified release command: ${rel}`);
|
|
100
|
+
}
|
|
101
|
+
return hash;
|
|
102
|
+
}
|
|
103
|
+
/** Kept for callers that only need the yes/no: the installed plugin is the verified release. */
|
|
104
|
+
export function installedPluginMatchesRelease(root, pluginFiles) { verifiedPluginContentHash(root, pluginFiles); return true; }
|
|
9
105
|
|
|
10
106
|
/** Authenticate exact published package bytes before reading one archive member. No extraction/install. */
|
|
11
107
|
export async function obtainVerifiedPublishedHookIdentity({ version, fetch: fetcher = globalThis.fetch,
|
|
@@ -43,7 +139,9 @@ export async function obtainVerifiedPublishedHookIdentity({ version, fetch: fetc
|
|
|
43
139
|
{ input: archive, timeout: Math.min(remaining, 3000), maxBuffer: 256 * 1024 });
|
|
44
140
|
if (result.error || result.status !== 0 || !result.stdout?.length) throw new Error('Platform tar cannot read exact published hooks entry');
|
|
45
141
|
if (!codexHookIdentities(JSON.parse(result.stdout)).size) throw new Error('Published native hooks are invalid');
|
|
46
|
-
|
|
142
|
+
let pluginFiles = null;
|
|
143
|
+
try { pluginFiles = publishedPluginFiles(archive); } catch { pluginFiles = null; /* content trust is then refused, never guessed */ }
|
|
144
|
+
return { state: 'verified', releaseIdentity: { version, integrity, hooksSha256: digest(result.stdout), pluginFiles },
|
|
47
145
|
metadataUrl, tarballUrl, archiveBytes: archive.length };
|
|
48
146
|
} catch (error) { return blocked(error.message); }
|
|
49
147
|
}
|
|
@@ -142,9 +240,14 @@ const rowsFor = (response) => {
|
|
|
142
240
|
if (!Array.isArray(response?.data)) throw new Error('Native hook registry unavailable');
|
|
143
241
|
return response.data.flatMap((r) => r.hooks || []).filter((r) => r.pluginId === OWNER);
|
|
144
242
|
};
|
|
145
|
-
|
|
243
|
+
// Codex <0.164: currentHash is the declaration hash. 0.164+: "<declaration hash>/<plugin content hash>" (discovery.rs).
|
|
244
|
+
const splitHash = (current) => { const [declaration, content = null] = String(current || '').split('/'); return { declaration, content }; };
|
|
245
|
+
function assertRegistry(rows, identities, contentHash = null) {
|
|
146
246
|
if (rows.length !== identities.size || new Set(rows.map((r) => r.key)).size !== identities.size
|
|
147
|
-
|| rows.some((row) => identities.get(row.key) !== row.currentHash)) throw new Error('Native registry differs from verified released artifact');
|
|
247
|
+
|| rows.some((row) => identities.get(row.key) !== splitHash(row.currentHash).declaration)) throw new Error('Native registry differs from verified released artifact');
|
|
248
|
+
if (rows.some((row) => splitHash(row.currentHash).content !== null && splitHash(row.currentHash).content !== contentHash)) {
|
|
249
|
+
throw new Error('Native plugin content fingerprint differs from the verified installed plugin');
|
|
250
|
+
}
|
|
148
251
|
}
|
|
149
252
|
|
|
150
253
|
function otherSettings(config, keys) {
|
|
@@ -153,6 +256,7 @@ function otherSettings(config, keys) {
|
|
|
153
256
|
const state = clone?.hooks?.state?.[key];
|
|
154
257
|
if (state && typeof state === 'object') {
|
|
155
258
|
delete state.trusted_hash;
|
|
259
|
+
delete state.trusted_content_hash;
|
|
156
260
|
if (!Object.keys(state).length) delete clone.hooks.state[key];
|
|
157
261
|
}
|
|
158
262
|
}
|
|
@@ -170,7 +274,7 @@ function otherSettings(config, keys) {
|
|
|
170
274
|
export async function reconcileVerifiedCodexHookTrust({ releaseIdentity, installedVersion, hooksPath,
|
|
171
275
|
configPath, nativeBinary, cwd = process.cwd(), backupDir = path.join(path.dirname(configPath || ''), 'backups'),
|
|
172
276
|
rpc: suppliedRpc, timeoutMs = 10000 } = {}) {
|
|
173
|
-
let client; let wrote = false; let writeAttempted = false; let backupPath;
|
|
277
|
+
let client; let wrote = false; let writeAttempted = false; let backupPath; let contentRefusal = 'Verified release plugin files unavailable';
|
|
174
278
|
try {
|
|
175
279
|
if (!releaseIdentity || !/^\d+\.\d+\.\d+$/.test(releaseIdentity.version || '')
|
|
176
280
|
|| installedVersion !== releaseIdentity.version || !/^[a-f0-9]{64}$/.test(releaseIdentity.hooksSha256 || '')
|
|
@@ -181,9 +285,19 @@ export async function reconcileVerifiedCodexHookTrust({ releaseIdentity, install
|
|
|
181
285
|
if (digest(bytes) !== releaseIdentity.hooksSha256) return blocked('Installed hooks differ from verified release');
|
|
182
286
|
const identities = codexHookIdentities(JSON.parse(bytes));
|
|
183
287
|
if (!identities.size) return blocked('Verified artifact has no native hooks');
|
|
288
|
+
// Codex 0.164+ also pins the installed plugin folder. Approve that fingerprint only for a folder that holds exactly
|
|
289
|
+
// the verified release's plugin/ bytes (Codex's own generated migrations aside); otherwise refuse, never guess.
|
|
290
|
+
const pluginRoot = path.dirname(path.dirname(hooksPath));
|
|
291
|
+
let contentHash = null;
|
|
292
|
+
if (releaseIdentity.pluginFiles instanceof Map) {
|
|
293
|
+
try { contentHash = verifiedPluginContentHash(pluginRoot, releaseIdentity.pluginFiles); }
|
|
294
|
+
catch (error) { contentHash = null; contentRefusal = error.message; }
|
|
295
|
+
}
|
|
184
296
|
if (!suppliedRpc) client = await nativeClient(nativeBinary, cwd, Date.now() + Math.min(timeoutMs, 20000), configPath);
|
|
185
297
|
const rpc = suppliedRpc || client.rpc;
|
|
186
|
-
const before = rowsFor(await rpc('hooks/list', { cwds: [cwd] }));
|
|
298
|
+
const before = rowsFor(await rpc('hooks/list', { cwds: [cwd] }));
|
|
299
|
+
if (!contentHash && before.some((row) => splitHash(row.currentHash).content !== null)) return blocked(contentRefusal);
|
|
300
|
+
assertRegistry(before, identities, contentHash);
|
|
187
301
|
const disabled = before.filter((r) => r.enabled === false).map((r) => r.key).sort();
|
|
188
302
|
if (before.some((r) => !['trusted', 'modified', 'untrusted', 'managed'].includes(r.trustStatus))) return blocked('Unknown native trust state');
|
|
189
303
|
const pending = before.filter((r) => r.enabled === true && ['modified', 'untrusted'].includes(r.trustStatus));
|
|
@@ -194,8 +308,11 @@ export async function reconcileVerifiedCodexHookTrust({ releaseIdentity, install
|
|
|
194
308
|
// Config stays memory-only except the private backup. No logging of effective config values.
|
|
195
309
|
const prior = fs.readFileSync(configPath);
|
|
196
310
|
backupPath = privateBackup(configPath, prior, backupDir);
|
|
197
|
-
const edits = pending.
|
|
198
|
-
value: identities.get(r.key), mergeStrategy: 'replace' }
|
|
311
|
+
const edits = pending.flatMap((r) => [{ keyPath: `hooks.state.${JSON.stringify(r.key)}.trusted_hash`,
|
|
312
|
+
value: identities.get(r.key), mergeStrategy: 'replace' },
|
|
313
|
+
// The second approval Codex 0.164 stores separately (plugin_trust.rs matches_hash).
|
|
314
|
+
...(splitHash(r.currentHash).content !== null ? [{ keyPath: `hooks.state.${JSON.stringify(r.key)}.trusted_content_hash`,
|
|
315
|
+
value: contentHash, mergeStrategy: 'replace' }] : [])]);
|
|
199
316
|
writeAttempted = true;
|
|
200
317
|
await rpc('config/batchWrite', { filePath: configPath, expectedVersion: users[0].version, reloadUserConfig: true, edits });
|
|
201
318
|
wrote = true;
|
|
@@ -203,7 +320,7 @@ export async function reconcileVerifiedCodexHookTrust({ releaseIdentity, install
|
|
|
203
320
|
const afterUsers = (afterRead.layers || []).filter((r) => r.name?.type === 'user' && r.name.file === configPath);
|
|
204
321
|
if (afterUsers.length !== 1 || otherSettings(users[0].config, pending.map((r) => r.key))
|
|
205
322
|
!== otherSettings(afterUsers[0].config, pending.map((r) => r.key))) throw new Error('Unrelated config changed; no whole-file rollback attempted');
|
|
206
|
-
const after = rowsFor(await rpc('hooks/list', { cwds: [cwd] })); assertRegistry(after, identities);
|
|
323
|
+
const after = rowsFor(await rpc('hooks/list', { cwds: [cwd] })); assertRegistry(after, identities, contentHash);
|
|
207
324
|
if (JSON.stringify(after.filter((r) => r.enabled === false).map((r) => r.key).sort()) !== JSON.stringify(disabled)
|
|
208
325
|
|| after.some((r) => r.enabled === true && !['trusted', 'managed'].includes(r.trustStatus))) throw new Error('Native trust repair verification incomplete');
|
|
209
326
|
return { state: 'registry-verified', changed: true, editedTrustHashes: edits.length,
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
// that one hook `modified`, the other ten still `trusted`. currentHash from `hooks/list` equals
|
|
24
24
|
// codexHookHash() below for every hook (tests/unit/codex-hook-trust.test.mjs pins the real values).
|
|
25
25
|
import crypto from 'node:crypto';
|
|
26
|
+
import nodeFs from 'node:fs';
|
|
26
27
|
|
|
27
28
|
const EVENT_LABEL = Object.freeze({
|
|
28
29
|
PreToolUse: 'pre_tool_use', PermissionRequest: 'permission_request', PostToolUse: 'post_tool_use',
|
|
@@ -92,3 +93,47 @@ export function codexTrustChanges(prevHooksFile, nextHooksFile, keySource) {
|
|
|
92
93
|
export const CODEX_TRUST_ACTION = 'Start Codex (interactive, in any project). At the "Hooks need review" prompt choose'
|
|
93
94
|
+ ' "Trust all and continue" — or type /hooks and trust each RuvNet Brain hook. Until then Codex skips them'
|
|
94
95
|
+ ' (codex exec never asks). Then re-run npx ruvnet-brain --doctor.';
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Codex's plugin bundle fingerprint, ported byte for byte from openai/codex rust-v0.164.0
|
|
99
|
+
* codex-rs/hooks/src/engine/plugin_fingerprint.rs plugin_content_hash(). Since 0.164 a plugin hook's
|
|
100
|
+
* `currentHash` is "<declaration hash>/<this hash>" and its approval stores the two separately
|
|
101
|
+
* (`trusted_hash` + `trusted_content_hash`, plugin_trust.rs matches_hash). Measured 2026-10-24: every Brain update
|
|
102
|
+
* left all 13 Codex hooks "modified" because the Brain wrote only the declaration hash.
|
|
103
|
+
* Walk: depth-first, entries sorted by file name (bytes), skipping __pycache__, .venv, *.pyc, *.egg-info; each entry
|
|
104
|
+
* hashes its root-relative path (u64 LE length + bytes), then "directory", or "file" + u64 LE size + contents.
|
|
105
|
+
* Anything that is neither (a symlink) throws, as it does in Codex.
|
|
106
|
+
*/
|
|
107
|
+
export function codexPluginContentHash(root, { fsImpl = null, onFile = null, platform = process.platform } = {}) {
|
|
108
|
+
const fs = fsImpl || nodeFs;
|
|
109
|
+
const u64 = (n) => { const b = Buffer.alloc(8); b.writeBigUInt64LE(BigInt(n)); return b; };
|
|
110
|
+
if (!fs.lstatSync(root).isDirectory()) throw new Error('plugin root is not a directory');
|
|
111
|
+
const hash = crypto.createHash('sha256').update('codex-plugin-bundle-v1');
|
|
112
|
+
// Rust hashes the OS-native relative path bytes: '\\' separators on Windows (review 2026-10-24).
|
|
113
|
+
const sep = platform === 'win32' ? '\\' : '/';
|
|
114
|
+
const pending = [''];
|
|
115
|
+
while (pending.length) {
|
|
116
|
+
const rel = pending.pop();
|
|
117
|
+
const base = rel.split('/').pop();
|
|
118
|
+
if (rel && (base === '__pycache__' || base === '.venv' || base.endsWith('.pyc') || base.endsWith('.egg-info'))) continue;
|
|
119
|
+
const name = Buffer.from(rel.split('/').join(sep));
|
|
120
|
+
hash.update(u64(name.length)).update(name);
|
|
121
|
+
const full = rel ? `${root}/${rel}` : root;
|
|
122
|
+
const st = fs.lstatSync(full);
|
|
123
|
+
if (st.isDirectory()) {
|
|
124
|
+
hash.update('directory');
|
|
125
|
+
const entries = fs.readdirSync(full).sort((a, b) => Buffer.compare(Buffer.from(a), Buffer.from(b)));
|
|
126
|
+
for (const entry of entries.reverse()) pending.push(rel ? `${rel}/${entry}` : entry);
|
|
127
|
+
} else if (st.isFile()) {
|
|
128
|
+
const bytes = fs.readFileSync(full);
|
|
129
|
+
if (bytes.length !== st.size) throw new Error('plugin file changed while fingerprinting');
|
|
130
|
+
// The caller sees exactly the bytes that enter the fingerprint: one read, so nothing can change between a check
|
|
131
|
+
// and the hash it approves (review 2026-10-24: a separate verification pass was a swap window).
|
|
132
|
+
onFile?.(rel, bytes);
|
|
133
|
+
hash.update('file').update(u64(st.size)).update(bytes);
|
|
134
|
+
} else {
|
|
135
|
+
throw new Error(`unsupported plugin entry type: ${full}`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return `sha256:${hash.digest('hex')}`;
|
|
139
|
+
}
|