Hook Policy Engine

SkillAI & models

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

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 Policy Engine 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-policy-engine/SKILL.md and read by ahel’s review.

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

EventWhen it firesstdin payloadstdout expected
PreToolUseBefore a tool call executes{tool_name, tool_input}{"decision":"approve"} or {"decision":"block","reason":"..."}
PostToolUseAfter a tool call completes{tool_name, tool_input, tool_response}{"decision":"approve"} or {"decision":"block","reason":"..."}
PostToolUseFailureAfter a tool call errors{tool_name, tool_input, error}{"decision":"approve"} (errors are logged)
NotificationClaude sends a message{message}{"decision":"approve"}
StopClaude is about to stop (end of turn){stop_reason}{"decision":"approve"} or {"decision":"block","reason":"..."}
UserPromptSubmitUser submits a prompt{prompt}{"decision":"approve"} or {"decision":"block","reason":"..."}
SessionStartNew session begins{session_id}{"decision":"approve"}

stdin format for PreToolUse/PostToolUse:

{
  "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

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

Common matcher patterns

MatcherFires 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:

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

Script: .claude/hooks/protect-sensitive-files.sh

#!/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:

{
  "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

#!/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:

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

Script: .claude/hooks/stop-until-tests-pass.sh

#!/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 || true)

# Count failure indicators (Vitest, Jest, Mocha, and generic patterns)
FAILURES=$(printf '%s' "$RESULT" | grep -cE "FAIL |failed|✗|× |Error:|AssertionError" 2>/dev/null || true)

if [ "$FAILURES" -gt 0 ]; then
  SUMMARY=$(printf '%s' "$RESULT" | tail -10)
  jq -n --arg summary "$SUMMARY" \
    '{"decision":"block","reason":("Tests are failing — fix before completing:\n\n"+$summary)}'
  exit 0
fi

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

Example behavior:

  • All tests pass → approved, Claude stops normally
  • 3 tests fail → blocked, Claude told to fix them first
  • No package.json → approved (non-Node projects unaffected)
  • No test files yet → approved (empty test suites skip)

Pack 4: post-compact-context-restoration

Event: PostToolUse Matcher: "Compact" Purpose: After context compaction, re-injects the active task description from .claude/active-task.md so Claude doesn't lose the thread of work in progress.

settings.json registration:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Compact",
        "hooks": [{ "type": "command", "command": "bash .claude/hooks/post-compact-context-restoration.sh" }]
      }
    ]
  }
}

Script: .claude/hooks/post-compact-context-restoration.sh

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

TASK_FILE=".claude/active-task.md"

if [ -f "$TASK_FILE" ]; then
  CONTENT=$(cat "$TASK_FILE")
  TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
  printf '\n[CONTEXT RESTORED AFTER COMPACT — %s]\n%s\n[END CONTEXT RESTORATION]\n' \
    "$TIMESTAMP" "$CONTENT" >&2
fi

# Also check for in-progress work notes
NOTES_FILE=".claude/work-in-progress.md"
if [ -f "$NOTES_FILE" ]; then
  NOTES=$(cat "$NOTES_FILE")
  printf '\n[WORK IN PROGRESS]\n%s\n[END WORK IN PROGRESS]\n' "$NOTES" >&2
fi

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

Workflow: To use this pack effectively, maintain .claude/active-task.md with the current task description. Update it when starting a new major task:

# Active Task

## Goal
Refactor authentication module to use JWT refresh tokens.

## In Progress
- [x] Updated token generation
- [ ] Update token validation middleware
- [ ] Update frontend auth store

## Key Context
- Token endpoint: /api/auth/refresh
- Store: src/stores/authStore.ts
- Middleware: src/middleware/auth.ts

After every compaction, this content is printed to stderr, which the harness injects back into context.


Pack 5: direnv-reload-on-cwd-change

Event: UserPromptSubmit Matcher: "" (all prompts) Purpose: Ensures environment variables are always current by reloading .envrc via direnv before processing each prompt. Prevents stale env vars when switching between projects or after .envrc changes.

settings.json registration:

{
  "hooks": {
    "UserPromptSubmit": [
      {
        "matcher": "",
        "hooks": [{ "type": "command", "command": "bash .claude/hooks/direnv-reload-on-cwd-change.sh" }]
      }
    ]
  }
}

Script: .claude/hooks/direnv-reload-on-cwd-change.sh

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

# Only run if direnv is installed and .envrc exists in current or parent dir
if ! command -v direnv &>/dev/null; then
  echo '{"decision":"approve"}'
  exit 0
fi

# Walk up to find .envrc
CUR="$PWD"
ENVRC_FOUND=false
while [ "$CUR" != "/" ]; do
  if [ -f "$CUR/.envrc" ]; then
    ENVRC_FOUND=true
    break
  fi
  CUR=$(dirname "$CUR")
done

if [ "$ENVRC_FOUND" = false ]; then
  echo '{"decision":"approve"}'
  exit 0
fi

# Allow and export — suppress output unless changed
direnv allow . 2>/dev/null || true
DIRENV_OUT=$(direnv export bash 2>/dev/null || true)

if [ -n "$DIRENV_OUT" ]; then
  printf '[direnv] Environment reloaded from .envrc (%d chars exported)\n' "${#DIRENV_OUT}" >&2
fi

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

Example behavior:

  • .envrc exists → direnv allow + export on each prompt
  • direnv not installed → silently approved
  • No .envrc anywhere up the tree → silently approved

Pack 6: task-created-governance

Event: PreToolUse Matcher: "Task" Purpose: Enforces minimum quality on task descriptions before subagents are spawned. Prevents "do the thing" tasks with no context, which produce poor agent output.

settings.json registration:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Task",
        "hooks": [{ "type": "command", "command": "bash .claude/hooks/task-created-governance.sh" }]
      }
    ]
  }
}

Script: .claude/hooks/task-created-governance.sh

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

INPUT=$(cat)
DESCRIPTION=$(printf '%s' "$INPUT" | jq -r '.tool_input.description // ""')
PROMPT=$(printf '%s' "$INPUT" | jq -r '.tool_input.prompt // ""')

# Use whichever field is populated
CONTENT="${DESCRIPTION:-$PROMPT}"

# Strip whitespace for length check
TRIMMED=$(printf '%s' "$CONTENT" | tr -d '[:space:]')

MIN_LENGTH=20

if [ "${#TRIMMED}" -lt "$MIN_LENGTH" ]; then
  jq -n \
    --arg len "${#TRIMMED}" \
    --arg min "$MIN_LENGTH" \
    '{"decision":"block","reason":("Task description is too short ("+$len+" chars, minimum "+$min+"). Add context: what needs to be done, what files are involved, and what success looks like.")}'
  exit 0
fi

# Block obviously vague tasks
VAGUE_PATTERNS=("fix it" "do this" "handle this" "implement it" "make it work")
LOWER=$(printf '%s' "$CONTENT" | tr '[:upper:]' '[:lower:]')
for pattern in "${VAGUE_PATTERNS[@]}"; do
  if [ "$LOWER" = "$pattern" ]; then
    echo '{"decision":"block","reason":"Task description is too vague. Describe the specific change, the files involved, and the expected outcome."}'
    exit 0
  fi
done

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

Example behavior:

  • Task "fix" → blocked (too short)
  • Task "fix it" → blocked (vague pattern)
  • Task "Refactor the authentication middleware in src/middleware/auth.ts to use JWT refresh tokens instead of session cookies" → approved

Pack 7: task-completed-quality-gate

Event: PostToolUse Matcher: "Task" Purpose: After a subagent task completes, runs TypeScript type checking to catch type errors introduced by the agent before the turn ends.

settings.json registration:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Task",
        "hooks": [{ "type": "command", "command": "bash .claude/hooks/task-completed-quality-gate.sh" }]
      }
    ]
  }
}

Script: .claude/hooks/task-completed-quality-gate.sh

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

# Only run if tsconfig exists
if [ ! -f "tsconfig.json" ]; then
  echo '{"decision":"approve"}'
  exit 0
fi

# Only run if TypeScript is installed
if ! command -v npx &>/dev/null; then
  echo '{"decision":"approve"}'
  exit 0
fi

# Run type check with timeout
TS_RESULT=$(timeout 60 npx tsc --noEmit 2>&1 || true)
TS_ERROR_COUNT=$(printf '%s' "$TS_RESULT" | grep -cE "error TS[0-9]+" 2>/dev/null || true)

if [ "$TS_ERROR_COUNT" -gt 0 ]; then
  # Truncate to first 20 errors for readability
  TS_SUMMARY=$(printf '%s' "$TS_RESULT" | grep -E "error TS[0-9]+" | head -20)
  jq -n \
    --arg count "$TS_ERROR_COUNT" \
    --arg errors "$TS_SUMMARY" \
    '{"decision":"block","reason":($count+" TypeScript error(s) introduced by task — fix before completing:\n\n"+$errors)}'
  exit 0
fi

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

Example behavior:

  • Agent introduces 3 type errors → blocked, errors listed
  • Agent's code is clean → approved
  • No tsconfig.json → approved (JS-only projects unaffected)
  • tsc times out after 60s → approved (prevent infinite block)

Pack 8: teammate-idle-enforcement

Event: Stop Matcher: "" (all stop events) Purpose: Records each completion timestamp so external monitoring tools can detect when Claude has been idle for extended periods. Pairs with the loop skill for automated idle detection.

settings.json registration:

{
  "hooks": {
    "Stop": [
      {
        "matcher": "",
        "hooks": [{ "type": "command", "command": "bash .claude/hooks/teammate-idle-enforcement.sh" }]
      }
    ]
  }
}

Script: .claude/hooks/teammate-idle-enforcement.sh

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

TIMESTAMP=$(date -u +%s)
TIMESTAMP_ISO=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
IDLE_LOG="/tmp/claude-last-stop"
IDLE_THRESHOLD_MINUTES=10

# Atomic write using flock
(
  flock -x 200
  printf '%s\n' "$TIMESTAMP" > "$IDLE_LOG"
) 200>"${IDLE_LOG}.lock" 2>/dev/null || printf '%s\n' "$TIMESTAMP" > "$IDLE_LOG"

# Check if previous stop was recorded and calculate idle duration
PREV_STOP_FILE="/tmp/claude-prev-stop"
if [ -f "$PREV_STOP_FILE" ]; then
  PREV_TS=$(cat "$PREV_STOP_FILE" 2>/dev/null || echo 0)
  IDLE_SECONDS=$(( TIMESTAMP - PREV_TS ))
  IDLE_MINUTES=$(( IDLE_SECONDS / 60 ))

  if [ "$IDLE_MINUTES" -ge "$IDLE_THRESHOLD_MINUTES" ]; then
    printf '[teammate-idle] Claude has been idle for %d minutes (since %s)\n' \
      "$IDLE_MINUTES" "$TIMESTAMP_ISO" >&2
  fi
fi

# Update previous stop timestamp
printf '%s\n' "$TIMESTAMP" > "$PREV_STOP_FILE"

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

Example behavior:

  • Normal completion → records timestamp, approves
  • First stop of session → no idle warning
  • Next stop 15 minutes later → logs "[teammate-idle] Claude has been idle for 15 minutes"
  • /tmp/claude-last-stop contains Unix timestamp readable by external tools

External idle check script:

#!/usr/bin/env bash
IDLE_LOG="/tmp/claude-last-stop"
THRESHOLD=600  # 10 minutes in seconds
if [ -f "$IDLE_LOG" ]; then
  LAST=$(cat "$IDLE_LOG")
  NOW=$(date +%s)
  DIFF=$(( NOW - LAST ))
  if [ "$DIFF" -gt "$THRESHOLD" ]; then
    echo "Claude idle for $(( DIFF / 60 )) minutes"
    exit 1
  fi
fi
echo "Active"

4. Composing Packs

Multiple packs stack in settings.json. Install order does not matter — all packs under the same event+matcher run in sequence:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          { "type": "command", "command": "bash .claude/hooks/protect-sensitive-files.sh" }
        ]
      },
      {
        "matcher": "Task",
        "hooks": [
          { "type": "command", "command": "bash .claude/hooks/task-created-governance.sh" }
        ]
      }
    ],
    "PostToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          { "type": "command", "command": "bash .claude/hooks/auto-format-after-edit.sh" }
        ]
      },
      {
        "matcher": "Compact",
        "hooks": [
          { "type": "command", "command": "bash .claude/hooks/post-compact-context-restoration.sh" }
        ]
      },
      {
        "matcher": "Task",
        "hooks": [
          { "type": "command", "command": "bash .claude/hooks/task-completed-quality-gate.sh" }
        ]
      }
    ],
    "Stop": [
      {
        "matcher": "",
        "hooks": [
          { "type": "command", "command": "bash .claude/hooks/stop-until-tests-pass.sh" },
          { "type": "command", "command": "bash .claude/hooks/teammate-idle-enforcement.sh" }
        ]
      }
    ],
    "UserPromptSubmit": [
      {
        "matcher": "",
        "hooks": [
          { "type": "command", "command": "bash .claude/hooks/direnv-reload-on-cwd-change.sh" }
        ]
      }
    ]
  }
}

Execution order within a matcher group

When two hooks share the same event+matcher, they run left-to-right in the hooks array. If stop-until-tests-pass blocks, teammate-idle-enforcement still runs (all hooks get a chance to execute — the block decision is applied after all hooks complete).

Recommended pack combinations

Safety-first setup (minimal overhead):

  • protect-sensitive-files — always-on PreToolUse guard

Active development (medium overhead):

  • protect-sensitive-files
  • auto-format-after-edit
  • task-created-governance

CI-grade local enforcement (higher overhead):

  • All of the above plus stop-until-tests-pass and task-completed-quality-gate

Agent team setup (full pack):

  • --all — install everything

5. Custom Pack Template

Use this template to write a new hook pack from scratch.

Checklist

  • Script starts with #!/usr/bin/env bash and set -euo pipefail
  • Reads stdin exactly once into INPUT=$(cat) — stdin is a stream, can only be read once
  • Uses jq -r to extract fields from $INPUT — never string parsing
  • Returns valid JSON to stdout: {"decision":"approve"} or {"decision":"block","reason":"..."}
  • Uses jq -n with --arg for constructing output JSON — never string concatenation
  • Validates file paths with realpath when path traversal is a risk
  • Uses flock for atomic writes to shared files
  • Uses || true after commands that may legitimately fail (grep, find, etc.)
  • Exits 0 in all paths — non-zero exit defaults to approve but logs an error
  • Has a graceful "no-op" path for environments where the dependency is absent

Template

#!/usr/bin/env bash
# <pack-name>.sh — <one-line description>
# Event:   <PreToolUse|PostToolUse|Stop|UserPromptSubmit|SessionStart>
# Matcher: "<regex|empty-for-all>"
set -euo pipefail

# Read stdin exactly once
INPUT=$(cat)

# --- Prerequisite checks ---
# Exit early if the environment doesn't support this pack
if ! command -v jq &>/dev/null; then
  # jq is required for safe JSON parsing — approve without running
  echo '{"decision":"approve"}'
  exit 0
fi

# --- Extract relevant fields ---
# Adjust field names based on the event type:
#   PreToolUse:  .tool_name, .tool_input.<field>
#   PostToolUse: .tool_name, .tool_input.<field>, .tool_response
#   Stop:        .stop_reason
#   UserPromptSubmit: .prompt
FIELD=$(printf '%s' "$INPUT" | jq -r '.tool_input.some_field // ""')

# --- Guard: skip if irrelevant ---
if [ -z "$FIELD" ]; then
  echo '{"decision":"approve"}'
  exit 0
fi

# --- Main logic ---
if <condition that should block>; then
  # Use jq -n with --arg to safely construct JSON (never string concatenation)
  jq -n --arg reason "Blocked because: $FIELD fails the policy" \
    '{"decision":"block","reason":$reason}'
  exit 0
fi

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

Testing your pack

# Test approve path
echo '{"tool_name":"Write","tool_input":{"file_path":"src/index.ts","content":"..."}}' \
  | bash .claude/hooks/my-pack.sh

# Test block path
echo '{"tool_name":"Write","tool_input":{"file_path":".env","content":"SECRET=abc"}}' \
  | bash .claude/hooks/my-pack.sh

# Validate output is valid JSON
echo '{"tool_name":"Write","tool_input":{"file_path":".env"}}' \
  | bash .claude/hooks/my-pack.sh | jq .

Registering in settings.json

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          { "type": "command", "command": "bash .claude/hooks/my-pack.sh" }
        ]
      }
    ]
  }
}

Use /cc-hooks test .claude/hooks/my-pack.sh after writing to validate both paths with the built-in harness test runner.

Signals

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