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