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.
@@ -0,0 +1,151 @@
1
+ """Operator-supplied, deny-by-default grants for exact persistent change plans."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+ from datetime import datetime, timezone
6
+ import hashlib
7
+ import os
8
+ from pathlib import Path
9
+ import re
10
+ from typing import Optional
11
+
12
+ from .operation_contract import OperationError, decode, digest
13
+
14
+ MAX_POLICY = 65536
15
+ PATHS = {"type": "array", "maxItems": 128, "uniqueItems": True,
16
+ "items": {"type": "string", "minLength": 1}}
17
+ SCHEMA = {
18
+ "type": "object", "additionalProperties": False, "required": ["version", "rules"],
19
+ "properties": {
20
+ "version": {"type": "integer", "enum": [1]},
21
+ "expires_at": {"type": "string", "pattern": r"^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ$"},
22
+ "rules": {"type": "array", "maxItems": 32, "items": {
23
+ "type": "object", "additionalProperties": False,
24
+ "required": ["id", "kinds", "config_paths", "repo_roots", "write_paths", "write_roots"],
25
+ "properties": {
26
+ "id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$"},
27
+ "kinds": {"type": "array", "minItems": 1, "maxItems": 4, "uniqueItems": True,
28
+ "items": {"enum": ["configure", "regenerate", "repo-identity", "undo"]}},
29
+ **{key: PATHS for key in ("config_paths", "repo_roots", "write_paths", "write_roots")},
30
+ }}},
31
+ },
32
+ }
33
+ AUTHORITY_SCHEMA = {
34
+ "type": "object", "additionalProperties": False, "required": ["kind"],
35
+ "properties": {"kind": {"enum": ["human", "policy"]}, "policy_path": {"type": "string"},
36
+ "policy_sha256": {"type": "string"}, "policy_fingerprint": {"type": "string"},
37
+ "rule_id": {"type": "string"}},
38
+ }
39
+
40
+
41
+ def _invalid(message: str) -> OperationError:
42
+ return OperationError("invalid_approval_policy", message, "needs_input")
43
+
44
+
45
+ def _path(value: object) -> Path:
46
+ if not isinstance(value, str) or not value or any(ord(c) < 32 or ord(c) == 127 for c in value):
47
+ raise _invalid("Policy paths must be absolute, normalized paths without control characters.")
48
+ path = Path(value)
49
+ if not path.is_absolute() or ".." in path.parts or str(path) != value or path.resolve() != path:
50
+ raise _invalid("Policy paths must be absolute and canonical; symlink aliases are not supported.")
51
+ return path
52
+
53
+
54
+ def parse(raw: object) -> dict:
55
+ if (not isinstance(raw, dict) or type(raw.get("version")) is not int or raw["version"] != 1 or
56
+ set(raw) - {"version", "expires_at", "rules"}):
57
+ raise _invalid("Use a version-1 operator approval policy with only rules and optional expires_at.")
58
+ rules = raw.get("rules")
59
+ if not isinstance(rules, list) or len(rules) > 32:
60
+ raise _invalid("Supply a rules array containing at most 32 rules.")
61
+ if "expires_at" in raw:
62
+ value = raw["expires_at"]
63
+ if not isinstance(value, str) or re.fullmatch(r"\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ", value) is None:
64
+ raise _invalid("expires_at must be a UTC timestamp, YYYY-MM-DDTHH:MM:SSZ.")
65
+ try:
66
+ datetime.fromisoformat(value[:-1] + "+00:00")
67
+ except ValueError:
68
+ raise _invalid("expires_at is not a valid UTC timestamp.") from None
69
+ seen = set()
70
+ for rule in rules:
71
+ if not isinstance(rule, dict) or set(rule) != {
72
+ "id", "kinds", "config_paths", "repo_roots", "write_paths", "write_roots"}:
73
+ raise _invalid("Every rule needs id, kinds, config_paths, repo_roots, write_paths and write_roots.")
74
+ name = rule["id"]
75
+ if not isinstance(name, str) or re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,63}", name) is None or name in seen:
76
+ raise _invalid("Rule IDs must be unique labels of at most 64 characters.")
77
+ seen.add(name)
78
+ kinds = rule["kinds"]
79
+ if (not isinstance(kinds, list) or not kinds or len(kinds) > 4 or
80
+ any(not isinstance(k, str) or k not in ("configure", "regenerate", "repo-identity", "undo") for k in kinds) or
81
+ len(set(kinds)) != len(kinds)):
82
+ raise _invalid("Rule kinds must be unique supported change kinds.")
83
+ for key in ("config_paths", "repo_roots", "write_paths", "write_roots"):
84
+ values = rule[key]
85
+ if not isinstance(values, list) or len(values) > 128:
86
+ raise _invalid("Each path scope must be an array containing at most 128 paths.")
87
+ paths = [_path(value) for value in values]
88
+ if len(set(paths)) != len(paths) or any(path == Path(path.anchor) for path in paths):
89
+ raise _invalid("Duplicate paths and filesystem-root scopes are not allowed.")
90
+ if not rule["write_paths"] and not rule["write_roots"]:
91
+ raise _invalid("A rule needs at least one explicit write path or write root.")
92
+ return raw
93
+
94
+
95
+ def _inside(path: Path, roots: list[str]) -> bool:
96
+ return any(path == Path(root) or Path(root) in path.parents for root in roots)
97
+
98
+
99
+ @dataclass(frozen=True)
100
+ class Policy:
101
+ path: Path
102
+ rules: list[dict]
103
+ sha256: str
104
+ fingerprint: str
105
+ expires_at: Optional[str]
106
+
107
+ def grant(self, document: dict) -> dict:
108
+ if self.expires_at and datetime.now(timezone.utc) >= datetime.fromisoformat(
109
+ self.expires_at[:-1] + "+00:00"):
110
+ raise OperationError("approval_policy_expired", "The operator approval policy expired.", "needs_approval")
111
+ request = document["request"]
112
+ source = request.get("config_path") or document["context"].get("config_path")
113
+ repo = request.get("repo") or document["context"].get("repo")
114
+ paths = [Path(c["path"]) for c in document["changes"]]
115
+ if any(p == self.path or p in self.path.parents for p in paths):
116
+ raise OperationError("approval_scope_denied", "An operator policy cannot authorize its own replacement.",
117
+ "needs_approval")
118
+ for rule in self.rules:
119
+ if request["kind"] not in rule["kinds"] or not source or source not in rule["config_paths"]:
120
+ continue
121
+ if repo and not _inside(Path(repo), rule["repo_roots"]):
122
+ continue
123
+ if not all(str(p) in rule["write_paths"] or _inside(p, rule["write_roots"]) for p in paths):
124
+ continue
125
+ return {"kind": "policy", "policy_path": str(self.path), "policy_sha256": self.sha256,
126
+ "policy_fingerprint": self.fingerprint, "rule_id": rule["id"]}
127
+ raise OperationError("approval_scope_denied", "No single operator rule covers this kind, config, repository "
128
+ "and every changed file. Obtain human approval or a narrower proposal.", "needs_approval")
129
+
130
+
131
+ def load(path: Path) -> Policy:
132
+ # Share the change planner's strict, bounded file/parent checks rather than interpreting symlinks differently.
133
+ from .changes import _bytes, _fingerprint, _snapshot
134
+ path = _path(str(path))
135
+ for parent in path.parents:
136
+ if parent.exists() and parent.stat().st_uid not in (0, os.getuid()):
137
+ raise _invalid("Policy parent directories must be owned by the operator or root.")
138
+ snapshot = _snapshot(path)
139
+ if snapshot is None:
140
+ raise _invalid("The explicitly selected approval policy does not exist.")
141
+ data = _bytes(snapshot)
142
+ try:
143
+ raw = parse(decode(data.decode("utf-8"), limit=MAX_POLICY))
144
+ except OperationError as exc:
145
+ if exc.code == "invalid_approval_policy":
146
+ raise
147
+ raise _invalid("The operator policy must be bounded JSON without duplicate fields.") from None
148
+ except (UnicodeError, ValueError):
149
+ raise _invalid("The operator policy must be valid UTF-8 JSON.") from None
150
+ return Policy(path, raw["rules"], hashlib.sha256(data).hexdigest(),
151
+ digest({"path": str(path), "file": _fingerprint(snapshot)}), raw.get("expires_at"))
gitpro/args.py ADDED
@@ -0,0 +1,82 @@
1
+ """Argument pieces shared by cli.py and the hot modules (guard, agents, prompt). Stdlib only.
2
+
3
+ A hot module exposes add_arguments(p). Its register() does
4
+ add_arguments(sub.add_parser(name, parents=[common, confirm]))
5
+ and its main(argv) does
6
+ p = argparse.ArgumentParser(prog="gitpro <name>", parents=[args.common(), args.confirm()])
7
+ add_arguments(p)
8
+ return args.guarded(lambda a: a.func(a), p.parse_args(argv[1:]))
9
+ so both routes accept the same arguments without importing cli.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import shlex
15
+ import subprocess
16
+ import sys
17
+ from pathlib import Path
18
+ from typing import Callable, Set
19
+
20
+ CAPABILITIES: Set[str] = set() # filled by register()/extend(); `gitpro --version --json` lists them sorted
21
+
22
+
23
+ def capability(*names: str) -> None:
24
+ """Announce what this build can do ("why", "clone.dry-run", ...); clients probe the list."""
25
+ CAPABILITIES.update(names)
26
+
27
+
28
+ def common() -> argparse.ArgumentParser:
29
+ """Parent parser: -c/--config PATH and the hidden --home."""
30
+ p = argparse.ArgumentParser(add_help=False)
31
+ p.add_argument("-c", "--config", type=Path, metavar="PATH", help="config file (default: see gitpro -h)")
32
+ p.add_argument("--home", type=Path, default=Path.home(), help=argparse.SUPPRESS)
33
+ return p
34
+
35
+
36
+ def confirm() -> argparse.ArgumentParser:
37
+ """Explicit execution: --write retains confirmation; -y also answers yes."""
38
+ p = argparse.ArgumentParser(add_help=False)
39
+ p.add_argument("-y", "--yes", action="store_true", help="don't ask for confirmation (needed without a terminal)")
40
+ p.add_argument("--write", action="store_true", help="execute after confirmation (default: preview only)")
41
+ return p
42
+
43
+
44
+ def preview_only(args: object) -> bool:
45
+ return not (getattr(args, "yes", False) or getattr(args, "write", False))
46
+
47
+
48
+ def preview_result(args: object, command: str, data: dict) -> int:
49
+ if getattr(args, "json", False):
50
+ from . import jsonout
51
+ jsonout.emit(command, dict(data, dry_run=True))
52
+ else:
53
+ print("Dry run: nothing written. Use -y/--yes or the command's --write option to execute.")
54
+ return 0
55
+
56
+
57
+ def fail(msg: str, args: object) -> int:
58
+ """Exit 2 with msg: the JSON error object on stdout under --json (nothing on stderr), else ✗ on stderr."""
59
+ if getattr(args, "json", False):
60
+ from . import jsonout
61
+ jsonout.error(getattr(args, "command", None) or "gitpro", msg)
62
+ else:
63
+ print(f"✗ {msg}", file=sys.stderr)
64
+ return 2
65
+
66
+
67
+ def guarded(func: Callable[[argparse.Namespace], int], args: argparse.Namespace) -> int:
68
+ """func(args) with gitpro's error handling: ConfigError, CalledProcessError, OSError and UnicodeError
69
+ become exit 2 through fail(); Ctrl-C / EOF exit 130."""
70
+ from .config import ConfigError
71
+ try:
72
+ return func(args)
73
+ except ConfigError as e:
74
+ return fail(str(e), args)
75
+ except subprocess.CalledProcessError as e:
76
+ cmd = e.cmd if isinstance(e.cmd, str) else shlex.join(str(c) for c in e.cmd)
77
+ return fail(f"{cmd} failed ({e.returncode})", args)
78
+ except (OSError, UnicodeError) as e:
79
+ return fail(str(e), args)
80
+ except (KeyboardInterrupt, EOFError):
81
+ print(file=sys.stderr)
82
+ return 130