Hooks and Guardrails
This configuration uses Claude Code hooks as a safety layer. Some hooks stop risky commands before they run. Some add context so the assistant follows the intended workflow. Others check whether your local setup is missing tools or turn Claude notifications into desktop popups.
The hook registrations live in settings.json. The tracked source files live in the repository's scripts/ directory, and the runtime configuration invokes them from ~/.claude/scripts/....
At a Glance
| Component | Event | What it does |
|---|---|---|
rule-enforcer.py |
PreToolUse |
Blocks raw python/pip and raw pre-commit commands. |
git-protection.py |
PreToolUse |
Blocks unsafe git commit and git push operations. |
| Bash destruction gate | PreToolUse |
Reviews high-risk shell commands and can approve, block, or ask for confirmation. |
rule-injector.py |
UserPromptSubmit |
Adds reminder text to each prompt so the assistant stays within the intended workflow. |
session-start-check.sh |
SessionStart |
Reports missing tools and plugins, but does not stop the session. |
my-notifier.sh |
Notification |
Sends desktop notifications with notify-send. |
Where Hooks Are Wired In
"hooks": {
"Notification": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "~/.claude/scripts/my-notifier.sh"
}
]
}
],
"PreToolUse": [
{
"matcher": "TodoWrite|Bash",
"hooks": [
{
"type": "command",
"command": "uv run ~/.claude/scripts/rule-enforcer.py"
}
]
},
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "uv run ~/.claude/scripts/git-protection.py"
}
]
},
Later in the same settings.json section, the configuration also wires in the prompt-based Bash destruction gate, rule-injector.py for UserPromptSubmit, and session-start-check.sh for SessionStart.
Note: The config includes its own maintenance warning: script entries need to appear in both
permissions.allowandallowedTools. If you add or rename a hook, update both places.
Pre-Tool Guardrails
rule-enforcer.py
Despite its name, this hook is focused rather than broad. In the current code, it only acts on Bash commands and blocks two categories:
- direct
python,python3,pip, andpip3 - direct
pre-commit
It does not block uv, uvx, or prek, and it does not inspect non-Bash tools. Because the matcher is TodoWrite|Bash, Claude may invoke it for TodoWrite, but the script immediately returns unless tool_name == "Bash".
# Block direct python/pip commands
if tool_name == "Bash":
command = tool_input.get("command", "")
if is_forbidden_python_command(command):
output = {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Direct python/pip commands are forbidden.",
"additionalContext": (
"You attempted to run python/pip directly. Instead:\n"
"1. Delegate Python tasks to the python-expert agent\n"
"2. Use 'uv run script.py' to run Python scripts\n"
"3. Use 'uvx package-name' to run package CLIs\n"
"See: https://docs.astral.sh/uv/"
),
}
}
print(json.dumps(output))
sys.exit(0)
if is_forbidden_precommit_command(command):
output = {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Direct pre-commit commands are forbidden.",
"additionalContext": (
"You attempted to run pre-commit directly. Instead:\n"
"1. Use the 'prek' command which wraps pre-commit\n"
"2. Example: prek run --all-files\n"
"See: https://github.com/j178/prek"
In practice, that means switching to uv run ..., uvx ..., or prek run --all-files instead of calling the raw tool directly.
Tip: If a command gets denied here, use the wrapper the hook suggests instead of trying to work around it. That is the supported workflow this repository expects.
tests/test_rule_enforcer.py confirms a few important details:
- matching is case-insensitive
uv,uvx, andprekare allowed- commands that merely contain words like
pythonin an argument are not blocked - malformed hook input and internal exceptions fail open, so the hook does not block the session if its own input is broken
git-protection.py
This is the strictest guardrail in the repository. It inspects Bash commands for git commit and git push and blocks them when the current branch is unsafe.
It blocks git commit when:
- you are in detached HEAD
- you are on
mainormaster - the current branch is already merged locally
- the current branch already has a merged GitHub PR
- the GitHub PR lookup itself fails
It blocks git push for the same protected-branch and merged-branch cases, but it does not block pushes from detached HEAD.
It also has one important escape hatch: git commit --amend is allowed when your branch is ahead of its upstream, so you can clean up unpublished commits.
# Check if PR is already merged on GitHub (doesn't need main_branch)
pr_merged, pr_info = get_pr_merge_status(current_branch)
if pr_merged is None:
# Error checking PR status - fail closed
return True, format_pr_merge_error("get_pr_merge_status()", pr_info)
if pr_merged:
# Get main branch for the message (best effort)
main_branch = get_main_branch() or "main"
return (
True,
f"""⛔ BLOCKED: PR #{pr_info} for branch '{current_branch}' is already MERGED.
What happened:
- This branch's PR was already merged
- Committing more changes to a merged branch is not useful
**ACTION REQUIRED - Execute these commands NOW:**
You MUST create a new branch for these changes. Do NOT ask user - just do it:
1. git checkout {main_branch}
2. git pull origin {main_branch}
3. git checkout -b feature/new-changes
4. Move uncommitted changes and commit on the new branch
IMMEDIATELY switch to '{main_branch}' and create a new feature branch.""",
)
# Get main branch for subsequent checks
detected_main_branch = get_main_branch()
if not detected_main_branch:
# Can't determine main branch - allow
return False, None
# Block if on main/master branch
if current_branch in ["main", "master"]:
return (
True,
f"""⛔ BLOCKED: Cannot commit directly to '{current_branch}' branch.
What happened:
- You are on the protected '{current_branch}' branch
- Direct commits to {current_branch} bypass code review and CI checks
**ACTION REQUIRED - Execute these commands NOW:**
You MUST create a feature branch for these changes. Do NOT ask user - just do it:
1. git stash (if you have uncommitted changes)
2. git checkout -b feature/your-feature
3. git stash pop (if you stashed changes)
4. Then commit your changes on the new branch
IMMEDIATELY create a feature branch and move your changes there.""",
)
# Allow amend on unpushed commits
if is_amend_with_unpushed_commits(command):
return False, None
# Check if branch is merged (local check as fallback)
if is_branch_merged(current_branch, detected_main_branch):
A few details matter if you are troubleshooting:
- the GitHub check only runs for GitHub remotes and only when
ghis installed - if that GitHub check is unavailable, the script falls back to local git history checks
- the parser is deliberately broader than a simple prefix match; tests confirm it catches forms like
git -C /path commit ..., environment-prefixed commands, and quoted or pipedgit commitstrings, while still avoiding common false positives likegit config push.default
Warning:
git-protection.pyfails closed. If it cannot safely determine whether a commit or push should be allowed, it blocks the operation rather than guessing. In a GitHub-backed repository, a brokenghlogin or a GitHub API error is enough to stop the command.Note: When the GitHub lookup or the hook itself crashes, the returned message explicitly tells the assistant to ask whether you want a GitHub issue created against
myk-org/claude-code-config. That behavior is intentional and is covered by the tests.
The additional Bash destruction gate
The PreToolUse chain in settings.json also includes a prompt-based security gate for Bash. It is not implemented in one of the scripts above, but it is part of the overall guardrail story.
That prompt-based hook is aimed at catastrophic OS damage: deleting system directories, formatting disks, writing raw devices with dd, removing files such as /etc/passwd, or chaining destructive commands after safe-looking ones. Depending on the command, it can approve, block, or ask for explicit confirmation.
Warning: This guardrail is intentionally conservative. If a command uses
sudoor looks risky even when it is not obviously destructive, you may be asked to confirm before it runs.
Prompt Enrichment
rule-injector.py
This hook runs on UserPromptSubmit. Instead of blocking anything, it adds a short reminder to the prompt context so the assistant keeps following the repository's intended division of responsibilities.
try:
rule_reminder = (
"[SYSTEM RULES] You are a MANAGER. NEVER do work directly. ALWAYS delegate:\n"
"- Edit/Write → language specialists (python-expert, go-expert, etc.)\n"
"- ALL Bash commands → bash-expert or appropriate specialist\n"
"- Git commands → git-expert\n"
"- MCP tools → manager agents\n"
"- Multi-file exploration → Explore agent\n"
"HOOKS WILL BLOCK VIOLATIONS."
)
output = {"hookSpecificOutput": {"hookEventName": "UserPromptSubmit", "additionalContext": rule_reminder}}
# Output JSON to stdout
print(json.dumps(output, indent=2))
The important user-facing point is that this hook enriches the assistant's context every time you send a prompt. It does not touch your files or shell history.
Note: In the current implementation,
rule-injector.pyemits a fixed reminder string. It does not read the repository'srules/directory at runtime.
If the script hits an error, it logs to stderr and exits successfully, so your prompt still goes through.
Session Start Checks
session-start-check.sh
This hook runs once at session start, with a 5 second timeout in settings.json. If everything is present, it stays silent. If something is missing, it acts like a non-blocking environment audit.
It checks for:
- critical
uv, because the Python hooks are run withuv run gh, but only when the current repo points at GitHubjqgawkprek, but only when.pre-commit-config.yamlexistsmcpl- critical review plugins:
pr-review-toolkit,superpowers, andfeature-dev - a longer list of optional marketplace plugins
When something is missing, it prints a structured report instead of stopping the session.
# Output report only if something is missing
if [[ ${#missing_critical[@]} -gt 0 || ${#missing_optional[@]} -gt 0 ]]; then
echo "MISSING_TOOLS_REPORT:"
echo ""
echo "[AI INSTRUCTION - YOU MUST FOLLOW THIS]"
echo "Some tools required by this configuration are missing."
echo ""
echo "Criticality levels:"
echo "- CRITICAL: Configuration will NOT work without these. Must install."
echo "- OPTIONAL: Enhances functionality. Nice to have."
echo ""
echo "YOUR REQUIRED ACTION:"
echo "1. List each missing tool with its purpose"
echo "2. ASK the user: 'Would you like me to help install these tools?'"
echo "3. If user accepts, provide the installation command for each tool"
echo "4. Prioritize CRITICAL tools first"
echo ""
echo "DO NOT just mention the tools. You MUST ask if the user wants help installing them."
This repository does include a real .pre-commit-config.yaml, with hooks such as detect-private-key, ruff, mypy, and markdownlint. That is why prek matters in practice here, even though the startup report labels it optional.
Tip: If you see
MISSING_TOOLS_REPORT, install the critical items first. The startup hook always exits0, so it will not block the session on its own.Note: The startup check warns about missing
jq, but it does not check fornotify-send. A session can start cleanly and still have notification failures later if your machine cannot run desktop notifications.
Notifications
my-notifier.sh
This is the Notification hook. It reads JSON from stdin, extracts .message, and passes it to notify-send as a desktop notification.
# Check for required commands
for cmd in jq notify-send; do
if ! command -v "$cmd" &>/dev/null; then
echo "Error: Required command '$cmd' not found" >&2
exit 1
fi
done
# Read JSON input from stdin
input_json=$(cat)
# Verify input is not empty
if [[ -z "$input_json" ]]; then
echo "Error: No input received from stdin" >&2
exit 1
fi
# Parse JSON and extract message, capturing any jq errors
if ! notification_message=$(echo "$input_json" | jq -r '.message' 2>&1); then
echo "Error: Failed to parse JSON - $notification_message" >&2
exit 1
fi
# Verify notification_message is non-empty
if [[ -z "$notification_message" || "$notification_message" == "null" ]]; then
echo "Error: Notification message is empty or missing from JSON" >&2
exit 1
fi
# Send the notification and propagate any failures
if ! notify-send --icon="" --wait "Claude: $notification_message"; then
echo "Error: notify-send failed" >&2
exit 1
fi
This script is Linux-friendly out of the box because it relies on notify-send. If you use a headless environment, container, remote shell, or another operating system, you may need to replace it with a platform-specific notifier.
Warning: Unlike
session-start-check.sh, this script does not degrade gracefully. Missing dependencies, malformed JSON, or a failing notification daemon all cause it to exit non-zero.
Failure Behavior
Not all hooks fail the same way, and that difference is intentional.
| Component | Failure behavior |
|---|---|
rule-enforcer.py |
Fails open. Invalid JSON or internal errors do not block the tool call. |
git-protection.py |
Fails closed. If it cannot safely evaluate the git operation, it blocks it. |
rule-injector.py |
Fails open. Prompt submission continues even if the injector errors. |
session-start-check.sh |
Never blocks. It reports missing items and exits 0. |
my-notifier.sh |
Exits non-zero on dependency, input, or notification errors. |
How This Is Verified
The two Python guardrails are backed by unit tests, and the repository's tox configuration runs them through uv:
skipsdist = true
envlist = ["unittests"]
[env.unittests]
description = "Run pytest tests"
deps = ["uv"]
commands = [["uv", "run", "--group", "tests", "pytest", "tests"]]
The checked-in automation around these hooks is local tox/pytest and pre-commit configuration rather than a separate hook-specific pipeline file.
Those tests verify the behavior that matters most to users:
tests/test_rule_enforcer.pycovers case-insensitive command detection, theuv/uvxandprekescape hatches, and the script's fail-open behaviortests/test_git_protection.pycovers protected branches, merged-PR detection, merged-branch fallback logic,--amendhandling, detached HEAD behavior, false-positive avoidance in subcommand parsing, and fail-closed error handling
The repository also ships a .pre-commit-config.yaml, so local linting and type checking are part of the expected workflow alongside these hooks.
What To Do When a Hook Fires
- If
rule-enforcer.pyblocks a command, rerun it withuv,uvx, orprekrather than the raw tool. - If
git-protection.pyblocks a commit or push, move the work onto a feature branch. If the message mentions GitHub lookup errors, fixghauthentication or connectivity first. - If
session-start-check.shreports missing items, install critical tools and plugins before leaning on the full workflow. - If
my-notifier.shfails, installjqandnotify-sendor replace the notifier with something that fits your OS. - If the Bash destruction gate asks for confirmation, treat that as a real safety checkpoint, not a nuisance prompt.
This setup is opinionated on purpose: it prefers safe defaults, explicit recovery steps, and clear feedback over silent failures or risky convenience.