|
| 1 | +"""Claude Code hook adapter: an offline PII firewall for agent tool calls. |
| 2 | +
|
| 3 | +Speaks the Claude Code hooks protocol (JSON on stdin, JSON on stdout): |
| 4 | +
|
| 5 | +- ``PreToolUse`` — gate outbound tool calls (Bash, WebFetch, Write, MCP |
| 6 | + tools). PII in the tool input yields an ``ask`` (default) or ``deny`` |
| 7 | + permission decision, so data is stopped *before* it leaves the machine. |
| 8 | +- ``UserPromptSubmit`` — non-blocking: warns the model that the prompt |
| 9 | + contains PII so it avoids repeating it in output or logs. |
| 10 | +- ``PostToolUse`` — non-blocking: warns when a tool result carries PII |
| 11 | + into the conversation context. |
| 12 | +
|
| 13 | +Configuration (environment variables): |
| 14 | +
|
| 15 | +- ``DATAFOG_HOOK_ACTION``: ``ask`` (default) or ``deny`` for PreToolUse. |
| 16 | +- ``DATAFOG_HOOK_ENTITIES``: comma-separated entity types to detect. |
| 17 | + Defaults to the high-precision set; noisy-in-code types (IP_ADDRESS, |
| 18 | + DOB, ZIP) must be opted into. |
| 19 | +
|
| 20 | +Failure policy: fail open. A hook bug must never brick a Claude Code |
| 21 | +session, so any unexpected error exits non-blocking with no output. |
| 22 | +""" |
| 23 | + |
| 24 | +import json |
| 25 | +import os |
| 26 | +import sys |
| 27 | +from typing import Any, Iterator, Mapping |
| 28 | + |
| 29 | +# High-precision defaults. IP_ADDRESS, DOB, and ZIP are deliberately |
| 30 | +# excluded: version strings, dates, and 5-digit numbers saturate coding |
| 31 | +# sessions and would make the firewall cry wolf (see DFPY-110). |
| 32 | +DEFAULT_ENTITY_TYPES = ["EMAIL", "PHONE", "CREDIT_CARD", "SSN"] |
| 33 | + |
| 34 | +VALID_ACTIONS = {"ask", "deny"} |
| 35 | + |
| 36 | +# Per-string scan cap, so a huge file write can't stall the hook. Applied |
| 37 | +# per string (not shared across the payload) so a padding field can't starve |
| 38 | +# the scan of later fields; TOTAL_SCAN_CHARS bounds the worst case overall. |
| 39 | +MAX_SCAN_CHARS = 1_000_000 |
| 40 | +TOTAL_SCAN_CHARS = 8_000_000 |
| 41 | + |
| 42 | +_EXIT_OK = 0 |
| 43 | +# Exit 1 is Claude Code's non-blocking error: stderr is shown to the user, |
| 44 | +# the tool call proceeds. Never exit 2 (blocking) on our own failures. |
| 45 | +_EXIT_FAIL_OPEN = 1 |
| 46 | + |
| 47 | + |
| 48 | +def _entity_types(env: Mapping[str, str]) -> list[str]: |
| 49 | + raw = env.get("DATAFOG_HOOK_ENTITIES", "") |
| 50 | + parsed = [t.strip().upper() for t in raw.split(",") if t.strip()] |
| 51 | + # An empty parse (unset, or a value like " , ") must fall back to the |
| 52 | + # defaults: passing [] downstream would disable filtering entirely and |
| 53 | + # silently enable the noisy opt-in entity types. |
| 54 | + return parsed or DEFAULT_ENTITY_TYPES |
| 55 | + |
| 56 | + |
| 57 | +def _action(env: Mapping[str, str]) -> str: |
| 58 | + action = env.get("DATAFOG_HOOK_ACTION", "ask").strip().lower() |
| 59 | + return action if action in VALID_ACTIONS else "ask" |
| 60 | + |
| 61 | + |
| 62 | +def _iter_strings(value: Any) -> Iterator[str]: |
| 63 | + """Yield every string embedded in a JSON-like structure. |
| 64 | +
|
| 65 | + Iterative (explicit stack), so adversarially deep nesting cannot |
| 66 | + trigger ``RecursionError`` and silently skip the scan. |
| 67 | + """ |
| 68 | + stack = [value] |
| 69 | + while stack: |
| 70 | + current = stack.pop() |
| 71 | + if isinstance(current, str): |
| 72 | + yield current |
| 73 | + elif isinstance(current, dict): |
| 74 | + stack.extend(current.values()) |
| 75 | + elif isinstance(current, (list, tuple)): |
| 76 | + stack.extend(current) |
| 77 | + |
| 78 | + |
| 79 | +def _scan_findings(value: Any, entity_types: list[str]) -> dict[str, int]: |
| 80 | + """Scan all strings in ``value``; return counts per entity type.""" |
| 81 | + import datafog |
| 82 | + |
| 83 | + counts: dict[str, int] = {} |
| 84 | + total_budget = TOTAL_SCAN_CHARS |
| 85 | + for text in _iter_strings(value): |
| 86 | + if total_budget <= 0: |
| 87 | + break |
| 88 | + chunk = text[: min(MAX_SCAN_CHARS, total_budget)] |
| 89 | + total_budget -= len(chunk) |
| 90 | + result = datafog.scan(chunk, engine="regex", entity_types=entity_types) |
| 91 | + for entity in result.entities: |
| 92 | + counts[entity.type] = counts.get(entity.type, 0) + 1 |
| 93 | + return counts |
| 94 | + |
| 95 | + |
| 96 | +def _summary(counts: dict[str, int]) -> str: |
| 97 | + """Render findings without ever echoing the matched PII itself.""" |
| 98 | + parts = [f"{etype} x{n}" for etype, n in sorted(counts.items())] |
| 99 | + return ", ".join(parts) |
| 100 | + |
| 101 | + |
| 102 | +def _emit(event: str, fields: dict[str, Any]) -> str: |
| 103 | + return json.dumps({"hookSpecificOutput": {"hookEventName": event, **fields}}) |
| 104 | + |
| 105 | + |
| 106 | +def _handle_pre_tool_use(payload: dict, env: Mapping[str, str]) -> str: |
| 107 | + counts = _scan_findings(payload.get("tool_input"), _entity_types(env)) |
| 108 | + if not counts: |
| 109 | + return "" |
| 110 | + tool = payload.get("tool_name", "tool") |
| 111 | + reason = ( |
| 112 | + f"DataFog PII firewall: {tool} input contains {_summary(counts)}. " |
| 113 | + "Redact or tokenize these values before sending them anywhere." |
| 114 | + ) |
| 115 | + return _emit( |
| 116 | + "PreToolUse", |
| 117 | + {"permissionDecision": _action(env), "permissionDecisionReason": reason}, |
| 118 | + ) |
| 119 | + |
| 120 | + |
| 121 | +def _handle_user_prompt_submit(payload: dict, env: Mapping[str, str]) -> str: |
| 122 | + counts = _scan_findings(payload.get("prompt"), _entity_types(env)) |
| 123 | + if not counts: |
| 124 | + return "" |
| 125 | + context = ( |
| 126 | + f"DataFog PII firewall: the user's prompt contains {_summary(counts)}. " |
| 127 | + "Avoid repeating these values verbatim in responses, code, or files." |
| 128 | + ) |
| 129 | + return _emit("UserPromptSubmit", {"additionalContext": context}) |
| 130 | + |
| 131 | + |
| 132 | +def _handle_post_tool_use(payload: dict, env: Mapping[str, str]) -> str: |
| 133 | + counts = _scan_findings(payload.get("tool_response"), _entity_types(env)) |
| 134 | + if not counts: |
| 135 | + return "" |
| 136 | + tool = payload.get("tool_name", "tool") |
| 137 | + context = ( |
| 138 | + f"DataFog PII firewall: {tool} output contains {_summary(counts)}. " |
| 139 | + "Avoid repeating these values verbatim in responses, code, or files." |
| 140 | + ) |
| 141 | + return _emit("PostToolUse", {"additionalContext": context}) |
| 142 | + |
| 143 | + |
| 144 | +_HANDLERS = { |
| 145 | + "PreToolUse": _handle_pre_tool_use, |
| 146 | + "UserPromptSubmit": _handle_user_prompt_submit, |
| 147 | + "PostToolUse": _handle_post_tool_use, |
| 148 | +} |
| 149 | + |
| 150 | + |
| 151 | +def run(payload: dict, env: Mapping[str, str]) -> tuple[int, str]: |
| 152 | + """Process one hook payload; return (exit_code, stdout). Fails open.""" |
| 153 | + try: |
| 154 | + handler = _HANDLERS.get(payload.get("hook_event_name", "")) |
| 155 | + if handler is None: |
| 156 | + return _EXIT_OK, "" |
| 157 | + return _EXIT_OK, handler(payload, env) |
| 158 | + except Exception as exc: # noqa: BLE001 — fail open by design |
| 159 | + print(f"datafog-hook error (fail-open): {exc}", file=sys.stderr) |
| 160 | + return _EXIT_FAIL_OPEN, "" |
| 161 | + |
| 162 | + |
| 163 | +def main() -> None: |
| 164 | + """Console entry point: ``datafog-hook``.""" |
| 165 | + # Catch everything, including RecursionError from json.load on |
| 166 | + # adversarially nested payloads: the fail-open contract applies to the |
| 167 | + # entire process, not just the handler. |
| 168 | + try: |
| 169 | + payload = json.load(sys.stdin) |
| 170 | + if not isinstance(payload, dict): |
| 171 | + payload = {} |
| 172 | + except Exception as exc: # noqa: BLE001 — fail open by design |
| 173 | + print(f"datafog-hook: invalid hook payload (fail-open): {exc}", file=sys.stderr) |
| 174 | + sys.exit(_EXIT_FAIL_OPEN) |
| 175 | + |
| 176 | + code, stdout = run(payload, os.environ) |
| 177 | + if stdout: |
| 178 | + print(stdout) |
| 179 | + sys.exit(code) |
| 180 | + |
| 181 | + |
| 182 | +if __name__ == "__main__": |
| 183 | + main() |
0 commit comments