flightdeck-connect 0.1.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.
conductor/__init__.py ADDED
@@ -0,0 +1,46 @@
1
+ """
2
+ Conductor CLI + MCP server — standalone PyPI distribution.
3
+
4
+ This is the agent-facing entry point to the Conductor substrate
5
+ (https://gitlab.com/dcairecai-group/erics-workspace, packages/api/
6
+ src/conductor/*). It ships FOUR console scripts:
7
+
8
+ conductor — the stdlib-only HTTP client for /v1/conductor/*
9
+ conductor-mcp — the stdio JSON-RPC Model Context Protocol server
10
+ (34 tools: 21 conductor_* + 13 flightdeck_*,
11
+ scoped at runtime via MCP_TOOL_SET)
12
+ flightdeck-verify — standalone offline evidence-pack verifier
13
+ flightdeck-gate — pre-merge git gate (verify-gated merge hook)
14
+
15
+ After `pip install conductor-cli`, an agent's MCP config (Claude
16
+ Code / Cursor / Codex) can point at `conductor-mcp` (or
17
+ `python -m conductor.mcp`) and the tools appear in the agent's tool
18
+ list — including the FlightDeck Agent Contract v1 loop
19
+ (get_handoff -> read_exam -> prepare_workspace -> submit_return;
20
+ see docs/spec/agent-contract.md).
21
+
22
+ This package is intentionally minimal:
23
+ - Pure Python stdlib. No third-party deps.
24
+ - Python 3.8+ supported (the embedded release-pack verifier runs
25
+ on even older interpreters, but this package targets 3.8+ to
26
+ match the CLI's tarfile filter='data' fallback path).
27
+ - Source modules: cli.py + mcp.py (upstream mirrors) plus the
28
+ package-native local_board.py, verifier.py, workspaces.py,
29
+ policy.py, verify_pack.py, chain_gate.py.
30
+
31
+ Source-of-truth provenance:
32
+ The canonical copies of cli.py + mcp.py live at
33
+ `packages/api/src/conductor/{cli,mcp}.py` in the upstream repo.
34
+ THIS package's copies are MIRRORS — when the upstream files
35
+ change, mirror the change here too. A drift-guard test in the
36
+ upstream repo (packages/api/tests/test_conductor_cli_mirror.py
37
+ — added alongside this package) sha256-compares the two copies
38
+ and fails CI if they diverge. (Known drift to reconcile at next
39
+ upstream sync: mcp.py SERVER_VERSION "0.5.0" vs package 0.1.0.)
40
+ The remaining modules are package-native — this package is their
41
+ canonical home.
42
+ """
43
+
44
+ __version__ = "0.1.0"
45
+
46
+ __all__ = ["__version__"]
@@ -0,0 +1,572 @@
1
+ """
2
+ Epic 1.5 — the verify-gated merge: FlightDeck's chain as a GIT GATE.
3
+
4
+ `flightdeck-gate` answers one question at merge time, with no desktop
5
+ app running: MAY THIS COMMIT LAND? A lane branch (fd/<agent>/<task>)
6
+ may merge only when the tamper-evident event chain shows a passing
7
+ verification FOR EXACTLY THE COMMIT BEING MERGED (or the operator
8
+ chain-recorded an explicit promotion override for that lane's current
9
+ round). Everything else about the repo — feature branches, main pulls,
10
+ non-FlightDeck merges — is none of this gate's business and passes
11
+ untouched.
12
+
13
+ This module is a FAITHFUL PORT of the desktop's chain machinery
14
+ (electron/boardFiles.ts): canonical-JSON hashing, walkEventChain's
15
+ linkage rules (genesis chainPrev=null, unchained-line skip, content
16
+ recompute), and the accept-gate conjuncts, held honest by a
17
+ CROSS-RUNTIME LOCKSTEP TEST — the TS side writes a real board, this
18
+ side must reach the same verdict (tests/electron/mergeGateHook.test.ts).
19
+ If you change the conjuncts on either side, that test forces you to
20
+ change the other.
21
+
22
+ Like every chain read: tamper-EVIDENT, not author-authentic. An actor
23
+ who can append to events.jsonl can mint self-consistent lines; the gate
24
+ raises the bar from "nothing checks" to "bypass requires forging the
25
+ ledger the evidence pack exports".
26
+
27
+ Exit codes (git-hook friendly):
28
+ 0 allowed (verified-green for this exact commit, chain-recorded
29
+ override for this round, or not a FlightDeck lane at all)
30
+ 1 REFUSED (reason on stderr)
31
+ 2 usage / IO error
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import argparse
37
+ import hashlib
38
+ import json
39
+ import os
40
+ import stat
41
+ import subprocess
42
+ import sys
43
+ from pathlib import Path
44
+ from typing import Any, Optional
45
+
46
+
47
+ # ---------------------------------------------------------------------------
48
+ # Canonical JSON + chain walk — ports of boardFiles.ts canonicalJson /
49
+ # computeChainHash / walkEventChain. Keep semantics IDENTICAL; the
50
+ # lockstep test is the referee.
51
+ # ---------------------------------------------------------------------------
52
+
53
+ def canonical_json(value: Any) -> str:
54
+ """Sorted-keys canonical serialization matching the TS canonicalJson:
55
+ JSON.stringify token semantics, object keys sorted recursively."""
56
+ if value is None or not isinstance(value, (dict, list)):
57
+ # json.dumps with ensure_ascii=False + compact separators matches
58
+ # JSON.stringify for the scalar shapes chain records contain
59
+ # (strings, booleans, integers, null).
60
+ return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
61
+ if isinstance(value, list):
62
+ return "[" + ",".join(canonical_json(item) for item in value) + "]"
63
+ entries = []
64
+ for key in sorted(value.keys()):
65
+ child = value[key]
66
+ entries.append(f"{json.dumps(key, ensure_ascii=False)}:{canonical_json(child)}")
67
+ return "{" + ",".join(entries) + "}"
68
+
69
+
70
+ def compute_chain_hash(record_with_prev: dict[str, Any], chain_prev: Optional[str]) -> str:
71
+ payload = f"{chain_prev or ''}\n{canonical_json(record_with_prev)}"
72
+ return hashlib.sha256(payload.encode("utf-8")).hexdigest()
73
+
74
+
75
+ def walk_event_chain(event_path: str | Path) -> dict[str, Any]:
76
+ """Mirror of walkEventChain: returns {valid, length, broken_at,
77
+ chained}. `chained` holds ONLY records on the validated linkage —
78
+ unchained legacy lines are skipped and never trusted."""
79
+ p = Path(event_path)
80
+ if not p.exists():
81
+ return {"valid": True, "length": 0, "broken_at": None, "chained": []}
82
+ raw = p.read_text(encoding="utf-8")
83
+ lines = [ln for ln in raw.replace("\r\n", "\n").split("\n") if ln]
84
+ chained: list[dict[str, Any]] = []
85
+ expected_prev: Optional[str] = None
86
+ saw_chained = False
87
+ for i, line in enumerate(lines):
88
+ try:
89
+ parsed = json.loads(line)
90
+ except json.JSONDecodeError:
91
+ return {"valid": False, "length": len(lines), "broken_at": i, "chained": chained}
92
+ if not isinstance(parsed, dict):
93
+ return {"valid": False, "length": len(lines), "broken_at": i, "chained": chained}
94
+ chain_hash = parsed.get("chainHash")
95
+ if not isinstance(chain_hash, str) or not chain_hash:
96
+ continue # unchained legacy line — no linkage, never trusted
97
+ if "chainPrev" not in parsed or not (
98
+ parsed["chainPrev"] is None or isinstance(parsed["chainPrev"], str)
99
+ ):
100
+ return {"valid": False, "length": len(lines), "broken_at": i, "chained": chained}
101
+ recorded_prev = parsed["chainPrev"]
102
+ if not saw_chained:
103
+ if recorded_prev is not None:
104
+ return {"valid": False, "length": len(lines), "broken_at": i, "chained": chained}
105
+ elif recorded_prev != expected_prev:
106
+ return {"valid": False, "length": len(lines), "broken_at": i, "chained": chained}
107
+ hashed_record = {k: v for k, v in parsed.items() if k != "chainHash"}
108
+ if compute_chain_hash(hashed_record, recorded_prev) != chain_hash:
109
+ return {"valid": False, "length": len(lines), "broken_at": i, "chained": chained}
110
+ expected_prev = chain_hash
111
+ saw_chained = True
112
+ chained.append(parsed)
113
+ return {"valid": True, "length": len(lines), "broken_at": None, "chained": chained}
114
+
115
+
116
+ # ---------------------------------------------------------------------------
117
+ # The merge gate
118
+ # ---------------------------------------------------------------------------
119
+
120
+ def _git(repo: str, *args: str) -> str:
121
+ out = subprocess.run(
122
+ ["git", "-C", repo, *args],
123
+ capture_output=True,
124
+ text=True,
125
+ timeout=60,
126
+ )
127
+ if out.returncode != 0:
128
+ raise RuntimeError(f"git {' '.join(args)} failed: {(out.stderr or out.stdout).strip()}")
129
+ return out.stdout
130
+
131
+
132
+ def _newest(records: list[dict[str, Any]]) -> dict[str, Any]:
133
+ return max(records, key=lambda r: str(r.get("at") or ""))
134
+
135
+
136
+ def resolve_merge_gate(board: str, repo: str, head: str, branch_hint: Optional[str] = None) -> dict[str, Any]:
137
+ """Decide whether commit `head` may merge into `repo`'s current
138
+ branch. Returns {allowed, reason, lane} — reason is operator-facing.
139
+
140
+ Decision order:
141
+ 1. Chain missing → only non-lane merges pass (an fd/ branch with
142
+ no chain has no provenance).
143
+ 2. Chain broken → refuse everything the gate speaks for.
144
+ 3. `head` matched to a chained dispatch pin (by sandboxBranch
145
+ resolving to `head`, or by branch_hint naming the pinned
146
+ branch). No match:
147
+ - branch_hint (or a ref containing head) starts with 'fd/'
148
+ → REFUSE (a lane-shaped branch must have provenance)
149
+ - otherwise → ALLOW (not a FlightDeck lane; none of our
150
+ business).
151
+ 4. Matched lane: allow iff the SAME conjuncts the desktop accept
152
+ gate uses hold for a verify record whose workspaceHead is
153
+ EXACTLY `head` (clean workspace, all checks passed, pin
154
+ intact, fresh), OR a chained promotion_override for this lane
155
+ is newer than the lane's newest dispatch (the operator's
156
+ recorded intent for this round).
157
+ """
158
+ event_path = os.path.join(board, ".conductor", "events.jsonl")
159
+
160
+ def branch_names_for_head() -> list[str]:
161
+ names: list[str] = []
162
+ if branch_hint:
163
+ names.append(branch_hint.strip())
164
+ try:
165
+ out = _git(repo, "branch", "--format=%(refname:short)", "--contains", head)
166
+ names.extend(n.strip() for n in out.splitlines() if n.strip())
167
+ except RuntimeError:
168
+ pass
169
+ return names
170
+
171
+ if not os.path.exists(event_path):
172
+ if any(n.startswith("fd/") for n in branch_names_for_head()):
173
+ return {
174
+ "allowed": False,
175
+ "reason": "this is a FlightDeck lane branch but the board has no event chain — no provenance, nothing to trust",
176
+ "lane": None,
177
+ }
178
+ return {"allowed": True, "reason": "not a FlightDeck lane (no event chain, non-lane branch)", "lane": None}
179
+
180
+ walk = walk_event_chain(event_path)
181
+ if not walk["valid"]:
182
+ return {
183
+ "allowed": False,
184
+ "reason": f"event chain integrity is broken at line {walk['broken_at']} — no record on it can be trusted",
185
+ "lane": None,
186
+ }
187
+
188
+ dispatches = [
189
+ r
190
+ for r in walk["chained"]
191
+ if r.get("type") == "dispatch" and isinstance(r.get("pin"), dict)
192
+ ]
193
+
194
+ # Match the merged head to a pinned lane branch.
195
+ lane_dispatches: list[dict[str, Any]] = []
196
+ for r in dispatches:
197
+ pin = r["pin"]
198
+ sandbox_branch = pin.get("sandboxBranch")
199
+ if not isinstance(sandbox_branch, str):
200
+ continue
201
+ if branch_hint and branch_hint.strip() == sandbox_branch:
202
+ lane_dispatches.append(r)
203
+ continue
204
+ try:
205
+ resolved = _git(repo, "rev-parse", "--verify", "--quiet", sandbox_branch).strip()
206
+ except RuntimeError:
207
+ continue
208
+ if resolved == head:
209
+ lane_dispatches.append(r)
210
+
211
+ if not lane_dispatches:
212
+ if any(n.startswith("fd/") for n in branch_names_for_head()):
213
+ return {
214
+ "allowed": False,
215
+ "reason": "this is a FlightDeck lane branch but no chain-validated dispatch pin names it — no provenance",
216
+ "lane": None,
217
+ }
218
+ return {"allowed": True, "reason": "not a FlightDeck lane", "lane": None}
219
+
220
+ newest_dispatch = _newest(lane_dispatches)
221
+ pin = newest_dispatch["pin"]
222
+ lane_branch = pin.get("sandboxBranch")
223
+ lane_agent = str(newest_dispatch.get("agent") or "").lower()
224
+ lane_task = newest_dispatch.get("taskId")
225
+ lane = {"branch": lane_branch, "agent": lane_agent, "taskId": lane_task}
226
+ pinned_workspace = pin.get("sandboxWorkspace")
227
+
228
+ # Operator's recorded override for this round lets the landing pass
229
+ # (promoteLaneBranch records it BEFORE its merge — this is how the
230
+ # override path clears the hook without sneaking around it). The override
231
+ # must name THE EXACT commit being merged: without laneHead binding, a
232
+ # round-N override was a durable skeleton key that cleared ANY later
233
+ # commit on the lane, so an attacker could `git branch -f` a backdoor onto
234
+ # the lane and merge it laundered under the operator's recorded intent
235
+ # (red-team HIGH). Legacy overrides lacking a laneHead do NOT match — fail
236
+ # closed rather than honor an unbounded override.
237
+ overrides = [
238
+ r
239
+ for r in walk["chained"]
240
+ if r.get("type") == "promotion_override"
241
+ and r.get("taskId") == lane_task
242
+ and str(r.get("agent") or "").lower() == lane_agent
243
+ and str(r.get("laneHead") or "") == head
244
+ and str(r.get("at") or "") > str(newest_dispatch.get("at") or "")
245
+ ]
246
+ if overrides:
247
+ return {
248
+ "allowed": True,
249
+ "reason": "operator override chain-recorded for this lane's current round",
250
+ "lane": lane,
251
+ }
252
+
253
+ # Verified-green for EXACTLY this commit — the same conjuncts the
254
+ # desktop accept gate uses (lockstep-tested against it).
255
+ verifies = [
256
+ r
257
+ for r in walk["chained"]
258
+ if r.get("type") == "verify"
259
+ and r.get("taskId") == lane_task
260
+ and isinstance(r.get("workspace"), str)
261
+ and isinstance(pinned_workspace, str)
262
+ and os.path.normcase(os.path.normpath(str(r.get("workspace"))))
263
+ == os.path.normcase(os.path.normpath(pinned_workspace))
264
+ ]
265
+ if not verifies:
266
+ return {
267
+ "allowed": False,
268
+ "reason": "no verification has been recorded for the dispatched sandbox — run the committed checks in FlightDeck first",
269
+ "lane": lane,
270
+ }
271
+ record = _newest(verifies)
272
+ checks: list[tuple[bool, str]] = [
273
+ (
274
+ str(record.get("at") or "") >= str(newest_dispatch.get("at") or ""),
275
+ "the newest verification predates the newest dispatch for this lane — re-run the checks",
276
+ ),
277
+ (
278
+ isinstance(record.get("checkCount"), int),
279
+ "the verify record predates check counting — re-run the checks",
280
+ ),
281
+ (
282
+ (record.get("checkCount") or 0) > 0,
283
+ "the exam ran zero checks — nothing was executed",
284
+ ),
285
+ (
286
+ not (isinstance(record.get("failCount"), int) and record["failCount"] > 0),
287
+ "committed checks failed",
288
+ ),
289
+ (
290
+ not (isinstance(record.get("infraCount"), int) and record["infraCount"] > 0),
291
+ "checks could not run (environment) — not a failure, and not a pass",
292
+ ),
293
+ (record.get("allPassed") is True, "the newest verification did not pass"),
294
+ (
295
+ record.get("policyAllowed") is not False,
296
+ "checks passed but the work violated the scope policy",
297
+ ),
298
+ (
299
+ record.get("pinStatus") == "intact",
300
+ f"the exam is not anchored to the dispatch pin ({record.get('pinStatus')})",
301
+ ),
302
+ (
303
+ not (isinstance(record.get("pinDetails"), list) and record["pinDetails"]),
304
+ "the pin verdict carries caveats",
305
+ ),
306
+ (
307
+ record.get("workspaceClean") is True,
308
+ "the workspace was not clean at verify time — HEAD does not name the graded content",
309
+ ),
310
+ (
311
+ record.get("workspaceHead") == head,
312
+ f"the verification graded a different commit ({str(record.get('workspaceHead'))[:8]}) than the one being merged ({head[:8]}) — re-run the checks",
313
+ ),
314
+ ]
315
+ for ok, reason in checks:
316
+ if not ok:
317
+ return {"allowed": False, "reason": reason, "lane": lane}
318
+
319
+ # Freshness: a lane-attributed signal ingested after the verify means
320
+ # newer work arrived that nobody checked (mirror of the desktop gate).
321
+ if lane_agent:
322
+ newer_return = any(
323
+ r.get("type") == "agent_signal"
324
+ and r.get("taskId") == lane_task
325
+ and str(r.get("agent") or "").lower() == lane_agent
326
+ and isinstance(r.get("ingestedAt"), str)
327
+ and isinstance(record.get("at"), str)
328
+ and r["ingestedAt"] > record["at"]
329
+ for r in walk["chained"]
330
+ )
331
+ if newer_return:
332
+ return {
333
+ "allowed": False,
334
+ "reason": "the agent returned again after the last verification — the pass describes older work; re-run the checks",
335
+ "lane": lane,
336
+ }
337
+
338
+ return {"allowed": True, "reason": "verified green for exactly this commit", "lane": lane}
339
+
340
+
341
+ # A POSITIVE refusal from the reference-transaction guard uses this sentinel
342
+ # exit code, and the hook aborts the transaction ONLY on this exact code. Any
343
+ # other non-zero exit (python missing, `conductor` not importable, usage/IO
344
+ # error) must FAIL OPEN — the reference-transaction hook fires on EVERY ref
345
+ # update, so a broken gate environment that exited 1 would brick ordinary
346
+ # commits and reset the repo unusable. Prevention is best-effort; the post-hoc
347
+ # audit is the backstop that does not depend on a working local environment.
348
+ REFTXN_BLOCK_EXIT = 97
349
+
350
+
351
+ def guard_ref_transaction(board: str, repo: str, phase: str, stdin_text: str) -> tuple[int, str]:
352
+ """reference-transaction hook body: block advancing a PROTECTED BASE branch
353
+ to an UNVERIFIED FlightDeck lane head via a path that creates NO merge
354
+ commit — fast-forward merge, `reset --hard`, `branch -f`, `update-ref`,
355
+ `push .` — which the pre-merge-commit hook structurally never sees. This
356
+ is the red-team HIGH: `git merge fd/lane` fast-forwards by default while
357
+ the base is unchanged, silently bypassing the merge-commit gate. Unlike
358
+ pre-merge-commit, this fires even under `git merge --no-verify`.
359
+
360
+ Only the 'prepared' phase can abort the transaction. We evaluate a ref
361
+ update ONLY when its NEW value is an existing fd/ lane tip landing on a
362
+ non-lane local branch — so ordinary commits and the app's own --no-ff merge
363
+ commit (whose new value is not a lane tip) are never gated, which also means
364
+ a broken chain cannot brick unrelated base-branch commits. Fail-open on
365
+ everything we do not positively identify as an unverified lane landing."""
366
+ if phase != "prepared":
367
+ return 0, ""
368
+ zero = {"0"}
369
+ for line in stdin_text.splitlines():
370
+ parts = line.split()
371
+ if len(parts) != 3:
372
+ continue
373
+ _old, new, ref = parts
374
+ if not ref.startswith("refs/heads/"):
375
+ continue # tags, remotes, HEAD, ORIG_HEAD, AUTO_MERGE, stash — not a base tip
376
+ if ref.startswith("refs/heads/fd/"):
377
+ continue # advancing a lane branch itself is fine
378
+ if set(new) <= zero:
379
+ continue # branch deletion (all-zero target)
380
+ # Act ONLY when `new` is currently a FlightDeck lane tip. In the
381
+ # 'prepared' phase refs still hold their OLD values, so the base ref
382
+ # under update does not yet point at `new`; only the lane branch does.
383
+ try:
384
+ pointed = _git(
385
+ repo, "for-each-ref", "--points-at", new, "--format=%(refname:short)", "refs/heads/fd/"
386
+ )
387
+ except RuntimeError:
388
+ continue # cannot tell → not positively a lane landing → fail open
389
+ fd_tips = [n.strip() for n in pointed.splitlines() if n.strip()]
390
+ if not fd_tips:
391
+ continue # not a lane tip (ordinary commit, or a --no-ff merge commit)
392
+ verdict = resolve_merge_gate(board, repo, new, branch_hint=fd_tips[0])
393
+ if not verdict["allowed"]:
394
+ return REFTXN_BLOCK_EXIT, (
395
+ f"flightdeck-gate: REFUSED to advance {ref} to unverified lane "
396
+ f"{fd_tips[0]} ({new[:8]}) — {verdict['reason']}\n"
397
+ "This fast-forward / ref update skips the merge-commit gate. "
398
+ "Verify the lane in FlightDeck, record an override, or use the "
399
+ "app's promotion. (`git merge --no-verify` does NOT skip this hook.)"
400
+ )
401
+ return 0, ""
402
+
403
+
404
+ # ---------------------------------------------------------------------------
405
+ # Hook install + CLI
406
+ # ---------------------------------------------------------------------------
407
+
408
+ _HOOK_TEMPLATE = """#!/bin/sh
409
+ # FlightDeck verify-gated merge (Epic 1.5) — installed by flightdeck-gate.
410
+ # Refuses to create a merge commit for a FlightDeck lane branch unless the
411
+ # tamper-evident event chain shows a passing verification for EXACTLY the
412
+ # commit being merged (or a chain-recorded operator override for this
413
+ # round). Non-FlightDeck merges pass untouched. Fail-closed: if the gate
414
+ # cannot identify or evaluate the merge, the commit is blocked — use
415
+ # `git merge --no-verify` for a deliberate, visible bypass, or remove
416
+ # this hook to opt out entirely.
417
+ #
418
+ # NOTE: pre-merge-commit runs BEFORE git writes MERGE_HEAD (verified on
419
+ # git 2.51), so the merge source is taken from GIT_REFLOG_ACTION
420
+ # ("merge <ref-as-typed>") — the one context git does provide here.
421
+ case "$GIT_REFLOG_ACTION" in
422
+ "merge "*) ref="${{GIT_REFLOG_ACTION#merge }}" ;;
423
+ *)
424
+ echo "flightdeck-gate: cannot identify the merge source (GIT_REFLOG_ACTION='$GIT_REFLOG_ACTION') — refusing fail-closed." >&2
425
+ exit 1 ;;
426
+ esac
427
+ head=$(git rev-parse -q --verify "$ref^{{commit}}") || {{
428
+ echo "flightdeck-gate: cannot resolve merge source '$ref' to a single commit (octopus merge?) — refusing fail-closed." >&2
429
+ exit 1
430
+ }}
431
+ {python} -m conductor.chain_gate check --board "{board}" --repo "$(git rev-parse --show-toplevel)" --head "$head" --branch "$ref"
432
+ status=$?
433
+ if [ $status -ne 0 ]; then
434
+ echo "" >&2
435
+ echo "FlightDeck gate refused this merge (see reason above)." >&2
436
+ echo "Verify the lane in FlightDeck, or record an explicit override there." >&2
437
+ echo "Run 'git merge --abort' to discard the staged merge." >&2
438
+ fi
439
+ exit $status
440
+ """
441
+
442
+
443
+ _REFTXN_TEMPLATE = """#!/bin/sh
444
+ # FlightDeck verify-gated merge (Epic 1.5) — reference-transaction guard,
445
+ # installed by flightdeck-gate alongside pre-merge-commit.
446
+ #
447
+ # pre-merge-commit only fires when git CREATES A MERGE COMMIT. A fast-forward
448
+ # `git merge fd/lane` (the default when the base has not diverged), a
449
+ # `git reset --hard`, `git branch -f`, `git update-ref`, or `git push .` moves
450
+ # the base ref with NO merge commit — pre-merge-commit never sees it, so an
451
+ # unverified lane could land silently. This hook closes that class: it refuses
452
+ # to advance a base branch to an UNVERIFIED FlightDeck lane tip. Non-lane
453
+ # advances (ordinary commits, the app's own --no-ff merge commit) pass
454
+ # untouched, and unlike pre-merge-commit this fires even under
455
+ # `git merge --no-verify`. Fail-open on anything it does not positively
456
+ # identify as an unverified lane landing.
457
+ {python} -m conductor.chain_gate guard-ref-transaction --board "{board}" --repo "$(git rev-parse --show-toplevel)" --phase "$1"
458
+ rc=$?
459
+ # Abort the ref update ONLY on a POSITIVE refusal (sentinel 97). Any other
460
+ # non-zero exit (python missing, `conductor` not importable, a git error) FAILS
461
+ # OPEN — this hook fires on EVERY ref update, so a broken gate environment that
462
+ # aborted would brick ordinary commits and leave the repo unusable. Prevention
463
+ # is best-effort; the post-hoc audit is the backstop that needs no local env.
464
+ if [ "$rc" = "97" ]; then
465
+ exit 1
466
+ fi
467
+ exit 0
468
+ """
469
+
470
+
471
+ def _write_hook(hooks: Path, name: str, body: str) -> str:
472
+ hook_path = hooks / name
473
+ if hook_path.exists() and "flightdeck-gate" not in hook_path.read_text(
474
+ encoding="utf-8", errors="replace"
475
+ ):
476
+ raise RuntimeError(
477
+ f"a {name} hook already exists at {hook_path} and is not FlightDeck's — refusing to overwrite it"
478
+ )
479
+ hook_path.write_text(body, encoding="utf-8", newline="\n")
480
+ hook_path.chmod(hook_path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
481
+ return str(hook_path)
482
+
483
+
484
+ def install_hook(board: str, repo: str) -> list[str]:
485
+ """Write BOTH gate hooks into `repo`, wired to this Python environment:
486
+ pre-merge-commit (gates merge-commit creation) and reference-transaction
487
+ (gates fast-forward / ref-advance landings the merge-commit hook can't
488
+ see). Also set merge.ff=false so a plain `git merge fd/lane` creates a
489
+ merge commit the primary hook can gate — defense-in-depth; `--ff-only` and
490
+ ref-rewrites are still caught by the reference-transaction hook. Refuses to
491
+ clobber a foreign hook of either name."""
492
+ git_dir = Path(_git(repo, "rev-parse", "--git-dir").strip())
493
+ if not git_dir.is_absolute():
494
+ git_dir = Path(repo) / git_dir
495
+ hooks = git_dir / "hooks"
496
+ hooks.mkdir(parents=True, exist_ok=True)
497
+ python = sys.executable.replace("\\", "/")
498
+ board_slash = board.replace("\\", "/")
499
+ written = [
500
+ _write_hook(
501
+ hooks,
502
+ "pre-merge-commit",
503
+ _HOOK_TEMPLATE.format(python=f'"{python}"', board=board_slash),
504
+ ),
505
+ _write_hook(
506
+ hooks,
507
+ "reference-transaction",
508
+ _REFTXN_TEMPLATE.format(python=f'"{python}"', board=board_slash),
509
+ ),
510
+ ]
511
+ # Defense-in-depth: make ordinary lane merges create a gate-able merge
512
+ # commit instead of fast-forwarding. Best-effort — the ref hook is the
513
+ # real backstop, so a failure here is not fatal.
514
+ try:
515
+ _git(repo, "config", "merge.ff", "false")
516
+ except RuntimeError:
517
+ pass
518
+ return written
519
+
520
+
521
+ def main(argv: Optional[list[str]] = None) -> int:
522
+ parser = argparse.ArgumentParser(
523
+ prog="flightdeck-gate",
524
+ description=(
525
+ "The verify-gated merge: refuse FlightDeck lane merges the "
526
+ "tamper-evident chain has not verified. Non-lane merges pass."
527
+ ),
528
+ )
529
+ sub = parser.add_subparsers(dest="cmd", required=True)
530
+ check = sub.add_parser("check", help="Gate one commit (git-hook entry point).")
531
+ check.add_argument("--board", required=True, help="FlightDeck board folder (holds .conductor/events.jsonl).")
532
+ check.add_argument("--repo", required=True, help="The repo the merge is landing in.")
533
+ check.add_argument("--head", required=True, help="The commit sha being merged (MERGE_HEAD).")
534
+ check.add_argument("--branch", default=None, help="Optional branch name hint for the merged head.")
535
+ inst = sub.add_parser("install-hook", help="Install the gate hooks into a repo.")
536
+ inst.add_argument("--board", required=True)
537
+ inst.add_argument("--repo", required=True)
538
+ reftxn = sub.add_parser(
539
+ "guard-ref-transaction",
540
+ help="reference-transaction hook entry point (reads the update triples on stdin).",
541
+ )
542
+ reftxn.add_argument("--board", required=True)
543
+ reftxn.add_argument("--repo", required=True)
544
+ reftxn.add_argument("--phase", required=True, help="git reference-transaction phase (prepared/committed/aborted).")
545
+ args = parser.parse_args(argv)
546
+
547
+ try:
548
+ if args.cmd == "install-hook":
549
+ for hook_path in install_hook(args.board, args.repo):
550
+ print(f"installed: {hook_path}")
551
+ print("Lane merges now require chain-verified green (or a recorded override).")
552
+ return 0
553
+ if args.cmd == "guard-ref-transaction":
554
+ code, message = guard_ref_transaction(
555
+ args.board, args.repo, args.phase, sys.stdin.read()
556
+ )
557
+ if message:
558
+ print(message, file=sys.stderr)
559
+ return code
560
+ verdict = resolve_merge_gate(args.board, args.repo, args.head.strip(), args.branch)
561
+ if verdict["allowed"]:
562
+ print(f"flightdeck-gate: ALLOWED — {verdict['reason']}")
563
+ return 0
564
+ print(f"flightdeck-gate: REFUSED — {verdict['reason']}", file=sys.stderr)
565
+ return 1
566
+ except RuntimeError as exc:
567
+ print(f"flightdeck-gate: error: {exc}", file=sys.stderr)
568
+ return 2
569
+
570
+
571
+ if __name__ == "__main__": # pragma: no cover
572
+ raise SystemExit(main())