@bookedsolid/rea 0.38.1 → 0.39.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.
@@ -97,6 +97,120 @@ 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
+ python3PyYamlReachable?: () => boolean;
128
+ awkOnPath?: () => string | null;
129
+ jqOnPath?: () => string | null;
130
+ }
131
+ /**
132
+ * Tier 1 — `rea hook policy-get`. Reachable when the rea CLI is
133
+ * present at one of the two shim-resolved paths (consumer install OR
134
+ * dogfood `dist/`) AND actually responds to `rea hook policy-get
135
+ * version --json`. The shim ladder uses that exact invocation as its
136
+ * Tier 1 probe (see `_pr_load_full_json` in `hooks/_lib/policy-reader.sh`);
137
+ * mirroring it here means a stale or broken dist (file present but
138
+ * import-throws / postinstall failed) reports `warn` — matching the
139
+ * real fall-through to Tier 2/3 the shim would do at runtime.
140
+ *
141
+ * Three states:
142
+ * - dist present + CLI responds → `pass` (canonical loader fully wired).
143
+ * - dist present + CLI broken → `warn` (stale build, missing native
144
+ * module, broken postinstall — needs `pnpm build` / `rea upgrade`).
145
+ * - dist absent → `warn` (not installed; Tier 2/3 still cover).
146
+ *
147
+ * Codex round-1 P2 (2026-05-16) replaced the file-existence-only
148
+ * probe with this CLI-invocation probe — pre-fix, a consumer with
149
+ * `dist/cli/index.js` present but throwing on load would see `pass`
150
+ * here while every real shim would silently fall through.
151
+ */
152
+ export declare function checkPolicyReaderTier1(baseDir: string, probes?: PolicyReaderProbes): CheckResult;
153
+ /**
154
+ * Tier 2 — python3 + stdlib `yaml` (PyYAML). Handles BOTH block-form
155
+ * and flow-form YAML; the practical floor when Tier 1 is unreachable.
156
+ *
157
+ * Three states:
158
+ * - python3 present + PyYAML importable → `pass`.
159
+ * - python3 present, PyYAML missing → `warn` (the loader will fall
160
+ * through to Tier 3, which only handles block-form).
161
+ * - python3 absent → `warn` (same Tier 3 fall-through).
162
+ *
163
+ * Never `fail` — Tier 3 is still a valid floor for block-form policy.
164
+ * The warning highlights the silent no-op risk for flow-form lookups
165
+ * when CLI is also unreachable.
166
+ */
167
+ export declare function checkPolicyReaderTier2(probes?: PolicyReaderProbes): CheckResult;
168
+ /**
169
+ * Tier 3 — awk block-form parser. Last-resort no-dep fallback.
170
+ * Practically always present (POSIX requirement); hard-fail only when
171
+ * truly absent (in which case the consumer has ZERO working fallback
172
+ * tiers and any CLI-absent shim invocation will silently fail-closed
173
+ * on every policy lookup).
174
+ */
175
+ export declare function checkPolicyReaderTier3(probes?: PolicyReaderProbes): CheckResult;
176
+ /**
177
+ * jq — optional accelerator used by Tier 1/2's JSON subtree parsing.
178
+ * Per the 0.37.0 round-1 P2 fix the helper falls back to a python3
179
+ * walker when jq is absent (still correct, just an extra spawn per
180
+ * leaf). `warn` when missing so operators know they're paying the
181
+ * latency cost.
182
+ *
183
+ * `info` when present — no action needed, just confirming the
184
+ * accelerator is wired.
185
+ */
186
+ export declare function checkPolicyReaderJq(probes?: PolicyReaderProbes): CheckResult;
187
+ /**
188
+ * Summary roll-up: which tiers are reachable, what's the effective
189
+ * floor when the CLI is unreachable, and is flow-form policy at risk
190
+ * of silent no-op.
191
+ *
192
+ * Four verdicts:
193
+ * - `pass` — Tier 1 OR Tier 2 reachable AND a JSON list walker
194
+ * (jq or python3) is available. Flow-form scalars AND flow-form
195
+ * arrays both parse correctly via whichever tier is hit first.
196
+ * - `warn` (flow-form-lists-degraded) — Tier 1 reachable but neither
197
+ * jq nor python3 on PATH. Flow-form SCALARS parse correctly via
198
+ * the CLI's JSON output, but `policy_reader_get_list` cannot
199
+ * iterate the resulting JSON array — it falls through to Tier 3
200
+ * awk, which silently misses flow-form arrays like
201
+ * `blocked_paths: [.env, ...]`. Codex round-1 P2 (2026-05-16).
202
+ * - `warn` (Tier-3-only) — Only Tier 3 (awk) reachable. Block-form
203
+ * policy works; flow-form scalars AND arrays both silently no-op
204
+ * on every shim fallback.
205
+ * - `fail` — No tiers reachable. Shims fail closed on every policy
206
+ * lookup. (Practically requires losing awk too — see Tier 3.)
207
+ *
208
+ * Tier 2 implies python3 is on PATH (it's the interpreter that runs
209
+ * the loader), so when Tier 2 is reachable the list-iteration python3
210
+ * fallback is also reachable — only the Tier-1-without-list-walker
211
+ * shape can produce the degraded warning.
212
+ */
213
+ export declare function checkPolicyReaderTierSummary(baseDir: string, probes?: PolicyReaderProbes): CheckResult;
100
214
  /**
101
215
  * Translate a `CodexProbeState` into two doctor CheckResults: one for
102
216
  * responsiveness (pass/warn) and one informational line about the last
@@ -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,559 @@ 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() {
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
+ const res = spawnSync('python3', ['-c', probeBody], {
1248
+ env: probeEnv,
1249
+ timeout: 5_000,
1250
+ stdio: ['ignore', 'ignore', 'ignore'],
1251
+ });
1252
+ return res.status === 0;
1253
+ }
1254
+ catch {
1255
+ return false;
1256
+ }
1257
+ }
1258
+ const DEFAULT_PROBES = {
1259
+ cliDistExists: defaultCliDistExists,
1260
+ cliInvokable: defaultCliInvokable,
1261
+ python3OnPath: () => resolveBinaryOnPath('python3'),
1262
+ python3PyYamlReachable: defaultPython3PyYamlReachable,
1263
+ awkOnPath: () => resolveBinaryOnPath('awk'),
1264
+ jqOnPath: () => resolveBinaryOnPath('jq'),
1265
+ };
1266
+ function resolveProbes(probes) {
1267
+ if (probes === undefined)
1268
+ return DEFAULT_PROBES;
1269
+ return { ...DEFAULT_PROBES, ...probes };
1270
+ }
1271
+ /**
1272
+ * Tier 1 — `rea hook policy-get`. Reachable when the rea CLI is
1273
+ * present at one of the two shim-resolved paths (consumer install OR
1274
+ * dogfood `dist/`) AND actually responds to `rea hook policy-get
1275
+ * version --json`. The shim ladder uses that exact invocation as its
1276
+ * Tier 1 probe (see `_pr_load_full_json` in `hooks/_lib/policy-reader.sh`);
1277
+ * mirroring it here means a stale or broken dist (file present but
1278
+ * import-throws / postinstall failed) reports `warn` — matching the
1279
+ * real fall-through to Tier 2/3 the shim would do at runtime.
1280
+ *
1281
+ * Three states:
1282
+ * - dist present + CLI responds → `pass` (canonical loader fully wired).
1283
+ * - dist present + CLI broken → `warn` (stale build, missing native
1284
+ * module, broken postinstall — needs `pnpm build` / `rea upgrade`).
1285
+ * - dist absent → `warn` (not installed; Tier 2/3 still cover).
1286
+ *
1287
+ * Codex round-1 P2 (2026-05-16) replaced the file-existence-only
1288
+ * probe with this CLI-invocation probe — pre-fix, a consumer with
1289
+ * `dist/cli/index.js` present but throwing on load would see `pass`
1290
+ * here while every real shim would silently fall through.
1291
+ */
1292
+ export function checkPolicyReaderTier1(baseDir, probes) {
1293
+ const label = 'policy-reader Tier 1 (rea CLI)';
1294
+ const p = resolveProbes(probes);
1295
+ const distPresent = p.cliDistExists(baseDir);
1296
+ if (!distPresent) {
1297
+ return {
1298
+ label,
1299
+ status: 'warn',
1300
+ detail: 'rea CLI dist not found at node_modules/@bookedsolid/rea/dist/cli/index.js or <baseDir>/dist/cli/index.js — ' +
1301
+ 'shims fall through to Tier 2/3 (works, but loses validated schema + full subtree shapes). ' +
1302
+ 'Consumer: run `pnpm i @bookedsolid/rea`. Dogfood: run `pnpm build`.',
1303
+ };
1304
+ }
1305
+ if (!p.cliInvokable(baseDir)) {
1306
+ return {
1307
+ label,
1308
+ status: 'warn',
1309
+ detail: 'rea CLI dist exists but `rea hook policy-get version --json` failed — the dist is ' +
1310
+ 'stale or broken (incomplete build, missing native module, broken postinstall). The ' +
1311
+ 'shim ladder will skip Tier 1 and fall through to Tier 2/3 just as this probe did. ' +
1312
+ 'Run `pnpm build` (dogfood) or `rea upgrade` (consumer) to rebuild.',
1313
+ };
1314
+ }
1315
+ return {
1316
+ label,
1317
+ status: 'pass',
1318
+ detail: 'rea CLI dist responds to `hook policy-get version --json` — canonical loader fully wired',
1319
+ };
1320
+ }
1321
+ /**
1322
+ * Tier 2 — python3 + stdlib `yaml` (PyYAML). Handles BOTH block-form
1323
+ * and flow-form YAML; the practical floor when Tier 1 is unreachable.
1324
+ *
1325
+ * Three states:
1326
+ * - python3 present + PyYAML importable → `pass`.
1327
+ * - python3 present, PyYAML missing → `warn` (the loader will fall
1328
+ * through to Tier 3, which only handles block-form).
1329
+ * - python3 absent → `warn` (same Tier 3 fall-through).
1330
+ *
1331
+ * Never `fail` — Tier 3 is still a valid floor for block-form policy.
1332
+ * The warning highlights the silent no-op risk for flow-form lookups
1333
+ * when CLI is also unreachable.
1334
+ */
1335
+ export function checkPolicyReaderTier2(probes) {
1336
+ const label = 'policy-reader Tier 2 (python3 + PyYAML)';
1337
+ const p = resolveProbes(probes);
1338
+ const py = p.python3OnPath();
1339
+ if (py === null) {
1340
+ return {
1341
+ label,
1342
+ status: 'warn',
1343
+ detail: 'python3 not on PATH — Tier 2 unavailable. Shims fall through to Tier 3 (awk, ' +
1344
+ 'block-form only). Flow-form policy (e.g. `local_review: { mode: off }`) silently ' +
1345
+ 'no-ops when the rea CLI is also unreachable. Install python3 to close this gap.',
1346
+ };
1347
+ }
1348
+ if (!p.python3PyYamlReachable()) {
1349
+ return {
1350
+ label,
1351
+ status: 'warn',
1352
+ detail: `python3 found at ${py} but \`import yaml\` failed — PyYAML missing. ` +
1353
+ 'Shims fall through to Tier 3 (awk, block-form only). Flow-form policy silently ' +
1354
+ 'no-ops when the rea CLI is also unreachable. Install: `pip3 install pyyaml`.',
1355
+ };
1356
+ }
1357
+ return {
1358
+ label,
1359
+ status: 'pass',
1360
+ detail: `python3 + PyYAML reachable at ${py} — flow-form policy parses correctly`,
1361
+ };
1362
+ }
1363
+ /**
1364
+ * Tier 3 — awk block-form parser. Last-resort no-dep fallback.
1365
+ * Practically always present (POSIX requirement); hard-fail only when
1366
+ * truly absent (in which case the consumer has ZERO working fallback
1367
+ * tiers and any CLI-absent shim invocation will silently fail-closed
1368
+ * on every policy lookup).
1369
+ */
1370
+ export function checkPolicyReaderTier3(probes) {
1371
+ const label = 'policy-reader Tier 3 (awk)';
1372
+ const p = resolveProbes(probes);
1373
+ const awk = p.awkOnPath();
1374
+ if (awk !== null) {
1375
+ return {
1376
+ label,
1377
+ status: 'pass',
1378
+ detail: `awk at ${awk} — block-form fallback available`,
1379
+ };
1380
+ }
1381
+ return {
1382
+ label,
1383
+ status: 'fail',
1384
+ detail: 'awk not on PATH — no fallback tier reachable. If the rea CLI and python3+PyYAML are ' +
1385
+ 'ALSO unreachable, every shim policy lookup fails closed. This is unusual; awk is a ' +
1386
+ 'POSIX requirement. Install awk (`mawk`, `gawk`, or `nawk`).',
1387
+ };
1388
+ }
1389
+ /**
1390
+ * jq — optional accelerator used by Tier 1/2's JSON subtree parsing.
1391
+ * Per the 0.37.0 round-1 P2 fix the helper falls back to a python3
1392
+ * walker when jq is absent (still correct, just an extra spawn per
1393
+ * leaf). `warn` when missing so operators know they're paying the
1394
+ * latency cost.
1395
+ *
1396
+ * `info` when present — no action needed, just confirming the
1397
+ * accelerator is wired.
1398
+ */
1399
+ export function checkPolicyReaderJq(probes) {
1400
+ const label = 'policy-reader jq (JSON accelerator)';
1401
+ const p = resolveProbes(probes);
1402
+ const jq = p.jqOnPath();
1403
+ if (jq !== null) {
1404
+ return {
1405
+ label,
1406
+ status: 'pass',
1407
+ detail: `jq at ${jq} — used by Tier 1/2 JSON subtree walking`,
1408
+ };
1409
+ }
1410
+ return {
1411
+ label,
1412
+ status: 'warn',
1413
+ detail: 'jq not on PATH — Tier 1/2 fall back to a python3 JSON walker per leaf (correct, ' +
1414
+ 'just slower). Install jq to reduce per-leaf spawn overhead.',
1415
+ };
1416
+ }
1417
+ /**
1418
+ * Summary roll-up: which tiers are reachable, what's the effective
1419
+ * floor when the CLI is unreachable, and is flow-form policy at risk
1420
+ * of silent no-op.
1421
+ *
1422
+ * Four verdicts:
1423
+ * - `pass` — Tier 1 OR Tier 2 reachable AND a JSON list walker
1424
+ * (jq or python3) is available. Flow-form scalars AND flow-form
1425
+ * arrays both parse correctly via whichever tier is hit first.
1426
+ * - `warn` (flow-form-lists-degraded) — Tier 1 reachable but neither
1427
+ * jq nor python3 on PATH. Flow-form SCALARS parse correctly via
1428
+ * the CLI's JSON output, but `policy_reader_get_list` cannot
1429
+ * iterate the resulting JSON array — it falls through to Tier 3
1430
+ * awk, which silently misses flow-form arrays like
1431
+ * `blocked_paths: [.env, ...]`. Codex round-1 P2 (2026-05-16).
1432
+ * - `warn` (Tier-3-only) — Only Tier 3 (awk) reachable. Block-form
1433
+ * policy works; flow-form scalars AND arrays both silently no-op
1434
+ * on every shim fallback.
1435
+ * - `fail` — No tiers reachable. Shims fail closed on every policy
1436
+ * lookup. (Practically requires losing awk too — see Tier 3.)
1437
+ *
1438
+ * Tier 2 implies python3 is on PATH (it's the interpreter that runs
1439
+ * the loader), so when Tier 2 is reachable the list-iteration python3
1440
+ * fallback is also reachable — only the Tier-1-without-list-walker
1441
+ * shape can produce the degraded warning.
1442
+ */
1443
+ export function checkPolicyReaderTierSummary(baseDir, probes) {
1444
+ const label = 'policy-reader effective floor';
1445
+ const p = resolveProbes(probes);
1446
+ // Mirror Tier 1's two-stage check — dist present + CLI invokable.
1447
+ // A stale/broken dist that fails the invokable probe is treated as
1448
+ // "Tier 1 not reachable" so the summary matches what the shim
1449
+ // ladder would actually do at runtime.
1450
+ const tier1 = p.cliDistExists(baseDir) && p.cliInvokable(baseDir);
1451
+ const py = p.python3OnPath();
1452
+ const tier2 = py !== null && p.python3PyYamlReachable();
1453
+ const tier3 = p.awkOnPath() !== null;
1454
+ const jq = p.jqOnPath();
1455
+ // List iteration after Tier 1/2 needs jq OR python3 to walk the
1456
+ // JSON. Tier 2 implies python3 on PATH (the interpreter that ran
1457
+ // the loader); so the only "lists broken" shape is Tier 1 reachable
1458
+ // but neither jq nor python3 on PATH.
1459
+ const listWalker = jq !== null || py !== null;
1460
+ const reachable = [];
1461
+ if (tier1)
1462
+ reachable.push('Tier 1 (CLI)');
1463
+ if (tier2)
1464
+ reachable.push('Tier 2 (python3+PyYAML)');
1465
+ if (tier3)
1466
+ reachable.push('Tier 3 (awk)');
1467
+ if (tier1 || tier2) {
1468
+ if (!listWalker) {
1469
+ // Tier 1 + no python3/jq. flow-form scalars work; flow-form
1470
+ // arrays silently no-op via Tier 3 fallthrough. (Tier 2 path
1471
+ // is unreachable here because Tier 2 requires python3.)
1472
+ return {
1473
+ label,
1474
+ status: 'warn',
1475
+ detail: `${reachable.join(', ')} reachable — flow-form scalars parse via Tier 1 CLI, ` +
1476
+ 'BUT neither jq nor python3 is on PATH so `policy_reader_get_list` cannot iterate ' +
1477
+ 'the resulting JSON arrays. Flow-form list policy (e.g. `blocked_paths: [.env, ...]`) ' +
1478
+ 'silently falls through to Tier 3 awk and misses inline arrays. Install jq ' +
1479
+ '(`brew install jq` / `apt-get install jq`) or python3 to close the gap.',
1480
+ };
1481
+ }
1482
+ return {
1483
+ label,
1484
+ status: 'pass',
1485
+ detail: `${reachable.join(', ')} reachable — flow-form policy parses correctly`,
1486
+ };
1487
+ }
1488
+ if (tier3) {
1489
+ return {
1490
+ label,
1491
+ status: 'warn',
1492
+ detail: 'only Tier 3 (awk, block-form ONLY) reachable — flow-form policy ' +
1493
+ '(e.g. `local_review: { mode: off }`, `blocked_paths: [.env, ...]`) silently ' +
1494
+ 'no-ops on every shim fallback path. Restore Tier 1 (rea CLI dist) or Tier 2 ' +
1495
+ '(python3 + PyYAML) to close the gap.',
1496
+ };
1497
+ }
1498
+ return {
1499
+ label,
1500
+ status: 'fail',
1501
+ detail: 'no policy-reader tier reachable — every shim policy lookup fails closed. ' +
1502
+ 'Install at least one of: rea CLI dist (Tier 1), python3 + PyYAML (Tier 2), ' +
1503
+ 'awk (Tier 3).',
1504
+ };
1505
+ }
953
1506
  /**
954
1507
  * Translate a `CodexProbeState` into two doctor CheckResults: one for
955
1508
  * responsiveness (pass/warn) and one informational line about the last
@@ -1457,9 +2010,15 @@ export function collectChecks(baseDir, codexProbeState, prePushState, options =
1457
2010
  const policyPath = reaPath(baseDir, POLICY_FILE);
1458
2011
  const registryPath = reaPath(baseDir, REGISTRY_FILE);
1459
2012
  const reaDirPath = path.join(baseDir, REA_DIR);
2013
+ // Run checkPolicyParses up-front so we can both push its result and
2014
+ // use the verdict to gate the 0.39.0 policy-reader tier checks below.
2015
+ // A malformed policy file should NOT trigger the tier-reachability
2016
+ // probes — those reports would misattribute a parse failure to a
2017
+ // runtime/install problem (codex round-3 P2, 2026-05-16).
2018
+ const policyParsesResult = checkPolicyParses(baseDir, policyPath);
1460
2019
  const checks = [
1461
2020
  checkFileExists('.rea/ directory exists', reaDirPath, true),
1462
- checkPolicyParses(baseDir, policyPath),
2021
+ policyParsesResult,
1463
2022
  checkRegistryParses(baseDir, registryPath),
1464
2023
  checkAgentsPresent(baseDir),
1465
2024
  checkHooksInstalled(baseDir),
@@ -1482,6 +2041,30 @@ export function collectChecks(baseDir, codexProbeState, prePushState, options =
1482
2041
  // went through in 0.29.0 → 0.30.0, after 4 release cycles of
1483
2042
  // propagation).
1484
2043
  checkDelegationAdvisoryHookRegistered(baseDir),
2044
+ // 0.39.0 — policy-reader tier visibility. Surfaces which tiers of
2045
+ // the 4-tier `hooks/_lib/policy-reader.sh` ladder are reachable in
2046
+ // this environment so operators can SEE whether flow-form policy
2047
+ // would silently no-op when the CLI is unreachable.
2048
+ //
2049
+ // Codex round-3 P2 (2026-05-16): gated on `policyParsesResult`
2050
+ // being a `pass` — NOT just `existsSync(policyPath)`. A
2051
+ // malformed policy file (present but unparseable) should report
2052
+ // exactly ONE failure — the parse-error from `checkPolicyParses`
2053
+ // above — and not also light up the tier probes with misleading
2054
+ // "Tier 1 dist exists but failed" or summary "ladder degraded"
2055
+ // diagnostics that misattribute a config bug to an
2056
+ // install/runtime problem. The parse-failure row already tells
2057
+ // the operator the right thing to fix; adding more downstream
2058
+ // noise would obscure it.
2059
+ ...(policyParsesResult.status === 'pass'
2060
+ ? [
2061
+ checkPolicyReaderTier1(baseDir),
2062
+ checkPolicyReaderTier2(),
2063
+ checkPolicyReaderTier3(),
2064
+ checkPolicyReaderJq(),
2065
+ checkPolicyReaderTierSummary(baseDir),
2066
+ ]
2067
+ : []),
1485
2068
  ];
1486
2069
  // Non-git escape hatch: when `.git/` is absent, both git-hook checks are
1487
2070
  // 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.38.1",
3
+ "version": "0.39.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)",