@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.
@@ -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+). Pinned via `-c model="<name>"` on
52
- * every `codex exec review` invocation. When unset, codex's own default
53
- * applies which today is the special-purpose `codex-auto-review`
54
- * model at `medium` reasoning, NOT the flagship.
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;
@@ -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+). Pinned via `-c model="<name>"` on
43
- * every `codex exec review` invocation. When unset, codex's own default
44
- * applies which today is the special-purpose `codex-auto-review`
45
- * model at `medium` reasoning, NOT the flagship.
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. */
@@ -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. */
@@ -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
- printf '%s' "$cmd" | awk '
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
- 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)
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) { rest = substr(tail, 2); continue }
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
- rest = substr(body, end + 1)
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(tail, 2)
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) { rest = substr(tail, 2); continue }
239
+ if (closed == 0) {
240
+ mrest = substr(mtail, 2)
241
+ rrest = substr(rtail, 2)
242
+ continue
243
+ }
128
244
  print out
129
- rest = substr(body, closed + 1)
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
- rest = tail
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 block.
56
+ # Read a list of scalars from a top-level sequence.
57
57
  # Usage: mapfile -t patterns < <(policy_list "delegate_to_subagent")
58
- # Handles inline "[]" as empty. Stops at the first non-"-" continuation line.
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
- if printf '%s' "$line" | grep -qE "${key}:[[:space:]]*\[\]"; then
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) BEFORE the prefix-pattern check so they
247
- # don't get caught by `.husky/`'s prefix block. This mirrors the
248
- # §5b allow-list that has been in settings-protection.sh since 0.13.2.
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