ruvnet-brain 4.0.35 → 4.0.90-dev
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 +4 -4
- package/bin/install.mjs +283 -23
- package/data/model-catalog.json +104 -15
- package/package.json +2 -1
- package/plugin/.claude-plugin/plugin.json +2 -2
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/commands/brain-console.md +72 -9
- package/plugin/commands/configure.md +67 -21
- package/plugin/commands/rvcb.md +72 -9
- package/plugin/hooks/codex-hooks.json +40 -33
- package/plugin/hooks/hook-contracts.json +14 -24
- package/plugin/hooks/hooks.json +7 -42
- package/plugin/mcp/server.mjs +23 -6
- package/plugin/scripts/adr-currency-gate.mjs +150 -0
- package/plugin/scripts/capability-registry.mjs +10 -1
- package/plugin/scripts/codex-hook-adapter.mjs +121 -19
- package/plugin/scripts/codex-hook-wrapper.mjs +61 -4
- package/plugin/scripts/continuation-gate.mjs +148 -8
- package/plugin/scripts/decision-gate.mjs +428 -0
- package/plugin/scripts/decision-outcomes.mjs +0 -0
- package/plugin/scripts/degradation-watch.mjs +271 -0
- package/plugin/scripts/ground-ruvnet.sh +51 -11
- package/plugin/scripts/hijack-ruvnet.sh +11 -3
- package/plugin/scripts/hook-registry.mjs +48 -3
- package/plugin/scripts/hook-shim.mjs +58 -3
- package/plugin/scripts/identifier-preflight.mjs +134 -0
- package/plugin/scripts/learn-capture.sh +50 -1
- package/plugin/scripts/learn-flush.mjs +5 -5
- package/plugin/scripts/lesson-bridge.mjs +343 -0
- package/plugin/scripts/lesson-hooks.sh +26 -0
- package/plugin/scripts/lesson-promote.mjs +50 -0
- package/plugin/scripts/lesson-store.mjs +6 -1
- package/plugin/scripts/mcp-readiness.mjs +107 -0
- package/plugin/scripts/protect-brain-state.sh +9 -0
- package/plugin/scripts/runtime-preferences.mjs +40 -0
- package/plugin/scripts/session-snapshot-hook.mjs +15 -6
- package/plugin/scripts/spend-guard.mjs +125 -0
- package/plugin/scripts/unprompted-runtime.mjs +12 -2
- package/plugin/scripts/update-apply.mjs +7 -2
- package/plugin/skills/ruvnet-brain/PLAYBOOK.md +20 -5
- package/plugin/skills/ruvnet-brain/SKILL.md +3 -3
- package/scripts/brain-score.mjs +252 -0
- package/scripts/brain-stamp.mjs +5 -1
- package/scripts/build-bundle.mjs +25 -1
- package/scripts/console-engine.mjs +1 -1
- package/scripts/health-repair.mjs +11 -2
- package/scripts/host-install-matrix.mjs +21 -0
- package/scripts/ingest-repo.mjs +66 -6
- package/scripts/learning-replay-cli.mjs +8 -3
- package/scripts/learning-replay-fixture.mjs +25 -6
- package/scripts/learning-replay-proof.mjs +38 -0
- package/scripts/nightly-wrapper.sh +13 -0
- package/scripts/onboarding-console.mjs +21 -1
- package/scripts/org-repo-count.mjs +119 -0
- package/scripts/publication-receipt.mjs +7 -3
- package/scripts/repo-count-detector.mjs +62 -0
- package/scripts/restore-local-ingests.mjs +116 -0
- package/scripts/selfcheck.mjs +9 -1
- package/scripts/stabilization-receipt.mjs +11 -1
- package/scripts/sync-census.mjs +0 -0
- package/scripts/sync-commands.mjs +117 -0
|
@@ -70,6 +70,97 @@ function projectKey() {
|
|
|
70
70
|
const LEDGER = process.env.RUVNET_WORK_LEDGER
|
|
71
71
|
|| path.join(HOME, '.config', 'ruvnet-brain', 'work-ledgers', `${projectKey()}.json`);
|
|
72
72
|
|
|
73
|
+
/**
|
|
74
|
+
* THE SAME PARTITION, APPLIED TO THE DERIVED SOURCES — added 2026-08-14 after this file was caught
|
|
75
|
+
* doing exactly what projectKey() above exists to prevent, one layer down.
|
|
76
|
+
*
|
|
77
|
+
* The ledger has been partitioned per project since day one, for the reason stated above: a
|
|
78
|
+
* commitment made in one repo must never fire in another. Every artifact-derived source added later
|
|
79
|
+
* (issues, red CI, open PRs, security alerts) read a MACHINE-GLOBAL file under ~/.cache/ruvnet-brain
|
|
80
|
+
* with no such partition. So the explicit half was scoped and the derived half was not, and the
|
|
81
|
+
* derived half is the half that is always populated.
|
|
82
|
+
*
|
|
83
|
+
* MEASURED, not supposed. Firing Stop in a fresh git repo whose remote is
|
|
84
|
+
* `someone-else/totally-unrelated`, with an EMPTY ledger:
|
|
85
|
+
* {"hookSpecificOutput":{"hookEventName":"Stop","additionalContext":"You have unfinished work you
|
|
86
|
+
* committed to. … ☐ PR #137 on stuinfla/ruvnet-brain is RED …"}}
|
|
87
|
+
* `additionalContext` at Stop CONTINUES THE TURN, so a stranger's every turn-end was being forced
|
|
88
|
+
* with orders to go fix another repository's pull requests. The forensic trail was already on this
|
|
89
|
+
* machine before anyone looked: ~/.config/ruvnet-brain/work-ledgers/AppealArmor.json holds ZERO
|
|
90
|
+
* items, yet AppealArmor.json.cooldown exists — and that lock is written ONLY on the path that
|
|
91
|
+
* forces. The gate forced a continuation in AppealArmor on 2026-08-13T21:39Z with nothing of
|
|
92
|
+
* AppealArmor's to say. Same for Ruv-Explainer, T, verify-prod and notgit.
|
|
93
|
+
*
|
|
94
|
+
* THE RULE: a derived item may only ever speak about a repository THIS working tree points at.
|
|
95
|
+
* Ownership is read from the git remotes, which is the only durable statement of "which repo is
|
|
96
|
+
* this" available at a Stop boundary — no network, no `gh`, no spawn.
|
|
97
|
+
*
|
|
98
|
+
* FAIL CLOSED ON SCOPE, FAIL OPEN ON BEHAVIOUR. Not a git repo, no remotes, unreadable config →
|
|
99
|
+
* we cannot confirm ownership, so the derived sources contribute NOTHING and the gate exits 0 in
|
|
100
|
+
* silence. It never blocks, never errors, never speaks about a repo it cannot prove is ours. The
|
|
101
|
+
* work LEDGER is untouched by all of this: a real commitment recorded in this project still forces
|
|
102
|
+
* exactly as before, which is the one behaviour that must never be weakened.
|
|
103
|
+
*/
|
|
104
|
+
function gitConfigPath(start) {
|
|
105
|
+
let dir = start;
|
|
106
|
+
for (let i = 0; i < 12; i++) {
|
|
107
|
+
const dot = path.join(dir, '.git');
|
|
108
|
+
try {
|
|
109
|
+
const st = fs.statSync(dot);
|
|
110
|
+
if (st.isDirectory()) return path.join(dot, 'config');
|
|
111
|
+
if (st.isFile()) {
|
|
112
|
+
// A linked worktree / submodule: `.git` is a file naming the real gitdir. The remotes live
|
|
113
|
+
// in the COMMON dir (…/.git), not in …/.git/worktrees/<name>, so follow `commondir` when
|
|
114
|
+
// it is present. Getting this wrong would silently return "owns nothing" in a worktree.
|
|
115
|
+
const m = /^gitdir:\s*(.+)$/m.exec(fs.readFileSync(dot, 'utf8'));
|
|
116
|
+
if (!m) return null;
|
|
117
|
+
const gitdir = path.resolve(dir, m[1].trim());
|
|
118
|
+
try {
|
|
119
|
+
const common = fs.readFileSync(path.join(gitdir, 'commondir'), 'utf8').trim();
|
|
120
|
+
return path.join(path.resolve(gitdir, common), 'config');
|
|
121
|
+
} catch { return path.join(gitdir, 'config'); }
|
|
122
|
+
}
|
|
123
|
+
} catch { /* no .git here — keep walking up */ }
|
|
124
|
+
const up = path.dirname(dir);
|
|
125
|
+
if (up === dir) return null;
|
|
126
|
+
dir = up;
|
|
127
|
+
}
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** `https://github.com/Owner/Name.git`, `git@github.com:Owner/Name`, `Owner/Name` → `owner/name`. */
|
|
132
|
+
function slugOf(raw) {
|
|
133
|
+
const s = String(raw ?? '').trim().replace(/\/+$/, '').replace(/\.git$/i, '');
|
|
134
|
+
if (!s) return null;
|
|
135
|
+
const tail = s
|
|
136
|
+
.replace(/^[a-z][a-z0-9+.-]*:\/\/[^/]+\//i, '') // scheme://host/
|
|
137
|
+
.replace(/^[^/@]+@[^:/]+:/, ''); // user@host:
|
|
138
|
+
const parts = tail.split('/').filter(Boolean);
|
|
139
|
+
return parts.length >= 2 ? parts.slice(-2).join('/').toLowerCase() : null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function ownedRepoSlugs() {
|
|
143
|
+
const owned = new Set();
|
|
144
|
+
try {
|
|
145
|
+
const cfg = gitConfigPath(process.cwd());
|
|
146
|
+
if (!cfg) return owned; // no git → owns nothing → derived sources mute
|
|
147
|
+
const text = fs.readFileSync(cfg, 'utf8');
|
|
148
|
+
// Every remote, not just origin: a fork legitimately owns both its origin and its upstream, and
|
|
149
|
+
// the artifacts name the upstream slug.
|
|
150
|
+
for (const m of text.matchAll(/^\s*url\s*=\s*(.+)$/gm)) {
|
|
151
|
+
const slug = slugOf(m[1]);
|
|
152
|
+
if (slug) owned.add(slug);
|
|
153
|
+
}
|
|
154
|
+
} catch { /* unreadable config → owns nothing */ }
|
|
155
|
+
return owned;
|
|
156
|
+
}
|
|
157
|
+
const OWNED_REPOS = ownedRepoSlugs();
|
|
158
|
+
/** True only when `repo` names a repository THIS working tree actually points at. */
|
|
159
|
+
const ownsRepo = (repo) => {
|
|
160
|
+
const slug = slugOf(repo);
|
|
161
|
+
return Boolean(slug) && OWNED_REPOS.has(slug);
|
|
162
|
+
};
|
|
163
|
+
|
|
73
164
|
const argv = process.argv.slice(2);
|
|
74
165
|
const arg = (f) => { const i = argv.indexOf(f); return i >= 0 && argv[i + 1] ? argv[i + 1] : null; };
|
|
75
166
|
const has = (f) => argv.includes(f);
|
|
@@ -187,6 +278,7 @@ function artifactOpenWork() {
|
|
|
187
278
|
const file = process.env.RUVNET_OPEN_ISSUES_FILE
|
|
188
279
|
|| path.join(HOME, '.cache', 'ruvnet-brain', 'open-issues.json');
|
|
189
280
|
const status = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
281
|
+
if (!ownsRepo(status?.repo)) return []; // another repo's backlog is not this project's work
|
|
190
282
|
const observedAt = Date.parse(status?.at || '');
|
|
191
283
|
if (!Number.isFinite(observedAt) || nowMs - observedAt > 6 * 3600_000) return [];
|
|
192
284
|
return (Array.isArray(status.issues) ? status.issues : [])
|
|
@@ -221,6 +313,9 @@ function redCiOpenWork() {
|
|
|
221
313
|
const status = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
222
314
|
const seen = new Set();
|
|
223
315
|
return Object.values(status || {})
|
|
316
|
+
// Per ENTRY, not per file: this artifact genuinely mixes repositories (measured on this
|
|
317
|
+
// machine — stuinfla/ruvnet-brain and stuinfla/AppealArmor rows sit side by side in it).
|
|
318
|
+
.filter((d) => ownsRepo(d?.repo))
|
|
224
319
|
.filter((d) => d?.state === 'resolved' && d.conclusion && d.conclusion !== 'success')
|
|
225
320
|
.filter((d) => {
|
|
226
321
|
const at = Date.parse(d.checkedAt || '');
|
|
@@ -267,6 +362,7 @@ function openPrWork() {
|
|
|
267
362
|
const file = process.env.RUVNET_OPEN_ISSUES_FILE
|
|
268
363
|
|| path.join(HOME, '.cache', 'ruvnet-brain', 'open-issues.json');
|
|
269
364
|
const status = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
365
|
+
if (!ownsRepo(status?.repo)) return []; // never send a stranger to merge our pull requests
|
|
270
366
|
const observedAt = Date.parse(status?.at || '');
|
|
271
367
|
if (!Number.isFinite(observedAt) || nowMs - observedAt > 6 * 3600_000) return [];
|
|
272
368
|
return (Array.isArray(status.prs) ? status.prs : [])
|
|
@@ -306,6 +402,7 @@ function securityAlertWork() {
|
|
|
306
402
|
const file = process.env.RUVNET_OPEN_ISSUES_FILE
|
|
307
403
|
|| path.join(HOME, '.cache', 'ruvnet-brain', 'open-issues.json');
|
|
308
404
|
const status = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
405
|
+
if (!ownsRepo(status?.repo)) return []; // GitHub emailed the OWNER of that repo, not this one
|
|
309
406
|
const observedAt = Date.parse(status?.at || '');
|
|
310
407
|
if (!Number.isFinite(observedAt) || nowMs - observedAt > 6 * 3600_000) return [];
|
|
311
408
|
const alerts = (Array.isArray(status.securityAlerts) ? status.securityAlerts : [])
|
|
@@ -362,12 +459,20 @@ if (!open.length) process.exit(EXIT_ALLOW); // nothing outstanding: silence is
|
|
|
362
459
|
const forceable = open.filter((i) => Number.isFinite(Date.parse(i.at)));
|
|
363
460
|
if (!forceable.length) process.exit(EXIT_ALLOW);
|
|
364
461
|
|
|
365
|
-
/**
|
|
462
|
+
/**
|
|
463
|
+
* Age, only ever used to LABEL an item — never to suppress one. See the note above.
|
|
464
|
+
*
|
|
465
|
+
* The VERB is part of the honesty, not decoration. A ledger row was `committed` to — the model
|
|
466
|
+
* agreed to it, and that agreement is what makes stopping a defect. A derived row was `observed`:
|
|
467
|
+
* its `at` is when the watcher last looked at GitHub, and nobody promised anything. Printing
|
|
468
|
+
* "committed 2h ago" against a Dependabot PR states an agreement that was never made.
|
|
469
|
+
*/
|
|
366
470
|
const ageLabel = (i) => {
|
|
367
471
|
const h = (nowMs - Date.parse(i.at)) / 3_600_000;
|
|
368
472
|
if (h < 1) return '';
|
|
369
|
-
|
|
370
|
-
return ` (
|
|
473
|
+
const verb = i.derived ? 'observed' : 'committed';
|
|
474
|
+
if (h < 24) return ` (${verb} ${Math.round(h)}h ago)`;
|
|
475
|
+
return ` (${verb} ${Math.round(h / 24)}d ago — still open)`;
|
|
371
476
|
};
|
|
372
477
|
|
|
373
478
|
/**
|
|
@@ -395,17 +500,49 @@ if (!claimCooldown(nowMs, COOLDOWN_MS)) process.exit(EXIT_ALLOW);
|
|
|
395
500
|
* DELIVERY. `additionalContext` in a Stop envelope forces the continuation (same protection as
|
|
396
501
|
* decision:block). Directive copy — continue, do not look for an exit.
|
|
397
502
|
*/
|
|
503
|
+
/**
|
|
504
|
+
* SAY WHAT THESE ACTUALLY ARE (2026-08-14). One header served both halves and it said "You have
|
|
505
|
+
* unfinished work you committed to" — true of a ledger row, FALSE of every derived one. Nobody
|
|
506
|
+
* committed to Dependabot's PR; a watcher observed it. Under a gate that forces the turn to
|
|
507
|
+
* continue, that sentence does not merely misdescribe the item, it manufactures an obligation and
|
|
508
|
+
* attributes it to the reader, which is the fabrication this whole project exists to refuse.
|
|
509
|
+
*
|
|
510
|
+
* So the two kinds are named separately and never merged into one claim.
|
|
511
|
+
*/
|
|
512
|
+
const committed = forceable.filter((i) => !i.derived);
|
|
513
|
+
const observed = forceable.filter((i) => i.derived);
|
|
514
|
+
// Every derived item names its own repo in its text; this is for the header, where the ONE repo
|
|
515
|
+
// this tree points at is the honest thing to say.
|
|
516
|
+
const repoLabel = [...OWNED_REPOS][0] || 'this repository';
|
|
517
|
+
|
|
518
|
+
const header = committed.length && observed.length
|
|
519
|
+
? [`You have unfinished work you committed to, and ${repoLabel} has open work of its own.`,
|
|
520
|
+
'Do NOT end the turn — continue now.']
|
|
521
|
+
: committed.length
|
|
522
|
+
? ['You have unfinished work you committed to. Do NOT end the turn — continue now.']
|
|
523
|
+
: [`${repoLabel} has open work: this is the repository's own current state as last observed —`,
|
|
524
|
+
'a breached issue, a red build, an unmerged PR or a security alert — NOT something you',
|
|
525
|
+
'committed to. Do NOT end the turn — continue now.'];
|
|
526
|
+
|
|
398
527
|
const lines = [
|
|
399
|
-
|
|
528
|
+
...header,
|
|
400
529
|
'Pick the highest-leverage open item below and make real progress on it this turn. Stop only when',
|
|
401
530
|
'EVERY item is genuinely done or blocked; if one is blocked, say why in a single line and move to',
|
|
402
531
|
'the next — never stop on the first obstacle, and never manufacture a reason to go quiet.',
|
|
403
532
|
'',
|
|
404
|
-
//
|
|
405
|
-
|
|
533
|
+
// Committed first, then observed: the promise outranks the backlog. Age is LABELLED, never used
|
|
534
|
+
// to suppress — an item open for days is the one most worth naming.
|
|
535
|
+
...[...committed, ...observed].slice(0, 8).map((i) => ` ☐ ${i.text}${ageLabel(i)}`),
|
|
406
536
|
...(forceable.length > 8 ? [` … and ${forceable.length - 8} more`] : []),
|
|
407
537
|
'',
|
|
408
|
-
|
|
538
|
+
// Only the ledger has a --done. A derived item clears by DOING the thing (merge it, fix the
|
|
539
|
+
// build, answer the issue, patch the advisory) and the next watcher run stops reporting it —
|
|
540
|
+
// which is the point of deriving it rather than remembering it. Offering --done for one would be
|
|
541
|
+
// offering a way to mark a red build finished without fixing it.
|
|
542
|
+
...(committed.length
|
|
543
|
+
? ['Mark each item done as you complete it: node plugin/scripts/continuation-gate.mjs --done "<exact item text>"']
|
|
544
|
+
: ['These clear by being done, not by being marked: merge or fix the PR, get the build green,',
|
|
545
|
+
'answer the issue, patch the advisory. The next observation stops listing them.']),
|
|
409
546
|
// THE HONEST EXIT, and it is what makes forcing old items safe.
|
|
410
547
|
//
|
|
411
548
|
// Fable's red-team #3 was right that a stale item pressuring every turn "breeds
|
|
@@ -417,7 +554,10 @@ const lines = [
|
|
|
417
554
|
// So the resolution is neither silence nor endless nagging: keep forcing, and name the honest
|
|
418
555
|
// disposal out loud. An item that is genuinely dead gets cleared — a deliberate, recorded act —
|
|
419
556
|
// instead of expiring on a timer nobody sees, or being falsely marked done to stop the noise.
|
|
420
|
-
|
|
557
|
+
// COMMITTED items only. A derived item is at most 6h old by construction (the freshness window),
|
|
558
|
+
// and "clear it, that is a legitimate answer" is advice about a promise — you cannot clear a red
|
|
559
|
+
// build by declaring it no longer real.
|
|
560
|
+
...(committed.some((i) => (nowMs - Date.parse(i.at)) > 24 * 3_600_000)
|
|
421
561
|
? ['', 'Some of these are days old. If one is genuinely no longer real, say so and CLEAR it —',
|
|
422
562
|
'that is a legitimate answer and the right one. What is never acceptable is marking it done',
|
|
423
563
|
'without doing it, or letting it age quietly out of view.']
|
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* decision-gate.mjs — ONE PreToolUse decision, from N policies, with ONE reason.
|
|
4
|
+
*
|
|
5
|
+
* WHY: four independent processes could each refuse the same Write, with no precedence and no shared
|
|
6
|
+
* context, so the user got one arbitrary reason and no hint a second wall stood behind it. The
|
|
7
|
+
* measurement and the full rationale are in docs/adr/0067 — not repeated here.
|
|
8
|
+
*
|
|
9
|
+
* This is ADR-040's speech-chokepoint invariant applied to REFUSAL. One pattern used twice, not two.
|
|
10
|
+
*
|
|
11
|
+
* THE POLICIES ARE UNCHANGED. Each already speaks `exit 0` = allow, `exit 2` + stderr = refuse — a
|
|
12
|
+
* verdict function that was only ever missing a caller. The gate runs each as a CAPTURED child and
|
|
13
|
+
* composes one decision, naming every policy that refused, in declared precedence order.
|
|
14
|
+
*
|
|
15
|
+
* FAIL-OPEN, deliberately: any failure of the GATE ITSELF allows. A gate that blocks because it
|
|
16
|
+
* cannot read a file is one users switch off, and a disabled gate protects nothing.
|
|
17
|
+
*/
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import { spawn } from 'node:child_process';
|
|
21
|
+
import { fileURLToPath } from 'node:url';
|
|
22
|
+
// resolveBash ONLY. `skipNoBash` is not a predicate — it is a one-time notice emitter that returns 0
|
|
23
|
+
// and WRITES TO STDERR, which on this hot path is the refusal channel: calling it would have injected
|
|
24
|
+
// an install hint into the middle of a refusal reason, or manufactured stderr on an allow. Read the
|
|
25
|
+
// signature, do not infer it from the name.
|
|
26
|
+
import { resolveBash } from './hook-shim-bash.mjs';
|
|
27
|
+
import { append as appendOutcome, actionKey, recordRefusal, resolve as resolveOutcome, sweepStale } from './decision-outcomes.mjs';
|
|
28
|
+
|
|
29
|
+
const SCRIPTS_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
30
|
+
const EVENT = process.argv[2] || '';
|
|
31
|
+
|
|
32
|
+
/** Exit codes that mean something to the host. Anything else from a policy is an ERROR, not a refusal. */
|
|
33
|
+
const ALLOW = 0;
|
|
34
|
+
const REFUSE = 2;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* ── THE BUDGET AND THE HOST TIMEOUT ARE ONE NUMBER, NOT TWO ──────────────────────────────────────
|
|
38
|
+
*
|
|
39
|
+
* THE DEFECT, measured by an adversarial audit on 2026-08-13. hooks.json declared `timeout: 5` for
|
|
40
|
+
* both PreToolUse entries; this file enforced an internal 4000ms budget. Those two numbers were never
|
|
41
|
+
* compared anywhere — not in code, not in a test — and 4000ms of policy work plus two node boots does
|
|
42
|
+
* not fit in 5000ms. Fourteen timed runs, in projects with no ruvnet-brain content:
|
|
43
|
+
*
|
|
44
|
+
* 3166 3579 3622 3816 4431 4464 4615 4959 | 5109 5118 5401 5526 5579 5700 ms
|
|
45
|
+
* ^ the 5s manifest timeout
|
|
46
|
+
*
|
|
47
|
+
* SIX OF FOURTEEN were killed by the host — including a plain `ls -la` at 5109ms and a Write at
|
|
48
|
+
* 5401ms. The host renders a killed hook as a FAILED PreToolUse HOOK, so the guard that exists to
|
|
49
|
+
* teach was instead producing the owner's literal complaint: "a ton of hook errors" on opening this
|
|
50
|
+
* plugin in another project. The gate was not refusing anything. It was timing out, and a timeout
|
|
51
|
+
* looks exactly like a broken plugin.
|
|
52
|
+
*
|
|
53
|
+
* The relationship, stated once so it can be asserted (tests/unit/decision-gate.test.mjs derives the
|
|
54
|
+
* manifest value FROM hooks.json — restating 5000 in the test would only re-create the drift):
|
|
55
|
+
*
|
|
56
|
+
* DEFAULT_BUDGET_MS + MIN_HEADROOM_MS ≤ hooks.json PreToolUse timeout × 1000
|
|
57
|
+
*
|
|
58
|
+
* MIN_HEADROOM_MS is not padding. It is the work outside the budget's control: the shim's node boot,
|
|
59
|
+
* this gate's node boot, the outcome-ledger writes, and the SIGKILL teardown of whatever the budget
|
|
60
|
+
* just cancelled.
|
|
61
|
+
*
|
|
62
|
+
* NARROWED BACK 2026-08-19, from 4000+5000 (which required a 10s manifest) to 2000+3000 (which fits
|
|
63
|
+
* the original 5s). The widening above was honest when it was made — those fourteen runs really did
|
|
64
|
+
* straddle the limit. What made it stale was the adr-currency-gate two-pass fix, which stopped the
|
|
65
|
+
* gate reading all 83 ADR bodies on every tool call. Re-measured through the FULL registered path
|
|
66
|
+
* (`hook-shim.mjs decision-gate write`, the way the host actually invokes it), eight runs in a
|
|
67
|
+
* stranger project and six in this repo:
|
|
68
|
+
*
|
|
69
|
+
* stranger: 424 405 405 415 417 416 411 415 ms
|
|
70
|
+
* this repo: 438 416 412 416 412 436 ms
|
|
71
|
+
*
|
|
72
|
+
* ~415ms against a 5000ms ceiling — twelve times the headroom, where before SIX OF FOURTEEN runs
|
|
73
|
+
* were being killed. The 10s ceiling was no longer buying anything, and a ceiling is not free: a
|
|
74
|
+
* sibling test caps PreToolUse at 5s precisely because the USER waits behind this hook on every
|
|
75
|
+
* Write and every Bash, and a 10s stall is the /rvbc hang that already burned people. THE RULE THIS
|
|
76
|
+
* ENCODES: a budget widened by evidence must be narrowed again when the evidence expires. Otherwise
|
|
77
|
+
* every emergency ratchets the ceiling up one notch permanently and nothing ever ratchets it back.
|
|
78
|
+
*/
|
|
79
|
+
export const DEFAULT_BUDGET_MS = 2000;
|
|
80
|
+
export const MIN_HEADROOM_MS = 3000;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* THE POLICY REGISTRY — the whole point of this file, and the thing that was previously scattered
|
|
84
|
+
* across seven hooks.json entries with no relationship to each other.
|
|
85
|
+
*
|
|
86
|
+
* ORDER IS PRECEDENCE, most fundamental first, and it is a claim about what matters most when two
|
|
87
|
+
* policies both object:
|
|
88
|
+
*
|
|
89
|
+
* protect-state the user's own consent boundary. It guards the OFF switch itself, so it
|
|
90
|
+
* outranks everything — ADR-054 §3 already says it matters MORE while the
|
|
91
|
+
* brain is off.
|
|
92
|
+
* hijack-ruvnet the managed-memory boundary (ADR-063): a correctness rule about where data
|
|
93
|
+
* goes, ahead of anything about process.
|
|
94
|
+
* ground-before-write don't write RuvNet-product code ungrounded (ADR-0012).
|
|
95
|
+
* design-wall don't ship a visual surface nobody looked at.
|
|
96
|
+
*
|
|
97
|
+
* `unprompted-speech` is LAST and is not really a peer: it is the speech chokepoint, which refuses
|
|
98
|
+
* only for a lesson the user personally opted into blocking. It is included so that Write/Edit and
|
|
99
|
+
* Bash have exactly ONE process that can refuse them — which is the entire invariant — and its
|
|
100
|
+
* allow-path stdout envelope is forwarded untouched.
|
|
101
|
+
*/
|
|
102
|
+
const POLICY = (id, file, interpreter = 'bash') => ({ id, file, interpreter });
|
|
103
|
+
const REFUSAL_POLICIES = [
|
|
104
|
+
POLICY('protect-state', 'protect-brain-state.sh'),
|
|
105
|
+
// degradation-watch sits second because it decides whether ANY record this system keeps is real.
|
|
106
|
+
// Measured 2026-08-13: better_sqlite3.node was built for NODE_MODULE_VERSION 141 against a node
|
|
107
|
+
// needing 137, ruflo fell back to sql.js, and nothing persisted for three days while every write
|
|
108
|
+
// printed `[OK] Data stored successfully`. A warning was printed on every one of those writes and
|
|
109
|
+
// read. It could not stop anything, because a warning is text and text is skimmable — so this is
|
|
110
|
+
// a refusal instead. It probes only for commands whose truth DEPENDS on durable memory (a lesson
|
|
111
|
+
// store, a ship), so ordinary Bash pays nothing.
|
|
112
|
+
POLICY('degradation-watch', 'degradation-watch.mjs', 'node'),
|
|
113
|
+
// identifier-preflight is FIRST among the cheap checks and costs one file read: it refuses a
|
|
114
|
+
// command that names a model this machine's CLI does not accept. `codex exec --model gpt-5.6`
|
|
115
|
+
// (correct: gpt-5.6-sol, in ~/.codex/config.toml) printed a 400 and EXITED 0 into a redirected
|
|
116
|
+
// file on 2026-08-13, so a 50-minute audit produced nothing and there was no exit code to catch.
|
|
117
|
+
// It refuses ONLY a positively-known-wrong value and allows every unknown, because a wall that
|
|
118
|
+
// fabricates a reason is one people learn to route around.
|
|
119
|
+
POLICY('identifier-preflight', 'identifier-preflight.mjs', 'node'),
|
|
120
|
+
POLICY('hijack-ruvnet', 'hijack-ruvnet.sh'),
|
|
121
|
+
POLICY('ground-before-write', 'ground-before-write.sh'),
|
|
122
|
+
POLICY('design-wall', 'design-wall.sh'),
|
|
123
|
+
// adr-currency-gate fires on the EDIT, where the pre-push gate fires on the push. Same rule, same
|
|
124
|
+
// machinery (it calls doc-currency.mjs, never a second copy of the logic) — moved to the earliest
|
|
125
|
+
// moment it has enough information. On 2026-08-13 four ADRs went stale together and were caught
|
|
126
|
+
// only at push, after three commits, when the work read as a toll booth. A gate at the end cannot
|
|
127
|
+
// shape the work; it can only penalise it, and it trains running at the wall. This one refuses
|
|
128
|
+
// DEBT, not change: you may edit governed code freely, but not while a document governing it is
|
|
129
|
+
// still unreconciled from the last round.
|
|
130
|
+
POLICY('adr-currency', 'adr-currency-gate.mjs', 'node'),
|
|
131
|
+
// spend-guard refuses an agent FLEET that would inherit metered API keys. The $1,600 of
|
|
132
|
+
// agentic-qe#557: ~374 headless agents billed api.anthropic.com per-token for 11 hours while the
|
|
133
|
+
// Claude Max subscription sat unused. The rule was stored, ratified and severity:high — and
|
|
134
|
+
// delivered as advisory text, which is what gets skimmed. `claude` and `codex` are the seats and
|
|
135
|
+
// are never touched; OPENROUTER is metered and deliberately allowed, because cost-optimal routing
|
|
136
|
+
// exists to spend it and a gate that fires on the feature you configured is the gate you disable.
|
|
137
|
+
POLICY('spend-guard', 'spend-guard.mjs', 'node'),
|
|
138
|
+
];
|
|
139
|
+
const SPEECH = { id: 'unprompted-speech', file: 'unprompted-runtime.mjs', interpreter: 'node' };
|
|
140
|
+
|
|
141
|
+
/** Which policies apply to which PreToolUse sub-event, mirroring the matchers they replaced. */
|
|
142
|
+
const REGISTRY = {
|
|
143
|
+
'write': ['protect-state', 'hijack-ruvnet', 'ground-before-write', 'adr-currency'],
|
|
144
|
+
// degradation-watch is bash-only on purpose: the acts it guards — `ruflo memory store`, `git
|
|
145
|
+
// push` — are commands, so the dependency is observable there and nowhere else.
|
|
146
|
+
'bash': ['protect-state', 'identifier-preflight', 'spend-guard', 'degradation-watch', 'hijack-ruvnet', 'design-wall'],
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
export function policiesFor(event, registry = REGISTRY, all = REFUSAL_POLICIES) {
|
|
150
|
+
const ids = registry[event] || [];
|
|
151
|
+
// Ordered by REFUSAL_POLICIES, never by the registry entry — precedence is a property of the
|
|
152
|
+
// policy, not of where someone happened to list it.
|
|
153
|
+
return all.filter((p) => ids.includes(p.id));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* ── APPLICABILITY: THE CHEAPEST POLICY IS THE ONE NEVER SPAWNED ──────────────────────────────────
|
|
158
|
+
*
|
|
159
|
+
* degradation-watch was spawned for EVERY Bash call and then exited 0 on its own second line — its
|
|
160
|
+
* `dependentEvent()` returns null for anything that is not a ship or a memory store, which is nearly
|
|
161
|
+
* everything. Measured here on 2026-08-14: 63-65ms of node boot bought to learn that `ls -la` is not
|
|
162
|
+
* `git push`, on every single Bash tool call, on the machine where a node boot costs 60ms. On the
|
|
163
|
+
* audit's machine that same boot is ~300ms.
|
|
164
|
+
*
|
|
165
|
+
* The predicate is IMPORTED, never restated. Copying the DEPENDENT_COMMANDS regexes up here would
|
|
166
|
+
* make two answers to one question and guarantee they drift — the same reason adr-currency-gate calls
|
|
167
|
+
* doc-currency.mjs instead of carrying a second copy of the logic.
|
|
168
|
+
*
|
|
169
|
+
* FAIL TOWARD RUNNING THE POLICY. If the import fails, or the predicate throws, the policy is
|
|
170
|
+
* spawned exactly as before: this is a latency optimisation and it may never become a way to silently
|
|
171
|
+
* disable a guard.
|
|
172
|
+
*/
|
|
173
|
+
let dependentEvent = null; // set from degradation-watch.mjs at startup; null → spawn it as before
|
|
174
|
+
/**
|
|
175
|
+
* Resolved once per invocation by the runtime block below; null means no bash on this host.
|
|
176
|
+
*
|
|
177
|
+
* Declared HERE, above that block, and not next to runPolicy() where it reads more naturally: the
|
|
178
|
+
* `if (isMain())` block runs during module evaluation, so a `let` declared after it sits in the
|
|
179
|
+
* temporal dead zone and the assignment throws — the identical mistake `speechEventFor` was already
|
|
180
|
+
* a hoisted `function` to avoid, recorded a few lines further down.
|
|
181
|
+
*/
|
|
182
|
+
let BASH = null;
|
|
183
|
+
const APPLICABILITY = {
|
|
184
|
+
'degradation-watch': (input) => (dependentEvent ? Boolean(dependentEvent(input.command)) : true),
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
/** Returns a skip reason, or null if the policy must be consulted. */
|
|
188
|
+
export function skipReason(policy, toolInput, table = APPLICABILITY) {
|
|
189
|
+
const test = table[policy.id];
|
|
190
|
+
if (!test) return null;
|
|
191
|
+
try { return test(toolInput || {}) ? null : 'not-applicable'; } catch { return null; }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Compose one decision from many verdicts.
|
|
196
|
+
*
|
|
197
|
+
* Pure and exported so the precedence rule is testable without spawning anything — the rule is the
|
|
198
|
+
* product here, and a rule only provable by running four bash scripts is a rule nobody re-checks.
|
|
199
|
+
*/
|
|
200
|
+
export function decide(verdicts) {
|
|
201
|
+
const refusals = verdicts.filter((v) => v.code === REFUSE);
|
|
202
|
+
if (!refusals.length) return { allow: true, refusals: [] };
|
|
203
|
+
const [first, ...also] = refusals;
|
|
204
|
+
// The winning policy's own words are the message — it wrote them for this moment, and replacing
|
|
205
|
+
// them with a summary of our own would lose the specific instruction the user needs.
|
|
206
|
+
let reason = (first.stderr || '').trim() || `refused by ${first.id} (no reason given)`;
|
|
207
|
+
if (also.length) {
|
|
208
|
+
// NAMING THE OTHERS IS THE POINT. Under four racing hooks the user fixed the first refusal, ran
|
|
209
|
+
// the command again, and hit the second — with no way to know it was there. One round-trip per
|
|
210
|
+
// wall is how a guard becomes something people route around.
|
|
211
|
+
reason += `\n\n Also refusing this action (fix these too, or they will stop you next):\n`
|
|
212
|
+
+ also.map((v) => ` · ${v.id}: ${firstLine(v.stderr) || 'no reason given'}`).join('\n');
|
|
213
|
+
}
|
|
214
|
+
return { allow: false, refusals: refusals.map((v) => v.id), reason };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const firstLine = (s) => String(s || '').trim().split('\n').map((l) => l.trim()).filter(Boolean)[0] || '';
|
|
218
|
+
|
|
219
|
+
/** Payload accessors — tolerant, because a malformed payload must degrade to "no measurement". */
|
|
220
|
+
function parsed(payload) { try { const o = JSON.parse(payload); return o && typeof o === 'object' ? o : {}; } catch { return {}; } }
|
|
221
|
+
function sessionOf(payload) { return String(parsed(payload).session_id || ''); }
|
|
222
|
+
function payloadTool(payload) { return String(parsed(payload).tool_name || ''); }
|
|
223
|
+
function payloadInput(payload) { return parsed(payload).tool_input || {}; }
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* The sub-event token unprompted-runtime switches on.
|
|
227
|
+
*
|
|
228
|
+
* A hoisted `function`, not a `const` arrow: the `if (isMain())` block below runs DURING module
|
|
229
|
+
* evaluation, so an arrow declared after it sits in the temporal dead zone — it threw
|
|
230
|
+
* `Cannot access 'speechEventFor' before initialization` on this gate's first live refusal.
|
|
231
|
+
*/
|
|
232
|
+
function speechEventFor(event) { return event === 'bash' ? 'PreToolUse-bash' : 'PreToolUse-write'; }
|
|
233
|
+
|
|
234
|
+
// ── Runtime ──────────────────────────────────────────────────────────────────────────────────────
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
if (isMain()) {
|
|
238
|
+
const started = Date.now();
|
|
239
|
+
const payload = readPayload();
|
|
240
|
+
const selected = policiesFor(EVENT);
|
|
241
|
+
// An unknown event is not an occasion to refuse anything. Same rule as unprompted-runtime's
|
|
242
|
+
// "never speak on a guess", pointed at the other decision.
|
|
243
|
+
if (!selected.length && EVENT !== 'write' && EVENT !== 'bash') process.exit(ALLOW);
|
|
244
|
+
|
|
245
|
+
const budgetMs = Number(process.env.RUVNET_DECISION_BUDGET_MS) || DEFAULT_BUDGET_MS;
|
|
246
|
+
const deadline = started + budgetMs;
|
|
247
|
+
// Resolved ONCE. On win32 resolveBash() can shell out to `where.exe`; four bash policies meant up
|
|
248
|
+
// to four of those per tool call, for an answer that cannot change mid-invocation.
|
|
249
|
+
BASH = resolveBash();
|
|
250
|
+
// Best-effort, and deliberately not a static import: a missing or broken degradation-watch.mjs
|
|
251
|
+
// must cost us the optimisation, not the whole gate. `runPolicy` already tolerates a missing
|
|
252
|
+
// policy file; a top-level `import` of it would have made that tolerance a lie.
|
|
253
|
+
try { ({ dependentEvent } = await import('./degradation-watch.mjs')); } catch { dependentEvent = null; }
|
|
254
|
+
|
|
255
|
+
const trace = []; // one row per policy — surfaced by RUVNET_DECISION_TRACE=1
|
|
256
|
+
const unconsulted = []; // policies the budget cost us. NEVER silent; see reportBudget().
|
|
257
|
+
const toolInput = payloadInput(payload);
|
|
258
|
+
const consulted = [];
|
|
259
|
+
for (const p of selected) {
|
|
260
|
+
const why = skipReason(p, toolInput);
|
|
261
|
+
if (why) trace.push({ id: p.id, ms: 0, skipped: why });
|
|
262
|
+
else consulted.push(p);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ── PARALLEL, and the reason is arithmetic ─────────────────────────────────────────────────────
|
|
266
|
+
// Sequentially the gate's wall time was SUM(policies); run together it is MAX(policies). Measured
|
|
267
|
+
// on a `git push` payload by the 2026-08-13 audit: 182 + 345 + 2145..3990 + 629 + 743 ≈ 4.0-5.9s
|
|
268
|
+
// sequential, against a 5s host timeout. Nothing about these policies wanted to be sequential —
|
|
269
|
+
// they share no state, write no files (only stderr), and `decide()` re-sorts the verdicts into
|
|
270
|
+
// REFUSAL_POLICIES precedence order regardless of which finished first. Every selected policy ran
|
|
271
|
+
// on every call before this change too: the gate never short-circuited on the first refusal,
|
|
272
|
+
// because naming EVERY wall is the whole point of ADR-067.
|
|
273
|
+
const results = await Promise.all(consulted.map((p) => runPolicy(p, payload, deadline, undefined, trace)));
|
|
274
|
+
const verdicts = results.filter((r) => typeof r.code === 'number');
|
|
275
|
+
for (const r of results) if (r.skipped === 'budget') unconsulted.push(r.id);
|
|
276
|
+
|
|
277
|
+
const decision = decide(verdicts);
|
|
278
|
+
|
|
279
|
+
// ── OBEDIENCE MEASUREMENT (ADR-067 §outcomes) ──────────────────────────────────────────────────
|
|
280
|
+
// This gate is the ONLY thing that sees every Write/Edit/Bash, so it can close the loop with no new
|
|
281
|
+
// hook: resolve first (did this call retry something we refused?), then open a new debt if we are
|
|
282
|
+
// about to refuse. Order matters — resolving after recording would close the debt we just opened.
|
|
283
|
+
// Entirely best-effort: measurement may never affect the verdict, so it runs after `decide`.
|
|
284
|
+
const session = sessionOf(payload);
|
|
285
|
+
try {
|
|
286
|
+
const key = actionKey(payloadTool(payload), toolInput);
|
|
287
|
+
const ts = Date.now();
|
|
288
|
+
if (session && key) {
|
|
289
|
+
sweepStale({ session, ts }); // debts from dead sessions become `abandoned`, never vanish
|
|
290
|
+
resolveOutcome({ session, key, allowed: decision.allow, ts });
|
|
291
|
+
if (!decision.allow) recordRefusal({ session, key, policies: decision.refusals, ts });
|
|
292
|
+
}
|
|
293
|
+
} catch { /* a ledger must never break a tool call */ }
|
|
294
|
+
|
|
295
|
+
if (!decision.allow) {
|
|
296
|
+
reportBudget({ session, unconsulted, trace, started, budgetMs });
|
|
297
|
+
process.stderr.write(`${decision.reason}\n`);
|
|
298
|
+
process.exit(REFUSE);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Nothing refused: run the speech chokepoint and forward its envelope verbatim. It owns its own
|
|
302
|
+
// per-channel policy; this gate does not inspect or re-decide anything it says.
|
|
303
|
+
//
|
|
304
|
+
// SEQUENTIAL ON PURPOSE, unlike the batch above. unprompted-runtime.mjs records an OFFERED row in
|
|
305
|
+
// the advocacy ledger when it delivers (its line ~372), so starting it in parallel and discarding
|
|
306
|
+
// its stdout after a refusal would book an offer the user never saw — inflating the denominator
|
|
307
|
+
// this project has a CI gate against fabricating. A saved ~280ms is not worth a fabricated number.
|
|
308
|
+
if (deadline - Date.now() <= 0) {
|
|
309
|
+
unconsulted.push(SPEECH.id);
|
|
310
|
+
} else {
|
|
311
|
+
const speech = await runPolicy(SPEECH, payload, deadline, speechEventFor(EVENT), trace);
|
|
312
|
+
if (speech.skipped === 'budget') unconsulted.push(SPEECH.id);
|
|
313
|
+
if (speech.code === REFUSE) {
|
|
314
|
+
reportBudget({ session, unconsulted, trace, started, budgetMs });
|
|
315
|
+
process.stderr.write(`${(speech.stderr || '').trim()}\n`);
|
|
316
|
+
process.exit(REFUSE);
|
|
317
|
+
}
|
|
318
|
+
reportBudget({ session, unconsulted, trace, started, budgetMs });
|
|
319
|
+
if (speech.stdout?.trim()) process.stdout.write(speech.stdout);
|
|
320
|
+
process.exit(ALLOW);
|
|
321
|
+
}
|
|
322
|
+
reportBudget({ session, unconsulted, trace, started, budgetMs });
|
|
323
|
+
process.exit(ALLOW);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* ── A BUDGET THAT CAN BE EXCEEDED SILENTLY FAILS OPEN WITHOUT SAYING SO ──────────────────────────
|
|
328
|
+
*
|
|
329
|
+
* The old loop did `break` when the budget ran out. Every remaining policy AND the speech chokepoint
|
|
330
|
+
* were then skipped with no record anywhere — the gate allowed, and nothing distinguished "five
|
|
331
|
+
* policies agreed this was fine" from "we ran out of time and stopped asking". That is this repo's
|
|
332
|
+
* signature defect wearing a different hat: silence standing in for a measurement. degradation-watch
|
|
333
|
+
* exists because a warning was printed and skimmed; this exists because nothing was printed at all.
|
|
334
|
+
*
|
|
335
|
+
* TWO CHANNELS, because each fails differently:
|
|
336
|
+
* · the outcome ledger (~/.config/ruvnet-brain/, survives `--update`) so a trip COMPOUNDS into
|
|
337
|
+
* evidence instead of scrolling past. `kind: 'budget-exceeded'` is inert in report()'s buckets —
|
|
338
|
+
* it counts neither as a refusal nor as a resolution, so it cannot move the obedience rate.
|
|
339
|
+
* · one stderr line, unconditionally, allow or refuse. Yes, that puts bytes on stderr during an
|
|
340
|
+
* exit-0 allow, which tests/unit/decision-gate.test.mjs asserts never happens on an ordinary
|
|
341
|
+
* write. That assertion is now a SECOND tripwire and is meant to be: after the parallel batch and
|
|
342
|
+
* the applicability skip, an ordinary write measures ~360ms against a 4000ms budget, so a trip
|
|
343
|
+
* there is not noise to be tolerated — it is the defect, and the suite should go red for it.
|
|
344
|
+
*/
|
|
345
|
+
function reportBudget({ session, unconsulted, trace, started, budgetMs }) {
|
|
346
|
+
const elapsed = Date.now() - started;
|
|
347
|
+
if (process.env.RUVNET_DECISION_TRACE === '1') {
|
|
348
|
+
process.stderr.write(`[decision-gate] ${EVENT} ${elapsed}ms budget=${budgetMs}ms ${JSON.stringify(trace)}\n`);
|
|
349
|
+
}
|
|
350
|
+
if (!unconsulted.length) return;
|
|
351
|
+
try {
|
|
352
|
+
appendOutcome({ kind: 'budget-exceeded', event: EVENT, session, unconsulted, elapsedMs: elapsed, budgetMs, ts: Date.now() });
|
|
353
|
+
} catch { /* a ledger must never break a tool call */ }
|
|
354
|
+
process.stderr.write(
|
|
355
|
+
`[decision-gate] ${budgetMs}ms budget exhausted after ${elapsed}ms — ALLOWED WITHOUT CONSULTING: `
|
|
356
|
+
+ `${unconsulted.join(', ')}. These policies did not vote; this allow is a timeout, not a verdict.\n`,
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Run one policy as a CAPTURED child. Never lets its bytes touch the real streams.
|
|
362
|
+
*
|
|
363
|
+
* Always resolves, never rejects, and always to an object — `{ id, code }` for a real verdict, or
|
|
364
|
+
* `{ id, skipped }` for anything else. The old version returned bare `null` for a missing file, a
|
|
365
|
+
* missing bash, a crash AND a timeout alike, which is precisely why a blown budget could not be
|
|
366
|
+
* reported: by the time the caller saw the result, the reason was gone.
|
|
367
|
+
*/
|
|
368
|
+
function runPolicy(p, payload, deadline, extraArg, trace) {
|
|
369
|
+
const t0 = Date.now();
|
|
370
|
+
const done = (r) => {
|
|
371
|
+
trace?.push({ id: p.id, ms: Date.now() - t0, ...(r.skipped ? { skipped: r.skipped } : { code: r.code }) });
|
|
372
|
+
return r;
|
|
373
|
+
};
|
|
374
|
+
const file = path.join(SCRIPTS_DIR, p.file);
|
|
375
|
+
if (!fs.existsSync(file)) return Promise.resolve(done({ id: p.id, skipped: 'missing' }));
|
|
376
|
+
let cmd; const args = [file];
|
|
377
|
+
if (p.interpreter === 'bash') {
|
|
378
|
+
if (!BASH) return Promise.resolve(done({ id: p.id, skipped: 'no-bash' })); // this policy cannot speak here
|
|
379
|
+
cmd = BASH;
|
|
380
|
+
} else {
|
|
381
|
+
cmd = process.execPath;
|
|
382
|
+
}
|
|
383
|
+
if (extraArg) args.push(extraArg);
|
|
384
|
+
const left = deadline - Date.now();
|
|
385
|
+
if (left <= 0) return Promise.resolve(done({ id: p.id, skipped: 'budget' }));
|
|
386
|
+
|
|
387
|
+
return new Promise((resolve) => {
|
|
388
|
+
let settled = false;
|
|
389
|
+
let timer = null;
|
|
390
|
+
const finish = (r) => { if (settled) return; settled = true; clearTimeout(timer); resolve(done(r)); };
|
|
391
|
+
let child;
|
|
392
|
+
try {
|
|
393
|
+
child = spawn(cmd, args, { stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env, RUVNET_DECISION_GATE: '1' } });
|
|
394
|
+
} catch { return finish({ id: p.id, skipped: 'spawn' }); }
|
|
395
|
+
// SIGKILL, not SIGTERM: a bash policy that has spawned its own child (jq, node, ruflo) can sit in
|
|
396
|
+
// a TERM handler, and the host's own kill is what we are racing. The whole batch shares ONE
|
|
397
|
+
// deadline, so a single slow policy cancels only the time it actually consumed.
|
|
398
|
+
timer = setTimeout(() => { try { child.kill('SIGKILL'); } catch { /* already gone */ } finish({ id: p.id, skipped: 'budget' }); }, left);
|
|
399
|
+
let stdout = ''; let stderr = ''; let bytes = 0;
|
|
400
|
+
const MAX = 1 << 20; // same ceiling spawnSync's maxBuffer enforced; a policy is not a data source
|
|
401
|
+
child.stdout.on('data', (d) => { if (bytes < MAX) { stdout += d; bytes += d.length; } });
|
|
402
|
+
child.stderr.on('data', (d) => { if (bytes < MAX) { stderr += d; bytes += d.length; } });
|
|
403
|
+
child.on('error', () => finish({ id: p.id, skipped: 'spawn' }));
|
|
404
|
+
// EPIPE when a policy exits before reading its payload (degradation-watch's fast path does).
|
|
405
|
+
// Unhandled, that error event would take the whole gate down and turn an allow into a hook error.
|
|
406
|
+
child.stdin.on('error', () => { /* the child did not want the payload; that is not a failure */ });
|
|
407
|
+
child.on('close', (code) => {
|
|
408
|
+
// A spawn failure, a timeout, or any code other than 0/2 is an ERROR — and an error here must
|
|
409
|
+
// never be mistaken for a refusal. That distinction is the one lesson-gate.mjs had to learn twice.
|
|
410
|
+
if (code !== ALLOW && code !== REFUSE) return finish({ id: p.id, skipped: `exit:${code}` });
|
|
411
|
+
finish({ id: p.id, code, stderr, stdout });
|
|
412
|
+
});
|
|
413
|
+
try { child.stdin.end(payload); } catch { /* handled by the stdin error listener above */ }
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function readPayload() {
|
|
418
|
+
if (process.stdin.isTTY) return '';
|
|
419
|
+
try { return fs.readFileSync(0, 'utf8'); } catch { return ''; }
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** Never able to crash a caller that merely imported this (see tests/unit/entrypoint-guard-safety). */
|
|
423
|
+
function isMain() {
|
|
424
|
+
try {
|
|
425
|
+
if (!process.argv[1]) return false;
|
|
426
|
+
return fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url));
|
|
427
|
+
} catch { return false; }
|
|
428
|
+
}
|
|
Binary file
|