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/cli.py ADDED
@@ -0,0 +1,410 @@
1
+ """phi-gate command line.
2
+
3
+ phi-gate scan [PATHS...] [--staged | --diff] [--json] [--all]
4
+ phi-gate redact [FILE|-] [--map MAP.json] [--all]
5
+ phi-gate restore --map MAP.json [FILE|-]
6
+ phi-gate status
7
+ phi-gate audit verify
8
+ phi-gate explain
9
+ phi-gate install-git-hook [--force]
10
+ phi-gate hook <EventName> (used by Claude Code)
11
+
12
+ Exit codes for `scan`: 0 clean or warnings only, 1 blocking findings, 2 error.
13
+ Every other command exits 0 on success and 2 on any error, including an
14
+ unexpected one; `audit verify` exits 1 when the chain is broken.
15
+
16
+ hooks/run.sh runs `hook_entry`, which writes ALLOW_TOKEN to file descriptor 3
17
+ only when the hook decided to allow. The wrapper allows an action only on
18
+ exit 0 together with that token, so an interpreter that merely exits 0
19
+ cannot let actions through.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import argparse
25
+ import json
26
+ import os
27
+ import shlex
28
+ import stat
29
+ import subprocess
30
+ import sys
31
+ from pathlib import Path
32
+
33
+ from . import __version__, audit, hooks
34
+ from .config import CONFIG_NAME, Config, ConfigError, load
35
+ from .detectors import DETECTORS, LOW
36
+ from .redact import redact, restore
37
+ from .scanner import FileResult, Unreadable, decode_bytes, iter_files, read_text, scan_file, scan_git_diff, worst
38
+
39
+ ALLOW_TOKEN = "PHI-GATE-ALLOW-1"
40
+ ALLOW_FD = 3
41
+ MAP_SUFFIX = ".map.json"
42
+
43
+ SEV_LABEL = {"high": "HIGH", "medium": "MED ", "low": "LOW "}
44
+
45
+
46
+ def _use_color(stream) -> bool:
47
+ if os.environ.get("NO_COLOR"):
48
+ return False
49
+ if os.environ.get("FORCE_COLOR"):
50
+ return True
51
+ return hasattr(stream, "isatty") and stream.isatty()
52
+
53
+
54
+ def _c(text: str, code: str, stream=None) -> str:
55
+ return f"\033[{code}m{text}\033[0m" if _use_color(stream or sys.stdout) else text
56
+
57
+
58
+ DECISION_COLOR = {"block": "1;31", "warn": "1;33", "allow": "1;32", "flagged": "1;35", "override": "1;34",
59
+ "audited": "2", "warning": "1;33"}
60
+ SEV_COLOR = {"high": "31", "medium": "33", "low": "2"}
61
+
62
+
63
+ def _print_result(r: FileResult, show_low: bool) -> None:
64
+ if r.skipped:
65
+ return
66
+ decision = _c(r.verdict.decision.upper(), DECISION_COLOR[r.verdict.decision])
67
+ if r.note == "oversize" and r.blocking:
68
+ print(f"{_c(r.path, '1')}: {decision} {'; '.join(r.verdict.reasons)}")
69
+ return
70
+ shown = [f for f in r.verdict.findings if show_low or f.severity != LOW]
71
+ if not shown and r.verdict.decision == "allow":
72
+ return
73
+ reasons = _c(f" ({'; '.join(r.verdict.reasons)})", "2") if r.verdict.reasons else ""
74
+ print(f"{_c(r.path, '1')}: {decision}{reasons}")
75
+ for f in shown:
76
+ loc = _c(f"{r.path}:{f.line}:{f.col}", "2")
77
+ print(f" {loc} {_c(SEV_LABEL[f.severity], SEV_COLOR[f.severity])} {f.category}")
78
+ if r.note:
79
+ print(_c(f" note: {r.note}", "2"))
80
+
81
+
82
+ def cmd_scan(args) -> int:
83
+ cfg = load(Path.cwd())
84
+ if args.staged or args.diff:
85
+ results = scan_git_diff(cfg, Path.cwd(), staged=args.staged)
86
+ else:
87
+ results = [scan_file(p, cfg) for p in iter_files(args.paths or ["."], cfg)]
88
+ if args.json:
89
+ payload = {
90
+ "decision": worst(results),
91
+ "files": [
92
+ {
93
+ "path": r.path,
94
+ "decision": r.verdict.decision,
95
+ "reasons": r.verdict.reasons,
96
+ "note": r.note,
97
+ "findings": [f.to_dict() for f in r.verdict.findings if args.all or f.severity != LOW],
98
+ }
99
+ for r in results
100
+ if not r.skipped and (r.verdict.findings or r.verdict.decision != "allow")
101
+ ],
102
+ }
103
+ print(json.dumps(payload, indent=2))
104
+ else:
105
+ for r in results:
106
+ _print_result(r, args.all)
107
+ scanned = sum(1 for r in results if not r.skipped)
108
+ overall = worst(results)
109
+ sys.stdout.flush()
110
+ verdict = _c(overall.upper(), DECISION_COLOR[overall], sys.stderr)
111
+ print(f"phi-gate: {scanned} file(s) scanned, result: {verdict}", file=sys.stderr)
112
+ if args.staged and overall == "block":
113
+ print(
114
+ _c("phi-gate: commit blocked.", "1;31", sys.stderr) + " Replace those values with synthetic ones, "
115
+ "or unstage the files with `git restore --staged <file>`.",
116
+ file=sys.stderr,
117
+ )
118
+ return 1 if worst(results) == "block" else 0
119
+
120
+
121
+ def _read_input(path: str | None, cfg: Config) -> str:
122
+ if path and path != "-":
123
+ return read_text(path, cfg)
124
+ text = decode_bytes(sys.stdin.buffer.read())
125
+ if text is None:
126
+ raise Unreadable("cannot read stdin: binary data")
127
+ return text
128
+
129
+
130
+ class MapPathError(Exception):
131
+ pass
132
+
133
+
134
+ def _git_ignored(path: Path, root: Path) -> bool | None:
135
+ """Whether git ignores `path`; None when `root` is not a git work tree."""
136
+ try:
137
+ res = subprocess.run(["git", "-C", str(root), "check-ignore", "-q", "--", str(path)],
138
+ capture_output=True, timeout=10)
139
+ except (OSError, subprocess.SubprocessError):
140
+ return None
141
+ return {0: True, 1: False}.get(res.returncode)
142
+
143
+
144
+ def _open_map(name: str, cfg: Config) -> int:
145
+ """Open the redaction map for writing, refusing anything that would let
146
+ the original values escape: a symbolic link, a file that is not a
147
+ regular file or has other hard links, or a path inside the project that
148
+ git would commit. Inside a git work tree the path must be ignored by git
149
+ (a `*.map.json` line in .gitignore, for example); in a project that is
150
+ not under git it must end in `.map.json`. The file is left with mode 0600."""
151
+ path = Path(name).expanduser()
152
+ if not path.is_absolute():
153
+ path = Path.cwd() / path
154
+ path = path.parent.resolve() / path.name
155
+ root = cfg.root.resolve()
156
+ if path == root or root in path.parents:
157
+ ignored = _git_ignored(path, root)
158
+ if ignored is False or (ignored is None and not path.name.endswith(MAP_SUFFIX)):
159
+ raise MapPathError(
160
+ f"refusing to write the redaction map to {path.name} inside the project: it holds the original "
161
+ f"values. Use a path outside the project, or a git-ignored name such as *{MAP_SUFFIX}."
162
+ )
163
+ flags = os.O_WRONLY | os.O_CREAT | os.O_NONBLOCK | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0)
164
+ try:
165
+ fd = os.open(path, flags, 0o600)
166
+ except OSError as exc:
167
+ raise MapPathError(f"cannot open the redaction map {path.name}: {exc.strerror or exc}") from exc
168
+ try:
169
+ st = os.fstat(fd)
170
+ if not stat.S_ISREG(st.st_mode) or st.st_nlink != 1:
171
+ raise MapPathError(f"refusing to write the redaction map to {path.name}: not a plain file")
172
+ if hasattr(os, "getuid") and st.st_uid != os.getuid():
173
+ raise MapPathError(f"refusing to write the redaction map to {path.name}: owned by another user")
174
+ os.fchmod(fd, 0o600)
175
+ os.ftruncate(fd, 0)
176
+ os.set_blocking(fd, True)
177
+ except BaseException:
178
+ os.close(fd)
179
+ raise
180
+ return fd
181
+
182
+
183
+ def cmd_redact(args) -> int:
184
+ cfg = load(Path.cwd())
185
+ fd = _open_map(args.map, cfg) if args.map else None
186
+ try:
187
+ text = _read_input(args.file, cfg)
188
+ out, mapping = redact(text, cfg, include_low=args.all)
189
+ except BaseException:
190
+ if fd is not None:
191
+ os.close(fd)
192
+ raise
193
+ sys.stdout.write(out)
194
+ sys.stdout.flush()
195
+ if fd is not None:
196
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
197
+ json.dump(mapping, fh, indent=2)
198
+ print(
199
+ f"\nphi-gate: wrote {len(mapping)} placeholder(s) to {args.map}. That file contains the original "
200
+ "values: keep it local and never commit it.",
201
+ file=sys.stderr,
202
+ )
203
+ else:
204
+ print(_c(f"\nphi-gate: replaced {len(mapping)} distinct value(s).", "2", sys.stderr), file=sys.stderr)
205
+ return 0
206
+
207
+
208
+ def _read_map(name: str) -> dict[str, str]:
209
+ try:
210
+ mapping = json.loads(Path(name).read_text(encoding="utf-8"))
211
+ except json.JSONDecodeError as exc:
212
+ raise MapPathError(f"{name} is not valid JSON (line {exc.lineno})") from exc
213
+ if not (isinstance(mapping, dict) and all(isinstance(k, str) and isinstance(v, str) for k, v in mapping.items())):
214
+ raise MapPathError(f"{name} is not a redaction map: expected an object of strings to strings")
215
+ return mapping
216
+
217
+
218
+ def cmd_restore(args) -> int:
219
+ mapping = _read_map(args.map)
220
+ sys.stdout.write(restore(_read_input(args.file, load(Path.cwd())), mapping))
221
+ return 0
222
+
223
+
224
+ def cmd_status(args) -> int:
225
+ cfg = load(Path.cwd())
226
+ source = "defaults (no " + CONFIG_NAME + " found)"
227
+ if cfg.source:
228
+ try:
229
+ source = "./" + str(Path(cfg.source).resolve().relative_to(Path.cwd().resolve()))
230
+ except ValueError:
231
+ source = str(cfg.source)
232
+ print(f"phi-gate {__version__}")
233
+ print(f" config {source}")
234
+ print(f" managed {', '.join(map(str, cfg.managed_layers)) or 'none'}")
235
+ if cfg.layers:
236
+ print(f" nested {', '.join(map(str, cfg.layers))} (can only tighten)")
237
+ print(f" mode {cfg.mode}")
238
+ print(f" allow_paths {', '.join(cfg.allow_paths) or 'none'}")
239
+ print(f" disabled {', '.join(cfg.disable) or 'none'}")
240
+ print(f" max_bytes {cfg.max_bytes} (oversize: {cfg.oversize})")
241
+ print(f" opaque {cfg.opaque}")
242
+ print(f" audit_log {cfg.audit_log or 'disabled'}"
243
+ f"{' (keyed)' if cfg.audit_key_file else ''}, targets: {cfg.audit_targets}")
244
+ events = audit.tail(cfg, args.n)
245
+ print(f"\nlast {len(events)} audit event(s), newest last (UTC):")
246
+ rows = []
247
+ for e in events:
248
+ cats = ", ".join(f"{k} x{v}" for k, v in e.get("categories", {}).items()) or e.get("note", "")
249
+ when = str(e.get("ts", ""))[11:19]
250
+ where = e.get("target") or ("prompt" if e.get("event") == "UserPromptSubmit" else "")
251
+ rows.append((when, str(e.get("decision", "")).upper(), e.get("event", ""), e.get("tool") or "", where, cats))
252
+ if rows:
253
+ widths = [max(len(r[i]) for r in rows) for i in range(5)]
254
+ for r in rows:
255
+ cells = [r[i].ljust(widths[i]) for i in range(5)]
256
+ cells[0] = _c(cells[0], "2")
257
+ cells[1] = _c(cells[1], DECISION_COLOR.get(r[1].lower(), "0"))
258
+ print(" " + " ".join(cells) + " " + r[5])
259
+ return 0
260
+
261
+
262
+ def cmd_audit_verify(args) -> int:
263
+ cfg = load(Path.cwd())
264
+ report = audit.verify(cfg)
265
+ if report.intact:
266
+ kind = "keyed" if cfg.audit_key_file else "unkeyed (detects accidental damage only)"
267
+ print(f"phi-gate: audit chain intact, {report.events} event(s), {kind}")
268
+ if cfg.audit_key_file and report.legacy:
269
+ print(f"phi-gate: {report.legacy} unkeyed event(s) precede the first keyed event", file=sys.stderr)
270
+ return 0
271
+ print(f"phi-gate: audit chain broken at line {report.broken_line}: {report.problem}", file=sys.stderr)
272
+ return 1
273
+
274
+
275
+ def cmd_explain(args) -> int:
276
+ print("phi-gate detectors (category, severity, description):\n")
277
+ for d in DETECTORS:
278
+ print(f" {d.name:15} {d.severity:6} {d.description}")
279
+ print(
280
+ "\nDecision rules: any HIGH finding blocks. A clinical fact (diagnosis code, date of "
281
+ "service) within record_window characters of a name, phone, email or address blocks. "
282
+ "Other MEDIUM findings warn. LOW findings are informational."
283
+ )
284
+ return 0
285
+
286
+
287
+ HOOK_TEMPLATE = """#!/bin/sh
288
+ # Installed by phi-gate. Blocks commits whose staged changes contain likely PHI.
289
+ # Runs the interpreter and the phi-gate copy recorded at install time, never
290
+ # whatever is first on PATH. If either is gone the commit is blocked.
291
+ PY={python}
292
+ SRC={src}
293
+ if [ ! -x "$PY" ] || [ ! -f "$SRC/phi_gate/cli.py" ]; then
294
+ echo "phi-gate: the checker recorded in this hook is missing ($PY, $SRC); run phi-gate install-git-hook --force." >&2
295
+ exit 1
296
+ fi
297
+ exec "$PY" -I -c 'import sys; sys.path.insert(0, sys.argv[1]); from phi_gate.cli import main; sys.exit(main(["scan", "--staged"]))' "$SRC"
298
+ """
299
+
300
+
301
+ def cmd_install_git_hook(args) -> int:
302
+ try:
303
+ git_dir = subprocess.run(
304
+ ["git", "rev-parse", "--git-path", "hooks"], capture_output=True, text=True, check=True
305
+ ).stdout.strip()
306
+ except (subprocess.CalledProcessError, FileNotFoundError):
307
+ print("phi-gate: not inside a git repository", file=sys.stderr)
308
+ return 2
309
+ hook = Path(git_dir) / "pre-commit"
310
+ if hook.exists() and not args.force:
311
+ print(f"phi-gate: {hook} already exists; re-run with --force to replace it", file=sys.stderr)
312
+ return 2
313
+ hook.parent.mkdir(parents=True, exist_ok=True)
314
+ src = Path(__file__).resolve().parent.parent
315
+ python = Path(sys.executable).absolute()
316
+ hook.write_text(HOOK_TEMPLATE.format(python=shlex.quote(str(python)), src=shlex.quote(str(src))),
317
+ encoding="utf-8")
318
+ hook.chmod(hook.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
319
+ print(f"phi-gate: installed {hook}")
320
+ return 0
321
+
322
+
323
+ def build_parser() -> argparse.ArgumentParser:
324
+ p = argparse.ArgumentParser(prog="phi-gate", description="Fail-closed PHI guardrails for AI coding agents.")
325
+ p.add_argument("--version", action="version", version=f"phi-gate {__version__}")
326
+ sub = p.add_subparsers(dest="cmd", required=True)
327
+
328
+ s = sub.add_parser("scan", help="scan files, directories or git changes")
329
+ s.add_argument("paths", nargs="*")
330
+ g = s.add_mutually_exclusive_group()
331
+ g.add_argument("--staged", action="store_true", help="judge staged changes by the PHI they introduce")
332
+ g.add_argument("--diff", action="store_true", help="judge unstaged changes by the PHI they introduce")
333
+ s.add_argument("--json", action="store_true")
334
+ s.add_argument("--all", action="store_true", help="include LOW (informational) findings")
335
+ s.set_defaults(func=cmd_scan)
336
+
337
+ r = sub.add_parser("redact", help="print a copy with typed placeholders")
338
+ r.add_argument("file", nargs="?")
339
+ r.add_argument("--map", help="write placeholder->value JSON (contains PHI)")
340
+ r.add_argument("--all", action="store_true", help="also redact LOW findings")
341
+ r.set_defaults(func=cmd_redact)
342
+
343
+ rs = sub.add_parser("restore", help="put original values back using a map")
344
+ rs.add_argument("file", nargs="?")
345
+ rs.add_argument("--map", required=True)
346
+ rs.set_defaults(func=cmd_restore)
347
+
348
+ st = sub.add_parser("status", help="show config and recent audit events")
349
+ st.add_argument("-n", type=int, default=15)
350
+ st.set_defaults(func=cmd_status)
351
+
352
+ au = sub.add_parser("audit", help="work with the audit log")
353
+ au_sub = au.add_subparsers(dest="audit_cmd", required=True)
354
+ verify = au_sub.add_parser("verify", help="check the log's hash chain; exit 1 if it is broken")
355
+ verify.set_defaults(func=cmd_audit_verify)
356
+
357
+ ex = sub.add_parser("explain", help="list detectors and decision rules")
358
+ ex.set_defaults(func=cmd_explain)
359
+
360
+ gh = sub.add_parser("install-git-hook", help="install a pre-commit hook in this repository")
361
+ gh.add_argument("--force", action="store_true")
362
+ gh.set_defaults(func=cmd_install_git_hook)
363
+
364
+ h = sub.add_parser("hook", help="Claude Code hook entry point (reads JSON on stdin)")
365
+ h.add_argument("event")
366
+ h.set_defaults(func=lambda a: hooks.main(a.event))
367
+ return p
368
+
369
+
370
+ def main(argv: list[str] | None = None) -> int:
371
+ args = build_parser().parse_args(argv)
372
+ try:
373
+ return args.func(args)
374
+ except ConfigError as exc:
375
+ print(f"phi-gate configuration error: {exc}", file=sys.stderr)
376
+ return 2
377
+ except FileNotFoundError as exc:
378
+ print(f"phi-gate: no such file or directory: {exc.filename or exc}", file=sys.stderr)
379
+ return 2
380
+ except (MapPathError, RuntimeError, Unreadable, OSError, ValueError) as exc:
381
+ print(f"phi-gate: {exc}", file=sys.stderr)
382
+ return 2
383
+ except (KeyboardInterrupt, Exception) as exc:
384
+ # Name only the exception type: its text could quote scanned content.
385
+ print(f"phi-gate: internal error ({type(exc).__name__})", file=sys.stderr)
386
+ return 2
387
+
388
+
389
+ def hook_entry(event: str) -> int:
390
+ """Run a Claude Code hook for hooks/run.sh. Only when the hook allows
391
+ (exit 0) is ALLOW_TOKEN written to file descriptor ALLOW_FD; if it cannot
392
+ be written the action is blocked. Any exit other than 0 becomes 2."""
393
+ try:
394
+ code = hooks.main(event)
395
+ except BaseException as exc: # hooks.main handles its own errors; this is a last resort
396
+ print(f"phi-gate internal error, blocking by default (fail-closed): {type(exc).__name__}", file=sys.stderr)
397
+ return 2
398
+ if code != 0:
399
+ return 2
400
+ try:
401
+ sys.stdout.flush()
402
+ os.write(ALLOW_FD, ALLOW_TOKEN.encode())
403
+ except (OSError, ValueError):
404
+ print("phi-gate: could not confirm the decision to the hook wrapper, blocking", file=sys.stderr)
405
+ return 2
406
+ return 0
407
+
408
+
409
+ if __name__ == "__main__": # pragma: no cover
410
+ sys.exit(main())