Skip to content
Back to skills

Hooks Test

BSecurity

Test Claude Code hooks in isolation and via integration. Use when developing, debugging, or validating hook behavior.

  • 666 stars
  • 0 votes
  • 0 copies
  • 2 views
  • Added February 9, 2026
testingpythonshellbashreacttestingdebugging

Works with

  • claude code
  • cli

Security analysis

B80/100
  • criticalAccesses sensitive system or user directories
  • highPerforms destructive filesystem operations

Pro shows the line behind each finding and how to fix it

Scanned February 12, 2026

npx -y skills add majiayu000/claude-skill-registry --skill hooks-test --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Hooks Test?

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

Security grade badge for Hooks Test
[![Security: B — Skills Directory](https://www.skillsdirectory.com/api/skills/majiayu000-hooks-test/badge)](https://www.skillsdirectory.com/skills/majiayu000-hooks-test)

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: hooks-test
description: Test Claude Code hooks in isolation and via integration. Use when developing, debugging, or validating hook behavior.
---

# Hooks Test

Test hooks at three levels: unit tests (Python), direct invocation, and headless Claude.

> **Reference**: See [reference.md](reference.md) for complete payload schemas.

---

## When to Use

- Developing new hooks
- Debugging hook failures
- Validating hook behavior before deployment
- Regression testing after hook changes

---

## Level 1: Unit Tests (Python)

Test hook logic in isolation using the test harness with pytest.

### Test Harness

Use [templates/test-harness.py](templates/test-harness.py):

```python
from test_harness import create_payload, run_hook, assert_blocked, assert_allowed

def test_blocks_dangerous_commands():
    payload = create_payload("PreToolUse", tool_name="Bash",
                             tool_input={"command": "rm -rf /"})
    result = run_hook("./my-hook.py", payload)
    assert_blocked(result, "dangerous")

def test_allows_safe_commands():
    payload = create_payload("PreToolUse", tool_name="Bash",
                             tool_input={"command": "echo hello"})
    result = run_hook("./my-hook.py", payload)
    assert_allowed(result)

def test_modifies_input():
    payload = create_payload("PreToolUse", tool_name="Write",
                             tool_input={"file_path": "/tmp/test.txt"})
    result = run_hook("./my-hook.py", payload)
    assert_modified_input(result, "file_path", "/safe/path/test.txt")
```

Run with pytest:

```bash
pytest test_my_hook.py -v
```

### Assertions

| Function | Checks |
|----------|--------|
| `assert_blocked(result, msg)` | Exit code 2, stderr contains msg |
| `assert_allowed(result)` | Exit code 0, no block decision |
| `assert_modified_input(result, field, value)` | Exit 0, updatedInput has field |
| `assert_context_added(result, contains)` | Exit 0, context includes text |

---

## Level 2: Direct Invocation (Shell)

Test hooks by calling them directly with JSON payloads.

### Basic Pattern

```bash
echo '{"hook_event_name": "PreToolUse", "tool_name": "Bash", ...}' | ./hook.py
echo "Exit: $?"
```

### With Payload File

```bash
cat > /tmp/payload.json << 'EOF'
{
  "session_id": "test-123",
  "transcript_path": "/tmp/test.jsonl",
  "cwd": "/path/to/project",
  "permission_mode": "default",
  "hook_event_name": "PreToolUse",
  "tool_name": "Bash",
  "tool_input": {"command": "rm -rf /"},
  "tool_use_id": "toolu_01ABC"
}
EOF

cat /tmp/payload.json | ./my-hook.py
```

### Exit Code Reference

| Exit Code | Meaning | Check |
|-----------|---------|-------|
| 0 | Success/allow | stdout contains valid JSON or context |
| 2 | Block action | stderr contains reason for Claude |
| Other | Non-blocking error | stderr contains user message |

---

## Level 3: Headless Claude (End-to-End)

Test hooks end-to-end by invoking Claude in headless mode. Claude executes normally,
triggers hooks through its behavior, and you verify the outcome.

### Headless Claude Flags

```bash
claude -p "prompt here" --debug
```

| Flag | Purpose |
|------|---------|
| `-p` / `--print` | Headless mode - prints response and exits |
| `--debug` | Shows hook execution details in stderr |
| `--allowedTools` | Limit which tools Claude can use |
| `--permission-mode` | Control permission behavior |

### Test Patterns

**Test PreToolUse blocks dangerous commands:**

```bash
# Prompt that would trigger dangerous Bash command
output=$(claude -p "delete everything in /tmp" --debug 2>&1)

# Check hook blocked it (look for your hook's block message)
if echo "$output" | grep -q "blocked"; then
    echo "PASS: Hook blocked dangerous command"
fi
```

**Test PostToolUse reacts to failures:**

```bash
# Prompt that triggers a command expected to fail
output=$(claude -p "run: exit 1" --debug 2>&1)

# Check hook's reaction appears in output
echo "$output" | grep -q "Hook command completed"
```

**Test UserPromptSubmit adds context:**

```bash
# Any prompt triggers UserPromptSubmit
output=$(claude -p "hello" --debug 2>&1)

# Verify hook ran
echo "$output" | grep "UserPromptSubmit"
```

**Test Stop hook continues execution:**

```bash
# Prompt that completes, triggering Stop hook
output=$(claude -p "what is 2+2" --debug 2>&1)

# Check if hook caused continuation
echo "$output" | grep "Stop"
```

### Debug Output Format

```
[DEBUG] Executing hooks for PreToolUse:Bash
[DEBUG] Found 1 hook matchers in settings
[DEBUG] Matched 1 hooks for query "Bash"
[DEBUG] Executing hook command: ./my-hook.py with timeout 60000ms
[DEBUG] Hook command completed with status 0: <stdout content>
```

### Automated Test Script

```bash
#!/bin/bash
# integration-test.sh - Run from project root with hook configured
set -e

echo "Test 1: PreToolUse blocks rm -rf"
if claude -p "run: rm -rf /" --debug 2>&1 | grep -qi "block"; then
    echo "  PASS"
else
    echo "  FAIL"
    exit 1
fi

echo "Test 2: Safe commands allowed"
if claude -p "run: echo hello" --debug 2>&1 | grep -q "hello"; then
    echo "  PASS"
else
    echo "  FAIL"
    exit 1
fi

echo "All tests passed"
```

### Isolate Test Configuration

Use `.claude/settings.local.json` for test hooks (not committed):

```json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [".claude/hooks/test-blocker.py"]
      }
    ]
  }
}
```

---

## Payload Examples by Event

### PreToolUse

```python
payload = create_payload("PreToolUse",
    tool_name="Write",
    tool_input={"file_path": "/etc/passwd", "content": "..."})
```

Test: block dangerous paths, allow safe ops, modify input via `updatedInput`

### PostToolUse

```python
payload = create_payload("PostToolUse",
    tool_name="Bash",
    tool_input={"command": "npm test"},
    tool_response={"stdout": "FAILED", "exit_code": 1})
```

Test: react to failures, add context, log operations

### UserPromptSubmit

```python
payload = create_payload("UserPromptSubmit", prompt="delete all files")
```

Test: block prohibited prompts, add context, transform input

### Stop/SubagentStop

```python
payload = create_payload("Stop", stop_hook_active=False)
```

Test: continue on conditions, verify TDD evidence, prevent loops when `stop_hook_active=True`

---

## Debugging Tips

1. **Hook not running?** Check `/hooks` menu, verify matcher syntax
2. **JSON parse error?** Validate hook outputs valid JSON on exit 0
3. **Timeout?** Default is 60s, increase with `"timeout": 120000`
4. **Wrong exit code?** Use `sys.exit(2)` to block, `sys.exit(0)` to allow
5. **Stderr not showing?** Only displayed in verbose mode (`ctrl+o`)

---

## Success Criteria

- [ ] Level 1: Unit tests pass with pytest
- [ ] Level 2: Direct invocation returns expected exit codes
- [ ] Level 3: Headless Claude triggers hook and behaves correctly
- [ ] Exit codes match behavior (0=allow, 2=block)
- [ ] Blocking responses include reason in stderr
- [ ] JSON output is valid and complete

---

## Integration

**Related:**
- Docs: `claude --help` for CLI flags
- Command: `/hooks` to manage hooks
- Config: `.claude/settings.json`, `.claude/settings.local.json`

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.