Skill Tracer
Skills › gstack › careful › check-careful.sh

check-careful.sh

424 lines · careful/bin/check-careful.sh at 3f79d81

1#!/usr/bin/env bash
2# check-careful.sh — PreToolUse hook for /careful skill
3# Reads JSON from stdin, checks a Bash or PowerShell command for destructive
4# patterns (best-effort string matching, not a security boundary).
5# Two tiers:
6# HIGH — a tiny set of catastrophic SIMPLE commands returns "deny"
7# (best-effort advisory hard-stop, not a policy boundary).
8# MEDIUM — the destructive families below return "ask" (always overridable).
9# The decision MUST be nested under hookSpecificOutput — Claude Code ignores a
10# top-level permissionDecision, which silently no-ops the warning.
11set -euo pipefail
13# Read stdin (JSON with tool_input)
14INPUT=$(cat)
16# Shared JSON helpers (extractor + encoder) — one copy for careful AND freeze.
17# See hook-extract.sh for the drift history that motivated the shared file.
18_HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
19# shellcheck source=careful/bin/hook-extract.sh
20# bash treats `.` on a MISSING file as fatal non-interactively; a partial
21# install must degrade to an ASK (this is the ask-tier hook), never silence.
22_HOOK_HELPER="$_HOOK_DIR/hook-extract.sh"
23if [ ! -f "$_HOOK_HELPER" ] || ! . "$_HOOK_HELPER" 2>/dev/null; then
24 printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask","permissionDecisionReason":"[careful] Hook helpers unavailable (broken install?) - cannot safety-check this command. Approve only if you know what it does."}}\n'
25 exit 0
26fi
28# Extract the "command" field value from tool_input with a real JSON parser.
30# The previous extractor was
31# grep -o '"command"[[:space:]]*:[[:space:]]*"[^"]*"'
32# whose [^"]* stops at the first escaped quote in the JSON string value. Any
33# destructive command preceded by a quoted argument was therefore truncated
34# away before the pattern checks ever ran:
36# git commit -m "wip" && rm -rf / -> CMD='git commit -m \' -> allowed
37# bash -c "rm -rf /" -> CMD='bash -c \' -> allowed
38# echo "x"; rm -rf ~ -> CMD='echo \' -> allowed
40# Parse the payload properly instead, and fail CLOSED when it cannot be parsed
41# at all — a hook that gates destructive commands must not allow-by-default on
42# unreadable input.
44# The same parser call reads tool_name: the PowerShell tool (Claude Code's
45# primary shell on Windows, and the only one without Git Bash) carries its
46# command in the same tool_input.command field as Bash (hooks reference,
47# "PreToolUse > PowerShell"). An older hook-extract.sh without the combined
48# reader (partial upgrade) still checks the command, as Bash.
49TOOL_NAME=""
50set +e
51if command -v gstack_hook_extract_tool >/dev/null 2>&1; then
52 gstack_hook_extract_tool "$INPUT" command
53 EXTRACT_RC=$?
54 TOOL_NAME="$GSTACK_HOOK_TOOL"
55 CMD="$GSTACK_HOOK_VALUE"
56else
57 CMD=$(gstack_hook_extract_field "$INPUT" command)
58 EXTRACT_RC=$?
59fi
60set -e
62# No parser available, or the payload is not parseable JSON. Fail closed.
63if [ "$EXTRACT_RC" -ne 0 ] && [ -n "$INPUT" ]; then
64 gstack_hook_decision ask "[careful] Could not parse the tool payload to safety-check this command. Approve only if you know what it does."
65 exit 0
66fi
68# Parsed fine, but there is genuinely no command field (non-shell payload) — allow.
69if [ -z "$CMD" ]; then
70 echo '{}'
71 exit 0
72fi
74# Log a hook fire event (pattern name only, never command content).
75# Shared helper respects GSTACK_HOME, so tests never write real analytics.
76_careful_log_fire() { gstack_hook_log_fire careful "$1"; }
78# Normalize: lowercase for case-insensitive SQL matching
79CMD_LOWER=$(printf '%s' "$CMD" | tr '[:upper:]' '[:lower:]')
81# --- Shell-obfuscation tripwire ---
82# Every check below inspects the command as a STRING, but bash executes what the
83# string MEANS after expansion. ${IFS} holds the default field separator and
84# contains no literal whitespace, so
86# rm${IFS}-rf${IFS}/
88# matches none of the `rm\s+` patterns while executing as a full recursive
89# delete. The same holds for a command assembled by a base64 decode piped to a
90# shell. Rather than try to out-parse bash, treat these splitting/decoding
91# primitives as a reason to ask: they are vanishingly rare in commands a human
92# actually means to run unattended.
93if grep -qE '\$\{IFS\}|\$IFS|\$\(echo[^)]*base64[^)]*\)|base64[[:space:]]+(-d|--decode)[^|]*\|[[:space:]]*(sh|bash)' <<< "$CMD" 2>/dev/null; then
94 gstack_hook_decision ask "[careful] Shell obfuscation detected (IFS word-splitting or base64-to-shell). Read the command carefully before approving."
95 exit 0
96fi
98# --- HIGH tier: hard deny (best-effort advisory hard-stop, NOT a policy boundary) ---
99# Only SIMPLE commands are eligible: string matching cannot resolve what a
100# compound command does (`cd X && git push --force` — whose cwd? which repo?),
101# so anything containing ; && || | or a newline falls through to the MEDIUM ask
102# families below — conservative failure = ask, never guess.
103# --force-with-lease is deliberately NOT matched here (it is the safe variant).
104# curl|sh stays MEDIUM/allow territory: hard-denying it would block legitimate
105# installer flows, including gstack's own setup pattern.
106_IS_SIMPLE=1
107case "$CMD" in
108 *';'*|*'&&'*|*'||'*|*'|'*|*$'\n'*) _IS_SIMPLE=0 ;;
109esac
110if [ "$_IS_SIMPLE" -eq 1 ]; then
111 # Recursive delete aimed at the filesystem root or the whole home directory.
112 # Tokenized: options (long or short, any position — --no-preserve-root may
113 # trail the target) are skipped; EVERY non-option token must be a root-class
114 # target (/, ~, $HOME, /*), and a recursive flag must be present. noglob is
115 # forced around word-splitting so a literal /* token never expands.
116 if grep -qE '^[[:space:]]*(sudo[[:space:]]+)?rm[[:space:]]' <<< "$CMD" 2>/dev/null \
117 && grep -qE '(^|[[:space:]])(-[a-zA-Z]*[rR][a-zA-Z]*|--recursive)([[:space:]]|$)' <<< "$CMD" 2>/dev/null; then
118 _ROOT_TARGETS=0
119 _SAFE_TARGETS=0
120 set -f
121 for _TOK in $CMD; do
122 # Strip one layer of surrounding quotes: rm -rf "/" is still rm -rf /.
123 _TOK="${_TOK#\"}"; _TOK="${_TOK%\"}"; _TOK="${_TOK#\'}"; _TOK="${_TOK%\'}"
124 case "$_TOK" in
125 # Skip non-target decoration: options, `--`, redirections (2>/dev/null
126 # is the most common suffix on agent-generated commands), backgrounding.
127 sudo|rm|-*|--|[0-9]'>'*|'>'*|'<'*|'&') continue ;;
128 '/'|'~'|'~/'|'$HOME'|'$HOME/'|'${HOME}'|'${HOME}/'|'/*'|'//') _ROOT_TARGETS=1 ;;
129 *) _SAFE_TARGETS=1 ;;
130 esac
131 done
132 set +f
133 if [ "$_ROOT_TARGETS" -eq 1 ] && [ "$_SAFE_TARGETS" -eq 0 ]; then
134 _careful_log_fire "high_rm_root"
135 gstack_hook_decision deny "[careful][HIGH] Recursive delete of / or the home directory is blocked while /careful is active. If you truly mean it, end the /careful session first."
136 exit 0
137 fi
138 fi
139 # Force-push to the repo's default branch (the shared history everyone pulls).
140 # Force is carried by -f/--force OR by git's plus-refspec syntax (+main,
141 # +HEAD:main) which needs no flag at all. --force-with-lease never matches.
142 if grep -qE '^[[:space:]]*git[[:space:]]+push([[:space:]]|$)' <<< "$CMD" 2>/dev/null; then
143 _HAS_FORCE=0
144 if grep -qE '(^|[[:space:]])(-f|--force)($|[[:space:]])' <<< "$CMD" 2>/dev/null; then
145 _HAS_FORCE=1
146 elif grep -qE '(^|[[:space:]])\+[^[:space:]]' <<< "$CMD" 2>/dev/null; then
147 _HAS_FORCE=1
148 fi
149 if [ "$_HAS_FORCE" -eq 1 ]; then
150 # Full branch path (slashed defaults like release/2.0 stay intact) and
151 # FIXED-STRING token comparison — never interpolate a branch name into
152 # an ERE (metacharacters would over/under-match).
153 _DEFAULT_BRANCH=$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's|^refs/remotes/origin/||' || true)
154 # Conductor worktrees often lack the origin/HEAD symbolic ref — without a
155 # fallback the HIGH tier would be silently inert in the primary deploy
156 # environment. Probe the two conventional defaults.
157 if [ -z "$_DEFAULT_BRANCH" ]; then
158 if git show-ref --verify -q refs/remotes/origin/main 2>/dev/null; then
159 _DEFAULT_BRANCH="main"
160 elif git show-ref --verify -q refs/remotes/origin/master 2>/dev/null; then
161 _DEFAULT_BRANCH="master"
162 fi
163 fi
164 if [ -n "$_DEFAULT_BRANCH" ]; then
165 _TARGETS_DEFAULT=0
166 set -f
167 for _TOK in $CMD; do
168 # Strip one layer of surrounding quotes: `git push -f origin "main"`
169 # must not dodge the deny just because the ref is quoted.
170 _TOK="${_TOK#\"}"; _TOK="${_TOK%\"}"; _TOK="${_TOK#\'}"; _TOK="${_TOK%\'}"
171 case "$_TOK" in git|push|sudo|-*) continue ;; esac
172 _REF="${_TOK#+}" # +main -> main
173 _REF="${_REF##*:}" # HEAD:main / src:main -> main
174 if [ "$_REF" = "$_DEFAULT_BRANCH" ]; then
175 _TARGETS_DEFAULT=1
176 break
177 fi
178 done
179 set +f
180 if [ "$_TARGETS_DEFAULT" -eq 0 ] && grep -qE '^[[:space:]]*git[[:space:]]+push([[:space:]]+(-f|--force))*[[:space:]]*$' <<< "$CMD" 2>/dev/null; then
181 # Bare `git push --force` (force flags only, no remote/ref): targets
182 # the current branch's upstream — the default branch only when ON it.
183 _CURRENT_BRANCH=$(git branch --show-current 2>/dev/null || true)
184 [ -n "$_CURRENT_BRANCH" ] && [ "$_CURRENT_BRANCH" = "$_DEFAULT_BRANCH" ] && _TARGETS_DEFAULT=1
185 fi
186 if [ "$_TARGETS_DEFAULT" -eq 1 ]; then
187 _careful_log_fire "high_force_push_default"
188 gstack_hook_decision deny "[careful][HIGH] Force-push to the default branch ($_DEFAULT_BRANCH) is blocked while /careful is active. Use --force-with-lease on a feature branch, or end the /careful session if you truly mean it."
189 exit 0
190 fi
191 fi
192 fi
193 fi
194fi
196# --- Check for safe exceptions (one standalone rm of build artifacts) ---
197# Match the complete command. Parsing only the last rm is unsafe because shell
198# syntax or comments can hide an earlier destructive command, for example:
199# rm -rf / # rm -rf node_modules
200# Unknown syntax fails closed and falls through to the destructive checks.
201# Two hardenings on top of the anchored shape (#2039 wave):
202# - flag cluster accepts capital -R (BSD/macOS recursive), so a single
203# `rm -Rf node_modules` stays allowed instead of prompting;
204# - target tokens exclude `(` and backtick, so command substitution that
205# ENDS in a whitelisted suffix (`rm -rf $(./wipe-all)/node_modules`)
206# cannot ride the whitelist. Plain $VAR expansion (no parenthesis) is
207# still allowed.
208# - multi-line commands never ride the whitelist: grep matches the anchored
209# shape against EACH line, so `rm -rf /\nrm -rf node_modules` would be
210# allowed by its second line. With the JSON-parser extraction the \n in
211# the payload is a real newline (the old grep extractor kept it as two
212# literal characters, which broke the anchored match by accident).
213case "$CMD" in
214 *$'\n'*) : ;; # multi-line: fall through to the destructive checks
215 *)
216 if grep -qE '^[[:space:]]*rm[[:space:]]+(-[a-zA-Z]*[rR][a-zA-Z]*[[:space:]]+|--recursive[[:space:]]+)(([^[:space:];&|#(`]*/)?(node_modules|\.next|dist|__pycache__|\.cache|build|\.turbo|coverage)[[:space:]]*)+$' <<< "$CMD" 2>/dev/null; then
217 echo '{}'
218 exit 0
219 fi
220 ;;
221esac
223# --- Destructive pattern checks (MEDIUM tier — always overridable) ---
224WARN=""
225PATTERN=""
227# Mask quoted -m/-am/--message argument content in git commit commands so that
228# descriptive commit messages do not trigger false positives (#1060). The
229# quote-aware scanner lives in mask-commit-message.awk beside this hook: as a
230# heredoc inside a command substitution, its quote characters are mis-parsed by
231# bash 3.2 (macOS /bin/bash), which made this whole hook a syntax error there.
232# A missing program or an awk failure leaves the command unmasked.
233CMD_MEDIUM="$CMD"
234CMD_MEDIUM_LOWER="$CMD_LOWER"
235_MASK_AWK="$_HOOK_DIR/mask-commit-message.awk"
236case "$CMD" in *git*commit*) _MAYBE_COMMIT=1 ;; *) _MAYBE_COMMIT=0 ;; esac
237if [ "$_MAYBE_COMMIT" -eq 1 ] && [ -f "$_MASK_AWK" ]; then
238 if _cmd_masked=$(printf '%s' "$CMD" | awk -f "$_MASK_AWK" 2>/dev/null); then
239 CMD_MEDIUM="$_cmd_masked"
240 CMD_MEDIUM_LOWER=$(printf '%s' "$CMD_MEDIUM" | tr '[:upper:]' '[:lower:]')
241 fi
242fi
244# --- PowerShell and cmd table (#3067) ---
245# Runs on the PowerShell tool's command, and on the part of any command from a
246# nested `powershell`/`pwsh`/`cmd` launcher onward (`pwsh -c "..."`,
247# `cmd /c rd /s /q x`, Git Bash's `cmd //c`). Plain Bash commands never reach
248# it, so Bash keeps exactly its old behavior. Best-effort: PowerShell can
249# build commands at runtime, so encoded and dynamic forms ask instead of being
250# parsed, and Claude Code permission deny rules stay the stronger layer.
251# Matching is bash [[ =~ ]] on the already-lowercased command (POSIX ERE,
252# bash 3.2 compatible): no added subprocess on the per-command path.
253_WIN_SCAN=""
254[ "$TOOL_NAME" = "PowerShell" ] && _WIN_SCAN="$CMD_LOWER"
255_WIN_LAUNCH_RE='(^|[^a-z0-9_.$-])((powershell|pwsh|cmd)(\.exe)?([[:space:]].*)?)$'
256if [ -z "$_WIN_SCAN" ] && [[ $CMD_LOWER =~ $_WIN_LAUNCH_RE ]]; then
257 _WIN_SCAN="${BASH_REMATCH[2]}"
258fi
259if [ -n "$_WIN_SCAN" ]; then
260 # cmd escapes with ^ (r^d is rd) and PowerShell with a backtick
261 # (Re`move-Item is Remove-Item); dropping both only joins characters.
262 _WIN_SCAN="${_WIN_SCAN//^/}"
263 _WIN_SCAN="${_WIN_SCAN//\`/}"
264 _NL=$'\n'
265 # Command position: start of text, a statement/pipeline separator, an
266 # opening bracket or quote, or right after a shell launcher and its
267 # switches. Names elsewhere (ord, --del, /rd/, git branch -d) never match.
268 _CP="(^|[;&|({}\"'${_NL}]|(powershell|pwsh|cmd)(\.exe)?([[:space:]]+(-|/+)[a-z]+([[:space:]:=]+[a-z0-9_.-]+)?)*)[[:space:]]*"
269 _STMT="[^;|${_NL}]*"
270 _CMD_STMT="[^;|&${_NL}]*"
271 _WHY_DYNAMIC="Encoded or dynamic PowerShell can't be inspected, so /careful cannot tell what it will run. Read it before approving; Claude Code permission deny rules (for example \"PowerShell(Remove-Item *)\") are the stronger layer."
272 # One table: pattern name, ERE, warning. Parameters match any unique
273 # prefix PowerShell accepts (-r/-rec/-recurse, -fo/-forc/-force; -f alone
274 # is ambiguous with -Filter), with or without a :$true value.
275 _WIN_RULES=(
276 ps_remove_item
277 "${_CP}(remove-item|rm|ri|del|erase|rd|rmdir)[[:space:]](${_STMT}[[:space:]])?-(r|re|rec|recu|recur|recurs|recurse|fo|for|forc|force)([[:space:]:]|$)"
278 "Destructive: PowerShell Remove-Item (or rm/ri/del/erase/rd/rmdir) with -Recurse or -Force. This permanently removes files."
279 cmd_rd_s
280 "${_CP}(rd|rmdir)[[:space:]]${_CMD_STMT}/s([[:space:]/]|$)"
281 "Destructive: cmd rd/rmdir /s deletes a whole directory tree."
282 cmd_del_s
283 "${_CP}(del|erase)[[:space:]]${_CMD_STMT}/s([[:space:]/]|$)"
284 "Destructive: cmd del/erase /s deletes matching files in every subdirectory."
285 ps_format_volume
286 "${_CP}format-volume([[:space:]]|$)"
287 "Destructive: Format-Volume erases a whole volume."
288 ps_clear_disk
289 "${_CP}clear-disk([[:space:]]|$)"
290 "Destructive: Clear-Disk wipes every partition on a disk."
291 ps_clear_content
292 "${_CP}(clear-content|clc)([[:space:]]|$)"
293 "Destructive: Clear-Content empties files (like truncate)."
294 ps_dotnet_delete
295 '\[(system\.)?io\.(directory|file)\]::delete'
296 "Destructive: .NET [IO.Directory]::Delete / [IO.File]::Delete removes files without Remove-Item."
297 ps_encoded_command
298 "(^|[^a-z0-9_.$-])(powershell|pwsh)(\.exe)?[[:space:]](${_STMT}[[:space:]])?-(e|ec|en|enc[a-z]*)([[:space:]:]|$)"
299 "PowerShell -EncodedCommand. ${_WHY_DYNAMIC}"
300 ps_invoke_expression
301 "${_CP}(invoke-expression|iex)([[:space:](]|$)"
302 "PowerShell Invoke-Expression (iex). ${_WHY_DYNAMIC}"
303 ps_start_process_shell
304 "${_CP}(start-process|saps|start)[[:space:]](${_STMT}[[:space:]\"',])?(powershell|pwsh|cmd|bash|wsl|sh)(\.exe)?([[:space:]\"',]|$)"
305 "Start-Process launching another shell. ${_WHY_DYNAMIC}"
306 ps_call_operator
307 "(^|[^&])&[[:space:]]*[\$(]"
308 "PowerShell call operator on a variable or expression (& \$cmd). ${_WHY_DYNAMIC}"
309 )
310 _WI=0
311 while [ "$_WI" -lt "${#_WIN_RULES[@]}" ]; do
312 _WRE="${_WIN_RULES[$((_WI + 1))]}"
313 if [[ $_WIN_SCAN =~ $_WRE ]]; then
314 PATTERN="${_WIN_RULES[$_WI]}"
315 WARN="${_WIN_RULES[$((_WI + 2))]} (pattern: $PATTERN)"
316 break
317 fi
318 _WI=$((_WI + 3))
319 done
320fi
322# rm -rf / rm -r / rm -R / rm --recursive (capital -R is BSD/macOS recursive)
323if [ -z "$WARN" ] && grep -qE 'rm\s+(-[a-zA-Z]*[rR]|--recursive)' <<< "$CMD_MEDIUM" 2>/dev/null; then
324 WARN="Destructive: recursive delete (rm -r). This permanently removes files."
325 PATTERN="rm_recursive"
326fi
328# DROP TABLE / DROP DATABASE
329if [ -z "$WARN" ] && grep -qE 'drop\s+(table|database)' <<< "$CMD_MEDIUM_LOWER" 2>/dev/null; then
330 WARN="Destructive: SQL DROP detected. This permanently deletes database objects."
331 PATTERN="drop_table"
332fi
334# TRUNCATE
335if [ -z "$WARN" ] && grep -qE '\btruncate\b' <<< "$CMD_MEDIUM_LOWER" 2>/dev/null; then
336 WARN="Destructive: SQL TRUNCATE detected. This deletes all rows from a table."
337 PATTERN="truncate"
338fi
340# git push --force / git push -f / plus-refspec force (git push origin +ref)
341if [ -z "$WARN" ] && grep -qE 'git\s+push\s' <<< "$CMD_MEDIUM" 2>/dev/null \
342 && grep -qE '(-f\b|--force|(^|[[:space:]])\+[^[:space:]])' <<< "$CMD_MEDIUM" 2>/dev/null; then
343 WARN="Destructive: git force-push rewrites remote history. Other contributors may lose work."
344 PATTERN="git_force_push"
345fi
347# git reset --hard
348if [ -z "$WARN" ] && grep -qE 'git\s+reset\s+--hard' <<< "$CMD_MEDIUM" 2>/dev/null; then
349 WARN="Destructive: git reset --hard discards all uncommitted changes."
350 PATTERN="git_reset_hard"
351fi
353# git checkout . / git restore .
354if [ -z "$WARN" ] && grep -qE 'git\s+(checkout|restore)\s+\.' <<< "$CMD_MEDIUM" 2>/dev/null; then
355 WARN="Destructive: discards all uncommitted changes in the working tree."
356 PATTERN="git_discard"
357fi
359# kubectl delete
360if [ -z "$WARN" ] && grep -qE 'kubectl\s+delete' <<< "$CMD_MEDIUM" 2>/dev/null; then
361 WARN="Destructive: kubectl delete removes Kubernetes resources. May impact production."
362 PATTERN="kubectl_delete"
363fi
365# docker rm -f / docker system prune
366if [ -z "$WARN" ] && grep -qE 'docker\s+(rm\s+-f|system\s+prune)' <<< "$CMD_MEDIUM" 2>/dev/null; then
367 WARN="Destructive: Docker force-remove or prune. May delete running containers or cached images."
368 PATTERN="docker_destructive"
369fi
371# --- Additive project patterns ---
372# Config can only ADD warn rules, never remove or weaken a baseline family:
373# these files are consulted AFTER the hardcoded checks and only when none of
374# them matched, so no file content can suppress a baseline warning. One POSIX
375# ERE per line; blank lines and #-comments skipped; an invalid regex is
376# skipped (never fatal — the hook must not break on a typo in config).
377if [ -z "$WARN" ]; then
378 # Same state root the writer (/careful via gstack-paths) uses — see
379 # gstack_hook_state_root in hook-extract.sh (#1459 class).
380 if command -v gstack_hook_state_root >/dev/null 2>&1; then
381 _GSTACK_HOME_DIR="$(gstack_hook_state_root; printf x)"; _GSTACK_HOME_DIR="${_GSTACK_HOME_DIR%x}"
382 else
383 # Older hook-extract.sh (partial upgrade): the plain chain beats dying
384 # under set -e with no decision JSON — rules under $HOME/.gstack still load.
385 _GSTACK_HOME_DIR="${GSTACK_HOME:-$HOME/.gstack}"
386 fi
387 _PATTERN_FILES="$_GSTACK_HOME_DIR/careful-patterns.txt"
388 # Short-circuit: resolving the project slug costs a subprocess + git call on
389 # EVERY Bash command while /careful is active — only pay it when some
390 # per-project pattern file actually exists anywhere.
391 _ANY_PROJ_PAT=$(find "$_GSTACK_HOME_DIR/projects" -maxdepth 2 -name careful-patterns.txt -print -quit 2>/dev/null || true)
392 if [ -n "$_ANY_PROJ_PAT" ]; then
393 eval "$("$_HOOK_DIR/../../bin/gstack-slug" 2>/dev/null)" 2>/dev/null || true
394 if [ -n "${SLUG:-}" ]; then
395 _PATTERN_FILES="$_PATTERN_FILES
396$_GSTACK_HOME_DIR/projects/$SLUG/careful-patterns.txt"
397 fi
398 fi
399 while IFS= read -r _PF; do
400 [ -f "$_PF" ] || continue
401 while IFS= read -r _PAT || [ -n "$_PAT" ]; do
402 case "$_PAT" in ''|'#'*) continue ;; esac
403 _PAT_RC=0
404 printf '' | grep -qE -- "$_PAT" 2>/dev/null || _PAT_RC=$?
405 [ "$_PAT_RC" -eq 2 ] && continue # invalid ERE — skip the line
406 if grep -qE -- "$_PAT" <<< "$CMD_MEDIUM" 2>/dev/null; then
407 WARN="Project rule matched: $_PAT"
408 PATTERN="project_rule"
409 break
410 fi
411 done < "$_PF"
412 [ -n "$WARN" ] && break
413 done <<EOF_PATTERN_FILES
414$_PATTERN_FILES
415EOF_PATTERN_FILES
416fi
418# --- Output ---
419if [ -n "$WARN" ]; then
420 _careful_log_fire "$PATTERN"
421 gstack_hook_decision ask "[careful] $WARN"
422else
423 echo '{}'
424fi