ruvnet-brain 4.5.10 → 4.5.11

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
@@ -7,7 +7,7 @@ Created: 2026-06-29 22:36:38 EDT
7
7
 
8
8
  # 🧠 RuvNet Brain
9
9
 
10
- ### 🧠 RuvNet Brain — [![RuvNet Brain version 4.5.10 — updated 2026-07-30 03:24 EDT](https://img.shields.io/badge/version_4.5.10-updated_2026--07--30_03:24_EDT-1E90FF?style=for-the-badge&labelColor=0757BA)](https://github.com/stuinfla/ruvnet-brain/blob/main/plugin/.claude-plugin/plugin.json)
10
+ ### 🧠 RuvNet Brain — [![RuvNet Brain version 4.5.11 — updated 2026-07-30 03:24 EDT](https://img.shields.io/badge/version_4.5.11-updated_2026--07--30_03:24_EDT-1E90FF?style=for-the-badge&labelColor=0757BA)](https://github.com/stuinfla/ruvnet-brain/blob/main/plugin/.claude-plugin/plugin.json)
11
11
 
12
12
  **A portable, source-grounded brain over Reuven Cohen's (rUv's) RuvNet stack — delivered as a Claude Code plugin that makes Claude _use_ the stack instead of fighting it.**
13
13
 
package/bin/install.mjs CHANGED
@@ -4931,7 +4931,7 @@ export function syncManagedRouterTools({ routerDir = path.join(os.homedir(), '.c
4931
4931
  const destination = path.join(routerDir, 'bin');
4932
4932
  fs.mkdirSync(destination, { recursive: true });
4933
4933
  let copied = 0;
4934
- for (const name of ['model-router-engine.mjs', 'model-router-setup.mjs', 'model-router-status.mjs', 'model-router-outcome.mjs', 'subscription-hosts.mjs', 'review-model-defaults.mjs', 'dual-host-deliberation.mjs', 'dual-host-suggest.mjs', 'route-cheap.mjs', 'dispatch-receipt.mjs', 'metaharness-receipts.mjs', 'metaharness-router.mjs', 'model-router-dispatch.mjs', 'model-currency.mjs', 'model-currency-evidence.mjs', 'user-model-prompt-hook.mjs', 'model-router-agent-hook.mjs', 'model-weekly-assessment.mjs', 'model-weekly-analyst.mjs', 'model-weekly-cycle.mjs', 'model-weekly-qualification.mjs', 'model-native-qualification.mjs', 'model-routing-launchers.mjs', 'model-terminal-gateway.mjs', 'model-terminal-launchers.mjs', 'claude-terminal-mod.mjs', 'model-native-catalog.mjs', 'model-analyst-sandbox.mjs', 'model-routing-policy-promotion.mjs', 'model-routing-gateway.mjs', 'native-subscription-usage.mjs', 'codex-routed.sh', 'goldie-weekly.sh']) {
4934
+ for (const name of ['model-router-engine.mjs', 'model-router-setup.mjs', 'model-router-status.mjs', 'model-router-outcome.mjs', 'subscription-hosts.mjs', 'review-model-defaults.mjs', 'dual-host-deliberation.mjs', 'dual-host-suggest.mjs', 'route-cheap.mjs', 'dispatch-receipt.mjs', 'metaharness-receipts.mjs', 'metaharness-router.mjs', 'model-router-dispatch.mjs', 'model-currency.mjs', 'model-currency-evidence.mjs', 'user-model-prompt-hook.mjs', 'model-router-agent-hook.mjs', 'model-weekly-assessment.mjs', 'model-weekly-analyst.mjs', 'model-weekly-cycle.mjs', 'model-weekly-qualification.mjs', 'model-native-qualification.mjs', 'model-routing-launchers.mjs', 'model-terminal-gateway.mjs', 'model-terminal-launchers.mjs', 'claude-terminal-mod.mjs', 'claude-controlled-terminal.mjs', 'model-native-catalog.mjs', 'model-analyst-sandbox.mjs', 'model-routing-policy-promotion.mjs', 'model-routing-gateway.mjs', 'native-subscription-usage.mjs', 'codex-routed.sh', 'goldie-weekly.sh']) {
4935
4935
  const source = path.join(packageRoot, 'scripts', name);
4936
4936
  if (!fs.existsSync(source)) throw new Error(`Packaged router tool missing: ${name}`);
4937
4937
  const target = path.join(destination, name);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ruvnet-brain",
3
- "version": "4.5.10",
3
+ "version": "4.5.11",
4
4
  "description": "One-command installer for RuvNet Brain \u2014 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": {
@@ -41,6 +41,7 @@
41
41
  "metaharness:receipts": "node scripts/metaharness-receipts.mjs",
42
42
  "route:cheap": "node scripts/route-cheap.mjs",
43
43
  "routing:eval": "node scripts/model-routing-eval.mjs",
44
+ "routing:hooks:plan": "node scripts/user-model-hook-install.mjs --plan",
44
45
  "hooks:security-guidance:compat": "node scripts/security-guidance-codex-compat.mjs",
45
46
  "route:receipt": "node scripts/dispatch-receipt.mjs",
46
47
  "trismart": "node scripts/trismart.mjs",
@@ -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.5.10",
4
+ "version": "4.5.11",
5
5
  "author": {
6
6
  "name": "Stuart Kerr"
7
7
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ruvnet-brain",
3
- "version": "4.5.10",
3
+ "version": "4.5.11",
4
4
  "description": "Source-grounded RuvNet knowledge, lifecycle enforcement, and learning for Codex.",
5
5
  "author": {
6
6
  "name": "Stuart Kerr"
@@ -0,0 +1,265 @@
1
+ #!/usr/bin/env node
2
+ // A controlled prompt boundary using native print/SDK controls, not the native terminal UI.
3
+ import path from 'node:path';
4
+ import fs from 'node:fs';
5
+ import os from 'node:os';
6
+ import crypto from 'node:crypto';
7
+ import { spawn, execFileSync } from 'node:child_process';
8
+ import { createInterface } from 'node:readline/promises';
9
+ import { pathToFileURL } from 'node:url';
10
+ import { StringDecoder } from 'node:string_decoder';
11
+ import { decideNativeTurn, appendGatewayReceipt } from './model-routing-gateway.mjs';
12
+ import { subscriptionEnvironment, assertSubscriptionAuth, validateDispatchDecision } from './model-router-dispatch.mjs';
13
+ import { validateClaudeTerminalSettings } from './model-terminal-launchers.mjs';
14
+
15
+ const REFUSED = 'Controlled Claude turn refused; no fallback.';
16
+ const uuid = value => /^[a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i.test(value || '');
17
+ const cleanText = value => String(value).replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, '');
18
+ const allocationEnv = /^(CLAUDE_CODE_EXTRA_BODY|CLAUDE_CODE_EFFORT_LEVEL|ANTHROPIC_DEFAULT_.*_MODEL|ANTHROPIC_MODEL|ANTHROPIC_SMALL_FAST_MODEL|CLAUDE_CODE_SUBAGENT_MODEL|CLAUDE_CODE_PLUGIN_(DIRS|CACHE_DIR|SEED_DIR)|CLAUDE_CODE_USE_COWORK_PLUGINS)$/;
19
+
20
+ /** Classic Brain hooks/MCP/skills remain loaded; native request-rewriting modules are refused. */
21
+ export function assertClaudeModuleBoundary(settings, { env = process.env, sessionId, read = fs.readFileSync } = {}) {
22
+ if (!settings || typeof settings !== 'object' || Object.keys(settings.env || {}).some(key => allocationEnv.test(key))) throw new Error(REFUSED);
23
+ const modules = value => value && typeof value === 'object' && Object.entries(value).some(([key, entry]) =>
24
+ key === 'modules' && (Array.isArray(entry) ? entry.length > 0 : Boolean(entry)) || modules(entry));
25
+ if (modules(settings.hooks)) throw new Error(REFUSED);
26
+ const config = env.CLAUDE_CONFIG_DIR || path.join(env.HOME || os.homedir(), '.claude');
27
+ const json = file => JSON.parse(read(file, 'utf8'));
28
+ const enabled = Object.entries(settings.enabledPlugins || {}).filter(([, value]) => value !== false).map(([key]) => key);
29
+ let registry;
30
+ if (enabled.length) registry = json(path.join(config, 'plugins', 'installed_plugins.json'));
31
+ for (const id of enabled) {
32
+ const installations = registry.plugins?.[id];
33
+ if (!Array.isArray(installations) || !installations.length) throw new Error(REFUSED);
34
+ const at = id.lastIndexOf('@'), name = id.slice(0, at), marketplace = id.slice(at + 1);
35
+ if (at < 1) throw new Error(REFUSED);
36
+ const known = json(path.join(config, 'plugins', 'known_marketplaces.json'));
37
+ const marketplaceRoot = known[marketplace]?.installLocation;
38
+ if (!path.isAbsolute(marketplaceRoot || '')) throw new Error(REFUSED);
39
+ const entry = json(path.join(marketplaceRoot, '.claude-plugin', 'marketplace.json')).plugins?.find(plugin => plugin.name === name);
40
+ if (!entry || modules(entry)) throw new Error(REFUSED);
41
+ // Refuse any registered module-bearing installation of an enabled ID; never guess scope precedence.
42
+ for (const installation of installations) {
43
+ const root = installation.installPath;
44
+ if (!path.isAbsolute(root || '')) throw new Error(REFUSED);
45
+ const manifestFiles = [path.join(root, '.claude-plugin', 'plugin.json'), path.join(root, 'plugin.json')];
46
+ // Native strict:false marketplace entries may supply the entire manifest (e.g. LSP plugins).
47
+ const manifests = manifestFiles.filter(file => fs.existsSync(file)).map(json);
48
+ if (!manifests.length && entry.strict === false) manifests.push(entry);
49
+ if (!manifests.length || manifests.some(modules)) throw new Error(REFUSED);
50
+ const hooks = [path.join(root, 'hooks', 'hooks.json')];
51
+ for (const relative of [...manifests.map(manifest => manifest.hooks), entry.hooks].flat().filter(value => typeof value === 'string')) {
52
+ const resolved = path.resolve(root, relative);
53
+ if (!resolved.startsWith(root + path.sep)) throw new Error(REFUSED);
54
+ hooks.push(resolved);
55
+ }
56
+ for (const file of hooks) if (fs.existsSync(file) && modules(json(file))) throw new Error(REFUSED);
57
+ }
58
+ }
59
+ const nonempty = folder => fs.existsSync(folder) && fs.readdirSync(folder).length > 0;
60
+ // Native 2.1.289 rft()/b(sessionId): <config-dir>/dev-mods/<session-id>.
61
+ if (nonempty(path.join(config, 'dev-mods', sessionId))) throw new Error(REFUSED);
62
+ const projects = path.join(config, 'projects');
63
+ if (fs.existsSync(projects)) for (const directory of fs.readdirSync(projects)) {
64
+ const transcript = path.join(projects, directory, `${sessionId}.jsonl`);
65
+ if (!fs.existsSync(transcript)) continue;
66
+ // A resumed transcript may restore a different dev-mods folder (native last-wins record).
67
+ for (const line of read(transcript, 'utf8').split('\n').filter(Boolean)) {
68
+ const record = JSON.parse(line);
69
+ if (record.type === 'dev-mods' && record.folder && nonempty(record.folder)) throw new Error(REFUSED);
70
+ }
71
+ }
72
+ }
73
+
74
+ /** Retire only the directly owned process; never infer descendant cleanup from its exit. */
75
+ export function retireControlledClaudeChild(child, { graceMs = 200, killMs = 200 } = {}) {
76
+ return new Promise(resolve => {
77
+ let finished = false, escalation, deadline;
78
+ const finish = () => {
79
+ if (finished) return;
80
+ finished = true; clearTimeout(escalation); clearTimeout(deadline);
81
+ child.removeListener('close', finish);
82
+ for (const stream of [child.stdin, child.stdout, child.stderr]) { stream?.removeAllListeners('data'); stream?.destroy(); }
83
+ child.unref?.(); resolve();
84
+ };
85
+ child.once('close', finish);
86
+ const kill = signal => { try { child.kill(signal); } catch { /* bounded cleanup still proceeds */ } };
87
+ escalation = setTimeout(() => kill('SIGKILL'), graceMs);
88
+ deadline = setTimeout(finish, graceMs + killMs);
89
+ kill('SIGTERM');
90
+ if (child.exitCode != null || child.signalCode != null) finish();
91
+ });
92
+ }
93
+
94
+ export function controlledClaudeArguments(decision, sessionId, resume = false) {
95
+ if (decision?.harness !== 'claude-code' || decision.provider !== 'anthropic' || decision.subscriptionCovered !== true ||
96
+ !/^claude-[a-z0-9][a-z0-9.-]*$/.test(decision.model || '') ||
97
+ !['low', 'medium', 'high', 'xhigh', 'max'].includes(decision.effort) || !uuid(sessionId)) throw new Error(REFUSED);
98
+ return ['--print', '--input-format', 'stream-json', '--output-format', 'stream-json', '--verbose',
99
+ '--model', decision.model, '--effort', decision.effort, '--permission-mode', 'manual', '--permission-prompts', 'host',
100
+ '--permission-prompt-tool', 'stdio',
101
+ '--disallowedTools', 'Agent,Task',
102
+ resume ? '--resume' : '--session-id', sessionId];
103
+ }
104
+
105
+ /** One native process per routed turn. Permission requests require an explicit host answer. */
106
+ export async function runControlledClaudeTurn({ binary, prompt, sessionId = crypto.randomUUID(), resume = false,
107
+ cwd = process.cwd(), env = process.env, decide = decideNativeTurn, verifyDecision = validateDispatchDecision,
108
+ checkSettings = validateClaudeTerminalSettings, checkAuth = assertSubscriptionAuth, spawnNative = spawn,
109
+ probe = execFileSync, approve = async () => false, output = () => {}, receipt = appendGatewayReceipt,
110
+ checkModules = assertClaudeModuleBoundary, signal, timeoutMs = 900000, handshakeMs = 10000 } = {}) {
111
+ if (!path.isAbsolute(binary || '') || typeof prompt !== 'string' || !prompt.trim() || prompt.length > 200000 ||
112
+ /^\s*\//.test(prompt) || !Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 900000 ||
113
+ !Number.isSafeInteger(handshakeMs) || handshakeMs <= 0 || handshakeMs > 30000 || signal?.aborted) throw new Error(REFUSED);
114
+ // Extra body / effort env overrides can bypass the native CLI's requested allocation.
115
+ if (Object.keys(env).some(key => allocationEnv.test(key))) throw new Error(REFUSED);
116
+ const clean = subscriptionEnvironment(env);
117
+ const managed = process.platform === 'darwin' ? '/Library/Application Support/ClaudeCode/managed-settings.json' : '/etc/claude-code/managed-settings.json';
118
+ if (fs.existsSync(managed)) throw new Error('Managed Claude settings require separate module-boundary qualification.');
119
+ checkSettings({ env: clean, cwd, home: env.HOME || os.homedir() });
120
+ checkAuth('claude-code', { env: clean, probe: (_name, args, options) => probe(binary, args, options) });
121
+ const decision = await decide(prompt, 'claude-code', { env: clean });
122
+ verifyDecision(decision);
123
+ const args = controlledClaudeArguments(decision, sessionId, resume);
124
+ if (signal?.aborted) throw new Error(REFUSED);
125
+ const child = spawnNative(binary, args, { cwd, env: clean, shell: false, stdio: ['pipe', 'pipe', 'pipe'] });
126
+ return new Promise((resolve, reject) => {
127
+ let done = false, phase = 'initialize', buffer = '', bytes = 0, result, observed = false, permissions = false;
128
+ const decoder = new StringDecoder('utf8');
129
+ const ids = { initialize: crypto.randomUUID(), before: crypto.randomUUID(), after: crypto.randomUUID() };
130
+ let handshake;
131
+ const timer = setTimeout(() => fail(), timeoutMs);
132
+ const abort = () => fail();
133
+ signal?.addEventListener('abort', abort, { once: true });
134
+ const clear = () => { clearTimeout(timer); clearTimeout(handshake); signal?.removeEventListener('abort', abort); };
135
+ const fail = () => {
136
+ if (done) return;
137
+ done = true; clear();
138
+ void retireControlledClaudeChild(child).then(() => reject(new Error(REFUSED)));
139
+ };
140
+ const send = message => { if (!done) child.stdin.write(JSON.stringify(message) + '\n'); };
141
+ const control = which => {
142
+ phase = which; clearTimeout(handshake); handshake = setTimeout(fail, handshakeMs);
143
+ send({ type: 'control_request', request_id: ids[which], request: { subtype: which === 'initialize' ? 'initialize' : 'get_settings' } });
144
+ };
145
+ const settingsMatch = value => value?.applied?.model === decision.model && value.applied.effort === decision.effort && !value.errors?.length &&
146
+ Array.isArray(value.sources) && !value.sources.some(source => source.source === 'policySettings' && Object.keys(source.settings || {}).length);
147
+ const onMessage = async message => {
148
+ if (done) return;
149
+ if (message.type === 'control_response') {
150
+ const response = message.response;
151
+ if (response?.request_id !== ids[phase] || response.subtype !== 'success') return fail();
152
+ if (phase === 'initialize') return control('before');
153
+ if (!settingsMatch(response.response)) return fail();
154
+ if (phase === 'before') {
155
+ clearTimeout(handshake); phase = 'turn';
156
+ checkModules(response.response.effective, { env: clean, sessionId });
157
+ verifyDecision(decision); // Allocation may have changed while native startup was running.
158
+ receipt({ ts: new Date().toISOString(), harness: 'claude-code', status: 'native-settings-observed',
159
+ model: decision.model, effort: decision.effort, taskClass: decision.taskClass, modelObserved: false,
160
+ evidence: 'get_settings applied model and effort before prompt; execution identity pending' }, { env: clean });
161
+ send({ type: 'user', session_id: sessionId, message: { role: 'user', content: prompt } });
162
+ } else if (phase === 'after') { checkModules(response.response.effective, { env: clean, sessionId }); phase = 'exit'; clearTimeout(handshake); child.stdin.end(); }
163
+ else fail();
164
+ return;
165
+ }
166
+ if (message.type === 'control_request') {
167
+ if (phase !== 'turn' || permissions || message.request?.subtype !== 'can_use_tool' || typeof message.request_id !== 'string') return fail();
168
+ if (['Agent', 'Task'].includes(message.request.tool_name)) return fail();
169
+ permissions = true;
170
+ let allowed = false;
171
+ try { allowed = await approve(message.request) === true; } catch { allowed = false; }
172
+ permissions = false;
173
+ send({ type: 'control_response', response: { subtype: 'success', request_id: message.request_id,
174
+ response: allowed ? { behavior: 'allow', updatedInput: message.request.input } :
175
+ { behavior: 'deny', message: 'The host did not approve this tool request.' } } });
176
+ return;
177
+ }
178
+ if (message.session_id && message.session_id !== sessionId) return fail();
179
+ if (message.type === 'assistant' && !message.parent_tool_use_id) {
180
+ if (phase !== 'turn' || message.message?.model !== decision.model) return fail();
181
+ observed = true;
182
+ for (const block of message.message.content || []) if (block.type === 'text') output(cleanText(block.text));
183
+ }
184
+ if (message.type === 'result') {
185
+ if (phase !== 'turn' || permissions || !observed || message.session_id !== sessionId || message.subtype !== 'success' ||
186
+ message.is_error || message.permission_denials?.length || message.errors?.length) return fail();
187
+ result = message; control('after');
188
+ }
189
+ };
190
+ child.stdout.on('data', chunk => {
191
+ if (done) return;
192
+ bytes += chunk.length; if (bytes > 16 * 1024 * 1024) return fail();
193
+ buffer += decoder.write(chunk);
194
+ let newline;
195
+ while ((newline = buffer.indexOf('\n')) !== -1 && !done) {
196
+ const line = buffer.slice(0, newline); buffer = buffer.slice(newline + 1);
197
+ try { void onMessage(JSON.parse(line)).catch(fail); } catch { fail(); }
198
+ }
199
+ });
200
+ child.stderr.on('data', () => {}); // Native diagnostics may contain credentials or prompt text.
201
+ child.stdin.on('error', fail); child.once('error', fail);
202
+ child.once('close', code => {
203
+ if (done) return;
204
+ if (code !== 0 || phase !== 'exit' || !result || buffer.trim() || decoder.end()) return fail();
205
+ try {
206
+ receipt({ ts: new Date().toISOString(), harness: 'claude-code', status: 'completed', model: decision.model,
207
+ effort: decision.effort, taskClass: decision.taskClass, modelObserved: true,
208
+ evidence: 'assistant model observed; get_settings applied effort matched before and after turn; per-request effort not exposed' }, { env: clean });
209
+ done = true; clear(); resolve({ sessionId, decision, modelObserved: true, effortSettingsObserved: true, perRequestEffortObserved: false });
210
+ } catch { fail(); }
211
+ });
212
+ control('initialize');
213
+ });
214
+ }
215
+
216
+ /** Native tool approvals are presented by this host; --print supplies no terminal dialogs. */
217
+ export async function launchControlledClaudeTerminal({ binary, args = [], input = process.stdin, output = process.stdout,
218
+ diagnostics = process.stderr, env = process.env, cwd = process.cwd(), runTurn = runControlledClaudeTurn } = {}) {
219
+ let sessionId, resume = false, initialPrompt, ownerBypass = false;
220
+ const remaining = [...args];
221
+ const invalid = () => new Error('Controlled Claude accepts only --resume <session UUID>, --permission-mode bypassPermissions, and a literal initial prompt.');
222
+ while (remaining[0]?.startsWith('-')) {
223
+ if (remaining[0] === '--resume' && !resume && uuid(remaining[1])) {
224
+ sessionId = remaining[1]; resume = true;
225
+ } else if (remaining[0] === '--permission-mode' && !ownerBypass && remaining[1] === 'bypassPermissions') {
226
+ ownerBypass = true;
227
+ } else throw invalid();
228
+ remaining.splice(0, 2);
229
+ }
230
+ if (remaining.some(arg => typeof arg !== 'string' || arg.startsWith('-'))) throw invalid();
231
+ if (remaining.length) initialPrompt = remaining.join(' ');
232
+ if (!input.isTTY || !output.isTTY) throw new Error('Controlled Claude requires a person at a terminal for prompts and approvals.');
233
+ const terminal = createInterface({ input, output });
234
+ const controller = new AbortController();
235
+ const cancel = () => controller.abort();
236
+ terminal.on('SIGINT', cancel);
237
+ diagnostics.write('Controlled Claude: one reviewed native allocation per prompt; tool approvals are answered here. Native Agent/Task workers are disabled; use the managed dispatcher for independent child work. /exit closes.\n');
238
+ if (ownerBypass) diagnostics.write('Owner permission bypass is active: native tool requests are approved by this host automatically. Agent/Task remain refused; routing and subscription guards remain active.\n');
239
+ try {
240
+ while (!controller.signal.aborted) {
241
+ const prompt = initialPrompt ?? await terminal.question('Claude> '); initialPrompt = undefined;
242
+ if (prompt.trim() === '/exit') break;
243
+ if (!prompt.trim()) continue;
244
+ const turn = await runTurn({ binary, prompt, sessionId, resume, cwd, env, signal: controller.signal,
245
+ output: text => output.write(text + '\n'), approve: async request => {
246
+ if (['Agent', 'Task'].includes(request.tool_name)) return false;
247
+ const details = cleanText(JSON.stringify({ tool: request.tool_name, input: request.input, reason: request.decision_reason }));
248
+ output.write(`Native permission request (untrusted tool text):\n${details}\n`);
249
+ if (ownerBypass) return true;
250
+ return (await terminal.question('Approve this tool request? Type yes: ')).trim() === 'yes';
251
+ } });
252
+ sessionId = turn.sessionId; resume = true;
253
+ diagnostics.write(`Native session: ${sessionId} (resume with --resume ${sessionId})\n`);
254
+ }
255
+ } finally { terminal.close(); }
256
+ }
257
+
258
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
259
+ const args = process.argv.slice(2);
260
+ if (args[0] !== '--real-binary' || !args[1] || args[2] !== '--') {
261
+ process.stderr.write('Expected --real-binary /absolute/native/claude -- [--resume UUID]\n'); process.exitCode = 1;
262
+ } else launchControlledClaudeTerminal({ binary: args[1], args: args.slice(3) }).catch(() => {
263
+ process.stderr.write(REFUSED + '\n'); process.exitCode = 1;
264
+ });
265
+ }
@@ -111,7 +111,7 @@ export function installTerminalLaunchers({ home = os.homedir(), nodeBinary = pro
111
111
  if ([runner, ...Object.keys(contents)].includes(binary)) throw new Error('Native terminal launcher recursion refused');
112
112
  }
113
113
  const receipt = { apply, launchers, configPath, config, shellSource, shellConflicts: shell.conflicts, backups: [],
114
- claudeEnforcementScope: realClaude ? 'Startup hook activation guard; continuing native worker enforcement requires separate proof' : null };
114
+ claudeEnforcementScope: realClaude ? 'Controlled native prompt boundary; model observations and effective effort settings checked for each completed turn' : null };
115
115
  if (!apply) return receipt;
116
116
  for (const file of [configPath, ...Object.keys(contents), ...(manageZsh ? [shellSource, zshrc] : [])]) {
117
117
  const original = backup(file); if (original) receipt.backups.push(original);
@@ -226,18 +226,22 @@ export function validateClaudeTerminalArguments(args) {
226
226
  return { options, prompts };
227
227
  }
228
228
 
229
+ function claudeAdministrativeArguments(args) {
230
+ const administrativeArgs = args[0] === '--permission-mode' && args[1] === 'bypassPermissions' ? args.slice(2) : args;
231
+ const information = new Set(['--help', '-h', '--version', '-v']);
232
+ const ownerPermissionFlags = new Set(['--dangerously-skip-permissions', '--allow-dangerously-skip-permissions']);
233
+ return administrativeArgs.some(arg => information.has(arg)) &&
234
+ administrativeArgs.every(arg => information.has(arg) || ownerPermissionFlags.has(arg)) ||
235
+ ['auth', 'mcp', 'plugin', 'plugins', 'update', 'upgrade', 'doctor', 'install'].includes(administrativeArgs[0]);
236
+ }
237
+
229
238
  /** Native plugin startup only: an unloaded/crashed native worker may skip subsequent hooks. */
230
239
  export async function runClaudeTerminal({ config, args = [], env = process.env, startupMs = 10000,
231
240
  tempRoot = os.tmpdir(), cwd = process.cwd(), signalSource = process, diagnostics = process.stderr } = {}) {
232
241
  if (env.RNB_TERMINAL_LAUNCH_ACTIVE) throw new Error('Native terminal launcher recursion refused');
233
242
  if (!Number.isFinite(startupMs) || startupMs <= 0 || startupMs > 60000) throw new Error('Bounded native hook startup deadline required');
234
243
  const binary = executable(config.realClaude), clean = subscriptionEnvironment(env);
235
- const information = new Set(['--help', '-h', '--version', '-v']);
236
- const ownerPermissionFlags = new Set(['--dangerously-skip-permissions', '--allow-dangerously-skip-permissions']);
237
- const administrative = args.some((arg) => information.has(arg)) &&
238
- args.every((arg) => information.has(arg) || ownerPermissionFlags.has(arg)) ||
239
- ['auth', 'mcp', 'plugin', 'plugins', 'update', 'upgrade', 'doctor', 'install'].includes(args[0]);
240
- if (administrative) {
244
+ if (claudeAdministrativeArguments(args)) {
241
245
  const child = spawn(binary, args, { env: clean, cwd, stdio: 'inherit', shell: false });
242
246
  const unforward = forwardSignals(child, signalSource);
243
247
  try { return await exitOf(child); } finally { unforward(); }
@@ -280,8 +284,14 @@ export async function runClaudeTerminal({ config, args = [], env = process.env,
280
284
  }
281
285
  }
282
286
 
283
- export async function runTerminalLauncher({ host, args, config, env = process.env, signalSource = process } = {}) {
284
- if (host === 'claude') return runClaudeTerminal({ config, args, env, signalSource });
287
+ export async function runTerminalLauncher({ host, args = [], config, env = process.env, signalSource = process } = {}) {
288
+ if (host === 'claude') {
289
+ if (claudeAdministrativeArguments(args)) return runClaudeTerminal({ config, args, env, signalSource });
290
+ if (env.RNB_TERMINAL_LAUNCH_ACTIVE) throw new Error('Native terminal launcher recursion refused');
291
+ const { launchControlledClaudeTerminal } = await import('./claude-controlled-terminal.mjs');
292
+ await launchControlledClaudeTerminal({ binary: executable(config.realClaude), args, env, cwd: process.cwd() });
293
+ return { code: 0, signal: null };
294
+ }
285
295
  if (host !== 'codex') throw new Error('Unsupported terminal host');
286
296
  const invocation = terminalInvocation({ host, args, config, env });
287
297
  const child = spawn(invocation.command, invocation.args, { env: { ...subscriptionEnvironment(env), RNB_TERMINAL_LAUNCH_ACTIVE: '1' }, stdio: 'inherit', shell: false });
@@ -111,7 +111,8 @@ export const RELEASE_REQUIREMENTS = Object.freeze({
111
111
  "tests/unit/forge-update-apply-rollback.test.mjs",
112
112
  "tests/unit/forge-update-archive-digest.test.mjs",
113
113
  "tests/unit/kb-copy-proof-legacy-sidecars.test.mjs",
114
- "tests/unit/kb-copy-proof-unknown-content.test.mjs"
114
+ "tests/unit/kb-copy-proof-unknown-content.test.mjs",
115
+ "tests/unit/user-model-hook-install.test.mjs"
115
116
  ]
116
117
  },
117
118
  {
@@ -134,8 +135,8 @@ export const RELEASE_REQUIREMENTS = Object.freeze({
134
135
  "tests/unit/model-routing-launchers.test.mjs"
135
136
  ],
136
137
  "platformFiles": {
137
- "linux": ["tests/unit/model-terminal-gateway.test.mjs", "tests/unit/model-terminal-launchers.test.mjs"],
138
- "macos": ["tests/unit/model-terminal-gateway.test.mjs", "tests/unit/model-terminal-launchers.test.mjs"],
138
+ "linux": ["tests/unit/model-terminal-gateway.test.mjs", "tests/unit/model-terminal-launchers.test.mjs", "tests/unit/claude-controlled-terminal.test.mjs"],
139
+ "macos": ["tests/unit/model-terminal-gateway.test.mjs", "tests/unit/model-terminal-launchers.test.mjs", "tests/unit/claude-controlled-terminal.test.mjs"],
139
140
  "windows": ["tests/unit/windows-terminal-boundary.test.mjs"]
140
141
  }
141
142
  },
@@ -0,0 +1,145 @@
1
+ #!/usr/bin/env node
2
+ // DISTINCT-FROM: scripts/model-router-setup.mjs — native per-user registration only; never deploys policy or runtime.
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import os from 'node:os';
6
+ import crypto from 'node:crypto';
7
+ import { pathToFileURL } from 'node:url';
8
+
9
+ export function shellQuote(value) { return `'${value.replaceAll("'", "'\\''")}'`; }
10
+
11
+ // Parse only standalone literal argv. Shell expansion, operators and redirection are never ownership.
12
+ function literalArgv(command) {
13
+ if (typeof command !== 'string') return null;
14
+ const args = []; let word = ''; let quote = ''; let active = false;
15
+ for (let i = 0; i < command.length; i++) {
16
+ const c = command[i];
17
+ if (quote === "'") { if (c === "'") quote = ''; else word += c; continue; }
18
+ if (quote === '"') {
19
+ if (c === '"') { quote = ''; continue; }
20
+ if ('$`\\'.includes(c)) return null;
21
+ word += c; continue;
22
+ }
23
+ if (c === "'" || c === '"') { quote = c; active = true; continue; }
24
+ if (c === '\\') { if (++i === command.length) return null; word += command[i]; active = true; continue; }
25
+ if (/\s/.test(c)) { if (active) { args.push(word); word = ''; active = false; } continue; }
26
+ if (';&|<>$`(){}*?!~'.includes(c)) return null;
27
+ word += c; active = true;
28
+ }
29
+ if (quote) return null;
30
+ if (active) args.push(word);
31
+ return args;
32
+ }
33
+
34
+ function realNode(command, nodeExecutable, searchPath) {
35
+ const candidates = path.isAbsolute(command) ? [command] : command === 'node'
36
+ ? searchPath.split(path.delimiter).filter(Boolean).map(dir => path.join(dir, command)) : [];
37
+ const trusted = fs.realpathSync(nodeExecutable);
38
+ for (const candidate of candidates) {
39
+ try { fs.accessSync(candidate, fs.constants.X_OK); return fs.realpathSync(candidate) === trusted; } catch { /* next PATH entry */ }
40
+ }
41
+ return false;
42
+ }
43
+
44
+ function assertSafePath(file, home) {
45
+ const relative = path.relative(home, file);
46
+ if (relative.startsWith('..') || path.isAbsolute(relative)) throw new Error('Path escapes supplied home');
47
+ let cursor = home;
48
+ for (const part of ['', ...relative.split(path.sep)]) {
49
+ if (part) cursor = path.join(cursor, part);
50
+ try {
51
+ const stat = fs.lstatSync(cursor);
52
+ if (stat.isSymbolicLink()) throw new Error(`Symlink refused: ${cursor}`);
53
+ if (cursor !== file && !stat.isDirectory()) throw new Error(`Non-directory ancestor: ${cursor}`);
54
+ if (cursor === file && !stat.isFile()) throw new Error(`Non-file target: ${cursor}`);
55
+ } catch (error) { if (error.code !== 'ENOENT') throw error; }
56
+ }
57
+ }
58
+
59
+ function configPlan(file, host, options) {
60
+ assertSafePath(file, options.home);
61
+ let before = null; let mode = 0o600;
62
+ try { before = fs.readFileSync(file, 'utf8'); mode = fs.statSync(file).mode & 0o777; } catch (error) { if (error.code !== 'ENOENT') throw error; }
63
+ let config;
64
+ try { config = before === null ? {} : JSON.parse(before); } catch { throw new Error(`Malformed JSON: ${file}`); }
65
+ const object = value => value && typeof value === 'object' && !Array.isArray(value);
66
+ if (!object(config) || (config.hooks !== undefined && !object(config.hooks))) throw new Error(`Malformed hooks object: ${file}`);
67
+ config.hooks ??= {};
68
+ const entries = config.hooks.UserPromptSubmit === undefined ? [] : config.hooks.UserPromptSubmit;
69
+ if (!Array.isArray(entries)) throw new Error(`Malformed UserPromptSubmit: ${file}`);
70
+ const owned = [];
71
+ for (const [index, entry] of entries.entries()) {
72
+ if (!object(entry) || !Array.isArray(entry.hooks)) throw new Error(`Malformed hook group: ${file}`);
73
+ for (const hook of entry.hooks) {
74
+ if (!object(hook) || typeof hook.type !== 'string') throw new Error(`Malformed hook: ${file}`);
75
+ if (hook.type !== 'command') continue;
76
+ if (typeof hook.command !== 'string') throw new Error(`Malformed command: ${file}`);
77
+ const args = literalArgv(hook.command);
78
+ const expected = host === 'claude' ? ['--claude'] : [];
79
+ if (args?.[1] === options.script && realNode(args[0], options.nodeExecutable, options.searchPath)
80
+ && JSON.stringify(args.slice(2)) === JSON.stringify(expected)) owned.push({ index, hook });
81
+ else if (args?.includes(options.script) || hook.command.includes(options.script)) {
82
+ throw new Error(`Unowned model-hook override requires review: ${file}`);
83
+ }
84
+ }
85
+ }
86
+ if (owned.length > 1) throw new Error(`Ambiguous duplicate owned registrations: ${file}`);
87
+ const nodeCommand = realNode('node', options.nodeExecutable, options.searchPath) ? 'node' : shellQuote(options.nodeExecutable);
88
+ const command = `${nodeCommand} ${shellQuote(options.script)}${host === 'claude' ? ' --claude' : ''}`;
89
+ if (!owned.length) entries.push({ hooks: [{ type: 'command', command, timeout: 3 }] });
90
+ // Existing exact registrations are preserved byte-for-byte, including private group options.
91
+ config.hooks.UserPromptSubmit = entries;
92
+ const after = owned.length ? before : `${JSON.stringify(config, null, 2)}\n`;
93
+ return { host, file, before, after, mode, changed: before !== after, registration: owned.length ? 'present' : 'add' };
94
+ }
95
+
96
+ export function planUserModelHooks({ home = os.homedir(), nodeExecutable = process.execPath, searchPath = process.env.PATH || '' } = {}) {
97
+ home = path.resolve(home);
98
+ const script = path.join(home, '.claude/model-router/bin/user-model-prompt-hook.mjs');
99
+ const options = { home, script, nodeExecutable: path.resolve(nodeExecutable), searchPath };
100
+ return { home, script, changes: [
101
+ configPlan(path.join(home, '.claude/settings.json'), 'claude', options),
102
+ configPlan(path.join(home, '.codex/hooks.json'), 'codex', options),
103
+ ] };
104
+ }
105
+
106
+ export function applyUserModelHooks(plan) {
107
+ assertSafePath(plan.script, plan.home);
108
+ if (!fs.existsSync(plan.script)) throw new Error('Install the user hook runtime before applying registrations');
109
+ // Validate every original before any writes, so stale plans never overwrite a concurrent edit.
110
+ for (const change of plan.changes) {
111
+ assertSafePath(change.file, plan.home);
112
+ const current = fs.existsSync(change.file) ? fs.readFileSync(change.file, 'utf8') : null;
113
+ if (current !== change.before) throw new Error(`Config changed since plan: ${change.file}`);
114
+ }
115
+ const written = [];
116
+ for (const change of plan.changes.filter(item => item.changed)) {
117
+ fs.mkdirSync(path.dirname(change.file), { recursive: true, mode: 0o700 });
118
+ assertSafePath(change.file, plan.home);
119
+ const suffix = `${Date.now()}-${crypto.randomBytes(6).toString('hex')}`;
120
+ const backup = change.before === null ? null : `${change.file}.backup-${suffix}`;
121
+ if (backup) fs.copyFileSync(change.file, backup, fs.constants.COPYFILE_EXCL);
122
+ const temporary = `${change.file}.tmp-${suffix}`;
123
+ let fd;
124
+ try {
125
+ fd = fs.openSync(temporary, 'wx', change.mode);
126
+ fs.writeFileSync(fd, change.after); fs.fchmodSync(fd, change.mode); fs.fsyncSync(fd); fs.closeSync(fd); fd = undefined;
127
+ assertSafePath(change.file, plan.home);
128
+ const latest = fs.existsSync(change.file) ? fs.readFileSync(change.file, 'utf8') : null;
129
+ if (latest !== change.before) throw new Error(`Config changed before rename: ${change.file}`);
130
+ fs.renameSync(temporary, change.file);
131
+ written.push({ file: change.file, backup });
132
+ } finally { if (fd !== undefined) fs.closeSync(fd); if (fs.existsSync(temporary)) fs.unlinkSync(temporary); }
133
+ }
134
+ return { applied: true, written };
135
+ }
136
+
137
+ if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
138
+ try {
139
+ const args = process.argv.slice(2);
140
+ if (args.some(arg => !['--apply', '--dry-run', '--plan'].includes(arg))) throw new Error('Usage: user-model-hook-install.mjs [--apply|--dry-run|--plan]');
141
+ const plan = planUserModelHooks();
142
+ const result = args.includes('--apply') ? applyUserModelHooks(plan) : { applied: false, script: plan.script, changes: plan.changes.map(({ host, file, changed, registration }) => ({ host, file, changed, registration })) };
143
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
144
+ } catch (error) { process.stderr.write(`${error.message}\n`); process.exitCode = 1; }
145
+ }