@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.
@@ -23,13 +23,11 @@ if ! command -v jq >/dev/null 2>&1; then
23
23
  fi
24
24
 
25
25
  # ── 3. HALT check ────────────────────────────────────────────────────────────
26
- REA_ROOT="${CLAUDE_PROJECT_DIR:-$(pwd)}"
27
- HALT_FILE="${REA_ROOT}/.rea/HALT"
28
- if [ -f "$HALT_FILE" ]; then
29
- printf 'REA HALT: %s\nAll agent operations suspended. Run: rea unfreeze\n' \
30
- "$(head -c 1024 "$HALT_FILE" 2>/dev/null || echo 'Reason unknown')" >&2
31
- exit 2
32
- fi
26
+ # 0.16.0: HALT check sourced from shared _lib/halt-check.sh.
27
+ # shellcheck source=_lib/halt-check.sh
28
+ source "$(dirname "$0")/_lib/halt-check.sh"
29
+ check_halt
30
+ REA_ROOT=$(rea_root)
33
31
 
34
32
  # ── 4. Extract file path from payload ─────────────────────────────────────────
35
33
  FILE_PATH=$(printf '%s' "$INPUT" | jq -r '.tool_input.file_path // empty' 2>/dev/null)
@@ -100,16 +98,12 @@ AGENT_WRITABLE=(
100
98
  '.rea/audit/'
101
99
  )
102
100
 
103
- normalize_path() {
104
- local p="$1"
105
- local root="$REA_ROOT"
106
- if [[ "$p" == "$root"/* ]]; then
107
- p="${p#$root/}"
108
- fi
109
- p=$(printf '%s' "$p" | sed 's/%2[Ff]/\//g; s/%2[Ee]/./g; s/%20/ /g')
110
- p="${p#./}"
111
- printf '%s' "$p"
112
- }
101
+ # 0.16.0: normalize_path migrated to shared `_lib/path-normalize.sh`.
102
+ # Both this hook AND settings-protection.sh consume the same helper
103
+ # so URL-decoding / backslash-translation / `./`-stripping cannot
104
+ # drift between them again.
105
+ # shellcheck source=_lib/path-normalize.sh
106
+ source "$(dirname "$0")/_lib/path-normalize.sh"
113
107
 
114
108
  NORMALIZED=$(normalize_path "$FILE_PATH")
115
109
 
@@ -211,4 +205,41 @@ for blocked in "${BLOCKED_PATHS[@]}"; do
211
205
  fi
212
206
  done
213
207
 
208
+ # ── 0.16.0 fix H.2: intermediate-symlink resolution ──────────────────────────
209
+ # Same shape as Helix Finding 2 against blocked_paths policy entries.
210
+ # If `secrets/` is in blocked_paths and an attacker creates
211
+ # `pretty/ -> ../secrets/`, then writes `pretty/foo`, the literal-match
212
+ # loop above sees `pretty/foo` (no match) and exits 0 — the downstream
213
+ # Write tool follows the symlink and lands the body in `secrets/foo`.
214
+ # Mirrors settings-protection.sh §6c.
215
+ if [[ -e "$FILE_PATH" || -d "$(dirname -- "$FILE_PATH")" ]]; then
216
+ parent_dir=$(dirname -- "$FILE_PATH")
217
+ if [[ -d "$parent_dir" ]]; then
218
+ resolved_parent=$(cd -P -- "$parent_dir" 2>/dev/null && pwd -P 2>/dev/null) || resolved_parent=""
219
+ if [[ -n "$resolved_parent" && "$resolved_parent" == "$REA_ROOT"/* ]]; then
220
+ relative_resolved="${resolved_parent#"$REA_ROOT"/}"
221
+ resolved_target="${relative_resolved}/$(basename -- "$FILE_PATH")"
222
+ resolved_target_lc=$(printf '%s' "$resolved_target" | tr '[:upper:]' '[:lower:]')
223
+ for blocked in "${BLOCKED_PATHS[@]}"; do
224
+ blocked_lc=$(printf '%s' "$blocked" | tr '[:upper:]' '[:lower:]')
225
+ if [[ "$resolved_target_lc" == "$blocked_lc" ]] || \
226
+ { [[ "$blocked_lc" == */ ]] && [[ "$resolved_target_lc" == "$blocked_lc"* ]]; }; then
227
+ {
228
+ printf 'BLOCKED PATH: intermediate-symlink resolution blocked\n'
229
+ printf '\n'
230
+ printf ' Logical: %s\n' "$FILE_PATH"
231
+ printf ' Resolved: %s\n' "$resolved_target"
232
+ printf ' Blocked by: %s\n' "$blocked"
233
+ printf ' Source: .rea/policy.yaml → blocked_paths\n'
234
+ printf '\n'
235
+ printf ' Rule: an intermediate directory of the path is a symlink\n'
236
+ printf ' whose target falls inside a blocked policy entry.\n'
237
+ } >&2
238
+ exit 2
239
+ fi
240
+ done
241
+ fi
242
+ fi
243
+ fi
244
+
214
245
  exit 0
@@ -23,27 +23,32 @@ check_halt
23
23
  INPUT="$(cat)"
24
24
  TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // ""')
25
25
 
26
- # Only handle Write and Edit
27
- if [[ "$TOOL_NAME" != "Write" && "$TOOL_NAME" != "Edit" ]]; then
26
+ # 0.15.0 fix: MultiEdit was not in the allowed tool_name set, so the gate
27
+ # silently exited 0 on every MultiEdit call against `.changeset/*.md`
28
+ # letting GHSA / CVE pre-disclosure through and skipping frontmatter
29
+ # validation. 0.16.0: NotebookEdit added too (changesets are .md files
30
+ # but a malicious agent could in principle route a .md write through
31
+ # NotebookEdit's new_source path; cheap to allow, free to test).
32
+ if [[ "$TOOL_NAME" != "Write" && "$TOOL_NAME" != "Edit" && "$TOOL_NAME" != "MultiEdit" && "$TOOL_NAME" != "NotebookEdit" ]]; then
28
33
  exit 0
29
34
  fi
30
35
 
31
- FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // ""')
36
+ require_jq
37
+
38
+ # 0.16.0: payload extraction migrated to `_lib/payload-read.sh`. Shared
39
+ # helpers handle every write-tier tool with the same defensive
40
+ # coercion. Adding the next write-tier tool is a one-line edit there.
41
+ # shellcheck source=_lib/payload-read.sh
42
+ source "$(dirname "$0")/_lib/payload-read.sh"
43
+
44
+ FILE_PATH=$(extract_file_path "$INPUT")
32
45
 
33
46
  # Only care about .changeset/*.md files — exclude README.md (changeset tool metadata)
34
47
  if ! echo "$FILE_PATH" | grep -qE '\.changeset/[^/]+\.md$' || echo "$FILE_PATH" | grep -qE '\.changeset/README\.md$'; then
35
48
  exit 0
36
49
  fi
37
50
 
38
- require_jq
39
-
40
- # Extract the content being written
41
- if [[ "$TOOL_NAME" == "Write" ]]; then
42
- CONTENT=$(echo "$INPUT" | jq -r '.tool_input.content // ""')
43
- else
44
- # For Edit: check the new_string being inserted
45
- CONTENT=$(echo "$INPUT" | jq -r '.tool_input.new_string // ""')
46
- fi
51
+ CONTENT=$(extract_write_content "$INPUT")
47
52
 
48
53
  # ─── 1. SECURITY DISCLOSURE CHECK ───────────────────────────────────────────
49
54
  #
@@ -90,6 +95,19 @@ fi
90
95
  #
91
96
  # A changeset without valid frontmatter is silently ignored by the changesets
92
97
  # tool — the package bump and CHANGELOG entry never appear in the release.
98
+ #
99
+ # 0.15.0 fix: skip frontmatter validation for MultiEdit. MultiEdit's
100
+ # `tool_input.edits[].new_string` payload is a list of partial string
101
+ # replacements, not the full file body — running the frontmatter
102
+ # validator against the concatenation of new_strings would reject every
103
+ # legitimate MultiEdit on an existing changeset (none of the edit
104
+ # fragments individually contains a frontmatter block, even though the
105
+ # resulting file does). The disclosure scan above still runs on
106
+ # MultiEdit content because GHSA/CVE patterns match per-fragment without
107
+ # any structural assumption.
108
+ if [[ "$TOOL_NAME" == "MultiEdit" ]]; then
109
+ exit 0
110
+ fi
93
111
 
94
112
  # Must start with ---
95
113
  if ! echo "$CONTENT" | head -1 | grep -qE '^---'; then
@@ -107,9 +125,20 @@ Brief description of what changed and why (close #N if applicable).
107
125
  Bump types: patch (bug fix/security), minor (new feature), major (breaking change)"
108
126
  fi
109
127
 
110
- # Must have at least one package bump entry and a closing ---
128
+ # Must have at least one package bump entry and a closing ---.
129
+ # 0.15.0 fix: accept single-quoted, double-quoted, AND unquoted package
130
+ # names (all three are valid YAML for the same string). Pre-fix the
131
+ # regex required single quotes, so a tool or human authoring the
132
+ # changeset with `"@scope/name": patch` was rejected as malformed even
133
+ # though the Changesets tool itself accepts every form.
134
+ #
135
+ # Codex round-1 P2-1 fix: explicit-alternation form (no backref) so
136
+ # the unquoted variant matches on BSD grep too. The earlier
137
+ # `^([\"']?)[^\"']+\1: ...` shape relied on backref-with-empty-capture
138
+ # semantics that BSD's grep rejects when the capture group's `?` made
139
+ # it absent — quoted forms matched on macOS but unquoted did not.
111
140
  FRONTMATTER=$(echo "$CONTENT" | awk '/^---/{count++; if(count==2){exit} next} count==1{print}')
112
- if ! echo "$FRONTMATTER" | grep -qE "^'.+': (patch|minor|major)"; then
141
+ if ! echo "$FRONTMATTER" | grep -qE "^(\"[^\"]+\"|'[^']+'|[^\"'[:space:]]+): (patch|minor|major)"; then
113
142
  json_output "block" \
114
143
  "CHANGESET FORMAT GATE: Frontmatter does not contain a valid package bump entry.
115
144
 
@@ -15,6 +15,15 @@
15
15
 
16
16
  set -uo pipefail
17
17
 
18
+ # Source shared shell-segment splitter (0.15.0). Provides
19
+ # `any_segment_matches "$CMD" PATTERN` which iterates segments split on
20
+ # &&/||/;/| and runs the pattern with `grep -qiE` against each
21
+ # prefix-stripped segment. Replaces full-command grep that
22
+ # false-positives on heredoc bodies and commit messages mentioning
23
+ # trigger words.
24
+ # shellcheck source=_lib/cmd-segments.sh
25
+ source "$(dirname "$0")/_lib/cmd-segments.sh"
26
+
18
27
  # ── 1. Read ALL stdin immediately before doing anything else ──────────────────
19
28
  INPUT=$(cat)
20
29
 
@@ -26,13 +35,11 @@ if ! command -v jq >/dev/null 2>&1; then
26
35
  fi
27
36
 
28
37
  # ── 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
38
+ # 0.16.0: HALT check sourced from shared _lib/halt-check.sh.
39
+ # shellcheck source=_lib/halt-check.sh
40
+ source "$(dirname "$0")/_lib/halt-check.sh"
41
+ check_halt
42
+ REA_ROOT=$(rea_root)
36
43
 
37
44
  # ── 4. Parse tool_input.command from the hook payload ─────────────────────────
38
45
  CMD=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
@@ -84,26 +91,21 @@ add_medium() {
84
91
  }
85
92
 
86
93
  # ── 7. Per-segment evaluation helper ──────────────────────────────────────────
87
- # Split on &&, ||, ;, and newlines and test a pattern against each segment.
88
- # Returns 0 if ANY segment matches the pattern.
89
- any_segment_matches() {
90
- local PATTERN="$1"
91
- while IFS= read -r SEG; do
92
- if printf '%s' "$SEG" | grep -qiE "$PATTERN"; then
93
- return 0
94
- fi
95
- done < <(printf '%s' "$CMD" | sed 's/&&/\n/g; s/||/\n/g; s/;/\n/g')
96
- return 1
97
- }
94
+ # (Migrated to `_lib/cmd-segments.sh::any_segment_matches` as of 0.15.0.
95
+ # The previous inline helper was defined here but never called — H3-H17
96
+ # all greped the WHOLE command, which false-positived on heredoc bodies
97
+ # and commit messages mentioning trigger words. Migration: every check
98
+ # now uses `any_segment_matches "$CMD" PATTERN` with the helper sourced
99
+ # at the top of this file.)
98
100
 
99
101
  # ── 8. Smart exclusion flags ──────────────────────────────────────────────────
100
102
  CMD_IS_REBASE_SAFE=0
101
- if printf '%s' "$CMD" | grep -qiE 'git[[:space:]]+(rebase)[[:space:]].*(--abort|--continue)'; then
103
+ if any_segment_starts_with "$CMD" 'git[[:space:]]+(rebase)[[:space:]].*(--abort|--continue)'; then
102
104
  CMD_IS_REBASE_SAFE=1
103
105
  fi
104
106
 
105
107
  CMD_IS_CLEAN_DRY=0
106
- if printf '%s' "$CMD" | grep -qiE 'git[[:space:]]+clean.*([ \t]-n|--dry-run)'; then
108
+ if any_segment_starts_with "$CMD" 'git[[:space:]]+clean.*([ \t]-n|--dry-run)'; then
107
109
  CMD_IS_CLEAN_DRY=1
108
110
  fi
109
111
 
@@ -111,26 +113,41 @@ fi
111
113
 
112
114
  # H1: git push --force or -f (per-segment — prevents --force-with-lease poisoning)
113
115
  # A segment containing --force-with-lease is excluded; other segments are not.
114
- while IFS= read -r SEGMENT; do
115
- SEGMENT=$(printf '%s' "$SEGMENT" | sed 's/^[[:space:]]*//')
116
- [[ -z "$SEGMENT" ]] && continue
117
- # Skip segments that use the safe --force-with-lease
118
- if printf '%s' "$SEGMENT" | grep -qiE 'git[[:space:]]+push.*--force-with-lease'; then
119
- continue
116
+ # 0.15.0: also catches `git push origin +<branch>` (refspec-prefix force-push
117
+ # shorthand) which the previous version missed.
118
+ _h1_check() {
119
+ local _raw="$1" SEGMENT="$2"
120
+ [[ -z "$SEGMENT" ]] && return 0
121
+ # 0.15.0 codex P1 fix: anchor on `^git push`. Pre-fix the unanchored
122
+ # match meant `echo "git push --force is bad"` triggered H1 even
123
+ # though no actual push was happening (the segment after prefix-strip
124
+ # was `echo "..."`, not `git push`). Anchoring scopes detection to
125
+ # segments whose first token IS git push.
126
+ printf '%s' "$SEGMENT" | grep -qiE '^git[[:space:]]+push([[:space:]]|$)' || return 0
127
+ # Skip segments that use the safe --force-with-lease.
128
+ if printf '%s' "$SEGMENT" | grep -qiE -- '--force-with-lease'; then
129
+ return 0
120
130
  fi
121
- if printf '%s' "$SEGMENT" | grep -qiE 'git[[:space:]]+push.*(--force|-f[[:space:]])' || \
122
- printf '%s' "$SEGMENT" | grep -qiE 'git[[:space:]]+push.*(--force|-f)$'; then
131
+ # 0.15.0 codex P1 fix: combined-flag forms (`-fu`, `-uf`, `-Fu`) and
132
+ # long-form `--force=value` were not caught by the previous
133
+ # `-f[[:space:]]` shape. The flag-cluster pattern `-[a-zA-Z]*f[a-zA-Z]*`
134
+ # (followed by space or EOS) mirrors how H11 handles rm flag clusters.
135
+ # The refspec-prefix `+` on a branch name is git's force-push shorthand.
136
+ if printf '%s' "$SEGMENT" | grep -qiE -- '--force([[:space:]]|=|$)' || \
137
+ printf '%s' "$SEGMENT" | grep -qiE -- '(^|[[:space:]])-[a-zA-Z]*f[a-zA-Z]*([[:space:]]|$)' || \
138
+ printf '%s' "$SEGMENT" | grep -qE -- '[[:space:]]\+[A-Za-z0-9_./-]'; then
123
139
  add_high \
124
140
  "git push --force — force push detected" \
125
141
  "Force-pushing rewrites public history and breaks collaborators' local copies." \
126
142
  "Alt: Use 'git push --force-with-lease' — blocks if upstream has new commits you haven't pulled."
127
- break
128
143
  fi
129
- done < <(printf '%s' "$CMD" | sed 's/&&/\n/g; s/||/\n/g; s/;/\n/g')
144
+ return 0
145
+ }
146
+ for_each_segment "$CMD" _h1_check
130
147
 
131
148
  # H2: git rebase — advisory (MEDIUM)
132
149
  if [[ $CMD_IS_REBASE_SAFE -eq 0 ]]; then
133
- if printf '%s' "$CMD" | grep -qiE 'git[[:space:]]+rebase([[:space:]]|$)'; then
150
+ if any_segment_starts_with "$CMD" 'git[[:space:]]+rebase([[:space:]]|$)'; then
134
151
  add_medium \
135
152
  "git rebase — rewrites commit history (advisory)" \
136
153
  "Rebase changes commit SHAs. Safe on local feature branches; dangerous on shared/published branches." \
@@ -140,7 +157,7 @@ if [[ $CMD_IS_REBASE_SAFE -eq 0 ]]; then
140
157
  fi
141
158
 
142
159
  # H3: git checkout -- .
143
- if printf '%s' "$CMD" | grep -qiE 'git[[:space:]]+checkout[[:space:]]+--[[:space:]]+\.'; then
160
+ if any_segment_starts_with "$CMD" 'git[[:space:]]+checkout[[:space:]]+--[[:space:]]+\.'; then
144
161
  add_high \
145
162
  "git checkout -- . — discards all uncommitted changes" \
146
163
  "Overwrites working tree changes with HEAD. Uncommitted work is lost permanently." \
@@ -148,8 +165,8 @@ if printf '%s' "$CMD" | grep -qiE 'git[[:space:]]+checkout[[:space:]]+--[[:space
148
165
  fi
149
166
 
150
167
  # H4: git restore . (any form — with or without --staged flag)
151
- if printf '%s' "$CMD" | grep -qiE 'git[[:space:]]+restore[[:space:]].*[[:space:]]\.([[:space:]]|$)' || \
152
- printf '%s' "$CMD" | grep -qiE 'git[[:space:]]+restore[[:space:]]+\.[[:space:]]*$'; then
168
+ if any_segment_starts_with "$CMD" 'git[[:space:]]+restore[[:space:]].*[[:space:]]\.([[:space:]]|$)' || \
169
+ any_segment_starts_with "$CMD" 'git[[:space:]]+restore[[:space:]]+\.[[:space:]]*$'; then
153
170
  add_high \
154
171
  "git restore . — discards all uncommitted changes" \
155
172
  "Restores every tracked file to HEAD, permanently discarding all working tree modifications." \
@@ -158,7 +175,7 @@ fi
158
175
 
159
176
  # H5: git clean -f
160
177
  if [[ $CMD_IS_CLEAN_DRY -eq 0 ]]; then
161
- if printf '%s' "$CMD" | grep -qiE 'git[[:space:]]+clean[[:space:]]+-[a-zA-Z]*f'; then
178
+ if any_segment_starts_with "$CMD" 'git[[:space:]]+clean[[:space:]]+-[a-zA-Z]*f'; then
162
179
  add_high \
163
180
  "git clean -f — removes untracked files" \
164
181
  "Permanently deletes untracked files from the working tree. Cannot be undone via git." \
@@ -167,7 +184,7 @@ if [[ $CMD_IS_CLEAN_DRY -eq 0 ]]; then
167
184
  fi
168
185
 
169
186
  # H6: DROP TABLE or DROP DATABASE in psql
170
- if printf '%s' "$CMD" | grep -qiE '(psql|pgcli)[^|&;]*DROP[[:space:]]+(TABLE|DATABASE|SCHEMA)'; then
187
+ if any_segment_matches "$CMD" '(psql|pgcli)[^|&;]*DROP[[:space:]]+(TABLE|DATABASE|SCHEMA)'; then
171
188
  add_high \
172
189
  "DROP TABLE/DATABASE via psql — destructive DDL" \
173
190
  "Running destructive DDL directly in psql bypasses migration pipeline safety checks." \
@@ -175,7 +192,7 @@ if printf '%s' "$CMD" | grep -qiE '(psql|pgcli)[^|&;]*DROP[[:space:]]+(TABLE|DAT
175
192
  fi
176
193
 
177
194
  # H7: kill -9 with pgrep subshell
178
- if printf '%s' "$CMD" | grep -qiE 'kill[[:space:]]+-9[[:space:]]+(\$\(|`)'; then
195
+ if any_segment_starts_with "$CMD" 'kill[[:space:]]+-9[[:space:]]+(\$\(|`)'; then
179
196
  add_high \
180
197
  "kill -9 with pgrep subshell — aggressive process termination" \
181
198
  "Sends SIGKILL to processes matched by name, which may kill unintended processes." \
@@ -183,7 +200,7 @@ if printf '%s' "$CMD" | grep -qiE 'kill[[:space:]]+-9[[:space:]]+(\$\(|`)'; then
183
200
  fi
184
201
 
185
202
  # H8: killall -9
186
- if printf '%s' "$CMD" | grep -qiE 'killall[[:space:]]+-9[[:space:]]+\S'; then
203
+ if any_segment_starts_with "$CMD" 'killall[[:space:]]+-9[[:space:]]+\S'; then
187
204
  add_high \
188
205
  "killall -9 — SIGKILL all matching processes" \
189
206
  "Immediately terminates all processes with the given name without cleanup." \
@@ -191,7 +208,7 @@ if printf '%s' "$CMD" | grep -qiE 'killall[[:space:]]+-9[[:space:]]+\S'; then
191
208
  fi
192
209
 
193
210
  # H9: git commit --no-verify
194
- if printf '%s' "$CMD" | grep -qiE 'git[[:space:]]+commit.*--no-verify'; then
211
+ if any_segment_starts_with "$CMD" 'git[[:space:]]+commit.*--no-verify'; then
195
212
  add_high \
196
213
  "git commit --no-verify — skipping pre-commit hooks" \
197
214
  "Bypasses all pre-commit safety gates including secret scanning and linting." \
@@ -199,7 +216,7 @@ if printf '%s' "$CMD" | grep -qiE 'git[[:space:]]+commit.*--no-verify'; then
199
216
  fi
200
217
 
201
218
  # H10: HUSKY=0 bypass — suppresses all git hooks without --no-verify
202
- if printf '%s' "$CMD" | grep -qiE '(^|[[:space:];]|&&|\|\|)HUSKY=0[[:space:]]+git[[:space:]]+(commit|push|tag)'; then
219
+ if any_segment_matches "$CMD" '(^|[[:space:];]|&&|\|\|)HUSKY=0[[:space:]]+git[[:space:]]+(commit|push|tag)'; then
203
220
  add_high \
204
221
  "HUSKY=0 — bypasses all husky git hooks" \
205
222
  "Setting HUSKY=0 disables pre-commit, commit-msg, and pre-push safety gates without --no-verify." \
@@ -208,13 +225,18 @@ fi
208
225
 
209
226
  # H11: rm -rf with broad targets
210
227
  # Covers combined flags (rm -rf, rm -fr), split flags (rm -r -f), and long flags (rm --recursive --force)
211
- BROAD_TARGETS='(\/|~\/|\.\/\*|\*|\.|src|dist|build|node_modules)'
212
- if printf '%s' "$CMD" | grep -qiE "rm[[:space:]]+-[a-zA-Z]*r[a-zA-Z]*f[[:space:]]+${BROAD_TARGETS}" || \
213
- printf '%s' "$CMD" | grep -qiE "rm[[:space:]]+-[a-zA-Z]*f[a-zA-Z]*r[[:space:]]+${BROAD_TARGETS}" || \
214
- printf '%s' "$CMD" | grep -qiE "rm[[:space:]]+-[a-zA-Z]*r[[:space:]]+-[a-zA-Z]*f[[:space:]]+${BROAD_TARGETS}" || \
215
- printf '%s' "$CMD" | grep -qiE "rm[[:space:]]+-[a-zA-Z]*f[[:space:]]+-[a-zA-Z]*r[[:space:]]+${BROAD_TARGETS}" || \
216
- printf '%s' "$CMD" | grep -qiE "rm[[:space:]]+--recursive[[:space:]]+--force[[:space:]]+${BROAD_TARGETS}" || \
217
- printf '%s' "$CMD" | grep -qiE "rm[[:space:]]+--force[[:space:]]+--recursive[[:space:]]+${BROAD_TARGETS}"; then
228
+ # 0.15.0 fix: anchored each target on word boundary (whitespace-or-EOS).
229
+ # The previous form had a bare `\.` which matched `rm -rf .git/foo`
230
+ # (legitimate `.git/`-tree cleanup). Each token now requires either
231
+ # end-of-string or whitespace after so `.` alone matches `rm -rf .`
232
+ # (the cwd, dangerous) but NOT `rm -rf .git/foo`.
233
+ BROAD_TARGETS='(\/|~\/|\.\/\*|\*|\.|src|dist|build|node_modules)([[:space:]]|$)'
234
+ if any_segment_starts_with "$CMD" "rm[[:space:]]+-[a-zA-Z]*r[a-zA-Z]*f[[:space:]]+${BROAD_TARGETS}" || \
235
+ any_segment_starts_with "$CMD" "rm[[:space:]]+-[a-zA-Z]*f[a-zA-Z]*r[[:space:]]+${BROAD_TARGETS}" || \
236
+ any_segment_starts_with "$CMD" "rm[[:space:]]+-[a-zA-Z]*r[[:space:]]+-[a-zA-Z]*f[[:space:]]+${BROAD_TARGETS}" || \
237
+ any_segment_starts_with "$CMD" "rm[[:space:]]+-[a-zA-Z]*f[[:space:]]+-[a-zA-Z]*r[[:space:]]+${BROAD_TARGETS}" || \
238
+ any_segment_starts_with "$CMD" "rm[[:space:]]+--recursive[[:space:]]+--force[[:space:]]+${BROAD_TARGETS}" || \
239
+ any_segment_starts_with "$CMD" "rm[[:space:]]+--force[[:space:]]+--recursive[[:space:]]+${BROAD_TARGETS}"; then
218
240
  add_high \
219
241
  "rm -rf with broad target — mass file deletion" \
220
242
  "Permanently deletes files and directories. Cannot be undone." \
@@ -222,7 +244,7 @@ if printf '%s' "$CMD" | grep -qiE "rm[[:space:]]+-[a-zA-Z]*r[a-zA-Z]*f[[:space:]
222
244
  fi
223
245
 
224
246
  # H12: curl/wget piped directly to shell (supply chain attack vector)
225
- if printf '%s' "$CMD" | grep -qiE '(curl|wget)[^|]*\|[[:space:]]*(bash|sh|zsh|fish)'; then
247
+ if any_segment_matches "$CMD" '(curl|wget)[^|]*\|[[:space:]]*(bash|sh|zsh|fish)'; then
226
248
  add_high \
227
249
  "curl/wget piped to shell — remote code execution" \
228
250
  "Executing remote scripts without inspection is a major supply chain risk." \
@@ -230,7 +252,7 @@ if printf '%s' "$CMD" | grep -qiE '(curl|wget)[^|]*\|[[:space:]]*(bash|sh|zsh|fi
230
252
  fi
231
253
 
232
254
  # H13: git push --no-verify — bypasses pre-push hooks
233
- if printf '%s' "$CMD" | grep -qiE 'git[[:space:]]+push.*--no-verify'; then
255
+ if any_segment_starts_with "$CMD" 'git[[:space:]]+push.*--no-verify'; then
234
256
  add_high \
235
257
  "git push --no-verify — skipping pre-push hooks" \
236
258
  "Bypasses all pre-push safety gates including CI checks." \
@@ -238,7 +260,7 @@ if printf '%s' "$CMD" | grep -qiE 'git[[:space:]]+push.*--no-verify'; then
238
260
  fi
239
261
 
240
262
  # H14: git -c core.hooksPath= — redirects or disables hook execution
241
- if printf '%s' "$CMD" | grep -qiE 'git[[:space:]]+-c[[:space:]]+core\.hookspath'; then
263
+ if any_segment_starts_with "$CMD" 'git[[:space:]]+-c[[:space:]]+core\.hookspath'; then
242
264
  add_high \
243
265
  "git -c core.hooksPath — overriding hooks directory" \
244
266
  "Redirecting the hooks path can disable all safety hooks." \
@@ -246,7 +268,7 @@ if printf '%s' "$CMD" | grep -qiE 'git[[:space:]]+-c[[:space:]]+core\.hookspath'
246
268
  fi
247
269
 
248
270
  # H15: REA_BYPASS env var — attempted escape hatch
249
- if printf '%s' "$CMD" | grep -qiE '(^|[[:space:];]|&&|\|\|)REA_BYPASS[[:space:]]*='; then
271
+ if any_segment_matches "$CMD" '(^|[[:space:];]|&&|\|\|)REA_BYPASS[[:space:]]*='; then
250
272
  add_high \
251
273
  "REA_BYPASS env var — unauthorized bypass attempt" \
252
274
  "Setting REA_BYPASS is not a supported escape mechanism and indicates a bypass attempt." \
@@ -254,60 +276,46 @@ if printf '%s' "$CMD" | grep -qiE '(^|[[:space:];]|&&|\|\|)REA_BYPASS[[:space:]]
254
276
  fi
255
277
 
256
278
  # H16: alias/function definitions containing bypass strings
257
- if printf '%s' "$CMD" | grep -qiE '(alias|function)[[:space:]]+[a-zA-Z_]+.*(--(no-verify|force)|HUSKY=0|core\.hookspath)'; then
279
+ if any_segment_matches "$CMD" '(alias|function)[[:space:]]+[a-zA-Z_]+.*(--(no-verify|force)|HUSKY=0|core\.hookspath)'; then
258
280
  add_high \
259
281
  "Alias/function definition with bypass — circumventing safety gates" \
260
282
  "Defining aliases or functions that embed bypass flags defeats safety hooks." \
261
283
  "Alt: Do not wrap bypass patterns in aliases or functions."
262
284
  fi
263
285
 
264
- # H17: context_protection — block commands that should be delegated to subagents
286
+ # H17: context_protection — block commands that should be delegated to subagents.
265
287
  # Reads context_protection.delegate_to_subagent from .rea/policy.yaml.
266
288
  # These commands produce excessive output that exhausts coordinator context windows.
267
- POLICY_FILE="${REA_ROOT}/.rea/policy.yaml"
268
- if [[ -f "$POLICY_FILE" ]]; then
269
- DELEGATE_PATTERNS=()
270
- IN_DELEGATE_BLOCK=0
271
- while IFS= read -r line; do
272
- if printf '%s' "$line" | grep -qE '^[[:space:]]*delegate_to_subagent:'; then
273
- # Check for inline empty array
274
- if printf '%s' "$line" | grep -qE 'delegate_to_subagent:[[:space:]]*\[\]'; then
275
- break
276
- fi
277
- IN_DELEGATE_BLOCK=1
278
- continue
279
- fi
280
- if [[ $IN_DELEGATE_BLOCK -eq 1 ]]; then
281
- # Block sequence items start with " - "
282
- if printf '%s' "$line" | grep -qE '^[[:space:]]*-[[:space:]]'; then
283
- pattern=$(printf '%s' "$line" | sed "s/^[[:space:]]*-[[:space:]]*//; s/^[\"']//; s/[\"']$//")
284
- if [[ -n "$pattern" ]]; then
285
- DELEGATE_PATTERNS+=("$pattern")
286
- fi
287
- else
288
- # Non-continuation line = end of block
289
- break
290
- fi
291
- fi
292
- done < "$POLICY_FILE"
293
-
294
- for pattern in "${DELEGATE_PATTERNS[@]+"${DELEGATE_PATTERNS[@]}"}"; do
295
- # Use fixed-string match — these are command prefixes, not regex
296
- if printf '%s' "$CMD" | grep -qF "$pattern"; then
297
- add_high \
298
- "Context protection — command must run in a subagent" \
299
- "This command produces excessive output that will exhaust the coordinator context window. Delegate it to a subagent instead of running it directly." \
300
- "Alt: Use the Agent tool to delegate: Agent(subagent_type: 'qa-engineer-automation', prompt: 'Run $pattern and report pass/fail summary only.')" \
301
- "Alt: The context_protection policy in .rea/policy.yaml lists commands that must be delegated."
302
- break
303
- fi
304
- done
305
- fi
289
+ #
290
+ # 0.16.0 fix J.2: replaced the inline YAML parser (40+ lines reimplementing
291
+ # block-sequence walking) with `policy_list` from `_lib/policy-read.sh`.
292
+ # Same parser shape as every other rea hook now reads policy via the shared
293
+ # helper; drift between hooks is structurally impossible.
294
+ # shellcheck source=_lib/policy-read.sh
295
+ source "$(dirname "$0")/_lib/policy-read.sh"
296
+
297
+ DELEGATE_PATTERNS=()
298
+ while IFS= read -r pattern; do
299
+ [[ -z "$pattern" ]] && continue
300
+ DELEGATE_PATTERNS+=("$pattern")
301
+ done < <(policy_list "delegate_to_subagent")
302
+
303
+ for pattern in "${DELEGATE_PATTERNS[@]+"${DELEGATE_PATTERNS[@]}"}"; do
304
+ # Use fixed-string match these are command prefixes, not regex.
305
+ if printf '%s' "$CMD" | grep -qF "$pattern"; then
306
+ add_high \
307
+ "Context protection — command must run in a subagent" \
308
+ "This command produces excessive output that will exhaust the coordinator context window. Delegate it to a subagent instead of running it directly." \
309
+ "Alt: Use the Agent tool to delegate: Agent(subagent_type: 'qa-engineer-automation', prompt: 'Run $pattern and report pass/fail summary only.')" \
310
+ "Alt: The context_protection policy in .rea/policy.yaml lists commands that must be delegated."
311
+ break
312
+ fi
313
+ done
306
314
 
307
315
  # ── 10. MEDIUM severity checks ────────────────────────────────────────────────
308
316
 
309
317
  # M1: npm install --force
310
- if printf '%s' "$CMD" | grep -qiE 'npm[[:space:]]+(install|i)[[:space:]].*--force'; then
318
+ if any_segment_matches "$CMD" 'npm[[:space:]]+(install|i)[[:space:]].*--force'; then
311
319
  add_medium \
312
320
  "npm install --force — bypasses dependency resolution" \
313
321
  "--force skips conflict checks and can install incompatible package versions." \
@@ -21,13 +21,11 @@ if ! command -v jq >/dev/null 2>&1; then
21
21
  fi
22
22
 
23
23
  # ── 3. HALT check ────────────────────────────────────────────────────────────
24
- REA_ROOT="${CLAUDE_PROJECT_DIR:-$(pwd)}"
25
- HALT_FILE="${REA_ROOT}/.rea/HALT"
26
- if [ -f "$HALT_FILE" ]; then
27
- printf 'REA HALT: %s\nAll agent operations suspended. Run: rea unfreeze\n' \
28
- "$(head -c 1024 "$HALT_FILE" 2>/dev/null || echo 'Reason unknown')" >&2
29
- exit 2
30
- fi
24
+ # 0.16.0: HALT check sourced from shared _lib/halt-check.sh.
25
+ # shellcheck source=_lib/halt-check.sh
26
+ source "$(dirname "$0")/_lib/halt-check.sh"
27
+ check_halt
28
+ REA_ROOT=$(rea_root)
31
29
 
32
30
  # ── 4. Parse command ──────────────────────────────────────────────────────────
33
31
  CMD=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
@@ -43,30 +41,60 @@ fi
43
41
  extract_packages() {
44
42
  local cmd="$1"
45
43
 
46
- # npm install/add with packages (skip flags and local paths)
47
- if printf '%s' "$cmd" | grep -qiE '(npm[[:space:]]+(install|i|add)|pnpm[[:space:]]+(add|install)|yarn[[:space:]]+add)[[:space:]]'; then
48
- # Extract the part after the install command
49
- local after_cmd
50
- after_cmd=$(printf '%s' "$cmd" | sed -E 's/.*(npm[[:space:]]+(install|i|add)|pnpm[[:space:]]+(add|install)|yarn[[:space:]]+add)[[:space:]]+//')
51
-
52
- # Split on spaces and filter
53
- for token in $after_cmd; do
54
- # Skip flags
55
- if [[ "$token" == -* ]]; then continue; fi
56
- # Skip local paths
57
- if [[ "$token" == ./* || "$token" == /* || "$token" == ../* ]]; then continue; fi
58
- # Skip empty
59
- if [[ -z "$token" ]]; then continue; fi
60
- # Strip version specifier for lookup
61
- local pkg_name
62
- pkg_name=$(printf '%s' "$token" | sed -E 's/@[^@/]+$//')
63
- # Handle scoped packages (@scope/name)
64
- if [[ -z "$pkg_name" ]]; then
65
- pkg_name="$token"
66
- fi
67
- printf '%s\n' "$pkg_name"
68
- done
69
- fi
44
+ # 0.15.0 fix: the previous parser ran `grep` against the entire bash
45
+ # command string with no segment boundary anchor. A heredoc body or
46
+ # commit-message containing `pnpm install` (e.g. inside
47
+ # `git commit -m "$(cat <<EOF ... pnpm install ... EOF)"`) matched the
48
+ # grep, the `.*` in the sed stripped up to that occurrence, and the rest
49
+ # of the command (`chore:`, `&&`, `||`, etc.) was passed to
50
+ # `npm view <token> name` and reported as missing packages. The hook
51
+ # then refused to commit perfectly innocent code.
52
+ #
53
+ # Fix: split the command on shell command separators (`;`, `&&`, `||`,
54
+ # `|`, newlines) and only run the install-detection on segments whose
55
+ # FIRST non-whitespace token is one of the install commands. Heredoc
56
+ # bodies inside `$()` substitutions are NOT split into separate segments
57
+ # the entire `$(cat <<EOF ... EOF)` is one token attached to the
58
+ # outer command but they're never the FIRST token on a segment, so
59
+ # the anchor rejects them.
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).
67
+ local segments
68
+ segments=$(printf '%s\n' "$cmd" | sed -E 's/(\|\||\&\&|;|\|)/\n/g')
69
+
70
+ while IFS= read -r segment; do
71
+ # Trim leading whitespace.
72
+ segment="${segment#"${segment%%[![:space:]]*}"}"
73
+ # Anchor to start: only match when the install command is the FIRST
74
+ # thing on the segment, optionally preceded by `sudo` / `exec` /
75
+ # `time` / etc.
76
+ if printf '%s' "$segment" | grep -qiE '^(sudo[[:space:]]+|exec[[:space:]]+|time[[:space:]]+)*(npm[[:space:]]+(install|i|add)|pnpm[[:space:]]+(add|install|i)|yarn[[:space:]]+add)[[:space:]]+'; then
77
+ # Strip the leading prefix wrappers + install command, leaving args.
78
+ local after_cmd
79
+ after_cmd=$(printf '%s' "$segment" | sed -E 's/^(sudo[[:space:]]+|exec[[:space:]]+|time[[:space:]]+)*(npm[[:space:]]+(install|i|add)|pnpm[[:space:]]+(add|install|i)|yarn[[:space:]]+add)[[:space:]]+//')
80
+
81
+ for token in $after_cmd; do
82
+ if [[ "$token" == -* ]]; then continue; fi
83
+ if [[ "$token" == ./* || "$token" == /* || "$token" == ../* ]]; then continue; fi
84
+ if [[ -z "$token" ]]; then continue; fi
85
+ # `npm view` can't validate `@workspace:*` / `link:` / `file:`
86
+ # prefixes (workspace protocols). Skip them — they're never npm
87
+ # registry packages.
88
+ if [[ "$token" == workspace:* || "$token" == link:* || "$token" == file:* || "$token" == git+* ]]; then continue; fi
89
+ local pkg_name
90
+ pkg_name=$(printf '%s' "$token" | sed -E 's/@[^@/]+$//')
91
+ if [[ -z "$pkg_name" ]]; then
92
+ pkg_name="$token"
93
+ fi
94
+ printf '%s\n' "$pkg_name"
95
+ done
96
+ fi
97
+ done <<< "$segments"
70
98
  }
71
99
 
72
100
  PACKAGES=$(extract_packages "$CMD")