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 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
- [![RuvNet Brain version 4.6.2 — README updated 2026-10-10 EDT](https://img.shields.io/badge/version_4.7.18-updated_2026--10--10_EDT-1E90FF?style=for-the-badge&labelColor=0757BA)](plugin/.claude-plugin/plugin.json)
12
+ [![RuvNet Brain version 4.6.2 — README updated 2026-10-10 EDT](https://img.shields.io/badge/version_4.7.19-updated_2026--10--10_EDT-1E90FF?style=for-the-badge&labelColor=0757BA)](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.18",
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.18",
4
+ "version": "4.7.19",
5
5
  "author": {
6
6
  "name": "Stuart Kerr"
7
7
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ruvnet-brain",
3
- "version": "4.7.18",
3
+ "version": "4.7.19",
4
4
  "description": "Source-grounded RuvNet knowledge, lifecycle enforcement, and learning for Codex.",
5
5
  "author": {
6
6
  "name": "Stuart Kerr"
@@ -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-10 06:00:22 EDT
4
- version: 1.0.2
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
- const refreshed = installNightlyRunner(plan);
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 bound = validDigest(receipt.sourceSha256) && receipt.kind === 'nightly-suite-update' && receipt.schemaVersion === 1
333
- && receipt.sourceSha256 === registration.updateModules?.['developer-update.mjs']?.sha256
334
- && Object.entries(receipt.sourceSnapshot || {}).every(([name, digest]) => validDigest(digest) && digest === registration.updateModules?.[name]?.sha256)
335
- && EXECUTION_MODULES.filter(name => name !== 'plugin-artifact-proof.mjs' || registration.updateModules?.[name]).every(name => validDigest(receipt.sourceSnapshot?.[name]) && receipt.sourceSnapshot[name] === registration.updateModules?.[name]?.sha256);
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.` : 'Coordinated update timestamp or terminal evidence is invalid.' };
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 { codexHookIdentities } from './codex-hook-trust.mjs';
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
- return { state: 'verified', releaseIdentity: { version, integrity, hooksSha256: digest(result.stdout) },
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
- function assertRegistry(rows, identities) {
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] })); assertRegistry(before, identities);
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.map((r) => ({ keyPath: `hooks.state.${JSON.stringify(r.key)}.trusted_hash`,
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
+ }