omnilane 0.42.8 → 0.44.0

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.
@@ -0,0 +1,475 @@
1
+ #!/usr/bin/env python3
2
+ """Re-sign this host's AA transport overlay after a vendor CLI changed.
3
+
4
+ Vendor CLIs update themselves, so the overlay's executable pins go stale as
5
+ routine traffic, and every lane of that vendor is then refused. This finds the
6
+ drift and, when it is safe to, repairs it without an operator:
7
+
8
+ detect drift -> check the signer -> canary -> re-probe into a staging root
9
+ -> build and load the staging overlay -> replace the live one atomically
10
+ -> one real dispatch per re-probed vendor -> restore the old one on failure
11
+
12
+ A changed executable is re-probed unattended only when it still carries the
13
+ signer the live overlay recorded and still sits in the same install location.
14
+ Anything else stops at a notification; `--approve VENDOR` is the operator saying
15
+ they looked. `--trust-adhoc VENDOR` is the operator saying an adhoc signature in
16
+ that install location is their own local step, so those updates count as
17
+ same-signer.
18
+
19
+ Exit codes: 0 nothing to do, or re-signed and verified; 10 drift found (--check);
20
+ 20 drift needs an operator; 30 attempted and rolled back; 2 not configured.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import argparse
25
+ import contextlib
26
+ import json
27
+ import os
28
+ import shutil
29
+ import subprocess
30
+ import sys
31
+ import time
32
+ from datetime import datetime, timezone
33
+ from pathlib import Path
34
+
35
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
36
+ import aa_policy # noqa: E402
37
+ import build_overlay # noqa: E402
38
+ import cli_provenance # noqa: E402
39
+ import probe_sweep # noqa: E402
40
+
41
+ EXIT_OK, EXIT_DRIFT, EXIT_OPERATOR, EXIT_ROLLED_BACK, EXIT_UNCONFIGURED = 0, 10, 20, 30, 2
42
+ VENDORS = probe_sweep.VENDORS
43
+
44
+
45
+ def sha256(path: Path) -> str:
46
+ return build_overlay.sha256(path)
47
+
48
+
49
+ def current_anchors() -> dict[str, dict[str, Path]]:
50
+ """What the runners would execute right now, per vendor."""
51
+ anchors = {}
52
+ for vendor in VENDORS:
53
+ found = shutil.which(build_overlay.CLI_NAMES[vendor])
54
+ anchors[vendor] = {
55
+ "cli": Path(found).resolve() if found else None,
56
+ "runner": build_overlay.REPO / "scripts/runners" / build_overlay.RUNNERS[vendor],
57
+ }
58
+ return anchors
59
+
60
+
61
+ def detect(overlay: dict, anchors: dict[str, dict[str, Path]]) -> dict[str, dict]:
62
+ """Per vendor: what no longer matches the live overlay, and the signer it recorded."""
63
+ report = {}
64
+ for vendor in VENDORS:
65
+ recorded = [entry for entry in overlay.get("evidence", []) if entry.get("vendor") == vendor]
66
+ runner_name = build_overlay.RUNNERS[vendor]
67
+ recorded_cli = next((e for e in recorded if Path(e["path"]).name != runner_name), None)
68
+ recorded_runner = next((e for e in recorded if Path(e["path"]).name == runner_name), None)
69
+ reasons = []
70
+ cli, runner = anchors[vendor]["cli"], anchors[vendor]["runner"]
71
+ if cli is None:
72
+ reasons.append(f"{build_overlay.CLI_NAMES[vendor]} is not on PATH")
73
+ elif recorded_cli is None:
74
+ reasons.append("the overlay pins no executable for this vendor")
75
+ elif str(cli) != recorded_cli["path"]:
76
+ # doctor hashes the recorded path, which an update leaves behind untouched.
77
+ reasons.append(f"runs {cli}, overlay pins {recorded_cli['path']}")
78
+ elif sha256(cli) != recorded_cli["sha256"]:
79
+ reasons.append(f"{cli} changed")
80
+ if recorded_runner is None or not runner.is_file():
81
+ reasons.append(f"{runner_name} is not pinned")
82
+ elif sha256(runner) != recorded_runner["sha256"]:
83
+ reasons.append(f"{runner_name} changed")
84
+ report[vendor] = {
85
+ "drifted": bool(reasons),
86
+ "reasons": reasons,
87
+ "cli": str(cli) if cli else None,
88
+ "cli_changed": any(runner_name not in reason for reason in reasons),
89
+ "recorded_cli_path": recorded_cli["path"] if recorded_cli else None,
90
+ "recorded_codesign": recorded_signer(recorded_cli),
91
+ }
92
+ return report
93
+
94
+
95
+ def recorded_signer(entry: dict | None) -> dict | None:
96
+ """The signer the overlay recorded for a CLI, with any waiver the operator attached."""
97
+ if not entry:
98
+ return None
99
+ signer = dict(entry.get("codesign") or {})
100
+ if entry.get("operator_trust"):
101
+ signer["operator_trust"] = entry["operator_trust"]
102
+ return signer or None
103
+
104
+
105
+ def gate(vendor_report: dict, approved: bool) -> tuple[bool, str]:
106
+ if not vendor_report["cli"]:
107
+ return False, "the CLI is not installed"
108
+ if not vendor_report["cli_changed"]:
109
+ return True, "only omnilane's own runner script changed"
110
+ current = cli_provenance.facts(vendor_report["cli"])
111
+ vendor_report["codesign"] = current
112
+ allowed, reason = cli_provenance.verdict(
113
+ vendor_report["recorded_codesign"], vendor_report["recorded_cli_path"],
114
+ current, vendor_report["cli"])
115
+ if not allowed and approved:
116
+ return True, f"approved by the operator ({reason})"
117
+ return allowed, reason
118
+
119
+
120
+ def canary(cli: str, runner=subprocess.run) -> tuple[bool, str]:
121
+ try:
122
+ result = runner([cli, "--version"], capture_output=True, text=True, timeout=30,
123
+ stdin=subprocess.DEVNULL)
124
+ except (OSError, subprocess.SubprocessError) as error:
125
+ return False, f"--version did not run: {error.__class__.__name__}"
126
+ text = (result.stdout or result.stderr).strip().splitlines()
127
+ if result.returncode != 0 or not text:
128
+ return False, f"--version exited {result.returncode}"
129
+ return True, text[0][:80]
130
+
131
+
132
+ def previous_root(overlay: dict) -> Path | None:
133
+ for entry in overlay.get("evidence", []):
134
+ if "vendor" not in entry and Path(entry["path"]).name == "probe-manifest.json":
135
+ return Path(entry["path"]).parent
136
+ return None
137
+
138
+
139
+ def verified(overlay_path: Path) -> dict[str, int]:
140
+ """Load an overlay the way dispatch does and count what it verifies per vendor."""
141
+ saved = {key: os.environ.get(key) for key in
142
+ ("OMNILANE_AA_TRANSPORT_OVERLAY", "OMNILANE_AA_OVERLAY_SHA256")}
143
+ os.environ["OMNILANE_AA_TRANSPORT_OVERLAY"] = str(overlay_path)
144
+ os.environ.pop("OMNILANE_AA_OVERLAY_SHA256", None)
145
+ try:
146
+ registry, _ = aa_policy.load_registry(build_overlay.REPO / "config/aa-model-policy.json")
147
+ finally:
148
+ for key, value in saved.items():
149
+ if value is None:
150
+ os.environ.pop(key, None)
151
+ else:
152
+ os.environ[key] = value
153
+ counts = {vendor: 0 for vendor in VENDORS}
154
+ stale = set(registry.get("_stale_transport_vendors", []))
155
+ for row in registry["scored_configs"]:
156
+ if row["vendor"] in counts and row["vendor"] not in stale \
157
+ and row["transport_mapping"].get("runtime_verified") is True:
158
+ counts[row["vendor"]] += 1
159
+ return counts
160
+
161
+
162
+ def smoke(vendor: str, overlay: dict, overlay_path: Path, timeout: int = 300,
163
+ runner=subprocess.run) -> tuple[bool, str]:
164
+ """One real advise dispatch on the vendor's cheapest verified selector.
165
+
166
+ The human assertion skips the ceiling, not the transport: the job still goes
167
+ through dispatch.sh, the runner and the CLI. The mapping gate itself was
168
+ already exercised by verified().
169
+ """
170
+ rows = [(build_overlay.ROWS[m["config_id"]], m) for m in overlay["mappings"]
171
+ if build_overlay.ROWS[m["config_id"]]["vendor"] == vendor
172
+ and (m["runtime_effort"] or m["selector_type"] == "model_id_encoded_effort"
173
+ or vendor == "claude")]
174
+ if not rows:
175
+ return False, "no verified mapping to dispatch"
176
+ row, mapping = min(rows, key=lambda pair: (pair[0]["score"], pair[0]["id"]))
177
+ token = f"OMNILANE_RESIGN_SMOKE_{vendor.upper()}"
178
+ command = ["bash", str(build_overlay.REPO / "scripts/dispatch.sh"), "--operator-asserted-human",
179
+ "--background", "--single-shot", "--timeout", str(timeout), "--vendor", vendor,
180
+ "--model", mapping["runtime_model"]]
181
+ if mapping["selector_type"] != "model_id_encoded_effort" and mapping["runtime_effort"]:
182
+ command += ["--effort", mapping["runtime_effort"]]
183
+ command += ["consult", f"Reply with exactly the text {token} and nothing else. Do not use tools."]
184
+ env = dict(os.environ, OMNILANE_AA_TRANSPORT_OVERLAY=str(overlay_path))
185
+ env.pop("OMNILANE_AA_OVERLAY_SHA256", None)
186
+ try:
187
+ started = runner(command, capture_output=True, text=True, timeout=120, env=env,
188
+ stdin=subprocess.DEVNULL)
189
+ except (OSError, subprocess.SubprocessError) as error:
190
+ return False, f"dispatch did not start: {error.__class__.__name__}"
191
+ job = started.stdout.strip().splitlines()[-1] if started.stdout.strip() else ""
192
+ if started.returncode != 0 or not job:
193
+ return False, f"dispatch exited {started.returncode}: {started.stderr.strip()[-200:]}"
194
+ home = Path(os.environ.get("OMNILANE_HOME") or Path.home() / ".omnilane")
195
+ directory = home / "jobs" / job
196
+ deadline = time.time() + timeout + 60
197
+ while time.time() < deadline and not (directory / "exit").exists():
198
+ time.sleep(3)
199
+ try:
200
+ code = (directory / "exit").read_text().strip()
201
+ answer = (directory / "out.txt").read_text(errors="replace")
202
+ except OSError:
203
+ return False, f"job {job} did not finish"
204
+ if code != "0" or token not in answer:
205
+ return False, f"job {job} exited {code} without the token"
206
+ return True, f"job {job} on {row['id']} answered"
207
+
208
+
209
+ def record_signers(live: Path, overlay: dict, report: dict, wanted: list[str], log) -> int:
210
+ """Operator action: adopt the signer of every executable the overlay already pins.
211
+
212
+ An overlay signed before 0.43.0 recorded no signer, so its first drift would
213
+ always stop for an operator. This is that operator decision made ahead of
214
+ time, and only for an executable whose path and hash still match the pin.
215
+ """
216
+ changed = []
217
+ for entry in overlay.get("evidence", []):
218
+ vendor = entry.get("vendor")
219
+ if vendor not in wanted or Path(entry["path"]).name == build_overlay.RUNNERS[vendor]:
220
+ continue
221
+ if report[vendor]["cli_changed"]:
222
+ log(f"omnilane: {vendor}: drifted, so its signer is not adopted; re-sign it instead")
223
+ continue
224
+ facts = cli_provenance.facts(entry["path"])
225
+ if entry.get("codesign") != facts:
226
+ entry["codesign"] = facts
227
+ changed.append(f"{vendor}={facts['signer']}")
228
+ if not changed:
229
+ log("omnilane: every pinned executable already has its signer recorded")
230
+ return EXIT_OK
231
+ backup = live.with_name(live.name + ".before-record-signers-" + datetime.now().strftime("%Y%m%d-%H%M%S"))
232
+ shutil.copy2(live, backup)
233
+ aa_policy.atomic_bytes(live, (json.dumps(overlay, indent=2, ensure_ascii=False) + "\n").encode())
234
+ try:
235
+ verified(live)
236
+ except (aa_policy.PolicyError, OSError, ValueError) as error:
237
+ aa_policy.atomic_bytes(live, backup.read_bytes())
238
+ log(f"omnilane: the overlay stopped loading ({error}); restored {backup}")
239
+ return EXIT_ROLLED_BACK
240
+ log(f"omnilane: recorded signers {', '.join(changed)} (backup {backup})")
241
+ return EXIT_OK
242
+
243
+
244
+ def trust_adhoc(live: Path, overlay: dict, report: dict, wanted: list[str], log) -> int:
245
+ """Operator action: an adhoc executable of this vendor, in its current install
246
+ location, may be re-probed unattended from now on.
247
+
248
+ The operator is saying a local step re-signs this CLI on every update, so the
249
+ vendor's team will never be on it. The waiver is bound to the install location
250
+ the overlay pins; a new directory or an unsigned executable still stops.
251
+ """
252
+ changed = []
253
+ for entry in overlay.get("evidence", []):
254
+ vendor = entry.get("vendor")
255
+ if vendor not in wanted or Path(entry["path"]).name == build_overlay.RUNNERS[vendor]:
256
+ continue
257
+ if entry.get("operator_trust") == cli_provenance.TRUST_ADHOC:
258
+ continue
259
+ if not entry.get("codesign"):
260
+ log(f"omnilane: {vendor} has no recorded signer to bind the trust to; "
261
+ "run omnilane resign --record-signers first")
262
+ continue
263
+ entry["operator_trust"] = cli_provenance.TRUST_ADHOC
264
+ entry["operator_trust_recorded_at"] = datetime.now(timezone.utc).isoformat()
265
+ changed.append(f"{vendor} in {cli_provenance.family(entry['path'])}")
266
+ if not changed:
267
+ log("omnilane: nothing to record; that vendor is already trusted adhoc, or the overlay pins no executable for it")
268
+ return EXIT_OK
269
+ backup = live.with_name(live.name + ".before-trust-adhoc-" + datetime.now().strftime("%Y%m%d-%H%M%S"))
270
+ shutil.copy2(live, backup)
271
+ aa_policy.atomic_bytes(live, (json.dumps(overlay, indent=2, ensure_ascii=False) + "\n").encode())
272
+ try:
273
+ verified(live)
274
+ except (aa_policy.PolicyError, OSError, ValueError) as error:
275
+ aa_policy.atomic_bytes(live, backup.read_bytes())
276
+ log(f"omnilane: the overlay stopped loading ({error}); restored {backup}")
277
+ return EXIT_ROLLED_BACK
278
+ log(f"omnilane: trusting adhoc {', '.join(changed)} (backup {backup})")
279
+ return EXIT_OK
280
+
281
+
282
+ def resign(args, log=print) -> int:
283
+ overlay_env = os.environ.get("OMNILANE_AA_TRANSPORT_OVERLAY")
284
+ if not overlay_env:
285
+ log("omnilane: no transport overlay is configured (OMNILANE_AA_TRANSPORT_OVERLAY); "
286
+ "there is nothing to re-sign. See the README, 'Let your AI assistant drive omnilane', Step 2.")
287
+ return EXIT_UNCONFIGURED
288
+ live = Path(overlay_env).expanduser()
289
+ try:
290
+ overlay = json.loads(live.read_text())
291
+ except (OSError, ValueError) as error:
292
+ log(f"omnilane: cannot read the live overlay {live}: {error}")
293
+ return EXIT_UNCONFIGURED
294
+ report = detect(overlay, current_anchors())
295
+ wanted = args.vendor or list(VENDORS)
296
+ if args.record_signers:
297
+ return record_signers(live, overlay, report, wanted, log)
298
+ if args.trust_adhoc:
299
+ return trust_adhoc(live, overlay, report, args.trust_adhoc, log)
300
+ drifted = [vendor for vendor in wanted if report[vendor]["drifted"]]
301
+ summary = {"host": overlay.get("host"), "checked_at": datetime.now(timezone.utc).isoformat(),
302
+ "live_overlay": str(live), "live_sha256": sha256(live), "vendors": report}
303
+ if not drifted:
304
+ log("omnilane: transport overlay matches what the runners execute; nothing to re-sign")
305
+ if args.json:
306
+ print(json.dumps(summary, ensure_ascii=False, indent=2))
307
+ return EXIT_OK
308
+ for vendor in drifted:
309
+ log(f"omnilane: {vendor} drifted: " + "; ".join(report[vendor]["reasons"]))
310
+ if args.check:
311
+ if args.json:
312
+ print(json.dumps(summary, ensure_ascii=False, indent=2))
313
+ log("omnilane: run `omnilane resign` to re-probe and re-sign")
314
+ return EXIT_DRIFT
315
+
316
+ proceed, held = [], []
317
+ for vendor in drifted:
318
+ allowed, reason = gate(report[vendor], vendor in (args.approve or []))
319
+ report[vendor]["gate"] = {"allowed": allowed, "reason": reason}
320
+ if allowed and report[vendor]["cli"]:
321
+ alive, detail = canary(report[vendor]["cli"])
322
+ report[vendor]["canary"] = {"ok": alive, "detail": detail}
323
+ allowed, reason = (allowed, reason) if alive else (False, f"canary failed: {detail}")
324
+ (proceed if allowed else held).append(vendor)
325
+ log(f"omnilane: {vendor}: {'re-probing' if allowed else 'NOT re-signing'} - {reason}")
326
+
327
+ sweep_id = "resign-" + datetime.now().strftime("%Y%m%d-%H%M%S")
328
+ home = Path(os.environ.get("OMNILANE_HOME") or Path.home() / ".omnilane")
329
+ if (home / "transport-evidence" / sweep_id).exists():
330
+ sweep_id += "-" + os.urandom(2).hex() # two runs in one second
331
+ root = home / "transport-evidence" / sweep_id
332
+ outcome = EXIT_OK
333
+ if proceed:
334
+ source = previous_root(overlay)
335
+ if source is None or not (source / "evidence").is_dir():
336
+ log("omnilane: the live overlay's probe evidence is gone; a full sweep is needed "
337
+ "(scripts/lib/probe_sweep.py --root NEW, then build_overlay.py --root NEW)")
338
+ return EXIT_OPERATOR
339
+ root.mkdir(parents=True)
340
+ shutil.copytree(source / "evidence", root / "evidence")
341
+ shutil.copy2(live, root / "live-overlay.BEFORE.json")
342
+ sweeps = {vendor: probe_sweep.sweep(vendor, root, log=log) for vendor in proceed}
343
+ summary["sweeps"] = sweeps
344
+ unfinished = [vendor for vendor, result in sweeps.items() if result["outcome"] != "done"]
345
+ for vendor in unfinished:
346
+ log(f"omnilane: {vendor}: {sweeps[vendor]['outcome']} - {sweeps[vendor]['detail']}")
347
+ # A selector the live overlay verifies and this sweep could not is more
348
+ # likely a provider having a bad hour than a selector that stopped working.
349
+ # Installing that would trade a stale pin for a smaller overlay.
350
+ was_verified = {mapping["config_id"] for mapping in overlay.get("mappings", [])}
351
+ for vendor, result in sweeps.items():
352
+ lost = sorted(was_verified & set(result["failed"]))
353
+ if lost and vendor not in unfinished and not args.allow_shrink:
354
+ result["regressed"] = lost
355
+ unfinished.append(vendor)
356
+ log(f"omnilane: {vendor}: {len(lost)} selector(s) the live overlay verifies failed "
357
+ f"this time ({', '.join(lost)}); keeping the old pin. Retry later, or pass "
358
+ "--allow-shrink if they are really gone")
359
+ if unfinished:
360
+ held += unfinished
361
+ proceed = [vendor for vendor in proceed if vendor not in unfinished]
362
+ # An unfinished vendor keeps its old evidence so the rebuild stays honest about it.
363
+ for vendor in unfinished:
364
+ for entry in probe_sweep.plan(vendor):
365
+ for old in (source / "evidence").glob(entry["name"] + ".*"):
366
+ shutil.copy2(old, root / "evidence" / old.name)
367
+ if proceed:
368
+ with contextlib.redirect_stdout(sys.stderr): # stdout is reserved for --json
369
+ build_overlay.main(["--root", str(root), "--source",
370
+ f"{overlay.get('host')} / omnilane resign {sweep_id} / "
371
+ f"re-probed: {', '.join(proceed)}"])
372
+ staged_path = root / "transport-contracts.local.json"
373
+ staged = json.loads(staged_path.read_text())
374
+ # Only a re-probed vendor gets a new pin; every other drifted one keeps the
375
+ # pin it had, because its new executable was never probed.
376
+ unprobed = [vendor for vendor in VENDORS if report[vendor]["drifted"] and vendor not in proceed]
377
+ for index, entry in enumerate(staged["evidence"]):
378
+ if entry.get("codesign") is not None:
379
+ # build_overlay anchors every vendor afresh, so a trust recorded on an
380
+ # untouched vendor would vanish with a re-sign of another one.
381
+ old = next((e for e in overlay["evidence"] if e.get("vendor") == entry["vendor"]
382
+ and e.get("operator_trust")), None)
383
+ if old is not None:
384
+ entry["operator_trust"] = old["operator_trust"]
385
+ entry["operator_trust_recorded_at"] = old.get("operator_trust_recorded_at")
386
+ if entry.get("vendor") in unprobed:
387
+ name = Path(entry["path"]).name
388
+ is_runner = name == build_overlay.RUNNERS[entry["vendor"]]
389
+ old = next((e for e in overlay["evidence"] if e.get("vendor") == entry["vendor"]
390
+ and (Path(e["path"]).name == build_overlay.RUNNERS[e["vendor"]]) == is_runner),
391
+ None)
392
+ if old is not None:
393
+ staged["evidence"][index] = old
394
+ staged_path.write_text(json.dumps(staged, indent=2, ensure_ascii=False) + "\n")
395
+ try:
396
+ before, after = verified(live), verified(staged_path)
397
+ except (aa_policy.PolicyError, OSError, ValueError) as error:
398
+ log(f"omnilane: the staged overlay does not load ({error}); live overlay untouched")
399
+ return EXIT_ROLLED_BACK
400
+ summary["verified"] = {"before": before, "after": after}
401
+ empty = [vendor for vendor in proceed if after[vendor] == 0]
402
+ if empty:
403
+ log(f"omnilane: staged overlay verifies nothing for {', '.join(empty)}; "
404
+ "live overlay untouched")
405
+ return EXIT_ROLLED_BACK
406
+ for vendor in proceed:
407
+ if after[vendor] < max(before[vendor], len(sweeps[vendor]["passed"])):
408
+ log(f"omnilane: {vendor}: fewer selectors verified than expected "
409
+ f"({before[vendor]} -> {after[vendor]}); see {root}/evidence")
410
+ backup = live.with_name(live.name + f".before-{sweep_id}")
411
+ shutil.copy2(live, backup)
412
+ aa_policy.atomic_bytes(live, staged_path.read_bytes())
413
+ log(f"omnilane: installed {staged_path} over {live} (backup {backup})")
414
+ failures = []
415
+ if not args.no_smoke:
416
+ for vendor in proceed:
417
+ ok, detail = smoke(vendor, staged, live)
418
+ summary.setdefault("smoke", {})[vendor] = {"ok": ok, "detail": detail}
419
+ log(f"omnilane: {vendor}: smoke {'passed' if ok else 'FAILED'} - {detail}")
420
+ if not ok:
421
+ failures.append(vendor)
422
+ if failures:
423
+ aa_policy.atomic_bytes(live, backup.read_bytes())
424
+ log(f"omnilane: smoke failed for {', '.join(failures)}; restored {backup}")
425
+ outcome = EXIT_ROLLED_BACK
426
+ if held and outcome == EXIT_OK:
427
+ outcome = EXIT_OPERATOR
428
+ for vendor in held:
429
+ sweep = summary.get("sweeps", {}).get(vendor, {})
430
+ if sweep.get("outcome") == "unprobeable":
431
+ # Waiting changes nothing either: the CLI has to be logged in first.
432
+ log(f"omnilane: {vendor} was not re-signed; its old pin stays. {sweep['detail']}: "
433
+ f"omnilane resign --vendor {vendor}")
434
+ elif report[vendor].get("gate", {}).get("allowed"):
435
+ # The signer was fine; the probes were not. Approval would change nothing.
436
+ log(f"omnilane: {vendor} was not re-signed; its old pin stays. Retry later: "
437
+ f"omnilane resign --vendor {vendor}")
438
+ else:
439
+ log(f"omnilane: {vendor} still needs an operator. After checking the executable: "
440
+ f"omnilane resign --vendor {vendor} --approve {vendor}")
441
+ summary.update(re_signed=proceed if outcome != EXIT_ROLLED_BACK else [], held=held,
442
+ exit_code=outcome, sweep_root=str(root) if root.exists() else None)
443
+ if root.exists():
444
+ (root / "resign-report.json").write_text(json.dumps(summary, ensure_ascii=False, indent=2,
445
+ default=str) + "\n")
446
+ if args.json:
447
+ print(json.dumps(summary, ensure_ascii=False, indent=2, default=str))
448
+ return outcome
449
+
450
+
451
+ def main(argv: list[str] | None = None) -> int:
452
+ parser = argparse.ArgumentParser(description=__doc__.splitlines()[0],
453
+ epilog=__doc__.split("Exit codes:")[1].strip())
454
+ parser.add_argument("--check", action="store_true", help="report drift and change nothing")
455
+ parser.add_argument("--vendor", action="append", choices=VENDORS,
456
+ help="limit to this vendor; may repeat")
457
+ parser.add_argument("--approve", action="append", choices=VENDORS,
458
+ help="operator approval to re-probe this vendor despite its signer check")
459
+ parser.add_argument("--trust-adhoc", action="append", choices=VENDORS, metavar="VENDOR",
460
+ help="operator action: this vendor's executable is re-signed adhoc by a local "
461
+ "step, so an adhoc update in the same install location may be re-probed "
462
+ "unattended; may repeat")
463
+ parser.add_argument("--record-signers", action="store_true",
464
+ help="operator action: adopt the signers of the executables already pinned")
465
+ parser.add_argument("--allow-shrink", action="store_true",
466
+ help="install even when a selector the live overlay verifies failed this time")
467
+ parser.add_argument("--no-smoke", action="store_true",
468
+ help="skip the real dispatch after installing (not for unattended use)")
469
+ parser.add_argument("--json", action="store_true", help="print the full report as JSON")
470
+ args = parser.parse_args(argv)
471
+ return resign(args, log=lambda text: print(text, file=sys.stderr))
472
+
473
+
474
+ if __name__ == "__main__":
475
+ raise SystemExit(main())
@@ -305,6 +305,37 @@ else
305
305
  fail changelog-release-link
306
306
  fi
307
307
 
308
+ # A runner script is pinned by this host's transport overlay. Shipping a changed
309
+ # one without re-signing refuses every lane of that vendor on the releasing host
310
+ # (0.42.8 did exactly that), and no offline test notices.
311
+ audit_overlay="${OMNILANE_AA_TRANSPORT_OVERLAY:-}"
312
+ if [[ -z "$audit_overlay" && -f "${OMNILANE_HOME:-$HOME/.omnilane}/local.sh" ]]; then
313
+ audit_overlay="$(
314
+ set +u
315
+ . "${OMNILANE_HOME:-$HOME/.omnilane}/local.sh" 2>/dev/null
316
+ printf '%s' "${OMNILANE_AA_TRANSPORT_OVERLAY:-}"
317
+ )"
318
+ fi
319
+ if [[ -z "$audit_overlay" || ! -r "$audit_overlay" ]] || ! command -v python3 >/dev/null 2>&1; then
320
+ warn runner-pins-unchecked
321
+ else
322
+ runner_drift="$(python3 - "$audit_overlay" "$ROOT" <<'PYTHON' 2>/dev/null || printf 'unreadable'
323
+ import hashlib, json, sys
324
+ from pathlib import Path
325
+ overlay, root = json.load(open(sys.argv[1])), Path(sys.argv[2])
326
+ pinned = {Path(e["path"]).name: e["sha256"] for e in overlay.get("evidence", []) if e.get("vendor")}
327
+ drift = [p.name for p in sorted((root / "scripts/runners").glob("run-*.sh"))
328
+ if p.name in pinned and hashlib.sha256(p.read_bytes()).hexdigest() != pinned[p.name]]
329
+ print(",".join(drift))
330
+ PYTHON
331
+ )"
332
+ if [[ -z "$runner_drift" ]]; then
333
+ pass runner-pins-current
334
+ else
335
+ fail "runner-pins-stale:$runner_drift (run: omnilane resign)"
336
+ fi
337
+ fi
338
+
308
339
  required=(
309
340
  VERSION LICENSE CHANGELOG.md README.md README.zh-TW.md README.zh-CN.md
310
341
  README.ja.md README.ko.md install.sh routing.yaml