@bookedsolid/rea 0.13.3 → 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.
@@ -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,13 +106,59 @@ 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
  }
113
121
 
114
122
  NORMALIZED=$(normalize_path "$FILE_PATH")
115
123
 
124
+ # ── 5a. Path-traversal rejection (0.14.0 iron-gate fix) ───────────────────────
125
+ # Reject any path containing a `..` segment BEFORE the literal-match below.
126
+ # Without this, `foo/../CODEOWNERS` would get past `normalize_path()` (which
127
+ # only strips leading project root + URL-decodes) and the literal-match
128
+ # loop would compare `foo/../CODEOWNERS` against the literal `CODEOWNERS`
129
+ # entry — which doesn't match, so the policy lets the write through. The
130
+ # downstream Write/Edit tool then resolves the traversal and writes to
131
+ # `CODEOWNERS` anyway, defeating the gate.
132
+ #
133
+ # Mirrors settings-protection.sh §5a (which has had this guard since
134
+ # 0.10.x). Both pre- and post-decode forms are checked because
135
+ # normalize_path() URL-decodes earlier and an attacker could split the
136
+ # traversal across encodings (`%2E%2E/`, `..%2F`, etc.).
137
+ raw_has_traversal=0
138
+ norm_has_traversal=0
139
+ case "/$FILE_PATH/" in
140
+ */../*) raw_has_traversal=1 ;;
141
+ esac
142
+ case "/$NORMALIZED/" in
143
+ */../*) norm_has_traversal=1 ;;
144
+ esac
145
+ # Also catch URL-encoded traversal in case some tool routes raw-encoded
146
+ # paths through here (e.g. file:// inputs). normalize_path()'s decoder
147
+ # only handles a fixed set; an unrecognized encoding would slip past.
148
+ case "$FILE_PATH" in
149
+ *%2[Ee]%2[Ee]*|*%2[Ee].*|*.%2[Ee]*) raw_has_traversal=1 ;;
150
+ esac
151
+ if [[ "$raw_has_traversal" -eq 1 ]] || [[ "$norm_has_traversal" -eq 1 ]]; then
152
+ {
153
+ printf 'BLOCKED PATH: path traversal rejected\n'
154
+ printf '\n'
155
+ printf ' File: %s\n' "$FILE_PATH"
156
+ printf " Rule: path contains a '..' segment; rewrite to a canonical\n"
157
+ printf ' project-relative path without traversal.\n'
158
+ } >&2
159
+ exit 2
160
+ fi
161
+
116
162
  for writable in "${AGENT_WRITABLE[@]}"; do
117
163
  if [[ "$NORMALIZED" == "$writable" ]] || [[ "$NORMALIZED" == "$writable"* && "$writable" == */ ]]; then
118
164
  exit 0
@@ -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." \