phi-gate 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
phi_gate/hooks.py ADDED
@@ -0,0 +1,644 @@
1
+ """Claude Code hook handlers.
2
+
3
+ Contract (see https://code.claude.com/docs/en/hooks):
4
+ - The hook receives a JSON payload on stdin.
5
+ - Exit 2 blocks. For PreToolUse the tool call is denied and stderr is shown
6
+ to Claude; for UserPromptSubmit the prompt is rejected before the model
7
+ sees it; for PostToolUse stderr is fed back to Claude.
8
+ - Exit 0 allows. A JSON object on stdout can attach a `systemMessage`.
9
+
10
+ Fail-closed: every error path here returns 2, an internal deadline returns 2
11
+ before Claude Code's own hook timeout (which would let the action through),
12
+ an event this module does not handle, or one that disagrees with the event
13
+ the hook was registered for, returns 2, and hooks/run.sh turns any other
14
+ non-zero exit (crash, missing interpreter) into 2 as well.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import os
21
+ import re
22
+ import signal
23
+ import subprocess
24
+ import sys
25
+ import tempfile
26
+ from dataclasses import dataclass
27
+ from pathlib import Path
28
+ from urllib.parse import unquote, urlparse
29
+
30
+ from . import audit, config, globs, shell
31
+ from .config import CONFIG_NAME, Config, ConfigError
32
+ from .policy import ALLOW, BLOCK, WARN, Verdict
33
+ from .scanner import MAX_DIR_FILES, FileResult, git_toplevel, scan_commit, scan_file, scan_text, walk
34
+
35
+ MAX_BASH_FILES = 200
36
+ MAX_TREE_FILES = 2 * MAX_DIR_FILES # files scanned across all directories one command reads
37
+ MAX_HISTORY_BLOBS = 500
38
+ MAX_HISTORY_BYTES = 64 * 1024 * 1024
39
+ MAX_REFERENCED_FILES = 50
40
+ DEFAULT_DEADLINE = 15 # seconds; hooks.json gives each hook 30
41
+ EDIT_TOOLS = {"Write", "Edit", "MultiEdit", "NotebookEdit"}
42
+ READ_TOOLS = {"Read", "NotebookRead"}
43
+ TOOL_EVENTS = {"PreToolUse", "PostToolUse"}
44
+ EVENTS = {"UserPromptSubmit", "PreToolUse", "PostToolUse"}
45
+ # Tools whose output repeats content the PreToolUse check already decided on.
46
+ POST_SKIP = EDIT_TOOLS | {"KillShell", "TodoWrite"}
47
+
48
+ REDACT_HINT = (
49
+ "Work with redacted or synthetic data instead: `phi-gate redact <file>` prints a copy with "
50
+ "typed placeholders. If this is synthetic test data, ask the user to add its path to "
51
+ f"allow_paths in {CONFIG_NAME} (agents cannot edit that file)."
52
+ )
53
+ PROTECTED_HINT = "phi-gate configuration, audit files and its command are human-managed. Ask the user to make this change themselves."
54
+
55
+
56
+ @dataclass
57
+ class Outcome:
58
+ code: int
59
+ stdout: str = ""
60
+ stderr: str = ""
61
+
62
+
63
+ @dataclass
64
+ class Decision:
65
+ """A PreToolUse decision. `reason` is a stable code for the audit log."""
66
+
67
+ decision: str
68
+ message: str = ""
69
+ verdict: Verdict | None = None
70
+ target: str | None = None
71
+ reason: str = ""
72
+
73
+
74
+ class Deadline(Exception):
75
+ pass
76
+
77
+
78
+ def _fail_open() -> bool:
79
+ """The explicit escape hatch for a broken install. A managed policy
80
+ switches it off unless the policy itself permits environment overrides."""
81
+ return os.environ.get("PHI_GATE_FAIL_OPEN") == "1" and config.env_overrides_permitted()
82
+
83
+
84
+ def _secret_block(cfg: Config, paths: list[Path], what: str) -> Decision | None:
85
+ """A block when any path is a secret phi-gate relies on (the audit key or
86
+ the salt that keys hashed audit targets)."""
87
+ hit = next((p for p in paths if config.is_secret(p, cfg)), None)
88
+ if hit is None:
89
+ return None
90
+ return Decision(BLOCK, f"phi-gate blocked {what} {cfg.relpath(hit)}: " + PROTECTED_HINT,
91
+ None, cfg.relpath(hit), "secret_read")
92
+
93
+
94
+ def _strings(obj) -> list[str]:
95
+ """String leaves of a JSON value, so multi-line content keeps its line
96
+ structure instead of being escaped by json.dumps."""
97
+ if isinstance(obj, str):
98
+ return [obj]
99
+ if isinstance(obj, dict):
100
+ return [s for v in obj.values() for s in _strings(v)]
101
+ if isinstance(obj, list):
102
+ return [s for v in obj for s in _strings(v)]
103
+ if isinstance(obj, (int, float)) and not isinstance(obj, bool):
104
+ return [str(obj)]
105
+ return []
106
+
107
+
108
+ def _text(value) -> str:
109
+ return "\n".join(_strings(value))
110
+
111
+
112
+ def _describe(verdict: Verdict, with_lines: bool = True) -> str:
113
+ parts = [verdict.summary()]
114
+ where = verdict.lines() if with_lines else ""
115
+ if where:
116
+ parts.append(where)
117
+ return "; ".join(parts)
118
+
119
+
120
+ def _why(r: FileResult) -> str:
121
+ return _describe(r.verdict) if r.verdict.findings else "; ".join(r.verdict.reasons)
122
+
123
+
124
+ def _abs(path: str, cwd: Path) -> Path:
125
+ p = Path(path).expanduser()
126
+ return p if p.is_absolute() else cwd / p
127
+
128
+
129
+ def _protected(cfg: Config, path: Path) -> bool:
130
+ """Configuration, audit files, and any file named like phi-gate's command
131
+ (a program by that name could otherwise pose as it)."""
132
+ return cfg.is_protected_path(path) or path.name.lower() == shell.ENTRY_NAME
133
+
134
+
135
+ def _file_block(r: FileResult, what: str) -> Decision:
136
+ return Decision(BLOCK, (
137
+ f"phi-gate blocked {what} {r.path}, which contains likely PHI ({_why(r)}). "
138
+ "Do not try to access, print, or copy this data another way. " + REDACT_HINT
139
+ ), r.verdict, r.path, "phi_in_file")
140
+
141
+
142
+ # --------------------------------------------------------------------------
143
+ # Bash
144
+ # --------------------------------------------------------------------------
145
+
146
+
147
+ def _scan_tree(cfg: Config, root: Path) -> tuple[FileResult | None, bool]:
148
+ """(first file with likely PHI under root, whether the whole tree was scanned)."""
149
+ listed = walk(root, MAX_DIR_FILES)
150
+ if listed is None:
151
+ return None, False
152
+ for p in listed:
153
+ if cfg.is_allowed_path(p):
154
+ continue
155
+ r = scan_file(p, cfg)
156
+ if r.blocking:
157
+ return r, True
158
+ return None, True
159
+
160
+
161
+ def _scan_blobs(cfg: Config, top: Path, blobs: list[shell.Blob]) -> FileResult | None:
162
+ """Scan the repository objects and working-tree files a git command would print."""
163
+ by_sha: dict[str, str] = {}
164
+ for b in blobs:
165
+ if cfg.is_allowed_path(top / b.path):
166
+ continue
167
+ if b.sha is None:
168
+ r = scan_file(top / b.path, cfg)
169
+ if r.blocking:
170
+ return r
171
+ else:
172
+ by_sha.setdefault(b.sha, b.path)
173
+ if len(by_sha) > MAX_HISTORY_BLOBS:
174
+ raise shell.Undetermined(f"it would print more than {MAX_HISTORY_BLOBS} files")
175
+ if not by_sha:
176
+ return None
177
+ shas = list(by_sha)
178
+ sizes: dict[str, int] = {}
179
+ check = subprocess.run(["git", "cat-file", "--batch-check"], cwd=top, input="\n".join(shas) + "\n",
180
+ capture_output=True, text=True, timeout=shell.GIT_TIMEOUT)
181
+ for line in check.stdout.splitlines():
182
+ parts = line.split()
183
+ if len(parts) == 3 and parts[1] == "blob":
184
+ sizes[parts[0]] = int(parts[2])
185
+ fetch: list[str] = []
186
+ for sha in shas:
187
+ rel = cfg.relpath(top / by_sha[sha])
188
+ if sha not in sizes:
189
+ continue # not a blob, or not present locally
190
+ if sizes[sha] > cfg.max_bytes and cfg.oversize == "block":
191
+ reason = f"file in git history is {sizes[sha]} bytes, over max_bytes={cfg.max_bytes}; not scanned"
192
+ return FileResult(rel, Verdict(BLOCK, [], [reason]), "oversize")
193
+ fetch.append(sha)
194
+ if sum(min(sizes[s], cfg.max_bytes) for s in fetch) > MAX_HISTORY_BYTES:
195
+ raise shell.Undetermined("the files it would print are too large to check in time")
196
+ for sha in fetch:
197
+ proc = subprocess.run(["git", "cat-file", "blob", sha], cwd=top, capture_output=True, timeout=shell.GIT_TIMEOUT)
198
+ if proc.returncode != 0:
199
+ raise shell.Undetermined("git could not read an object it would print")
200
+ with tempfile.TemporaryDirectory(prefix="phi-gate-") as tmp:
201
+ copy = Path(tmp) / (Path(by_sha[sha]).name or "blob")
202
+ copy.write_bytes(proc.stdout[: cfg.max_bytes])
203
+ r = scan_file(copy, cfg)
204
+ r.path = f"{cfg.relpath(top / by_sha[sha])} (git object {sha[:10]})"
205
+ if r.blocking:
206
+ return r
207
+ return None
208
+
209
+
210
+ def _bash(command: str, cfg: Config, cwd: Path) -> Decision:
211
+ a = shell.analyze(command, cwd)
212
+
213
+ hit = next((p for p in a.writes if cfg.protect_config and cfg.is_protected_path(p)), None)
214
+ if hit is None:
215
+ hit = next((p for p in a.created if p.name.lower() == shell.ENTRY_NAME), None)
216
+ if hit is not None:
217
+ return Decision(BLOCK, f"phi-gate blocked a shell command that modifies {cfg.relpath(hit)}: " + PROTECTED_HINT,
218
+ None, cfg.relpath(hit), "config_protected")
219
+
220
+ if a.disables_hooks:
221
+ return Decision(BLOCK, "phi-gate blocked a command that changes core.hooksPath (it can disable commit checks).",
222
+ reason="hook_bypass_hooks_path")
223
+
224
+ if a.shows_staged and not a.commits:
225
+ bad = [r for r in scan_commit(cfg, cwd, broad=False) if r.blocking]
226
+ if bad:
227
+ return Decision(BLOCK, (
228
+ f"phi-gate blocked a command that prints staged changes: {bad[0].path} contains likely PHI "
229
+ f"({_why(bad[0])}). " + REDACT_HINT
230
+ ), bad[0].verdict, bad[0].path, "phi_in_staged_changes")
231
+
232
+ if a.commits:
233
+ if any(c.bypass for c in a.commits):
234
+ return Decision(BLOCK, (
235
+ "phi-gate blocked `git commit --no-verify`: skipping hooks would skip the PHI check on this "
236
+ "commit. Commit without --no-verify."
237
+ ), reason="hook_bypass_no_verify")
238
+ broad = a.stages_changes or any(c.include_all for c in a.commits)
239
+ bad = [r for r in scan_commit(cfg, cwd, broad) if r.blocking]
240
+ if bad:
241
+ detail = "; ".join(f"{r.path} ({_why(r)})" for r in bad[:5])
242
+ return Decision(BLOCK, (
243
+ f"phi-gate blocked this commit: the changes it would include contain likely PHI in {detail}. "
244
+ "Remove or replace those values (or unstage those files) before committing. " + REDACT_HINT
245
+ ), bad[0].verdict, bad[0].path, "phi_in_commit")
246
+
247
+ v = scan_text(command, cfg)
248
+ if v.blocking:
249
+ return Decision(BLOCK, (
250
+ f"phi-gate blocked a shell command that contains likely PHI ({_describe(v, with_lines=False)}). "
251
+ "Do not put identifiers in commands, URLs, or request bodies."
252
+ ), v, None, "phi_in_command")
253
+
254
+ files = list(a.read_files)
255
+ secret = _secret_block(cfg, files + list(a.read_dirs), "a shell command that reads")
256
+ if secret is not None:
257
+ return secret
258
+ root = (git_toplevel(cwd) or cfg.root).resolve() if a.moves else cfg.root
259
+ for move in a.moves:
260
+ dest = move.dest.resolve() if move.dest.exists() else move.dest.parent.resolve() / move.dest.name
261
+ if dest != root and root not in dest.parents: # moved out of the project: a copy elsewhere
262
+ for src in move.sources:
263
+ (files if src.is_file() else a.read_dirs).append(src)
264
+ if len(files) > MAX_BASH_FILES:
265
+ return Decision(BLOCK, (
266
+ f"phi-gate blocked a shell command that reads more than {MAX_BASH_FILES} files; it cannot check "
267
+ "them all before the command runs. Narrow the command to specific paths."
268
+ ), reason="too_many_files")
269
+
270
+ unscanned: list[Path] = []
271
+ tree_files: list[Path] = []
272
+ for d in a.read_dirs:
273
+ if cfg.is_allowed_path(d):
274
+ continue
275
+ listed = walk(d, MAX_DIR_FILES)
276
+ if listed is None or len(tree_files) + len(listed) > MAX_TREE_FILES:
277
+ unscanned.append(d)
278
+ continue
279
+ tree_files += listed
280
+ secret = _secret_block(cfg, tree_files, "a shell command that reads")
281
+ if secret is not None:
282
+ return secret
283
+ for p in files + tree_files:
284
+ r = scan_file(p, cfg)
285
+ if r.blocking:
286
+ return _file_block(r, "a shell command that reads")
287
+
288
+ egress = ", ".join(dict.fromkeys(a.egress))
289
+ if unscanned and egress:
290
+ return Decision(BLOCK, (
291
+ f"phi-gate blocked a shell command that reads {cfg.relpath(unscanned[0])}, a directory too large to "
292
+ f"check before the command runs, and sends data off this machine ({egress}). Name specific files, or "
293
+ "scan the directory first with `phi-gate scan`."
294
+ ), None, cfg.relpath(unscanned[0]), "unscanned_egress")
295
+
296
+ for read in a.git_reads:
297
+ top = git_toplevel(read.cwd)
298
+ if top is None:
299
+ continue
300
+ try:
301
+ printed = _scan_blobs(cfg, top, shell.history_blobs(read))
302
+ except (shell.Undetermined, subprocess.SubprocessError, OSError) as exc:
303
+ return Decision(BLOCK, (
304
+ f"phi-gate blocked `git {read.sub}`: it prints file content from the repository, and phi-gate "
305
+ f"could not determine which files ({exc}). Name a specific commit and path."
306
+ ), reason="history_undetermined")
307
+ if printed is not None and printed.blocking:
308
+ return Decision(BLOCK, (
309
+ f"phi-gate blocked `git {read.sub}`: it would print {printed.path}, which contains likely PHI "
310
+ f"({_why(printed)}). Files removed from the working tree remain in git history. " + REDACT_HINT
311
+ ), printed.verdict, printed.path, "phi_in_history")
312
+
313
+ if a.unresolved:
314
+ start = git_toplevel(cwd) or cfg.root
315
+ found, complete = _scan_tree(cfg, start)
316
+ if found is not None:
317
+ return Decision(BLOCK, (
318
+ f"phi-gate blocked a shell command that reads a path known only when it runs ({a.unresolved[0]}), "
319
+ f"and this project holds likely PHI in {found.path} ({_why(found)}). Use literal paths so they "
320
+ "can be checked first. " + REDACT_HINT
321
+ ), found.verdict, found.path, "unresolved_read")
322
+ if not complete and egress:
323
+ return Decision(BLOCK, (
324
+ f"phi-gate blocked a shell command that reads a path known only when it runs ({a.unresolved[0]}) "
325
+ f"and sends data off this machine ({egress}); the project is too large to rule out PHI first. "
326
+ "Use literal paths."
327
+ ), reason="unresolved_egress")
328
+ return Decision(ALLOW, verdict=v)
329
+
330
+
331
+ # --------------------------------------------------------------------------
332
+ # PreToolUse
333
+ # --------------------------------------------------------------------------
334
+
335
+
336
+ def _referenced_files(value, cwd: Path) -> list[Path]:
337
+ """Existing files named by string values in a tool input (paths or file:// URIs)."""
338
+ out: list[Path] = []
339
+ for s in _strings(value):
340
+ if not s or len(s) > 4096 or "\n" in s:
341
+ continue
342
+ candidate = s
343
+ if s.startswith("file://"):
344
+ candidate = unquote(urlparse(s).path)
345
+ elif "://" in s:
346
+ continue
347
+ try:
348
+ p = _abs(candidate, cwd)
349
+ if p.is_file():
350
+ out.append(p)
351
+ except (OSError, ValueError):
352
+ continue
353
+ if len(out) >= MAX_REFERENCED_FILES:
354
+ break
355
+ return out
356
+
357
+
358
+ def _scan_input(tool: str, ti: dict, cfg: Config, cwd: Path, files: bool) -> Decision:
359
+ """Scan a tool's whole input, and with `files`, any file it names."""
360
+ v = scan_text(_text(ti), cfg)
361
+ if v.blocking:
362
+ return Decision(BLOCK, (
363
+ f"phi-gate blocked {tool}: its input contains likely PHI ({_describe(v, with_lines=False)}). "
364
+ "Do not send identifiers to other tools, agents or services."
365
+ ), v, None, "phi_in_input")
366
+ if files:
367
+ referenced = _referenced_files(ti, cwd)
368
+ secret = _secret_block(cfg, referenced, f"{tool} on")
369
+ if secret is not None:
370
+ return secret
371
+ for p in referenced:
372
+ r = scan_file(p, cfg)
373
+ if r.blocking:
374
+ return _file_block(r, f"{tool} on")
375
+ return Decision(ALLOW, verdict=v)
376
+
377
+
378
+ def _read(tool: str, path, cwd: Path, cfg: Config) -> Decision:
379
+ if not isinstance(path, str) or not path:
380
+ raise ValueError(f"{tool} tool_input has no path")
381
+ secret = _secret_block(cfg, [_abs(path, cwd)], f"{tool} of")
382
+ if secret is not None:
383
+ return secret
384
+ r = scan_file(_abs(path, cwd), cfg)
385
+ if r.blocking:
386
+ return Decision(BLOCK, (
387
+ f"phi-gate blocked {tool} of {r.path}: likely PHI ({_why(r)}). "
388
+ "Do not try to access, print, or copy this data another way. " + REDACT_HINT
389
+ ), r.verdict, r.path, "phi_in_file")
390
+ if r.verdict.decision == WARN:
391
+ return Decision(WARN, f"phi-gate: {r.path} may contain identifiers ({_describe(r.verdict)}).", r.verdict, r.path)
392
+ return Decision(ALLOW, verdict=r.verdict, target=r.path)
393
+
394
+
395
+ def _pre_tool(payload: dict, cfg: Config, cwd: Path) -> Decision:
396
+ tool = payload["tool_name"]
397
+ ti = payload.get("tool_input") or {}
398
+ if not isinstance(ti, dict):
399
+ raise ValueError("tool_input is not an object")
400
+
401
+ if tool in EDIT_TOOLS:
402
+ raw_targets = [ti.get("file_path"), ti.get("notebook_path")]
403
+ raw_targets += [e.get("file_path") for e in (ti.get("edits") or []) if isinstance(e, dict)]
404
+ targets = [_abs(t, cwd) for t in raw_targets if isinstance(t, str) and t]
405
+ for t in targets:
406
+ if _protected(cfg, t):
407
+ return Decision(BLOCK, f"phi-gate blocked {tool} on {cfg.relpath(t)}: " + PROTECTED_HINT,
408
+ None, cfg.relpath(t), "config_protected")
409
+ target = targets[0] if targets else None
410
+ if target is not None and cfg.is_allowed_path(target):
411
+ return Decision(ALLOW, target=cfg.relpath(target))
412
+ if tool == "Write":
413
+ text = _text(ti.get("content"))
414
+ elif tool == "Edit":
415
+ text = _text(ti.get("new_string"))
416
+ elif tool == "MultiEdit":
417
+ text = "\n".join(_text(e.get("new_string")) for e in (ti.get("edits") or []) if isinstance(e, dict))
418
+ else:
419
+ text = _text(ti.get("new_source"))
420
+ v = scan_text(text, cfg)
421
+ rel = cfg.relpath(target) if target is not None else None
422
+ if v.blocking:
423
+ return Decision(BLOCK, (
424
+ f"phi-gate blocked {tool} to {rel or 'a file'}: the new content contains likely PHI "
425
+ f"({_describe(v, with_lines=False)}). Use placeholders or synthetic values instead (SSNs "
426
+ "starting with 9, 555-0100 to 555-0199 phone numbers, example.com emails). If this path holds "
427
+ f"synthetic fixtures, ask the user to add it to allow_paths in {CONFIG_NAME}."
428
+ ), v, rel, "phi_in_content")
429
+ return Decision(WARN if v.decision == WARN else ALLOW, verdict=v, target=rel)
430
+
431
+ if tool == "Bash":
432
+ command = ti.get("command")
433
+ if not isinstance(command, str):
434
+ raise ValueError("Bash tool_input.command is not a string")
435
+ return _bash(command, cfg, cwd)
436
+
437
+ if tool == "Read":
438
+ return _read(tool, ti.get("file_path"), cwd, cfg)
439
+ if tool == "NotebookRead":
440
+ return _read(tool, ti.get("notebook_path"), cwd, cfg)
441
+
442
+ if tool == "Grep":
443
+ v = scan_text(_text(ti.get("pattern")), cfg)
444
+ if v.blocking:
445
+ return Decision(BLOCK, (
446
+ f"phi-gate blocked Grep: the search pattern contains likely PHI ({_describe(v, with_lines=False)})."
447
+ ), v, None, "phi_in_input")
448
+ path = ti.get("path")
449
+ root = _abs(path, cwd) if isinstance(path, str) and path else cwd
450
+ if root.is_file():
451
+ targets = [root]
452
+ elif root.is_dir() and not cfg.is_allowed_path(root):
453
+ glob = ti.get("glob")
454
+ pattern = glob if isinstance(glob, str) else None
455
+ # Large trees rely on the output scan. The glob narrows what is
456
+ # scanned, and one that cannot be followed scans everything.
457
+ found = walk(root, MAX_DIR_FILES, keep=globs.named_segments(pattern)) or []
458
+ targets = globs.select(found, pattern)
459
+ else:
460
+ targets = []
461
+ secret = _secret_block(cfg, [root, *targets], "Grep over")
462
+ if secret is not None:
463
+ return secret
464
+ for t in targets:
465
+ r = scan_file(t, cfg)
466
+ if r.blocking:
467
+ return Decision(BLOCK, (
468
+ f"phi-gate blocked Grep over {r.path}: the file contains likely PHI ({_why(r)}). " + REDACT_HINT
469
+ ), r.verdict, r.path, "phi_in_file")
470
+ return Decision(ALLOW, verdict=v)
471
+
472
+ # Inputs that only describe work (agent prompts, search patterns, shell
473
+ # ids) are scanned as text; tools that may open what their input names
474
+ # (MCP tools, resources, tools phi-gate does not know) also have the
475
+ # files their input names scanned.
476
+ text_only = tool in ("Task", "Agent", "Glob", "LS", "BashOutput", "KillShell", "WebFetch", "WebSearch",
477
+ "TodoWrite", "ExitPlanMode")
478
+ return _scan_input(tool, ti, cfg, cwd, files=not text_only)
479
+
480
+
481
+ # --------------------------------------------------------------------------
482
+ # UserPromptSubmit
483
+ # --------------------------------------------------------------------------
484
+
485
+ _MENTION = re.compile(r"(?:(?<=\s)|^)@(?:\"([^\"\n]+)\"|([^\s\"'<>|;,]+))")
486
+
487
+
488
+ def _mentions(prompt: str, cwd: Path) -> list[Path]:
489
+ out: list[Path] = []
490
+ for m in _MENTION.finditer(prompt):
491
+ raw = (m.group(1) or m.group(2)).split("#", 1)[0].rstrip(".,:;!?)]}")
492
+ if not raw:
493
+ continue
494
+ try:
495
+ p = _abs(raw, cwd)
496
+ if p.is_file():
497
+ out.append(p)
498
+ except (OSError, ValueError):
499
+ continue
500
+ return out[:MAX_REFERENCED_FILES]
501
+
502
+
503
+ # --------------------------------------------------------------------------
504
+ # Dispatcher
505
+ # --------------------------------------------------------------------------
506
+
507
+
508
+ def decide(event: str, payload: dict, cfg: Config) -> Outcome:
509
+ cwd = Path(payload.get("cwd") or os.getcwd())
510
+ session = payload.get("session_id")
511
+ tool = payload.get("tool_name")
512
+ if event in TOOL_EVENTS and not (isinstance(tool, str) and tool):
513
+ raise ValueError(f"{event} payload has no tool_name")
514
+
515
+ v: Verdict | None
516
+ target: str | None = None
517
+ reason = ""
518
+ if event == "UserPromptSubmit":
519
+ prompt = payload.get("prompt")
520
+ if not isinstance(prompt, str):
521
+ raise ValueError("UserPromptSubmit payload has no prompt")
522
+ token = cfg.prompt_override_token
523
+ if token and prompt.lstrip().startswith(token):
524
+ audit.record(cfg, event=event, decision="override", session_id=session, note="user override token")
525
+ return Outcome(0)
526
+ v = scan_text(prompt, cfg)
527
+ decision, reason = v.decision, "phi_in_prompt"
528
+ override = f", or start the prompt with {token} to send it anyway (this is logged)." if token else "."
529
+ msg = (
530
+ "phi-gate blocked this prompt before it reached the model: it looks like it contains PHI "
531
+ f"({_describe(v, with_lines=False)}). Replace identifiers with placeholders (`phi-gate redact` can "
532
+ "do it)" + override
533
+ )
534
+ if decision != BLOCK:
535
+ for p in _mentions(prompt, cwd):
536
+ r = scan_file(p, cfg)
537
+ if r.blocking:
538
+ decision, v, target, reason = BLOCK, r.verdict, r.path, "phi_in_mention"
539
+ msg = (
540
+ f"phi-gate blocked this prompt before it reached the model: it attaches {r.path}, which "
541
+ f"contains likely PHI ({_why(r)}). " + REDACT_HINT + override
542
+ )
543
+ break
544
+ elif event == "PreToolUse":
545
+ d = _pre_tool(payload, cfg, cwd)
546
+ decision, msg, v, target, reason = d.decision, d.message, d.verdict, d.target, d.reason
547
+ elif event == "PostToolUse":
548
+ if not cfg.scan_tool_output or tool in POST_SKIP:
549
+ return Outcome(0)
550
+ ti = payload.get("tool_input") or {}
551
+ if tool in READ_TOOLS and isinstance(ti, dict):
552
+ path = ti.get("file_path") or ti.get("notebook_path")
553
+ if isinstance(path, str) and path and cfg.is_allowed_path(_abs(path, cwd)):
554
+ return Outcome(0)
555
+ output = payload.get("tool_response", payload.get("tool_output", ""))
556
+ v = scan_text(_text(output), cfg)
557
+ decision, reason = v.decision, "phi_in_output"
558
+ msg = (
559
+ f"phi-gate: the output of this {tool} call contained likely PHI ({_describe(v, with_lines=False)}). "
560
+ "It is now in context. Do not repeat, quote, summarize, or copy these values into code, files, "
561
+ "commits, or messages; refer to records by placeholder or row number. Tell the user that PHI was "
562
+ "exposed in this session so they can review it."
563
+ )
564
+ else:
565
+ raise ValueError(f"unhandled hook event {event!r}")
566
+
567
+ if decision == BLOCK:
568
+ if event == "PostToolUse":
569
+ effective = {"block": "flagged", "warn": WARN, "audit": "audited"}[cfg.mode]
570
+ else:
571
+ effective = {"block": BLOCK, "warn": WARN, "audit": "audited"}[cfg.mode]
572
+ audit.record(cfg, event=event, decision=effective, verdict=v, tool=tool, target=target,
573
+ session_id=session, note=reason or None)
574
+ if cfg.mode == "block":
575
+ return Outcome(2, stderr=msg)
576
+ if cfg.mode == "warn":
577
+ return Outcome(0, stdout=json.dumps({"systemMessage": msg}))
578
+ return Outcome(0)
579
+
580
+ if decision == WARN and cfg.warn_in_hooks and msg:
581
+ audit.record(cfg, event=event, decision=WARN, verdict=v, tool=tool, target=target, session_id=session)
582
+ return Outcome(0, stdout=json.dumps({"systemMessage": msg}))
583
+ return Outcome(0)
584
+
585
+
586
+ def _arm_deadline() -> None:
587
+ seconds = int(os.environ.get("PHI_GATE_DEADLINE", DEFAULT_DEADLINE))
588
+ if seconds > 0 and hasattr(signal, "SIGALRM"):
589
+ def _expired(signum, frame):
590
+ raise Deadline(f"check did not finish within {seconds}s")
591
+
592
+ signal.signal(signal.SIGALRM, _expired)
593
+ signal.alarm(seconds)
594
+
595
+
596
+ def _event(registered: str, payload: dict) -> str:
597
+ """The event to handle. The event the hook was registered for must be
598
+ named, known, and agree with the payload's hook_event_name when the
599
+ payload carries one."""
600
+ named = payload.get("hook_event_name")
601
+ if registered not in EVENTS:
602
+ raise ValueError(f"hook registered for unknown event {registered!r}")
603
+ if named is not None and named != registered:
604
+ raise ValueError(f"payload event {named!r} does not match the registered event {registered!r}")
605
+ return registered
606
+
607
+
608
+ def main(event: str | None = None) -> int:
609
+ registered = event if event is not None else (sys.argv[1] if len(sys.argv) > 1 else "")
610
+ try:
611
+ _arm_deadline()
612
+ raw = sys.stdin.read()
613
+ if not raw.strip():
614
+ raise ValueError("empty hook payload")
615
+ payload = json.loads(raw)
616
+ if not isinstance(payload, dict):
617
+ raise ValueError("hook payload is not a JSON object")
618
+ resolved = _event(registered, payload)
619
+ try:
620
+ cfg = config.load_for_payload(payload)
621
+ except ConfigError as exc:
622
+ if _fail_open():
623
+ return 0
624
+ print(f"phi-gate configuration error, blocking until fixed: {exc}", file=sys.stderr)
625
+ return 2
626
+ outcome = decide(resolved, payload, cfg)
627
+ except BaseException as exc: # fail-closed on anything unexpected, including the deadline
628
+ if _fail_open():
629
+ return 0
630
+ kind = "took too long" if isinstance(exc, Deadline) else "internal error"
631
+ print(f"phi-gate {kind}, blocking by default (fail-closed): {type(exc).__name__}: {exc}", file=sys.stderr)
632
+ return 2
633
+ finally:
634
+ if hasattr(signal, "SIGALRM"):
635
+ signal.alarm(0)
636
+ if outcome.stdout:
637
+ print(outcome.stdout)
638
+ if outcome.stderr:
639
+ print(outcome.stderr, file=sys.stderr)
640
+ return outcome.code
641
+
642
+
643
+ if __name__ == "__main__": # pragma: no cover
644
+ sys.exit(main())