Claude
Skills
Sign in
Back

hook-policy-engine

Included with Lifetime
$97 forever

Hook policy engine with 8 installable packs for safety, verification, context recovery, and teammate quality gates

General

What this skill does


# Hook Policy Engine

Installable hook policy packs for Claude Code. Each pack is a hardened bash script registered in `.claude/settings.json`. Install individually or all at once via `/cc-hooks install`.

---

## 1. Hook Events Reference

| Event | When it fires | stdin payload | stdout expected |
|-------|--------------|---------------|-----------------|
| `PreToolUse` | Before a tool call executes | `{tool_name, tool_input}` | `{"decision":"approve"}` or `{"decision":"block","reason":"..."}` |
| `PostToolUse` | After a tool call completes | `{tool_name, tool_input, tool_response}` | `{"decision":"approve"}` or `{"decision":"block","reason":"..."}` |
| `PostToolUseFailure` | After a tool call errors | `{tool_name, tool_input, error}` | `{"decision":"approve"}` (errors are logged) |
| `Notification` | Claude sends a message | `{message}` | `{"decision":"approve"}` |
| `Stop` | Claude is about to stop (end of turn) | `{stop_reason}` | `{"decision":"approve"}` or `{"decision":"block","reason":"..."}` |
| `UserPromptSubmit` | User submits a prompt | `{prompt}` | `{"decision":"approve"}` or `{"decision":"block","reason":"..."}` |
| `SessionStart` | New session begins | `{session_id}` | `{"decision":"approve"}` |

**stdin format for PreToolUse/PostToolUse:**
```json
{
  "tool_name": "Write",
  "tool_input": {
    "file_path": "/path/to/file",
    "content": "..."
  },
  "tool_response": "..."
}
```

All hooks must exit 0. Non-zero exit codes cause the harness to log an error and default to `approve`.

---

## 2. If-Based Filtering (matcher field)

The `matcher` field in `settings.json` is a **regex** matched against `tool_name`. A hook only fires when the regex matches the tool being called.

### Registration structure

```json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          { "type": "command", "command": "bash .claude/hooks/protect-sensitive-files.sh" }
        ]
      }
    ]
  }
}
```

### Common matcher patterns

| Matcher | Fires on |
|---------|----------|
| `"Write\|Edit"` | Write or Edit tool calls only |
| `"Task"` | Task tool calls only (create, update, etc.) |
| `"Compact"` | Compaction events only |
| `"Bash"` | Bash tool calls only |
| `"Write\|Edit\|Bash"` | Write, Edit, or Bash |
| `""` | All tool calls (no filter) |
| `"^(?!Bash)"` | Everything except Bash |

Multiple hooks under the same event+matcher execute in order. If any returns `{"decision":"block"}`, the operation is blocked. All hooks receive the same stdin JSON.

---

## 3. The 8 Packs

### Pack 1: protect-sensitive-files

**Event:** PreToolUse  
**Matcher:** `"Write|Edit"`  
**Purpose:** Blocks writes to sensitive files before they happen.

**settings.json registration:**
```json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [{ "type": "command", "command": "bash .claude/hooks/protect-sensitive-files.sh" }]
      }
    ]
  }
}
```

**Script: `.claude/hooks/protect-sensitive-files.sh`**
```bash
#!/usr/bin/env bash
set -euo pipefail

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

if [ -z "$FILE" ]; then
  echo '{"decision":"approve"}'
  exit 0
fi

# Resolve to absolute path for reliable matching
RESOLVED=$(realpath -m "$FILE" 2>/dev/null || echo "$FILE")

BLOCKED_PATTERNS=(
  ".env"
  "*.key"
  "*.pem"
  "*.p12"
  "*.pfx"
  "*.crt"
  "*.cer"
  "secrets/"
  "credentials"
  ".aws/credentials"
  ".ssh/id_rsa"
  ".ssh/id_ed25519"
  ".npmrc"
  ".pypirc"
)

for pattern in "${BLOCKED_PATTERNS[@]}"; do
  # Match against both resolved path and basename
  BASENAME=$(basename "$RESOLVED")
  case "$RESOLVED" in
    *"$pattern"*) ;;
    *) case "$BASENAME" in *"$pattern"*) ;; *) continue ;; esac ;;
  esac
  jq -n --arg f "$FILE" \
    '{"decision":"block","reason":("Blocked write to sensitive file: "+$f+" — store secrets in environment variables or a secrets manager, never in source files")}'
  exit 0
done

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

**Example behavior:**
- Write to `.env` → blocked with reason
- Edit to `config/app.ts` → approved
- Write to `secrets/api-key.txt` → blocked

---

### Pack 2: auto-format-after-edit

**Event:** PostToolUse  
**Matcher:** `"Write|Edit"`  
**Purpose:** Runs the appropriate formatter after every file write, keeping code style consistent without manual intervention.

**settings.json registration:**
```json
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [{ "type": "command", "command": "bash .claude/hooks/auto-format-after-edit.sh" }]
      }
    ]
  }
}
```

**Script: `.claude/hooks/auto-format-after-edit.sh`**
```bash
#!/usr/bin/env bash
set -euo pipefail

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

if [ -z "$FILE" ] || [ ! -f "$FILE" ]; then
  echo '{"decision":"approve"}'
  exit 0
fi

# Security: reject path traversal outside project root
PROJECT_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd)
RESOLVED=$(realpath "$FILE" 2>/dev/null || echo "$FILE")
if [[ "$RESOLVED" != "$PROJECT_ROOT"* ]]; then
  echo '{"decision":"approve"}'
  exit 0
fi

EXT="${FILE##*.}"

case "$EXT" in
  ts|tsx|js|jsx|mjs|cjs)
    if command -v prettier &>/dev/null; then
      prettier --write "$FILE" --log-level warn 2>/dev/null || true
    fi
    if command -v eslint &>/dev/null; then
      eslint --fix "$FILE" --quiet 2>/dev/null || true
    fi
    ;;
  py)
    if command -v black &>/dev/null; then
      black --quiet "$FILE" 2>/dev/null || true
    fi
    if command -v isort &>/dev/null; then
      isort --quiet "$FILE" 2>/dev/null || true
    fi
    ;;
  rs)
    if command -v rustfmt &>/dev/null; then
      rustfmt --edition 2021 "$FILE" 2>/dev/null || true
    fi
    ;;
  go)
    if command -v gofmt &>/dev/null; then
      gofmt -w "$FILE" 2>/dev/null || true
    fi
    ;;
  json)
    if command -v prettier &>/dev/null; then
      prettier --write "$FILE" --parser json --log-level warn 2>/dev/null || true
    fi
    ;;
  md|mdx)
    if command -v prettier &>/dev/null; then
      prettier --write "$FILE" --parser markdown --log-level warn 2>/dev/null || true
    fi
    ;;
  css|scss|less)
    if command -v prettier &>/dev/null; then
      prettier --write "$FILE" --log-level warn 2>/dev/null || true
    fi
    ;;
  *)
    # No formatter for this extension — approve silently
    ;;
esac

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

**Example behavior:**
- Write to `src/components/Button.tsx` → runs prettier then eslint --fix
- Write to `api/handler.py` → runs black then isort
- Write to `lib/parser.rs` → runs rustfmt
- Write to `README.md` → runs prettier with markdown parser

---

### Pack 3: stop-until-tests-pass

**Event:** Stop  
**Matcher:** `""` (all stop events)  
**Purpose:** Prevents Claude from completing a turn if the test suite is currently failing. Forces fix-the-tests-first discipline.

**settings.json registration:**
```json
{
  "hooks": {
    "Stop": [
      {
        "matcher": "",
        "hooks": [{ "type": "command", "command": "bash .claude/hooks/stop-until-tests-pass.sh" }]
      }
    ]
  }
}
```

**Script: `.claude/hooks/stop-until-tests-pass.sh`**
```bash
#!/usr/bin/env bash
set -euo pipefail

# Only run if package.json has a test script
if [ ! -f "package.json" ]; then
  echo '{"decision":"approve"}'
  exit 0
fi

TEST_SCRIPT=$(jq -r '.scripts.test // ""' package.json 2>/dev/null || echo "")
if [ -z "$TEST_SCRIPT" ] || [ "$TEST_SCRIPT" = "null" ]; then
  echo '{"decision":"approve"}'
  exit 0
fi

# Skip if no test files exist (new project with stub test script)
TEST_FILE_COUNT=$(find . \( -name "*.test.ts" -o -name "*.test.js" -o -name "*.spec.ts" -o -name "*.spec.js" \) \
  -not -path "*/node_modules/*" 2>/dev/null | wc -l || echo 0)
if [ "$TEST_FILE_COUNT" -eq 0 ]; then
  echo '{"decision":"approve"}'
  exit 0
fi

# Run tests with timeout (120s max)
RESULT=$(timeout 120 npm test --silent 2>&1 |

Related in General