Building Guardrails for AI Coding Assistants: A PreToolUse Hook System for Claude Code
Source: Dev.to
AI Coding Assistants vs. Project Rules
AI coding assistants implement code quickly but follow their training defaults, not your project rules. They don’t know that your team:
- bans
git reset --hard - requires GPG‑signed commits
- uses a different GitHub org than the user’s username
Claude Code’s PreToolUse hook system intercepts tool calls before execution, blocks dangerous operations, and injects contextual guidance. This article walks through a production implementation consisting of four specialized hooks.
Version Note
The additionalContext feature used in this article requires Claude Code v2.1.9 (released January 16 2026) or later. Earlier versions supported blocking hooks but not context injection. This capability originated from feature request #15345.
The Hook Contract
A PreToolUse hook is an executable that receives JSON on stdin and communicates via exit codes and stdout/stderr:
{
"tool_name": "Bash",
"tool_input": { "command": "git reset --hard HEAD~3" }
}
| Exit code | Meaning | Output |
|---|---|---|
0 | Allow – stdout is parsed for additionalContext | See “allow with context” format below |
2 | Block – stderr is displayed to the user | – |
Allow‑with‑Context Output Format
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"additionalContext": "Your reminder or context here"
}
}
Hook 1 – Safety Guards
The first hook blocks destructive operations before they execute.
BLOCKING_RULES: list[SafetyRule] = [
SafetyRule(
pattern=r"git\s+reset\s+--hard",
action="block",
message=(
"BLOCKED: Use `git stash` or `git rebase` instead. "
"`git reset --hard` destroys uncommitted work."
),
),
SafetyRule(
pattern=r"--no-gpg-sign",
action="block",
message="BLOCKED: GPG signing is mandatory. Fix GPG issues, don't bypass.",
),
SafetyRule(
pattern=r"git\s+(?:add\s+)?(?:-A|--all|\.\s*$)",
action="block",
message=(
"BLOCKED: Explicitly stage files to avoid committing unwanted files."
),
),
]
Why?
When asked to commit changes, Claude often runsgit add -Ato stage everything. In repos with build artifacts or.envfiles, this stages files that should never be committed. Forcing explicit staging ensures intentional commits.
Warning Rules (allow with context)
WARNING_RULES: list[SafetyRule] = [
SafetyRule(
pattern=r"git\s+push\s+--force(?!\s*-with-lease)",
action="warn",
message="WARNING: Use `--force-with-lease` for safer force push.",
),
SafetyRule(
pattern=r"\.env",
action="warn",
message="WARNING: This file may contain secrets. Never commit .env files.",
),
]
Decision Logic
def check_command(command: str) -> tuple[str, str]:
# Check blocking rules first
for rule in BLOCKING_RULES:
if rule.matches(command):
return ("block", rule.message)
# Then warning rules
for rule in WARNING_RULES:
if rule.matches(command):
return ("warn", rule.message)
# Default: allow
return ("allow", "")
Hook 2 – Context Injection for GitHub API Calls
Repository paths do not always match GitHub organization names.
- Local checkout:
~/dev/AcmeCorp/webapp - GitHub owner:
AcmeCorp(not the userjdoe).
Without intervention, Claude defaults to the wrong owner for API calls. This hook injects repository metadata whenever Claude uses GitHub MCP tools.
GITHUB_BASE_CONTEXT = """CONTEXT: This repo's GitHub owner is 'AcmeCorp', NOT 'jdoe'.
Always use owner='AcmeCorp' in GitHub API calls.
Repository name: webapp
Default branch: main"""
PROJECT_BOARD_CONTEXT = """
Project board ID: PVT_xxxxxxxxxxxxxxxxxxxx
Epic Priority field ID: PVTF_xxxxxxxxxxxxxxxxxxxxxxx
Status field ID: PVTSSF_xxxxxxxxxxxxxxxxxxxxxxx
Note: gh project item-edit silently fails for Number fields. Use GraphQL mutations instead."""
Hook Matcher
{
"matcher": "mcp__github__.*",
"hooks": [
{ "type": "command", "command": "python3 /path/to/context-injection.py" }
]
}
Project‑related tools receive the additional board‑ID context as well.
Hook 3 – Workflow Rule Enforcement
Some workflow rules cannot be enforced by linters (e.g., TDD discipline, worktree usage, commit signing). This hook surfaces reminders at relevant moments without blocking operations.
GPG_REMINDER = WorkflowReminder(
message="GPG REMINDER: Never use `--no-gpg-sign`. If GPG fails, check sandbox mode."
)
TDD_REMINDER = WorkflowReminder(
message=(
"TDD REMINDER: Is there a failing test? Write the RED test first, "
"then write minimal code to make it GREEN."
)
)
WORKTREE_REMINDER = WorkflowReminder(
message=(
"WORKTREE REMINDER: You appear to be working directly on main. "
"Use `git worktree add .worktrees/issue-XX-description -b issue-XX-description`"
)
)
Reminder Injection Logic
def get_write_edit_reminders(file_path: str) -> list[str]:
reminders = []
# TDD reminder for Go production code
if is_go_production_code(file_path):
reminders.append(TDD_REMINDER.message)
# Worktree reminder if editing production code outside a worktree
if not is_in_worktree() and is_production_code_path(file_path):
reminders.append(WORKTREE_REMINDER.message)
return reminders
Hook 4 – (Optional) Custom Extensions
You can add further hooks for:
- Secret scanning – block or warn when a command references known secret patterns.
- Dependency checks – inject context about approved package versions before running
npm install. - CI/CD gating – require a successful local test run before allowing a
git push.
Each new hook follows the same contract: read JSON from stdin, decide via exit code, and optionally return additionalContext.
Summary
By layering these four PreToolUse hooks you get:
| Hook | Primary Goal | Outcome |
|---|---|---|
| Safety Guards | Block destructive commands, warn on risky patterns | Prevent data loss, surface best‑practice warnings |
| GitHub Context Injection | Align Claude’s API calls with actual repo metadata | Correct owner/board IDs, avoid mis‑targeted API calls |
| Workflow Rule Enforcement | Remind developers of team‑specific processes | Reinforce TDD, worktree usage, GPG signing without blocking |
| Custom Extensions | Tailor to additional project constraints | Flexible, future‑proof enforcement |
Implementing this hook chain lets Claude Code respect your organization’s policies while still providing the rapid assistance you expect from an AI coding assistant.
reminders.append(WORKTREE_REMINDER.message)
return reminders
Worktree Check
The worktree check uses an environment variable to detect the current directory:
def is_in_worktree() -> bool:
cwd = os.environ.get("PWD", os.getcwd())
return ".worktrees/" in cwd
Hook 4: Skill Suggestion Engine
Claude Code supports skills (reusable instruction sets for specific domains). The skill‑suggestion hook maps file patterns to relevant skills, prompting Claude to load domain‑specific guidance before proceeding.
SKILL_TRIGGERS: list[SkillTrigger] = [
# Test files need test classification + TDD patterns
SkillTrigger(
skills=("classifying-test-sizes", "developing-with-tdd"),
path_contains="_test.go",
),
# Temporal workflows need TDD + Temporal + Go monorepo skills
SkillTrigger(
skills=("developing-with-tdd", "developing-with-temporal", "working-with-go-monorepo"),
path_contains="apps/workers/",
extension=".go",
),
# Database migrations
SkillTrigger(
skills=("working-with-atlas-migrations",),
path_contains="db/migrations/",
),
]
The trigger list uses first‑match‑wins ordering. Specific patterns (test files, migrations) appear before generic patterns (any Go file in apps/). This ensures a _test.go file triggers test‑specific skills rather than generic Go guidance.
The output reminds Claude to invoke the relevant skills:
if skills:
skill_list = ", ".join(f"'{s}'" for s in skills)
output = {
"hookSpecificOutput": {
"additionalContext": (
f"SKILL REMINDER: Before proceeding, invoke these skills: {skill_list}. "
f"They contain patterns, gotchas, and best practices for this file type."
)
}
}
Hook Configuration
The settings.json file orchestrates which hooks run for which tools:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Read|Write|Edit",
"hooks": [{ "type": "command", "command": "python3 .../suggest-skills.py" }]
},
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "python3 .../safety-guards.py" },
{ "type": "command", "command": "python3 .../workflow-rules.py" }
]
},
{
"matcher": "Write|Edit",
"hooks": [{ "type": "command", "command": "python3 .../workflow-rules.py" }]
},
{
"matcher": "mcp__github__.*",
"hooks": [{ "type": "command", "command": "python3 .../context-injection.py" }]
}
]
}
}
- The
matcherfield accepts regular‑expression patterns. - Bash commands receive both safety guards and workflow rules.
- Write/Edit operations trigger workflow rules and skill suggestions.
- GitHub MCP tools receive context injection.
Observations
In my workflow, the hooks have changed how Claude interacts with the repository:
- Claude no longer attempts
git reset --hard. The safety hook blocks it and suggests alternatives. - GitHub API calls default to the correct owner. Before the context hook, I corrected the owner manually in roughly one of five API calls.
- The TDD reminder appears on every Go file edit. I cannot quantify compliance, but the reminder makes skipping the red‑test step feel deliberate rather than accidental.
- Skill loading happens automatically when I edit workflow files, instead of requiring me to remember to invoke them.
Implementation Considerations
- Hooks cannot modify tool inputs; they can only allow, block, or inject context.
- Complex validation requiring multiple file reads will slow down every tool call.
- Hooks see individual tool calls, not the broader conversation context.
- Error handling defaults to permissive: JSON‑parse failures result in exit code 0 (allow) rather than blocking operations. This prevents a malformed hook from breaking the development workflow entirely.
The complete implementation consists of four Python files totaling under 400 lines. Each hook has a single responsibility, making them straightforward to test and modify. Adding new concerns means adding new hooks, keeping each hook focused.