@softspark/ai-toolkit 4.17.0 → 4.18.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.
package/llms.txt CHANGED
@@ -21,6 +21,7 @@
21
21
  - [Plan: Offline-First SLM Profile — Lightweight Mode for Local Models](kb/history/completed/offline-slm-profile-plan-20260411.md)
22
22
  - [Retirement: Native Tool-Output Filter](kb/history/completed/output-filter-retirement-20260726.md)
23
23
  - [Plan: Output & Token Discipline](kb/history/completed/output-token-discipline-plan-20260504.md)
24
+ - [rtk Pack Integration](kb/history/completed/rtk-pack-integration-20260726.md)
24
25
  - [How-To Guides](kb/howto/README.md)
25
26
  - [Plan: Cloud Security Pack — Multi-Cloud Audit](kb/planning/cloud-security-pack-plan.md)
26
27
  - [Plan: Drop Cascade hooks after 2026-07-01 sunset](kb/planning/drop-cascade-hooks-after-sunset.md)
@@ -29,6 +30,7 @@
29
30
  - [SOP: AI Toolkit Maintenance](kb/procedures/maintenance-sop.md)
30
31
  - [SOP: Release Preparation](kb/procedures/release-preparation-sop.md)
31
32
  - [SOP: Release Verification](kb/procedures/release-verification-sop.md)
33
+ - [SOP: rtk Upstream Sync](kb/procedures/rtk-upstream-sync-sop.md)
32
34
  - [Agents Catalog](kb/reference/agents-catalog.md)
33
35
  - [Anti-Pattern Registry Format](kb/reference/anti-pattern-registry-format.md)
34
36
  - [AI Toolkit Architecture](kb/reference/architecture-overview.md)
package/manifest.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "4.17.0",
2
+ "version": "4.18.0",
3
3
  "components": {
4
4
  "agents": {
5
5
  "description": "44 specialized agents (orchestrator, backend, frontend, security, devops, etc.)",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softspark/ai-toolkit",
3
- "version": "4.17.0",
3
+ "version": "4.18.0",
4
4
  "description": "AI coding toolkit: 108 skills, 44 agents, 12 developer-tool integrations, recoverable native tool-output filtering, Claude Chat/Cowork export, safety constitution, SARIF audit, and signed npm provenance.",
5
5
  "keywords": [
6
6
  "claude",
@@ -454,6 +454,27 @@ def audit(toolkit_root: Path) -> list[Finding]:
454
454
  scan_secrets(agent_md, findings)
455
455
  scan_unicode(agent_md, findings)
456
456
 
457
+ # Scan plugin packs. Pack code ships and executes exactly like skill code,
458
+ # and rtk-pack's install script downloads and runs a native binary, so
459
+ # leaving app/plugins out of the HIGH gate exempted the highest-risk code
460
+ # in the repo from the check that exists to catch it.
461
+ plugins = app / "plugins"
462
+ if plugins.is_dir():
463
+ for pack_dir in sorted(plugins.iterdir()):
464
+ if not pack_dir.is_dir():
465
+ continue
466
+ for py in sorted(pack_dir.rglob("*.py")):
467
+ scan_file_patterns(py, PYTHON_HIGH, "HIGH", findings)
468
+ scan_file_patterns(py, PYTHON_WARN, "WARN", findings)
469
+ scan_secrets(py, findings)
470
+ for sh in sorted(pack_dir.rglob("*.sh")):
471
+ scan_file_patterns(sh, BASH_HIGH, "HIGH", findings)
472
+ scan_file_patterns(sh, BASH_WARN, "WARN", findings)
473
+ scan_secrets(sh, findings)
474
+ for md in sorted(pack_dir.rglob("*.md")):
475
+ scan_secrets(md, findings)
476
+ scan_unicode(md, findings)
477
+
457
478
  # Unicode safety across the rest of the shipped prompt surface.
458
479
  for extra in ("rules", "personas", "mcp-templates"):
459
480
  extra_dir = app / extra
package/scripts/plugin.py CHANGED
@@ -76,8 +76,8 @@ CODEX_PLUGIN_ASSET_MARKER = "# ai-toolkit-managed: codex-plugin-hook"
76
76
  def _empty_state() -> dict:
77
77
  return {
78
78
  "targets": {
79
- "claude": {"installed": []},
80
- "codex": {"installed": []},
79
+ "claude": {"installed": [], "versions": {}},
80
+ "codex": {"installed": [], "versions": {}},
81
81
  }
82
82
  }
83
83
 
@@ -103,6 +103,13 @@ def load_state() -> dict:
103
103
  installed = targets.get(editor, {}).get("installed", [])
104
104
  if isinstance(installed, list):
105
105
  state["targets"][editor]["installed"] = sorted(set(installed))
106
+ # Absent in state written before versions were tracked, so
107
+ # every pack looks stale once and is updated exactly once.
108
+ versions = targets.get(editor, {}).get("versions", {})
109
+ if isinstance(versions, dict):
110
+ state["targets"][editor]["versions"] = {
111
+ k: v for k, v in versions.items() if isinstance(v, str)
112
+ }
106
113
  return state
107
114
 
108
115
 
@@ -118,6 +125,18 @@ def _installed_for(state: dict, editor: str) -> list[str]:
118
125
  return list(state.get("targets", {}).get(editor, {}).get("installed", []))
119
126
 
120
127
 
128
+ def _installed_version(state: dict, editor: str, name: str) -> str:
129
+ return state.get("targets", {}).get(editor, {}).get("versions", {}).get(name, "")
130
+
131
+
132
+ def _record_version(state: dict, editor: str, name: str, version: str) -> None:
133
+ state.setdefault("targets", {}).setdefault(editor, {}).setdefault("versions", {})[name] = version
134
+
135
+
136
+ def _forget_version(state: dict, editor: str, name: str) -> None:
137
+ state.get("targets", {}).get(editor, {}).get("versions", {}).pop(name, None)
138
+
139
+
121
140
  def _set_installed(state: dict, editor: str, names: list[str]) -> None:
122
141
  state.setdefault("targets", {}).setdefault(editor, {})
123
142
  state["targets"][editor]["installed"] = sorted(set(names))
@@ -264,6 +283,11 @@ def _copy_plugin_scripts(name: str, pack_dir: Path, installed_items: list[str])
264
283
  for script_file in sorted(plugin_scripts_dir.iterdir()):
265
284
  if script_file.name.startswith("__"):
266
285
  continue
286
+ # copy2 on a directory raises IsADirectoryError and aborts the install
287
+ # halfway with nothing rolled back, so a pack that ships scripts/bin/
288
+ # or a stray __pycache__ would break it.
289
+ if not script_file.is_file():
290
+ continue
267
291
  dest = scripts_dest / script_file.name
268
292
  shutil.copy2(script_file, dest)
269
293
  if script_file.suffix in (".py", ".sh"):
@@ -271,8 +295,13 @@ def _copy_plugin_scripts(name: str, pack_dir: Path, installed_items: list[str])
271
295
  print(f" Copied script: {script_file.name}")
272
296
  installed_items.append(f"script:{dest}")
273
297
 
274
- init_script = plugin_scripts_dir / "init_db.py"
275
- if init_script.is_file():
298
+ # `init.py` is the generic name; `init_db.py` predates it and is what
299
+ # memory-pack ships. A pack whose init script is named anything else is
300
+ # silently never run, and the install still reports success.
301
+ for candidate in ("init.py", "init_db.py"):
302
+ init_script = plugin_scripts_dir / candidate
303
+ if not init_script.is_file():
304
+ continue
276
305
  result = subprocess.run(
277
306
  ["python3", str(init_script)],
278
307
  capture_output=True,
@@ -280,8 +309,10 @@ def _copy_plugin_scripts(name: str, pack_dir: Path, installed_items: list[str])
280
309
  )
281
310
  if result.returncode == 0 and result.stdout.strip():
282
311
  print(f" Init: {result.stdout.strip()}")
283
- elif result.returncode != 0 and result.stderr.strip():
284
- print(f" WARN init failed: {result.stderr.strip()}")
312
+ elif result.returncode != 0:
313
+ detail = result.stderr.strip() or result.stdout.strip() or "no output"
314
+ print(f" WARN init failed: {detail}")
315
+ break
285
316
 
286
317
 
287
318
  def _copy_plugin_hook_scripts(name: str, hook_specs: list[dict], installed_items: list[str]) -> None:
@@ -908,6 +939,10 @@ def install_pack(name: str, editor: str) -> bool:
908
939
  if name not in installed:
909
940
  installed.append(name)
910
941
  _set_installed(state, editor, installed)
942
+ # Recorded so `update --all` can skip a pack whose manifest has not moved.
943
+ # Without it, update removes and reinstalls every pack every time, which for
944
+ # a pack that downloads a binary means refetching it on every core update.
945
+ _record_version(state, editor, name, str(pack.get("version", "")))
911
946
  save_state(state)
912
947
  return True
913
948
 
@@ -941,17 +976,40 @@ def remove_pack(name: str, editor: str) -> bool:
941
976
 
942
977
  installed = [p for p in installed if p != name]
943
978
  _set_installed(state, editor, installed)
979
+ _forget_version(state, editor, name)
944
980
  save_state(state)
945
981
  return True
946
982
 
947
983
 
948
- def update_pack(name: str, editor: str) -> bool:
984
+ def pack_update_pending(name: str, editor: str) -> tuple[bool, str, str]:
985
+ """(needs_update, installed_version, available_version) for one pack."""
986
+ state = load_state()
987
+ if name not in _installed_for(state, editor):
988
+ return False, "", ""
989
+ pack = find_pack(name)
990
+ if not pack:
991
+ return False, _installed_version(state, editor, name), ""
992
+ available = str(pack.get("version", ""))
993
+ current = _installed_version(state, editor, name)
994
+ return current != available, current, available
995
+
996
+
997
+ def update_pack(name: str, editor: str, *, force: bool = False) -> bool:
949
998
  state = load_state()
950
999
  if name not in _installed_for(state, editor):
951
1000
  print(f" Plugin '{name}' is not installed for {editor} — use 'install' instead")
952
1001
  return False
953
1002
 
954
- print(f" Updating: {name} for {editor}")
1003
+ pending, current, available = pack_update_pending(name, editor)
1004
+ if not pending and not force:
1005
+ # Silent no-op by design: `ai-toolkit update` runs this for every
1006
+ # installed pack on every invocation.
1007
+ return True
1008
+
1009
+ if current and available:
1010
+ print(f" Updating: {name} for {editor} ({current} -> {available})")
1011
+ else:
1012
+ print(f" Updating: {name} for {editor}")
955
1013
  remove_pack(name, editor)
956
1014
  return install_pack(name, editor)
957
1015
 
@@ -1090,6 +1148,34 @@ def _show_memory_stats() -> None:
1090
1148
  print(f" DB: {_human_size(MEMORY_DB.stat().st_size)} (error reading stats)")
1091
1149
 
1092
1150
 
1151
+ def _show_pack_status(name: str, pack_dir: Path) -> None:
1152
+ """Let a pack report its own state via scripts/status.py.
1153
+
1154
+ Generic counterpart to the install-time init.py hook. Before this, anything
1155
+ beyond a hook listing meant another hardcoded `if name == ...` branch, which
1156
+ is why memory-pack is the only pack that ever reported anything.
1157
+
1158
+ The script owns its output format; it is indented and shown verbatim.
1159
+ Failure is not an error: status must never be the thing that breaks.
1160
+ """
1161
+ status_script = pack_dir / "scripts" / "status.py"
1162
+ if not status_script.is_file():
1163
+ return
1164
+ try:
1165
+ result = subprocess.run(
1166
+ ["python3", str(status_script)],
1167
+ capture_output=True,
1168
+ text=True,
1169
+ timeout=15,
1170
+ )
1171
+ except (OSError, subprocess.SubprocessError) as exc:
1172
+ print(f" (status unavailable: {exc})")
1173
+ return
1174
+ stream = result.stdout if result.stdout.strip() else result.stderr
1175
+ for line in stream.strip().splitlines():
1176
+ print(f" {line}")
1177
+
1178
+
1093
1179
  def cmd_status(editors: list[str]) -> None:
1094
1180
  state = load_state()
1095
1181
  shown = False
@@ -1117,6 +1203,8 @@ def cmd_status(editors: list[str]) -> None:
1117
1203
  print(f" Hooks: {', '.join(h.name for h in hooks)}")
1118
1204
  if name == "memory-pack":
1119
1205
  _show_memory_stats()
1206
+ else:
1207
+ _show_pack_status(name, Path(pack["_dir"]))
1120
1208
  print()
1121
1209
 
1122
1210
  if not shown and all(not _installed_for(state, editor) for editor in editors):
@@ -1199,24 +1287,56 @@ def _cmd_remove(args: list[str], editors: list[str]) -> None:
1199
1287
  def _cmd_update(args: list[str], editors: list[str]) -> None:
1200
1288
  if not args:
1201
1289
  print("Usage: ai-toolkit plugin update [--editor claude|codex|all] <pack-name> [...]")
1202
- print(" ai-toolkit plugin update [--editor claude|codex|all] --all")
1290
+ print(" ai-toolkit plugin update [--editor claude|codex|all] --all [--dry-run]")
1203
1291
  sys.exit(1)
1292
+
1293
+ dry_run = "--dry-run" in args or "--list" in args
1294
+ force = "--force" in args
1295
+ everything = "--all" in args
1296
+ explicit = [a for a in args if not a.startswith("--")]
1297
+
1204
1298
  state = load_state()
1205
1299
  for editor in editors:
1206
- names = list(_installed_for(state, editor)) if "--all" in args else args
1300
+ names = list(_installed_for(state, editor)) if everything else explicit
1207
1301
  if not names:
1302
+ if everything:
1303
+ # Nothing installed is the common case; do not make the core
1304
+ # update noisy about it.
1305
+ continue
1208
1306
  print(f"No plugins installed for {editor}.")
1209
1307
  print()
1210
1308
  continue
1211
- if "--all" in args:
1212
- print(f"Updating {len(names)} installed plugin(s) for {editor}...\n")
1309
+
1310
+ if dry_run:
1311
+ pending = []
1312
+ for name in names:
1313
+ needs, current, available = pack_update_pending(name, editor)
1314
+ if needs or force:
1315
+ pending.append(f"{name} ({current or 'unrecorded'} -> {available or 'unknown'})")
1316
+ if pending:
1317
+ print(f"Would update for {editor}: {', '.join(pending)}")
1318
+ else:
1319
+ print(f"All {len(names)} pack(s) up to date for {editor}")
1320
+ print()
1321
+ continue
1322
+
1213
1323
  ok = 0
1324
+ failed: list[str] = []
1214
1325
  for name in names:
1215
- if update_pack(name, editor):
1216
- ok += 1
1217
- print()
1218
- if "--all" in args:
1326
+ try:
1327
+ if update_pack(name, editor, force=force):
1328
+ ok += 1
1329
+ else:
1330
+ failed.append(name)
1331
+ except Exception as exc: # noqa: BLE001
1332
+ # A pack failure must never abort the run: `ai-toolkit update`
1333
+ # calls this after the core update has already succeeded.
1334
+ print(f" WARN update failed for {name}: {exc}")
1335
+ failed.append(name)
1336
+ if everything and (failed or force):
1219
1337
  print(f"Updated: {ok}/{len(names)} packs for {editor}")
1338
+ if failed:
1339
+ print(f" Failed: {', '.join(failed)}")
1220
1340
  print()
1221
1341
 
1222
1342
 
@@ -0,0 +1,335 @@
1
+ #!/usr/bin/env python3
2
+ """Verify a cross-built rtk binary is silent, and fingerprint it for drift.
3
+
4
+ Phase 1 of kb/history/completed/rtk-pack-integration-20260726.md. "No telemetry symbols in
5
+ the binary" is NOT a usable acceptance test: the guard is a runtime branch on a
6
+ const (`telemetry.rs:23-26`), not a `#[cfg]`, and `Cargo.toml:51` sets
7
+ `strip = true`, so a symbol check passes for the wrong reason. This script
8
+ asserts what is actually checkable instead:
9
+
10
+ build-gate RTK_TELEMETRY_URL / RTK_TELEMETRY_TOKEN unset at build time
11
+ runs the binary starts and reports its version
12
+ no-state a sandboxed run creates no telemetry state on disk
13
+ offline a run with no network route behaves identically (Linux only)
14
+ fingerprint size, digest and TLS-marker scan, recorded for drift detection
15
+
16
+ Stdlib only. Emits JSON to stdout; exits non-zero if any assertion fails.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import argparse
21
+ import hashlib
22
+ import json
23
+ import os
24
+ import platform
25
+ import re
26
+ import shutil
27
+ import subprocess
28
+ import sys
29
+ import tempfile
30
+ from pathlib import Path
31
+
32
+ # Compile-time telemetry inputs. Upstream injects these in its own release
33
+ # workflow (release.yml:85-86); our builds must leave both undefined.
34
+ TELEMETRY_BUILD_VARS = ("RTK_TELEMETRY_URL", "RTK_TELEMETRY_TOKEN")
35
+
36
+ # Telemetry state rtk would create if it ever ran the ping path. Names come from
37
+ # src/core/telemetry.rs (salt file) and the tracking DB under the data dir.
38
+ TELEMETRY_STATE_GLOBS = ("**/rtk/*salt*", "**/rtk/telemetry*", "**/rtk/*consent*")
39
+
40
+ # webpki-roots embeds CA subjects as readable DER. Their presence means rustls
41
+ # survived LTO, which is advisory rather than a failure: it says the HTTP stack
42
+ # was linked in, not that anything is sent. Recorded so drift is visible.
43
+ TLS_MARKERS = (b"ISRG Root X1", b"DigiCert", b"Baltimore CyberTrust", b"GlobalSign")
44
+
45
+ RUNNABLE_HERE = {
46
+ ("Darwin", "arm64"): {"aarch64-apple-darwin"},
47
+ ("Darwin", "x86_64"): {"x86_64-apple-darwin"},
48
+ ("Linux", "x86_64"): {"x86_64-unknown-linux-gnu", "x86_64-unknown-linux-musl"},
49
+ ("Linux", "aarch64"): {"aarch64-unknown-linux-gnu", "aarch64-unknown-linux-musl"},
50
+ ("Windows", "AMD64"): {"x86_64-pc-windows-msvc"},
51
+ }
52
+
53
+ # qemu-user turns the one genuinely cross-built target into a verifiable one.
54
+ # Without it every assertion below reports "skipped" and the artifact ships
55
+ # having been started exactly zero times.
56
+ QEMU_FOR = {
57
+ "aarch64-unknown-linux-gnu": ("qemu-aarch64-static", "qemu-aarch64"),
58
+ "aarch64-unknown-linux-musl": ("qemu-aarch64-static", "qemu-aarch64"),
59
+ }
60
+
61
+ # Rosetta 2 does the same job on Apple silicon, which is what lets us verify an
62
+ # x86_64 artifact built on an arm64 runner. Probed, never assumed: the image can
63
+ # ship without it.
64
+ ROSETTA_FOR = {"x86_64-apple-darwin": ("Darwin", "arm64")}
65
+
66
+
67
+ class Failure(Exception):
68
+ pass
69
+
70
+
71
+ def can_run_natively(target: str) -> bool:
72
+ return target in RUNNABLE_HERE.get((platform.system(), platform.machine()), set())
73
+
74
+
75
+ def emulator_for(target: str) -> list | None:
76
+ """qemu invocation for this target, with the sysroot passed as a flag.
77
+
78
+ The prefix goes through `-L` rather than QEMU_LD_PREFIX because the offline
79
+ check runs under sudo, and sudo's env_reset strips the variable. A gnu
80
+ target is dynamically linked, so losing it means qemu cannot find
81
+ ld-linux-aarch64.so.1 and the process dies with 255 before main.
82
+ """
83
+ if can_run_natively(target) or platform.system() != "Linux":
84
+ return None
85
+ for candidate in QEMU_FOR.get(target, ()):
86
+ found = shutil.which(candidate)
87
+ if not found:
88
+ continue
89
+ prefix = os.environ.get("QEMU_LD_PREFIX", "")
90
+ return [found, "-L", prefix] if prefix else [found]
91
+ return None
92
+
93
+
94
+ def rosetta_for(target: str) -> list | None:
95
+ if ROSETTA_FOR.get(target) != (platform.system(), platform.machine()):
96
+ return None
97
+ if shutil.which("arch") is None:
98
+ return None
99
+ try:
100
+ probe = subprocess.run(
101
+ ["arch", "-x86_64", "/usr/bin/true"], capture_output=True, timeout=30
102
+ )
103
+ except (OSError, subprocess.SubprocessError):
104
+ return None
105
+ return ["arch", "-x86_64"] if probe.returncode == 0 else None
106
+
107
+
108
+ def launcher(target: str):
109
+ """(wrapper, how) for running this target here, or (None, reason)."""
110
+ if can_run_natively(target):
111
+ return [], "native"
112
+ emu = emulator_for(target)
113
+ if emu:
114
+ return emu, f"emulated via {Path(emu[0]).name}"
115
+ rosetta = rosetta_for(target)
116
+ if rosetta:
117
+ return rosetta, "translated via Rosetta 2"
118
+ return None, f"{target} is not runnable on {platform.system()}/{platform.machine()} and no emulator is installed"
119
+
120
+
121
+ def sandbox_env(root: Path) -> dict:
122
+ """An environment whose config/data/cache all resolve inside root."""
123
+ env = dict(os.environ)
124
+ for var in TELEMETRY_BUILD_VARS:
125
+ env.pop(var, None)
126
+ env["HOME"] = str(root)
127
+ env["USERPROFILE"] = str(root)
128
+ env["XDG_CONFIG_HOME"] = str(root / "config")
129
+ env["XDG_DATA_HOME"] = str(root / "data")
130
+ env["XDG_CACHE_HOME"] = str(root / "cache")
131
+ env["APPDATA"] = str(root / "AppData" / "Roaming")
132
+ env["LOCALAPPDATA"] = str(root / "AppData" / "Local")
133
+ return env
134
+
135
+
136
+ def run(binary: Path, args: list, env: dict, wrapper: list | None = None):
137
+ cmd = (wrapper or []) + [str(binary)] + args
138
+ proc = subprocess.run(cmd, env=env, capture_output=True, timeout=120)
139
+ return proc.returncode, proc.stdout.decode("utf-8", "replace"), proc.stderr.decode("utf-8", "replace")
140
+
141
+
142
+ def check_build_gate() -> dict:
143
+ """The variables must be undefined in this environment too.
144
+
145
+ The workflow asserts this before `cargo build`; re-asserting here catches a
146
+ verification job that was handed a binary from a contaminated build.
147
+ """
148
+ leaked = [v for v in TELEMETRY_BUILD_VARS if os.environ.get(v)]
149
+ if leaked:
150
+ raise Failure(f"telemetry build variables are set: {', '.join(leaked)}")
151
+ return {"pass": True, "checked": list(TELEMETRY_BUILD_VARS)}
152
+
153
+
154
+ def check_runs(binary: Path, target: str, upstream_tag: str) -> dict:
155
+ """Starts, identifies itself as rtk, and reports the version we asked for.
156
+
157
+ Without the identity assertion this check passes for any binary that exits
158
+ 0 on an unknown flag, `/bin/echo` included.
159
+ """
160
+ wrapper, how = launcher(target)
161
+ if wrapper is None:
162
+ return {"pass": None, "skipped": how}
163
+ with tempfile.TemporaryDirectory() as tmp:
164
+ code, out, err = run(binary, ["--version"], sandbox_env(Path(tmp)), wrapper=wrapper)
165
+ if code != 0:
166
+ raise Failure(f"`rtk --version` exited {code}: {err.strip()[:200]}")
167
+ version = (out.strip() or err.strip())
168
+ if "rtk" not in version.lower():
169
+ raise Failure(f"`--version` output does not identify rtk: {version[:120]!r}")
170
+ expected = upstream_tag.lstrip("v")
171
+ if expected and expected not in version:
172
+ raise Failure(f"version {version[:120]!r} does not match upstream tag {upstream_tag}")
173
+ return {"pass": True, "version": version, "how": how}
174
+
175
+
176
+ def check_no_state(binary: Path, target: str) -> dict:
177
+ """A real command must not leave telemetry state behind."""
178
+ wrapper, how = launcher(target)
179
+ if wrapper is None:
180
+ return {"pass": None, "skipped": how}
181
+ with tempfile.TemporaryDirectory() as tmp:
182
+ root = Path(tmp)
183
+ env = sandbox_env(root)
184
+ for args in (["--version"], ["--help"], ["git", "status"]):
185
+ run(binary, args, env, wrapper=wrapper)
186
+ found = sorted(
187
+ str(p.relative_to(root))
188
+ for pattern in TELEMETRY_STATE_GLOBS
189
+ for p in root.glob(pattern)
190
+ if p.is_file()
191
+ )
192
+ if found:
193
+ raise Failure(f"telemetry state created: {found}")
194
+ return {"pass": True, "sandbox_clean": True}
195
+
196
+
197
+ def check_offline(binary: Path, target: str) -> dict:
198
+ """Run with no network route and assert identical behaviour.
199
+
200
+ `unshare -rn` gives a network namespace with only a down loopback, so any
201
+ outbound connection fails immediately. There is no equivalent that works
202
+ unprivileged on macOS or Windows runners, so this assertion is Linux-only
203
+ and reports itself skipped elsewhere rather than pretending to pass.
204
+ """
205
+ wrapper, how = launcher(target)
206
+ if wrapper is None:
207
+ return {"pass": None, "skipped": how}
208
+ if platform.system() != "Linux" or shutil.which("unshare") is None:
209
+ return {"pass": None, "skipped": "unshare(1) network namespaces are Linux-only"}
210
+
211
+ # Ubuntu 24.04 sets kernel.apparmor_restrict_unprivileged_userns=1, so the
212
+ # unprivileged form is refused on GitHub runners and this assertion silently
213
+ # became a skip. Fall back to passwordless sudo, which runners have.
214
+ #
215
+ # Each entry is (baseline, isolated): identical except for the network
216
+ # namespace. Comparing against a plain run instead would confound the
217
+ # network with sudo's env_reset, and the difference would be read as
218
+ # evidence about the binary when it is evidence about the harness.
219
+ isolators = [
220
+ (["unshare", "-r"], ["unshare", "-rn"]),
221
+ (["sudo", "-n", "unshare", "-r"], ["sudo", "-n", "unshare", "-rn"]),
222
+ ]
223
+ baseline = isolated = None
224
+ for base, iso in isolators:
225
+ if subprocess.run(iso + ["true"], capture_output=True, timeout=30).returncode == 0:
226
+ baseline, isolated = base, iso
227
+ break
228
+ if isolated is None:
229
+ return {"pass": None, "skipped": "no usable network namespace: unprivileged userns refused and sudo unavailable"}
230
+
231
+ with tempfile.TemporaryDirectory() as tmp:
232
+ env = sandbox_env(Path(tmp))
233
+ online_code, _, online_err = run(binary, ["--version"], env, wrapper=baseline + wrapper)
234
+ offline_code, _, offline_err = run(binary, ["--version"], env, wrapper=isolated + wrapper)
235
+ if online_code != 0:
236
+ # The baseline could not start inside the namespace, so the comparison
237
+ # says nothing about the binary. That is a harness limitation, not a
238
+ # defect in the artifact, and reporting it as a failure would blame the
239
+ # thing being measured for the measurement not working. Skip instead,
240
+ # and carry the reason so it is visible rather than silent.
241
+ return {
242
+ "pass": None,
243
+ "skipped": (
244
+ f"baseline run under {' '.join(baseline)} exited {online_code}, "
245
+ f"so the network comparison proves nothing"
246
+ ),
247
+ "detail": online_err.strip()[:200],
248
+ }
249
+ if offline_code != online_code:
250
+ raise Failure(
251
+ f"behaviour differs without a network route: online exit {online_code}, offline exit {offline_code}"
252
+ )
253
+ return {
254
+ "pass": True,
255
+ "exit_code": offline_code,
256
+ "stderr_empty": not offline_err.strip(),
257
+ "isolator": " ".join(isolated),
258
+ }
259
+
260
+
261
+ def fingerprint(binary: Path) -> dict:
262
+ data = binary.read_bytes()
263
+ markers = sorted(m.decode() for m in TLS_MARKERS if m in data)
264
+ printable = re.findall(rb"[\x20-\x7e]{8,}", data)
265
+ return {
266
+ "size_bytes": len(data),
267
+ "sha256": hashlib.sha256(data).hexdigest(),
268
+ "tls_markers_present": markers,
269
+ "printable_string_count": len(printable),
270
+ "strings_digest": hashlib.sha256(b"\n".join(sorted(set(printable)))).hexdigest(),
271
+ }
272
+
273
+
274
+ def main() -> int:
275
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
276
+ ap.add_argument("--binary", required=True, type=Path)
277
+ ap.add_argument("--target", required=True)
278
+ ap.add_argument("--upstream-tag", default="", help="recorded in the manifest for traceability")
279
+ ap.add_argument("--out", type=Path, help="write the manifest here as well as stdout")
280
+ args = ap.parse_args()
281
+
282
+ if not args.binary.is_file():
283
+ print(json.dumps({"error": f"no such binary: {args.binary}"}), file=sys.stderr)
284
+ return 2
285
+
286
+ checks = {}
287
+ failures = []
288
+ for name, fn in (
289
+ ("build_gate", lambda: check_build_gate()),
290
+ ("runs", lambda: check_runs(args.binary, args.target, args.upstream_tag)),
291
+ ("no_state", lambda: check_no_state(args.binary, args.target)),
292
+ ("offline", lambda: check_offline(args.binary, args.target)),
293
+ ):
294
+ try:
295
+ checks[name] = fn()
296
+ except Failure as exc:
297
+ checks[name] = {"pass": False, "reason": str(exc)}
298
+ failures.append(f"{name}: {exc}")
299
+ except (OSError, subprocess.SubprocessError) as exc:
300
+ checks[name] = {"pass": False, "reason": f"{type(exc).__name__}: {exc}"}
301
+ failures.append(f"{name}: {exc}")
302
+
303
+ # A target nobody could start here must not report "pass". Every runtime
304
+ # assertion would have been skipped, and a green tick on an artifact that
305
+ # was never executed is the same silent-pass trap this plan keeps finding.
306
+ ran_anything = checks.get("runs", {}).get("pass") is True
307
+ if failures:
308
+ verdict = "fail"
309
+ elif ran_anything:
310
+ verdict = "pass"
311
+ else:
312
+ verdict = "inconclusive"
313
+
314
+ manifest = {
315
+ "target": args.target,
316
+ "upstream_tag": args.upstream_tag,
317
+ "host": f"{platform.system()}/{platform.machine()}",
318
+ # Recorded because the emulated path only works when the workflow
319
+ # supplies the cross sysroot; a manifest that does not say so cannot be
320
+ # audited later.
321
+ "qemu_ld_prefix": os.environ.get("QEMU_LD_PREFIX", ""),
322
+ "checks": checks,
323
+ "fingerprint": fingerprint(args.binary),
324
+ "verdict": verdict,
325
+ "failures": failures,
326
+ }
327
+ text = json.dumps(manifest, indent=2, sort_keys=True)
328
+ print(text)
329
+ if args.out:
330
+ args.out.write_text(text + "\n", encoding="utf-8")
331
+ return 1 if failures else 0
332
+
333
+
334
+ if __name__ == "__main__":
335
+ sys.exit(main())