Skip to content
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.

From plugin
dotclaude
87014 skills7 agents8 hooks
Install
> /plugin marketplace add poshan0126/dotclaude

Ships 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