@bookedsolid/rea 0.41.0 → 0.42.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/MIGRATING.md +139 -0
- package/dist/cli/audit-summary.d.ts +15 -0
- package/dist/cli/audit-summary.js +94 -38
- package/dist/cli/doctor.d.ts +44 -4
- package/dist/cli/doctor.js +141 -37
- package/dist/cli/upgrade-check.d.ts +38 -0
- package/dist/cli/upgrade-check.js +93 -7
- package/dist/cli/upgrade.js +42 -0
- package/package.json +1 -1
package/MIGRATING.md
CHANGED
|
@@ -389,6 +389,145 @@ per finding and produces more consistent verdicts — fewer
|
|
|
389
389
|
same-code-different-verdict round-trips. Trade-off is push-gate
|
|
390
390
|
latency.
|
|
391
391
|
|
|
392
|
+
## Node-binary hook scanner (added in 0.32.0)
|
|
393
|
+
|
|
394
|
+
Pre-0.32.0 every `.claude/hooks/*.sh` carried the full gate body in
|
|
395
|
+
bash. Adversarial review consistently caught bash-only edge cases that
|
|
396
|
+
were structurally unfixable in shell — multi-line awk encodings,
|
|
397
|
+
ANSI-C escapes, deep nested-shell decoding. 0.32.0 pivoted the entire
|
|
398
|
+
hook surface to a Node-binary scanner: hooks became thin shims (~20-80
|
|
399
|
+
LOC each) that delegate the actual gate work to `rea hook <name>` —
|
|
400
|
+
which runs the canonical scanner inside `dist/cli/index.js`.
|
|
401
|
+
|
|
402
|
+
**Consumer impact:**
|
|
403
|
+
|
|
404
|
+
- Run `pnpm install` (or `npm install`) after upgrading to 0.32.0+ so
|
|
405
|
+
`dist/cli/index.js` is built and the shims have something to call.
|
|
406
|
+
- `.claude/hooks/*.sh` files on disk are noticeably smaller after
|
|
407
|
+
`rea upgrade`; this is the canonical post-0.32.0 shape, not a
|
|
408
|
+
truncation. `rea doctor` will tell you if a shim is the wrong
|
|
409
|
+
vintage.
|
|
410
|
+
- The audit trail is unchanged: hooks still emit `rea.bash_scan`-class
|
|
411
|
+
records to `.rea/audit.jsonl` with the same field shape.
|
|
412
|
+
- Performance is materially better — single Node startup per scan
|
|
413
|
+
instead of an awk/sed pipeline per pattern.
|
|
414
|
+
|
|
415
|
+
If `rea doctor` reports `policy-reader Tier 1 (rea CLI)` as `warn:
|
|
416
|
+
dist not found`, you skipped the build step. Run `pnpm install`.
|
|
417
|
+
|
|
418
|
+
## Graceful-degradation policy reader (added in 0.37.0)
|
|
419
|
+
|
|
420
|
+
The shimmed hooks need to read `.rea/policy.yaml` from a bash context
|
|
421
|
+
that may or may not have python3, jq, or rea's CLI on PATH. 0.37.0
|
|
422
|
+
formalized a 4-tier reader ladder:
|
|
423
|
+
|
|
424
|
+
1. **Tier 1** — `rea hook policy-get` (requires `dist/cli/index.js`)
|
|
425
|
+
2. **Tier 2** — `python3 + stdlib yaml` (PyYAML) — handles flow-form
|
|
426
|
+
3. **Tier 3** — POSIX `awk` block-form parser (the always-available floor)
|
|
427
|
+
4. **Fail-closed** — every tier unreachable: shim refuses the action
|
|
428
|
+
|
|
429
|
+
Tier 1 → 2 → 3 fallthrough is silent at hook-runtime; that's
|
|
430
|
+
intentional (graceful degradation), but means an unreachable Tier 1 +
|
|
431
|
+
unreachable Tier 2 can silently downgrade flow-form policy lookups to
|
|
432
|
+
block-form-only. `rea doctor` (0.39.0+) surfaces all three tier
|
|
433
|
+
reachabilities so you can spot the gap.
|
|
434
|
+
|
|
435
|
+
**Consumer impact:**
|
|
436
|
+
|
|
437
|
+
- If you use FLOW-form YAML for any policy block (e.g.
|
|
438
|
+
`blocked_paths: [.env, ".env.*"]`), make sure either the rea CLI
|
|
439
|
+
dist is present OR `python3 + PyYAML` is installed. With ONLY awk
|
|
440
|
+
reachable, flow-form lookups silently no-op on every shim
|
|
441
|
+
fallthrough path and your declared policy isn't enforced.
|
|
442
|
+
- Install PyYAML on CI runners: `pip3 install pyyaml`. On consumer
|
|
443
|
+
developer machines, it's almost always already present (macOS ships
|
|
444
|
+
it; major Linux distros bundle it with python3).
|
|
445
|
+
- For list-valued policy keys (`blocked_paths`, `protected_writes`),
|
|
446
|
+
the loader iterates the resulting JSON via jq OR python3. Have at
|
|
447
|
+
least one on PATH or `rea doctor` (0.42.0+) will report `fail` on
|
|
448
|
+
the `policy-reader Tier 3 (awk)` row with a list-walker-specific
|
|
449
|
+
remediation message.
|
|
450
|
+
|
|
451
|
+
## Shim runtime extraction (added in 0.38.0)
|
|
452
|
+
|
|
453
|
+
Cosmetic-only refactor: every `.claude/hooks/*.sh` shim now sources
|
|
454
|
+
`hooks/_lib/shim-runtime.sh` for shared boilerplate (env loading,
|
|
455
|
+
tier classification, audit-event emission). **No consumer action
|
|
456
|
+
required** — the change is byte-equivalent at the gate surface. New
|
|
457
|
+
shims you author can adopt the same runtime by sourcing the shared
|
|
458
|
+
helper; documented in the shim authoring guide.
|
|
459
|
+
|
|
460
|
+
## Doctor health surfaces for the policy reader (added in 0.39.0)
|
|
461
|
+
|
|
462
|
+
`rea doctor` gained explicit reachability checks for the 4-tier
|
|
463
|
+
ladder, the dist invokability probe, and a sandbox-containment check
|
|
464
|
+
on the resolved `dist/cli/index.js` path. Output lines you'll see:
|
|
465
|
+
|
|
466
|
+
- `policy-reader Tier 1 (rea CLI)` — pass/warn based on dist
|
|
467
|
+
presence + actual invocation
|
|
468
|
+
- `policy-reader Tier 2 (python3 + PyYAML)` — pass/warn based on
|
|
469
|
+
python3 + import yaml succeeding
|
|
470
|
+
- `policy-reader Tier 3 (awk)` — pass when awk present; warn or
|
|
471
|
+
fail conditional on whether other tiers cover the gap (0.40.0
|
|
472
|
+
refined the verdict logic; 0.42.0 hardened the list-walker
|
|
473
|
+
predicate)
|
|
474
|
+
- `policy-reader effective floor` — summary verdict across all three
|
|
475
|
+
- `policy-reader jq (JSON accelerator)` — info-level, calls out
|
|
476
|
+
Tier 1/2 perf when jq is absent
|
|
477
|
+
|
|
478
|
+
**Consumer action:** run `rea doctor` after each upgrade. The lines
|
|
479
|
+
above accurately reflect what your shims will do at runtime — a
|
|
480
|
+
`warn` is not a hard failure but signals a posture worth knowing
|
|
481
|
+
about (e.g. flow-form policy silently no-ops). A `fail` on any tier
|
|
482
|
+
row IS a hard failure that the doctor exits non-zero on.
|
|
483
|
+
|
|
484
|
+
## Upgrade preview + audit summary (added in 0.41.0)
|
|
485
|
+
|
|
486
|
+
Two new consumer-facing commands rolled out:
|
|
487
|
+
|
|
488
|
+
### `rea upgrade --check`
|
|
489
|
+
|
|
490
|
+
Dry-run preview of what `rea upgrade` would write, file-by-file, with
|
|
491
|
+
unified diffs. JSON output via `--json`. Always exits 0 — this is a
|
|
492
|
+
preview, not a gate. Use it before any non-trivial rea upgrade to
|
|
493
|
+
sanity-check the diff:
|
|
494
|
+
|
|
495
|
+
```bash
|
|
496
|
+
rea upgrade --check # human-readable table + diffs
|
|
497
|
+
rea upgrade --check --json # machine-readable for CI
|
|
498
|
+
rea upgrade --check --no-diff # counts + paths only
|
|
499
|
+
```
|
|
500
|
+
|
|
501
|
+
0.42.0 added the same settings-schema validation that `rea upgrade`
|
|
502
|
+
itself runs — if the merged settings would fail schema parse (typo'd
|
|
503
|
+
hook event, malformed hook command, …), the preview surfaces the
|
|
504
|
+
`WOULD REFUSE` message rather than promising a write the real
|
|
505
|
+
upgrade would refuse. The `settings_validation` field in the JSON
|
|
506
|
+
output carries the structured outcome.
|
|
507
|
+
|
|
508
|
+
### `rea audit summary`
|
|
509
|
+
|
|
510
|
+
High-level rollup of the audit log: counts by `tool_name`, `tier`,
|
|
511
|
+
`status`, `session`, the time window covered, and a sample-verified
|
|
512
|
+
chain-integrity check. `--since <duration>` (e.g. `24h`, `7d`, `2w`)
|
|
513
|
+
narrows to a recent window:
|
|
514
|
+
|
|
515
|
+
```bash
|
|
516
|
+
rea audit summary # all time
|
|
517
|
+
rea audit summary --since 24h # last 24 hours
|
|
518
|
+
rea audit summary --since 7d --json # last week, JSON
|
|
519
|
+
```
|
|
520
|
+
|
|
521
|
+
0.42.0 hardened the rotated-file walk: pre-0.42.0 `--since` pruned
|
|
522
|
+
rotated audit segments by filename stamp, which is wall-clock at the
|
|
523
|
+
rotation INSTANT — not the earliest record contained. A rotated file
|
|
524
|
+
from N days ago can contain records from N+M days ago when the
|
|
525
|
+
rotation cycle was long, so pruning by filename silently dropped
|
|
526
|
+
in-window records. Post-0.42.0 the walker reads every rotated file
|
|
527
|
+
under `--since` and lets the per-record timestamp filter drop the
|
|
528
|
+
out-of-window entries. Correctness over micro-optimization;
|
|
529
|
+
`rea audit summary` performance is unchanged in practice.
|
|
530
|
+
|
|
392
531
|
## Policy knobs worth setting
|
|
393
532
|
|
|
394
533
|
For consumers with a long-running migration branch (>30 commits since
|
|
@@ -81,6 +81,21 @@ export interface AuditSummaryResult {
|
|
|
81
81
|
window_end: string | null;
|
|
82
82
|
/** Absolute paths of audit files walked. */
|
|
83
83
|
files_scanned: string[];
|
|
84
|
+
/**
|
|
85
|
+
* 0.42.0 codex round 4 P2 + round 6 P2 (2026-05-16) — reserved for
|
|
86
|
+
* future use; ALWAYS EMPTY in 0.42.0. The original intent (round 4)
|
|
87
|
+
* was to soft-skip rotated segments that the operator could not
|
|
88
|
+
* read (e.g. EACCES/EPERM after a backup restore). Round 6 showed
|
|
89
|
+
* the soft-skip was unsound: without per-segment time-range
|
|
90
|
+
* metadata we cannot prove a skipped file is out-of-scope for the
|
|
91
|
+
* `--since` window, so a silent skip risks an undercount + a
|
|
92
|
+
* misleading `chain_integrity: ok`. The current implementation
|
|
93
|
+
* therefore throws on any non-ENOENT read error; this field is
|
|
94
|
+
* kept in the public schema so a future release that ships
|
|
95
|
+
* per-segment time-range metadata can populate it without breaking
|
|
96
|
+
* JSON consumers.
|
|
97
|
+
*/
|
|
98
|
+
unreadable_segments: string[];
|
|
84
99
|
total_events: number;
|
|
85
100
|
by_tool_name: Record<string, number>;
|
|
86
101
|
by_tier: Record<string, number>;
|
|
@@ -118,48 +118,49 @@ export function parseDurationSeconds(raw) {
|
|
|
118
118
|
* PLUS the current `audit.jsonl`. Round-1 P2: the prior shape
|
|
119
119
|
* dropped rotated history silently while the header still
|
|
120
120
|
* advertised "all time", undercounting long-lived repos.
|
|
121
|
-
* - `windowStart` set: walk
|
|
122
|
-
* timestamp
|
|
123
|
-
*
|
|
124
|
-
*
|
|
121
|
+
* - `windowStart` set: walk EVERY rotated file. The per-record
|
|
122
|
+
* timestamp filter inside `computeAuditSummary` then drops
|
|
123
|
+
* out-of-window records during the scan. 0.41.0 round-3 P2 +
|
|
124
|
+
* 0.42.0 charter item 3: rotated filenames are NOT authoritative
|
|
125
|
+
* for "earliest contained record" — they are wall-clock at the
|
|
126
|
+
* ROTATION INSTANT, which can be days after the file's earliest
|
|
127
|
+
* contents when the rotation size cap is reached late. Pruning
|
|
128
|
+
* by filename therefore drops in-window records from
|
|
129
|
+
* conservatively-rotated logs (a rotated file from 7 days ago can
|
|
130
|
+
* still contain records from 14 days ago because the previous
|
|
131
|
+
* rotation event was 14 days ago). The cost of walking every
|
|
132
|
+
* rotated segment under `--since` is bounded by the rotation cap
|
|
133
|
+
* × number of segments — comfortably manageable in the
|
|
134
|
+
* summary-rollup setting where we already read every byte for
|
|
135
|
+
* the in-window scan; the win is correctness.
|
|
125
136
|
*
|
|
126
|
-
* Sort order is timestamp-ascending; the current
|
|
127
|
-
* always appended last (it is the newest segment
|
|
137
|
+
* Sort order is timestamp-ascending (by FILENAME stamp); the current
|
|
138
|
+
* `audit.jsonl` is always appended last (it is the newest segment
|
|
139
|
+
* of the chain).
|
|
128
140
|
*/
|
|
129
141
|
async function resolveSummaryFileWalk(baseDir, windowStart) {
|
|
130
142
|
const reaDir = path.join(baseDir, REA_DIR);
|
|
131
143
|
const currentAudit = path.join(reaDir, AUDIT_FILE);
|
|
132
144
|
const files = [];
|
|
133
145
|
const rotated = await listRotatedAuditFiles(reaDir);
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
return Number.isNaN(d.getTime()) ? null : d;
|
|
153
|
-
};
|
|
154
|
-
const cutoffIdx = rotated.findIndex((n) => {
|
|
155
|
-
const d = stampToDate(n);
|
|
156
|
-
return d !== null && d >= windowStart;
|
|
157
|
-
});
|
|
158
|
-
const startIdx = cutoffIdx === -1 ? Math.max(0, rotated.length - 1) : Math.max(0, cutoffIdx - 1);
|
|
159
|
-
for (const name of rotated.slice(startIdx)) {
|
|
160
|
-
files.push(path.join(reaDir, name));
|
|
161
|
-
}
|
|
162
|
-
}
|
|
146
|
+
// Both `windowStart === null` and `windowStart` set: walk every
|
|
147
|
+
// rotated segment. Pre-0.42.0 the `windowStart` branch attempted to
|
|
148
|
+
// prune rotated files by their filename stamp ("rotated at >=
|
|
149
|
+
// windowStart minus one buffer file"). That was wrong: the filename
|
|
150
|
+
// stamp marks the ROTATION event, not the earliest record contained
|
|
151
|
+
// in the file. A rotated file's records can pre-date its filename
|
|
152
|
+
// stamp by days when the previous rotation cycle was long. Walking
|
|
153
|
+
// every rotated file and letting the per-record `timestamp >=
|
|
154
|
+
// windowStart` filter inside `computeAuditSummary` decide is the
|
|
155
|
+
// only correct approach: we never falsely drop an in-window record
|
|
156
|
+
// because of where it happens to live on disk. Reference:
|
|
157
|
+
// 0.41.0 round-3 P2 + 0.42.0 charter item 3.
|
|
158
|
+
//
|
|
159
|
+
// `windowStart === null` (no --since) already walks every rotated
|
|
160
|
+
// segment — same code path.
|
|
161
|
+
void windowStart; // intentionally unused — full-walk is correct in both modes
|
|
162
|
+
for (const name of rotated)
|
|
163
|
+
files.push(path.join(reaDir, name));
|
|
163
164
|
try {
|
|
164
165
|
const stat = await fs.stat(currentAudit);
|
|
165
166
|
if (stat.isFile())
|
|
@@ -261,16 +262,52 @@ export async function computeAuditSummary(options = {}) {
|
|
|
261
262
|
let latest = null;
|
|
262
263
|
// We only feed in-window records to the chain-sample check.
|
|
263
264
|
const inWindowRecords = [];
|
|
265
|
+
// 0.42.0 codex round 4 P2 + round 6 P2 (2026-05-16): reserved for
|
|
266
|
+
// future per-segment time-range metadata that would let us prove a
|
|
267
|
+
// skipped file is out of scope. Always empty under 0.42.0 — see
|
|
268
|
+
// the AuditSummaryResult.unreadable_segments docstring.
|
|
269
|
+
const unreadableSegments = [];
|
|
270
|
+
// We rebuild the actually-read file list as we go so the summary
|
|
271
|
+
// never claims to have scanned a file that was silently skipped.
|
|
272
|
+
// (Currently identical to `files` minus ENOENT entries since every
|
|
273
|
+
// other read error throws — kept as a separate accumulator so the
|
|
274
|
+
// shape stays correct when the future `unreadable_segments`
|
|
275
|
+
// soft-skip path lands.)
|
|
276
|
+
const actuallyScanned = [];
|
|
264
277
|
for (const filePath of files) {
|
|
265
278
|
let raw;
|
|
266
279
|
try {
|
|
267
280
|
raw = await fs.readFile(filePath, 'utf8');
|
|
268
281
|
}
|
|
269
282
|
catch (e) {
|
|
270
|
-
|
|
283
|
+
const errno = e.code;
|
|
284
|
+
if (errno === 'ENOENT')
|
|
271
285
|
continue;
|
|
272
|
-
|
|
286
|
+
// 0.42.0 codex round 4 P2 + round 5 P2 + round 6 P2 (2026-05-16):
|
|
287
|
+
// earlier rounds attempted to soft-skip unreadable rotations to
|
|
288
|
+
// accommodate backup-restore artifacts. Round 6 caught that the
|
|
289
|
+
// soft-skip is unsound: `resolveSummaryFileWalk` now enqueues
|
|
290
|
+
// every rotated segment under `--since` (filename-stamp pruning
|
|
291
|
+
// was correctly removed because the stamp marks the rotation
|
|
292
|
+
// event, not the earliest record contained), so we CANNOT prove
|
|
293
|
+
// an unreadable file is out of scope without reading it. A
|
|
294
|
+
// silent skip would mean `rea audit summary` could exit 0 with
|
|
295
|
+
// an undercount AND `chain_integrity: ok` while real in-window
|
|
296
|
+
// records went uncounted.
|
|
297
|
+
//
|
|
298
|
+
// Throwing with a precise, actionable error is the right call:
|
|
299
|
+
// the operator can chmod the file, move it out of .rea/, or
|
|
300
|
+
// delete it. `unreadable_segments` in the result is reserved
|
|
301
|
+
// for the never-reached future case where we can prove a file
|
|
302
|
+
// is genuinely out of scope (we'd need rotation start/end
|
|
303
|
+
// metadata for that — out of scope here).
|
|
304
|
+
throw new Error(`rea audit summary: cannot read ${filePath} (${errno ?? 'unknown errno'}). ` +
|
|
305
|
+
`An unreadable audit segment may contain in-window records, so the summary ` +
|
|
306
|
+
`would be silently incomplete. Fix permissions (e.g. \`chmod u+r ${filePath}\`), ` +
|
|
307
|
+
`or move the file out of \`.rea/\` if you no longer need it. The current ` +
|
|
308
|
+
`audit.jsonl is always required.`);
|
|
273
309
|
}
|
|
310
|
+
actuallyScanned.push(filePath);
|
|
274
311
|
for (const line of raw.split('\n')) {
|
|
275
312
|
if (line.length === 0)
|
|
276
313
|
continue;
|
|
@@ -322,7 +359,12 @@ export async function computeAuditSummary(options = {}) {
|
|
|
322
359
|
window_seconds: windowSeconds,
|
|
323
360
|
window_start: windowStart !== null ? windowStart.toISOString() : null,
|
|
324
361
|
window_end: windowEnd !== null ? windowEnd.toISOString() : null,
|
|
325
|
-
|
|
362
|
+
// 0.42.0 codex round 4 P2: report only the files actually read.
|
|
363
|
+
// Unreadable rotations are reported separately under
|
|
364
|
+
// `unreadable_segments` so consumers can tell the difference
|
|
365
|
+
// between "scanned and empty" and "skipped because permissions".
|
|
366
|
+
files_scanned: actuallyScanned,
|
|
367
|
+
unreadable_segments: unreadableSegments,
|
|
326
368
|
total_events: totalEvents,
|
|
327
369
|
by_tool_name: byToolName,
|
|
328
370
|
by_tier: byTier,
|
|
@@ -387,6 +429,13 @@ export function renderAuditSummary(result) {
|
|
|
387
429
|
lines.push('(no audit files found — has `rea serve` ever run?)');
|
|
388
430
|
lines.push('');
|
|
389
431
|
}
|
|
432
|
+
// 0.42.0 codex round 4 P2: even in the zero-events early-return,
|
|
433
|
+
// surface unreadable segments so the operator sees the gap.
|
|
434
|
+
if (result.unreadable_segments.length > 0) {
|
|
435
|
+
lines.push(`unreadable rotated segments: ${String(result.unreadable_segments.length)} ` +
|
|
436
|
+
`(see stderr for paths; fix permissions and re-run to include them)`);
|
|
437
|
+
lines.push('');
|
|
438
|
+
}
|
|
390
439
|
return lines.join('\n');
|
|
391
440
|
}
|
|
392
441
|
const total = result.total_events;
|
|
@@ -409,6 +458,13 @@ export function renderAuditSummary(result) {
|
|
|
409
458
|
: 'unsampled (no records in window)';
|
|
410
459
|
lines.push(`chain integrity: ${chainLabel}`);
|
|
411
460
|
lines.push(`files scanned: ${String(result.files_scanned.length)}`);
|
|
461
|
+
// 0.42.0 codex round 4 P2 (2026-05-16): surface unreadable rotated
|
|
462
|
+
// segments so an operator scanning the rendered summary doesn't
|
|
463
|
+
// miss a skipped archive that the JSON consumers can see.
|
|
464
|
+
if (result.unreadable_segments.length > 0) {
|
|
465
|
+
lines.push(`unreadable rotated segments: ${String(result.unreadable_segments.length)} ` +
|
|
466
|
+
`(see stderr for paths; fix permissions and re-run to include them)`);
|
|
467
|
+
}
|
|
412
468
|
lines.push('');
|
|
413
469
|
return lines.join('\n');
|
|
414
470
|
}
|
package/dist/cli/doctor.d.ts
CHANGED
|
@@ -138,6 +138,26 @@ export interface PolicyReaderProbes {
|
|
|
138
138
|
* ignore the argument; the default production probe uses it.
|
|
139
139
|
*/
|
|
140
140
|
python3PyYamlReachable?: (baseDir: string) => boolean;
|
|
141
|
+
/**
|
|
142
|
+
* 0.42.0 codex round 5 P2 (2026-05-16) — execution probe for the
|
|
143
|
+
* python3 list-walker branch in `policy_reader_get_list`. That
|
|
144
|
+
* branch needs to spawn `python3 -c "..."` with `import json` from
|
|
145
|
+
* stdlib; PyYAML is irrelevant. The check is execution-based (not
|
|
146
|
+
* PATH-only) because a `python3` symlink can resolve on PATH but
|
|
147
|
+
* fail to start in the current sandbox (dangling pyenv/asdf stub,
|
|
148
|
+
* permission-denied interpreter, missing dynamic libs). A PATH-only
|
|
149
|
+
* check would let the doctor declare `warn` on a box where the
|
|
150
|
+
* shim will actually fall through to Tier 3 — masking a real
|
|
151
|
+
* enforcement gap for list-valued policy keys.
|
|
152
|
+
*
|
|
153
|
+
* The probe runs `python3 -c "import json; print('ok')"` with the
|
|
154
|
+
* same env scrub as the PyYAML probe (PYTHONPATH/PYTHONHOME/
|
|
155
|
+
* PYTHONSTARTUP unset, PYTHONSAFEPATH=1, sys.path scrubbed) so a
|
|
156
|
+
* malicious repo cannot plant a `./json.py` that shadows stdlib
|
|
157
|
+
* and falsely report `true` while the real loader (which scrubs)
|
|
158
|
+
* fails.
|
|
159
|
+
*/
|
|
160
|
+
python3ListWalkerReachable?: (baseDir: string) => boolean;
|
|
141
161
|
awkOnPath?: () => string | null;
|
|
142
162
|
jqOnPath?: () => string | null;
|
|
143
163
|
}
|
|
@@ -183,12 +203,12 @@ export declare function checkPolicyReaderTier2(baseDir: string, probes?: PolicyR
|
|
|
183
203
|
* Practically always present (POSIX requirement).
|
|
184
204
|
*
|
|
185
205
|
* 0.40.0 charter item 2 — conditional verdict, refined by codex
|
|
186
|
-
* round 1 P2:
|
|
206
|
+
* round 1 P2 (0.40.0) and round 2 P2 (0.42.0):
|
|
187
207
|
* - awk present → `pass`
|
|
188
208
|
* - awk absent AND Tier 2 reachable → `warn`
|
|
189
209
|
* (Tier 2 implies python3, which is a list-walker)
|
|
190
210
|
* - awk absent AND Tier 1 reachable AND a list walker
|
|
191
|
-
* (jq OR
|
|
211
|
+
* (jq OR full Tier-2 reachable) is usable → `warn`
|
|
192
212
|
* - awk absent AND Tier 1 reachable BUT no list walker → `fail`
|
|
193
213
|
* (codex round 1 P2 — list-valued policy reads silently
|
|
194
214
|
* fail-closed even though scalar reads work, so the
|
|
@@ -207,9 +227,29 @@ export declare function checkPolicyReaderTier2(baseDir: string, probes?: PolicyR
|
|
|
207
227
|
* functional box that has python3 + jq + the rea CLI all wired but
|
|
208
228
|
* happens to lack awk.
|
|
209
229
|
*
|
|
230
|
+
* List-iteration semantic (clarifying note for codex round 2 P2,
|
|
231
|
+
* 2026-05-16): `policy_reader_get_list` in
|
|
232
|
+
* `hooks/_lib/policy-reader.sh` walks the cached subtree JSON via
|
|
233
|
+
* `jq` OR `python3` (stdlib-only — `json` module, no PyYAML import).
|
|
234
|
+
* PyYAML is only needed for Tier 2 itself (YAML PARSING into JSON),
|
|
235
|
+
* NOT for iterating the already-parsed JSON arrays at list-read time.
|
|
236
|
+
*
|
|
237
|
+
* Codex round 5 P2 (2026-05-16): the "list walker" predicate uses
|
|
238
|
+
* `python3ListWalkerReachable` — an EXECUTION probe that actually
|
|
239
|
+
* spawns `python3 -c "import json"` — instead of `python3OnPath`. A
|
|
240
|
+
* PATH-only check passes for broken pyenv/asdf shims, dangling
|
|
241
|
+
* symlinks, and sandboxed environments where the interpreter cannot
|
|
242
|
+
* start; in those cases the shim's list-walker branch would actually
|
|
243
|
+
* fail and `blocked_paths`/`protected_writes` enforcement would
|
|
244
|
+
* silently break while doctor reported `warn`. The execution probe
|
|
245
|
+
* mirrors `defaultPython3PyYamlReachable` exactly but swaps the
|
|
246
|
+
* `import yaml` for `import json` so it's not gated on PyYAML
|
|
247
|
+
* availability (which is irrelevant to list iteration).
|
|
248
|
+
*
|
|
210
249
|
* Takes `baseDir` so it can evaluate Tier 1's two-stage check (dist
|
|
211
|
-
* present + CLI invokable)
|
|
212
|
-
* threaded through
|
|
250
|
+
* present + CLI invokable), Tier 2's reachability, and the
|
|
251
|
+
* list-walker execution probe. All probes are threaded through
|
|
252
|
+
* identically.
|
|
213
253
|
*/
|
|
214
254
|
export declare function checkPolicyReaderTier3(baseDir: string, probes?: PolicyReaderProbes): CheckResult;
|
|
215
255
|
/**
|
package/dist/cli/doctor.js
CHANGED
|
@@ -1264,18 +1264,74 @@ function defaultPython3PyYamlReachable(baseDir) {
|
|
|
1264
1264
|
return false;
|
|
1265
1265
|
}
|
|
1266
1266
|
}
|
|
1267
|
+
/**
|
|
1268
|
+
* 0.42.0 codex round 5 P2 (2026-05-16) — execution probe for the
|
|
1269
|
+
* python3 list-walker. Mirrors `defaultPython3PyYamlReachable` exactly
|
|
1270
|
+
* but swaps the `import yaml` for `import json` (the actual stdlib
|
|
1271
|
+
* module the shim's list-walker branch imports). Spawning the
|
|
1272
|
+
* interpreter end-to-end catches the broken-symlink / unreachable-
|
|
1273
|
+
* shim case that a bare PATH check misses.
|
|
1274
|
+
*/
|
|
1275
|
+
function defaultPython3ListWalkerReachable(baseDir) {
|
|
1276
|
+
try {
|
|
1277
|
+
const probeEnv = { ...process.env, PYTHONSAFEPATH: '1' };
|
|
1278
|
+
delete probeEnv['PYTHONPATH'];
|
|
1279
|
+
delete probeEnv['PYTHONHOME'];
|
|
1280
|
+
delete probeEnv['PYTHONSTARTUP'];
|
|
1281
|
+
// Same sys.path scrub as the production loader, applied before
|
|
1282
|
+
// `import json`. `json` is stdlib so a malicious `./json.py`
|
|
1283
|
+
// attack would matter the same way `./yaml.py` does.
|
|
1284
|
+
const probeBody = [
|
|
1285
|
+
'import sys',
|
|
1286
|
+
'import os',
|
|
1287
|
+
'_cwd = os.getcwd()',
|
|
1288
|
+
'_cwd_real = os.path.realpath(_cwd)',
|
|
1289
|
+
'sys.path[:] = [p for p in sys.path if p not in ("", ".", _cwd, _cwd_real)]',
|
|
1290
|
+
'import json',
|
|
1291
|
+
'sys.stdout.write("ok")',
|
|
1292
|
+
].join('\n');
|
|
1293
|
+
const res = spawnSync('python3', ['-c', probeBody], {
|
|
1294
|
+
cwd: baseDir,
|
|
1295
|
+
env: probeEnv,
|
|
1296
|
+
timeout: 5_000,
|
|
1297
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
1298
|
+
});
|
|
1299
|
+
if (res.status !== 0)
|
|
1300
|
+
return false;
|
|
1301
|
+
return (res.stdout?.toString().trim() ?? '') === 'ok';
|
|
1302
|
+
}
|
|
1303
|
+
catch {
|
|
1304
|
+
return false;
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1267
1307
|
const DEFAULT_PROBES = {
|
|
1268
1308
|
cliDistExists: defaultCliDistExists,
|
|
1269
1309
|
cliInvokable: defaultCliInvokable,
|
|
1270
1310
|
python3OnPath: () => resolveBinaryOnPath('python3'),
|
|
1271
1311
|
python3PyYamlReachable: defaultPython3PyYamlReachable,
|
|
1312
|
+
python3ListWalkerReachable: defaultPython3ListWalkerReachable,
|
|
1272
1313
|
awkOnPath: () => resolveBinaryOnPath('awk'),
|
|
1273
1314
|
jqOnPath: () => resolveBinaryOnPath('jq'),
|
|
1274
1315
|
};
|
|
1275
1316
|
function resolveProbes(probes) {
|
|
1276
1317
|
if (probes === undefined)
|
|
1277
1318
|
return DEFAULT_PROBES;
|
|
1278
|
-
|
|
1319
|
+
// 0.42.0 codex round 5 P2 (2026-05-16): when a caller (typically
|
|
1320
|
+
// a unit test) stubs `python3OnPath` but does NOT stub the new
|
|
1321
|
+
// `python3ListWalkerReachable` execution probe, derive a faithful
|
|
1322
|
+
// fallback from `python3OnPath` instead of falling through to the
|
|
1323
|
+
// real `defaultPython3ListWalkerReachable` (which would spawn a
|
|
1324
|
+
// python3 subprocess and break test determinism). The convention
|
|
1325
|
+
// matches `python3PyYamlReachable` in the existing test suite:
|
|
1326
|
+
// stubs that say "python3 is present" want both downstream probes
|
|
1327
|
+
// to report reachable, and stubs that say "python3 is absent" want
|
|
1328
|
+
// both downstream probes to report unreachable.
|
|
1329
|
+
const overrides = { ...probes };
|
|
1330
|
+
if (overrides.python3ListWalkerReachable === undefined && overrides.python3OnPath !== undefined) {
|
|
1331
|
+
const stubbedPython3OnPath = overrides.python3OnPath;
|
|
1332
|
+
overrides.python3ListWalkerReachable = () => stubbedPython3OnPath() !== null;
|
|
1333
|
+
}
|
|
1334
|
+
return { ...DEFAULT_PROBES, ...overrides };
|
|
1279
1335
|
}
|
|
1280
1336
|
/**
|
|
1281
1337
|
* Tier 1 — `rea hook policy-get`. Reachable when the rea CLI is
|
|
@@ -1374,12 +1430,12 @@ export function checkPolicyReaderTier2(baseDir, probes) {
|
|
|
1374
1430
|
* Practically always present (POSIX requirement).
|
|
1375
1431
|
*
|
|
1376
1432
|
* 0.40.0 charter item 2 — conditional verdict, refined by codex
|
|
1377
|
-
* round 1 P2:
|
|
1433
|
+
* round 1 P2 (0.40.0) and round 2 P2 (0.42.0):
|
|
1378
1434
|
* - awk present → `pass`
|
|
1379
1435
|
* - awk absent AND Tier 2 reachable → `warn`
|
|
1380
1436
|
* (Tier 2 implies python3, which is a list-walker)
|
|
1381
1437
|
* - awk absent AND Tier 1 reachable AND a list walker
|
|
1382
|
-
* (jq OR
|
|
1438
|
+
* (jq OR full Tier-2 reachable) is usable → `warn`
|
|
1383
1439
|
* - awk absent AND Tier 1 reachable BUT no list walker → `fail`
|
|
1384
1440
|
* (codex round 1 P2 — list-valued policy reads silently
|
|
1385
1441
|
* fail-closed even though scalar reads work, so the
|
|
@@ -1398,9 +1454,29 @@ export function checkPolicyReaderTier2(baseDir, probes) {
|
|
|
1398
1454
|
* functional box that has python3 + jq + the rea CLI all wired but
|
|
1399
1455
|
* happens to lack awk.
|
|
1400
1456
|
*
|
|
1457
|
+
* List-iteration semantic (clarifying note for codex round 2 P2,
|
|
1458
|
+
* 2026-05-16): `policy_reader_get_list` in
|
|
1459
|
+
* `hooks/_lib/policy-reader.sh` walks the cached subtree JSON via
|
|
1460
|
+
* `jq` OR `python3` (stdlib-only — `json` module, no PyYAML import).
|
|
1461
|
+
* PyYAML is only needed for Tier 2 itself (YAML PARSING into JSON),
|
|
1462
|
+
* NOT for iterating the already-parsed JSON arrays at list-read time.
|
|
1463
|
+
*
|
|
1464
|
+
* Codex round 5 P2 (2026-05-16): the "list walker" predicate uses
|
|
1465
|
+
* `python3ListWalkerReachable` — an EXECUTION probe that actually
|
|
1466
|
+
* spawns `python3 -c "import json"` — instead of `python3OnPath`. A
|
|
1467
|
+
* PATH-only check passes for broken pyenv/asdf shims, dangling
|
|
1468
|
+
* symlinks, and sandboxed environments where the interpreter cannot
|
|
1469
|
+
* start; in those cases the shim's list-walker branch would actually
|
|
1470
|
+
* fail and `blocked_paths`/`protected_writes` enforcement would
|
|
1471
|
+
* silently break while doctor reported `warn`. The execution probe
|
|
1472
|
+
* mirrors `defaultPython3PyYamlReachable` exactly but swaps the
|
|
1473
|
+
* `import yaml` for `import json` so it's not gated on PyYAML
|
|
1474
|
+
* availability (which is irrelevant to list iteration).
|
|
1475
|
+
*
|
|
1401
1476
|
* Takes `baseDir` so it can evaluate Tier 1's two-stage check (dist
|
|
1402
|
-
* present + CLI invokable)
|
|
1403
|
-
* threaded through
|
|
1477
|
+
* present + CLI invokable), Tier 2's reachability, and the
|
|
1478
|
+
* list-walker execution probe. All probes are threaded through
|
|
1479
|
+
* identically.
|
|
1404
1480
|
*/
|
|
1405
1481
|
export function checkPolicyReaderTier3(baseDir, probes) {
|
|
1406
1482
|
const label = 'policy-reader Tier 3 (awk)';
|
|
@@ -1420,22 +1496,25 @@ export function checkPolicyReaderTier3(baseDir, probes) {
|
|
|
1420
1496
|
// ladder would actually do at runtime.
|
|
1421
1497
|
const tier1 = p.cliDistExists(baseDir) && p.cliInvokable(baseDir);
|
|
1422
1498
|
const tier2 = p.python3OnPath() !== null && p.python3PyYamlReachable(baseDir);
|
|
1423
|
-
// Codex round 1 P2 (
|
|
1424
|
-
//
|
|
1425
|
-
//
|
|
1426
|
-
//
|
|
1427
|
-
//
|
|
1428
|
-
//
|
|
1429
|
-
//
|
|
1430
|
-
//
|
|
1431
|
-
//
|
|
1432
|
-
//
|
|
1433
|
-
//
|
|
1434
|
-
//
|
|
1435
|
-
//
|
|
1436
|
-
//
|
|
1437
|
-
|
|
1438
|
-
|
|
1499
|
+
// Codex round 1 P2 (0.40.0) + round 2 P2 corrected (0.42.0,
|
|
1500
|
+
// 2026-05-16) + round 5 P2 (0.42.0, 2026-05-16): the
|
|
1501
|
+
// downgrade-to-warn branch needs a list walker too.
|
|
1502
|
+
// `policy_reader_get_list` iterates the parsed JSON array via jq
|
|
1503
|
+
// OR python3. The python3 branch uses `json` from stdlib only —
|
|
1504
|
+
// PyYAML is NOT required (it's only needed for Tier 2's YAML
|
|
1505
|
+
// parsing step, which has already run by the time list iteration
|
|
1506
|
+
// executes).
|
|
1507
|
+
//
|
|
1508
|
+
// Round 5 P2 hardening: the python3 leg of this predicate uses an
|
|
1509
|
+
// EXECUTION probe (`python3ListWalkerReachable`), not just a PATH
|
|
1510
|
+
// check. A `python3` symlink can resolve on PATH while the
|
|
1511
|
+
// interpreter itself fails to start (dangling pyenv/asdf shim,
|
|
1512
|
+
// sandboxed runner without dynamic libs, permission denied on the
|
|
1513
|
+
// resolved binary). PATH-only would let doctor declare `warn` on
|
|
1514
|
+
// a box where the shim's list walker would actually fail —
|
|
1515
|
+
// silently breaking `blocked_paths` / `protected_writes`
|
|
1516
|
+
// enforcement while doctor exits 0.
|
|
1517
|
+
const listWalker = p.jqOnPath() !== null || p.python3ListWalkerReachable(baseDir);
|
|
1439
1518
|
if (tier2 || (tier1 && listWalker)) {
|
|
1440
1519
|
const reachable = [];
|
|
1441
1520
|
if (tier1)
|
|
@@ -1451,21 +1530,43 @@ export function checkPolicyReaderTier3(baseDir, probes) {
|
|
|
1451
1530
|
'to restore the last-resort fallback.',
|
|
1452
1531
|
};
|
|
1453
1532
|
}
|
|
1454
|
-
// Codex round 1 P2: separate "no list
|
|
1455
|
-
// catastrophic "no tier at all" case.
|
|
1456
|
-
// AND no python3 AND no awk means
|
|
1457
|
-
// fail-closed silently — distinct from the
|
|
1458
|
-
// no-CLI-no-python-no-awk shape, and worth a precise
|
|
1533
|
+
// Codex round 1 P2 (0.40.0) + round 2 P2 (0.42.0): separate "no list
|
|
1534
|
+
// walker" diagnosis from the catastrophic "no tier at all" case.
|
|
1535
|
+
// Tier 1 reachable but no jq AND no python3 AND no awk means
|
|
1536
|
+
// list-valued policy reads fail-closed silently — distinct from the
|
|
1537
|
+
// truly-empty no-CLI-no-python-no-awk shape, and worth a precise
|
|
1538
|
+
// remediation. The python3-as-list-walker signal is plain
|
|
1539
|
+
// `python3OnPath` (the `json` module is stdlib — PyYAML is NOT
|
|
1540
|
+
// required for list iteration).
|
|
1459
1541
|
if (tier1) {
|
|
1542
|
+
// 0.42.0 codex round 6 P3 (2026-05-16): distinguish "python3 not
|
|
1543
|
+
// on PATH" from "python3 on PATH but execution fails". Pre-fix
|
|
1544
|
+
// this branch always reported "python3 is not on PATH" even when
|
|
1545
|
+
// a python3 binary was resolvable but a broken pyenv/asdf shim
|
|
1546
|
+
// or sandboxed interpreter failed the execution probe — that
|
|
1547
|
+
// sent operators toward the wrong remediation. Round 5 added
|
|
1548
|
+
// the execution probe specifically to surface this case; the
|
|
1549
|
+
// diagnostic needs to follow.
|
|
1550
|
+
const pythonOnPath = p.python3OnPath();
|
|
1551
|
+
const pythonState = pythonOnPath === null
|
|
1552
|
+
? 'python3 is not on PATH'
|
|
1553
|
+
: `python3 at ${pythonOnPath} cannot execute \`import json\` (broken pyenv/asdf shim, ` +
|
|
1554
|
+
'sandboxed interpreter, or permission-denied binary — fix the interpreter or ' +
|
|
1555
|
+
'remove the shim)';
|
|
1556
|
+
const remediation = pythonOnPath === null
|
|
1557
|
+
? 'Install awk OR jq OR python3 to restore list-iteration.'
|
|
1558
|
+
: `Install awk OR jq, or repair the python3 interpreter at ${pythonOnPath}, ` +
|
|
1559
|
+
'to restore list-iteration.';
|
|
1460
1560
|
return {
|
|
1461
1561
|
label,
|
|
1462
1562
|
status: 'fail',
|
|
1463
|
-
detail:
|
|
1464
|
-
'flow-form scalars, but `policy_reader_get_list`
|
|
1465
|
-
'(e.g. `blocked_paths: [.env, ...]`)
|
|
1466
|
-
'resulting JSON arrays.
|
|
1467
|
-
'`blocked-paths-
|
|
1468
|
-
|
|
1563
|
+
detail: `awk not on PATH AND jq is not on PATH AND ${pythonState} — ` +
|
|
1564
|
+
'Tier 1 (rea CLI) parses flow-form scalars, but `policy_reader_get_list` ' +
|
|
1565
|
+
'cannot iterate list-valued keys (e.g. `blocked_paths: [.env, ...]`) ' +
|
|
1566
|
+
'without jq OR python3 OR awk to walk the resulting JSON arrays. ' +
|
|
1567
|
+
'Affected hooks (`blocked-paths-bash-gate.sh`, ' +
|
|
1568
|
+
`\`blocked-paths-enforcer.sh\`, …) see an EMPTY list and silently stop ` +
|
|
1569
|
+
`enforcing. ${remediation}`,
|
|
1469
1570
|
};
|
|
1470
1571
|
}
|
|
1471
1572
|
return {
|
|
@@ -1542,11 +1643,14 @@ export function checkPolicyReaderTierSummary(baseDir, probes) {
|
|
|
1542
1643
|
const tier2 = py !== null && p.python3PyYamlReachable(baseDir);
|
|
1543
1644
|
const tier3 = p.awkOnPath() !== null;
|
|
1544
1645
|
const jq = p.jqOnPath();
|
|
1545
|
-
//
|
|
1546
|
-
//
|
|
1547
|
-
//
|
|
1548
|
-
// but
|
|
1549
|
-
|
|
1646
|
+
// 0.42.0 codex round 5 P2 (2026-05-16): list iteration after Tier
|
|
1647
|
+
// 1/2 needs jq OR a python3 that can ACTUALLY execute (not just
|
|
1648
|
+
// resolve on PATH). The execution probe catches the broken-shim
|
|
1649
|
+
// case where `python3` resolves but the interpreter cannot start —
|
|
1650
|
+
// PATH-only would falsely declare the list walker "usable" on a
|
|
1651
|
+
// box where the shim's python3 branch will fall through to Tier 3
|
|
1652
|
+
// and silently miss flow-form arrays.
|
|
1653
|
+
const listWalker = jq !== null || p.python3ListWalkerReachable(baseDir);
|
|
1550
1654
|
const reachable = [];
|
|
1551
1655
|
if (tier1)
|
|
1552
1656
|
reachable.push('Tier 1 (CLI)');
|
|
@@ -97,6 +97,24 @@ export interface UpgradeCheckFile {
|
|
|
97
97
|
* row (e.g. "removed-upstream — kept by default unless --force"). */
|
|
98
98
|
note?: string;
|
|
99
99
|
}
|
|
100
|
+
/**
|
|
101
|
+
* 0.42.0 charter item 2 — surface the same settings-schema validation
|
|
102
|
+
* the real upgrade flow runs. `runUpgrade` calls `validateSettings`
|
|
103
|
+
* on the merged result and refuses the write (throws) when it fails;
|
|
104
|
+
* pre-0.42.0 `rea upgrade --check` never invoked that check, so a
|
|
105
|
+
* preview could promise a write that the real upgrade would refuse.
|
|
106
|
+
*
|
|
107
|
+
* - `parsed: true` — schema validation succeeded; `errors` is empty.
|
|
108
|
+
* The real upgrade WOULD write the merged settings on demand.
|
|
109
|
+
* - `parsed: false` — schema validation failed. `errors` carries the
|
|
110
|
+
* same zod-issue strings `runUpgrade` would surface in its throw
|
|
111
|
+
* message. The real upgrade would refuse and leave settings.json
|
|
112
|
+
* untouched.
|
|
113
|
+
*/
|
|
114
|
+
export interface UpgradeCheckSettingsValidation {
|
|
115
|
+
parsed: boolean;
|
|
116
|
+
errors: string[];
|
|
117
|
+
}
|
|
100
118
|
export interface UpgradeCheckPlan {
|
|
101
119
|
schema_version: typeof UPGRADE_CHECK_SCHEMA_VERSION;
|
|
102
120
|
rea_version: string;
|
|
@@ -112,6 +130,26 @@ export interface UpgradeCheckPlan {
|
|
|
112
130
|
removed_upstream: number;
|
|
113
131
|
};
|
|
114
132
|
files: UpgradeCheckFile[];
|
|
133
|
+
/** 0.42.0 — schema-validation outcome on the merged settings.json
|
|
134
|
+
* the real `rea upgrade` would write. `null` when the synthetic
|
|
135
|
+
* settings classification did not produce a merged result (should
|
|
136
|
+
* not happen in practice; defensive). */
|
|
137
|
+
settings_validation: UpgradeCheckSettingsValidation | null;
|
|
138
|
+
/**
|
|
139
|
+
* 0.42.0 codex round 3 P2 (2026-05-16) — top-level "preview = real"
|
|
140
|
+
* verdict. `true` when `rea upgrade` would actually start mutating
|
|
141
|
+
* the install; `false` when the new pre-flight (settings-validation)
|
|
142
|
+
* gate would refuse the upgrade before any file is written.
|
|
143
|
+
*
|
|
144
|
+
* Why this matters: `counts` + `files` still describe what WOULD be
|
|
145
|
+
* written if validation passed (operators want the diff so they can
|
|
146
|
+
* fix the underlying invalid setting and see the upgrade preview in
|
|
147
|
+
* one shot). But CI and automation consuming the JSON need a single
|
|
148
|
+
* unambiguous signal that the real upgrade will write nothing in
|
|
149
|
+
* the current state. Use `would_apply` as that signal; treat
|
|
150
|
+
* `files[]` + `counts` as conditional on `would_apply === true`.
|
|
151
|
+
*/
|
|
152
|
+
would_apply: boolean;
|
|
115
153
|
}
|
|
116
154
|
export interface ComputeUpgradeCheckOptions {
|
|
117
155
|
/** Defaults to `process.cwd()`. */
|
|
@@ -65,6 +65,7 @@ import { manifestExists, readManifest } from './install/manifest-io.js';
|
|
|
65
65
|
import { sha256OfBuffer, sha256OfFile } from './install/sha.js';
|
|
66
66
|
import { diffUnified, DIFF_TOO_LARGE_NOTICE } from './install/unified-diff.js';
|
|
67
67
|
import { loadPolicy } from '../policy/loader.js';
|
|
68
|
+
import { validateSettings } from '../config/settings-schema.js';
|
|
68
69
|
import { err, getPkgVersion, log } from './utils.js';
|
|
69
70
|
/** Hard cap on the diff input size. Mirrors `DIFF_SIZE_CAP_BYTES` in
|
|
70
71
|
* upgrade.ts so the two preview surfaces agree on the "too big to
|
|
@@ -288,6 +289,11 @@ async function classifyClaudeMd(resolvedRoot, includeDiffs) {
|
|
|
288
289
|
* upgrade flow, we prune known-stale hook tokens BEFORE merging the
|
|
289
290
|
* default-desired hook set — the order matters so the merge sees a
|
|
290
291
|
* clean baseline.
|
|
292
|
+
*
|
|
293
|
+
* 0.42.0 — also returns the merged object so the caller can run the
|
|
294
|
+
* same `validateSettings` check the real `runUpgrade` runs. We hand
|
|
295
|
+
* back the merged shape directly (not the file rendering) so the
|
|
296
|
+
* caller can decide whether to thread it into the schema check.
|
|
291
297
|
*/
|
|
292
298
|
async function classifySettings(resolvedRoot, includeDiffs) {
|
|
293
299
|
const desired = defaultDesiredHooks();
|
|
@@ -344,7 +350,7 @@ async function classifySettings(resolvedRoot, includeDiffs) {
|
|
|
344
350
|
Object.assign(file, safeDiff(oldText, newText, file.path));
|
|
345
351
|
}
|
|
346
352
|
}
|
|
347
|
-
return file;
|
|
353
|
+
return { file, merged: mergeResult.merged };
|
|
348
354
|
}
|
|
349
355
|
/**
|
|
350
356
|
* Build a `removed_upstream` entry from a manifest record that has no
|
|
@@ -480,14 +486,37 @@ export async function computeUpgradeCheck(options = {}) {
|
|
|
480
486
|
}
|
|
481
487
|
}
|
|
482
488
|
// Synthetic entries.
|
|
483
|
-
const
|
|
484
|
-
files.push(
|
|
489
|
+
const settingsClassification = await classifySettings(resolvedRoot, includeDiffs);
|
|
490
|
+
files.push(settingsClassification.file);
|
|
485
491
|
const claudeMd = await classifyClaudeMd(resolvedRoot, includeDiffs);
|
|
486
492
|
if (claudeMd !== null)
|
|
487
493
|
files.push(claudeMd);
|
|
488
494
|
const gitignoreFile = await classifyGitignore(resolvedRoot, includeDiffs);
|
|
489
495
|
if (gitignoreFile !== null)
|
|
490
496
|
files.push(gitignoreFile);
|
|
497
|
+
// 0.42.0 charter item 2 — schema-validate the merged settings the
|
|
498
|
+
// real `runUpgrade` would write. `runUpgrade` calls `validateSettings`
|
|
499
|
+
// (non-strict, matching the upgrade flow's posture) and throws when
|
|
500
|
+
// the merged result fails — refusing the write. Pre-0.42.0 the
|
|
501
|
+
// preview never ran this check, so the planner could promise a write
|
|
502
|
+
// that the real upgrade would refuse. Reproduce the exact validation
|
|
503
|
+
// shape here so the JSON `settings_validation` field is byte-for-byte
|
|
504
|
+
// what `runUpgrade` would see.
|
|
505
|
+
const validation = validateSettings(settingsClassification.merged, { strict: false });
|
|
506
|
+
const settingsValidation = {
|
|
507
|
+
parsed: validation.parsed,
|
|
508
|
+
errors: validation.errors,
|
|
509
|
+
};
|
|
510
|
+
// If validation failed, annotate the settings file row so the
|
|
511
|
+
// human-readable rendering surfaces the refusal alongside the count
|
|
512
|
+
// table (operators reading the table without scrolling to the
|
|
513
|
+
// footer still see the warning). Note appends rather than overwrites
|
|
514
|
+
// so the existing merge / prune annotations remain visible.
|
|
515
|
+
if (!validation.parsed) {
|
|
516
|
+
const refusalNote = `WOULD REFUSE: schema validation failed — ${validation.errors.join('; ')}`;
|
|
517
|
+
const f = settingsClassification.file;
|
|
518
|
+
f.note = f.note !== undefined ? `${f.note}; ${refusalNote}` : refusalNote;
|
|
519
|
+
}
|
|
491
520
|
// Stable sort: action priority (modified → created → removed_upstream
|
|
492
521
|
// → unchanged) then path. Operators reviewing the table want to see
|
|
493
522
|
// the changed entries first.
|
|
@@ -513,6 +542,13 @@ export async function computeUpgradeCheck(options = {}) {
|
|
|
513
542
|
bootstrap: isBootstrap,
|
|
514
543
|
counts,
|
|
515
544
|
files,
|
|
545
|
+
settings_validation: settingsValidation,
|
|
546
|
+
// Codex round 3 P2 (2026-05-16): mirror runUpgrade's pre-flight
|
|
547
|
+
// gate. `would_apply` is true when the real upgrade would actually
|
|
548
|
+
// start mutating disk — currently the only gate is settings-schema
|
|
549
|
+
// validation, but more pre-flight checks may land in future
|
|
550
|
+
// releases (in which case they get ANDed in here).
|
|
551
|
+
would_apply: settingsValidation.parsed,
|
|
516
552
|
};
|
|
517
553
|
}
|
|
518
554
|
/**
|
|
@@ -528,8 +564,20 @@ export function renderUpgradeCheck(plan) {
|
|
|
528
564
|
lines.push(` bootstrap mode: no install-manifest found yet`);
|
|
529
565
|
}
|
|
530
566
|
lines.push('');
|
|
567
|
+
// Codex round 3 P2 (2026-05-16): when a pre-flight gate would
|
|
568
|
+
// refuse the upgrade, the counts/files below describe what WOULD
|
|
569
|
+
// happen if the gate passed — but `rea upgrade` will actually
|
|
570
|
+
// write nothing in the current state. Lead with that banner so
|
|
571
|
+
// operators don't read the summary table as a promise of action.
|
|
572
|
+
if (!plan.would_apply) {
|
|
573
|
+
lines.push('BLOCKED — `rea upgrade` would refuse to apply this plan in its current state. ' +
|
|
574
|
+
'The summary below describes the would-be plan IF the refusal were fixed first; ' +
|
|
575
|
+
'the real upgrade writes nothing until the refusal clears.');
|
|
576
|
+
lines.push('');
|
|
577
|
+
}
|
|
531
578
|
const totalChanges = plan.counts.created + plan.counts.modified + plan.counts.removed_upstream;
|
|
532
|
-
|
|
579
|
+
const summaryLabel = plan.would_apply ? 'planned change(s)' : 'change(s) blocked by refusal';
|
|
580
|
+
lines.push(`Summary — ${String(totalChanges)} ${summaryLabel}:`);
|
|
533
581
|
lines.push(` created: ${String(plan.counts.created)}`);
|
|
534
582
|
lines.push(` modified: ${String(plan.counts.modified)}`);
|
|
535
583
|
lines.push(` removed-upstream: ${String(plan.counts.removed_upstream)}`);
|
|
@@ -538,6 +586,17 @@ export function renderUpgradeCheck(plan) {
|
|
|
538
586
|
if (totalChanges === 0) {
|
|
539
587
|
lines.push('No changes — your install is already in sync with this rea version.');
|
|
540
588
|
lines.push('');
|
|
589
|
+
// 0.42.0 — even with zero planned changes, surface a validation
|
|
590
|
+
// failure here so an operator doesn't see "in sync" and miss the
|
|
591
|
+
// settings refusal.
|
|
592
|
+
if (plan.settings_validation !== null && !plan.settings_validation.parsed) {
|
|
593
|
+
lines.push('WARNING: `rea upgrade` would REFUSE to run — the merged ' +
|
|
594
|
+
'.claude/settings.json would fail schema validation:');
|
|
595
|
+
for (const e of plan.settings_validation.errors) {
|
|
596
|
+
lines.push(` - ${e}`);
|
|
597
|
+
}
|
|
598
|
+
lines.push('');
|
|
599
|
+
}
|
|
541
600
|
return lines.join('\n');
|
|
542
601
|
}
|
|
543
602
|
// Per-file detail — skip `unchanged` entries.
|
|
@@ -545,7 +604,12 @@ export function renderUpgradeCheck(plan) {
|
|
|
545
604
|
if (file.action === 'unchanged')
|
|
546
605
|
continue;
|
|
547
606
|
const marker = file.action === 'created' ? '+' : file.action === 'removed_upstream' ? '-' : '~';
|
|
548
|
-
const
|
|
607
|
+
const baseLabel = file.action === 'removed_upstream' ? 'removed-upstream' : file.action;
|
|
608
|
+
// Codex round 3 P2 (2026-05-16): when a refusal is active, every
|
|
609
|
+
// would-be-mutated file row gets a BLOCKED suffix so a quick scroll
|
|
610
|
+
// through the per-file detail cannot miss the fact that no write
|
|
611
|
+
// will happen until the refusal clears.
|
|
612
|
+
const label = plan.would_apply ? baseLabel : `${baseLabel} (BLOCKED — refusal active)`;
|
|
549
613
|
const syntheticTag = file.synthetic !== undefined ? ` [${file.synthetic}]` : '';
|
|
550
614
|
lines.push(`${marker} ${file.path}${syntheticTag} — ${label}`);
|
|
551
615
|
if (file.note !== undefined)
|
|
@@ -572,8 +636,30 @@ export function renderUpgradeCheck(plan) {
|
|
|
572
636
|
}
|
|
573
637
|
lines.push('');
|
|
574
638
|
}
|
|
575
|
-
|
|
576
|
-
|
|
639
|
+
// 0.42.0 — surface settings-schema validation outcome alongside the
|
|
640
|
+
// footer. When the merged settings would fail validation, `rea
|
|
641
|
+
// upgrade` would refuse to start at all (codex round 2 P2 moved the
|
|
642
|
+
// validation to a pre-flight check BEFORE any file writes); we
|
|
643
|
+
// report that here so consumers can fix policy + settings before
|
|
644
|
+
// invoking the real upgrade.
|
|
645
|
+
if (plan.settings_validation !== null && !plan.settings_validation.parsed) {
|
|
646
|
+
lines.push('');
|
|
647
|
+
lines.push('WARNING: `rea upgrade` would REFUSE to run — the merged ' +
|
|
648
|
+
'.claude/settings.json would fail schema validation:');
|
|
649
|
+
for (const e of plan.settings_validation.errors) {
|
|
650
|
+
lines.push(` - ${e}`);
|
|
651
|
+
}
|
|
652
|
+
lines.push('No files will be written by `rea upgrade` while this is true — the ' +
|
|
653
|
+
'pre-flight check runs before any canonical hook or agent file is ' +
|
|
654
|
+
'installed AND before the 0.11.0 .rea/policy.yaml migration, so your ' +
|
|
655
|
+
'existing install stays untouched. Fix the settings entries flagged ' +
|
|
656
|
+
'above and re-run `rea upgrade --check`.');
|
|
657
|
+
lines.push('');
|
|
658
|
+
}
|
|
659
|
+
else {
|
|
660
|
+
lines.push('No changes were written. Run `rea upgrade` (without --check) to apply.');
|
|
661
|
+
lines.push('');
|
|
662
|
+
}
|
|
577
663
|
return lines.join('\n');
|
|
578
664
|
}
|
|
579
665
|
/**
|
package/dist/cli/upgrade.js
CHANGED
|
@@ -460,10 +460,52 @@ export async function runUpgrade(options = {}) {
|
|
|
460
460
|
if (options.force === true && !dryRun) {
|
|
461
461
|
warn('--force: overwriting locally-modified files and deleting removed-upstream entries without prompt.');
|
|
462
462
|
}
|
|
463
|
+
// 0.42.0 codex round 2 P2 (2026-05-16): pre-flight the merged
|
|
464
|
+
// settings validation BEFORE any file writes happen. Pre-correction,
|
|
465
|
+
// `upgradeSettings` ran AFTER the canonical-file write loop and only
|
|
466
|
+
// then validated the merged result; throwing here meant canonical
|
|
467
|
+
// hook + agent files had already been written to disk, breaking
|
|
468
|
+
// `rea upgrade --check`'s implicit "preview = real" contract (the
|
|
469
|
+
// check footer correctly claimed validation would refuse, but did
|
|
470
|
+
// not warn that hook files would still be written first).
|
|
471
|
+
//
|
|
472
|
+
// Codex round 2 P2 (2026-05-16, follow-up): this MUST run before
|
|
473
|
+
// `migrateReviewPolicyFor0110` as well — the 0.11.0 policy migration
|
|
474
|
+
// rewrites `.rea/policy.yaml` and creates a `policy.yaml.bak-*`
|
|
475
|
+
// sibling. If the pre-flight runs after that migration, the
|
|
476
|
+
// "no files have been written" claim in the refusal message
|
|
477
|
+
// becomes false for pre-0.11 consumers with malformed settings.
|
|
478
|
+
// Ordering: pre-flight FIRST, then migration, then canonical
|
|
479
|
+
// enumeration + write loop.
|
|
480
|
+
//
|
|
481
|
+
// The pre-flight reuses the same helpers as `upgradeSettings` —
|
|
482
|
+
// `readSettings` / `pruneHookCommands` / `mergeSettings` /
|
|
483
|
+
// `validateSettings` are all pure functions over existing on-disk
|
|
484
|
+
// state and `defaultDesiredHooks()`, so running them twice is
|
|
485
|
+
// cheap (microseconds — single JSON parse + small object merge)
|
|
486
|
+
// and the second run inside `upgradeSettings` re-derives the same
|
|
487
|
+
// result before writing. Atomicity guarantee: if validation fails,
|
|
488
|
+
// `runUpgrade` aborts here with ZERO mutations to disk.
|
|
489
|
+
{
|
|
490
|
+
const desired = defaultDesiredHooks();
|
|
491
|
+
const { settings: existingSettings } = readSettings(resolvedRoot);
|
|
492
|
+
const pruned = pruneHookCommands(existingSettings, STALE_HOOK_COMMAND_TOKENS);
|
|
493
|
+
const mergeResult = mergeSettings(pruned.merged, desired);
|
|
494
|
+
const validation = validateSettings(mergeResult.merged);
|
|
495
|
+
if (!validation.parsed) {
|
|
496
|
+
throw new Error(`rea upgrade: refusing to start because the merged .claude/settings.json would ` +
|
|
497
|
+
`fail schema validation. This is a safety guardrail — no files have been written ` +
|
|
498
|
+
`(including no .rea/policy.yaml 0.11.0 migration). Your existing install is ` +
|
|
499
|
+
`unchanged. zod errors: ${validation.errors.join('; ')}`);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
463
502
|
// 0.11.0 migration — strip removed review.* fields and backfill the new
|
|
464
503
|
// concerns_blocks default. Runs before canonical file reconciliation so a
|
|
465
504
|
// policy that fails strict schema load (which happens on upgrade from
|
|
466
505
|
// 0.10.x the moment we re-read `.rea/policy.yaml`) is cleaned up first.
|
|
506
|
+
// 0.42.0 round 2 P2 follow-up: ordered AFTER the pre-flight validation
|
|
507
|
+
// above so a settings-validation refusal does not leave a half-migrated
|
|
508
|
+
// .rea/policy.yaml + backup behind.
|
|
467
509
|
await migrateReviewPolicyFor0110(resolvedRoot, { dryRun });
|
|
468
510
|
const canonicalFiles = await enumerateCanonicalFiles();
|
|
469
511
|
if (canonicalFiles.length === 0) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bookedsolid/rea",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.42.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)",
|