Claude Code Kit

Hook Source Code

Full source code for all built-in hooks included in Claude Code Kit.

Hook Source Code

All hooks live in .claude/hooks/ and receive tool input as JSON via stdin. Exit 0 to allow, exit 2 to block (PreToolUse only).

PreToolUse Hooks

protect-files.sh

Blocks edits to sensitive files — .env, credentials, private keys, lock files.

#!/usr/bin/env bash
#
# protect-files.sh — PreToolUse hook
# Blocks edits to sensitive files (secrets, credentials, lock files)
#
# Reads tool input from stdin (JSON with tool_name and tool_input)
#

set -euo pipefail

INPUT=$(cat)

parse_json_field() {
  local field="$1"
  if command -v jq &>/dev/null; then
    echo "$INPUT" | jq -r "(.tool_input.${field} // .${field}) // empty" 2>/dev/null || true
  elif command -v python3 &>/dev/null; then
    echo "$INPUT" | python3 -c "import sys,json;d=json.load(sys.stdin);v=d.get('tool_input',d);print(v.get('${field}',d.get('${field}','')))" 2>/dev/null || true
  else
    echo "$INPUT" | grep -oE "\"${field}\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" | head -1 | sed 's/.*:[[:space:]]*"//;s/"$//' || true
  fi
}

TOOL_NAME=$(parse_json_field "tool_name")

# Only check file-writing tools
case "$TOOL_NAME" in
  Edit|Write|NotebookEdit) ;;
  *) exit 0 ;;
esac

# Extract file path from tool input
FILE_PATH=$(parse_json_field "file_path")
[ -z "$FILE_PATH" ] && exit 0

BASENAME=$(basename "$FILE_PATH")
DIRNAME=$(dirname "$FILE_PATH")

# Protected file patterns
BLOCKED=false
REASON=""

case "$BASENAME" in
  .env|.env.local|.env.production|.env.staging|.env.development)
    BLOCKED=true
    REASON="Environment file with secrets"
    ;;
  .env.example|.env.template|.env.sample|.env.test)
    ;; # Allow these — they don't contain real secrets
  .env.*)
    BLOCKED=true
    REASON="Environment file with secrets"
    ;;
  credentials.json|service-account.json|serviceAccountKey.json)
    BLOCKED=true
    REASON="Credential file"
    ;;
  id_rsa|id_ed25519|id_ecdsa|*.pem|*.key)
    BLOCKED=true
    REASON="Private key file"
    ;;
  *.p12|*.pfx)
    BLOCKED=true
    REASON="Certificate file"
    ;;
  *.jks)
    BLOCKED=true
    REASON="Java keystore file"
    ;;
  firebase-adminsdk*.json)
    BLOCKED=true
    REASON="Firebase Admin SDK credential file"
    ;;
  google-services.json)
    BLOCKED=true
    REASON="Android Google services config"
    ;;
  GoogleService-Info.plist)
    BLOCKED=true
    REASON="iOS Google services config"
    ;;
  package-lock.json|yarn.lock|pnpm-lock.yaml|Gemfile.lock|poetry.lock|Cargo.lock)
    BLOCKED=true
    REASON="Lock file (should be auto-generated)"
    ;;
esac

# Check directory patterns
case "$DIRNAME" in
  */.ssh|*/.ssh/*|*/.gnupg|*/.gnupg/*|*/.aws|*/.aws/*)
    BLOCKED=true
    REASON="Sensitive config directory"
    ;;
esac

if [ "$BLOCKED" = true ]; then
  echo "BLOCKED: $REASON$FILE_PATH"
  echo ""
  echo "If you need to modify this file, ask the user to do it manually"
  echo "or get explicit approval first."
  exit 2
fi

exit 0

branch-protect.sh

Blocks direct pushes to main/master and force pushes.

#!/usr/bin/env bash
#
# branch-protect.sh — PreToolUse hook
# Blocks direct pushes to main/master branch
#
# Reads tool input from stdin (JSON with tool_name and tool_input)
#

set -euo pipefail

INPUT=$(cat)

parse_json_field() {
  local field="$1"
  if command -v jq &>/dev/null; then
    echo "$INPUT" | jq -r "(.tool_input.${field} // .${field}) // empty" 2>/dev/null || true
  elif command -v python3 &>/dev/null; then
    echo "$INPUT" | python3 -c "import sys,json;d=json.load(sys.stdin);v=d.get('tool_input',d);print(v.get('${field}',d.get('${field}','')))" 2>/dev/null || true
  else
    echo "$INPUT" | grep -oE "\"${field}\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" | head -1 | sed 's/.*:[[:space:]]*"//;s/"$//' || true
  fi
}

TOOL_NAME=$(parse_json_field "tool_name")

# Only check Bash tool
[ "$TOOL_NAME" != "Bash" ] && exit 0

COMMAND=$(parse_json_field "command")
[ -z "$COMMAND" ] && exit 0

# Check for force push (check first — always block regardless of branch)
# Allow --force-with-lease (safer alternative) but block --force and -f
if echo "$COMMAND" | grep -qE 'git\s+push\s+.*--force-with-lease'; then
  : # Allow --force-with-lease (only overwrites if remote matches expectations)
elif echo "$COMMAND" | grep -qE 'git\s+push\s+.*--force|git\s+push\s+.*-f\b'; then
  echo "BLOCKED: Force push detected"
  echo ""
  echo "Force pushing can overwrite remote history."
  echo "Consider using --force-with-lease for a safer alternative,"
  echo "or get explicit approval from the user before force pushing."
  exit 2
fi

# Check for git push to protected branches (explicit branch name)
if echo "$COMMAND" | grep -qE 'git\s+push\s+(\S+\s+)?(main|master)\s*($|[;&|])|git\s+push\s+.*\s+HEAD:(main|master)\b'; then
  echo "BLOCKED: Direct push to main/master branch"
  echo ""
  echo "Create a feature branch and open a PR instead:"
  echo "  git checkout -b feat/your-feature"
  echo "  git push -u origin feat/your-feature"
  exit 2
fi

# Check for bare `git push` when on main/master (no branch specified)
if echo "$COMMAND" | grep -qE '(^|[;&|]\s*)git\s+push(\s+-u)?(\s+origin)?\s*($|[;&|])'; then
  CURRENT_BRANCH=$(git branch --show-current 2>/dev/null) || CURRENT_BRANCH=""
  if [ -z "$CURRENT_BRANCH" ]; then
    exit 0  # Cannot determine branch, allow the push
  fi
  if [ "$CURRENT_BRANCH" = "main" ] || [ "$CURRENT_BRANCH" = "master" ]; then
    echo "BLOCKED: You are on '$CURRENT_BRANCH' — bare 'git push' would push to protected branch"
    echo ""
    echo "Create a feature branch and open a PR instead:"
    echo "  git checkout -b feat/your-feature"
    echo "  git push -u origin feat/your-feature"
    exit 2
  fi
fi

exit 0

block-dangerous-commands.sh

Blocks destructive shell commands — rm -rf /, git reset --hard, DROP TABLE, etc.

#!/usr/bin/env bash
#
# block-dangerous-commands.sh — PreToolUse hook
# Blocks destructive shell commands that are hard to reverse
#
# Reads tool input from stdin (JSON with tool_name and tool_input)
#

set -euo pipefail

INPUT=$(cat)

parse_json_field() {
  local field="$1"
  if command -v jq &>/dev/null; then
    echo "$INPUT" | jq -r "(.tool_input.${field} // .${field}) // empty" 2>/dev/null || true
  elif command -v python3 &>/dev/null; then
    echo "$INPUT" | python3 -c "import sys,json;d=json.load(sys.stdin);v=d.get('tool_input',d);print(v.get('${field}',d.get('${field}','')))" 2>/dev/null || true
  else
    echo "$INPUT" | grep -oE "\"${field}\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" | head -1 | sed 's/.*:[[:space:]]*"//;s/"$//' || true
  fi
}

TOOL_NAME=$(parse_json_field "tool_name")

# Only check Bash tool
[ "$TOOL_NAME" != "Bash" ] && exit 0

COMMAND=$(parse_json_field "command")
[ -z "$COMMAND" ] && exit 0

BLOCKED=false
REASON=""

# Destructive file operations — catch rm -rf, rm -r -f, rm --recursive --force, etc.
RM_RECURSIVE='rm\s+(-[a-zA-Z]*r[a-zA-Z]*\s+(-[a-zA-Z]+\s+)*|-r\s+-f\s+|-f\s+-r\s+|--recursive\s+(-f\s+|--force\s+)?|-r\s+--force\s+)'

if echo "$COMMAND" | grep -qE "${RM_RECURSIVE}/[[:space:]]*($|[;&|])"; then
  BLOCKED=true
  REASON="Recursive delete on root directory"
fi

if echo "$COMMAND" | grep -qE "${RM_RECURSIVE}(~|\\\$HOME|\\\$\{HOME\})\b"; then
  BLOCKED=true
  REASON="Recursive delete on home directory"
fi

if echo "$COMMAND" | grep -qE "${RM_RECURSIVE}\\.\s*($|[;&|])"; then
  BLOCKED=true
  REASON="Recursive delete on current directory"
fi

if echo "$COMMAND" | grep -qE "${RM_RECURSIVE}\\*"; then
  BLOCKED=true
  REASON="Recursive delete with wildcard"
fi

# Git history destruction
if echo "$COMMAND" | grep -qE 'git\s+reset\s+--hard'; then
  BLOCKED=true
  REASON="git reset --hard discards all uncommitted changes"
fi

if echo "$COMMAND" | grep -qE 'git\s+clean\s+(-[a-zA-Z]*f[a-zA-Z]*d|-[a-zA-Z]*d[a-zA-Z]*f|-[a-zA-Z]*f\s+-[a-zA-Z]*d|-[a-zA-Z]*d\s+-[a-zA-Z]*f)'; then
  BLOCKED=true
  REASON="git clean -fd permanently deletes untracked files"
fi

# Database destruction
if echo "$COMMAND" | grep -qiE 'DROP\s+(TABLE|DATABASE|SCHEMA)\b'; then
  BLOCKED=true
  REASON="SQL DROP statement — destructive database operation"
fi

if echo "$COMMAND" | grep -qiE 'TRUNCATE\s+TABLE\b'; then
  BLOCKED=true
  REASON="SQL TRUNCATE — deletes all rows permanently"
fi

# Docker destruction
if echo "$COMMAND" | grep -qE 'docker\s+system\s+prune\s+-a'; then
  BLOCKED=true
  REASON="Docker system prune -a removes all unused images and containers"
fi

# chmod/chown on broad paths
if echo "$COMMAND" | grep -qE '(chmod|chown)\s+-R\s+.*\s+/'; then
  BLOCKED=true
  REASON="Recursive permission change on root path"
fi

if [ "$BLOCKED" = true ]; then
  echo "BLOCKED: $REASON"
  echo ""
  echo "Command: $COMMAND"
  echo ""
  echo "This command is potentially destructive and hard to reverse."
  echo "Get explicit approval from the user before running it."
  exit 2
fi

exit 0

conventional-commit.sh

Validates commit messages follow the conventional commit format.

#!/usr/bin/env bash
#
# conventional-commit.sh — PreToolUse hook
# Validates commit messages follow conventional commit format
#
# Format: <type>: <description>
# Types: feat, fix, refactor, test, docs, chore, perf, ci, build, style
#

set -euo pipefail

INPUT=$(cat)

parse_json_field() {
  local field="$1"
  if command -v jq &>/dev/null; then
    echo "$INPUT" | jq -r "(.tool_input.${field} // .${field}) // empty" 2>/dev/null || true
  elif command -v python3 &>/dev/null; then
    echo "$INPUT" | python3 -c "import sys,json;d=json.load(sys.stdin);v=d.get('tool_input',d);print(v.get('${field}',d.get('${field}','')))" 2>/dev/null || true
  else
    echo "$INPUT" | grep -oE "\"${field}\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" | head -1 | sed 's/.*:[[:space:]]*"//;s/"$//' || true
  fi
}

TOOL_NAME=$(parse_json_field "tool_name")

# Only check Bash tool
[ "$TOOL_NAME" != "Bash" ] && exit 0

COMMAND=$(parse_json_field "command")
[ -z "$COMMAND" ] && exit 0

# Only check git commit commands
if ! echo "$COMMAND" | grep -qE 'git\s+commit'; then
  exit 0
fi

# Extract commit message from -m/--message flag
MSG=$(echo "$COMMAND" | grep -oE '(-m|--message)[[:space:]]*"[^"]*"' | sed -E 's/(-m|--message)[[:space:]]*"//;s/"$//' || echo "")

# Also try single quotes
if [ -z "$MSG" ]; then
  MSG=$(echo "$COMMAND" | grep -oE "(-m|--message)[[:space:]]*'[^']*'" | sed -E "s/(-m|--message)[[:space:]]*'//;s/'$//" || echo "")
fi

# If using heredoc or no -m flag, skip validation
if [ -z "$MSG" ]; then
  exit 0
fi

# Get first line of commit message
FIRST_LINE=$(echo "$MSG" | head -1)

# Validate conventional commit format
VALID_TYPES="feat|fix|refactor|test|docs|chore|perf|ci|build|style"

if ! echo "$FIRST_LINE" | grep -qE "^($VALID_TYPES)(\(.+\))?: .+"; then
  echo "BLOCKED: Commit message doesn't follow conventional commit format"
  echo ""
  echo "  Got:      $FIRST_LINE"
  echo "  Expected: <type>: <description>"
  echo ""
  echo "  Valid types: feat, fix, refactor, test, docs, chore, perf, ci, build, style"
  echo ""
  echo "  Examples:"
  echo "    feat: add user search endpoint"
  echo "    fix: handle null response from auth API"
  echo "    refactor(auth): simplify token validation"
  exit 2
fi

exit 0

PostToolUse Hooks

secret-scan.sh

Scans edited files for accidentally committed secrets — AWS keys, API tokens, private keys, JWTs, GitHub tokens.

Runs after every Edit or Write operation. Outputs warnings but does not block (exit 0). Change to exit 2 to make it blocking.

#!/usr/bin/env bash
#
# secret-scan.sh — PostToolUse hook
# Scans edited files for accidentally committed secrets
#
# Reads tool input from stdin (JSON with tool_name and tool_input)
#

set -euo pipefail

INPUT=$(cat)

parse_json_field() {
  local field="$1"
  if command -v jq &>/dev/null; then
    echo "$INPUT" | jq -r "(.tool_input.${field} // .${field}) // empty" 2>/dev/null || true
  elif command -v python3 &>/dev/null; then
    echo "$INPUT" | python3 -c "import sys,json;d=json.load(sys.stdin);v=d.get('tool_input',d);print(v.get('${field}',d.get('${field}','')))" 2>/dev/null || true
  else
    echo "$INPUT" | grep -oE "\"${field}\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" | head -1 | sed 's/.*:[[:space:]]*"//;s/"$//' || true
  fi
}

TOOL_NAME=$(parse_json_field "tool_name")

# Only run after file edits
case "$TOOL_NAME" in
  Edit|Write|NotebookEdit) ;;
  *) exit 0 ;;
esac

FILE_PATH=$(parse_json_field "file_path")
[ -z "$FILE_PATH" ] && exit 0
[ ! -f "$FILE_PATH" ] && exit 0

# Skip binary files (check for "text" in file output — works on both macOS and Linux)
if ! file "$FILE_PATH" | grep -qi "text"; then
  exit 0
fi

# Skip lock files and common non-source files
BASENAME=$(basename "$FILE_PATH")
case "$BASENAME" in
  package-lock.json|yarn.lock|pnpm-lock.yaml|*.lock) exit 0 ;;
  *.min.js|*.min.css|*.bundle.js|*.map) exit 0 ;;
  *.g.dart|*.designer.cs|*.generated.swift|*.generated.ts) exit 0 ;;
esac

FINDINGS=""

# AWS keys
if grep -nE 'AKIA[0-9A-Z]{16}' "$FILE_PATH" >/dev/null 2>&1; then
  FINDINGS="${FINDINGS}\n  - AWS Access Key ID detected"
fi

# Generic API key patterns (key = "...", api_key: "...", etc.)
SQ="'"
if grep -nE "(api[_-]?key|api[_-]?secret|auth[_-]?token|access[_-]?token|secret[_-]?key)[[:space:]]*[=:][[:space:]]*[\"${SQ}][A-Za-z0-9+/=_-]{20,}[\"${SQ}]" "$FILE_PATH" >/dev/null 2>&1; then
  FINDINGS="${FINDINGS}\n  - API key or token assignment detected"
fi

# Private keys
if grep -nE 'BEGIN (RSA |EC |DSA |OPENSSH )?PRIVATE KEY' "$FILE_PATH" >/dev/null 2>&1; then
  FINDINGS="${FINDINGS}\n  - Private key detected"
fi

# Common password patterns
if grep -nE "(password|passwd|pwd)[[:space:]]*[=:][[:space:]]*[\"${SQ}][^\"${SQ}]{8,}[\"${SQ}]" "$FILE_PATH" >/dev/null 2>&1; then
  FINDINGS="${FINDINGS}\n  - Hardcoded password detected"
fi

# JWT tokens
if grep -nE 'eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}' "$FILE_PATH" >/dev/null 2>&1; then
  FINDINGS="${FINDINGS}\n  - JWT token detected"
fi

# GitHub tokens
if grep -nE '(ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9_]{36,}' "$FILE_PATH" >/dev/null 2>&1; then
  FINDINGS="${FINDINGS}\n  - GitHub token detected"
fi

# Slack tokens
if grep -nE 'xox[bpars]-[A-Za-z0-9-]{10,}' "$FILE_PATH" >/dev/null 2>&1; then
  FINDINGS="${FINDINGS}\n  - Slack token detected"
fi

# Google API keys
if grep -nE 'AIza[0-9A-Za-z_-]{35}' "$FILE_PATH" >/dev/null 2>&1; then
  FINDINGS="${FINDINGS}\n  - Google API key detected"
fi

# Stripe keys
if grep -nE '(sk|pk)_live_[A-Za-z0-9]{20,}' "$FILE_PATH" >/dev/null 2>&1; then
  FINDINGS="${FINDINGS}\n  - Stripe live key detected"
fi

# SendGrid keys
if grep -nE 'SG\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}' "$FILE_PATH" >/dev/null 2>&1; then
  FINDINGS="${FINDINGS}\n  - SendGrid API key detected"
fi

# Twilio Account SID and Auth Token
if grep -nE 'AC[a-f0-9]{32}' "$FILE_PATH" >/dev/null 2>&1; then
  FINDINGS="${FINDINGS}\n  - Twilio Account SID detected"
fi

# npm tokens
if grep -nE 'npm_[A-Za-z0-9]{36,}' "$FILE_PATH" >/dev/null 2>&1; then
  FINDINGS="${FINDINGS}\n  - npm token detected"
fi

# PyPI tokens
if grep -nE 'pypi-[A-Za-z0-9_-]{50,}' "$FILE_PATH" >/dev/null 2>&1; then
  FINDINGS="${FINDINGS}\n  - PyPI token detected"
fi

# Database connection strings with passwords
if grep -nE '(mongodb|postgres|postgresql|mysql|redis)://[^:]+:[^@]+@' "$FILE_PATH" >/dev/null 2>&1; then
  FINDINGS="${FINDINGS}\n  - Database connection string with credentials detected"
fi

if [ -n "$FINDINGS" ]; then
  echo "WARNING: Potential secrets found in $FILE_PATH"
  printf '%b\n' "$FINDINGS"
  echo ""
  echo "If these are intentional (e.g., test fixtures, examples),"
  echo "you can ignore this warning. Otherwise, remove the secrets"
  echo "and use environment variables instead."
  # Exit 0 (warning only, don't block)
  # Change to exit 2 if you want to block edits with secrets
  exit 0
fi

exit 0

unicode-scan.sh

Detects invisible Unicode characters that may indicate Glassworm-style supply chain attacks or code obfuscation. Scans for:

  • Variation Selectors (U+FE00-FE0F) — primary Glassworm payload encoding vector
  • Variation Selectors Supplement (U+E0100-E01EF) — secondary Glassworm vector
  • Zero Width characters (U+200B-200D, U+2060, U+FEFF mid-file) — code obfuscation
  • Tags block (U+E0000-E007F) — hidden text encoding

Uses perl -CSD for reliable UTF-8 detection with python3 fallback. Supports allowlisting via // kit-allow-unicode comment in the first 5 lines of a file.

Runs after every Edit or Write operation. Outputs warnings but does not block (exit 0). Change to exit 2 to make it blocking.

#!/usr/bin/env bash
#
# unicode-scan.sh — PostToolUse hook
# Detects invisible Unicode characters that may indicate Glassworm-style
# supply chain attacks or code obfuscation.
#
# Scans edited files for:
#   - Variation Selectors (U+FE00–FE0F) — Glassworm payload encoding
#   - Variation Selectors Supplement (U+E0100–E01EF) — Glassworm payload encoding
#   - Zero Width characters (U+200B–200D, U+2060, U+FEFF mid-file)
#   - Tags block (U+E0000–E007F) — hidden text encoding
#
# Exit 0 = warn only (default)
# Change final exit to "exit 2" to block edits containing invisible Unicode
#

set -euo pipefail

INPUT=$(cat)

parse_json_field() {
  local field="$1"
  if command -v jq &>/dev/null; then
    echo "$INPUT" | jq -r "(.tool_input.${field} // .${field}) // empty" 2>/dev/null || true
  elif command -v python3 &>/dev/null; then
    echo "$INPUT" | python3 -c "import sys,json;d=json.load(sys.stdin);v=d.get('tool_input',d);print(v.get('${field}',d.get('${field}','')))" 2>/dev/null || true
  else
    echo "$INPUT" | grep -oE "\"${field}\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" | head -1 | sed 's/.*:[[:space:]]*"//;s/"$//' || true
  fi
}

TOOL_NAME=$(parse_json_field "tool_name")

# Only run after file edits
case "$TOOL_NAME" in
  Edit|Write|NotebookEdit) ;;
  *) exit 0 ;;
esac

FILE_PATH=$(parse_json_field "file_path")
[ -z "$FILE_PATH" ] && exit 0
[ ! -f "$FILE_PATH" ] && exit 0

# Skip binary files
if ! file "$FILE_PATH" | grep -qi "text"; then
  exit 0
fi

# Skip files that legitimately contain Unicode (fonts, locale data, etc.)
BASENAME=$(basename "$FILE_PATH")
case "$BASENAME" in
  package-lock.json|yarn.lock|pnpm-lock.yaml|*.lock) exit 0 ;;
  *.min.js|*.min.css|*.bundle.js|*.map) exit 0 ;;
  *.woff|*.woff2|*.ttf|*.otf|*.eot) exit 0 ;;
  *.po|*.mo) exit 0 ;;
esac

# Check for allowlist comment in file
if head -5 "$FILE_PATH" | grep -q "kit-allow-unicode" 2>/dev/null; then
  exit 0
fi

FINDINGS=""

# Use perl for reliable Unicode detection (available on macOS and most Linux)
if command -v perl &>/dev/null; then

  # Zero Width Space (U+200B)
  if ! perl -CSD -ne 'exit 1 if /\x{200B}/' "$FILE_PATH" 2>/dev/null; then
    FINDINGS="${FINDINGS}\n  - Zero Width Space (U+200B) detected"
  fi

  # Zero Width Non-Joiner (U+200C)
  if ! perl -CSD -ne 'exit 1 if /\x{200C}/' "$FILE_PATH" 2>/dev/null; then
    FINDINGS="${FINDINGS}\n  - Zero Width Non-Joiner (U+200C) detected"
  fi

  # Zero Width Joiner (U+200D)
  if ! perl -CSD -ne 'exit 1 if /\x{200D}/' "$FILE_PATH" 2>/dev/null; then
    FINDINGS="${FINDINGS}\n  - Zero Width Joiner (U+200D) detected"
  fi

  # Word Joiner (U+2060)
  if ! perl -CSD -ne 'exit 1 if /\x{2060}/' "$FILE_PATH" 2>/dev/null; then
    FINDINGS="${FINDINGS}\n  - Word Joiner (U+2060) detected"
  fi

  # BOM mid-file (U+FEFF) — skip first line (legitimate BOM position)
  if ! tail -n +2 "$FILE_PATH" | perl -CSD -ne 'exit 1 if /\x{FEFF}/' 2>/dev/null; then
    FINDINGS="${FINDINGS}\n  - Zero Width No-Break Space / BOM mid-file (U+FEFF) detected"
  fi

  # Variation Selectors (U+FE00–FE0F) — primary Glassworm vector
  if ! perl -CSD -ne 'exit 1 if /[\x{FE00}-\x{FE0F}]/' "$FILE_PATH" 2>/dev/null; then
    FINDINGS="${FINDINGS}\n  - Variation Selectors (U+FE00-FE0F) detected — GLASSWORM ATTACK VECTOR"
  fi

  # Variation Selectors Supplement (U+E0100–E01EF) — primary Glassworm vector
  if ! perl -CSD -ne 'exit 1 if /[\x{E0100}-\x{E01EF}]/' "$FILE_PATH" 2>/dev/null; then
    FINDINGS="${FINDINGS}\n  - Variation Selectors Supplement (U+E0100-E01EF) detected — GLASSWORM ATTACK VECTOR"
  fi

  # Tags block (U+E0000–E007F) — hidden text encoding
  if ! perl -CSD -ne 'exit 1 if /[\x{E0000}-\x{E007F}]/' "$FILE_PATH" 2>/dev/null; then
    FINDINGS="${FINDINGS}\n  - Tags block (U+E0000-E007F) detected — hidden text encoding"
  fi

elif command -v python3 &>/dev/null; then
  # Fallback: use python3 for detection
  PYTHON_RESULT=$(python3 -c "
import sys, re

findings = []
try:
    with open(sys.argv[1], 'r', encoding='utf-8', errors='ignore') as f:
        content = f.read()
        # Skip first-line BOM
        lines = content.split('\n')
        content_no_first = '\n'.join(lines[1:]) if len(lines) > 1 else ''

        checks = [
            (r'\u200B', 'Zero Width Space (U+200B)'),
            (r'\u200C', 'Zero Width Non-Joiner (U+200C)'),
            (r'\u200D', 'Zero Width Joiner (U+200D)'),
            (r'\u2060', 'Word Joiner (U+2060)'),
            (r'[\uFE00-\uFE0F]', 'Variation Selectors (U+FE00-FE0F) — GLASSWORM ATTACK VECTOR'),
            (r'[\U000E0000-\U000E007F]', 'Tags block (U+E0000-E007F) — hidden text encoding'),
            (r'[\U000E0100-\U000E01EF]', 'Variation Selectors Supplement (U+E0100-E01EF) — GLASSWORM ATTACK VECTOR'),
        ]
        for pattern, desc in checks:
            if re.search(pattern, content):
                findings.append(desc)

        # BOM mid-file only
        if re.search(r'\uFEFF', content_no_first):
            findings.append('Zero Width No-Break Space / BOM mid-file (U+FEFF)')

except Exception:
    pass

for f in findings:
    print(f)
" "$FILE_PATH" 2>/dev/null)

  if [ -n "$PYTHON_RESULT" ]; then
    while IFS= read -r line; do
      FINDINGS="${FINDINGS}\n  - ${line}"
    done <<< "$PYTHON_RESULT"
  fi
else
  # No perl or python3 — skip check silently
  exit 0
fi

if [ -n "$FINDINGS" ]; then
  echo "SECURITY WARNING: Invisible Unicode characters detected in $FILE_PATH"
  printf '%b\n' "$FINDINGS"
  echo ""
  echo "This may indicate a Glassworm-style supply chain attack."
  echo "Invisible characters can hide malicious payloads that execute via eval()."
  echo ""
  echo "Actions:"
  echo "  1. Inspect the file with: xxd $FILE_PATH | less"
  echo "  2. If intentional, add '// kit-allow-unicode' to the first 5 lines"
  echo ""
  # Exit 0 = warn only. Change to exit 2 to block edits with invisible Unicode.
  exit 0
fi

exit 0

skill-compliance.sh (optional)

After file edits, detects which skills are relevant to the edited file and surfaces matching checklist items as a reminder. Matches by file extension and skill content keywords.

auto-lint.sh (optional)

Runs the appropriate linter after file edits — eslint, ruff, gofmt, clippy, rubocop. Detects the language by file extension and finds the project root by looking for package.json, pyproject.toml, go.mod, or Cargo.toml.

auto-format.sh (optional)

Runs the appropriate formatter after file edits — prettier, black, gofmt, rustfmt, rubocop. Same project root detection as auto-lint.

Other Hooks

task-complete-notify.sh (Stop)

Sends a desktop notification when Claude finishes a task. Works on macOS (osascript) and Linux (notify-send). Plays a sound effect.

skill-extract-reminder.sh (UserPromptSubmit, optional)

Injects a reminder into Claude's context to consider extracting non-obvious knowledge as a skill. Runs on every user prompt submission.