@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.
- package/dist/cli/doctor.js +45 -36
- package/dist/hooks/_lib/segments.js +67 -7
- package/dist/hooks/secret-scanner/index.js +64 -2
- package/hooks/_lib/policy-reader.sh +948 -0
- package/hooks/attribution-advisory.sh +125 -66
- package/hooks/blocked-paths-bash-gate.sh +12 -12
- package/hooks/blocked-paths-enforcer.sh +11 -11
- package/hooks/local-review-gate.sh +111 -81
- package/hooks/protected-paths-bash-gate.sh +11 -15
- package/hooks/settings-protection.sh +11 -14
- package/package.json +3 -2
- package/scripts/lint-awk-shim-quotes.mjs +386 -0
- package/templates/_lib_policy-reader.dogfood-staged.sh +948 -0
- package/templates/attribution-advisory.dogfood-staged.sh +125 -66
- package/templates/blocked-paths-bash-gate.dogfood-staged.sh +12 -12
- package/templates/blocked-paths-enforcer.dogfood-staged.sh +11 -11
- package/templates/local-review-gate.dogfood-staged.sh +111 -81
- package/templates/protected-paths-bash-gate.dogfood-staged.sh +11 -15
- package/templates/settings-protection.dogfood-staged.sh +11 -14
|
@@ -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.
|
|
67
|
-
#
|
|
68
|
-
#
|
|
69
|
-
#
|
|
70
|
-
#
|
|
71
|
-
#
|
|
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.
|
|
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
|
-
#
|
|
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.
|
|
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 < <(
|
|
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 < <(
|
|
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
|
-
#
|
|
162
|
-
#
|
|
163
|
-
#
|
|
164
|
-
#
|
|
165
|
-
#
|
|
166
|
-
#
|
|
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
|
-
#
|
|
169
|
-
#
|
|
170
|
-
# `
|
|
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
|
-
#
|
|
176
|
-
#
|
|
177
|
-
#
|
|
178
|
-
#
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
202
|
-
|
|
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' "$
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
#
|
|
85
|
-
#
|
|
86
|
-
#
|
|
87
|
-
#
|
|
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 < <(
|
|
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
|
-
#
|
|
101
|
-
#
|
|
102
|
-
#
|
|
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 < <(
|
|
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
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bookedsolid/rea",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.37.0",
|
|
4
4
|
"description": "Agentic governance layer for Claude Code — policy enforcement, hook-based safety gates, audit logging, and Codex-integrated adversarial review for AI-assisted projects",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Booked Solid Technology <oss@bookedsolid.tech> (https://bookedsolid.tech)",
|
|
@@ -95,8 +95,9 @@
|
|
|
95
95
|
"scripts": {
|
|
96
96
|
"build": "tsc -p tsconfig.build.json",
|
|
97
97
|
"postinstall": "node scripts/postinstall.mjs",
|
|
98
|
-
"lint": "pnpm run lint:regex && eslint .",
|
|
98
|
+
"lint": "pnpm run lint:regex && pnpm run lint:awk-quotes && eslint .",
|
|
99
99
|
"lint:regex": "node scripts/lint-safe-regex.mjs",
|
|
100
|
+
"lint:awk-quotes": "node scripts/lint-awk-shim-quotes.mjs",
|
|
100
101
|
"format": "prettier --write .",
|
|
101
102
|
"format:check": "prettier --check .",
|
|
102
103
|
"test": "pnpm run build && pnpm run test:dogfood && pnpm run test:bash-syntax && node scripts/run-vitest.mjs",
|