@bookedsolid/rea 0.16.2 → 0.16.4

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.
@@ -260,6 +260,7 @@ export function defaultDesiredHooks() {
260
260
  { type: 'command', command: `${base}/dangerous-bash-interceptor.sh`, timeout: 10000, statusMessage: 'Checking command safety...' },
261
261
  { type: 'command', command: `${base}/env-file-protection.sh`, timeout: 5000, statusMessage: 'Checking for .env file reads...' },
262
262
  { type: 'command', command: `${base}/protected-paths-bash-gate.sh`, timeout: 5000, statusMessage: 'Checking for shell-redirect to protected paths...' },
263
+ { type: 'command', command: `${base}/blocked-paths-bash-gate.sh`, timeout: 5000, statusMessage: 'Checking for shell-redirect to policy-blocked paths...' },
263
264
  { type: 'command', command: `${base}/dependency-audit-gate.sh`, timeout: 15000, statusMessage: 'Verifying package exists...' },
264
265
  { type: 'command', command: `${base}/security-disclosure-gate.sh`, timeout: 5000, statusMessage: 'Checking disclosure policy...' },
265
266
  { type: 'command', command: `${base}/pr-issue-link-gate.sh`, timeout: 5000, statusMessage: 'Checking PR for issue reference...' },
@@ -158,6 +158,51 @@ export async function runCodexReview(options) {
158
158
  '--ephemeral',
159
159
  ];
160
160
  const args = options.prompt !== undefined && options.prompt.length > 0 ? [...baseArgs, options.prompt] : baseArgs;
161
+ // 0.16.3 helix-016.1 #1 fix: pre-flight probe for the codex CLI before
162
+ // we hand control to the long-running review subprocess. The original
163
+ // try/catch around `spawner(...)` only caught synchronous ENOENT; on
164
+ // some platforms (Linux child_process under certain shell configs)
165
+ // the missing-binary error arrives as a `'error'` event AFTER spawn
166
+ // has returned a child handle, and on others codex CLI is present
167
+ // but a wrapper script exits non-zero before any JSONL emerges. Both
168
+ // shapes leak through the existing classify path as `subprocess` /
169
+ // `protocol` errors with stack-frame-shaped messages instead of the
170
+ // friendly install hint defined on `CodexNotInstalledError`.
171
+ //
172
+ // The probe runs `codex --version` synchronously with a 2-second cap
173
+ // (cheap; codex --version returns in <50ms when the binary exists).
174
+ // If the binary is absent OR the probe exits non-zero AND the error
175
+ // is ENOENT-class, we throw `CodexNotInstalledError` directly so
176
+ // `index.ts:561` formats it as the headline `PUSH BLOCKED:` line.
177
+ // We deliberately do NOT use the probe for binaries that exist but
178
+ // fail their version check — those are real subprocess errors and
179
+ // belong in the existing classify path.
180
+ //
181
+ // The probe is skipped when `spawnImpl` is provided so unit tests
182
+ // continue to control the entire spawn surface deterministically.
183
+ if (options.spawnImpl === undefined) {
184
+ const probe = spawnSync('codex', ['--version'], {
185
+ cwd: options.cwd,
186
+ env: options.env ?? process.env,
187
+ timeout: 2000,
188
+ encoding: 'utf8',
189
+ });
190
+ // `error` is set when the OS could not start the binary at all
191
+ // (ENOENT, EACCES, ENOTDIR). Codex returning a non-zero status
192
+ // because of a downstream issue is NOT the same condition — let
193
+ // it fall through to the main run where the existing classifier
194
+ // produces a meaningful subprocess error.
195
+ if (probe.error !== undefined) {
196
+ const code = probe.error.code;
197
+ if (code === 'ENOENT')
198
+ throw new CodexNotInstalledError();
199
+ // EACCES on the codex binary is operationally identical to "not
200
+ // installed" for the user — they need to install or fix perms.
201
+ if (code === 'EACCES')
202
+ throw new CodexNotInstalledError();
203
+ throw probe.error;
204
+ }
205
+ }
161
206
  let child;
162
207
  try {
163
208
  child = spawner('codex', args, {
@@ -11,6 +11,7 @@ declare const PolicySchema: z.ZodObject<{
11
11
  promotion_requires_human_approval: z.ZodBoolean;
12
12
  block_ai_attribution: z.ZodDefault<z.ZodBoolean>;
13
13
  blocked_paths: z.ZodArray<z.ZodString, "many">;
14
+ protected_paths_relax: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
14
15
  notification_channel: z.ZodDefault<z.ZodString>;
15
16
  injection_detection: z.ZodOptional<z.ZodEnum<["block", "warn"]>>;
16
17
  injection: z.ZodOptional<z.ZodObject<{
@@ -171,6 +172,7 @@ declare const PolicySchema: z.ZodObject<{
171
172
  promotion_requires_human_approval: boolean;
172
173
  block_ai_attribution: boolean;
173
174
  blocked_paths: string[];
175
+ protected_paths_relax: string[];
174
176
  notification_channel: string;
175
177
  injection_detection?: "block" | "warn" | undefined;
176
178
  injection?: {
@@ -218,6 +220,7 @@ declare const PolicySchema: z.ZodObject<{
218
220
  promotion_requires_human_approval: boolean;
219
221
  blocked_paths: string[];
220
222
  block_ai_attribution?: boolean | undefined;
223
+ protected_paths_relax?: string[] | undefined;
221
224
  notification_channel?: string | undefined;
222
225
  injection_detection?: "block" | "warn" | undefined;
223
226
  injection?: {
@@ -160,6 +160,12 @@ const PolicySchema = z
160
160
  promotion_requires_human_approval: z.boolean(),
161
161
  block_ai_attribution: z.boolean().default(false),
162
162
  blocked_paths: z.array(z.string()),
163
+ // 0.16.3 F7: opt-in relax list. Consumers can list rea-managed
164
+ // hard-protected patterns they want unblocked (e.g. `.husky/` to
165
+ // author their own husky hooks). The kill-switch invariants
166
+ // (`.rea/HALT`, `.rea/policy.yaml`, `.claude/settings.json`) are
167
+ // ignored if listed — see hooks/_lib/protected-paths.sh.
168
+ protected_paths_relax: z.array(z.string()).default([]),
163
169
  notification_channel: z.string().default(''),
164
170
  injection_detection: z.enum(['block', 'warn']).optional(),
165
171
  injection: InjectionPolicySchema.optional(),
@@ -26,6 +26,7 @@ export declare const ProfileSchema: z.ZodObject<{
26
26
  promotion_requires_human_approval: z.ZodOptional<z.ZodBoolean>;
27
27
  block_ai_attribution: z.ZodOptional<z.ZodBoolean>;
28
28
  blocked_paths: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
29
+ protected_paths_relax: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
29
30
  notification_channel: z.ZodOptional<z.ZodString>;
30
31
  injection_detection: z.ZodOptional<z.ZodEnum<["block", "warn"]>>;
31
32
  injection: z.ZodOptional<z.ZodObject<{
@@ -51,6 +52,7 @@ export declare const ProfileSchema: z.ZodObject<{
51
52
  promotion_requires_human_approval?: boolean | undefined;
52
53
  block_ai_attribution?: boolean | undefined;
53
54
  blocked_paths?: string[] | undefined;
55
+ protected_paths_relax?: string[] | undefined;
54
56
  notification_channel?: string | undefined;
55
57
  injection_detection?: "block" | "warn" | undefined;
56
58
  injection?: {
@@ -66,6 +68,7 @@ export declare const ProfileSchema: z.ZodObject<{
66
68
  promotion_requires_human_approval?: boolean | undefined;
67
69
  block_ai_attribution?: boolean | undefined;
68
70
  blocked_paths?: string[] | undefined;
71
+ protected_paths_relax?: string[] | undefined;
69
72
  notification_channel?: string | undefined;
70
73
  injection_detection?: "block" | "warn" | undefined;
71
74
  injection?: {
@@ -48,6 +48,7 @@ export const ProfileSchema = z
48
48
  promotion_requires_human_approval: z.boolean().optional(),
49
49
  block_ai_attribution: z.boolean().optional(),
50
50
  blocked_paths: z.array(z.string()).optional(),
51
+ protected_paths_relax: z.array(z.string()).optional(),
51
52
  notification_channel: z.string().optional(),
52
53
  injection_detection: z.enum(['block', 'warn']).optional(),
53
54
  injection: InjectionProfileSchema.optional(),
@@ -268,6 +268,7 @@ export interface Policy {
268
268
  promotion_requires_human_approval: boolean;
269
269
  block_ai_attribution: boolean;
270
270
  blocked_paths: string[];
271
+ protected_paths_relax: string[];
271
272
  notification_channel: string;
272
273
  injection_detection?: 'block' | 'warn';
273
274
  injection?: InjectionPolicy;
@@ -33,17 +33,23 @@
33
33
  # form matches PATTERN (a `grep -qiE` extended regex). Returns 1
34
34
  # if no segment matches.
35
35
  #
36
- # Quoting awareness: the splitter is NOT quote-aware. A separator inside
37
- # a quoted string would be split. This is INTENTIONAL and SAFE: the
38
- # segments-vs-callback contract is "find segments that anchor on a
39
- # trigger word." Over-splitting produces extra segments that don't
40
- # anchor; they're ignored. Under-splitting (treating a quoted separator
41
- # as part of one segment) is what the original bug was. The trade-off
42
- # explicitly accepts over-splitting.
36
+ # Quoting awareness (0.16.3 helix-016.1 #2 fix): the splitter masks
37
+ # shell separators that occur INSIDE matched `"..."` and `'...'` quote
38
+ # spans before splitting. Earlier versions split on every unescaped
39
+ # `&`/`;`/`|`/newline regardless of quote context, which produced
40
+ # false-positive segment boundaries inside quoted prose:
43
41
  #
44
- # Quoting note for future maintainers: do not "fix" the over-splitting
45
- # without breaking the security property. Quote-aware splitting in pure
46
- # bash is a real lift; if needed it should move to a Node helper.
42
+ # echo "release note & git push --force now"
43
+ #
44
+ # The pre-fix splitter broke that into two segments and the H1 force-push
45
+ # detector anchored on `git push --force` at the head of segment 2 — a
46
+ # real false positive helix reproduced spontaneously during diagnostic
47
+ # work. The fix walks the input once, replaces in-quote separators with
48
+ # multi-byte sentinels (impossible-to-collide forms), splits on the
49
+ # remaining un-quoted separators, then restores the sentinels back to
50
+ # their literal characters in the surviving segments. Single-quoted spans
51
+ # do NOT honor `\` escapes; double-quoted spans treat `\"` as a literal
52
+ # `"` and skip past it.
47
53
 
48
54
  # Split $1 on shell command separators. Emits one segment per line on
49
55
  # stdout (empty segments preserved). Used by both higher-level helpers
@@ -54,6 +60,22 @@ _rea_split_segments() {
54
60
  # We use printf+sed instead of bash IFS=$'...' read so the splitter
55
61
  # behaves identically across BSD and GNU sed.
56
62
  #
63
+ # Pipeline overview (post-0.16.3 quote-mask):
64
+ # 1. awk one-pass mask: replace `;` `&` `|` `\n` INSIDE matched
65
+ # `"..."` / `'...'` spans with multi-byte sentinels so the
66
+ # separator-splitting passes below ignore them. Single-quoted
67
+ # spans have no escape semantics; double-quoted spans treat
68
+ # `\"` as a literal quote and continue inside the span.
69
+ # 2. existing `>|` swap (preserves the bash noclobber-override
70
+ # operator across the splitting passes).
71
+ # 3. existing `&&` swap (so step 4 doesn't break compound `&&`
72
+ # operators apart while still splitting on bare `&`).
73
+ # 4. sed split on `||`, `;`, bare `|`, bare `&`.
74
+ # 5. unswap `&&` / `>|` placeholders.
75
+ # 6. restore the in-quote sentinels back to literal chars so each
76
+ # surviving segment sees its quoted prose intact (downstream
77
+ # hooks regex against the segments and need the literal bytes).
78
+ #
57
79
  # 0.16.0 codex P1 fix (helix-015 #3): the prior sed split on bare `|`
58
80
  # which broke bash's `>|` (noclobber-override redirect) into two
59
81
  # segments — `printf x >` then ` .rea/HALT`. The redirect detector
@@ -77,12 +99,130 @@ _rea_split_segments() {
77
99
  # token is `sleep`, and `any_segment_starts_with($CMD, 'git push')`
78
100
  # missed the force-push entirely. Add `&` to the separator set, but
79
101
  # AFTER `&&` is already swapped out so we don't break it apart.
80
- printf '%s\n' "$cmd" \
102
+ # 0.16.3 helix-016.1 #2 fix: quote-mask in-quote separators before
103
+ # splitting so quoted prose no longer over-splits and anchors trigger
104
+ # words at the head of phantom segments. See header comment for the
105
+ # full rationale.
106
+ printf '%s' "$cmd" \
107
+ | awk '
108
+ BEGIN {
109
+ SC = "__REA_SEP_SC_a8f2c1__"
110
+ AMP = "__REA_SEP_AMP_a8f2c1__"
111
+ PIPE = "__REA_SEP_PIPE_a8f2c1__"
112
+ NL = "__REA_SEP_NL_a8f2c1__"
113
+ }
114
+ {
115
+ line = $0
116
+ out = ""
117
+ i = 1
118
+ n = length(line)
119
+ mode = 0 # 0=plain, 1=double, 2=single
120
+ while (i <= n) {
121
+ ch = substr(line, i, 1)
122
+ if (mode == 0) {
123
+ if (ch == "\"") { mode = 1; out = out ch; i++; continue }
124
+ if (ch == "'\''") { mode = 2; out = out ch; i++; continue }
125
+ out = out ch
126
+ i++
127
+ continue
128
+ }
129
+ if (mode == 2) {
130
+ # Single quotes: no escape semantics. Only `'\''` ends.
131
+ if (ch == "'\''") { mode = 0; out = out ch; i++; continue }
132
+ if (ch == ";") { out = out SC; i++; continue }
133
+ if (ch == "&") { out = out AMP; i++; continue }
134
+ if (ch == "|") { out = out PIPE; i++; continue }
135
+ # awk record-mode: literal newlines inside single-quoted
136
+ # heredoc bodies arrive as separate records; mask is
137
+ # per-record so they remain separators across records by
138
+ # design (the original splitter behavior).
139
+ out = out ch
140
+ i++
141
+ continue
142
+ }
143
+ # mode == 1 (double-quoted)
144
+ if (ch == "\\" && i < n) {
145
+ # Preserve `\"` and `\\` escape sequences literally; do not
146
+ # exit the double-quoted span on the escaped quote.
147
+ nxt = substr(line, i + 1, 1)
148
+ out = out ch nxt
149
+ i += 2
150
+ continue
151
+ }
152
+ if (ch == "\"") { mode = 0; out = out ch; i++; continue }
153
+ if (ch == ";") { out = out SC; i++; continue }
154
+ if (ch == "&") { out = out AMP; i++; continue }
155
+ if (ch == "|") { out = out PIPE; i++; continue }
156
+ out = out ch
157
+ i++
158
+ }
159
+ print out
160
+ }' \
81
161
  | sed -E 's/>\|/__REA_GTPIPE_a8f2c1__/g' \
82
162
  | sed -E 's/&&/__REA_LOGAND_a8f2c1__/g' \
83
163
  | sed -E 's/(\|\||;|\||&)/\n/g' \
84
164
  | sed -E 's/__REA_LOGAND_a8f2c1__/\n/g' \
85
- | sed -E 's/__REA_GTPIPE_a8f2c1__/>|/g'
165
+ | sed -E 's/__REA_GTPIPE_a8f2c1__/>|/g' \
166
+ | sed -E 's/__REA_SEP_SC_a8f2c1__/;/g; s/__REA_SEP_AMP_a8f2c1__/\&/g; s/__REA_SEP_PIPE_a8f2c1__/|/g; s/__REA_SEP_NL_a8f2c1__/\n/g'
167
+ }
168
+
169
+ # Apply only the quote-mask preprocessing pass. Returns the input with
170
+ # in-quote `;`/`&`/`|`/newline replaced by sentinels but WITHOUT splitting
171
+ # on the un-masked operators. Useful for multi-segment-property checks
172
+ # (H12 curl-pipe-shell) that need to scan the whole command-line as one
173
+ # string while still ignoring in-quote prose. Restores quoted-content
174
+ # pipe to a placeholder (`__REA_INQUOTE_PIPE__`) so a regex against the
175
+ # masked output can match a real `|` token without false-positiving on
176
+ # in-quote `|` characters.
177
+ quote_masked_cmd() {
178
+ local cmd="$1"
179
+ printf '%s' "$cmd" \
180
+ | awk '
181
+ BEGIN {
182
+ INQ_PIPE = "__REA_INQUOTE_PIPE_a8f2c1__"
183
+ INQ_SC = "__REA_INQUOTE_SC_a8f2c1__"
184
+ INQ_AMP = "__REA_INQUOTE_AMP_a8f2c1__"
185
+ }
186
+ {
187
+ line = $0
188
+ out = ""
189
+ i = 1
190
+ n = length(line)
191
+ mode = 0
192
+ while (i <= n) {
193
+ ch = substr(line, i, 1)
194
+ if (mode == 0) {
195
+ if (ch == "\"") { mode = 1; out = out ch; i++; continue }
196
+ if (ch == "'\''") { mode = 2; out = out ch; i++; continue }
197
+ out = out ch
198
+ i++
199
+ continue
200
+ }
201
+ if (mode == 2) {
202
+ if (ch == "'\''") { mode = 0; out = out ch; i++; continue }
203
+ if (ch == "|") { out = out INQ_PIPE; i++; continue }
204
+ if (ch == ";") { out = out INQ_SC; i++; continue }
205
+ if (ch == "&") { out = out INQ_AMP; i++; continue }
206
+ out = out ch
207
+ i++
208
+ continue
209
+ }
210
+ if (ch == "\\" && i < n) {
211
+ out = out ch substr(line, i + 1, 1)
212
+ i += 2
213
+ continue
214
+ }
215
+ if (ch == "\"") { mode = 0; out = out ch; i++; continue }
216
+ if (ch == "|") { out = out INQ_PIPE; i++; continue }
217
+ if (ch == ";") { out = out INQ_SC; i++; continue }
218
+ if (ch == "&") { out = out INQ_AMP; i++; continue }
219
+ out = out ch
220
+ i++
221
+ }
222
+ # awk auto-appends a newline on `print`; strip it so the
223
+ # caller gets exactly what was passed in.
224
+ printf "%s", out
225
+ }'
86
226
  }
87
227
 
88
228
  # Strip leading whitespace and well-known command prefixes from a single
@@ -9,13 +9,37 @@
9
9
  # by the principal-engineer audit. The fix: factor the list out so
10
10
  # both hooks read the same data, and protect against shell redirects
11
11
  # in addition to Write/Edit/MultiEdit tools.
12
+ #
13
+ # 0.16.3 F7: the list is now policy-driven via `protected_paths_relax`.
14
+ # Consumers who legitimately need to author `.husky/<hookname>` files
15
+ # (or other paths in the rea-managed hard list) can opt out per-pattern
16
+ # by listing the entry in `.rea/policy.yaml`:
17
+ #
18
+ # protected_paths_relax:
19
+ # - .husky/ # I author my own husky hooks; opt out of rea protection
20
+ #
21
+ # Pre-0.16.3 the only escape was editing rea-managed source itself —
22
+ # which `protected-paths-bash-gate.sh` (also rea-managed) would refuse.
23
+ # That left consumers stuck. The relax list closes that escape hatch
24
+ # without weakening the integrity of the governance layer itself.
25
+ #
26
+ # KILL-SWITCH INVARIANTS — these patterns are ALWAYS protected, even
27
+ # if a consumer lists them in `protected_paths_relax`. They represent
28
+ # the integrity of the governance layer; relaxing them would let an
29
+ # agent disable rea, defeating the entire product.
30
+ #
31
+ # .rea/HALT — the kill switch itself
32
+ # .rea/policy.yaml — the policy that defines all enforcement
33
+ # .claude/settings.json — the hook registration that activates rea
34
+ #
35
+ # Listing a kill-switch invariant in `protected_paths_relax` is silently
36
+ # ignored AND a stderr advisory is emitted on first read.
12
37
 
13
- # The path list is bash glob patterns matched against project-root-
14
- # relative paths. Suffix `/` indicates a prefix match; no suffix means
15
- # (case-insensitive) exact match — see `rea_path_is_protected` for the
16
- # helix-015 #2 lowercase-comparison rationale. Mirrors the array in
17
- # settings-protection.sh §6.
18
- REA_PROTECTED_PATTERNS=(
38
+ # The full hard-protected list. Suffix `/` indicates a prefix match;
39
+ # no suffix means (case-insensitive) exact match see
40
+ # `rea_path_is_protected` for the helix-015 #2 lowercase-comparison
41
+ # rationale.
42
+ REA_PROTECTED_PATTERNS_FULL=(
19
43
  '.claude/settings.json'
20
44
  '.claude/settings.local.json'
21
45
  '.husky/'
@@ -23,9 +47,127 @@ REA_PROTECTED_PATTERNS=(
23
47
  '.rea/HALT'
24
48
  )
25
49
 
26
- # Test whether a project-relative path matches any protected pattern.
50
+ # Kill-switch invariants never relaxable. Subset of FULL.
51
+ REA_KILL_SWITCH_INVARIANTS=(
52
+ '.claude/settings.json'
53
+ '.rea/policy.yaml'
54
+ '.rea/HALT'
55
+ )
56
+
57
+ # Effective patterns after applying the relax list. Computed lazily on
58
+ # first call to `rea_path_is_protected`; stays the same for the lifetime
59
+ # of the hook process.
60
+ REA_PROTECTED_PATTERNS=()
61
+ _REA_PROTECTED_PATTERNS_LOADED=0
62
+
63
+ # True if $1 is a kill-switch invariant (case-insensitive exact or
64
+ # prefix match per the same rules as the protected list itself).
65
+ _rea_is_kill_switch() {
66
+ local p="$1"
67
+ local p_lc inv inv_lc
68
+ p_lc=$(printf '%s' "$p" | tr '[:upper:]' '[:lower:]')
69
+ for inv in "${REA_KILL_SWITCH_INVARIANTS[@]}"; do
70
+ inv_lc=$(printf '%s' "$inv" | tr '[:upper:]' '[:lower:]')
71
+ if [[ "$p_lc" == "$inv_lc" ]]; then
72
+ return 0
73
+ fi
74
+ done
75
+ return 1
76
+ }
77
+
78
+ # Load the effective list, applying `protected_paths_relax` from policy.
79
+ # Sources policy-read.sh on demand so this lib stays self-contained.
80
+ _rea_load_protected_patterns() {
81
+ if [ "$_REA_PROTECTED_PATTERNS_LOADED" = "1" ]; then
82
+ return 0
83
+ fi
84
+ # Source policy-read if not already sourced. The caller may have
85
+ # already done so; checking for a known function avoids double-source.
86
+ if ! command -v policy_list >/dev/null 2>&1; then
87
+ # Resolve relative to THIS file's dir, not the caller's.
88
+ # shellcheck source=policy-read.sh
89
+ source "${BASH_SOURCE[0]%/*}/policy-read.sh" 2>/dev/null || true
90
+ fi
91
+
92
+ local relax_list=()
93
+ if command -v policy_list >/dev/null 2>&1; then
94
+ while IFS= read -r entry; do
95
+ [ -z "$entry" ] && continue
96
+ relax_list+=("$entry")
97
+ done < <(policy_list "protected_paths_relax" 2>/dev/null || true)
98
+ fi
99
+
100
+ # Validate relax entries: any kill-switch invariant in the list is
101
+ # silently dropped from "permitted to relax" but emits a stderr
102
+ # advisory so the operator can see why their relax didn't take
103
+ # effect.
104
+ local relaxed_set=()
105
+ local r
106
+ for r in "${relax_list[@]+"${relax_list[@]}"}"; do
107
+ if _rea_is_kill_switch "$r"; then
108
+ printf 'rea: protected_paths_relax: %s is a kill-switch invariant and cannot be relaxed; ignoring.\n' \
109
+ "$r" >&2
110
+ else
111
+ relaxed_set+=("$r")
112
+ fi
113
+ done
114
+
115
+ # Build the effective list: every FULL entry that is NOT in the
116
+ # relaxed set (case-insensitive comparison).
117
+ local pat pat_lc rentry rentry_lc relaxed
118
+ for pat in "${REA_PROTECTED_PATTERNS_FULL[@]}"; do
119
+ pat_lc=$(printf '%s' "$pat" | tr '[:upper:]' '[:lower:]')
120
+ relaxed=0
121
+ for rentry in "${relaxed_set[@]+"${relaxed_set[@]}"}"; do
122
+ rentry_lc=$(printf '%s' "$rentry" | tr '[:upper:]' '[:lower:]')
123
+ if [[ "$pat_lc" == "$rentry_lc" ]]; then
124
+ relaxed=1
125
+ break
126
+ fi
127
+ done
128
+ if [ "$relaxed" = "0" ]; then
129
+ REA_PROTECTED_PATTERNS+=("$pat")
130
+ fi
131
+ done
132
+
133
+ _REA_PROTECTED_PATTERNS_LOADED=1
134
+ }
135
+
136
+ # Test whether a project-relative path is in the documented husky
137
+ # extension surface (`.husky/commit-msg.d/*`, `.husky/pre-push.d/*`).
138
+ # Returns 0 on match, 1 on no match. Case-insensitive.
139
+ #
140
+ # 0.16.4 helix-018 Option B: settings-protection.sh §5b has carved
141
+ # this surface out of write-tier protection since 0.13.2 — consumers
142
+ # write extension fragments here freely. Pre-0.16.4 the BASH-tier
143
+ # gates (`protected-paths-bash-gate.sh`, `blocked-paths-bash-gate.sh`)
144
+ # had no parity carve-out, so a `cat <<EOF > .husky/pre-push.d/X`
145
+ # redirect was refused by the bash-gate even though the equivalent
146
+ # Write-tool call would succeed. This helper bakes the carve-out
147
+ # into the shared lib so every caller inherits it uniformly.
148
+ rea_path_is_extension_surface() {
149
+ local p_lc
150
+ p_lc=$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')
151
+ case "$p_lc" in
152
+ .husky/commit-msg.d/*|.husky/pre-push.d/*|.husky/pre-commit.d/*)
153
+ # Refuse the bare directory itself — only fragments INSIDE
154
+ # the surface count. `.husky/pre-push.d/` (trailing slash, no
155
+ # fragment) and `.husky/pre-push.d` (the dir node) both fall
156
+ # through to the protection check via the parent prefix.
157
+ case "$p_lc" in
158
+ .husky/commit-msg.d/|.husky/pre-push.d/|.husky/pre-commit.d/) return 1 ;;
159
+ esac
160
+ return 0
161
+ ;;
162
+ esac
163
+ return 1
164
+ }
165
+
166
+ # Test whether a project-relative path matches any protected pattern
167
+ # (after applying `protected_paths_relax`). Returns 0 on match, 1 on
168
+ # no match.
169
+ #
27
170
  # Usage: if rea_path_is_protected ".rea/HALT"; then echo "blocked"; fi
28
- # Returns 0 on match, 1 on no match.
29
171
  #
30
172
  # 0.16.0 codex P1 fix (helix-015 #2): match case-insensitively.
31
173
  # macOS APFS (default case-insensitive) lets `.ClAuDe/settings.json`
@@ -33,11 +175,22 @@ REA_PROTECTED_PATTERNS=(
33
175
  # §6 has had a CI matcher since 0.10.x; this helper was missing it.
34
176
  # We lowercase BOTH sides so the comparison is symmetric — callers can
35
177
  # pass either case.
178
+ #
179
+ # 0.16.4 helix-018 Option B: paths inside the documented husky
180
+ # extension surface (`.husky/{commit-msg,pre-push,pre-commit}.d/*`)
181
+ # return 1 (not protected) BEFORE the prefix-pattern check so they
182
+ # don't get caught by `.husky/`'s prefix block. This mirrors the
183
+ # §5b allow-list that has been in settings-protection.sh since 0.13.2.
36
184
  rea_path_is_protected() {
185
+ _rea_load_protected_patterns
186
+ # Extension-surface allow-list — short-circuit before pattern match.
187
+ if rea_path_is_extension_surface "$1"; then
188
+ return 1
189
+ fi
37
190
  local p_lc
38
191
  p_lc=$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')
39
192
  local pattern pattern_lc
40
- for pattern in "${REA_PROTECTED_PATTERNS[@]}"; do
193
+ for pattern in "${REA_PROTECTED_PATTERNS[@]+"${REA_PROTECTED_PATTERNS[@]}"}"; do
41
194
  pattern_lc=$(printf '%s' "$pattern" | tr '[:upper:]' '[:lower:]')
42
195
  if [[ "$p_lc" == "$pattern_lc" ]]; then
43
196
  return 0
@@ -0,0 +1,280 @@
1
+ #!/bin/bash
2
+ # PreToolUse hook: blocked-paths-bash-gate.sh
3
+ # Fires BEFORE every Bash tool call.
4
+ # Refuses Bash commands that write to entries in policy.yaml's
5
+ # `blocked_paths` list via shell redirection or write-flag utilities.
6
+ #
7
+ # Background (0.16.3, discord-ops Round 9 #1): the existing
8
+ # blocked-paths-enforcer.sh only fires on Write/Edit/MultiEdit/
9
+ # NotebookEdit. Bash-tier writes to blocked_paths entries bypass it
10
+ # entirely:
11
+ #
12
+ # echo x > .env
13
+ # cp src.txt .env
14
+ # sed -i '' '1d' .env.production
15
+ # node -e "fs.writeFileSync('.env','x')"
16
+ #
17
+ # `protected-paths-bash-gate.sh` covers the HARD list (HALT, policy.yaml,
18
+ # settings.json, .husky/*) — but the soft, runtime-configurable
19
+ # `blocked_paths` list never had a Bash-tier counterpart. discord-ops
20
+ # independently caught this gap during their cycle 9 audit.
21
+ #
22
+ # This hook closes the gap by reading the same `blocked_paths` list that
23
+ # blocked-paths-enforcer.sh reads, applying the same redirect / write-
24
+ # utility detection pipeline as protected-paths-bash-gate.sh, and
25
+ # blocking when the resolved target matches any entry.
26
+ #
27
+ # Exit codes:
28
+ # 0 = no blocked-path write detected — allow
29
+ # 2 = blocked-path write via Bash detected — block
30
+ #
31
+ # Detection: `node -e "fs.writeFileSync('.env','x')"` — Node's
32
+ # fs.writeFileSync called against a blocked path is also detected by
33
+ # argument scan. Other interpreter constructions (perl, python, etc.)
34
+ # remain a known coverage gap for the same reason the env-file-protection
35
+ # hook lists hard caps in its header comment: defense-in-depth, not an
36
+ # adversarial firewall.
37
+
38
+ set -uo pipefail
39
+
40
+ # shellcheck source=_lib/cmd-segments.sh
41
+ source "$(dirname "$0")/_lib/cmd-segments.sh"
42
+ # shellcheck source=_lib/path-normalize.sh
43
+ source "$(dirname "$0")/_lib/path-normalize.sh"
44
+ # shellcheck source=_lib/policy-read.sh
45
+ source "$(dirname "$0")/_lib/policy-read.sh"
46
+ # shellcheck source=_lib/halt-check.sh
47
+ source "$(dirname "$0")/_lib/halt-check.sh"
48
+
49
+ INPUT=$(cat)
50
+
51
+ if ! command -v jq >/dev/null 2>&1; then
52
+ printf 'REA ERROR: jq is required but not installed.\n' >&2
53
+ exit 2
54
+ fi
55
+
56
+ check_halt
57
+ REA_ROOT=$(rea_root)
58
+
59
+ CMD=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
60
+ if [[ -z "$CMD" ]]; then
61
+ exit 0
62
+ fi
63
+
64
+ # Load blocked_paths list. If the policy is missing or the list is empty,
65
+ # this hook is a no-op (matches blocked-paths-enforcer.sh semantics).
66
+ BLOCKED_PATHS=()
67
+ while IFS= read -r entry; do
68
+ [[ -z "$entry" ]] && continue
69
+ BLOCKED_PATHS+=("$entry")
70
+ done < <(policy_list "blocked_paths")
71
+
72
+ if [[ ${#BLOCKED_PATHS[@]} -eq 0 ]]; then
73
+ exit 0
74
+ fi
75
+
76
+ # Match a normalized project-relative path against the loaded
77
+ # blocked_paths list using the same matching rules as
78
+ # blocked-paths-enforcer.sh:
79
+ # - directory match (entry ends with `/`) → prefix match
80
+ # - glob entry (contains `*`) → ERE conversion + anchored match
81
+ # - otherwise → exact (case-insensitive) match
82
+ # Returns 0 + sets MATCHED on hit, 1 on no hit.
83
+ MATCHED=""
84
+ _match_blocked() {
85
+ local target_lc="$1"
86
+ MATCHED=""
87
+ local entry entry_lc regex
88
+ for entry in "${BLOCKED_PATHS[@]}"; do
89
+ entry_lc=$(printf '%s' "$entry" | tr '[:upper:]' '[:lower:]')
90
+ if [[ "$entry_lc" == */ ]]; then
91
+ if [[ "$target_lc" == "$entry_lc"* ]] || [[ "$target_lc" == "${entry_lc%/}" ]]; then
92
+ MATCHED="$entry"
93
+ return 0
94
+ fi
95
+ continue
96
+ fi
97
+ if [[ "$entry" == *'*'* ]]; then
98
+ regex=$(printf '%s' "$entry_lc" | sed 's/\./\\./g; s/\*/.*/g')
99
+ if printf '%s' "$target_lc" | grep -qE "^${regex}$"; then
100
+ MATCHED="$entry"
101
+ return 0
102
+ fi
103
+ continue
104
+ fi
105
+ if [[ "$target_lc" == "$entry_lc" ]]; then
106
+ MATCHED="$entry"
107
+ return 0
108
+ fi
109
+ done
110
+ return 1
111
+ }
112
+
113
+ # Normalize a path token and apply the same `..` walk + outside-REA_ROOT
114
+ # sentinel trick as protected-paths-bash-gate.sh::_normalize_target.
115
+ # Returns the normalized lowercased project-relative path on stdout, or
116
+ # `__rea_outside_root__:<resolved>` when the path resolves outside the
117
+ # project root.
118
+ _normalize_target() {
119
+ local t="$1"
120
+ if [[ "$t" =~ ^\"(.*)\"$ ]]; then t="${BASH_REMATCH[1]}"; fi
121
+ if [[ "$t" =~ ^\'(.*)\'$ ]]; then t="${BASH_REMATCH[1]}"; fi
122
+ case "/$t/" in
123
+ */../*)
124
+ local abs="$t"
125
+ [[ "$abs" != /* ]] && abs="$REA_ROOT/$abs"
126
+ local -a raw_parts parts=()
127
+ IFS='/' read -ra raw_parts <<<"$abs"
128
+ for part in "${raw_parts[@]}"; do
129
+ case "$part" in
130
+ ''|.) continue ;;
131
+ ..) [[ "${#parts[@]}" -gt 0 ]] && unset 'parts[${#parts[@]}-1]' ;;
132
+ *) parts+=("$part") ;;
133
+ esac
134
+ done
135
+ t="/$(IFS=/; printf '%s' "${parts[*]}")"
136
+ if [[ "$t" != "$REA_ROOT" && "$t" != "$REA_ROOT"/* ]]; then
137
+ printf '__rea_outside_root__:%s' "$t"
138
+ return 0
139
+ fi
140
+ ;;
141
+ esac
142
+ t=$(normalize_path "$t")
143
+ printf '%s' "$t" | tr '[:upper:]' '[:lower:]'
144
+ }
145
+
146
+ _refuse() {
147
+ local pattern="$1" target="$2" segment="$3"
148
+ {
149
+ printf 'BLOCKED PATH (bash): write denied by policy\n'
150
+ printf '\n'
151
+ printf ' Blocked by: %s\n' "$pattern"
152
+ printf ' Resolved target: %s\n' "$target"
153
+ printf ' Segment: %s\n' "$segment"
154
+ printf '\n'
155
+ printf ' Source: .rea/policy.yaml → blocked_paths\n'
156
+ printf ' Rule: blocked_paths entries are unreachable via Bash redirects\n'
157
+ printf ' too — not just Write/Edit/MultiEdit. To modify, a human\n'
158
+ printf ' must edit directly or update blocked_paths in policy.yaml.\n'
159
+ } >&2
160
+ exit 2
161
+ }
162
+
163
+ # Check a single resolved-target token. Refuses on hit.
164
+ _check_token() {
165
+ local token="$1" segment="$2"
166
+ [[ -z "$token" ]] && return 0
167
+ local resolved
168
+ resolved=$(_normalize_target "$token")
169
+ if [[ "$resolved" == __rea_outside_root__:* ]]; then
170
+ # Outside REA_ROOT → can't be in blocked_paths (blocked_paths is
171
+ # project-relative). Allow; the protected-paths gate handles
172
+ # outside-root rejection on the protected list itself.
173
+ return 0
174
+ fi
175
+ if _match_blocked "$resolved"; then
176
+ _refuse "$MATCHED" "$resolved" "$segment"
177
+ fi
178
+ return 0
179
+ }
180
+
181
+ # Scan one segment for redirect / write-utility / node-fs targets and
182
+ # refuse on any hit. Mirrors protected-paths-bash-gate.sh::_check_segment
183
+ # layout, with a few additions to catch discord-ops Round 9 #1's exact
184
+ # Node-interpreter and sed-script-on-target shapes.
185
+ _check_segment() {
186
+ local _raw="$1" segment="$2"
187
+ [[ -z "$segment" ]] && return 0
188
+
189
+ # Same regex set as protected-paths-bash-gate.sh — fd-prefix-aware
190
+ # redirects, cp/mv tail target, sed -i target, dd of=, plus a
191
+ # token-walk for tee/truncate/install/ln. Keeps behavior consistent
192
+ # across the two bash gates.
193
+ local re_redirect='(^|[[:space:]])(&>>|&>|[0-9]+>>|[0-9]+>\||[0-9]+>|>>|>\||>)[[:space:]]*([^[:space:]&|;<>]+)'
194
+ local re_cpmv='(^|[[:space:]])(cp|mv)[[:space:]]+[^&|;<>]+[[:space:]]([^[:space:]&|;<>]+)[[:space:]]*$'
195
+ local re_sed='(^|[[:space:]])sed[[:space:]]+(-[a-zA-Z]*i[a-zA-Z]*[^[:space:]]*)[[:space:]]+[^&|;<>]+[[:space:]]([^[:space:]&|;<>]+)[[:space:]]*$'
196
+ local re_dd='(^|[[:space:]])dd[[:space:]]+[^&|;<>]*of=([^[:space:]&|;<>]+)'
197
+
198
+ if [[ "$segment" =~ $re_redirect ]]; then
199
+ _check_token "${BASH_REMATCH[3]}" "$segment"
200
+ fi
201
+ if [[ "$segment" =~ $re_cpmv ]]; then
202
+ _check_token "${BASH_REMATCH[3]}" "$segment"
203
+ fi
204
+ if [[ "$segment" =~ $re_sed ]]; then
205
+ _check_token "${BASH_REMATCH[3]}" "$segment"
206
+ fi
207
+ if [[ "$segment" =~ $re_dd ]]; then
208
+ _check_token "${BASH_REMATCH[2]}" "$segment"
209
+ fi
210
+
211
+ # tee / truncate / install / ln — token-walk identical to
212
+ # protected-paths-bash-gate.sh.
213
+ local _seg_for_walk="$segment"
214
+ _seg_for_walk="${_seg_for_walk#"${_seg_for_walk%%[![:space:]]*}"}"
215
+ local first_tok
216
+ first_tok=$(printf '%s' "$_seg_for_walk" | awk '{print $1}')
217
+ case "$first_tok" in
218
+ tee|truncate|install|ln)
219
+ local found_cmd=""
220
+ # shellcheck disable=SC2086
221
+ set -- $_seg_for_walk
222
+ while [ "$#" -gt 0 ]; do
223
+ local tok="$1"
224
+ shift
225
+ if [[ -z "$found_cmd" ]]; then
226
+ case "$tok" in
227
+ tee|truncate|install|ln) found_cmd="$tok" ;;
228
+ esac
229
+ continue
230
+ fi
231
+ case "$tok" in
232
+ --) continue ;;
233
+ --*=*) continue ;;
234
+ --*)
235
+ case "$tok" in
236
+ --append|--ignore-interrupts|--no-clobber|--force|--no-target-directory|--symbolic|--no-dereference|--reference=*) continue ;;
237
+ *) shift 2>/dev/null || true; continue ;;
238
+ esac
239
+ ;;
240
+ -*)
241
+ case "$tok" in
242
+ -s*|-m*|-o*|-g*|-t*) shift 2>/dev/null || true ;;
243
+ esac
244
+ continue
245
+ ;;
246
+ *)
247
+ _check_token "$tok" "$segment"
248
+ ;;
249
+ esac
250
+ done
251
+ ;;
252
+ esac
253
+
254
+ # Node-interpreter fs.writeFileSync / fs.appendFileSync / fs.createWriteStream
255
+ # detection (discord-ops Round 9 #1 explicit shape). Anchored on
256
+ # `node -e ...` or `node --eval ...`. Conservative regex: pulls the
257
+ # first quoted argument out of the call.
258
+ local re_node_write='(^|[[:space:]])node[[:space:]]+(-e|--eval|-p|--print)[[:space:]]+'
259
+ if [[ "$segment" =~ $re_node_write ]]; then
260
+ # Find any quoted-string argument that contains fs.write* /
261
+ # fs.append* / createWriteStream + a path-looking arg. This is a
262
+ # best-effort scan; the goal is the obvious vector, not full JS.
263
+ local node_targets
264
+ node_targets=$(printf '%s' "$segment" \
265
+ | grep -oE "fs\.(writeFileSync|writeFile|appendFileSync|appendFile|createWriteStream)\([[:space:]]*[\"'][^\"']+[\"']" \
266
+ | sed -E "s/.*\([[:space:]]*[\"']([^\"']+)[\"'].*/\\1/" || true)
267
+ if [[ -n "$node_targets" ]]; then
268
+ while IFS= read -r tgt; do
269
+ [[ -z "$tgt" ]] && continue
270
+ _check_token "$tgt" "$segment"
271
+ done <<<"$node_targets"
272
+ fi
273
+ fi
274
+
275
+ return 0
276
+ }
277
+
278
+ for_each_segment "$CMD" _check_segment
279
+
280
+ exit 0
@@ -245,13 +245,20 @@ fi
245
245
 
246
246
  # H12: curl/wget piped directly to shell (supply chain attack vector).
247
247
  # 0.16.1 helix-016 P1 fix: this check requires BOTH the curl/wget call
248
- # AND the `| sh` to appear in the same shell pipeline. The 0.16.0
249
- # refactor moved this into `any_segment_matches`, but the segmenter
250
- # splits on `|` first — so `curl https://x | sh` decomposed into two
251
- # segments (`curl https://x`, `sh`) and the regex (which requires both
252
- # in one segment) never matched. Pipe-RCE is fundamentally a
253
- # multi-segment property and must be checked against the raw command.
254
- if printf '%s' "$CMD" | grep -qiE '(curl|wget)[^|]*\|[[:space:]]*(sudo[[:space:]]+)?(bash|sh|zsh|fish)'; then
248
+ # AND the `| sh` to appear in the same shell pipeline. Pipe-RCE is
249
+ # fundamentally a multi-segment property splitting on `|` would
250
+ # decompose `curl https://x | sh` into two unrelated segments — so the
251
+ # detection must run against the un-split command.
252
+ #
253
+ # 0.16.3 helix-016.1 #2 sibling fix: pre-fix the check ran against the
254
+ # raw `$CMD`, which false-positived on `git commit -m "...curl|sh..."`
255
+ # (literal pipe inside the commit-message body). The fix is to scan
256
+ # the QUOTE-MASKED form of the command — same un-split shape, but
257
+ # in-quote pipes are replaced with a sentinel that the regex doesn't
258
+ # match. Real curl-pipe-shell still matches because the pipe between
259
+ # `curl https://x` and `sh` is outside any quote span.
260
+ H12_MASKED=$(quote_masked_cmd "$CMD")
261
+ if printf '%s' "$H12_MASKED" | grep -qiE '(curl|wget)[^|]*\|[[:space:]]*(sudo[[:space:]]+)?(bash|sh|zsh|fish)'; then
255
262
  add_high \
256
263
  "curl/wget piped to shell — remote code execution" \
257
264
  "Executing remote scripts without inspection is a major supply chain risk." \
@@ -307,9 +314,26 @@ while IFS= read -r pattern; do
307
314
  DELEGATE_PATTERNS+=("$pattern")
308
315
  done < <(policy_list "delegate_to_subagent")
309
316
 
317
+ # 0.16.3 discord-ops Round 9 #3 fix: anchor the match on segment-start
318
+ # instead of unanchored substring search. The patterns from
319
+ # `policy_list "delegate_to_subagent"` are command prefixes
320
+ # (`pnpm run build`, `pnpm test`, `pnpm run lint`); a substring search
321
+ # fired on commit messages and prose mentioning those prefixes
322
+ # (`git commit -m "doc: when to delegate pnpm test to subagent"`).
323
+ # `any_segment_starts_with` regexes against the prefix-stripped form of
324
+ # each segment, so patterns now only match when the command segment
325
+ # actually invokes the named tool.
326
+ #
327
+ # `grep -qF` was fixed-string; `any_segment_starts_with` runs grep -E.
328
+ # The patterns from policy.yaml are literal prefixes — escape ERE
329
+ # metacharacters before passing them through.
330
+ _escape_ere() {
331
+ printf '%s' "$1" | sed 's/[][\\.*^$(){}+?|]/\\&/g'
332
+ }
333
+
310
334
  for pattern in "${DELEGATE_PATTERNS[@]+"${DELEGATE_PATTERNS[@]}"}"; do
311
- # Use fixed-string match — these are command prefixes, not regex.
312
- if printf '%s' "$CMD" | grep -qF "$pattern"; then
335
+ pattern_re=$(_escape_ere "$pattern")
336
+ if any_segment_starts_with "$CMD" "${pattern_re}([[:space:]]|$)"; then
313
337
  add_high \
314
338
  "Context protection — command must run in a subagent" \
315
339
  "This command produces excessive output that will exhaust the coordinator context window. Delegate it to a subagent instead of running it directly." \
@@ -65,7 +65,15 @@ truncate_cmd() {
65
65
  # The goal is to block casual and accidental reads, not defeat a determined
66
66
  # adversary with shell access.
67
67
  PATTERN_UTILITY='(cat|head|tail|less|more|grep|sed|awk|bat|strings|printf|xargs|tee|jq|python3?[[:space:]]+-c|ruby[[:space:]]+-e)[[:space:]]'
68
- # Also catch: source/., cp (reads then writes elsewhere)
68
+ # Also catch: source/., cp (reads then writes elsewhere).
69
+ #
70
+ # 0.16.3 discord-ops Round 9 #4 fix: anchored on segment-start. Pre-fix
71
+ # `any_segment_matches` matched anywhere in the segment, so
72
+ # `git commit -m "fix: don't source .env files"` fired even though no
73
+ # real source-of-.env was happening — the trigger words appeared inside
74
+ # the quoted commit-message body. The patterns are command prefixes
75
+ # (`source PATH`, `. PATH`, `cp X PATH`), so segment-start anchoring is
76
+ # the correct shape.
69
77
  PATTERN_SOURCE='(source|\.)[[:space:]]+[^;|&]*\.env'
70
78
  PATTERN_CP_ENV='cp[[:space:]]+[^;|&]*\.env'
71
79
  # .env* files or .envrc (direnv)
@@ -83,9 +91,10 @@ if any_segment_matches_both "$CMD" "$PATTERN_UTILITY" "$PATTERN_ENV_FILE"; then
83
91
  MATCHES_BOTH_SAME_SEGMENT=1
84
92
  fi
85
93
 
86
- # Direct source/cp of .env files — always block
87
- if any_segment_matches "$CMD" "$PATTERN_SOURCE" || \
88
- any_segment_matches "$CMD" "$PATTERN_CP_ENV"; then
94
+ # Direct source/cp of .env files — always block (segment-start anchored
95
+ # per discord-ops Round 9 #4).
96
+ if any_segment_starts_with "$CMD" "$PATTERN_SOURCE" || \
97
+ any_segment_starts_with "$CMD" "$PATTERN_CP_ENV"; then
89
98
  TRUNCATED_CMD=$(truncate_cmd "$CMD")
90
99
  {
91
100
  printf 'ENV FILE PROTECTION: Direct sourcing or copying of .env files is blocked.\n'
@@ -40,8 +40,23 @@ fi
40
40
  COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // ""')
41
41
 
42
42
  # Only intercept gh issue create
43
- if ! echo "$COMMAND" | grep -qE 'gh\s+issue\s+create'; then
44
- exit 0
43
+ # 0.16.3 F8: anchor at segment start so `gh pr create --body "context: gh issue create earlier"`
44
+ # does not match. Same anchoring class as F5/F6 in this release. Source the
45
+ # segment splitter and use any_segment_starts_with — when the cmd-segments
46
+ # lib isn't reachable for any reason, fall back to the legacy unanchored
47
+ # grep (defense-in-depth: better to over-block prose mentions than miss a
48
+ # real `gh issue create`).
49
+ # shellcheck source=_lib/cmd-segments.sh
50
+ if [ -f "$(dirname "$0")/_lib/cmd-segments.sh" ]; then
51
+ # shellcheck source=_lib/cmd-segments.sh
52
+ source "$(dirname "$0")/_lib/cmd-segments.sh"
53
+ if ! any_segment_starts_with "$COMMAND" 'gh[[:space:]]+issue[[:space:]]+create'; then
54
+ exit 0
55
+ fi
56
+ else
57
+ if ! echo "$COMMAND" | grep -qE 'gh\s+issue\s+create'; then
58
+ exit 0
59
+ fi
45
60
  fi
46
61
 
47
62
  require_jq
@@ -86,8 +101,107 @@ SECURITY_PATTERNS=(
86
101
  'jail.break'
87
102
  )
88
103
 
89
- # Scan the full command text (title + body + flags) for sensitive patterns
90
- FULL_TEXT=$(echo "$COMMAND" | tr '[:upper:]' '[:lower:]')
104
+ # Scan the full command text (title + body + flags) for sensitive patterns.
105
+ #
106
+ # 0.16.3 discord-ops Round 9 #2 fix: pre-fix the scan only saw what was on
107
+ # the command line, so `gh issue create --body-file leak.md` (or `-F`)
108
+ # routed the body through a file the regex never read. We now resolve the
109
+ # named flag's path argument(s), read up to 64 KiB of each (cap covers
110
+ # realistic issue bodies; a multi-megabyte body is suspicious in itself),
111
+ # and prepend the lowercased file contents to FULL_TEXT before the
112
+ # pattern scan. Stdin form (`-F -` or `--body-file -`) is intentionally
113
+ # skipped — the hook's stdin is the tool payload, not the issue body,
114
+ # and re-reading is impossible. Files outside REA_ROOT (resolved via
115
+ # `..` traversal) are refused as a defense-in-depth measure mirroring
116
+ # protected-paths-bash-gate.sh's outside-root sentinel.
117
+ REA_ROOT="${CLAUDE_PROJECT_DIR:-$(pwd)}"
118
+ BODY_FILE_TEXT=""
119
+ _extract_body_file_paths() {
120
+ # Emit each `--body-file PATH` and `-F PATH` argument on its own line.
121
+ # Skips the stdin form (`-`) and `-F=foo`/`--body-file=foo` (handled
122
+ # by a separate awk pass below).
123
+ printf '%s' "$COMMAND" \
124
+ | awk '
125
+ BEGIN { skip_next = 0; flag_was = "" }
126
+ {
127
+ n = split($0, toks, /[[:space:]]+/)
128
+ for (i = 1; i <= n; i++) {
129
+ t = toks[i]
130
+ if (skip_next) {
131
+ skip_next = 0
132
+ if (t == "-" || t == "") continue
133
+ # Strip surrounding quotes from the token if present.
134
+ gsub(/^["'"'"']/, "", t)
135
+ gsub(/["'"'"']$/, "", t)
136
+ print t
137
+ continue
138
+ }
139
+ if (t == "--body-file" || t == "-F") { skip_next = 1; continue }
140
+ # Equals form.
141
+ if (t ~ /^--body-file=/) {
142
+ v = substr(t, length("--body-file=") + 1)
143
+ gsub(/^["'"'"']/, "", v); gsub(/["'"'"']$/, "", v)
144
+ if (v != "" && v != "-") print v
145
+ }
146
+ if (t ~ /^-F=/) {
147
+ v = substr(t, length("-F=") + 1)
148
+ gsub(/^["'"'"']/, "", v); gsub(/["'"'"']$/, "", v)
149
+ if (v != "" && v != "-") print v
150
+ }
151
+ }
152
+ }'
153
+ }
154
+ while IFS= read -r body_path; do
155
+ [[ -z "$body_path" ]] && continue
156
+ raw_path="$body_path"
157
+ # Resolve relative to the hook's cwd (the agent's project dir). gh
158
+ # accepts both absolute paths (e.g. tmpfiles like /var/folders/…) and
159
+ # cwd-relative paths; we honor both. Absolute paths NOT containing
160
+ # `..` are taken at face value.
161
+ if [[ "$body_path" != /* ]]; then
162
+ body_path="$(pwd)/$body_path"
163
+ fi
164
+ # Walk `..` segments. The only outside-REA_ROOT shape we refuse is one
165
+ # where the canonical form contains `..` (i.e. an explicit traversal
166
+ # by the caller). Plain absolute tmp paths are NOT refused — gh issue
167
+ # body-files are very commonly written to /var/folders or /tmp and
168
+ # rejecting those would defeat the scan in routine use.
169
+ had_traversal=0
170
+ case "/$raw_path/" in */../*) had_traversal=1 ;; esac
171
+ resolved="$body_path"
172
+ if [[ "$had_traversal" -eq 1 ]]; then
173
+ IFS='/' read -ra _bf_parts_raw <<<"$body_path"
174
+ _bf_parts=()
175
+ for _seg in "${_bf_parts_raw[@]}"; do
176
+ case "$_seg" in
177
+ ''|.) continue ;;
178
+ ..) [[ "${#_bf_parts[@]}" -gt 0 ]] && unset '_bf_parts[${#_bf_parts[@]}-1]' ;;
179
+ *) _bf_parts+=("$_seg") ;;
180
+ esac
181
+ done
182
+ resolved="/$(IFS=/; printf '%s' "${_bf_parts[*]}")"
183
+ # If the raw path used `..` AND the resolved form escapes REA_ROOT,
184
+ # refuse — that's the obfuscation shape we care about. A file under
185
+ # /tmp or /var/folders without `..` segments is fine.
186
+ if [[ "$resolved" != "$REA_ROOT" && "$resolved" != "$REA_ROOT"/* ]]; then
187
+ printf 'security-disclosure-gate: --body-file path uses `..` traversal escaping project root; skipping body scan\n' >&2
188
+ continue
189
+ fi
190
+ fi
191
+ if [[ ! -r "$resolved" ]]; then
192
+ printf 'security-disclosure-gate: --body-file %s unreadable; skipping body scan\n' "$raw_path" >&2
193
+ continue
194
+ fi
195
+ # Cap at 64 KiB. Lowercase to match FULL_TEXT case folding.
196
+ body_chunk=$(head -c 65536 "$resolved" 2>/dev/null | tr '[:upper:]' '[:lower:]') || body_chunk=""
197
+ if [[ -n "$body_chunk" ]]; then
198
+ BODY_FILE_TEXT="${BODY_FILE_TEXT}
199
+ ${body_chunk}"
200
+ fi
201
+ done < <(_extract_body_file_paths)
202
+
203
+ FULL_TEXT="${BODY_FILE_TEXT}
204
+ $(echo "$COMMAND" | tr '[:upper:]' '[:lower:]')"
91
205
 
92
206
  MATCHED_PATTERN=""
93
207
  for PATTERN in "${SECURITY_PATTERNS[@]}"; do
@@ -226,13 +226,16 @@ esac
226
226
  # §6 runs BEFORE the patch-session allowlist so hook-patch sessions cannot
227
227
  # reach .rea/policy.yaml, .rea/HALT, or .claude/settings.json via any glob
228
228
  # creativity.
229
- PROTECTED_PATTERNS=(
230
- '.claude/settings.json'
231
- '.claude/settings.local.json'
232
- '.husky/'
233
- '.rea/policy.yaml'
234
- '.rea/HALT'
235
- )
229
+ #
230
+ # 0.16.3 F7: list is sourced from `_lib/protected-paths.sh`, which honors
231
+ # the `protected_paths_relax` policy key (kill-switch invariants always
232
+ # stay protected — see the lib for the always-protected subset).
233
+ # shellcheck source=_lib/protected-paths.sh
234
+ source "$(dirname "$0")/_lib/protected-paths.sh"
235
+ # Trigger lazy load now so PROTECTED_PATTERNS reflects the relaxed list
236
+ # from the start of this hook process.
237
+ rea_path_is_protected "/__rea_force_load__" >/dev/null 2>&1 || true
238
+ PROTECTED_PATTERNS=("${REA_PROTECTED_PATTERNS[@]}")
236
239
 
237
240
  # Patterns that are protected from general agent edits but can be unlocked by
238
241
  # REA_HOOK_PATCH_SESSION. Kept separate from the hard-protected list above so
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bookedsolid/rea",
3
- "version": "0.16.2",
3
+ "version": "0.16.4",
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)",