@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/CHANGELOG.md +52 -0
- package/README.md +10 -10
- package/app/.claude-plugin/plugin.json +1 -1
- package/app/plugins/README.md +16 -4
- package/app/plugins/rtk-pack/README.md +123 -0
- package/app/plugins/rtk-pack/hooks/rewrite.sh +79 -0
- package/app/plugins/rtk-pack/plugin.json +60 -0
- package/app/plugins/rtk-pack/scripts/init.py +252 -0
- package/app/plugins/rtk-pack/scripts/status.py +105 -0
- package/bin/ai-toolkit.js +15 -0
- package/kb/history/completed/rtk-pack-integration-20260726.md +710 -0
- package/kb/procedures/maintenance-sop.md +1 -1
- package/kb/procedures/release-preparation-sop.md +8 -3
- package/kb/procedures/rtk-upstream-sync-sop.md +279 -0
- package/kb/reference/cli-reference.md +1 -1
- package/kb/reference/plugin-pack-conventions.md +17 -2
- package/llms-full.txt +1028 -7
- package/llms.txt +2 -0
- package/manifest.json +1 -1
- package/package.json +1 -1
- package/scripts/audit_skills.py +21 -0
- package/scripts/plugin.py +136 -16
- package/scripts/verify_rtk_binary.py +335 -0
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@softspark/ai-toolkit",
|
|
3
|
-
"version": "4.
|
|
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",
|
package/scripts/audit_skills.py
CHANGED
|
@@ -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
|
-
|
|
275
|
-
|
|
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
|
|
284
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1212
|
-
|
|
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
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
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())
|