@bookedsolid/rea 0.21.0 → 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.
package/dist/cli/init.js
CHANGED
|
@@ -85,7 +85,7 @@ function resolveLayered(profileName, reagentTranslated) {
|
|
|
85
85
|
}
|
|
86
86
|
return layered;
|
|
87
87
|
}
|
|
88
|
-
async function runWizard(options, targetDir, reagentPolicyPath, layeredBase) {
|
|
88
|
+
async function runWizard(options, targetDir, reagentPolicyPath, layeredBase, existingPolicy = undefined) {
|
|
89
89
|
const projectName = detectProjectName(targetDir);
|
|
90
90
|
p.intro(`rea init — ${projectName}`);
|
|
91
91
|
let fromReagent = options.fromReagent === true;
|
|
@@ -124,9 +124,15 @@ async function runWizard(options, targetDir, reagentPolicyPath, layeredBase) {
|
|
|
124
124
|
cancel('Init cancelled.');
|
|
125
125
|
profileName = picked;
|
|
126
126
|
}
|
|
127
|
-
|
|
127
|
+
// 0.21.1: prefer the existing on-disk value over the profile default
|
|
128
|
+
// so re-running `rea init` doesn't reset an operator's manual edit.
|
|
129
|
+
const autonomyDefault = existingPolicy?.autonomyLevel
|
|
130
|
+
?? layeredBase.autonomy_level
|
|
131
|
+
?? AutonomyLevel.L1;
|
|
128
132
|
const autonomyPick = await p.select({
|
|
129
|
-
message:
|
|
133
|
+
message: existingPolicy?.autonomyLevel !== undefined
|
|
134
|
+
? `Starting autonomy_level (current: ${existingPolicy.autonomyLevel})`
|
|
135
|
+
: 'Starting autonomy_level',
|
|
130
136
|
initialValue: autonomyDefault,
|
|
131
137
|
options: [
|
|
132
138
|
{ value: AutonomyLevel.L0, label: 'L0', hint: 'read-only; every write needs approval' },
|
|
@@ -139,9 +145,13 @@ async function runWizard(options, targetDir, reagentPolicyPath, layeredBase) {
|
|
|
139
145
|
cancel('Init cancelled.');
|
|
140
146
|
const autonomyLevel = autonomyPick;
|
|
141
147
|
const maxCandidates = AUTONOMY_LEVELS.filter((lvl) => levelRank(lvl) >= levelRank(autonomyLevel));
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
148
|
+
// 0.21.1: prefer existing on-disk max_autonomy_level over profile default.
|
|
149
|
+
const defaultMax = (existingPolicy?.maxAutonomyLevel !== undefined &&
|
|
150
|
+
maxCandidates.includes(existingPolicy.maxAutonomyLevel) &&
|
|
151
|
+
existingPolicy.maxAutonomyLevel) ||
|
|
152
|
+
(layeredBase.max_autonomy_level !== undefined &&
|
|
153
|
+
maxCandidates.includes(layeredBase.max_autonomy_level) &&
|
|
154
|
+
layeredBase.max_autonomy_level) ||
|
|
145
155
|
maxCandidates.find((l) => l === AutonomyLevel.L2) ||
|
|
146
156
|
autonomyLevel;
|
|
147
157
|
const maxOptions = maxCandidates.map((lvl) => {
|
|
@@ -233,6 +243,79 @@ async function printCodexInstallAssist() {
|
|
|
233
243
|
console.log(' Install via the Claude Code Codex plugin helper: `/codex:setup`,');
|
|
234
244
|
console.log(' or set `review.codex_required: false` in .rea/policy.yaml to opt out.');
|
|
235
245
|
}
|
|
246
|
+
/**
|
|
247
|
+
* Read user-mutable values from an existing `.rea/policy.yaml`.
|
|
248
|
+
* Returns undefined when the file doesn't exist or fails to parse.
|
|
249
|
+
*
|
|
250
|
+
* The reader is permissive — any field that fails to extract is
|
|
251
|
+
* dropped from the result; the caller falls back to the profile
|
|
252
|
+
* default for that one field. This is the idempotency contract
|
|
253
|
+
* extension introduced in 0.17.0 (`installed_at` preservation),
|
|
254
|
+
* extended in 0.21.1 to cover every field an operator might
|
|
255
|
+
* manually edit between init runs.
|
|
256
|
+
*
|
|
257
|
+
* Profile-switch is allowed but advisory: when the existing
|
|
258
|
+
* `profile:` value disagrees with the requested one, the existing
|
|
259
|
+
* VALUES are still preserved. Operators who want full reset pass
|
|
260
|
+
* `--force` to bypass the file-existence check entirely.
|
|
261
|
+
*/
|
|
262
|
+
function readExistingPolicyForPreservation(targetDir) {
|
|
263
|
+
const policyPath = path.join(targetDir, REA_DIR, POLICY_FILE);
|
|
264
|
+
if (!fs.existsSync(policyPath))
|
|
265
|
+
return undefined;
|
|
266
|
+
try {
|
|
267
|
+
const raw = fs.readFileSync(policyPath, 'utf8');
|
|
268
|
+
const out = {};
|
|
269
|
+
// Profile (informational; used for stderr advisory).
|
|
270
|
+
const pm = raw.match(/^profile:\s*['"]?([a-z0-9-]+)['"]?\s*$/m);
|
|
271
|
+
if (pm)
|
|
272
|
+
out.profile = pm[1];
|
|
273
|
+
// Autonomy + ceiling (enum).
|
|
274
|
+
const am = raw.match(/^autonomy_level:\s*(L[0-3])\s*$/m);
|
|
275
|
+
const amVal = am?.[1];
|
|
276
|
+
if (amVal !== undefined && Object.values(AutonomyLevel).includes(amVal)) {
|
|
277
|
+
out.autonomyLevel = amVal;
|
|
278
|
+
}
|
|
279
|
+
const mm = raw.match(/^max_autonomy_level:\s*(L[0-3])\s*$/m);
|
|
280
|
+
const mmVal = mm?.[1];
|
|
281
|
+
if (mmVal !== undefined && Object.values(AutonomyLevel).includes(mmVal)) {
|
|
282
|
+
out.maxAutonomyLevel = mmVal;
|
|
283
|
+
}
|
|
284
|
+
// block_ai_attribution.
|
|
285
|
+
const bm = raw.match(/^block_ai_attribution:\s*(true|false)\s*$/m);
|
|
286
|
+
if (bm?.[1] !== undefined)
|
|
287
|
+
out.blockAiAttribution = bm[1] === 'true';
|
|
288
|
+
// blocked_paths block-sequence — line-by-line scan.
|
|
289
|
+
const bpStart = raw.match(/^blocked_paths:\s*$/m);
|
|
290
|
+
if (bpStart) {
|
|
291
|
+
const after = raw.slice((bpStart.index ?? 0) + bpStart[0].length + 1);
|
|
292
|
+
const lines = after.split('\n');
|
|
293
|
+
const collected = [];
|
|
294
|
+
for (const line of lines) {
|
|
295
|
+
const m2 = line.match(/^\s*-\s+(?:['"]([^'"]+)['"]|(\S.*?))\s*$/);
|
|
296
|
+
if (!m2)
|
|
297
|
+
break;
|
|
298
|
+
const v = m2[1] ?? m2[2];
|
|
299
|
+
if (v !== undefined)
|
|
300
|
+
collected.push(v);
|
|
301
|
+
}
|
|
302
|
+
if (collected.length > 0)
|
|
303
|
+
out.blockedPaths = collected;
|
|
304
|
+
}
|
|
305
|
+
// notification_channel.
|
|
306
|
+
const nm = raw.match(/^notification_channel:\s*['"]?([^'"\n]*)['"]?\s*$/m);
|
|
307
|
+
if (nm?.[1] !== undefined)
|
|
308
|
+
out.notificationChannel = nm[1];
|
|
309
|
+
// review.codex_required (under nested `review:` block).
|
|
310
|
+
const cm = raw.match(/^\s+codex_required:\s*(true|false)\s*$/m);
|
|
311
|
+
if (cm?.[1] !== undefined)
|
|
312
|
+
out.codexRequired = cm[1] === 'true';
|
|
313
|
+
return out;
|
|
314
|
+
}
|
|
315
|
+
catch {
|
|
316
|
+
return undefined;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
236
319
|
function readExistingInstalledAt(policyPath) {
|
|
237
320
|
try {
|
|
238
321
|
if (!fs.existsSync(policyPath))
|
|
@@ -480,30 +563,58 @@ export async function runInit(options) {
|
|
|
480
563
|
}
|
|
481
564
|
}
|
|
482
565
|
const layeredBase = resolveLayered(profileName, reagentTranslated);
|
|
566
|
+
// 0.21.1: preserve user-mutable policy values across re-init (idempotency
|
|
567
|
+
// class — same as the `installed_at` fix from 0.17.0). Pre-fix, every
|
|
568
|
+
// `rea init` re-applied profile defaults, silently resetting an
|
|
569
|
+
// operator's `autonomy_level: L2` back to the profile's L1, etc.
|
|
570
|
+
// Read the existing policy if present and merge: explicit existing
|
|
571
|
+
// value wins over profile default. Operator opts out with --force
|
|
572
|
+
// (existing flag — bypass the file-existence check entirely).
|
|
573
|
+
// Profile-switch case: when the existing profile name disagrees with
|
|
574
|
+
// the requested profile, the existing values are STILL preserved by
|
|
575
|
+
// default but a stderr advisory names what was kept; operator can
|
|
576
|
+
// pass --force to fully reset.
|
|
577
|
+
const existingPolicy = readExistingPolicyForPreservation(targetDir);
|
|
483
578
|
let config;
|
|
484
579
|
if (options.yes === true) {
|
|
485
580
|
// G11.4 non-interactive codex resolution:
|
|
486
581
|
// 1. Explicit --codex / --no-codex flag wins.
|
|
487
|
-
// 2. Otherwise
|
|
582
|
+
// 2. Otherwise existing policy value wins (preserves operator edit).
|
|
583
|
+
// 3. Otherwise derive from the profile name (`*-no-codex` → false).
|
|
488
584
|
const codexRequired = options.codex !== undefined
|
|
489
585
|
? options.codex
|
|
490
|
-
: profileDefaultCodexRequired(profileName);
|
|
586
|
+
: (existingPolicy?.codexRequired ?? profileDefaultCodexRequired(profileName));
|
|
491
587
|
config = {
|
|
492
588
|
profile: profileName,
|
|
493
|
-
autonomyLevel:
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
589
|
+
autonomyLevel: existingPolicy?.autonomyLevel
|
|
590
|
+
?? layeredBase.autonomy_level
|
|
591
|
+
?? AutonomyLevel.L1,
|
|
592
|
+
maxAutonomyLevel: existingPolicy?.maxAutonomyLevel
|
|
593
|
+
?? layeredBase.max_autonomy_level
|
|
594
|
+
?? AutonomyLevel.L2,
|
|
595
|
+
blockAiAttribution: existingPolicy?.blockAiAttribution
|
|
596
|
+
?? layeredBase.block_ai_attribution
|
|
597
|
+
?? true,
|
|
598
|
+
blockedPaths: existingPolicy?.blockedPaths
|
|
599
|
+
?? layeredBase.blocked_paths
|
|
600
|
+
?? ['.env', '.env.*'],
|
|
601
|
+
notificationChannel: existingPolicy?.notificationChannel
|
|
602
|
+
?? layeredBase.notification_channel
|
|
603
|
+
?? '',
|
|
498
604
|
codexRequired,
|
|
499
605
|
fromReagent,
|
|
500
606
|
reagentPolicyPath,
|
|
501
607
|
reagentNotices,
|
|
502
608
|
};
|
|
503
|
-
|
|
609
|
+
if (existingPolicy !== undefined) {
|
|
610
|
+
log(`Non-interactive init (re-run): preserving existing autonomy=${config.autonomyLevel}, max=${config.maxAutonomyLevel}, attribution-block=${config.blockAiAttribution}, codex_required=${config.codexRequired}. Pass --force to reset to profile defaults.`);
|
|
611
|
+
}
|
|
612
|
+
else {
|
|
613
|
+
log(`Non-interactive init: profile=${profileName}, autonomy=${config.autonomyLevel}, max=${config.maxAutonomyLevel}, attribution-block=${config.blockAiAttribution}, codex_required=${config.codexRequired}`);
|
|
614
|
+
}
|
|
504
615
|
}
|
|
505
616
|
else {
|
|
506
|
-
config = await runWizard(options, targetDir, reagentPolicyPath, layeredBase);
|
|
617
|
+
config = await runWizard(options, targetDir, reagentPolicyPath, layeredBase, existingPolicy);
|
|
507
618
|
config.reagentNotices = reagentNotices;
|
|
508
619
|
}
|
|
509
620
|
if (!fs.existsSync(reaDir))
|
|
@@ -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)",
|