gstack/careful/bin/check-careful.sh

168 lines
7.8 KiB
Bash
Executable File

#!/usr/bin/env bash
# check-careful.sh — PreToolUse hook for /careful skill
# Reads JSON from stdin, checks Bash command for destructive patterns.
# Returns a PreToolUse hookSpecificOutput with permissionDecision "ask" to warn,
# or {} to allow. The decision MUST be nested under hookSpecificOutput — Claude
# Code ignores a top-level permissionDecision, which silently no-ops the warning.
set -euo pipefail
# Read stdin (JSON with tool_input)
INPUT=$(cat)
# Extract the "command" field value from tool_input with a real JSON parser.
#
# The previous extractor was
# grep -o '"command"[[:space:]]*:[[:space:]]*"[^"]*"'
# whose [^"]* stops at the first escaped quote in the JSON string value. Any
# destructive command preceded by a quoted argument was therefore truncated
# away before the pattern checks ever ran:
#
# git commit -m "wip" && rm -rf / -> CMD='git commit -m \' -> allowed
# bash -c "rm -rf /" -> CMD='bash -c \' -> allowed
# echo "x"; rm -rf ~ -> CMD='echo \' -> allowed
#
# The python3 fallback never rescued these because CMD was non-empty, so the
# `[ -z "$CMD" ]` guard did not fire. Parse the payload properly instead, and
# fail CLOSED when it cannot be parsed at all — a hook that gates destructive
# commands must not allow-by-default on unreadable input.
#
# python3 is tried first because it ships with macOS and most Linux distros and
# is reliably on PATH in a hook environment; node is the fallback.
extract_cmd() {
if command -v python3 >/dev/null 2>&1; then
printf '%s' "$INPUT" | python3 -c 'import sys,json; d=json.loads(sys.stdin.read()); c=d.get("tool_input",{}).get("command",""); sys.stdout.write(c if isinstance(c,str) else "")' 2>/dev/null && return 0
fi
if command -v node >/dev/null 2>&1; then
printf '%s' "$INPUT" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{const j=JSON.parse(s);const c=(j&&j.tool_input&&j.tool_input.command)||"";process.stdout.write(typeof c==="string"?c:"")}catch(e){process.exit(3)}})' 2>/dev/null && return 0
fi
return 1
}
set +e
CMD=$(extract_cmd)
EXTRACT_RC=$?
set -e
# No parser available, or the payload is not parseable JSON. Fail closed.
if [ "$EXTRACT_RC" -ne 0 ] && [ -n "$INPUT" ]; then
printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask","permissionDecisionReason":"[careful] Could not parse the tool payload to safety-check this command. Approve only if you know what it does."}}\n'
exit 0
fi
# Parsed fine, but there is genuinely no command field (non-Bash payload) — allow.
if [ -z "$CMD" ]; then
echo '{}'
exit 0
fi
# Normalize: lowercase for case-insensitive SQL matching
CMD_LOWER=$(printf '%s' "$CMD" | tr '[:upper:]' '[:lower:]')
# --- Shell-obfuscation tripwire ---
# Every check below inspects the command as a STRING, but bash executes what the
# string MEANS after expansion. ${IFS} holds the default field separator and
# contains no literal whitespace, so
#
# rm${IFS}-rf${IFS}/
#
# matches none of the `rm\s+` patterns while executing as a full recursive
# delete. The same holds for a command assembled by a base64 decode piped to a
# shell. Rather than try to out-parse bash, treat these splitting/decoding
# primitives as a reason to ask: they are vanishingly rare in commands a human
# actually means to run unattended.
if printf '%s' "$CMD" | grep -qE '\$\{IFS\}|\$IFS|\$\(echo[^)]*base64[^)]*\)|base64[[:space:]]+(-d|--decode)[^|]*\|[[:space:]]*(sh|bash)' 2>/dev/null; then
printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask","permissionDecisionReason":"[careful] Shell obfuscation detected (IFS word-splitting or base64-to-shell). Read the command carefully before approving."}}\n'
exit 0
fi
# --- Check for safe exceptions (one standalone rm of build artifacts) ---
# Match the complete command. Parsing only the last rm is unsafe because shell
# syntax or comments can hide an earlier destructive command, for example:
# rm -rf / # rm -rf node_modules
# Unknown syntax fails closed and falls through to the destructive checks.
# Two hardenings on top of the anchored shape (#2039 wave):
# - flag cluster accepts capital -R (BSD/macOS recursive), so a single
# `rm -Rf node_modules` stays allowed instead of prompting;
# - target tokens exclude `(` and backtick, so command substitution that
# ENDS in a whitelisted suffix (`rm -rf $(./wipe-all)/node_modules`)
# cannot ride the whitelist. Plain $VAR expansion (no parenthesis) is
# still allowed.
# - multi-line commands never ride the whitelist: grep matches the anchored
# shape against EACH line, so `rm -rf /\nrm -rf node_modules` would be
# allowed by its second line. With the JSON-parser extraction the \n in
# the payload is a real newline (the old grep extractor kept it as two
# literal characters, which broke the anchored match by accident).
case "$CMD" in
*$'\n'*) : ;; # multi-line: fall through to the destructive checks
*)
if printf '%s' "$CMD" | 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:]]*)+$' 2>/dev/null; then
echo '{}'
exit 0
fi
;;
esac
# --- Destructive pattern checks ---
WARN=""
PATTERN=""
# rm -rf / rm -r / rm -R / rm --recursive (capital -R is BSD/macOS recursive)
if printf '%s' "$CMD" | grep -qE 'rm\s+(-[a-zA-Z]*[rR]|--recursive)' 2>/dev/null; then
WARN="Destructive: recursive delete (rm -r). This permanently removes files."
PATTERN="rm_recursive"
fi
# DROP TABLE / DROP DATABASE
if [ -z "$WARN" ] && printf '%s' "$CMD_LOWER" | grep -qE 'drop\s+(table|database)' 2>/dev/null; then
WARN="Destructive: SQL DROP detected. This permanently deletes database objects."
PATTERN="drop_table"
fi
# TRUNCATE
if [ -z "$WARN" ] && printf '%s' "$CMD_LOWER" | grep -qE '\btruncate\b' 2>/dev/null; then
WARN="Destructive: SQL TRUNCATE detected. This deletes all rows from a table."
PATTERN="truncate"
fi
# git push --force / git push -f
if [ -z "$WARN" ] && printf '%s' "$CMD" | grep -qE 'git\s+push\s+.*(-f\b|--force)' 2>/dev/null; then
WARN="Destructive: git force-push rewrites remote history. Other contributors may lose work."
PATTERN="git_force_push"
fi
# git reset --hard
if [ -z "$WARN" ] && printf '%s' "$CMD" | grep -qE 'git\s+reset\s+--hard' 2>/dev/null; then
WARN="Destructive: git reset --hard discards all uncommitted changes."
PATTERN="git_reset_hard"
fi
# git checkout . / git restore .
if [ -z "$WARN" ] && printf '%s' "$CMD" | grep -qE 'git\s+(checkout|restore)\s+\.' 2>/dev/null; then
WARN="Destructive: discards all uncommitted changes in the working tree."
PATTERN="git_discard"
fi
# kubectl delete
if [ -z "$WARN" ] && printf '%s' "$CMD" | grep -qE 'kubectl\s+delete' 2>/dev/null; then
WARN="Destructive: kubectl delete removes Kubernetes resources. May impact production."
PATTERN="kubectl_delete"
fi
# docker rm -f / docker system prune
if [ -z "$WARN" ] && printf '%s' "$CMD" | grep -qE 'docker\s+(rm\s+-f|system\s+prune)' 2>/dev/null; then
WARN="Destructive: Docker force-remove or prune. May delete running containers or cached images."
PATTERN="docker_destructive"
fi
# --- Output ---
if [ -n "$WARN" ]; then
# Log hook fire event (pattern name only, never command content)
mkdir -p ~/.gstack/analytics 2>/dev/null || true
echo '{"event":"hook_fire","skill":"careful","pattern":"'"$PATTERN"'","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
WARN_ESCAPED=$(printf '%s' "$WARN" | sed 's/"/\\"/g')
printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask","permissionDecisionReason":"[careful] %s"}}\n' "$WARN_ESCAPED"
else
echo '{}'
fi