gitpro 0.3.1__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.
gitpro/policy.py ADDED
@@ -0,0 +1,1808 @@
1
+ """Team policy (spec 14): an org's policy TOML (allowed email domains per remote owner, signing, trailer
2
+ rules), checked on this machine (`gitpro check --policy`, `gitpro team`), in the guard's hooks
3
+ (`hook_commit`, `hook_push`, `hook_message`, named by guard.HOOKS) and in CI (`gitpro check --policy FILE
4
+ --range A..B`, wrapped by the composite action.yml at the repo root).
5
+
6
+ Hot path: the guard's hooks import this module, so it imports only the standard library, gitpro.config,
7
+ gitpro.trailers and gitpro.resolve (loaded already by the guard; needed for resolve.EXTRA_CHECKS) at
8
+ module level. audit, guard, health, forge and agents are imported inside functions.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import os
14
+ import posixpath
15
+ import re
16
+ import subprocess
17
+ import sys
18
+ import tempfile
19
+ from dataclasses import dataclass, replace
20
+ from pathlib import Path
21
+ from typing import Dict, List, Optional, Sequence, Tuple
22
+
23
+ from . import config, resolve, trailers
24
+ from .config import ConfigError
25
+
26
+ PROG = "gitpro"
27
+ BLOCK = 3 # guard.BLOCK
28
+ GIT_TIMEOUT = 5 # guard.GIT_TIMEOUT: every git call inside a hook
29
+ RANGE_TIMEOUT = 120 # --range: one git log over a PR's commits (signature checks included)
30
+ REPO_POLICY = ".gitpro-policy.toml"
31
+ FORMATS = ("ssh", "gpg", "x509")
32
+ TOP_KEYS = ("policy", "rule")
33
+ POLICY_KEYS = ("name", "version", "contact", "allowed_signers")
34
+ RULE_KEYS = ("host", "owners", "allowed_email_domains", "allowed_emails", "require_signing", "signing_formats",
35
+ "co_authored_by", "forbid_emails", "require_verified_email")
36
+ VERSION = 1 # the newest policy format this gitpro understands
37
+ DOMAIN_RE = re.compile(r"^[A-Za-z0-9.-]+$")
38
+ REL_PATH_RE = re.compile(r"^[A-Za-z0-9._@+-][A-Za-z0-9._/@+-]*$")
39
+ SIGNED = ("G", "U", "X", "Y", "E", "R")
40
+ WARNINGS = {"X": "expired", "Y": "expired key", "E": "unverifiable here"}
41
+ GOOD = {"G": "good signature", "U": "good signature, unknown validity"}
42
+ _RANK = {"allow": 0, "require": 1, "forbid": 2} # rules disagree: forbid wins over require over allow
43
+
44
+
45
+ # ---------------------------------------------------------------- data
46
+
47
+ @dataclass(frozen=True)
48
+ class Rule:
49
+ host: str
50
+ owners: Tuple[str, ...] = ()
51
+ allowed_email_domains: Tuple[str, ...] = ()
52
+ allowed_emails: Tuple[str, ...] = ()
53
+ require_signing: bool = False
54
+ signing_formats: Tuple[str, ...] = ()
55
+ co_authored_by: str = "allow"
56
+ forbid_emails: Tuple[str, ...] = ()
57
+ require_verified_email: bool = False
58
+ index: int = 1 # position in the file, from 1
59
+
60
+ def to_json(self) -> dict:
61
+ return {"index": self.index, "host": self.host, "owners": list(self.owners),
62
+ "allowed_email_domains": list(self.allowed_email_domains),
63
+ "allowed_emails": list(self.allowed_emails), "require_signing": self.require_signing,
64
+ "signing_formats": list(self.signing_formats), "co_authored_by": self.co_authored_by,
65
+ "forbid_emails": list(self.forbid_emails), "require_verified_email": self.require_verified_email}
66
+
67
+ def emails(self) -> bool:
68
+ return bool(self.allowed_email_domains or self.allowed_emails)
69
+
70
+ def signing(self) -> bool:
71
+ return self.require_signing or bool(self.signing_formats)
72
+
73
+
74
+ @dataclass(frozen=True)
75
+ class Policy:
76
+ name: str
77
+ path: str # the file, ~ expanded
78
+ rules: Tuple[Rule, ...]
79
+ version: int = 1
80
+ contact: Optional[str] = None
81
+ allowed_signers: Optional[str] = None # absolute path of the ssh allowed_signers file it ships
82
+
83
+ def cause(self) -> str:
84
+ return f"policy: {self.name}" + (f" ({self.contact})" if self.contact else "")
85
+
86
+
87
+ Found = List[Tuple[Policy, List[Rule]]] # policies that apply, each with the rules that match
88
+
89
+
90
+ # ---------------------------------------------------------------- loading and validation
91
+
92
+ _CACHE: Dict[str, Tuple[Tuple[int, int], Policy]] = {}
93
+
94
+
95
+ def _tilde(path: object) -> str:
96
+ return resolve._tilde(str(path))
97
+
98
+
99
+ def _strings(v: object, where: str, key: str, errs: List[str], pattern: Optional["re.Pattern[str]"] = None,
100
+ allowed: Sequence[str] = ()) -> Tuple[str, ...]:
101
+ if v is None:
102
+ return ()
103
+ if not isinstance(v, list) or not all(isinstance(s, str) and s for s in v):
104
+ errs.append(f"{where}: {key} must be a list of strings")
105
+ return ()
106
+ for s in v:
107
+ if config.CONTROL_RE.search(s):
108
+ errs.append(f"{where}: {key} contains a control character")
109
+ elif pattern is not None and not pattern.match(s):
110
+ errs.append(f"{where}: {key} has an invalid entry {s!r}")
111
+ elif allowed and s not in allowed:
112
+ errs.append(f"{where}: {key} has {s!r} (allowed: {', '.join(allowed)})")
113
+ return tuple(v)
114
+
115
+
116
+ def _bool(v: object, where: str, key: str, errs: List[str]) -> bool:
117
+ if v is None:
118
+ return False
119
+ if not isinstance(v, bool):
120
+ errs.append(f"{where}: {key} must be true or false")
121
+ return False
122
+ return v
123
+
124
+
125
+ def _text(v: object, where: str, key: str, errs: List[str]) -> Optional[str]:
126
+ if v is None:
127
+ return None
128
+ if not isinstance(v, str) or not v.strip() or config.CONTROL_RE.search(v):
129
+ errs.append(f"{where}: {key} must be a non-empty string without control characters")
130
+ return None
131
+ return v
132
+
133
+
134
+ def _unknown(table: dict, allowed: Sequence[str], where: str, errs: List[str]) -> None:
135
+ extra = sorted(set(table) - set(allowed))
136
+ if extra:
137
+ errs.append(f"{where}: unknown key(s) {', '.join(extra)} (allowed: {', '.join(allowed)})")
138
+
139
+
140
+ def _within(path: object, root: object) -> bool:
141
+ """Does path, symlinks resolved, stay inside root (symlinks resolved)?"""
142
+ real, top = os.path.realpath(str(path)), os.path.realpath(str(root))
143
+ return real == top or real.startswith(top.rstrip(os.sep) + os.sep)
144
+
145
+
146
+ def _signers(v: object, path: Path, errs: List[str], *, base: Optional[Path] = None,
147
+ inside: Optional[Path] = None) -> Optional[str]:
148
+ """The allowed_signers file: relative to base (default: the policy's directory), or ~/... or /...
149
+ With inside (a policy that comes from a repo), it must be relative and stay inside that directory."""
150
+ if v is None:
151
+ return None
152
+ ok = isinstance(v, str) and (config.PATH_RE.match(v) or REL_PATH_RE.match(v)) and ".." not in v.split("/")
153
+ if not ok:
154
+ errs.append(f"{path}: [policy]: allowed_signers must be a path relative to the policy file, or start "
155
+ f"with ~/ or /")
156
+ return None
157
+ assert isinstance(v, str)
158
+ if inside is not None and (v.startswith("~/") or v.startswith("/")):
159
+ errs.append(f"{path}: [policy]: allowed_signers must be a path inside the repo, relative to the policy file "
160
+ f"(got {v})")
161
+ return None
162
+ p = Path(os.path.expanduser(v)) if v.startswith("~/") else (
163
+ Path(v) if v.startswith("/") else (base or path.parent) / v)
164
+ if inside is not None and not _within(p, inside):
165
+ errs.append(f"{path}: [policy]: allowed_signers {v} leaves the repo (a symlink?); refused")
166
+ return None
167
+ if not p.is_file():
168
+ errs.append(f"{path}: allowed_signers {p} not found")
169
+ return str(p)
170
+
171
+
172
+ def _rule(r: object, n: int, path: Path, errs: List[str]) -> Optional[Rule]:
173
+ where = f"{path}: [[rule]] {n}"
174
+ if not isinstance(r, dict):
175
+ errs.append(f"{where}: must be a table")
176
+ return None
177
+ _unknown(r, RULE_KEYS, where, errs)
178
+ host = r.get("host")
179
+ if not isinstance(host, str) or not config.HOST_RE.match(host):
180
+ errs.append(f"{where}: host must be a host name like github.com" if host is not None
181
+ else f"{where}: host is required")
182
+ host = ""
183
+ co = r.get("co_authored_by", "allow")
184
+ if co not in trailers.VALUES:
185
+ errs.append(f"{where}: co_authored_by must be one of {', '.join(trailers.VALUES)} (got {co!r})")
186
+ co = "allow"
187
+ return Rule(host=host,
188
+ owners=_strings(r.get("owners"), where, "owners", errs, config.OWNER_RE),
189
+ allowed_email_domains=_strings(r.get("allowed_email_domains"), where, "allowed_email_domains", errs,
190
+ DOMAIN_RE),
191
+ allowed_emails=_strings(r.get("allowed_emails"), where, "allowed_emails", errs),
192
+ require_signing=_bool(r.get("require_signing"), where, "require_signing", errs),
193
+ signing_formats=_strings(r.get("signing_formats"), where, "signing_formats", errs, allowed=FORMATS),
194
+ co_authored_by=co,
195
+ forbid_emails=_strings(r.get("forbid_emails"), where, "forbid_emails", errs),
196
+ require_verified_email=_bool(r.get("require_verified_email"), where, "require_verified_email", errs),
197
+ index=n)
198
+
199
+
200
+ def parse(data: dict, path: Path, *, signers_base: Optional[Path] = None,
201
+ inside: Optional[Path] = None) -> Tuple[Optional[Policy], List[str]]:
202
+ """(Policy, []) for a valid policy table, else (None, every error as "<path>: <where>: <problem>").
203
+ signers_base: where a relative allowed_signers is looked up (default: path's directory); inside: the
204
+ repo a repo policy comes from (its allowed_signers must stay inside)."""
205
+ errs: List[str] = []
206
+ _unknown(data, TOP_KEYS, str(path), errs)
207
+ head = data.get("policy", {})
208
+ if not isinstance(head, dict):
209
+ errs.append(f"{path}: [policy]: must be a table")
210
+ head = {}
211
+ _unknown(head, POLICY_KEYS, f"{path}: [policy]", errs)
212
+ name = _text(head.get("name"), f"{path}: [policy]", "name", errs) or path.name
213
+ contact = _text(head.get("contact"), f"{path}: [policy]", "contact", errs)
214
+ version = head.get("version", VERSION)
215
+ if not isinstance(version, int) or isinstance(version, bool) or version < 1:
216
+ errs.append(f"{path}: [policy]: version must be a whole number (1)")
217
+ version = VERSION
218
+ elif version > VERSION:
219
+ errs.append(f"{path}: [policy]: version {version} is newer than this gitpro understands ({VERSION}); "
220
+ f"upgrade gitpro")
221
+ signers = _signers(head.get("allowed_signers"), path, errs, base=signers_base, inside=inside)
222
+ raw = data.get("rule")
223
+ rules: List[Rule] = []
224
+ if raw is None:
225
+ errs.append(f"{path}: [[rule]]: at least one [[rule]] is required")
226
+ elif not isinstance(raw, list):
227
+ errs.append(f"{path}: rule: write rules as [[rule]]")
228
+ else:
229
+ for n, r in enumerate(raw, 1):
230
+ rule = _rule(r, n, path, errs)
231
+ if rule is not None:
232
+ rules.append(rule)
233
+ if errs:
234
+ return None, errs
235
+ return Policy(name, str(path), tuple(rules), version, contact, signers), []
236
+
237
+
238
+ def _toml(text: str, path: Path) -> dict:
239
+ """config._load_toml, with the parser's own failures on hostile input (a TOML nested 100 000 deep
240
+ exhausts the recursion limit) as ConfigError too, so they exit 2 like any invalid policy."""
241
+ try:
242
+ return config._load_toml(text, path)
243
+ except (RecursionError, MemoryError):
244
+ raise ConfigError(f"{path}: invalid TOML: nested too deeply") from None
245
+ except (ValueError, TypeError) as e:
246
+ raise ConfigError(f"{path}: invalid TOML: {e}") from None
247
+
248
+
249
+ def _read(path: Path, *, check_perms: bool, who: str) -> Tuple[os.stat_result, Path]:
250
+ try:
251
+ st = path.stat()
252
+ except FileNotFoundError:
253
+ raise ConfigError(f"policy {_tilde(path)}: not found" + (f" ({who})" if who else "")) from None
254
+ except OSError as e:
255
+ raise ConfigError(f"policy {_tilde(path)}: {e.strerror or e}") from None
256
+ if check_perms:
257
+ config.check(st.st_uid == os.getuid() and not st.st_mode & 0o022,
258
+ f"{path}: must be owned by you and not group/world-writable (chmod 600 {path})")
259
+ return st, path
260
+
261
+
262
+ def load(path: object, *, check_perms: bool = True, who: str = "", inside: Optional[Path] = None) -> Policy:
263
+ """The validated policy in path; ConfigError when it is missing, invalid or (check_perms, local use)
264
+ group/world-writable or not the user's. Cached by (path, mtime_ns, size). who names the referrer
265
+ ("identity work-github") in the not-found message. inside: a repo's own policy, which (and whose
266
+ allowed_signers) must not leave that directory through a symlink."""
267
+ p = Path(os.path.expanduser(str(path)))
268
+ if inside is not None and p.exists() and not _within(p, inside):
269
+ raise ConfigError(f"{p} leaves the repo (a symlink?); refused")
270
+ st, _ = _read(p, check_perms=check_perms, who=who)
271
+ key = (st.st_mtime_ns, st.st_size)
272
+ ckey = str(p) if inside is None else f"{p}\0{inside}"
273
+ hit = _CACHE.get(ckey)
274
+ if hit is not None and hit[0] == key:
275
+ return hit[1]
276
+ try:
277
+ text = p.read_text(encoding="utf-8")
278
+ except UnicodeError:
279
+ raise ConfigError(f"{p}: not UTF-8 text") from None
280
+ except OSError as e:
281
+ raise ConfigError(f"policy {_tilde(p)}: {e.strerror or e}") from None
282
+ data = _toml(text, p)
283
+ pol, errs = parse(data, p, inside=inside)
284
+ if pol is None:
285
+ raise ConfigError(errs[0])
286
+ _CACHE[ckey] = (key, pol)
287
+ return pol
288
+
289
+
290
+ def lint(path: object) -> Tuple[Optional[Policy], List[str]]:
291
+ """(policy, every error) for `policy lint`; no permission check, no cache."""
292
+ p = Path(os.path.expanduser(str(path)))
293
+ try:
294
+ text = p.read_text(encoding="utf-8")
295
+ except FileNotFoundError:
296
+ return None, [f"policy {_tilde(p)}: not found"]
297
+ except (OSError, UnicodeError) as e:
298
+ return None, [f"{p}: {e}"]
299
+ try:
300
+ data = _toml(text, p)
301
+ except ConfigError as e:
302
+ return None, [str(e)]
303
+ return parse(data, p)
304
+
305
+
306
+ # ---------------------------------------------------------------- which rules apply
307
+
308
+ def _glob(pattern: str, text: str) -> bool:
309
+ """An owner glob, case-insensitive, with gitpro's wildmatch rules: "*", "?" and [...] stay inside one
310
+ path segment; "**" as a whole segment crosses "/"."""
311
+ return resolve.wildmatch(pattern.casefold(), text.casefold())
312
+
313
+
314
+ def _owner_ok(globs: Sequence[str], owner: Optional[str]) -> bool:
315
+ """owner (maybe nested: group/sub) matches one of the globs, itself or through a parent group (a rule
316
+ for a GitLab group covers its subgroups). "*" never matches "/"."""
317
+ if not globs:
318
+ return True
319
+ if not owner:
320
+ return False
321
+ parts = owner.split("/")
322
+ prefixes = ["/".join(parts[:i]) for i in range(1, len(parts) + 1)]
323
+ return any(_glob(g, pre) for g in globs for pre in prefixes)
324
+
325
+
326
+ def _where(url: str, cfg: object = None) -> Tuple[Optional[str], Optional[str]]:
327
+ """(host, owner) of a remote url; an identity's ssh_alias host counts as the identity's real host."""
328
+ host, owner, _ = resolve.parse_url(url)
329
+ if host and cfg is not None:
330
+ for i in getattr(cfg, "identities", ()):
331
+ if i.ssh_alias and i.ssh_alias.casefold() == host.casefold():
332
+ host = i.host
333
+ return host, owner
334
+
335
+
336
+ def rule_matches(rule: Rule, url: str, cfg: object = None) -> bool:
337
+ host, owner = _where(url, cfg)
338
+ return bool(host) and rule.host.casefold() == host.casefold() and _owner_ok(rule.owners, owner)
339
+
340
+
341
+ def _overlaps(rule: Rule, ident: object) -> bool:
342
+ """Could rule apply to a repo of this identity (same host, owners overlapping)?"""
343
+ if rule.host.casefold() != str(getattr(ident, "host", "")).casefold():
344
+ return False
345
+ owners = tuple(getattr(ident, "owners", ()) or ())
346
+ if not rule.owners or not owners:
347
+ return True
348
+ return any(_owner_ok(rule.owners, o) or any(_glob(o, g) for g in rule.owners) for o in owners)
349
+
350
+
351
+ def match(policies: Sequence[Policy], urls: Sequence[str], cfg: object = None) -> Found:
352
+ out: Found = []
353
+ for pol in policies:
354
+ rules = [r for r in pol.rules if any(rule_matches(r, u, cfg) for u in urls)]
355
+ if rules:
356
+ out.append((pol, rules))
357
+ return out
358
+
359
+
360
+ def sources(cfg: object, identity: object = None, extra: Sequence[str] = ()) -> List[Tuple[str, str]]:
361
+ """(path, who) of every policy file that may apply: extra (--policy), [policy].files, identity.policy."""
362
+ out = [(str(p), "--policy") for p in extra]
363
+ out += [(f, "[policy] files") for f in getattr(getattr(cfg, "policy", None), "files", ()) or ()]
364
+ if identity is not None and getattr(identity, "policy", None):
365
+ out.append((identity.policy, f"identity {identity.id}"))
366
+ seen, uniq = set(), []
367
+ for path, who in out:
368
+ key = os.path.realpath(os.path.expanduser(path))
369
+ if key not in seen:
370
+ seen.add(key)
371
+ uniq.append((path, who))
372
+ return uniq
373
+
374
+
375
+ def _load_all(srcs: Sequence[Tuple[str, str]], check_perms: bool) -> List[Policy]:
376
+ return [load(p, check_perms=check_perms, who="" if who == "--policy" else who) for p, who in srcs]
377
+
378
+
379
+ def any_policy(cfg: object) -> bool:
380
+ """Does the config name a policy at all (no file is read)?"""
381
+ return bool(getattr(getattr(cfg, "policy", None), "files", ())) or \
382
+ any(getattr(i, "policy", None) for i in getattr(cfg, "identities", ()))
383
+
384
+
385
+ def _repo_urls(remotes: Dict[str, str], matched: Optional[str] = None) -> List[str]:
386
+ """The url the rules judge: the one that matched the identity, else origin, else every remote's."""
387
+ if matched:
388
+ return [matched]
389
+ if "origin" in remotes:
390
+ return [remotes["origin"]]
391
+ return list(remotes.values())
392
+
393
+
394
+ def _looks_like_url(s: str) -> bool:
395
+ return not os.path.isdir(s) and ("://" in s or bool(re.match(r"^[^/:]+:", s)))
396
+
397
+
398
+ def applicable(repo_or_url: object, cfg: object, *, extra: Sequence[str] = (), check_perms: bool = True) -> Found:
399
+ """The policies (with their matching rules) for a repo directory or a remote url: [policy].files plus
400
+ the matched identity's policy (plus extra paths, `--policy`). Rules match on the remote's host and
401
+ owner; an empty owners list matches every owner on the host."""
402
+ s = str(repo_or_url)
403
+ if _looks_like_url(s):
404
+ ident = resolve.match_url(s, cfg)[0] if cfg is not None else None
405
+ urls = [s]
406
+ else:
407
+ repo = Path(s)
408
+ ident, matched = None, None
409
+ if cfg is not None:
410
+ exp = resolve.expected(repo, cfg)
411
+ ident, matched = exp.match.identity, exp.match.url
412
+ urls = _repo_urls(resolve.remotes(repo), matched)
413
+ if not urls:
414
+ return []
415
+ return match(_load_all(sources(cfg, ident, extra), check_perms), urls, cfg)
416
+
417
+
418
+ # ---------------------------------------------------------------- the rules themselves
419
+
420
+ def email_problem(rules: Sequence[Rule], email: Optional[str]) -> Optional[Tuple[str, str]]:
421
+ """(rule key, message) for the first rule this author/committer email breaks, else None."""
422
+ if not email:
423
+ return None
424
+ for r in rules:
425
+ hit = next((g for g in r.forbid_emails if trailers.glob_match(g, email)), None)
426
+ if hit is not None:
427
+ return "forbid_emails", f"email {email} is forbidden (forbid_emails has \"{hit}\")"
428
+ domain = email.rpartition("@")[2].casefold()
429
+ for r in rules:
430
+ if not r.emails():
431
+ continue
432
+ if domain in {d.casefold() for d in r.allowed_email_domains}:
433
+ continue
434
+ if any(trailers.glob_match(g, email) for g in r.allowed_emails):
435
+ continue
436
+ shown = ", ".join(r.allowed_email_domains) or "only the addresses in allowed_emails"
437
+ return "allowed_email_domains", f"email domain {domain} not allowed ({shown})"
438
+ return None
439
+
440
+
441
+ def _domains(rules: Sequence[Rule]) -> List[str]:
442
+ return list(dict.fromkeys(d for r in rules for d in r.allowed_email_domains))
443
+
444
+
445
+ def trailer_policy(pol: Policy, rules: Sequence[Rule]) -> trailers.Policy:
446
+ """The trailer rules of these policy rules as a trailers.Policy (forbid > require > allow)."""
447
+ co = max((r.co_authored_by for r in rules), key=lambda v: _RANK[v], default="allow")
448
+ emails = tuple(dict.fromkeys(g for r in rules for g in r.forbid_emails))
449
+ return trailers.Policy(co, emails, f"policy {pol.name}", "[[rule]]", "[[rule]]")
450
+
451
+
452
+ def trailer_problems(pol: Policy, rules: Sequence[Rule], found: Sequence[trailers.Trailer],
453
+ merge: bool = False) -> List[Tuple[str, str]]:
454
+ """(rule key, message) per trailer violation; a merge needs no Co-Authored-By."""
455
+ tp = trailer_policy(pol, rules)
456
+ co = "allow" if merge and tp.co_authored_by == "require" else tp.co_authored_by
457
+ out = [("co_authored_by", m) for m in trailers.check(found, replace(tp, co_authored_by=co, forbid_emails=()))]
458
+ if co != "forbid" and tp.forbid_emails:
459
+ out += [("forbid_emails", m) for m in trailers.check(found, replace(tp, co_authored_by="allow"))]
460
+ return out
461
+
462
+
463
+ def _sig_format(body: bytes) -> Optional[str]:
464
+ """ssh | gpg | x509 | unknown from a raw commit's gpgsig header, None when it carries no signature."""
465
+ head = body.split(b"\n\n", 1)[0]
466
+ for line in head.split(b"\n"):
467
+ if line.startswith(b"gpgsig ") or line.startswith(b"gpgsig-sha256 "):
468
+ first = line.split(b" ", 1)[1]
469
+ if b"SSH SIGNATURE" in first:
470
+ return "ssh"
471
+ if b"PGP SIGNATURE" in first:
472
+ return "gpg"
473
+ if b"SIGNED MESSAGE" in first:
474
+ return "x509"
475
+ return "unknown"
476
+ return None
477
+
478
+
479
+ def classify(git_status: str, fmt: Optional[str], signer: str) -> str:
480
+ """The %G? status we judge: git says N for a signed commit when no allowed signers are configured and
481
+ U with no signer for an ssh key that is not in them; both are E (cannot check) here."""
482
+ if fmt is None:
483
+ return "N"
484
+ if git_status == "N":
485
+ return "E"
486
+ if git_status == "U" and fmt == "ssh" and not signer:
487
+ return "E"
488
+ return git_status or "E"
489
+
490
+
491
+ SSH_KEY_TYPES = ("ssh-ed25519", "ssh-rsa", "ssh-dss", "ecdsa-sha2-nistp256", "ecdsa-sha2-nistp384",
492
+ "ecdsa-sha2-nistp521", "sk-ssh-ed25519@openssh.com", "sk-ecdsa-sha2-nistp256@openssh.com")
493
+
494
+
495
+ def _ssh_fields(line: str) -> List[str]:
496
+ """Whitespace-separated fields of an allowed_signers line; double quotes group (and are removed)."""
497
+ out: List[str] = []
498
+ cur: List[str] = []
499
+ quoted = started = False
500
+ for ch in line:
501
+ if ch == '"':
502
+ quoted, started = not quoted, True
503
+ elif ch.isspace() and not quoted:
504
+ if started:
505
+ out.append("".join(cur))
506
+ cur, started = [], False
507
+ else:
508
+ cur.append(ch)
509
+ started = True
510
+ if started:
511
+ out.append("".join(cur))
512
+ return out
513
+
514
+
515
+ def _ssh_options(text: str) -> Dict[str, str]:
516
+ """name (lowercased) -> value of an options field: comma-separated, quotes may hold commas."""
517
+ out: Dict[str, str] = {}
518
+ cur: List[str] = []
519
+ quoted = False
520
+ for ch in text + ",":
521
+ if ch == '"':
522
+ quoted = not quoted
523
+ elif ch == "," and not quoted:
524
+ item = "".join(cur)
525
+ if item:
526
+ k, _, v = item.partition("=")
527
+ out[k.strip().lower()] = v
528
+ cur = []
529
+ else:
530
+ cur.append(ch)
531
+ return out
532
+
533
+
534
+ def ssh_fingerprint(blob_b64: str) -> Optional[str]:
535
+ """OpenSSH's SHA256 fingerprint of a base64 public key blob ("SHA256:" + unpadded base64)."""
536
+ import base64
537
+ import binascii
538
+ import hashlib
539
+ try:
540
+ blob = base64.b64decode(blob_b64.encode("ascii"), validate=True)
541
+ except (binascii.Error, ValueError, UnicodeError):
542
+ return None
543
+ return "SHA256:" + base64.b64encode(hashlib.sha256(blob).digest()).decode("ascii").rstrip("=")
544
+
545
+
546
+ def signer_principals(files: Sequence[str]) -> Dict[str, List[str]]:
547
+ """key fingerprint -> the principal patterns of every allowed_signers line for that key (lines whose
548
+ namespaces option excludes "git" and cert-authority lines are skipped). git records only the first
549
+ principal that verifies as %GS, so the committer is checked against this union instead."""
550
+ out: Dict[str, List[str]] = {}
551
+ for name in dict.fromkeys(files):
552
+ try:
553
+ text = Path(name).read_text(encoding="utf-8", errors="replace")
554
+ except OSError:
555
+ continue
556
+ for line in text.splitlines():
557
+ line = line.strip()
558
+ if not line or line.startswith("#"):
559
+ continue
560
+ f = _ssh_fields(line)
561
+ if len(f) < 3:
562
+ continue
563
+ if f[1] in SSH_KEY_TYPES:
564
+ opts, blob = {}, f[2]
565
+ elif len(f) >= 4 and f[2] in SSH_KEY_TYPES:
566
+ opts, blob = _ssh_options(f[1]), f[3]
567
+ else:
568
+ continue
569
+ if "cert-authority" in opts:
570
+ continue
571
+ ns = opts.get("namespaces")
572
+ if ns is not None and not _pattern_list_ok(ns.split(","), "git", fold=False):
573
+ continue
574
+ fp = ssh_fingerprint(blob)
575
+ if fp:
576
+ out.setdefault(fp, []).extend(p.strip() for p in f[0].split(",") if p.strip())
577
+ return out
578
+
579
+
580
+ def _pattern_list_ok(pats: Sequence[str], text: str, *, fold: bool = True) -> bool:
581
+ """OpenSSH pattern-list rules: a matching "!pattern" denies, else any matching pattern allows."""
582
+ match = trailers.glob_match if fold else (lambda g, t: resolve.wildmatch(g, t))
583
+ pats = [p.strip() for p in pats if p.strip()]
584
+ if any(p.startswith("!") and match(p[1:], text) for p in pats):
585
+ return False
586
+ return any(not p.startswith("!") and match(p, text) for p in pats)
587
+
588
+
589
+ def principal_ok(signer: object, email: Optional[str]) -> bool:
590
+ """Does the committer email match the signer's principal patterns (a list, or one comma-separated
591
+ string; ssh pattern-list rules, "!" negates; case-insensitive)? git itself does not compare them: a key
592
+ listed for alice@ verifies a commit whose committer claims to be bob@ as G."""
593
+ raw = signer.split(",") if isinstance(signer, str) else list(signer or ())
594
+ pats = [p.strip() for p in raw if p and p.strip()]
595
+ if not email or not pats:
596
+ return False
597
+ if any(p.startswith("!") and trailers.glob_match(p[1:], email) for p in pats):
598
+ return False
599
+ return any(not p.startswith("!") and trailers.glob_match(p, email) for p in pats)
600
+
601
+
602
+ def signing_problems(rules: Sequence[Rule], status: str, fmt: Optional[str], ships_signers: bool,
603
+ merge: bool = False, *, signer: str = "", committer: str = "",
604
+ principals: Optional[Sequence[str]] = None) -> List[Tuple[str, str]]:
605
+ """(rule key, message) per signing violation of one commit. With the policy's allowed_signers, an ssh
606
+ signature must also come from a principal matching the committer email (rule allowed_signers):
607
+ principals are the patterns of the key's allowed_signers lines (signer_principals), else %GS alone."""
608
+ out: List[Tuple[str, str]] = []
609
+ required = any(r.require_signing for r in rules)
610
+ if required:
611
+ if status == "N":
612
+ out.append(("require_signing", "not signed (require_signing)"))
613
+ elif status == "B":
614
+ out.append(("require_signing", "bad signature (require_signing)"))
615
+ elif status == "R":
616
+ out.append(("require_signing", "signed with a revoked key"))
617
+ elif status == "E" and ships_signers:
618
+ if fmt == "ssh":
619
+ out.append(("require_signing", "signed by a key that is not in the policy's allowed_signers"))
620
+ else:
621
+ out.append(("require_signing", f"{fmt or 'unknown'} signature cannot be checked: the policy's "
622
+ "allowed_signers lists ssh keys only"))
623
+ pats = list(principals) if principals else ([signer] if signer else [])
624
+ if ships_signers and fmt == "ssh" and status in ("G", "U") and pats and not principal_ok(pats, committer):
625
+ out.append(("allowed_signers", f"signed by {','.join(pats)} (allowed_signers), but committed as {committer}"))
626
+ if fmt is not None:
627
+ for r in rules:
628
+ if r.signing_formats and fmt not in r.signing_formats:
629
+ out.append(("signing_formats", f"format {fmt} not allowed ({', '.join(r.signing_formats)})"))
630
+ break
631
+ return out
632
+
633
+
634
+ # ---------------------------------------------------------------- git
635
+
636
+ def _git(repo: Path, *argv: str, pre: Sequence[str] = (), stdin: Optional[bytes] = None,
637
+ timeout: int = GIT_TIMEOUT, what: str = "") -> bytes:
638
+ try:
639
+ r = subprocess.run(["git", *pre, "-C", str(repo), *argv], input=stdin, capture_output=True,
640
+ env=resolve._child_env(), timeout=timeout,
641
+ **({} if stdin is not None else {"stdin": subprocess.DEVNULL}))
642
+ except FileNotFoundError:
643
+ raise ConfigError("git not found") from None
644
+ except subprocess.TimeoutExpired:
645
+ raise ConfigError(f"{what or 'git ' + argv[0]} timed out after {timeout}s") from None
646
+ if r.returncode:
647
+ err = r.stderr.decode("utf-8", "replace").strip().splitlines()
648
+ raise ConfigError(f"{what or 'git ' + argv[0]} failed: {err[-1] if err else r.returncode}")
649
+ return r.stdout
650
+
651
+
652
+ def _formats(repo: Path, shas: Sequence[str], timeout: int) -> Dict[str, Optional[str]]:
653
+ """sha -> signature format from the raw commit headers (one `git cat-file --batch`)."""
654
+ if not shas:
655
+ return {}
656
+ out = _git(repo, "cat-file", "--batch", stdin="".join(s + "\n" for s in shas).encode(), timeout=timeout)
657
+ res: Dict[str, Optional[str]] = {}
658
+ pos = 0
659
+ while pos < len(out):
660
+ nl = out.find(b"\n", pos)
661
+ if nl < 0:
662
+ break
663
+ head = out[pos:nl].split()
664
+ pos = nl + 1
665
+ if len(head) < 3:
666
+ continue
667
+ size = int(head[2])
668
+ res[head[0].decode()] = _sig_format(out[pos:pos + size])
669
+ pos += size + 1
670
+ return res
671
+
672
+
673
+ @dataclass
674
+ class _Commit:
675
+ sha: str
676
+ status: str
677
+ key: str
678
+ signer: str
679
+ author_email: str
680
+ committer_email: str
681
+ date: str
682
+ parents: Tuple[str, ...]
683
+ trailers: List[trailers.Trailer]
684
+ subject: str = ""
685
+ fmt: Optional[str] = None
686
+
687
+
688
+ def _signers_args(found: Found, tmp: List[str]) -> Tuple[List[str], bool]:
689
+ """-c gpg.ssh.allowedSignersFile=... for the allowed_signers the policies ship (several: one temp file)."""
690
+ files = list(dict.fromkeys(p.allowed_signers for p, _ in found if p.allowed_signers))
691
+ if not files:
692
+ return [], False
693
+ path = files[0]
694
+ if len(files) > 1:
695
+ fd, path = tempfile.mkstemp(prefix="gitpro-signers-")
696
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
697
+ for name in files:
698
+ f.write(Path(name).read_text(encoding="utf-8", errors="replace").rstrip("\n") + "\n")
699
+ tmp.append(path)
700
+ return ["-c", f"gpg.ssh.allowedSignersFile={path}"], True
701
+
702
+
703
+ _FIELDS = ("%H", "%G?", "%GK", "%GS", "%ae", "%ce", "%cs", "%P", "%s", "%(trailers:only,unfold)")
704
+
705
+
706
+ def _log(repo: Path, revs: Sequence[str], *, signing: bool, pre: Sequence[str], timeout: int) -> List[_Commit]:
707
+ # Count and identify records independently of untrusted commit text.
708
+ shas = _git(repo, "rev-list", *revs, "--", timeout=timeout).decode("ascii").splitlines()
709
+ if not shas:
710
+ return []
711
+ fields = [f if signing or f not in ("%G?", "%GK", "%GS") else "-" for f in _FIELDS]
712
+ fmt = "%x00".join(fields)
713
+ raw = _git(repo, "log", "-z", "--no-color", "--no-show-signature", f"--format={fmt}",
714
+ "--no-walk=unsorted", "--stdin", pre=pre, stdin="".join(s + "\n" for s in shas).encode(),
715
+ timeout=timeout, what=f"git log {' '.join(revs)}")
716
+ parts = raw.split(b"\0")
717
+ if parts[-1] != b"" or len(parts) != len(shas) * len(fields) + 1:
718
+ raise ConfigError("cannot check policy: malformed commit records from git log")
719
+ out: List[_Commit] = []
720
+ for n, sha in enumerate(shas):
721
+ f = [p.decode("utf-8", "replace") for p in parts[n * len(fields):(n + 1) * len(fields)]]
722
+ if f[0] != sha:
723
+ raise ConfigError(f"cannot check policy: git log did not return expected commit {sha}")
724
+ out.append(_Commit(f[0], f[1] if signing else "", f[2] if signing else "", f[3] if signing else "",
725
+ f[4], f[5], f[6], tuple(f[7].split()), trailers.split_lines(f[9]), f[8]))
726
+ if signing:
727
+ fmts = _formats(repo, [c.sha for c in out], timeout)
728
+ for c in out:
729
+ c.fmt = fmts.get(c.sha)
730
+ c.status = classify(c.status, c.fmt, c.signer)
731
+ return out
732
+
733
+
734
+ def _judge(c: _Commit, found: Found, signing: bool, ships: bool,
735
+ by_key: Optional[Dict[str, List[str]]] = None) -> List[dict]:
736
+ """Every violation of one commit: {"policy", "rule", "which", "message"}. by_key: signer_principals."""
737
+ out: List[dict] = []
738
+ merge = len(c.parents) > 1
739
+ for pol, rules in found:
740
+ a = email_problem(rules, c.author_email)
741
+ k = email_problem(rules, c.committer_email)
742
+ if a and k and a == k:
743
+ out.append({"policy": pol.name, "rule": a[0], "which": "both", "message": a[1]})
744
+ else:
745
+ if a:
746
+ out.append({"policy": pol.name, "rule": a[0], "which": "author", "message": a[1]})
747
+ if k:
748
+ out.append({"policy": pol.name, "rule": k[0], "which": "committer", "message": "committer " + k[1]})
749
+ if signing:
750
+ for key, msg in signing_problems(rules, c.status, c.fmt, ships, merge, signer=c.signer,
751
+ committer=c.committer_email,
752
+ principals=(by_key or {}).get(c.key)):
753
+ out.append({"policy": pol.name, "rule": key, "which": "signature", "message": msg})
754
+ for key, msg in trailer_problems(pol, rules, c.trailers, merge):
755
+ out.append({"policy": pol.name, "rule": key, "which": "trailer", "message": msg})
756
+ return out
757
+
758
+
759
+ # ---------------------------------------------------------------- CI mode: --range
760
+
761
+ def _empty_range(text: str) -> bool:
762
+ return text.strip() in ("", "..", "...")
763
+
764
+
765
+ def check_range(repo: Path, rng: str, found: Found, *, timeout: int = RANGE_TIMEOUT) -> dict:
766
+ """The commits of rng in repo judged against found: {"commits", "violations", "signing", "rows"}."""
767
+ from . import audit
768
+ if _empty_range(rng):
769
+ raise ConfigError(f"commit range is empty ({rng!r}); pass --range BASE..HEAD")
770
+ revs = audit.range_tokens(rng)
771
+ signing = any(r.signing() for _, rules in found for r in rules)
772
+ tmp: List[str] = []
773
+ try:
774
+ pre, ships = _signers_args(found, tmp) if signing else ([], False)
775
+ commits = _log(repo, revs, signing=signing, pre=pre, timeout=timeout)
776
+ finally:
777
+ for t in tmp:
778
+ try:
779
+ os.unlink(t)
780
+ except OSError:
781
+ pass
782
+ by_key = signer_principals([p.allowed_signers for p, _ in found if p.allowed_signers]) if ships else {}
783
+ violations, sig, rows = [], [], []
784
+ for c in commits:
785
+ found_v = _judge(c, found, signing, ships, by_key)
786
+ for v in found_v:
787
+ violations.append({"sha": c.sha, "author_email": c.author_email, "committer_email": c.committer_email,
788
+ **v})
789
+ if signing:
790
+ sig.append({"sha": c.sha, "status": c.status, "format": c.fmt, "signed": c.status in SIGNED,
791
+ "warning": WARNINGS.get(c.status)})
792
+ rows.append((c, found_v))
793
+ unverifiable = sum(1 for c in commits if signing and c.status == "E")
794
+ return {"commits": len(commits), "violations": violations, "signing": sig, "rows": rows,
795
+ "signing_checked": signing, "unverifiable": unverifiable}
796
+
797
+
798
+ def _ok_text(c: _Commit, signing: bool) -> str:
799
+ if not signing or c.status not in SIGNED:
800
+ return "✓"
801
+ what = c.fmt or "unknown format"
802
+ if c.status in GOOD:
803
+ return f"✓ signed ({what}, {GOOD[c.status]})"
804
+ return f"✓ signed ({what}) ⚠ {WARNINGS.get(c.status, c.status)}"
805
+
806
+
807
+ def _render_range(data: dict, names: str, where: str, rng: str, contact: str) -> List[str]:
808
+ n = data["commits"]
809
+ lines = [f"policy {names} for {where}, {n} commit{'s' if n != 1 else ''} in {rng}"]
810
+ pad = " " * (2 + 7 + 2 + 10 + 2 + 22)
811
+ bad = 0
812
+ for c, found_v in data["rows"]:
813
+ lead = f" {c.sha[:7]} {c.date:<10} {c.author_email:<21} "
814
+ if not found_v:
815
+ lines.append(lead + _ok_text(c, data["signing_checked"]))
816
+ continue
817
+ bad += 1
818
+ lines.append(lead + f"✗ {found_v[0]['message']}")
819
+ lines += [pad + f"✗ {v['message']}" for v in found_v[1:]]
820
+ tail = f"; contact {contact}" if contact else ""
821
+ if bad:
822
+ lines.append(f"{bad} of {n} commit{'s' if n != 1 else ''} violate{'s' if n == 1 else ''} the policy{tail}")
823
+ else:
824
+ lines.append(f"✓ {n} commit{'s' if n != 1 else ''} meet{'s' if n == 1 else ''} the policy")
825
+ k = data.get("unverifiable", 0)
826
+ if k:
827
+ lines.append(f"⚠ {k} signature{'s' if k != 1 else ''} could not be verified in this environment (key not "
828
+ f"known here; gpg/x509 use this machine's keyring, ssh the policy's allowed_signers)")
829
+ return lines
830
+
831
+
832
+ def _config_or_none(args, warnings: Optional[List[str]] = None) -> Optional[object]:
833
+ """The user's config, None when there is none. A broken one raises, or with warnings (range mode)
834
+ is noted there and skipped."""
835
+ try:
836
+ path, how = config.find(getattr(args, "config", None), Path(getattr(args, "home", None) or Path.home()))
837
+ except ConfigError:
838
+ return None
839
+ if not path.is_file() and how != "--config":
840
+ return None
841
+ try:
842
+ return config.load_all(path, how)
843
+ except ConfigError as e:
844
+ if warnings is None:
845
+ raise
846
+ warnings.append(f"config not used: {e}")
847
+ return None
848
+
849
+
850
+ def _need_repo(path: object) -> Path:
851
+ top = resolve.is_repo(Path(path))
852
+ if top is None:
853
+ raise ConfigError(f"{path}: not a git repository")
854
+ return top
855
+
856
+
857
+ def _show_where(url: Optional[str], repo: Path) -> str:
858
+ if not url:
859
+ return _tilde(repo)
860
+ host, owner, name = resolve.parse_url(url)
861
+ return "/".join(p for p in (host, owner, name) if p) or (resolve.redact_url(url) or "")
862
+
863
+
864
+ def _range_base(repo: Path, revs: Sequence[str]) -> Optional[str]:
865
+ """The one commit the range excludes (A of A..B, the merge base of A...B), else None."""
866
+ out = _git(repo, "rev-parse", "--revs-only", *revs, what="git rev-parse").decode("utf-8", "replace").split()
867
+ neg = list(dict.fromkeys(x[1:] for x in out if x.startswith("^")))
868
+ return neg[0] if len(neg) == 1 else None
869
+
870
+
871
+ def _base_blob(repo: Path, base: str, rel: str) -> Optional[bytes]:
872
+ """The file rel as committed in base; None when base has no such path. A symlink or a directory there
873
+ is refused (a repo policy must not point outside the repo)."""
874
+ out = _git(repo, "ls-tree", "-z", base, "--", rel).decode("utf-8", "replace")
875
+ entries = [e for e in out.split("\0") if e]
876
+ entry = next((e for e in entries if e.split("\t", 1)[-1] == rel), None)
877
+ if entry is None:
878
+ return None
879
+ mode = entry.split(None, 1)[0]
880
+ if mode == "120000":
881
+ raise ConfigError(f"{rel} is a symlink in {base[:7]}; a repo policy file must be a regular file inside the repo")
882
+ if not mode.startswith("100"):
883
+ raise ConfigError(f"{rel} in {base[:7]} is not a file")
884
+ return _git(repo, "cat-file", "blob", f"{base}:{rel}")
885
+
886
+
887
+ class _RangePolicies:
888
+ """Loads the policies of a --range run. A policy file inside the repo (and the allowed_signers it
889
+ names) is read from the range's base commit, never from the checkout the range produced: a pull
890
+ request cannot widen the policy that judges it."""
891
+
892
+ def __init__(self, repo: Path, revs: Sequence[str], tmp: str, warnings: List[str]):
893
+ self.repo, self.revs, self.tmp, self.warnings = repo, list(revs), Path(tmp), warnings
894
+ self._base: Optional[str] = None
895
+ self.added = False
896
+
897
+ def base(self, rel: str) -> str:
898
+ if self._base is None:
899
+ self._base = _range_base(self.repo, self.revs)
900
+ if self._base is None:
901
+ raise ConfigError(f"the range {' '.join(self.revs)} has no single base commit to read {rel} from; use "
902
+ f"BASE..HEAD, or pass --policy FILE from outside the repo")
903
+ return self._base
904
+
905
+ def rel(self, path: object) -> Optional[str]:
906
+ """Resolve external aliases only until entering the repo; checkout symlinks cannot rename a
907
+ policy's protected-base entry."""
908
+ p = os.path.abspath(os.path.expanduser(str(path)))
909
+ tops = tuple(dict.fromkeys((os.path.abspath(self.repo), os.path.realpath(self.repo))))
910
+ for _ in range(40):
911
+ for top in tops:
912
+ if p.startswith(top.rstrip(os.sep) + os.sep):
913
+ return Path(os.path.relpath(p, top)).as_posix()
914
+ current = Path(p)
915
+ for ancestor in (*reversed(current.parents), current):
916
+ if ancestor.is_symlink():
917
+ try:
918
+ target = Path(os.readlink(ancestor))
919
+ except OSError as e:
920
+ raise ConfigError(f"cannot resolve policy path {path}: {e}") from None
921
+ if not target.is_absolute():
922
+ target = ancestor.parent / target
923
+ p = os.path.abspath(target / current.relative_to(ancestor))
924
+ break
925
+ else:
926
+ return None
927
+ raise ConfigError(f"cannot resolve policy path {path}: too many symlinks")
928
+
929
+ def changed(self, rels: Sequence[str], base: str) -> List[str]:
930
+ """Which of rels the range touches (a commit in it, merges included against their first parent, or
931
+ the checkout differs from base)."""
932
+ out = _git(self.repo, "log", "--format=", "--name-only", "--diff-merges=first-parent", *self.revs, "--",
933
+ *rels, timeout=RANGE_TIMEOUT)
934
+ hit = {x.strip() for x in out.decode("utf-8", "replace").splitlines() if x.strip()}
935
+ for rel in rels:
936
+ if rel in hit:
937
+ continue
938
+ wt = self.repo / rel
939
+ try:
940
+ now = wt.read_bytes() if wt.is_file() and not wt.is_symlink() else None
941
+ except OSError:
942
+ now = None
943
+ if now is not None and now != _base_blob(self.repo, base, rel):
944
+ hit.add(rel)
945
+ return [r for r in rels if r in hit]
946
+
947
+ def load(self, path: object, who: str = "", *, optional: bool = False) -> Optional[Policy]:
948
+ rel = self.rel(path)
949
+ if rel is None:
950
+ return load(path, check_perms=False, who=who)
951
+ try:
952
+ base = self.base(rel)
953
+ except ConfigError:
954
+ if optional and not (self.repo / rel).exists():
955
+ return None
956
+ raise
957
+ shown = self.repo / rel
958
+ blob = _base_blob(self.repo, base, rel)
959
+ if blob is None:
960
+ if (self.repo / rel).exists() or self.changed([rel], base):
961
+ self.warnings.append(f"{rel}: policy added in this range; not enforced until merged")
962
+ self.added = True
963
+ return None
964
+ if optional:
965
+ return None
966
+ raise ConfigError(f"policy {rel}: not found in the range base {base[:7]}" + (f" ({who})" if who else ""))
967
+ try:
968
+ text = blob.decode("utf-8")
969
+ except UnicodeError:
970
+ raise ConfigError(f"{shown}: not UTF-8 text (at {base[:7]})") from None
971
+ data = _toml(text, shown)
972
+ root = self.tmp / f"base{len(list(self.tmp.iterdir()))}"
973
+ here = posixpath.dirname(rel)
974
+ head = data.get("policy")
975
+ v = head.get("allowed_signers") if isinstance(head, dict) else None
976
+ srel = None
977
+ if isinstance(v, str) and REL_PATH_RE.match(v) and ".." not in v.split("/"):
978
+ srel = posixpath.normpath(posixpath.join(here, v))
979
+ sblob = _base_blob(self.repo, base, srel)
980
+ if sblob is None:
981
+ raise ConfigError(f"{shown}: allowed_signers {self.repo / srel} not found in the range base {base[:7]}")
982
+ dst = root / srel
983
+ dst.parent.mkdir(parents=True, exist_ok=True)
984
+ dst.write_bytes(sblob)
985
+ root.mkdir(parents=True, exist_ok=True)
986
+ pol, errs = parse(data, shown, signers_base=root / here if here else root, inside=root)
987
+ if pol is None:
988
+ raise ConfigError(errs[0])
989
+ for f in self.changed([rel] + ([srel] if srel else []), base):
990
+ self.warnings.append(f"this range changes {f}; it is judged by the base version ({base[:7]})")
991
+ return pol
992
+
993
+
994
+ def _cmd_range(args) -> int:
995
+ from . import audit, jsonout
996
+ if _empty_range(args.range):
997
+ raise ConfigError(f"commit range is empty ({args.range!r}); pass --range BASE..HEAD")
998
+ revs = audit.range_tokens(args.range)
999
+ repo = _need_repo(args.repo or os.getcwd())
1000
+ rems = resolve.remotes(repo)
1001
+ urls = _repo_urls(rems)
1002
+ url = urls[0] if urls else None
1003
+ shown = resolve.redact_url(url) if url else _tilde(repo)
1004
+ hint = "; pass --policy FILE or add .gitpro-policy.toml"
1005
+ warnings: List[str] = []
1006
+ with tempfile.TemporaryDirectory(prefix="gitpro-policy-") as tmp:
1007
+ src = _RangePolicies(repo, revs, tmp, warnings)
1008
+ if args.policy_files:
1009
+ pols = [p for p in (src.load(f) for f in args.policy_files) if p is not None]
1010
+ found = match(pols, urls)
1011
+ if pols and not found:
1012
+ raise ConfigError(f"no policy applies to {shown} (no rule of {', '.join(args.policy_files)} matches)")
1013
+ else:
1014
+ found = []
1015
+ cfg = _config_or_none(args, warnings)
1016
+ if cfg is not None and urls:
1017
+ ident = resolve.match_url(url, cfg)[0]
1018
+ pols = [p for p in (src.load(f, who) for f, who in sources(cfg, ident)) if p is not None]
1019
+ found = match(pols, urls, cfg)
1020
+ if not found:
1021
+ pol = src.load(repo / REPO_POLICY, optional=True)
1022
+ if pol is not None:
1023
+ found = match([pol], urls)
1024
+ if not found and not src.added:
1025
+ raise ConfigError(f"no policy applies to {shown}{hint}")
1026
+ for w in warnings:
1027
+ print(f"⚠ {w}", file=sys.stderr)
1028
+ data = check_range(repo, args.range, found)
1029
+ contact = ", ".join(dict.fromkeys(p.contact for p, _ in found if p.contact))
1030
+ if getattr(args, "json", False):
1031
+ jsonout.emit("check", {
1032
+ "ok": not data["violations"], "mode": "policy", "repo": str(repo), "remote": shown,
1033
+ "policies": [{"name": p.name, "file": p.path, "rules": len(rules)} for p, rules in found],
1034
+ "range": args.range, "commits": data["commits"], "violations": data["violations"],
1035
+ "signing": data["signing"], "unverifiable": data["unverifiable"], "contact": contact or None,
1036
+ "warnings": warnings})
1037
+ elif not data["commits"]:
1038
+ print(f"✓ no commits in {args.range}")
1039
+ elif not found:
1040
+ print(f"no policy enforced for {data['commits']} commit{'s' if data['commits'] != 1 else ''} in {args.range}")
1041
+ else:
1042
+ names = ", ".join(p.name for p, _ in found)
1043
+ print("\n".join(_render_range(data, names, _show_where(url, repo), args.range, contact)))
1044
+ return 1 if data["violations"] else 0
1045
+
1046
+
1047
+ # ---------------------------------------------------------------- machine mode
1048
+
1049
+ @dataclass(frozen=True)
1050
+ class _Person:
1051
+ id: str
1052
+ label: str # "identity work-github" | "[default]"
1053
+ email: Optional[str]
1054
+ sign: bool
1055
+ ssh_key: Optional[str]
1056
+ trailers: trailers.Policy
1057
+ table: str # where a trailers fix goes
1058
+
1059
+
1060
+ def _person(cfg, ident) -> _Person:
1061
+ d = cfg.default
1062
+ tp = trailers.policy_for(ident, d)
1063
+ if ident is None:
1064
+ return _Person("(default)", "[default]", d.email, bool(d.sign), d.ssh_key, tp, "[default.trailers]")
1065
+ return _Person(ident.id, f"identity {ident.id}", ident.email, config.signs(ident, d), ident.ssh_key or d.ssh_key,
1066
+ tp, "[identity.trailers]")
1067
+
1068
+
1069
+ def _check(name: str, label: str, level: str, message: str, fix: str = "") -> dict:
1070
+ return {"name": name, "label": label, "ok": level != "error", "level": level, "message": message, "fix": fix}
1071
+
1072
+
1073
+ _VERB = {"allow": "allows", "forbid": "forbids", "require": "requires"}
1074
+
1075
+
1076
+ def identity_checks(cfg, person: _Person, pol: Policy, rules: Sequence[Rule], keys: Optional[dict] = None,
1077
+ online: bool = False, verified: Optional[Dict[str, Optional[bool]]] = None,
1078
+ verified_why: str = "") -> List[dict]:
1079
+ """The rows of `check --policy` for one identity and policy. keys: pub path -> registered (True/False/None)
1080
+ from `team --online`; verified: email -> verified (True/False/None, missing = unknown); verified_why: why
1081
+ the forge could not be asked."""
1082
+ out: List[dict] = []
1083
+ if any(r.emails() or r.forbid_emails for r in rules):
1084
+ email = person.email or "(no email)"
1085
+ prob = email_problem(rules, person.email) if person.email else ("allowed_email_domains", "no email set")
1086
+ if prob:
1087
+ doms = _domains(rules)
1088
+ fix = (f"use an address in {', '.join(doms)}" if doms else "use an allowed address") + \
1089
+ (f", or ask {pol.contact} for an exception" if pol.contact else "")
1090
+ out.append(_check("email domain", email, "error", prob[1], fix))
1091
+ else:
1092
+ dom = (person.email or "").rpartition("@")[2]
1093
+ hit = next((d for d in _domains(rules) if d.casefold() == dom.casefold()), None)
1094
+ out.append(_check("email domain", email, "ok", hit or "allowed_emails" if any(r.emails() for r in rules)
1095
+ else "not forbidden"))
1096
+ if any(r.signing() for r in rules):
1097
+ required = any(r.require_signing for r in rules)
1098
+ fmts = list(dict.fromkeys(f for r in rules for f in r.signing_formats))
1099
+ label = "required" if required else "formats " + ", ".join(fmts)
1100
+ if required and not person.sign:
1101
+ out.append(_check("signing", label, "error", f"{person.label} has sign = false",
1102
+ f"set sign = true, run {PROG} apply"))
1103
+ elif person.sign and fmts and "ssh" not in fmts:
1104
+ out.append(_check("signing", label, "error", f"{PROG} signs with ssh but the policy allows {', '.join(fmts)}",
1105
+ "sign with an allowed format outside gitpro"))
1106
+ else:
1107
+ out.append(_check("signing", label, "ok", "sign = true" if person.sign else "not required"))
1108
+ if required:
1109
+ if not person.ssh_key:
1110
+ out.append(_check("signing key", "(none)", "error", f"{person.label} has no ssh_key to sign with",
1111
+ "set ssh_key in the identity"))
1112
+ else:
1113
+ pub = os.path.expanduser(person.ssh_key) + ".pub"
1114
+ shown = _tilde(pub)
1115
+ reg = (keys or {}).get(pub) if online else None
1116
+ if not os.path.isfile(pub):
1117
+ out.append(_check("signing key", shown, "error", f"{shown} not found", f"{PROG} keys --generate"))
1118
+ elif reg is True:
1119
+ out.append(_check("signing key", shown, "ok", "registered as a signing key"))
1120
+ elif reg is False:
1121
+ out.append(_check("signing key", shown, "error", "not registered as a signing key",
1122
+ f"{PROG} keys --upload"))
1123
+ elif online:
1124
+ out.append(_check("signing key", shown, "warning", "could not check that it is registered"))
1125
+ else:
1126
+ out.append(_check("signing key", shown, "warning",
1127
+ "not checked (run gitpro team --online to verify it is registered)"))
1128
+ tp = trailer_policy(pol, rules)
1129
+ if tp.co_authored_by != "allow":
1130
+ mine = person.trailers
1131
+ if mine.co_authored_by == tp.co_authored_by:
1132
+ out.append(_check("trailers", tp.co_authored_by, "ok",
1133
+ f'{mine.table} co_authored_by = "{mine.co_authored_by}"'))
1134
+ else:
1135
+ out.append(_check("trailers", tp.co_authored_by, "warning",
1136
+ f"policy {_VERB[tp.co_authored_by]} Co-Authored-By but the "
1137
+ f"{'identity' if person.id != '(default)' else '[default]'} "
1138
+ f"{_VERB[mine.co_authored_by]} it",
1139
+ f'set {person.table} co_authored_by = "{tp.co_authored_by}"'))
1140
+ if any(r.require_verified_email for r in rules):
1141
+ state = (verified or {}).get((person.email or "").casefold()) if online else None
1142
+ if state is True:
1143
+ out.append(_check("verified email", person.email or "", "ok", "verified on the forge"))
1144
+ elif state is False:
1145
+ out.append(_check("verified email", person.email or "", "error", "not verified on the forge",
1146
+ "verify the address in the forge's email settings"))
1147
+ else:
1148
+ out.append(_check("verified email", person.email or "", "warning",
1149
+ "not checked (needs --online)" if not online else
1150
+ "could not check" + (f" ({verified_why})" if verified_why else "")))
1151
+ return out
1152
+
1153
+
1154
+ def machine_data(cfg, repo: Optional[Path] = None, extra: Sequence[str] = (), *, check_perms: bool = True,
1155
+ keys: Optional[dict] = None, online: bool = False,
1156
+ verified: Optional[Dict[str, Optional[bool]]] = None) -> List[dict]:
1157
+ """One entry per (identity, policy) that applies: {"id", "policy", "file", "contact", "checks"}.
1158
+ With repo: the identity git uses there and the rules matching its remote. Without: every identity with
1159
+ a policy (its own, [policy].files, extra), with the rules for its host and owners."""
1160
+ out: List[dict] = []
1161
+
1162
+ def add(ident, found: Found) -> None:
1163
+ person = _person(cfg, ident)
1164
+ for pol, rules in found:
1165
+ out.append({"id": person.id, "policy": pol.name, "file": pol.path, "contact": pol.contact,
1166
+ "checks": identity_checks(cfg, person, pol, rules, keys, online, verified)})
1167
+
1168
+ if repo is not None:
1169
+ exp = resolve.expected(repo, cfg)
1170
+ ident = exp.match.identity
1171
+ urls = _repo_urls(resolve.remotes(repo), exp.match.url)
1172
+ if urls:
1173
+ add(ident, match(_load_all(sources(cfg, ident, extra), check_perms), urls, cfg))
1174
+ return out
1175
+ for ident in cfg.identities:
1176
+ pols = _load_all(sources(cfg, ident, extra), check_perms)
1177
+ found = [(p, [r for r in p.rules if _overlaps(r, ident)]) for p in pols]
1178
+ add(ident, [(p, rules) for p, rules in found if rules])
1179
+ return out
1180
+
1181
+
1182
+ def check_machine(cfg, repo: Optional[Path] = None, extra: Sequence[str] = ()) -> List[resolve.Problem]:
1183
+ """What this machine violates, as resolve.Problem (code "policy"); a policy that cannot be loaded is
1184
+ one problem too. Never raises ConfigError (doctor's policy row)."""
1185
+ try:
1186
+ entries = machine_data(cfg, repo, extra)
1187
+ except ConfigError as e:
1188
+ return [resolve.Problem("policy", str(e), None)]
1189
+ return [resolve.Problem("policy", f"policy {e['policy']}: {c['message']}", c["fix"] or None)
1190
+ for e in entries for c in e["checks"] if c["level"] == "error"]
1191
+
1192
+
1193
+ def check_repo(repo: Path, cfg, extra: Sequence[str] = ()) -> List[dict]:
1194
+ """machine_data for one repo (`check --policy --repo DIR`)."""
1195
+ return machine_data(cfg, _need_repo(repo), extra)
1196
+
1197
+
1198
+ _MARK = {"ok": "✓", "warning": "⚠", "error": "✗"}
1199
+
1200
+
1201
+ def _render_machine(entries: List[dict]) -> List[str]:
1202
+ lines: List[str] = []
1203
+ for e in entries:
1204
+ lines.append(f"policy {e['policy']} ({_tilde(e['file'])}) for {e['id']}")
1205
+ for c in e["checks"]:
1206
+ text = f"{_MARK[c['level']]} {c['message']}" + (f" → {c['fix']}" if c["fix"] and c["level"] != "ok" else "")
1207
+ lines.append(f" {c['name']:<14} {c['label']:<18} {text}")
1208
+ return lines
1209
+
1210
+
1211
+ def _cmd_machine(args) -> int:
1212
+ from . import jsonout
1213
+ path, how = config.find(args.config, Path(args.home))
1214
+ cfg = config.load_all(path, how)
1215
+ repo = _need_repo(args.repo) if args.repo else resolve.is_repo(Path(os.getcwd()))
1216
+ entries = machine_data(cfg, repo, args.policy_files or ())
1217
+ problems = sum(1 for e in entries for c in e["checks"] if c["level"] == "error")
1218
+ if getattr(args, "json", False):
1219
+ jsonout.emit("check", {"ok": problems == 0, "mode": "machine", "repo": str(repo) if repo else None,
1220
+ "identities": [{**e, "checks": [{k: c[k] for k in ("name", "ok", "level", "message",
1221
+ "fix")} for c in e["checks"]]}
1222
+ for e in entries], "problems": problems})
1223
+ return 1 if problems else 0
1224
+ if not entries:
1225
+ where = f"to {_tilde(repo)}" if repo else "to any identity (set policy = \"~/…\" in an identity, or pass " \
1226
+ "--policy FILE)"
1227
+ print(f"no policy applies {where}")
1228
+ return 0
1229
+ lines = _render_machine(entries)
1230
+ lines.append(f"{problems} problem{'s' if problems != 1 else ''}" if problems else "✓ this machine meets the policy")
1231
+ print("\n".join(lines))
1232
+ return 1 if problems else 0
1233
+
1234
+
1235
+ def _cmd_check(args) -> int:
1236
+ resolve.require_git()
1237
+ if args.range is not None:
1238
+ return _cmd_range(args)
1239
+ return _cmd_machine(args)
1240
+
1241
+
1242
+ # ---------------------------------------------------------------- resolve.EXTRA_CHECKS
1243
+
1244
+ def check_report(report) -> List[resolve.Problem]:
1245
+ """The identity's own policy against what resolve expects (email, signing); code "policy". Only the
1246
+ identity's `policy` is visible here (the callback gets the Report, not the config)."""
1247
+ ident = report.expected.match.identity
1248
+ if ident is None or not getattr(ident, "policy", None):
1249
+ return []
1250
+ try:
1251
+ pol = load(ident.policy, who=f"identity {ident.id}")
1252
+ except ConfigError as e:
1253
+ return [resolve.Problem("policy", str(e), None)]
1254
+ urls = _repo_urls(report.effective.remotes, report.expected.match.url)
1255
+ rules = [r for r in pol.rules if any(rule_matches(r, u) for u in urls)]
1256
+ if not rules:
1257
+ return []
1258
+ out: List[resolve.Problem] = []
1259
+ prob = email_problem(rules, report.expected.email)
1260
+ if prob:
1261
+ doms = _domains(rules)
1262
+ out.append(resolve.Problem("policy", f"policy {pol.name}: identity {ident.id} email {report.expected.email}: "
1263
+ f"{prob[1]}",
1264
+ f"use an address in {', '.join(doms)}" if doms else None))
1265
+ if any(r.require_signing for r in rules) and not report.expected.sign:
1266
+ out.append(resolve.Problem("policy", f"policy {pol.name} requires signed commits but identity {ident.id} has "
1267
+ f"sign = false", f"set sign = true, run {PROG} apply"))
1268
+ return out
1269
+
1270
+
1271
+ resolve.EXTRA_CHECKS[:] = [f for f in resolve.EXTRA_CHECKS
1272
+ if not (getattr(f, "__module__", None) == __name__
1273
+ and getattr(f, "__qualname__", None) == "check_report")]
1274
+ resolve.EXTRA_CHECKS.append(check_report)
1275
+
1276
+
1277
+ # ---------------------------------------------------------------- guard entries (01 §5.6)
1278
+
1279
+ def _hook_found(ctx, url: Optional[str] = None) -> Found:
1280
+ """The policies for the hook's repo; [] at once (no git, no file read) when the config names none."""
1281
+ cfg = ctx.cfg
1282
+ if not any_policy(cfg):
1283
+ return []
1284
+ rep = getattr(ctx, "report", None)
1285
+ if rep is not None:
1286
+ ident = rep.expected.match.identity
1287
+ urls = [url] if url else _repo_urls(rep.effective.remotes, rep.expected.match.url)
1288
+ else:
1289
+ exp = resolve.expected(Path(ctx.repo), cfg)
1290
+ ident = exp.match.identity
1291
+ urls = [url] if url else _repo_urls(resolve.remotes(Path(ctx.repo)), exp.match.url)
1292
+ if not urls:
1293
+ return []
1294
+ srcs = sources(cfg, ident)
1295
+ if not srcs:
1296
+ return []
1297
+ return match(_load_all(srcs, check_perms=True), urls, cfg)
1298
+
1299
+
1300
+ def _block(ctx, pol: Policy, head: str, lines: Sequence[str], fix: str) -> int:
1301
+ warn = getattr(getattr(ctx.cfg, "guard", None), "on_mismatch", "block") != "block"
1302
+ ctx.say(("⚠" if warn else "✗") + f" gitpro: {head}")
1303
+ for line in lines:
1304
+ ctx.say(" " + line)
1305
+ ctx.say(f" cause: {pol.cause()}")
1306
+ if fix:
1307
+ ctx.say(f" fix: {fix}")
1308
+ if warn:
1309
+ ctx.say(' (not blocking: [guard] on_mismatch = "warn")')
1310
+ return BLOCK
1311
+
1312
+
1313
+ def _safe(ctx, fn) -> int:
1314
+ try:
1315
+ return fn(ctx)
1316
+ except KeyboardInterrupt:
1317
+ raise
1318
+ except BaseException as e: # noqa: BLE001 -- a broken policy must never block a commit
1319
+ msg = str(e) if isinstance(e, ConfigError) else (f"{type(e).__name__}: {e}" if str(e) else type(e).__name__)
1320
+ try:
1321
+ ctx.say(f"⚠ gitpro policy: {msg}; not blocking")
1322
+ except Exception: # noqa: BLE001
1323
+ pass
1324
+ return 0
1325
+
1326
+
1327
+ def _email_fix(rules: Sequence[Rule], key: str) -> str:
1328
+ doms = _domains(rules)
1329
+ if key == "allowed_email_domains" and doms:
1330
+ return f"commit as an address in {', '.join(doms)} (git config user.email …)"
1331
+ return "commit as another address (git config user.email …)"
1332
+
1333
+
1334
+ def _hook_commit(ctx) -> int:
1335
+ found = _hook_found(ctx)
1336
+ for pol, rules in found:
1337
+ for which, email in (("committed", ctx.committer_email), ("authored", ctx.author_email)):
1338
+ prob = email_problem(rules, email)
1339
+ if prob:
1340
+ return _block(ctx, pol, f"this commit would be {which} as {email}: {prob[1]}", (),
1341
+ _email_fix(rules, prob[0]))
1342
+ return 0
1343
+
1344
+
1345
+ def hook_commit(ctx) -> int:
1346
+ """pre-commit: the committer and author emails against the repo's policies. 3 to refuse, else 0."""
1347
+ return _safe(ctx, _hook_commit)
1348
+
1349
+
1350
+ def _outgoing(ctx, signing: bool, pre: Sequence[str]) -> List[_Commit]:
1351
+ """The commits this push sends that no remote-tracking ref has (the listing guard uses)."""
1352
+ seen, out = set(), []
1353
+ for line in (ctx.stdin or "").splitlines():
1354
+ parts = line.split()
1355
+ if len(parts) != 4 or set(parts[1]) == {"0"}:
1356
+ continue
1357
+ local, remote = parts[1], parts[3]
1358
+ revs = [local]
1359
+ if set(remote) != {"0"}:
1360
+ try:
1361
+ _git(ctx.repo, "cat-file", "-e", f"{remote}^{{commit}}")
1362
+ revs.append(f"^{remote}")
1363
+ except ConfigError:
1364
+ pass
1365
+ for c in _log(ctx.repo, [*revs, "--not", "--remotes"], signing=signing, pre=pre, timeout=GIT_TIMEOUT):
1366
+ if c.sha not in seen:
1367
+ seen.add(c.sha)
1368
+ out.append(c)
1369
+ return out
1370
+
1371
+
1372
+ def _hook_push(ctx) -> int:
1373
+ url = ctx.args[1] if len(ctx.args) > 1 else None
1374
+ found = _hook_found(ctx, url)
1375
+ if not found:
1376
+ return 0
1377
+ signing = any(r.signing() for _, rules in found for r in rules)
1378
+ tmp: List[str] = []
1379
+ try:
1380
+ pre, ships = _signers_args(found, tmp) if signing else ([], False)
1381
+ commits = _outgoing(ctx, signing, pre)
1382
+ finally:
1383
+ for t in tmp:
1384
+ try:
1385
+ os.unlink(t)
1386
+ except OSError:
1387
+ pass
1388
+ for pol, rules in found:
1389
+ bad = []
1390
+ for c in commits:
1391
+ for v in _judge(c, [(pol, rules)], signing, ships):
1392
+ bad.append(f"{c.sha[:7]} {c.author_email} {v['message']}")
1393
+ if bad:
1394
+ n = len(bad)
1395
+ shown = bad[:5] + ([f"… and {n - 5} more"] if n > 5 else [])
1396
+ return _block(ctx, pol, f"this push breaks policy {pol.name} ({n} problem{'s' if n != 1 else ''})", shown,
1397
+ "rewrite the listed commits (git rebase -i; sign with git commit --amend -S), then push")
1398
+ return 0
1399
+
1400
+
1401
+ def hook_push(ctx) -> int:
1402
+ """pre-push: every outgoing commit against the remote's policies (emails, trailers, and %G? when a rule
1403
+ requires signing). 3 to refuse, else 0."""
1404
+ return _safe(ctx, _hook_push)
1405
+
1406
+
1407
+ def _in_merge() -> bool:
1408
+ fn = getattr(trailers, "_in_merge", None)
1409
+ return bool(fn()) if fn else False
1410
+
1411
+
1412
+ def _hook_message(ctx) -> int:
1413
+ if not any_policy(ctx.cfg):
1414
+ return 0
1415
+ if not ctx.args:
1416
+ raise ConfigError("commit-msg called without a message file")
1417
+ found = _hook_found(ctx)
1418
+ if not found:
1419
+ return 0
1420
+ msg = Path(ctx.args[0])
1421
+ if not msg.is_absolute() and not msg.exists():
1422
+ msg = Path(ctx.repo) / msg
1423
+ parsed = trailers.parse(msg)
1424
+ merge = _in_merge()
1425
+ for pol, rules in found:
1426
+ probs = trailer_problems(pol, rules, parsed, merge)
1427
+ if probs:
1428
+ head, rest = probs[0][1], [f"also: {m}" for _, m in probs[1:]]
1429
+ fix = "remove the trailer from the message" if not any(
1430
+ "requires" in m for _, m in probs) else "end the message with a trailer line: Co-Authored-By: Name <email>"
1431
+ return _block(ctx, pol, head, rest, fix)
1432
+ return 0
1433
+
1434
+
1435
+ def hook_message(ctx) -> int:
1436
+ """commit-msg: the message's trailers against the repo's policies. 3 to refuse, else 0."""
1437
+ return _safe(ctx, _hook_message)
1438
+
1439
+
1440
+ # ---------------------------------------------------------------- team
1441
+
1442
+ def _optional(name: str):
1443
+ import importlib
1444
+ try:
1445
+ return importlib.import_module(name)
1446
+ except ImportError:
1447
+ return None
1448
+
1449
+
1450
+ def _registered(cfg, home: Path) -> Dict[str, Optional[bool]]:
1451
+ """pub path -> registered as a signing key (09 health.check_keys --online); {} without health. The
1452
+ caller has announced already (the forge session's gate), so check_keys prints nothing."""
1453
+ health = _optional("gitpro.health")
1454
+ if health is None or not hasattr(health, "check_keys"):
1455
+ return {}
1456
+ out: Dict[str, Optional[bool]] = {}
1457
+ data = health.check_keys(cfg, online=True, announce=False, home=home)
1458
+ for k in data.get("keys", []):
1459
+ reg = k.get("registered")
1460
+ pub = k.get("pub") or (str(k.get("path")) + ".pub")
1461
+ out[os.path.expanduser(str(pub))] = None if not isinstance(reg, dict) or reg.get("signing") is None \
1462
+ else bool(reg.get("signing"))
1463
+ return out
1464
+
1465
+
1466
+ TEAM_WHAT = "which SSH keys are registered on your accounts"
1467
+ TEAM_WHAT_EMAILS = TEAM_WHAT + " and which of your emails they have verified"
1468
+ TEAM_SENDS = "only the list requests; keys and emails are compared locally"
1469
+
1470
+
1471
+ def _session_ids(cfg) -> List[str]:
1472
+ """The forge targets of `team --online`: [default] when it has an account (health.check_keys asks it
1473
+ too), then every identity."""
1474
+ d = getattr(cfg, "default", None)
1475
+ return (["default"] if getattr(d, "gh_account", None) or getattr(d, "glab_account", None) else []) + \
1476
+ [i.id for i in cfg.identities]
1477
+
1478
+
1479
+ def _mentions(text: str, word: str) -> bool:
1480
+ return bool(word) and re.search(rf"(?<![\w.-]){re.escape(word)}(?![\w-])", text) is not None
1481
+
1482
+
1483
+ def _verified(cfg, home: Path, as_json: bool, session: object = None, why: Optional[List[str]] = None,
1484
+ reasons: Optional[Dict[str, str]] = None) -> Dict[str, Optional[bool]]:
1485
+ """casefolded email -> verified on the forge: True / False / None (unknown; an email missing from the
1486
+ map is unknown too). session: the gated forge.Session of the run, else one is made and gated here.
1487
+ A failure is kept as unknown, its reason appended to why (and warned through the session). reasons
1488
+ gets, per casefolded email and per identity id left unknown, the forge warnings that name its account
1489
+ or identity."""
1490
+ from . import forge
1491
+ make, emails = getattr(forge, "Session", None), getattr(forge, "emails", None)
1492
+ if make is None or emails is None:
1493
+ if why is not None:
1494
+ why.append("this gitpro has no forge support")
1495
+ return {}
1496
+ s = session
1497
+ try:
1498
+ if s is None:
1499
+ s = make(cfg, home, _session_ids(cfg), as_json=as_json)
1500
+ if hasattr(s, "gate"):
1501
+ rc = s.gate("team", TEAM_WHAT_EMAILS, TEAM_SENDS, explicit=True,
1502
+ args=argparse.Namespace(json=as_json, command="team"))
1503
+ if rc is not None:
1504
+ raise ConfigError("the forge could not be asked")
1505
+ seen = len(getattr(s, "warnings", None) or [])
1506
+ got = emails(s) or ()
1507
+ new = list(getattr(s, "warnings", None) or [])[seen:]
1508
+ except (ConfigError, OSError, subprocess.SubprocessError, UnicodeError, ValueError) as e:
1509
+ msg = f"verified email: could not ask the forge: {e}"
1510
+ if why is not None:
1511
+ why.append(str(e))
1512
+ if hasattr(s, "warn"):
1513
+ s.warn(msg)
1514
+ else:
1515
+ print(f"⚠ {msg}", file=sys.stderr)
1516
+ return {}
1517
+ out: Dict[str, Optional[bool]] = {}
1518
+ for e in got:
1519
+ if isinstance(e, dict) and e.get("email"):
1520
+ v = e.get("verified")
1521
+ key = str(e["email"]).casefold()
1522
+ if v is True or v is False:
1523
+ out[key] = False if out.get(key) is False else v # two accounts disagree: not verified
1524
+ else:
1525
+ out.setdefault(key, None)
1526
+ said = [w for w in new if _mentions(w, str(e.get("account") or "")) or _mentions(w, str(e.get("id") or ""))]
1527
+ if said and reasons is not None:
1528
+ reasons.setdefault(key, "; ".join(said))
1529
+ if reasons is not None:
1530
+ for i in getattr(cfg, "identities", ()) or ():
1531
+ said = [w for w in new if _mentions(w, i.id)]
1532
+ if said and (i.email or "").casefold() not in out:
1533
+ reasons.setdefault(i.id, "; ".join(said))
1534
+ return out
1535
+
1536
+
1537
+ def _worst(checks: Sequence[dict], *names: str) -> Optional[str]:
1538
+ levels = [c["level"] for c in checks if c["name"] in names]
1539
+ for lv in ("error", "warning", "ok"):
1540
+ if lv in levels:
1541
+ return lv
1542
+ return None
1543
+
1544
+
1545
+ def _cell(level: Optional[str], online: bool = True) -> str:
1546
+ if level is None:
1547
+ return "·"
1548
+ if level == "warning" and not online:
1549
+ return "⚠ (--online)"
1550
+ return _MARK[level]
1551
+
1552
+
1553
+ def _needs_verified(cfg) -> bool:
1554
+ """Does any rule that applies to an identity require a verified email? (Unreadable policies count as no:
1555
+ the rows report them.)"""
1556
+ for ident in cfg.identities:
1557
+ for path, who in sources(cfg, ident):
1558
+ try:
1559
+ pol = load(path, who=who)
1560
+ except ConfigError:
1561
+ continue
1562
+ if any(r.require_verified_email and _overlaps(r, ident) for r in pol.rules):
1563
+ return True
1564
+ return False
1565
+
1566
+
1567
+ def team_data(cfg, home: Path, *, online: bool = False, as_json: bool = False,
1568
+ explicit_config: Optional[Path] = None, session: object = None,
1569
+ needs_verified: Optional[bool] = None) -> dict:
1570
+ """The rows of `team`. online: session is the gated forge.Session (cmd_team opens it)."""
1571
+ keys = _registered(cfg, home) if online else None
1572
+ if needs_verified is None:
1573
+ needs_verified = online and _needs_verified(cfg)
1574
+ why: List[str] = []
1575
+ reasons: Dict[str, str] = {}
1576
+ verified = _verified(cfg, home, as_json, session, why, reasons) if online and needs_verified else None
1577
+ ids = []
1578
+ for ident in cfg.identities:
1579
+ pols = _load_all(sources(cfg, ident), check_perms=True)
1580
+ found = [(p, [r for r in p.rules if _overlaps(r, ident)]) for p in pols]
1581
+ found = [(p, rules) for p, rules in found if rules]
1582
+ person = _person(cfg, ident)
1583
+ checks = [c for p, rules in found
1584
+ for c in identity_checks(cfg, person, p, rules, keys, online, verified,
1585
+ reasons.get((person.email or "").casefold()) or reasons.get(ident.id)
1586
+ or "; ".join(why))]
1587
+ ids.append({"id": ident.id, "policy": ", ".join(p.name for p, _ in found) or None,
1588
+ "policies": [{"name": p.name, "file": p.path, "contact": p.contact} for p, _ in found],
1589
+ "ok": not any(c["level"] == "error" for c in checks),
1590
+ "checks": [{k: c[k] for k in ("name", "ok", "level", "message", "fix")} for c in checks]})
1591
+ guard = _optional("gitpro.guard")
1592
+ agents = _optional("gitpro.agents")
1593
+ g = a = None
1594
+ if guard is not None and hasattr(guard, "status"):
1595
+ try:
1596
+ g = guard.status(home)
1597
+ except Exception as e: # noqa: BLE001
1598
+ g = {"installed": "none", "error": str(e)}
1599
+ if agents is not None and hasattr(agents, "status_data"):
1600
+ try:
1601
+ a = agents.status_data(home, explicit_config)
1602
+ except Exception as e: # noqa: BLE001
1603
+ a = {"error": str(e)}
1604
+ return {"ok": all(i["ok"] for i in ids), "identities": ids, "guard": g, "agents": a,
1605
+ "warnings": list(getattr(session, "warnings", None) or [])}
1606
+
1607
+
1608
+ def _shell_path(path: str) -> str:
1609
+ """path for a command line the user can paste: ~/rest when under home, quoted where needed."""
1610
+ import shlex
1611
+ shown = _tilde(path)
1612
+ return "~/" + shlex.quote(shown[2:]) if shown.startswith("~/") else shlex.quote(shown)
1613
+
1614
+
1615
+ def check_hint(ids: Sequence[dict]) -> str:
1616
+ """The command that shows the details of failing identities: `gitpro check --policy FILE` per policy
1617
+ file they name (machine mode, every identity with a policy)."""
1618
+ files = list(dict.fromkeys(p["file"] for i in ids if not i["ok"] for p in i.get("policies") or () if p.get("file")))
1619
+ return " ".join([PROG, "check"] + [f"--policy {_shell_path(f)}" for f in files])
1620
+
1621
+
1622
+ def _render_team(data: dict, online: bool) -> List[str]:
1623
+ ids = data["identities"]
1624
+ w = max([len("identity")] + [len(i["id"]) for i in ids]) + 2
1625
+ lines = ["team status (this machine)",
1626
+ f" {'identity':<{w}}{'policy':<29}{'email':<7}{'signing':<9}{'key registered':<16}"
1627
+ f"{'verified':<14}trailers"]
1628
+ for i in ids:
1629
+ if not i["policy"]:
1630
+ lines.append(f" {i['id']:<{w}}{'(none)':<29}{'·':<7}{'·':<9}{'·':<16}{'·':<14}·")
1631
+ continue
1632
+ name = i["policy"] if len(i["policy"]) <= 27 else i["policy"][:26] + "…"
1633
+ c = i["checks"]
1634
+ lines.append(f" {i['id']:<{w}}{name:<29}{_cell(_worst(c, 'email domain')):<7}"
1635
+ f"{_cell(_worst(c, 'signing')):<9}{_cell(_worst(c, 'signing key'), online):<16}"
1636
+ f"{_cell(_worst(c, 'verified email'), online):<14}{_cell(_worst(c, 'trailers'))}")
1637
+ g = data["guard"]
1638
+ if g is None:
1639
+ guard = "guard: · not installed"
1640
+ elif g.get("installed", "none") != "none":
1641
+ guard = f"guard: installed ({g.get('on_mismatch', 'block')})"
1642
+ else:
1643
+ guard = "guard: not installed"
1644
+ a = data["agents"]
1645
+ if a is None:
1646
+ agent = "agent hook: · not installed"
1647
+ else:
1648
+ marks = []
1649
+ for name, v in (a.get("agents") or {}).items():
1650
+ on = isinstance(v, dict) and any(v.get(k) for k in ("user", "project", "plugin"))
1651
+ marks.append(f"{name} {'✓' if on else '·'}")
1652
+ agent = "agent hook: " + (" ".join(marks) or "·")
1653
+ lines.append(f"{guard:<29}{agent}")
1654
+ bad = sum(1 for i in ids if not i["ok"])
1655
+ if bad:
1656
+ lines.append(f"{bad} identit{'y violates its' if bad == 1 else 'ies violate their'} policy → "
1657
+ f"{check_hint(ids)}")
1658
+ elif any(i["policy"] for i in ids):
1659
+ lines.append("✓ every identity meets its policy")
1660
+ else:
1661
+ lines.append("no identity has a policy (set policy = \"~/…\" in an identity)")
1662
+ return lines
1663
+
1664
+
1665
+ def cmd_team(args) -> int:
1666
+ from . import jsonout
1667
+ resolve.require_git()
1668
+ home = Path(args.home)
1669
+ path, how = config.find(args.config, home)
1670
+ cfg = config.load_all(path, how)
1671
+ as_json = getattr(args, "json", False)
1672
+ session = None
1673
+ needs = False
1674
+ if args.online:
1675
+ from . import forge
1676
+ if not (forge.available("gh") or forge.available("glab")):
1677
+ raise ConfigError("team --online needs gh or glab on PATH (signed in) to ask the forge which keys and "
1678
+ "emails are registered")
1679
+ # one announce line for every forge call of this run (key lists, verified emails): first on stdout,
1680
+ # on stderr under --json, through the session's gate
1681
+ needs = _needs_verified(cfg)
1682
+ session = forge.Session(cfg, home, _session_ids(cfg), as_json=as_json)
1683
+ rc = session.gate("team", TEAM_WHAT_EMAILS if needs else TEAM_WHAT, TEAM_SENDS, explicit=True, args=args)
1684
+ if rc is not None:
1685
+ return rc
1686
+ data = team_data(cfg, home, online=args.online, as_json=as_json, explicit_config=args.config,
1687
+ session=session, needs_verified=needs)
1688
+ if getattr(args, "json", False):
1689
+ jsonout.emit("team", data)
1690
+ else:
1691
+ print("\n".join(_render_team(data, args.online)))
1692
+ return 0 if data["ok"] else 1
1693
+
1694
+
1695
+ # ---------------------------------------------------------------- policy show / lint
1696
+
1697
+ def _show_lines(found: Found) -> List[str]:
1698
+ lines: List[str] = []
1699
+ for pol, rules in found:
1700
+ lines.append(f"policy {pol.name} ({_tilde(pol.path)})" + (f", contact {pol.contact}" if pol.contact else ""))
1701
+ for r in rules:
1702
+ lines.append(f" rule {r.index}: {r.host}/{', '.join(r.owners) if r.owners else '*'}")
1703
+ if r.emails():
1704
+ lines.append(f" email domains {', '.join(r.allowed_email_domains) or '(none)'}"
1705
+ + (f"; also {', '.join(r.allowed_emails)}" if r.allowed_emails else ""))
1706
+ if r.signing():
1707
+ lines.append(f" signing {'required' if r.require_signing else 'optional'}"
1708
+ + (f" ({', '.join(r.signing_formats)})" if r.signing_formats else ""))
1709
+ lines.append(f" co_authored_by {r.co_authored_by}")
1710
+ if r.forbid_emails:
1711
+ lines.append(f" forbid_emails {', '.join(r.forbid_emails)}")
1712
+ if r.require_verified_email:
1713
+ lines.append(" verified email required (checked by gitpro team --online)")
1714
+ return lines
1715
+
1716
+
1717
+ def cmd_show(args) -> int:
1718
+ from . import jsonout
1719
+ resolve.require_git()
1720
+ repo = _need_repo(args.dir)
1721
+ cfg = _config_or_none(args)
1722
+ found: Found = applicable(repo, cfg) if cfg is not None else []
1723
+ if not found and (repo / REPO_POLICY).is_file():
1724
+ found = match([load(repo / REPO_POLICY, check_perms=False, inside=repo)], _repo_urls(resolve.remotes(repo)),
1725
+ cfg)
1726
+ if getattr(args, "json", False):
1727
+ jsonout.emit("policy", {"repo": str(repo), "policies": [
1728
+ {"name": p.name, "file": p.path, "version": p.version, "contact": p.contact,
1729
+ "allowed_signers": p.allowed_signers, "rules": [r.to_json() for r in rules]} for p, rules in found]})
1730
+ return 0
1731
+ if not found:
1732
+ print(f"no policy applies to {_tilde(repo)}")
1733
+ return 0
1734
+ print("\n".join(_show_lines(found)))
1735
+ return 0
1736
+
1737
+
1738
+ def cmd_lint(args) -> int:
1739
+ from . import jsonout
1740
+ pol, errs = lint(args.file)
1741
+ if getattr(args, "json", False):
1742
+ jsonout.emit("policy", {"ok": not errs, "file": str(args.file), "errors": errs,
1743
+ "name": pol.name if pol else None, "rules": len(pol.rules) if pol else None})
1744
+ return 2 if errs else 0
1745
+ if errs:
1746
+ for e in errs:
1747
+ print(f"✗ {e}", file=sys.stderr)
1748
+ return 2
1749
+ assert pol is not None
1750
+ print(f"✓ {_tilde(args.file)}: policy {pol.name}, {len(pol.rules)} rule{'s' if len(pol.rules) != 1 else ''}")
1751
+ return 0
1752
+
1753
+
1754
+ # ---------------------------------------------------------------- doctor
1755
+
1756
+ def doctor_section(ctx) -> dict:
1757
+ """16's policy row: does this machine meet the policies its config names?"""
1758
+ cfg = ctx.need_cfg()
1759
+ if not any_policy(cfg):
1760
+ return {"status": "ok", "summary": "no policy configured", "problems": [], "details": []}
1761
+ probs = check_machine(cfg)
1762
+ return {"status": "error" if probs else "ok",
1763
+ "summary": probs[0].message if probs else "machine meets the team policy",
1764
+ "problems": [{"message": p.message, "fix": p.fix or "", "level": "error"} for p in probs],
1765
+ "details": []}
1766
+
1767
+
1768
+ # ---------------------------------------------------------------- CLI
1769
+
1770
+ def add_check_flags(p: argparse.ArgumentParser) -> None:
1771
+ p.add_argument("--policy", dest="policy_files", action="append", metavar="FILE",
1772
+ help="check against a team policy file (repeatable; default: the policies the config names)")
1773
+ p.add_argument("--range", metavar="A..B", help="CI mode: check the commits of this range against the policy")
1774
+ p.add_argument("--repo", metavar="DIR", help="the repo to check (default: the current directory)")
1775
+
1776
+
1777
+ def check_handler(args) -> Optional[int]:
1778
+ if not (getattr(args, "policy_files", None) or getattr(args, "range", None) is not None
1779
+ or getattr(args, "repo", None)):
1780
+ return None
1781
+ return _cmd_check(args)
1782
+
1783
+
1784
+ def register(sub, common, confirm) -> None:
1785
+ from .args import capability
1786
+ sp = sub.add_parser("team", parents=[common], help="does this machine meet each identity's team policy?")
1787
+ sp.add_argument("--online", action="store_true", help="also ask gh/glab which keys and emails are registered")
1788
+ sp.add_argument("--json", action="store_true", help="machine-readable output")
1789
+ sp.set_defaults(func=cmd_team)
1790
+ pp = sub.add_parser("policy", help="show or lint team policy files")
1791
+ ps = pp.add_subparsers(dest="policy_command", metavar="ACTION")
1792
+ ps.required = True
1793
+ s = ps.add_parser("show", parents=[common], help="the policy rules that apply to a repo")
1794
+ s.add_argument("dir", nargs="?", default=".", help="repo directory (default: .)")
1795
+ s.add_argument("--json", action="store_true", help="machine-readable output")
1796
+ s.set_defaults(func=cmd_show)
1797
+ s = ps.add_parser("lint", help="validate a policy file (every error, no permission check)")
1798
+ s.add_argument("file", type=Path, help="the policy TOML")
1799
+ s.add_argument("--json", action="store_true", help="machine-readable output")
1800
+ s.set_defaults(func=cmd_lint)
1801
+ capability("policy", "team")
1802
+
1803
+
1804
+ def extend(subs) -> None:
1805
+ from .args import capability
1806
+ from .cli import extend_command
1807
+ extend_command(subs, "check", add_check_flags, check_handler)
1808
+ capability("check.policy")