@bookedsolid/rea 0.21.1 → 0.22.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.
@@ -87,14 +87,29 @@
87
87
  # correct. We only need the mask to suppress matching; the captured
88
88
  # payload is read off the original string.
89
89
  #
90
- # Limitation: ONE level of unwrapping. A wrapper inside a wrapper
91
- # (`bash -c "bash -c 'innermost'"`) emits only the second-level payload
92
- # (`bash -c 'innermost'`), not the third-level. This is enough for
93
- # every consumer-reported bypass; deeper nesting can be added later
94
- # without changing the contract.
90
+ # 0.21.2 helix-022 #3: recurse to fixed point with depth bound 8.
91
+ # Pre-fix the function did exactly ONE level of unwrap, so
92
+ # `bash -lc "bash -lc 'printf x > .rea/HALT'"` emitted the
93
+ # middle wrapper as a segment but NEVER the inner `printf x > ...`.
94
+ # Now each extracted payload is re-fed through the unwrap until
95
+ # either no payload is found (fixed point) or depth 8 is reached.
96
+ # Depth limit prevents pathological inputs; on overflow the helper
97
+ # emits a stderr advisory but does not refuse — caller falls back
98
+ # to logical-form-only enforcement of the partial unwrap.
95
99
  _rea_unwrap_nested_shells() {
100
+ _rea_unwrap_at_depth "$1" 0
101
+ }
102
+
103
+ _rea_unwrap_at_depth() {
96
104
  local cmd="$1"
105
+ local depth="$2"
106
+ local max_depth=8
97
107
  printf '%s\n' "$cmd"
108
+ if [[ $depth -ge $max_depth ]]; then
109
+ printf 'rea: nested-shell unwrap depth limit (%d) reached on payload %.80s...\n' \
110
+ "$max_depth" "$cmd" >&2
111
+ return 0
112
+ fi
98
113
  # Build a mask where in-quote `"` `'` `;` `&` `|` characters are
99
114
  # replaced with multi-byte sentinels so the wrapper regex below
100
115
  # cannot match wrapper syntax that lives inside outer quoted prose.
@@ -172,7 +187,10 @@ _rea_unwrap_nested_shells() {
172
187
  # masked form; payload extraction reads the raw form using the same
173
188
  # offsets. Because the mask is byte-for-byte width-preserving, the
174
189
  # same RSTART/RLENGTH applies to both.
175
- printf '' | awk -v raw="$cmd" -v masked="$masked" '
190
+ #
191
+ # 0.21.2: capture payloads to a local var; iterate to recurse.
192
+ local _unwrap_payloads
193
+ _unwrap_payloads=$(printf '' | awk -v raw="$cmd" -v masked="$masked" '
176
194
  BEGIN {
177
195
  # Wrapper-prefix regex: shell-name + optional flag tokens + -c-style flag.
178
196
  # Each flag token is `-` followed by 1+ letters and trailing space.
@@ -263,7 +281,14 @@ _rea_unwrap_nested_shells() {
263
281
  }
264
282
  # Empty action with no input rules — explicitly drive the loop from
265
283
  # END so awk does not require any input records.
266
- END {}'
284
+ END {}')
285
+ # Recurse on each extracted payload with depth+1.
286
+ if [[ -n "$_unwrap_payloads" ]]; then
287
+ while IFS= read -r _unwrap_p; do
288
+ [[ -z "$_unwrap_p" ]] && continue
289
+ _rea_unwrap_at_depth "$_unwrap_p" $((depth + 1))
290
+ done <<< "$_unwrap_payloads"
291
+ fi
267
292
  }
268
293
 
269
294
  # Split $1 on shell command separators. Emits one segment per line on
@@ -0,0 +1,71 @@
1
+ # shellcheck shell=bash
2
+ # hooks/_lib/interpreter-scanner.sh — extract write-operation targets
3
+ # from interpreter `-e` / `--eval` invocations.
4
+ #
5
+ # 0.21.2 helix-022 #2: shared between blocked-paths-bash-gate.sh and
6
+ # protected-paths-bash-gate.sh. Pre-fix the protected gate had no
7
+ # interpreter scanner — `node -e "fs.writeFileSync('.rea/HALT','x')"`
8
+ # bypassed the protected-path check while the soft blocked-paths gate
9
+ # (which had its own copy of the scanner) caught equivalent writes
10
+ # against soft-list paths.
11
+ #
12
+ # Coverage:
13
+ # node -e | --eval | -p | --print (also fs.writeFile / appendFile
14
+ # / createWriteStream variants)
15
+ # python | python2 | python3 -c (open(...,'w'), pathlib.write_*)
16
+ # ruby -e (File.write, IO.write)
17
+ # perl -e (open + print, syswrite)
18
+ #
19
+ # Returns: stdout — one path per line, raw (post-quote-strip but no
20
+ # normalization). Caller passes each through their own _normalize_target
21
+ # / _check_token / rea_path_is_protected pipeline.
22
+
23
+ # Extract write targets from an interpreter -e / --eval invocation.
24
+ # Usage: rea_interpreter_write_targets "$segment"
25
+ # Returns each path on a separate line on stdout.
26
+ # No output means no interpreter-write-shape was detected.
27
+ rea_interpreter_write_targets() {
28
+ local segment="$1"
29
+
30
+ # Node — fs.writeFileSync / fs.writeFile / fs.appendFileSync /
31
+ # fs.appendFile / fs.createWriteStream
32
+ if [[ "$segment" =~ (^|[[:space:]])node[[:space:]]+(-e|--eval|-p|--print)[[:space:]]+ ]]; then
33
+ printf '%s' "$segment" \
34
+ | grep -oE "fs\.(writeFileSync|writeFile|appendFileSync|appendFile|createWriteStream)\([[:space:]]*[\"'][^\"']+[\"']" \
35
+ | sed -E "s/.*\([[:space:]]*[\"']([^\"']+)[\"'].*/\\1/" \
36
+ || true
37
+ fi
38
+
39
+ # Python — open(PATH, 'w'|'wb'|'a'|'ab'|'w+'|'r+'|'x'|'xb') |
40
+ # pathlib.Path(PATH).write_text|.write_bytes
41
+ if [[ "$segment" =~ (^|[[:space:]])python[23]?[[:space:]]+(-c) ]]; then
42
+ # open(...,'w'-style)
43
+ printf '%s' "$segment" \
44
+ | grep -oE "open\([[:space:]]*[\"'][^\"']+[\"'][[:space:]]*,[[:space:]]*[\"'](w|wb|a|ab|w\+|r\+|x|xb)[\"']" \
45
+ | sed -E "s/open\([[:space:]]*[\"']([^\"']+)[\"'].*/\\1/" \
46
+ || true
47
+ # pathlib write_text / write_bytes
48
+ printf '%s' "$segment" \
49
+ | grep -oE "Path\([[:space:]]*[\"'][^\"']+[\"'][[:space:]]*\)\.(write_text|write_bytes)" \
50
+ | sed -E "s/Path\([[:space:]]*[\"']([^\"']+)[\"'].*/\\1/" \
51
+ || true
52
+ fi
53
+
54
+ # Ruby — File.write(PATH, ...) | IO.write(PATH, ...)
55
+ if [[ "$segment" =~ (^|[[:space:]])ruby[[:space:]]+(-e) ]]; then
56
+ printf '%s' "$segment" \
57
+ | grep -oE "(File|IO)\.write\([[:space:]]*[\"'][^\"']+[\"']" \
58
+ | sed -E "s/.*\([[:space:]]*[\"']([^\"']+)[\"'].*/\\1/" \
59
+ || true
60
+ fi
61
+
62
+ # Perl — open(FH, '>file') | open(FH, '>>file') | sysopen(... O_WRONLY)
63
+ # Conservative: capture the literal `>file` / `>>file` form, which is
64
+ # the common shell-style spelling Perl accepts in 2-arg open.
65
+ if [[ "$segment" =~ (^|[[:space:]])perl[[:space:]]+(-e) ]]; then
66
+ printf '%s' "$segment" \
67
+ | grep -oE "open\([[:space:]]*[A-Z_]+,[[:space:]]*[\"']>{1,2}[^\"']+[\"']" \
68
+ | sed -E "s/.*[\"']>+([^\"']+)[\"'].*/\\1/" \
69
+ || true
70
+ fi
71
+ }
@@ -58,9 +58,36 @@ resolve_parent_realpath() {
58
58
  local parent_dir
59
59
  parent_dir=$(dirname -- "$target_path")
60
60
  if [[ ! -d "$parent_dir" ]]; then
61
- # Parent doesn't exist yetcaller should treat as "no realpath
62
- # available" and fall back to logical-path checks. Return empty.
63
- printf ''
61
+ # 0.21.2 helix-022 #1: parent doesn't exist on disk but a
62
+ # SYMLINK along the path might. Walk up to the nearest existing
63
+ # ancestor, resolve THAT, then append the unresolved tail. Pre-fix
64
+ # this returned empty for any path whose terminal directory is
65
+ # created mid-segment (`mkdir -p linkroot/.husky/sub` followed by
66
+ # a redirect into `.husky/sub/X`); the caller fell back to logical-
67
+ # path-only enforcement, restoring the symlink-walk bypass.
68
+ local walk="$parent_dir"
69
+ local tail=""
70
+ while [[ -n "$walk" && "$walk" != "/" && "$walk" != "." && ! -d "$walk" ]]; do
71
+ tail="$(basename -- "$walk")${tail:+/$tail}"
72
+ walk="$(dirname -- "$walk")"
73
+ done
74
+ if [[ -z "$walk" || "$walk" == "/" ]]; then
75
+ # Walked all the way up; no existing ancestor inside the project.
76
+ # Caller still has the logical-path check; return empty.
77
+ printf ''
78
+ return 0
79
+ fi
80
+ local resolved_walk
81
+ resolved_walk=$(cd -P -- "$walk" 2>/dev/null && pwd -P 2>/dev/null) || resolved_walk=""
82
+ if [[ -z "$resolved_walk" ]]; then
83
+ printf ''
84
+ return 0
85
+ fi
86
+ if [[ -n "$tail" ]]; then
87
+ printf '%s/%s' "$resolved_walk" "$tail"
88
+ else
89
+ printf '%s' "$resolved_walk"
90
+ fi
64
91
  return 0
65
92
  fi
66
93
  # `cd -P` follows symlinks; `pwd -P` prints the resolved physical
@@ -45,6 +45,8 @@ source "$(dirname "$0")/_lib/path-normalize.sh"
45
45
  source "$(dirname "$0")/_lib/policy-read.sh"
46
46
  # shellcheck source=_lib/halt-check.sh
47
47
  source "$(dirname "$0")/_lib/halt-check.sh"
48
+ # shellcheck source=_lib/interpreter-scanner.sh
49
+ source "$(dirname "$0")/_lib/interpreter-scanner.sh"
48
50
 
49
51
  INPUT=$(cat)
50
52
 
@@ -270,25 +272,17 @@ _check_segment() {
270
272
  ;;
271
273
  esac
272
274
 
273
- # Node-interpreter fs.writeFileSync / fs.appendFileSync / fs.createWriteStream
274
- # detection (discord-ops Round 9 #1 explicit shape). Anchored on
275
- # `node -e ...` or `node --eval ...`. Conservative regex: pulls the
276
- # first quoted argument out of the call.
277
- local re_node_write='(^|[[:space:]])node[[:space:]]+(-e|--eval|-p|--print)[[:space:]]+'
278
- if [[ "$segment" =~ $re_node_write ]]; then
279
- # Find any quoted-string argument that contains fs.write* /
280
- # fs.append* / createWriteStream + a path-looking arg. This is a
281
- # best-effort scan; the goal is the obvious vector, not full JS.
282
- local node_targets
283
- node_targets=$(printf '%s' "$segment" \
284
- | grep -oE "fs\.(writeFileSync|writeFile|appendFileSync|appendFile|createWriteStream)\([[:space:]]*[\"'][^\"']+[\"']" \
285
- | sed -E "s/.*\([[:space:]]*[\"']([^\"']+)[\"'].*/\\1/" || true)
286
- if [[ -n "$node_targets" ]]; then
287
- while IFS= read -r tgt; do
288
- [[ -z "$tgt" ]] && continue
289
- _check_token "$tgt" "$segment"
290
- done <<<"$node_targets"
291
- fi
275
+ # 0.21.2 helix-022 #2: interpreter scanner factored to
276
+ # _lib/interpreter-scanner.sh and shared with protected-paths-bash-gate.
277
+ # Covers node -e fs.writeFileSync, python -c open(...,'w'),
278
+ # ruby -e File.write, perl -e open(FH,'>...').
279
+ local interp_targets
280
+ interp_targets=$(rea_interpreter_write_targets "$segment")
281
+ if [[ -n "$interp_targets" ]]; then
282
+ while IFS= read -r tgt; do
283
+ [[ -z "$tgt" ]] && continue
284
+ _check_token "$tgt" "$segment"
285
+ done <<<"$interp_targets"
292
286
  fi
293
287
 
294
288
  return 0
@@ -31,6 +31,8 @@ source "$(dirname "$0")/_lib/protected-paths.sh"
31
31
  source "$(dirname "$0")/_lib/path-normalize.sh"
32
32
  # shellcheck source=_lib/cmd-segments.sh
33
33
  source "$(dirname "$0")/_lib/cmd-segments.sh"
34
+ # shellcheck source=_lib/interpreter-scanner.sh
35
+ source "$(dirname "$0")/_lib/interpreter-scanner.sh"
34
36
 
35
37
  INPUT=$(cat)
36
38
 
@@ -72,6 +74,20 @@ _normalize_target() {
72
74
  # Strip matching surrounding quotes.
73
75
  if [[ "$t" =~ ^\"(.*)\"$ ]]; then t="${BASH_REMATCH[1]}"; fi
74
76
  if [[ "$t" =~ ^\'(.*)\'$ ]]; then t="${BASH_REMATCH[1]}"; fi
77
+ # 0.21.2 helix-022 #5: fail closed on shell parameter/command
78
+ # substitution in the target. `printf x > "$p"` (where p was set
79
+ # earlier in the segment to `.rea/HALT`) bypassed the gate because
80
+ # neither the logical nor resolved-form check matched the literal
81
+ # string `$p`. We DO NOT try to resolve `$NAME=value` assignments
82
+ # in the same segment — that's a partial-execution semantic this
83
+ # static analyzer cannot guarantee. Refuse with a clear sentinel
84
+ # so the caller emits the actionable error message.
85
+ case "$t" in
86
+ *'$'*|*'`'*)
87
+ printf '__rea_unresolved_expansion__:%s' "$t"
88
+ return 0
89
+ ;;
90
+ esac
75
91
  # If the path contains `..` segments, resolve them aggressively. We
76
92
  # cannot rely on `realpath` being installed; do a manual resolution
77
93
  # by walking segments. This is the helix-015 P1 fix: pre-fix, the
@@ -121,6 +137,83 @@ _normalize_target() {
121
137
  printf '%s' "$t" | tr '[:upper:]' '[:lower:]'
122
138
  }
123
139
 
140
+ # 0.21.2 helix-022 #4: cp/mv destination extractor. Walks the segment
141
+ # token-by-token, skips flags (single-dash, double-dash, `--` end-of-
142
+ # options separator), returns the LAST positional argument — which is
143
+ # the destination per POSIX cp/mv semantic.
144
+ #
145
+ # Handles:
146
+ # cp src dst → dst
147
+ # cp -f src dst → dst
148
+ # cp --force src dst → dst
149
+ # cp a b c dst → dst (multi-source: last is destination)
150
+ # cp -- -src dst → dst (-- ends option processing)
151
+ # cp -t dir src → src is the source after -t flag (-t SOURCE_FIRST)
152
+ # but we don't try to follow -t semantics; we
153
+ # conservatively treat the LAST positional as
154
+ # the destination, which over-blocks `-t dir src`
155
+ # (destination becomes `src`) — the caller's
156
+ # rea_path_is_protected check then determines
157
+ # if that's actually protected. False-positive
158
+ # case is narrow.
159
+ #
160
+ # Flag-with-value awareness: short flag clusters that take a value
161
+ # (cp -t TARGET_DIR, mv -S SUFFIX, install -m MODE, etc.) consume the
162
+ # next token. Conservative heuristic: known short-options-with-values
163
+ # get the next token consumed.
164
+ _extract_cpmv_destination() {
165
+ local segment="$1"
166
+ local stripped="${segment#"${segment%%[![:space:]]*}"}"
167
+ # Word-split on whitespace. `set --` is intentional; downstream
168
+ # iteration consumes positional args.
169
+ local positionals=()
170
+ local found_cmd=""
171
+ local end_of_options=0
172
+ # shellcheck disable=SC2086
173
+ set -- $stripped
174
+ while [ "$#" -gt 0 ]; do
175
+ local tok="$1"
176
+ shift
177
+ if [[ -z "$found_cmd" ]]; then
178
+ case "$tok" in
179
+ cp|mv) found_cmd="$tok" ;;
180
+ esac
181
+ continue
182
+ fi
183
+ if [[ "$end_of_options" -eq 1 ]]; then
184
+ positionals+=("$tok")
185
+ continue
186
+ fi
187
+ case "$tok" in
188
+ --) end_of_options=1; continue ;;
189
+ --*=*) continue ;;
190
+ --*)
191
+ # Long flags that take a value as the next token.
192
+ case "$tok" in
193
+ --target-directory|--reply|--suffix|--backup|--reflink|--strip-trailing-slashes)
194
+ shift 2>/dev/null || true
195
+ ;;
196
+ esac
197
+ continue
198
+ ;;
199
+ -*)
200
+ # Short flag cluster. Check the LAST char — if it's a known
201
+ # value-taking flag, consume the next token.
202
+ case "$tok" in
203
+ *-t|*-S|*-Z|*-T) shift 2>/dev/null || true ;;
204
+ esac
205
+ continue
206
+ ;;
207
+ *)
208
+ positionals+=("$tok")
209
+ ;;
210
+ esac
211
+ done
212
+ if [[ ${#positionals[@]} -ge 2 ]]; then
213
+ printf '%s' "${positionals[$((${#positionals[@]} - 1))]}"
214
+ fi
215
+ }
216
+
124
217
  # Refuse and exit 2 with a uniform error message.
125
218
  _refuse() {
126
219
  local pattern="$1" target="$2" segment="$3"
@@ -159,7 +252,15 @@ _check_segment() {
159
252
  # pattern accepts: optional fd-prefix, then `>` or `>>` or `>|`, with
160
253
  # optional `&` for stderr-merge variants.
161
254
  local re_redirect='(^|[[:space:]])(&>>|&>|[0-9]+>>|[0-9]+>\||[0-9]+>|>>|>\||>)[[:space:]]*([^[:space:]&|;<>]+)'
162
- local re_cpmv='(^|[[:space:]])(cp|mv)[[:space:]]+[^&|;<>]+[[:space:]]([^[:space:]&|;<>]+)[[:space:]]*$'
255
+ # 0.21.2 helix-022 #4: cp/mv detection now uses an explicit argv-walk
256
+ # (`_extract_cpmv_destination`) instead of regex-with-backtracking so
257
+ # every shape is handled — `cp -f src dst`, multi-source `cp a b dst`,
258
+ # `cp --no-clobber src dst`, `cp -- src dst`. The walker treats the
259
+ # LAST positional as the destination (POSIX cp/mv semantic). The
260
+ # sentinel `re_cpmv` regex below is retained ONLY as a cheap pre-screen
261
+ # — it matches the command name to avoid running the walker on every
262
+ # segment, but never returns the destination (the walker does).
263
+ local re_cpmv_screen='(^|[[:space:]])(cp|mv)[[:space:]]+'
163
264
  local re_sed='(^|[[:space:]])sed[[:space:]]+(-[a-zA-Z]*i[a-zA-Z]*[^[:space:]]*)[[:space:]]+[^&|;<>]+[[:space:]]([^[:space:]&|;<>]+)[[:space:]]*$'
164
265
  local re_dd='(^|[[:space:]])dd[[:space:]]+[^&|;<>]*of=([^[:space:]&|;<>]+)'
165
266
  # 0.15.0 codex P1 fix: replaced the bash-3.2-broken `(...)*` pattern
@@ -171,9 +272,17 @@ _check_segment() {
171
272
  if [[ "$segment" =~ $re_redirect ]]; then
172
273
  target_token="${BASH_REMATCH[3]}"
173
274
  detected_form="redirect ${BASH_REMATCH[2]}"
174
- elif [[ "$segment" =~ $re_cpmv ]]; then
175
- target_token="${BASH_REMATCH[3]}"
176
- detected_form="${BASH_REMATCH[2]}"
275
+ elif [[ "$segment" =~ $re_cpmv_screen ]]; then
276
+ # 0.21.2 helix-022 #4: extract destination via argv-walk; LAST
277
+ # positional is the destination per POSIX cp/mv semantic.
278
+ local _cpmv_cmd="${BASH_REMATCH[2]}"
279
+ target_token=$(_extract_cpmv_destination "$segment")
280
+ detected_form="$_cpmv_cmd"
281
+ if [[ -z "$target_token" ]]; then
282
+ # No positional destination found — segment isn't actually a
283
+ # valid cp/mv invocation. Fall through.
284
+ :
285
+ fi
177
286
  elif [[ "$segment" =~ $re_sed ]]; then
178
287
  target_token="${BASH_REMATCH[3]}"
179
288
  detected_form="sed -i"
@@ -246,6 +355,18 @@ _check_segment() {
246
355
  } >&2
247
356
  exit 2
248
357
  fi
358
+ # 0.21.2 helix-022 #5: shell expansion in target — refuse.
359
+ if [[ "$_t" == __rea_unresolved_expansion__:* ]]; then
360
+ local raw="${_t#__rea_unresolved_expansion__:}"
361
+ {
362
+ printf 'PROTECTED PATH (bash): unresolved shell expansion in target\n'
363
+ printf ' Token: %s\n Segment: %s\n' "$raw" "$segment"
364
+ printf ' Rule: $-substitution and `command-substitution` in redirect\n'
365
+ printf ' targets are refused at static-analysis time. Resolve\n'
366
+ printf ' the variable to a literal path before the redirect.\n'
367
+ } >&2
368
+ exit 2
369
+ fi
249
370
  # 0.20.1 helix-021 #1: resolve intermediate symlinks via
250
371
  # `cd -P / pwd -P` parent-canonicalization (Write-tier parity).
251
372
  # `ln -s ../ .husky/pre-push.d/linkdir; printf x > .husky/pre-push.d/linkdir/pre-push`
@@ -285,7 +406,13 @@ _check_segment() {
285
406
  done
286
407
  fi
287
408
 
409
+ # 0.21.2 helix-022 #2: when no shell-redirect target was found,
410
+ # interpreter-scanner pass before returning. `node -e
411
+ # "fs.writeFileSync('.rea/HALT','x')"` has NO redirect or cp/mv
412
+ # token but still writes a protected path. Run the scanner on the
413
+ # raw segment; refuse if any extracted target is protected.
288
414
  if [[ -z "$target_token" ]]; then
415
+ _interpreter_scan_and_refuse_protected "$segment"
289
416
  return 0
290
417
  fi
291
418
 
@@ -307,6 +434,21 @@ _check_segment() {
307
434
  } >&2
308
435
  exit 2
309
436
  fi
437
+ # 0.21.2 helix-022 #5: shell expansion in target — refuse.
438
+ if [[ "$target" == __rea_unresolved_expansion__:* ]]; then
439
+ local raw="${target#__rea_unresolved_expansion__:}"
440
+ {
441
+ printf 'PROTECTED PATH (bash): unresolved shell expansion in target\n'
442
+ printf '\n'
443
+ printf ' Token: %s\n' "$raw"
444
+ printf ' Segment: %s\n' "$segment"
445
+ printf '\n'
446
+ printf ' Rule: $-substitution and `command-substitution` in redirect\n'
447
+ printf ' targets are refused at static-analysis time. Resolve\n'
448
+ printf ' the variable to a literal path before the redirect.\n'
449
+ } >&2
450
+ exit 2
451
+ fi
310
452
  # 0.20.1 helix-021 #1: resolve intermediate symlinks. See parallel
311
453
  # block in the multi-target loop above for the rationale.
312
454
  local target_resolved
@@ -340,9 +482,55 @@ _check_segment() {
340
482
  done
341
483
  _refuse "$matched" "$hit_form" "$segment"
342
484
  fi
485
+
486
+ # 0.21.2 helix-022 #2: interpreter-scanner pass even when a
487
+ # shell-redirect target was already found. A single segment can
488
+ # have BOTH a shell redirect AND a node -e fs.write*; both must
489
+ # be checked.
490
+ _interpreter_scan_and_refuse_protected "$segment"
491
+
343
492
  return 0
344
493
  }
345
494
 
495
+ # 0.21.2 helix-022 #2: interpreter-scanner pass. Catches
496
+ # `node -e "fs.writeFileSync('.rea/HALT','x')"` and equivalents in
497
+ # python/ruby/perl. The blocked-paths sibling has had this since
498
+ # 0.16.3 F3; this is parity. Each extracted target runs through
499
+ # `_normalize_target` + `rea_path_is_protected` so the existing
500
+ # logical-form + symlink-resolved-form checks both apply.
501
+ _interpreter_scan_and_refuse_protected() {
502
+ local segment="$1"
503
+ local _interp_targets
504
+ _interp_targets=$(rea_interpreter_write_targets "$segment")
505
+ [[ -z "$_interp_targets" ]] && return 0
506
+ while IFS= read -r _interp_t; do
507
+ [[ -z "$_interp_t" ]] && continue
508
+ local _norm
509
+ _norm=$(_normalize_target "$_interp_t")
510
+ if [[ "$_norm" == __rea_outside_root__:* || "$_norm" == __rea_unresolved_expansion__:* ]]; then
511
+ continue
512
+ fi
513
+ local _norm_resolved
514
+ _norm_resolved=$(rea_resolved_relative_form "$_interp_t")
515
+ if rea_path_is_protected "$_norm" \
516
+ || ([[ -n "$_norm_resolved" && "$_norm_resolved" != __rea_outside_root__:* ]] \
517
+ && rea_path_is_protected "$_norm_resolved"); then
518
+ local matched_interp="" pattern_lc
519
+ local hit_form="$_norm"
520
+ if [[ -n "$_norm_resolved" ]] && rea_path_is_protected "$_norm_resolved" \
521
+ && ! rea_path_is_protected "$_norm"; then
522
+ hit_form="$_norm_resolved"
523
+ fi
524
+ for pattern in "${REA_PROTECTED_PATTERNS[@]}"; do
525
+ pattern_lc=$(printf '%s' "$pattern" | tr '[:upper:]' '[:lower:]')
526
+ if [[ "$hit_form" == "$pattern_lc" ]]; then matched_interp="$pattern"; break; fi
527
+ if [[ "$pattern_lc" == */ && "$hit_form" == "$pattern_lc"* ]]; then matched_interp="$pattern"; break; fi
528
+ done
529
+ _refuse "$matched_interp" "$hit_form" "$segment"
530
+ fi
531
+ done <<<"$_interp_targets"
532
+ }
533
+
346
534
  for_each_segment "$CMD" _check_segment
347
535
 
348
536
  exit 0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bookedsolid/rea",
3
- "version": "0.21.1",
3
+ "version": "0.22.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)",