omc-os 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. merge_discretion_gate.py +744 -0
  2. omc/__init__.py +26 -0
  3. omc/__main__.py +15 -0
  4. omc_adapters.py +201 -0
  5. omc_driver.py +970 -0
  6. omc_live.py +527 -0
  7. omc_os-0.3.0.dist-info/METADATA +443 -0
  8. omc_os-0.3.0.dist-info/RECORD +64 -0
  9. omc_os-0.3.0.dist-info/WHEEL +5 -0
  10. omc_os-0.3.0.dist-info/entry_points.txt +2 -0
  11. omc_os-0.3.0.dist-info/licenses/LICENSE +21 -0
  12. omc_os-0.3.0.dist-info/top_level.txt +11 -0
  13. omc_seat_assets.py +145 -0
  14. omc_seat_runner/Dockerfile +52 -0
  15. omc_seat_runner/README.md +127 -0
  16. omc_seat_runner/__init__.py +4 -0
  17. omc_seat_runner/build.sh +19 -0
  18. omc_seat_runner/entrypoint.sh +53 -0
  19. omc_seat_runner/launch.sh +105 -0
  20. omc_seat_runner/model_api_gateway.py +426 -0
  21. omc_seat_runner/seat_model_client.py +309 -0
  22. seat_patch_sanitizer.py +348 -0
  23. seat_preflight.py +180 -0
  24. vcos/__init__.py +61 -0
  25. vcos/ai_bom.py +80 -0
  26. vcos/approval_config.py +176 -0
  27. vcos/artifact_validator.py +62 -0
  28. vcos/assurance.py +108 -0
  29. vcos/charter_builder.py +26 -0
  30. vcos/context_heatmap.py +93 -0
  31. vcos/context_packet.py +141 -0
  32. vcos/contracts.py +199 -0
  33. vcos/corpus.py +276 -0
  34. vcos/decision_ledger.py +178 -0
  35. vcos/decision_tree.py +70 -0
  36. vcos/eval_packs.py +69 -0
  37. vcos/evidence_parsers.py +164 -0
  38. vcos/evidence_registry.py +79 -0
  39. vcos/evidence_store.py +74 -0
  40. vcos/failure_taxonomy.py +109 -0
  41. vcos/guides.py +101 -0
  42. vcos/handoff.py +117 -0
  43. vcos/inner_loop.py +141 -0
  44. vcos/kpi.py +54 -0
  45. vcos/lifecycle.py +100 -0
  46. vcos/lifecycle_conformance.py +317 -0
  47. vcos/loader.py +163 -0
  48. vcos/loop_contract.py +111 -0
  49. vcos/merge_gate.py +515 -0
  50. vcos/merge_queue.py +154 -0
  51. vcos/migrate.py +68 -0
  52. vcos/node_judge.py +434 -0
  53. vcos/observe.py +172 -0
  54. vcos/orchestrate.py +117 -0
  55. vcos/policy.py +81 -0
  56. vcos/recursion_guard.py +115 -0
  57. vcos/reliability.py +110 -0
  58. vcos/review_orchestrator.py +87 -0
  59. vcos/sdd_adapter.py +83 -0
  60. vcos/skill_diff.py +56 -0
  61. vcos/sota_watcher.py +90 -0
  62. vcos/source_truth.py +60 -0
  63. vcos/validators.py +239 -0
  64. vcos_autoloop.py +850 -0
@@ -0,0 +1,744 @@
1
+ #!/usr/bin/env python3
2
+ """Top-seat merge-discretion gate (AT-v1.0 §AT7 mechanism, issue #138, owner grant
3
+ 2026-07-09) — Ring-1 合并裁量的确定性可执行化 + Ring-2 危险边界的确定性检测。
4
+
5
+ Honest positioning (this gate does NOT dress discipline up as a fence):
6
+ This is a DETERMINISTIC EXECUTOR of the merge authority the owner already
7
+ granted to the top orchestration seat, PLUS a boundary interceptor. It is
8
+ NOT an independent reviewer, it does NOT submit an `approve`, and it does
9
+ NOT claim to enforce review independence — under a same-machine model that
10
+ cannot be proven (see gate 3). It supersedes the GATE-FAILed auto_review_gate.
11
+ The GitHub SERVER-SIDE CI hard floor backstops it; this gate can NOT and does
12
+ NOT try to bypass that floor (it NEVER uses --admin). Every refusal fails
13
+ closed and emits an owner-escalation package rather than guessing.
14
+
15
+ Four doors run in order; any failure -> exit 1 + refusal reason + escalation
16
+ package (never a silent pass, never a stack trace):
17
+
18
+ gate 1 boundary classification from the LOCAL git raw diff (NOT the compare
19
+ API — its files[] is capped at 300 and only the first page carries
20
+ filenames, so a boundary file can hide). The classification base is
21
+ bound SHA-for-SHA to GitHub's declared PR base/head oids.
22
+ gate 2 server-side evidence, fail-closed: state==OPEN, mergeable==MERGEABLE,
23
+ every required check SUCCESS, head oid pinned + consistent with gate 1.
24
+ gate 3 structured verification evidence: existence + structure + full-SHA
25
+ binding of exactly two roles (joint_review_no_block +
26
+ opus_independent_verify). Verifies presence/structure/SHA-binding
27
+ ONLY — it does NOT prove review independence.
28
+ gate 4 execution (only under --execute and after 1-3 pass): re-read head oid,
29
+ `gh pr merge <pr> --merge --match-head-commit <pin>` (server-side SHA
30
+ guard closes the TOCTOU residual window), append audit to
31
+ .ai/merge-decisions.jsonl (itself a gate-1 deny path -> a PR cannot
32
+ alter its own audit ledger).
33
+
34
+ Repo-nature note (surfaced to and confirmed by owner before landing): this repo
35
+ IS the governance kit itself, so most changes touch a trust surface -> this gate
36
+ will correctly escalate MOST code PRs here to the owner (governance-heavy human
37
+ review is a FEATURE, not a bug). The autonomy dividend is realized in downstream
38
+ consumer repos where business code is not a trust surface (low deny hit-rate).
39
+
40
+ Gate history: FINAL spec after 4 security-gate rounds; 13 findings folded across
41
+ R1-R4 (R1 BLOCK-1 local-raw-diff base, BLOCK-2 structured evidence, MAJOR-3
42
+ deny-list breadth, MAJOR-4 --match-head-commit, MAJOR-5 SHA binding, MINOR-6
43
+ NFC/casefold, MINOR-7 audit self-protection; R4 terminal = MDG-003 three-fixture
44
+ form).
45
+
46
+ Contract:
47
+ scripts/merge_discretion_gate.py <pr> [--execute] \\
48
+ --evidence joint_review_no_block=<path> \\
49
+ --evidence opus_independent_verify=<path>
50
+
51
+ Pure stdlib. Default dry-run; --execute merges. Exit codes:
52
+ 0 = mergeable (dry-run) / merged (execute)
53
+ 1 = refused (fail-closed) + escalation package
54
+ 2 = usage / environment error
55
+ """
56
+ from __future__ import annotations
57
+
58
+ import argparse
59
+ import datetime
60
+ import hashlib
61
+ import json
62
+ import os
63
+ import re
64
+ import subprocess
65
+ import sys
66
+ import unicodedata
67
+ from pathlib import Path
68
+
69
+ # --- constants ---------------------------------------------------------------
70
+
71
+ AUDIT_LEDGER = ".ai/merge-decisions.jsonl"
72
+ # anti-abuse ceiling on diff record count (local raw diff has NO truncation; this
73
+ # is a runaway guard, not a data-source limit). >= this many records -> refuse.
74
+ DEFAULT_MAX_FILES = 1000
75
+ # git object modes we hard-reject regardless of path.
76
+ SYMLINK_MODE = "120000"
77
+ GITLINK_MODE = "160000"
78
+ # role -> the only verdict token that counts as "passed" for that role.
79
+ ROLE_PASS_VERDICT = {
80
+ "joint_review_no_block": "no_block",
81
+ "opus_independent_verify": "no_major_deviation",
82
+ }
83
+ REQUIRED_ROLES = tuple(sorted(ROLE_PASS_VERDICT))
84
+ EVIDENCE_REQUIRED_FIELDS = ("role", "verdict", "head_sha", "producer", "generated_at")
85
+
86
+ # --- boundary deny-list (source comments = the trust-surface System-of-Record) --
87
+ # Every comment ties a pattern back to WHY the path is a boundary. Changing this
88
+ # list is itself a change to scripts/** (self-modification) -> a boundary. Both
89
+ # the OLD and NEW path of every diff record is checked against these.
90
+
91
+ # Exact repo-relative paths (matched by exact NFC/casefold equality).
92
+ DENY_EXACT = (
93
+ # governance docs — routing/collaboration/ownership SSOTs
94
+ "docs/OWNERSHIP.md",
95
+ "docs/AGENT-COLLABORATION-MECHANISM.md",
96
+ "docs/agents/multimodel-cli-orchestration-guide.md",
97
+ "docs/maps/system-model.json", # system-model SoR (this gate registers here)
98
+ "AGENTS.md", # cross-agent SSOT
99
+ "CLAUDE.md", # Claude entry (imports AGENTS.md)
100
+ # root SoT documents (system-model root-meta-docs registration surface)
101
+ "CHANGELOG.md",
102
+ "CONTEXT.md",
103
+ "EVIDENCE-FIRST-OS-COMPLETE-SPEC.md",
104
+ "README.md",
105
+ "README.zh.md",
106
+ # kit trust surface — install/verify entrypoints + env + release lock
107
+ "install.sh",
108
+ "uninstall.sh",
109
+ "verify.sh",
110
+ ".envrc",
111
+ "release.lock.json",
112
+ "tools/codex_review.sh", # gate script (review wrapper)
113
+ # self-protection
114
+ "scripts/merge_discretion_gate.py", # this gate
115
+ AUDIT_LEDGER, # audit ledger — tamper-proofing (R1-MINOR-7)
116
+ )
117
+
118
+ # Segment-aware globs (`*` does NOT cross `/`; `**` crosses segments incl. zero).
119
+ DENY_GLOBS = (
120
+ # governance docs (whole trees)
121
+ "docs/adr/**", # ADRs — decision record
122
+ "docs/governance/**", # governance docs
123
+ "docs/CMAG-*", # CMAG merge-gate docs
124
+ "docs/multi-model-orchestration/**", # A[A-Z] adjudications + Orchestration V3 + README = routing/collab SSOT
125
+ # GitHub / ownership
126
+ ".github/**", # workflows / CODEOWNERS / config
127
+ "CODEOWNERS", # root CODEOWNERS
128
+ "**/CODEOWNERS", # CODEOWNERS anywhere
129
+ # kit trust surface (whole tree)
130
+ "templates/**", # rules/hooks/settings/ai-policies/codex — all trust surfaces
131
+ # runtime trust surface (whole trees — per-file whitelisting is leak-prone)
132
+ "runtime/vcos/**", # merge_queue/ai_bom/contracts/orchestrate/inner_loop/validators/assurance/eval_packs/loader — validation machine chain
133
+ "runtime/.vcos/**", # registry / vcos state
134
+ # gate scripts (system-model lists scripts/ wholesale as a dev-harness gate surface)
135
+ "scripts/**", # orchestration_kit/check_*/mission_resume/merge-settings/gen_*/doctor + this gate
136
+ # CI corpus protection (R3: server runs the PR's OWN tests — weakening tests can go green)
137
+ "runtime/tests/**", # test corpus IS a gate surface
138
+ "tests/**", # reserved: guards against a future top-level tests/ escape
139
+ )
140
+
141
+
142
+ # --- failure signalling ------------------------------------------------------
143
+
144
+ class Refusal(Exception):
145
+ """Fail-closed refusal (exit 1). Carries an owner-escalation package."""
146
+
147
+ def __init__(self, reason: str, *, rule: str | None = None,
148
+ path: str | None = None):
149
+ super().__init__(reason)
150
+ self.reason = reason
151
+ self.rule = rule
152
+ self.path = path
153
+
154
+ def escalation(self, pr: str) -> dict:
155
+ return {
156
+ "pr": pr,
157
+ "decision": "REFUSED",
158
+ "reason": self.reason,
159
+ "matched_rule": self.rule,
160
+ "matched_path": self.path,
161
+ "action": "owner sign-off required (owner 亲批)",
162
+ }
163
+
164
+
165
+ class GateError(Exception):
166
+ """Usage / environment error (exit 2)."""
167
+
168
+
169
+ # --- subprocess wrappers (fail closed on nonzero / empty) --------------------
170
+ # These are the seam the offline tests monkeypatch. Each raises Refusal on a
171
+ # failed/empty invocation so a broken tool NEVER reads as a green light.
172
+
173
+ def _run(cmd: list[str]) -> str:
174
+ """Run a command; return stdout. Fail closed: nonzero exit OR empty stdout
175
+ -> Refusal. FileNotFoundError (tool absent) -> Refusal."""
176
+ try:
177
+ proc = subprocess.run(cmd, capture_output=True, text=True, check=False)
178
+ except FileNotFoundError as exc:
179
+ raise Refusal(f"tool not found: {cmd[0]} ({exc})")
180
+ except OSError as exc: # pragma: no cover - defensive
181
+ raise Refusal(f"failed to run {cmd[0]}: {exc}")
182
+ if proc.returncode != 0:
183
+ err = (proc.stderr or "").strip() or (proc.stdout or "").strip()
184
+ raise Refusal(f"command failed ({' '.join(cmd[:3])} ... rc={proc.returncode}): {err}")
185
+ if not proc.stdout.strip():
186
+ raise Refusal(f"command produced empty output: {' '.join(cmd[:3])} ...")
187
+ return proc.stdout
188
+
189
+
190
+ def _run_ok(cmd: list[str], env: dict | None = None) -> str:
191
+ """Run a command that legitimately produces empty stdout on success (git
192
+ fetch/push, gh pr merge — their human output goes to stderr). Fail closed on
193
+ NONZERO exit only; empty stdout is NOT a failure here.
194
+
195
+ `env` defaults to None -> the child inherits this process's environment
196
+ UNCHANGED (door1-3 git calls stay byte-identical). Only door4's App-identity
197
+ merge passes an augmented env (a NEW dict; os.environ is never mutated)."""
198
+ try:
199
+ proc = subprocess.run(cmd, capture_output=True, text=True, check=False,
200
+ env=env)
201
+ except FileNotFoundError as exc:
202
+ raise Refusal(f"tool not found: {cmd[0]} ({exc})")
203
+ except OSError as exc: # pragma: no cover - defensive
204
+ raise Refusal(f"failed to run {cmd[0]}: {exc}")
205
+ if proc.returncode != 0:
206
+ err = (proc.stderr or "").strip() or (proc.stdout or "").strip()
207
+ raise Refusal(f"command failed ({' '.join(cmd[:3])} ... rc={proc.returncode}): {err}")
208
+ return proc.stdout
209
+
210
+
211
+ def gh_pr_view(pr: str, fields: str) -> dict:
212
+ """`gh pr view <pr> --json <fields>` -> dict (fail-closed on bad JSON)."""
213
+ out = _run(["gh", "pr", "view", pr, "--json", fields])
214
+ try:
215
+ data = json.loads(out)
216
+ except json.JSONDecodeError as exc:
217
+ raise Refusal(f"gh pr view returned unparseable JSON: {exc}")
218
+ if not isinstance(data, dict):
219
+ raise Refusal("gh pr view did not return a JSON object")
220
+ return data
221
+
222
+
223
+ def gh_pr_checks(pr: str) -> list[dict]:
224
+ """Required checks only. Fail-closed: a nonzero `gh pr checks` exit refuses
225
+ outright (see FOLD-2 below) before any row parsing; only on a clean (rc=0)
226
+ invocation do we return rows for door 2 to adjudicate (every required check
227
+ must be state==SUCCESS; empty set / any non-SUCCESS -> refuse)."""
228
+ cmd = ["gh", "pr", "checks", pr, "--required", "--json", "name,state,bucket"]
229
+ try:
230
+ proc = subprocess.run(cmd, capture_output=True, text=True, check=False)
231
+ except FileNotFoundError as exc:
232
+ raise Refusal(f"tool not found: gh ({exc})")
233
+ # FOLD-2 (Codex adversarial): a NONZERO `gh pr checks` exit is fail-closed
234
+ # EVEN IF stdout carries parseable JSON — a failed invocation (auth/env drop,
235
+ # rate limit, partial fetch) must never read as a green light. (gh also
236
+ # returns nonzero merely because a check is failing/pending; refusing there
237
+ # is still correct — door 2 requires every required check green anyway.)
238
+ if proc.returncode != 0:
239
+ err = (proc.stderr or "").strip() or (proc.stdout or "").strip()
240
+ raise Refusal(f"gh pr checks exited nonzero (rc={proc.returncode}) — "
241
+ f"refusing (fail-closed): {err}")
242
+ out = (proc.stdout or "").strip()
243
+ if not out:
244
+ # empty output (no required checks, or gh error with no rows) -> fail closed
245
+ raise Refusal("gh pr checks produced no rows (no required checks / gh error)")
246
+ try:
247
+ rows = json.loads(out)
248
+ except json.JSONDecodeError as exc:
249
+ raise Refusal(f"gh pr checks returned unparseable JSON: {exc}")
250
+ if not isinstance(rows, list):
251
+ raise Refusal("gh pr checks did not return a JSON array")
252
+ return rows
253
+
254
+
255
+ def git_fetch_refs(pr: str) -> None:
256
+ """Fetch base main + PR head into EXPLICIT temp refs (not FETCH_HEAD).
257
+ git fetch writes to stderr and leaves stdout empty on success, so this uses
258
+ the return-code-only wrapper (empty stdout is NOT failure)."""
259
+ _run_ok(["git", "fetch", "origin",
260
+ "+refs/heads/main:refs/mdg/base",
261
+ f"+refs/pull/{pr}/head:refs/mdg/head"])
262
+
263
+
264
+ def git_rev_parse(ref: str) -> str:
265
+ return _run(["git", "rev-parse", ref]).strip()
266
+
267
+
268
+ def git_diff_raw(base_oid: str, head_oid: str) -> str:
269
+ """`git diff --raw -z <base>...<head>` — merge-base..head, raw records carry
270
+ old/new mode+status+path directly and -z removes path-escaping ambiguity.
271
+ An empty diff is legal here (a doc-only no-op), so this does NOT go through
272
+ the empty-stdout guard in _run."""
273
+ cmd = ["git", "diff", "--raw", "-z", f"{base_oid}...{head_oid}"]
274
+ try:
275
+ proc = subprocess.run(cmd, capture_output=True, text=True, check=False)
276
+ except FileNotFoundError as exc:
277
+ raise Refusal(f"tool not found: git ({exc})")
278
+ if proc.returncode != 0:
279
+ raise Refusal(f"git diff failed (rc={proc.returncode}): {(proc.stderr or '').strip()}")
280
+ return proc.stdout
281
+
282
+
283
+ def _app_merge_token() -> str:
284
+ """Mint a short-lived GitHub App installation token by calling an EXTERNAL
285
+ helper (opt-in App MERGE identity, item 2). The gate NEVER reads the App
286
+ private key (.pem): the helper alone touches the key (openssl-signs a JWT
287
+ locally) and the gate receives ONLY the resulting installation token on
288
+ stdout (A3 key-isolation boundary).
289
+
290
+ fail-closed at every edge (this is the merge trust surface):
291
+ - helper path = env OMC_MINT_TOKEN_SH or ~/.secrets/omc-mint-installation-token.sh
292
+ - config path = env OMC_APP_CONFIG or ~/.secrets/omc-app-config.env, handed
293
+ to the helper via env (the helper `source`s it — NOT interpolated into a
294
+ shell string here, MINOR-5)
295
+ - hard 20s timeout (MAJOR-1); TimeoutExpired -> Refusal, merge never runs
296
+ - nonzero rc -> a GENERIC, token-free Refusal (MINOR-3: helper stdout/stderr
297
+ is NEVER echoed — a failure channel could carry the minted token)
298
+ - strip-then-reject empty/whitespace token (MAJOR-2: an empty GH_TOKEN reads
299
+ to gh as "unset" and silently falls back to the ambient PAT)"""
300
+ helper = os.environ.get("OMC_MINT_TOKEN_SH") or \
301
+ os.path.expanduser("~/.secrets/omc-mint-installation-token.sh")
302
+ cfg_path = os.environ.get("OMC_APP_CONFIG") or \
303
+ os.path.expanduser("~/.secrets/omc-app-config.env")
304
+ try:
305
+ proc = subprocess.run(
306
+ [helper],
307
+ env={**os.environ, "OMC_APP_CONFIG": cfg_path},
308
+ capture_output=True, text=True, timeout=20,
309
+ )
310
+ except subprocess.TimeoutExpired:
311
+ raise Refusal("app token mint timed out (>20s) — refusing (fail-closed)",
312
+ rule="app-mint-timeout")
313
+ except FileNotFoundError:
314
+ raise Refusal(f"app token mint helper not found: {helper}",
315
+ rule="app-mint-helper-missing")
316
+ except OSError as exc: # pragma: no cover - defensive
317
+ raise Refusal(f"app token mint helper failed to launch (rc=?): {exc.errno}",
318
+ rule="app-mint-launch-failed")
319
+ if proc.returncode != 0:
320
+ # GENERIC message only — do NOT interpolate proc.stdout/stderr (MINOR-3).
321
+ raise Refusal(f"app token mint failed (rc={proc.returncode})",
322
+ rule="app-mint-nonzero")
323
+ tok = proc.stdout.strip()
324
+ if not tok:
325
+ raise Refusal("app token mint returned empty", rule="app-mint-empty")
326
+ return tok
327
+
328
+
329
+ def gh_pr_merge(pr: str, pin: str) -> str:
330
+ """Server-side SHA-guarded merge. NEVER --admin (respects the CI hard floor).
331
+ gh prints its confirmation to stderr, so use the return-code-only wrapper —
332
+ a NONZERO exit (e.g. head-commit mismatch) still fails closed.
333
+
334
+ OMC_MERGE_VIA_APP=1 (opt-in) -> merge under a GitHub App installation identity
335
+ instead of the ambient owner PAT: mint a token via the external helper and
336
+ inject GH_TOKEN into ONLY this merge subprocess's env (a NEW augmented dict —
337
+ os.environ is never mutated; PATH/HOME preserved so gh still runs). Any mint
338
+ failure fails closed (Refusal) — NEVER a silent fallback to the PAT. Unset ->
339
+ env=None, i.e. the ambient authentication, byte-identical to prior behavior.
340
+ The argv is UNCHANGED in both modes (no --admin ever)."""
341
+ argv = ["gh", "pr", "merge", pr, "--merge", "--match-head-commit", pin]
342
+ if os.environ.get("OMC_MERGE_VIA_APP") == "1":
343
+ tok = _app_merge_token()
344
+ env = {**os.environ, "GH_TOKEN": tok} # augment, not replace (MINOR-4)
345
+ return _run_ok(argv, env=env)
346
+ return _run_ok(argv)
347
+
348
+
349
+ # --- segment-aware glob matcher (hand-written) -------------------------------
350
+ # `*` matches within ONE segment (never crosses `/`); `**` matches zero or more
351
+ # whole segments. Tests pin that `*.md` does NOT match a nested path.
352
+
353
+ def _seg_token_match(pat_seg: str, txt_seg: str) -> bool:
354
+ rx = "^" + "".join("[^/]*" if ch == "*" else re.escape(ch) for ch in pat_seg) + "$"
355
+ return re.match(rx, txt_seg) is not None
356
+
357
+
358
+ def _match_segs(pat: list[str], txt: list[str]) -> bool:
359
+ if not pat:
360
+ return not txt
361
+ if pat[0] == "**":
362
+ # `**` consumes zero or more whole segments.
363
+ for k in range(len(txt) + 1):
364
+ if _match_segs(pat[1:], txt[k:]):
365
+ return True
366
+ return False
367
+ if not txt:
368
+ return False
369
+ if _seg_token_match(pat[0], txt[0]):
370
+ return _match_segs(pat[1:], txt[1:])
371
+ return False
372
+
373
+
374
+ def seg_glob_match(pattern: str, path: str) -> bool:
375
+ return _match_segs(pattern.split("/"), path.split("/"))
376
+
377
+
378
+ def _match_any(path: str, exacts, globs) -> str | None:
379
+ """Return the first matching rule string, or None."""
380
+ for e in exacts:
381
+ if path == e:
382
+ return e
383
+ for g in globs:
384
+ if seg_glob_match(g, path):
385
+ return g
386
+ return None
387
+
388
+
389
+ def boundary_rule_for(path: str) -> str | None:
390
+ """Deny-list hit for a repo-relative path. NFC-normalizes first, then also
391
+ tries a casefold pass (R1-MINOR-6: on macOS a case/Unicode-folded path may
392
+ collide with a protected rule even when the exact NFC path does not — treat
393
+ that as a hit)."""
394
+ nfc = unicodedata.normalize("NFC", path)
395
+ hit = _match_any(nfc, DENY_EXACT, DENY_GLOBS)
396
+ if hit is not None:
397
+ return hit
398
+ cf = nfc.casefold()
399
+ hit = _match_any(cf, [e.casefold() for e in DENY_EXACT],
400
+ [g.casefold() for g in DENY_GLOBS])
401
+ if hit is not None:
402
+ return f"{hit} (casefold/NFC collision)"
403
+ return None
404
+
405
+
406
+ # --- path normalization ------------------------------------------------------
407
+
408
+ def normalize_repo_path(path: str) -> str:
409
+ """Repo-relative, NFC-normalized. Fail-closed reject: `..` segment,
410
+ backslash, double-slash, leading-slash, empty."""
411
+ if not path:
412
+ raise Refusal("empty path in diff record")
413
+ if path.startswith("/"):
414
+ raise Refusal(f"absolute (leading-slash) path in diff: {path!r}")
415
+ if "\\" in path:
416
+ raise Refusal(f"backslash in diff path: {path!r}")
417
+ if "//" in path:
418
+ raise Refusal(f"double-slash in diff path: {path!r}")
419
+ if any(seg == ".." for seg in path.split("/")):
420
+ raise Refusal(f"parent-traversal (..) in diff path: {path!r}")
421
+ return unicodedata.normalize("NFC", path)
422
+
423
+
424
+ # --- raw diff parsing --------------------------------------------------------
425
+
426
+ def parse_raw_diff(raw: str) -> list[dict]:
427
+ """Parse `git diff --raw -z` into records:
428
+ {old_mode,new_mode,old_sha,new_sha,status,paths:[...]}.
429
+ Fail-closed on any structural surprise."""
430
+ tokens = raw.split("\0")
431
+ records: list[dict] = []
432
+ i = 0
433
+ n = len(tokens)
434
+ while i < n:
435
+ tok = tokens[i]
436
+ if tok == "":
437
+ i += 1
438
+ continue
439
+ if not tok.startswith(":"):
440
+ raise Refusal(f"malformed raw-diff record (expected ':'): {tok!r}")
441
+ parts = tok[1:].split(" ")
442
+ if len(parts) != 5:
443
+ raise Refusal(f"malformed raw-diff metadata field: {tok!r}")
444
+ old_mode, new_mode, old_sha, new_sha, status = parts
445
+ status_letter = status[:1]
446
+ if status_letter in ("R", "C"):
447
+ if i + 2 >= n:
448
+ raise Refusal("truncated rename/copy raw-diff record")
449
+ paths = [tokens[i + 1], tokens[i + 2]]
450
+ i += 3
451
+ else:
452
+ if i + 1 >= n:
453
+ raise Refusal("truncated raw-diff record (missing path)")
454
+ paths = [tokens[i + 1]]
455
+ i += 2
456
+ records.append({
457
+ "old_mode": old_mode, "new_mode": new_mode,
458
+ "old_sha": old_sha, "new_sha": new_sha,
459
+ "status": status, "paths": paths,
460
+ })
461
+ return records
462
+
463
+
464
+ def _check_mode(mode: str) -> None:
465
+ """Reject symlink/gitlink modes; reject unparseable modes (fail-closed)."""
466
+ if mode in (SYMLINK_MODE, GITLINK_MODE):
467
+ raise Refusal(f"rejected git object mode {mode} "
468
+ f"({'symlink' if mode == SYMLINK_MODE else 'gitlink'})")
469
+ try:
470
+ int(mode, 8)
471
+ except (ValueError, TypeError):
472
+ raise Refusal(f"unparseable git object mode: {mode!r}")
473
+
474
+
475
+ # --- gate 1: boundary classification -----------------------------------------
476
+
477
+ def door1_boundary(pr: str, max_files: int = DEFAULT_MAX_FILES) -> tuple[str, str, set[str]]:
478
+ """Returns (base_oid, head_oid, diff_paths). Raises Refusal on any boundary
479
+ hit / structural problem."""
480
+ meta = gh_pr_view(pr, "state,mergeable,baseRefName,baseRefOid,headRefOid")
481
+ base_ref = meta.get("baseRefName")
482
+ if base_ref != "main":
483
+ raise Refusal(f"base branch is {base_ref!r}, not 'main' — refusing",
484
+ rule="base!=main")
485
+ base_oid = meta.get("baseRefOid")
486
+ head_oid = meta.get("headRefOid")
487
+ if not base_oid or not head_oid:
488
+ raise Refusal("gh pr view missing baseRefOid/headRefOid")
489
+
490
+ git_fetch_refs(pr)
491
+ if git_rev_parse("refs/mdg/base") != base_oid:
492
+ raise Refusal("fetched base SHA != gh baseRefOid (base drift) — refusing",
493
+ rule="base-sha-binding")
494
+ if git_rev_parse("refs/mdg/head") != head_oid:
495
+ raise Refusal("fetched head SHA != gh headRefOid (head drift) — refusing",
496
+ rule="head-sha-binding")
497
+
498
+ raw = git_diff_raw(base_oid, head_oid)
499
+ records = parse_raw_diff(raw)
500
+ if len(records) >= max_files:
501
+ raise Refusal(f"diff record count {len(records)} >= ceiling {max_files} "
502
+ "(anti-abuse) — refusing", rule="max-files")
503
+
504
+ diff_paths: set[str] = set()
505
+ for rec in records:
506
+ _check_mode(rec["old_mode"])
507
+ _check_mode(rec["new_mode"])
508
+ for raw_path in rec["paths"]:
509
+ norm = normalize_repo_path(raw_path)
510
+ diff_paths.add(norm)
511
+ rule = boundary_rule_for(norm)
512
+ if rule is not None:
513
+ raise Refusal(f"boundary path in diff: {norm} (rule: {rule}) — "
514
+ "owner sign-off required", rule=rule, path=norm)
515
+ return base_oid, head_oid, diff_paths
516
+
517
+
518
+ # --- gate 2: server-side evidence --------------------------------------------
519
+
520
+ def door2_server_evidence(pr: str, head_oid_from_d1: str) -> str:
521
+ """Returns the pinned head SHA. Raises Refusal fail-closed."""
522
+ meta = gh_pr_view(pr, "state,mergeable,headRefOid")
523
+ state = meta.get("state")
524
+ if state != "OPEN":
525
+ raise Refusal(f"PR state is {state!r}, not OPEN — refusing", rule="state!=OPEN")
526
+ mergeable = meta.get("mergeable")
527
+ if mergeable != "MERGEABLE":
528
+ raise Refusal(f"mergeable is {mergeable!r}, not MERGEABLE — refusing",
529
+ rule="not-mergeable")
530
+ head_pin = meta.get("headRefOid")
531
+ if not head_pin:
532
+ raise Refusal("gh pr view missing headRefOid")
533
+ if head_pin != head_oid_from_d1:
534
+ raise Refusal("head SHA changed between gate 1 and gate 2 — refusing",
535
+ rule="head-inconsistent")
536
+
537
+ rows = gh_pr_checks(pr)
538
+ if not rows:
539
+ raise Refusal("no required checks present — refusing (fail-closed)",
540
+ rule="no-checks")
541
+ for row in rows:
542
+ state = (row.get("state") or "").upper()
543
+ # FOLD-1 (Codex adversarial): ONLY state==SUCCESS counts as green. A
544
+ # required gate that was SKIPPED / NEUTRAL (or pending / failure / error /
545
+ # unknown) is NOT a gate that passed — treating a skipped required context
546
+ # as green would defeat the whole point of it being required. bucket=="pass"
547
+ # is NOT trusted alone (a skipped required check can bucket as pass).
548
+ if state != "SUCCESS":
549
+ name = row.get("name", "?")
550
+ bucket = (row.get("bucket") or "?").lower()
551
+ raise Refusal(f"required check not SUCCESS: {name} "
552
+ f"(state={state or '?'}, bucket={bucket}) — refusing",
553
+ rule="check-not-success")
554
+ return head_pin
555
+
556
+
557
+ # --- gate 3: structured verification evidence --------------------------------
558
+
559
+ def _sha256_file(path: Path) -> str:
560
+ h = hashlib.sha256()
561
+ h.update(path.read_bytes())
562
+ return h.hexdigest()
563
+
564
+
565
+ def door3_evidence(pr: str, evidence_map: dict, head_pin: str,
566
+ diff_paths: set[str]) -> list[dict]:
567
+ """Verifies existence + structure + full-SHA binding of exactly two roles.
568
+ Does NOT prove review independence (same-machine limit). Returns the audit
569
+ evidence list [{role,path,sha256}, ...]."""
570
+ roles = tuple(sorted(evidence_map))
571
+ if roles != REQUIRED_ROLES:
572
+ raise Refusal(f"evidence roles must be exactly {list(REQUIRED_ROLES)}, "
573
+ f"got {list(roles)} — refusing", rule="evidence-roles")
574
+
575
+ seen_paths: dict[str, str] = {}
576
+ audit: list[dict] = []
577
+ for role in REQUIRED_ROLES:
578
+ raw_path = evidence_map[role]
579
+ if os.path.isabs(raw_path):
580
+ raise Refusal(f"evidence path must be relative (absolute rejected): "
581
+ f"{raw_path}", rule="evidence-abs-path")
582
+ if any(seg == ".." for seg in raw_path.replace("\\", "/").split("/")):
583
+ raise Refusal(f"evidence path traversal (..): {raw_path}",
584
+ rule="evidence-traversal")
585
+ norm = os.path.normpath(raw_path)
586
+ if norm in seen_paths:
587
+ raise Refusal(f"two roles share one evidence path: {norm} — refusing",
588
+ rule="evidence-dup-path")
589
+ seen_paths[norm] = role
590
+
591
+ # self-evidence guard: evidence must not be carried by the PR's own diff.
592
+ if norm in diff_paths or raw_path in diff_paths:
593
+ raise Refusal(f"evidence file is in the PR diff (self-evidence): "
594
+ f"{norm} — refusing", rule="evidence-in-diff")
595
+
596
+ p = Path(raw_path)
597
+ if not p.is_file():
598
+ raise Refusal(f"evidence file missing/unreadable: {raw_path}",
599
+ rule="evidence-missing")
600
+ try:
601
+ data = json.loads(p.read_text(encoding="utf-8"))
602
+ except (OSError, json.JSONDecodeError) as exc:
603
+ raise Refusal(f"evidence not readable/parseable JSON: {raw_path} ({exc})",
604
+ rule="evidence-unparseable")
605
+ if not isinstance(data, dict):
606
+ raise Refusal(f"evidence must be a JSON object: {raw_path}",
607
+ rule="evidence-not-object")
608
+ for field in EVIDENCE_REQUIRED_FIELDS:
609
+ if field not in data:
610
+ raise Refusal(f"evidence {role} missing field {field!r}: {raw_path}",
611
+ rule="evidence-field-missing")
612
+ if data["head_sha"] != head_pin:
613
+ raise Refusal(f"evidence {role} head_sha {data['head_sha']!r} != pinned "
614
+ f"{head_pin!r} — refusing", rule="evidence-sha-mismatch")
615
+ if data["role"] != role:
616
+ raise Refusal(f"evidence role field {data['role']!r} != flag role {role!r}",
617
+ rule="evidence-role-mismatch")
618
+ if data["verdict"] != ROLE_PASS_VERDICT[role]:
619
+ raise Refusal(f"evidence {role} verdict {data['verdict']!r} is not a pass "
620
+ f"({ROLE_PASS_VERDICT[role]!r} required) — refusing",
621
+ rule="evidence-verdict-not-pass")
622
+
623
+ audit.append({"role": role, "path": norm, "sha256": _sha256_file(p)})
624
+ return audit
625
+
626
+
627
+ # --- gate 4: execution -------------------------------------------------------
628
+
629
+ def door4_execute(pr: str, head_pin: str) -> str:
630
+ """Re-read head (drift -> refuse), then SHA-guarded merge."""
631
+ meta = gh_pr_view(pr, "headRefOid")
632
+ current = meta.get("headRefOid")
633
+ if current != head_pin:
634
+ raise Refusal(f"head SHA changed before merge ({current!r} != pinned "
635
+ f"{head_pin!r}) — refusing", rule="head-drift-pre-merge")
636
+ return gh_pr_merge(pr, head_pin)
637
+
638
+
639
+ # --- orchestration -----------------------------------------------------------
640
+
641
+ def _now_iso() -> str:
642
+ return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
643
+
644
+
645
+ def run_gate(pr: str, evidence_map: dict, *, execute: bool = False,
646
+ max_files: int = DEFAULT_MAX_FILES) -> dict:
647
+ """Run the four doors. Returns a decision dict:
648
+ {rc, status, reason?, escalation?, audit?}. rc: 0 ok, 1 refused, 2 usage."""
649
+ if not re.fullmatch(r"[0-9]+", pr):
650
+ raise GateError(f"PR number must be pure decimal, got {pr!r}")
651
+ try:
652
+ base_oid, head_oid, diff_paths = door1_boundary(pr, max_files=max_files)
653
+ head_pin = door2_server_evidence(pr, head_oid)
654
+ evidence_audit = door3_evidence(pr, evidence_map, head_pin, diff_paths)
655
+ audit = {
656
+ "ts": _now_iso(),
657
+ "pr": pr,
658
+ "base_sha": base_oid,
659
+ "head_sha": head_pin,
660
+ "boundary_scan": {"files_scanned": len(diff_paths), "boundary_hit": False},
661
+ "evidence": evidence_audit,
662
+ }
663
+ if not execute:
664
+ audit["decision"] = "MERGEABLE_DRY_RUN"
665
+ return {"rc": 0, "status": "mergeable", "audit": audit}
666
+ merge_out = door4_execute(pr, head_pin)
667
+ audit["decision"] = "MERGED"
668
+ audit["merge_output"] = merge_out.strip()
669
+ _append_audit(audit)
670
+ return {"rc": 0, "status": "merged", "audit": audit}
671
+ except Refusal as ref:
672
+ return {"rc": 1, "status": "refused", "reason": ref.reason,
673
+ "escalation": ref.escalation(pr)}
674
+
675
+
676
+ def _append_audit(audit: dict) -> None:
677
+ path = Path(AUDIT_LEDGER)
678
+ path.parent.mkdir(parents=True, exist_ok=True)
679
+ with open(path, "a", encoding="utf-8") as fh:
680
+ fh.write(json.dumps(audit, sort_keys=True) + "\n")
681
+
682
+
683
+ # --- CLI ---------------------------------------------------------------------
684
+
685
+ def _parse_evidence(items: list[str]) -> dict:
686
+ out: dict = {}
687
+ for item in items or []:
688
+ if "=" not in item:
689
+ raise GateError(f"--evidence must be role=path, got {item!r}")
690
+ role, path = item.split("=", 1)
691
+ role = role.strip()
692
+ if not role or not path.strip():
693
+ raise GateError(f"--evidence must be role=path, got {item!r}")
694
+ if role in out:
695
+ raise GateError(f"duplicate evidence role: {role}")
696
+ out[role] = path.strip()
697
+ return out
698
+
699
+
700
+ def build_parser() -> argparse.ArgumentParser:
701
+ parser = argparse.ArgumentParser(
702
+ description="Top-seat merge-discretion gate (AT-v1.0 §AT7, #138): "
703
+ "deterministic executor of owner-granted merge authority + "
704
+ "boundary interceptor. Dry-run by default.",
705
+ )
706
+ parser.add_argument("pr", help="PR number (pure decimal)")
707
+ parser.add_argument("--execute", action="store_true",
708
+ help="perform the merge (default: dry-run only)")
709
+ parser.add_argument("--evidence", action="append", default=[], metavar="ROLE=PATH",
710
+ help="role=path; exactly two required: "
711
+ "joint_review_no_block and opus_independent_verify")
712
+ parser.add_argument("--max-files", type=int, default=DEFAULT_MAX_FILES,
713
+ help=f"anti-abuse diff-record ceiling (default {DEFAULT_MAX_FILES})")
714
+ return parser
715
+
716
+
717
+ def main(argv: list[str] | None = None) -> int:
718
+ parser = build_parser()
719
+ args = parser.parse_args(argv)
720
+ try:
721
+ evidence_map = _parse_evidence(args.evidence)
722
+ decision = run_gate(args.pr, evidence_map, execute=args.execute,
723
+ max_files=args.max_files)
724
+ except GateError as exc:
725
+ print(f"merge-discretion-gate: usage/environment error: {exc}", file=sys.stderr)
726
+ return 2
727
+
728
+ if decision["rc"] == 0:
729
+ if decision["status"] == "mergeable":
730
+ print(f"MERGEABLE (dry-run): PR #{args.pr} clears all four gates. "
731
+ "Re-run with --execute to merge.")
732
+ print("audit preview:")
733
+ else:
734
+ print(f"MERGED: PR #{args.pr} merged (SHA-guarded).")
735
+ print(json.dumps(decision["audit"], indent=2, sort_keys=True))
736
+ return 0
737
+ # refused
738
+ print(f"REFUSED: PR #{args.pr}: {decision['reason']}", file=sys.stderr)
739
+ print(json.dumps(decision["escalation"], indent=2, sort_keys=True), file=sys.stderr)
740
+ return 1
741
+
742
+
743
+ if __name__ == "__main__":
744
+ sys.exit(main())