Hooks in Practice — Security Monitoring, the Hookify Rule Engine, and the Ralph Loop
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.