@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.
- package/hooks/_lib/policy-reader.sh +948 -0
- package/hooks/_lib/shim-runtime.sh +405 -0
- package/hooks/architecture-review-gate.sh +11 -103
- package/hooks/attribution-advisory.sh +43 -155
- package/hooks/blocked-paths-bash-gate.sh +35 -149
- package/hooks/blocked-paths-enforcer.sh +35 -140
- package/hooks/changeset-security-gate.sh +26 -119
- package/hooks/dangerous-bash-interceptor.sh +46 -170
- package/hooks/delegation-advisory.sh +26 -144
- package/hooks/delegation-capture.sh +33 -139
- package/hooks/dependency-audit-gate.sh +29 -121
- package/hooks/env-file-protection.sh +30 -141
- package/hooks/local-review-gate.sh +191 -396
- package/hooks/pr-issue-link-gate.sh +16 -118
- package/hooks/protected-paths-bash-gate.sh +57 -160
- package/hooks/secret-scanner.sh +90 -213
- package/hooks/security-disclosure-gate.sh +32 -155
- package/hooks/settings-protection.sh +56 -179
- package/package.json +1 -1
- package/templates/_lib_policy-reader.dogfood-staged.sh +948 -0
- package/templates/_lib_shim-runtime.dogfood-staged.sh +405 -0
- package/templates/architecture-review-gate.dogfood-staged.sh +11 -103
- package/templates/attribution-advisory.dogfood-staged.sh +43 -155
- package/templates/blocked-paths-bash-gate.dogfood-staged.sh +35 -149
- package/templates/blocked-paths-enforcer.dogfood-staged.sh +35 -140
- package/templates/changeset-security-gate.dogfood-staged.sh +26 -119
- package/templates/dangerous-bash-interceptor.dogfood-staged.sh +46 -170
- package/templates/delegation-advisory.dogfood-staged.sh +44 -0
- package/templates/delegation-capture.dogfood-staged.sh +52 -0
- package/templates/dependency-audit-gate.dogfood-staged.sh +29 -121
- package/templates/env-file-protection.dogfood-staged.sh +30 -141
- package/templates/local-review-gate.dogfood-staged.sh +191 -396
- package/templates/pr-issue-link-gate.dogfood-staged.sh +16 -118
- package/templates/protected-paths-bash-gate.dogfood-staged.sh +57 -160
- package/templates/secret-scanner.dogfood-staged.sh +90 -213
- package/templates/security-disclosure-gate.dogfood-staged.sh +32 -155
- 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
|
+
}
|