@bookedsolid/rea 0.14.0 → 0.16.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.
@@ -36,7 +36,7 @@ You may read additional files in the repo if needed for context, but do so read-
36
36
  5. **Parse the Codex output** — extract structured findings.
37
37
  6. **Classify findings** by category: security, correctness, edge cases, test gaps, API design, performance.
38
38
  7. **Assign verdict**: `pass` (no material findings), `concerns` (findings worth addressing but not blocking), `blocking` (findings that must be fixed before merge).
39
- 8. **Emit an audit entry** (optional in 0.11.0+) the pre-push gate does not consult audit records to decide pass/fail, so you are no longer REQUIRED to emit a `codex.review` record on every interactive review. However, append one anyway via the public `@bookedsolid/rea/audit` helper when it helps forensic traceability (investigation of an intermittent verdict, review-history audit, etc.):
39
+ 8. **Emit an audit entry — REQUIRED** for every `/codex-review` invocation. The pre-push gate does not consult audit records to decide pass/fail (post-0.11.0 the gate is stateless), but the `/codex-review` slash command's Step 3 verifies an audit entry was appended for this run and surfaces "review never happened" to the user when one is missing. The two specs are a contract pair audit emission is what tells the operator their interactive review actually completed. Append via the public `@bookedsolid/rea/audit` helper:
40
40
 
41
41
  ```ts
42
42
  import { appendAuditRecord, CODEX_REVIEW_TOOL_NAME, CODEX_REVIEW_SERVER_NAME, Tier, InvocationStatus } from '@bookedsolid/rea/audit';
@@ -7,6 +7,7 @@ allowed-tools:
7
7
  - Bash(git branch:*)
8
8
  - Bash(git rev-parse:*)
9
9
  - Read
10
+ - Agent
10
11
  ---
11
12
 
12
13
  # /codex-review — Adversarial Review via Codex
@@ -54,23 +55,17 @@ Invoke the `codex-adversarial` agent with:
54
55
 
55
56
  The agent wraps `/codex:adversarial-review` and returns structured findings.
56
57
 
57
- ## Step 3 — Record to audit log
58
+ ## Step 3 — (Optional) verify audit entry
58
59
 
59
- Every Codex invocation produces an audit entry. The `codex-adversarial` agent writes it via the middleware chain automatically, but verify the entry was recorded:
60
+ Audit emission is **optional** in 0.11.0+. The pre-push gate is stateless and does not consult audit records to decide pass/fail; the agent's structured findings ARE the review. The agent will append an audit entry when it helps forensic traceability (intermittent verdicts, review-history audits) but its absence is not a failure.
61
+
62
+ If you want to confirm an entry was written for this run:
60
63
 
61
64
  ```bash
62
65
  tail -n 1 .rea/audit.jsonl
63
66
  ```
64
67
 
65
- The entry must include:
66
-
67
- - `tool: "codex-adversarial-review"`
68
- - `head_sha: <SHA>`
69
- - `target: <ref>`
70
- - `finding_count: <N>`
71
- - `verdict: pass | concerns | blocking`
72
-
73
- If the audit entry is missing, report it clearly — do not proceed as if the review happened.
68
+ A `codex-adversarial-review` entry with `head_sha`, `target`, `finding_count`, and `verdict` fields is informative — but DO NOT treat its absence as a failure. The review happened if the agent returned text. (Pre-0.15.0 this step was a hard verification gate that contradicted the agent's "audit optional" contract — see Helix Finding 3, 2026-05-03.)
74
69
 
75
70
  ## Step 4 — Report
76
71
 
@@ -3,6 +3,7 @@ description: Activate the REA kill switch — writes .rea/HALT with a reason, bl
3
3
  argument-hint: "<reason>"
4
4
  allowed-tools:
5
5
  - Bash(npx rea freeze:*)
6
+ - Read
6
7
  ---
7
8
 
8
9
  # /freeze — Activate the Kill Switch
@@ -7,6 +7,7 @@ allowed-tools:
7
7
  - Bash(git branch:*)
8
8
  - Bash(git status:*)
9
9
  - Read
10
+ - Agent
10
11
  ---
11
12
 
12
13
  # /review — Code Review on Current Changes
@@ -203,7 +203,7 @@ function checkSettingsJson(baseDir) {
203
203
  const settingsPath = path.join(baseDir, '.claude', 'settings.json');
204
204
  if (!fs.existsSync(settingsPath)) {
205
205
  return {
206
- label: 'settings.json matchers cover Bash + Write|Edit',
206
+ label: 'settings.json matchers cover Bash + Write|Edit|MultiEdit|NotebookEdit',
207
207
  status: 'fail',
208
208
  detail: `missing: ${settingsPath}`,
209
209
  };
@@ -216,23 +216,30 @@ function checkSettingsJson(baseDir) {
216
216
  const needs = [];
217
217
  if (!matchers.has('Bash'))
218
218
  needs.push('Bash');
219
- if (!matchers.has('Write|Edit'))
220
- needs.push('Write|Edit');
219
+ // 0.16.0: matcher widened to `Write|Edit|MultiEdit|NotebookEdit`. Doctor
220
+ // accepts any of the three historical shapes so pre-0.14.0 / pre-0.16.0
221
+ // installs that haven't run `rea upgrade` still report accurately. The
222
+ // canonical from `defaultDesiredHooks()` is the widest matcher.
223
+ if (!matchers.has('Write|Edit|MultiEdit|NotebookEdit') &&
224
+ !matchers.has('Write|Edit|MultiEdit') &&
225
+ !matchers.has('Write|Edit')) {
226
+ needs.push('Write|Edit|MultiEdit|NotebookEdit');
227
+ }
221
228
  if (needs.length === 0) {
222
229
  return {
223
- label: 'settings.json matchers cover Bash + Write|Edit',
230
+ label: 'settings.json matchers cover Bash + Write|Edit|MultiEdit|NotebookEdit',
224
231
  status: 'pass',
225
232
  };
226
233
  }
227
234
  return {
228
- label: 'settings.json matchers cover Bash + Write|Edit',
235
+ label: 'settings.json matchers cover Bash + Write|Edit|MultiEdit|NotebookEdit',
229
236
  status: 'fail',
230
237
  detail: `missing PreToolUse matchers: ${needs.join(', ')}`,
231
238
  };
232
239
  }
233
240
  catch (e) {
234
241
  return {
235
- label: 'settings.json matchers cover Bash + Write|Edit',
242
+ label: 'settings.json matchers cover Bash + Write|Edit|MultiEdit|NotebookEdit',
236
243
  status: 'fail',
237
244
  detail: e instanceof Error ? e.message : String(e),
238
245
  };
@@ -259,6 +259,7 @@ export function defaultDesiredHooks() {
259
259
  hooks: [
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
+ { type: 'command', command: `${base}/protected-paths-bash-gate.sh`, timeout: 5000, statusMessage: 'Checking for shell-redirect to protected paths...' },
262
263
  { type: 'command', command: `${base}/dependency-audit-gate.sh`, timeout: 15000, statusMessage: 'Verifying package exists...' },
263
264
  { type: 'command', command: `${base}/security-disclosure-gate.sh`, timeout: 5000, statusMessage: 'Checking disclosure policy...' },
264
265
  { type: 'command', command: `${base}/pr-issue-link-gate.sh`, timeout: 5000, statusMessage: 'Checking PR for issue reference...' },
@@ -267,7 +268,7 @@ export function defaultDesiredHooks() {
267
268
  },
268
269
  {
269
270
  event: 'PreToolUse',
270
- matcher: 'Write|Edit',
271
+ matcher: 'Write|Edit|MultiEdit|NotebookEdit',
271
272
  hooks: [
272
273
  { type: 'command', command: `${base}/secret-scanner.sh`, timeout: 15000, statusMessage: 'Scanning for credentials...' },
273
274
  { type: 'command', command: `${base}/settings-protection.sh`, timeout: 5000, statusMessage: 'Checking settings protection...' },
@@ -277,7 +278,7 @@ export function defaultDesiredHooks() {
277
278
  },
278
279
  {
279
280
  event: 'PostToolUse',
280
- matcher: 'Write|Edit',
281
+ matcher: 'Write|Edit|MultiEdit|NotebookEdit',
281
282
  hooks: [
282
283
  { type: 'command', command: `${base}/architecture-review-gate.sh`, timeout: 10000, statusMessage: 'Checking architecture impact...' },
283
284
  ],
@@ -0,0 +1,176 @@
1
+ # shellcheck shell=bash
2
+ # hooks/_lib/cmd-segments.sh — shell-segment splitting for Bash-tier hooks.
3
+ #
4
+ # Background: hooks that gate `Bash` tool calls grep `.tool_input.command`
5
+ # for danger words (`rm -rf`, `git restore`, `pnpm install`, etc.). Pre-
6
+ # 0.15.0 every hook ran a single `grep -qE PATTERN "$cmd"` against the
7
+ # whole command string. That false-positives on heredoc bodies and
8
+ # commit messages where the trigger word appears inside content rather
9
+ # than as a command:
10
+ #
11
+ # git commit -m "$(cat <<'EOF'
12
+ # docs: explain why we don't run rm -rf node_modules in CI
13
+ # EOF
14
+ # )"
15
+ #
16
+ # The unanchored regex matches `rm -rf` inside the heredoc body and the
17
+ # hook blocks a perfectly safe commit. Hit during the 2026-05-03 session
18
+ # repeatedly — the pattern that motivated dependency-audit-gate's 0.15.0
19
+ # segment-split fix.
20
+ #
21
+ # This helper exposes two primitives every Bash-tier hook should use:
22
+ #
23
+ # for_each_segment "$CMD" CALLBACK
24
+ # Splits $CMD on shell command separators (`;`, `&&`, `||`, `|`,
25
+ # newlines) and invokes CALLBACK with each segment as $1, plus the
26
+ # leading-prefix-stripped form as $2 (with `sudo`/`exec`/`time`/
27
+ # `then`/`do`/env-var-assignment prefixes removed). Returns 0 if
28
+ # CALLBACK returned 0 for every segment, or the first non-zero
29
+ # CALLBACK exit otherwise.
30
+ #
31
+ # any_segment_matches "$CMD" PATTERN
32
+ # Iterates segments and returns 0 if any segment's prefix-stripped
33
+ # form matches PATTERN (a `grep -qiE` extended regex). Returns 1
34
+ # if no segment matches.
35
+ #
36
+ # Quoting awareness: the splitter is NOT quote-aware. A separator inside
37
+ # a quoted string would be split. This is INTENTIONAL and SAFE: the
38
+ # segments-vs-callback contract is "find segments that anchor on a
39
+ # trigger word." Over-splitting produces extra segments that don't
40
+ # anchor; they're ignored. Under-splitting (treating a quoted separator
41
+ # as part of one segment) is what the original bug was. The trade-off
42
+ # explicitly accepts over-splitting.
43
+ #
44
+ # Quoting note for future maintainers: do not "fix" the over-splitting
45
+ # without breaking the security property. Quote-aware splitting in pure
46
+ # bash is a real lift; if needed it should move to a Node helper.
47
+
48
+ # Split $1 on shell command separators. Emits one segment per line on
49
+ # stdout (empty segments preserved). Used by both higher-level helpers
50
+ # below; not generally called by hooks directly.
51
+ _rea_split_segments() {
52
+ local cmd="$1"
53
+ # GNU sed and BSD sed both honor `s/PATTERN/\n/g` with `-E` for ERE.
54
+ # We use printf+sed instead of bash IFS=$'...' read so the splitter
55
+ # behaves identically across BSD and GNU sed.
56
+ #
57
+ # 0.16.0 codex P1 fix (helix-015 #3): the prior sed split on bare `|`
58
+ # which broke bash's `>|` (noclobber-override redirect) into two
59
+ # segments — `printf x >` then ` .rea/HALT`. The redirect detector
60
+ # then never saw a complete `>|` operator and the bash-gate let the
61
+ # write through.
62
+ #
63
+ # 0.16.0 codex P2-1 fix: the placeholder must NOT collide with any
64
+ # legal byte the agent could supply. The earlier `\x01` (SOH) is a
65
+ # legal UTF-8 byte and rare-but-possible in commands; if a payload
66
+ # contained `\x01` literally, the third sed pass would manufacture
67
+ # a `>|` operator that wasn't in the original — corrupting downstream
68
+ # parsing in either fail-open or fail-closed directions depending on
69
+ # what came after. The new sentinel `__REA_GTPIPE_a8f2c1__` is
70
+ # multi-byte alphanumeric, impossible to collide with shell input
71
+ # under any encoding we care about (any agent that intentionally
72
+ # included this string would already be obviously trying to confuse
73
+ # the splitter — and even then, the worst case is fail-closed).
74
+ printf '%s\n' "$cmd" \
75
+ | sed -E 's/>\|/__REA_GTPIPE_a8f2c1__/g' \
76
+ | sed -E 's/(\|\||&&|;|\|)/\n/g' \
77
+ | sed -E 's/__REA_GTPIPE_a8f2c1__/>|/g'
78
+ }
79
+
80
+ # Strip leading whitespace and well-known command prefixes from a single
81
+ # segment. Returns the prefix-stripped form on stdout. Examples:
82
+ # " sudo pnpm install foo" → "pnpm install foo"
83
+ # "NODE_ENV=production pnpm add x" → "pnpm add x"
84
+ # "then pnpm add lodash" → "pnpm add lodash"
85
+ _rea_strip_prefix() {
86
+ local seg="$1"
87
+ # Trim leading whitespace.
88
+ seg="${seg#"${seg%%[![:space:]]*}"}"
89
+ # Strip ONE prefix at a time, looping. This handles compounds like
90
+ # `sudo NODE_ENV=production pnpm add foo`.
91
+ while :; do
92
+ case "$seg" in
93
+ sudo[[:space:]]*|exec[[:space:]]*|time[[:space:]]*|then[[:space:]]*|do[[:space:]]*|else[[:space:]]*)
94
+ # Drop the prefix word and any subsequent whitespace.
95
+ seg="${seg#* }"
96
+ seg="${seg#"${seg%%[![:space:]]*}"}"
97
+ ;;
98
+ *)
99
+ # Env-var assignment prefix (`KEY=value `) — only strip if the
100
+ # token before the first space looks like NAME=value.
101
+ if [[ "$seg" =~ ^[A-Za-z_][A-Za-z0-9_]*=[^[:space:]]+[[:space:]]+ ]]; then
102
+ seg="${seg#* }"
103
+ seg="${seg#"${seg%%[![:space:]]*}"}"
104
+ else
105
+ break
106
+ fi
107
+ ;;
108
+ esac
109
+ done
110
+ printf '%s' "$seg"
111
+ }
112
+
113
+ # Iterate every segment of $1 and invoke $2 (a function name) with the
114
+ # raw segment as $1 and the prefix-stripped form as $2. The callback's
115
+ # return value is honored: a non-zero return aborts the iteration and
116
+ # becomes the helper's return value.
117
+ for_each_segment() {
118
+ local cmd="$1"
119
+ local callback="$2"
120
+ local segment stripped rc
121
+ while IFS= read -r segment; do
122
+ stripped=$(_rea_strip_prefix "$segment")
123
+ "$callback" "$segment" "$stripped"
124
+ rc=$?
125
+ if [ "$rc" -ne 0 ]; then
126
+ return "$rc"
127
+ fi
128
+ done < <(_rea_split_segments "$cmd")
129
+ return 0
130
+ }
131
+
132
+ # Return 0 if any segment of $1 (after prefix-stripping) matches the
133
+ # extended regex $2 ANYWHERE (not anchored). Case-insensitive. Returns 1
134
+ # if no segment matches.
135
+ #
136
+ # Use this for patterns that may legitimately appear mid-segment, e.g.
137
+ # `Co-Authored-By:` in a commit message body. For "is the segment a
138
+ # call to <command>" use `any_segment_starts_with` instead — that
139
+ # anchors on the start so `echo "rm -rf foo"` doesn't trip an
140
+ # `rm -rf` detector.
141
+ any_segment_matches() {
142
+ local cmd="$1"
143
+ local pattern="$2"
144
+ local segment stripped
145
+ while IFS= read -r segment; do
146
+ stripped=$(_rea_strip_prefix "$segment")
147
+ if printf '%s' "$stripped" | grep -qiE "$pattern"; then
148
+ return 0
149
+ fi
150
+ done < <(_rea_split_segments "$cmd")
151
+ return 1
152
+ }
153
+
154
+ # Return 0 if any segment of $1 (after prefix-stripping) STARTS WITH
155
+ # the extended regex $2. Case-insensitive. Returns 1 if no segment
156
+ # starts with the pattern.
157
+ #
158
+ # This is the right shape for "is this segment a call to <command>"
159
+ # checks. `echo "rm -rf foo"` does NOT trigger an `rm -rf` detector
160
+ # because the segment starts with `echo`, not `rm`. Compare to
161
+ # `any_segment_matches`, which matches anywhere in the segment and
162
+ # would fire on the echo'd argument.
163
+ any_segment_starts_with() {
164
+ local cmd="$1"
165
+ local pattern="$2"
166
+ local segment stripped
167
+ while IFS= read -r segment; do
168
+ stripped=$(_rea_strip_prefix "$segment")
169
+ # `^` anchor + caller pattern. `(?:)` non-capturing group not
170
+ # supported in BSD ERE; we use a simple literal `^` prepend.
171
+ if printf '%s' "$stripped" | grep -qiE "^${pattern}"; then
172
+ return 0
173
+ fi
174
+ done < <(_rea_split_segments "$cmd")
175
+ return 1
176
+ }
@@ -7,7 +7,13 @@
7
7
  # call with a clear error that surfaces the file's contents so the operator
8
8
  # knows why the system was frozen.
9
9
 
10
- set -euo pipefail
10
+ # NOTE: do NOT set `-e` here. This file is sourced by hooks that
11
+ # intentionally tolerate non-zero exits (e.g. secret-scanner.sh runs
12
+ # multiple grep passes that may legitimately produce non-zero from
13
+ # patterns that don't match). Setting -e in the sourced lib would
14
+ # propagate to the caller and cause spurious exit-1s on any benign
15
+ # non-zero return. Only set the safer subset.
16
+ set -uo pipefail
11
17
 
12
18
  # Find the .rea/ directory by walking up from CLAUDE_PROJECT_DIR or cwd.
13
19
  # Falls back to CLAUDE_PROJECT_DIR or the current working directory when no
@@ -0,0 +1,72 @@
1
+ # shellcheck shell=bash
2
+ # hooks/_lib/path-normalize.sh — canonical path normalization shared
3
+ # across every hook that compares `tool_input.file_path` against a
4
+ # literal/glob policy entry.
5
+ #
6
+ # Pre-0.16.0 each hook reimplemented its own normalize_path. Drift
7
+ # was the result: settings-protection.sh translated `\` → `/` and
8
+ # decoded `%5C` since 0.10.x; blocked-paths-enforcer caught up only
9
+ # in 0.15.0; architecture-review-gate has never normalized at all.
10
+ # This single helper is now the source of truth for both forms.
11
+ #
12
+ # normalize_path PATH
13
+ # Strip $REA_ROOT prefix → URL-decode `%2F`/`%2E`/`%20`/`%5C`
14
+ # → translate `\` → `/` → strip leading `./` segments. Echoes
15
+ # the project-relative form.
16
+ #
17
+ # resolve_parent_realpath PATH
18
+ # Resolve the realpath of the parent dir of PATH via `cd -P && pwd -P`.
19
+ # Returns the empty string if the parent doesn't exist (legitimate
20
+ # "we are creating the parent" case — caller should NOT treat
21
+ # empty as a fail). Used to detect intermediate-symlink bypass:
22
+ # if the realpath of the parent doesn't contain the protected
23
+ # surface anymore, the path resolved out of the surface.
24
+ #
25
+ # All normalization happens in pure bash + sed/tr — no python/perl/
26
+ # readlink -f dependency, so it works on macOS, Alpine, and minimal
27
+ # CI containers.
28
+
29
+ # REA_ROOT is required to be set by the caller (every rea hook sets
30
+ # it from CLAUDE_PROJECT_DIR or pwd). Default to current dir if missing
31
+ # so this lib is sourceable from a test harness that hasn't set it.
32
+ : "${REA_ROOT:=${CLAUDE_PROJECT_DIR:-$(pwd)}}"
33
+
34
+ normalize_path() {
35
+ local p="$1"
36
+ # Strip $REA_ROOT/ prefix (with or without trailing slash).
37
+ if [[ "$p" == "$REA_ROOT"/* ]]; then
38
+ p="${p#"$REA_ROOT"/}"
39
+ fi
40
+ # URL-decode common sequences. Include %5C for backslash so
41
+ # Windows / Git Bash percent-encoded paths normalize the same as
42
+ # forward-slash forms.
43
+ p=$(printf '%s' "$p" | sed 's/%2[Ff]/\//g; s/%2[Ee]/./g; s/%20/ /g; s/%5[Cc]/\\/g')
44
+ # Translate any backslash separators to forward slashes.
45
+ p=$(printf '%s' "$p" | tr '\\\\' '/')
46
+ # Strip leading `./` segments. We deliberately do NOT strip
47
+ # interior `./` — that transformation corrupts `..` traversals
48
+ # (e.g. `.../` collapsed to `../`) and hides traversal from any
49
+ # downstream check.
50
+ while [[ "$p" == ./* ]]; do
51
+ p="${p#./}"
52
+ done
53
+ printf '%s' "$p"
54
+ }
55
+
56
+ resolve_parent_realpath() {
57
+ local target_path="$1"
58
+ local parent_dir
59
+ parent_dir=$(dirname -- "$target_path")
60
+ if [[ ! -d "$parent_dir" ]]; then
61
+ # Parent doesn't exist yet — caller should treat as "no realpath
62
+ # available" and fall back to logical-path checks. Return empty.
63
+ printf ''
64
+ return 0
65
+ fi
66
+ # `cd -P` follows symlinks; `pwd -P` prints the resolved physical
67
+ # path. Subshell scopes the cd so we don't pollute the caller's
68
+ # working directory.
69
+ local resolved
70
+ resolved=$(cd -P -- "$parent_dir" 2>/dev/null && pwd -P 2>/dev/null) || resolved=""
71
+ printf '%s' "$resolved"
72
+ }
@@ -0,0 +1,66 @@
1
+ # shellcheck shell=bash
2
+ # hooks/_lib/payload-read.sh — shared payload extraction across the
3
+ # write-tier tools (Write, Edit, MultiEdit, NotebookEdit).
4
+ #
5
+ # Pre-0.16.0 every content-scanning hook (secret-scanner.sh,
6
+ # changeset-security-gate.sh) carried its own jq expression that
7
+ # walked tool_input.{content, new_string, edits[].new_string}. When
8
+ # Anthropic added MultiEdit (0.14.0 fix) every hook needed a
9
+ # parallel patch and one was missed. NotebookEdit is the next tool
10
+ # in the same family — `tool_input.notebook_path` (path) +
11
+ # `tool_input.new_source` (cell content). This single helper handles
12
+ # all four tools so adding the next one is a one-line edit here, not
13
+ # a sweep across N hooks.
14
+ #
15
+ # extract_write_content INPUT_JSON
16
+ # Echo the content of the about-to-be-written payload, regardless
17
+ # of which write tool produced it. Tries in order:
18
+ # 1. .tool_input.content (Write)
19
+ # 2. .tool_input.new_string (Edit)
20
+ # 3. .tool_input.edits[].new_string (MultiEdit, joined \n)
21
+ # 4. .tool_input.new_source (NotebookEdit cell)
22
+ # Returns empty string when none of these are present (caller
23
+ # should treat empty as "nothing to scan, exit 0"). Defensive
24
+ # coercion via `tostring` + array-type-guard so a malformed
25
+ # payload (non-string new_string, non-array edits) fails closed
26
+ # to the empty string rather than fail-open via jq error.
27
+ #
28
+ # extract_file_path INPUT_JSON
29
+ # Echo the file_path / notebook_path of the payload. NotebookEdit
30
+ # uses notebook_path; Write/Edit/MultiEdit use file_path. Returns
31
+ # empty string when neither is present.
32
+ #
33
+ # Both helpers require jq.
34
+
35
+ extract_write_content() {
36
+ local input="$1"
37
+ printf '%s' "$input" | jq -r '
38
+ # Try Write content first.
39
+ if (.tool_input.content // "") != "" then
40
+ .tool_input.content
41
+ # Then Edit new_string.
42
+ elif (.tool_input.new_string // "") != "" then
43
+ .tool_input.new_string
44
+ # Then MultiEdit edits[].new_string (defensive: type-guard +
45
+ # tostring so heterogeneous types do not error jq).
46
+ elif ((.tool_input.edits // [] | if type=="array" then . else [] end | length) > 0) then
47
+ (.tool_input.edits // [] | if type=="array" then . else [] end)
48
+ | map((.new_string // "") | tostring)
49
+ | join("\n")
50
+ # Then NotebookEdit new_source (notebook cell content).
51
+ elif (.tool_input.new_source // "") != "" then
52
+ .tool_input.new_source
53
+ else
54
+ ""
55
+ end
56
+ ' 2>/dev/null
57
+ }
58
+
59
+ extract_file_path() {
60
+ local input="$1"
61
+ printf '%s' "$input" | jq -r '
62
+ .tool_input.file_path
63
+ // .tool_input.notebook_path
64
+ // empty
65
+ ' 2>/dev/null
66
+ }
@@ -7,7 +7,10 @@
7
7
  # project root via rea_root() from halt-check.sh, but will re-derive it if
8
8
  # REA_ROOT is unset.
9
9
 
10
- set -euo pipefail
10
+ # NOTE: do NOT set `-e` here — see hooks/_lib/halt-check.sh for the
11
+ # rationale. This is a sourced library; -e would propagate to callers
12
+ # and cause spurious exit-1s on benign non-zero returns from grep/sed.
13
+ set -uo pipefail
11
14
 
12
15
  # Resolve the path to .rea/policy.yaml for the current project.
13
16
  # Prints an empty string if no policy file is found — callers should treat
@@ -0,0 +1,50 @@
1
+ # shellcheck shell=bash
2
+ # hooks/_lib/protected-paths.sh — single source of truth for the
3
+ # hard-protected path list shared between the Write/Edit tier
4
+ # (`settings-protection.sh`) and the Bash tier (`protected-paths-bash-gate.sh`).
5
+ #
6
+ # Pre-0.15.0 this list was duplicated inline in settings-protection.sh;
7
+ # the bash-redirect bypass (`> .rea/HALT`, `tee .rea/policy.yaml`,
8
+ # `cp X .claude/settings.json`, `sed -i .husky/pre-push`) was caught
9
+ # by the principal-engineer audit. The fix: factor the list out so
10
+ # both hooks read the same data, and protect against shell redirects
11
+ # in addition to Write/Edit/MultiEdit tools.
12
+
13
+ # The path list is bash glob patterns matched against project-root-
14
+ # relative paths. Suffix `/` indicates a prefix match; no suffix means
15
+ # (case-insensitive) exact match — see `rea_path_is_protected` for the
16
+ # helix-015 #2 lowercase-comparison rationale. Mirrors the array in
17
+ # settings-protection.sh §6.
18
+ REA_PROTECTED_PATTERNS=(
19
+ '.claude/settings.json'
20
+ '.claude/settings.local.json'
21
+ '.husky/'
22
+ '.rea/policy.yaml'
23
+ '.rea/HALT'
24
+ )
25
+
26
+ # Test whether a project-relative path matches any protected pattern.
27
+ # Usage: if rea_path_is_protected ".rea/HALT"; then echo "blocked"; fi
28
+ # Returns 0 on match, 1 on no match.
29
+ #
30
+ # 0.16.0 codex P1 fix (helix-015 #2): match case-insensitively.
31
+ # macOS APFS (default case-insensitive) lets `.ClAuDe/settings.json`
32
+ # land on the same file as `.claude/settings.json`. settings-protection.sh
33
+ # §6 has had a CI matcher since 0.10.x; this helper was missing it.
34
+ # We lowercase BOTH sides so the comparison is symmetric — callers can
35
+ # pass either case.
36
+ rea_path_is_protected() {
37
+ local p_lc
38
+ p_lc=$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')
39
+ local pattern pattern_lc
40
+ for pattern in "${REA_PROTECTED_PATTERNS[@]}"; do
41
+ pattern_lc=$(printf '%s' "$pattern" | tr '[:upper:]' '[:lower:]')
42
+ if [[ "$p_lc" == "$pattern_lc" ]]; then
43
+ return 0
44
+ fi
45
+ if [[ "$pattern_lc" == */ ]] && [[ "$p_lc" == "$pattern_lc"* ]]; then
46
+ return 0
47
+ fi
48
+ done
49
+ return 1
50
+ }
@@ -18,13 +18,11 @@ if ! command -v jq >/dev/null 2>&1; then
18
18
  fi
19
19
 
20
20
  # ── 3. HALT check ────────────────────────────────────────────────────────────
21
- REA_ROOT="${CLAUDE_PROJECT_DIR:-$(pwd)}"
22
- HALT_FILE="${REA_ROOT}/.rea/HALT"
23
- if [ -f "$HALT_FILE" ]; then
24
- printf 'REA HALT: %s\nAll agent operations suspended. Run: rea unfreeze\n' \
25
- "$(head -c 1024 "$HALT_FILE" 2>/dev/null || echo 'Reason unknown')" >&2
26
- exit 2
27
- fi
21
+ # 0.16.0: HALT check sourced from shared _lib/halt-check.sh.
22
+ # shellcheck source=_lib/halt-check.sh
23
+ source "$(dirname "$0")/_lib/halt-check.sh"
24
+ check_halt
25
+ REA_ROOT=$(rea_root)
28
26
 
29
27
  # ── 4. Check if enabled ──────────────────────────────────────────────────────
30
28
  POLICY_FILE="${REA_ROOT}/.rea/policy.yaml"
@@ -41,10 +39,15 @@ if [[ -z "$FILE_PATH" ]]; then
41
39
  exit 0
42
40
  fi
43
41
 
44
- # Normalize to relative path
45
- if [[ "$FILE_PATH" == "$REA_ROOT"/* ]]; then
46
- FILE_PATH="${FILE_PATH#$REA_ROOT/}"
47
- fi
42
+ # 0.16.0 fix D.1: normalize via shared `_lib/path-normalize.sh` so
43
+ # Windows / Git Bash backslash paths and URL-encoded forms are handled
44
+ # uniformly with the rest of the hook layer. Pre-fix, this hook only
45
+ # stripped $REA_ROOT prefix — `src\gateway\foo.ts` (Windows) or
46
+ # `src%2Fgateway%2Ffoo.ts` (URL-encoded) silently bypassed the
47
+ # architectural review.
48
+ # shellcheck source=_lib/path-normalize.sh
49
+ source "$(dirname "$0")/_lib/path-normalize.sh"
50
+ FILE_PATH=$(normalize_path "$FILE_PATH")
48
51
 
49
52
  # ── 6. Check architecture-sensitive paths ─────────────────────────────────────
50
53
  ARCH_PATTERNS=(
@@ -26,13 +26,11 @@ if ! command -v jq >/dev/null 2>&1; then
26
26
  fi
27
27
 
28
28
  # ── 3. HALT check ─────────────────────────────────────────────────────────────
29
- REA_ROOT="${CLAUDE_PROJECT_DIR:-$(pwd)}"
30
- HALT_FILE="${REA_ROOT}/.rea/HALT"
31
- if [ -f "$HALT_FILE" ]; then
32
- printf 'REA HALT: %s\nAll agent operations suspended. Run: rea unfreeze\n' \
33
- "$(head -c 1024 "$HALT_FILE" 2>/dev/null || echo 'Reason unknown')" >&2
34
- exit 2
35
- fi
29
+ # 0.16.0: HALT check sourced from shared _lib/halt-check.sh.
30
+ # shellcheck source=_lib/halt-check.sh
31
+ source "$(dirname "$0")/_lib/halt-check.sh"
32
+ check_halt
33
+ REA_ROOT=$(rea_root)
36
34
 
37
35
  # ── 4. Check if attribution blocking is enabled ──────────────────────────────
38
36
  POLICY_FILE="${REA_ROOT}/.rea/policy.yaml"
@@ -50,14 +48,23 @@ if [[ -z "$CMD" ]]; then
50
48
  exit 0
51
49
  fi
52
50
 
51
+ # 0.15.0: source the shared shell-segment splitter. Pre-fix, the
52
+ # attribution patterns greped the FULL command — `git commit -m "Note:
53
+ # Co-Authored-By with AI was removed in 0.14"` matched and the commit
54
+ # was blocked even though the message was COMMENTING on attribution
55
+ # rather than including it. Per-segment anchoring scopes detection to
56
+ # segments whose first token is `git commit` / `gh pr create|edit`.
57
+ # shellcheck source=_lib/cmd-segments.sh
58
+ source "$(dirname "$0")/_lib/cmd-segments.sh"
59
+
53
60
  # ── 6. Check if this is a relevant command ────────────────────────────────────
54
61
  IS_RELEVANT=0
55
62
 
56
- if printf '%s' "$CMD" | grep -qiE 'gh[[:space:]]+pr[[:space:]]+(create|edit)'; then
63
+ if any_segment_matches "$CMD" 'gh[[:space:]]+pr[[:space:]]+(create|edit)'; then
57
64
  IS_RELEVANT=1
58
65
  fi
59
66
 
60
- if printf '%s' "$CMD" | grep -qiE 'git[[:space:]]+commit'; then
67
+ if any_segment_matches "$CMD" 'git[[:space:]]+commit'; then
61
68
  IS_RELEVANT=1
62
69
  fi
63
70
 
@@ -70,27 +77,27 @@ fi
70
77
  FOUND=0
71
78
 
72
79
  # Co-Authored-By with noreply@ email
73
- if printf '%s' "$CMD" | grep -qiE 'Co-Authored-By:.*noreply@'; then
80
+ if any_segment_matches "$CMD" 'Co-Authored-By:.*noreply@'; then
74
81
  FOUND=1
75
82
  fi
76
83
 
77
84
  # Co-Authored-By with known AI names
78
- if printf '%s' "$CMD" | grep -qiE 'Co-Authored-By:.*\b(Claude|Sonnet|Opus|Haiku|Copilot|GPT|ChatGPT|Gemini|Cursor|Codeium|Tabnine|Amazon Q|CodeWhisperer|Devin|Windsurf|Cline|Aider|Anthropic|OpenAI|GitHub Copilot)\b'; then
85
+ if any_segment_matches "$CMD" 'Co-Authored-By:.*\b(Claude|Sonnet|Opus|Haiku|Copilot|GPT|ChatGPT|Gemini|Cursor|Codeium|Tabnine|Amazon Q|CodeWhisperer|Devin|Windsurf|Cline|Aider|Anthropic|OpenAI|GitHub Copilot)\b'; then
79
86
  FOUND=1
80
87
  fi
81
88
 
82
89
  # "Generated/Built/Powered with/by [AI Tool]" lines
83
- if printf '%s' "$CMD" | grep -qiE '(Generated|Created|Built|Powered|Authored|Written|Produced)[[:space:]]+(with|by)[[:space:]]+(Claude|Copilot|GPT|ChatGPT|Gemini|Cursor|Codeium|Tabnine|CodeWhisperer|Devin|Windsurf|Cline|Aider|AI|an? AI)\b'; then
90
+ if any_segment_matches "$CMD" '(Generated|Created|Built|Powered|Authored|Written|Produced)[[:space:]]+(with|by)[[:space:]]+(Claude|Copilot|GPT|ChatGPT|Gemini|Cursor|Codeium|Tabnine|CodeWhisperer|Devin|Windsurf|Cline|Aider|AI|an? AI)\b'; then
84
91
  FOUND=1
85
92
  fi
86
93
 
87
94
  # Markdown-linked attribution
88
- if printf '%s' "$CMD" | grep -qiE '\[Claude Code\]|\[GitHub Copilot\]|\[ChatGPT\]|\[Gemini\]|\[Cursor\]'; then
95
+ if any_segment_matches "$CMD" '\[Claude Code\]|\[GitHub Copilot\]|\[ChatGPT\]|\[Gemini\]|\[Cursor\]'; then
89
96
  FOUND=1
90
97
  fi
91
98
 
92
99
  # Emoji attribution
93
- if printf '%s' "$CMD" | grep -qE '🤖.*[Gg]enerated'; then
100
+ if any_segment_matches "$CMD" '🤖.*[Gg]enerated'; then
94
101
  FOUND=1
95
102
  fi
96
103