@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
|
-
#
|
|
91
|
-
#
|
|
92
|
-
#
|
|
93
|
-
#
|
|
94
|
-
#
|
|
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
|
-
|
|
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
|
-
#
|
|
62
|
-
#
|
|
63
|
-
|
|
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
|
-
#
|
|
274
|
-
#
|
|
275
|
-
#
|
|
276
|
-
#
|
|
277
|
-
local
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
-
|
|
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" =~ $
|
|
175
|
-
|
|
176
|
-
|
|
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.
|
|
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)",
|