Skip to content

Instantly share code, notes, and snippets.

@ahgraber
Last active April 7, 2026 18:19
Show Gist options
  • Select an option

  • Save ahgraber/2efa040f9ba8d15a6e0da7712105dbf3 to your computer and use it in GitHub Desktop.

Select an option

Save ahgraber/2efa040f9ba8d15a6e0da7712105dbf3 to your computer and use it in GitHub Desktop.
How I Use AI (Apr 2026) - Hooks

Claude Code Configuration

Security hooks

All hooks live in hooks/ and are symlinked into ~/.claude/hooks/ by Home Manager. They run as Claude Code lifecycle events and exit with one of three codes:

Exit code Meaning
0 Allow (or ask-mode: present a permission dialog)
2 Hard block — Claude cannot proceed with this tool call

Hook inventory

Hook Event Matcher What it does
session-tool-checks.sh SessionStart Warns if uv or gitleaks are missing from PATH
block-dangerous-bash.sh PreToolUse Bash Blocks reverse shells, pipe-to-shell, obfuscated exec, clipboard reads, and fork bombs
block-sensitive-paths.py PreToolUse Bash|Write|Edit Blocks access to zero-access paths (.env, SSH keys, AWS credentials, shell history, kubeconfig, etc.)
block-destructive-ops.py PreToolUse Bash Pattern-driven block/ask for destructive operations across git, filesystem, cloud, database, and more
block-secrets.py PreToolUse Bash|Read|Write|Edit Scans tool arguments for secrets; blocks network transmission of secrets, asks on local access
bash-command-validator.sh PreToolUse Bash General-purpose command validation (last-resort catch-all)
posttooluse-precommit.sh PostToolUse Edit|Write Runs pre-commit checks after file writes

Pattern file

block-destructive-ops-patterns.toml drives block-destructive-ops.py. Edit it to add, remove, or tune patterns without touching Python. Each rule requires pattern, description, reason, and mode (block or ask). Rules are grouped by category ([git], [filesystem], [cloud], [database], etc.) and evaluated in order — first match wins.

Localhost exemption

block-dangerous-bash.sh and block-secrets.py both exempt connections to localhost / 127.0.0.1 / ::1 so local development servers are not incorrectly blocked.

Directory exemption

block-sensitive-paths.py and block-secrets.py skip name-based pattern matches inside package directories (.venv, site-packages, node_modules) and the Claude config dir (.claude) to avoid false positives on dependency files that happen to contain credential-like names.

Attribution

Security hardening based on:

#!/usr/bin/env bash
set -euo pipefail
# Claude Code Hook: Bash Command Validator
#
# This hook runs as a PreToolUse hook for the Bash tool. It validates bash
# commands against a set of rules before execution. Rules: redirects grep to rg,
# find -name to rg, and python/pip invocations to uv.
#
# Read more about hooks here: https://docs.anthropic.com/en/docs/claude-code/hooks
#
# {
# "hooks": {
# "PreToolUse": [
# {
# "matcher": "Bash",
# "hooks": [
# {
# "type": "command",
# "command": "/path/to/bash-command-validator.sh"
# }
# ]
# }
# ]
# }
# }
if ! command -v jq >/dev/null 2>&1; then
echo "Error: jq is required for bash-command-validator.sh" >&2
exit 1
fi
if ! command -v perl >/dev/null 2>&1; then
echo "Error: perl is required for bash-command-validator.sh" >&2
exit 1
fi
matches_pattern() {
local pattern="${1}"
VALIDATION_PATTERN="${pattern}" perl -0e '
my $pattern = $ENV{VALIDATION_PATTERN};
my $text = do { local $/; <STDIN> };
my $regex = eval { qr{$pattern} };
exit 1 if $@;
exit($text =~ $regex ? 0 : 1);
'
}
input_json="$(cat)"
if ! jq -e . >/dev/null 2>&1 <<<"${input_json}"; then
echo "Error: Invalid JSON input" >&2
exit 1
fi
tool_name="$(jq -r '.tool_name // ""' <<<"${input_json}")"
if [[ "${tool_name}" != "Bash" ]]; then
exit 0
fi
command_text="$(jq -r '.tool_input.command // ""' <<<"${input_json}")"
if [[ -z "${command_text}" ]]; then
exit 0
fi
patterns=()
messages=()
rule() {
patterns+=("$1")
messages+=("$2")
}
rule '^grep\b(?!.*\|)' \
"Use 'rg' (ripgrep) instead of 'grep' for better performance and features"
rule '^find\s+\S+\s+-name\b' \
"Use 'rg --files | rg pattern' or 'rg --files -g pattern' instead of 'find -name' for better performance"
rule "(?:^|[;|&()]{1,2})\\s*(?:[A-Za-z_][A-Za-z0-9_]*=(?:\"[^\"]*\"|'[^']*'|\\S+)\\s+)*(?:[./][\\w./]*/)?(?:python|python3)(?:[0-9.]+)?\\s" \
"Use 'uv run' instead of invoking python/python3 directly. For scripts with dependencies, use uv inline script metadata (# /// script) and run with 'uv run script.py'"
rule '(?<!\w)pip\s+(install|uninstall|download)\b' \
"Use 'uv add' or 'uv run --with <pkg>' instead of pip. For one-off scripts, declare dependencies inline with uv script metadata (# /// script)"
rule '(?<!\w)pip3\s+(install|uninstall|download)\b' \
"Use 'uv add' or 'uv run --with <pkg>' instead of pip3. For one-off scripts, declare dependencies inline with uv script metadata (# /// script)"
rule '(?<!\w)(?<!uv run )py(?:test|\.test)\b' \
"Use 'uv run pytest' instead of invoking pytest/py.test directly"
rule '\bpre-commit\s+run\b' \
"Use 'prek run' instead of 'pre-commit run' — prek is the sandbox-compatible wrapper available in PATH"
rule '\bgit\s+commit\b(?!.*(?:-[A-Za-z]*S[A-Za-z]*\b|--gpg-sign\b))' \
"Git commits must be signed. Use 'git commit -S'"
issues=()
for idx in "${!patterns[@]}"; do
if matches_pattern "${patterns[idx]}" <<<"${command_text}"; then
issues+=("${messages[idx]}")
fi
done
if ((${#issues[@]} > 0)); then
for message in "${issues[@]}"; do
printf '• %s\n' "${message}" >&2
done
exit 2
fi
exit 0
#!/usr/bin/env bash
# Claude Code Hook: Block dangerous bash patterns
#
# PreToolUse hook for the Bash tool.
# Blocks reverse shells, pipe-to-shell, obfuscated exec, clipboard reads,
# and fork bombs before they can be executed.
#
# Exit codes:
# 0 = allow
# 2 = block (hard block, no recovery path in current invocation)
set -uo pipefail
# Helper: return 0 if stdin text matches the given Perl-compatible regex
matches_pattern() {
local pattern="${1}"
VALIDATION_PATTERN="${pattern}" perl -0e '
my $pattern = $ENV{VALIDATION_PATTERN};
my $text = do { local $/; <STDIN> };
my $regex = eval { qr{$pattern}si };
exit 1 if $@;
exit($text =~ $regex ? 0 : 1);
'
}
_AI_INSTRUCTIONS="
---
AI INSTRUCTIONS:
You have been blocked from performing this operation. Do NOT attempt to find alternative commands, equivalent steps, or workarounds that achieve the same result through a different path.
Stop and report the following to the user immediately:
1. Your current task — what you were working on
2. Your intended step — what you were trying to do and why
3. What was blocked — the specific action blocked and the reason given above
4. How the user can help — describe exactly what command or steps the user could run manually to complete this on your behalf, and explain why they might or might not want to proceed"
block() {
printf '%s\n%s\n' "${1}" "${_AI_INSTRUCTIONS}" >&2
exit 2
}
# Read stdin; fail closed on any error (Bash-only hook)
input_json="$(cat)" || exit 2
tool_name="$(jq -re '.tool_name // empty' 2>/dev/null <<< "${input_json}")" || exit 2
[[ "${tool_name}" == "Bash" ]] || exit 0
command_text="$(jq -r '.tool_input.command // ""' 2>/dev/null <<< "${input_json}")" || exit 2
[[ -n "${command_text}" ]] || exit 0
# Loopback exemption constants used by the evaluation loop.
# For /dev/tcp and nc patterns the loopback check is positional (host segment)
# to prevent bypass via appended text such as "; echo localhost".
readonly LOCAL_DEVTCP_RE='\/dev\/(tcp|udp)\/(127\.0\.0\.1|localhost|::1|0\.0\.0\.0)\/'
readonly LOCAL_NC_RE='\bnc\b[^;|&\n]*(127\.0\.0\.1|localhost|::1|0\.0\.0\.0)\s+[0-9]'
readonly LOCAL_GENERIC_RE='(127\.0\.0\.1|localhost|::1|0\.0\.0\.0)'
# ── Rule registration ─────────────────────────────────────────────────────────
# rule <exemption> <pattern> <message>
# exemption: devtcp | nc | generic | none
# pattern: PCRE matched case-insensitively with dot-matches-newline
# message: human-readable block reason passed to block()
rules=()
rule() { rules+=("$1" "$2" "$3"); }
# ── Message variables ─────────────────────────────────────────────────────────
_MSG_REVERSE_SHELL="Blocked: reverse shell pattern detected.
This command opens a network connection back to a remote host and executes an interactive shell, giving the remote host full command-line control over this system."
_MSG_PIPE_TO_SHELL="Blocked: download-and-execute pipeline detected.
This command downloads a script from the internet and immediately executes it, bypassing any opportunity to inspect what the script will do before it runs."
_MSG_OBFUSCATED="Blocked: obfuscated execution detected.
This command decodes hidden content at runtime and executes it as a shell script, making the actual payload invisible to static code review."
_MSG_CLIPBOARD="Blocked: clipboard read detected.
This command reads the system clipboard, which may contain passwords, tokens, or other sensitive data the user recently copied."
_MSG_FORK_BOMB="Blocked: fork bomb detected.
This command defines a function that recursively spawns processes until system memory is exhausted, causing a system crash."
_MSG_CRON="Blocked: cron injection detected.
This command installs or modifies cron jobs, which can establish persistent backdoors that survive reboots and run arbitrary code on a schedule."
_MSG_SSH_KEY="Blocked: SSH key manipulation detected.
This command modifies SSH keys or authorized_keys, which could grant persistent unauthorized access to this system or to remote systems."
_MSG_PRIVILEGE="Blocked: privilege escalation detected.
This command elevates to root or another user's privileges.
Claude Code should operate within the current user's permissions.
Delegate these commands to the user to run manually if they are necessary for the task."
_MSG_MACOS="Blocked: macOS system interaction detected.
This command launches applications, runs AppleScript, or modifies system preferences, which can have side effects outside the development environment."
# ── Reverse shells ────────────────────────────────────────────────────────────
# bash -i redirected to /dev/tcp or /dev/udp
rule devtcp 'bash\s+-i\b.*/dev/(tcp|udp)/' "${_MSG_REVERSE_SHELL}"
# Direct /dev/tcp or /dev/udp socket usage (e.g. exec 5<>/dev/tcp/host/port)
rule devtcp '/dev/(tcp|udp)/[^/\s]+/[0-9]+' "${_MSG_REVERSE_SHELL}"
# netcat / ncat with -e (exec) or -c (command)
rule nc '\b(nc|ncat)\b[^;|&\n]*\s-(e|c)\s' "${_MSG_REVERSE_SHELL}"
# Named pipe (mkfifo) piped through nc — bidirectional shell over netcat
rule nc '\bmkfifo\b[^;|&\n]*\|\s*.*\bnc\s' "${_MSG_REVERSE_SHELL}"
# socat forwarding to an interactive shell
rule generic '\bsocat\b[^;|&\n]*(exec:|system:)[^;|&\n]*(sh|bash|zsh)' "${_MSG_REVERSE_SHELL}"
# telnet piped to a shell
rule generic '\btelnet\s+\S+\s+[0-9]+\s*\|.*\b(sh|bash)\b' "${_MSG_REVERSE_SHELL}"
# Scripting language one-liner reverse shells
rule generic '\bpython[0-9.]?\b[^;|&\n]*socket[^;|&\n]*(exec|connect)' "${_MSG_REVERSE_SHELL}"
rule generic '\bperl\b[^;|&\n]*-e[^;|&\n]*socket' "${_MSG_REVERSE_SHELL}"
rule generic '\bruby\b[^;|&\n]*-e[^;|&\n]*TCPSocket' "${_MSG_REVERSE_SHELL}"
rule generic '\bphp\b[^;|&\n]*-r[^;|&\n]*fsockopen' "${_MSG_REVERSE_SHELL}"
# ── Pipe-to-shell ─────────────────────────────────────────────────────────────
rule none '(curl|wget|fetch)\b[^|]*\|\s*(ba?sh|zsh|python[0-9.]?|ruby)\b' "${_MSG_PIPE_TO_SHELL}"
# ── Obfuscated execution ──────────────────────────────────────────────────────
# base64 decode piped directly to a shell
rule none 'base64\s+(-d|--decode)\b[^|]*\|\s*(ba?sh|zsh|python[0-9.]?)' "${_MSG_OBFUSCATED}"
# pipeline that decodes base64 before passing to a shell
rule none '\|\s*base64\s+(-d|--decode)\b[^|]*\|\s*(ba?sh|zsh)' "${_MSG_OBFUSCATED}"
# eval of a command substitution (classic arbitrary-code injection)
rule none '\beval\s*\$\(' "${_MSG_OBFUSCATED}"
rule none '\beval\s*`' "${_MSG_OBFUSCATED}"
# hex decode piped to a shell
rule none 'xxd\s+-r\b[^|]*\|\s*(ba?sh|zsh)' "${_MSG_OBFUSCATED}"
# ── Clipboard reads ───────────────────────────────────────────────────────────
rule none '\bpbpaste\b' "${_MSG_CLIPBOARD}"
rule none '\bxclip\b[^;|&\n]*(--output|-o)\b' "${_MSG_CLIPBOARD}"
rule none '\bxsel\b[^;|&\n]*(--output|-o)\b' "${_MSG_CLIPBOARD}"
rule none '\bwl-paste\b' "${_MSG_CLIPBOARD}"
rule none '\bGet-Clipboard\b' "${_MSG_CLIPBOARD}"
# ── Fork bomb ─────────────────────────────────────────────────────────────────
# Canonical bash fork bomb: :(){ :|:& };:
rule none ':\s*\(\s*\)\s*\{[^}]*:\s*\|[^}]*:\s*&[^}]*\}' "${_MSG_FORK_BOMB}"
# ── Cron injection ────────────────────────────────────────────────────────────
rule none '\|\s*crontab\b' "${_MSG_CRON}"
rule none '\bcrontab\s+-\s*$' "${_MSG_CRON}"
rule none '\bcrontab\s+[^-\s]' "${_MSG_CRON}"
# ── SSH key manipulation ──────────────────────────────────────────────────────
rule none '>>\s*~?/?\S*\.ssh/authorized_keys' "${_MSG_SSH_KEY}"
rule none '>\s*~?/?\S*\.ssh/authorized_keys' "${_MSG_SSH_KEY}"
rule none '\bcp\b[^;|&\n]*\.ssh/(?:id_|authorized_keys)' "${_MSG_SSH_KEY}"
# ── Privilege escalation ──────────────────────────────────────────────────────
rule none '(?:^|[;\n|&]\s*)sudo\b' "${_MSG_PRIVILEGE}"
rule none '(?:^|[;\n|&]\s*)su\b' "${_MSG_PRIVILEGE}"
# ── macOS system interaction ──────────────────────────────────────────────────
rule none '\bosascript\b' "${_MSG_MACOS}"
rule none '\bdefaults\s+write\b' "${_MSG_MACOS}"
# ── Evaluation loop ───────────────────────────────────────────────────────────
for (( i = 0; i < ${#rules[@]}; i += 3 )); do
exemption="${rules[i]}"
pattern="${rules[i+1]}"
message="${rules[i+2]}"
if matches_pattern "${pattern}" <<< "${command_text}"; then
case "${exemption}" in
devtcp)
matches_pattern "${LOCAL_DEVTCP_RE}" <<< "${command_text}" && continue
;;
nc)
matches_pattern "${LOCAL_NC_RE}" <<< "${command_text}" && continue
;;
generic)
matches_pattern "${LOCAL_GENERIC_RE}" <<< "${command_text}" && continue
;;
none) ;; # no exemption — fall through to block()
*)
printf 'block-dangerous-bash.sh: unknown exemption type "%s"\n' "${exemption}" >&2
exit 2
;;
esac
block "${message}"
fi
done
exit 0
# Block Destructive Operations — Pattern File
#
# Each rule requires:
# pattern - regex matched against the Bash command
# description - what the matched operation does and its effect
# reason - why it is blocked or flagged (safety/security concern)
# mode - "block" (exit 2) or "ask" (exit 0 + permission dialog)
#
# Optional:
# case_insensitive = true (for SQL patterns; shell patterns match as-is)
#
# Rules are evaluated in order; the first match wins.
# Structure: [[category.rules]] groups rules by concern for readability.
# ─────────────────────────────────────────────────────────────────────────────
# GIT
# ─────────────────────────────────────────────────────────────────────────────
[git]
description = "Version control history and branch operations"
[[git.rules]]
pattern = '\bgit\s+filter-branch\b'
description = "Rewrites the entire git commit history"
reason = "Irreversibly rewrites history; filtered-out work is permanently lost once the reflog expires"
mode = "block"
[[git.rules]]
pattern = '\bgit\s+filter-repo\b'
description = "Rewrites the entire git commit history (modern replacement for filter-branch)"
reason = "Irreversibly rewrites history; filtered-out work is permanently lost"
mode = "block"
[[git.rules]]
pattern = '\bgit\s+reflog\s+expire\b'
description = "Expires reflog entries, removing the safety net for recovering lost commits"
reason = "Destroys the reflog; without it, accidental resets or lost branches cannot be recovered"
mode = "block"
# Force push: --force without --force-with-lease (both long and short flags)
[[git.rules]]
pattern = '\bgit\s+push\b(?!.*--force-with-lease).*\s--force\b'
description = "Force-pushes changes to the remote, overwriting its history unconditionally"
reason = "Can erase other contributors' work without warning; use --force-with-lease instead"
mode = "block"
[[git.rules]]
pattern = '\bgit\s+push\b(?!.*--force-with-lease).*\s-[a-zA-Z]*f[a-zA-Z]*\b'
description = "Force-pushes changes to the remote using the short flag"
reason = "Can erase other contributors' work without warning; use --force-with-lease instead"
mode = "block"
[[git.rules]]
pattern = '\bgit\s+push\s+--delete\b'
description = "Deletes a remote branch or tag"
reason = "Remote branch deletion affects all collaborators; confirm the target ref"
mode = "ask"
[[git.rules]]
pattern = '\bgit\s+push\b.*\s--force-with-lease\b'
description = "Force-pushes but verifies no one has pushed since your last fetch"
reason = "Still rewrites remote history; safer than --force but can still discard work if the lease is stale"
mode = "ask"
[[git.rules]]
pattern = '\bgit\s+reset\s+--hard\b'
description = "Resets the working tree and index to a commit, discarding all uncommitted changes"
reason = "Permanently discards uncommitted work; any unsaved changes are unrecoverable"
mode = "ask"
[[git.rules]]
pattern = '\bgit\s+clean\s+(?:-[a-zA-Z]*f[a-zA-Z]*\b|-[a-zA-Z]*d[a-zA-Z]*f[a-zA-Z]*\b)'
description = "Removes untracked files (and optionally directories) from the working tree"
reason = "Permanently deletes untracked files that have never been committed; they cannot be recovered"
mode = "ask"
[[git.rules]]
pattern = '\bgit\s+stash\s+drop\b'
description = "Permanently deletes a single stash entry"
reason = "Stash entries are not in the reflog; a dropped stash cannot be recovered"
mode = "ask"
[[git.rules]]
pattern = '\bgit\s+stash\s+clear\b'
description = "Permanently deletes all stash entries at once"
reason = "All stash entries are permanently lost with no recovery path"
mode = "ask"
[[git.rules]]
pattern = '\bgit\s+branch\s+(?:-[a-zA-Z]*D[a-zA-Z]*\b|-D\b)'
description = "Force-deletes a branch even if it has unmerged commits"
reason = "Unmerged commits may become unreachable and eventually garbage-collected"
mode = "ask"
[[git.rules]]
pattern = '\bgit\s+checkout\s+\.\s*$'
description = "Discards all unstaged changes in the working directory"
reason = "Reverts all modified tracked files to their last committed state; unsaved changes are lost"
mode = "ask"
[[git.rules]]
pattern = '\bgit\s+restore\s+\.\s*$'
description = "Discards all unstaged changes in the working directory"
reason = "Reverts all modified tracked files to their last committed state; unsaved changes are lost"
mode = "ask"
[[git.rules]]
pattern = '\bgit\s+add\s+(?:\.\s*$|-A\b)'
description = "Stages all files in the repository, including untracked files"
reason = "May accidentally stage sensitive files (.env, credentials) or large binaries; prefer staging specific files"
mode = "ask"
[[git.rules]]
pattern = '\bgh\s+repo\s+delete\b'
description = "Deletes an entire GitHub repository"
reason = "Repository deletion removes all code, issues, PRs, and history permanently"
mode = "block"
# ─────────────────────────────────────────────────────────────────────────────
# FILESYSTEM
# ─────────────────────────────────────────────────────────────────────────────
[filesystem]
description = "File and directory operations with broad or irreversible effects"
# rm -rf targeting absolute or home paths (catastrophic)
[[filesystem.rules]]
pattern = '\brm\s+(?:-[a-zA-Z]*\s+)*-[a-zA-Z]*(?:[rR][a-zA-Z]*[fF]|[fF][a-zA-Z]*[rR])[a-zA-Z]*\s+(?:~|/)'
description = "Recursively force-deletes files starting from the home directory or an absolute path"
reason = "Irreversibly deletes entire directory trees; absolute-path rm -rf is almost always catastrophic"
mode = "block"
[[filesystem.rules]]
pattern = '\bmkfs\.'
description = "Formats a disk partition with a new filesystem"
reason = "Formatting a partition irreversibly erases all data on it"
mode = "block"
[[filesystem.rules]]
pattern = '\bdd\b.*\bof=/dev/[sh]d'
description = "Writes raw data directly to a disk device"
reason = "dd to a disk device can overwrite partition tables and data with no recovery"
mode = "block"
[[filesystem.rules]]
pattern = '\bchmod\s+777\s+/'
description = "Sets world-readable/writable/executable permissions on a system path"
reason = "chmod 777 on system paths is a severe security risk"
mode = "block"
# General recursive rm (not just -rf to absolute paths)
[[filesystem.rules]]
pattern = '\brm\s+(?:-[a-zA-Z]*\s+)*-[a-zA-Z]*r\b'
description = "Recursively deletes files and directories"
reason = "Recursive deletion can remove large directory trees; confirm this is the intended target"
mode = "ask"
[[filesystem.rules]]
pattern = '\bchmod\s+-R\b'
description = "Recursively changes file permissions on an entire directory tree"
reason = "Recursive permission changes can break file access across an entire tree"
mode = "ask"
[[filesystem.rules]]
pattern = '\bchown\s+-R\b'
description = "Recursively changes file ownership on an entire directory tree"
reason = "Recursive ownership changes can lock out current users from their own files"
mode = "ask"
[[filesystem.rules]]
pattern = '\bhistory\s+-c\b'
description = "Clears the current shell history"
reason = "Clearing history removes the audit trail of commands run in this session"
mode = "ask"
# ─────────────────────────────────────────────────────────────────────────────
# PROCESS MANAGEMENT
# ─────────────────────────────────────────────────────────────────────────────
[process]
description = "Process signaling and termination"
[[process.rules]]
pattern = '\bkillall\b'
description = "Sends a signal to all processes matching a name"
reason = "killall can terminate critical system processes or user applications indiscriminately"
mode = "ask"
[[process.rules]]
pattern = '\bpkill\b'
description = "Sends a signal to processes matching a pattern"
reason = "pkill with a broad pattern can terminate unintended processes"
mode = "ask"
[[process.rules]]
pattern = '\bkill\s+-9\b'
description = "Sends SIGKILL to a process, forcing immediate termination"
reason = "SIGKILL prevents the process from cleaning up; data corruption or resource leaks may result"
mode = "ask"
# ─────────────────────────────────────────────────────────────────────────────
# CLOUD PROVIDERS
# ─────────────────────────────────────────────────────────────────────────────
[cloud]
description = "Cloud provider CLI operations that delete or destroy resources"
# AWS
[[cloud.rules]]
pattern = '\baws\s+s3\s+rm\b.*\s--recursive\b'
description = "Recursively deletes all objects under an S3 prefix"
reason = "S3 deletions are permanent; recursive removal can erase entire buckets of production data"
mode = "ask"
[[cloud.rules]]
pattern = '\baws\s+s3\s+sync\b.*\s--delete\b'
description = "Syncs to S3 and deletes remote objects not present in the source"
reason = "Can silently erase production data that exists in S3 but not in the sync source"
mode = "ask"
# GCP
[[cloud.rules]]
pattern = '\bgcloud\b.*\s(?:delete|destroy)\b'
description = "Deletes or destroys a Google Cloud resource"
reason = "Cloud resource deletions may be irreversible and can affect live production systems"
mode = "ask"
# Firebase
[[cloud.rules]]
pattern = '\bfirebase\b.*\s(?:delete|projects:delete)\b'
description = "Deletes a Firebase resource or entire project"
reason = "Firebase project deletion is permanent and cannot be undone"
mode = "ask"
# Vercel
[[cloud.rules]]
pattern = '\bvercel\b.*\s(?:projects\s+rm|remove)\b'
description = "Removes a Vercel project or deployment"
reason = "Vercel project removal permanently deletes all deployments and configuration"
mode = "ask"
# Netlify
[[cloud.rules]]
pattern = '\bnetlify\b.*\s(?:sites:delete|delete)\b'
description = "Deletes a Netlify site"
reason = "Site deletion removes all deploys and configuration permanently"
mode = "ask"
# Cloudflare
[[cloud.rules]]
pattern = '\bwrangler\b.*\s(?:delete|destroy)\b'
description = "Deletes a Cloudflare Workers or Pages resource"
reason = "Cloudflare resource deletion affects live traffic immediately"
mode = "ask"
# Heroku
[[cloud.rules]]
pattern = '\bheroku\s+apps:destroy\b'
description = "Destroys a Heroku application"
reason = "App destruction removes all dynos, add-ons, and configuration permanently"
mode = "ask"
# Fly.io
[[cloud.rules]]
pattern = '\bfly\s+apps\s+destroy\b'
description = "Destroys a Fly.io application"
reason = "App destruction removes all machines and volumes permanently"
mode = "ask"
# DigitalOcean
[[cloud.rules]]
pattern = '\bdoctl\b.*\s(?:delete|destroy)\b'
description = "Deletes a DigitalOcean resource"
reason = "Resource deletion may destroy droplets, databases, or storage volumes"
mode = "ask"
# Supabase
[[cloud.rules]]
pattern = '\bsupabase\b.*\s(?:projects\s+delete|db\s+reset)\b'
description = "Deletes a Supabase project or resets a database"
reason = "Project deletion or database reset permanently removes data and configuration"
mode = "ask"
# ─────────────────────────────────────────────────────────────────────────────
# INFRASTRUCTURE & ORCHESTRATION
# ─────────────────────────────────────────────────────────────────────────────
[infra]
description = "Infrastructure-as-code and container orchestration operations"
# Terraform / OpenTofu
[[infra.rules]]
pattern = '\b(?:terraform|tofu)\s+destroy\b'
description = "Destroys all infrastructure resources managed by this workspace"
reason = "Irreversibly removes cloud resources including databases, servers, and networks"
mode = "ask"
# Pulumi
[[infra.rules]]
pattern = '\bpulumi\s+destroy\b'
description = "Destroys all resources in a Pulumi stack"
reason = "Irreversibly removes infrastructure resources managed by this stack"
mode = "ask"
# Serverless Framework
[[infra.rules]]
pattern = '\b(?:serverless|sls)\s+remove\b'
description = "Removes a Serverless Framework service and all its cloud resources"
reason = "Removes Lambda functions, API Gateways, and associated resources permanently"
mode = "ask"
# AWS SAM
[[infra.rules]]
pattern = '\bsam\s+delete\b'
description = "Deletes an AWS SAM application stack"
reason = "Removes all CloudFormation resources created by the stack"
mode = "ask"
# Kubernetes
[[infra.rules]]
pattern = '\bkubectl\s+delete\b.*\s(?:--all\b|-A\b|--all-namespaces\b)'
description = "Deletes Kubernetes resources across all namespaces or all resources of a type"
reason = "Can take down entire cluster workloads; --all in core namespaces removes system components"
mode = "block"
[[infra.rules]]
pattern = '\bkubectl\s+delete\s+namespace\b'
description = "Deletes an entire Kubernetes namespace and all resources within it"
reason = "Namespace deletion cascades to all pods, services, and configs in the namespace"
mode = "ask"
# Helm
[[infra.rules]]
pattern = '\bhelm\s+(?:uninstall|delete)\b'
description = "Uninstalls a Helm release and removes all associated Kubernetes resources"
reason = "Removes all deployed resources; data in PersistentVolumeClaims may be lost"
mode = "ask"
[[infra.rules]]
pattern = '\bhelm\s+(?:install|upgrade)\b'
description = "Installs or upgrades a Helm chart on a Kubernetes cluster"
reason = "Helm install/upgrade modifies live cluster workloads"
mode = "ask"
# Docker
[[infra.rules]]
pattern = '\bdocker\s+system\s+prune\b.*\s-a\b'
description = "Removes all unused Docker images, containers, volumes, and build cache"
reason = "Removes all cached images; recovery requires re-pulling and rebuilding everything"
mode = "ask"
[[infra.rules]]
pattern = '\bdocker\s+(?:rm|rmi)\s+-f\b'
description = "Force-removes Docker containers or images"
reason = "Force removal skips confirmation and may disrupt running workloads"
mode = "ask"
[[infra.rules]]
pattern = '\bdocker\s+volume\s+(?:rm|prune)\b'
description = "Removes Docker volumes containing persistent data"
reason = "Volume removal permanently deletes data stored outside containers"
mode = "ask"
# ─────────────────────────────────────────────────────────────────────────────
# DATABASES
# ─────────────────────────────────────────────────────────────────────────────
[database]
description = "Database operations that delete or wipe data"
# Redis
[[database.rules]]
pattern = '\bFLUSHALL\b'
case_insensitive = true
description = "Deletes all keys from all Redis databases simultaneously"
reason = "FLUSHALL is an immediate, unrecoverable wipe of all Redis data across every database"
mode = "block"
[[database.rules]]
pattern = '\bFLUSHDB\b'
case_insensitive = true
description = "Deletes all keys from the currently selected Redis database"
reason = "FLUSHDB immediately and permanently removes all data from the selected database"
mode = "block"
[[database.rules]]
pattern = '\bredis-cli\b.*\bFLUSH'
case_insensitive = true
description = "Invokes a Redis FLUSH command via the CLI"
reason = "Redis FLUSH commands permanently wipe all data with no recovery"
mode = "block"
# MongoDB
[[database.rules]]
pattern = 'dropDatabase\s*\('
description = "Drops an entire MongoDB database and all its collections"
reason = "MongoDB dropDatabase permanently removes the database; there is no undo operation"
mode = "block"
[[database.rules]]
pattern = '\.\s*drop\s*\(\s*\)'
description = "Drops a MongoDB collection and all documents within it"
reason = "Collection drops are permanent; all documents are immediately and irreversibly deleted"
mode = "block"
[[database.rules]]
pattern = '\b(?:mongosh?|mongo)\s+.*--eval\s+.*drop'
description = "Drops a MongoDB database or collection via CLI eval"
reason = "MongoDB drop operations are permanent and irreversible"
mode = "block"
# PostgreSQL
[[database.rules]]
pattern = '\bdropdb\b'
description = "Drops a PostgreSQL database via the CLI utility"
reason = "dropdb permanently deletes the database and all its data"
mode = "block"
# MySQL
[[database.rules]]
pattern = '\bmysqladmin\s+drop\b'
description = "Drops a MySQL database via the mysqladmin utility"
reason = "mysqladmin drop permanently deletes the database"
mode = "block"
# SQL (generic — case-insensitive)
[[database.rules]]
pattern = '(?:^|\b)DROP\s+(?:TABLE|DATABASE|SCHEMA)\b'
case_insensitive = true
description = "Drops a SQL table, database, or schema and all data within it"
reason = "SQL DROP is irreversible; all data in the dropped object is permanently deleted"
mode = "block"
[[database.rules]]
pattern = '\bDELETE\s+FROM\s+\w+\s*;'
case_insensitive = true
description = "Deletes every row from a SQL table (no WHERE clause)"
reason = "DELETE without WHERE removes all rows; this is almost never intentional"
mode = "block"
[[database.rules]]
pattern = '\bDELETE\s+FROM\s+\w+\s*$'
case_insensitive = true
description = "Deletes every row from a SQL table (no WHERE clause)"
reason = "DELETE without WHERE removes all rows; add a WHERE clause to scope the deletion"
mode = "block"
[[database.rules]]
pattern = '\bTRUNCATE\s+(?:TABLE\s+)?\w+'
case_insensitive = true
description = "Removes all rows from a SQL table without logging individual deletions"
reason = "TRUNCATE is faster than DELETE but equally destructive; all data is permanently removed"
mode = "ask"
# ─────────────────────────────────────────────────────────────────────────────
# PUBLISHING & DISTRIBUTION
# ─────────────────────────────────────────────────────────────────────────────
[publishing]
description = "Package and image publishing to registries"
[[publishing.rules]]
pattern = '\b(?:npm|yarn|pnpm)\s+publish\b'
description = "Publishes a package to the npm registry"
reason = "Publishing is a public, largely irreversible action; verify version and contents first"
mode = "ask"
[[publishing.rules]]
pattern = '\bnpm\s+unpublish\b'
description = "Removes a published package version from the npm registry"
reason = "Unpublishing can break downstream consumers who depend on this version"
mode = "ask"
[[publishing.rules]]
pattern = '\bdocker\s+push\b'
description = "Pushes a container image to a registry"
reason = "Pushed images are immediately available to consumers; verify the tag and contents"
mode = "ask"
[[publishing.rules]]
pattern = '\buv\s+publish\b'
description = "Publishes a Python package to PyPI"
reason = "Publishing to PyPI is public and version numbers cannot be reused once published"
mode = "ask"
# ─────────────────────────────────────────────────────────────────────────────
# PACKAGE MANAGEMENT (destructive)
# ─────────────────────────────────────────────────────────────────────────────
[package]
description = "Package operations that remove dependencies"
[[package.rules]]
pattern = '\buv\s+(?:pip\s+)?(?:remove|uninstall)\b'
description = "Removes a Python package from the environment"
reason = "Package removal may break dependent code; confirm this is intentional"
mode = "ask"
# ─────────────────────────────────────────────────────────────────────────────
# NETWORK ACCESS
# ─────────────────────────────────────────────────────────────────────────────
[network]
description = "Commands establishing connections to remote hosts"
[[network.rules]]
pattern = '\bssh\s+'
description = "Opens an SSH connection to a remote host"
reason = "SSH establishes interactive access to a remote system; confirm the target host"
mode = "ask"
[[network.rules]]
pattern = '\bscp\b'
description = "Copies files to or from a remote host over SSH"
reason = "scp transfers files to/from remote systems; confirm source, destination, and host"
mode = "ask"
[[network.rules]]
pattern = '\brsync\b'
description = "Synchronizes files between local and remote paths"
reason = "rsync can overwrite or delete files on the destination; confirm direction and target"
mode = "ask"
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///
"""
Claude Code Hook: Block destructive operations.
PreToolUse hook for the Bash tool.
Loads rules from block-destructive-ops-patterns.toml and matches each command
against them, producing a hard block (exit 2) or a confirmation prompt
(exit 0 + JSON) as specified by the matched rule's mode.
If the pattern file is missing or unreadable, the hook exits 0 (fail-open on
own configuration errors rather than blocking all Bash commands).
"""
import json
import re
import sys
import tomllib
from pathlib import Path
def load_rules() -> list[dict]:
"""Load rules from the co-located TOML file. Return [] on any error.
Supports both flat (``[[rules]]``) and nested (``[[category.rules]]``)
TOML structures. Nested categories are flattened into a single list.
"""
patterns_file = Path(__file__).parent / "block-destructive-ops-patterns.toml"
try:
with open(patterns_file, "rb") as f:
data = tomllib.load(f)
except Exception:
return []
# Flat structure: [[rules]]
if "rules" in data and isinstance(data["rules"], list):
return data["rules"]
# Nested structure: [[category.rules]]
all_rules: list[dict] = []
for key, section in data.items():
if isinstance(section, dict) and "rules" in section:
all_rules.extend(section["rules"])
return all_rules
_AI_INSTRUCTIONS = """
---
AI INSTRUCTIONS:
You have been blocked from performing this operation. Do NOT attempt to find alternative commands, equivalent steps, or workarounds that achieve the same result through a different path.
Stop and report the following to the user immediately:
1. Your current task — what you were working on
2. Your intended step — what you were trying to do and why
3. What was blocked — the specific action blocked and the reason given above
4. How the user can help — describe exactly what command or steps the user could run manually to complete this on your behalf, and explain why they might or might not want to proceed"""
def make_ask_json(reason: str) -> str:
return json.dumps(
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "ask",
"permissionDecisionReason": reason,
}
}
)
def main() -> None:
try:
data = json.load(sys.stdin)
except Exception:
sys.exit(2) # fail-closed: this hook only handles Bash (sensitive tool)
if data.get("tool_name") != "Bash":
sys.exit(0)
command = data.get("tool_input", {}).get("command", "")
if not command:
sys.exit(0)
rules = load_rules()
if not rules:
sys.exit(0)
for rule in rules:
pattern = rule.get("pattern", "")
if not pattern:
continue
flags = re.IGNORECASE if rule.get("case_insensitive", False) else 0
try:
compiled = re.compile(pattern, flags)
except re.error:
continue
if not compiled.search(command):
continue
description = rule.get("description", "")
reason = rule.get("reason", "")
mode = rule.get("mode", "block")
explanation = description
if reason:
explanation += f"\n\n{reason}"
if mode == "block":
sys.stderr.write(f"Blocked: {explanation}\n{_AI_INSTRUCTIONS}\n")
sys.exit(2)
elif mode == "ask":
sys.stdout.write(make_ask_json(explanation))
sys.exit(0)
sys.exit(0)
if __name__ == "__main__":
try:
main()
except SystemExit:
raise
except Exception:
# ErrorHandlingByDamagePotential: this hook only handles Bash
# (a sensitive tool), so always fail-closed on internal error.
sys.exit(2)
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///
"""
Claude Code Hook: Block secret exfiltration and flag sensitive access.
PreToolUse hook — runs for Bash, Read, Write, and Edit tool calls.
Block mode (exit 2):
- Secret detected in Bash command + network destination (exfiltration)
- Credential file content piped to network (.env, .npmrc, .ssh, .aws, etc.)
- Environment variable dump sent to network
- DNS exfiltration patterns
Ask mode (exit 0 + JSON):
- Secret detected in tool_input (universal scan — debugging context)
- Credential store read (keychain, vault, secretsmanager, etc.)
- Read/Write/Edit targeting a file whose name suggests it holds secrets
Performance: built-in regex patterns run first (~microseconds). gitleaks is only
invoked when built-in patterns find a match (for confirmation + broader coverage)
or when a Bash command has network context (where missing a secret is catastrophic).
Error handling (per hook-interface ErrorHandlingByDamagePotential):
- Bash/Write/Edit: exit 2 on internal error (fail-closed)
- All other tools: exit 1 on internal error (fail-open with error logged)
"""
import io
import json
import os
import re
import subprocess
import sys
SENSITIVE_TOOLS = {"Bash", "Write", "Edit"}
# ── Shared risk explanation ───────────────────────────────────────────────────
# Appended to all block/ask messages to give consistent downstream-risk context.
_EXPOSURE_RISK = (
"Exposed credentials risk unauthorised access, account takeover, and data "
"exfiltration across every service they protect."
)
# ── Network tool detection ─────────────────────────────────────────────────────
NETWORK_CMD_RE = re.compile(
r"\b(?:curl|wget|nc|ncat|netcat|ftp|sftp|scp|rsync|socat|ssh(?!-\w)|telnet)\b"
r"|\bopenssl\s+s_client\b",
re.IGNORECASE,
)
# Loopback exemption: checks URL hosts and nc host arguments in context.
LOCAL_URL_RE = re.compile(
r"https?://(?:localhost|127\.0\.0\.1|::1|0\.0\.0\.0)[:/\s\"']",
re.IGNORECASE,
)
LOCAL_NC_RE = re.compile(
r"\bnc\b[^|;&\n]*(?:localhost|127\.0\.0\.1|::1|0\.0\.0\.0)\s+[0-9]",
re.IGNORECASE,
)
# Loopback exemption for connection-based tools: scp, rsync, ssh
# Matches loopback address in the host/destination position.
LOCAL_HOST_ARG_RE = re.compile(
r"\b(?:scp|rsync)\b[^|;&\n]*\s(?:localhost|127\.0\.0\.1|::1|0\.0\.0\.0):"
r"|\bssh\b[^|;&\n]*\s(?:localhost|127\.0\.0\.1|::1|0\.0\.0\.0)\b",
re.IGNORECASE,
)
# ── Environment dump patterns ─────────────────────────────────────────────────
ENV_DUMP_RE = re.compile(r"\b(?:env\b|printenv\b|export\s+-p\b)", re.IGNORECASE)
# ── DNS exfiltration patterns ─────────────────────────────────────────────────
DNS_EXFIL_RE = re.compile(
r"\$\([^)]+\)\.[a-zA-Z0-9-]+\.[a-zA-Z]{2,}" # $(cmd).attacker.com
r"|\b(?:nslookup|dig|host)\s+.*\$\(" # dig $(...)
)
# ── Credential file pipes (block: file content → network) ────────────────────
# Catches `cat .env | curl`, `cat ~/.npmrc | nc`, etc.
CREDENTIAL_FILE_PIPE_RE = re.compile(
r"\b(?:cat|less|head|tail|bat)\s+[^\s|;&#]*"
r"(?:\.env\b|\.envrc\b|\.npmrc\b|\.netrc\b|\.git-credentials\b"
r"|\.aws/credentials\b|\.ssh/id_\w+\b|\.kube/config\b"
r"|credentials\b|secret\b)"
r"[^|;&#]*\|",
re.IGNORECASE,
)
# ── Credential store reads (ask mode) ─────────────────────────────────────────
CREDENTIAL_STORE_RE = re.compile(
r"\bsecurity\s+find-(?:generic|internet)-password\b"
r"|\bvault\s+(?:read|kv\s+get)\b"
r"|\bop\s+(?:read|item\s+get|run)\b"
r"|\baws\s+secretsmanager\b"
r"|\bkubectl\s+get\s+secret\b",
re.IGNORECASE,
)
# ── Built-in secret patterns ─────────────────────────────────────────────────
# Used as fast pre-scan AND as gitleaks fallback.
# High-precision patterns to minimise false positives.
BUILTIN_SECRET_RES = [
# Cloud provider keys
re.compile(r"\bAKIA[0-9A-Z]{16}\b"), # AWS access key ID
re.compile(r"(?i)aws_secret_access_key\s*=\s*\S{20,}"), # AWS secret key assignment
# Platform tokens
re.compile(r"\b(?:ghp|gho|ghs|ghr)_[a-zA-Z0-9]{36}\b"), # GitHub tokens
re.compile(r"\bgithub_pat_[a-zA-Z0-9_]{22,}\b"), # GitHub fine-grained PAT
re.compile(r"\bsk-ant-api[0-9]+-[a-zA-Z0-9]{70,}\b"), # Anthropic
re.compile(r"\bsk-(?:proj-)?[a-zA-Z0-9T]{48,}\b"), # OpenAI-style
re.compile(r"\bxox[bpra]-[0-9]{9,}-[a-zA-Z0-9-]+\b"), # Slack
re.compile(r"[MN][A-Za-z\d]{23,}\.[A-Za-z\d_-]{6}\.[A-Za-z\d_-]{27,}"), # Discord
# Cryptographic material
re.compile(r"-----BEGIN\s+(?:\w+\s+)?PRIVATE\s+KEY-----"), # PEM private key
re.compile(r"-----BEGIN\s+PGP\s+PRIVATE\s+KEY\s+BLOCK-----"), # GPG private key
# Auth headers & generic assignments
re.compile(
r"(?i)(?:bearer|authorization)\s+[A-Za-z0-9\-_.~+/]{20,}"
), # Bearer/auth tokens
re.compile(
r"(?i)(?:api[_-]?key|api[_-]?secret|secret[_-]?key|access[_-]?token)"
r"\s*[=:]\s*[A-Za-z0-9+/\-_.]{16,}"
), # Generic secret assignment
]
# ── File path heuristics for Read/Write/Edit (ask mode) ───────────────────────
CREDENTIAL_FILE_RE = re.compile(
r"(?:^|[/\\])(?:"
r"id_rsa|id_ed25519|id_ecdsa|id_dsa" # SSH keys
r"|[^/\\]*\.(?:key|pem|keystore|jks|p12|pfx)" # key/cert/keystore ext
r"|[^/\\]*(?:token|password|passwd|api[_-]?key|auth_key|secret|credential)[^/\\]*" # named patterns
r"|\.npmrc|\.netrc|\.git-credentials" # credential config files
r")$",
re.IGNORECASE,
)
# ── Directory exemption for name-based patterns ──────────────────────────────
# Name-based patterns don't apply inside these directories, where words like
# 'secret' or 'credentials' are valid file/module names.
EXEMPT_DIR_RE = re.compile(
r"(?:^|[/\\])(?:\.venv|venv|env|site-packages|node_modules|\.claude|claude[/\\]hooks)(?:[/\\]|$)",
re.IGNORECASE,
)
# ── Credential directory prefixes (ask mode) ────────────────────────────────
# Files within well-known credential directories trigger ask regardless of name.
CREDENTIAL_DIR_PREFIXES = ["~/.ssh/", "~/.aws/", "~/.kube/", "~/.gnupg/"]
# ── Gitleaks integration ───────────────────────────────────────────────────────
def scan_with_gitleaks(text: str) -> bool | None:
"""Scan text with gitleaks. Returns True/False/None (unavailable)."""
try:
result = subprocess.run(
["gitleaks", "detect", "--pipe", "--no-banner", "-l", "warn", "--redact"],
input=text,
capture_output=True,
text=True,
timeout=8,
)
return result.returncode == 1
except FileNotFoundError:
return None
except Exception:
return None
def builtin_has_secret(text: str) -> bool:
"""Fast built-in pattern scan (~microseconds)."""
return any(p.search(text) for p in BUILTIN_SECRET_RES)
def has_credential(text: str, *, deep: bool = False) -> bool:
"""
Detect secrets in text.
Fast path (deep=False): built-in patterns only.
Deep path (deep=True): built-in first, then gitleaks for broader coverage.
"""
if builtin_has_secret(text):
return True
if deep:
result = scan_with_gitleaks(text)
if result is not None:
return result
return False
# ── Hook output helpers ────────────────────────────────────────────────────────
_AI_INSTRUCTIONS = """
---
AI INSTRUCTIONS:
You have been blocked from performing this operation. Do NOT attempt to find alternative commands, equivalent steps, or workarounds that achieve the same result through a different path.
Stop and report the following to the user immediately:
1. Your current task — what you were working on
2. Your intended step — what you were trying to do and why
3. What was blocked — the specific action blocked and the reason given above
4. How the user can help — describe exactly what command or steps the user could run manually to complete this on your behalf, and explain why they might or might not want to proceed"""
def block(message: str) -> None:
sys.stderr.write(message + "\n" + _AI_INSTRUCTIONS + "\n")
sys.exit(2)
def ask(reason: str) -> None:
sys.stdout.write(
json.dumps(
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "ask",
"permissionDecisionReason": reason,
}
}
)
)
sys.exit(0)
# ── Main ───────────────────────────────────────────────────────────────────────
def main() -> None:
try:
data = json.load(sys.stdin)
except Exception:
sys.exit(0)
tool_name: str = data.get("tool_name", "")
tool_input: dict = data.get("tool_input", {})
# ── Pre-compute network context for Bash (used by both specific and universal checks)
command = tool_input.get("command", "") if tool_name == "Bash" else ""
has_network = bool(NETWORK_CMD_RE.search(command)) if command else False
is_local = (
bool(
LOCAL_URL_RE.search(command)
or LOCAL_NC_RE.search(command)
or LOCAL_HOST_ARG_RE.search(command)
)
if command
else False
)
is_bash_with_network = has_network and not is_local
# ── Bash-specific pattern checks ─────────────────────────────────────
# These run before the universal scan — they detect exfiltration patterns
# that don't require gitleaks (structural patterns, not secret content).
if command:
# DNS exfiltration (block unconditionally — no localhost exemption)
if DNS_EXFIL_RE.search(command):
block(
"Blocked: DNS exfiltration pattern detected.\n"
"This command encodes credentials or sensitive information into DNS "
"hostnames, bypassing standard network egress controls while evading "
f"detection. {_EXPOSURE_RISK}"
)
# Credential file content piped to network (block unless localhost)
if CREDENTIAL_FILE_PIPE_RE.search(command) and has_network and not is_local:
block(
"Blocked: credential file piped to a network command.\n"
"This command reads a file likely to contain credentials or secrets "
"(.env, .npmrc, SSH keys, AWS credentials) and pipes its contents "
f"to a remote host, directly exposing those credentials. {_EXPOSURE_RISK}"
)
# Environment dump to network (block unless localhost)
if ENV_DUMP_RE.search(command) and has_network and not is_local:
block(
"Blocked: environment variable dump to network detected.\n"
"This command exports all environment variables — including any "
"credentials, API keys, or tokens stored there — and sends them "
f"to a remote host, exposing all of them at once. {_EXPOSURE_RISK}"
)
# Credential store read + network → block
if CREDENTIAL_STORE_RE.search(command) and has_network and not is_local:
block(
"Blocked: credential store read piped to a remote network destination.\n"
"This command reads a secret from a credential store (keychain, vault, "
"or secrets manager) and sends the result to a remote host, directly "
f"exposing those credentials. {_EXPOSURE_RISK}"
)
# Credential store read (local) → ask
if CREDENTIAL_STORE_RE.search(command):
ask(
"This command reads credentials from a system credential store "
"(keychain, vault, or secrets manager). Exposing credentials to "
"Claude's context creates risk: a prompt injection attack could "
"trick Claude into forwarding the retrieved secret to an attacker. "
f"{_EXPOSURE_RISK} Approve only if this access is intentional and "
"the credentials will not leave this session."
)
# ── File path heuristics for Read/Write/Edit ─────────────────────────
if tool_name in ("Read", "Write", "Edit"):
path = tool_input.get("file_path") or tool_input.get("path", "")
if path:
# Check 2: credential directory prefixes (unconditional)
norm_path = os.path.normpath(os.path.expanduser(path))
for dir_prefix in CREDENTIAL_DIR_PREFIXES:
expanded = os.path.normpath(os.path.expanduser(dir_prefix))
if norm_path.startswith(expanded + os.sep) or norm_path == expanded:
ask(
f"This operation targets '{path}', which is inside a "
f"credential directory ({dir_prefix}). Files here typically "
"contain private keys, access tokens, or authentication "
"credentials. Exposing these to Claude's context risks prompt "
f"injection attacks that could forward secrets to an attacker. "
f"{_EXPOSURE_RISK} Approve only if you intentionally want "
"Claude to access this material."
)
# Check 1: credential file name patterns (with package-dir exemption)
if CREDENTIAL_FILE_RE.search(path) and not EXEMPT_DIR_RE.search(path):
ask(
f"This operation targets '{path}', whose name suggests it may "
"contain credentials, tokens, or key material. Exposing this to "
"Claude's context risks prompt injection attacks that could forward "
f"secrets to an attacker. {_EXPOSURE_RISK} Approve only if you "
"intentionally want Claude to access this material."
)
# ── Universal secret scan ────────────────────────────────────────────
# Serialize tool_input and scan for secret patterns.
# Fast path: built-in patterns only (~microseconds).
# Deep path (gitleaks): only for Bash commands with network context,
# where missing a secret would enable exfiltration.
serialized = json.dumps(tool_input)
if has_credential(serialized, deep=is_bash_with_network):
if is_bash_with_network:
block(
"Blocked: credential detected in command with a network destination.\n"
"This command contains an API key or authentication credential and "
"sends data to a remote host, directly exposing those credentials. "
f"{_EXPOSURE_RISK}"
)
ask(
"A credential or API key pattern was detected in this tool call's "
"arguments. A prompt injection attack could trick Claude into forwarding "
f"these credentials to an attacker. {_EXPOSURE_RISK} If this is "
"intentional (e.g., testing authentication locally), approve below. "
"Otherwise deny and remove the credential before re-running."
)
sys.exit(0)
_current_tool_name = ""
if __name__ == "__main__":
try:
raw = sys.stdin.read()
try:
_current_tool_name = json.loads(raw).get("tool_name", "")
except Exception:
pass
sys.stdin = io.StringIO(raw)
main()
except SystemExit:
raise
except Exception:
if _current_tool_name in SENSITIVE_TOOLS:
sys.exit(2)
sys.exit(1)
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///
"""
Claude Code Hook: Block reads and writes to sensitive/zero-access paths.
PreToolUse hook for Read, Write, Edit, and Bash tools.
Hard-blocks (exit 2) any attempt to read from or write to zero-access paths.
Zero-access paths include: dot-env files, ~/.ssh, ~/.aws, ~/.kube, ~/.gnupg,
certificate/key files, Terraform state, and files with 'credentials' or
'secret' in their name (with package-directory exemption).
"""
import json
import os
import re
import sys
# ── Shared risk explanation ───────────────────────────────────────────────────
_EXPOSURE_RISK = (
"Exposed credentials risk unauthorised access, account takeover, and data "
"exfiltration across every service they protect."
)
# ── Directory exemption for name-based patterns ──────────────────────────────
# Name-based patterns ('credentials', 'secret') don't apply inside these
# directories, where those words are valid file/module names rather than
# indicators of actual secrets.
# - Package/dependency dirs: .venv, site-packages, node_modules
# - Tool config dirs: ~/.claude, claude/hooks (hooks like 'block-secrets.py')
EXEMPT_DIR_RE = re.compile(
r"(?:^|[/\\])(?:\.venv|venv|env|site-packages|node_modules|\.claude|claude[/\\]hooks)(?:[/\\]|$)",
re.IGNORECASE,
)
# ── Extension-based patterns (unconditional, no package-dir exemption) ────────
EXTENSION_RE = re.compile(
r"\.(pem|key|p12|pfx|crt|cer|tfstate|tfstate\.backup)$",
re.IGNORECASE,
)
# ── Dot-env file patterns (unconditional) ─────────────────────────────────────
DOTENV_RE = re.compile(r"(?:^|[/\\])\.env(?:\.[^/\\]+)?$")
ENVRC_RE = re.compile(r"(?:^|[/\\])\.envrc$")
# ── Directory prefixes that are always zero-access ────────────────────────────
ZERO_ACCESS_DIRS = ["~/.ssh/", "~/.aws/", "~/.kube/", "~/.gnupg/"]
# ── Specific files that are always zero-access ───────────────────────────────
ZERO_ACCESS_FILES_RE = re.compile(
r"(?:^|[/\\])\.(?:bash_history|zsh_history|sh_history|node_repl_history|python_history)$",
re.IGNORECASE,
)
# ── Name-based patterns (require package-dir exemption check) ─────────────────
NAME_PATTERN_RE = re.compile(r"credentials|secret", re.IGNORECASE)
# ── Bash write-to-path detection ──────────────────────────────────────────────
# Patterns that indicate a bash command is writing to a path:
# - output redirection: > path or >> path
# - cp/mv/install with destination as last argument
# - tee writing to a file
# - chmod or chown targeting a path
def expand(path: str) -> str:
return os.path.normpath(os.path.expanduser(path))
def is_in_exempt_dir(path: str) -> bool:
return bool(EXEMPT_DIR_RE.search(path))
def is_zero_access(path: str) -> tuple[bool, bool]:
"""Return (is_zero_access, needs_exemption_check)."""
norm = expand(path)
if EXTENSION_RE.search(norm):
return True, False
if DOTENV_RE.search(path) or ENVRC_RE.search(path):
return True, False
if ZERO_ACCESS_FILES_RE.search(path):
return True, False
for dir_pat in ZERO_ACCESS_DIRS:
prefix = expand(dir_pat)
if norm.startswith(prefix + os.sep) or norm == prefix:
return True, False
basename = os.path.basename(norm)
if NAME_PATTERN_RE.search(basename):
return True, True # matched but needs package-dir exemption check
return False, False
def should_block(path: str) -> bool:
matched, needs_exemption = is_zero_access(path)
if not matched:
return False
if needs_exemption and is_in_exempt_dir(path):
return False
return True
_AI_INSTRUCTIONS = """
---
AI INSTRUCTIONS:
You have been blocked from performing this operation. Do NOT attempt to find alternative commands, equivalent steps, or workarounds that achieve the same result through a different path.
Stop and report the following to the user immediately:
1. Your current task — what you were working on
2. Your intended step — what you were trying to do and why
3. What was blocked — the specific action blocked and the reason given above
4. How the user can help — describe exactly what command or steps the user could run manually to complete this on your behalf, and explain why they might or might not want to proceed"""
def block(path: str, tool: str) -> None:
action = "Reading" if tool == "Read" else "Accessing"
sys.stderr.write(
f"Blocked: {tool} targeting zero-access path '{path}'.\n"
f"{action} this path would expose credentials, private keys, or "
"authentication secrets to Claude's context. A prompt injection attack "
"could trick Claude into forwarding this material to an attacker. "
f"{_EXPOSURE_RISK}\n"
f"{_AI_INSTRUCTIONS}\n"
)
sys.exit(2)
def extract_bash_write_targets(command: str) -> list[str]:
"""Extract likely write-destination paths from a Bash command string."""
targets: list[str] = []
# Output redirections: > path or >> path (exclude <<, <<<, 2>, etc.)
for m in re.finditer(r"(?<![<2&])>>?\s*([^\s|&;><]+)", command):
targets.append(m.group(1))
# cp / mv / install — destination is the final non-option token
for cmd in ("cp", "mv", "install"):
for m in re.finditer(
rf"\b{cmd}\b\s+((?:(?:-[^\s]+|--[^\s]+|\S+)\s+)+?)(\S+)\s*(?:$|[|&;])",
command,
):
targets.append(m.group(2))
# tee: tee [-ai] <file>
for m in re.finditer(r"\btee\b(?:\s+-[ai]+)*\s+([^\s|&;]+)", command):
targets.append(m.group(1))
# chmod / chown: <cmd> <mode-or-owner> <path>
for cmd in ("chmod", "chown"):
for m in re.finditer(rf"\b{cmd}\b\s+\S+\s+([^\s|&;]+)", command):
targets.append(m.group(1))
return targets
def main() -> None:
try:
data = json.load(sys.stdin)
except Exception:
sys.exit(
0
) # fail-open: this hook handles mixed sensitive+read-only tools (Bash/Read/Write/Edit);
# on parse failure the tool type is unknown, so fail-open per hook-interface spec
tool_name = data.get("tool_name", "")
tool_input = data.get("tool_input", {})
if tool_name not in ("Bash", "Read", "Write", "Edit"):
sys.exit(0)
if tool_name in ("Read", "Write", "Edit"):
path = tool_input.get("file_path") or tool_input.get("path", "")
if path and should_block(path):
block(path, tool_name)
elif tool_name == "Bash":
command = tool_input.get("command", "")
if not command:
sys.exit(0)
for target in extract_bash_write_targets(command):
if should_block(target):
block(target, "Bash write")
sys.exit(0)
if __name__ == "__main__":
try:
main()
except SystemExit:
raise
except Exception:
# ErrorHandlingByDamagePotential: this hook handles Read (read-only) in addition
# to Bash/Write/Edit (sensitive), so fail-open on internal error per hook-interface spec.
sys.exit(0)
#!/usr/bin/env bash
set -euo pipefail
root="${CLAUDE_PROJECT_DIR:-$PWD}"
if [[ -f "$root/.pre-commit-config.yaml" ]]; then
:
elif [[ -f "$root/.pre-commit-config.yml" ]]; then
:
else
exit 0
fi
mapfile -t files < <(
jq -r '[.tool_input.file_path, (.tool_input.file_paths[]?)] | .[] | select(type=="string" and length>0)' \
| awk '!seen[$0]++'
)
((${#files[@]})) || exit 0
if command -v prek >/dev/null 2>&1; then
prek run --files "${files[@]}"
elif command -v pre-commit >/dev/null 2>&1; then
pre-commit run --files "${files[@]}"
else
exit 0
fi
#!/usr/bin/env bash
# Claude Code Hook: Session tool checks
#
# SessionStart hook that verifies required security tools are available.
# - uv: required (blocks session if missing — Python hooks depend on it)
# - jq: required (blocks session if missing — bash hooks need it for JSON parsing)
# - perl: required (blocks session if missing — block-dangerous-bash.sh needs PCRE)
# - gitleaks: recommended (warns if missing — block-secrets.py falls back to
# built-in patterns with reduced coverage)
#
# Read more about hooks here: https://docs.anthropic.com/en/docs/claude-code/hooks
set -uo pipefail
# ── Tool registration ─────────────────────────────────────────────────────────
# require_tool <name> <install_hint> — exits 2 if tool is missing
# recommend_tool <name> <install_hint> — warns if missing, session proceeds
required_tools=()
recommended_tools=()
require_tool() { required_tools+=("$1" "$2"); }
recommend_tool() { recommended_tools+=("$1" "$2"); }
# ── Required tools ────────────────────────────────────────────────────────────
require_tool uv \
"Install: curl -LsSf https://astral.sh/uv/install.sh | sh
Or via Homebrew: brew install uv
uv is required for Python-based security hooks."
require_tool jq \
"Install: brew install jq
More options: https://jqlang.github.io/jq/download/
jq is required by bash-based hooks for JSON input parsing."
require_tool perl \
"Install: pre-installed on macOS; on Linux use your package manager
(apt install perl, dnf install perl)
perl is required by block-dangerous-bash.sh for PCRE pattern matching."
# ── Recommended tools ─────────────────────────────────────────────────────────
recommend_tool gitleaks \
"Install: brew install gitleaks
More options: https://github.com/gitleaks/gitleaks#installing
Secret detection will fall back to built-in patterns with reduced coverage."
# ── Evaluation ────────────────────────────────────────────────────────────────
for (( i = 0; i < ${#required_tools[@]}; i += 2 )); do
name="${required_tools[i]}"
hint="${required_tools[i+1]}"
if ! command -v "${name}" >/dev/null 2>&1; then
printf '⚠️ %s is not installed or not in PATH.\n\n' "${name}" >&2
printf ' %s\n' "${hint}" >&2
exit 2
fi
done
for (( i = 0; i < ${#recommended_tools[@]}; i += 2 )); do
name="${recommended_tools[i]}"
hint="${recommended_tools[i+1]}"
if ! command -v "${name}" >/dev/null 2>&1; then
printf '⚠️ %s is not installed or not in PATH.\n\n' "${name}" >&2
printf ' %s\n' "${hint}" >&2
# exit 0: recommended tools are not required — session proceeds
fi
done
exit 0
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment