@bookedsolid/rea 0.38.1 → 0.40.0
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/dist/cli/delegation-advisory.d.ts +40 -0
- package/dist/cli/delegation-advisory.js +86 -8
- package/dist/cli/doctor.d.ts +153 -0
- package/dist/cli/doctor.js +675 -2
- package/package.json +1 -1
|
@@ -89,7 +89,47 @@ export interface HookDelegationAdvisoryOptions {
|
|
|
89
89
|
* Production omits.
|
|
90
90
|
*/
|
|
91
91
|
stdinOverride?: string;
|
|
92
|
+
/**
|
|
93
|
+
* Test seam — override the sleep used by
|
|
94
|
+
* `sessionHasRealDelegation`'s poll-and-backoff loop. Production
|
|
95
|
+
* omits and uses real `setTimeout`-backed sleeps; tests pass a
|
|
96
|
+
* controllable fake so the race-coverage tests don't wall-clock on
|
|
97
|
+
* the real 500ms budget.
|
|
98
|
+
*
|
|
99
|
+
* 0.40.0 charter item 1.
|
|
100
|
+
*/
|
|
101
|
+
sleepOverride?: (ms: number) => Promise<void>;
|
|
92
102
|
}
|
|
103
|
+
/**
|
|
104
|
+
* Backoff schedule (in milliseconds) for `sessionHasRealDelegation`'s
|
|
105
|
+
* poll-and-backoff loop.
|
|
106
|
+
*
|
|
107
|
+
* 0.40.0 charter item 1 — closes the `& disown` race between
|
|
108
|
+
* `delegation-capture.sh` (which fire-and-forgets `rea hook
|
|
109
|
+
* delegation-signal --detach &` for sub-50ms PreToolUse latency) and the
|
|
110
|
+
* `delegation-advisory.sh` PostToolUse path (which reads the audit log
|
|
111
|
+
* to decide whether the session has delegated). A `git commit` landing
|
|
112
|
+
* within the narrow window between an Agent dispatch and the audit
|
|
113
|
+
* append-on-disk would read the stale chain, see no delegation, fire
|
|
114
|
+
* the nudge spuriously, AND write the `.fired` sentinel — silencing
|
|
115
|
+
* every future advisory in the session even though delegation DID
|
|
116
|
+
* happen.
|
|
117
|
+
*
|
|
118
|
+
* The schedule is delays BETWEEN re-reads (NOT cumulative): 50ms,
|
|
119
|
+
* 150ms, 300ms. Total worst-case 500ms — acceptable hot-path budget
|
|
120
|
+
* for a PostToolUse hook running on `Bash|Edit|Write|MultiEdit|
|
|
121
|
+
* NotebookEdit`. The first read is immediate (no upfront delay), so a
|
|
122
|
+
* session that DID delegate before threshold-crossing pays zero extra
|
|
123
|
+
* latency. Only the rare "we crossed threshold while a recent
|
|
124
|
+
* delegation signal hasn't yet hit disk" case pays the full budget,
|
|
125
|
+
* and only ONCE per session (the `.fired` sentinel suppresses future
|
|
126
|
+
* scans).
|
|
127
|
+
*
|
|
128
|
+
* Exported for the race-coverage test in
|
|
129
|
+
* `delegation-advisory.test.ts` so it can assert on the schedule
|
|
130
|
+
* without duplicating the constant.
|
|
131
|
+
*/
|
|
132
|
+
export declare const DELEGATION_POLL_BACKOFF_MS: readonly number[];
|
|
93
133
|
/**
|
|
94
134
|
* Derive a filesystem-safe, **collision-free** per-session state-key
|
|
95
135
|
* basename from an untrusted session id.
|
|
@@ -70,6 +70,36 @@ import { loadDelegationRecords, listRotatedAuditFiles, } from './audit-specialis
|
|
|
70
70
|
import { discoverRoster, countsAsRealDelegation, DEFAULT_EXEMPT_SUBAGENTS, } from './roster.js';
|
|
71
71
|
import { REA_DIR } from './utils.js';
|
|
72
72
|
const DEFAULT_THRESHOLD = 25;
|
|
73
|
+
/**
|
|
74
|
+
* Backoff schedule (in milliseconds) for `sessionHasRealDelegation`'s
|
|
75
|
+
* poll-and-backoff loop.
|
|
76
|
+
*
|
|
77
|
+
* 0.40.0 charter item 1 — closes the `& disown` race between
|
|
78
|
+
* `delegation-capture.sh` (which fire-and-forgets `rea hook
|
|
79
|
+
* delegation-signal --detach &` for sub-50ms PreToolUse latency) and the
|
|
80
|
+
* `delegation-advisory.sh` PostToolUse path (which reads the audit log
|
|
81
|
+
* to decide whether the session has delegated). A `git commit` landing
|
|
82
|
+
* within the narrow window between an Agent dispatch and the audit
|
|
83
|
+
* append-on-disk would read the stale chain, see no delegation, fire
|
|
84
|
+
* the nudge spuriously, AND write the `.fired` sentinel — silencing
|
|
85
|
+
* every future advisory in the session even though delegation DID
|
|
86
|
+
* happen.
|
|
87
|
+
*
|
|
88
|
+
* The schedule is delays BETWEEN re-reads (NOT cumulative): 50ms,
|
|
89
|
+
* 150ms, 300ms. Total worst-case 500ms — acceptable hot-path budget
|
|
90
|
+
* for a PostToolUse hook running on `Bash|Edit|Write|MultiEdit|
|
|
91
|
+
* NotebookEdit`. The first read is immediate (no upfront delay), so a
|
|
92
|
+
* session that DID delegate before threshold-crossing pays zero extra
|
|
93
|
+
* latency. Only the rare "we crossed threshold while a recent
|
|
94
|
+
* delegation signal hasn't yet hit disk" case pays the full budget,
|
|
95
|
+
* and only ONCE per session (the `.fired` sentinel suppresses future
|
|
96
|
+
* scans).
|
|
97
|
+
*
|
|
98
|
+
* Exported for the race-coverage test in
|
|
99
|
+
* `delegation-advisory.test.ts` so it can assert on the schedule
|
|
100
|
+
* without duplicating the constant.
|
|
101
|
+
*/
|
|
102
|
+
export const DELEGATION_POLL_BACKOFF_MS = [50, 150, 300];
|
|
73
103
|
/**
|
|
74
104
|
* Maximum length of the human-readable prefix in a state key. The full
|
|
75
105
|
* key is `<prefix>-<16-hex-hash>`, so a 64-char cap keeps basenames well
|
|
@@ -250,7 +280,22 @@ export function advisoryMessage(count, threshold) {
|
|
|
250
280
|
* `since` anchor is `undefined` and behavior is the pre-0.31.0
|
|
251
281
|
* single-file walk.
|
|
252
282
|
*/
|
|
253
|
-
|
|
283
|
+
/**
|
|
284
|
+
* Real-clock sleep used by the production poll-and-backoff loop.
|
|
285
|
+
* Factored out so tests can swap it for a fake controllable scheduler
|
|
286
|
+
* via `HookDelegationAdvisoryOptions.sleepOverride`.
|
|
287
|
+
*/
|
|
288
|
+
function realSleep(ms) {
|
|
289
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Single audit-chain scan: returns `'delegated'` when a real
|
|
293
|
+
* delegation signal is found, `'not-delegated'` when scanning succeeds
|
|
294
|
+
* but no real signal is in the chain, and `'unreadable'` when audit
|
|
295
|
+
* loading throws (the chain is missing / unreadable). Split out from
|
|
296
|
+
* the polling loop so each retry runs identical scan logic.
|
|
297
|
+
*/
|
|
298
|
+
async function scanForRealDelegationOnce(reaRoot, sessionId, exemptSubagents) {
|
|
254
299
|
let records;
|
|
255
300
|
try {
|
|
256
301
|
// Resolve the rotated-file set the same way `rea audit specialists`
|
|
@@ -263,13 +308,10 @@ async function sessionHasRealDelegation(reaRoot, sessionId, exemptSubagents) {
|
|
|
263
308
|
records = loaded.records;
|
|
264
309
|
}
|
|
265
310
|
catch {
|
|
266
|
-
|
|
267
|
-
// we DON'T fire (fail toward silence, not toward a false-positive
|
|
268
|
-
// nudge). Returning `true` here suppresses the advisory.
|
|
269
|
-
return true;
|
|
311
|
+
return 'unreadable';
|
|
270
312
|
}
|
|
271
313
|
if (records.length === 0)
|
|
272
|
-
return
|
|
314
|
+
return 'not-delegated';
|
|
273
315
|
const roster = discoverRoster(reaRoot);
|
|
274
316
|
for (const rec of records) {
|
|
275
317
|
if (countsAsRealDelegation({
|
|
@@ -278,9 +320,45 @@ async function sessionHasRealDelegation(reaRoot, sessionId, exemptSubagents) {
|
|
|
278
320
|
roster,
|
|
279
321
|
exempt: exemptSubagents,
|
|
280
322
|
})) {
|
|
281
|
-
return
|
|
323
|
+
return 'delegated';
|
|
282
324
|
}
|
|
283
325
|
}
|
|
326
|
+
return 'not-delegated';
|
|
327
|
+
}
|
|
328
|
+
async function sessionHasRealDelegation(reaRoot, sessionId, exemptSubagents, sleep = realSleep) {
|
|
329
|
+
// 0.40.0 charter item 1 — poll-and-backoff before declaring
|
|
330
|
+
// "no delegation in this session".
|
|
331
|
+
//
|
|
332
|
+
// The producer (`delegation-capture.sh`) calls `rea hook
|
|
333
|
+
// delegation-signal --detach &` to fire-and-forget the audit append.
|
|
334
|
+
// For sub-50ms PreToolUse latency this is the right call, but it
|
|
335
|
+
// opens a narrow race: a write-class call (Bash/Edit/Write/…)
|
|
336
|
+
// landing in the same tick as an Agent/Skill dispatch can run this
|
|
337
|
+
// predicate BEFORE the audit append commits to disk. Pre-fix, the
|
|
338
|
+
// function then returned `false`, the caller fired the advisory,
|
|
339
|
+
// wrote the `.fired` sentinel, and silenced every future nudge in
|
|
340
|
+
// the session — even though delegation DID happen.
|
|
341
|
+
//
|
|
342
|
+
// Each retry runs a full audit scan. The first scan is immediate
|
|
343
|
+
// (no upfront delay); subsequent scans wait per
|
|
344
|
+
// `DELEGATION_POLL_BACKOFF_MS`. Worst-case total: 50+150+300 = 500ms
|
|
345
|
+
// for the four-scan path. We exit early as soon as a delegation is
|
|
346
|
+
// observed OR the chain becomes unreadable (preserving the pre-fix
|
|
347
|
+
// "audit log unreadable → suppress the advisory" posture so a
|
|
348
|
+
// missing chain never produces a false-positive nudge).
|
|
349
|
+
let outcome = await scanForRealDelegationOnce(reaRoot, sessionId, exemptSubagents);
|
|
350
|
+
if (outcome === 'delegated')
|
|
351
|
+
return true;
|
|
352
|
+
if (outcome === 'unreadable')
|
|
353
|
+
return true;
|
|
354
|
+
for (const waitMs of DELEGATION_POLL_BACKOFF_MS) {
|
|
355
|
+
await sleep(waitMs);
|
|
356
|
+
outcome = await scanForRealDelegationOnce(reaRoot, sessionId, exemptSubagents);
|
|
357
|
+
if (outcome === 'delegated')
|
|
358
|
+
return true;
|
|
359
|
+
if (outcome === 'unreadable')
|
|
360
|
+
return true;
|
|
361
|
+
}
|
|
284
362
|
return false;
|
|
285
363
|
}
|
|
286
364
|
/**
|
|
@@ -379,7 +457,7 @@ export async function computeDelegationAdvisory(options) {
|
|
|
379
457
|
// verbatim (or `'unknown'` for untagged sessions), so the `stateKey`
|
|
380
458
|
// filesystem form would never match (see the comment at the
|
|
381
459
|
// `auditSessionId` / `stateKey` split above).
|
|
382
|
-
const delegated = await sessionHasRealDelegation(reaRoot, auditSessionId, policy.exemptSubagents);
|
|
460
|
+
const delegated = await sessionHasRealDelegation(reaRoot, auditSessionId, policy.exemptSubagents, options.sleepOverride);
|
|
383
461
|
if (delegated) {
|
|
384
462
|
// Session DID delegate to a real specialist — no nudge warranted.
|
|
385
463
|
// We deliberately do NOT write the `.fired` sentinel here: if the
|
package/dist/cli/doctor.d.ts
CHANGED
|
@@ -97,6 +97,159 @@ export declare function checkPrepareCommitMsgHook(baseDir: string): CheckResult;
|
|
|
97
97
|
* (the doctor then short-circuits past every Codex check).
|
|
98
98
|
*/
|
|
99
99
|
export declare function checkCodexBinaryOnPath(): CheckResult;
|
|
100
|
+
/**
|
|
101
|
+
* Probe interface accepted by the policy-reader tier checks. Each
|
|
102
|
+
* field is optional; when omitted the check uses the real-environment
|
|
103
|
+
* default (PATH walk, spawnSync). Tests inject stubs to get
|
|
104
|
+
* deterministic, fast verdicts without touching the real filesystem or
|
|
105
|
+
* spawning subprocesses.
|
|
106
|
+
*
|
|
107
|
+
* - `cliDistExists` — does the rea CLI binary exist on disk at one of
|
|
108
|
+
* the two shim-resolved paths? Cheap (single `existsSync`). Used to
|
|
109
|
+
* give a clear "missing vs. broken" error message when Tier 1 is
|
|
110
|
+
* unreachable.
|
|
111
|
+
* - `cliInvokable` — does the resolved CLI actually respond to
|
|
112
|
+
* `rea hook policy-get version --json`? The expensive probe (one
|
|
113
|
+
* subprocess spawn). Mirrors EXACTLY what `_pr_load_full_json`
|
|
114
|
+
* does in `hooks/_lib/policy-reader.sh` so a stale or broken dist
|
|
115
|
+
* reports `warn` here — same outcome the real shim ladder would
|
|
116
|
+
* produce. Codex round-1 P2 (2026-05-16).
|
|
117
|
+
* - `python3OnPath` / `python3PyYamlReachable` — Tier 2 reachability.
|
|
118
|
+
* `python3PyYamlReachable` returns `true` when both python3 AND the
|
|
119
|
+
* `yaml` stdlib (PyYAML) can be imported (the Tier 2 loader needs
|
|
120
|
+
* both).
|
|
121
|
+
* - `awkOnPath` / `jqOnPath` — Tier 3 + JSON-accelerator reachability.
|
|
122
|
+
*/
|
|
123
|
+
export interface PolicyReaderProbes {
|
|
124
|
+
cliDistExists?: (baseDir: string) => boolean;
|
|
125
|
+
cliInvokable?: (baseDir: string) => boolean;
|
|
126
|
+
python3OnPath?: () => string | null;
|
|
127
|
+
/**
|
|
128
|
+
* 0.40.0 charter item 3 — accepts the consumer's `baseDir` so the
|
|
129
|
+
* probe can thread it as `cwd` to the spawned `python3 -c` process.
|
|
130
|
+
* Pre-fix, the spawn happened in doctor's own cwd, which meant the
|
|
131
|
+
* sys.path scrub (which removes "", ".", CWD, realpath(CWD) to
|
|
132
|
+
* mirror policy-reader.sh's defense against a malicious repo-local
|
|
133
|
+
* `./yaml.py`) operated on the wrong directory when `rea doctor`
|
|
134
|
+
* was invoked from outside the consumer tree (e.g. `cd /tmp && rea
|
|
135
|
+
* doctor --base-dir /Users/.../consumer-repo`).
|
|
136
|
+
*
|
|
137
|
+
* Probes that don't care about cwd (test stubs, fakes) can simply
|
|
138
|
+
* ignore the argument; the default production probe uses it.
|
|
139
|
+
*/
|
|
140
|
+
python3PyYamlReachable?: (baseDir: string) => boolean;
|
|
141
|
+
awkOnPath?: () => string | null;
|
|
142
|
+
jqOnPath?: () => string | null;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Tier 1 — `rea hook policy-get`. Reachable when the rea CLI is
|
|
146
|
+
* present at one of the two shim-resolved paths (consumer install OR
|
|
147
|
+
* dogfood `dist/`) AND actually responds to `rea hook policy-get
|
|
148
|
+
* version --json`. The shim ladder uses that exact invocation as its
|
|
149
|
+
* Tier 1 probe (see `_pr_load_full_json` in `hooks/_lib/policy-reader.sh`);
|
|
150
|
+
* mirroring it here means a stale or broken dist (file present but
|
|
151
|
+
* import-throws / postinstall failed) reports `warn` — matching the
|
|
152
|
+
* real fall-through to Tier 2/3 the shim would do at runtime.
|
|
153
|
+
*
|
|
154
|
+
* Three states:
|
|
155
|
+
* - dist present + CLI responds → `pass` (canonical loader fully wired).
|
|
156
|
+
* - dist present + CLI broken → `warn` (stale build, missing native
|
|
157
|
+
* module, broken postinstall — needs `pnpm build` / `rea upgrade`).
|
|
158
|
+
* - dist absent → `warn` (not installed; Tier 2/3 still cover).
|
|
159
|
+
*
|
|
160
|
+
* Codex round-1 P2 (2026-05-16) replaced the file-existence-only
|
|
161
|
+
* probe with this CLI-invocation probe — pre-fix, a consumer with
|
|
162
|
+
* `dist/cli/index.js` present but throwing on load would see `pass`
|
|
163
|
+
* here while every real shim would silently fall through.
|
|
164
|
+
*/
|
|
165
|
+
export declare function checkPolicyReaderTier1(baseDir: string, probes?: PolicyReaderProbes): CheckResult;
|
|
166
|
+
/**
|
|
167
|
+
* Tier 2 — python3 + stdlib `yaml` (PyYAML). Handles BOTH block-form
|
|
168
|
+
* and flow-form YAML; the practical floor when Tier 1 is unreachable.
|
|
169
|
+
*
|
|
170
|
+
* Three states:
|
|
171
|
+
* - python3 present + PyYAML importable → `pass`.
|
|
172
|
+
* - python3 present, PyYAML missing → `warn` (the loader will fall
|
|
173
|
+
* through to Tier 3, which only handles block-form).
|
|
174
|
+
* - python3 absent → `warn` (same Tier 3 fall-through).
|
|
175
|
+
*
|
|
176
|
+
* Never `fail` — Tier 3 is still a valid floor for block-form policy.
|
|
177
|
+
* The warning highlights the silent no-op risk for flow-form lookups
|
|
178
|
+
* when CLI is also unreachable.
|
|
179
|
+
*/
|
|
180
|
+
export declare function checkPolicyReaderTier2(baseDir: string, probes?: PolicyReaderProbes): CheckResult;
|
|
181
|
+
/**
|
|
182
|
+
* Tier 3 — awk block-form parser. Last-resort no-dep fallback.
|
|
183
|
+
* Practically always present (POSIX requirement).
|
|
184
|
+
*
|
|
185
|
+
* 0.40.0 charter item 2 — conditional verdict, refined by codex
|
|
186
|
+
* round 1 P2:
|
|
187
|
+
* - awk present → `pass`
|
|
188
|
+
* - awk absent AND Tier 2 reachable → `warn`
|
|
189
|
+
* (Tier 2 implies python3, which is a list-walker)
|
|
190
|
+
* - awk absent AND Tier 1 reachable AND a list walker
|
|
191
|
+
* (jq OR python3) is on PATH → `warn`
|
|
192
|
+
* - awk absent AND Tier 1 reachable BUT no list walker → `fail`
|
|
193
|
+
* (codex round 1 P2 — list-valued policy reads silently
|
|
194
|
+
* fail-closed even though scalar reads work, so the
|
|
195
|
+
* downgrade-to-warn is misleading; doctor would exit 0 on a
|
|
196
|
+
* broken install)
|
|
197
|
+
* - awk absent AND no other tier reachable → `fail`
|
|
198
|
+
*
|
|
199
|
+
* Pre-fix the absent-awk branch always returned `fail` — but when
|
|
200
|
+
* Tier 1 (rea CLI) AND/OR Tier 2 (python3+PyYAML) are reachable AND
|
|
201
|
+
* the list walker exists, the operator's effective floor is fine even
|
|
202
|
+
* without awk; Tier 3 is the LAST fallback, not a hard requirement.
|
|
203
|
+
* The summary check (`checkPolicyReaderTierSummary`) already
|
|
204
|
+
* aggregates correctly; this per-tier verdict now reflects the same
|
|
205
|
+
* severity logic so an operator who reads ONLY the Tier 3 row isn't
|
|
206
|
+
* misled into thinking the install is broken on a perfectly-
|
|
207
|
+
* functional box that has python3 + jq + the rea CLI all wired but
|
|
208
|
+
* happens to lack awk.
|
|
209
|
+
*
|
|
210
|
+
* Takes `baseDir` so it can evaluate Tier 1's two-stage check (dist
|
|
211
|
+
* present + CLI invokable) and Tier 2's reachability. Probes are
|
|
212
|
+
* threaded through identically.
|
|
213
|
+
*/
|
|
214
|
+
export declare function checkPolicyReaderTier3(baseDir: string, probes?: PolicyReaderProbes): CheckResult;
|
|
215
|
+
/**
|
|
216
|
+
* jq — optional accelerator used by Tier 1/2's JSON subtree parsing.
|
|
217
|
+
* Per the 0.37.0 round-1 P2 fix the helper falls back to a python3
|
|
218
|
+
* walker when jq is absent (still correct, just an extra spawn per
|
|
219
|
+
* leaf). `warn` when missing so operators know they're paying the
|
|
220
|
+
* latency cost.
|
|
221
|
+
*
|
|
222
|
+
* `info` when present — no action needed, just confirming the
|
|
223
|
+
* accelerator is wired.
|
|
224
|
+
*/
|
|
225
|
+
export declare function checkPolicyReaderJq(probes?: PolicyReaderProbes): CheckResult;
|
|
226
|
+
/**
|
|
227
|
+
* Summary roll-up: which tiers are reachable, what's the effective
|
|
228
|
+
* floor when the CLI is unreachable, and is flow-form policy at risk
|
|
229
|
+
* of silent no-op.
|
|
230
|
+
*
|
|
231
|
+
* Four verdicts:
|
|
232
|
+
* - `pass` — Tier 1 OR Tier 2 reachable AND a JSON list walker
|
|
233
|
+
* (jq or python3) is available. Flow-form scalars AND flow-form
|
|
234
|
+
* arrays both parse correctly via whichever tier is hit first.
|
|
235
|
+
* - `warn` (flow-form-lists-degraded) — Tier 1 reachable but neither
|
|
236
|
+
* jq nor python3 on PATH. Flow-form SCALARS parse correctly via
|
|
237
|
+
* the CLI's JSON output, but `policy_reader_get_list` cannot
|
|
238
|
+
* iterate the resulting JSON array — it falls through to Tier 3
|
|
239
|
+
* awk, which silently misses flow-form arrays like
|
|
240
|
+
* `blocked_paths: [.env, ...]`. Codex round-1 P2 (2026-05-16).
|
|
241
|
+
* - `warn` (Tier-3-only) — Only Tier 3 (awk) reachable. Block-form
|
|
242
|
+
* policy works; flow-form scalars AND arrays both silently no-op
|
|
243
|
+
* on every shim fallback.
|
|
244
|
+
* - `fail` — No tiers reachable. Shims fail closed on every policy
|
|
245
|
+
* lookup. (Practically requires losing awk too — see Tier 3.)
|
|
246
|
+
*
|
|
247
|
+
* Tier 2 implies python3 is on PATH (it's the interpreter that runs
|
|
248
|
+
* the loader), so when Tier 2 is reachable the list-iteration python3
|
|
249
|
+
* fallback is also reachable — only the Tier-1-without-list-walker
|
|
250
|
+
* shape can produce the degraded warning.
|
|
251
|
+
*/
|
|
252
|
+
export declare function checkPolicyReaderTierSummary(baseDir: string, probes?: PolicyReaderProbes): CheckResult;
|
|
100
253
|
/**
|
|
101
254
|
* Translate a `CodexProbeState` into two doctor CheckResults: one for
|
|
102
255
|
* responsiveness (pass/warn) and one informational line about the last
|
package/dist/cli/doctor.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { execFileSync } from 'node:child_process';
|
|
1
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
2
2
|
import crypto from 'node:crypto';
|
|
3
3
|
import fs from 'node:fs';
|
|
4
4
|
import fsPromises from 'node:fs/promises';
|
|
@@ -950,6 +950,649 @@ export function checkCodexBinaryOnPath() {
|
|
|
950
950
|
'To disable the push-gate instead, set policy.review.codex_required: false in .rea/policy.yaml.',
|
|
951
951
|
};
|
|
952
952
|
}
|
|
953
|
+
/**
|
|
954
|
+
* 0.39.0 — `rea doctor` visibility into the 4-tier shim policy reader.
|
|
955
|
+
*
|
|
956
|
+
* `hooks/_lib/policy-reader.sh` (introduced 0.37.0) is the unified
|
|
957
|
+
* shim-side policy reader. Each shim sources it and reads policy
|
|
958
|
+
* values via a graceful-degradation ladder:
|
|
959
|
+
*
|
|
960
|
+
* Tier 1: `rea hook policy-get --json` — canonical TS loader.
|
|
961
|
+
* Tier 2: `python3` + stdlib `yaml` (PyYAML).
|
|
962
|
+
* Tier 3: `awk` block-form parser (last resort, block-form ONLY).
|
|
963
|
+
* Tier 4: fail-closed sentinel.
|
|
964
|
+
*
|
|
965
|
+
* The Tier 1/2 path handles BOTH block-form and flow-form YAML
|
|
966
|
+
* (`local_review: { mode: off }`). Tier 3 only handles block-form, so
|
|
967
|
+
* a consumer with flow-form policy AND no reachable CLI AND no python3
|
|
968
|
+
* silently no-ops on every shim fallback path — exactly the split-brain
|
|
969
|
+
* 0.37.0 set out to fix. The risk persists if the consumer's box lacks
|
|
970
|
+
* the upper tiers; operators currently have no way to see which tier
|
|
971
|
+
* their shims would actually use.
|
|
972
|
+
*
|
|
973
|
+
* These doctor checks surface the tier inventory so the gap is visible
|
|
974
|
+
* before it produces a silent regression. Each check is independent and
|
|
975
|
+
* uses optional probe-function injection so unit tests can simulate any
|
|
976
|
+
* combination of tier availability without manipulating PATH.
|
|
977
|
+
*
|
|
978
|
+
* Pure environment probes — no policy read, no shim spawn. Doctor calls
|
|
979
|
+
* each one in turn and the summary check aggregates the verdicts.
|
|
980
|
+
*/
|
|
981
|
+
/**
|
|
982
|
+
* Cheap PATH walker — returns the absolute path of `bin` when found
|
|
983
|
+
* with an executable bit set, or `null` otherwise. Mirrors
|
|
984
|
+
* `resolveCodexBinary`'s POSIX path but generalized for any binary.
|
|
985
|
+
*
|
|
986
|
+
* Windows path: walks PATHEXT and the bare name like `resolveCodexBinary`
|
|
987
|
+
* does for `codex`. Most consumer machines that run the shim ladder are
|
|
988
|
+
* POSIX (the shim is bash); Windows support is best-effort.
|
|
989
|
+
*/
|
|
990
|
+
function resolveBinaryOnPath(bin) {
|
|
991
|
+
const isWindows = process.platform === 'win32';
|
|
992
|
+
const pathEnv = process.env.PATH ?? process.env.Path ?? '';
|
|
993
|
+
if (pathEnv.length === 0)
|
|
994
|
+
return null;
|
|
995
|
+
const sep = isWindows ? ';' : ':';
|
|
996
|
+
const entries = pathEnv.split(sep).filter((p) => p.length > 0);
|
|
997
|
+
if (isWindows) {
|
|
998
|
+
const pathExt = (process.env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD').split(';');
|
|
999
|
+
for (const dir of entries) {
|
|
1000
|
+
for (const ext of pathExt) {
|
|
1001
|
+
const candidate = path.join(dir, `${bin}${ext}`);
|
|
1002
|
+
try {
|
|
1003
|
+
const st = fs.statSync(candidate);
|
|
1004
|
+
if (st.isFile())
|
|
1005
|
+
return candidate;
|
|
1006
|
+
}
|
|
1007
|
+
catch {
|
|
1008
|
+
// not present — keep walking
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
const bare = path.join(dir, bin);
|
|
1012
|
+
try {
|
|
1013
|
+
const st = fs.statSync(bare);
|
|
1014
|
+
if (st.isFile())
|
|
1015
|
+
return bare;
|
|
1016
|
+
}
|
|
1017
|
+
catch {
|
|
1018
|
+
// not present — keep walking
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
return null;
|
|
1022
|
+
}
|
|
1023
|
+
for (const dir of entries) {
|
|
1024
|
+
const candidate = path.join(dir, bin);
|
|
1025
|
+
try {
|
|
1026
|
+
const st = fs.statSync(candidate);
|
|
1027
|
+
if (st.isFile() && (st.mode & 0o111) !== 0)
|
|
1028
|
+
return candidate;
|
|
1029
|
+
}
|
|
1030
|
+
catch {
|
|
1031
|
+
// not present — keep walking
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
return null;
|
|
1035
|
+
}
|
|
1036
|
+
/** Resolve the shim's preferred CLI dist path, or null when no layout matches. */
|
|
1037
|
+
function resolveCliDistPath(baseDir) {
|
|
1038
|
+
// The shim's Tier 1 path requires the rea CLI binary to be
|
|
1039
|
+
// resolvable from the consumer's tree. Two layouts cover every
|
|
1040
|
+
// real-world install:
|
|
1041
|
+
// 1. <baseDir>/node_modules/@bookedsolid/rea/dist/cli/index.js
|
|
1042
|
+
// (consumer install — `pnpm i @bookedsolid/rea`)
|
|
1043
|
+
// 2. <baseDir>/dist/cli/index.js
|
|
1044
|
+
// (rea-repo dogfood after `pnpm build`)
|
|
1045
|
+
// Either presence is enough for the shim's sandboxed CLI resolution
|
|
1046
|
+
// (see hooks/_lib/shim-runtime.sh).
|
|
1047
|
+
const consumerCli = path.join(baseDir, 'node_modules', '@bookedsolid', 'rea', 'dist', 'cli', 'index.js');
|
|
1048
|
+
if (fs.existsSync(consumerCli))
|
|
1049
|
+
return consumerCli;
|
|
1050
|
+
const dogfoodCli = path.join(baseDir, 'dist', 'cli', 'index.js');
|
|
1051
|
+
if (fs.existsSync(dogfoodCli))
|
|
1052
|
+
return dogfoodCli;
|
|
1053
|
+
return null;
|
|
1054
|
+
}
|
|
1055
|
+
function defaultCliDistExists(baseDir) {
|
|
1056
|
+
return resolveCliDistPath(baseDir) !== null;
|
|
1057
|
+
}
|
|
1058
|
+
/**
|
|
1059
|
+
* Sandbox check — mirrors `shim_sandbox_check` in
|
|
1060
|
+
* `hooks/_lib/shim-runtime.sh` (introduced 0.38.0).
|
|
1061
|
+
*
|
|
1062
|
+
* Codex round-2 P1 (2026-05-16): the pre-fix `defaultCliInvokable`
|
|
1063
|
+
* spawned the resolved CLI WITHOUT this validation. An attacker who
|
|
1064
|
+
* could plant a `dist/cli/index.js` outside `realpath(baseDir)` (via
|
|
1065
|
+
* a symlink) — OR plant one inside the tree but WITHOUT an ancestor
|
|
1066
|
+
* `package.json` whose `name === "@bookedsolid/rea"` — would have
|
|
1067
|
+
* their forged code executed every time doctor probed Tier 1
|
|
1068
|
+
* reachability. The real shim chain refuses these layouts; the
|
|
1069
|
+
* doctor probe MUST refuse them identically so it cannot be tricked
|
|
1070
|
+
* into reporting `pass` on a layout the production shims would
|
|
1071
|
+
* never trust.
|
|
1072
|
+
*
|
|
1073
|
+
* Returns `true` when:
|
|
1074
|
+
* 1. `realpath(cli)` resolves AND lives INSIDE `realpath(baseDir)`
|
|
1075
|
+
* (no symlink-out of the project)
|
|
1076
|
+
* 2. an ancestor `package.json` (walking up from
|
|
1077
|
+
* `dirname(dirname(dirname(real)))` — i.e. the package root for
|
|
1078
|
+
* a `dist/cli/index.js` shape) has `name === "@bookedsolid/rea"`
|
|
1079
|
+
* (max 20 hops)
|
|
1080
|
+
*
|
|
1081
|
+
* Returns `false` on any failure (realpath miss, escapes-project,
|
|
1082
|
+
* missing/wrong package.json). Doctor's Tier 1 check then treats a
|
|
1083
|
+
* sandbox-failed CLI identically to a CLI-missing layout — both
|
|
1084
|
+
* report `warn` ("Tier 1 unreachable") rather than `pass`.
|
|
1085
|
+
*
|
|
1086
|
+
* This mirrors the bash logic EXACTLY:
|
|
1087
|
+
* - `fs.realpathSync` on both paths (no symlink slippage)
|
|
1088
|
+
* - path-prefix containment via `realProj + sep` (so a sibling
|
|
1089
|
+
* directory whose name STARTS with realProj cannot match)
|
|
1090
|
+
* - ancestor walk capped at 20 hops with a filesystem-root break
|
|
1091
|
+
* (`cur === path.dirname(cur)`)
|
|
1092
|
+
* - JSON parse failures in any candidate `package.json` are
|
|
1093
|
+
* swallowed and the walk continues (mirrors the bash `try/catch`)
|
|
1094
|
+
*
|
|
1095
|
+
* Kept in sync with the bash helper: any future change to the
|
|
1096
|
+
* sandbox-check shape (e.g. CLI-shape enforcement) MUST be applied
|
|
1097
|
+
* in both places.
|
|
1098
|
+
*/
|
|
1099
|
+
function sandboxCheckCli(cli, baseDir) {
|
|
1100
|
+
let real;
|
|
1101
|
+
let realProj;
|
|
1102
|
+
try {
|
|
1103
|
+
real = fs.realpathSync(cli);
|
|
1104
|
+
}
|
|
1105
|
+
catch {
|
|
1106
|
+
return false;
|
|
1107
|
+
}
|
|
1108
|
+
try {
|
|
1109
|
+
realProj = fs.realpathSync(baseDir);
|
|
1110
|
+
}
|
|
1111
|
+
catch {
|
|
1112
|
+
return false;
|
|
1113
|
+
}
|
|
1114
|
+
const sep = path.sep;
|
|
1115
|
+
const projWithSep = realProj.endsWith(sep) ? realProj : realProj + sep;
|
|
1116
|
+
if (!(real === realProj || real.startsWith(projWithSep))) {
|
|
1117
|
+
return false;
|
|
1118
|
+
}
|
|
1119
|
+
// Walk ancestor directories from the package root (3 levels up
|
|
1120
|
+
// from a `<root>/dist/cli/index.js` shape) looking for a
|
|
1121
|
+
// package.json whose `name === "@bookedsolid/rea"`. Max 20 hops
|
|
1122
|
+
// with a filesystem-root break so we never loop forever on
|
|
1123
|
+
// exotic mount layouts.
|
|
1124
|
+
let cur = path.dirname(path.dirname(path.dirname(real)));
|
|
1125
|
+
for (let i = 0; i < 20 && cur && cur !== path.dirname(cur); i += 1) {
|
|
1126
|
+
const pj = path.join(cur, 'package.json');
|
|
1127
|
+
if (fs.existsSync(pj)) {
|
|
1128
|
+
try {
|
|
1129
|
+
const data = JSON.parse(fs.readFileSync(pj, 'utf8'));
|
|
1130
|
+
if (data && data.name === '@bookedsolid/rea') {
|
|
1131
|
+
return true;
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
catch {
|
|
1135
|
+
// keep walking — malformed package.json on the path is not fatal
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
cur = path.dirname(cur);
|
|
1139
|
+
}
|
|
1140
|
+
return false;
|
|
1141
|
+
}
|
|
1142
|
+
/**
|
|
1143
|
+
* Codex round-1 P2 (2026-05-16): the file-presence probe alone allows
|
|
1144
|
+
* a stale or broken dist (e.g. an upgrade-lagged consumer who never
|
|
1145
|
+
* re-ran `pnpm build`) to falsely report `pass` while the real shim
|
|
1146
|
+
* ladder in `hooks/_lib/policy-reader.sh` would skip Tier 1 because
|
|
1147
|
+
* `rea hook policy-get version --json` exits non-zero. We mirror that
|
|
1148
|
+
* exact probe verbatim — same key (`version`), same `--json` flag,
|
|
1149
|
+
* same accept-criterion (exit 0 + non-empty stdout).
|
|
1150
|
+
*
|
|
1151
|
+
* Codex round-2 P1 (2026-05-16): BEFORE invoking the resolved CLI,
|
|
1152
|
+
* apply the same realpath + ancestor-package.json sandbox check the
|
|
1153
|
+
* shims apply in `hooks/_lib/shim-runtime.sh::shim_sandbox_check`.
|
|
1154
|
+
* Pre-fix, an attacker who could plant a `dist/cli/index.js` via a
|
|
1155
|
+
* symlink-out (or without a `@bookedsolid/rea` package.json ancestor)
|
|
1156
|
+
* would have their forged code executed every probe call — yet the
|
|
1157
|
+
* real shim ladder would refuse the same layout. This probe MUST
|
|
1158
|
+
* refuse identically so it cannot mis-report `pass` on an
|
|
1159
|
+
* unsandboxed CLI.
|
|
1160
|
+
*
|
|
1161
|
+
* Returns `true` when the CLI responds correctly; `false` when the
|
|
1162
|
+
* dist is missing OR present-but-broken OR present-but-unsandboxed.
|
|
1163
|
+
* Doctor's Tier 1 check then surfaces the difference: missing →
|
|
1164
|
+
* install guidance; broken/unsandboxed → rebuild guidance. (The
|
|
1165
|
+
* unsandboxed branch deliberately collapses into the "broken" bucket
|
|
1166
|
+
* because either way Tier 1 is unreachable for the shim chain.)
|
|
1167
|
+
*
|
|
1168
|
+
* 8s timeout: the CLI's `hook policy-get` path is local-only (zod
|
|
1169
|
+
* load + YAML parse + JSON walk); on any reasonable machine it
|
|
1170
|
+
* resolves in under a second. The timeout is a defense against a CLI
|
|
1171
|
+
* that hangs on import (a broken postinstall, a missing native module)
|
|
1172
|
+
* rather than a normal-operation budget.
|
|
1173
|
+
*/
|
|
1174
|
+
function defaultCliInvokable(baseDir) {
|
|
1175
|
+
const cli = resolveCliDistPath(baseDir);
|
|
1176
|
+
if (cli === null)
|
|
1177
|
+
return false;
|
|
1178
|
+
// Codex round-2 P1: sandbox check BEFORE spawn. Pre-fix the probe
|
|
1179
|
+
// spawned arbitrary code that happened to live at the expected
|
|
1180
|
+
// shim-resolved path; if a symlink-out OR a missing rea
|
|
1181
|
+
// package.json ancestor existed, we executed an attacker payload.
|
|
1182
|
+
if (!sandboxCheckCli(cli, baseDir))
|
|
1183
|
+
return false;
|
|
1184
|
+
try {
|
|
1185
|
+
const res = spawnSync('node', [cli, 'hook', 'policy-get', 'version', '--json'], {
|
|
1186
|
+
cwd: baseDir,
|
|
1187
|
+
timeout: 8_000,
|
|
1188
|
+
// Tier 1 reads policy.yaml at REA_ROOT — propagate so the probe
|
|
1189
|
+
// honors the same scope the real shim chain would (a missing
|
|
1190
|
+
// `CLAUDE_PROJECT_DIR` falls back to cwd, which doctor has
|
|
1191
|
+
// already set).
|
|
1192
|
+
env: { ...process.env, CLAUDE_PROJECT_DIR: baseDir },
|
|
1193
|
+
encoding: 'utf8',
|
|
1194
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
1195
|
+
});
|
|
1196
|
+
if (res.status !== 0)
|
|
1197
|
+
return false;
|
|
1198
|
+
const out = (res.stdout ?? '').trim();
|
|
1199
|
+
return out.length > 0;
|
|
1200
|
+
}
|
|
1201
|
+
catch {
|
|
1202
|
+
return false;
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
function defaultPython3PyYamlReachable(baseDir) {
|
|
1206
|
+
// The Tier 2 loader runs `python3 -c "import yaml"`. We mirror that
|
|
1207
|
+
// probe verbatim so a `yaml`-installable-but-broken interpreter is
|
|
1208
|
+
// not falsely reported as "reachable". Apply the SAME env scrub
|
|
1209
|
+
// (PYTHONPATH / PYTHONHOME / PYTHONSTARTUP unset, PYTHONSAFEPATH=1)
|
|
1210
|
+
// that policy-reader.sh applies, so a repo-local `yaml.py` cannot
|
|
1211
|
+
// shadow the stdlib copy here either — otherwise this probe would
|
|
1212
|
+
// report `true` against a malicious repo where the actual loader
|
|
1213
|
+
// would (correctly) refuse to import.
|
|
1214
|
+
//
|
|
1215
|
+
// Codex round-3 P1 (2026-05-16): `PYTHONSAFEPATH=1` is the env-var
|
|
1216
|
+
// form of `python3 -P` and is only honored on Python 3.11+. On
|
|
1217
|
+
// Python 3.4-3.10 (still installed by default on macOS Big Sur /
|
|
1218
|
+
// Monterey / Ventura, RHEL 8, Ubuntu 20.04, …) it is SILENTLY
|
|
1219
|
+
// IGNORED — meaning the interpreter will still prepend `""`/`"."`/
|
|
1220
|
+
// CWD to `sys.path[0]` and import a repo-local `./yaml.py` instead
|
|
1221
|
+
// of the stdlib copy. The production loader in
|
|
1222
|
+
// hooks/_lib/policy-reader.sh closes this gap with a defensive
|
|
1223
|
+
// sys.path scrub at the top of every `python3 -c` body (see the
|
|
1224
|
+
// "Codex round 2 P1" comment block in policy-reader.sh:256-267).
|
|
1225
|
+
// We MUST mirror that scrub here — without it, a malicious repo
|
|
1226
|
+
// could plant `./yaml.py`, get this probe to report `true`, while
|
|
1227
|
+
// the real Tier 2 loader (which DOES scrub) refuses to import and
|
|
1228
|
+
// falls through to Tier 3. The doctor verdict would then point
|
|
1229
|
+
// operators at the wrong tier when diagnosing a stuck shim.
|
|
1230
|
+
try {
|
|
1231
|
+
const probeEnv = { ...process.env, PYTHONSAFEPATH: '1' };
|
|
1232
|
+
delete probeEnv['PYTHONPATH'];
|
|
1233
|
+
delete probeEnv['PYTHONHOME'];
|
|
1234
|
+
delete probeEnv['PYTHONSTARTUP'];
|
|
1235
|
+
// Same scrub shape as policy-reader.sh's Tier 2 body — strip
|
|
1236
|
+
// empty/CWD entries from sys.path BEFORE the `import yaml` so
|
|
1237
|
+
// the probe and the production loader produce the same answer
|
|
1238
|
+
// on Python 3.4-3.10.
|
|
1239
|
+
const probeBody = [
|
|
1240
|
+
'import sys',
|
|
1241
|
+
'import os',
|
|
1242
|
+
'_cwd = os.getcwd()',
|
|
1243
|
+
'_cwd_real = os.path.realpath(_cwd)',
|
|
1244
|
+
'sys.path[:] = [p for p in sys.path if p not in ("", ".", _cwd, _cwd_real)]',
|
|
1245
|
+
'import yaml',
|
|
1246
|
+
].join('\n');
|
|
1247
|
+
// 0.40.0 charter item 3 — thread `baseDir` as cwd so the sys.path
|
|
1248
|
+
// scrub above strips THIS consumer's repo root (the directory the
|
|
1249
|
+
// production shim chain runs from), not doctor's own cwd. Pre-fix,
|
|
1250
|
+
// `rea doctor --base-dir <consumer>` invoked from `/tmp/foo` would
|
|
1251
|
+
// scrub against `/tmp/foo`, leaving any `<consumer>/yaml.py`
|
|
1252
|
+
// shadowing potential undetected — exactly the multi-repo workflow
|
|
1253
|
+
// every other doctor probe (cliInvokable, …) already handles by
|
|
1254
|
+
// setting cwd to baseDir.
|
|
1255
|
+
const res = spawnSync('python3', ['-c', probeBody], {
|
|
1256
|
+
cwd: baseDir,
|
|
1257
|
+
env: probeEnv,
|
|
1258
|
+
timeout: 5_000,
|
|
1259
|
+
stdio: ['ignore', 'ignore', 'ignore'],
|
|
1260
|
+
});
|
|
1261
|
+
return res.status === 0;
|
|
1262
|
+
}
|
|
1263
|
+
catch {
|
|
1264
|
+
return false;
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
const DEFAULT_PROBES = {
|
|
1268
|
+
cliDistExists: defaultCliDistExists,
|
|
1269
|
+
cliInvokable: defaultCliInvokable,
|
|
1270
|
+
python3OnPath: () => resolveBinaryOnPath('python3'),
|
|
1271
|
+
python3PyYamlReachable: defaultPython3PyYamlReachable,
|
|
1272
|
+
awkOnPath: () => resolveBinaryOnPath('awk'),
|
|
1273
|
+
jqOnPath: () => resolveBinaryOnPath('jq'),
|
|
1274
|
+
};
|
|
1275
|
+
function resolveProbes(probes) {
|
|
1276
|
+
if (probes === undefined)
|
|
1277
|
+
return DEFAULT_PROBES;
|
|
1278
|
+
return { ...DEFAULT_PROBES, ...probes };
|
|
1279
|
+
}
|
|
1280
|
+
/**
|
|
1281
|
+
* Tier 1 — `rea hook policy-get`. Reachable when the rea CLI is
|
|
1282
|
+
* present at one of the two shim-resolved paths (consumer install OR
|
|
1283
|
+
* dogfood `dist/`) AND actually responds to `rea hook policy-get
|
|
1284
|
+
* version --json`. The shim ladder uses that exact invocation as its
|
|
1285
|
+
* Tier 1 probe (see `_pr_load_full_json` in `hooks/_lib/policy-reader.sh`);
|
|
1286
|
+
* mirroring it here means a stale or broken dist (file present but
|
|
1287
|
+
* import-throws / postinstall failed) reports `warn` — matching the
|
|
1288
|
+
* real fall-through to Tier 2/3 the shim would do at runtime.
|
|
1289
|
+
*
|
|
1290
|
+
* Three states:
|
|
1291
|
+
* - dist present + CLI responds → `pass` (canonical loader fully wired).
|
|
1292
|
+
* - dist present + CLI broken → `warn` (stale build, missing native
|
|
1293
|
+
* module, broken postinstall — needs `pnpm build` / `rea upgrade`).
|
|
1294
|
+
* - dist absent → `warn` (not installed; Tier 2/3 still cover).
|
|
1295
|
+
*
|
|
1296
|
+
* Codex round-1 P2 (2026-05-16) replaced the file-existence-only
|
|
1297
|
+
* probe with this CLI-invocation probe — pre-fix, a consumer with
|
|
1298
|
+
* `dist/cli/index.js` present but throwing on load would see `pass`
|
|
1299
|
+
* here while every real shim would silently fall through.
|
|
1300
|
+
*/
|
|
1301
|
+
export function checkPolicyReaderTier1(baseDir, probes) {
|
|
1302
|
+
const label = 'policy-reader Tier 1 (rea CLI)';
|
|
1303
|
+
const p = resolveProbes(probes);
|
|
1304
|
+
const distPresent = p.cliDistExists(baseDir);
|
|
1305
|
+
if (!distPresent) {
|
|
1306
|
+
return {
|
|
1307
|
+
label,
|
|
1308
|
+
status: 'warn',
|
|
1309
|
+
detail: 'rea CLI dist not found at node_modules/@bookedsolid/rea/dist/cli/index.js or <baseDir>/dist/cli/index.js — ' +
|
|
1310
|
+
'shims fall through to Tier 2/3 (works, but loses validated schema + full subtree shapes). ' +
|
|
1311
|
+
'Consumer: run `pnpm i @bookedsolid/rea`. Dogfood: run `pnpm build`.',
|
|
1312
|
+
};
|
|
1313
|
+
}
|
|
1314
|
+
if (!p.cliInvokable(baseDir)) {
|
|
1315
|
+
return {
|
|
1316
|
+
label,
|
|
1317
|
+
status: 'warn',
|
|
1318
|
+
detail: 'rea CLI dist exists but `rea hook policy-get version --json` failed — the dist is ' +
|
|
1319
|
+
'stale or broken (incomplete build, missing native module, broken postinstall). The ' +
|
|
1320
|
+
'shim ladder will skip Tier 1 and fall through to Tier 2/3 just as this probe did. ' +
|
|
1321
|
+
'Run `pnpm build` (dogfood) or `rea upgrade` (consumer) to rebuild.',
|
|
1322
|
+
};
|
|
1323
|
+
}
|
|
1324
|
+
return {
|
|
1325
|
+
label,
|
|
1326
|
+
status: 'pass',
|
|
1327
|
+
detail: 'rea CLI dist responds to `hook policy-get version --json` — canonical loader fully wired',
|
|
1328
|
+
};
|
|
1329
|
+
}
|
|
1330
|
+
/**
|
|
1331
|
+
* Tier 2 — python3 + stdlib `yaml` (PyYAML). Handles BOTH block-form
|
|
1332
|
+
* and flow-form YAML; the practical floor when Tier 1 is unreachable.
|
|
1333
|
+
*
|
|
1334
|
+
* Three states:
|
|
1335
|
+
* - python3 present + PyYAML importable → `pass`.
|
|
1336
|
+
* - python3 present, PyYAML missing → `warn` (the loader will fall
|
|
1337
|
+
* through to Tier 3, which only handles block-form).
|
|
1338
|
+
* - python3 absent → `warn` (same Tier 3 fall-through).
|
|
1339
|
+
*
|
|
1340
|
+
* Never `fail` — Tier 3 is still a valid floor for block-form policy.
|
|
1341
|
+
* The warning highlights the silent no-op risk for flow-form lookups
|
|
1342
|
+
* when CLI is also unreachable.
|
|
1343
|
+
*/
|
|
1344
|
+
export function checkPolicyReaderTier2(baseDir, probes) {
|
|
1345
|
+
const label = 'policy-reader Tier 2 (python3 + PyYAML)';
|
|
1346
|
+
const p = resolveProbes(probes);
|
|
1347
|
+
const py = p.python3OnPath();
|
|
1348
|
+
if (py === null) {
|
|
1349
|
+
return {
|
|
1350
|
+
label,
|
|
1351
|
+
status: 'warn',
|
|
1352
|
+
detail: 'python3 not on PATH — Tier 2 unavailable. Shims fall through to Tier 3 (awk, ' +
|
|
1353
|
+
'block-form only). Flow-form policy (e.g. `local_review: { mode: off }`) silently ' +
|
|
1354
|
+
'no-ops when the rea CLI is also unreachable. Install python3 to close this gap.',
|
|
1355
|
+
};
|
|
1356
|
+
}
|
|
1357
|
+
if (!p.python3PyYamlReachable(baseDir)) {
|
|
1358
|
+
return {
|
|
1359
|
+
label,
|
|
1360
|
+
status: 'warn',
|
|
1361
|
+
detail: `python3 found at ${py} but \`import yaml\` failed — PyYAML missing. ` +
|
|
1362
|
+
'Shims fall through to Tier 3 (awk, block-form only). Flow-form policy silently ' +
|
|
1363
|
+
'no-ops when the rea CLI is also unreachable. Install: `pip3 install pyyaml`.',
|
|
1364
|
+
};
|
|
1365
|
+
}
|
|
1366
|
+
return {
|
|
1367
|
+
label,
|
|
1368
|
+
status: 'pass',
|
|
1369
|
+
detail: `python3 + PyYAML reachable at ${py} — flow-form policy parses correctly`,
|
|
1370
|
+
};
|
|
1371
|
+
}
|
|
1372
|
+
/**
|
|
1373
|
+
* Tier 3 — awk block-form parser. Last-resort no-dep fallback.
|
|
1374
|
+
* Practically always present (POSIX requirement).
|
|
1375
|
+
*
|
|
1376
|
+
* 0.40.0 charter item 2 — conditional verdict, refined by codex
|
|
1377
|
+
* round 1 P2:
|
|
1378
|
+
* - awk present → `pass`
|
|
1379
|
+
* - awk absent AND Tier 2 reachable → `warn`
|
|
1380
|
+
* (Tier 2 implies python3, which is a list-walker)
|
|
1381
|
+
* - awk absent AND Tier 1 reachable AND a list walker
|
|
1382
|
+
* (jq OR python3) is on PATH → `warn`
|
|
1383
|
+
* - awk absent AND Tier 1 reachable BUT no list walker → `fail`
|
|
1384
|
+
* (codex round 1 P2 — list-valued policy reads silently
|
|
1385
|
+
* fail-closed even though scalar reads work, so the
|
|
1386
|
+
* downgrade-to-warn is misleading; doctor would exit 0 on a
|
|
1387
|
+
* broken install)
|
|
1388
|
+
* - awk absent AND no other tier reachable → `fail`
|
|
1389
|
+
*
|
|
1390
|
+
* Pre-fix the absent-awk branch always returned `fail` — but when
|
|
1391
|
+
* Tier 1 (rea CLI) AND/OR Tier 2 (python3+PyYAML) are reachable AND
|
|
1392
|
+
* the list walker exists, the operator's effective floor is fine even
|
|
1393
|
+
* without awk; Tier 3 is the LAST fallback, not a hard requirement.
|
|
1394
|
+
* The summary check (`checkPolicyReaderTierSummary`) already
|
|
1395
|
+
* aggregates correctly; this per-tier verdict now reflects the same
|
|
1396
|
+
* severity logic so an operator who reads ONLY the Tier 3 row isn't
|
|
1397
|
+
* misled into thinking the install is broken on a perfectly-
|
|
1398
|
+
* functional box that has python3 + jq + the rea CLI all wired but
|
|
1399
|
+
* happens to lack awk.
|
|
1400
|
+
*
|
|
1401
|
+
* Takes `baseDir` so it can evaluate Tier 1's two-stage check (dist
|
|
1402
|
+
* present + CLI invokable) and Tier 2's reachability. Probes are
|
|
1403
|
+
* threaded through identically.
|
|
1404
|
+
*/
|
|
1405
|
+
export function checkPolicyReaderTier3(baseDir, probes) {
|
|
1406
|
+
const label = 'policy-reader Tier 3 (awk)';
|
|
1407
|
+
const p = resolveProbes(probes);
|
|
1408
|
+
const awk = p.awkOnPath();
|
|
1409
|
+
if (awk !== null) {
|
|
1410
|
+
return {
|
|
1411
|
+
label,
|
|
1412
|
+
status: 'pass',
|
|
1413
|
+
detail: `awk at ${awk} — block-form fallback available`,
|
|
1414
|
+
};
|
|
1415
|
+
}
|
|
1416
|
+
// 0.40.0 — awk is absent. Decide whether this is `warn` (other tiers
|
|
1417
|
+
// cover) or `fail` (catastrophic — no working policy lookup tier).
|
|
1418
|
+
// Mirror Tier 1's two-stage check (dist + invokable) and Tier 2's
|
|
1419
|
+
// python3 + PyYAML pair so the verdict here matches what the shim
|
|
1420
|
+
// ladder would actually do at runtime.
|
|
1421
|
+
const tier1 = p.cliDistExists(baseDir) && p.cliInvokable(baseDir);
|
|
1422
|
+
const tier2 = p.python3OnPath() !== null && p.python3PyYamlReachable(baseDir);
|
|
1423
|
+
// Codex round 1 P2 (2026-05-16): the downgrade-to-warn branch needs
|
|
1424
|
+
// a list walker too. `policy_reader_get_list` (the helper that reads
|
|
1425
|
+
// list-valued keys like `blocked_paths`) iterates the parsed JSON
|
|
1426
|
+
// array via jq OR python3, falling back to Tier 3 awk for inline
|
|
1427
|
+
// arrays. With awk gone AND no jq AND no python3, list-valued
|
|
1428
|
+
// policy reads silently fail-closed even when Tier 1 is reachable —
|
|
1429
|
+
// `blocked-paths-bash-gate.sh` etc. would see an EMPTY blocked-paths
|
|
1430
|
+
// set and stop enforcing entries the operator declared. Pre-fix
|
|
1431
|
+
// this concrete shape (cliInvokable + no python3 + no jq + no awk)
|
|
1432
|
+
// returned `warn` and the doctor exited 0 on a broken install.
|
|
1433
|
+
// Post-fix the downgrade requires a list walker; otherwise we stay
|
|
1434
|
+
// on `fail`. Tier 2 implies python3 on PATH (the interpreter that
|
|
1435
|
+
// ran PyYAML), so Tier 2 always brings list-walker support — no
|
|
1436
|
+
// additional check needed for the Tier-2 branch.
|
|
1437
|
+
const py = p.python3OnPath();
|
|
1438
|
+
const listWalker = p.jqOnPath() !== null || py !== null;
|
|
1439
|
+
if (tier2 || (tier1 && listWalker)) {
|
|
1440
|
+
const reachable = [];
|
|
1441
|
+
if (tier1)
|
|
1442
|
+
reachable.push('Tier 1 (rea CLI)');
|
|
1443
|
+
if (tier2)
|
|
1444
|
+
reachable.push('Tier 2 (python3+PyYAML)');
|
|
1445
|
+
return {
|
|
1446
|
+
label,
|
|
1447
|
+
status: 'warn',
|
|
1448
|
+
detail: `awk not on PATH — Tier 3 (block-form fallback) unreachable. ${reachable.join(' and ')} ` +
|
|
1449
|
+
'still cover the shim ladder, so policy lookups continue to work; this is a ' +
|
|
1450
|
+
'soft degradation, not a hard failure. Install awk (`mawk`, `gawk`, or `nawk`) ' +
|
|
1451
|
+
'to restore the last-resort fallback.',
|
|
1452
|
+
};
|
|
1453
|
+
}
|
|
1454
|
+
// Codex round 1 P2: separate "no list walker" diagnosis from the
|
|
1455
|
+
// catastrophic "no tier at all" case. Tier 1 reachable but no jq
|
|
1456
|
+
// AND no python3 AND no awk means list-valued policy reads
|
|
1457
|
+
// fail-closed silently — distinct from the truly-empty
|
|
1458
|
+
// no-CLI-no-python-no-awk shape, and worth a precise remediation.
|
|
1459
|
+
if (tier1) {
|
|
1460
|
+
return {
|
|
1461
|
+
label,
|
|
1462
|
+
status: 'fail',
|
|
1463
|
+
detail: 'awk not on PATH AND neither jq nor python3 is on PATH — Tier 1 (rea CLI) parses ' +
|
|
1464
|
+
'flow-form scalars, but `policy_reader_get_list` cannot iterate list-valued keys ' +
|
|
1465
|
+
'(e.g. `blocked_paths: [.env, ...]`) without jq, python3, OR awk to walk the ' +
|
|
1466
|
+
'resulting JSON arrays. Affected hooks (`blocked-paths-bash-gate.sh`, ' +
|
|
1467
|
+
'`blocked-paths-enforcer.sh`, …) see an EMPTY list and silently stop enforcing. ' +
|
|
1468
|
+
'Install awk OR jq OR python3 to restore list-iteration.',
|
|
1469
|
+
};
|
|
1470
|
+
}
|
|
1471
|
+
return {
|
|
1472
|
+
label,
|
|
1473
|
+
status: 'fail',
|
|
1474
|
+
detail: 'awk not on PATH — no fallback tier reachable. If the rea CLI and python3+PyYAML are ' +
|
|
1475
|
+
'ALSO unreachable, every shim policy lookup fails closed. This is unusual; awk is a ' +
|
|
1476
|
+
'POSIX requirement. Install awk (`mawk`, `gawk`, or `nawk`).',
|
|
1477
|
+
};
|
|
1478
|
+
}
|
|
1479
|
+
/**
|
|
1480
|
+
* jq — optional accelerator used by Tier 1/2's JSON subtree parsing.
|
|
1481
|
+
* Per the 0.37.0 round-1 P2 fix the helper falls back to a python3
|
|
1482
|
+
* walker when jq is absent (still correct, just an extra spawn per
|
|
1483
|
+
* leaf). `warn` when missing so operators know they're paying the
|
|
1484
|
+
* latency cost.
|
|
1485
|
+
*
|
|
1486
|
+
* `info` when present — no action needed, just confirming the
|
|
1487
|
+
* accelerator is wired.
|
|
1488
|
+
*/
|
|
1489
|
+
export function checkPolicyReaderJq(probes) {
|
|
1490
|
+
const label = 'policy-reader jq (JSON accelerator)';
|
|
1491
|
+
const p = resolveProbes(probes);
|
|
1492
|
+
const jq = p.jqOnPath();
|
|
1493
|
+
if (jq !== null) {
|
|
1494
|
+
return {
|
|
1495
|
+
label,
|
|
1496
|
+
status: 'pass',
|
|
1497
|
+
detail: `jq at ${jq} — used by Tier 1/2 JSON subtree walking`,
|
|
1498
|
+
};
|
|
1499
|
+
}
|
|
1500
|
+
return {
|
|
1501
|
+
label,
|
|
1502
|
+
status: 'warn',
|
|
1503
|
+
detail: 'jq not on PATH — Tier 1/2 fall back to a python3 JSON walker per leaf (correct, ' +
|
|
1504
|
+
'just slower). Install jq to reduce per-leaf spawn overhead.',
|
|
1505
|
+
};
|
|
1506
|
+
}
|
|
1507
|
+
/**
|
|
1508
|
+
* Summary roll-up: which tiers are reachable, what's the effective
|
|
1509
|
+
* floor when the CLI is unreachable, and is flow-form policy at risk
|
|
1510
|
+
* of silent no-op.
|
|
1511
|
+
*
|
|
1512
|
+
* Four verdicts:
|
|
1513
|
+
* - `pass` — Tier 1 OR Tier 2 reachable AND a JSON list walker
|
|
1514
|
+
* (jq or python3) is available. Flow-form scalars AND flow-form
|
|
1515
|
+
* arrays both parse correctly via whichever tier is hit first.
|
|
1516
|
+
* - `warn` (flow-form-lists-degraded) — Tier 1 reachable but neither
|
|
1517
|
+
* jq nor python3 on PATH. Flow-form SCALARS parse correctly via
|
|
1518
|
+
* the CLI's JSON output, but `policy_reader_get_list` cannot
|
|
1519
|
+
* iterate the resulting JSON array — it falls through to Tier 3
|
|
1520
|
+
* awk, which silently misses flow-form arrays like
|
|
1521
|
+
* `blocked_paths: [.env, ...]`. Codex round-1 P2 (2026-05-16).
|
|
1522
|
+
* - `warn` (Tier-3-only) — Only Tier 3 (awk) reachable. Block-form
|
|
1523
|
+
* policy works; flow-form scalars AND arrays both silently no-op
|
|
1524
|
+
* on every shim fallback.
|
|
1525
|
+
* - `fail` — No tiers reachable. Shims fail closed on every policy
|
|
1526
|
+
* lookup. (Practically requires losing awk too — see Tier 3.)
|
|
1527
|
+
*
|
|
1528
|
+
* Tier 2 implies python3 is on PATH (it's the interpreter that runs
|
|
1529
|
+
* the loader), so when Tier 2 is reachable the list-iteration python3
|
|
1530
|
+
* fallback is also reachable — only the Tier-1-without-list-walker
|
|
1531
|
+
* shape can produce the degraded warning.
|
|
1532
|
+
*/
|
|
1533
|
+
export function checkPolicyReaderTierSummary(baseDir, probes) {
|
|
1534
|
+
const label = 'policy-reader effective floor';
|
|
1535
|
+
const p = resolveProbes(probes);
|
|
1536
|
+
// Mirror Tier 1's two-stage check — dist present + CLI invokable.
|
|
1537
|
+
// A stale/broken dist that fails the invokable probe is treated as
|
|
1538
|
+
// "Tier 1 not reachable" so the summary matches what the shim
|
|
1539
|
+
// ladder would actually do at runtime.
|
|
1540
|
+
const tier1 = p.cliDistExists(baseDir) && p.cliInvokable(baseDir);
|
|
1541
|
+
const py = p.python3OnPath();
|
|
1542
|
+
const tier2 = py !== null && p.python3PyYamlReachable(baseDir);
|
|
1543
|
+
const tier3 = p.awkOnPath() !== null;
|
|
1544
|
+
const jq = p.jqOnPath();
|
|
1545
|
+
// List iteration after Tier 1/2 needs jq OR python3 to walk the
|
|
1546
|
+
// JSON. Tier 2 implies python3 on PATH (the interpreter that ran
|
|
1547
|
+
// the loader); so the only "lists broken" shape is Tier 1 reachable
|
|
1548
|
+
// but neither jq nor python3 on PATH.
|
|
1549
|
+
const listWalker = jq !== null || py !== null;
|
|
1550
|
+
const reachable = [];
|
|
1551
|
+
if (tier1)
|
|
1552
|
+
reachable.push('Tier 1 (CLI)');
|
|
1553
|
+
if (tier2)
|
|
1554
|
+
reachable.push('Tier 2 (python3+PyYAML)');
|
|
1555
|
+
if (tier3)
|
|
1556
|
+
reachable.push('Tier 3 (awk)');
|
|
1557
|
+
if (tier1 || tier2) {
|
|
1558
|
+
if (!listWalker) {
|
|
1559
|
+
// Tier 1 + no python3/jq. flow-form scalars work; flow-form
|
|
1560
|
+
// arrays silently no-op via Tier 3 fallthrough. (Tier 2 path
|
|
1561
|
+
// is unreachable here because Tier 2 requires python3.)
|
|
1562
|
+
return {
|
|
1563
|
+
label,
|
|
1564
|
+
status: 'warn',
|
|
1565
|
+
detail: `${reachable.join(', ')} reachable — flow-form scalars parse via Tier 1 CLI, ` +
|
|
1566
|
+
'BUT neither jq nor python3 is on PATH so `policy_reader_get_list` cannot iterate ' +
|
|
1567
|
+
'the resulting JSON arrays. Flow-form list policy (e.g. `blocked_paths: [.env, ...]`) ' +
|
|
1568
|
+
'silently falls through to Tier 3 awk and misses inline arrays. Install jq ' +
|
|
1569
|
+
'(`brew install jq` / `apt-get install jq`) or python3 to close the gap.',
|
|
1570
|
+
};
|
|
1571
|
+
}
|
|
1572
|
+
return {
|
|
1573
|
+
label,
|
|
1574
|
+
status: 'pass',
|
|
1575
|
+
detail: `${reachable.join(', ')} reachable — flow-form policy parses correctly`,
|
|
1576
|
+
};
|
|
1577
|
+
}
|
|
1578
|
+
if (tier3) {
|
|
1579
|
+
return {
|
|
1580
|
+
label,
|
|
1581
|
+
status: 'warn',
|
|
1582
|
+
detail: 'only Tier 3 (awk, block-form ONLY) reachable — flow-form policy ' +
|
|
1583
|
+
'(e.g. `local_review: { mode: off }`, `blocked_paths: [.env, ...]`) silently ' +
|
|
1584
|
+
'no-ops on every shim fallback path. Restore Tier 1 (rea CLI dist) or Tier 2 ' +
|
|
1585
|
+
'(python3 + PyYAML) to close the gap.',
|
|
1586
|
+
};
|
|
1587
|
+
}
|
|
1588
|
+
return {
|
|
1589
|
+
label,
|
|
1590
|
+
status: 'fail',
|
|
1591
|
+
detail: 'no policy-reader tier reachable — every shim policy lookup fails closed. ' +
|
|
1592
|
+
'Install at least one of: rea CLI dist (Tier 1), python3 + PyYAML (Tier 2), ' +
|
|
1593
|
+
'awk (Tier 3).',
|
|
1594
|
+
};
|
|
1595
|
+
}
|
|
953
1596
|
/**
|
|
954
1597
|
* Translate a `CodexProbeState` into two doctor CheckResults: one for
|
|
955
1598
|
* responsiveness (pass/warn) and one informational line about the last
|
|
@@ -1457,9 +2100,15 @@ export function collectChecks(baseDir, codexProbeState, prePushState, options =
|
|
|
1457
2100
|
const policyPath = reaPath(baseDir, POLICY_FILE);
|
|
1458
2101
|
const registryPath = reaPath(baseDir, REGISTRY_FILE);
|
|
1459
2102
|
const reaDirPath = path.join(baseDir, REA_DIR);
|
|
2103
|
+
// Run checkPolicyParses up-front so we can both push its result and
|
|
2104
|
+
// use the verdict to gate the 0.39.0 policy-reader tier checks below.
|
|
2105
|
+
// A malformed policy file should NOT trigger the tier-reachability
|
|
2106
|
+
// probes — those reports would misattribute a parse failure to a
|
|
2107
|
+
// runtime/install problem (codex round-3 P2, 2026-05-16).
|
|
2108
|
+
const policyParsesResult = checkPolicyParses(baseDir, policyPath);
|
|
1460
2109
|
const checks = [
|
|
1461
2110
|
checkFileExists('.rea/ directory exists', reaDirPath, true),
|
|
1462
|
-
|
|
2111
|
+
policyParsesResult,
|
|
1463
2112
|
checkRegistryParses(baseDir, registryPath),
|
|
1464
2113
|
checkAgentsPresent(baseDir),
|
|
1465
2114
|
checkHooksInstalled(baseDir),
|
|
@@ -1482,6 +2131,30 @@ export function collectChecks(baseDir, codexProbeState, prePushState, options =
|
|
|
1482
2131
|
// went through in 0.29.0 → 0.30.0, after 4 release cycles of
|
|
1483
2132
|
// propagation).
|
|
1484
2133
|
checkDelegationAdvisoryHookRegistered(baseDir),
|
|
2134
|
+
// 0.39.0 — policy-reader tier visibility. Surfaces which tiers of
|
|
2135
|
+
// the 4-tier `hooks/_lib/policy-reader.sh` ladder are reachable in
|
|
2136
|
+
// this environment so operators can SEE whether flow-form policy
|
|
2137
|
+
// would silently no-op when the CLI is unreachable.
|
|
2138
|
+
//
|
|
2139
|
+
// Codex round-3 P2 (2026-05-16): gated on `policyParsesResult`
|
|
2140
|
+
// being a `pass` — NOT just `existsSync(policyPath)`. A
|
|
2141
|
+
// malformed policy file (present but unparseable) should report
|
|
2142
|
+
// exactly ONE failure — the parse-error from `checkPolicyParses`
|
|
2143
|
+
// above — and not also light up the tier probes with misleading
|
|
2144
|
+
// "Tier 1 dist exists but failed" or summary "ladder degraded"
|
|
2145
|
+
// diagnostics that misattribute a config bug to an
|
|
2146
|
+
// install/runtime problem. The parse-failure row already tells
|
|
2147
|
+
// the operator the right thing to fix; adding more downstream
|
|
2148
|
+
// noise would obscure it.
|
|
2149
|
+
...(policyParsesResult.status === 'pass'
|
|
2150
|
+
? [
|
|
2151
|
+
checkPolicyReaderTier1(baseDir),
|
|
2152
|
+
checkPolicyReaderTier2(baseDir),
|
|
2153
|
+
checkPolicyReaderTier3(baseDir),
|
|
2154
|
+
checkPolicyReaderJq(),
|
|
2155
|
+
checkPolicyReaderTierSummary(baseDir),
|
|
2156
|
+
]
|
|
2157
|
+
: []),
|
|
1485
2158
|
];
|
|
1486
2159
|
// Non-git escape hatch: when `.git/` is absent, both git-hook checks are
|
|
1487
2160
|
// meaningless (commit-msg + pre-push can't be invoked without git). Emit
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bookedsolid/rea",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.40.0",
|
|
4
4
|
"description": "Agentic governance layer for Claude Code — policy enforcement, hook-based safety gates, audit logging, and Codex-integrated adversarial review for AI-assisted projects",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Booked Solid Technology <oss@bookedsolid.tech> (https://bookedsolid.tech)",
|