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/config.py
ADDED
|
@@ -0,0 +1,600 @@
|
|
|
1
|
+
"""Configuration loading.
|
|
2
|
+
|
|
3
|
+
phi-gate reads `.phi-gate.toml` for the project that a hook payload or a
|
|
4
|
+
command runs in. The project root is `CLAUDE_PROJECT_DIR` when the working
|
|
5
|
+
directory lies inside it, else the nearest enclosing git working tree, else
|
|
6
|
+
the working directory. The nearest `.phi-gate.toml` at or above that root is
|
|
7
|
+
the project configuration. A `.phi-gate.toml` in a subdirectory between the
|
|
8
|
+
root and the working directory can only make the policy stricter; see
|
|
9
|
+
`_merge`.
|
|
10
|
+
|
|
11
|
+
The file is meant to be edited by humans only: the Claude Code hooks refuse
|
|
12
|
+
agent edits to it and to every other file that can switch phi-gate off (see
|
|
13
|
+
`is_protected`), so an agent cannot loosen its own guardrails.
|
|
14
|
+
|
|
15
|
+
Unknown keys and bad values raise ConfigError. The hooks turn that into a
|
|
16
|
+
block (fail-closed), because a silently ignored typo in an allowlist or mode
|
|
17
|
+
setting is worse than a loud error.
|
|
18
|
+
|
|
19
|
+
A security team can also install a managed policy at a path developers and
|
|
20
|
+
agents cannot write to. It is a floor: a repository can tighten it but never
|
|
21
|
+
loosen it. Exceptions (`allow_paths`, `allow_values`, `disable`), the audit
|
|
22
|
+
log location and the prompt override token come from the policy alone unless
|
|
23
|
+
it sets `allow_repo_allowlists = true`. When a managed policy is installed,
|
|
24
|
+
the `PHI_GATE_CONFIG` and `PHI_GATE_FAIL_OPEN` environment overrides are
|
|
25
|
+
ignored unless it sets `allow_env_overrides = true`, and
|
|
26
|
+
`PHI_GATE_MANAGED_CONFIG` can only add a stricter layer.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import fnmatch
|
|
32
|
+
import importlib
|
|
33
|
+
import os
|
|
34
|
+
import re
|
|
35
|
+
from dataclasses import dataclass, field, fields
|
|
36
|
+
from pathlib import Path, PurePosixPath
|
|
37
|
+
from typing import Any
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
import tomllib
|
|
41
|
+
except ModuleNotFoundError: # pragma: no cover - Python < 3.11
|
|
42
|
+
tomllib = None # type: ignore[assignment]
|
|
43
|
+
|
|
44
|
+
from .detectors import CATEGORIES
|
|
45
|
+
|
|
46
|
+
CONFIG_NAME = ".phi-gate.toml"
|
|
47
|
+
STATE_DIR = ".phi-gate"
|
|
48
|
+
|
|
49
|
+
MODES = ("block", "warn", "audit")
|
|
50
|
+
OVERSIZE = ("block", "scan_head")
|
|
51
|
+
OPAQUE = ("block", "allow")
|
|
52
|
+
AUDIT_TARGETS = ("category", "hash", "path")
|
|
53
|
+
MANAGED_PATHS = ("/etc/phi-gate/policy.toml", "/Library/Application Support/phi-gate/policy.toml")
|
|
54
|
+
|
|
55
|
+
REGEX_PREFIX = "re:"
|
|
56
|
+
MAX_VALUE_REGEX = 200
|
|
57
|
+
|
|
58
|
+
# File names that can switch phi-gate or the agent's hooks off, at any depth.
|
|
59
|
+
PROTECTED_NAMES = frozenset({
|
|
60
|
+
CONFIG_NAME, ".mcp.json", ".claude.json", ".pre-commit-config.yaml", ".pre-commit-config.yml",
|
|
61
|
+
})
|
|
62
|
+
# Directories whose whole contents are protected, at any depth.
|
|
63
|
+
PROTECTED_DIRS = frozenset({STATE_DIR, ".claude"})
|
|
64
|
+
GIT_CONFIG_NAMES = frozenset({"config", "config.worktree"})
|
|
65
|
+
|
|
66
|
+
# Keys only a managed policy may set.
|
|
67
|
+
POLICY_ONLY = frozenset({"allow_repo_allowlists", "allow_env_overrides"})
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class ConfigError(Exception):
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class Config:
|
|
76
|
+
root: Path = field(default_factory=Path.cwd)
|
|
77
|
+
source: Path | None = None
|
|
78
|
+
mode: str = "block"
|
|
79
|
+
record_window: int = 400
|
|
80
|
+
max_bytes: int = 5_000_000
|
|
81
|
+
oversize: str = "block"
|
|
82
|
+
opaque: str = "allow"
|
|
83
|
+
allow_paths: list[str] = field(default_factory=list)
|
|
84
|
+
disable: list[str] = field(default_factory=list)
|
|
85
|
+
allow_values: list[str] = field(default_factory=list)
|
|
86
|
+
audit_log: str = ".phi-gate/audit.jsonl"
|
|
87
|
+
audit_key_file: str = ""
|
|
88
|
+
audit_targets: str = "hash"
|
|
89
|
+
protect_config: bool = True
|
|
90
|
+
prompt_override_token: str = "#phi-ok"
|
|
91
|
+
scan_tool_output: bool = True
|
|
92
|
+
warn_in_hooks: bool = False
|
|
93
|
+
allow_repo_allowlists: bool = False
|
|
94
|
+
allow_env_overrides: bool = False
|
|
95
|
+
managed: Path | None = None
|
|
96
|
+
managed_layers: list[Path] = field(default_factory=list)
|
|
97
|
+
layers: list[Path] = field(default_factory=list)
|
|
98
|
+
|
|
99
|
+
_allow_literals: frozenset[str] = field(default_factory=frozenset, repr=False, init=False)
|
|
100
|
+
_allow_value_rx: list[re.Pattern] = field(default_factory=list, repr=False, init=False)
|
|
101
|
+
_allow_globs: list[tuple[str, ...]] = field(default_factory=list, repr=False, init=False)
|
|
102
|
+
|
|
103
|
+
def __post_init__(self) -> None:
|
|
104
|
+
self.validate()
|
|
105
|
+
|
|
106
|
+
def validate(self) -> None:
|
|
107
|
+
for name in ("mode", "oversize", "opaque", "audit_log", "audit_key_file", "audit_targets",
|
|
108
|
+
"prompt_override_token"):
|
|
109
|
+
if not isinstance(getattr(self, name), str):
|
|
110
|
+
raise ConfigError(f"{name} must be a string")
|
|
111
|
+
for name in ("record_window", "max_bytes"):
|
|
112
|
+
value = getattr(self, name)
|
|
113
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
114
|
+
raise ConfigError(f"{name} must be an integer")
|
|
115
|
+
for name in ("protect_config", "scan_tool_output", "warn_in_hooks", "allow_repo_allowlists",
|
|
116
|
+
"allow_env_overrides"):
|
|
117
|
+
if not isinstance(getattr(self, name), bool):
|
|
118
|
+
raise ConfigError(f"{name} must be true or false")
|
|
119
|
+
for name in ("allow_paths", "disable", "allow_values"):
|
|
120
|
+
value = getattr(self, name)
|
|
121
|
+
if not (isinstance(value, list) and all(isinstance(x, str) for x in value)):
|
|
122
|
+
raise ConfigError(f"{name} must be a list of strings")
|
|
123
|
+
if self.mode not in MODES:
|
|
124
|
+
raise ConfigError(f"mode must be one of {MODES}, got {self.mode!r}")
|
|
125
|
+
if self.oversize not in OVERSIZE:
|
|
126
|
+
raise ConfigError(f"oversize must be one of {OVERSIZE}, got {self.oversize!r}")
|
|
127
|
+
if self.opaque not in OPAQUE:
|
|
128
|
+
raise ConfigError(f"opaque must be one of {OPAQUE}, got {self.opaque!r}")
|
|
129
|
+
if self.audit_targets not in AUDIT_TARGETS:
|
|
130
|
+
raise ConfigError(f"audit_targets must be one of {AUDIT_TARGETS}, got {self.audit_targets!r}")
|
|
131
|
+
if self.record_window < 0:
|
|
132
|
+
raise ConfigError("record_window must be a non-negative integer")
|
|
133
|
+
if self.max_bytes <= 0:
|
|
134
|
+
raise ConfigError("max_bytes must be a positive integer")
|
|
135
|
+
unknown = sorted(set(self.disable) - set(CATEGORIES))
|
|
136
|
+
if unknown:
|
|
137
|
+
raise ConfigError(f"disable lists unknown categories {unknown}; known: {CATEGORIES}")
|
|
138
|
+
literals, patterns = set(), []
|
|
139
|
+
for value in self.allow_values:
|
|
140
|
+
if value.startswith(REGEX_PREFIX):
|
|
141
|
+
patterns.append(compile_value_regex(value[len(REGEX_PREFIX):]))
|
|
142
|
+
else:
|
|
143
|
+
literals.add(value)
|
|
144
|
+
self._allow_literals = frozenset(literals)
|
|
145
|
+
self._allow_value_rx = patterns
|
|
146
|
+
self._allow_globs = [compile_path_glob(p) for p in self.allow_paths]
|
|
147
|
+
|
|
148
|
+
# ------------------------------------------------------------------
|
|
149
|
+
def is_allowed_value(self, value: str) -> bool:
|
|
150
|
+
return value in self._allow_literals or any(rx.fullmatch(value) for rx in self._allow_value_rx)
|
|
151
|
+
|
|
152
|
+
def _absolute(self, path: str | os.PathLike) -> Path:
|
|
153
|
+
p = Path(path).expanduser()
|
|
154
|
+
return p if p.is_absolute() else self.root / p
|
|
155
|
+
|
|
156
|
+
def inside_parts(self, path: str | os.PathLike) -> tuple[str, ...] | None:
|
|
157
|
+
"""The path's segments relative to the project root after resolving
|
|
158
|
+
symbolic links, or None when it lies outside the root."""
|
|
159
|
+
try:
|
|
160
|
+
rel = self._absolute(path).resolve().relative_to(self.root.resolve())
|
|
161
|
+
except (ValueError, OSError, RuntimeError):
|
|
162
|
+
return None
|
|
163
|
+
return PurePosixPath(rel).parts
|
|
164
|
+
|
|
165
|
+
def relpath(self, path: str | os.PathLike) -> str:
|
|
166
|
+
p = self._absolute(path)
|
|
167
|
+
try:
|
|
168
|
+
rel = p.resolve().relative_to(self.root.resolve())
|
|
169
|
+
except ValueError:
|
|
170
|
+
return p.resolve().as_posix()
|
|
171
|
+
return PurePosixPath(rel).as_posix()
|
|
172
|
+
|
|
173
|
+
def is_allowed_path(self, path: str | os.PathLike) -> bool:
|
|
174
|
+
"""Whether an `allow_paths` glob covers the path. Paths outside the
|
|
175
|
+
project root are never allowlisted."""
|
|
176
|
+
parts = self.inside_parts(path)
|
|
177
|
+
if not parts:
|
|
178
|
+
return False
|
|
179
|
+
return any(_glob_match(segments, parts) for segments in self._allow_globs)
|
|
180
|
+
|
|
181
|
+
def audit_log_path(self) -> Path | None:
|
|
182
|
+
if not self.audit_log:
|
|
183
|
+
return None
|
|
184
|
+
return self._absolute(self.audit_log)
|
|
185
|
+
|
|
186
|
+
def is_protected_path(self, path: str | os.PathLike) -> bool:
|
|
187
|
+
"""Whether writing the path could change or switch off phi-gate: its
|
|
188
|
+
configuration and state, Claude Code settings and MCP configuration,
|
|
189
|
+
git hooks and git configuration, pre-commit configuration, and the
|
|
190
|
+
audit log and audit key. Matching ignores case, because on macOS and
|
|
191
|
+
Windows `.PHI-GATE.TOML` is the same file."""
|
|
192
|
+
if not self.protect_config:
|
|
193
|
+
return False
|
|
194
|
+
literal = self._absolute(path)
|
|
195
|
+
try:
|
|
196
|
+
resolved = literal.resolve()
|
|
197
|
+
except (OSError, RuntimeError):
|
|
198
|
+
return True
|
|
199
|
+
for candidate in (literal, resolved):
|
|
200
|
+
if _protected_parts(self._parts_for(candidate)):
|
|
201
|
+
return True
|
|
202
|
+
special = {_fold(s) for path in self._special_paths() for s in (path, path.resolve())}
|
|
203
|
+
return _fold(literal) in special or _fold(resolved) in special
|
|
204
|
+
|
|
205
|
+
def _special_paths(self) -> list[Path]:
|
|
206
|
+
"""The audit log, the salt that keys hashed audit targets, and the
|
|
207
|
+
audit key file."""
|
|
208
|
+
out = []
|
|
209
|
+
log = self.audit_log_path()
|
|
210
|
+
if log is not None:
|
|
211
|
+
out += [log, log.with_name(log.name + ".salt")]
|
|
212
|
+
if self.audit_key_file:
|
|
213
|
+
out.append(Path(self.audit_key_file).expanduser())
|
|
214
|
+
return out
|
|
215
|
+
|
|
216
|
+
def is_secret_path(self, path: str | os.PathLike) -> bool:
|
|
217
|
+
"""Whether reading the path would hand the agent a secret phi-gate
|
|
218
|
+
relies on: the audit key, or the salt that keys hashed audit targets."""
|
|
219
|
+
try:
|
|
220
|
+
resolved = self._absolute(path).resolve()
|
|
221
|
+
except (OSError, RuntimeError):
|
|
222
|
+
return True
|
|
223
|
+
log = self.audit_log_path()
|
|
224
|
+
secrets = [p for p in self._special_paths() if log is None or p != log]
|
|
225
|
+
return _fold(resolved) in {_fold(s) for path in secrets for s in (path, path.resolve())}
|
|
226
|
+
|
|
227
|
+
def _parts_for(self, p: Path) -> tuple[str, ...]:
|
|
228
|
+
"""Segments relative to the project root when inside it (in either
|
|
229
|
+
its literal or its resolved spelling), else the absolute segments."""
|
|
230
|
+
for base in (self.root, self.root.resolve()):
|
|
231
|
+
try:
|
|
232
|
+
return PurePosixPath(p.relative_to(base)).parts
|
|
233
|
+
except ValueError:
|
|
234
|
+
continue
|
|
235
|
+
return PurePosixPath(p).parts
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _fold(path: Path) -> str:
|
|
239
|
+
return os.path.normcase(str(path)).lower()
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _protected_parts(parts: tuple[str, ...]) -> bool:
|
|
243
|
+
lowered = [part.lower() for part in parts if part not in ("/", "")]
|
|
244
|
+
if not lowered:
|
|
245
|
+
return False
|
|
246
|
+
for i in range(len(lowered) - 3):
|
|
247
|
+
if lowered[i] == ".claude" and lowered[i + 1] == "worktrees":
|
|
248
|
+
# A Claude Code worktree is a checkout of the project: its files
|
|
249
|
+
# are judged as the project's own, so only its protected files
|
|
250
|
+
# (and the worktrees folder itself) are refused.
|
|
251
|
+
return _protected_parts(tuple(lowered[:i])) or _protected_parts(tuple(lowered[i + 3:]))
|
|
252
|
+
if lowered[-1] in PROTECTED_NAMES:
|
|
253
|
+
return True
|
|
254
|
+
if PROTECTED_DIRS.intersection(lowered):
|
|
255
|
+
return True
|
|
256
|
+
for i, part in enumerate(lowered):
|
|
257
|
+
if part == ".git":
|
|
258
|
+
rest = lowered[i + 1:]
|
|
259
|
+
if not rest or "hooks" in rest or rest[-1] in GIT_CONFIG_NAMES:
|
|
260
|
+
return True
|
|
261
|
+
return False
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def is_protected(path: str | os.PathLike, cfg: Config) -> bool:
|
|
265
|
+
"""Whether an agent must be refused a write to `path`. The single check
|
|
266
|
+
the hooks and the shell analysis use for every write target."""
|
|
267
|
+
return cfg.is_protected_path(path)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def is_secret(path: str | os.PathLike, cfg: Config) -> bool:
|
|
271
|
+
"""Whether an agent must be refused a read of `path`."""
|
|
272
|
+
return cfg.is_secret_path(path)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
# ----------------------------------------------------------------------
|
|
276
|
+
# allow_paths globs
|
|
277
|
+
# ----------------------------------------------------------------------
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def compile_path_glob(pattern: str) -> tuple[str, ...]:
|
|
281
|
+
"""An `allow_paths` entry as path segments, read the way `.gitignore`
|
|
282
|
+
reads a pattern: `*`, `?` and `[...]` stay within one segment, `**`
|
|
283
|
+
spans any number of segments, a pattern with no `/` except a trailing one
|
|
284
|
+
matches at any depth, a leading `/` anchors to the project root, and a
|
|
285
|
+
trailing `/` covers everything below a directory."""
|
|
286
|
+
text = pattern.strip()
|
|
287
|
+
if not text:
|
|
288
|
+
raise ConfigError("allow_paths contains an empty pattern")
|
|
289
|
+
if "\\" in text:
|
|
290
|
+
raise ConfigError(f"allow_paths pattern {pattern!r} contains a backslash; use / between folders")
|
|
291
|
+
text = text.removeprefix("./")
|
|
292
|
+
anchored = text.startswith("/")
|
|
293
|
+
directory = text.endswith("/")
|
|
294
|
+
core = text.strip("/")
|
|
295
|
+
if not core:
|
|
296
|
+
raise ConfigError(f"allow_paths pattern {pattern!r} names no path")
|
|
297
|
+
segments: list[str] = []
|
|
298
|
+
for seg in core.split("/"):
|
|
299
|
+
if seg in ("", "."):
|
|
300
|
+
continue
|
|
301
|
+
if seg == "..":
|
|
302
|
+
raise ConfigError(f"allow_paths pattern {pattern!r} leaves the project root")
|
|
303
|
+
if "**" in seg and seg != "**":
|
|
304
|
+
seg = re.sub(r"\*{2,}", "*", seg)
|
|
305
|
+
if seg == "**" and segments[-1:] == ["**"]:
|
|
306
|
+
continue
|
|
307
|
+
segments.append(seg)
|
|
308
|
+
if not anchored and "/" not in core:
|
|
309
|
+
segments.insert(0, "**")
|
|
310
|
+
if directory and segments[-1:] != ["**"]:
|
|
311
|
+
segments.append("**")
|
|
312
|
+
return tuple(segments)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _glob_match(segments: tuple[str, ...], parts: tuple[str, ...]) -> bool:
|
|
316
|
+
"""Anchored match of glob segments against path segments. A dynamic
|
|
317
|
+
programme over (glob segment, path segment), so the cost is their
|
|
318
|
+
product however many `**` the glob holds."""
|
|
319
|
+
m = len(parts)
|
|
320
|
+
reach = [True] + [False] * m
|
|
321
|
+
for seg in segments:
|
|
322
|
+
nxt = [False] * (m + 1)
|
|
323
|
+
if seg == "**":
|
|
324
|
+
seen = False
|
|
325
|
+
for j in range(m + 1):
|
|
326
|
+
seen = seen or reach[j]
|
|
327
|
+
nxt[j] = seen
|
|
328
|
+
else:
|
|
329
|
+
for j in range(m):
|
|
330
|
+
if reach[j] and fnmatch.fnmatchcase(parts[j], seg):
|
|
331
|
+
nxt[j + 1] = True
|
|
332
|
+
reach = nxt
|
|
333
|
+
return reach[m]
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
# ----------------------------------------------------------------------
|
|
337
|
+
# allow_values regular expressions
|
|
338
|
+
# ----------------------------------------------------------------------
|
|
339
|
+
|
|
340
|
+
_REPEATS = {"MAX_REPEAT", "MIN_REPEAT", "POSSESSIVE_REPEAT"}
|
|
341
|
+
_REFUSED = {"GROUPREF", "GROUPREF_EXISTS", "GROUPREF_IGNORE", "GROUPREF_LOC_IGNORE", "GROUPREF_UNI_IGNORE"}
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _regex_parser() -> Any:
|
|
345
|
+
return importlib.import_module("re._parser")
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _check_regex_tree(items: Any, repeated: bool) -> None:
|
|
349
|
+
for op, av in items:
|
|
350
|
+
name = str(op)
|
|
351
|
+
if name in _REFUSED:
|
|
352
|
+
raise ConfigError("allow_values regex uses a backreference")
|
|
353
|
+
if name in _REPEATS:
|
|
354
|
+
low, high, sub = av
|
|
355
|
+
if repeated:
|
|
356
|
+
raise ConfigError("allow_values regex nests one quantifier inside another")
|
|
357
|
+
_check_regex_tree(sub, repeated or high != 1)
|
|
358
|
+
elif name == "BRANCH":
|
|
359
|
+
if repeated:
|
|
360
|
+
raise ConfigError("allow_values regex repeats an alternation")
|
|
361
|
+
for branch in av[1]:
|
|
362
|
+
_check_regex_tree(branch, repeated)
|
|
363
|
+
elif name == "SUBPATTERN":
|
|
364
|
+
_check_regex_tree(av[-1], repeated)
|
|
365
|
+
elif name in ("ASSERT", "ASSERT_NOT"):
|
|
366
|
+
_check_regex_tree(av[1], repeated)
|
|
367
|
+
elif name == "ATOMIC_GROUP":
|
|
368
|
+
_check_regex_tree(av, repeated)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def compile_value_regex(pattern: str) -> re.Pattern:
|
|
372
|
+
"""Compile an `allow_values` entry written as `re:<pattern>`. The
|
|
373
|
+
pattern is matched against every detected value, and a repository writes
|
|
374
|
+
it, so constructs that can backtrack exponentially are refused: a
|
|
375
|
+
quantifier inside another quantifier, a repeated alternation, and
|
|
376
|
+
backreferences. Patterns are limited to MAX_VALUE_REGEX characters."""
|
|
377
|
+
if len(pattern) > MAX_VALUE_REGEX:
|
|
378
|
+
raise ConfigError(f"allow_values regex is longer than {MAX_VALUE_REGEX} characters")
|
|
379
|
+
try:
|
|
380
|
+
tree = _regex_parser().parse(pattern)
|
|
381
|
+
compiled = re.compile(pattern)
|
|
382
|
+
except re.error as exc:
|
|
383
|
+
raise ConfigError(f"allow_values contains an invalid regex: {exc}") from exc
|
|
384
|
+
_check_regex_tree(tree, False)
|
|
385
|
+
return compiled
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
# ----------------------------------------------------------------------
|
|
389
|
+
# Reading and merging
|
|
390
|
+
# ----------------------------------------------------------------------
|
|
391
|
+
|
|
392
|
+
KEYS = {
|
|
393
|
+
"mode", "record_window", "max_bytes", "oversize", "opaque", "allow_paths", "disable",
|
|
394
|
+
"allow_values", "audit_log", "audit_key_file", "audit_targets", "protect_config",
|
|
395
|
+
"prompt_override_token", "scan_tool_output", "warn_in_hooks", "allow_repo_allowlists",
|
|
396
|
+
"allow_env_overrides",
|
|
397
|
+
}
|
|
398
|
+
DEFAULTS: dict[str, Any] = {
|
|
399
|
+
f.name: (f.default_factory() if callable(f.default_factory) else f.default) # type: ignore[misc]
|
|
400
|
+
for f in fields(Config)
|
|
401
|
+
if f.name in KEYS
|
|
402
|
+
}
|
|
403
|
+
LIST_KEYS = ("allow_paths", "allow_values", "disable")
|
|
404
|
+
# What `allow_repo_allowlists` lets a repository add under a policy: exceptions
|
|
405
|
+
# for named paths and values, never switching a detector off.
|
|
406
|
+
REPO_ALLOWLIST_KEYS = ("allow_paths", "allow_values")
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _read_settings(path: Path) -> dict:
|
|
410
|
+
if tomllib is None:
|
|
411
|
+
raise ConfigError("phi-gate needs Python 3.11+ to read .phi-gate.toml")
|
|
412
|
+
try:
|
|
413
|
+
data = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
414
|
+
except (OSError, UnicodeDecodeError, tomllib.TOMLDecodeError) as exc:
|
|
415
|
+
raise ConfigError(f"cannot parse {path}: {exc}") from exc
|
|
416
|
+
if "phi-gate" in data:
|
|
417
|
+
section = data["phi-gate"]
|
|
418
|
+
if not isinstance(section, dict):
|
|
419
|
+
raise ConfigError(f"{path}: phi-gate must be a table ([phi-gate])")
|
|
420
|
+
outside = sorted(set(data) - {"phi-gate"})
|
|
421
|
+
if outside:
|
|
422
|
+
raise ConfigError(f"{path}: keys outside the [phi-gate] table {outside}")
|
|
423
|
+
else:
|
|
424
|
+
section = data
|
|
425
|
+
unknown = sorted(set(section) - KEYS)
|
|
426
|
+
if unknown:
|
|
427
|
+
raise ConfigError(f"{path}: unknown keys {unknown}")
|
|
428
|
+
settings = dict(section)
|
|
429
|
+
try:
|
|
430
|
+
Config(**settings)
|
|
431
|
+
except TypeError as exc: # pragma: no cover - guarded by the key check
|
|
432
|
+
raise ConfigError(f"{path}: {exc}") from exc
|
|
433
|
+
except ConfigError as exc:
|
|
434
|
+
raise ConfigError(f"{path}: {exc}") from exc
|
|
435
|
+
return settings
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _stricter(order: tuple[str, ...], a: str, b: str) -> str:
|
|
439
|
+
return a if order.index(a) < order.index(b) else b
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _merge(base: dict, layer: dict, *, lists_from_layer: bool = False) -> dict:
|
|
443
|
+
"""Combine `layer` over `base` so that the result is never looser than
|
|
444
|
+
`base`. `base` holds every key (defaults filled in). Enumerations and
|
|
445
|
+
numeric limits take the stricter value, booleans the safer one. Lists,
|
|
446
|
+
the audit log, the audit key and policy-only keys stay as `base` has
|
|
447
|
+
them, except that with `lists_from_layer` the layer's `allow_paths` and
|
|
448
|
+
`allow_values` are added. A
|
|
449
|
+
layer may switch the prompt override off but never choose a token."""
|
|
450
|
+
merged = dict(base)
|
|
451
|
+
for key, value in layer.items():
|
|
452
|
+
if key == "mode":
|
|
453
|
+
merged[key] = _stricter(MODES, base[key], value)
|
|
454
|
+
elif key == "oversize":
|
|
455
|
+
merged[key] = _stricter(OVERSIZE, base[key], value)
|
|
456
|
+
elif key == "opaque":
|
|
457
|
+
merged[key] = _stricter(OPAQUE, base[key], value)
|
|
458
|
+
elif key == "audit_targets":
|
|
459
|
+
merged[key] = _stricter(AUDIT_TARGETS, base[key], value)
|
|
460
|
+
elif key == "max_bytes":
|
|
461
|
+
merged[key] = min(base[key], value)
|
|
462
|
+
elif key == "record_window":
|
|
463
|
+
merged[key] = max(base[key], value)
|
|
464
|
+
elif key in ("protect_config", "scan_tool_output", "warn_in_hooks"):
|
|
465
|
+
merged[key] = base[key] or value
|
|
466
|
+
elif key == "prompt_override_token":
|
|
467
|
+
if value == "":
|
|
468
|
+
merged[key] = ""
|
|
469
|
+
elif key in REPO_ALLOWLIST_KEYS and lists_from_layer:
|
|
470
|
+
merged[key] = list(dict.fromkeys([*base[key], *value]))
|
|
471
|
+
return merged
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def system_policy() -> Path | None:
|
|
475
|
+
"""The managed policy installed at a system path, if any. A path that
|
|
476
|
+
exists but is not a regular file is an error, not an absence."""
|
|
477
|
+
for candidate in map(Path, MANAGED_PATHS):
|
|
478
|
+
if candidate.exists():
|
|
479
|
+
if not candidate.is_file():
|
|
480
|
+
raise ConfigError(f"managed policy {candidate} is not a regular file")
|
|
481
|
+
return candidate
|
|
482
|
+
return None
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def env_overrides_permitted() -> bool:
|
|
486
|
+
"""Whether PHI_GATE_FAIL_OPEN, PHI_GATE_PYTHON, PHI_GATE_CONFIG and
|
|
487
|
+
PHI_GATE_MANAGED_CONFIG may take effect: always without a system managed
|
|
488
|
+
policy, and with one only if it sets `allow_env_overrides = true`. An
|
|
489
|
+
unreadable policy permits nothing."""
|
|
490
|
+
try:
|
|
491
|
+
policy = system_policy()
|
|
492
|
+
if policy is None:
|
|
493
|
+
return True
|
|
494
|
+
return _read_settings(policy).get("allow_env_overrides") is True
|
|
495
|
+
except ConfigError:
|
|
496
|
+
return False
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def find_managed() -> list[Path]:
|
|
500
|
+
"""Managed policy layers, strongest first. The system policy, when
|
|
501
|
+
installed, always applies; PHI_GATE_MANAGED_CONFIG then adds a layer that
|
|
502
|
+
can only tighten it. Without a system policy the variable names the
|
|
503
|
+
policy."""
|
|
504
|
+
layers: list[Path] = []
|
|
505
|
+
policy = system_policy()
|
|
506
|
+
if policy is not None:
|
|
507
|
+
layers.append(policy)
|
|
508
|
+
env = os.environ.get("PHI_GATE_MANAGED_CONFIG")
|
|
509
|
+
if env:
|
|
510
|
+
p = Path(env)
|
|
511
|
+
if not p.is_file():
|
|
512
|
+
raise ConfigError(f"PHI_GATE_MANAGED_CONFIG points to a missing file: {env}")
|
|
513
|
+
if not layers or p.resolve() != layers[0].resolve():
|
|
514
|
+
layers.append(p)
|
|
515
|
+
return layers
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def project_root(start: str | os.PathLike) -> Path:
|
|
519
|
+
"""CLAUDE_PROJECT_DIR when `start` lies inside it, else the nearest
|
|
520
|
+
directory at or above `start` holding a `.git` entry, else `start`."""
|
|
521
|
+
cur = Path(start).expanduser().resolve()
|
|
522
|
+
env = os.environ.get("CLAUDE_PROJECT_DIR")
|
|
523
|
+
if env:
|
|
524
|
+
project = Path(env).expanduser().resolve()
|
|
525
|
+
if project.is_dir() and (cur == project or project in cur.parents):
|
|
526
|
+
return project
|
|
527
|
+
for d in (cur, *cur.parents):
|
|
528
|
+
if (d / ".git").exists():
|
|
529
|
+
return d
|
|
530
|
+
return cur
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def find_config(start: Path) -> Path | None:
|
|
534
|
+
"""The project configuration: PHI_GATE_CONFIG when permitted, else the
|
|
535
|
+
nearest `.phi-gate.toml` at or above the project root."""
|
|
536
|
+
env = os.environ.get("PHI_GATE_CONFIG")
|
|
537
|
+
if env and env_overrides_permitted():
|
|
538
|
+
p = Path(env)
|
|
539
|
+
if not p.is_file():
|
|
540
|
+
raise ConfigError(f"PHI_GATE_CONFIG points to a missing file: {env}")
|
|
541
|
+
return p
|
|
542
|
+
root = project_root(start)
|
|
543
|
+
for d in (root, *root.parents):
|
|
544
|
+
candidate = d / CONFIG_NAME
|
|
545
|
+
if candidate.is_file():
|
|
546
|
+
return candidate
|
|
547
|
+
return None
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def nested_configs(root: Path, start: Path) -> list[Path]:
|
|
551
|
+
"""`.phi-gate.toml` files in directories below the project root down to
|
|
552
|
+
`start`, outermost first."""
|
|
553
|
+
cur = start.expanduser().resolve()
|
|
554
|
+
if cur == root or root not in cur.parents:
|
|
555
|
+
return []
|
|
556
|
+
chain = [d for d in (cur, *cur.parents) if root in d.parents]
|
|
557
|
+
return [d / CONFIG_NAME for d in reversed(chain) if (d / CONFIG_NAME).is_file()]
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def load(start: str | os.PathLike | None = None) -> Config:
|
|
561
|
+
start_path = Path(start) if start else Path.cwd()
|
|
562
|
+
root = project_root(start_path)
|
|
563
|
+
path = find_config(start_path)
|
|
564
|
+
managed = find_managed()
|
|
565
|
+
repo = _read_settings(path) if path else {}
|
|
566
|
+
nested = nested_configs(root, start_path)
|
|
567
|
+
nested_settings = [_read_settings(p) for p in nested]
|
|
568
|
+
|
|
569
|
+
if managed:
|
|
570
|
+
policies = [_read_settings(p) for p in managed]
|
|
571
|
+
effective = {**DEFAULTS, **policies[0]}
|
|
572
|
+
for extra in policies[1:]:
|
|
573
|
+
effective = _merge(effective, extra)
|
|
574
|
+
settings = _merge(effective, repo, lists_from_layer=effective["allow_repo_allowlists"])
|
|
575
|
+
else:
|
|
576
|
+
policy_only = sorted(POLICY_ONLY & set(repo))
|
|
577
|
+
if policy_only:
|
|
578
|
+
raise ConfigError(f"{path}: {policy_only} may only be set in a managed policy")
|
|
579
|
+
settings = {**DEFAULTS, **repo}
|
|
580
|
+
key_file = settings["audit_key_file"]
|
|
581
|
+
if key_file:
|
|
582
|
+
key_path = Path(key_file).expanduser()
|
|
583
|
+
if not key_path.is_absolute():
|
|
584
|
+
raise ConfigError(f"{path}: audit_key_file must be an absolute path")
|
|
585
|
+
resolved = key_path.resolve()
|
|
586
|
+
if resolved == root.resolve() or root.resolve() in resolved.parents:
|
|
587
|
+
raise ConfigError(f"{path}: audit_key_file must lie outside the project")
|
|
588
|
+
for extra in nested_settings:
|
|
589
|
+
settings = _merge(settings, extra)
|
|
590
|
+
return Config(
|
|
591
|
+
root=root, source=path, managed=managed[0] if managed else None,
|
|
592
|
+
managed_layers=managed, layers=nested, **settings,
|
|
593
|
+
)
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def load_for_payload(payload: dict) -> Config:
|
|
597
|
+
"""The configuration for a hook payload, from its `cwd` (or the
|
|
598
|
+
process's working directory when the payload carries none)."""
|
|
599
|
+
cwd = payload.get("cwd")
|
|
600
|
+
return load(cwd if isinstance(cwd, str) and cwd else None)
|