diff --git a/.claude/hooks/block-risky-bash.sh b/.claude/hooks/block-risky-bash.sh new file mode 100755 index 00000000..5eb6bbaf --- /dev/null +++ b/.claude/hooks/block-risky-bash.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# PreToolUse Bash guard: block dangerous shell commands. +# Denies: rm -rf, git reset --hard, git push -f, git clean -fd, chmod -R 777, +# fork bombs, block device overwrites, mkfs, paths escaping project root. +set -euo pipefail + +INPUT=$(cat) +CMD=$(echo "$INPUT" | jq -r '.tool_input.command // empty') +BASE="${CLAUDE_PROJECT_DIR:-}" +[[ -z "$BASE" ]] && BASE=$(echo "$INPUT" | jq -r '.cwd // empty') +[[ -z "$BASE" ]] && BASE="$(pwd)" + +# Normalize: collapse whitespace, strip leading/trailing +CMD_NORM=$(echo "$CMD" | tr -s '[:space:]' ' ' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') + +deny() { + local reason="$1" + jq -n --arg r "$reason" '{ + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: $r + } + }' + exit 0 +} + +# Dangerous patterns (case-insensitive where sensible) +case "$CMD_NORM" in + *"rm -rf"*|*"rm -fr"*|*"rm -f -r"*|*"rm -r -f"*) deny "Destructive rm -rf blocked by security hook" ;; + *"git reset --hard"*) deny "git reset --hard would lose uncommitted work" ;; + *"git push --force"*|*"git push -f"*|*"git push -f "*) deny "git push --force would rewrite history" ;; + *"git clean -fd"*|*"git clean -f -d"*) deny "git clean -fd deletes untracked files" ;; + *"chmod -R 777"*|*"chmod -R 0777"*) deny "chmod -R 777 is a security risk" ;; + *":(){ :"*"};:"*) deny "Fork bomb pattern blocked" ;; + *"> /dev/sd"*|*">/dev/sd"*) deny "Block device overwrite blocked" ;; + *"mkfs "*|*"mkfs."*) deny "Disk format command blocked" ;; +esac + +# Check for path traversal escaping project root (../ outside project) +# Only if we have a sensible base +if [[ -n "$BASE" ]] && [[ -d "$BASE" ]]; then + ABS_BASE=$(cd "$BASE" && pwd) + # Simple heuristic: command contains .. and would resolve outside project + if echo "$CMD_NORM" | grep -qE '\.\./|/\.\.'; then + # Extract plausible paths and check - allow ../ within project + if echo "$CMD_NORM" | grep -qE '(rm|mv|cp|cat|chmod|chown)\s+.*\.\.'; then + # Could be risky; be conservative for rm/mv/cp + if echo "$CMD_NORM" | grep -qE '\brm\b.*\.\.'; then + deny "Path traversal with rm blocked" + fi + fi + fi +fi + +exit 0 diff --git a/.claude/hooks/protect-files.sh b/.claude/hooks/protect-files.sh new file mode 100755 index 00000000..c8ac2211 --- /dev/null +++ b/.claude/hooks/protect-files.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# PreToolUse Edit|Write guard: block edits outside project and to protected paths. +# Denies: paths outside project, .git/, .env*, lockfiles, node_modules/ +set -euo pipefail + +INPUT=$(cat) +FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty') +BASE="${CLAUDE_PROJECT_DIR:-}" +[[ -z "$BASE" ]] && BASE=$(echo "$INPUT" | jq -r '.cwd // empty') +[[ -z "$BASE" ]] && BASE="$(pwd)" + +# Resolve to absolute path +if [[ -z "$FILE_PATH" ]]; then + exit 0 +fi +ABS_BASE=$(cd "$BASE" 2>/dev/null && pwd) || true +[[ -z "$ABS_BASE" ]] && ABS_BASE=$(python3 -c "import os,sys; print(os.path.abspath(os.path.normpath(sys.argv[1])))" "$BASE" 2>/dev/null) || true +[[ -z "$ABS_BASE" ]] && ABS_BASE="$BASE" +# Ensure base has trailing slash for prefix check +[[ "$ABS_BASE" != */ ]] && ABS_BASE="${ABS_BASE}/" +if [[ "$FILE_PATH" != /* ]]; then + ABS_PATH="$ABS_BASE${FILE_PATH#./}" +else + ABS_PATH="$FILE_PATH" +fi +# Normalize path (collapse .. and ., no symlink resolution needed) +ABS_PATH=$(python3 -c "import os,sys; print(os.path.abspath(os.path.normpath(sys.argv[1])))" "$ABS_PATH" 2>/dev/null) || true +[[ -z "$ABS_PATH" ]] && ABS_PATH="$ABS_BASE${FILE_PATH#./}" + +# Protected patterns (path contains or equals) +PROTECTED_PATTERNS=( + ".git/" + ".env" + ".env.local" + "node_modules/" + "package-lock.json" + "pnpm-lock.yaml" +) + +for pattern in "${PROTECTED_PATTERNS[@]}"; do + if [[ "$ABS_PATH" == *"$pattern"* ]] || [[ "$ABS_PATH" == *"/$pattern" ]]; then + echo "Blocked: $ABS_PATH matches protected pattern '$pattern'" >&2 + jq -n --arg r "Edit blocked: path matches protected pattern ($pattern)" '{ + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: $r + } + }' + exit 0 + fi +done + +# .env.*.local +if [[ "$ABS_PATH" =~ \.env\..*\.local$ ]]; then + echo "Blocked: $ABS_PATH is an env secrets file" >&2 + jq -n '{ + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: "Edit blocked: .env.*.local files contain secrets" + } + }' + exit 0 +fi + +# Ensure path is under project root (ABS_BASE has trailing /) +if [[ "$ABS_PATH" != "$ABS_BASE"* ]] && [[ "$ABS_PATH" != "$BASE"* ]]; then + echo "Blocked: $ABS_PATH is outside project ($ABS_BASE)" >&2 + jq -n --arg r "Edit blocked: path is outside project directory" '{ + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: $r + } + }' + exit 0 +fi + +exit 0 diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000..73207c49 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,24 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/block-risky-bash.sh" + } + ] + }, + { + "matcher": "Edit|Write", + "hooks": [ + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-files.sh" + } + ] + } + ] + } +}