Development
Hook
Hooks
What dotclaude runs automatically, and when. A hook is a command Claude Code fires at a fixed moment, without you asking for it.
Install
> /plugin marketplace add poshan0126/dotclaudeShips with dotclaude. Installing the plugin gets these hooks.
Where it lives
- hooks/auto-test.shGitHub
Read the script
#!/bin/bash # Finds and runs the matching test file after Claude edits a source file. # PostToolUse hook for Edit|Write. # Silent on success. Only emits output when tests fail, so passing tests # contribute zero tokens. Skips test files themselves, config files, and # non-testable extensions. # Requires jq for JSON parsing. if ! command -v jq >/dev/null 2>&1; then exit 0 fi # Emit failing-test output, capped so one broad failure (or a whole-suite runner # like `go test ./...` / `cargo test`) can't flood the conversation context. emit_test_failures() { echo "auto-test: failures in $REL_TEST" local total total=$(printf '%s\n' "$OUTPUT" | wc -l | tr -d ' ') if [ "$total" -gt 60 ]; then printf '%s\n' "$OUTPUT" | tail -n 60 echo "(output truncated — $((total - 60)) earlier lines hidden; run the test file directly for full logs)" else printf '%s\n' "$OUTPUT" fi } # Test seam: fixtures set DOTCLAUDE_AUTOTEST_FAKE_LINES to exercise the failure # emit + truncation path hermetically, since language test runners aren't # installed in every CI environment. Mirrors the DRYRUN/FINGERPRINT seams in the # sibling hooks. Never set in normal use. if [ -n "${DOTCLAUDE_AUTOTEST_FAKE_LINES:-}" ]; then REL_TEST="fake/synthetic_test" OUTPUT=$(seq 1 "$DOTCLAUDE_AUTOTEST_FAKE_LINES" 2>/dev/null | sed 's/^/FAIL line /') emit_test_failures exit 0 fi INPUT=$(cat) FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty') if [ -z "$FILE_PATH" ] || [ ! -f "$FILE_PATH" ]; then exit 0 fi BASENAME=$(basename "$FILE_PATH") EXTENSION="${BASENAME##*.}" NAME="${BASENAME%.*}" DIR=$(dirname "$FILE_PATH") # Skip if the edited file IS a test file. case "$BASENAME" in *.test.*|*.spec.*|*_test.*|*_spec.*|test_*|spec_*) exit 0 ;; esac # Skip config, style, and non-code files. case "$EXTENSION" in json|yaml|yml|toml|ini|cfg|env|md|txt|css|scss|less|svg|png|jpg|ico|html) exit 0 ;; esac # Skip files in non-testable directories. case "$FILE_PATH" in */.claude/*|*/public/*|*/static/*|*/assets/*|*/__mocks__/*) exit 0 ;; esac # Find project root. find_project_root() { local dir="$PWD" while [ "$dir" != "/" ]; do if [ -f "$dir/package.json" ] || [ -f "$dir/pyproject.toml" ] || [ -f "$dir/Cargo.toml" ] || [ -f "$dir/go.mod" ] || [ -d "$dir/.git" ]; then echo "$dir" return fi dir=$(dirname "$dir") done echo "$PWD" } ROOT=$(find_project_root) STEM="$NAME" # Search for a matching test file in the usual conventions. find_test_file() { local stem="$1" local ext="$2" local patterns=( "${stem}.test.${ext}" "${stem}.spec.${ext}" "${stem}_test.${ext}" "${stem}_spec.${ext}" "test_${stem}.${ext}" ) # Same directory first. for pattern in "${patterns[@]}"; do [ -f "${DIR}/${pattern}" ] && { echo "${DIR}/${pattern}"; return; } done # __tests__ subdirectory (Jest convention). for pattern in "${patterns[@]}"; do [ -f "${DIR}/__tests__/${pattern}" ] && { echo "${DIR}/__tests__/${pattern}"; return; } done # Parallel test directory structure (src/foo.ts -> tests/foo.test.ts). local rel_dir="${DIR#$ROOT/}" local test_rel_dir for test_root in "tests" "test" "__tests__" "spec"; do test_rel_dir=$(echo "$rel_dir" | sed "s|^src/|${test_root}/|;s|^lib/|${test_root}/|") for pattern in "${patterns[@]}"; do [ -f "${ROOT}/${test_rel_dir}/${pattern}" ] && { echo "${ROOT}/${test_rel_dir}/${pattern}"; return; } done done # Broad search as last resort, depth-limited to stay fast. local found for pattern in "${patterns[@]}"; do found=$(find "$ROOT" -maxdepth 5 -name "$pattern" -not -path "*/node_modules/*" -not -path "*/.git/*" -print -quit 2>/dev/null) [ -n "$found" ] && { echo "$found"; return; } done } TEST_FILE=$(find_test_file "$STEM" "$EXTENSION") if [ -z "$TEST_FILE" ]; then # No matching test found, not an error. exit 0 fi # Make path relative for cleaner output if we end up emitting failure logs. REL_TEST="${TEST_FILE#$ROOT/}" # Run tests, capture output, only emit on failure. # Use default (non-verbose) reporters to keep failure logs tight. OUTPUT="" EXIT=0 case "$EXTENSION" in js|jsx|ts|tsx|mjs|cjs) if [ -f "$ROOT/node_modules/.bin/vitest" ]; then OUTPUT=$(cd "$ROOT" && npx vitest run "$REL_TEST" 2>&1); EXIT=$? elif [ -f "$ROOT/node_modules/.bin/jest" ]; then OUTPUT=$(cd "$ROOT" && npx jest "$REL_TEST" 2>&1); EXIT=$? elif [ -f "$ROOT/node_modules/.bin/mocha" ]; then OUTPUT=$(cd "$ROOT" && npx mocha "$REL_TEST" 2>&1); EXIT=$? else OUTPUT=$(cd "$ROOT" && npm test -- "$REL_TEST" 2>&1); EXIT=$? fi ;; py) if command -v pytest >/dev/null 2>&1; then OUTPUT=$(cd "$ROOT" && pytest "$REL_TEST" 2>&1); EXIT=$? elif command -v python3 >/dev/null 2>&1; then OUTPUT=$(cd "$ROOT" && python3 -m unittest "$REL_TEST" 2>&1); EXIT=$? elif command -v python >/dev/null 2>&1; then OUTPUT=$(cd "$ROOT" && python -m unittest "$REL_TEST" 2>&1); EXIT=$? fi ;; go) OUTPUT=$(cd "$DIR" && go test ./... 2>&1); EXIT=$? ;; rs) OUTPUT=$(cd "$ROOT" && cargo test 2>&1); EXIT=$? ;; *) exit 0 ;; esac if [ "$EXIT" -ne 0 ]; then emit_test_failures fi exit 0 - hooks/block-dangerous-commands.shGitHub
Read the script
#!/usr/bin/env bash # Blocks dangerous shell commands: push to protected branches, force push, # destructive operations. PreToolUse hook for Bash operations. # Exit 2 = block. Exit 0 = allow. # # Configurable via env: # CLAUDE_PROTECTED_BRANCHES comma list (default: derived from git + main,master) set -uo pipefail emit_deny() { # Emit a JSON deny decision and exit 2. local reason="${1//\"/\\\"}" printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"%s"}}\n' "$reason" exit 2 } if ! command -v jq >/dev/null 2>&1; then emit_deny "jq is required for command protection hooks but is not installed." fi INPUT=$(cat) COMMAND=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null || true) [ -z "$COMMAND" ] && exit 0 # ── Protected branch list ──────────────────────────────────────────────── DEFAULT_BRANCHES="main,master" if GIT_DEFAULT=$(git config --get init.defaultBranch 2>/dev/null) && [ -n "$GIT_DEFAULT" ]; then DEFAULT_BRANCHES="$DEFAULT_BRANCHES,$GIT_DEFAULT" fi PROTECTED_BRANCHES="${CLAUDE_PROTECTED_BRANCHES:-$DEFAULT_BRANCHES}" # Build a regex alternation: main|master|develop|... BR_REGEX=$(printf '%s' "$PROTECTED_BRANCHES" | tr ',' '\n' | awk 'NF{printf "%s%s",sep,$0; sep="|"}') contains_cmd() { printf '%s' "$COMMAND" | grep -qE "$1"; } contains_icmd() { printf '%s' "$COMMAND" | grep -qiE "$1"; } # ── Git push protections ──────────────────────────────────────────────── if contains_cmd '(^|[;&|()]+[[:space:]]*)git[[:space:]]+push'; then # Explicit refspec to a protected branch (origin main, :main, HEAD:main, remote branch) if contains_cmd "git[[:space:]]+push[[:space:]]+[^[:space:]]+[[:space:]]+([^[:space:]]*:)?($BR_REGEX)(\$|[[:space:]])"; then MATCHED_BRANCH=$(printf '%s' "$COMMAND" | grep -oE "($BR_REGEX)(\$|[[:space:]])" | head -1 | tr -d '[:space:]') emit_deny "Blocked: push to protected branch '${MATCHED_BRANCH:-main}'. Use a feature branch and open a PR." fi if contains_cmd "git[[:space:]]+push.*:($BR_REGEX)(\$|[[:space:]])"; then MATCHED_BRANCH=$(printf '%s' "$COMMAND" | grep -oE ":($BR_REGEX)(\$|[[:space:]])" | head -1 | tr -d ': [:space:]') emit_deny "Blocked: push to protected branch '${MATCHED_BRANCH:-main}' via refspec. Use a feature branch and open a PR." fi # Bare `git push` while on protected branch if contains_cmd 'git[[:space:]]+push[[:space:]]*($|[;&|])'; then CURRENT=$(git branch --show-current 2>/dev/null || true) if [ -n "$CURRENT" ] && printf '%s' ",$PROTECTED_BRANCHES," | grep -q ",$CURRENT,"; then emit_deny "Blocked: you are on '$CURRENT' (a protected branch). Switch to a feature branch." fi fi # Force push (but allow --force-with-lease) if contains_cmd 'git[[:space:]]+push([[:space:]]+[^[:space:]]+)*[[:space:]]+(-[a-zA-Z]*f[a-zA-Z]*|--force)([[:space:]=]|$)' \ && ! contains_cmd '\-\-force-with-lease'; then emit_deny "Blocked: force push is not allowed. Use --force-with-lease if you must overwrite remote." fi fi # ── Destructive filesystem operations ─────────────────────────────────── # rm -rf targeting root, home, $HOME, $VAR (any unresolved expansion), or parent traversal. # We normalise quotes before matching so "my folder", '$HOME/trash', etc. Are all inspected. CMD_NOQUOTE=$(printf '%s' "$COMMAND" | tr -d "'\"") if printf '%s' "$CMD_NOQUOTE" | grep -qE 'rm[[:space:]]+(-[a-zA-Z]*[[:space:]]+)*-?[a-zA-Z]*r[a-zA-Z]*f[a-zA-Z]*[[:space:]]+(/([[:space:]]|\*|$)|~|\$HOME|\$[A-Za-z_][A-Za-z0-9_]*|\.\./\.\.)' ; then emit_deny "Blocked: recursive force-delete on /, ~, \$HOME, an unresolved \$VAR, or .../.. Path. Specify a concrete safe target." fi # rm -rf /usr, /etc, /var, /bin, etc. if printf '%s' "$CMD_NOQUOTE" | grep -qE 'rm[[:space:]]+(-[a-zA-Z]+[[:space:]]+)*-?[a-zA-Z]*r[a-zA-Z]*f[a-zA-Z]*[[:space:]]+/(usr|etc|var|bin|sbin|lib|opt|root|boot)([[:space:]/]|$)'; then emit_deny "Blocked: recursive delete targeting a system directory." fi # ── Dangerous database operations ─────────────────────────────────────── # DROP TABLE|DATABASE|SCHEMA if contains_icmd 'DROP[[:space:]]+(TABLE|DATABASE|SCHEMA)[[:space:]]+'; then emit_deny "Blocked: DROP TABLE/DATABASE/SCHEMA detected. Run manually if intended." fi # DELETE FROM without a WHERE on the SAME statement. # Split on ';' so multi-statement inputs are analysed per-statement. if printf '%s\n' "$COMMAND" | awk ' BEGIN { IGNORECASE=1; RS=";" } /DELETE[[:space:]]+FROM[[:space:]]+[A-Za-z_][A-Za-z0-9_.]*/ { if ($0 !~ /WHERE/) { print "BAD"; exit } } ' | grep -q BAD; then emit_deny "Blocked: DELETE FROM without a WHERE clause. Add a WHERE or run manually." fi if contains_icmd 'TRUNCATE[[:space:]]+TABLE'; then emit_deny "Blocked: TRUNCATE TABLE detected. Run manually if intended." fi # ── Dangerous system commands ─────────────────────────────────────────── # chmod: any world-writable/universal mode (0?777 or a+rwx) if contains_cmd 'chmod([[:space:]]+-[a-zA-Z]+)*[[:space:]]+0?777([[:space:]]|$)' \ || contains_cmd 'chmod([[:space:]]+-[a-zA-Z]+)*[[:space:]]+a\+rwx([[:space:]]|$)'; then emit_deny "Blocked: chmod 777 / a+rwx grants everyone full access. Use restrictive perms." fi # curl/wget piped to a shell if contains_cmd '(curl|wget)[[:space:]].*\|[[:space:]]*(sudo[[:space:]]+)?(bash|sh|zsh|ksh|fish|dash|csh)([[:space:]]|$)'; then emit_deny "Blocked: piping downloaded content directly to a shell is dangerous." fi # Disk / partition. Note: only REDIRECTIONS to /dev/ are destructive. `2>/dev/null` is not. # Pattern matches: `>[ ]*/dev/<something>` but NOT `2>/dev/null` or `&>/dev/null` style for fd-null. # Strategy: match `>` optionally with whitespace, followed by /dev/<name>, EXCLUDING /dev/null and /dev/stderr/stdout. if printf '%s' "$COMMAND" | grep -qE '(^|[^0-9&])>[[:space:]]*/dev/[a-zA-Z][a-zA-Z0-9]*' \ && ! printf '%s' "$COMMAND" | grep -qE '>[[:space:]]*/dev/(null|stdout|stderr|tty|zero|random|urandom)([[:space:]]|$)' ; then - hooks/format-on-save.shGitHub
Read the script
#!/bin/bash # Auto-formats files after Claude edits them. # PostToolUse hook for Edit|Write. # Silent on success. All formatter stdout and stderr is redirected so the # hook contributes zero tokens on the common path. Auto-detects formatters # (requires both the binary AND a config file). # Requires jq for JSON parsing. if ! command -v jq >/dev/null 2>&1; then exit 0 fi INPUT=$(cat) FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty') if [ -z "$FILE_PATH" ] || [ ! -f "$FILE_PATH" ]; then exit 0 fi EXTENSION="${FILE_PATH##*.}" FORMATTED=false # Find the project root (nearest directory with a manifest or .git). find_project_root() { local dir="$PWD" while [ "$dir" != "/" ]; do if [ -f "$dir/package.json" ] || [ -f "$dir/pyproject.toml" ] || [ -f "$dir/Cargo.toml" ] || [ -f "$dir/go.mod" ] || [ -d "$dir/.git" ]; then echo "$dir" return fi dir=$(dirname "$dir") done echo "$PWD" } ROOT=$(find_project_root) # Biome (JS, TS, JSON, CSS all-in-one). Faster than Prettier; check first. if [ "$FORMATTED" = false ] && [ -f "$ROOT/node_modules/.bin/biome" ] && { [ -f "$ROOT/biome.json" ] || [ -f "$ROOT/biome.jsonc" ]; }; then case "$EXTENSION" in js|jsx|ts|tsx|json|css) npx biome format --write "$FILE_PATH" >/dev/null 2>&1 && FORMATTED=true ;; esac fi # Prettier (Node.js, TypeScript, web). if [ "$FORMATTED" = false ] && [ -f "$ROOT/node_modules/.bin/prettier" ]; then HAS_PRETTIER_CONFIG=false for cfg in .prettierrc .prettierrc.json .prettierrc.yml .prettierrc.yaml .prettierrc.js .prettierrc.cjs .prettierrc.mjs .prettierrc.toml prettier.config.js prettier.config.cjs prettier.config.mjs; do if [ -f "$ROOT/$cfg" ]; then HAS_PRETTIER_CONFIG=true break fi done if [ "$HAS_PRETTIER_CONFIG" = false ] && [ -f "$ROOT/package.json" ] && grep -q '"prettier"' "$ROOT/package.json" 2>/dev/null; then HAS_PRETTIER_CONFIG=true fi if [ "$HAS_PRETTIER_CONFIG" = true ]; then case "$EXTENSION" in js|jsx|ts|tsx|json|css|scss|md|yaml|yml|html) npx prettier --write "$FILE_PATH" >/dev/null 2>&1 && FORMATTED=true ;; esac fi fi # Ruff (Python). Modern replacement for Black + isort. if [ "$FORMATTED" = false ] && command -v ruff >/dev/null 2>&1; then HAS_RUFF_CONFIG=false if [ -f "$ROOT/ruff.toml" ] || [ -f "$ROOT/.ruff.toml" ]; then HAS_RUFF_CONFIG=true elif [ -f "$ROOT/pyproject.toml" ] && grep -q '\[tool\.ruff\]' "$ROOT/pyproject.toml" 2>/dev/null; then HAS_RUFF_CONFIG=true fi if [ "$HAS_RUFF_CONFIG" = true ]; then case "$EXTENSION" in py) ruff format "$FILE_PATH" >/dev/null 2>&1 ruff check --fix "$FILE_PATH" >/dev/null 2>&1 FORMATTED=true ;; esac fi fi # Black + isort (Python). Fallback if Ruff is not configured. if [ "$FORMATTED" = false ] && command -v black >/dev/null 2>&1; then HAS_BLACK_CONFIG=false if [ -f "$ROOT/pyproject.toml" ] && grep -q '\[tool\.black\]' "$ROOT/pyproject.toml" 2>/dev/null; then HAS_BLACK_CONFIG=true fi if [ "$HAS_BLACK_CONFIG" = true ]; then case "$EXTENSION" in py) black --quiet "$FILE_PATH" >/dev/null 2>&1 command -v isort >/dev/null 2>&1 && isort --quiet "$FILE_PATH" >/dev/null 2>&1 FORMATTED=true ;; esac fi fi # Rust (rustfmt is standard, no config check needed). if [ "$FORMATTED" = false ] && command -v rustfmt >/dev/null 2>&1; then case "$EXTENSION" in rs) rustfmt "$FILE_PATH" >/dev/null 2>&1 && FORMATTED=true ;; esac fi # Go (gofmt is standard, no config check needed). if [ "$FORMATTED" = false ] && command -v gofmt >/dev/null 2>&1; then case "$EXTENSION" in go) gofmt -w "$FILE_PATH" >/dev/null 2>&1 && FORMATTED=true ;; esac fi exit 0 - hooks/notify.shGitHub
Read the script
#!/bin/bash # Sends a native OS notification when Claude needs user input. # Used as a Notification hook. # Supports macOS (osascript), Linux (notify-send), and WSL (powershell). INPUT=$(cat 2>/dev/null) # Extract the notification message if jq is available MESSAGE="Claude Code needs your attention" if command -v jq >/dev/null 2>&1 && [ -n "$INPUT" ]; then MSG=$(echo "$INPUT" | jq -r '.message // empty' 2>/dev/null) if [ -n "$MSG" ]; then MESSAGE="$MSG" fi fi TITLE="Claude Code" # Test/dry-run mode: print instead of notifying (used by hook fixtures). if [ "${DOTCLAUDE_NOTIFY_DRYRUN:-0}" = "1" ]; then echo "notify: $TITLE: $MESSAGE" exit 0 fi # macOS if command -v osascript >/dev/null 2>&1; then osascript -e "display notification \"$MESSAGE\" with title \"$TITLE\"" 2>/dev/null exit 0 fi # Linux (native) if command -v notify-send >/dev/null 2>&1; then notify-send "$TITLE" "$MESSAGE" 2>/dev/null exit 0 fi # WSL → Windows toast if command -v powershell.exe >/dev/null 2>&1; then powershell.exe -Command "[System.Reflection.Assembly]::LoadWithPartialName('System.Windows.Forms') | Out-Null; \$n = New-Object System.Windows.Forms.NotifyIcon; \$n.Icon = [System.Drawing.SystemIcons]::Information; \$n.Visible = \$true; \$n.ShowBalloonTip(5000, '$TITLE', '$MESSAGE', 'Info')" 2>/dev/null exit 0 fi # No notification method available. Silent exit exit 0 - hooks/protect-files.shGitHub
Read the script
#!/usr/bin/env bash # Blocks edits to sensitive or generated files. # PreToolUse hook for Edit|Write operations. # Exit 2 = block. Exit 0 = allow. set -uo pipefail emit() { # $1 = decision (deny|ask) ; $2 = reason local decision="$1" local reason="${2//\"/\\\"}" printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"%s","permissionDecisionReason":"%s"}}\n' "$decision" "$reason" exit 2 } if ! command -v jq >/dev/null 2>&1; then emit deny "jq is required for file protection hooks but is not installed." fi INPUT=$(cat) FILE_PATH=$(printf '%s' "$INPUT" | jq -r '.tool_input.file_path // empty' 2>/dev/null || true) [ -z "$FILE_PATH" ] && exit 0 BASENAME=$(basename -- "$FILE_PATH") # Case-insensitive comparison copy BASENAME_LC=$(printf '%s' "$BASENAME" | tr '[:upper:]' '[:lower:]') PATH_LC=$(printf '%s' "$FILE_PATH" | tr '[:upper:]' '[:lower:]') # Protected basename patterns. Matched case-insensitively via BASENAME_LC. PROTECTED_PATTERNS=( ".env" ".env.*" "*.pem" "*.key" "*.crt" "*.p12" "*.pfx" "id_rsa" "id_ed25519" "credentials.json" ".npmrc" ".pypirc" "package-lock.json" "yarn.lock" "pnpm-lock.yaml" "*.gen.ts" "*.generated.*" "*.min.js" "*.min.css" ) shopt -s nocasematch 2>/dev/null || true for pattern in "${PROTECTED_PATTERNS[@]}"; do # Using bash case with nocasematch for case-insensitive glob match. case "$BASENAME_LC" in $pattern) emit deny "Protected file: $BASENAME matches pattern '$pattern'" ;; esac done # Sensitive directories (use lower-cased path for case-insensitive on mac/Windows). case "$PATH_LC" in .git/*|*/.git/*) emit deny "Cannot edit files inside .git/" ;; secrets/*|*/secrets/*) emit deny "Cannot edit files inside secrets/" ;; .env|.env.*|*/.env|*/.env.*) emit deny "Cannot edit .env files" ;; .claude/hooks/*|*/.claude/hooks/*) emit deny "Cannot edit hook scripts. These enforce security boundaries." ;; .claude/settings.json|*/.claude/settings.json|.claude/settings.local.json|*/.claude/settings.local.json) emit ask "Editing settings.json. This controls permissions and hooks. Confirm this change." ;; esac exit 0 - hooks/scan-secrets.shGitHub
Read the script
#!/bin/bash # Scans file content for accidental secrets before writing. # Used as a PreToolUse hook for Edit|Write operations. # Exit 2 = block. Exit 0 = allow. # Requires jq for JSON parsing. Allow if missing (don't block the user) if ! command -v jq >/dev/null 2>&1; then exit 0 fi INPUT=$(cat) TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty') # Extract the content being written if [ "$TOOL_NAME" = "Write" ]; then CONTENT=$(echo "$INPUT" | jq -r '.tool_input.content // empty') elif [ "$TOOL_NAME" = "Edit" ]; then CONTENT=$(echo "$INPUT" | jq -r '.tool_input.new_string // empty') else exit 0 fi if [ -z "$CONTENT" ]; then exit 0 fi # --- High-confidence secret patterns --- MATCHES="" # AWS Access Key IDs if echo "$CONTENT" | grep -qE 'AKIA[0-9A-Z]{16}'; then MATCHES="$MATCHES AWS access key (AKIA...);" fi # AWS Secret Access Keys (40 chars base64 after a key assignment) if echo "$CONTENT" | grep -qiE '(aws_secret_access_key|secret_key)[[:space:]]*[=:][[:space:]]*["'\''"]?[A-Za-z0-9/+=]{40}'; then MATCHES="$MATCHES AWS secret key;" fi # GitHub tokens (PAT, OAuth, App) if echo "$CONTENT" | grep -qE '(ghp_|gho_|ghs_|ghr_|github_pat_)[a-zA-Z0-9_]{20,}'; then MATCHES="$MATCHES GitHub token;" fi # OpenAI / Stripe / Anthropic style keys (sk-..., incl. hyphenated sk-ant-api03-...) if echo "$CONTENT" | grep -qE 'sk-[a-zA-Z0-9-]{20,}'; then MATCHES="$MATCHES API key (sk-...);" fi # Slack tokens if echo "$CONTENT" | grep -qE 'xox[bpras]-[0-9a-zA-Z-]{10,}'; then MATCHES="$MATCHES Slack token;" fi # Private key blocks if echo "$CONTENT" | grep -qE -- '-----BEGIN[[:space:]]+(RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----'; then MATCHES="$MATCHES private key block;" fi # Connection strings with embedded credentials if echo "$CONTENT" | grep -qE '(mongodb|postgres|mysql|redis|amqp|smtp)(\+[a-z]+)?://[^:[:space:]]+:[^@[:space:]]+@'; then MATCHES="$MATCHES connection string with credentials;" fi # Generic password/secret/token assignments with literal string values # Matches: password = "actual_value", SECRET_KEY: 'actual_value', api_token="actual_value" # Excludes: env var references like process.env.*, os.environ.*, ${...}, getenv(...) if echo "$CONTENT" | grep -qiE '(password|secret|token|api_key|apikey|api_secret)[[:space:]]*[=:][[:space:]]*["'\''"][^"'\''"]{8,}["'\''"]' && \ ! echo "$CONTENT" | grep -qiE '(password|secret|token|api_key|apikey|api_secret)[[:space:]]*[=:][[:space:]]*["'\''"]?(process\.env|os\.environ|getenv|\$\{|ENV\[|env\()'; then MATCHES="$MATCHES hardcoded credential;" fi if [ -n "$MATCHES" ]; then # Use "ask" not "deny". Warn the user but let them override (could be test fixtures) REASON="Possible secret detected in content:$MATCHES Review carefully before allowing." echo "{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"ask\",\"permissionDecisionReason\":\"$REASON\"}}" exit 2 fi exit 0 - hooks/session-start.shGitHub
- hooks/warn-large-files.shGitHub
All 8 scripts are listed above. The source is inlined for 6 of them, starting with whatever hooks.json actually runs. See all of them in the repo.
Read the script before you install anything that runs on your machine. This is the one part of a plugin that acts without being asked.
Ships withdotclaude
A lean .claude/ setup for daily development. Seven specialist agents, twelve workflow skills, six modular rules, and eight safety/productivity hooks. No bloat, no model assignments, no opinions you can't override.
Get the whole plugin
Stats
870
Stars
204
Forks
Maintained
Maintenance
Shell
Language
MIT
License
1mo ago
Last commit
6mo ago
Created
5h ago
Added
Repo: poshan0126/dotclaude

