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/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)