ruvnet-brain 4.5.4 → 4.5.6
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 +2 -2
- package/bin/install.mjs +162 -27
- package/config/model-router/catalog.template.json +126 -52
- package/config/model-router/claude-terminal-mod/.claude-plugin/plugin.json +1 -0
- package/config/model-router/claude-terminal-mod/README.md +22 -0
- package/config/model-router/claude-terminal-mod/hooks/hooks.json +1 -0
- package/config/model-router/claude-terminal-mod/hooks/policy.default.mjs +2 -0
- package/config/model-router/claude-terminal-mod/hooks/register.js +66 -0
- package/config/model-router/claude-terminal-mod/hooks/routing.js +55 -0
- package/config/model-router/claude-terminal-mod/hooks/runtime.js +2 -0
- package/config/model-router/claude-terminal-mod/tests/native.test.ts +86 -0
- package/config/model-router/policy.default.mjs +97 -75
- package/config/model-router/qualification-contract.json +124 -0
- package/config/model-router/routing-eval-cases.json +275 -0
- package/config/model-router/routing-policy.template.json +76 -0
- package/config/model-router/weekly-analyst-instruction.md +60 -0
- package/data/model-catalog.json +44 -49
- package/package.json +6 -3
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/mcp/managed-cli-interface.mjs +9 -4
- package/plugin/scripts/capability-claim-evidence.mjs +9 -2
- package/plugin/scripts/codex-hook-adapter.mjs +18 -9
- package/plugin/scripts/project-capture-queue.mjs +7 -1
- package/plugin/scripts/project-progression-hook.mjs +16 -7
- package/plugin/scripts/project-progression-outbox.mjs +96 -15
- package/plugin/scripts/project-progression-producer.mjs +12 -1
- package/plugin/scripts/project-progression-store.mjs +53 -1
- package/plugin/scripts/project-transition-hook.mjs +19 -8
- package/plugin/scripts/session-snapshot-hook.mjs +2 -1
- package/scripts/claude-terminal-mod.mjs +89 -0
- package/scripts/codex-hook-trust-reconcile.mjs +247 -0
- package/scripts/codex-routed.sh +3 -36
- package/scripts/goldie-weekly.sh +8 -64
- package/scripts/metaharness-router.mjs +7 -1
- package/scripts/model-analyst-sandbox.mjs +54 -0
- package/scripts/model-currency-evidence.mjs +139 -0
- package/scripts/model-currency.mjs +230 -0
- package/scripts/model-native-catalog.mjs +111 -0
- package/scripts/model-native-qualification.mjs +251 -0
- package/scripts/model-router-agent-hook.mjs +136 -0
- package/scripts/model-router-dispatch.mjs +161 -0
- package/scripts/model-router-engine.mjs +155 -104
- package/scripts/model-routing-eval.mjs +108 -0
- package/scripts/model-routing-gateway.mjs +453 -0
- package/scripts/model-routing-launchers.mjs +209 -0
- package/scripts/model-routing-policy-promotion.mjs +203 -0
- package/scripts/model-terminal-gateway.mjs +310 -0
- package/scripts/model-terminal-launchers.mjs +300 -0
- package/scripts/model-weekly-analyst.mjs +299 -0
- package/scripts/model-weekly-assessment.mjs +91 -0
- package/scripts/model-weekly-cycle.mjs +183 -0
- package/scripts/model-weekly-qualification.mjs +362 -0
- package/scripts/native-subscription-usage.mjs +57 -0
- package/scripts/release-qualification-contract.mjs +50 -1
- package/scripts/release-qualification.mjs +9 -6
- package/scripts/security-guidance-codex-compat.mjs +142 -0
- package/scripts/user-model-prompt-hook.mjs +69 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Materialize the sandboxed native mod and bridge it to the existing reviewed policy engine.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import crypto from 'node:crypto';
|
|
6
|
+
import { spawnSync } from 'node:child_process';
|
|
7
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
8
|
+
import { classify } from '../config/model-router/policy.default.mjs';
|
|
9
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
10
|
+
export const MOD_SOURCE = path.join(ROOT, 'config/model-router/claude-terminal-mod');
|
|
11
|
+
export function terminalModDigest(pluginRoot) {
|
|
12
|
+
const hash = crypto.createHash('sha256');
|
|
13
|
+
for (const relative of ['.claude-plugin/plugin.json', 'hooks/hooks.json', 'hooks/register.js', 'hooks/routing.js', 'hooks/policy.default.mjs', 'hooks/runtime.js']) {
|
|
14
|
+
hash.update(relative); hash.update('\0'); hash.update(fs.readFileSync(path.join(pluginRoot, relative))); hash.update('\0');
|
|
15
|
+
}
|
|
16
|
+
return hash.digest('hex');
|
|
17
|
+
}
|
|
18
|
+
export function prepareClaudeTerminalMod({ destination, nodePath = process.execPath,
|
|
19
|
+
helperPath = fileURLToPath(import.meta.url), enginePath = path.join(ROOT, 'scripts/model-router-engine.mjs') } = {}) {
|
|
20
|
+
if (!destination || !path.isAbsolute(destination)) throw new Error('Absolute mod destination required');
|
|
21
|
+
for (const file of [nodePath, helperPath, enginePath]) if (!path.isAbsolute(file) || !fs.existsSync(file)) throw new Error('Native mod dependency unavailable');
|
|
22
|
+
fs.mkdirSync(destination, { recursive: true, mode: 0o700 });
|
|
23
|
+
for (const part of ['.claude-plugin/plugin.json', 'hooks/hooks.json', 'hooks/register.js', 'hooks/routing.js']) {
|
|
24
|
+
const dest = path.join(destination, part);
|
|
25
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
26
|
+
fs.copyFileSync(path.join(MOD_SOURCE, part), dest);
|
|
27
|
+
}
|
|
28
|
+
const policyPath = path.join(destination, 'hooks/policy.default.mjs');
|
|
29
|
+
fs.copyFileSync(path.join(ROOT, 'config/model-router/policy.default.mjs'), policyPath);
|
|
30
|
+
fs.writeFileSync(path.join(destination, 'hooks/runtime.js'), `export default ${JSON.stringify({ nodePath, helperPath, enginePath, policyPath })};\n`, { mode: 0o600 });
|
|
31
|
+
return { pluginDir: destination, policyPath, modDigest: terminalModDigest(destination) };
|
|
32
|
+
}
|
|
33
|
+
export function validateDecision(decision, now = Date.now()) {
|
|
34
|
+
const age = now - Date.parse(decision?.selectionReviewedAt);
|
|
35
|
+
const maxAge = decision?.selectionMaxAgeMs;
|
|
36
|
+
if (decision?.harness !== 'claude-code' || decision.provider !== 'anthropic' || decision.subscriptionCovered !== true ||
|
|
37
|
+
!/^claude-[a-z0-9][a-z0-9.-]*$/.test(decision.model || '') || !['fast', 'medium', 'hard'].includes(decision.taskClass) ||
|
|
38
|
+
!['low', 'medium', 'high', 'xhigh', 'max'].includes(decision.effort) ||
|
|
39
|
+
!Number.isSafeInteger(maxAge) || maxAge <= 0 || maxAge > 604800000 || !Number.isFinite(age) || age < 0 ||
|
|
40
|
+
!/^[a-f0-9]{64}$/.test(decision.selectionRouteDigest || '')) throw new Error('Native terminal routing decision invalid; no fallback');
|
|
41
|
+
return decision;
|
|
42
|
+
}
|
|
43
|
+
export function routeNativePrompt({ prompt, turnId, minimumClass, enginePath = path.join(ROOT, 'scripts/model-router-engine.mjs'),
|
|
44
|
+
policyPath = path.join(ROOT, 'config/model-router/policy.default.mjs'), env = process.env } = {}) {
|
|
45
|
+
if (typeof prompt !== 'string' || !prompt.trim() || prompt.length > 200000) throw new Error('Native routing requires a bounded text prompt');
|
|
46
|
+
if (minimumClass !== undefined && !['fast', 'medium', 'hard'].includes(minimumClass)) throw new Error('Invalid class floor');
|
|
47
|
+
const codeFences = Math.floor((prompt.match(/```/g) || []).length / 2);
|
|
48
|
+
const hasCode = codeFences > 0 || /\b(function|const|let|def|class|import|=>|SELECT|async)\b/.test(prompt) || /[{};]\s*$/m.test(prompt);
|
|
49
|
+
const rank = { fast: 0, medium: 1, hard: 2 };
|
|
50
|
+
const canonicalFloor = classify({ taskHints: prompt, hasCode }, 'claude-code');
|
|
51
|
+
const floor = rank[minimumClass] > rank[canonicalFloor] ? minimumClass : canonicalFloor;
|
|
52
|
+
const classificationPrompt = floor === 'hard' ? 'final substantive review\n' + prompt : prompt;
|
|
53
|
+
// Let the engine honor the user's policy.mjs; the bundled copy is only the
|
|
54
|
+
// deterministic classification floor used by the sandboxed hook.
|
|
55
|
+
const result = spawnSync(process.execPath, [enginePath, '--harness', 'claude-code', '--request-json', '--policy-only', '--json'],
|
|
56
|
+
{ input: JSON.stringify({ prompt: classificationPrompt }), encoding: 'utf8', env, timeout: 8000, maxBuffer: 262144 });
|
|
57
|
+
if (result.error || result.status !== 0) throw new Error('Reviewed native routing policy unavailable; no fallback');
|
|
58
|
+
const d = validateDecision(JSON.parse(result.stdout));
|
|
59
|
+
if (rank[d.taskClass] < rank[floor]) throw new Error('Reviewed native routing policy below canonical floor; no fallback');
|
|
60
|
+
// No prompt, reasoning, credentials, or arbitrary engine output crosses back to the sandbox.
|
|
61
|
+
return { schemaVersion: 1, turnId: typeof turnId === 'string' ? turnId : null, model: d.model, effort: d.effort,
|
|
62
|
+
taskClass: d.taskClass, expiresAt: Date.parse(d.selectionReviewedAt) + d.selectionMaxAgeMs,
|
|
63
|
+
subscriptionCovered: true, routeDigest: d.selectionRouteDigest };
|
|
64
|
+
}
|
|
65
|
+
export function writeReadinessReceipt({ nonce, receiptPath, pluginRoot, version, sessionId, status = 'ready' } = {}) {
|
|
66
|
+
if (!/^[a-f0-9]{64}$/.test(nonce || '') || !path.isAbsolute(receiptPath || '') ||
|
|
67
|
+
!path.isAbsolute(pluginRoot || '') || (!/^2\.1\.\d+$/.test(version || '') || Number(version.split('.')[2]) < 287) || typeof sessionId !== 'string' ||
|
|
68
|
+
!['ready', 'health'].includes(status)) throw new Error('Invalid native mod readiness request');
|
|
69
|
+
const receipt = { schemaVersion: 1, nonce, pluginRoot: fs.realpathSync(pluginRoot), nativeVersion: version,
|
|
70
|
+
sessionId, status, modDigest: terminalModDigest(pluginRoot), observedAt: new Date().toISOString(), pid: process.ppid,
|
|
71
|
+
scope: 'hook activation only; native load failure or worker crash can skip hooks' };
|
|
72
|
+
const parent = path.dirname(receiptPath);
|
|
73
|
+
if (!fs.existsSync(parent)) throw new Error('Readiness directory must be prepared by launcher');
|
|
74
|
+
const temporary = path.join(parent, `.rnb-ready-${crypto.randomBytes(16).toString('hex')}`);
|
|
75
|
+
fs.writeFileSync(temporary, JSON.stringify(receipt), { mode: 0o600, flag: 'wx' });
|
|
76
|
+
fs.renameSync(temporary, receiptPath);
|
|
77
|
+
return receipt;
|
|
78
|
+
}
|
|
79
|
+
async function main() {
|
|
80
|
+
const mode = process.argv[2];
|
|
81
|
+
const request = JSON.parse(fs.readFileSync(0, 'utf8'));
|
|
82
|
+
if (mode === '--decision') process.stdout.write(JSON.stringify(routeNativePrompt(request)) + '\n');
|
|
83
|
+
else if (mode === '--ready' || mode === '--health') process.stdout.write(JSON.stringify(writeReadinessReceipt({ ...request, status: mode === '--ready' ? 'ready' : 'health' })) + '\n');
|
|
84
|
+
else if (mode === '--prepare') process.stdout.write(JSON.stringify(prepareClaudeTerminalMod(request)) + '\n');
|
|
85
|
+
else throw new Error('Expected --prepare, --decision, --ready, or --health');
|
|
86
|
+
}
|
|
87
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main().catch(() => {
|
|
88
|
+
process.stderr.write('claude-terminal-mod: routing or activation unavailable; no fallback\n'); process.exitCode = 1;
|
|
89
|
+
});
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
// Metadata-only repair. releaseIdentity is an independently verified release input owned by the
|
|
2
|
+
// installer, NOT a hash/boolean synthesized from the installed file. No artifact discovery/inference.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import crypto from 'node:crypto';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
8
|
+
import { codexHookIdentities } from './codex-hook-trust.mjs';
|
|
9
|
+
|
|
10
|
+
/** Authenticate exact published package bytes before reading one archive member. No extraction/install. */
|
|
11
|
+
export async function obtainVerifiedPublishedHookIdentity({ version, fetch: fetcher = globalThis.fetch,
|
|
12
|
+
timeoutMs = 10000, maxTarballBytes = 32 * 1024 * 1024, tarBinary = '/usr/bin/tar' } = {}) {
|
|
13
|
+
if (!/^\d+\.\d+\.\d+$/.test(version || '') || typeof fetcher !== 'function') return blocked('Exact released version and HTTPS fetch required');
|
|
14
|
+
const deadline = Date.now() + timeoutMs;
|
|
15
|
+
const get = async (url, limit) => {
|
|
16
|
+
const remaining = deadline - Date.now(); if (remaining <= 0) throw new Error('Published artifact fetch deadline exceeded');
|
|
17
|
+
const response = await fetcher(url, { redirect: 'error', signal: AbortSignal.timeout(remaining) });
|
|
18
|
+
if (!response.ok || !response.body) throw new Error('Published artifact HTTPS fetch failed');
|
|
19
|
+
if (Number(response.headers.get('content-length')) > limit) throw new Error('Published artifact exceeds size bound');
|
|
20
|
+
const reader = response.body.getReader(); const chunks = []; let length = 0;
|
|
21
|
+
try {
|
|
22
|
+
while (true) {
|
|
23
|
+
if (Date.now() >= deadline) throw new Error('Published artifact fetch deadline exceeded');
|
|
24
|
+
const { done, value } = await reader.read(); if (done) break;
|
|
25
|
+
length += value.byteLength; if (length > limit) throw new Error('Published artifact exceeds size bound');
|
|
26
|
+
chunks.push(Buffer.from(value));
|
|
27
|
+
}
|
|
28
|
+
} finally { await reader.cancel().catch(() => {}); }
|
|
29
|
+
return Buffer.concat(chunks);
|
|
30
|
+
};
|
|
31
|
+
try {
|
|
32
|
+
const metadataUrl = `https://registry.npmjs.org/ruvnet-brain/${version}`;
|
|
33
|
+
const metadata = JSON.parse((await get(metadataUrl, 2 * 1024 * 1024)).toString('utf8'));
|
|
34
|
+
const tarballUrl = `https://registry.npmjs.org/ruvnet-brain/-/ruvnet-brain-${version}.tgz`;
|
|
35
|
+
if (metadata.name !== 'ruvnet-brain' || metadata.version !== version || metadata.dist?.tarball !== tarballUrl
|
|
36
|
+
|| !/^sha512-[A-Za-z0-9+/]{86}==$/.test(metadata.dist?.integrity || '')) throw new Error('Published version/path/integrity mismatch');
|
|
37
|
+
const archive = await get(tarballUrl, maxTarballBytes);
|
|
38
|
+
const integrity = `sha512-${crypto.createHash('sha512').update(archive).digest('base64')}`;
|
|
39
|
+
if (integrity !== metadata.dist.integrity) throw new Error('Published archive integrity mismatch');
|
|
40
|
+
const remaining = deadline - Date.now(); if (remaining <= 0) throw new Error('Published artifact deadline exceeded');
|
|
41
|
+
// stdout-only exact member: no archive paths are ever written to disk or traversed.
|
|
42
|
+
const result = spawnSync(tarBinary, ['-xzOf', '-', 'package/plugin/hooks/codex-hooks.json'],
|
|
43
|
+
{ input: archive, timeout: Math.min(remaining, 3000), maxBuffer: 256 * 1024 });
|
|
44
|
+
if (result.error || result.status !== 0 || !result.stdout?.length) throw new Error('Platform tar cannot read exact published hooks entry');
|
|
45
|
+
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) },
|
|
47
|
+
metadataUrl, tarballUrl, archiveBytes: archive.length };
|
|
48
|
+
} catch (error) { return blocked(error.message); }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const activeMetadataChildren = new Map();
|
|
52
|
+
async function shutdownMetadataChild(child) {
|
|
53
|
+
const entry = activeMetadataChildren.get(child); if (!entry) return;
|
|
54
|
+
if (entry.closing) return entry.closing;
|
|
55
|
+
entry.closing = (async () => {
|
|
56
|
+
const wait = async () => {
|
|
57
|
+
let timer;
|
|
58
|
+
try { await Promise.race([entry.exited, new Promise((resolve) => { timer = setTimeout(resolve, 350); })]); }
|
|
59
|
+
finally { clearTimeout(timer); }
|
|
60
|
+
};
|
|
61
|
+
if (child.pid && child.exitCode === null && child.signalCode === null) {
|
|
62
|
+
child.kill('SIGTERM'); await wait();
|
|
63
|
+
if (child.exitCode === null && child.signalCode === null) { child.kill('SIGKILL'); await wait(); }
|
|
64
|
+
}
|
|
65
|
+
if (child.pid && child.exitCode === null && child.signalCode === null) throw new Error('Native metadata child exit unverified');
|
|
66
|
+
activeMetadataChildren.delete(child);
|
|
67
|
+
})();
|
|
68
|
+
return entry.closing;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const OWNER = 'ruvnet-brain@ruvnet-brain';
|
|
72
|
+
const digest = (bytes) => crypto.createHash('sha256').update(bytes).digest('hex');
|
|
73
|
+
const blocked = (reason) => ({ state: 'blocked', changed: false, reason });
|
|
74
|
+
function privateBackup(configPath, bytes, backupDir) {
|
|
75
|
+
fs.mkdirSync(backupDir, { recursive: true, mode: 0o700 });
|
|
76
|
+
if (fs.lstatSync(backupDir).isSymbolicLink()) throw new Error('Backup directory symlink refused');
|
|
77
|
+
const file = path.join(backupDir, `rnb-hook-trust-${new Date().toISOString().replaceAll(':', '-')}-${crypto.randomUUID()}.toml`);
|
|
78
|
+
const fd = fs.openSync(file, 'wx', 0o600);
|
|
79
|
+
try { fs.writeFileSync(fd, bytes); fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
|
|
80
|
+
const dir = fs.openSync(backupDir, 'r'); try { fs.fsyncSync(dir); } finally { fs.closeSync(dir); }
|
|
81
|
+
return file;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function resolveNativeBinary(binary, { env = process.env, platform = process.platform } = {}) {
|
|
85
|
+
if (typeof binary !== 'string' || !binary) throw new Error('Native executable required');
|
|
86
|
+
const candidates = path.isAbsolute(binary) ? [binary] : /^[A-Za-z0-9._-]+$/.test(binary)
|
|
87
|
+
? String(env.PATH || '').split(platform === 'win32' ? ';' : path.delimiter).filter(Boolean).flatMap((dir) => {
|
|
88
|
+
const suffixes = platform === 'win32' && !path.extname(binary) ? String(env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';') : [''];
|
|
89
|
+
return suffixes.map((suffix) => path.resolve(dir, binary + suffix));
|
|
90
|
+
}) : [];
|
|
91
|
+
for (const candidate of candidates) {
|
|
92
|
+
try {
|
|
93
|
+
fs.accessSync(candidate, platform === 'win32' ? fs.constants.F_OK : fs.constants.X_OK);
|
|
94
|
+
if (!fs.statSync(candidate).isFile()) continue;
|
|
95
|
+
const resolved = fs.realpathSync(candidate);
|
|
96
|
+
if (resolved === fs.realpathSync(fileURLToPath(import.meta.url))) throw new Error('Recursive native gateway refused');
|
|
97
|
+
return resolved;
|
|
98
|
+
} catch (error) { if (error.message === 'Recursive native gateway refused') throw error; }
|
|
99
|
+
}
|
|
100
|
+
throw new Error('Native executable unavailable on configured PATH');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Only initialize/config/read/config/batchWrite/hooks/list are ever sent by this client. */
|
|
104
|
+
async function nativeClient(binary, cwd, deadlineAt, configPath) {
|
|
105
|
+
binary = resolveNativeBinary(binary);
|
|
106
|
+
const env = { ...process.env, CODEX_HOME: path.dirname(configPath) };
|
|
107
|
+
for (const key of ['OPENAI_API_KEY', 'OPENAI_BASE_URL', 'ANTHROPIC_API_KEY', 'CODEX_API_KEY']) delete env[key];
|
|
108
|
+
const child = spawn(binary, ['app-server', '--listen', 'stdio://'], { cwd, env, stdio: ['pipe', 'pipe', 'ignore'] });
|
|
109
|
+
const exited = new Promise((resolve) => { child.once('exit', resolve); child.once('error', () => { if (!child.pid) resolve(); }); });
|
|
110
|
+
activeMetadataChildren.set(child, { exited });
|
|
111
|
+
let nextId = 1; let buffer = ''; const pending = new Map();
|
|
112
|
+
const rejectAll = (message) => { for (const item of pending.values()) { clearTimeout(item.timer); item.reject(new Error(message)); } pending.clear(); };
|
|
113
|
+
child.on('error', () => rejectAll('Native metadata process failed'));
|
|
114
|
+
child.stdin.on('error', () => rejectAll('Native metadata input failed'));
|
|
115
|
+
child.on('exit', () => rejectAll('Native metadata process exited'));
|
|
116
|
+
child.stdout.on('data', (chunk) => {
|
|
117
|
+
buffer += chunk;
|
|
118
|
+
if (Buffer.byteLength(buffer) > 8 * 1024 * 1024) { rejectAll('Native metadata output exceeds bound'); child.kill(); return; }
|
|
119
|
+
while (buffer.includes('\n')) {
|
|
120
|
+
const end = buffer.indexOf('\n'); const line = buffer.slice(0, end); buffer = buffer.slice(end + 1);
|
|
121
|
+
let row; try { row = JSON.parse(line); } catch { continue; }
|
|
122
|
+
const item = pending.get(row.id); if (!item) continue;
|
|
123
|
+
pending.delete(row.id); clearTimeout(item.timer);
|
|
124
|
+
// Do not include native error details: they may contain private config values.
|
|
125
|
+
if (row.error) item.reject(new Error(`Native ${item.method} rejected`)); else item.resolve(row.result);
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
const rpc = (method, params) => new Promise((resolve, reject) => {
|
|
129
|
+
const remaining = deadlineAt - Date.now(); if (remaining <= 0) { reject(new Error('Native metadata deadline exceeded')); return; }
|
|
130
|
+
const id = nextId++; const timer = setTimeout(() => { pending.delete(id); reject(new Error(`Native ${method} timed out`)); }, remaining);
|
|
131
|
+
pending.set(id, { resolve, reject, timer, method });
|
|
132
|
+
child.stdin.write(`${JSON.stringify({ id, method, params })}\n`, (error) => { if (error) rejectAll('Native metadata input failed'); });
|
|
133
|
+
});
|
|
134
|
+
const close = async () => { rejectAll('Native metadata client closed'); child.stdin.destroy(); await shutdownMetadataChild(child); };
|
|
135
|
+
try {
|
|
136
|
+
await rpc('initialize', { clientInfo: { name: 'rnb_verified_hook_trust', version: '1' }, capabilities: { experimentalApi: true } });
|
|
137
|
+
child.stdin.write(`${JSON.stringify({ method: 'initialized' })}\n`);
|
|
138
|
+
return { rpc, close };
|
|
139
|
+
} catch (error) { await close(); throw error; }
|
|
140
|
+
}
|
|
141
|
+
const rowsFor = (response) => {
|
|
142
|
+
if (!Array.isArray(response?.data)) throw new Error('Native hook registry unavailable');
|
|
143
|
+
return response.data.flatMap((r) => r.hooks || []).filter((r) => r.pluginId === OWNER);
|
|
144
|
+
};
|
|
145
|
+
function assertRegistry(rows, identities) {
|
|
146
|
+
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');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function otherSettings(config, keys) {
|
|
151
|
+
const clone = structuredClone(config);
|
|
152
|
+
for (const key of keys) {
|
|
153
|
+
const state = clone?.hooks?.state?.[key];
|
|
154
|
+
if (state && typeof state === 'object') {
|
|
155
|
+
delete state.trusted_hash;
|
|
156
|
+
if (!Object.keys(state).length) delete clone.hooks.state[key];
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (clone?.hooks?.state && !Object.keys(clone.hooks.state).length) delete clone.hooks.state;
|
|
160
|
+
if (clone?.hooks && !Object.keys(clone.hooks).length) delete clone.hooks;
|
|
161
|
+
const canonical = (v) => Array.isArray(v) ? v.map(canonical) : v && typeof v === 'object'
|
|
162
|
+
? Object.fromEntries(Object.keys(v).sort().map((k) => [k, canonical(v[k])])) : v;
|
|
163
|
+
return JSON.stringify(canonical(clone));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Caller must authenticate releaseIdentity against its verified tarball/protected-release receipt.
|
|
167
|
+
* The helper checks installed bytes/version and native identities; it cannot authenticate caller data.
|
|
168
|
+
* rpc injection owns its own client lifetime. Default client never starts a thread, turn or account call.
|
|
169
|
+
*/
|
|
170
|
+
export async function reconcileVerifiedCodexHookTrust({ releaseIdentity, installedVersion, hooksPath,
|
|
171
|
+
configPath, nativeBinary, cwd = process.cwd(), backupDir = path.join(path.dirname(configPath || ''), 'backups'),
|
|
172
|
+
rpc: suppliedRpc, timeoutMs = 10000 } = {}) {
|
|
173
|
+
let client; let wrote = false; let writeAttempted = false; let backupPath;
|
|
174
|
+
try {
|
|
175
|
+
if (!releaseIdentity || !/^\d+\.\d+\.\d+$/.test(releaseIdentity.version || '')
|
|
176
|
+
|| installedVersion !== releaseIdentity.version || !/^[a-f0-9]{64}$/.test(releaseIdentity.hooksSha256 || '')
|
|
177
|
+
|| !/^sha512-[A-Za-z0-9+/]{86}==$/.test(releaseIdentity.integrity || '')) return blocked('Verified released artifact identity required');
|
|
178
|
+
if (![hooksPath, configPath, backupDir].every((p) => typeof p === 'string' && path.isAbsolute(p))) return blocked('Absolute owned paths required');
|
|
179
|
+
if (fs.lstatSync(hooksPath).isSymbolicLink() || fs.lstatSync(configPath).isSymbolicLink()) return blocked('Source/config symlink refused');
|
|
180
|
+
const bytes = fs.readFileSync(hooksPath);
|
|
181
|
+
if (digest(bytes) !== releaseIdentity.hooksSha256) return blocked('Installed hooks differ from verified release');
|
|
182
|
+
const identities = codexHookIdentities(JSON.parse(bytes));
|
|
183
|
+
if (!identities.size) return blocked('Verified artifact has no native hooks');
|
|
184
|
+
if (!suppliedRpc) client = await nativeClient(nativeBinary, cwd, Date.now() + Math.min(timeoutMs, 20000), configPath);
|
|
185
|
+
const rpc = suppliedRpc || client.rpc;
|
|
186
|
+
const before = rowsFor(await rpc('hooks/list', { cwds: [cwd] })); assertRegistry(before, identities);
|
|
187
|
+
const disabled = before.filter((r) => r.enabled === false).map((r) => r.key).sort();
|
|
188
|
+
if (before.some((r) => !['trusted', 'modified', 'untrusted', 'managed'].includes(r.trustStatus))) return blocked('Unknown native trust state');
|
|
189
|
+
const pending = before.filter((r) => r.enabled === true && ['modified', 'untrusted'].includes(r.trustStatus));
|
|
190
|
+
if (!pending.length) return { state: 'unchanged', changed: false, disabledPreserved: disabled };
|
|
191
|
+
const read = await rpc('config/read', { includeLayers: true });
|
|
192
|
+
const users = (read.layers || []).filter((r) => r.name?.type === 'user' && r.name.file === configPath);
|
|
193
|
+
if (users.length !== 1 || typeof users[0].version !== 'string' || !users[0].version) return blocked('Current user config version unavailable');
|
|
194
|
+
// Config stays memory-only except the private backup. No logging of effective config values.
|
|
195
|
+
const prior = fs.readFileSync(configPath);
|
|
196
|
+
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' }));
|
|
199
|
+
writeAttempted = true;
|
|
200
|
+
await rpc('config/batchWrite', { filePath: configPath, expectedVersion: users[0].version, reloadUserConfig: true, edits });
|
|
201
|
+
wrote = true;
|
|
202
|
+
const afterRead = await rpc('config/read', { includeLayers: true });
|
|
203
|
+
const afterUsers = (afterRead.layers || []).filter((r) => r.name?.type === 'user' && r.name.file === configPath);
|
|
204
|
+
if (afterUsers.length !== 1 || otherSettings(users[0].config, pending.map((r) => r.key))
|
|
205
|
+
!== 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);
|
|
207
|
+
if (JSON.stringify(after.filter((r) => r.enabled === false).map((r) => r.key).sort()) !== JSON.stringify(disabled)
|
|
208
|
+
|| after.some((r) => r.enabled === true && !['trusted', 'managed'].includes(r.trustStatus))) throw new Error('Native trust repair verification incomplete');
|
|
209
|
+
return { state: 'registry-verified', changed: true, editedTrustHashes: edits.length,
|
|
210
|
+
enabledBrainTrusted: after.filter((r) => r.enabled === true).length, disabledPreserved: disabled,
|
|
211
|
+
artifactHooksSha256: releaseIdentity.hooksSha256, backupPath, otherSemanticSettingsUnchanged: true,
|
|
212
|
+
priorConfigSha256: digest(prior), afterConfigSha256: digest(fs.readFileSync(configPath)), inferenceRequests: 0,
|
|
213
|
+
executionAcceptance: 'not-run' };
|
|
214
|
+
} catch (error) {
|
|
215
|
+
// Never restore an entire backup over concurrent writers. A successful write followed by failed
|
|
216
|
+
// verification is degraded, not "unchanged"; the installer must expose this outcome.
|
|
217
|
+
return { state: wrote || writeAttempted ? 'degraded' : 'blocked', changed: wrote ? true : writeAttempted ? 'unknown' : false, reason: error.message, ...(backupPath ? { backupPath } : {}) };
|
|
218
|
+
} finally { await client?.close(); }
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Explicit CLI only; importing the helper never starts a process or reads stdin.
|
|
222
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
|
|
223
|
+
&& process.argv[2] === '--reconcile-installed') {
|
|
224
|
+
const started = Date.now(); let emitted = false; let reconcileStarted = false;
|
|
225
|
+
const emit = (receipt) => { if (!emitted) { emitted = true; process.stdout.write(JSON.stringify(receipt) + '\n'); } };
|
|
226
|
+
const guard = setTimeout(async () => {
|
|
227
|
+
await Promise.allSettled([...activeMetadataChildren.keys()].map(shutdownMetadataChild));
|
|
228
|
+
emit({ state: 'degraded', changed: 'unknown', reason: 'CLI deadline exceeded; verify native trust state', inferenceRequests: 0 });
|
|
229
|
+
process.exit(1);
|
|
230
|
+
}, 29000);
|
|
231
|
+
(async () => {
|
|
232
|
+
let input = ''; for await (const chunk of process.stdin) {
|
|
233
|
+
input += chunk; if (Buffer.byteLength(input) > 16384) throw new Error('CLI input exceeds bound');
|
|
234
|
+
}
|
|
235
|
+
const args = JSON.parse(input);
|
|
236
|
+
if (!args || typeof args !== 'object' || Array.isArray(args)
|
|
237
|
+
|| Object.keys(args).some((k) => !['installedVersion', 'hooksPath', 'configPath', 'nativeBinary', 'cwd'].includes(k))) throw new Error('Invalid CLI options');
|
|
238
|
+
const proof = await obtainVerifiedPublishedHookIdentity({ version: args.installedVersion, timeoutMs: 10000 });
|
|
239
|
+
if (proof.state !== 'verified') { emit(proof); return; }
|
|
240
|
+
reconcileStarted = true;
|
|
241
|
+
const result = await reconcileVerifiedCodexHookTrust({ ...args, releaseIdentity: proof.releaseIdentity,
|
|
242
|
+
timeoutMs: Math.max(1, Math.min(20000, 28000 - (Date.now() - started))) });
|
|
243
|
+
emit(result);
|
|
244
|
+
})().catch(() => emit(reconcileStarted
|
|
245
|
+
? { state: 'degraded', changed: 'unknown', reason: 'Native reconciliation or shutdown failed; verify trust state', inferenceRequests: 0 }
|
|
246
|
+
: blocked('CLI request or published artifact proof failed'))).finally(() => clearTimeout(guard));
|
|
247
|
+
}
|
package/scripts/codex-routed.sh
CHANGED
|
@@ -1,38 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
|
-
#
|
|
3
|
-
|
|
4
|
-
# per-prompt model-selection path available to it. Part of the MetaHarness router (see
|
|
5
|
-
# scripts/model-router-engine.mjs, the harness-neutral prompt -> {model, reason} decision
|
|
6
|
-
# engine). This script only CONSULTS the engine and LAUNCHES codex — all selection logic
|
|
7
|
-
# (features, policy, pricing) lives in the engine, not here.
|
|
8
|
-
#
|
|
9
|
-
# Contract: never block Stuart's launch. If the engine errors, times out, or returns a
|
|
10
|
-
# null model, fall straight through to plain `codex` with no --model flag — a missing
|
|
11
|
-
# routing decision must never be worse than no routing at all.
|
|
12
|
-
set -uo pipefail
|
|
2
|
+
# One routed native noninteractive worker. No silent fallback; model and effort are enforced together.
|
|
3
|
+
set -euo pipefail
|
|
13
4
|
DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
14
|
-
|
|
15
|
-
DECISION=$(node "$DIR/model-router-engine.mjs" --harness codex --prompt "$*" --json 2>/dev/null)
|
|
16
|
-
|
|
17
|
-
# Extract .model + a short .reason without a jq dependency (Stuart directive). python3 ships
|
|
18
|
-
# with macOS by default and its json module handles arbitrary reason text (quotes, unicode)
|
|
19
|
-
# more safely than hand-rolled JS string escaping in a node -e one-liner would. Single call,
|
|
20
|
-
# tab-delimited output, so we only shell out once per launch.
|
|
21
|
-
IFS=$'\t' read -r M REASON <<< "$(printf '%s' "$DECISION" | python3 -c '
|
|
22
|
-
import json, sys
|
|
23
|
-
try:
|
|
24
|
-
d = json.load(sys.stdin)
|
|
25
|
-
m = d.get("model") or ""
|
|
26
|
-
r = (d.get("reason") or "").replace("\n", " ").replace("\t", " ")[:80]
|
|
27
|
-
print(f"{m}\t{r}")
|
|
28
|
-
except Exception:
|
|
29
|
-
print("\t")
|
|
30
|
-
' 2>/dev/null)"
|
|
31
|
-
|
|
32
|
-
if [ -z "${M:-}" ] || [ "$M" = "None" ]; then
|
|
33
|
-
# Engine failed / no policy resolved a model — never block, just launch codex unrouted.
|
|
34
|
-
exec codex "$@"
|
|
35
|
-
fi
|
|
36
|
-
|
|
37
|
-
printf '\x1b[2m🧭 codex-routed → %s (%s)\x1b[0m\n' "$M" "$REASON"
|
|
38
|
-
exec codex --model "$M" "$@"
|
|
5
|
+
exec node "$DIR/model-router-dispatch.mjs" --harness codex -- "$@"
|
package/scripts/goldie-weekly.sh
CHANGED
|
@@ -1,67 +1,11 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
|
-
#
|
|
3
|
-
#
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
# drift flags + radar + the dated brief. Pure data, no LLM, no key needed.
|
|
10
|
-
# 2. JUDGMENT (headless Claude, subscription-covered): answers the brief's three standing
|
|
11
|
-
# questions (bucket count, best-model-per-bucket per public evals, radar adoption) with real
|
|
12
|
-
# web research, APPENDED to the same brief as a PROPOSAL — never auto-applied to policy.mjs.
|
|
13
|
-
# Skippable (GOLDIE_SKIP_JUDGMENT=1) and its failure never hides layer 1's result.
|
|
14
|
-
set -u
|
|
15
|
-
cd /Users/stuartkerr/Code/ruvnet-brain || exit 1
|
|
16
|
-
mkdir -p logs
|
|
17
|
-
LOG=logs/goldie.log
|
|
18
|
-
TODAY=$(date +%F)
|
|
19
|
-
BRIEF="$HOME/.claude/model-router/goldie/$TODAY.md"
|
|
20
|
-
|
|
21
|
-
echo "===== goldie-weekly — $(date -u +%FT%TZ) =====" >> "$LOG"
|
|
22
|
-
|
|
23
|
-
# ── Layer 1: deterministic refresh (must succeed for the run to count) ──
|
|
24
|
-
if ! /usr/local/bin/node scripts/goldie-research.mjs >> "$LOG" 2>&1; then
|
|
25
|
-
sh scripts/notify.sh "🔴 Goldie FAILED — model catalog is going stale" \
|
|
26
|
-
"goldie-research.mjs could not produce this week's brief (OpenRouter fetch or catalog write failed). The router is now running on last week's picture. See logs/goldie.log." \
|
|
27
|
-
urgent "rotating_light" || true
|
|
2
|
+
# Existing Goldie launchd label/calendar stays unchanged. Run installed, guarded native cycle;
|
|
3
|
+
# policy promotion is a separate evidence-qualified boundary. No legacy model/API proposer.
|
|
4
|
+
set -eu
|
|
5
|
+
CYCLE="$HOME/.claude/model-router/bin/model-weekly-cycle.mjs"
|
|
6
|
+
NODE_BINARY="${RUVNET_NODE_BINARY:-/opt/homebrew/opt/node@24/bin/node}"
|
|
7
|
+
if [ ! -f "$CYCLE" ] || [ ! -x "$NODE_BINARY" ]; then
|
|
8
|
+
echo 'Weekly model cycle is not installed; prior policy retained.' >&2
|
|
28
9
|
exit 1
|
|
29
10
|
fi
|
|
30
|
-
|
|
31
|
-
# ── Layer 2: judgment (best-effort, never blocks; appends to the brief) ──
|
|
32
|
-
JUDGMENT="skipped"
|
|
33
|
-
if [ "${GOLDIE_SKIP_JUDGMENT:-0}" != "1" ] && command -v claude >/dev/null 2>&1; then
|
|
34
|
-
PROMPT="You are Goldie, the weekly model-landscape researcher for a prompt->model router.
|
|
35
|
-
Read $BRIEF (this week's deterministic data) and ~/.claude/model-router/catalog.json (the candidate
|
|
36
|
-
catalog) and ~/.claude/model-router/policy.default.mjs (the current placeholder policy). Then use web
|
|
37
|
-
search on the current public evaluations (Artificial Analysis, LMArena, SWE-bench and similar) to
|
|
38
|
-
answer the brief's three standing questions with sources and dates:
|
|
39
|
-
(1) how many BUCKETS should prompts be classified into and what are they;
|
|
40
|
-
(2) the best model per bucket right now on capability-per-cost-per-speed, split into: covered by a
|
|
41
|
-
Claude Max subscription (claude-code harness), covered by a ChatGPT/Codex subscription (codex
|
|
42
|
-
harness), and cheapest-capable OpenRouter API model;
|
|
43
|
-
(3) whether any radar model in the brief deserves wiring up, and what that requires.
|
|
44
|
-
Rules: cite sources with dates for every claim; distinguish MEASURED numbers from vendor claims;
|
|
45
|
-
recommendations are PROPOSALS for catalog.json/policy.mjs — do not edit any file. End with a
|
|
46
|
-
'## Proposed policy changes' section in plain, reviewable prose.
|
|
47
|
-
Write your full answer to stdout as markdown."
|
|
48
|
-
# env -u ANTHROPIC_API_KEY: a stray/stale API key in the environment makes headless claude bill
|
|
49
|
-
# (or fail on) the API instead of riding the Claude Max login — the exact "spend where the
|
|
50
|
-
# subscription is free" mistake this system exists to kill. Found live 2026-07-12: an invalid
|
|
51
|
-
# inherited key failed the whole judgment layer with "Invalid API key". Subscription, always.
|
|
52
|
-
if OUT=$(timeout 900 env -u ANTHROPIC_API_KEY claude -p "$PROMPT" --model sonnet --allowed-tools "WebSearch,WebFetch,Read" 2>>"$LOG"); then
|
|
53
|
-
{ echo ""; echo "---"; echo ""; echo "# Judgment layer (headless Claude, $(date -u +%FT%TZ))"; echo ""; echo "$OUT"; } >> "$BRIEF"
|
|
54
|
-
JUDGMENT="ok"
|
|
55
|
-
else
|
|
56
|
-
JUDGMENT="FAILED (see logs/goldie.log)"
|
|
57
|
-
{ echo ""; echo "---"; echo ""; echo "# Judgment layer: FAILED this week ($(date -u +%FT%TZ)) — deterministic data above still fresh."; } >> "$BRIEF"
|
|
58
|
-
fi
|
|
59
|
-
fi
|
|
60
|
-
|
|
61
|
-
# ── Exactly one summary push per run — success included (silence is never a signal) ──
|
|
62
|
-
HEADLINES=$(grep -E "PRICE DRIFT|NOT FOUND" "$BRIEF" | head -3)
|
|
63
|
-
sh scripts/notify.sh "🧭 Goldie ran — model catalog refreshed" \
|
|
64
|
-
"Weekly brief: $BRIEF. Judgment layer: $JUDGMENT.${HEADLINES:+ ATTENTION: $HEADLINES}" \
|
|
65
|
-
default "compass" || true
|
|
66
|
-
echo "===== goldie-weekly done (judgment: $JUDGMENT) =====" >> "$LOG"
|
|
67
|
-
exit 0
|
|
11
|
+
exec "$NODE_BINARY" "$CYCLE" --router-dir "$HOME/.claude/model-router"
|
|
@@ -142,7 +142,13 @@ export async function route(prompt, candidates, profile, { qualityBar = 0.7, k =
|
|
|
142
142
|
}
|
|
143
143
|
|
|
144
144
|
const prices = effectivePrices(candidates, profile);
|
|
145
|
-
const
|
|
145
|
+
const allowed = new Set(candidates.map((m) => m.id));
|
|
146
|
+
const constrainedRows = rows.map((row) => ({ ...row,
|
|
147
|
+
scores: Object.fromEntries(Object.entries(row.scores).filter(([id]) => allowed.has(id))),
|
|
148
|
+
})).filter((row) => Object.keys(row.scores).length);
|
|
149
|
+
if (constrainedRows.length < MIN_LABELS) return { routedBy: 'COLD-START',
|
|
150
|
+
reason: 'Insufficient labels for policy-eligible models', labels: constrainedRows.length };
|
|
151
|
+
const router = mod.Router.fromExamples(constrainedRows, prices, { k, qualityBar });
|
|
146
152
|
const pick = router.route(await embed(prompt));
|
|
147
153
|
|
|
148
154
|
return {
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// DISTINCT-FROM: native-subscription-usage.mjs — isolated child tool-denial trust, never an inference turn.
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { spawn } from 'node:child_process';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
export const TOOL_DENIAL = { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: 'Weekly analyst tools are prohibited; analyse supplied evidence only.' } };
|
|
8
|
+
const quote = (value) => `'${value.replaceAll("'", "'\\''")}'`;
|
|
9
|
+
export function createAnalystHome(runDir) {
|
|
10
|
+
const requestedHome = path.join(runDir, 'native-home'); fs.mkdirSync(requestedHome, { mode: 0o700 }); const home = fs.realpathSync(requestedHome);
|
|
11
|
+
const source = path.join(os.homedir(), '.codex');
|
|
12
|
+
const auth = fs.realpathSync(path.join(source, 'auth.json'));
|
|
13
|
+
if (!fs.statSync(auth).isFile()) throw new Error('Canonical native OAuth file unavailable');
|
|
14
|
+
fs.symlinkSync(auth, path.join(home, 'auth.json'));
|
|
15
|
+
fs.copyFileSync(path.join(source, 'models_cache.json'), path.join(home, 'models_cache.json')); fs.chmodSync(path.join(home, 'models_cache.json'), 0o600);
|
|
16
|
+
fs.writeFileSync(path.join(home, 'config.toml'), '', { mode: 0o600 });
|
|
17
|
+
const command = `${quote(process.execPath)} ${quote(fileURLToPath(import.meta.url))} --deny`;
|
|
18
|
+
fs.writeFileSync(path.join(home, 'hooks.json'), JSON.stringify({ hooks: { PreToolUse: [{ matcher: '.*', hooks: [{ type: 'command', command, timeout: 5 }] }] } }), { mode: 0o600 });
|
|
19
|
+
return { home, command };
|
|
20
|
+
}
|
|
21
|
+
export function sameNativeConfigPath(actual, expected, platform = process.platform) {
|
|
22
|
+
if (typeof actual !== 'string' || !actual) return false;
|
|
23
|
+
const paths = platform === 'win32' ? path.win32 : path.posix;
|
|
24
|
+
const normalize = value => { const resolved = paths.resolve(value); return platform === 'win32' ? resolved.toLowerCase() : resolved; };
|
|
25
|
+
return normalize(actual) === normalize(expected);
|
|
26
|
+
}
|
|
27
|
+
export async function trustAnalystDenial({ home, command, env = process.env, spawnHost = spawn, timeoutMs = 8000 }) {
|
|
28
|
+
const child = spawnHost('codex', ['app-server', '--strict-config', '-c', 'features.plugins=false', '-c', 'service_tier="default"', '--listen', 'stdio://'], { cwd: home, env: { ...env, CODEX_HOME: home }, stdio: ['pipe', 'pipe', 'pipe'], shell: false });
|
|
29
|
+
let id = 0, buffer = ''; const pending = new Map(); let failure;
|
|
30
|
+
const fail = () => { failure = new Error('Private native hook metadata transport failed'); for (const p of pending.values()) p.reject(failure); pending.clear(); };
|
|
31
|
+
child.on('error', fail); child.on('exit', fail); child.stderr.on('data', () => {}); child.stdin.on('error', fail);
|
|
32
|
+
child.stdout.on('data', (chunk) => { buffer += chunk; if (buffer.length > 1048576) return fail(); let split;
|
|
33
|
+
while ((split = buffer.indexOf('\n')) >= 0) { const line = buffer.slice(0, split); buffer = buffer.slice(split + 1); let r; try { r = JSON.parse(line); } catch { continue; }
|
|
34
|
+
const p = pending.get(r.id); if (!p) continue; pending.delete(r.id); r.error ? p.reject(new Error('Private native hook metadata request rejected')) : p.resolve(r.result);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
const request = (method, params) => new Promise((resolve, reject) => { if (failure) return reject(failure); const n = ++id; pending.set(n, { resolve, reject }); child.stdin.write(JSON.stringify({ id: n, method, params }) + '\n'); });
|
|
38
|
+
const timer = setTimeout(() => { fail(); child.kill(); }, timeoutMs);
|
|
39
|
+
const oneHook = (result) => { const entries = result?.data ?? []; const hooks = entries.flatMap((e) => e.hooks ?? []);
|
|
40
|
+
if (entries.some((e) => e.errors?.length) || hooks.length !== 1 || hooks[0].eventName !== 'preToolUse' || hooks[0].matcher !== '.*' || hooks[0].command !== command || !hooks[0].enabled || !/^sha256:[a-f0-9]{64}$/.test(hooks[0].currentHash)) throw new Error('Private deny hook is not the sole enabled hook'); return hooks[0]; };
|
|
41
|
+
try {
|
|
42
|
+
await request('initialize', { clientInfo: { name: 'weekly_analyst_sandbox', version: '1' }, capabilities: { experimentalApi: true } }); child.stdin.write('{"method":"initialized"}\n');
|
|
43
|
+
const first = oneHook(await request('hooks/list', { cwds: [home] }));
|
|
44
|
+
const config = await request('config/read', { cwd: home, includeLayers: true });
|
|
45
|
+
const layer = config.layers?.find((l) => sameNativeConfigPath(l.name?.file, path.join(home, 'config.toml')));
|
|
46
|
+
if (!layer?.version) throw new Error('Private native config version missing');
|
|
47
|
+
await request('config/batchWrite', { filePath: path.join(home, 'config.toml'), expectedVersion: layer.version, reloadUserConfig: true,
|
|
48
|
+
edits: [{ keyPath: `hooks.state.${JSON.stringify(first.key)}.trusted_hash`, value: first.currentHash, mergeStrategy: 'replace' }] });
|
|
49
|
+
const final = oneHook(await request('hooks/list', { cwds: [home] }));
|
|
50
|
+
if (final.trustStatus !== 'trusted' || final.currentHash !== first.currentHash) throw new Error('Private deny hook trust not accepted');
|
|
51
|
+
return { trusted: true, hookKey: final.key, currentHash: final.currentHash, inferenceStarted: false };
|
|
52
|
+
} finally { clearTimeout(timer); child.stdin.end(); child.kill(); }
|
|
53
|
+
}
|
|
54
|
+
if (process.argv.includes('--deny')) console.log(JSON.stringify(TOOL_DENIAL));
|