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 ADDED
@@ -0,0 +1,3 @@
1
+ """phi-gate: fail-closed PHI guardrails for AI coding agents."""
2
+
3
+ __version__ = "0.2.0"
phi_gate/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
phi_gate/audit.py ADDED
@@ -0,0 +1,330 @@
1
+ """Append-only, tamper-evident audit log.
2
+
3
+ Each event records what was decided, where, and which categories triggered
4
+ it. It never records matched values, hashes of values, or surrounding text:
5
+ a 9-digit SSN space is small enough that a hash is as good as the value, and
6
+ an audit trail must not become a second copy of the PHI it is protecting.
7
+
8
+ A file name can itself be PHI (`JaneDoe_labs.csv`), so the final component
9
+ of a target path is recorded according to `audit_targets`:
10
+
11
+ * `hash` (default): a keyed hash of the name plus its extension, such as
12
+ `data/#3f2a9c1d0b7e4a65.csv`. The key is the audit key when one is
13
+ configured, else a random salt kept beside the log (`<log>.salt`, mode
14
+ 0600). The same file gives the same hash, so repeated events can be
15
+ correlated without revealing the name.
16
+ * `category`: the extension only, such as `data/[csv]`.
17
+ * `path`: the path as given. Use only where file names are known not to
18
+ carry identifiers.
19
+
20
+ Events form a hash chain: each carries the hash of the one before it, so
21
+ editing, removing or reordering a line breaks every hash after it, and
22
+ `phi-gate audit verify` finds where. Without a key the chain is only
23
+ tamper-evident against accidental damage, since anyone who can write the log
24
+ can recompute it. With `audit_key_file` set (in the managed policy, or in a
25
+ repository configuration as a path outside the project) each link is an
26
+ HMAC-SHA256 under that key, marked `"mac": "hmac-sha256"` with a key
27
+ identifier `"kid"`. Unkeyed events written before the key was introduced
28
+ still verify as a legacy prefix; an unkeyed event after a keyed one breaks
29
+ the chain. A chain cannot show that its newest lines were cut off, so a
30
+ deployment that needs that guarantee ships events somewhere the developer
31
+ cannot write as they are made.
32
+
33
+ A failure to write an event is reported on stderr. In `audit` mode the log
34
+ is the only control, so a failure raises AuditWriteError and the hook blocks
35
+ the action; a prompt override that cannot be logged is refused the same way.
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import hashlib
41
+ import hmac
42
+ import json
43
+ import os
44
+ import secrets
45
+ import sys
46
+ import uuid
47
+ from dataclasses import dataclass
48
+ from datetime import UTC, datetime
49
+ from pathlib import Path, PurePosixPath
50
+
51
+ from .config import Config, ConfigError
52
+ from .policy import Verdict
53
+
54
+ try:
55
+ import fcntl
56
+ except ModuleNotFoundError: # pragma: no cover - Windows
57
+ fcntl = None # type: ignore[assignment]
58
+
59
+ SCHEMA_VERSION = 1
60
+ GENESIS = "0" * 64
61
+ TAIL_BLOCK = 4096
62
+ MAC = "hmac-sha256"
63
+ MIN_KEY_BYTES = 16
64
+ MAX_EXTENSION = 8
65
+
66
+
67
+ class AuditWriteError(Exception):
68
+ """An event that must be recorded could not be written."""
69
+
70
+
71
+ def _log_path(cfg: Config) -> Path:
72
+ path = Path(cfg.audit_log)
73
+ return path if path.is_absolute() else cfg.root / path
74
+
75
+
76
+ def _salt_path(cfg: Config) -> Path:
77
+ log = _log_path(cfg)
78
+ return log.with_name(log.name + ".salt")
79
+
80
+
81
+ def audit_key(cfg: Config) -> bytes | None:
82
+ """The configured audit key, or None. A configured key that cannot be
83
+ read, or is shorter than MIN_KEY_BYTES, is a configuration error."""
84
+ if not cfg.audit_key_file:
85
+ return None
86
+ path = Path(cfg.audit_key_file).expanduser()
87
+ try:
88
+ key = path.read_bytes().strip()
89
+ except OSError as exc:
90
+ raise ConfigError(f"cannot read audit_key_file {path}: {exc.strerror or exc}") from exc
91
+ if len(key) < MIN_KEY_BYTES:
92
+ raise ConfigError(f"audit_key_file {path} holds fewer than {MIN_KEY_BYTES} bytes")
93
+ return key
94
+
95
+
96
+ def key_id(key: bytes) -> str:
97
+ return hashlib.sha256(b"phi-gate audit key id\0" + key).hexdigest()[:16]
98
+
99
+
100
+ def _canonical(entry: dict) -> bytes:
101
+ body = {k: v for k, v in entry.items() if k != "hash"}
102
+ return json.dumps(body, sort_keys=True, separators=(",", ":")).encode()
103
+
104
+
105
+ def _digest(entry: dict, key: bytes | None = None) -> str:
106
+ if key is None:
107
+ return hashlib.sha256(_canonical(entry)).hexdigest()
108
+ return hmac.new(key, _canonical(entry), hashlib.sha256).hexdigest()
109
+
110
+
111
+ def _last_hash(fd: int) -> str:
112
+ """The hash on the file's last line, read from the end so that writing an
113
+ event does not get slower as the log grows."""
114
+ size = os.fstat(fd).st_size
115
+ if size == 0:
116
+ return GENESIS
117
+ tail = b""
118
+ position = size
119
+ while position > 0 and tail.count(b"\n") < 2:
120
+ position = max(position - TAIL_BLOCK, 0)
121
+ tail = os.pread(fd, min(TAIL_BLOCK, size - position), position) + tail
122
+ try:
123
+ return json.loads(tail.strip().rsplit(b"\n", 1)[-1]).get("hash", GENESIS)
124
+ except (ValueError, AttributeError):
125
+ return GENESIS # a damaged line: verification reports it
126
+
127
+
128
+ def _append(path: Path, entry: dict, key: bytes | None) -> None:
129
+ flags = os.O_RDWR | os.O_APPEND | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0)
130
+ fd = os.open(path, flags, 0o600)
131
+ try:
132
+ # Reading the last hash and writing the next line must be one step, or
133
+ # two hooks firing together would fork the chain.
134
+ if fcntl is not None:
135
+ fcntl.flock(fd, fcntl.LOCK_EX)
136
+ if key is not None:
137
+ entry["mac"] = MAC
138
+ entry["kid"] = key_id(key)
139
+ entry["prev"] = _last_hash(fd)
140
+ entry["hash"] = _digest(entry, key)
141
+ os.write(fd, (json.dumps(entry, sort_keys=True) + "\n").encode())
142
+ finally:
143
+ os.close(fd)
144
+
145
+
146
+ def _salt(cfg: Config) -> bytes:
147
+ """A random per-log salt for hashed targets, created on first use."""
148
+ path = _salt_path(cfg)
149
+ flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0)
150
+ try:
151
+ fd = os.open(path, flags)
152
+ except FileNotFoundError:
153
+ value = secrets.token_hex(32).encode()
154
+ try:
155
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0), 0o600)
156
+ except FileExistsError:
157
+ return _salt(cfg)
158
+ try:
159
+ os.write(fd, value)
160
+ finally:
161
+ os.close(fd)
162
+ return value
163
+ try:
164
+ value = os.read(fd, 4096).strip()
165
+ finally:
166
+ os.close(fd)
167
+ if len(value) < MIN_KEY_BYTES:
168
+ raise OSError(f"audit salt {path} is too short")
169
+ return value
170
+
171
+
172
+ def _extension(name: str) -> str:
173
+ suffix = PurePosixPath(name).suffix
174
+ return suffix if 1 < len(suffix) <= MAX_EXTENSION and suffix[1:].isalnum() else ""
175
+
176
+
177
+ def describe_target(cfg: Config, target: str, key: bytes | None = None) -> str:
178
+ """The form of `target` written to the log, per `audit_targets`."""
179
+ if cfg.audit_targets == "path" or not target:
180
+ return target
181
+ head, sep, name = target.rstrip("/").rpartition("/")
182
+ prefix = head + sep
183
+ ext = _extension(name)
184
+ if cfg.audit_targets == "category":
185
+ return f"{prefix}[{ext[1:].lower() if ext else 'file'}]"
186
+ try:
187
+ secret = key if key is not None else _salt(cfg)
188
+ except OSError:
189
+ return f"{prefix}[{ext[1:].lower() if ext else 'file'}]"
190
+ digest = hmac.new(secret, b"target\0" + name.encode("utf-8", "surrogatepass"), hashlib.sha256).hexdigest()
191
+ return f"{prefix}#{digest[:16]}{ext}"
192
+
193
+
194
+ def _scrub(text: str, name: str, replacement: str) -> str:
195
+ return text.replace(name, replacement) if name and name in text else text
196
+
197
+
198
+ def _warn(message: str) -> None:
199
+ try:
200
+ print(f"phi-gate warning: {message}", file=sys.stderr)
201
+ except (OSError, ValueError):
202
+ pass
203
+
204
+
205
+ def record(
206
+ cfg: Config,
207
+ *,
208
+ event: str,
209
+ decision: str,
210
+ verdict: Verdict | None = None,
211
+ tool: str | None = None,
212
+ target: str | None = None,
213
+ session_id: str | None = None,
214
+ note: str | None = None,
215
+ ) -> None:
216
+ """Append one event. Raises AuditWriteError when it cannot be written
217
+ and the decision depends on the log: in `audit` mode, and for a prompt
218
+ override. Otherwise a failure is reported on stderr."""
219
+ if not cfg.audit_log:
220
+ return
221
+ must_record = cfg.mode == "audit" or decision == "override"
222
+ try:
223
+ key = audit_key(cfg)
224
+ path = _log_path(cfg)
225
+ path.parent.mkdir(parents=True, exist_ok=True)
226
+ ignore = path.parent / ".gitignore"
227
+ if path.parent.name == ".phi-gate" and not ignore.exists():
228
+ ignore.write_text("*\n", encoding="utf-8")
229
+ reasons = list(verdict.reasons) if verdict else []
230
+ if target:
231
+ shown = describe_target(cfg, target, key)
232
+ name = target.rstrip("/").rpartition("/")[2]
233
+ shown_name = shown.rpartition("/")[2]
234
+ if shown != target and len(name) > 2:
235
+ note = _scrub(note, name, shown_name) if note else note
236
+ reasons = [_scrub(r, name, shown_name) for r in reasons]
237
+ target = shown
238
+ entry = {
239
+ "v": SCHEMA_VERSION,
240
+ "id": uuid.uuid4().hex,
241
+ "ts": datetime.now(UTC).isoformat(timespec="seconds"),
242
+ "event": event,
243
+ "decision": decision,
244
+ "tool": tool,
245
+ "target": target,
246
+ "categories": dict(verdict.counts()) if verdict else {},
247
+ "reasons": reasons,
248
+ "session": session_id,
249
+ "note": note,
250
+ }
251
+ entry = {k: v for k, v in entry.items() if v not in (None, [], {})}
252
+ _append(path, entry, key)
253
+ except (OSError, ConfigError) as exc:
254
+ detail = exc.strerror if isinstance(exc, OSError) and exc.strerror else str(exc)
255
+ message = f"could not write the audit log {cfg.audit_log}: {detail}"
256
+ if must_record:
257
+ raise AuditWriteError(message) from exc
258
+ _warn(message)
259
+
260
+
261
+ def tail(cfg: Config, n: int = 20) -> list[dict]:
262
+ if not cfg.audit_log:
263
+ return []
264
+ path = _log_path(cfg)
265
+ if not path.is_file():
266
+ return []
267
+ lines = path.read_text(encoding="utf-8", errors="replace").splitlines()[-n:]
268
+ out = []
269
+ for ln in lines:
270
+ try:
271
+ out.append(json.loads(ln))
272
+ except json.JSONDecodeError:
273
+ continue
274
+ return out
275
+
276
+
277
+ @dataclass
278
+ class ChainReport:
279
+ events: int
280
+ broken_line: int | None = None
281
+ problem: str = ""
282
+ keyed: int = 0
283
+ legacy: int = 0
284
+
285
+ @property
286
+ def intact(self) -> bool:
287
+ return self.broken_line is None
288
+
289
+
290
+ def verify(cfg: Config) -> ChainReport:
291
+ """Walk the chain and report the first line that does not hold. Keyed
292
+ events are checked with the configured audit key; a keyed event without
293
+ that key, or under a different key, does not verify."""
294
+ key = audit_key(cfg)
295
+ if not cfg.audit_log or not _log_path(cfg).is_file():
296
+ return ChainReport(0)
297
+ kid = key_id(key) if key is not None else None
298
+ previous = GENESIS
299
+ events = keyed = legacy = 0
300
+ with _log_path(cfg).open(encoding="utf-8", errors="replace") as fh:
301
+ for number, line in enumerate(fh, 1):
302
+ try:
303
+ entry = json.loads(line)
304
+ claimed = entry["hash"]
305
+ linked = entry["prev"]
306
+ mac = entry.get("mac")
307
+ except (ValueError, KeyError, TypeError, AttributeError):
308
+ return ChainReport(events, number, "not a chained audit event", keyed, legacy)
309
+ if linked != previous:
310
+ return ChainReport(events, number, "does not follow the event before it", keyed, legacy)
311
+ if mac is None:
312
+ if keyed:
313
+ return ChainReport(events, number, "unkeyed event after keyed events", keyed, legacy)
314
+ if _digest(entry) != claimed:
315
+ return ChainReport(events, number, "content does not match its hash", keyed, legacy)
316
+ legacy += 1
317
+ elif mac == MAC:
318
+ if key is None:
319
+ return ChainReport(events, number, "keyed event, but no audit_key_file is configured",
320
+ keyed, legacy)
321
+ if entry.get("kid") != kid:
322
+ return ChainReport(events, number, "keyed under a different audit key", keyed, legacy)
323
+ if not hmac.compare_digest(_digest(entry, key), str(claimed)):
324
+ return ChainReport(events, number, "content does not match its keyed hash", keyed, legacy)
325
+ keyed += 1
326
+ else:
327
+ return ChainReport(events, number, f"unknown integrity scheme {mac!r}", keyed, legacy)
328
+ previous = claimed
329
+ events += 1
330
+ return ChainReport(events, keyed=keyed, legacy=legacy)