@bookedsolid/rea 0.17.0 → 0.19.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/.husky/commit-msg +10 -2
- package/agents/codex-adversarial.md +7 -2
- package/commands/codex-review.md +8 -4
- package/dist/cli/init.js +17 -0
- package/dist/cli/upgrade.js +16 -1
- package/dist/hooks/push-gate/codex-runner.js +18 -7
- package/dist/hooks/push-gate/index.js +80 -1
- package/dist/hooks/push-gate/policy.d.ts +17 -0
- package/dist/hooks/push-gate/policy.js +13 -0
- package/dist/hooks/push-gate/verdict-cache.d.ts +98 -0
- package/dist/hooks/push-gate/verdict-cache.js +190 -0
- package/dist/policy/loader.d.ts +21 -4
- package/dist/policy/loader.js +17 -4
- package/dist/policy/profiles.d.ts +34 -0
- package/dist/policy/profiles.js +15 -0
- package/dist/policy/types.d.ts +11 -0
- package/hooks/_lib/cmd-segments.sh +144 -20
- package/hooks/_lib/policy-read.sh +91 -3
- package/hooks/_lib/protected-paths.sh +78 -7
- package/hooks/attribution-advisory.sh +28 -3
- package/hooks/security-disclosure-gate.sh +17 -0
- package/package.json +1 -1
- package/profiles/bst-internal.yaml +8 -0
- package/scripts/postinstall.mjs +39 -1
package/dist/policy/loader.d.ts
CHANGED
|
@@ -48,10 +48,14 @@ declare const PolicySchema: z.ZodObject<{
|
|
|
48
48
|
*/
|
|
49
49
|
auto_narrow_threshold: z.ZodOptional<z.ZodNumber>;
|
|
50
50
|
/**
|
|
51
|
-
* Codex CLI model override (0.13.4
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
51
|
+
* Codex CLI model override (0.13.4+; runtime-default since 0.18.0).
|
|
52
|
+
* Pinned via `-c model="<name>"` on every `codex exec review`
|
|
53
|
+
* invocation. **0.18.0 iron-gate runtime default**: when unset, the
|
|
54
|
+
* runtime hardcodes `gpt-5.4` — codex's own default
|
|
55
|
+
* (`codex-auto-review` at medium) is no longer reachable through the
|
|
56
|
+
* rea push-gate. To select a different model, set this key
|
|
57
|
+
* explicitly. config.toml is consulted ONLY when the explicit value
|
|
58
|
+
* passed by rea is `undefined`, which the runtime never does.
|
|
55
59
|
*
|
|
56
60
|
* For serious adversarial review on consumer codebases (where verdict
|
|
57
61
|
* stability matters) the recommended setting is `gpt-5.4` with
|
|
@@ -77,6 +81,15 @@ declare const PolicySchema: z.ZodObject<{
|
|
|
77
81
|
* matters less than throughput.
|
|
78
82
|
*/
|
|
79
83
|
codex_reasoning_effort: z.ZodOptional<z.ZodEnum<["low", "medium", "high"]>>;
|
|
84
|
+
/**
|
|
85
|
+
* Verdict cache TTL in milliseconds (0.18.1+ helixir #1, #4, #7, #8).
|
|
86
|
+
* Default 86_400_000 (24 hours). When a push of `head_sha` produces
|
|
87
|
+
* a non-blocking verdict, the result is written to
|
|
88
|
+
* `.rea/last-review.cache.json`. Subsequent pushes of the same SHA
|
|
89
|
+
* within the TTL skip the codex invocation and reuse the cached
|
|
90
|
+
* verdict. Set to 0 to disable caching (every push re-invokes codex).
|
|
91
|
+
*/
|
|
92
|
+
cache_ttl_ms: z.ZodOptional<z.ZodNumber>;
|
|
80
93
|
}, "strict", z.ZodTypeAny, {
|
|
81
94
|
codex_required?: boolean | undefined;
|
|
82
95
|
concerns_blocks?: boolean | undefined;
|
|
@@ -85,6 +98,7 @@ declare const PolicySchema: z.ZodObject<{
|
|
|
85
98
|
auto_narrow_threshold?: number | undefined;
|
|
86
99
|
codex_model?: string | undefined;
|
|
87
100
|
codex_reasoning_effort?: "low" | "medium" | "high" | undefined;
|
|
101
|
+
cache_ttl_ms?: number | undefined;
|
|
88
102
|
}, {
|
|
89
103
|
codex_required?: boolean | undefined;
|
|
90
104
|
concerns_blocks?: boolean | undefined;
|
|
@@ -93,6 +107,7 @@ declare const PolicySchema: z.ZodObject<{
|
|
|
93
107
|
auto_narrow_threshold?: number | undefined;
|
|
94
108
|
codex_model?: string | undefined;
|
|
95
109
|
codex_reasoning_effort?: "low" | "medium" | "high" | undefined;
|
|
110
|
+
cache_ttl_ms?: number | undefined;
|
|
96
111
|
}>>;
|
|
97
112
|
redact: z.ZodOptional<z.ZodObject<{
|
|
98
113
|
match_timeout_ms: z.ZodOptional<z.ZodNumber>;
|
|
@@ -192,6 +207,7 @@ declare const PolicySchema: z.ZodObject<{
|
|
|
192
207
|
auto_narrow_threshold?: number | undefined;
|
|
193
208
|
codex_model?: string | undefined;
|
|
194
209
|
codex_reasoning_effort?: "low" | "medium" | "high" | undefined;
|
|
210
|
+
cache_ttl_ms?: number | undefined;
|
|
195
211
|
} | undefined;
|
|
196
212
|
redact?: {
|
|
197
213
|
match_timeout_ms?: number | undefined;
|
|
@@ -241,6 +257,7 @@ declare const PolicySchema: z.ZodObject<{
|
|
|
241
257
|
auto_narrow_threshold?: number | undefined;
|
|
242
258
|
codex_model?: string | undefined;
|
|
243
259
|
codex_reasoning_effort?: "low" | "medium" | "high" | undefined;
|
|
260
|
+
cache_ttl_ms?: number | undefined;
|
|
244
261
|
} | undefined;
|
|
245
262
|
redact?: {
|
|
246
263
|
match_timeout_ms?: number | undefined;
|
package/dist/policy/loader.js
CHANGED
|
@@ -39,10 +39,14 @@ const ReviewPolicySchema = z
|
|
|
39
39
|
*/
|
|
40
40
|
auto_narrow_threshold: z.number().int().nonnegative().optional(),
|
|
41
41
|
/**
|
|
42
|
-
* Codex CLI model override (0.13.4
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
42
|
+
* Codex CLI model override (0.13.4+; runtime-default since 0.18.0).
|
|
43
|
+
* Pinned via `-c model="<name>"` on every `codex exec review`
|
|
44
|
+
* invocation. **0.18.0 iron-gate runtime default**: when unset, the
|
|
45
|
+
* runtime hardcodes `gpt-5.4` — codex's own default
|
|
46
|
+
* (`codex-auto-review` at medium) is no longer reachable through the
|
|
47
|
+
* rea push-gate. To select a different model, set this key
|
|
48
|
+
* explicitly. config.toml is consulted ONLY when the explicit value
|
|
49
|
+
* passed by rea is `undefined`, which the runtime never does.
|
|
46
50
|
*
|
|
47
51
|
* For serious adversarial review on consumer codebases (where verdict
|
|
48
52
|
* stability matters) the recommended setting is `gpt-5.4` with
|
|
@@ -68,6 +72,15 @@ const ReviewPolicySchema = z
|
|
|
68
72
|
* matters less than throughput.
|
|
69
73
|
*/
|
|
70
74
|
codex_reasoning_effort: z.enum(['low', 'medium', 'high']).optional(),
|
|
75
|
+
/**
|
|
76
|
+
* Verdict cache TTL in milliseconds (0.18.1+ helixir #1, #4, #7, #8).
|
|
77
|
+
* Default 86_400_000 (24 hours). When a push of `head_sha` produces
|
|
78
|
+
* a non-blocking verdict, the result is written to
|
|
79
|
+
* `.rea/last-review.cache.json`. Subsequent pushes of the same SHA
|
|
80
|
+
* within the TTL skip the codex invocation and reuse the cached
|
|
81
|
+
* verdict. Set to 0 to disable caching (every push re-invokes codex).
|
|
82
|
+
*/
|
|
83
|
+
cache_ttl_ms: z.number().int().nonnegative().optional(),
|
|
71
84
|
})
|
|
72
85
|
.strict();
|
|
73
86
|
/**
|
|
@@ -47,6 +47,28 @@ export declare const ProfileSchema: z.ZodObject<{
|
|
|
47
47
|
delegate_to_subagent?: string[] | undefined;
|
|
48
48
|
max_bash_output_lines?: number | undefined;
|
|
49
49
|
}>>;
|
|
50
|
+
audit: z.ZodOptional<z.ZodObject<{
|
|
51
|
+
rotation: z.ZodOptional<z.ZodObject<{
|
|
52
|
+
max_bytes: z.ZodOptional<z.ZodNumber>;
|
|
53
|
+
max_age_days: z.ZodOptional<z.ZodNumber>;
|
|
54
|
+
}, "strip", z.ZodTypeAny, {
|
|
55
|
+
max_bytes?: number | undefined;
|
|
56
|
+
max_age_days?: number | undefined;
|
|
57
|
+
}, {
|
|
58
|
+
max_bytes?: number | undefined;
|
|
59
|
+
max_age_days?: number | undefined;
|
|
60
|
+
}>>;
|
|
61
|
+
}, "strip", z.ZodTypeAny, {
|
|
62
|
+
rotation?: {
|
|
63
|
+
max_bytes?: number | undefined;
|
|
64
|
+
max_age_days?: number | undefined;
|
|
65
|
+
} | undefined;
|
|
66
|
+
}, {
|
|
67
|
+
rotation?: {
|
|
68
|
+
max_bytes?: number | undefined;
|
|
69
|
+
max_age_days?: number | undefined;
|
|
70
|
+
} | undefined;
|
|
71
|
+
}>>;
|
|
50
72
|
}, "strict", z.ZodTypeAny, {
|
|
51
73
|
autonomy_level?: AutonomyLevel | undefined;
|
|
52
74
|
max_autonomy_level?: AutonomyLevel | undefined;
|
|
@@ -64,6 +86,12 @@ export declare const ProfileSchema: z.ZodObject<{
|
|
|
64
86
|
delegate_to_subagent?: string[] | undefined;
|
|
65
87
|
max_bash_output_lines?: number | undefined;
|
|
66
88
|
} | undefined;
|
|
89
|
+
audit?: {
|
|
90
|
+
rotation?: {
|
|
91
|
+
max_bytes?: number | undefined;
|
|
92
|
+
max_age_days?: number | undefined;
|
|
93
|
+
} | undefined;
|
|
94
|
+
} | undefined;
|
|
67
95
|
}, {
|
|
68
96
|
autonomy_level?: AutonomyLevel | undefined;
|
|
69
97
|
max_autonomy_level?: AutonomyLevel | undefined;
|
|
@@ -81,6 +109,12 @@ export declare const ProfileSchema: z.ZodObject<{
|
|
|
81
109
|
delegate_to_subagent?: string[] | undefined;
|
|
82
110
|
max_bash_output_lines?: number | undefined;
|
|
83
111
|
} | undefined;
|
|
112
|
+
audit?: {
|
|
113
|
+
rotation?: {
|
|
114
|
+
max_bytes?: number | undefined;
|
|
115
|
+
max_age_days?: number | undefined;
|
|
116
|
+
} | undefined;
|
|
117
|
+
} | undefined;
|
|
84
118
|
}>;
|
|
85
119
|
export type Profile = z.infer<typeof ProfileSchema>;
|
|
86
120
|
/** Hard defaults applied before any profile or wizard answer. */
|
package/dist/policy/profiles.js
CHANGED
|
@@ -54,6 +54,21 @@ export const ProfileSchema = z
|
|
|
54
54
|
injection_detection: z.enum(['block', 'warn']).optional(),
|
|
55
55
|
injection: InjectionProfileSchema.optional(),
|
|
56
56
|
context_protection: ContextProtectionProfileSchema.optional(),
|
|
57
|
+
// 0.18.1+ helixir #9: profiles can ship audit-rotation defaults.
|
|
58
|
+
// The full audit policy block validates at load time via
|
|
59
|
+
// `AuditPolicySchema` in loader.ts; profiles only need to declare
|
|
60
|
+
// the rotation knob (most consumer profiles will leave this empty
|
|
61
|
+
// — the default 50 MiB / 30 days are sane).
|
|
62
|
+
audit: z
|
|
63
|
+
.object({
|
|
64
|
+
rotation: z
|
|
65
|
+
.object({
|
|
66
|
+
max_bytes: z.number().int().positive().optional(),
|
|
67
|
+
max_age_days: z.number().int().positive().optional(),
|
|
68
|
+
})
|
|
69
|
+
.optional(),
|
|
70
|
+
})
|
|
71
|
+
.optional(),
|
|
57
72
|
})
|
|
58
73
|
.strict();
|
|
59
74
|
/** Hard defaults applied before any profile or wizard answer. */
|
package/dist/policy/types.d.ts
CHANGED
|
@@ -158,6 +158,17 @@ export interface ReviewPolicy {
|
|
|
158
158
|
* throughput.
|
|
159
159
|
*/
|
|
160
160
|
codex_reasoning_effort?: 'low' | 'medium' | 'high';
|
|
161
|
+
/**
|
|
162
|
+
* Verdict cache TTL in milliseconds (0.18.1+ helixir #1, #4, #7, #8).
|
|
163
|
+
* Default 86_400_000 (24 hours). When a push of `head_sha` produces a
|
|
164
|
+
* non-blocking verdict, the result is written to
|
|
165
|
+
* `.rea/last-review.cache.json`. Subsequent pushes of the same SHA
|
|
166
|
+
* within the TTL skip the codex invocation and reuse the cached
|
|
167
|
+
* verdict. Set to `0` to disable caching (every push re-invokes
|
|
168
|
+
* codex — pre-0.18.1 behavior). Verdict flips on the same SHA emit
|
|
169
|
+
* a `rea.push_gate.verdict_flip` audit event and overwrite the cache.
|
|
170
|
+
*/
|
|
171
|
+
cache_ttl_ms?: number;
|
|
161
172
|
}
|
|
162
173
|
/**
|
|
163
174
|
* User-supplied redaction pattern entry. Each pattern has a stable `name` used
|
|
@@ -73,6 +73,20 @@
|
|
|
73
73
|
# escapes (per POSIX). Multiple wrappers per command-line are handled
|
|
74
74
|
# (e.g. `foo; bash -c 'bar' && sh -c 'baz'` emits both `bar` and `baz`).
|
|
75
75
|
#
|
|
76
|
+
# 0.18.0 helix-020 G1.A fix: the unwrap pass scans a QUOTE-MASKED form
|
|
77
|
+
# of the input, not the raw input. Pre-fix, a quoted argument that
|
|
78
|
+
# MENTIONED a wrapper (e.g. `git commit -m "docs: mention bash -c 'npm
|
|
79
|
+
# install left-pad'"`) would emit a phantom inner-payload segment, and
|
|
80
|
+
# `dependency-audit-gate.sh` would block the innocent commit. The
|
|
81
|
+
# quote-mask layer (the same one `_rea_split_segments` uses) replaces
|
|
82
|
+
# all in-quote separators AND in-quote single/double quote characters
|
|
83
|
+
# with multi-byte sentinels — so the wrapper regex can no longer match
|
|
84
|
+
# inside an outer quoted span. The unwrapped payload itself is still
|
|
85
|
+
# emitted from the un-masked input by recomputing offsets back to the
|
|
86
|
+
# raw string, so escape semantics inside legitimate wrappers stay
|
|
87
|
+
# correct. We only need the mask to suppress matching; the captured
|
|
88
|
+
# payload is read off the original string.
|
|
89
|
+
#
|
|
76
90
|
# Limitation: ONE level of unwrapping. A wrapper inside a wrapper
|
|
77
91
|
# (`bash -c "bash -c 'innermost'"`) emits only the second-level payload
|
|
78
92
|
# (`bash -c 'innermost'`), not the third-level. This is enough for
|
|
@@ -81,32 +95,130 @@
|
|
|
81
95
|
_rea_unwrap_nested_shells() {
|
|
82
96
|
local cmd="$1"
|
|
83
97
|
printf '%s\n' "$cmd"
|
|
84
|
-
|
|
98
|
+
# Build a mask where in-quote `"` `'` `;` `&` `|` characters are
|
|
99
|
+
# replaced with multi-byte sentinels so the wrapper regex below
|
|
100
|
+
# cannot match wrapper syntax that lives inside outer quoted prose.
|
|
101
|
+
# We also mask the in-quote QUOTE characters themselves so the awk
|
|
102
|
+
# body's quote-state heuristic (which looks at the byte immediately
|
|
103
|
+
# after the matched wrapper-prefix region) cannot mistake an inner
|
|
104
|
+
# quote for a payload-opening quote. Sentinel bytes are aligned to
|
|
105
|
+
# be the same width as their original character (single-byte) so
|
|
106
|
+
# offsets into the raw string remain valid for payload extraction.
|
|
107
|
+
#
|
|
108
|
+
# Approach: rather than synthesize a per-byte sentinel of width 1,
|
|
109
|
+
# we run the awk wrapper-scan against a SEPARATE masked stream and
|
|
110
|
+
# then translate matched RSTART/RLENGTH offsets back to the original
|
|
111
|
+
# string. We do that by passing both strings into awk (raw via stdin,
|
|
112
|
+
# masked via -v MASKED) and tracking the same index across both —
|
|
113
|
+
# since the mask substitutes single bytes with single bytes only
|
|
114
|
+
# (placeholder bytes drawn from the C0 control-character range) the
|
|
115
|
+
# offsets line up.
|
|
116
|
+
#
|
|
117
|
+
# Placeholder bytes — chosen from the C0 control range so they
|
|
118
|
+
# cannot appear in real shell input under UTF-8 (NUL, BEL, VT, FF
|
|
119
|
+
# are reserved by some shells; we use SOH/STX/ETX/ENQ/ACK which are
|
|
120
|
+
# not assigned operational meaning by any shell we ship with).
|
|
121
|
+
# \x01 SOH — replaces in-quote `"`
|
|
122
|
+
# \x02 STX — replaces in-quote `'`
|
|
123
|
+
# \x03 ETX — replaces in-quote `;`
|
|
124
|
+
# \x05 ENQ — replaces in-quote `&`
|
|
125
|
+
# \x06 ACK — replaces in-quote `|`
|
|
126
|
+
local masked
|
|
127
|
+
masked=$(printf '%s' "$cmd" | awk '
|
|
128
|
+
{
|
|
129
|
+
line = $0
|
|
130
|
+
out = ""
|
|
131
|
+
i = 1
|
|
132
|
+
n = length(line)
|
|
133
|
+
mode = 0
|
|
134
|
+
while (i <= n) {
|
|
135
|
+
ch = substr(line, i, 1)
|
|
136
|
+
if (mode == 0) {
|
|
137
|
+
if (ch == "\"") { mode = 1; out = out ch; i++; continue }
|
|
138
|
+
if (ch == "'\''") { mode = 2; out = out ch; i++; continue }
|
|
139
|
+
out = out ch
|
|
140
|
+
i++
|
|
141
|
+
continue
|
|
142
|
+
}
|
|
143
|
+
if (mode == 2) {
|
|
144
|
+
if (ch == "'\''") { mode = 0; out = out "\002"; i++; continue }
|
|
145
|
+
if (ch == ";") { out = out "\003"; i++; continue }
|
|
146
|
+
if (ch == "&") { out = out "\005"; i++; continue }
|
|
147
|
+
if (ch == "|") { out = out "\006"; i++; continue }
|
|
148
|
+
if (ch == "\"") { out = out "\001"; i++; continue }
|
|
149
|
+
out = out ch
|
|
150
|
+
i++
|
|
151
|
+
continue
|
|
152
|
+
}
|
|
153
|
+
# mode == 1 (double-quoted)
|
|
154
|
+
if (ch == "\\" && i < n) {
|
|
155
|
+
# Preserve the escape pair literally — width preserved.
|
|
156
|
+
nxt = substr(line, i + 1, 1)
|
|
157
|
+
out = out ch nxt
|
|
158
|
+
i += 2
|
|
159
|
+
continue
|
|
160
|
+
}
|
|
161
|
+
if (ch == "\"") { mode = 0; out = out "\001"; i++; continue }
|
|
162
|
+
if (ch == ";") { out = out "\003"; i++; continue }
|
|
163
|
+
if (ch == "&") { out = out "\005"; i++; continue }
|
|
164
|
+
if (ch == "|") { out = out "\006"; i++; continue }
|
|
165
|
+
if (ch == "'\''") { out = out "\002"; i++; continue }
|
|
166
|
+
out = out ch
|
|
167
|
+
i++
|
|
168
|
+
}
|
|
169
|
+
printf "%s", out
|
|
170
|
+
}')
|
|
171
|
+
# Pass both raw and masked into awk. Wrapper-regex matches against the
|
|
172
|
+
# masked form; payload extraction reads the raw form using the same
|
|
173
|
+
# offsets. Because the mask is byte-for-byte width-preserving, the
|
|
174
|
+
# same RSTART/RLENGTH applies to both.
|
|
175
|
+
printf '' | awk -v raw="$cmd" -v masked="$masked" '
|
|
85
176
|
BEGIN {
|
|
86
177
|
# Wrapper-prefix regex: shell-name + optional flag tokens + -c-style flag.
|
|
87
178
|
# Each flag token is `-` followed by 1+ letters and trailing space.
|
|
179
|
+
# NOTE: matches only OUTSIDE outer quoted spans because in-quote
|
|
180
|
+
# `"`, `'\''`, `;`, `&`, `|` are masked out in `masked`. The leading
|
|
181
|
+
# alternation `(^|[[:space:]&|;])` therefore cannot anchor on a
|
|
182
|
+
# masked separator, and the shell-name token itself can no longer
|
|
183
|
+
# appear adjacent to a masked quote-introducer.
|
|
88
184
|
WRAP = "(^|[[:space:]&|;])(bash|sh|zsh|dash|ksh)([[:space:]]+-[a-zA-Z]+)*[[:space:]]+-(c|lc|lic|ic|cl|cli|li|il)[[:space:]]+"
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
185
|
+
# Track the cursor in BOTH raw and masked. Because the mask is
|
|
186
|
+
# byte-for-byte width-preserving, the same RSTART/RLENGTH applies
|
|
187
|
+
# to both — but each iteration of the loop must SLICE both strings
|
|
188
|
+
# by the same amount so subsequent matches see synchronized tails.
|
|
189
|
+
mrest = masked
|
|
190
|
+
rrest = raw
|
|
191
|
+
while (length(mrest) > 0) {
|
|
192
|
+
if (! match(mrest, WRAP)) break
|
|
193
|
+
# Tail begins immediately after the matched wrapper prefix in
|
|
194
|
+
# BOTH strings (offsets line up — mask is width-preserving).
|
|
195
|
+
mtail = substr(mrest, RSTART + RLENGTH)
|
|
196
|
+
rtail = substr(rrest, RSTART + RLENGTH)
|
|
197
|
+
# The wrapper-payload-introducing quote must be a REAL outer
|
|
198
|
+
# quote — i.e. not a masked in-quote sentinel. Probe the raw
|
|
199
|
+
# form for the introducer character, which the mask preserved
|
|
200
|
+
# verbatim only when it was an outer quote.
|
|
201
|
+
first = substr(rtail, 1, 1)
|
|
202
|
+
mfirst = substr(mtail, 1, 1)
|
|
203
|
+
if (first == "'\''" && mfirst == "'\''") {
|
|
204
|
+
# Single-quoted body: no escape semantics; runs to next `'\''`.
|
|
205
|
+
body = substr(rtail, 2)
|
|
206
|
+
mbody = substr(mtail, 2)
|
|
100
207
|
end = index(body, "'\''")
|
|
101
|
-
if (end == 0) {
|
|
208
|
+
if (end == 0) {
|
|
209
|
+
mrest = substr(mtail, 2)
|
|
210
|
+
rrest = substr(rtail, 2)
|
|
211
|
+
continue
|
|
212
|
+
}
|
|
102
213
|
payload = substr(body, 1, end - 1)
|
|
103
214
|
print payload
|
|
104
|
-
|
|
215
|
+
mrest = substr(mbody, end + 1)
|
|
216
|
+
rrest = substr(body, end + 1)
|
|
105
217
|
continue
|
|
106
218
|
}
|
|
107
|
-
if (first == "\"") {
|
|
219
|
+
if (first == "\"" && mfirst == "\"") {
|
|
108
220
|
# Double-quoted body: \" and \\ are literal escapes.
|
|
109
|
-
body = substr(
|
|
221
|
+
body = substr(rtail, 2)
|
|
110
222
|
n = length(body)
|
|
111
223
|
j = 1
|
|
112
224
|
out = ""
|
|
@@ -124,15 +236,27 @@ _rea_unwrap_nested_shells() {
|
|
|
124
236
|
out = out c
|
|
125
237
|
j++
|
|
126
238
|
}
|
|
127
|
-
if (closed == 0) {
|
|
239
|
+
if (closed == 0) {
|
|
240
|
+
mrest = substr(mtail, 2)
|
|
241
|
+
rrest = substr(rtail, 2)
|
|
242
|
+
continue
|
|
243
|
+
}
|
|
128
244
|
print out
|
|
129
|
-
|
|
245
|
+
# Skip past the opening `"` (1 byte) AND the closing `"` (1
|
|
246
|
+
# byte at body[closed], i.e. mtail[closed+1]). Cursor lands
|
|
247
|
+
# at mtail[closed+2].
|
|
248
|
+
mrest = substr(mtail, closed + 2)
|
|
249
|
+
rrest = substr(rtail, closed + 2)
|
|
130
250
|
continue
|
|
131
251
|
}
|
|
132
252
|
# Non-quoted argument — proceed past the matched prefix only.
|
|
133
|
-
|
|
253
|
+
mrest = mtail
|
|
254
|
+
rrest = rtail
|
|
134
255
|
}
|
|
135
|
-
}
|
|
256
|
+
}
|
|
257
|
+
# Empty action with no input rules — explicitly drive the loop from
|
|
258
|
+
# END so awk does not require any input records.
|
|
259
|
+
END {}'
|
|
136
260
|
}
|
|
137
261
|
|
|
138
262
|
# Split $1 on shell command separators. Emits one segment per line on
|
|
@@ -53,20 +53,80 @@ policy_bool_true() {
|
|
|
53
53
|
[[ "$value" == "true" ]]
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
# Read a list of scalars from a top-level sequence
|
|
56
|
+
# Read a list of scalars from a top-level sequence.
|
|
57
57
|
# Usage: mapfile -t patterns < <(policy_list "delegate_to_subagent")
|
|
58
|
-
#
|
|
58
|
+
#
|
|
59
|
+
# Recognized YAML forms:
|
|
60
|
+
#
|
|
61
|
+
# 1. Block sequence (the historical / canonical form):
|
|
62
|
+
# blocked_paths:
|
|
63
|
+
# - .env
|
|
64
|
+
# - .env.*
|
|
65
|
+
# - .rea/HALT
|
|
66
|
+
#
|
|
67
|
+
# 2. Empty inline array (since 0.1.x):
|
|
68
|
+
# blocked_paths: [] # → no entries (returns successfully)
|
|
69
|
+
#
|
|
70
|
+
# 3. Non-empty inline array (added 0.18.0 G1.B/G1.C):
|
|
71
|
+
# blocked_paths: [.env, .env.*, .rea/HALT]
|
|
72
|
+
#
|
|
73
|
+
# Inline arrays may span multiple lines:
|
|
74
|
+
#
|
|
75
|
+
# blocked_paths: [
|
|
76
|
+
# .env,
|
|
77
|
+
# .env.*,
|
|
78
|
+
# .rea/HALT
|
|
79
|
+
# ]
|
|
80
|
+
#
|
|
81
|
+
# Quoted entries (single or double quotes) are unquoted. Leading and
|
|
82
|
+
# trailing whitespace on each entry is trimmed. Empty entries (e.g. from
|
|
83
|
+
# a trailing `,`) are skipped silently.
|
|
84
|
+
#
|
|
85
|
+
# Pre-fix (G1.B/G1.C): the inline array form was VALID YAML but parsed
|
|
86
|
+
# to an empty list — silent bypass of `blocked-paths-bash-gate.sh` and
|
|
87
|
+
# silent ignore of `protected_writes` overrides. Fixed by extending the
|
|
88
|
+
# parser to recognize the inline form in addition to the block form.
|
|
89
|
+
#
|
|
90
|
+
# The block form is still preferred (sed-friendly, line-aligned diffs)
|
|
91
|
+
# but the inline form is now equally enforced.
|
|
59
92
|
policy_list() {
|
|
60
93
|
local key="$1"
|
|
61
94
|
local policy
|
|
62
95
|
policy=$(policy_path)
|
|
63
96
|
[[ -z "$policy" ]] && return 0
|
|
64
97
|
local in_block=0
|
|
98
|
+
local in_inline=0
|
|
99
|
+
local inline_buf=""
|
|
65
100
|
while IFS= read -r line; do
|
|
101
|
+
# Skip while we're collecting an inline-array body across lines.
|
|
102
|
+
if [[ $in_inline -eq 1 ]]; then
|
|
103
|
+
inline_buf="${inline_buf} ${line}"
|
|
104
|
+
# Detect the closing `]` (any position on the line).
|
|
105
|
+
if printf '%s' "$line" | grep -qE '\]'; then
|
|
106
|
+
_policy_emit_inline_array "$inline_buf"
|
|
107
|
+
return 0
|
|
108
|
+
fi
|
|
109
|
+
continue
|
|
110
|
+
fi
|
|
66
111
|
if printf '%s' "$line" | grep -qE "^[[:space:]]*${key}:"; then
|
|
67
|
-
|
|
112
|
+
# Empty inline `[]` — explicit empty list.
|
|
113
|
+
if printf '%s' "$line" | grep -qE "${key}:[[:space:]]*\[[[:space:]]*\]"; then
|
|
68
114
|
return 0
|
|
69
115
|
fi
|
|
116
|
+
# Non-empty inline `[ ... ]` — parse the bracketed body. May or
|
|
117
|
+
# may not close on the same line.
|
|
118
|
+
if printf '%s' "$line" | grep -qE "${key}:[[:space:]]*\["; then
|
|
119
|
+
# Strip everything up to and including the opening `[`.
|
|
120
|
+
inline_buf=$(printf '%s' "$line" | sed -E "s/^.*${key}:[[:space:]]*\[//")
|
|
121
|
+
if printf '%s' "$inline_buf" | grep -qE '\]'; then
|
|
122
|
+
# Single-line inline array.
|
|
123
|
+
_policy_emit_inline_array "$inline_buf"
|
|
124
|
+
return 0
|
|
125
|
+
fi
|
|
126
|
+
in_inline=1
|
|
127
|
+
continue
|
|
128
|
+
fi
|
|
129
|
+
# Block-form sequence header — entries follow on subsequent lines.
|
|
70
130
|
in_block=1
|
|
71
131
|
continue
|
|
72
132
|
fi
|
|
@@ -80,3 +140,31 @@ policy_list() {
|
|
|
80
140
|
fi
|
|
81
141
|
done < "$policy"
|
|
82
142
|
}
|
|
143
|
+
|
|
144
|
+
# Emit each entry of an inline-array body (everything between `[` and
|
|
145
|
+
# `]`, possibly across newlines if the caller concatenated lines with
|
|
146
|
+
# spaces). Strips outer brackets, splits on `,`, trims whitespace and
|
|
147
|
+
# matched outer quotes, drops empty entries (trailing-comma tolerance).
|
|
148
|
+
_policy_emit_inline_array() {
|
|
149
|
+
local buf="$1"
|
|
150
|
+
# Drop the closing `]` and anything after it (line comments etc).
|
|
151
|
+
buf=$(printf '%s' "$buf" | sed -E 's/\].*$//')
|
|
152
|
+
# Split on commas.
|
|
153
|
+
local IFS=','
|
|
154
|
+
local raw
|
|
155
|
+
for raw in $buf; do
|
|
156
|
+
# Trim leading + trailing whitespace.
|
|
157
|
+
raw="${raw#"${raw%%[![:space:]]*}"}"
|
|
158
|
+
raw="${raw%"${raw##*[![:space:]]}"}"
|
|
159
|
+
# Drop trailing inline comment (` # comment`).
|
|
160
|
+
raw=$(printf '%s' "$raw" | sed -E 's/[[:space:]]+#.*$//')
|
|
161
|
+
# Re-trim after comment stripping.
|
|
162
|
+
raw="${raw#"${raw%%[![:space:]]*}"}"
|
|
163
|
+
raw="${raw%"${raw##*[![:space:]]}"}"
|
|
164
|
+
# Skip empty entries (trailing comma, blank line in multi-line form).
|
|
165
|
+
[[ -z "$raw" ]] && continue
|
|
166
|
+
# Strip matched outer single or double quotes.
|
|
167
|
+
raw=$(printf '%s' "$raw" | sed -E "s/^[\"']//; s/[\"']$//")
|
|
168
|
+
printf '%s\n' "$raw"
|
|
169
|
+
done
|
|
170
|
+
}
|
|
@@ -58,6 +58,13 @@ REA_KILL_SWITCH_INVARIANTS=(
|
|
|
58
58
|
# first call to `rea_path_is_protected`; stays the same for the lifetime
|
|
59
59
|
# of the hook process.
|
|
60
60
|
REA_PROTECTED_PATTERNS=()
|
|
61
|
+
# 0.18.0 helix-020 G2 fix: track which patterns came from the consumer's
|
|
62
|
+
# explicit `protected_writes` override (vs. the hardcoded default). The
|
|
63
|
+
# override-first ordering in `rea_path_is_protected` checks ONLY this
|
|
64
|
+
# subset before consulting the extension-surface allow-list, so an
|
|
65
|
+
# explicit `protected_writes: [.husky/pre-push.d/]` can re-protect a
|
|
66
|
+
# path that the allow-list would otherwise let through.
|
|
67
|
+
REA_PROTECTED_OVERRIDE_PATTERNS=()
|
|
61
68
|
_REA_PROTECTED_PATTERNS_LOADED=0
|
|
62
69
|
|
|
63
70
|
# True if $1 is a kill-switch invariant (case-insensitive exact or
|
|
@@ -195,6 +202,31 @@ _rea_load_protected_patterns() {
|
|
|
195
202
|
fi
|
|
196
203
|
done
|
|
197
204
|
|
|
205
|
+
# 0.18.0 helix-020 G2: also expose the EXPLICIT-OVERRIDE subset so
|
|
206
|
+
# `rea_path_is_protected` can prioritize override matches over the
|
|
207
|
+
# extension-surface allow-list. Only entries that came from a
|
|
208
|
+
# `protected_writes:` declaration land here — kill-switch invariants
|
|
209
|
+
# added defensively in step 2 above are NOT included (they get the
|
|
210
|
+
# historical "extension surface relaxes them" treatment, since the
|
|
211
|
+
# user did NOT explicitly opt in to protecting husky fragments).
|
|
212
|
+
if [ "$protected_writes_set" = "1" ]; then
|
|
213
|
+
local ow ow_lc rentry_lc2 relaxed2
|
|
214
|
+
for ow in "${writes_list[@]+"${writes_list[@]}"}"; do
|
|
215
|
+
ow_lc=$(printf '%s' "$ow" | tr '[:upper:]' '[:lower:]')
|
|
216
|
+
relaxed2=0
|
|
217
|
+
for rentry in "${relaxed_set[@]+"${relaxed_set[@]}"}"; do
|
|
218
|
+
rentry_lc2=$(printf '%s' "$rentry" | tr '[:upper:]' '[:lower:]')
|
|
219
|
+
if [[ "$ow_lc" == "$rentry_lc2" ]]; then
|
|
220
|
+
relaxed2=1
|
|
221
|
+
break
|
|
222
|
+
fi
|
|
223
|
+
done
|
|
224
|
+
if [ "$relaxed2" = "0" ]; then
|
|
225
|
+
REA_PROTECTED_OVERRIDE_PATTERNS+=("$ow")
|
|
226
|
+
fi
|
|
227
|
+
done
|
|
228
|
+
fi
|
|
229
|
+
|
|
198
230
|
_REA_PROTECTED_PATTERNS_LOADED=1
|
|
199
231
|
}
|
|
200
232
|
|
|
@@ -243,18 +275,57 @@ rea_path_is_extension_surface() {
|
|
|
243
275
|
#
|
|
244
276
|
# 0.16.4 helix-018 Option B: paths inside the documented husky
|
|
245
277
|
# extension surface (`.husky/{commit-msg,pre-push,pre-commit}.d/*`)
|
|
246
|
-
# return 1 (not protected)
|
|
247
|
-
#
|
|
248
|
-
#
|
|
278
|
+
# return 1 (not protected) by default so they don't get caught by
|
|
279
|
+
# `.husky/`'s prefix block. This mirrors the §5b allow-list that has
|
|
280
|
+
# been in settings-protection.sh since 0.13.2.
|
|
281
|
+
#
|
|
282
|
+
# 0.18.0 helix-020 G2 fix: ORDER MATTERS. The pre-fix function checked
|
|
283
|
+
# the extension-surface allow-list FIRST and short-circuited "not
|
|
284
|
+
# protected" unconditionally. That made the `protected_writes` /
|
|
285
|
+
# `protected_paths` override silently ineffective for any path inside
|
|
286
|
+
# the extension surface — a consumer who wanted `.husky/pre-push.d/`
|
|
287
|
+
# hardened could not opt in. The fix: explicit overrides win FIRST
|
|
288
|
+
# (the consumer asked for this), then the extension-surface
|
|
289
|
+
# short-circuit applies to anything else, then the default protected
|
|
290
|
+
# list. Pseudocode is the canonical version from helix-020 Interactive
|
|
291
|
+
# Finding 1.
|
|
249
292
|
rea_path_is_protected() {
|
|
250
293
|
_rea_load_protected_patterns
|
|
251
|
-
# Extension-surface allow-list — short-circuit before pattern match.
|
|
252
|
-
if rea_path_is_extension_surface "$1"; then
|
|
253
|
-
return 1
|
|
254
|
-
fi
|
|
255
294
|
local p_lc
|
|
256
295
|
p_lc=$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')
|
|
257
296
|
local pattern pattern_lc
|
|
297
|
+
|
|
298
|
+
# 1. Explicit `protected_writes` overrides win. If the consumer
|
|
299
|
+
# listed this path (or its parent prefix) in `protected_writes`,
|
|
300
|
+
# we honor that intent even when the path is on the extension
|
|
301
|
+
# surface. This is what lets a consumer harden their managed
|
|
302
|
+
# `.husky/pre-push.d/` fragments — the carve-out for unmanaged
|
|
303
|
+
# consumer fragments is the default, but it can be undone.
|
|
304
|
+
for pattern in "${REA_PROTECTED_OVERRIDE_PATTERNS[@]+"${REA_PROTECTED_OVERRIDE_PATTERNS[@]}"}"; do
|
|
305
|
+
pattern_lc=$(printf '%s' "$pattern" | tr '[:upper:]' '[:lower:]')
|
|
306
|
+
if [[ "$p_lc" == "$pattern_lc" ]]; then
|
|
307
|
+
return 0
|
|
308
|
+
fi
|
|
309
|
+
if [[ "$pattern_lc" == */ ]] && [[ "$p_lc" == "$pattern_lc"* ]]; then
|
|
310
|
+
return 0
|
|
311
|
+
fi
|
|
312
|
+
done
|
|
313
|
+
|
|
314
|
+
# 2. Extension-surface allow-list. Paths inside the documented
|
|
315
|
+
# husky extension surface (`.husky/{commit-msg,pre-push,pre-commit}.d/*`)
|
|
316
|
+
# are NOT protected by default — the consumer manages those
|
|
317
|
+
# fragments freely; settings-protection.sh §5b has the same
|
|
318
|
+
# carve-out on the Write/Edit side. Step 1 above is what lets a
|
|
319
|
+
# consumer override that default per-path.
|
|
320
|
+
if rea_path_is_extension_surface "$1"; then
|
|
321
|
+
return 1
|
|
322
|
+
fi
|
|
323
|
+
|
|
324
|
+
# 3. Default protected list (kill-switch invariants + `.husky/`
|
|
325
|
+
# prefix block + `.claude/settings*` + `.rea/policy.yaml`). When
|
|
326
|
+
# `protected_writes` was set, kill-switch invariants are still
|
|
327
|
+
# enforced via this branch because they were added back into
|
|
328
|
+
# REA_PROTECTED_PATTERNS during `_rea_load_protected_patterns`.
|
|
258
329
|
for pattern in "${REA_PROTECTED_PATTERNS[@]+"${REA_PROTECTED_PATTERNS[@]}"}"; do
|
|
259
330
|
pattern_lc=$(printf '%s' "$pattern" | tr '[:upper:]' '[:lower:]')
|
|
260
331
|
if [[ "$p_lc" == "$pattern_lc" ]]; then
|