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.
- merge_discretion_gate.py +744 -0
- omc/__init__.py +26 -0
- omc/__main__.py +15 -0
- omc_adapters.py +201 -0
- omc_driver.py +970 -0
- omc_live.py +527 -0
- omc_os-0.3.0.dist-info/METADATA +443 -0
- omc_os-0.3.0.dist-info/RECORD +64 -0
- omc_os-0.3.0.dist-info/WHEEL +5 -0
- omc_os-0.3.0.dist-info/entry_points.txt +2 -0
- omc_os-0.3.0.dist-info/licenses/LICENSE +21 -0
- omc_os-0.3.0.dist-info/top_level.txt +11 -0
- omc_seat_assets.py +145 -0
- omc_seat_runner/Dockerfile +52 -0
- omc_seat_runner/README.md +127 -0
- omc_seat_runner/__init__.py +4 -0
- omc_seat_runner/build.sh +19 -0
- omc_seat_runner/entrypoint.sh +53 -0
- omc_seat_runner/launch.sh +105 -0
- omc_seat_runner/model_api_gateway.py +426 -0
- omc_seat_runner/seat_model_client.py +309 -0
- seat_patch_sanitizer.py +348 -0
- seat_preflight.py +180 -0
- vcos/__init__.py +61 -0
- vcos/ai_bom.py +80 -0
- vcos/approval_config.py +176 -0
- vcos/artifact_validator.py +62 -0
- vcos/assurance.py +108 -0
- vcos/charter_builder.py +26 -0
- vcos/context_heatmap.py +93 -0
- vcos/context_packet.py +141 -0
- vcos/contracts.py +199 -0
- vcos/corpus.py +276 -0
- vcos/decision_ledger.py +178 -0
- vcos/decision_tree.py +70 -0
- vcos/eval_packs.py +69 -0
- vcos/evidence_parsers.py +164 -0
- vcos/evidence_registry.py +79 -0
- vcos/evidence_store.py +74 -0
- vcos/failure_taxonomy.py +109 -0
- vcos/guides.py +101 -0
- vcos/handoff.py +117 -0
- vcos/inner_loop.py +141 -0
- vcos/kpi.py +54 -0
- vcos/lifecycle.py +100 -0
- vcos/lifecycle_conformance.py +317 -0
- vcos/loader.py +163 -0
- vcos/loop_contract.py +111 -0
- vcos/merge_gate.py +515 -0
- vcos/merge_queue.py +154 -0
- vcos/migrate.py +68 -0
- vcos/node_judge.py +434 -0
- vcos/observe.py +172 -0
- vcos/orchestrate.py +117 -0
- vcos/policy.py +81 -0
- vcos/recursion_guard.py +115 -0
- vcos/reliability.py +110 -0
- vcos/review_orchestrator.py +87 -0
- vcos/sdd_adapter.py +83 -0
- vcos/skill_diff.py +56 -0
- vcos/sota_watcher.py +90 -0
- vcos/source_truth.py +60 -0
- vcos/validators.py +239 -0
- vcos_autoloop.py +850 -0
merge_discretion_gate.py
ADDED
|
@@ -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())
|