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/changes.py ADDED
@@ -0,0 +1,853 @@
1
+ """Exact, approved persistent changes with durable backups and bounded recovery."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import base64
6
+ import contextlib
7
+ import difflib
8
+ import hashlib
9
+ import json
10
+ import os
11
+ import sqlite3
12
+ import stat
13
+ import sys
14
+ import tempfile
15
+ import time
16
+ from pathlib import Path
17
+ from typing import Iterator, Optional
18
+
19
+ from . import change_contract as contract
20
+ from . import approval_policy
21
+ from . import config, files, jsonout, resolve
22
+ from .operation_contract import MAX_REQUEST, OperationError, decode, digest
23
+ from .operation_io import Ledger, configuration_lock, process, repository_lock, state_dir, termination_signals
24
+ from .operation_io import sync_path as _sync
25
+
26
+ MAX_FILE = 1024 * 1024
27
+ MAX_PLAN = 4 * MAX_FILE
28
+ MAX_FILES = 128
29
+
30
+
31
+ def _safe_path(path: Path) -> None:
32
+ if not path.is_absolute() or ".." in path.parts:
33
+ raise OperationError("unsafe_path", "Change paths must be absolute and normalized.", "blocked")
34
+ for parent in (path, *path.parents):
35
+ try:
36
+ st = parent.lstat()
37
+ except FileNotFoundError:
38
+ continue
39
+ if stat.S_ISLNK(st.st_mode):
40
+ raise OperationError("unsafe_path", f"Symlinks are not supported in change paths: {parent}", "blocked")
41
+ if parent != path and not stat.S_ISDIR(st.st_mode):
42
+ raise OperationError("unsafe_path", f"A change parent is not a directory: {parent}", "blocked")
43
+ if parent != path and st.st_mode & 0o022 and not st.st_mode & stat.S_ISVTX:
44
+ raise OperationError("unsafe_path", f"A change parent is writable by others: {parent}", "blocked")
45
+
46
+
47
+ def _protect_git_metadata(path: Path) -> None:
48
+ _safe_path(path)
49
+ for parent in (path, *path.parents):
50
+ # Bare/separate Git dirs need not be named .git; linked dirs carry commondir.
51
+ if parent.name.casefold() == ".git" or ((parent / "HEAD").is_file() and (
52
+ (parent / "objects").is_dir() or (parent / "refs").is_dir() or
53
+ (parent / "commondir").is_file())):
54
+ raise OperationError("unsafe_target", f"Managed configuration cannot replace Git metadata: {path}. "
55
+ "Use repo-identity for a supported local identity repair.", "blocked")
56
+
57
+
58
+ def _snapshot(path: Path) -> Optional[dict]:
59
+ _safe_path(path)
60
+ try:
61
+ fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
62
+ except FileNotFoundError:
63
+ return None
64
+ with os.fdopen(fd, "rb") as stream:
65
+ st = os.fstat(stream.fileno())
66
+ if not stat.S_ISREG(st.st_mode) or st.st_uid not in (0, os.getuid()) or st.st_mode & 0o022:
67
+ raise OperationError("unsafe_file", f"Expected a trusted, non-writable-by-others regular file: {path}",
68
+ "blocked")
69
+ data = stream.read(MAX_FILE + 1)
70
+ after = os.fstat(stream.fileno())
71
+ if len(data) > MAX_FILE:
72
+ raise OperationError("file_too_large", f"Change inputs are limited to 1 MiB per file: {path}", "blocked")
73
+ if (st.st_size, st.st_mtime_ns, st.st_ctime_ns) != (after.st_size, after.st_mtime_ns, after.st_ctime_ns):
74
+ raise OperationError("stale_plan", "A file changed while it was being read.", "blocked")
75
+ return {**_content(data, stat.S_IMODE(st.st_mode)), "uid": st.st_uid,
76
+ "stamp": [st.st_dev, st.st_ino, st.st_mtime_ns, st.st_ctime_ns]}
77
+
78
+
79
+ def _content(data: bytes, mode: int) -> dict:
80
+ return {"data": base64.b64encode(data).decode("ascii"), "sha256": hashlib.sha256(data).hexdigest(), "mode": mode}
81
+
82
+
83
+ def _bytes(value: Optional[dict]) -> bytes:
84
+ return base64.b64decode(value["data"], validate=True) if value else b""
85
+
86
+
87
+ def _fingerprint(value: Optional[dict]) -> Optional[dict]:
88
+ return {k: v for k, v in value.items() if k != "data"} if value else None
89
+
90
+
91
+ def _same_content(left: Optional[dict], right: Optional[dict]) -> bool:
92
+ return ((left is None and right is None) or
93
+ (left is not None and right is not None and
94
+ (left["sha256"], left["mode"]) == (right["sha256"], right["mode"])))
95
+
96
+
97
+ class Store(Ledger):
98
+ """Shares the private, FULL-synchronous operation database; old bytes are the undo backup."""
99
+
100
+ def __init__(self, readonly: bool = False) -> None:
101
+ try:
102
+ super().__init__(readonly)
103
+ except OperationError as exc:
104
+ if readonly and exc.code == "unknown_request":
105
+ raise OperationError("approval_required", "No persistent change approval is recorded.",
106
+ "needs_approval") from None
107
+ raise
108
+ if not readonly:
109
+ try:
110
+ with self.db:
111
+ self.db.execute("CREATE TABLE IF NOT EXISTS changes "
112
+ "(id TEXT PRIMARY KEY, document TEXT NOT NULL, state TEXT NOT NULL, "
113
+ "approved_at REAL NOT NULL, result TEXT)")
114
+ self.db.execute("CREATE TABLE IF NOT EXISTS change_locks "
115
+ "(id TEXT, path TEXT, device INTEGER, inode INTEGER, ctime INTEGER, "
116
+ "PRIMARY KEY(id, path))")
117
+ self.db.execute("CREATE TABLE IF NOT EXISTS change_writes "
118
+ "(id TEXT, path TEXT, PRIMARY KEY(id, path))")
119
+ self.db.execute("CREATE TABLE IF NOT EXISTS change_authorizations "
120
+ "(id TEXT PRIMARY KEY, authority TEXT NOT NULL)")
121
+ columns = {row[1] for row in self.db.execute("PRAGMA table_info(changes)")}
122
+ if "authority_kind" not in columns:
123
+ self.db.execute("ALTER TABLE changes ADD COLUMN authority_kind TEXT NOT NULL DEFAULT 'human'")
124
+ except (OSError, sqlite3.Error):
125
+ self.close()
126
+ raise
127
+
128
+ def lookup(self, identifier: str) -> tuple[dict, str, Optional[dict]]:
129
+ exists = self.db.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='changes'").fetchone()
130
+ row = (self.db.execute("SELECT document, state, result FROM changes WHERE id=?", (identifier,)).fetchone()
131
+ if exists else None)
132
+ if row is None:
133
+ raise OperationError("approval_required", "No approval is recorded for this exact plan.", "needs_approval")
134
+ document = json.loads(row[0])
135
+ result = json.loads(row[2]) if row[2] is not None else None
136
+ if (not isinstance(document, dict) or digest(document) != identifier or
137
+ row[1] not in ("approved", "applying", "applied", "rolled_back", "indeterminate") or
138
+ (row[1] in ("applied", "rolled_back", "indeterminate") and not isinstance(result, dict))):
139
+ raise OperationError("journal_corrupt", "The recorded change does not match its ID.", "indeterminate")
140
+ return document, row[1], result
141
+
142
+ def approve(self, document: dict, authority: Optional[dict] = None) -> None:
143
+ authority = authority or {"kind": "human"}
144
+ identifier = digest(document)
145
+ with self.db:
146
+ inserted = self.db.execute("INSERT OR IGNORE INTO changes "
147
+ "(id, document, state, approved_at, result, authority_kind) "
148
+ "VALUES (?, ?, 'approved', ?, NULL, ?)",
149
+ (identifier, json.dumps(document), time.time(), authority["kind"])).rowcount
150
+ state = self.db.execute("SELECT state FROM changes WHERE id=?", (identifier,)).fetchone()[0]
151
+ if inserted or (state == "approved" and (
152
+ authority["kind"] == "human" or self.authority(identifier)["kind"] == "policy")):
153
+ self.db.execute("INSERT OR REPLACE INTO change_authorizations VALUES (?, ?)",
154
+ (identifier, json.dumps(authority)))
155
+ self.db.execute("UPDATE changes SET authority_kind=?, approved_at=? WHERE id=?",
156
+ (authority["kind"], time.time(), identifier))
157
+
158
+ def authority(self, identifier: str) -> dict:
159
+ table = self.db.execute("SELECT 1 FROM sqlite_master WHERE name='change_authorizations'").fetchone()
160
+ row = (self.db.execute("SELECT authority FROM change_authorizations WHERE id=?", (identifier,)).fetchone()
161
+ if table else None)
162
+ columns = {col[1] for col in self.db.execute("PRAGMA table_info(changes)")}
163
+ kind = (self.db.execute("SELECT authority_kind FROM changes WHERE id=?", (identifier,)).fetchone()[0]
164
+ if "authority_kind" in columns else "human")
165
+ # Records created before scoped policies existed were exclusively terminal-approved.
166
+ value = json.loads(row[0]) if row else {"kind": "human"}
167
+ if not isinstance(value, dict) or value.get("kind") not in ("human", "policy") or value["kind"] != kind:
168
+ raise OperationError("journal_corrupt", "Invalid approval authority record.", "indeterminate")
169
+ return value
170
+
171
+ def transition(self, identifier: str, state: str, result: Optional[dict] = None) -> None:
172
+ with self.db:
173
+ self.db.execute("UPDATE changes SET state=?, result=? WHERE id=?",
174
+ (state, json.dumps(result) if result is not None else None, identifier))
175
+
176
+ def record_lock(self, identifier: str, path: Path, st: os.stat_result) -> None:
177
+ with self.db:
178
+ self.db.execute("INSERT OR REPLACE INTO change_locks VALUES (?, ?, ?, ?, ?)",
179
+ (identifier, str(path), st.st_dev, st.st_ino, st.st_ctime_ns))
180
+
181
+ def record_write(self, identifier: str, path: Path) -> None:
182
+ with self.db:
183
+ self.db.execute("INSERT OR IGNORE INTO change_writes VALUES (?, ?)", (identifier, str(path)))
184
+
185
+ def written(self, identifier: str) -> set[str]:
186
+ return {row[0] for row in self.db.execute("SELECT path FROM change_writes WHERE id=?", (identifier,))}
187
+
188
+ def release_orphan_locks(self, document: dict) -> None:
189
+ allowed = {str(Path(c["path"]).with_name(Path(c["path"]).name + ".lock")) for c in document["changes"]}
190
+ for name, device, inode, ctime in self.db.execute(
191
+ "SELECT path, device, inode, ctime FROM change_locks WHERE id=?", (digest(document),)):
192
+ if name not in allowed:
193
+ raise OperationError("journal_corrupt", "Unexpected target lock in the change journal.", "indeterminate")
194
+ path = Path(name)
195
+ _safe_path(path)
196
+ try:
197
+ current = path.lstat()
198
+ except FileNotFoundError:
199
+ continue
200
+ if (current.st_dev, current.st_ino, current.st_ctime_ns) == (device, inode, ctime):
201
+ path.unlink()
202
+ _sync_dir(path.parent)
203
+
204
+
205
+ class Builder:
206
+ def __init__(self, request: dict):
207
+ self.request = request
208
+ self.home = Path.home().resolve()
209
+ self.inputs: dict[str, Optional[dict]] = {}
210
+ self.changes: list[dict] = []
211
+ self.context: dict = {}
212
+
213
+ def read(self, path: Path) -> Optional[dict]:
214
+ key = str(path)
215
+ if key not in self.inputs:
216
+ if len(self.inputs) >= MAX_FILES:
217
+ raise OperationError("plan_too_large", "A change is limited to 128 input files.", "blocked")
218
+ self.inputs[key] = _snapshot(path)
219
+ return self.inputs[key]
220
+
221
+ def target(self, path: Path, after: Optional[dict]) -> None:
222
+ before = self.read(path)
223
+ if before is not None and before["uid"] != os.getuid():
224
+ raise OperationError("unsafe_file", f"Change targets must be owned by you: {path}", "blocked")
225
+ if not _same_content(before, after):
226
+ self.changes.append({"path": str(path), "before": before, "after": after})
227
+
228
+ def document(self) -> dict:
229
+ parents = {}
230
+ for name, before in self.inputs.items():
231
+ path = Path(name)
232
+ if _snapshot(path) != before:
233
+ raise OperationError("stale_plan", "Change inputs moved while planning; propose again.", "blocked")
234
+ for parent in path.parents:
235
+ if str(parent) in parents:
236
+ continue
237
+ try:
238
+ st = parent.lstat()
239
+ parents[str(parent)] = [st.st_dev, st.st_ino, st.st_uid, stat.S_IMODE(st.st_mode)]
240
+ except FileNotFoundError:
241
+ parents[str(parent)] = None
242
+ document = {"version": 1, "home": str(self.home), "request": self.request, "context": self.context,
243
+ "inputs": {p: _fingerprint(v) for p, v in sorted(self.inputs.items())},
244
+ "parents": parents, "changes": sorted(self.changes, key=lambda c: c["path"])}
245
+ if len(json.dumps(document).encode()) > MAX_PLAN:
246
+ raise OperationError("plan_too_large", "A change and its backups are limited to 4 MiB.", "blocked")
247
+ return document
248
+
249
+
250
+ def _managed(builder: Builder) -> config.Config:
251
+ from . import plan, render
252
+ source = Path(builder.request["config_path"])
253
+ _protect_git_metadata(source)
254
+ current = builder.read(source)
255
+ if builder.request["kind"] == "configure":
256
+ text = builder.request["toml"]
257
+ elif current is not None:
258
+ text = _bytes(current).decode("utf-8")
259
+ else:
260
+ raise OperationError("missing_config", "No configuration exists; supply a configure proposal.",
261
+ "needs_input")
262
+ cfg = config.loads(text, source, "--config")
263
+ env = plan.Env(builder.home)
264
+ paths = [builder.home / n for n in (plan.MANAGED_GITCONFIG, ".gitconfig", plan.ALLOWED_SIGNERS,
265
+ ".ssh/config", ".ssh/" + plan.MANAGED_SSH)]
266
+ paths += plan.guard_paths(env) + [env.expand(i.file) for i in cfg.identities]
267
+ private_keys = {env.expand(person.ssh_key) for person in (cfg.default, *cfg.identities) if person.ssh_key}
268
+ # Check paths before the renderer follows or reads them, including its old manifest.
269
+ managed = builder.read(builder.home / plan.MANAGED_GITCONFIG)
270
+ paths += [env.expand(p) for p in render.manifest_of(_bytes(managed).decode("utf-8"))]
271
+ for path in (source, *paths):
272
+ _protect_git_metadata(path)
273
+ journal = state_dir().resolve()
274
+ if path == journal or journal in path.parents or path in journal.parents:
275
+ raise OperationError("unsafe_target", "Managed files cannot replace the operation journal or its locks.",
276
+ "blocked")
277
+ if builder.home not in path.parents or path in private_keys:
278
+ raise OperationError("unsafe_target", "Managed changes must stay inside home and cannot replace keys.",
279
+ "blocked")
280
+ if builder.home / ".ssh" in path.parents and path not in (
281
+ builder.home / ".ssh/config", builder.home / ".ssh" / plan.MANAGED_SSH):
282
+ raise OperationError("unsafe_target", "Only gitpro's two managed SSH configuration files may change.",
283
+ "blocked")
284
+ builder.read(path)
285
+ for key in private_keys:
286
+ builder.read(render.pub_path(key))
287
+ from .setup import _validate
288
+ _validate(text, source, builder.home)
289
+ targets, stale = plan.build(cfg, env)
290
+ for change in files.diff(targets, stale):
291
+ before = builder.read(change.path)
292
+ after = _content(_preserve_lines(_bytes(before), change.new), change.mode) if change.new is not None else None
293
+ builder.target(change.path, after)
294
+ if builder.request["kind"] == "configure":
295
+ builder.target(source, _content(text.encode("utf-8"), 0o600))
296
+ return cfg
297
+
298
+
299
+ def _preserve_lines(before: bytes, text: str) -> bytes:
300
+ """Keep unchanged hand-written bytes, including CRLF; use their line ending for inserted lines."""
301
+ old = before.decode("utf-8")
302
+ raw = old.splitlines(True)
303
+ normalized = old.replace("\r\n", "\n").replace("\r", "\n").splitlines(True)
304
+ new = text.splitlines(True)
305
+ ending = "\r\n" if old.count("\r\n") > old.count("\n") - old.count("\r\n") else "\n"
306
+ out = []
307
+ for tag, a, b, c, d in difflib.SequenceMatcher(a=normalized, b=new, autojunk=False).get_opcodes():
308
+ out.extend(raw[a:b] if tag == "equal" else [line.replace("\n", ending) for line in new[c:d]])
309
+ return "".join(out).encode("utf-8")
310
+
311
+
312
+ def _repo_identity(builder: Builder, cfg: config.Config) -> None:
313
+ if builder.changes:
314
+ raise OperationError("not_applied", "Approve a regenerate change before repairing repository overrides.",
315
+ "needs_input")
316
+ from .operations import child_environment
317
+ env = child_environment()
318
+ repo = Path(builder.request["repo"])
319
+ layout = resolve._need_layout(repo, env)
320
+ if layout.top != repo:
321
+ raise OperationError("invalid_repo", "Supply a worktree root, not a bare repository or subdirectory.",
322
+ "needs_input")
323
+ builder.context["common_dir"] = str(layout.common_dir)
324
+ local = layout.common_dir / "config"
325
+ if (repo / ".git").is_file():
326
+ builder.read(repo / ".git")
327
+ builder.read(layout.git_dir / "config.worktree")
328
+ before = builder.read(local)
329
+ if before is None:
330
+ raise OperationError("invalid_repo", "The repository has no local configuration file.", "blocked")
331
+ report = resolve.compare(repo, cfg, git_env=env, extra_checks=False)
332
+ output = process(["git", "config", "-z", "--show-scope", "--show-origin", "--list"], repo, env)
333
+ if output.code != 0 or output.stopped:
334
+ raise OperationError("config_read_failed", "Could not read effective repository configuration.", "blocked")
335
+ parts = output.out.split("\0")
336
+ if (len(parts) - 1) % 3 or parts[-1]:
337
+ raise OperationError("config_read_failed", "Malformed effective repository configuration.", "blocked")
338
+ beneath = {}
339
+ for n in range(0, len(parts) - 1, 3):
340
+ scope, origin, item = parts[n:n + 3]
341
+ origin = resolve._absolute(origin, layout)
342
+ key, _, value = item.partition("\n")
343
+ if origin.startswith("file:"):
344
+ builder.read(Path(origin[5:]))
345
+ if key.lower() in ("user.name", "user.email") and origin != "file:" + str(local):
346
+ beneath[key.lower()] = value
347
+ if scope in ("local", "worktree") and (
348
+ key.lower().startswith(("include.", "includeif.")) or scope == "worktree" or
349
+ key.lower() == "extensions.worktreeconfig"):
350
+ raise OperationError("unsupported_repair", "Pins, local includes and worktree-specific configuration "
351
+ "need separate human repair.", "needs_input")
352
+ keys = []
353
+ for field in ("name", "email"):
354
+ setting = getattr(report.effective, field)
355
+ expected = getattr(report.expected, field)
356
+ if setting.value != expected:
357
+ if (not expected or setting.scope != "repo" or setting.origin != "file:" + str(local) or
358
+ beneath.get("user." + field) != expected):
359
+ raise OperationError("unsupported_repair", "Removing direct local overrides would not restore "
360
+ "the configured identity.", "needs_input")
361
+ keys.append("user." + field)
362
+ if any(p.code != "override" for p in report.problems):
363
+ raise OperationError("unsupported_repair", "This repository also needs repairs beyond direct name/email "
364
+ "overrides; inspect gitpro why.", "needs_input")
365
+ builder.context.update(effective_digest=digest(output.out), identity={"name": report.expected.name,
366
+ "email": report.expected.email})
367
+ if not keys:
368
+ return
369
+ with tempfile.TemporaryDirectory(prefix="gitpro-change-") as tmp:
370
+ copy = Path(tmp) / "config"
371
+ files.atomic_write(copy, _bytes(before), 0o600, overwrite=False)
372
+ for key in keys:
373
+ result = process(["git", "config", "--file", str(copy), "--unset-all", key], repo, env)
374
+ if result.code != 0 or result.stopped:
375
+ raise OperationError("repair_failed", "Could not prepare the repository identity repair.", "blocked")
376
+ builder.target(local, _content(copy.read_bytes(), before["mode"]))
377
+
378
+
379
+ def _request(raw: object, cfg_path: Optional[Path] = None) -> dict:
380
+ if isinstance(raw, dict) and cfg_path is not None and raw.get("kind") != "undo" and "config_path" not in raw:
381
+ raw = dict(raw, config_path=str(cfg_path))
382
+ return contract.parse(raw)
383
+
384
+
385
+ def build(raw: object, cfg_path: Optional[Path] = None) -> dict:
386
+ request = _request(raw, cfg_path)
387
+ builder = Builder(request)
388
+ if request["kind"] == "undo":
389
+ with contextlib.closing(Store(readonly=True)) as store:
390
+ original, state, _ = store.lookup(request["change_id"])
391
+ if state != "applied" or original["home"] != str(builder.home):
392
+ raise OperationError("undo_unavailable", "Only a successfully applied change for this home can be undone.",
393
+ "blocked")
394
+ builder.context = {"undo_of": request["change_id"], **{
395
+ k: v for k, v in original["context"].items() if k == "common_dir"}}
396
+ for key in ("config_path", "repo"):
397
+ value = original["request"].get(key) or original["context"].get(key)
398
+ if value:
399
+ builder.context[key] = value
400
+ for change in original["changes"]:
401
+ path = Path(change["path"])
402
+ if not _same_content(builder.read(path), change["after"]):
403
+ raise OperationError("stale_plan", "An applied file changed; undo will not overwrite it.", "blocked")
404
+ builder.target(path, change["before"])
405
+ else:
406
+ cfg = _managed(builder)
407
+ if request["kind"] == "repo-identity":
408
+ _repo_identity(builder, cfg)
409
+ document = builder.document()
410
+ review = json.dumps({"request": document["request"], "changes": _display(document)}, ensure_ascii=False)
411
+ if resolve.redact_text(review) != review:
412
+ raise OperationError("sensitive_configuration", "The proposed request/diff contains credential-bearing URLs. "
413
+ "Use credential helpers rather than literal credentials in configuration.", "needs_input")
414
+ return document
415
+
416
+
417
+ def _display(document: dict) -> list[dict]:
418
+ changes = []
419
+ for change in document["changes"]:
420
+ before, after = change["before"], change["after"]
421
+ action = "create" if before is None else "remove" if after is None else (
422
+ "chmod" if before["sha256"] == after["sha256"] else "update")
423
+ # No context: unrelated hand-written credentials must not leak into a proposal.
424
+ diff = "".join(line if line.endswith("\n") else line + "\n\\n"
425
+ for line in difflib.unified_diff(
426
+ _bytes(before).decode("utf-8").splitlines(True), _bytes(after).decode("utf-8").splitlines(True),
427
+ "before", "after", n=0))
428
+ changes.append({"path": change["path"], "action": action,
429
+ "before_sha256": before["sha256"] if before else None,
430
+ "after_sha256": after["sha256"] if after else None,
431
+ "before_mode": before["mode"] if before else None,
432
+ "after_mode": after["mode"] if after else None, "diff": diff})
433
+ return changes
434
+
435
+
436
+ def _result(command: str, document: dict, status: str) -> dict:
437
+ return {"version": 1, "command": "change " + command, "ok": True,
438
+ "decision": "needs_approval" if status == "proposed" else "ready",
439
+ "problems": [], "plan_id": digest(document), "status": status}
440
+
441
+
442
+ def propose(raw: object, cfg_path: Optional[Path] = None, *, policy_path: Optional[Path] = None) -> dict:
443
+ try:
444
+ document = build(raw, cfg_path)
445
+ result = {**_result("propose", document, "proposed" if document["changes"] else "no_changes"),
446
+ "request": document["request"], "changes": _display(document), "authority": {"kind": "human"},
447
+ "approval": "Human review or a covering operator policy is required; a plan ID is not authorization."}
448
+ if policy_path is not None:
449
+ selected = approval_policy.load(policy_path)
450
+ try:
451
+ result["authority"] = selected.grant(document)
452
+ result["decision"] = "ready"
453
+ result["approval"] = "Covered by operator policy; apply still requires host authorization and revalidation."
454
+ except OperationError as exc:
455
+ if exc.decision != "needs_approval":
456
+ raise
457
+ result["problems"].append(exc.problem())
458
+ return result
459
+ except OperationError as exc:
460
+ return contract.error("propose", exc)
461
+ except config.ConfigError as exc:
462
+ return contract.error("propose", OperationError("invalid_config", "Invalid configuration: " +
463
+ resolve.redact_text(str(exc)), "needs_input"))
464
+ except (OSError, ValueError, sqlite3.Error):
465
+ return contract.error("propose", OperationError("planning_failed", "Could not read or render change inputs.",
466
+ "blocked"))
467
+
468
+
469
+ def approve(proposal: object) -> dict:
470
+ """Record human approval through a terminal; operator policy grants use a separate path."""
471
+ try:
472
+ if not sys.stdin.isatty() or not sys.stdout.isatty():
473
+ raise OperationError("terminal_required", "Approval requires a human-controlled interactive terminal.",
474
+ "needs_approval")
475
+ if not sys.stderr.isatty():
476
+ raise OperationError("terminal_required", "Approval review must also be visible on the terminal (stderr).",
477
+ "needs_approval")
478
+ if not isinstance(proposal, dict) or proposal.get("command") != "change propose":
479
+ raise OperationError("invalid_plan", "Supply the JSON result of change propose.", "needs_input")
480
+ identifier = contract.plan_id(proposal.get("plan_id"))
481
+ document = build(proposal.get("request"))
482
+ if digest(document) != identifier:
483
+ raise OperationError("stale_plan", "The proposal no longer matches disk; propose and review again.", "blocked")
484
+ from rich.console import Console
485
+ from rich.text import Text
486
+ from rich.prompt import Confirm
487
+ out = Console(stderr=True, highlight=False)
488
+ out.print(Text(f"Approve persistent gitpro change {identifier}\n"))
489
+ for item in _display(document):
490
+ out.print(Text(f"{item['action']} {item['path']} "
491
+ f"(mode {item['before_mode']} -> {item['after_mode']})\n{item['diff']}"))
492
+ out.print("This approval covers these exact files and conflict-safe rollback on failure.\n"
493
+ "Parent directories may be created privately. It does not authorize future changed plans.")
494
+ if not document["changes"]:
495
+ return _result("approve", document, "no_changes")
496
+ if not Confirm.ask("Approve this exact change?", default=False, console=out):
497
+ return dict(contract.error("approve", OperationError("approval_declined", "No approval recorded.",
498
+ "needs_approval")), status="rejected")
499
+ with configuration_lock(exclusive=True):
500
+ if digest(build(document["request"])) != identifier:
501
+ raise OperationError("stale_plan", "Inputs changed during review; no approval recorded.", "blocked")
502
+ with contextlib.closing(Store()) as store:
503
+ store.approve(document)
504
+ _, state, result = store.lookup(identifier)
505
+ return dict(result or _result("approve", document, state), command="change approve")
506
+ except OperationError as exc:
507
+ return contract.error("approve", exc)
508
+ except (OSError, ValueError, config.ConfigError, sqlite3.Error, KeyboardInterrupt):
509
+ return contract.error("approve", OperationError("approval_failed", "Approval was cancelled or could not "
510
+ "be durably recorded. Inspect change status.", "indeterminate"))
511
+
512
+
513
+ def _sync_dir(path: Path) -> None:
514
+ _sync(path, directory=True)
515
+
516
+
517
+ @contextlib.contextmanager
518
+ def _target_lock(path: Path, store: Store, identifier: str) -> Iterator[None]:
519
+ lock = path.with_name(path.name + ".lock")
520
+ try:
521
+ fd = os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY | os.O_NOFOLLOW, 0o600)
522
+ except FileExistsError:
523
+ raise OperationError("target_busy", f"A file is locked; no write was made to {path}.", "blocked") from None
524
+ st = os.fstat(fd)
525
+ os.close(fd)
526
+ try:
527
+ store.record_lock(identifier, lock, st)
528
+ yield
529
+ finally:
530
+ try:
531
+ current = lock.lstat()
532
+ except FileNotFoundError:
533
+ current = None
534
+ if current and (st.st_dev, st.st_ino, st.st_ctime_ns) == (
535
+ current.st_dev, current.st_ino, current.st_ctime_ns):
536
+ lock.unlink()
537
+ _sync_dir(lock.parent)
538
+
539
+
540
+ def _replace(path: Path, expected: Optional[dict], wanted: Optional[dict], store: Store, identifier: str) -> None:
541
+ _safe_path(path)
542
+ missing = [p for p in path.parents if not p.exists()]
543
+ for parent in reversed(missing):
544
+ parent.mkdir(mode=0o700)
545
+ _sync_dir(parent.parent)
546
+ with _target_lock(path, store, identifier):
547
+ if _snapshot(path) != expected:
548
+ raise OperationError("stale_plan", f"A file changed before replacement: {path}", "blocked")
549
+ store.record_write(identifier, path)
550
+ if wanted is None:
551
+ path.unlink()
552
+ else:
553
+ files.atomic_write(path, _bytes(wanted), wanted["mode"], follow_symlinks=False,
554
+ overwrite=expected is not None)
555
+ _sync(path)
556
+ _sync_dir(path.parent)
557
+ if not _same_content(_snapshot(path), wanted):
558
+ raise OperationError("verification_failed", f"Replacement could not be verified: {path}", "indeterminate")
559
+
560
+
561
+ def _rollback(document: dict, store: Store) -> list[str]:
562
+ conflicts = []
563
+ identifier = digest(document)
564
+ written = store.written(identifier)
565
+ for change in reversed(document["changes"]):
566
+ path = Path(change["path"])
567
+ if str(path) not in written:
568
+ continue
569
+ try:
570
+ current = _snapshot(path)
571
+ if _same_content(current, change["before"]):
572
+ if current is not None:
573
+ _sync(path)
574
+ _sync_dir(path.parent)
575
+ continue
576
+ if not _same_content(current, change["after"]):
577
+ conflicts.append(str(path))
578
+ continue
579
+ _replace(path, current, change["before"], store, identifier)
580
+ except (OSError, OperationError, sqlite3.Error):
581
+ conflicts.append(str(path))
582
+ return conflicts
583
+
584
+
585
+ def _verify(document: dict) -> None:
586
+ changed = {c["path"] for c in document["changes"]}
587
+ for name, before in document["inputs"].items():
588
+ if name not in changed and _fingerprint(_snapshot(Path(name))) != before:
589
+ raise OperationError("verification_failed", "An unchanged input moved while applying the change.",
590
+ "indeterminate")
591
+ for change in document["changes"]:
592
+ if not _same_content(_snapshot(Path(change["path"])), change["after"]):
593
+ raise OperationError("verification_failed", "The applied files do not match their reviewed contents.",
594
+ "indeterminate")
595
+ if document["request"]["kind"] == "repo-identity":
596
+ from .operations import child_environment
597
+ cfg = config.load_all(Path(document["request"]["config_path"]))
598
+ report = resolve.compare(Path(document["request"]["repo"]), cfg, git_env=child_environment(),
599
+ extra_checks=False)
600
+ if not report.ok():
601
+ raise OperationError("verification_failed", "The repository identity is not repaired.", "indeterminate")
602
+
603
+
604
+ def _replay(document: dict, state: str, previous: Optional[dict], command: str) -> Optional[dict]:
605
+ if document["home"] != str(Path.home().resolve()):
606
+ raise OperationError("wrong_home", "This approval belongs to another home directory.", "blocked")
607
+ if state not in ("applied", "rolled_back") or previous is None:
608
+ return None
609
+ if command == "apply" and state == "rolled_back" and previous["ok"]:
610
+ return dict(contract.error(command, OperationError("change_rolled_back", "This change was recovered, not "
611
+ "applied. A new attempt needs a fresh proposal and approval.", "blocked")),
612
+ plan_id=digest(document), status=state, replayed=True)
613
+ return dict(previous, command="change " + command, replayed=True)
614
+
615
+
616
+ def _authorize(identifier: str, raw: object, policy_path: Optional[Path], cfg_path: Optional[Path]) -> None:
617
+ if policy_path is None:
618
+ raise OperationError("approval_required", "Unattended authorization requires an operator-selected policy.",
619
+ "needs_approval")
620
+ with configuration_lock(exclusive=True):
621
+ document = build(raw, cfg_path)
622
+ if digest(document) != identifier:
623
+ raise OperationError("stale_plan", "The supplied request no longer matches the reviewed plan.", "blocked")
624
+ authority = approval_policy.load(policy_path).grant(document)
625
+ with contextlib.closing(Store()) as store:
626
+ store.approve(document, authority)
627
+
628
+
629
+ def _apply(identifier: str, recover: bool, policy_path: Optional[Path] = None) -> dict:
630
+ command = "recover" if recover else "apply"
631
+ with contextlib.closing(Store(readonly=True)) as store:
632
+ completed = _replay(*store.lookup(identifier), command)
633
+ if completed is not None:
634
+ return completed
635
+ with configuration_lock(exclusive=True), contextlib.closing(Store()) as store:
636
+ document, state, previous = store.lookup(identifier)
637
+ completed = _replay(document, state, previous, command)
638
+ if completed is not None:
639
+ return completed
640
+ if state not in ("approved", "applying", "indeterminate"):
641
+ raise OperationError("journal_corrupt", "Unknown change journal state.", "indeterminate")
642
+ if state != "approved" and not recover:
643
+ raise OperationError("outcome_unknown", "This change was interrupted. Inspect status, then request "
644
+ "change recover; apply will not repeat its writes.", "indeterminate")
645
+ if recover and state == "approved":
646
+ raise OperationError("not_started", "This approved change has not started; there is nothing to recover.",
647
+ "blocked")
648
+ common = document["context"].get("common_dir")
649
+ with repository_lock(Path(common)) if common else contextlib.nullcontext():
650
+ if not recover:
651
+ if digest(build(document["request"])) != identifier:
652
+ raise OperationError("stale_plan", "The approved inputs changed; propose and approve again.", "blocked")
653
+ authority = store.authority(identifier)
654
+ if authority["kind"] == "policy":
655
+ if policy_path is None:
656
+ raise OperationError("approval_required", "This plan needs its operator policy at execution.",
657
+ "needs_approval")
658
+ current = approval_policy.load(policy_path).grant(document)
659
+ if current != authority:
660
+ raise OperationError("approval_policy_changed", "The granting policy changed; explicitly "
661
+ "reauthorize the exact plan or obtain human approval.", "needs_approval")
662
+ store.transition(identifier, "applying")
663
+ try:
664
+ if recover:
665
+ store.release_orphan_locks(document)
666
+ conflicts = _rollback(document, store)
667
+ if conflicts:
668
+ raise OperationError("recovery_conflict", "Recovery preserved changed or locked files: " +
669
+ ", ".join(conflicts), "indeterminate")
670
+ result = _result(command, document, "rolled_back")
671
+ else:
672
+ for change in document["changes"]:
673
+ _replace(Path(change["path"]), change["before"], change["after"], store, identifier)
674
+ _verify(document)
675
+ result = _result(command, document, "applied")
676
+ except (OSError, OperationError, config.ConfigError, ValueError, KeyboardInterrupt) as exc:
677
+ conflicts = _rollback(document, store) if not recover else ["recovery incomplete"]
678
+ problem = exc if isinstance(exc, OperationError) else OperationError(
679
+ "apply_failed", "The change failed or was cancelled.", "blocked")
680
+ result = contract.error(command, problem)
681
+ result.update(plan_id=identifier, status="indeterminate" if conflicts else "rolled_back")
682
+ result["decision"] = "indeterminate" if conflicts else "blocked"
683
+ if conflicts:
684
+ result["problems"].append(OperationError("recovery_required", "Could not restore: " +
685
+ ", ".join(conflicts) + ". Backups are retained; inspect the files "
686
+ "and use change recover, not apply.", "indeterminate").problem())
687
+ result.update(replayed=False, backup_store=str(state_dir() / "ledger.sqlite3"),
688
+ authority=store.authority(identifier),
689
+ verification="exact file contents and modes; run preflight for operation readiness")
690
+ try:
691
+ store.transition(identifier, result["status"], result)
692
+ except sqlite3.Error:
693
+ return dict(contract.error(command, OperationError("journal_failed", "The files may have changed, "
694
+ "but their outcome could not be recorded. Inspect status and recover; do not reapply.",
695
+ "indeterminate")), plan_id=identifier, status="indeterminate")
696
+ return result
697
+
698
+
699
+ def apply(identifier: object, *, authorized: bool = False, recover: bool = False,
700
+ raw: object = None, policy_path: Optional[Path] = None, cfg_path: Optional[Path] = None) -> dict:
701
+ command = "recover" if recover else "apply"
702
+ try:
703
+ identifier = contract.plan_id(identifier)
704
+ if not authorized:
705
+ raise OperationError("authorization_required", "Applying or recovering needs explicit host/user "
706
+ "authorization as well as recorded approval.", "needs_approval")
707
+ if raw is not None:
708
+ if recover:
709
+ raise OperationError("invalid_request", "Recovery uses the original recorded rollback permission.",
710
+ "needs_input")
711
+ # Completed or uncertain attempts must never be reauthorized into a fresh forward execution.
712
+ needs_grant = True
713
+ normalized = _request(raw, cfg_path)
714
+ try:
715
+ with contextlib.closing(Store(readonly=True)) as store:
716
+ document, state, previous = store.lookup(identifier)
717
+ if normalized != document["request"]:
718
+ raise OperationError("idempotency_conflict", "The request differs from this recorded plan.",
719
+ "blocked")
720
+ completed = _replay(document, state, previous, command)
721
+ if completed is not None:
722
+ return completed
723
+ if state != "approved":
724
+ raise OperationError("outcome_unknown", "Inspect and recover this interrupted attempt.",
725
+ "indeterminate")
726
+ needs_grant = store.authority(identifier)["kind"] != "human"
727
+ except OperationError as exc:
728
+ if exc.code != "approval_required":
729
+ raise
730
+ if needs_grant:
731
+ _authorize(identifier, raw, policy_path, cfg_path)
732
+ return _apply(identifier, recover, policy_path)
733
+ except OperationError as exc:
734
+ return contract.error(command, exc)
735
+ except (OSError, ValueError, config.ConfigError, sqlite3.Error, KeyboardInterrupt):
736
+ return contract.error(command, OperationError("change_failed", "Could not complete the change. Inspect "
737
+ "change status before any retry.", "indeterminate"))
738
+
739
+
740
+ def status(identifier: object) -> dict:
741
+ try:
742
+ identifier = contract.plan_id(identifier)
743
+ with contextlib.closing(Store(readonly=True)) as store:
744
+ document, state, result = store.lookup(identifier)
745
+ authority = store.authority(identifier)
746
+ details = {"files": [c["path"] for c in document["changes"]],
747
+ "authority": authority,
748
+ "backup_store": str(state_dir() / "ledger.sqlite3")}
749
+ if result is not None:
750
+ return dict(result, **details, command="change status", replayed=True)
751
+ if state != "approved":
752
+ return dict(contract.error("status", OperationError("outcome_unknown", "Apply was interrupted or is "
753
+ "active. Do not reapply; inspect and request recovery.", "indeterminate")),
754
+ **details, plan_id=identifier, status=state)
755
+ return {**_result("status", document, state), **details}
756
+ except OperationError as exc:
757
+ return contract.error("status", exc)
758
+ except (OSError, ValueError, sqlite3.Error):
759
+ return contract.error("status", OperationError("journal_failed", "Could not read the change journal.",
760
+ "indeterminate"))
761
+
762
+
763
+ def command(args: argparse.Namespace) -> int:
764
+ with termination_signals():
765
+ action = args.change_command
766
+ if getattr(args, "schema", False):
767
+ jsonout.emit("change-schema", {"version": 1, "request": contract.REQUEST_SCHEMA,
768
+ "result": contract.RESULT_SCHEMA, "approval_policy": approval_policy.SCHEMA})
769
+ return 0
770
+ try:
771
+ if action in ("propose", "approve"):
772
+ source = args.request if action == "propose" else args.proposal
773
+ if not source:
774
+ raise OperationError("missing_input", "Supply the request/proposal JSON file.", "needs_input")
775
+ limit = MAX_REQUEST if action == "propose" else MAX_PLAN
776
+ if source == "-":
777
+ text = sys.stdin.read(limit + 1)
778
+ else:
779
+ with open(source, encoding="utf-8") as stream:
780
+ text = stream.read(limit + 1)
781
+ if action == "propose":
782
+ result = propose(decode(text), args.config, policy_path=args.approval_policy)
783
+ else:
784
+ if len(text.encode()) > limit:
785
+ raise OperationError("invalid_plan", "Proposal exceeds 4 MiB.", "needs_input")
786
+ proposal = decode(text, limit=limit)
787
+ if args.write:
788
+ result = approve(proposal)
789
+ else:
790
+ if not isinstance(proposal, dict):
791
+ raise OperationError("invalid_plan", "Supply a change proposal.", "needs_input")
792
+ result = propose(proposal.get("request"))
793
+ if result["ok"] and result["plan_id"] != proposal.get("plan_id"):
794
+ raise OperationError("stale_plan", "The proposal changed; propose again.", "blocked")
795
+ elif action == "status":
796
+ result = status(args.plan_id)
797
+ else:
798
+ raw = None
799
+ if action == "apply" and args.proposal:
800
+ with open(args.proposal, encoding="utf-8") as stream:
801
+ proposal = decode(stream.read(MAX_PLAN + 1), limit=MAX_PLAN)
802
+ if not isinstance(proposal, dict) or proposal.get("plan_id") != args.plan_id:
803
+ raise OperationError("invalid_plan", "Proposal and --plan-id must match.", "needs_input")
804
+ raw = proposal.get("request")
805
+ if raw is None:
806
+ raise OperationError("invalid_request", "Proposal has no request.", "needs_input")
807
+ if not (args.yes or args.write):
808
+ if action == "recover":
809
+ result = status(args.plan_id)
810
+ else:
811
+ if raw is None:
812
+ with contextlib.closing(Store(readonly=True)) as store:
813
+ document, _, _ = store.lookup(contract.plan_id(args.plan_id))
814
+ raw = document["request"]
815
+ result = propose(raw, policy_path=getattr(args, "approval_policy", None))
816
+ if result["ok"] and result["plan_id"] != args.plan_id:
817
+ raise OperationError("stale_plan", "The inputs changed; propose again.", "blocked")
818
+ else:
819
+ result = apply(args.plan_id, authorized=True, recover=action == "recover", raw=raw,
820
+ policy_path=getattr(args, "approval_policy", None))
821
+ except OperationError as exc:
822
+ result = contract.error(action, exc)
823
+ except (OSError, ValueError):
824
+ result = contract.error(action, OperationError("invalid_request", "Could not read the JSON input.",
825
+ "needs_input"))
826
+ jsonout.emit(result["command"], result)
827
+ return 0 if result["ok"] else (3 if result["decision"] == "blocked" else 2)
828
+
829
+
830
+ def register(sub: argparse._SubParsersAction, common: argparse.ArgumentParser,
831
+ confirm: argparse.ArgumentParser) -> None:
832
+ from .args import capability
833
+ parent = sub.add_parser("change", help="propose, approve and apply exact persistent identity changes")
834
+ commands = parent.add_subparsers(dest="change_command", required=True)
835
+ for name in ("propose", "approve", "apply", "status", "recover"):
836
+ parents = [common] if name == "propose" else [confirm] if name in ("apply", "recover") else []
837
+ parser = commands.add_parser(name, parents=parents, help=name + " a persistent change")
838
+ parser.add_argument("--json", action="store_true", help="JSON is always emitted (approval also needs a terminal)")
839
+ if name == "propose":
840
+ parser.add_argument("--request", metavar="FILE", help="versioned change JSON (- reads stdin)")
841
+ parser.add_argument("--schema", action="store_true", help="print request and result JSON Schemas")
842
+ elif name == "approve":
843
+ parser.add_argument("--proposal", required=True, metavar="FILE", help="saved change propose JSON")
844
+ parser.add_argument("--write", action="store_true", help="record human approval after terminal review")
845
+ else:
846
+ parser.add_argument("--plan-id", required=True, help="the complete approved plan ID")
847
+ if name in ("propose", "apply"):
848
+ parser.add_argument("--approval-policy", type=Path, help="operator-owned scoped authorization JSON")
849
+ if name == "apply":
850
+ parser.add_argument("--proposal", metavar="FILE", help="exact proposal to authorize under --approval-policy")
851
+ parser.set_defaults(func=command)
852
+ capability("change-propose.v1", "change-approve.v1", "change-apply.v1", "change-status.v1", "change-recover.v1")
853
+ capability("approval-policy.v1")