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/__init__.py +4 -0
- gitpro/__main__.py +5 -0
- gitpro/agents.py +1928 -0
- gitpro/approval_policy.py +151 -0
- gitpro/args.py +82 -0
- gitpro/audit.py +800 -0
- gitpro/change_contract.py +117 -0
- gitpro/changes.py +853 -0
- gitpro/cli.py +499 -0
- gitpro/clone.py +505 -0
- gitpro/completion.py +407 -0
- gitpro/config.py +517 -0
- gitpro/devcontainer.py +442 -0
- gitpro/discover.py +1579 -0
- gitpro/doctor.py +572 -0
- gitpro/envcmd.py +370 -0
- gitpro/example.toml +102 -0
- gitpro/files.py +139 -0
- gitpro/forge.py +1244 -0
- gitpro/guard.py +1121 -0
- gitpro/health.py +1241 -0
- gitpro/hot.py +38 -0
- gitpro/jsonfile.py +195 -0
- gitpro/jsonout.py +26 -0
- gitpro/keys.py +59 -0
- gitpro/lint.py +50 -0
- gitpro/lost.py +874 -0
- gitpro/mcp.py +1055 -0
- gitpro/operation_contract.py +178 -0
- gitpro/operation_io.py +256 -0
- gitpro/operation_reconcile.py +191 -0
- gitpro/operations.py +841 -0
- gitpro/pin.py +89 -0
- gitpro/plan.py +199 -0
- gitpro/policy.py +1808 -0
- gitpro/prompt.py +304 -0
- gitpro/render.py +474 -0
- gitpro/resolve.py +898 -0
- gitpro/scan.py +358 -0
- gitpro/scanroots.py +146 -0
- gitpro/setup.py +260 -0
- gitpro/trailers.py +218 -0
- gitpro/tui.py +178 -0
- gitpro/undo.py +63 -0
- gitpro/view.py +155 -0
- gitpro-0.3.1.dist-info/METADATA +103 -0
- gitpro-0.3.1.dist-info/RECORD +50 -0
- gitpro-0.3.1.dist-info/WHEEL +4 -0
- gitpro-0.3.1.dist-info/entry_points.txt +2 -0
- gitpro-0.3.1.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|