Read OSS

Hooks in Practice — Security Monitoring, the Hookify Rule Engine, and the Ralph Loop

Advanced

Prerequisites

  • Article 2: Plugin System Deep Dive (hook basics, event types, exit codes)
  • Basic Python knowledge
  • Basic shell scripting

Hooks in Practice — Security Monitoring, the Hookify Rule Engine, and the Ralph Loop

Part 2 introduced hooks as event-driven interceptors with an exit code protocol. But the difference between understanding the protocol and building something useful with it is vast. This article examines three production hook implementations that span the complexity spectrum — from a 15-line shell script to a multi-module Python rule engine — and extracts the design patterns you'll need to build your own.

Security Guidance: Pattern Monitoring with Deduplication

The security-guidance plugin is the most feature-complete single-file hook in the repository. At plugins/security-guidance/hooks/security_reminder_hook.py, it monitors nine security antipatterns and blocks tool execution with a warning on first encounter per session.

The patterns are defined as a list of dictionaries starting at line 31:

plugins/security-guidance/hooks/security_reminder_hook.py#L31-L126

Pattern Detection Method What It Catches
github_actions_workflow Path check (.github/workflows/) Command injection in workflow files
child_process_exec Substring (exec(, execSync() Shell injection via child_process
new_function_injection Substring (new Function) Code injection via Function constructor
eval_injection Substring (eval() Arbitrary code execution
react_dangerously_set_html Substring XSS via React
document_write_xss Substring XSS via document.write
innerHTML_xss Substring (.innerHTML =) XSS via innerHTML
pickle_deserialization Substring (pickle) Arbitrary code execution via pickle
os_system_injection Substring (os.system) Shell injection in Python

Two detection methods coexist: path-based (the GitHub Actions pattern checks the file path) and content-based (all others check the new content being written). The check_patterns function at line 183 tries path-based first, then content-based:

flowchart TD
    INPUT["Hook receives JSON via stdin"] --> PARSE["Parse tool_name, tool_input"]
    PARSE --> RELEVANT{"Tool is<br/>Edit/Write/MultiEdit?"}
    RELEVANT -->|No| ALLOW["exit 0 (allow)"]
    RELEVANT -->|Yes| EXTRACT["Extract file_path + content"]
    EXTRACT --> CHECK["Check against 9 patterns"]
    CHECK --> MATCH{"Pattern<br/>matched?"}
    MATCH -->|No| ALLOW
    MATCH -->|Yes| DEDUP{"Already warned<br/>this session?"}
    DEDUP -->|Yes| ALLOW
    DEDUP -->|No| SAVE["Save to state file"]
    SAVE --> BLOCK["Print warning to stderr<br/>exit 2 (BLOCK)"]

    style BLOCK fill:#E53935,color:#fff
    style ALLOW fill:#4CAF50,color:#fff

The deduplication system is elegant. Each session gets its own state file at ~/.claude/security_warnings_state_{session_id}.json, containing a set of {file_path}-{rule_name} keys. The first time a pattern fires for a given file, it blocks with exit 2. Subsequent matches for the same file+rule combination silently allow the operation:

plugins/security-guidance/hooks/security_reminder_hook.py#L258-L276

The probabilistic cleanup is my favorite detail. State files accumulate across sessions — one per session ID. Rather than implementing a deterministic cleanup schedule, the hook rolls a 10% dice on every invocation:

plugins/security-guidance/hooks/security_reminder_hook.py#L226-L228

# Periodically clean up old state files (10% chance per run)
if random.random() < 0.1:
    cleanup_old_state_files()

The cleanup function removes state files older than 30 days. At a 10% trigger rate with multiple hook invocations per session, this ensures cleanup happens regularly without adding latency to every hook call. It's a pattern worth stealing for any hook that maintains persistent state.

The ENABLE_SECURITY_REMINDER environment variable provides an escape hatch — set it to \"0\" to disable the hook entirely without uninstalling the plugin.

Hookify: A Configurable Rule Engine

If security-guidance is a single-purpose hook, hookify is a framework for building hooks. It implements a three-layer architecture: a config loader that parses markdown rule files, a rule evaluation engine with six operators, and entry-point scripts for each hook event.

classDiagram
    class ConfigLoader {
        +extract_frontmatter(content) tuple
        +load_rules(event) List~Rule~
        +load_rule_file(path) Rule
    }

    class Rule {
        +name: str
        +enabled: bool
        +event: str
        +conditions: List~Condition~
        +action: str
        +tool_matcher: str
        +message: str
        +from_dict(frontmatter, message) Rule
    }

    class Condition {
        +field: str
        +operator: str
        +pattern: str
        +from_dict(data) Condition
    }

    class RuleEngine {
        +evaluate_rules(rules, input_data) dict
        -_rule_matches(rule, input_data) bool
        -_check_condition(condition, ...) bool
        -_extract_field(field, ...) str
        -_regex_match(pattern, text) bool
    }

    class PreToolUseHook {
        +main()
    }

    ConfigLoader --> Rule : creates
    Rule --> Condition : contains
    RuleEngine --> Rule : evaluates
    PreToolUseHook --> ConfigLoader : uses
    PreToolUseHook --> RuleEngine : uses

The Custom YAML Parser

Hookify includes its own YAML frontmatter parser rather than depending on PyYAML. This is a deliberate design choice — hooks execute in diverse environments, and external dependencies are a reliability risk. The parser at plugins/hookify/core/config_loader.py#L87-L195 handles the subset of YAML that rule files actually use: simple key-value pairs, lists, and nested dictionaries within lists.

Rule files live in .claude/hookify.*.local.md — note the .local.md suffix, which means they're gitignored by convention and session-local. The frontmatter defines the rule structure:

---
name: dangerous-rm
enabled: true
event: bash
conditions:
  - field: command, operator: regex_match, pattern: "rm\s+-rf"
action: block
---

⚠️ Dangerous command detected! This rm -rf could delete important files.

The Rule Engine

The evaluation engine at plugins/hookify/core/rule_engine.py supports six operators:

Operator Behavior
regex_match Regex search against field value
contains Substring check
equals Exact match
not_contains Inverted substring check
starts_with Prefix check
ends_with Suffix check

The regex compilation uses @lru_cache(maxsize=128) to avoid recompiling patterns on repeated hook invocations:

plugins/hookify/core/rule_engine.py#L14-L24

@lru_cache(maxsize=128)
def compile_regex(pattern: str) -> re.Pattern:
    return re.compile(pattern, re.IGNORECASE)

When rules match, the engine produces different output formats depending on the hook event type. For PreToolUse, blocking rules set "permissionDecision": "deny". For Stop events, they set "decision": "block". Warning rules only include a systemMessage:

plugins/hookify/core/rule_engine.py#L60-L94

Fail-Open Design

The most important architectural decision in hookify is its fail-open philosophy. The entry point at plugins/hookify/hooks/pretooluse.py wraps the entire main function in a try/except that always exits 0:

try:
    from hookify.core.config_loader import load_rules
    from hookify.core.rule_engine import RuleEngine
except ImportError as e:
    error_msg = {"systemMessage": f"Hookify import error: {e}"}
    print(json.dumps(error_msg), file=sys.stdout)
    sys.exit(0)

And in the main function:

except Exception as e:
    error_output = {
        "systemMessage": f"Hookify error: {str(e)}"
    }
    print(json.dumps(error_output), file=sys.stdout)
finally:
    # ALWAYS exit 0 - never block operations due to hook errors
    sys.exit(0)

The comment says it all: never block operations due to hook errors. A broken rule file shouldn't prevent a developer from using Claude Code. Errors are reported via systemMessage but never via exit code 2. This is the correct default for productivity-focused hooks — security hooks may want the opposite behavior.

Tip: When building hooks, decide your failure mode upfront. Productivity hooks should fail open (exit 0 on errors). Security hooks should fail closed (exit 2 on errors). Hookify chose fail-open because blocking a developer's workflow due to a YAML typo is worse than missing a warning.

The Simplest Hook: Session Context Injection

To contrast the complexity of security-guidance and hookify, here's the entire explanatory-output-style hook — 15 lines:

plugins/explanatory-output-style/hooks-handlers/session-start.sh#L1-L15

#!/usr/bin/env bash

cat << 'EOF'
{
  "hookSpecificOutput": {
    "hookEventName": "SessionStart",
    "additionalContext": "You are in 'explanatory' output style mode..."
  }
}
EOF

exit 0

That's it. A cat heredoc that outputs JSON with an additionalContext field, followed by exit 0. The additionalContext gets injected into the session, making Claude behave in "explanatory mode" — providing educational insights as it works.

Its registration at plugins/explanatory-output-style/hooks/hooks.json is equally minimal — a single SessionStart hook entry.

This is an important lesson: hooks don't need to be complex. The simplest useful hook is a shell script that prints JSON and exits 0. Start here, and add complexity only when the use case demands it.

Hook Output Formats and Exit Code Protocol

Let's consolidate the hook communication protocol into a reference table. The output format varies by event type:

Event Key Output Fields Exit 0 Means Exit 2 Means
PreToolUse permissionDecision (allow/deny/ask), updatedInput, systemMessage Allow tool Block tool
PostToolUse systemMessage Stdout shown in transcript Stderr fed to Claude
Stop decision (approve/block), reason, systemMessage Allow exit Block exit, re-inject reason
SubagentStop Same as Stop Allow subagent exit Block subagent exit
SessionStart hookSpecificOutput.additionalContext Context injected Error
UserPromptSubmit systemMessage Allow prompt Block prompt

All hooks receive JSON via stdin with common fields: session_id, transcript_path, cwd, permission_mode, hook_event_name. Event-specific fields include tool_name and tool_input for tool events, user_prompt for UserPromptSubmit, and reason for Stop/SubagentStop.

The bash_command_validator example at examples/hooks/bash_command_validator_example.py#L56-L83 is the cleanest reference implementation for the exit code protocol:

issues = _validate_command(command)
if issues:
    for message in issues:
        print(f"• {message}", file=sys.stderr)
    # Exit code 2 blocks tool call and shows stderr to Claude
    sys.exit(2)

Three exit codes, three meanings: 0 is success/allow, 2 is block (stderr shown to Claude), and anything else is a non-blocking error (stderr shown to user but not Claude). This three-way protocol gives hooks precise control over how failures are communicated.

flowchart LR
    HOOK["Hook Executes"] --> E0{"Exit 0"}
    HOOK --> E2{"Exit 2"}
    HOOK --> E1{"Exit 1/other"}

    E0 --> ALLOW["Allow operation<br/>stdout → transcript"]
    E2 --> BLOCK["Block operation<br/>stderr → Claude"]
    E1 --> WARN["Non-blocking error<br/>stderr → user only"]

    style ALLOW fill:#4CAF50,color:#fff
    style BLOCK fill:#E53935,color:#fff
    style WARN fill:#FF9800,color:#fff

What's Next

We've now covered plugins from architecture to implementation. In Part 5, we'll shift to the automation side of the repository — the 12 GitHub Actions workflows, TypeScript lifecycle scripts, and security sandboxing that manage thousands of issues using Claude Code itself. We'll trace an issue from creation through AI-powered triage and deduplication, staleness sweeping, and auto-closing, and examine the three-layer security model that makes running AI agents on GitHub safe.