ruvnet-brain 4.3.40 → 4.4.1
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 +179 -36
- package/kb/brain-profile.mjs +17 -2
- package/kb/forge-update.mjs +6 -2
- package/kb/lifecycle-evidence-retention.mjs +12 -9
- package/kb/refresh-run.mjs +17 -1
- package/kb/update-storage-transaction.mjs +33 -5
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/hooks/codex-hooks.json +2 -2
- package/plugin/hooks/hooks.json +1 -1
- package/plugin/scripts/capability-registry.mjs +3 -3
- package/plugin/scripts/codex-hook-wrapper.mjs +7 -2
- package/plugin/scripts/design-wall.sh +1 -0
- package/plugin/scripts/ground-before-write.sh +1 -0
- package/plugin/scripts/ground-ruvnet.sh +3 -3
- package/plugin/scripts/grounding-answer.mjs +129 -0
- package/plugin/scripts/grounding-stamp.sh +32 -31
- package/plugin/scripts/grounding-turn-evidence.mjs +146 -5
- package/plugin/scripts/grounding-turn-gate.mjs +25 -6
- package/plugin/scripts/hook-shim.mjs +3 -0
- package/plugin/scripts/kling-preflight.sh +1 -0
- package/plugin/scripts/learn-capture.sh +1 -0
- package/plugin/scripts/project-progression-reader.mjs +10 -0
- package/plugin/scripts/project-progression-sources.mjs +16 -4
- package/plugin/scripts/project-progression-store.mjs +201 -6
- package/plugin/scripts/protect-brain-state.sh +1 -0
- package/plugin/scripts/route-dispatch.sh +1 -0
- package/plugin/scripts/session-snapshot-hook.mjs +383 -37
- package/plugin/scripts/session-start-health.mjs +24 -3
- package/plugin/scripts/session-start-update-plane.mjs +1 -1
- package/plugin/scripts/update-apply.mjs +22 -2
- package/scripts/console-instances.mjs +203 -0
- package/scripts/console-runtime-identity.mjs +2 -0
- package/scripts/corpus-canary.mjs +130 -18
- package/scripts/customer-seams.mjs +84 -0
- package/scripts/customer-state-matrix.mjs +363 -0
- package/scripts/full-suite-gate.mjs +162 -0
- package/scripts/grounding-turn-replay.mjs +11 -3
- package/scripts/hook-qualify-core.mjs +346 -0
- package/scripts/hook-qualify-hosts.mjs +115 -0
- package/scripts/hook-qualify.mjs +101 -0
- package/scripts/host-cli.mjs +115 -0
- package/scripts/qe/agentic-qe-4.3.mjs +0 -1
- package/scripts/route-gold-rank.mjs +156 -0
- package/scripts/route-index-memory.mjs +51 -0
- package/scripts/route-latency-warm.mjs +123 -0
- package/scripts/wired-check.mjs +17 -3
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
// hook-qualify-core.mjs — the pure + process-boundary parts of the hook qualification harness.
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS EXISTS. A hook that writes to stderr, exits non-zero, prints a malformed envelope or
|
|
4
|
+
// outlives its timeout is surfaced to the user as a "hook error". That is the failure that makes
|
|
5
|
+
// people switch the product off, so every registered hook is qualified against the HOST contract,
|
|
6
|
+
// not against its own header comment.
|
|
7
|
+
//
|
|
8
|
+
// NOTHING IS HAND-LISTED. The hooks come from plugin/hooks/hooks.json (Claude Code, and Grok through
|
|
9
|
+
// its Claude-compat layer) and plugin/hooks/codex-hooks.json (Codex); the hook's mode from
|
|
10
|
+
// hook-shim.mjs's TABLE (plugin/scripts/hook-registry.mjs parses it); the payload shapes from REAL
|
|
11
|
+
// captures of each host (tests/fixtures/hook-payloads/<host>/, provenance recorded inside each file).
|
|
12
|
+
//
|
|
13
|
+
// What the EXISTING tests already prove (hook-registry-lint, hook-contract, hook-hardening,
|
|
14
|
+
// hook-battery, hook-shim*, codex-*-parity, codex-lifecycle-hooks) is registry shape, per-hook unit
|
|
15
|
+
// behaviour and Codex envelope translation. What they do not do — and this does — is run EVERY
|
|
16
|
+
// registered command, exactly as the host would, through the adverse-input matrix with the host's
|
|
17
|
+
// own exit/stdout/stderr/timeout contract as the oracle.
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import os from 'node:os';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
22
|
+
import { shimIdIn, codexDispatchIdIn, shimTable, matchedTools, REPO } from '../plugin/scripts/hook-registry.mjs';
|
|
23
|
+
|
|
24
|
+
export const HOSTS = ['claude', 'codex', 'grok'];
|
|
25
|
+
/** Hosts clamp some events below the registered timeout (Codex prints "clamping SessionEnd hook timeout to 3s"). */
|
|
26
|
+
export const HOST_CAP_SEC = { codex: { SessionEnd: 3 }, claude: {}, grok: {} };
|
|
27
|
+
/** A hook must finish inside this fraction of its effective timeout on an idle machine. */
|
|
28
|
+
export const TIMEOUT_MARGIN = 0.6;
|
|
29
|
+
|
|
30
|
+
// ── registry ─────────────────────────────────────────────────────────────────────────────────────
|
|
31
|
+
export function registrations(root = REPO, hosts = HOSTS) {
|
|
32
|
+
const table = shimTable(root);
|
|
33
|
+
const out = [];
|
|
34
|
+
for (const host of hosts) {
|
|
35
|
+
const file = path.join(root, 'plugin', 'hooks', host === 'codex' ? 'codex-hooks.json' : 'hooks.json');
|
|
36
|
+
const hooks = JSON.parse(fs.readFileSync(file, 'utf8')).hooks || {};
|
|
37
|
+
for (const [event, groups] of Object.entries(hooks)) {
|
|
38
|
+
for (const g of groups) for (const h of g.hooks || []) {
|
|
39
|
+
const hookId = host === 'codex' ? codexDispatchIdIn(h.command) : shimIdIn(h.command);
|
|
40
|
+
const tail = (host === 'codex' ? /"\s+\d+\s+\S+\s*(.*)$/ : /hook-shim\.mjs["'`]?\s+\S+\s*(.*?)(?:\s*\|\|\s*true)?\s*$/).exec(h.command)?.[1]?.trim() || '';
|
|
41
|
+
const mode = table[hookId]?.mode || 'unknown';
|
|
42
|
+
const cap = HOST_CAP_SEC[host]?.[event];
|
|
43
|
+
out.push({ host, event, matcher: g.matcher ?? '*', command: h.command, timeoutSec: h.timeout, effectiveSec: cap ? Math.min(h.timeout, cap) : h.timeout,
|
|
44
|
+
hookId, args: tail, mode, body: table[hookId], label: `${host}/${event}/${hookId}${tail ? ` ${tail}` : ''}` });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ── fixtures (REAL host captures, tokenised) ─────────────────────────────────────────────────────
|
|
52
|
+
const FIXDIR = (root, host) => path.join(root, 'tests', 'fixtures', 'hook-payloads', host);
|
|
53
|
+
// MEASURED (grok 1.0.13, tests: scratch probe 4): a registered matcher written with Claude tool names fires on Grok's
|
|
54
|
+
// native `write` tool (matchers `Write` and the plugin's ^(Write|...)$ fired; the native spelling ^write$ did NOT), so Grok
|
|
55
|
+
// matches against the Claude-name alias while the payload keeps the native name. Only that one pair was measured.
|
|
56
|
+
const GROK_ALIAS = { write: 'Write' };
|
|
57
|
+
// Grok sends snake_case event names (`pre_tool_use`); the registry speaks Claude's PascalCase.
|
|
58
|
+
const pascal = (e) => String(e).split('_').map((x) => x[0].toUpperCase() + x.slice(1)).join('');
|
|
59
|
+
const eventOf = (host, p) => (host === 'grok' ? pascal(p.hook_event_name) : p.hook_event_name);
|
|
60
|
+
export function fixturesFor(reg, root = REPO) {
|
|
61
|
+
const dir = FIXDIR(root, reg.host);
|
|
62
|
+
const all = fs.readdirSync(dir).map((f) => ({ name: f.replace(/\.json$/, ''), ...JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')) }));
|
|
63
|
+
const ofEvent = all.filter((f) => eventOf(reg.host, f.payload) === reg.event);
|
|
64
|
+
if (!/ToolUse$/.test(reg.event) && ofEvent.length) return ofEvent;
|
|
65
|
+
// DERIVED (labelled) fixtures: an event or tool no capture exercised.
|
|
66
|
+
if (reg.event === 'PreCompact') {
|
|
67
|
+
const base = all.find((f) => eventOf(reg.host, f.payload) === 'SessionEnd');
|
|
68
|
+
return base ? [{ name: 'PreCompact-derived', _provenance: 'DERIVED from the captured SessionEnd payload + the documented PreCompact fields (trigger, custom_instructions); the host was not driven to compact', payload: { ...base.payload, hook_event_name: reg.host === 'grok' ? 'pre_compact' : 'PreCompact', trigger: 'manual', custom_instructions: '' } }] : [];
|
|
69
|
+
}
|
|
70
|
+
if (!/ToolUse$/.test(reg.event)) return [];
|
|
71
|
+
const re = new RegExp(reg.matcher);
|
|
72
|
+
const hits = ofEvent.filter((f) => re.test(reg.host === 'grok' ? (GROK_ALIAS[f.payload.tool_name] || f.payload.tool_name) : f.payload.tool_name));
|
|
73
|
+
if (hits.length) return hits;
|
|
74
|
+
const probe = 'mcp__ruvnet_brain__search_ruvnet';
|
|
75
|
+
if (re.test(probe) && ofEvent[0]) {
|
|
76
|
+
const p = { ...ofEvent[0].payload, tool_name: probe, tool_input: { query: 'what is ruflo' } };
|
|
77
|
+
if (reg.event === 'PostToolUse') p.tool_response = { content: [{ type: 'text', text: 'Searched 1 RuvNet repos (ruflo).\n#1 repo=ruflo\npath : ruflo/docs/x.md' }] };
|
|
78
|
+
return [{ name: `${reg.event}-derived-search_ruvnet`, _provenance: 'DERIVED from the captured shape of the same host/event; matcher selects a tool no capture exercised', payload: p }];
|
|
79
|
+
}
|
|
80
|
+
return [];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ── world: an isolated HOME + cwd + TMPDIR, and (for Codex) an installed wrapper + active generation ─
|
|
84
|
+
export function makeWorld(host, { root = REPO, label = 'qual' } = {}) {
|
|
85
|
+
const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), `hq-${label}-`)));
|
|
86
|
+
const w = { dir, home: path.join(dir, 'home'), cwd: path.join(dir, 'cwd'), tmp: path.join(dir, 'tmp'), host, root, n: 0 };
|
|
87
|
+
for (const d of [w.home, w.cwd, w.tmp]) fs.mkdirSync(d, { recursive: true });
|
|
88
|
+
spawnSync('git', ['init', '-q', '.'], { cwd: w.cwd, env: { ...process.env, HOME: w.home } });
|
|
89
|
+
const brain = path.join(w.home, '.cache', 'ruvnet-brain');
|
|
90
|
+
fs.mkdirSync(path.join(brain, 'versions'), { recursive: true });
|
|
91
|
+
fs.cpSync(path.join(root, 'plugin'), path.join(brain, 'versions', 'qual'), { recursive: true });
|
|
92
|
+
fs.writeFileSync(path.join(brain, 'active.json'), JSON.stringify({ generation: 'qual', version: 'qual', codeRoot: 'versions/qual' }));
|
|
93
|
+
if (host === 'codex') {
|
|
94
|
+
fs.copyFileSync(path.join(root, 'plugin', 'scripts', 'codex-hook-wrapper.mjs'), path.join(brain, 'codex-hook.mjs'));
|
|
95
|
+
fs.copyFileSync(path.join(root, 'plugin', 'scripts', 'development-maintenance.mjs'), path.join(brain, 'development-maintenance.mjs'));
|
|
96
|
+
}
|
|
97
|
+
return w;
|
|
98
|
+
}
|
|
99
|
+
export function worldEnv(w, extra = {}) {
|
|
100
|
+
const env = {};
|
|
101
|
+
for (const k of ['PATH', 'LANG', 'LC_ALL', 'SHELL', 'USER']) if (process.env[k] !== undefined) env[k] = process.env[k];
|
|
102
|
+
env.PATH = `${path.dirname(process.execPath)}${path.delimiter}${env.PATH || '/usr/bin:/bin'}`;
|
|
103
|
+
Object.assign(env, { HOME: w.home, USERPROFILE: w.home, TMPDIR: w.tmp, CLAUDE_PROJECT_DIR: w.cwd, RUVNET_BRAIN_HOME: path.join(w.home, '.cache', 'ruvnet-brain') });
|
|
104
|
+
if (w.host === 'codex') env.CODEX_HOME = path.join(w.home, '.codex');
|
|
105
|
+
else env.CLAUDE_PLUGIN_ROOT = path.join(w.root, 'plugin');
|
|
106
|
+
if (w.host === 'grok') env.GROK_PLUGIN_ROOT = path.join(w.root, 'plugin');
|
|
107
|
+
return Object.assign(env, extra);
|
|
108
|
+
}
|
|
109
|
+
export function cleanupWorld(w) {
|
|
110
|
+
try { spawnSync('chmod', ['-R', 'u+rwX', w.dir]); fs.rmSync(w.dir, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
111
|
+
}
|
|
112
|
+
function realise(fix, w) {
|
|
113
|
+
const sid = `qual-${process.pid}-${++w.n}`;
|
|
114
|
+
const transcript = path.join(w.home, '.claude', 'projects', 'p', `${sid}.jsonl`);
|
|
115
|
+
fs.mkdirSync(path.dirname(transcript), { recursive: true });
|
|
116
|
+
fs.writeFileSync(transcript, JSON.stringify({ type: 'user', message: { role: 'user', content: 'hi' } }) + '\n');
|
|
117
|
+
const s = JSON.stringify(fix.payload).split('{{TRANSCRIPT_DIR}}').join(transcript.replace(/\.jsonl$/, '')).split('{{TRANSCRIPT}}').join(transcript)
|
|
118
|
+
.split('{{ROOT}}').join(w.cwd).split('{{CWD}}').join(w.cwd).split('{{SESSION_ID}}').join(sid).split('{{PROMPT_ID}}').join('p1').split('{{TURN_ID}}').join('t1')
|
|
119
|
+
.split('{{TOOL_USE_ID}}').join('toolu_1').split('{{HOME}}').join(w.home);
|
|
120
|
+
const p = JSON.parse(s);
|
|
121
|
+
if (p.transcript_path === null) p.transcript_path = null;
|
|
122
|
+
return p;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ── the contract (the ORACLE) ────────────────────────────────────────────────────────────────────
|
|
126
|
+
const CLAUDE_TOP = new Set(['continue', 'stopReason', 'suppressOutput', 'systemMessage', 'decision', 'reason', 'hookSpecificOutput']);
|
|
127
|
+
const CLAUDE_SPECIFIC = {
|
|
128
|
+
SessionStart: ['additionalContext', 'initialUserMessage', 'sessionTitle', 'watchPaths', 'reloadSkills'],
|
|
129
|
+
UserPromptSubmit: ['additionalContext', 'sessionTitle'],
|
|
130
|
+
PreToolUse: ['permissionDecision', 'permissionDecisionReason', 'updatedInput', 'additionalContext'],
|
|
131
|
+
PostToolUse: ['additionalContext', 'updatedToolOutput', 'updatedMCPToolOutput'],
|
|
132
|
+
Stop: ['additionalContext'], PreCompact: [], SessionEnd: [],
|
|
133
|
+
};
|
|
134
|
+
// Codex: read out of the host binary's own `<event>.command.output` schemas (codex-hook-adapter.mjs header).
|
|
135
|
+
const CODEX_SPECIFIC = { SessionStart: ['additionalContext'], UserPromptSubmit: ['additionalContext'], PreToolUse: ['permissionDecision', 'permissionDecisionReason', 'additionalContext'],
|
|
136
|
+
PostToolUse: ['additionalContext', 'updatedToolOutput', 'updatedMCPToolOutput'] };
|
|
137
|
+
const CODEX_TOP = new Set(['continue', 'stopReason', 'suppressOutput', 'systemMessage', 'terminalSequence', 'decision', 'reason', 'hookSpecificOutput']);
|
|
138
|
+
|
|
139
|
+
/** Returns a list of contract violations for one stdout on one (host, event). Pure. */
|
|
140
|
+
export function checkStdout(host, event, stdout, { eventNameChecked = true } = {}) {
|
|
141
|
+
const errs = [];
|
|
142
|
+
const text = stdout.toString('utf8');
|
|
143
|
+
if (!text.trim()) return errs;
|
|
144
|
+
if (/\u0000/.test(text)) errs.push('stdout contains a NUL byte');
|
|
145
|
+
const codex = host === 'codex';
|
|
146
|
+
if (event === 'SessionEnd' || (codex && event === 'PreCompact')) return [`${event} must write nothing to stdout (host discards or rejects it), wrote ${stdout.length} bytes`];
|
|
147
|
+
// `[RuvNet Brain — ...]` banners are plain text, so only `{` (or a `[` that really parses) is JSON.
|
|
148
|
+
const looksJson = /^\s*\{/.test(text) || (/^\s*\[/.test(text) && (() => { try { JSON.parse(text); return true; } catch { return false; } })());
|
|
149
|
+
if (!looksJson) {
|
|
150
|
+
if (!codex && (event === 'SessionStart' || event === 'UserPromptSubmit')) return errs; // plain text is added as context
|
|
151
|
+
return [`stdout is plain text on ${host}/${event}, where the host requires a JSON envelope: ${JSON.stringify(text.slice(0, 60))}`];
|
|
152
|
+
}
|
|
153
|
+
let obj;
|
|
154
|
+
try { obj = JSON.parse(text); } catch (e) { return [`stdout is not ONE valid JSON value (${e.message.slice(0, 60)})`]; }
|
|
155
|
+
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return ['stdout JSON is not an object'];
|
|
156
|
+
const top = codex ? CODEX_TOP : CLAUDE_TOP;
|
|
157
|
+
for (const k of Object.keys(obj)) if (!top.has(k)) errs.push(`unknown top-level field "${k}" for ${host}`);
|
|
158
|
+
if (obj.decision !== undefined) {
|
|
159
|
+
if (obj.decision !== 'block' && !(host !== 'codex' && obj.decision === 'approve')) errs.push(`decision "${obj.decision}" is not accepted on ${host}`);
|
|
160
|
+
if (obj.decision === 'block' && !(typeof obj.reason === 'string' && obj.reason.trim())) errs.push('decision:block without a non-empty reason');
|
|
161
|
+
}
|
|
162
|
+
if (codex && event === 'Stop' && Object.keys(obj).some((k) => k !== 'decision' && k !== 'reason' && k !== 'continue' && k !== 'stopReason' && k !== 'systemMessage' && k !== 'suppressOutput')) errs.push('Codex Stop has no hookSpecificOutput');
|
|
163
|
+
if (obj.hookSpecificOutput !== undefined) {
|
|
164
|
+
const hs = obj.hookSpecificOutput;
|
|
165
|
+
const allowed = (codex ? CODEX_SPECIFIC : CLAUDE_SPECIFIC)[event];
|
|
166
|
+
if (!hs || typeof hs !== 'object' || Array.isArray(hs)) errs.push('hookSpecificOutput is not an object');
|
|
167
|
+
else {
|
|
168
|
+
if (!allowed) errs.push(`${event} has no hookSpecificOutput on ${host}`);
|
|
169
|
+
if (eventNameChecked && hs.hookEventName !== event) errs.push(`hookSpecificOutput.hookEventName ${JSON.stringify(hs.hookEventName)} != ${event}`);
|
|
170
|
+
for (const k of Object.keys(hs)) if (k !== 'hookEventName' && !(allowed || []).includes(k)) errs.push(`unknown hookSpecificOutput field "${k}" for ${host}/${event}`);
|
|
171
|
+
if (codex && hs.permissionDecision !== undefined && hs.permissionDecision !== 'deny') errs.push(`Codex accepts only permissionDecision:"deny", got "${hs.permissionDecision}"`);
|
|
172
|
+
if (hs.additionalContext !== undefined && typeof hs.additionalContext !== 'string') errs.push('additionalContext is not a string');
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return errs;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** All contract violations for one run. Pure — this is what the guard proofs break. */
|
|
179
|
+
export function judge(reg, caseName, r, { maxMs = null, mayBlock = false } = {}) {
|
|
180
|
+
const f = [];
|
|
181
|
+
const allowed = new Set([0]);
|
|
182
|
+
if (reg.mode === 'blocking' && mayBlock) allowed.add(2);
|
|
183
|
+
if (r.timedOut) f.push(`TIMEOUT: still running after ${r.ms}ms (registered ${reg.timeoutSec}s, effective ${reg.effectiveSec}s)`);
|
|
184
|
+
else if (r.signal) f.push(`killed by ${r.signal}`);
|
|
185
|
+
else if (!allowed.has(r.status)) f.push(`exit ${r.status} (allowed: ${[...allowed].join(',')}; a non-zero exit is shown to the user as a hook error)`);
|
|
186
|
+
if (r.stderr.length) f.push(`stderr not empty (${r.stderr.length} bytes): ${JSON.stringify(r.stderr.toString('utf8').slice(0, 120))}`);
|
|
187
|
+
if (!r.timedOut) f.push(...checkStdout(reg.host, reg.event, r.stdout, { eventNameChecked: caseName !== 'wrong-event-name' }));
|
|
188
|
+
const budget = maxMs ?? reg.effectiveSec * 1000 * TIMEOUT_MARGIN;
|
|
189
|
+
if (!r.timedOut && r.ms > budget) f.push(`SLOW: ${r.ms}ms > ${Math.round(budget)}ms (${TIMEOUT_MARGIN * 100}% of the ${reg.effectiveSec}s effective timeout)`);
|
|
190
|
+
return f;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Static contract checks that need no process. */
|
|
194
|
+
export function staticFindings(regs) {
|
|
195
|
+
const f = [];
|
|
196
|
+
for (const r of regs) {
|
|
197
|
+
const cap = HOST_CAP_SEC[r.host]?.[r.event];
|
|
198
|
+
if (cap && r.timeoutSec > cap) f.push({ reg: r, msg: `registers timeout ${r.timeoutSec}s but ${r.host} clamps ${r.event} to ${cap}s and prints a warning on every session ("clamping ${r.event} hook timeout to ${cap}s")` });
|
|
199
|
+
if (r.host === 'codex') {
|
|
200
|
+
const inline = Number(/"\s+(\d+)\s+/.exec(r.command)?.[1]);
|
|
201
|
+
if (inline && inline >= r.effectiveSec * 1000) f.push({ reg: r, msg: `inline budget ${inline}ms is not inside the ${r.effectiveSec}s effective host timeout` });
|
|
202
|
+
}
|
|
203
|
+
if (r.mode === 'unknown') f.push({ reg: r, msg: `hook id "${r.hookId}" is not in hook-shim.mjs's TABLE` });
|
|
204
|
+
}
|
|
205
|
+
return f;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ── process runner ───────────────────────────────────────────────────────────────────────────────
|
|
209
|
+
const sandboxOk = process.platform === 'darwin' && fs.existsSync('/usr/bin/sandbox-exec');
|
|
210
|
+
export const sandboxAvailable = () => sandboxOk;
|
|
211
|
+
export const NET_DENY = '(version 1)(allow default)(deny network*)';
|
|
212
|
+
export const writeProfile = (dir) => `(version 1)(allow default)(deny file-write* (require-all (require-not (subpath "${dir}")) (require-not (subpath "/dev"))) (with send-signal SIGKILL))`;
|
|
213
|
+
|
|
214
|
+
/** Run one command string through /bin/sh as a host would. */
|
|
215
|
+
export function runCommand(command, { cwd, env, stdin = '', stdinMode = 'pipe', timeoutMs, profile = null, argv = null }) {
|
|
216
|
+
return new Promise((resolve) => {
|
|
217
|
+
const [cmd, args] = profile ? ['/usr/bin/sandbox-exec', ['-p', profile, ...(argv || ['/bin/sh', '-c', command])]] : (argv ? [argv[0], argv.slice(1)] : ['/bin/sh', ['-c', command]]);
|
|
218
|
+
const t0 = Date.now();
|
|
219
|
+
const child = spawn(cmd, args, { cwd, env, stdio: [stdinMode === 'closed' ? 'ignore' : 'pipe', 'pipe', 'pipe'], detached: true });
|
|
220
|
+
const out = []; const err = []; let bytes = 0; let timedOut = false;
|
|
221
|
+
child.stdout.on('data', (d) => { if (bytes < 16e6) { out.push(d); bytes += d.length; } });
|
|
222
|
+
child.stderr.on('data', (d) => { if (bytes < 16e6) { err.push(d); bytes += d.length; } });
|
|
223
|
+
const timer = setTimeout(() => { timedOut = true; try { process.kill(-child.pid, 'SIGKILL'); } catch { child.kill('SIGKILL'); } }, timeoutMs);
|
|
224
|
+
if (child.stdin) {
|
|
225
|
+
child.stdin.on('error', () => {});
|
|
226
|
+
if (stdinMode === 'held') child.stdin.write(stdin); // one complete value, pipe left open
|
|
227
|
+
else child.stdin.end(stdin);
|
|
228
|
+
}
|
|
229
|
+
child.on('close', (status, signal) => {
|
|
230
|
+
clearTimeout(timer);
|
|
231
|
+
try { process.kill(-child.pid, 'SIGKILL'); } catch { /* group already gone */ }
|
|
232
|
+
resolve({ status, signal: timedOut ? null : signal, stdout: Buffer.concat(out), stderr: Buffer.concat(err), ms: Date.now() - t0, timedOut });
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const json = (o) => JSON.stringify(o);
|
|
238
|
+
const promptKey = (p) => ('prompt' in p ? 'prompt' : null);
|
|
239
|
+
function deadPid() { const r = spawnSync(process.execPath, ['-e', 'process.stdout.write(String(process.pid))']); return Number(r.stdout.toString()); }
|
|
240
|
+
function makeReadOnly(dir) {
|
|
241
|
+
const walk = (d) => { for (const e of fs.readdirSync(d, { withFileTypes: true })) { const p = path.join(d, e.name); if (e.isDirectory()) walk(p); else fs.chmodSync(p, 0o444); } fs.chmodSync(d, 0o555); };
|
|
242
|
+
walk(dir);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** The adverse-input matrix. `stdin(payload)` -> string|Buffer. */
|
|
246
|
+
export const CASES = [
|
|
247
|
+
{ name: 'baseline-real', stdin: (p) => json(p) },
|
|
248
|
+
{ name: 'empty-stdin', stdin: () => '' },
|
|
249
|
+
{ name: 'closed-stdin', stdinMode: 'closed' },
|
|
250
|
+
{ name: 'held-open-stdin', stdinMode: 'held', stdin: (p) => json(p) },
|
|
251
|
+
{ name: 'malformed-json', stdin: () => '{"hook_event_name": nope, "prompt": ' },
|
|
252
|
+
{ name: 'truncated-json', stdin: (p) => json(p).slice(0, Math.floor(json(p).length / 2)) },
|
|
253
|
+
{ name: '5mb-payload', stdin: (p) => json({ ...p, ...(promptKey(p) ? { prompt: `${p.prompt} ${'x'.repeat(5 * 1024 * 1024)}` } : { padding: 'x'.repeat(5 * 1024 * 1024) }) }) },
|
|
254
|
+
{ name: 'unicode-emoji', stdin: (p) => json({ ...p, ...(promptKey(p) ? { prompt: 'héllo 🚀 你好 ñ — ruflo agentdb ' } : { last_assistant_message: 'héllo 🚀 你好' }) }) },
|
|
255
|
+
{ name: 'nul-bytes', stdin: (p) => Buffer.concat([Buffer.from(json({ ...p, ...(promptKey(p) ? { prompt: 'a\u0000b ruflo' } : {}) })), Buffer.from([0]), Buffer.from('\n')]) },
|
|
256
|
+
{ name: 'missing-fields', stdin: () => '{}' },
|
|
257
|
+
{ name: 'only-event-name', stdin: (p) => json({ hook_event_name: p.hook_event_name }) },
|
|
258
|
+
{ name: 'wrong-types', stdin: (p) => json({ ...p, session_id: 123, prompt: null, cwd: {}, tool_input: 'x', transcript_path: 5, last_assistant_message: [] }) },
|
|
259
|
+
{ name: 'wrong-event-name', stdin: (p) => json({ ...p, hook_event_name: p.hook_event_name === 'Stop' ? 'SessionStart' : 'Stop' }) },
|
|
260
|
+
{ name: 'home-unset', stdin: (p) => json(p), env: (e) => { delete e.HOME; delete e.USERPROFILE; } },
|
|
261
|
+
{ name: 'home-readonly', stdin: (p) => json(p), pre: (w) => makeReadOnly(w.home) },
|
|
262
|
+
{ name: 'no-network', stdin: (p) => json(p), profile: NET_DENY, env: (e) => { e.HTTP_PROXY = e.HTTPS_PROXY = 'http://127.0.0.1:9'; e.NO_PROXY = ''; } },
|
|
263
|
+
{ name: 'dead-pid-lock', stdin: (p) => json(p), pre: (w) => {
|
|
264
|
+
const pid = deadPid(); const brain = path.join(w.home, '.cache', 'ruvnet-brain'); const at = new Date().toISOString();
|
|
265
|
+
fs.mkdirSync(brain, { recursive: true });
|
|
266
|
+
fs.writeFileSync(path.join(brain, 'auto-update.lock'), JSON.stringify({ pid, at }) + '\n');
|
|
267
|
+
fs.mkdirSync(path.join(brain, '.update.lock'), { recursive: true });
|
|
268
|
+
fs.writeFileSync(path.join(brain, '.update.lock', 'owner.json'), JSON.stringify({ pid, at }));
|
|
269
|
+
fs.writeFileSync(path.join(w.cwd, '.cooldown'), at);
|
|
270
|
+
} },
|
|
271
|
+
];
|
|
272
|
+
|
|
273
|
+
/** Run one (registration, fixture, case). World is fresh per call unless one is supplied. */
|
|
274
|
+
export async function runCase(reg, fix, c, { root = REPO, world = null, timeoutMs = null, maxMs = null } = {}) {
|
|
275
|
+
const w = world || makeWorld(reg.host, { root, label: c.name });
|
|
276
|
+
try {
|
|
277
|
+
const payload = realise(fix, w);
|
|
278
|
+
c.pre?.(w);
|
|
279
|
+
const env = worldEnv(w); c.env?.(env);
|
|
280
|
+
const stdin = c.stdin ? c.stdin(payload) : '';
|
|
281
|
+
const r = await runCommand(reg.command, { cwd: w.cwd, env, stdin, stdinMode: c.stdinMode || 'pipe', timeoutMs: timeoutMs ?? (reg.effectiveSec * 1000 + 4000),
|
|
282
|
+
profile: c.profile && sandboxOk ? c.profile : null });
|
|
283
|
+
return { reg, fixture: fix.name, case: c.name, skipped: c.profile && !sandboxOk ? 'sandbox-exec unavailable' : null, ...r, findings: judge(reg, c.name, r, { maxMs }) };
|
|
284
|
+
} finally { if (!world) cleanupWorld(w); }
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** 20 concurrent invocations of the same hook in one world. */
|
|
288
|
+
export async function runConcurrent(reg, fix, n = 20, opts = {}) {
|
|
289
|
+
const w = makeWorld(reg.host, { root: opts.root || REPO, label: 'conc' });
|
|
290
|
+
try {
|
|
291
|
+
const rs = await Promise.all(Array.from({ length: n }, () => runCase(reg, fix, { name: 'concurrent', stdin: (p) => json(p) }, { ...opts, world: w, timeoutMs: reg.effectiveSec * 1000 * 4 })));
|
|
292
|
+
// Concurrency is a LOAD test: the slow-path budget is checked against the registered timeout, not the idle margin.
|
|
293
|
+
for (const r of rs) r.findings = judge(reg, 'concurrent', r, { maxMs: reg.effectiveSec * 1000 });
|
|
294
|
+
return rs;
|
|
295
|
+
} finally { cleanupWorld(w); }
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** N sequential invocations; returns per-call ms and the on-disk growth of HOME. */
|
|
299
|
+
export async function runRepeat(reg, fix, n = 50, opts = {}) {
|
|
300
|
+
const w = makeWorld(reg.host, { root: opts.root || REPO, label: 'repeat' });
|
|
301
|
+
const size = () => { let files = 0; let bytes = 0; const walk = (d) => { for (const e of fs.readdirSync(d, { withFileTypes: true })) { const p = path.join(d, e.name); if (e.isDirectory()) walk(p); else { try { bytes += fs.statSync(p).size; files++; } catch { /* raced */ } } } }; walk(w.home); return { files, bytes }; };
|
|
302
|
+
try {
|
|
303
|
+
const before = size(); const rs = [];
|
|
304
|
+
for (let i = 0; i < n; i++) rs.push(await runCase(reg, fix, { name: 'repeat', stdin: (p) => json(p) }, { ...opts, world: w }));
|
|
305
|
+
const after = size();
|
|
306
|
+
return { runs: rs, growth: { files: after.files - before.files, bytes: after.bytes - before.bytes }, firstMs: rs[0].ms, lastMs: rs.at(-1).ms };
|
|
307
|
+
} finally { cleanupWorld(w); }
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Run the hook BODY directly under a write-containment sandbox; a write outside the world kills it. */
|
|
311
|
+
export async function auditWrites(reg, fix, { root = REPO } = {}) {
|
|
312
|
+
if (!sandboxOk) return { skipped: 'sandbox-exec unavailable on this platform' };
|
|
313
|
+
if (!reg.body?.file) return { skipped: 'no body in hook-shim TABLE' };
|
|
314
|
+
const w = makeWorld(reg.host, { root, label: 'wr' });
|
|
315
|
+
try {
|
|
316
|
+
const payload = realise(fix, w);
|
|
317
|
+
const env = worldEnv(w); if (reg.host === 'codex') env.RUVNET_HOOK_HOST = 'codex';
|
|
318
|
+
const file = path.join(root, 'plugin', 'scripts', reg.body.file);
|
|
319
|
+
const interp = reg.body.interpreter === 'node' ? process.execPath : '/bin/bash';
|
|
320
|
+
const extra = reg.args ? reg.args.split(/\s+/) : [];
|
|
321
|
+
const r = await runCommand('', { cwd: w.cwd, env, stdin: json(payload), timeoutMs: reg.effectiveSec * 1000 + 4000, profile: writeProfile(w.dir), argv: [interp, file, ...extra] });
|
|
322
|
+
return { violated: r.signal === 'SIGKILL', status: r.status, signal: r.signal, ms: r.ms };
|
|
323
|
+
} finally { cleanupWorld(w); }
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ── overload measurement ─────────────────────────────────────────────────────────────────────────
|
|
327
|
+
export const PROMPTS = ['ok', 'what is ruflo?', 'build a REST API service with agentdb memory', 'Fix the failing test in src/foo.test.ts',
|
|
328
|
+
'can ruflo do swarm consensus? check the architecture and plan the refactor'];
|
|
329
|
+
/** Bytes every SessionStart + UserPromptSubmit hook injects, per prompt, for one host. */
|
|
330
|
+
export async function measureInjection(host, { root = REPO } = {}) {
|
|
331
|
+
const regs = registrations(root, [host]).filter((r) => r.event === 'SessionStart' || r.event === 'UserPromptSubmit');
|
|
332
|
+
const rows = [];
|
|
333
|
+
for (const prompt of PROMPTS) {
|
|
334
|
+
const per = [];
|
|
335
|
+
for (const reg of regs) {
|
|
336
|
+
if (reg.event === 'SessionStart' && prompt !== PROMPTS[0]) continue;
|
|
337
|
+
const fix = fixturesFor(reg, root)[0];
|
|
338
|
+
const fx = reg.event === 'UserPromptSubmit' ? { ...fix, payload: { ...fix.payload, prompt } } : fix;
|
|
339
|
+
const r = await runCase(reg, fx, { name: 'measure', stdin: (p) => json(p) }, { root });
|
|
340
|
+
per.push({ hook: reg.label, bytes: r.stdout.length, ms: r.ms });
|
|
341
|
+
}
|
|
342
|
+
rows.push({ prompt, perHook: per, totalBytes: per.reduce((a, b) => a + b.bytes, 0) });
|
|
343
|
+
}
|
|
344
|
+
return rows;
|
|
345
|
+
}
|
|
346
|
+
export { matchedTools };
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// hook-qualify-hosts.mjs — LAYER 2: one short REAL turn per host binary, plugin hooks live, scanned for
|
|
2
|
+
// any hook error. Never loops; cwd is a throwaway git-init'ed temp dir so no real project state is
|
|
3
|
+
// touched. The host binaries use the owner's real HOME (auth lives there), so host-level state such as
|
|
4
|
+
// ~/.cache/ruvnet-brain is exercised exactly as in use — that is the point of the layer.
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { spawnSync } from 'node:child_process';
|
|
9
|
+
import { REPO } from '../plugin/scripts/hook-registry.mjs';
|
|
10
|
+
|
|
11
|
+
const BIN = {
|
|
12
|
+
claude: process.env.HOOK_QUALIFY_CLAUDE || path.join(os.homedir(), '.npm-global', 'bin', 'claude'),
|
|
13
|
+
codex: process.env.HOOK_QUALIFY_CODEX || path.join(os.homedir(), '.local', 'bin', 'codex'),
|
|
14
|
+
grok: process.env.HOOK_QUALIFY_GROK || path.join(os.homedir(), '.local', 'bin', 'grok'),
|
|
15
|
+
};
|
|
16
|
+
const PROMPT = 'Reply with exactly: ok';
|
|
17
|
+
|
|
18
|
+
export async function waitForLoad(maxLoad, waitMs, log = () => {}) {
|
|
19
|
+
const until = Date.now() + waitMs;
|
|
20
|
+
while (os.loadavg()[0] >= maxLoad) {
|
|
21
|
+
if (Date.now() > until) return false;
|
|
22
|
+
log(`load ${os.loadavg()[0].toFixed(0)} >= ${maxLoad}; waiting`);
|
|
23
|
+
await new Promise((r) => setTimeout(r, 10_000));
|
|
24
|
+
}
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
function scratch(name) {
|
|
28
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), `hq-l2-${name}-`));
|
|
29
|
+
const cwd = path.join(dir, 'cwd'); fs.mkdirSync(cwd);
|
|
30
|
+
spawnSync('git', ['init', '-q', '.'], { cwd });
|
|
31
|
+
return { dir, cwd };
|
|
32
|
+
}
|
|
33
|
+
const cleanEnv = () => { const e = { ...process.env }; for (const k of Object.keys(e)) if (/^CLAUDE_CODE_|^CLAUDECODE$|^CLAUDE_PID$/.test(k) && k !== 'CLAUDE_CODE_EXECPATH') delete e[k]; return e; };
|
|
34
|
+
const lines = (s) => String(s || '').split('\n').filter(Boolean);
|
|
35
|
+
const jsonl = (s) => lines(s).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
|
|
36
|
+
|
|
37
|
+
/** Pure: scan a real `claude -p --output-format stream-json --verbose --include-hook-events` run. */
|
|
38
|
+
export function scanClaude({ stdout, stderr, debug = '' }) {
|
|
39
|
+
const f = []; const ev = jsonl(stdout);
|
|
40
|
+
for (const o of ev) {
|
|
41
|
+
if (o.type !== 'system' || o.subtype !== 'hook_response') continue;
|
|
42
|
+
const who = `${o.hook_event}:${o.hook_name}`;
|
|
43
|
+
if (o.exit_code !== 0) f.push(`${who} exit ${o.exit_code} (${o.outcome})`);
|
|
44
|
+
else if (o.outcome && o.outcome !== 'success') f.push(`${who} outcome ${o.outcome}`);
|
|
45
|
+
if (o.stderr) f.push(`${who} wrote stderr: ${JSON.stringify(String(o.stderr).slice(0, 120))}`);
|
|
46
|
+
if (/^\s*\{/.test(o.stdout || '')) { try { JSON.parse(o.stdout); } catch { f.push(`${who} stdout is not valid JSON`); } }
|
|
47
|
+
}
|
|
48
|
+
if (stderr.trim()) f.push(`process stderr: ${JSON.stringify(stderr.trim().slice(0, 300))}`);
|
|
49
|
+
for (const l of lines(debug)) if (/\] (?:Hook|SessionEnd|SessionStart|PreToolUse|PostToolUse|Stop|UserPromptSubmit)\b.*(?:timed out|cancelled|failed|invalid)/i.test(l)) f.push(`debug: ${l.replace(/^\S+ \[DEBUG\] /, '').slice(0, 220)}`);
|
|
50
|
+
return { findings: [...new Set(f)], hookResponses: ev.filter((o) => o.subtype === 'hook_response').length };
|
|
51
|
+
}
|
|
52
|
+
/** Pure: scan a real `codex exec --json` run. */
|
|
53
|
+
export function scanCodex({ stdout, stderr }) {
|
|
54
|
+
const f = [];
|
|
55
|
+
for (const o of jsonl(stdout)) {
|
|
56
|
+
if (o.item?.type === 'error' || o.type === 'error') { const m = o.item?.message || o.message || ''; if (/hook|clamp/i.test(m)) f.push(`host reports: ${m.slice(0, 200)}`); }
|
|
57
|
+
}
|
|
58
|
+
for (const l of lines(stderr)) if (/hook/i.test(l)) f.push(`stderr: ${l.slice(0, 200)}`);
|
|
59
|
+
return { findings: [...new Set(f)], unrelatedStderr: lines(stderr).filter((l) => !/hook/i.test(l)).slice(0, 3) };
|
|
60
|
+
}
|
|
61
|
+
/** Pure: scan a real `grok -p --debug-file` run. */
|
|
62
|
+
export function scanGrok({ stdout, stderr, debug = '' }) {
|
|
63
|
+
const f = []; let loaded = null; const pluginHooks = /plugin discovered name=ruvnet-brain .*has_hooks=true/.test(debug);
|
|
64
|
+
for (const l of lines(debug)) {
|
|
65
|
+
const m = /loaded hooks hook_count=(\d+)/.exec(l); if (m) loaded = Number(m[1]);
|
|
66
|
+
if (/xai_grok_hooks/.test(l) && /\b(WARN|ERROR)\b/.test(l)) f.push(`grok hooks: ${l.replace(/^\S+\s+/, '').slice(0, 240)}`);
|
|
67
|
+
if (/hook (failed|timed out|error)|hook_error|hook.*(exit code|invalid)/i.test(l) && /xai_grok_hooks|dispatcher/.test(l)) f.push(`grok hooks: ${l.replace(/^\S+\s+/, '').slice(0, 240)}`);
|
|
68
|
+
}
|
|
69
|
+
if (stderr.trim()) f.push(`process stderr: ${JSON.stringify(stderr.trim().slice(0, 300))}`);
|
|
70
|
+
if (/hook/i.test(stdout) && /(error|failed)/i.test(stdout.match(/.{0,80}hook.{0,120}/i)?.[0] || '')) f.push('hook error text in the output stream');
|
|
71
|
+
return { findings: [...new Set(f)], hooksLoaded: loaded, pluginHasHooks: pluginHooks, pluginHooksRan: /hook_name=plugin|hook_name=ruvnet/.test(debug) };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function runClaude(root, dbgDir) {
|
|
75
|
+
const { dir, cwd } = scratch('claude'); const debugFile = path.join(dbgDir, 'claude-debug.log');
|
|
76
|
+
const r = spawnSync(BIN.claude, ['-p', PROMPT, '--output-format', 'stream-json', '--verbose', '--include-hook-events', '--no-session-persistence',
|
|
77
|
+
'--setting-sources', 'project', '--plugin-dir', path.join(root, 'plugin'), '--debug-file', debugFile, '--max-turns', '2'],
|
|
78
|
+
{ cwd, env: cleanEnv(), encoding: 'utf8', timeout: 240_000, maxBuffer: 64e6 });
|
|
79
|
+
const debug = fs.existsSync(debugFile) ? fs.readFileSync(debugFile, 'utf8') : '';
|
|
80
|
+
const s = scanClaude({ stdout: r.stdout || '', stderr: r.stderr || '', debug });
|
|
81
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
82
|
+
return { status: r.status === 0 && !s.findings.length ? 'PASS' : 'FAIL', exit: r.status, ...s, note: 'plugin loaded from this checkout via --plugin-dir; user/local settings not loaded' };
|
|
83
|
+
}
|
|
84
|
+
async function runCodex(root, dbgDir) {
|
|
85
|
+
const { dir, cwd } = scratch('codex');
|
|
86
|
+
const r = spawnSync(BIN.codex, ['exec', '--json', '--ephemeral', '--skip-git-repo-check', '-C', cwd, PROMPT], { cwd, env: process.env, encoding: 'utf8', timeout: 240_000, maxBuffer: 64e6 });
|
|
87
|
+
fs.writeFileSync(path.join(dbgDir, 'codex-out.jsonl'), r.stdout || '');
|
|
88
|
+
const s = scanCodex({ stdout: r.stdout || '', stderr: r.stderr || '' });
|
|
89
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
90
|
+
return { status: r.status === 0 && !s.findings.length ? 'PASS' : 'FAIL', exit: r.status, ...s, note: 'runs the hooks INSTALLED in ~/.codex (the owner\'s real configuration), not this checkout' };
|
|
91
|
+
}
|
|
92
|
+
async function runGrok(root, dbgDir) {
|
|
93
|
+
const { dir, cwd } = scratch('grok'); const debugFile = path.join(dbgDir, 'grok-debug.log');
|
|
94
|
+
const r = spawnSync(BIN.grok, ['-p', PROMPT, '--output-format', 'streaming-json', '--debug-file', debugFile, '--always-approve', '--max-turns', '2', '--cwd', cwd],
|
|
95
|
+
{ cwd, env: process.env, encoding: 'utf8', timeout: 240_000, maxBuffer: 64e6 });
|
|
96
|
+
const blob = `${r.stdout || ''}${r.stderr || ''}`;
|
|
97
|
+
if (/not signed in|grok login/i.test(blob)) { fs.rmSync(dir, { recursive: true, force: true }); return { status: 'BLOCKED', reason: 'Grok is not signed in (owner action: grok login --device-code)' }; }
|
|
98
|
+
const debug = fs.existsSync(debugFile) ? fs.readFileSync(debugFile, 'utf8') : '';
|
|
99
|
+
const s = scanGrok({ stdout: r.stdout || '', stderr: r.stderr || '', debug });
|
|
100
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
101
|
+
return { status: r.status === 0 && !s.findings.length ? 'PASS' : 'FAIL', exit: r.status, ...s, note: 'throwaway cwd is untrusted by Grok, so project/plugin hook loading there is part of what is measured' };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function layer2(hosts, { maxLoad = 40, waitMs = 15 * 60_000, log = () => {}, root = REPO } = {}) {
|
|
105
|
+
const dbgDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hq-l2-logs-'));
|
|
106
|
+
const out = [];
|
|
107
|
+
for (const host of hosts) {
|
|
108
|
+
if (!(await waitForLoad(maxLoad, waitMs, log))) { out.push({ host, status: 'BLOCKED', reason: `machine load stayed >= ${maxLoad}` }); continue; }
|
|
109
|
+
if (!fs.existsSync(BIN[host])) { out.push({ host, status: 'BLOCKED', reason: `${BIN[host]} not found` }); continue; }
|
|
110
|
+
const fn = { claude: runClaude, codex: runCodex, grok: runGrok }[host];
|
|
111
|
+
const t0 = Date.now();
|
|
112
|
+
out.push({ host, ...(await fn(root, dbgDir)), ms: Date.now() - t0, logs: dbgDir });
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// hook-qualify.mjs — qualify every registered hook against the host contract it runs under.
|
|
3
|
+
//
|
|
4
|
+
// (defaults: --repeat 10 --concurrent 2 so a busy machine is not hammered; the owner's full ask is --repeat 50 --concurrent 20)
|
|
5
|
+
// node scripts/hook-qualify.mjs Layer 1 for claude+codex+grok (offline, deterministic)
|
|
6
|
+
// node scripts/hook-qualify.mjs --layer2 claude,codex,grok real-host smoke, ONE turn each
|
|
7
|
+
// node scripts/hook-qualify.mjs --hosts codex --cases baseline-real,empty-stdin --repeat 5 --concurrent 5
|
|
8
|
+
// node scripts/hook-qualify.mjs --measure bytes injected per prompt by SessionStart+UserPromptSubmit
|
|
9
|
+
// node scripts/hook-qualify.mjs --json out.json machine-readable matrix
|
|
10
|
+
//
|
|
11
|
+
// Exit 0 only when every row is PASS. A qualification that cannot run (no sandbox, host not signed in)
|
|
12
|
+
// is reported as SKIP/BLOCKED with the reason — never as PASS.
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import os from 'node:os';
|
|
15
|
+
import { pathToFileURL } from 'node:url';
|
|
16
|
+
import {
|
|
17
|
+
CASES, HOSTS, auditWrites, fixturesFor, measureInjection, registrations, runCase, runConcurrent, runRepeat,
|
|
18
|
+
sandboxAvailable, staticFindings,
|
|
19
|
+
} from './hook-qualify-core.mjs';
|
|
20
|
+
|
|
21
|
+
function arg(name, dflt = null) {
|
|
22
|
+
const i = process.argv.indexOf(`--${name}`);
|
|
23
|
+
if (i < 0) return dflt;
|
|
24
|
+
const v = process.argv[i + 1];
|
|
25
|
+
return v === undefined || v.startsWith('--') ? true : v;
|
|
26
|
+
}
|
|
27
|
+
const pct = (xs, p) => { const s = [...xs].sort((a, b) => a - b); return s[Math.min(s.length - 1, Math.floor(s.length * p))] ?? 0; };
|
|
28
|
+
|
|
29
|
+
export async function layer1({ hosts = HOSTS, cases = null, repeat = 50, concurrent = 20, log = () => {} } = {}) {
|
|
30
|
+
const regs = registrations(undefined, hosts);
|
|
31
|
+
const rows = [];
|
|
32
|
+
for (const f of staticFindings(regs)) rows.push({ reg: f.reg, kind: 'static', pass: false, findings: [f.msg] });
|
|
33
|
+
const selected = CASES.filter((c) => !cases || cases.includes(c.name));
|
|
34
|
+
for (const reg of regs) {
|
|
35
|
+
const fixes = fixturesFor(reg);
|
|
36
|
+
if (!fixes.length) { rows.push({ reg, kind: 'fixture', pass: false, findings: [`no captured payload for ${reg.host}/${reg.event} matcher ${reg.matcher}`] }); continue; }
|
|
37
|
+
const fix = fixes[0];
|
|
38
|
+
log(` ${reg.label}`);
|
|
39
|
+
const runs = [];
|
|
40
|
+
for (const c of selected) runs.push(await runCase(reg, fix, c));
|
|
41
|
+
const failed = runs.filter((r) => r.findings.length);
|
|
42
|
+
const base = runs.find((r) => r.case === 'baseline-real');
|
|
43
|
+
const row = { reg, kind: 'matrix', fixture: fix.name, provenance: fix._provenance, cases: runs.length, failed: failed.length,
|
|
44
|
+
skipped: runs.filter((r) => r.skipped).map((r) => r.case), p50: pct(runs.map((r) => r.ms), 0.5), max: Math.max(...runs.map((r) => r.ms)),
|
|
45
|
+
baselineMs: base?.ms, baselineBytes: base?.stdout.length, failures: failed.map((r) => ({ case: r.case, findings: r.findings })) };
|
|
46
|
+
if (repeat && (!cases || cases.includes('repeat'))) { const rp = await runRepeat(reg, fix, repeat); row.repeat = { n: repeat, growth: rp.growth, firstMs: rp.firstMs, lastMs: rp.lastMs, p95: pct(rp.runs.map((r) => r.ms), 0.95), failed: rp.runs.filter((r) => r.findings.length).length, sample: rp.runs.find((r) => r.findings.length)?.findings }; }
|
|
47
|
+
if (concurrent && (!cases || cases.includes('concurrent'))) { const cr = await runConcurrent(reg, fix, concurrent); row.concurrent = { n: concurrent, maxMs: Math.max(...cr.map((r) => r.ms)), failed: cr.filter((r) => r.findings.length).length, sample: cr.find((r) => r.findings.length)?.findings }; }
|
|
48
|
+
if (!cases || cases.includes('write-audit')) row.writes = await auditWrites(reg, fix);
|
|
49
|
+
row.pass = !failed.length && !(row.repeat?.failed) && !(row.concurrent?.failed) && !row.writes?.violated;
|
|
50
|
+
rows.push(row);
|
|
51
|
+
}
|
|
52
|
+
return rows;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function render(rows) {
|
|
56
|
+
const out = [];
|
|
57
|
+
out.push('HOST EVENT HOOK RESULT cases base(ms) p50/max(ms) timeout(s) bytes x20max growth(50x) writes');
|
|
58
|
+
for (const r of rows) {
|
|
59
|
+
if (r.kind !== 'matrix') { out.push(`${r.reg.host.padEnd(6)} ${r.reg.event.padEnd(17)} ${r.reg.label.split('/').slice(2).join('/').padEnd(29)} FAIL ${r.kind}: ${r.findings[0]}`); continue; }
|
|
60
|
+
const g = r.repeat ? `${r.repeat.growth.files}f/${r.repeat.growth.bytes}B` : '-';
|
|
61
|
+
const w = r.writes?.skipped ? `SKIP(${r.writes.skipped.slice(0, 18)})` : r.writes ? (r.writes.violated ? 'VIOLATION' : 'ok') : '-';
|
|
62
|
+
out.push(`${r.reg.host.padEnd(6)} ${r.reg.event.padEnd(17)} ${r.reg.label.split('/').slice(2).join('/').padEnd(29)} ${(r.pass ? 'PASS' : 'FAIL').padEnd(7)} ${String(r.cases - r.failed).padStart(2)}/${String(r.cases).padEnd(3)} ${String(r.baselineMs).padStart(7)} ${`${r.p50}/${r.max}`.padStart(12)} ${String(r.reg.effectiveSec).padStart(6)}/${r.reg.timeoutSec} ${String(r.baselineBytes).padStart(5)} ${String(r.concurrent?.maxMs ?? '-').padStart(6)} ${g.padEnd(13)} ${w}`);
|
|
63
|
+
}
|
|
64
|
+
const bad = rows.filter((r) => !r.pass);
|
|
65
|
+
if (bad.length) out.push('', 'FAILURES');
|
|
66
|
+
for (const r of bad) {
|
|
67
|
+
out.push(`- ${r.reg.label}`);
|
|
68
|
+
for (const f of r.findings || []) out.push(` ${f}`);
|
|
69
|
+
for (const f of r.failures || []) out.push(` [${f.case}] ${f.findings.join(' | ')}`);
|
|
70
|
+
if (r.repeat?.failed) out.push(` [repeat x${r.repeat.n}] ${r.repeat.failed} failing: ${(r.repeat.sample || []).join(' | ')}`);
|
|
71
|
+
if (r.concurrent?.failed) out.push(` [concurrent x${r.concurrent.n}] ${r.concurrent.failed} failing: ${(r.concurrent.sample || []).join(' | ')}`);
|
|
72
|
+
if (r.writes?.violated) out.push(' [write-audit] wrote outside the isolated HOME/cwd (killed by the sandbox)');
|
|
73
|
+
}
|
|
74
|
+
return out.join('\n');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function main() {
|
|
78
|
+
const hosts = String(arg('hosts', HOSTS.join(','))).split(',');
|
|
79
|
+
const cases = arg('cases') ? String(arg('cases')).split(',') : null;
|
|
80
|
+
if (arg('measure')) {
|
|
81
|
+
for (const h of hosts.filter((x) => x !== 'grok')) {
|
|
82
|
+
console.log(`== bytes injected by SessionStart + UserPromptSubmit hooks (${h}) ==`);
|
|
83
|
+
for (const row of await measureInjection(h)) console.log(`${String(row.totalBytes).padStart(6)} B ${JSON.stringify(row.prompt)} ${row.perHook.map((p) => `${p.hook.split('/').slice(1).join('/')}=${p.bytes}`).join(' ')}`);
|
|
84
|
+
}
|
|
85
|
+
return 0;
|
|
86
|
+
}
|
|
87
|
+
if (arg('layer2')) {
|
|
88
|
+
const { layer2 } = await import('./hook-qualify-hosts.mjs');
|
|
89
|
+
const res = await layer2(String(arg('layer2')).split(','), { maxLoad: Number(arg('max-load', 40)), log: console.log });
|
|
90
|
+
console.log(JSON.stringify(res, null, 1));
|
|
91
|
+
return res.every((r) => r.status === 'PASS') ? 0 : 1;
|
|
92
|
+
}
|
|
93
|
+
if (!sandboxAvailable()) console.log('NOTE: sandbox-exec unavailable — the no-network and write-containment guards are SKIPPED, not passed.');
|
|
94
|
+
console.log(`load ${os.loadavg().map((x) => x.toFixed(1)).join(' ')} (timing assertions assume an idle machine)`);
|
|
95
|
+
const rows = await layer1({ hosts, cases, repeat: Number(arg('repeat', 10)), concurrent: Number(arg('concurrent', 2)), log: console.error });
|
|
96
|
+
console.log(render(rows));
|
|
97
|
+
if (arg('json')) fs.writeFileSync(String(arg('json')), JSON.stringify(rows, (k, v) => (k === 'body' ? undefined : v), 1));
|
|
98
|
+
return rows.every((r) => r.pass) ? 0 : 1;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main().then((c) => process.exit(c), (e) => { console.error(e); process.exit(2); });
|