Skip to content
Back to skills

Hook Development

ASecurity

Use when creating, modifying, or debugging Claude Code hooks — PreToolUse, PostToolUse, Stop, SubagentStop, SessionStart, SessionEnd, UserPromptSubmit, PreCompact, Notification. Covers the plugin `hooks/hooks.json` wrapper format vs. the user `settings.json` direct format, matchers, security patterns, `$CLAUDE_PLUGIN_ROOT` portability, lifecycle limitations, and debugging. Trigger on "add a hook", "validate tool use", "block dangerous commands", "enforce completion", "hook-based automation".

  • 4 stars
  • 0 votes
  • 0 copies
  • 2 views
  • Added September 12, 2026
ai-agentsrustgoshellbashreactnodenodejsdebugginggitsecurity

Works with

  • claude code
  • mcp

Security analysis

A100/100

Pro scans all 2 files and shows the line behind each finding

Scanned September 12, 2026

npx -y skills add aibot88/sec_skill_store --skill hook-development --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Hook Development?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Hook Development
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/aibot88-hook-development/badge)](https://www.skillsdirectory.com/skills/aibot88-hook-development)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: hook-development
description: Use when creating, modifying, or debugging Claude Code hooks — PreToolUse, PostToolUse, Stop, SubagentStop, SessionStart, SessionEnd, UserPromptSubmit, PreCompact, Notification. Covers the plugin `hooks/hooks.json` wrapper format vs. the user `settings.json` direct format, matchers, security patterns, `$CLAUDE_PLUGIN_ROOT` portability, lifecycle limitations, and debugging. Trigger on "add a hook", "validate tool use", "block dangerous commands", "enforce completion", "hook-based automation".
---

# Hook Development for Claude Code Plugins

Adapted from [claude-plugins-official/plugin-dev/skills/hook-development](https://github.com/anthropics/claude-plugins-official/tree/main/plugins/plugin-dev/skills/hook-development). Trimmed to what we actually author (our plugin already has 6 event matchers covering 7 hook handlers — see `hooks/hooks.json`).

## Hook types

### Prompt-based (LLM-driven, for complex reasoning)

```json
{
  "type": "prompt",
  "prompt": "Evaluate if this tool use is appropriate: $TOOL_INPUT",
  "timeout": 30
}
```

Supported events: `Stop`, `SubagentStop`, `UserPromptSubmit`, `PreToolUse`.

Use for: context-aware decisions, flexible evaluation, natural-language reasoning.

### Command (deterministic, for fast checks)

```json
{
  "type": "command",
  "command": "${CLAUDE_PLUGIN_ROOT}/hooks/validate.mjs",
  "timeout": 60
}
```

Use for: fast deterministic validations, file-system ops, external tools, performance-critical paths.

**Our convention:** all our command hooks are `.mjs` (Node.js) — see `hooks/pre-bash-destructive-guard.mjs`, `hooks/enforce-scope.mjs`. The v3.0 migration moved us off bash for native Windows support.

## Configuration formats

This is where people trip up. Two formats exist; they are NOT interchangeable.

### Plugin `hooks/hooks.json` — wrapper format

```json
{
  "description": "Plugin hook description (optional)",
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          { "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/validate.mjs" }
        ]
      }
    ]
  }
}
```

- `hooks` wrapper is required
- `description` is optional

### User `.claude/settings.json` — direct format

```json
{
  "PreToolUse": [
    {
      "matcher": "Write|Edit",
      "hooks": [
        { "type": "command", "command": "~/my-hook.sh" }
      ]
    }
  ]
}
```

- No wrapper
- No description

Mixing these up is the #1 reason new hooks don't fire.

## Hook events

| Event | When | Use for |
|-------|------|---------|
| `PreToolUse` | Before tool runs | Validate, modify, block |
| `PostToolUse` | After tool completes | React to result, log |
| `UserPromptSubmit` | User submits prompt | Add context, validate |
| `Stop` | Main agent stopping | Completeness check |
| `SubagentStop` | Subagent stopping | Task validation |
| `SessionStart` | Session begins | Context load |
| `SessionEnd` | Session ends | Cleanup, logging |
| `PreCompact` | Before compaction | Preserve critical state |
| `Notification` | User notified | Logging, reactions |

### PreToolUse output schema

```json
{
  "hookSpecificOutput": {
    "permissionDecision": "allow|deny|ask",
    "updatedInput": { "field": "modified_value" }
  },
  "systemMessage": "Explanation shown to Claude"
}
```

### Stop / SubagentStop output

```json
{
  "decision": "approve|block",
  "reason": "Why blocked / approved",
  "systemMessage": "Additional context"
}
```

### SessionStart: persist env vars

```bash
echo "export PROJECT_TYPE=nodejs" >> "$CLAUDE_ENV_FILE"
```

`$CLAUDE_ENV_FILE` is unique to SessionStart hooks.

## Input schema

All hooks receive JSON on stdin:

```json
{
  "session_id": "abc123",
  "transcript_path": "/path/to/transcript.jsonl",
  "cwd": "/current/working/dir",
  "permission_mode": "ask|allow",
  "hook_event_name": "PreToolUse"
}
```

Event-specific extras:
- `PreToolUse`/`PostToolUse`: `tool_name`, `tool_input`, `tool_result`
- `UserPromptSubmit`: `user_prompt`
- `Stop`/`SubagentStop`: `reason`

Access in prompt hooks via `$TOOL_INPUT`, `$TOOL_RESULT`, `$USER_PROMPT`.

## Environment variables

| Var | Scope | Purpose |
|-----|-------|---------|
| `$CLAUDE_PROJECT_DIR` | All | Project root |
| `$CLAUDE_PLUGIN_ROOT` | Plugin hooks | Plugin directory — **use this, never hardcode paths** |
| `$CLAUDE_ENV_FILE` | SessionStart only | Persist env vars |
| `$CLAUDE_CODE_REMOTE` | All (conditional) | Set if running remote |

### Portability rule

```json
// ✅ Portable — works everywhere the plugin installs
{ "command": "${CLAUDE_PLUGIN_ROOT}/hooks/guard.mjs" }

// ❌ Broken — only works on Daisy's machine
{ "command": "/Users/bernhardgoetzendorfer/Projects/.../guard.mjs" }
```

## Matchers

```json
"matcher": "Write"                       // Exact tool
"matcher": "Read|Write|Edit"             // Multiple
"matcher": "*"                           // All tools
"matcher": "mcp__.*__delete.*"           // Regex — all MCP delete tools
"matcher": "mcp__gitlab_.*"              // Specific MCP server
```

Matchers are **case-sensitive**.

## Security best practices

### Validate inputs (command hooks)

```bash
#!/bin/bash
set -euo pipefail

input=$(cat)
tool_name=$(echo "$input" | jq -r '.tool_name')

if [[ ! "$tool_name" =~ ^[a-zA-Z0-9_]+$ ]]; then
  echo '{"decision": "deny", "reason": "Invalid tool name"}' >&2
  exit 2
fi
```

In Node/`.mjs` hooks (our convention), same principle — parse stdin JSON, validate structure before trusting.

### Path safety

```bash
file_path=$(echo "$input" | jq -r '.tool_input.file_path')

# Deny path traversal
[[ "$file_path" == *".."* ]] && { echo '{"decision":"deny","reason":"Path traversal"}' >&2; exit 2; }

# Deny sensitive files
[[ "$file_path" == *".env"* ]] && { echo '{"decision":"deny","reason":"Sensitive file"}' >&2; exit 2; }
```

Our `enforce-scope.mjs` implements this for wave-scope boundaries.

### Quote variables

```bash
echo "$file_path"        # ✅
cd "$CLAUDE_PROJECT_DIR" # ✅
echo $file_path          # ❌ unquoted injection risk
```

### Timeouts

Defaults: command hooks 60s, prompt hooks 30s. Set explicitly when the work is known-slow:

```json
{ "type": "command", "command": "...", "timeout": 10 }
```

## Parallel execution

All matching hooks run **in parallel** — they don't see each other's output, ordering is non-deterministic. Design for independence.

## Lifecycle limitation — NO hot-swap

Hooks load at session start. Changes to `hooks.json` or hook scripts do **not** affect the running session.

To test hook changes:
1. Edit hook
2. Exit Claude Code
3. Restart (`claude` or `cc`)
4. Verify with `/hooks` command or `claude --debug`

This is the #2 reason "my hook isn't working" — the change hasn't loaded yet.

## Debugging

### Debug mode

```bash
claude --debug
```

Surfaces hook registration, execution logs, stdin/stdout JSON, timing.

### Test a command hook directly

```bash
echo '{"tool_name":"Write","tool_input":{"file_path":"/test"}}' | \
  ${CLAUDE_PLUGIN_ROOT}/hooks/guard.mjs
echo "Exit code: $?"
```

### Validate JSON output

```bash
output=$(./your-hook.mjs < test-input.json)
echo "$output" | jq .
```

Invalid JSON breaks silently — always verify.

## Conditional activation

Pattern: check for a flag file or config before running:

```bash
#!/bin/bash
FLAG_FILE="$CLAUDE_PROJECT_DIR/.enable-strict-validation"
[[ ! -f "$FLAG_FILE" ]] && exit 0   # Flag not present, skip
# ... validation logic
```

Or config-based (matches our Session-Config pattern):

```bash
CONFIG_FILE="$CLAUDE_PROJECT_DIR/.claude/config.json"
enabled=$(jq -r '.strictMode // false' "$CONFIG_FILE" 2>/dev/null)
[[ "$enabled" != "true" ]] && exit 0
```

## Our in-house examples (read these, not the upstream `examples/`)

- `hooks/pre-bash-destructive-guard.mjs` — policy-driven command blocker, 13 rules in `.orchestrator/policy/blocked-commands.json`
- `hooks/enforce-scope.mjs` — wave-scope boundary enforcement using `.orchestrator/wave-scope.json`
- `hooks/on-session-start.mjs` — banner + session init
- `hooks/post-edit-validate.mjs` — validates edits after the fact
- `hooks/on-stop.mjs` — session-event capture + metrics

## Do / Don't

**Do:**
- Prompt-based hooks for complex logic, command hooks for fast deterministic checks
- Always `${CLAUDE_PLUGIN_ROOT}` for paths
- Validate every input field before trusting it
- Quote all shell variables
- Set explicit timeouts for known-slow work
- Return structured JSON on stdout

**Don't:**
- Hardcoded paths
- Trust `tool_input` without validation
- Long-running hooks (blocks the tool call)
- Rely on execution order (hooks run in parallel)
- Mutate global state
- Log sensitive data to stdout/stderr

## Implementation checklist

- [ ] Event chosen (PreToolUse / Stop / …) matches intent
- [ ] Prompt-based vs. command decided based on whether reasoning is needed
- [ ] `hooks/hooks.json` uses **wrapper** format, NOT settings direct format
- [ ] `${CLAUDE_PLUGIN_ROOT}` for all paths
- [ ] Input validation on every field you read from stdin
- [ ] Timeout set if work is known-slow
- [ ] Tested directly via `echo '...' | hook.mjs`
- [ ] Tested in-session with `claude --debug`
- [ ] README/docs updated

## References

- [Official hooks docs](https://docs.claude.com/en/docs/claude-code/hooks)
- Upstream: [patterns.md](https://github.com/anthropics/claude-plugins-official/tree/main/plugins/plugin-dev/skills/hook-development/references/patterns.md), [advanced.md](https://github.com/anthropics/claude-plugins-official/tree/main/plugins/plugin-dev/skills/hook-development/references/advanced.md) — read these for edge cases we haven't hit yet

Files in this skill

  • SKILL.md9.5 KB
  • source.json1 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.