Hook Script Library

SkillSecurity

Security-hardened hook script implementations, ready-to-paste templates for security-guard, auto-format, inject-context, session-init, on-stop, and lessons-learned-capture

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the Hook Script Library skill

What this skill tells your AI

The instructions your AI receives, as published by thelobbi/claude in plugins/claude-code-expert/skills-old/hook-script-library/SKILL.md and read by ahel’s review.

Six production-ready hook scripts with security hardening. Copy to .claude/hooks/ and register in .claude/settings.json.

Security Principles Applied

All scripts follow these rules:

  • set -euo pipefail at the top
  • jq for JSON (never string concatenation)
  • realpath for path validation (prevents traversal)
  • flock for atomic file writes (prevents interleaved concurrent writes)
  • Reject filenames starting with - (flag injection prevention)
  • Hardcoded blocklists only (never source from external files)
  • printf '%s' for untrusted data, not echo

1. security-guard.sh

Registered on: PreToolUse with matcher Bash

Blocks hardcoded dangerous commands before Claude executes them. This is defense-in-depth only — use settings.json deny list as the primary control.

#!/usr/bin/env bash
set -euo pipefail

INPUT=$(head -c 65536)
if ! printf '%s' "$INPUT" | jq -e . >/dev/null 2>&1; then
  echo '{"decision": "approve"}'
  exit 0
fi

TOOL_INPUT=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // ""')

# Hardcoded blocklist — do NOT source from external files
BLOCKED_PATTERNS=(
  "rm -rf /"
  "sudo rm"
  "mkfs"
  "dd if="
  "> /dev/sd"
  "chmod -R 777"
  "curl.*| sh"
  "curl.*| bash"
  "wget.*| sh"
  "wget.*| bash"
)

for pattern in "${BLOCKED_PATTERNS[@]}"; do
  if printf '%s' "$TOOL_INPUT" | grep -qF "$pattern"; then
    jq -n --arg p "$pattern" '{"decision":"block","reason":("Blocked dangerous command: "+$p)}'
    exit 0
  fi
done

echo '{"decision": "approve"}'

2. auto-format.sh

Registered on: PostToolUse with matcher Write|Edit

Runs the appropriate formatter immediately after Claude writes a file. Includes path traversal protection.

#!/usr/bin/env bash
set -euo pipefail

INPUT=$(head -c 65536)
FILE=$(printf '%s' "$INPUT" | jq -r '.tool_input.file_path // .tool_input.path // ""')

# Validate: file must exist, be a regular file, be inside project
if [ -z "$FILE" ] || [ ! -f "$FILE" ]; then
  echo '{"decision": "approve"}'
  exit 0
fi

REAL=$(realpath "$FILE" 2>/dev/null) || { echo '{"decision": "approve"}'; exit 0; }
WORKDIR=$(realpath "$PWD")

# Reject paths outside project root (path traversal guard)
if [[ "$REAL" != "$WORKDIR"/* ]]; then
  echo '{"decision": "approve"}'
  exit 0
fi

# Reject filenames starting with dash (flag injection guard)
BASENAME=$(basename "$REAL")
if [[ "$BASENAME" == -* ]]; then
  echo '{"decision": "approve"}'
  exit 0
fi

# Format based on extension
case "$REAL" in
  *.ts|*.tsx|*.js|*.jsx|*.json|*.css|*.scss|*.md)
    npx prettier --write "$REAL" 2>/dev/null || true ;;
  *.py)
    black "$REAL" 2>/dev/null || ruff format "$REAL" 2>/dev/null || true ;;
  *.rs)
    rustfmt "$REAL" 2>/dev/null || true ;;
  *.go)
    gofmt -w "$REAL" 2>/dev/null || true ;;
  *.sh)
    shfmt -w "$REAL" 2>/dev/null || true ;;
esac

echo '{"decision": "approve"}'

3. inject-context.sh

Registered on: UserPromptSubmit

Injects dynamic context (date, branch, uncommitted file count) on every prompt. Claude receives this as additional context before processing.

#!/usr/bin/env bash
set -euo pipefail

# Inject dynamic context — stdout is added to Claude's context
DATE=$(date '+%Y-%m-%d %H:%M')
BRANCH=$(git branch --show-current 2>/dev/null || echo "no-git")
UNCOMMITTED=$(git status --porcelain 2>/dev/null | wc -l | tr -d ' ')
LAST_COMMIT=$(git log --oneline -1 2>/dev/null || echo "no commits")

echo "[Session Context] Date: $DATE | Branch: $BRANCH | Uncommitted: $UNCOMMITTED files | Last commit: $LAST_COMMIT"
echo '{"decision": "approve"}'

How it works: On UserPromptSubmit, stdout is prepended to the user's message as context. Use this for date injection, active branch, workspace state — anything Claude should know before processing each turn.


4. session-init.sh

Registered on: SessionStart

Fires when a session begins or resumes. Outputs status information to stderr (shown as system messages) and checks for stale memory files.

#!/usr/bin/env bash
set -euo pipefail

echo "Session started: $(date '+%Y-%m-%d %H:%M')" >&2
echo "Branch: $(git branch --show-current 2>/dev/null || echo 'no-git')" >&2
echo "Last commit: $(git log --oneline -1 2>/dev/null || echo 'no commits')" >&2

# Warn about stale memory rotation
LESSONS=".claude/rules/lessons-learned.md"
if [ -f "$LESSONS" ]; then
  LINES=$(wc -l < "$LESSONS")
  if [ "$LINES" -gt 200 ]; then
    echo "WARNING: lessons-learned.md has $LINES lines — run /cc-memory --rotate to prune resolved entries" >&2
  fi
fi

echo '{"decision": "approve"}'

5. on-stop.sh

Registered on: Stop

Fires when Claude finishes a response turn. Use for reminders, notifications, or light cleanup.

#!/usr/bin/env bash
set -euo pipefail

# Remind about uncommitted work at end of each turn
UNCOMMITTED=$(git status --porcelain 2>/dev/null | wc -l | tr -d ' ')
if [ "$UNCOMMITTED" -gt 0 ]; then
  echo "Reminder: $UNCOMMITTED uncommitted files" >&2
fi

echo '{"decision": "approve"}'

6. lessons-learned-capture.sh

Registered on: PostToolUseFailure

The most important hook. Auto-captures every tool failure to .claude/rules/lessons-learned.md for the self-healing loop. Uses flock for atomic writes and sanitizes inputs to prevent injection.

#!/usr/bin/env bash
set -euo pipefail

INPUT=$(head -c 65536)
if ! printf '%s' "$INPUT" | jq -e . >/dev/null 2>&1; then
  echo '{"decision": "approve"}'
  exit 0
fi

TOOL=$(printf '%s' "$INPUT" | jq -r '.tool_name // ""')
ERROR=$(printf '%s' "$INPUT" | jq -r '.error // ""')

if [ -z "$ERROR" ] || [ "$ERROR" = "null" ]; then
  echo '{"decision": "approve"}'
  exit 0
fi

# Sanitize: strip shell metacharacters to prevent injection
SAFE_TOOL=$(printf '%s' "$TOOL" | head -c 50 | tr -d '`$()\\!"'"'"'')
SAFE_ERROR=$(printf '%s' "$ERROR" | head -c 200 | tr -d '`$()\\!"'"'"'')
TIMESTAMP=$(date -u '+%Y-%m-%dT%H:%M:%SZ')
LESSONS=".claude/rules/lessons-learned.md"

# flock ensures atomic append — prevents interleaved writes if hooks run concurrently
(
  flock -x 200
  printf '\n### Error: %s failure (%s)\n- **Tool:** %s\n- **Error:** %s\n- **Status:** NEEDS_FIX - Claude should document the fix here after resolving\n' \
    "$SAFE_TOOL" "$TIMESTAMP" "$SAFE_TOOL" "$SAFE_ERROR" \
    >> "$LESSONS"
) 200>/tmp/lessons-learned.lock

echo '{"decision": "approve"}'

settings.json — Register All Six

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [{ "type": "command", "command": "bash .claude/hooks/security-guard.sh" }]
      }
    ],
    "PostToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [{ "type": "command", "command": "bash .claude/hooks/auto-format.sh" }]
      }
    ],
    "PostToolUseFailure": [
      {
        "matcher": "*",
        "hooks": [{ "type": "command", "command": "bash .claude/hooks/lessons-learned-capture.sh" }]
      }
    ],
    "Stop": [
      {
        "matcher": "",
        "hooks": [{ "type": "command", "command": "bash .claude/hooks/on-stop.sh" }]
      }
    ],
    "UserPromptSubmit": [
      {
        "matcher": "",
        "hooks": [{ "type": "command", "command": "bash .claude/hooks/inject-context.sh" }]
      }
    ],
    "SessionStart": [
      {
        "matcher": "",
        "hooks": [{ "type": "command", "command": "bash .claude/hooks/session-init.sh" }]
      }
    ]
  }
}

Quick Deploy

# Create hooks directory
mkdir -p .claude/hooks

# Make all hooks executable after writing
chmod +x .claude/hooks/*.sh

# Verify hook syntax before registering
bash -n .claude/hooks/security-guard.sh && echo "OK"
bash -n .claude/hooks/lessons-learned-capture.sh && echo "OK"

Stack-Specific Additional Hooks

Detected StackHookEventMatcherAction
TypeScriptauto-typecheck.shPostToolUseWrite|Edittsc --noEmit
ESLintauto-lint.shPostToolUseWrite|Editeslint --fix
Dockerno-latest-tag.shPreToolUseBashBlock :latest tags
Gitno-env-commit.shPreToolUseBashBlock .env commits
Python (Black)auto-format-py.shPostToolUseWrite|Editblack
Rustauto-clippy.shPostToolUseWrite|Editcargo clippy

See skills/lsp-integration/SKILL.md for TypeScript, Python, and Rust diagnostics hook implementations.


New Hook Features (v2.1.83–v2.1.101)

Conditional Hooks (if field)

Scope a hook to specific tool calls using permission rule syntax. Reduces process overhead on busy sessions — your pre-commit check only spawns for git commits, not every Bash call.

{
  "hooks": {
    "PreToolUse": [{
      "hooks": [{
        "if": "Bash(git commit *)",
        "type": "command",
        "command": ".claude/hooks/lint-staged.sh"
      }]
    }]
  }
}

More if examples:

"if": "Bash(git push *)"          # only git push
"if": "Bash(rm *)"               # only rm commands
"if": "Write(src/**)"            # only writes inside src/
"if": "Edit(*.ts)"               # only TypeScript edits
"if": "Bash(* --force *)"        # any command with --force

CwdChanged and FileChanged Events

New hook events for reactive setups (direnv-style auto-loading, per-directory tool activation):

{
  "hooks": {
    "CwdChanged": [{
      "hooks": [{ "type": "command", "command": ".claude/hooks/direnv-reload.sh" }]
    }],
    "FileChanged": [{
      "hooks": [{ "type": "command", "command": ".claude/hooks/file-watcher.sh" }]
    }]
  }
}

CwdChanged fires when the working directory changes. Use it to reload environment variables, switch tool configs, or update the CLAUDE.md context.

FileChanged fires when a file on disk changes externally. Use it for live reload triggers or invalidating caches.

PermissionDenied Event

Fires when the auto mode classifier blocks an action. Return retry: true to let Claude try an alternative approach, or log the denial for audit purposes.

{
  "hooks": {
    "PermissionDenied": [{
      "hooks": [{ "type": "command", "command": ".claude/hooks/permission-denied.sh" }]
    }]
  }
}
#!/usr/bin/env bash
set -euo pipefail
INPUT=$(cat)
TOOL=$(echo "$INPUT" | jq -r '.tool_name // "unknown"')
printf '%s\t%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$TOOL" >> .claude/logs/denied.log
echo '{"retry": true}'

UserPromptSubmit: Setting Session Title

UserPromptSubmit hooks can now set the session title by returning hookSpecificOutput.sessionTitle. Useful for labeling sessions with the task context so they're identifiable in history.

#!/usr/bin/env bash
set -euo pipefail
INPUT=$(cat)
PROMPT=$(echo "$INPUT" | jq -r '.prompt // ""')
# Extract first ~50 chars for session title
TITLE=$(echo "$PROMPT" | head -c 50 | tr '\n' ' ' | sed 's/[[:space:]]*$//')
jq -n --arg t "$TITLE" '{"hookSpecificOutput": {"sessionTitle": $t}}'

Hook Output Size Limit

Hook output over 50K characters is automatically saved to disk with a path reference + preview, instead of being injected into context. Design hooks to return minimal, structured output — not full logs.

Signals

GitHub stars
21
Forks
2
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
hook-script-library
Source
github.com/thelobbi/claude