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/__init__.py +3 -0
- phi_gate/__main__.py +5 -0
- phi_gate/audit.py +330 -0
- phi_gate/cli.py +410 -0
- phi_gate/config.py +600 -0
- phi_gate/detectors.py +1086 -0
- phi_gate/globs.py +227 -0
- phi_gate/hooks.py +644 -0
- phi_gate/policy.py +97 -0
- phi_gate/py.typed +0 -0
- phi_gate/redact.py +236 -0
- phi_gate/scanner.py +776 -0
- phi_gate/shell.py +1582 -0
- phi_gate-0.2.0.dist-info/METADATA +344 -0
- phi_gate-0.2.0.dist-info/RECORD +19 -0
- phi_gate-0.2.0.dist-info/WHEEL +5 -0
- phi_gate-0.2.0.dist-info/entry_points.txt +2 -0
- phi_gate-0.2.0.dist-info/licenses/LICENSE +21 -0
- phi_gate-0.2.0.dist-info/top_level.txt +1 -0
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())
|