@bookedsolid/rea 0.14.0 → 0.15.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',
207
207
  status: 'fail',
208
208
  detail: `missing: ${settingsPath}`,
209
209
  };
@@ -216,23 +216,29 @@ 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.15.0: matcher was widened from `Write|Edit` to `Write|Edit|MultiEdit`
220
+ // in 0.14.0; doctor's check missed the rename. Accept either form so
221
+ // pre-0.14.0 installs that haven't run `rea upgrade` still report
222
+ // accurately, but the canonical produced by `defaultDesiredHooks()` is
223
+ // the wider matcher.
224
+ if (!matchers.has('Write|Edit|MultiEdit') && !matchers.has('Write|Edit')) {
225
+ needs.push('Write|Edit|MultiEdit');
226
+ }
221
227
  if (needs.length === 0) {
222
228
  return {
223
- label: 'settings.json matchers cover Bash + Write|Edit',
229
+ label: 'settings.json matchers cover Bash + Write|Edit|MultiEdit',
224
230
  status: 'pass',
225
231
  };
226
232
  }
227
233
  return {
228
- label: 'settings.json matchers cover Bash + Write|Edit',
234
+ label: 'settings.json matchers cover Bash + Write|Edit|MultiEdit',
229
235
  status: 'fail',
230
236
  detail: `missing PreToolUse matchers: ${needs.join(', ')}`,
231
237
  };
232
238
  }
233
239
  catch (e) {
234
240
  return {
235
- label: 'settings.json matchers cover Bash + Write|Edit',
241
+ label: 'settings.json matchers cover Bash + Write|Edit|MultiEdit',
236
242
  status: 'fail',
237
243
  detail: e instanceof Error ? e.message : String(e),
238
244
  };
@@ -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',
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',
281
282
  hooks: [
282
283
  { type: 'command', command: `${base}/architecture-review-gate.sh`, timeout: 10000, statusMessage: 'Checking architecture impact...' },
283
284
  ],
@@ -0,0 +1,155 @@
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
+ printf '%s\n' "$cmd" | sed -E 's/(\|\||&&|;|\|)/\n/g'
57
+ }
58
+
59
+ # Strip leading whitespace and well-known command prefixes from a single
60
+ # segment. Returns the prefix-stripped form on stdout. Examples:
61
+ # " sudo pnpm install foo" → "pnpm install foo"
62
+ # "NODE_ENV=production pnpm add x" → "pnpm add x"
63
+ # "then pnpm add lodash" → "pnpm add lodash"
64
+ _rea_strip_prefix() {
65
+ local seg="$1"
66
+ # Trim leading whitespace.
67
+ seg="${seg#"${seg%%[![:space:]]*}"}"
68
+ # Strip ONE prefix at a time, looping. This handles compounds like
69
+ # `sudo NODE_ENV=production pnpm add foo`.
70
+ while :; do
71
+ case "$seg" in
72
+ sudo[[:space:]]*|exec[[:space:]]*|time[[:space:]]*|then[[:space:]]*|do[[:space:]]*|else[[:space:]]*)
73
+ # Drop the prefix word and any subsequent whitespace.
74
+ seg="${seg#* }"
75
+ seg="${seg#"${seg%%[![:space:]]*}"}"
76
+ ;;
77
+ *)
78
+ # Env-var assignment prefix (`KEY=value `) — only strip if the
79
+ # token before the first space looks like NAME=value.
80
+ if [[ "$seg" =~ ^[A-Za-z_][A-Za-z0-9_]*=[^[:space:]]+[[:space:]]+ ]]; then
81
+ seg="${seg#* }"
82
+ seg="${seg#"${seg%%[![:space:]]*}"}"
83
+ else
84
+ break
85
+ fi
86
+ ;;
87
+ esac
88
+ done
89
+ printf '%s' "$seg"
90
+ }
91
+
92
+ # Iterate every segment of $1 and invoke $2 (a function name) with the
93
+ # raw segment as $1 and the prefix-stripped form as $2. The callback's
94
+ # return value is honored: a non-zero return aborts the iteration and
95
+ # becomes the helper's return value.
96
+ for_each_segment() {
97
+ local cmd="$1"
98
+ local callback="$2"
99
+ local segment stripped rc
100
+ while IFS= read -r segment; do
101
+ stripped=$(_rea_strip_prefix "$segment")
102
+ "$callback" "$segment" "$stripped"
103
+ rc=$?
104
+ if [ "$rc" -ne 0 ]; then
105
+ return "$rc"
106
+ fi
107
+ done < <(_rea_split_segments "$cmd")
108
+ return 0
109
+ }
110
+
111
+ # Return 0 if any segment of $1 (after prefix-stripping) matches the
112
+ # extended regex $2 ANYWHERE (not anchored). Case-insensitive. Returns 1
113
+ # if no segment matches.
114
+ #
115
+ # Use this for patterns that may legitimately appear mid-segment, e.g.
116
+ # `Co-Authored-By:` in a commit message body. For "is the segment a
117
+ # call to <command>" use `any_segment_starts_with` instead — that
118
+ # anchors on the start so `echo "rm -rf foo"` doesn't trip an
119
+ # `rm -rf` detector.
120
+ any_segment_matches() {
121
+ local cmd="$1"
122
+ local pattern="$2"
123
+ local segment stripped
124
+ while IFS= read -r segment; do
125
+ stripped=$(_rea_strip_prefix "$segment")
126
+ if printf '%s' "$stripped" | grep -qiE "$pattern"; then
127
+ return 0
128
+ fi
129
+ done < <(_rea_split_segments "$cmd")
130
+ return 1
131
+ }
132
+
133
+ # Return 0 if any segment of $1 (after prefix-stripping) STARTS WITH
134
+ # the extended regex $2. Case-insensitive. Returns 1 if no segment
135
+ # starts with the pattern.
136
+ #
137
+ # This is the right shape for "is this segment a call to <command>"
138
+ # checks. `echo "rm -rf foo"` does NOT trigger an `rm -rf` detector
139
+ # because the segment starts with `echo`, not `rm`. Compare to
140
+ # `any_segment_matches`, which matches anywhere in the segment and
141
+ # would fire on the echo'd argument.
142
+ any_segment_starts_with() {
143
+ local cmd="$1"
144
+ local pattern="$2"
145
+ local segment stripped
146
+ while IFS= read -r segment; do
147
+ stripped=$(_rea_strip_prefix "$segment")
148
+ # `^` anchor + caller pattern. `(?:)` non-capturing group not
149
+ # supported in BSD ERE; we use a simple literal `^` prepend.
150
+ if printf '%s' "$stripped" | grep -qiE "^${pattern}"; then
151
+ return 0
152
+ fi
153
+ done < <(_rea_split_segments "$cmd")
154
+ return 1
155
+ }
@@ -0,0 +1,39 @@
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
+ # exact match. Mirrors the array in settings-protection.sh §6.
16
+ REA_PROTECTED_PATTERNS=(
17
+ '.claude/settings.json'
18
+ '.claude/settings.local.json'
19
+ '.husky/'
20
+ '.rea/policy.yaml'
21
+ '.rea/HALT'
22
+ )
23
+
24
+ # Test whether a project-relative path matches any protected pattern.
25
+ # Usage: if rea_path_is_protected ".rea/HALT"; then echo "blocked"; fi
26
+ # Returns 0 on match, 1 on no match.
27
+ rea_path_is_protected() {
28
+ local p="$1"
29
+ local pattern
30
+ for pattern in "${REA_PROTECTED_PATTERNS[@]}"; do
31
+ if [[ "$p" == "$pattern" ]]; then
32
+ return 0
33
+ fi
34
+ if [[ "$pattern" == */ ]] && [[ "$p" == "$pattern"* ]]; then
35
+ return 0
36
+ fi
37
+ done
38
+ return 1
39
+ }
@@ -50,14 +50,23 @@ if [[ -z "$CMD" ]]; then
50
50
  exit 0
51
51
  fi
52
52
 
53
+ # 0.15.0: source the shared shell-segment splitter. Pre-fix, the
54
+ # attribution patterns greped the FULL command — `git commit -m "Note:
55
+ # Co-Authored-By with AI was removed in 0.14"` matched and the commit
56
+ # was blocked even though the message was COMMENTING on attribution
57
+ # rather than including it. Per-segment anchoring scopes detection to
58
+ # segments whose first token is `git commit` / `gh pr create|edit`.
59
+ # shellcheck source=_lib/cmd-segments.sh
60
+ source "$(dirname "$0")/_lib/cmd-segments.sh"
61
+
53
62
  # ── 6. Check if this is a relevant command ────────────────────────────────────
54
63
  IS_RELEVANT=0
55
64
 
56
- if printf '%s' "$CMD" | grep -qiE 'gh[[:space:]]+pr[[:space:]]+(create|edit)'; then
65
+ if any_segment_matches "$CMD" 'gh[[:space:]]+pr[[:space:]]+(create|edit)'; then
57
66
  IS_RELEVANT=1
58
67
  fi
59
68
 
60
- if printf '%s' "$CMD" | grep -qiE 'git[[:space:]]+commit'; then
69
+ if any_segment_matches "$CMD" 'git[[:space:]]+commit'; then
61
70
  IS_RELEVANT=1
62
71
  fi
63
72
 
@@ -70,27 +79,27 @@ fi
70
79
  FOUND=0
71
80
 
72
81
  # Co-Authored-By with noreply@ email
73
- if printf '%s' "$CMD" | grep -qiE 'Co-Authored-By:.*noreply@'; then
82
+ if any_segment_matches "$CMD" 'Co-Authored-By:.*noreply@'; then
74
83
  FOUND=1
75
84
  fi
76
85
 
77
86
  # 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
87
+ 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
88
  FOUND=1
80
89
  fi
81
90
 
82
91
  # "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
92
+ 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
93
  FOUND=1
85
94
  fi
86
95
 
87
96
  # Markdown-linked attribution
88
- if printf '%s' "$CMD" | grep -qiE '\[Claude Code\]|\[GitHub Copilot\]|\[ChatGPT\]|\[Gemini\]|\[Cursor\]'; then
97
+ if any_segment_matches "$CMD" '\[Claude Code\]|\[GitHub Copilot\]|\[ChatGPT\]|\[Gemini\]|\[Cursor\]'; then
89
98
  FOUND=1
90
99
  fi
91
100
 
92
101
  # Emoji attribution
93
- if printf '%s' "$CMD" | grep -qE '🤖.*[Gg]enerated'; then
102
+ if any_segment_matches "$CMD" '🤖.*[Gg]enerated'; then
94
103
  FOUND=1
95
104
  fi
96
105
 
@@ -106,7 +106,15 @@ normalize_path() {
106
106
  if [[ "$p" == "$root"/* ]]; then
107
107
  p="${p#$root/}"
108
108
  fi
109
- p=$(printf '%s' "$p" | sed 's/%2[Ff]/\//g; s/%2[Ee]/./g; s/%20/ /g')
109
+ # 0.15.0 fix: include `\` (and `%5C` percent-encoded form) in the
110
+ # normalization. Without this, a path like
111
+ # `.github\workflows\release.yml` under Windows / Git Bash reaches
112
+ # that file but compares as a different string than
113
+ # `.github/workflows/release.yml`, missing the literal blocked-paths
114
+ # match. Mirrors settings-protection.sh §4 which has had backslash
115
+ # normalization since 0.10.x.
116
+ p=$(printf '%s' "$p" | sed 's/%2[Ff]/\//g; s/%2[Ee]/./g; s/%20/ /g; s/%5[Cc]/\\/g')
117
+ p=$(printf '%s' "$p" | tr '\\\\' '/')
110
118
  p="${p#./}"
111
119
  printf '%s' "$p"
112
120
  }
@@ -23,8 +23,12 @@ 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. Same bypass shape as the secret-scanner MultiEdit issue
30
+ # fixed in 0.14.0; this is the second hook in the same family.
31
+ if [[ "$TOOL_NAME" != "Write" && "$TOOL_NAME" != "Edit" && "$TOOL_NAME" != "MultiEdit" ]]; then
28
32
  exit 0
29
33
  fi
30
34
 
@@ -37,12 +41,22 @@ fi
37
41
 
38
42
  require_jq
39
43
 
40
- # Extract the content being written
44
+ # Extract the content being written. MultiEdit content lives at
45
+ # `tool_input.edits[].new_string` (an array, not a scalar) — see the
46
+ # corresponding extraction in hooks/secret-scanner.sh. We use the same
47
+ # defensive coercion (`tostring` + `if type=="array" then . else [] end`)
48
+ # so a malformed payload fails closed: either yields the empty string
49
+ # (no scan needed, exit 0) or yields a pattern-scannable string.
41
50
  if [[ "$TOOL_NAME" == "Write" ]]; then
42
51
  CONTENT=$(echo "$INPUT" | jq -r '.tool_input.content // ""')
43
- else
44
- # For Edit: check the new_string being inserted
52
+ elif [[ "$TOOL_NAME" == "Edit" ]]; then
45
53
  CONTENT=$(echo "$INPUT" | jq -r '.tool_input.new_string // ""')
54
+ else
55
+ CONTENT=$(echo "$INPUT" | jq -r '
56
+ (.tool_input.edits // [] | if type=="array" then . else [] end)
57
+ | map((.new_string // "") | tostring)
58
+ | join("\n")
59
+ ')
46
60
  fi
47
61
 
48
62
  # ─── 1. SECURITY DISCLOSURE CHECK ───────────────────────────────────────────
@@ -90,6 +104,19 @@ fi
90
104
  #
91
105
  # A changeset without valid frontmatter is silently ignored by the changesets
92
106
  # tool — the package bump and CHANGELOG entry never appear in the release.
107
+ #
108
+ # 0.15.0 fix: skip frontmatter validation for MultiEdit. MultiEdit's
109
+ # `tool_input.edits[].new_string` payload is a list of partial string
110
+ # replacements, not the full file body — running the frontmatter
111
+ # validator against the concatenation of new_strings would reject every
112
+ # legitimate MultiEdit on an existing changeset (none of the edit
113
+ # fragments individually contains a frontmatter block, even though the
114
+ # resulting file does). The disclosure scan above still runs on
115
+ # MultiEdit content because GHSA/CVE patterns match per-fragment without
116
+ # any structural assumption.
117
+ if [[ "$TOOL_NAME" == "MultiEdit" ]]; then
118
+ exit 0
119
+ fi
93
120
 
94
121
  # Must start with ---
95
122
  if ! echo "$CONTENT" | head -1 | grep -qE '^---'; then
@@ -107,9 +134,20 @@ Brief description of what changed and why (close #N if applicable).
107
134
  Bump types: patch (bug fix/security), minor (new feature), major (breaking change)"
108
135
  fi
109
136
 
110
- # Must have at least one package bump entry and a closing ---
137
+ # Must have at least one package bump entry and a closing ---.
138
+ # 0.15.0 fix: accept single-quoted, double-quoted, AND unquoted package
139
+ # names (all three are valid YAML for the same string). Pre-fix the
140
+ # regex required single quotes, so a tool or human authoring the
141
+ # changeset with `"@scope/name": patch` was rejected as malformed even
142
+ # though the Changesets tool itself accepts every form.
143
+ #
144
+ # Codex round-1 P2-1 fix: explicit-alternation form (no backref) so
145
+ # the unquoted variant matches on BSD grep too. The earlier
146
+ # `^([\"']?)[^\"']+\1: ...` shape relied on backref-with-empty-capture
147
+ # semantics that BSD's grep rejects when the capture group's `?` made
148
+ # it absent — quoted forms matched on macOS but unquoted did not.
111
149
  FRONTMATTER=$(echo "$CONTENT" | awk '/^---/{count++; if(count==2){exit} next} count==1{print}')
112
- if ! echo "$FRONTMATTER" | grep -qE "^'.+': (patch|minor|major)"; then
150
+ if ! echo "$FRONTMATTER" | grep -qE "^(\"[^\"]+\"|'[^']+'|[^\"'[:space:]]+): (patch|minor|major)"; then
113
151
  json_output "block" \
114
152
  "CHANGESET FORMAT GATE: Frontmatter does not contain a valid package bump entry.
115
153
 
@@ -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
 
@@ -84,26 +93,21 @@ add_medium() {
84
93
  }
85
94
 
86
95
  # ── 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
- }
96
+ # (Migrated to `_lib/cmd-segments.sh::any_segment_matches` as of 0.15.0.
97
+ # The previous inline helper was defined here but never called — H3-H17
98
+ # all greped the WHOLE command, which false-positived on heredoc bodies
99
+ # and commit messages mentioning trigger words. Migration: every check
100
+ # now uses `any_segment_matches "$CMD" PATTERN` with the helper sourced
101
+ # at the top of this file.)
98
102
 
99
103
  # ── 8. Smart exclusion flags ──────────────────────────────────────────────────
100
104
  CMD_IS_REBASE_SAFE=0
101
- if printf '%s' "$CMD" | grep -qiE 'git[[:space:]]+(rebase)[[:space:]].*(--abort|--continue)'; then
105
+ if any_segment_starts_with "$CMD" 'git[[:space:]]+(rebase)[[:space:]].*(--abort|--continue)'; then
102
106
  CMD_IS_REBASE_SAFE=1
103
107
  fi
104
108
 
105
109
  CMD_IS_CLEAN_DRY=0
106
- if printf '%s' "$CMD" | grep -qiE 'git[[:space:]]+clean.*([ \t]-n|--dry-run)'; then
110
+ if any_segment_starts_with "$CMD" 'git[[:space:]]+clean.*([ \t]-n|--dry-run)'; then
107
111
  CMD_IS_CLEAN_DRY=1
108
112
  fi
109
113
 
@@ -111,26 +115,41 @@ fi
111
115
 
112
116
  # H1: git push --force or -f (per-segment — prevents --force-with-lease poisoning)
113
117
  # 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
118
+ # 0.15.0: also catches `git push origin +<branch>` (refspec-prefix force-push
119
+ # shorthand) which the previous version missed.
120
+ _h1_check() {
121
+ local _raw="$1" SEGMENT="$2"
122
+ [[ -z "$SEGMENT" ]] && return 0
123
+ # 0.15.0 codex P1 fix: anchor on `^git push`. Pre-fix the unanchored
124
+ # match meant `echo "git push --force is bad"` triggered H1 even
125
+ # though no actual push was happening (the segment after prefix-strip
126
+ # was `echo "..."`, not `git push`). Anchoring scopes detection to
127
+ # segments whose first token IS git push.
128
+ printf '%s' "$SEGMENT" | grep -qiE '^git[[:space:]]+push([[:space:]]|$)' || return 0
129
+ # Skip segments that use the safe --force-with-lease.
130
+ if printf '%s' "$SEGMENT" | grep -qiE -- '--force-with-lease'; then
131
+ return 0
120
132
  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
133
+ # 0.15.0 codex P1 fix: combined-flag forms (`-fu`, `-uf`, `-Fu`) and
134
+ # long-form `--force=value` were not caught by the previous
135
+ # `-f[[:space:]]` shape. The flag-cluster pattern `-[a-zA-Z]*f[a-zA-Z]*`
136
+ # (followed by space or EOS) mirrors how H11 handles rm flag clusters.
137
+ # The refspec-prefix `+` on a branch name is git's force-push shorthand.
138
+ if printf '%s' "$SEGMENT" | grep -qiE -- '--force([[:space:]]|=|$)' || \
139
+ printf '%s' "$SEGMENT" | grep -qiE -- '(^|[[:space:]])-[a-zA-Z]*f[a-zA-Z]*([[:space:]]|$)' || \
140
+ printf '%s' "$SEGMENT" | grep -qE -- '[[:space:]]\+[A-Za-z0-9_./-]'; then
123
141
  add_high \
124
142
  "git push --force — force push detected" \
125
143
  "Force-pushing rewrites public history and breaks collaborators' local copies." \
126
144
  "Alt: Use 'git push --force-with-lease' — blocks if upstream has new commits you haven't pulled."
127
- break
128
145
  fi
129
- done < <(printf '%s' "$CMD" | sed 's/&&/\n/g; s/||/\n/g; s/;/\n/g')
146
+ return 0
147
+ }
148
+ for_each_segment "$CMD" _h1_check
130
149
 
131
150
  # H2: git rebase — advisory (MEDIUM)
132
151
  if [[ $CMD_IS_REBASE_SAFE -eq 0 ]]; then
133
- if printf '%s' "$CMD" | grep -qiE 'git[[:space:]]+rebase([[:space:]]|$)'; then
152
+ if any_segment_starts_with "$CMD" 'git[[:space:]]+rebase([[:space:]]|$)'; then
134
153
  add_medium \
135
154
  "git rebase — rewrites commit history (advisory)" \
136
155
  "Rebase changes commit SHAs. Safe on local feature branches; dangerous on shared/published branches." \
@@ -140,7 +159,7 @@ if [[ $CMD_IS_REBASE_SAFE -eq 0 ]]; then
140
159
  fi
141
160
 
142
161
  # H3: git checkout -- .
143
- if printf '%s' "$CMD" | grep -qiE 'git[[:space:]]+checkout[[:space:]]+--[[:space:]]+\.'; then
162
+ if any_segment_starts_with "$CMD" 'git[[:space:]]+checkout[[:space:]]+--[[:space:]]+\.'; then
144
163
  add_high \
145
164
  "git checkout -- . — discards all uncommitted changes" \
146
165
  "Overwrites working tree changes with HEAD. Uncommitted work is lost permanently." \
@@ -148,8 +167,8 @@ if printf '%s' "$CMD" | grep -qiE 'git[[:space:]]+checkout[[:space:]]+--[[:space
148
167
  fi
149
168
 
150
169
  # 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
170
+ if any_segment_starts_with "$CMD" 'git[[:space:]]+restore[[:space:]].*[[:space:]]\.([[:space:]]|$)' || \
171
+ any_segment_starts_with "$CMD" 'git[[:space:]]+restore[[:space:]]+\.[[:space:]]*$'; then
153
172
  add_high \
154
173
  "git restore . — discards all uncommitted changes" \
155
174
  "Restores every tracked file to HEAD, permanently discarding all working tree modifications." \
@@ -158,7 +177,7 @@ fi
158
177
 
159
178
  # H5: git clean -f
160
179
  if [[ $CMD_IS_CLEAN_DRY -eq 0 ]]; then
161
- if printf '%s' "$CMD" | grep -qiE 'git[[:space:]]+clean[[:space:]]+-[a-zA-Z]*f'; then
180
+ if any_segment_starts_with "$CMD" 'git[[:space:]]+clean[[:space:]]+-[a-zA-Z]*f'; then
162
181
  add_high \
163
182
  "git clean -f — removes untracked files" \
164
183
  "Permanently deletes untracked files from the working tree. Cannot be undone via git." \
@@ -167,7 +186,7 @@ if [[ $CMD_IS_CLEAN_DRY -eq 0 ]]; then
167
186
  fi
168
187
 
169
188
  # H6: DROP TABLE or DROP DATABASE in psql
170
- if printf '%s' "$CMD" | grep -qiE '(psql|pgcli)[^|&;]*DROP[[:space:]]+(TABLE|DATABASE|SCHEMA)'; then
189
+ if any_segment_matches "$CMD" '(psql|pgcli)[^|&;]*DROP[[:space:]]+(TABLE|DATABASE|SCHEMA)'; then
171
190
  add_high \
172
191
  "DROP TABLE/DATABASE via psql — destructive DDL" \
173
192
  "Running destructive DDL directly in psql bypasses migration pipeline safety checks." \
@@ -175,7 +194,7 @@ if printf '%s' "$CMD" | grep -qiE '(psql|pgcli)[^|&;]*DROP[[:space:]]+(TABLE|DAT
175
194
  fi
176
195
 
177
196
  # H7: kill -9 with pgrep subshell
178
- if printf '%s' "$CMD" | grep -qiE 'kill[[:space:]]+-9[[:space:]]+(\$\(|`)'; then
197
+ if any_segment_starts_with "$CMD" 'kill[[:space:]]+-9[[:space:]]+(\$\(|`)'; then
179
198
  add_high \
180
199
  "kill -9 with pgrep subshell — aggressive process termination" \
181
200
  "Sends SIGKILL to processes matched by name, which may kill unintended processes." \
@@ -183,7 +202,7 @@ if printf '%s' "$CMD" | grep -qiE 'kill[[:space:]]+-9[[:space:]]+(\$\(|`)'; then
183
202
  fi
184
203
 
185
204
  # H8: killall -9
186
- if printf '%s' "$CMD" | grep -qiE 'killall[[:space:]]+-9[[:space:]]+\S'; then
205
+ if any_segment_starts_with "$CMD" 'killall[[:space:]]+-9[[:space:]]+\S'; then
187
206
  add_high \
188
207
  "killall -9 — SIGKILL all matching processes" \
189
208
  "Immediately terminates all processes with the given name without cleanup." \
@@ -191,7 +210,7 @@ if printf '%s' "$CMD" | grep -qiE 'killall[[:space:]]+-9[[:space:]]+\S'; then
191
210
  fi
192
211
 
193
212
  # H9: git commit --no-verify
194
- if printf '%s' "$CMD" | grep -qiE 'git[[:space:]]+commit.*--no-verify'; then
213
+ if any_segment_starts_with "$CMD" 'git[[:space:]]+commit.*--no-verify'; then
195
214
  add_high \
196
215
  "git commit --no-verify — skipping pre-commit hooks" \
197
216
  "Bypasses all pre-commit safety gates including secret scanning and linting." \
@@ -199,7 +218,7 @@ if printf '%s' "$CMD" | grep -qiE 'git[[:space:]]+commit.*--no-verify'; then
199
218
  fi
200
219
 
201
220
  # 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
221
+ if any_segment_matches "$CMD" '(^|[[:space:];]|&&|\|\|)HUSKY=0[[:space:]]+git[[:space:]]+(commit|push|tag)'; then
203
222
  add_high \
204
223
  "HUSKY=0 — bypasses all husky git hooks" \
205
224
  "Setting HUSKY=0 disables pre-commit, commit-msg, and pre-push safety gates without --no-verify." \
@@ -208,13 +227,18 @@ fi
208
227
 
209
228
  # H11: rm -rf with broad targets
210
229
  # 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
230
+ # 0.15.0 fix: anchored each target on word boundary (whitespace-or-EOS).
231
+ # The previous form had a bare `\.` which matched `rm -rf .git/foo`
232
+ # (legitimate `.git/`-tree cleanup). Each token now requires either
233
+ # end-of-string or whitespace after so `.` alone matches `rm -rf .`
234
+ # (the cwd, dangerous) but NOT `rm -rf .git/foo`.
235
+ BROAD_TARGETS='(\/|~\/|\.\/\*|\*|\.|src|dist|build|node_modules)([[:space:]]|$)'
236
+ if any_segment_starts_with "$CMD" "rm[[:space:]]+-[a-zA-Z]*r[a-zA-Z]*f[[:space:]]+${BROAD_TARGETS}" || \
237
+ any_segment_starts_with "$CMD" "rm[[:space:]]+-[a-zA-Z]*f[a-zA-Z]*r[[:space:]]+${BROAD_TARGETS}" || \
238
+ any_segment_starts_with "$CMD" "rm[[:space:]]+-[a-zA-Z]*r[[:space:]]+-[a-zA-Z]*f[[:space:]]+${BROAD_TARGETS}" || \
239
+ any_segment_starts_with "$CMD" "rm[[:space:]]+-[a-zA-Z]*f[[:space:]]+-[a-zA-Z]*r[[:space:]]+${BROAD_TARGETS}" || \
240
+ any_segment_starts_with "$CMD" "rm[[:space:]]+--recursive[[:space:]]+--force[[:space:]]+${BROAD_TARGETS}" || \
241
+ any_segment_starts_with "$CMD" "rm[[:space:]]+--force[[:space:]]+--recursive[[:space:]]+${BROAD_TARGETS}"; then
218
242
  add_high \
219
243
  "rm -rf with broad target — mass file deletion" \
220
244
  "Permanently deletes files and directories. Cannot be undone." \
@@ -222,7 +246,7 @@ if printf '%s' "$CMD" | grep -qiE "rm[[:space:]]+-[a-zA-Z]*r[a-zA-Z]*f[[:space:]
222
246
  fi
223
247
 
224
248
  # 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
249
+ if any_segment_matches "$CMD" '(curl|wget)[^|]*\|[[:space:]]*(bash|sh|zsh|fish)'; then
226
250
  add_high \
227
251
  "curl/wget piped to shell — remote code execution" \
228
252
  "Executing remote scripts without inspection is a major supply chain risk." \
@@ -230,7 +254,7 @@ if printf '%s' "$CMD" | grep -qiE '(curl|wget)[^|]*\|[[:space:]]*(bash|sh|zsh|fi
230
254
  fi
231
255
 
232
256
  # H13: git push --no-verify — bypasses pre-push hooks
233
- if printf '%s' "$CMD" | grep -qiE 'git[[:space:]]+push.*--no-verify'; then
257
+ if any_segment_starts_with "$CMD" 'git[[:space:]]+push.*--no-verify'; then
234
258
  add_high \
235
259
  "git push --no-verify — skipping pre-push hooks" \
236
260
  "Bypasses all pre-push safety gates including CI checks." \
@@ -238,7 +262,7 @@ if printf '%s' "$CMD" | grep -qiE 'git[[:space:]]+push.*--no-verify'; then
238
262
  fi
239
263
 
240
264
  # H14: git -c core.hooksPath= — redirects or disables hook execution
241
- if printf '%s' "$CMD" | grep -qiE 'git[[:space:]]+-c[[:space:]]+core\.hookspath'; then
265
+ if any_segment_starts_with "$CMD" 'git[[:space:]]+-c[[:space:]]+core\.hookspath'; then
242
266
  add_high \
243
267
  "git -c core.hooksPath — overriding hooks directory" \
244
268
  "Redirecting the hooks path can disable all safety hooks." \
@@ -246,7 +270,7 @@ if printf '%s' "$CMD" | grep -qiE 'git[[:space:]]+-c[[:space:]]+core\.hookspath'
246
270
  fi
247
271
 
248
272
  # H15: REA_BYPASS env var — attempted escape hatch
249
- if printf '%s' "$CMD" | grep -qiE '(^|[[:space:];]|&&|\|\|)REA_BYPASS[[:space:]]*='; then
273
+ if any_segment_matches "$CMD" '(^|[[:space:];]|&&|\|\|)REA_BYPASS[[:space:]]*='; then
250
274
  add_high \
251
275
  "REA_BYPASS env var — unauthorized bypass attempt" \
252
276
  "Setting REA_BYPASS is not a supported escape mechanism and indicates a bypass attempt." \
@@ -254,7 +278,7 @@ if printf '%s' "$CMD" | grep -qiE '(^|[[:space:];]|&&|\|\|)REA_BYPASS[[:space:]]
254
278
  fi
255
279
 
256
280
  # 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
281
+ if any_segment_matches "$CMD" '(alias|function)[[:space:]]+[a-zA-Z_]+.*(--(no-verify|force)|HUSKY=0|core\.hookspath)'; then
258
282
  add_high \
259
283
  "Alias/function definition with bypass — circumventing safety gates" \
260
284
  "Defining aliases or functions that embed bypass flags defeats safety hooks." \
@@ -307,7 +331,7 @@ fi
307
331
  # ── 10. MEDIUM severity checks ────────────────────────────────────────────────
308
332
 
309
333
  # M1: npm install --force
310
- if printf '%s' "$CMD" | grep -qiE 'npm[[:space:]]+(install|i)[[:space:]].*--force'; then
334
+ if any_segment_matches "$CMD" 'npm[[:space:]]+(install|i)[[:space:]].*--force'; then
311
335
  add_medium \
312
336
  "npm install --force — bypasses dependency resolution" \
313
337
  "--force skips conflict checks and can install incompatible package versions." \
@@ -43,30 +43,60 @@ fi
43
43
  extract_packages() {
44
44
  local cmd="$1"
45
45
 
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
46
+ # 0.15.0 fix: the previous parser ran `grep` against the entire bash
47
+ # command string with no segment boundary anchor. A heredoc body or
48
+ # commit-message containing `pnpm install` (e.g. inside
49
+ # `git commit -m "$(cat <<EOF ... pnpm install ... EOF)"`) matched the
50
+ # grep, the `.*` in the sed stripped up to that occurrence, and the rest
51
+ # of the command (`chore:`, `&&`, `||`, etc.) was passed to
52
+ # `npm view <token> name` and reported as missing packages. The hook
53
+ # then refused to commit perfectly innocent code.
54
+ #
55
+ # Fix: split the command on shell command separators (`;`, `&&`, `||`,
56
+ # `|`, newlines) and only run the install-detection on segments whose
57
+ # FIRST non-whitespace token is one of the install commands. Heredoc
58
+ # bodies inside `$()` substitutions are NOT split into separate segments
59
+ # the entire `$(cat <<EOF ... EOF)` is one token attached to the
60
+ # outer command but they're never the FIRST token on a segment, so
61
+ # the anchor rejects them.
62
+
63
+ # Tokenize on shell separators. Each `IFS=` entry becomes a separate
64
+ # segment we can anchor against. We use bash's `mapfile` with a sed
65
+ # to inject newlines at separators; awk-based splitting handles the
66
+ # quoting heuristic well enough for the realistic cases (agent-issued
67
+ # commands rarely have separators inside single-quoted strings that
68
+ # would confuse this).
69
+ local segments
70
+ segments=$(printf '%s\n' "$cmd" | sed -E 's/(\|\||\&\&|;|\|)/\n/g')
71
+
72
+ while IFS= read -r segment; do
73
+ # Trim leading whitespace.
74
+ segment="${segment#"${segment%%[![:space:]]*}"}"
75
+ # Anchor to start: only match when the install command is the FIRST
76
+ # thing on the segment, optionally preceded by `sudo` / `exec` /
77
+ # `time` / etc.
78
+ 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
79
+ # Strip the leading prefix wrappers + install command, leaving args.
80
+ local after_cmd
81
+ 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:]]+//')
82
+
83
+ for token in $after_cmd; do
84
+ if [[ "$token" == -* ]]; then continue; fi
85
+ if [[ "$token" == ./* || "$token" == /* || "$token" == ../* ]]; then continue; fi
86
+ if [[ -z "$token" ]]; then continue; fi
87
+ # `npm view` can't validate `@workspace:*` / `link:` / `file:`
88
+ # prefixes (workspace protocols). Skip them — they're never npm
89
+ # registry packages.
90
+ if [[ "$token" == workspace:* || "$token" == link:* || "$token" == file:* || "$token" == git+* ]]; then continue; fi
91
+ local pkg_name
92
+ pkg_name=$(printf '%s' "$token" | sed -E 's/@[^@/]+$//')
93
+ if [[ -z "$pkg_name" ]]; then
94
+ pkg_name="$token"
95
+ fi
96
+ printf '%s\n' "$pkg_name"
97
+ done
98
+ fi
99
+ done <<< "$segments"
70
100
  }
71
101
 
72
102
  PACKAGES=$(extract_packages "$CMD")
@@ -17,6 +17,12 @@
17
17
 
18
18
  set -uo pipefail
19
19
 
20
+ # Source shared shell-segment splitter (0.15.0). Replaces full-command
21
+ # grep that false-positives on commit messages mentioning `.env` (e.g.
22
+ # `git commit -m "stop reading .env via cat"`).
23
+ # shellcheck source=_lib/cmd-segments.sh
24
+ source "$(dirname "$0")/_lib/cmd-segments.sh"
25
+
20
26
  INPUT=$(cat)
21
27
 
22
28
  # ── Dependency check ──────────────────────────────────────────────────────────
@@ -70,17 +76,21 @@ PATTERN_ENV_FILE='(\.env[a-zA-Z0-9._-]*|\.envrc)([[:space:]]|"|'"'"'|$)'
70
76
  MATCHES_UTILITY=0
71
77
  MATCHES_ENV_FILE=0
72
78
 
73
- if printf '%s' "$CMD" | grep -qE "$PATTERN_UTILITY"; then
79
+ # 0.15.0: per-segment match. Pre-fix this greped the FULL command which
80
+ # false-positived on commit messages: `git commit -m "stop reading .env
81
+ # files via cat"` matched both PATTERN_UTILITY (cat) and PATTERN_ENV_FILE
82
+ # (.env) and the hook blocked a perfectly safe commit.
83
+ if any_segment_matches "$CMD" "$PATTERN_UTILITY"; then
74
84
  MATCHES_UTILITY=1
75
85
  fi
76
86
 
77
- if printf '%s' "$CMD" | grep -qE "$PATTERN_ENV_FILE"; then
87
+ if any_segment_matches "$CMD" "$PATTERN_ENV_FILE"; then
78
88
  MATCHES_ENV_FILE=1
79
89
  fi
80
90
 
81
91
  # Direct source/cp of .env files — always block
82
- if printf '%s' "$CMD" | grep -qE "$PATTERN_SOURCE" || \
83
- printf '%s' "$CMD" | grep -qE "$PATTERN_CP_ENV"; then
92
+ if any_segment_matches "$CMD" "$PATTERN_SOURCE" || \
93
+ any_segment_matches "$CMD" "$PATTERN_CP_ENV"; then
84
94
  TRUNCATED_CMD=$(truncate_cmd "$CMD")
85
95
  {
86
96
  printf 'ENV FILE PROTECTION: Direct sourcing or copying of .env files is blocked.\n'
@@ -0,0 +1,213 @@
1
+ #!/bin/bash
2
+ # PreToolUse hook: protected-paths-bash-gate.sh
3
+ # Fires BEFORE every Bash tool call.
4
+ # Refuses Bash commands that write to PROTECTED_PATTERNS via shell
5
+ # redirection or write-flag utilities — the kill-switch and policy
6
+ # files MUST be unreachable via any tool surface, including Bash.
7
+ #
8
+ # Pre-0.15.0, settings-protection.sh §6 protected `.rea/HALT`,
9
+ # `.rea/policy.yaml`, `.claude/settings.json`, `.husky/*` against
10
+ # Write/Edit/MultiEdit tool calls. But shell redirects bypassed it
11
+ # entirely:
12
+ #
13
+ # printf '...' > .rea/HALT # bypass — Bash matcher only
14
+ # tee .rea/policy.yaml < new.yaml # bypass
15
+ # cp new-settings.json .claude/settings.json
16
+ # sed -i '' '/foo/d' .husky/pre-push
17
+ # dd of=.rea/HALT
18
+ #
19
+ # This hook closes that gap by detecting redirect/write patterns
20
+ # whose target matches the same `_lib/protected-paths.sh` allowlist.
21
+ #
22
+ # Exit codes:
23
+ # 0 = no protected-path write detected — allow
24
+ # 2 = protected-path write via Bash detected — block
25
+
26
+ set -uo pipefail
27
+
28
+ # shellcheck source=_lib/protected-paths.sh
29
+ source "$(dirname "$0")/_lib/protected-paths.sh"
30
+ # shellcheck source=_lib/cmd-segments.sh
31
+ source "$(dirname "$0")/_lib/cmd-segments.sh"
32
+
33
+ INPUT=$(cat)
34
+
35
+ if ! command -v jq >/dev/null 2>&1; then
36
+ printf 'REA ERROR: jq is required but not installed.\n' >&2
37
+ exit 2
38
+ fi
39
+
40
+ REA_ROOT="${CLAUDE_PROJECT_DIR:-$(pwd)}"
41
+
42
+ # HALT check — uniform with other hooks.
43
+ HALT_FILE="${REA_ROOT}/.rea/HALT"
44
+ if [ -f "$HALT_FILE" ]; then
45
+ printf 'REA HALT: %s\nAll agent operations suspended. Run: rea unfreeze\n' \
46
+ "$(head -c 1024 "$HALT_FILE" 2>/dev/null || echo 'Reason unknown')" >&2
47
+ exit 2
48
+ fi
49
+
50
+ CMD=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
51
+ if [[ -z "$CMD" ]]; then
52
+ exit 0
53
+ fi
54
+
55
+ # Normalize a path token: strip enclosing quotes, strip leading
56
+ # `$REA_ROOT/`, strip leading `./`. The result is project-relative
57
+ # for matching against REA_PROTECTED_PATTERNS.
58
+ _normalize_target() {
59
+ local t="$1"
60
+ # Strip matching surrounding quotes.
61
+ if [[ "$t" =~ ^\"(.*)\"$ ]]; then t="${BASH_REMATCH[1]}"; fi
62
+ if [[ "$t" =~ ^\'(.*)\'$ ]]; then t="${BASH_REMATCH[1]}"; fi
63
+ # Strip $REA_ROOT prefix (with or without trailing slash).
64
+ if [[ "$t" == "$REA_ROOT"/* ]]; then t="${t#"$REA_ROOT"/}"; fi
65
+ # Strip leading ./
66
+ while [[ "$t" == ./* ]]; do t="${t#./}"; done
67
+ printf '%s' "$t"
68
+ }
69
+
70
+ # Refuse and exit 2 with a uniform error message.
71
+ _refuse() {
72
+ local pattern="$1" target="$2" segment="$3"
73
+ {
74
+ printf 'PROTECTED PATH (bash): write to a package-managed file blocked\n'
75
+ printf '\n'
76
+ printf ' Pattern matched: %s\n' "$pattern"
77
+ printf ' Resolved target: %s\n' "$target"
78
+ printf ' Segment: %s\n' "$segment"
79
+ printf '\n'
80
+ printf ' Rule: protected paths (kill-switch, policy.yaml, settings.json,\n'
81
+ printf ' .husky/*) are unreachable via Bash redirects too — not just\n'
82
+ printf ' Write/Edit/MultiEdit. To modify, a human must edit directly.\n'
83
+ } >&2
84
+ exit 2
85
+ }
86
+
87
+ # Inspect one segment for redirect / write patterns and refuse if the
88
+ # target matches any protected pattern.
89
+ _check_segment() {
90
+ local _raw="$1" segment="$2"
91
+ [[ -z "$segment" ]] && return 0
92
+
93
+ local target_token=""
94
+ local detected_form=""
95
+
96
+ # bash `[[ =~ ]]` regex literals with `|` and `(...)` parsed inline
97
+ # confuse some bash versions on macOS. Use named variables for each
98
+ # pattern so the literal stays in a string context only.
99
+ local re_redirect='(^|[[:space:]])(&>|2>>|2>|>>|>)[[:space:]]*([^[:space:]&|;<>]+)'
100
+ local re_cpmv='(^|[[:space:]])(cp|mv)[[:space:]]+[^&|;<>]+[[:space:]]([^[:space:]&|;<>]+)[[:space:]]*$'
101
+ local re_sed='(^|[[:space:]])sed[[:space:]]+(-[a-zA-Z]*i[a-zA-Z]*[^[:space:]]*)[[:space:]]+[^&|;<>]+[[:space:]]([^[:space:]&|;<>]+)[[:space:]]*$'
102
+ local re_dd='(^|[[:space:]])dd[[:space:]]+[^&|;<>]*of=([^[:space:]&|;<>]+)'
103
+ # 0.15.0 codex P1 fix: replaced the bash-3.2-broken `(...)*` pattern
104
+ # for tee/truncate flag-skipping with a token-walk approach that
105
+ # works across BSD bash 3.2 and GNU bash 4+. Walks every token after
106
+ # the command, skips flags (single-dash short, double-dash long with
107
+ # optional =value), returns the first non-flag token as the target.
108
+
109
+ if [[ "$segment" =~ $re_redirect ]]; then
110
+ target_token="${BASH_REMATCH[3]}"
111
+ detected_form="redirect ${BASH_REMATCH[2]}"
112
+ elif [[ "$segment" =~ $re_cpmv ]]; then
113
+ target_token="${BASH_REMATCH[3]}"
114
+ detected_form="${BASH_REMATCH[2]}"
115
+ elif [[ "$segment" =~ $re_sed ]]; then
116
+ target_token="${BASH_REMATCH[3]}"
117
+ detected_form="sed -i"
118
+ elif [[ "$segment" =~ $re_dd ]]; then
119
+ target_token="${BASH_REMATCH[2]}"
120
+ detected_form="dd of="
121
+ else
122
+ # tee / truncate / install / ln — token-walk for cross-bash safety.
123
+ # Read tokens, find the command, then return the first non-flag arg.
124
+ local prev_word="" found_cmd=""
125
+ local _seg_for_walk="$segment"
126
+ # Strip leading whitespace.
127
+ _seg_for_walk="${_seg_for_walk#"${_seg_for_walk%%[![:space:]]*}"}"
128
+ # shellcheck disable=SC2086
129
+ set -- $_seg_for_walk
130
+ while [ "$#" -gt 0 ]; do
131
+ local tok="$1"
132
+ shift
133
+ if [[ -z "$found_cmd" ]]; then
134
+ case "$tok" in
135
+ tee|truncate|install|ln)
136
+ found_cmd="$tok"
137
+ ;;
138
+ esac
139
+ prev_word="$tok"
140
+ continue
141
+ fi
142
+ # We're inside the command's argv. Skip flags.
143
+ case "$tok" in
144
+ --) continue ;;
145
+ --*=*) continue ;;
146
+ --*)
147
+ # Long flag — may take a value as the NEXT token (we don't
148
+ # know which long options take values). For safety, skip
149
+ # only known no-value long flags; otherwise consume the
150
+ # next token too if it looks like a value.
151
+ case "$tok" in
152
+ --append|--ignore-interrupts|--no-clobber|--force|--no-target-directory|--symbolic|--no-dereference|--reference=*) continue ;;
153
+ *) shift 2>/dev/null || true; continue ;;
154
+ esac
155
+ ;;
156
+ -*)
157
+ # Short flag cluster. Skip. truncate -s SIZE — `-s` is a flag,
158
+ # SIZE is its arg. We're conservative: skip the next token if
159
+ # the flag cluster's last char is one of the size-bearing
160
+ # flags (truncate -s, install -m, ln -t).
161
+ case "$tok" in
162
+ -s*|-m*|-o*|-g*|-t*) shift 2>/dev/null || true ;;
163
+ esac
164
+ continue
165
+ ;;
166
+ *)
167
+ # First non-flag token — this is the target (or, for cp/mv-
168
+ # like commands, the first source; the cpmv detector above
169
+ # handles those separately). We treat ALL non-flag args as
170
+ # potential targets and check each — that catches
171
+ # `tee a b c` where any of a/b/c could be a protected file.
172
+ target_token="$tok"
173
+ detected_form="$found_cmd"
174
+ # Check this token immediately; if not protected, keep
175
+ # walking — there may be more positional args.
176
+ local _t
177
+ _t=$(_normalize_target "$target_token")
178
+ if rea_path_is_protected "$_t"; then
179
+ local matched=""
180
+ for pattern in "${REA_PROTECTED_PATTERNS[@]}"; do
181
+ if [[ "$_t" == "$pattern" ]]; then matched="$pattern"; break; fi
182
+ if [[ "$pattern" == */ && "$_t" == "$pattern"* ]]; then matched="$pattern"; break; fi
183
+ done
184
+ _refuse "$matched" "$_t" "$segment"
185
+ fi
186
+ # Reset target_token so the post-loop check doesn't double-check.
187
+ target_token=""
188
+ ;;
189
+ esac
190
+ done
191
+ fi
192
+
193
+ if [[ -z "$target_token" ]]; then
194
+ return 0
195
+ fi
196
+
197
+ local target
198
+ target=$(_normalize_target "$target_token")
199
+ if rea_path_is_protected "$target"; then
200
+ # Find the matching pattern for the error message.
201
+ local matched=""
202
+ for pattern in "${REA_PROTECTED_PATTERNS[@]}"; do
203
+ if [[ "$target" == "$pattern" ]]; then matched="$pattern"; break; fi
204
+ if [[ "$pattern" == */ && "$target" == "$pattern"* ]]; then matched="$pattern"; break; fi
205
+ done
206
+ _refuse "$matched" "$target" "$segment"
207
+ fi
208
+ return 0
209
+ }
210
+
211
+ for_each_segment "$CMD" _check_segment
212
+
213
+ exit 0
@@ -157,14 +157,27 @@ LOWER_NORM=$(printf '%s' "$NORMALIZED" | tr '[:upper:]' '[:lower:]')
157
157
  # package-managed body — §5a kills it before this matcher runs.
158
158
  #
159
159
  # SECURITY (defense-in-depth): symlinks INSIDE the .d/ surface are
160
- # refused. A fragment is a short shell script authored in place;
161
- # consumers do not need symlinks here. Without this check, a sequence
162
- # like `ln -s ../pre-push .husky/pre-push.d/00-evil; write 00-evil`
163
- # would be allowed by §5b's path-string match and the downstream
164
- # Write/Edit tool would follow the symlink, overwriting the
165
- # package-managed `.husky/pre-push` body that §6 is meant to protect.
166
- # Costs near-zero (no legitimate use case for symlinked fragments);
167
- # closes the path-string→symlink bypass completely.
160
+ # refused both final-component AND intermediate-directory symlinks.
161
+ # A fragment is a short shell script authored in place; consumers do
162
+ # not need symlinks here. Without these checks the gate has two
163
+ # bypass shapes:
164
+ #
165
+ # (a) Final-component symlink:
166
+ # ln -s ../pre-push .husky/pre-push.d/00-evil; write 00-evil
167
+ # caught by `[ -L "$FILE_PATH" ]`.
168
+ #
169
+ # (b) Intermediate-directory symlink (helix Finding 2 / 0.15.0):
170
+ # mkdir .husky/pre-push.d; ln -s ../ .husky/pre-push.d/linkdir
171
+ # write .husky/pre-push.d/linkdir/pre-push
172
+ # — `[ -L $FILE_PATH ]` only inspects the FINAL component, so a
173
+ # not-yet-existing target whose parent contains a symlink resolves
174
+ # to outside the surface (here: `.husky/pre-push`), letting the
175
+ # attacker write through to the package-managed body.
176
+ #
177
+ # Resolve the realpath of the parent directory and require it to live
178
+ # under the literal extension surface. Use a portable `cd ... && pwd -P`
179
+ # subshell pattern (no Python or readlink -f dependency required).
180
+ # Closes the path-string→symlink bypass completely.
168
181
  case "$LOWER_NORM" in
169
182
  .husky/commit-msg.d/*|.husky/pre-push.d/*)
170
183
  if [ -L "$FILE_PATH" ]; then
@@ -178,6 +191,34 @@ case "$LOWER_NORM" in
178
191
  } >&2
179
192
  exit 2
180
193
  fi
194
+ # Resolve the parent directory's realpath. If any intermediate
195
+ # component is a symlink whose target leaves the surface, the
196
+ # resolved path no longer contains `/.husky/<surface>.d/` and we
197
+ # refuse. The parent dir must already exist for this check; if it
198
+ # doesn't, the write is creating the parent, in which case there
199
+ # is no intermediate symlink to follow yet.
200
+ parent_dir=$(dirname -- "$FILE_PATH")
201
+ if [ -d "$parent_dir" ]; then
202
+ resolved_parent=$(cd -P -- "$parent_dir" 2>/dev/null && pwd -P 2>/dev/null) || resolved_parent=""
203
+ if [ -n "$resolved_parent" ]; then
204
+ case "$resolved_parent" in
205
+ *"/.husky/commit-msg.d"*|*"/.husky/pre-push.d"*) : ;;
206
+ *)
207
+ {
208
+ printf 'SETTINGS PROTECTION: extension path resolves outside surface\n'
209
+ printf '\n'
210
+ printf ' Logical: %s\n' "$SAFE_FILE_PATH"
211
+ printf ' Resolved: %s\n' "$resolved_parent"
212
+ printf ' Rule: an intermediate directory of the extension path is a\n'
213
+ printf ' symlink whose target leaves .husky/{commit-msg,pre-push}.d/.\n'
214
+ printf ' Refused to prevent symlinked-parent bypass of the\n'
215
+ printf ' package-managed body protection.\n'
216
+ } >&2
217
+ exit 2
218
+ ;;
219
+ esac
220
+ fi
221
+ fi
181
222
  # Documented extension surface — agents can write here freely.
182
223
  exit 0
183
224
  ;;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bookedsolid/rea",
3
- "version": "0.14.0",
3
+ "version": "0.15.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)",
@@ -96,9 +96,11 @@
96
96
  "lint:regex": "node scripts/lint-safe-regex.mjs",
97
97
  "format": "prettier --write .",
98
98
  "format:check": "prettier --check .",
99
- "test": "vitest run",
99
+ "test": "pnpm run test:dogfood && pnpm run test:bash-syntax && vitest run",
100
100
  "test:watch": "vitest",
101
101
  "test:coverage": "vitest run --coverage",
102
+ "test:dogfood": "node tools/check-dogfood-drift.mjs",
103
+ "test:bash-syntax": "bash -c 'for f in hooks/*.sh hooks/_lib/*.sh; do bash -n \"$f\" || exit 1; done && echo \"[bash-syntax] OK — all hooks parse cleanly\"'",
102
104
  "type-check": "tsc --noEmit",
103
105
  "changeset": "changeset",
104
106
  "changeset:version": "changeset version",