@bookedsolid/rea 0.16.4 → 0.17.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 +42 -2
- package/dist/policy/loader.d.ts +3 -0
- package/dist/policy/loader.js +13 -5
- package/dist/policy/profiles.d.ts +3 -0
- package/dist/policy/profiles.js +1 -0
- package/dist/policy/types.d.ts +1 -0
- package/hooks/_lib/cmd-segments.sh +92 -1
- package/hooks/_lib/protected-paths.sh +68 -3
- package/hooks/dangerous-bash-interceptor.sh +15 -2
- package/hooks/dependency-audit-gate.sh +20 -7
- package/hooks/security-disclosure-gate.sh +91 -29
- package/package.json +1 -1
package/dist/cli/init.js
CHANGED
|
@@ -233,10 +233,28 @@ async function printCodexInstallAssist() {
|
|
|
233
233
|
console.log(' Install via the Claude Code Codex plugin helper: `/codex:setup`,');
|
|
234
234
|
console.log(' or set `review.codex_required: false` in .rea/policy.yaml to opt out.');
|
|
235
235
|
}
|
|
236
|
+
function readExistingInstalledAt(policyPath) {
|
|
237
|
+
try {
|
|
238
|
+
if (!fs.existsSync(policyPath))
|
|
239
|
+
return undefined;
|
|
240
|
+
const raw = fs.readFileSync(policyPath, 'utf8');
|
|
241
|
+
const m = raw.match(/^installed_at:\s*"([^"]+)"\s*$/m);
|
|
242
|
+
return m ? m[1] : undefined;
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
return undefined;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
236
248
|
function writePolicyYaml(targetDir, config, layered) {
|
|
237
249
|
const policyPath = path.join(targetDir, REA_DIR, POLICY_FILE);
|
|
238
250
|
const installedBy = process.env.USER ?? os.userInfo().username ?? 'unknown';
|
|
239
|
-
|
|
251
|
+
// 0.17.0 idempotency: preserve the original `installed_at` if a policy
|
|
252
|
+
// already exists. Without this, every `rea init` re-stamps the field
|
|
253
|
+
// and produces a non-idempotent diff. The first install date is the
|
|
254
|
+
// semantically correct value — re-runs reflect refreshes, not new
|
|
255
|
+
// installs. Falls back to `new Date()` only when the file is absent
|
|
256
|
+
// or unparseable.
|
|
257
|
+
const installedAt = readExistingInstalledAt(policyPath) ?? new Date().toISOString();
|
|
240
258
|
const lines = [];
|
|
241
259
|
lines.push(`# .rea/policy.yaml — managed by rea v${getPkgVersion()}`);
|
|
242
260
|
lines.push(`# Edit carefully: tightening takes effect on next load; loosening requires human approval.`);
|
|
@@ -349,14 +367,36 @@ async function writeInstallManifest(targetDir, profile, fragmentInput) {
|
|
|
349
367
|
sha256: sha256OfBuffer(buildFragment(fragmentInput)),
|
|
350
368
|
source: 'claude-md',
|
|
351
369
|
});
|
|
370
|
+
// 0.17.0 idempotency: preserve the original `installed_at` from a
|
|
371
|
+
// prior manifest if present. The first install date is the semantic
|
|
372
|
+
// truth — re-runs reflect refreshes, not new installs.
|
|
373
|
+
const manifestPath = path.join(targetDir, REA_DIR, 'install-manifest.json');
|
|
352
374
|
const manifest = {
|
|
353
375
|
version: getPkgVersion(),
|
|
354
376
|
profile,
|
|
355
|
-
installed_at: new Date().toISOString(),
|
|
377
|
+
installed_at: readExistingManifestInstalledAt(manifestPath) ?? new Date().toISOString(),
|
|
356
378
|
files: entries,
|
|
357
379
|
};
|
|
358
380
|
return writeManifestAtomic(targetDir, manifest);
|
|
359
381
|
}
|
|
382
|
+
function readExistingManifestInstalledAt(manifestPath) {
|
|
383
|
+
try {
|
|
384
|
+
if (!fs.existsSync(manifestPath))
|
|
385
|
+
return undefined;
|
|
386
|
+
const raw = fs.readFileSync(manifestPath, 'utf8');
|
|
387
|
+
const parsed = JSON.parse(raw);
|
|
388
|
+
if (typeof parsed === 'object' &&
|
|
389
|
+
parsed !== null &&
|
|
390
|
+
'installed_at' in parsed &&
|
|
391
|
+
typeof parsed.installed_at === 'string') {
|
|
392
|
+
return parsed.installed_at;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
catch {
|
|
396
|
+
// Fall through — caller stamps a fresh date.
|
|
397
|
+
}
|
|
398
|
+
return undefined;
|
|
399
|
+
}
|
|
360
400
|
export async function runInit(options) {
|
|
361
401
|
const targetDir = process.cwd();
|
|
362
402
|
const reagentPolicyPath = detectReagentPolicy(targetDir);
|
package/dist/policy/loader.d.ts
CHANGED
|
@@ -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_writes: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
|
|
14
15
|
protected_paths_relax: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
|
|
15
16
|
notification_channel: z.ZodDefault<z.ZodString>;
|
|
16
17
|
injection_detection: z.ZodOptional<z.ZodEnum<["block", "warn"]>>;
|
|
@@ -174,6 +175,7 @@ declare const PolicySchema: z.ZodObject<{
|
|
|
174
175
|
blocked_paths: string[];
|
|
175
176
|
protected_paths_relax: string[];
|
|
176
177
|
notification_channel: string;
|
|
178
|
+
protected_writes?: string[] | undefined;
|
|
177
179
|
injection_detection?: "block" | "warn" | undefined;
|
|
178
180
|
injection?: {
|
|
179
181
|
suspicious_blocks_writes?: boolean | undefined;
|
|
@@ -220,6 +222,7 @@ declare const PolicySchema: z.ZodObject<{
|
|
|
220
222
|
promotion_requires_human_approval: boolean;
|
|
221
223
|
blocked_paths: string[];
|
|
222
224
|
block_ai_attribution?: boolean | undefined;
|
|
225
|
+
protected_writes?: string[] | undefined;
|
|
223
226
|
protected_paths_relax?: string[] | undefined;
|
|
224
227
|
notification_channel?: string | undefined;
|
|
225
228
|
injection_detection?: "block" | "warn" | undefined;
|
package/dist/policy/loader.js
CHANGED
|
@@ -160,11 +160,19 @@ 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.
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
//
|
|
167
|
-
//
|
|
163
|
+
// 0.16.5 F9 (helix-018 Option A): full policy-driven definition of
|
|
164
|
+
// the rea-managed write-protection list. When set, fully owns the
|
|
165
|
+
// protected set (kill-switch invariants are always added). When
|
|
166
|
+
// unset, defaults to the 5 historical patterns. Consumers who want
|
|
167
|
+
// to ADD a path (e.g. `.github/workflows/`) or remove non-invariant
|
|
168
|
+
// entries (e.g. `.husky/`) declare the full list here.
|
|
169
|
+
protected_writes: z.array(z.string()).optional(),
|
|
170
|
+
// 0.16.3 F7: opt-in subtractor. Removes entries from whatever the
|
|
171
|
+
// effective protected set is (default OR `protected_writes`).
|
|
172
|
+
// Kill-switch invariants (`.rea/HALT`, `.rea/policy.yaml`,
|
|
173
|
+
// `.claude/settings.json`) are silently dropped from the relax
|
|
174
|
+
// list — see hooks/_lib/protected-paths.sh. Both keys can coexist;
|
|
175
|
+
// `protected_paths_relax` runs AFTER `protected_writes`.
|
|
168
176
|
protected_paths_relax: z.array(z.string()).default([]),
|
|
169
177
|
notification_channel: z.string().default(''),
|
|
170
178
|
injection_detection: z.enum(['block', 'warn']).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_writes: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
|
|
29
30
|
protected_paths_relax: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
|
|
30
31
|
notification_channel: z.ZodOptional<z.ZodString>;
|
|
31
32
|
injection_detection: z.ZodOptional<z.ZodEnum<["block", "warn"]>>;
|
|
@@ -52,6 +53,7 @@ export declare const ProfileSchema: z.ZodObject<{
|
|
|
52
53
|
promotion_requires_human_approval?: boolean | undefined;
|
|
53
54
|
block_ai_attribution?: boolean | undefined;
|
|
54
55
|
blocked_paths?: string[] | undefined;
|
|
56
|
+
protected_writes?: string[] | undefined;
|
|
55
57
|
protected_paths_relax?: string[] | undefined;
|
|
56
58
|
notification_channel?: string | undefined;
|
|
57
59
|
injection_detection?: "block" | "warn" | undefined;
|
|
@@ -68,6 +70,7 @@ export declare const ProfileSchema: z.ZodObject<{
|
|
|
68
70
|
promotion_requires_human_approval?: boolean | undefined;
|
|
69
71
|
block_ai_attribution?: boolean | undefined;
|
|
70
72
|
blocked_paths?: string[] | undefined;
|
|
73
|
+
protected_writes?: string[] | undefined;
|
|
71
74
|
protected_paths_relax?: string[] | undefined;
|
|
72
75
|
notification_channel?: string | undefined;
|
|
73
76
|
injection_detection?: "block" | "warn" | undefined;
|
package/dist/policy/profiles.js
CHANGED
|
@@ -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_writes: z.array(z.string()).optional(),
|
|
51
52
|
protected_paths_relax: z.array(z.string()).optional(),
|
|
52
53
|
notification_channel: z.string().optional(),
|
|
53
54
|
injection_detection: z.enum(['block', 'warn']).optional(),
|
package/dist/policy/types.d.ts
CHANGED
|
@@ -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_writes?: string[];
|
|
271
272
|
protected_paths_relax: string[];
|
|
272
273
|
notification_channel: string;
|
|
273
274
|
injection_detection?: 'block' | 'warn';
|
|
@@ -51,6 +51,90 @@
|
|
|
51
51
|
# do NOT honor `\` escapes; double-quoted spans treat `\"` as a literal
|
|
52
52
|
# `"` and skip past it.
|
|
53
53
|
|
|
54
|
+
# Unwrap nested shell wrappers — `bash -c 'PAYLOAD'`, `sh -lc "PAYLOAD"`,
|
|
55
|
+
# `zsh -ic 'PAYLOAD'`, etc. Emits the input string AS-IS plus each inner
|
|
56
|
+
# PAYLOAD as a separate line. Pre-0.17.0 the splitter never parsed
|
|
57
|
+
# inside wrapped quotes, so `bash -c 'git push --force'` produced a
|
|
58
|
+
# single segment whose first token was `bash` — defeating every check
|
|
59
|
+
# that uses `any_segment_starts_with`. This helper makes the inner
|
|
60
|
+
# payload visible as its own segment, so every existing detection rule
|
|
61
|
+
# fires uniformly on wrapped and unwrapped commands.
|
|
62
|
+
#
|
|
63
|
+
# Closes helix-017 #1, #2, #3 (0.16.2):
|
|
64
|
+
# - `bash -lc 'git push --force origin HEAD'` → payload now seen by H1
|
|
65
|
+
# - `bash -c 'printf x > .rea/HALT'` → payload now seen by bash-gate
|
|
66
|
+
# - `bash -lc 'npm install some-package'` → payload now seen by audit-gate
|
|
67
|
+
#
|
|
68
|
+
# Recognized wrapper shape (case-insensitive shell name):
|
|
69
|
+
# (bash|sh|zsh|dash|ksh) [optional -flags...] (-c|-lc|-lic|-ic|-cl|-cli) (QUOTED_ARG)
|
|
70
|
+
#
|
|
71
|
+
# QUOTED_ARG can be single- or double-quoted. Single-quote bodies have no
|
|
72
|
+
# escape semantics. Double-quote bodies treat \" and \\ as literal
|
|
73
|
+
# escapes (per POSIX). Multiple wrappers per command-line are handled
|
|
74
|
+
# (e.g. `foo; bash -c 'bar' && sh -c 'baz'` emits both `bar` and `baz`).
|
|
75
|
+
#
|
|
76
|
+
# Limitation: ONE level of unwrapping. A wrapper inside a wrapper
|
|
77
|
+
# (`bash -c "bash -c 'innermost'"`) emits only the second-level payload
|
|
78
|
+
# (`bash -c 'innermost'`), not the third-level. This is enough for
|
|
79
|
+
# every consumer-reported bypass; deeper nesting can be added later
|
|
80
|
+
# without changing the contract.
|
|
81
|
+
_rea_unwrap_nested_shells() {
|
|
82
|
+
local cmd="$1"
|
|
83
|
+
printf '%s\n' "$cmd"
|
|
84
|
+
printf '%s' "$cmd" | awk '
|
|
85
|
+
BEGIN {
|
|
86
|
+
# Wrapper-prefix regex: shell-name + optional flag tokens + -c-style flag.
|
|
87
|
+
# Each flag token is `-` followed by 1+ letters and trailing space.
|
|
88
|
+
WRAP = "(^|[[:space:]&|;])(bash|sh|zsh|dash|ksh)([[:space:]]+-[a-zA-Z]+)*[[:space:]]+-(c|lc|lic|ic|cl|cli|li|il)[[:space:]]+"
|
|
89
|
+
}
|
|
90
|
+
{
|
|
91
|
+
rest = $0
|
|
92
|
+
while (length(rest) > 0) {
|
|
93
|
+
if (! match(rest, WRAP)) break
|
|
94
|
+
# Tail begins immediately after the matched wrapper prefix.
|
|
95
|
+
tail = substr(rest, RSTART + RLENGTH)
|
|
96
|
+
first = substr(tail, 1, 1)
|
|
97
|
+
if (first == "'\''") {
|
|
98
|
+
# Single-quoted body: no escape semantics; runs to next `'"'"'`.
|
|
99
|
+
body = substr(tail, 2)
|
|
100
|
+
end = index(body, "'\''")
|
|
101
|
+
if (end == 0) { rest = substr(tail, 2); continue }
|
|
102
|
+
payload = substr(body, 1, end - 1)
|
|
103
|
+
print payload
|
|
104
|
+
rest = substr(body, end + 1)
|
|
105
|
+
continue
|
|
106
|
+
}
|
|
107
|
+
if (first == "\"") {
|
|
108
|
+
# Double-quoted body: \" and \\ are literal escapes.
|
|
109
|
+
body = substr(tail, 2)
|
|
110
|
+
n = length(body)
|
|
111
|
+
j = 1
|
|
112
|
+
out = ""
|
|
113
|
+
closed = 0
|
|
114
|
+
while (j <= n) {
|
|
115
|
+
c = substr(body, j, 1)
|
|
116
|
+
if (c == "\\" && j < n) {
|
|
117
|
+
nxt = substr(body, j + 1, 1)
|
|
118
|
+
if (nxt == "\"" || nxt == "\\") { out = out nxt; j += 2; continue }
|
|
119
|
+
out = out c nxt
|
|
120
|
+
j += 2
|
|
121
|
+
continue
|
|
122
|
+
}
|
|
123
|
+
if (c == "\"") { closed = j; break }
|
|
124
|
+
out = out c
|
|
125
|
+
j++
|
|
126
|
+
}
|
|
127
|
+
if (closed == 0) { rest = substr(tail, 2); continue }
|
|
128
|
+
print out
|
|
129
|
+
rest = substr(body, closed + 1)
|
|
130
|
+
continue
|
|
131
|
+
}
|
|
132
|
+
# Non-quoted argument — proceed past the matched prefix only.
|
|
133
|
+
rest = tail
|
|
134
|
+
}
|
|
135
|
+
}'
|
|
136
|
+
}
|
|
137
|
+
|
|
54
138
|
# Split $1 on shell command separators. Emits one segment per line on
|
|
55
139
|
# stdout (empty segments preserved). Used by both higher-level helpers
|
|
56
140
|
# below; not generally called by hooks directly.
|
|
@@ -103,7 +187,14 @@ _rea_split_segments() {
|
|
|
103
187
|
# splitting so quoted prose no longer over-splits and anchors trigger
|
|
104
188
|
# words at the head of phantom segments. See header comment for the
|
|
105
189
|
# full rationale.
|
|
106
|
-
|
|
190
|
+
#
|
|
191
|
+
# 0.17.0 helix-017 #1-#3 fix: unwrap `bash -c 'PAYLOAD'` style
|
|
192
|
+
# wrappers BEFORE the quote-mask + split passes. The unwrap step
|
|
193
|
+
# emits the original line plus each inner PAYLOAD as separate
|
|
194
|
+
# records; the existing pipeline then quote-masks and splits each
|
|
195
|
+
# record independently. Inner payload anchors trigger words for the
|
|
196
|
+
# `any_segment_*` checks downstream.
|
|
197
|
+
_rea_unwrap_nested_shells "$cmd" \
|
|
107
198
|
| awk '
|
|
108
199
|
BEGIN {
|
|
109
200
|
SC = "__REA_SEP_SC_a8f2c1__"
|
|
@@ -75,8 +75,16 @@ _rea_is_kill_switch() {
|
|
|
75
75
|
return 1
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
-
# Load the effective list, applying `
|
|
78
|
+
# Load the effective list, applying `protected_writes` (full override
|
|
79
|
+
# from policy) and `protected_paths_relax` (subtractor) from policy.
|
|
79
80
|
# Sources policy-read.sh on demand so this lib stays self-contained.
|
|
81
|
+
#
|
|
82
|
+
# 0.17.0 helix-018 Option A: `protected_writes` lets consumers fully
|
|
83
|
+
# define the protected list. When set, replaces the hardcoded default;
|
|
84
|
+
# kill-switch invariants are always added back regardless. When unset,
|
|
85
|
+
# defaults to REA_PROTECTED_PATTERNS_FULL (the historical 5 patterns).
|
|
86
|
+
# `protected_paths_relax` then subtracts from whatever the effective
|
|
87
|
+
# set is (kill-switch invariants are non-relaxable).
|
|
80
88
|
_rea_load_protected_patterns() {
|
|
81
89
|
if [ "$_REA_PROTECTED_PATTERNS_LOADED" = "1" ]; then
|
|
82
90
|
return 0
|
|
@@ -89,14 +97,71 @@ _rea_load_protected_patterns() {
|
|
|
89
97
|
source "${BASH_SOURCE[0]%/*}/policy-read.sh" 2>/dev/null || true
|
|
90
98
|
fi
|
|
91
99
|
|
|
100
|
+
# Read both policy keys.
|
|
101
|
+
local writes_list=()
|
|
92
102
|
local relax_list=()
|
|
103
|
+
local protected_writes_set=0
|
|
93
104
|
if command -v policy_list >/dev/null 2>&1; then
|
|
105
|
+
# `protected_writes`: detect "set but empty" vs "unset" via a probe.
|
|
106
|
+
# policy_list returns nothing for both cases, so we use a sentinel
|
|
107
|
+
# check on the YAML key existence via a separate probe.
|
|
108
|
+
local pw_present
|
|
109
|
+
pw_present=$(policy_scalar "protected_writes" 2>/dev/null || true)
|
|
110
|
+
# If the key is a list (yq returns "null" or empty for scalar reads
|
|
111
|
+
# of a list), policy_list reads it. We detect "key exists" by
|
|
112
|
+
# checking either policy_scalar's return OR policy_list's output.
|
|
113
|
+
while IFS= read -r entry; do
|
|
114
|
+
[ -z "$entry" ] && continue
|
|
115
|
+
writes_list+=("$entry")
|
|
116
|
+
protected_writes_set=1
|
|
117
|
+
done < <(policy_list "protected_writes" 2>/dev/null || true)
|
|
118
|
+
# If pw_present is "[]" (empty array) — policy_list returns nothing
|
|
119
|
+
# but the key IS set. policy_scalar of a list returns "null" or
|
|
120
|
+
# the literal `[]`. Treat any of those as "set".
|
|
121
|
+
case "$pw_present" in
|
|
122
|
+
'[]'|'null') protected_writes_set=1 ;;
|
|
123
|
+
esac
|
|
124
|
+
|
|
94
125
|
while IFS= read -r entry; do
|
|
95
126
|
[ -z "$entry" ] && continue
|
|
96
127
|
relax_list+=("$entry")
|
|
97
128
|
done < <(policy_list "protected_paths_relax" 2>/dev/null || true)
|
|
98
129
|
fi
|
|
99
130
|
|
|
131
|
+
# Compose the BASE list:
|
|
132
|
+
# - If `protected_writes` set in policy: that list, plus kill-switch
|
|
133
|
+
# invariants always added (deduped).
|
|
134
|
+
# - Else: REA_PROTECTED_PATTERNS_FULL (hardcoded historical default).
|
|
135
|
+
local base_list=()
|
|
136
|
+
if [ "$protected_writes_set" = "1" ]; then
|
|
137
|
+
local w
|
|
138
|
+
for w in "${writes_list[@]+"${writes_list[@]}"}"; do
|
|
139
|
+
base_list+=("$w")
|
|
140
|
+
done
|
|
141
|
+
# Add kill-switch invariants if not already present.
|
|
142
|
+
local inv inv_lc found
|
|
143
|
+
for inv in "${REA_KILL_SWITCH_INVARIANTS[@]}"; do
|
|
144
|
+
inv_lc=$(printf '%s' "$inv" | tr '[:upper:]' '[:lower:]')
|
|
145
|
+
found=0
|
|
146
|
+
local b b_lc
|
|
147
|
+
for b in "${base_list[@]+"${base_list[@]}"}"; do
|
|
148
|
+
b_lc=$(printf '%s' "$b" | tr '[:upper:]' '[:lower:]')
|
|
149
|
+
if [[ "$b_lc" == "$inv_lc" ]]; then
|
|
150
|
+
found=1
|
|
151
|
+
break
|
|
152
|
+
fi
|
|
153
|
+
done
|
|
154
|
+
if [ "$found" = "0" ]; then
|
|
155
|
+
base_list+=("$inv")
|
|
156
|
+
fi
|
|
157
|
+
done
|
|
158
|
+
else
|
|
159
|
+
local pat
|
|
160
|
+
for pat in "${REA_PROTECTED_PATTERNS_FULL[@]}"; do
|
|
161
|
+
base_list+=("$pat")
|
|
162
|
+
done
|
|
163
|
+
fi
|
|
164
|
+
|
|
100
165
|
# Validate relax entries: any kill-switch invariant in the list is
|
|
101
166
|
# silently dropped from "permitted to relax" but emits a stderr
|
|
102
167
|
# advisory so the operator can see why their relax didn't take
|
|
@@ -112,10 +177,10 @@ _rea_load_protected_patterns() {
|
|
|
112
177
|
fi
|
|
113
178
|
done
|
|
114
179
|
|
|
115
|
-
# Build the effective list: every
|
|
180
|
+
# Build the effective list: every BASE entry that is NOT in the
|
|
116
181
|
# relaxed set (case-insensitive comparison).
|
|
117
182
|
local pat pat_lc rentry rentry_lc relaxed
|
|
118
|
-
for pat in "${
|
|
183
|
+
for pat in "${base_list[@]+"${base_list[@]}"}"; do
|
|
119
184
|
pat_lc=$(printf '%s' "$pat" | tr '[:upper:]' '[:lower:]')
|
|
120
185
|
relaxed=0
|
|
121
186
|
for rentry in "${relaxed_set[@]+"${relaxed_set[@]}"}"; do
|
|
@@ -257,8 +257,21 @@ fi
|
|
|
257
257
|
# in-quote pipes are replaced with a sentinel that the regex doesn't
|
|
258
258
|
# match. Real curl-pipe-shell still matches because the pipe between
|
|
259
259
|
# `curl https://x` and `sh` is outside any quote span.
|
|
260
|
-
|
|
261
|
-
|
|
260
|
+
# 0.17.0 helix-017 #1 fix: also scan inner payloads of nested-shell
|
|
261
|
+
# wrappers (`zsh -c "curl https://x | sh"`). The unwrap helper emits
|
|
262
|
+
# the original command + each inner payload as separate lines; we
|
|
263
|
+
# quote-mask each line independently and grep. If ANY emitted line
|
|
264
|
+
# contains a real curl-pipe-shell, fire H12.
|
|
265
|
+
H12_HIT=0
|
|
266
|
+
while IFS= read -r _h12_line; do
|
|
267
|
+
[ -z "$_h12_line" ] && continue
|
|
268
|
+
_h12_masked=$(quote_masked_cmd "$_h12_line")
|
|
269
|
+
if printf '%s' "$_h12_masked" | grep -qiE '(curl|wget)[^|]*\|[[:space:]]*(sudo[[:space:]]+)?(bash|sh|zsh|fish)'; then
|
|
270
|
+
H12_HIT=1
|
|
271
|
+
break
|
|
272
|
+
fi
|
|
273
|
+
done < <(_rea_unwrap_nested_shells "$CMD")
|
|
274
|
+
if [ "$H12_HIT" = "1" ]; then
|
|
262
275
|
add_high \
|
|
263
276
|
"curl/wget piped to shell — remote code execution" \
|
|
264
277
|
"Executing remote scripts without inspection is a major supply chain risk." \
|
|
@@ -58,14 +58,27 @@ extract_packages() {
|
|
|
58
58
|
# outer command — but they're never the FIRST token on a segment, so
|
|
59
59
|
# the anchor rejects them.
|
|
60
60
|
|
|
61
|
-
#
|
|
62
|
-
#
|
|
63
|
-
#
|
|
64
|
-
#
|
|
65
|
-
#
|
|
66
|
-
#
|
|
61
|
+
# 0.17.0 helix-017 #3: unwrap nested-shell wrappers (`bash -c 'PAYLOAD'`,
|
|
62
|
+
# `sh -lc "PAYLOAD"`, etc.) before splitting so the inner install
|
|
63
|
+
# command becomes a segment that anchors against the install-pattern
|
|
64
|
+
# check below. Pre-fix `bash -lc 'npm install pkg'` produced a single
|
|
65
|
+
# segment whose first token was `bash` — install-detection skipped.
|
|
66
|
+
# 0.17.0 helix-019 #3: delegate splitting to the shared
|
|
67
|
+
# `_rea_split_segments` so this gate inherits the full separator set
|
|
68
|
+
# (including bare `&` background-process operator added in 0.16.1)
|
|
69
|
+
# and the quote-mask that prevents over-fire from in-quote separators.
|
|
70
|
+
# Pre-fix the local segmenter splat on `|||&&|;|` only, missing bare
|
|
71
|
+
# `&` — `echo warmup & pnpm add lodash` stayed merged into one segment
|
|
72
|
+
# and the install-pattern leading-token check skipped it entirely.
|
|
67
73
|
local segments
|
|
68
|
-
|
|
74
|
+
if [ -f "$(dirname "$0")/_lib/cmd-segments.sh" ]; then
|
|
75
|
+
# shellcheck source=_lib/cmd-segments.sh
|
|
76
|
+
source "$(dirname "$0")/_lib/cmd-segments.sh"
|
|
77
|
+
segments=$(_rea_split_segments "$cmd")
|
|
78
|
+
else
|
|
79
|
+
# Fallback (lib unavailable): legacy local splitter preserved.
|
|
80
|
+
segments=$(printf '%s\n' "$cmd" | sed -E 's/(\|\||\&\&|;|\||\&)/\n/g')
|
|
81
|
+
fi
|
|
69
82
|
|
|
70
83
|
while IFS= read -r segment; do
|
|
71
84
|
# Trim leading whitespace.
|
|
@@ -118,37 +118,87 @@ REA_ROOT="${CLAUDE_PROJECT_DIR:-$(pwd)}"
|
|
|
118
118
|
BODY_FILE_TEXT=""
|
|
119
119
|
_extract_body_file_paths() {
|
|
120
120
|
# Emit each `--body-file PATH` and `-F PATH` argument on its own line.
|
|
121
|
-
# Skips the stdin form (`-`) and
|
|
122
|
-
#
|
|
121
|
+
# Skips the stdin form (`-`) and emits the path verbatim from the
|
|
122
|
+
# equals-form (`--body-file=PATH` / `-F=PATH`).
|
|
123
|
+
#
|
|
124
|
+
# 0.17.0 helix-019 #2: quote-aware tokenization. The pre-fix awk split
|
|
125
|
+
# on whitespace, breaking `--body-file "security notes.md"` into three
|
|
126
|
+
# tokens — the hook then tried to read `"security` (with literal
|
|
127
|
+
# leading quote), failed, and silently skipped the body scan. Now we
|
|
128
|
+
# walk the string with quote-state awareness: whitespace inside
|
|
129
|
+
# matched `"..."` / `'...'` spans is part of the token, not a
|
|
130
|
+
# separator. Single-quote spans have no escape semantics; double-quote
|
|
131
|
+
# spans treat `\"` and `\\` as literal escapes (POSIX shell rules).
|
|
123
132
|
printf '%s' "$COMMAND" \
|
|
124
133
|
| awk '
|
|
125
|
-
BEGIN { skip_next = 0
|
|
134
|
+
BEGIN { skip_next = 0 }
|
|
135
|
+
function strip_outer_quotes(s, n, first, last) {
|
|
136
|
+
n = length(s)
|
|
137
|
+
if (n < 2) return s
|
|
138
|
+
first = substr(s, 1, 1)
|
|
139
|
+
last = substr(s, n, 1)
|
|
140
|
+
if ((first == "\"" && last == "\"") || (first == "'\''" && last == "'\''")) {
|
|
141
|
+
return substr(s, 2, n - 2)
|
|
142
|
+
}
|
|
143
|
+
return s
|
|
144
|
+
}
|
|
145
|
+
function emit_token(t) {
|
|
146
|
+
if (skip_next) {
|
|
147
|
+
skip_next = 0
|
|
148
|
+
if (t == "-" || t == "") return
|
|
149
|
+
t = strip_outer_quotes(t)
|
|
150
|
+
print t
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
if (t == "--body-file" || t == "-F") { skip_next = 1; return }
|
|
154
|
+
if (t ~ /^--body-file=/) {
|
|
155
|
+
v = substr(t, length("--body-file=") + 1)
|
|
156
|
+
v = strip_outer_quotes(v)
|
|
157
|
+
if (v != "" && v != "-") print v
|
|
158
|
+
}
|
|
159
|
+
if (t ~ /^-F=/) {
|
|
160
|
+
v = substr(t, length("-F=") + 1)
|
|
161
|
+
v = strip_outer_quotes(v)
|
|
162
|
+
if (v != "" && v != "-") print v
|
|
163
|
+
}
|
|
164
|
+
}
|
|
126
165
|
{
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
166
|
+
line = $0
|
|
167
|
+
n = length(line)
|
|
168
|
+
i = 1
|
|
169
|
+
tok = ""
|
|
170
|
+
mode = 0 # 0=plain, 1=double-quoted, 2=single-quoted
|
|
171
|
+
while (i <= n) {
|
|
172
|
+
ch = substr(line, i, 1)
|
|
173
|
+
if (mode == 0) {
|
|
174
|
+
if (ch == " " || ch == "\t") {
|
|
175
|
+
if (tok != "") { emit_token(tok); tok = "" }
|
|
176
|
+
i++; continue
|
|
177
|
+
}
|
|
178
|
+
if (ch == "\"") { mode = 1; tok = tok ch; i++; continue }
|
|
179
|
+
if (ch == "'\''") { mode = 2; tok = tok ch; i++; continue }
|
|
180
|
+
tok = tok ch
|
|
181
|
+
i++
|
|
137
182
|
continue
|
|
138
183
|
}
|
|
139
|
-
if (
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
184
|
+
if (mode == 1) {
|
|
185
|
+
if (ch == "\\" && i < n) {
|
|
186
|
+
nxt = substr(line, i + 1, 1)
|
|
187
|
+
tok = tok ch nxt
|
|
188
|
+
i += 2
|
|
189
|
+
continue
|
|
190
|
+
}
|
|
191
|
+
if (ch == "\"") { mode = 0; tok = tok ch; i++; continue }
|
|
192
|
+
tok = tok ch
|
|
193
|
+
i++
|
|
194
|
+
continue
|
|
150
195
|
}
|
|
196
|
+
# mode == 2
|
|
197
|
+
if (ch == "'\''") { mode = 0; tok = tok ch; i++; continue }
|
|
198
|
+
tok = tok ch
|
|
199
|
+
i++
|
|
151
200
|
}
|
|
201
|
+
if (tok != "") emit_token(tok)
|
|
152
202
|
}'
|
|
153
203
|
}
|
|
154
204
|
while IFS= read -r body_path; do
|
|
@@ -180,12 +230,24 @@ while IFS= read -r body_path; do
|
|
|
180
230
|
esac
|
|
181
231
|
done
|
|
182
232
|
resolved="/$(IFS=/; printf '%s' "${_bf_parts[*]}")"
|
|
183
|
-
#
|
|
184
|
-
#
|
|
185
|
-
#
|
|
233
|
+
# 0.17.0 helix-019 #1: HARD REFUSAL on traversal escaping REA_ROOT.
|
|
234
|
+
# Pre-fix the gate logged "skipping body scan" and exited 0 — every
|
|
235
|
+
# sensitive payload at the resolved external path bypassed the
|
|
236
|
+
# disclosure check. The traversal-out-of-root shape exists ONLY to
|
|
237
|
+
# obfuscate; legitimate workflows pass absolute tmpfile paths
|
|
238
|
+
# (`/tmp/...`, `/var/folders/...`) without `..` segments.
|
|
186
239
|
if [[ "$resolved" != "$REA_ROOT" && "$resolved" != "$REA_ROOT"/* ]]; then
|
|
187
|
-
|
|
188
|
-
|
|
240
|
+
{
|
|
241
|
+
printf 'SECURITY DISCLOSURE GATE: --body-file path traversal escapes project root\n'
|
|
242
|
+
printf '\n'
|
|
243
|
+
printf ' Path: %s\n' "$raw_path"
|
|
244
|
+
printf ' Resolved: %s\n' "$resolved"
|
|
245
|
+
printf '\n'
|
|
246
|
+
printf ' Rule: --body-file paths whose canonical form uses `..` segments to\n'
|
|
247
|
+
printf ' escape REA_ROOT are refused. Move the file inside the project\n'
|
|
248
|
+
printf ' tree, or paste the body inline via --body.\n'
|
|
249
|
+
} >&2
|
|
250
|
+
exit 2
|
|
189
251
|
fi
|
|
190
252
|
fi
|
|
191
253
|
if [[ ! -r "$resolved" ]]; then
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bookedsolid/rea",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.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)",
|