Hook Script Library
SkillSecuritySecurity-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.
No other account needed.
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 pipefailat the topjqfor JSON (never string concatenation)realpathfor path validation (prevents traversal)flockfor 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, notecho
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 Stack | Hook | Event | Matcher | Action |
|---|---|---|---|---|
| TypeScript | auto-typecheck.sh | PostToolUse | Write|Edit | tsc --noEmit |
| ESLint | auto-lint.sh | PostToolUse | Write|Edit | eslint --fix |
| Docker | no-latest-tag.sh | PreToolUse | Bash | Block :latest tags |
| Git | no-env-commit.sh | PreToolUse | Bash | Block .env commits |
| Python (Black) | auto-format-py.sh | PostToolUse | Write|Edit | black |
| Rust | auto-clippy.sh | PostToolUse | Write|Edit | cargo 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