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 +46 -0
- conductor/chain_gate.py +572 -0
- conductor/cli.py +1059 -0
- conductor/connect.py +370 -0
- conductor/local_board.py +1023 -0
- conductor/mcp.py +2087 -0
- conductor/policy.py +474 -0
- conductor/verifier.py +433 -0
- conductor/verify_pack.py +104 -0
- conductor/workspaces.py +181 -0
- flightdeck_connect-0.1.0.dist-info/METADATA +205 -0
- flightdeck_connect-0.1.0.dist-info/RECORD +15 -0
- flightdeck_connect-0.1.0.dist-info/WHEEL +5 -0
- flightdeck_connect-0.1.0.dist-info/entry_points.txt +6 -0
- flightdeck_connect-0.1.0.dist-info/top_level.txt +1 -0
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__"]
|
conductor/chain_gate.py
ADDED
|
@@ -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())
|