Building Guardrails for AI Coding Assistants: A PreToolUse Hook System for Claude Code

Published: (January 18, 2026 at 05:09 PM EST)
6 min read
Source: Dev.to

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 codeMeaningOutput
0Allowstdout is parsed for additionalContextSee “allow with context” format below
2Blockstderr 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 runs git add -A to stage everything. In repos with build artifacts or .env files, 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 user jdoe).

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:

HookPrimary GoalOutcome
Safety GuardsBlock destructive commands, warn on risky patternsPrevent data loss, surface best‑practice warnings
GitHub Context InjectionAlign Claude’s API calls with actual repo metadataCorrect owner/board IDs, avoid mis‑targeted API calls
Workflow Rule EnforcementRemind developers of team‑specific processesReinforce TDD, worktree usage, GPG signing without blocking
Custom ExtensionsTailor to additional project constraintsFlexible, 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 matcher field 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.

Back to Blog

Related posts

Read more »

Rapg: TUI-based Secret Manager

We've all been there. You join a new project, and the first thing you hear is: > 'Check the pinned message in Slack for the .env file.' Or you have several .env...

Technology is an Enabler, not a Saviour

Why clarity of thinking matters more than the tools you use Technology is often treated as a magic switch—flip it on, and everything improves. New software, pl...