@bookedsolid/rea 0.36.0 → 0.38.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.
Files changed (37) hide show
  1. package/hooks/_lib/policy-reader.sh +948 -0
  2. package/hooks/_lib/shim-runtime.sh +405 -0
  3. package/hooks/architecture-review-gate.sh +11 -103
  4. package/hooks/attribution-advisory.sh +43 -155
  5. package/hooks/blocked-paths-bash-gate.sh +35 -149
  6. package/hooks/blocked-paths-enforcer.sh +35 -140
  7. package/hooks/changeset-security-gate.sh +26 -119
  8. package/hooks/dangerous-bash-interceptor.sh +46 -170
  9. package/hooks/delegation-advisory.sh +26 -144
  10. package/hooks/delegation-capture.sh +33 -139
  11. package/hooks/dependency-audit-gate.sh +29 -121
  12. package/hooks/env-file-protection.sh +30 -141
  13. package/hooks/local-review-gate.sh +191 -396
  14. package/hooks/pr-issue-link-gate.sh +16 -118
  15. package/hooks/protected-paths-bash-gate.sh +57 -160
  16. package/hooks/secret-scanner.sh +90 -213
  17. package/hooks/security-disclosure-gate.sh +32 -155
  18. package/hooks/settings-protection.sh +56 -179
  19. package/package.json +1 -1
  20. package/templates/_lib_policy-reader.dogfood-staged.sh +948 -0
  21. package/templates/_lib_shim-runtime.dogfood-staged.sh +405 -0
  22. package/templates/architecture-review-gate.dogfood-staged.sh +11 -103
  23. package/templates/attribution-advisory.dogfood-staged.sh +43 -155
  24. package/templates/blocked-paths-bash-gate.dogfood-staged.sh +35 -149
  25. package/templates/blocked-paths-enforcer.dogfood-staged.sh +35 -140
  26. package/templates/changeset-security-gate.dogfood-staged.sh +26 -119
  27. package/templates/dangerous-bash-interceptor.dogfood-staged.sh +46 -170
  28. package/templates/delegation-advisory.dogfood-staged.sh +44 -0
  29. package/templates/delegation-capture.dogfood-staged.sh +52 -0
  30. package/templates/dependency-audit-gate.dogfood-staged.sh +29 -121
  31. package/templates/env-file-protection.dogfood-staged.sh +30 -141
  32. package/templates/local-review-gate.dogfood-staged.sh +191 -396
  33. package/templates/pr-issue-link-gate.dogfood-staged.sh +16 -118
  34. package/templates/protected-paths-bash-gate.dogfood-staged.sh +57 -160
  35. package/templates/secret-scanner.dogfood-staged.sh +90 -213
  36. package/templates/security-disclosure-gate.dogfood-staged.sh +32 -155
  37. package/templates/settings-protection.dogfood-staged.sh +56 -179
@@ -0,0 +1,948 @@
1
+ #!/bin/bash
2
+ # hooks/_lib/policy-reader.sh — unified 4-tier policy reader for shims.
3
+ # Introduced 0.37.0.
4
+ #
5
+ # Source via:
6
+ # source "$(dirname "$0")/_lib/policy-reader.sh"
7
+ #
8
+ # # Problem this solves
9
+ #
10
+ # Across releases 0.34.0 / 0.35.0 the shims acquired ad-hoc per-shim
11
+ # YAML parsers (awk programs) used ONLY when the rea CLI is
12
+ # unreachable (fresh / unbuilt install, sandbox failure, etc). Each
13
+ # parser was block-form-only. The canonical TS loader in
14
+ # src/policy/loader.ts accepts BOTH block-form AND flow-form YAML
15
+ # (e.g. `local_review: { mode: off }` or `blocked_paths: [.env, ...]`).
16
+ # Silent split-brain: a consumer with a flow-form policy + missing CLI
17
+ # silently skipped the gate.
18
+ #
19
+ # # Ladder
20
+ #
21
+ # This helper consolidates the per-shim parsers into a 4-tier
22
+ # graceful-degradation ladder:
23
+ #
24
+ # Tier 1 — `rea hook policy-get` (canonical, validated TS loader).
25
+ # Handles BOTH block AND flow form identically. Source of
26
+ # truth. Tried first when the caller has populated
27
+ # `REA_ARGV` (the same 2-tier sandboxed CLI resolution
28
+ # shims already do up top).
29
+ #
30
+ # Tier 2 — python3 with stdlib + PyYAML. Falls back when the CLI is
31
+ # unreachable. Handles BOTH block AND flow form.
32
+ # Mirrors the pattern proven in `.husky/prepare-commit-msg`
33
+ # (0.30.0 attribution augmenter).
34
+ #
35
+ # Tier 3 — awk block-form parser. Last-resort, no-dep fallback.
36
+ # Block-form ONLY (the same limitation as the pre-0.37.0
37
+ # per-shim parsers). Used when both Tier 1 and Tier 2 are
38
+ # unavailable.
39
+ #
40
+ # Tier 4 — fail. Function returns 1; stdout is empty. The shim
41
+ # decides how to handle (blocking-tier hooks fail closed;
42
+ # advisory-tier hooks fall open).
43
+ #
44
+ # # Caller usage
45
+ #
46
+ # Callers set `REA_ARGV` to the resolved rea CLI invocation (empty
47
+ # array when the CLI was unreachable / failed sandbox). The helper
48
+ # uses that array to invoke Tier 1; when empty it skips Tier 1.
49
+ #
50
+ # Scalar read:
51
+ # value=$(policy_reader_get "review.local_review.mode")
52
+ # # stdout: the scalar value (empty when unset)
53
+ # # exit: 0 = ok (even when empty), 1 = unreadable (all tiers failed)
54
+ #
55
+ # Subtree read (one call → all leaves cached for jq):
56
+ # policy_reader_get_subtree_json "review.local_review"
57
+ # # stdout: JSON of the subtree (e.g. `{"mode":"off","refuse_at":"both"}`)
58
+ # # or `null` when path unset.
59
+ # # exit: 0 = ok, 1 = unreadable.
60
+ #
61
+ # List read:
62
+ # while IFS= read -r entry; do ...; done < <(policy_reader_get_list "blocked_paths")
63
+ # # stdout: one entry per line (empty list → no lines)
64
+ # # exit: 0 = ok, 1 = unreadable.
65
+ #
66
+ # # Force-tier mode (testing)
67
+ #
68
+ # POLICY_READER_FORCE_TIER=cli # only Tier 1
69
+ # POLICY_READER_FORCE_TIER=python3 # only Tier 2
70
+ # POLICY_READER_FORCE_TIER=awk # only Tier 3
71
+ # POLICY_READER_FORCE_TIER=none # skip all tiers; force exit 1
72
+ #
73
+ # # Cache
74
+ #
75
+ # The first Tier-1 / Tier-2 call resolves the policy file ONCE as JSON
76
+ # (the entire document), caches it in `_REA_POLICY_FULL_JSON`, and all
77
+ # subsequent calls read leaves from the cache via jq. This mirrors the
78
+ # 0.34.0 `_lrg_subtree_json` pattern but extends it to the whole
79
+ # document — so a hook that reads 5 keys pays ONE node-spawn cost.
80
+ # Tier 3 (awk) is parsed per-call (no JSON intermediate; block-form
81
+ # only).
82
+ #
83
+ # Cache key invalidation is by hook invocation: each `source` of this
84
+ # file resets the cache (shells DO re-source on each hook fire because
85
+ # Claude Code spawns a fresh bash per event).
86
+
87
+ # Do NOT set `-e` — sourced library, propagates to callers. See
88
+ # hooks/_lib/halt-check.sh for rationale.
89
+ set -uo pipefail
90
+
91
+ # Resolve the project root (REA_ROOT) and policy file path. Mirrors
92
+ # the logic in `policy-read.sh::policy_path`.
93
+ _pr_policy_path() {
94
+ local root="${REA_ROOT:-}"
95
+ if [ -z "$root" ]; then
96
+ if command -v rea_root >/dev/null 2>&1; then
97
+ root=$(rea_root)
98
+ else
99
+ root="${CLAUDE_PROJECT_DIR:-$(pwd)}"
100
+ fi
101
+ fi
102
+ local policy="${root}/.rea/policy.yaml"
103
+ if [ -f "$policy" ]; then
104
+ printf '%s' "$policy"
105
+ fi
106
+ }
107
+
108
+ # Cache the entire policy doc as JSON on first read. `_REA_POLICY_FULL_JSON`
109
+ # is "" until first read, then "null" or a JSON document body. The
110
+ # `_REA_POLICY_LOADED` flag distinguishes "not loaded" from "loaded
111
+ # and unreadable".
112
+ #
113
+ # _REA_POLICY_LOADED=0 — not yet attempted
114
+ # _REA_POLICY_LOADED=1 — attempted, succeeded (JSON in _REA_POLICY_FULL_JSON)
115
+ # _REA_POLICY_LOADED=2 — attempted, failed all loadable tiers (Tier 3 awk
116
+ # still available for block-form leaf reads)
117
+ _REA_POLICY_FULL_JSON=""
118
+ _REA_POLICY_LOADED=0
119
+ _REA_POLICY_LOADED_TIER="" # informational: "cli" | "python3" | "" (unset / awk fallback)
120
+
121
+ # Tier 1 — CLI. Reads the WHOLE document as JSON via the empty-key
122
+ # trick: `rea hook policy-get --json` doesn't accept an empty key
123
+ # (validation rejects it), so we request a known top-level key
124
+ # (`profile`) just to confirm reachability, then walk the document
125
+ # tier-by-tier via the helper. Actually, a cleaner approach is to use
126
+ # `--json` on the deepest key the caller actually asked for — but
127
+ # subtree mode wants different roots. So instead, we load the FULL
128
+ # document once via a tiny in-process node invocation that uses the
129
+ # already-resolved REA_ARGV CLI to parse policy.yaml.
130
+ #
131
+ # Implementation: ask the CLI for a known top-level key (`version`)
132
+ # with `--json` to verify reachability, but DON'T use that as the
133
+ # cache. Instead, when the CLI is reachable, ask it for ".raw" via a
134
+ # tiny node program that reads the policy file directly and emits
135
+ # YAML→JSON. Wait — that's a second binary. Simpler approach:
136
+ #
137
+ # For CLI tier: invoke `rea hook policy-get <key> --json` per-call,
138
+ # but cache the result keyed by the requested subtree/leaf. The
139
+ # 3×-spawn cost across leaves matters; we mitigate by encouraging
140
+ # callers to use `policy_reader_get_subtree_json` and parse with jq.
141
+ # This matches the existing local-review-gate.sh pattern.
142
+ #
143
+ # For Tier 2 (python3): load the ENTIRE document as JSON in one
144
+ # python invocation, cache forever. Any subsequent get is a jq query
145
+ # on the cached JSON — no second python spawn.
146
+ #
147
+ # Tier 3 awk does not produce JSON; it returns per-key scalars. The
148
+ # subtree mode cannot be served by Tier 3 — it returns exit 1 unless
149
+ # a tier above produced JSON.
150
+
151
+ # Load the full document JSON via Tier 1 or Tier 2. Sets the cache
152
+ # vars. Idempotent; subsequent calls are a fast-path return.
153
+ _pr_load_full_json() {
154
+ if [ "$_REA_POLICY_LOADED" != "0" ]; then
155
+ return 0
156
+ fi
157
+ local policy
158
+ policy=$(_pr_policy_path)
159
+ if [ -z "$policy" ]; then
160
+ _REA_POLICY_LOADED=2
161
+ return 0
162
+ fi
163
+
164
+ local force="${POLICY_READER_FORCE_TIER:-}"
165
+
166
+ # ---- Tier 1: rea CLI ----
167
+ # We use `rea hook policy-get` to read a SINGLE marker key with
168
+ # `--json` to confirm reachability and shape. The CLI cannot emit
169
+ # the whole document at once (no `--full` subcommand), but it CAN
170
+ # emit any subtree as JSON. For the cache we read `--json` on the
171
+ # ROOT-LIKE keys that callers actually use; per-call. So the Tier 1
172
+ # path is "directly answer each policy_reader_get_* call via a fresh
173
+ # CLI invocation" — see _pr_tier1_get below.
174
+ #
175
+ # We still record reachability HERE so later calls don't re-probe.
176
+ if [ "$force" = "" ] || [ "$force" = "cli" ]; then
177
+ if [ -n "${REA_ARGV+x}" ] && [ "${#REA_ARGV[@]}" -gt 0 ]; then
178
+ # Probe with a known key. `version` is always present in a
179
+ # validated policy. Use `--json` so we can tell parse-success
180
+ # from key-missing (the value `null` would mean unset top-level
181
+ # key, which would itself be a bug; we just need exit 0 + a
182
+ # non-empty stdout).
183
+ local probe
184
+ probe=$("${REA_ARGV[@]}" hook policy-get version --json 2>/dev/null) || probe=""
185
+ if [ -n "$probe" ]; then
186
+ _REA_POLICY_LOADED=1
187
+ _REA_POLICY_LOADED_TIER="cli"
188
+ return 0
189
+ fi
190
+ fi
191
+ if [ "$force" = "cli" ]; then
192
+ _REA_POLICY_LOADED=2
193
+ return 0
194
+ fi
195
+ fi
196
+
197
+ # ---- Tier 2: python3 with PyYAML ----
198
+ if [ "$force" = "" ] || [ "$force" = "python3" ]; then
199
+ if command -v python3 >/dev/null 2>&1; then
200
+ local json
201
+ # The python program tries to import `yaml` (PyYAML). If absent,
202
+ # it exits non-zero and we fall through to Tier 3. If present,
203
+ # it loads the whole document and emits JSON on stdout.
204
+ #
205
+ # Codex round 2 P1 + round 3 P2 (2026-05-16): isolate the
206
+ # interpreter from repo-local imports. Without protection,
207
+ # Python prepends the project CWD to `sys.path[0]` when reading
208
+ # the program from stdin (`-`), so a malicious repo that ships
209
+ # `yaml.py` or `json.py` would have it imported BEFORE the
210
+ # stdlib copy — turning every policy lookup into arbitrary code
211
+ # execution.
212
+ #
213
+ # Layered defense:
214
+ # 1. `env -u PYTHONPATH -u PYTHONHOME -u PYTHONSTARTUP` —
215
+ # explicitly remove the env vars an attacker could use to
216
+ # inject a search path or startup hook BEFORE we ever
217
+ # invoke python3. Round 3 P2: PYTHONSAFEPATH only blocks
218
+ # the script-directory and cwd prepend; absolute paths
219
+ # injected via PYTHONPATH survive. Unsetting closes that
220
+ # hole. PYTHONHOME and PYTHONSTARTUP are unset for the
221
+ # same family of reasons (alternate stdlib root, code-on-
222
+ # startup hook).
223
+ # 2. PYTHONSAFEPATH=1 (env-var form of `-P`, Python 3.11+) —
224
+ # tells the interpreter NOT to prepend the script's
225
+ # directory / cwd to sys.path.
226
+ # 3. Manual sys.path scrub at the top of the program (any
227
+ # Python version) — removes "", ".", and cwd entries that
228
+ # may have slipped through on older interpreters where
229
+ # PYTHONSAFEPATH is silently ignored (3.10 and earlier).
230
+ #
231
+ # We deliberately do NOT use `-I` ("isolated mode") here even
232
+ # though it implies `-P` on 3.11+. `-I` also removes user
233
+ # site-packages (`~/.local/lib/...`), which on many macOS /
234
+ # Linux developer machines is where PyYAML lives. `-I` would
235
+ # turn a working Tier 2 into a Tier 3 fall-through for the
236
+ # majority of real installs. The env-scrub + PYTHONSAFEPATH +
237
+ # sys.path scrub combination achieves the same security
238
+ # guarantee without breaking the import path for PyYAML.
239
+ json=$(env -u PYTHONPATH -u PYTHONHOME -u PYTHONSTARTUP \
240
+ PYTHONSAFEPATH=1 python3 - "$policy" <<'PY' 2>/dev/null
241
+ # Tier 2 — load policy.yaml via PyYAML and emit JSON.
242
+ #
243
+ # IMPORTANT: the canonical TS loader uses the `yaml` npm package which
244
+ # defaults to YAML 1.2 semantics. YAML 1.2 dropped the
245
+ # `on`/`off`/`yes`/`no` boolean aliases that YAML 1.1 (PyYAML's
246
+ # default) still coerces. A consumer policy with
247
+ # `local_review: { mode: off }` should be the STRING `"off"` (matching
248
+ # the CLI), not Python's `False`.
249
+ #
250
+ # Use PyYAML's resolver in 1.2 mode by clearing the bool resolver
251
+ # aliases. This preserves `true`/`false` booleans while leaving
252
+ # `on`/`off`/`yes`/`no` as strings — exactly matching the TS loader.
253
+ import sys
254
+ import os
255
+
256
+ # Codex round 2 P1: defensive sys.path scrub for Python 3.4-3.10 where
257
+ # `-I` doesn't include `-P` semantics. Remove any entry that is empty
258
+ # (""), "." or the project CWD. These are the entries Python adds
259
+ # automatically when reading from stdin; on the target Python version
260
+ # (3.11+) `-I`/PYTHONSAFEPATH already removes them, but on older
261
+ # interpreters we must do it manually.
262
+ _cwd = os.getcwd()
263
+ _cwd_real = os.path.realpath(_cwd)
264
+ sys.path[:] = [
265
+ p for p in sys.path
266
+ if p not in ("", ".", _cwd, _cwd_real)
267
+ ]
268
+
269
+ import json
270
+ try:
271
+ import yaml
272
+ except Exception:
273
+ sys.exit(2)
274
+
275
+ class _Yaml12Loader(yaml.SafeLoader):
276
+ """SafeLoader with YAML-1.2-style booleans (only true/false)."""
277
+
278
+ # Replace the bool resolver with one that only matches `true|false`
279
+ # (case-insensitive). PyYAML's default also matches yes/no/on/off.
280
+ _Yaml12Loader.yaml_implicit_resolvers = {
281
+ k: [(tag, regexp) for tag, regexp in v if tag != 'tag:yaml.org,2002:bool']
282
+ for k, v in yaml.SafeLoader.yaml_implicit_resolvers.items()
283
+ }
284
+ import re as _re
285
+ _bool_re = _re.compile(r'^(?:true|True|TRUE|false|False|FALSE)$')
286
+ _Yaml12Loader.add_implicit_resolver(
287
+ 'tag:yaml.org,2002:bool',
288
+ _bool_re,
289
+ list('tTfF'),
290
+ )
291
+
292
+ path = sys.argv[1]
293
+ try:
294
+ with open(path, 'r', encoding='utf-8') as fh:
295
+ doc = yaml.load(fh, Loader=_Yaml12Loader)
296
+ except Exception:
297
+ sys.exit(3)
298
+ try:
299
+ json.dump(doc, sys.stdout, ensure_ascii=False)
300
+ except Exception:
301
+ sys.exit(4)
302
+ PY
303
+ )
304
+ if [ -n "$json" ]; then
305
+ _REA_POLICY_FULL_JSON="$json"
306
+ _REA_POLICY_LOADED=1
307
+ _REA_POLICY_LOADED_TIER="python3"
308
+ return 0
309
+ fi
310
+ fi
311
+ if [ "$force" = "python3" ]; then
312
+ _REA_POLICY_LOADED=2
313
+ return 0
314
+ fi
315
+ fi
316
+
317
+ # ---- Tier 3: awk (block-form only) — cannot produce JSON, so the
318
+ # subtree cache stays empty. Per-call awk reads in _pr_tier3_* still
319
+ # work for scalar/list reads. ----
320
+ _REA_POLICY_LOADED=2
321
+ return 0
322
+ }
323
+
324
+ # Walk a dotted path through a JSON document and emit either the
325
+ # scalar value (no surrounding quotes) or, in --json mode, the JSON
326
+ # form of the leaf. Uses jq when available; falls back to a python3
327
+ # one-liner when jq is absent but python3 is on PATH.
328
+ #
329
+ # Codex round 2 P2 (2026-05-16): the pre-round-2 implementation
330
+ # returned exit 0 with empty stdout when jq was missing, silently
331
+ # dropping flow-form policy lookups on python3-but-no-jq systems
332
+ # (a normal shape — PyYAML is widely installed, jq is not). The
333
+ # Tier 3 awk fallback only handles block-form, so a consumer with
334
+ # `local_review: { mode: off }` and no jq would silently lose
335
+ # inline-form parsing. The python3 fallback below reads the same
336
+ # cached JSON the helper already produced, so no new YAML parsing
337
+ # happens — just JSON walking.
338
+ #
339
+ # Args: $1 = JSON doc, $2 = dotted key, $3 = "scalar"|"json"
340
+ # Stdout: the value (empty if missing); exit 0.
341
+ _pr_jq_walk() {
342
+ local doc="$1"
343
+ local key="$2"
344
+ local mode="$3"
345
+ # `POLICY_READER_DISABLE_JQ=1` forces the no-jq fallback path even
346
+ # when jq is on PATH. Used by the test suite to exercise the python3
347
+ # walker on CI runners where jq is universally installed (Apple
348
+ # ships jq in /usr/bin).
349
+ if [ "${POLICY_READER_DISABLE_JQ:-0}" != "1" ] && command -v jq >/dev/null 2>&1; then
350
+ # Build a jq getpath query from the dotted key.
351
+ # `policy_reader_get` validates the key shape upstream; here we just
352
+ # split on `.` and pass as an array.
353
+ local jq_path
354
+ jq_path=$(printf '%s' "$key" | awk -F'.' '{
355
+ out="["
356
+ for (i=1; i<=NF; i++) {
357
+ if (i>1) out=out","
358
+ gsub(/"/, "\\\"", $i)
359
+ out=out"\""$i"\""
360
+ }
361
+ out=out"]"
362
+ print out
363
+ }')
364
+ if [ "$mode" = "json" ]; then
365
+ printf '%s' "$doc" | jq -c --argjson p "$jq_path" 'getpath($p)' 2>/dev/null
366
+ else
367
+ # Scalar mode: emit primitives as their string form, objects/arrays
368
+ # as empty (caller treats as unset).
369
+ printf '%s' "$doc" | jq -r --argjson p "$jq_path" '
370
+ getpath($p) as $v
371
+ | if $v == null then empty
372
+ elif ($v|type) == "string" or ($v|type) == "number" or ($v|type) == "boolean"
373
+ then $v | tostring
374
+ else empty
375
+ end
376
+ ' 2>/dev/null
377
+ fi
378
+ return 0
379
+ fi
380
+ # jq absent — use python3 to walk the cached JSON. The doc was
381
+ # already produced by python3 (Tier 2 loader); we know python3 is
382
+ # reachable. We guard with command -v anyway in case the environment
383
+ # changed between calls.
384
+ if ! command -v python3 >/dev/null 2>&1; then
385
+ return 0
386
+ fi
387
+ # The key shape is already validated by the public entry points
388
+ # (`policy_reader_get*`) to contain only `[A-Za-z0-9_.]`. We pass it
389
+ # AND the JSON doc as argv (not embedded in the program text) so
390
+ # neither can break out of the string literal. Two-redirect heredocs
391
+ # don't compose reliably across bash versions, so use argv for the
392
+ # JSON payload rather than stdin.
393
+ #
394
+ # Codex round 2 P1 + round 3 P2 hardening: env scrub +
395
+ # PYTHONSAFEPATH=1 + sys.path scrub prevent repo-local `json.py` /
396
+ # shadow stdlib modules from being imported, regardless of whether
397
+ # they're injected via cwd, script-dir, or PYTHONPATH/PYTHONHOME.
398
+ # See the Tier 2 loader above for the full rationale (and why `-I`
399
+ # isolated mode is intentionally NOT used — it would additionally
400
+ # drop user site-packages where PyYAML often lives).
401
+ env -u PYTHONPATH -u PYTHONHOME -u PYTHONSTARTUP \
402
+ PYTHONSAFEPATH=1 python3 -c '
403
+ import sys
404
+ import os
405
+ # Defensive sys.path scrub for Python 3.4-3.10 (-P semantics).
406
+ _cwd = os.getcwd()
407
+ _cwd_real = os.path.realpath(_cwd)
408
+ sys.path[:] = [
409
+ p for p in sys.path
410
+ if p not in ("", ".", _cwd, _cwd_real)
411
+ ]
412
+ import json
413
+
414
+ # argv[1]: the JSON doc
415
+ # argv[2]: dotted key (validated to [A-Za-z0-9_.])
416
+ # argv[3]: "scalar" | "json"
417
+ try:
418
+ doc = json.loads(sys.argv[1])
419
+ except Exception:
420
+ sys.exit(0)
421
+ key = sys.argv[2]
422
+ mode = sys.argv[3]
423
+
424
+ segments = key.split(".")
425
+ cur = doc
426
+ for seg in segments:
427
+ if isinstance(cur, dict) and seg in cur:
428
+ cur = cur[seg]
429
+ else:
430
+ cur = None
431
+ break
432
+
433
+ if mode == "json":
434
+ if cur is None:
435
+ sys.stdout.write("null")
436
+ else:
437
+ json.dump(cur, sys.stdout, ensure_ascii=False, separators=(",", ":"))
438
+ else:
439
+ # Scalar mode — primitives only.
440
+ if cur is None:
441
+ sys.exit(0)
442
+ if isinstance(cur, bool):
443
+ sys.stdout.write("true" if cur else "false")
444
+ elif isinstance(cur, (int, float, str)):
445
+ sys.stdout.write(str(cur))
446
+ # Object/array: empty stdout (caller treats as unset).
447
+ ' "$doc" "$key" "$mode" 2>/dev/null
448
+ return 0
449
+ }
450
+
451
+ # Tier 1 (per-call): invoke `rea hook policy-get KEY [--json]` directly.
452
+ # Used when _REA_POLICY_LOADED_TIER="cli" — Tier 2 cache is empty so we
453
+ # must shell out per leaf. Cached per-key in the helper's own kv store.
454
+ #
455
+ # We store the cache in two parallel arrays-of-keys/values (bash 3.2
456
+ # has no associative arrays). Capacity is small (handful of policy
457
+ # reads per hook) so linear scan is fine.
458
+ _REA_POLICY_KV_KEYS=()
459
+ _REA_POLICY_KV_VALS=()
460
+
461
+ _pr_kv_get() {
462
+ local key="$1"
463
+ local i=0
464
+ local n="${#_REA_POLICY_KV_KEYS[@]}"
465
+ while [ "$i" -lt "$n" ]; do
466
+ if [ "${_REA_POLICY_KV_KEYS[$i]}" = "$key" ]; then
467
+ printf '%s' "${_REA_POLICY_KV_VALS[$i]}"
468
+ return 0
469
+ fi
470
+ i=$((i + 1))
471
+ done
472
+ return 1
473
+ }
474
+
475
+ _pr_kv_set() {
476
+ local key="$1"
477
+ local val="$2"
478
+ _REA_POLICY_KV_KEYS+=("$key")
479
+ _REA_POLICY_KV_VALS+=("$val")
480
+ }
481
+
482
+ # Tier 1 per-call CLI read. Returns 0 + writes value (possibly empty)
483
+ # on success; returns 1 when the CLI fails or wasn't probe-good.
484
+ _pr_tier1_get() {
485
+ local key="$1"
486
+ local mode="$2" # "scalar" | "json"
487
+ if [ "$_REA_POLICY_LOADED_TIER" != "cli" ]; then
488
+ return 1
489
+ fi
490
+ if [ "${#REA_ARGV[@]}" -eq 0 ]; then
491
+ return 1
492
+ fi
493
+ local cache_key="$mode:$key"
494
+ local cached
495
+ if cached=$(_pr_kv_get "$cache_key"); then
496
+ printf '%s' "$cached"
497
+ return 0
498
+ fi
499
+ local out
500
+ local rc
501
+ if [ "$mode" = "json" ]; then
502
+ out=$("${REA_ARGV[@]}" hook policy-get "$key" --json 2>/dev/null)
503
+ rc=$?
504
+ else
505
+ out=$("${REA_ARGV[@]}" hook policy-get "$key" 2>/dev/null)
506
+ rc=$?
507
+ fi
508
+ if [ "$rc" -ne 0 ]; then
509
+ return 1
510
+ fi
511
+ # Cache empty results too (they mean "unset" — same as a non-empty
512
+ # cached value).
513
+ _pr_kv_set "$cache_key" "$out"
514
+ printf '%s' "$out"
515
+ return 0
516
+ }
517
+
518
+ # Tier 2 — read from the cached full-doc JSON. Uses jq when available;
519
+ # `_pr_jq_walk` transparently falls back to a python3 one-liner when
520
+ # jq is absent. Codex round 2 P2: pre-round-2 this function returned
521
+ # exit 1 when jq was missing, falling through to Tier 3 (awk) which
522
+ # only handles block-form — silently losing inline-form parsing on
523
+ # python3-but-no-jq systems.
524
+ _pr_tier2_get() {
525
+ local key="$1"
526
+ local mode="$2"
527
+ if [ "$_REA_POLICY_LOADED_TIER" != "python3" ]; then
528
+ return 1
529
+ fi
530
+ if [ -z "$_REA_POLICY_FULL_JSON" ] || [ "$_REA_POLICY_FULL_JSON" = "null" ]; then
531
+ # Policy parsed to null (empty file) — every key is unset. Return
532
+ # success with empty stdout for scalar; `null` for json.
533
+ if [ "$mode" = "json" ]; then
534
+ printf 'null'
535
+ fi
536
+ return 0
537
+ fi
538
+ _pr_jq_walk "$_REA_POLICY_FULL_JSON" "$key" "$mode"
539
+ return 0
540
+ }
541
+
542
+ # Tier 3 — awk block-form scalar reader.
543
+ #
544
+ # Supports 1-, 2-, and 3-segment dotted keys. Inline-form mappings are
545
+ # silently missed (documented Tier 3 limitation; Tier 1 / Tier 2 cover
546
+ # the inline cases).
547
+ #
548
+ # Returns 0 + (possibly empty) stdout when the parse succeeds (even
549
+ # when key is unset). Returns 1 only when the policy file is missing
550
+ # or awk isn't available.
551
+ _pr_tier3_get_scalar() {
552
+ local key="$1"
553
+ local policy
554
+ policy=$(_pr_policy_path)
555
+ if [ -z "$policy" ] || ! command -v awk >/dev/null 2>&1; then
556
+ return 1
557
+ fi
558
+ # Split the dotted key.
559
+ local IFS_BACKUP="$IFS"
560
+ IFS='.'
561
+ # shellcheck disable=SC2086
562
+ set -- $key
563
+ IFS="$IFS_BACKUP"
564
+ local n=$#
565
+ case "$n" in
566
+ 1)
567
+ _pr_tier3_top_scalar "$1" "$policy"
568
+ ;;
569
+ 2)
570
+ _pr_tier3_nested_scalar "$1" "$2" "" "$policy"
571
+ ;;
572
+ 3)
573
+ _pr_tier3_nested_scalar "$1" "$2" "$3" "$policy"
574
+ ;;
575
+ *)
576
+ # >3 segments — not supported by Tier 3 (no real-world hook
577
+ # reads deeper than 3). Caller should rely on Tier 1 / Tier 2.
578
+ return 0
579
+ ;;
580
+ esac
581
+ return 0
582
+ }
583
+
584
+ # Top-level scalar (e.g. `block_ai_attribution`).
585
+ _pr_tier3_top_scalar() {
586
+ local key="$1"
587
+ local policy="$2"
588
+ awk -v k="$key" '
589
+ BEGIN { pat_obj = "^" k ":[[:space:]]*$"; pat_val = "^" k ":[[:space:]]+" }
590
+ /^[[:space:]]*#/ { next }
591
+ match($0, pat_val) {
592
+ val = $0
593
+ sub(pat_val, "", val)
594
+ sub(/[[:space:]]+#.*$/, "", val)
595
+ gsub(/^["'\'']|["'\'']$/, "", val)
596
+ printf "%s", val
597
+ exit 0
598
+ }
599
+ ' "$policy"
600
+ }
601
+
602
+ # Nested scalar — same shape as policy-read.sh::_rea_awk_nested_scalar,
603
+ # extended to handle a 2-segment query (child only, no grandchild).
604
+ # When grandchild is empty, the inner key is matched directly under
605
+ # the top-level parent.
606
+ _pr_tier3_nested_scalar() {
607
+ local parent="$1"
608
+ local child="$2"
609
+ local grandchild="$3"
610
+ local policy="$4"
611
+ awk -v parent="$parent" -v child="$child" -v grandchild="$grandchild" '
612
+ function indent_of(line, n, c) {
613
+ n = 0
614
+ while (n < length(line)) {
615
+ c = substr(line, n + 1, 1)
616
+ if (c == " " || c == "\t") n++
617
+ else break
618
+ }
619
+ return n
620
+ }
621
+ BEGIN { in_parent = 0; parent_indent = -1; in_child = 0; child_indent = -1 }
622
+ /^[[:space:]]*#/ { next }
623
+ {
624
+ ind = indent_of($0)
625
+ stripped = $0
626
+ sub(/^[[:space:]]+/, "", stripped)
627
+ if (!in_parent && stripped ~ ("^" parent ":[[:space:]]*$") && ind == 0) {
628
+ in_parent = 1
629
+ parent_indent = 0
630
+ next
631
+ }
632
+ if (in_parent && ind <= parent_indent && stripped != "") {
633
+ in_parent = 0
634
+ in_child = 0
635
+ }
636
+ # Grandchild mode: descend one more level.
637
+ if (grandchild != "") {
638
+ if (in_parent && !in_child && stripped ~ ("^" child ":[[:space:]]*$") && ind > parent_indent) {
639
+ in_child = 1
640
+ child_indent = ind
641
+ next
642
+ }
643
+ if (in_child && ind <= child_indent && stripped != "") {
644
+ in_child = 0
645
+ }
646
+ if (in_child && match(stripped, ("^" grandchild ":[[:space:]]+"))) {
647
+ val = stripped
648
+ sub(("^" grandchild ":[[:space:]]+"), "", val)
649
+ sub(/[[:space:]]+#.*$/, "", val)
650
+ gsub(/^["'\'']|["'\'']$/, "", val)
651
+ printf "%s", val
652
+ exit 0
653
+ }
654
+ } else {
655
+ # 2-segment: child is the leaf.
656
+ if (in_parent && match(stripped, ("^" child ":[[:space:]]+"))) {
657
+ val = stripped
658
+ sub(("^" child ":[[:space:]]+"), "", val)
659
+ sub(/[[:space:]]+#.*$/, "", val)
660
+ gsub(/^["'\'']|["'\'']$/, "", val)
661
+ printf "%s", val
662
+ exit 0
663
+ }
664
+ }
665
+ }
666
+ ' "$policy"
667
+ }
668
+
669
+ # Tier 3 list reader — block-form sequence under a top-level key.
670
+ # Used for `blocked_paths`, `protected_writes`, etc. Inline-form arrays
671
+ # would be missed; the caller should rely on Tier 1 or Tier 2 for those.
672
+ _pr_tier3_get_list() {
673
+ local key="$1"
674
+ local policy
675
+ policy=$(_pr_policy_path)
676
+ if [ -z "$policy" ] || ! command -v awk >/dev/null 2>&1; then
677
+ return 1
678
+ fi
679
+ # Only top-level lists are supported by Tier 3 (the existing
680
+ # per-shim awk parsers were also top-level-only). Reject dotted keys.
681
+ case "$key" in
682
+ *.*) return 0 ;;
683
+ esac
684
+ awk -v key="$key" '
685
+ /^[^[:space:]]/ { in_block=0 }
686
+ $0 ~ ("^" key ":[[:space:]]*$") { in_block=1; next }
687
+ in_block && /^[[:space:]]*-[[:space:]]/ {
688
+ val = $0
689
+ sub(/^[[:space:]]*-[[:space:]]*/, "", val)
690
+ sub(/[[:space:]]+#.*$/, "", val)
691
+ gsub(/^["'\'']|["'\'']$/, "", val)
692
+ print val
693
+ }
694
+ ' "$policy"
695
+ return 0
696
+ }
697
+
698
+ # Public: read a scalar policy value at a dotted key.
699
+ # Stdout: the value (empty when unset). Exit: 0 = ok, 1 = unreadable.
700
+ policy_reader_get() {
701
+ local key="$1"
702
+ # Validate key shape (POSIX identifiers separated by dots).
703
+ case "$key" in
704
+ "" | *[!A-Za-z0-9_.]* )
705
+ return 1 ;;
706
+ esac
707
+ case "$key" in
708
+ .* | *. | *..* )
709
+ return 1 ;;
710
+ esac
711
+
712
+ local force="${POLICY_READER_FORCE_TIER:-}"
713
+ if [ "$force" = "none" ]; then
714
+ return 1
715
+ fi
716
+
717
+ _pr_load_full_json
718
+
719
+ # Tier 1 if reachable.
720
+ if [ "$force" = "" ] || [ "$force" = "cli" ]; then
721
+ local v
722
+ if v=$(_pr_tier1_get "$key" "scalar"); then
723
+ printf '%s' "$v"
724
+ return 0
725
+ fi
726
+ if [ "$force" = "cli" ]; then
727
+ return 1
728
+ fi
729
+ fi
730
+
731
+ # Tier 2 if cached.
732
+ if [ "$force" = "" ] || [ "$force" = "python3" ]; then
733
+ local v
734
+ if v=$(_pr_tier2_get "$key" "scalar"); then
735
+ printf '%s' "$v"
736
+ return 0
737
+ fi
738
+ if [ "$force" = "python3" ]; then
739
+ return 1
740
+ fi
741
+ fi
742
+
743
+ # Tier 3 awk (block-form only).
744
+ if [ "$force" = "" ] || [ "$force" = "awk" ]; then
745
+ if _pr_tier3_get_scalar "$key"; then
746
+ return 0
747
+ fi
748
+ fi
749
+
750
+ # All tiers failed.
751
+ return 1
752
+ }
753
+
754
+ # Public: read a subtree as JSON. Useful when a hook needs multiple
755
+ # leaves under the same parent — fetches all at once via the cached
756
+ # Tier 2 JSON, or via a single Tier 1 `--json` call when only the CLI
757
+ # is available. Tier 3 cannot serve subtree reads (returns 1).
758
+ #
759
+ # Stdout: JSON form of the subtree (`null` if unset).
760
+ # Exit: 0 = ok, 1 = unreadable.
761
+ policy_reader_get_subtree_json() {
762
+ local key="$1"
763
+ case "$key" in
764
+ "" | *[!A-Za-z0-9_.]* )
765
+ return 1 ;;
766
+ esac
767
+ case "$key" in
768
+ .* | *. | *..* )
769
+ return 1 ;;
770
+ esac
771
+
772
+ local force="${POLICY_READER_FORCE_TIER:-}"
773
+ if [ "$force" = "none" ]; then
774
+ return 1
775
+ fi
776
+
777
+ _pr_load_full_json
778
+
779
+ if [ "$force" = "" ] || [ "$force" = "cli" ]; then
780
+ local v
781
+ if v=$(_pr_tier1_get "$key" "json"); then
782
+ # Tier 1 CLI returns `null` for unset; both are valid.
783
+ [ -z "$v" ] && v="null"
784
+ printf '%s' "$v"
785
+ return 0
786
+ fi
787
+ if [ "$force" = "cli" ]; then
788
+ return 1
789
+ fi
790
+ fi
791
+
792
+ if [ "$force" = "" ] || [ "$force" = "python3" ]; then
793
+ local v
794
+ if v=$(_pr_tier2_get "$key" "json"); then
795
+ [ -z "$v" ] && v="null"
796
+ printf '%s' "$v"
797
+ return 0
798
+ fi
799
+ if [ "$force" = "python3" ]; then
800
+ return 1
801
+ fi
802
+ fi
803
+
804
+ # Tier 3 cannot serve subtree JSON.
805
+ return 1
806
+ }
807
+
808
+ # Public: read a top-level list of scalars (e.g. blocked_paths). Emits
809
+ # one entry per line on stdout. Supports flow-form arrays via Tier 1 /
810
+ # Tier 2; Tier 3 only handles block-form.
811
+ #
812
+ # Exit: 0 = ok (even when empty), 1 = unreadable.
813
+ policy_reader_get_list() {
814
+ local key="$1"
815
+ case "$key" in
816
+ "" | *[!A-Za-z0-9_.]* )
817
+ return 1 ;;
818
+ esac
819
+ case "$key" in
820
+ .* | *. | *..* )
821
+ return 1 ;;
822
+ esac
823
+
824
+ local force="${POLICY_READER_FORCE_TIER:-}"
825
+ if [ "$force" = "none" ]; then
826
+ return 1
827
+ fi
828
+
829
+ _pr_load_full_json
830
+
831
+ # Tier 1 / Tier 2 via JSON + jq.
832
+ local json=""
833
+ local source_tier=""
834
+ if [ "$force" = "" ] || [ "$force" = "cli" ]; then
835
+ if [ "$_REA_POLICY_LOADED_TIER" = "cli" ]; then
836
+ if json=$(_pr_tier1_get "$key" "json"); then
837
+ source_tier="cli"
838
+ fi
839
+ fi
840
+ if [ "$force" = "cli" ] && [ -z "$source_tier" ]; then
841
+ return 1
842
+ fi
843
+ fi
844
+ if [ -z "$source_tier" ] && { [ "$force" = "" ] || [ "$force" = "python3" ]; }; then
845
+ if [ "$_REA_POLICY_LOADED_TIER" = "python3" ]; then
846
+ if json=$(_pr_tier2_get "$key" "json"); then
847
+ source_tier="python3"
848
+ fi
849
+ fi
850
+ if [ "$force" = "python3" ] && [ -z "$source_tier" ]; then
851
+ return 1
852
+ fi
853
+ fi
854
+ if [ -n "$source_tier" ]; then
855
+ # Emit each array element as a line. Non-array (null / scalar /
856
+ # object) → empty output, exit 0.
857
+ if [ -z "$json" ] || [ "$json" = "null" ]; then
858
+ return 0
859
+ fi
860
+ # `POLICY_READER_DISABLE_JQ=1` forces the no-jq fallback path
861
+ # even when jq is on PATH — see _pr_jq_walk for rationale.
862
+ if [ "${POLICY_READER_DISABLE_JQ:-0}" != "1" ] && command -v jq >/dev/null 2>&1; then
863
+ printf '%s' "$json" | jq -r '
864
+ if type == "array" then
865
+ .[] | tostring
866
+ else
867
+ empty
868
+ end
869
+ ' 2>/dev/null
870
+ return 0
871
+ fi
872
+ # Codex round 2 P2 (2026-05-16): jq absent but Tier 1/2 produced
873
+ # JSON — iterate via python3 rather than falling through to Tier 3
874
+ # (which only handles block-form lists and would silently miss
875
+ # flow-form `blocked_paths: [.env, ...]`). The JSON payload is
876
+ # passed as argv so a malicious value cannot inject code; argv
877
+ # length on every modern OS comfortably accommodates policy.yaml
878
+ # contents (kilobytes, not megabytes).
879
+ #
880
+ # Codex round 2 P1 + round 3 P2 hardening: env scrub +
881
+ # PYTHONSAFEPATH=1 + sys.path scrub — see _pr_jq_walk / Tier 2
882
+ # loader for full rationale (and why `-I` isolated mode is
883
+ # intentionally NOT used).
884
+ if command -v python3 >/dev/null 2>&1; then
885
+ env -u PYTHONPATH -u PYTHONHOME -u PYTHONSTARTUP \
886
+ PYTHONSAFEPATH=1 python3 -c '
887
+ import sys
888
+ import os
889
+ _cwd = os.getcwd()
890
+ _cwd_real = os.path.realpath(_cwd)
891
+ sys.path[:] = [
892
+ p for p in sys.path
893
+ if p not in ("", ".", _cwd, _cwd_real)
894
+ ]
895
+ import json
896
+ try:
897
+ doc = json.loads(sys.argv[1])
898
+ except Exception:
899
+ sys.exit(0)
900
+ if isinstance(doc, list):
901
+ for item in doc:
902
+ if isinstance(item, bool):
903
+ sys.stdout.write("true\n" if item else "false\n")
904
+ elif isinstance(item, (int, float, str)):
905
+ sys.stdout.write(str(item) + "\n")
906
+ # Skip non-primitives — matches jq `.[] | tostring` posture
907
+ # for object/array elements (they would render as JSON-string
908
+ # repr under jq; the consumers only use primitive lists).
909
+ ' "$json" 2>/dev/null
910
+ return 0
911
+ fi
912
+ # Neither jq nor python3 reachable for list iteration. Fall through
913
+ # to Tier 3 (block-form awk parser).
914
+ fi
915
+
916
+ # Tier 3 awk (block-form only).
917
+ if [ "$force" = "" ] || [ "$force" = "awk" ]; then
918
+ if _pr_tier3_get_list "$key"; then
919
+ return 0
920
+ fi
921
+ fi
922
+
923
+ return 1
924
+ }
925
+
926
+ # Public: which tier was used for the last load? Returns "cli",
927
+ # "python3", "awk", or "" (none reached). Useful for diagnostics and
928
+ # tests.
929
+ policy_reader_loaded_tier() {
930
+ if [ "$_REA_POLICY_LOADED" = "0" ]; then
931
+ _pr_load_full_json
932
+ fi
933
+ if [ -n "$_REA_POLICY_LOADED_TIER" ]; then
934
+ printf '%s' "$_REA_POLICY_LOADED_TIER"
935
+ return 0
936
+ fi
937
+ # If we reached the awk fallback the loader recorded
938
+ # _REA_POLICY_LOADED=2 with no tier. We can't pre-validate awk
939
+ # availability without trying it; report "awk" optimistically when
940
+ # awk is on PATH and the policy file exists.
941
+ local policy
942
+ policy=$(_pr_policy_path)
943
+ if [ -n "$policy" ] && command -v awk >/dev/null 2>&1; then
944
+ printf 'awk'
945
+ return 0
946
+ fi
947
+ return 1
948
+ }