Skip to content

Commit d44d6b0

Browse files
authored
Merge pull request #152 from DataFog/feat/claude-code-hook
feat: add Claude Code hook adapter (datafog-hook)
2 parents 2119918 + 8402d9e commit d44d6b0

5 files changed

Lines changed: 506 additions & 0 deletions

File tree

datafog/integrations/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Adapters that embed DataFog into agent harnesses and pipelines."""
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
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()
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
# DataFog PII Firewall for Claude Code
2+
3+
Stop PII from leaving your machine through agent tool calls. This hook scans
4+
every outbound tool invocation (shell commands, web requests, file writes,
5+
MCP tools) in ~70ms and asks for confirmation — or blocks outright — when it
6+
finds emails, phone numbers, credit cards, or SSNs.
7+
8+
## Install
9+
10+
```bash
11+
pip install datafog
12+
```
13+
14+
Then add the hook to `~/.claude/settings.json` (all projects) or
15+
`.claude/settings.json` (one project):
16+
17+
```json
18+
{
19+
"hooks": {
20+
"PreToolUse": [
21+
{
22+
"matcher": "Bash|WebFetch|WebSearch|Write|Edit|mcp__.*",
23+
"hooks": [
24+
{ "type": "command", "command": "datafog-hook", "timeout": 10 }
25+
]
26+
}
27+
],
28+
"UserPromptSubmit": [
29+
{
30+
"hooks": [
31+
{ "type": "command", "command": "datafog-hook", "timeout": 10 }
32+
]
33+
}
34+
],
35+
"PostToolUse": [
36+
{
37+
"matcher": "Read|Bash|WebFetch|mcp__.*",
38+
"hooks": [
39+
{ "type": "command", "command": "datafog-hook", "timeout": 10 }
40+
]
41+
}
42+
]
43+
}
44+
}
45+
```
46+
47+
That's it. Try asking Claude to `curl` something containing a test credit
48+
card number — the call is intercepted before it runs:
49+
50+
> DataFog PII firewall: Bash input contains CREDIT_CARD x1, EMAIL x1.
51+
> Redact or tokenize these values before sending them anywhere.
52+
53+
## What each hook does
54+
55+
| Event | Behavior |
56+
| ------------------ | ---------------------------------------------------------------------------------------------------------------- |
57+
| `PreToolUse` | Gates the tool call. Default `ask` shows you what was found; `deny` blocks and tells Claude to redact and retry. |
58+
| `UserPromptSubmit` | Non-blocking. Warns Claude your prompt contains PII so it avoids repeating it into files, code, or logs. |
59+
| `PostToolUse` | Non-blocking. Warns when a tool result (file read, API response) carries PII into the conversation. |
60+
61+
## Configuration
62+
63+
Environment variables (set in `settings.json` `env` or your shell):
64+
65+
- `DATAFOG_HOOK_ACTION``ask` (default) or `deny` for PreToolUse.
66+
**Important:** `ask` defers to your permission mode — in
67+
`--dangerously-skip-permissions` or auto-accept sessions, the ask is
68+
silently approved and nothing is intercepted. If you run with permissions
69+
relaxed (exactly when you most need a firewall), use `deny`:
70+
71+
```json
72+
{
73+
"type": "command",
74+
"command": "DATAFOG_HOOK_ACTION=deny datafog-hook",
75+
"timeout": 10
76+
}
77+
```
78+
79+
In `deny` mode the tool call is hard-blocked before it executes, the
80+
model is told what was found, and it self-corrects by redacting.
81+
82+
- `DATAFOG_HOOK_ENTITIES` — comma-separated entity types. Default:
83+
`EMAIL,PHONE,CREDIT_CARD,SSN`. Noisier types (`IP_ADDRESS`, `DOB`, `ZIP`)
84+
are available but opt-in — version strings, dates, and 5-digit numbers are
85+
everywhere in coding sessions.
86+
87+
## What this actually protects against
88+
89+
The realistic risk in agent sessions is rarely "the user asks for a
90+
PII-laden network call." It's **second-order leakage**: you paste a real
91+
stack trace or customer record while debugging, and forty turns later the
92+
agent helpfully hardcodes that email into a committed test fixture, a
93+
GitHub issue, or a Slack message. The data crossed a boundary and nobody
94+
asked it to.
95+
96+
That's what the `Write|Edit|Bash|mcp__.*` gates cover: the moment PII is
97+
**re-emitted** into a file, command, or external tool, it appears in the
98+
tool input and the firewall fires — before the write, before the network
99+
call.
100+
101+
What this does _not_ cover: PII you hand the agent directly (a bank
102+
statement, a log file). By the time anything local can scan it, it is
103+
already in the session context, already sent to the model API, and already
104+
in your local transcript files. The hook warns the model so it avoids
105+
repeating those values, but the inbound event itself is not preventable at
106+
the hook layer — redact _before_ sharing (`datafog redact` on a copy) if
107+
the model provider must not see the data.
108+
109+
## Limitations
110+
111+
Be honest with yourself about what a regex gate at the tool boundary can do:
112+
113+
- **It sees tool-input text, nothing else.** `curl -d @file.txt`, an env
114+
var expansion, string concatenation, or base64 all bypass the gate —
115+
the PII never appears in the command string. This is a seatbelt against
116+
accidental leakage, not armor against deliberate exfiltration or prompt
117+
injection.
118+
- **Inbound PII is warned about, not blocked** (see above).
119+
- **Images and PDFs are not scanned.** A bank statement PDF often reaches
120+
the model as page images; regex sees nothing.
121+
- **Regex precision is imperfect.** Defaults are tuned high-precision
122+
(checksummed/structured types on; dates, ZIPs, and IPs off), but false
123+
positives and negatives happen. Validators and confidence scoring are on
124+
the roadmap.
125+
- **Fail-open by design.** A hook failure means that call went unscanned
126+
rather than your session breaking.
127+
128+
## Design notes
129+
130+
- **Offline.** DataFog's core makes zero network calls and has one
131+
dependency (pydantic). Nothing about your session leaves your machine.
132+
- **Fast.** ~70ms per invocation including process startup; the scan itself
133+
is microseconds.
134+
- **Fail-open.** A bug in the hook exits non-blocking — it will never brick
135+
your Claude Code session. The flip side: a hook failure means that call
136+
went unscanned, so treat this as a seatbelt, not a guarantee.
137+
- **Bounded scanning.** Each string is scanned up to 1MB (8MB per payload
138+
total). PII hidden beyond those caps in a single enormous field is missed
139+
by design — the hook must stay fast enough to run on every tool call.
140+
- **No PII in output.** Findings are reported as type counts
141+
(`EMAIL x2`), never as the matched values — hook output itself lands in
142+
transcripts.

setup.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@
120120
entry_points={
121121
"console_scripts": [
122122
"datafog=datafog.client:app [cli]", # Requires cli extra
123+
"datafog-hook=datafog.integrations.claude_code:main", # Core only
123124
],
124125
},
125126
classifiers=[

0 commit comments

Comments
 (0)