@bookedsolid/rea 0.35.0 → 0.37.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.
@@ -20,6 +20,15 @@
20
20
  # symlink-out + tarball-replacement attacks on the resolved CLI AND
21
21
  # stale-node_modules version skew that would otherwise turn every
22
22
  # Bash dispatch into a hard failure.
23
+ #
24
+ # Codex round 2 P1 (2026-05-16): the sandbox check now runs BEFORE
25
+ # the policy read. The pre-round-2 order called
26
+ # `policy_reader_get block_ai_attribution` first; that read invokes
27
+ # the resolved CLI through Tier 1 of the unified reader — meaning an
28
+ # unsandboxed CLI executed BEFORE the sandbox guard fired. Fixed by
29
+ # validating sandbox first; on failure REA_ARGV is cleared so the
30
+ # reader degrades to Tier 2 / Tier 3 (both pure file-parse, no
31
+ # arbitrary-code-execution).
23
32
 
24
33
  set -uo pipefail
25
34
 
@@ -63,22 +72,12 @@ if [ "$RELEVANT" -eq 0 ]; then
63
72
  exit 0
64
73
  fi
65
74
 
66
- # 2b. Policy short-circuit (round-6 P2). The pre-0.32.0 bash body
67
- # no-op'd when `block_ai_attribution` was absent or false. Without
68
- # this check, an unbuilt/stale install would refuse `git commit`
69
- # even on repos that DELIBERATELY disable the attribution gate.
70
- # Read the policy via a simple grep the canonical loader
71
- # handles inline forms but we only need block form here, and a
72
- # conservative "true-and-only-true counts" rule matches the
73
- # intent (false / absent / inline-only all → no enforcement).
74
- POLICY_FILE="$REA_ROOT/.rea/policy.yaml"
75
- if [ ! -f "$POLICY_FILE" ] || ! grep -qE '^block_ai_attribution:[[:space:]]*true([[:space:]]|$)' "$POLICY_FILE"; then
76
- # Attribution blocking disabled — pre-0.32.0 bash body would have
77
- # exited 0 here. Don't refuse on stale-install grounds.
78
- exit 0
79
- fi
80
-
81
- # 3. Resolve the rea CLI.
75
+ # 2b. Resolve the rea CLI first the unified policy reader uses
76
+ # REA_ARGV (when populated) as its Tier 1 source. Reordered from
77
+ # the pre-0.37.0 shape (where the CLI was resolved AFTER the policy
78
+ # grep) so the policy short-circuit below can route through Tier 1
79
+ # when the CLI is reachable, falling through Tier 2 (python3 +
80
+ # PyYAML) and Tier 3 (awk block-form) on stale/unbuilt installs.
82
81
  REA_ARGV=()
83
82
  RESOLVED_CLI_PATH=""
84
83
  if [ -f "$proj/node_modules/@bookedsolid/rea/dist/cli/index.js" ]; then
@@ -89,7 +88,115 @@ elif [ -f "$proj/dist/cli/index.js" ]; then
89
88
  RESOLVED_CLI_PATH="$proj/dist/cli/index.js"
90
89
  fi
91
90
 
91
+ # 2c. Realpath sandbox check — MUST run BEFORE any policy read that
92
+ # could route through Tier 1 (CLI). Codex round 2 P1: previously
93
+ # the policy_reader_get call below executed the resolved CLI to
94
+ # read `block_ai_attribution`. An attacker who symlinked
95
+ # dist/cli/index.js → /tmp/forged-tree (or who otherwise compromised
96
+ # the path) would have their forged CLI invoked during policy
97
+ # lookup BEFORE the sandbox check ran — defeating the trust
98
+ # boundary this shim is supposed to enforce.
99
+ #
100
+ # Fix: validate the CLI's sandbox shape first. On failure, clear
101
+ # REA_ARGV so the unified policy reader falls back to Tier 2
102
+ # (python3 + PyYAML) / Tier 3 (awk block-form) and the unsafe CLI
103
+ # never runs. The shim then re-evaluates fail-closed posture at
104
+ # §2e below (CLI absent + attribution-enabled → exit 2).
105
+ #
106
+ # Other 5 migrated shims (e.g. delegation-advisory) naturally avoid
107
+ # this ordering bug because their policy reads are NESTED inside
108
+ # `if [ "${#REA_ARGV[@]}" -eq 0 ]` (the CLI-absent path). This
109
+ # shim's flow is different — it reads policy unconditionally to
110
+ # decide whether to fail-closed at all.
111
+ SANDBOX_CHECK_RESULT=""
112
+ if [ "${#REA_ARGV[@]}" -gt 0 ]; then
113
+ if ! command -v node >/dev/null 2>&1; then
114
+ # No node on PATH — cannot run sandbox probe. Clear REA_ARGV so
115
+ # the policy reader skips Tier 1; later §2e will catch the
116
+ # CLI-required-but-absent state and refuse explicitly.
117
+ SANDBOX_CHECK_RESULT="bad:no-node"
118
+ REA_ARGV=()
119
+ else
120
+ SANDBOX_CHECK_RESULT=$(node -e '
121
+ const fs = require("fs");
122
+ const path = require("path");
123
+ const cli = process.argv[1];
124
+ const projDir = process.argv[2];
125
+ let real, realProj;
126
+ try { real = fs.realpathSync(cli); } catch (e) {
127
+ process.stdout.write("bad:realpath"); process.exit(1);
128
+ }
129
+ try { realProj = fs.realpathSync(projDir); } catch (e) {
130
+ process.stdout.write("bad:realpath-proj"); process.exit(1);
131
+ }
132
+ const sep = path.sep;
133
+ const projWithSep = realProj.endsWith(sep) ? realProj : realProj + sep;
134
+ if (!(real === realProj || real.startsWith(projWithSep))) {
135
+ process.stdout.write("bad:cli-escapes-project"); process.exit(1);
136
+ }
137
+ let cur = path.dirname(path.dirname(path.dirname(real)));
138
+ let found = false;
139
+ for (let i = 0; i < 20 && cur && cur !== path.dirname(cur); i += 1) {
140
+ const pj = path.join(cur, "package.json");
141
+ if (fs.existsSync(pj)) {
142
+ try {
143
+ const data = JSON.parse(fs.readFileSync(pj, "utf8"));
144
+ if (data && data.name === "@bookedsolid/rea") { found = true; break; }
145
+ } catch (e) { /* keep walking */ }
146
+ }
147
+ cur = path.dirname(cur);
148
+ }
149
+ if (!found) { process.stdout.write("bad:no-rea-pkg-json"); process.exit(1); }
150
+ process.stdout.write("ok");
151
+ ' -- "$RESOLVED_CLI_PATH" "$proj" 2>/dev/null)
152
+ if [ "$SANDBOX_CHECK_RESULT" != "ok" ]; then
153
+ # Sandbox failed — drop the unsafe CLI from REA_ARGV BEFORE
154
+ # reading policy. The unified reader will degrade to Tier 2 /
155
+ # Tier 3, both of which only read the policy file (no
156
+ # arbitrary-code-execution risk).
157
+ REA_ARGV=()
158
+ fi
159
+ fi
160
+ fi
161
+
162
+ # 2d. Policy short-circuit (round-6 P2, generalized in 0.37.0). The
163
+ # pre-0.32.0 bash body no-op'd when `block_ai_attribution` was
164
+ # absent or false. Without this check, an unbuilt/stale install
165
+ # would refuse `git commit` even on repos that DELIBERATELY
166
+ # disable the attribution gate.
167
+ #
168
+ # 0.37.0: route through `policy_reader_get` (4-tier ladder). The
169
+ # pre-0.37.0 grep matched ONLY block-form `block_ai_attribution:
170
+ # true`; inline-form (`block_ai_attribution: true` at any nesting
171
+ # accident) and quoted-form variants were missed. The reader's
172
+ # Tier 1 / Tier 2 paths handle both forms identically to the
173
+ # canonical TS loader; Tier 3 preserves the pre-0.37.0 block-only
174
+ # posture as a graceful-degrade fallback.
175
+ #
176
+ # Codex round 2 P1: by the time we reach this line, REA_ARGV is
177
+ # EITHER (a) populated and sandbox-validated, OR (b) empty — never
178
+ # populated-but-untrusted. The policy reader can safely use Tier 1.
179
+ # shellcheck source=_lib/policy-reader.sh
180
+ source "$(dirname "$0")/_lib/policy-reader.sh"
181
+ ATTR_ENABLED=$(policy_reader_get block_ai_attribution)
182
+ if [ "$ATTR_ENABLED" != "true" ]; then
183
+ # Attribution blocking disabled (or unreadable on Tier 3 fallback +
184
+ # missing policy file) — pre-0.32.0 bash body would have exited 0
185
+ # here. Don't refuse on stale-install grounds.
186
+ exit 0
187
+ fi
188
+
189
+ # 2e. CLI required from here on — we need the parser-backed Node binary
190
+ # to scan for attribution patterns. If REA_ARGV is empty because
191
+ # either (a) the CLI wasn't installed/built or (b) sandbox check
192
+ # failed and we cleared it above, refuse explicitly with a tailored
193
+ # message.
92
194
  if [ "${#REA_ARGV[@]}" -eq 0 ]; then
195
+ if [ -n "$SANDBOX_CHECK_RESULT" ] && [ "$SANDBOX_CHECK_RESULT" != "ok" ]; then
196
+ # Sandbox failure path — preserve forensic detail.
197
+ printf 'rea: attribution-advisory FAILED sandbox check (%s) — refusing.\n' "$SANDBOX_CHECK_RESULT" >&2
198
+ exit 2
199
+ fi
93
200
  # 0.32.0 round-4 P2: when `block_ai_attribution: true`, this hook is
94
201
  # blocking-tier — the pre-0.32.0 bash body enforced the policy
95
202
  # without a compiled CLI. Falling through to exit 0 would silently
@@ -102,55 +209,7 @@ if [ "${#REA_ARGV[@]}" -eq 0 ]; then
102
209
  exit 2
103
210
  fi
104
211
 
105
- # 3. Realpath sandbox check.
106
- if ! command -v node >/dev/null 2>&1; then
107
- printf 'rea: attribution-advisory cannot run — `node` is not on PATH.\n' >&2
108
- printf 'Install Node 22+ (engines.node) to restore enforcement.\n' >&2
109
- exit 2
110
- fi
111
-
112
- sandbox_check=$(node -e '
113
- const fs = require("fs");
114
- const path = require("path");
115
- const cli = process.argv[1];
116
- const projDir = process.argv[2];
117
- let real, realProj;
118
- try { real = fs.realpathSync(cli); } catch (e) {
119
- process.stdout.write("bad:realpath"); process.exit(1);
120
- }
121
- try { realProj = fs.realpathSync(projDir); } catch (e) {
122
- process.stdout.write("bad:realpath-proj"); process.exit(1);
123
- }
124
- const sep = path.sep;
125
- const projWithSep = realProj.endsWith(sep) ? realProj : realProj + sep;
126
- if (!(real === realProj || real.startsWith(projWithSep))) {
127
- process.stdout.write("bad:cli-escapes-project"); process.exit(1);
128
- }
129
- let cur = path.dirname(path.dirname(path.dirname(real)));
130
- let found = false;
131
- for (let i = 0; i < 20 && cur && cur !== path.dirname(cur); i += 1) {
132
- const pj = path.join(cur, "package.json");
133
- if (fs.existsSync(pj)) {
134
- try {
135
- const data = JSON.parse(fs.readFileSync(pj, "utf8"));
136
- if (data && data.name === "@bookedsolid/rea") { found = true; break; }
137
- } catch (e) { /* keep walking */ }
138
- }
139
- cur = path.dirname(cur);
140
- }
141
- if (!found) { process.stdout.write("bad:no-rea-pkg-json"); process.exit(1); }
142
- process.stdout.write("ok");
143
- ' -- "$RESOLVED_CLI_PATH" "$proj" 2>/dev/null)
144
-
145
- if [ "$sandbox_check" != "ok" ]; then
146
- # 0.32.0 round-4 P2: fail closed (blocking-tier when policy enables —
147
- # see top-of-file rationale). Sandbox failure means the CLI cannot
148
- # be authenticated; refuse rather than silently bypass.
149
- printf 'rea: attribution-advisory FAILED sandbox check (%s) — refusing.\n' "$sandbox_check" >&2
150
- exit 2
151
- fi
152
-
153
- # 4. Version-probe: confirm the resolved CLI implements
212
+ # 3. Version-probe: confirm the resolved CLI implements
154
213
  # `hook attribution-advisory`. Codex round 1 P1.
155
214
  probe_out=$("${REA_ARGV[@]}" hook attribution-advisory --help 2>&1)
156
215
  probe_status=$?
@@ -165,6 +224,6 @@ if [ "$probe_status" -ne 0 ] || ! printf '%s' "$probe_out" | grep -q -e 'attribu
165
224
  exit 2
166
225
  fi
167
226
 
168
- # 5. Forward stdin (already captured up-front for the relevance gate).
227
+ # 4. Forward stdin (already captured up-front for the relevance gate).
169
228
  printf '%s' "$INPUT" | "${REA_ARGV[@]}" hook attribution-advisory
170
229
  exit $?
@@ -82,26 +82,26 @@ if [ "${#REA_ARGV[@]}" -eq 0 ]; then
82
82
  if [ ! -f "$POLICY_FILE" ]; then
83
83
  exit 0
84
84
  fi
85
+ # 0.37.0: route blocked_paths reads through the unified
86
+ # policy-reader (Tier 1 CLI → Tier 2 python3 → Tier 3 awk
87
+ # block-form). Pre-0.37.0 the per-shim awk parser missed flow-form
88
+ # arrays (`blocked_paths: [.env, .env.*, ...]`), silently exiting 0
89
+ # on relevant Bash calls when the CLI was unreachable. The
90
+ # 4-tier ladder closes that bypass via Tier 2 whenever python3 +
91
+ # PyYAML are available (common on macOS Homebrew + most Linux
92
+ # distros); falls through to Tier 3 (block-form only) otherwise.
93
+ # shellcheck source=_lib/policy-reader.sh
94
+ source "$(dirname "$0")/_lib/policy-reader.sh"
85
95
  # Substring scan: does the command mention any blocked_paths entry?
86
96
  # Coarse — over-trigger is fine, under-trigger is the bypass we MUST
87
- # avoid. Strip YAML quotes/comments via a minimal awk filter.
97
+ # avoid.
88
98
  CLI_MISSING_RELEVANT=0
89
99
  while IFS= read -r entry; do
90
100
  [ -z "$entry" ] && continue
91
101
  case "$CLI_MISSING_CMD" in
92
102
  *"$entry"*) CLI_MISSING_RELEVANT=1; break ;;
93
103
  esac
94
- done < <(awk '
95
- /^blocked_paths:/ { in_block=1; next }
96
- in_block && /^[[:space:]]*-/ {
97
- sub(/^[[:space:]]*-[[:space:]]*/, "")
98
- gsub(/^["'\'']/, "")
99
- gsub(/["'\'']$/, "")
100
- print
101
- next
102
- }
103
- in_block && /^[^[:space:]-]/ { in_block=0 }
104
- ' "$POLICY_FILE" 2>/dev/null)
104
+ done < <(policy_reader_get_list blocked_paths 2>/dev/null)
105
105
  if [ "$CLI_MISSING_RELEVANT" -eq 0 ]; then
106
106
  exit 0
107
107
  fi
@@ -73,6 +73,16 @@ if [ "${#REA_ARGV[@]}" -eq 0 ]; then
73
73
  if [ ! -f "$POLICY_FILE" ]; then
74
74
  exit 0
75
75
  fi
76
+ # 0.37.0: route blocked_paths reads through the unified
77
+ # policy-reader (Tier 1 CLI → Tier 2 python3 → Tier 3 awk
78
+ # block-form). Pre-0.37.0 the inline awk parser missed flow-form
79
+ # arrays (`blocked_paths: [.env, .env.*, ...]`), silently allowing
80
+ # writes to those paths when the CLI was unreachable. The 4-tier
81
+ # ladder closes the bypass via Tier 2 when python3 + PyYAML are
82
+ # reachable; Tier 3 preserves the pre-0.37.0 block-only posture as
83
+ # a no-dep fallback.
84
+ # shellcheck source=_lib/policy-reader.sh
85
+ source "$(dirname "$0")/_lib/policy-reader.sh"
76
86
  CLI_MISSING_RELEVANT=0
77
87
  while IFS= read -r entry; do
78
88
  [ -z "$entry" ] && continue
@@ -94,17 +104,7 @@ if [ "${#REA_ARGV[@]}" -eq 0 ]; then
94
104
  case "$CLI_MISSING_FILE_PATH" in
95
105
  *"$base"*) CLI_MISSING_RELEVANT=1; break ;;
96
106
  esac
97
- done < <(awk '
98
- /^blocked_paths:/ { in_block=1; next }
99
- in_block && /^[[:space:]]*-/ {
100
- sub(/^[[:space:]]*-[[:space:]]*/, "")
101
- gsub(/^["'\'']/, "")
102
- gsub(/["'\'']$/, "")
103
- print
104
- next
105
- }
106
- in_block && /^[^[:space:]-]/ { in_block=0 }
107
- ' "$POLICY_FILE" 2>/dev/null)
107
+ done < <(policy_reader_get_list blocked_paths 2>/dev/null)
108
108
  if [ "$CLI_MISSING_RELEVANT" -eq 0 ]; then
109
109
  exit 0
110
110
  fi
@@ -158,100 +158,130 @@ if [ "${#REA_ARGV[@]}" -gt 0 ] && command -v node >/dev/null 2>&1; then
158
158
  fi
159
159
  fi
160
160
 
161
- # Helper: read a `local_review.<leaf>` policy key. Tries
162
- # `rea hook policy-get review.local_review --json` (one node-spawn for
163
- # the whole subtree) first that path handles inline + block YAML
164
- # identically since it goes through the canonical `yaml.parse()`.
165
- # Falls back to a block-form awk parser when the CLI isn't available
166
- # or jq isn't installed. Empty stdout "default applies".
161
+ # 0.37.0: route policy reads through the unified policy-reader. The
162
+ # pre-0.37.0 helper here was a hand-rolled dual-tier (CLI subtree
163
+ # JSON + per-leaf awk block-form parser). The new helper consolidates
164
+ # CLI + python3 + awk into a single 4-tier ladder, so inline-form
165
+ # mappings like `local_review: { mode: off, refuse_at: commit }` now
166
+ # work even on installs where the CLI is unreachable AND python3 +
167
+ # PyYAML are available (the previous bash awk fallback missed inline
168
+ # forms entirely — silent no-op on stale-CLI installs).
167
169
  #
168
- # 0.34.0 round-2 P2 fix: pre-fix the shim only ran the block-form awk
169
- # parser, so inline-form mappings like
170
- # `local_review: { mode: off, refuse_at: commit }` silently no-op'd on
171
- # stale-CLI installs (the canonical loader DOES handle them — only the
172
- # shim was block-only). Hybrid policy reader mirrors the pattern used
173
- # by prepare-commit-msg's augmenter.
170
+ # Behavior preserved: empty stdout "default applies"; the helper
171
+ # returns 0 even when the key is unset, so the existing callers'
172
+ # `case` statements work unchanged.
174
173
  #
175
- # The subtree JSON is fetched ONCE per Bash event (cached in
176
- # `_lrg_subtree_json`) so we don't pay 3x node-spawn cost. The cache
177
- # variable is "" until first call, "<none>" if the CLI / jq path
178
- # returned no usable JSON (so awk fallback runs), or the JSON body.
179
- _lrg_subtree_json=""
180
- _lrg_read_policy() {
181
- # $1 = dotted key (e.g. `review.local_review.mode`)
182
- local key="$1"
183
- local leaf="${key##*.}"
184
- # 1. First call: try `rea hook policy-get review.local_review --json`.
185
- # Subsequent calls reuse the cached subtree.
186
- if [ -z "$_lrg_subtree_json" ]; then
187
- if [ "${#REA_ARGV[@]}" -gt 0 ] && command -v jq >/dev/null 2>&1; then
188
- local json
189
- json=$("${REA_ARGV[@]}" hook policy-get review.local_review --json 2>/dev/null || true)
190
- # `null` indicates the path was unset leaves jq to print
191
- # `null` for any leaf, which we treat as "default applies".
192
- if [ -n "$json" ]; then
193
- _lrg_subtree_json="$json"
194
- else
195
- _lrg_subtree_json="<none>"
196
- fi
197
- else
198
- _lrg_subtree_json="<none>"
199
- fi
174
+ # Codex round 4 P2 (2026-05-16): local-review-gate fires on EVERY Bash
175
+ # PreToolUse event and reads three leaves from `review.local_review`
176
+ # (mode + refuse_at + bypass_env_var). The unified reader's CLI tier
177
+ # spawns a fresh `rea hook policy-get` per leaf, so the hot path went
178
+ # from 1 CLI startup (the pre-0.37.0 subtree call) to 4 (version probe
179
+ # + 3 leaves). Restore the subtree-cache shape: fetch
180
+ # `review.local_review` as JSON once, then extract leaves locally. Falls
181
+ # back to per-leaf reads when the subtree call returns null/empty (e.g.
182
+ # Tier 3 awk can't serve subtree — that's documented and the per-leaf
183
+ # block-form parser handles those cases via the unified reader's
184
+ # fall-through ladder).
185
+ # shellcheck source=_lib/policy-reader.sh
186
+ source "$(dirname "$0")/_lib/policy-reader.sh"
187
+
188
+ # Subtree cache: populated lazily on first read. Empty string means
189
+ # "not yet attempted"; "null" means "attempted, key unset"; any other
190
+ # value is the JSON object.
191
+ _LRG_LR_SUBTREE_JSON=""
192
+
193
+ _lrg_load_local_review_subtree() {
194
+ if [ -n "$_LRG_LR_SUBTREE_JSON" ]; then
195
+ return 0
196
+ fi
197
+ local sub
198
+ sub=$(policy_reader_get_subtree_json review.local_review 2>/dev/null)
199
+ if [ -z "$sub" ]; then
200
+ _LRG_LR_SUBTREE_JSON="null"
201
+ else
202
+ _LRG_LR_SUBTREE_JSON="$sub"
200
203
  fi
201
- # 2. If we have JSON, ask jq for the leaf.
202
- if [ "$_lrg_subtree_json" != "<none>" ]; then
204
+ }
205
+
206
+ # Extract a leaf from the cached subtree JSON. When subtree retrieval
207
+ # failed (e.g. Tier 3 awk fallback), or the leaf isn't present in the
208
+ # JSON, returns empty + non-zero so the caller can fall back to a
209
+ # per-leaf read.
210
+ _lrg_subtree_leaf() {
211
+ local leaf="$1"
212
+ if [ -z "$_LRG_LR_SUBTREE_JSON" ] || [ "$_LRG_LR_SUBTREE_JSON" = "null" ]; then
213
+ return 1
214
+ fi
215
+ # Try jq first; fall back to a python3 one-liner. Same hardened
216
+ # invocation shape as policy-reader.sh's no-jq fallback (env -u +
217
+ # PYTHONSAFEPATH + sys.path scrub).
218
+ if command -v jq >/dev/null 2>&1; then
203
219
  local out
204
- out=$(printf '%s' "$_lrg_subtree_json" | jq -r --arg k "$leaf" '
205
- if type == "object" and has($k) and (.[$k] != null) then .[$k] | tostring else "" end
206
- ' 2>/dev/null || true)
220
+ out=$(printf '%s' "$_LRG_LR_SUBTREE_JSON" | jq -r --arg k "$leaf" '
221
+ .[$k] as $v
222
+ | if $v == null then empty
223
+ elif ($v|type) == "string" or ($v|type) == "number" or ($v|type) == "boolean"
224
+ then $v | tostring
225
+ else empty
226
+ end
227
+ ' 2>/dev/null)
207
228
  if [ -n "$out" ]; then
208
229
  printf '%s' "$out"
209
230
  return 0
210
231
  fi
211
- # JSON path present but leaf unset → fall through to default. Do
212
- # NOT also try the awk parser; the canonical loader is the source
213
- # of truth here.
214
- return 0
232
+ return 1
215
233
  fi
216
- # 3. Fallback: block-form awk parser (legacy 0.34.0 round-1 path).
217
- # Only covers `review.local_review.<leaf>`. Inline-form mappings
218
- # fall through to "" defaults which is the SAME posture as the
219
- # pre-0.34.0 bash hook, but now with the CLI path above providing
220
- # inline-form support whenever the CLI is reachable.
221
- if [ ! -f "$POLICY_FILE" ] || ! command -v awk >/dev/null 2>&1; then
222
- return 0
234
+ if command -v python3 >/dev/null 2>&1; then
235
+ local out
236
+ out=$(env -u PYTHONPATH -u PYTHONHOME -u PYTHONSTARTUP \
237
+ PYTHONSAFEPATH=1 python3 -c '
238
+ import sys
239
+ import os
240
+ _cwd = os.getcwd()
241
+ _cwd_real = os.path.realpath(_cwd)
242
+ sys.path[:] = [p for p in sys.path if p not in ("", ".", _cwd, _cwd_real)]
243
+ import json
244
+ try:
245
+ doc = json.loads(sys.argv[1])
246
+ except Exception:
247
+ sys.exit(0)
248
+ leaf = sys.argv[2]
249
+ if isinstance(doc, dict) and leaf in doc:
250
+ v = doc[leaf]
251
+ if isinstance(v, bool):
252
+ sys.stdout.write("true" if v else "false")
253
+ elif isinstance(v, (int, float, str)):
254
+ sys.stdout.write(str(v))
255
+ ' "$_LRG_LR_SUBTREE_JSON" "$leaf" 2>/dev/null)
256
+ if [ -n "$out" ]; then
257
+ printf '%s' "$out"
258
+ return 0
259
+ fi
223
260
  fi
261
+ return 1
262
+ }
263
+
264
+ _lrg_read_policy() {
265
+ # $1 = dotted key (e.g. `review.local_review.mode`)
266
+ #
267
+ # For `review.local_review.*` leaves, try the subtree cache first
268
+ # (one CLI startup serves all three leaves). Fall back to a per-key
269
+ # read for everything else — and for leaves that the subtree cache
270
+ # couldn't produce (e.g. Tier 3 awk fallback where subtree mode is
271
+ # unsupported).
272
+ local key="$1"
224
273
  case "$key" in
225
- review.local_review.mode)
226
- awk '
227
- /^review[[:space:]]*:[[:space:]]*$/ { in_review=1; next }
228
- /^[^[:space:]]/ { in_review=0; in_lr=0; next }
229
- in_review && /^[[:space:]]+local_review[[:space:]]*:[[:space:]]*$/ { in_lr=1; next }
230
- in_lr && /^[[:space:]]{2,}[a-zA-Z]/ {
231
- if ($1 ~ /^mode:/) { print $2; exit }
232
- }
233
- in_lr && /^[[:space:]]{0,2}[a-zA-Z]/ && !/^[[:space:]]+local_review/ { in_lr=0 }
234
- ' "$POLICY_FILE" 2>/dev/null | tr -d '"' | tr -d "'" | head -1
235
- ;;
236
- review.local_review.refuse_at)
237
- awk '
238
- /^review[[:space:]]*:[[:space:]]*$/ { in_review=1; next }
239
- /^[^[:space:]]/ { in_review=0; in_lr=0; next }
240
- in_review && /^[[:space:]]+local_review[[:space:]]*:[[:space:]]*$/ { in_lr=1; next }
241
- in_lr && /^[[:space:]]{2,}refuse_at[[:space:]]*:/ { print $2; exit }
242
- in_lr && /^[[:space:]]{0,2}[a-zA-Z]/ && !/^[[:space:]]+local_review/ && !/^[[:space:]]+(mode|refuse_at|bypass_env_var|max_age_seconds)/ { in_lr=0 }
243
- ' "$POLICY_FILE" 2>/dev/null | tr -d '"' | tr -d "'" | head -1
244
- ;;
245
- review.local_review.bypass_env_var)
246
- awk '
247
- /^review[[:space:]]*:[[:space:]]*$/ { in_review=1; next }
248
- /^[^[:space:]]/ { in_review=0; in_lr=0; next }
249
- in_review && /^[[:space:]]+local_review[[:space:]]*:[[:space:]]*$/ { in_lr=1; next }
250
- in_lr && /^[[:space:]]{2,}bypass_env_var[[:space:]]*:/ { print $2; exit }
251
- in_lr && /^[[:space:]]{0,2}[a-zA-Z]/ && !/^[[:space:]]+local_review/ && !/^[[:space:]]+(mode|refuse_at|bypass_env_var|max_age_seconds)/ { in_lr=0 }
252
- ' "$POLICY_FILE" 2>/dev/null | tr -d '"' | tr -d "'" | head -1
274
+ review.local_review.*)
275
+ _lrg_load_local_review_subtree
276
+ local leaf="${key##*.}"
277
+ local v
278
+ if v=$(_lrg_subtree_leaf "$leaf"); then
279
+ printf '%s' "$v"
280
+ return 0
281
+ fi
253
282
  ;;
254
283
  esac
284
+ policy_reader_get "$key" 2>/dev/null
255
285
  }
256
286
 
257
287
  # 4. Mode-off short-circuit. Mirrors the bash hook's
@@ -81,13 +81,19 @@ if [ "${#REA_ARGV[@]}" -eq 0 ]; then
81
81
  *".rea/last-review"*) CLI_MISSING_RELEVANT=1 ;;
82
82
  *".claude\\"*|*".husky\\"*|*".rea\\"*) CLI_MISSING_RELEVANT=1 ;;
83
83
  esac
84
- # Codex round-1 P2 fix: scan policy.protected_writes entries too so a
85
- # consumer-defined protected path isn't silently allowed when the CLI
86
- # is missing. Read the policy via the same awk parser the consumer-
87
- # facing relevance pre-gates use for blocked_paths.
84
+ # 0.37.0: route protected_writes reads through the unified
85
+ # policy-reader (Tier 1 CLI Tier 2 python3 → Tier 3 awk
86
+ # block-form). Pre-0.37.0 the inline awk parser missed flow-form
87
+ # arrays (`protected_writes: [path/a, path/b]`) on CLI-missing
88
+ # installs, silently allowing writes to consumer-defined protected
89
+ # paths. The 4-tier ladder closes the bypass via Tier 2 whenever
90
+ # python3 + PyYAML are reachable; Tier 3 preserves the pre-0.37.0
91
+ # block-only posture as a no-dep fallback.
88
92
  if [ "$CLI_MISSING_RELEVANT" -eq 0 ]; then
89
93
  POLICY_FILE="${REA_ROOT}/.rea/policy.yaml"
90
94
  if [ -f "$POLICY_FILE" ]; then
95
+ # shellcheck source=_lib/policy-reader.sh
96
+ source "$(dirname "$0")/_lib/policy-reader.sh"
91
97
  while IFS= read -r entry; do
92
98
  [ -z "$entry" ] && continue
93
99
  base="$entry"
@@ -98,17 +104,7 @@ if [ "${#REA_ARGV[@]}" -eq 0 ]; then
98
104
  case "$CLI_MISSING_CMD" in
99
105
  *"$base"*) CLI_MISSING_RELEVANT=1; break ;;
100
106
  esac
101
- done < <(awk '
102
- /^protected_writes:/ { in_block=1; next }
103
- in_block && /^[[:space:]]*-/ {
104
- sub(/^[[:space:]]*-[[:space:]]*/, "")
105
- gsub(/^["'\'']/, "")
106
- gsub(/["'\'']$/, "")
107
- print
108
- next
109
- }
110
- in_block && /^[^[:space:]-]/ { in_block=0 }
111
- ' "$POLICY_FILE" 2>/dev/null)
107
+ done < <(policy_reader_get_list protected_writes 2>/dev/null)
112
108
  fi
113
109
  fi
114
110
  if [ "$CLI_MISSING_RELEVANT" -eq 0 ]; then
@@ -97,12 +97,19 @@ if [ "${#REA_ARGV[@]}" -eq 0 ]; then
97
97
  *".claude\\"*|*".husky\\"*|*".rea\\"*) CLI_MISSING_RELEVANT=1 ;;
98
98
  *"..%2F"*|*"%2E%2E"*) CLI_MISSING_RELEVANT=1 ;;
99
99
  esac
100
- # Codex round-1 P2 fix: scan policy.protected_writes entries too so a
101
- # consumer-defined protected path isn't silently allowed when the CLI
102
- # is missing.
100
+ # 0.37.0: route protected_writes reads through the unified
101
+ # policy-reader (Tier 1 CLI Tier 2 python3 → Tier 3 awk
102
+ # block-form). Pre-0.37.0 the inline awk parser missed flow-form
103
+ # arrays (`protected_writes: [path/a, path/b]`), silently allowing
104
+ # writes to consumer-defined protected paths when the CLI was
105
+ # unreachable. The 4-tier ladder closes the bypass via Tier 2 when
106
+ # python3 + PyYAML are reachable; Tier 3 preserves the pre-0.37.0
107
+ # block-only posture as a no-dep fallback.
103
108
  if [ "$CLI_MISSING_RELEVANT" -eq 0 ]; then
104
109
  POLICY_FILE="${REA_ROOT}/.rea/policy.yaml"
105
110
  if [ -f "$POLICY_FILE" ]; then
111
+ # shellcheck source=_lib/policy-reader.sh
112
+ source "$(dirname "$0")/_lib/policy-reader.sh"
106
113
  while IFS= read -r entry; do
107
114
  [ -z "$entry" ] && continue
108
115
  base="$entry"
@@ -113,17 +120,7 @@ if [ "${#REA_ARGV[@]}" -eq 0 ]; then
113
120
  case "$CLI_MISSING_FILE_PATH" in
114
121
  *"$base"*) CLI_MISSING_RELEVANT=1; break ;;
115
122
  esac
116
- done < <(awk '
117
- /^protected_writes:/ { in_block=1; next }
118
- in_block && /^[[:space:]]*-/ {
119
- sub(/^[[:space:]]*-[[:space:]]*/, "")
120
- gsub(/^["'\'']/, "")
121
- gsub(/["'\'']$/, "")
122
- print
123
- next
124
- }
125
- in_block && /^[^[:space:]-]/ { in_block=0 }
126
- ' "$POLICY_FILE" 2>/dev/null)
123
+ done < <(policy_reader_get_list protected_writes 2>/dev/null)
127
124
  fi
128
125
  fi
129
126
  if [ "$CLI_MISSING_RELEVANT" -eq 0 ]; then