@rightkit/release 0.6.12 → 0.6.14

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.
Files changed (109) hide show
  1. package/README.md +49 -1
  2. package/adapter-env.mjs +165 -0
  3. package/android-ndk.mjs +5 -1
  4. package/android-release.mjs +73 -8
  5. package/app-check.mjs +22 -8
  6. package/app-closure.mjs +214 -0
  7. package/assemble-app-command.mjs +24 -7
  8. package/atomic-write.mjs +57 -0
  9. package/bin/darwin/rightkit-swap +0 -0
  10. package/bin/rightkit-gate.mjs +1190 -335
  11. package/broker-client.mjs +106 -0
  12. package/broker-exception.mjs +211 -0
  13. package/broker-handshake.mjs +160 -0
  14. package/broker-pins.mjs +27 -8
  15. package/broker-wire.mjs +262 -0
  16. package/build-launch.mjs +364 -12
  17. package/build-ownership.mjs +117 -49
  18. package/build-release.mjs +3 -2
  19. package/cache-command.mjs +7 -2
  20. package/candidate-build-receipt.mjs +96 -0
  21. package/candidate-receipt-command.mjs +29 -0
  22. package/cargo-contract.mjs +4 -3
  23. package/cargo-ledger.mjs +1 -1
  24. package/cargo-license-notices.mjs +2 -2
  25. package/cargo-shapes.mjs +332 -0
  26. package/cargo-target-shim.mjs +44 -5
  27. package/cargo-target.mjs +5 -0
  28. package/carried-adapter-run.mjs +31 -0
  29. package/carried-cargo-run.mjs +9 -0
  30. package/carried-cargo.mjs +237 -0
  31. package/carrier-census.mjs +124 -0
  32. package/class-a-launch-guard.mjs +266 -33
  33. package/cli/right-release.mjs +2 -0
  34. package/clippy-route.mjs +23 -11
  35. package/compile-inputs.mjs +9 -12
  36. package/control-plane.mjs +18 -0
  37. package/deps-cargo-lock.mjs +208 -0
  38. package/deps.mjs +5 -0
  39. package/deterministic-scans.mjs +273 -44
  40. package/development-signing.mjs +14 -1
  41. package/development.mjs +109 -24
  42. package/direct-origin.mjs +107 -0
  43. package/disk-command.mjs +1 -0
  44. package/disk-policy.mjs +18 -3
  45. package/freshness-census.mjs +206 -58
  46. package/gate-executor.mjs +30 -12
  47. package/gate-stages.mjs +93 -16
  48. package/github-release.mjs +2 -1
  49. package/github-upload-gate.mjs +2 -1
  50. package/install-tauri-authenticode.mjs +346 -0
  51. package/ios-dev.mjs +77 -23
  52. package/ios-package-recipe.mjs +100 -41
  53. package/linux-artifacts.mjs +17 -2
  54. package/linux-qualifier.mjs +83 -0
  55. package/local-publish.mjs +36 -14
  56. package/local-runtime.mjs +2 -2
  57. package/lock-identity.mjs +138 -0
  58. package/lsclean.sh +0 -0
  59. package/managed-cargo.mjs +151 -44
  60. package/managed-roots.mjs +130 -12
  61. package/media-sidecar-catalog.mjs +1 -1
  62. package/native-adapters.mjs +411 -0
  63. package/native-helpers.mjs +26 -18
  64. package/native-tool-planning.mjs +94 -0
  65. package/ownership-mark.mjs +182 -0
  66. package/pack-digest.mjs +122 -0
  67. package/package.json +12 -12
  68. package/platform-dir.mjs +14 -0
  69. package/pnpm-adapter-grammar.mjs +226 -0
  70. package/progress-control.mjs +7 -2
  71. package/provenance-real-receipt.pins.json +7 -0
  72. package/provenance-verify.mjs +262 -1
  73. package/publication-outcomes.mjs +21 -0
  74. package/publish-update.mjs +2 -1
  75. package/qa-journeys-prebuilt-run.mjs +18 -0
  76. package/qa-journeys-prebuilt.mjs +148 -0
  77. package/qa-snapshot-run.mjs +53 -0
  78. package/qa-snapshot.mjs +164 -0
  79. package/qualification-verify.mjs +144 -0
  80. package/qualify-reuse.mjs +38 -0
  81. package/recipe-build.mjs +16 -3
  82. package/release-qa-audit.mjs +2 -2
  83. package/release-qa-command.mjs +2 -1
  84. package/release-qa-receipt.mjs +3 -2
  85. package/release-state.mjs +3 -2
  86. package/release.mjs +14 -3
  87. package/rightkit-drift.mjs +27 -5
  88. package/rightkit-versions.json +15 -10
  89. package/rustright-names.mjs +32 -0
  90. package/scan-tool-enrolment.mjs +167 -0
  91. package/sign-windows.mjs +212 -112
  92. package/signing-files.mjs +35 -0
  93. package/signing-identity.mjs +7 -0
  94. package/snapshot-broker.mjs +528 -57
  95. package/snapshot-check.mjs +95 -11
  96. package/snapshot-metadata.mjs +123 -0
  97. package/snapshot-request.mjs +389 -96
  98. package/stage-binary.mjs +22 -4
  99. package/stage-commands.mjs +56 -13
  100. package/stage-sidecar.mjs +93 -21
  101. package/suite-signing-policy.mjs +657 -0
  102. package/suite-signing.mjs +223 -0
  103. package/swift-scratch.mjs +25 -0
  104. package/tauri-locked.mjs +25 -0
  105. package/test-command.mjs +164 -29
  106. package/unit-gate.mjs +201 -31
  107. package/upload-large.mjs +0 -0
  108. package/web-build.mjs +2 -2
  109. package/win-package-recipe.mjs +26 -2
@@ -31,8 +31,10 @@ import { cargoManifestState } from "./cargo-manifest-text.mjs";
31
31
  import { LAUNCH_GUARD_IMPORT } from "./class-a-launch-guard.mjs";
32
32
  import { advisoryCacheRoot, ensureAdvisoryDb, prepareDenyOverlay, readAdvisoryPin } from "./advisory-db.mjs";
33
33
  import { observedEnvOf, readRequestRef, verifySnapshotReceipt, writeSnapshotRequest } from "./snapshot-request.mjs";
34
- import { SCAN_TOKEN_ENV, readLedger, scanTokenOf, unclaimedLedgers } from "./cargo-ledger.mjs";
35
- import { verifyBrokerLedger } from "./snapshot-broker.mjs";
34
+ import { SCAN_TOKEN_ENV, appendLedger, readLedger, scanTokenOf, unclaimedLedgers } from "./cargo-ledger.mjs";
35
+ import { brokerSection, carryBrokerAdapter, verifyBrokerLedger } from "./snapshot-broker.mjs";
36
+ import { CANCEL_WAIT_ENV, operationDeadlineEnv } from "./broker-wire.mjs";
37
+ import { removeCarrier } from "./carrier-census.mjs";
36
38
 
37
39
  export const SCAN_RESULT_SCHEMA = "rightkit.deterministic-scans/1";
38
40
  /**
@@ -382,6 +384,7 @@ const GENERATOR_INPUT = /^(crates\/[^/]+\/(Cargo\.toml|README\.md)|packages\/[^/
382
384
  const PROBES = Object.freeze({
383
385
  rustfmt: ["--version"], node: ["--version"], rustright: ["cargo", "--version"], pnpm: ["--version"],
384
386
  actionlint: ["-version"], shellcheck: ["--version"], "right-git": ["--version"], swift: ["--version"],
387
+ "cargo-deny": ["--version"], "cargo-audit": ["audit", "--version"], // as cargo itself starts the subcommand binaries
385
388
  });
386
389
  /**
387
390
  * The one version of each analyzer whose verdict class A accepts. A class A finding must not depend on which build of the
@@ -423,6 +426,80 @@ export function pinnedVersionProblem(tool, version) {
423
426
  return `${tool} found ${version ? `"${version}"` : "with no readable version"}, class A requires ${tool} ${required} (PINNED_SCAN_TOOLS in @rightkit/release deterministic-scans.mjs); another version judges scripts differently, so it is refused, never passed or skipped`;
424
427
  }
425
428
 
429
+ /**
430
+ * RA-18 / L9-03: the analyzers whose verdict class A accepts run only from an ENROLLED executable. The scan-tool policy is data the host
431
+ * owner enrols (ci/rk-pipeline.json `tools.<name>.<platform>`: an absolute `path`, the file's `sha256` and the `version`), handed to
432
+ * runScans as `options.toolPolicy` (scanToolPolicyFromPipeline reads it). A tool is resolved ONCE per run from that entry, never from PATH: the
433
+ * file's real path must be an executable regular file whose SHA-256 is the enrolled one, its `--version` must name the enrolled version, and the
434
+ * same digest is read again immediately before every scan that runs it. The path, digest and version are bound into the scan's evidence
435
+ * (`row.identities`, `result.toolIdentities`) and into its cache key. A tool with no enrolment is TOOL_UNTRUSTED, an enrolled file that
436
+ * is absent or not runnable is TOOL_MISSING, a file whose bytes are not the enrolled ones (at resolution or after the probe) is TOOL_REPLACED,
437
+ * another version is TOOL_VERSION: never a pass, never a skip, and a PATH stub that answers `--version` convincingly changes none of this.
438
+ * A scan the broker carries (a snapshot-broker host's cargo-deny / cargo-audit) runs the broker's own registered tool: its identity is the
439
+ * broker's tool registration (RustRight), recorded here as `source: "broker"` and never claimed as bound by the gate.
440
+ */
441
+ export const TRUSTED_SCAN_TOOLS = Object.freeze(["actionlint", "shellcheck", "cargo-deny", "cargo-audit"]);
442
+ const SHA256_HEX = /^[0-9a-f]{64}$/i;
443
+
444
+ /** The scan-tool policy of a pipeline file's `tools` object for `platform`: `{ [tool]: { path, sha256, version } }` for each trusted tool that has a `<platform>` entry (shape unchecked here; resolveTrustedScanTool judges it). */
445
+ export function scanToolPolicyFromPipeline(tools, platform = process.platform) {
446
+ const policy = {};
447
+ for (const name of TRUSTED_SCAN_TOOLS) {
448
+ const entry = tools && typeof tools === "object" && Object.hasOwn(tools, name) ? tools[name] : null;
449
+ const host = entry && typeof entry === "object" && Object.hasOwn(entry, platform) ? entry[platform] : null;
450
+ if (host && typeof host === "object") policy[name] = { path: host.path, sha256: host.sha256, version: host.version ?? entry.version };
451
+ }
452
+ return policy;
453
+ }
454
+
455
+ const sha256OfFile = (file) => {
456
+ const hash = crypto.createHash("sha256"); const fd = fs.openSync(file, "r");
457
+ try { const buffer = Buffer.alloc(1 << 20); for (let n = fs.readSync(fd, buffer, 0, buffer.length, null); n > 0; n = fs.readSync(fd, buffer, 0, buffer.length, null)) hash.update(buffer.subarray(0, n)); } finally { fs.closeSync(fd); }
458
+ return hash.digest("hex");
459
+ };
460
+ const DEFAULT_TOOL_OPS = Object.freeze({ realpath: (p) => fs.realpathSync.native(p), stat: (p) => fs.statSync(p), digestFile: sha256OfFile });
461
+
462
+ /** `{ ok: true, identity: { tool, path, enrolledPath, sha256, version, source: "policy" } }` or `{ ok: false, code, why }` (TOOL_UNTRUSTED | TOOL_MISSING | TOOL_REPLACED). `ops` (realpath/stat/digestFile) is for tests of the judging only. */
463
+ export function resolveTrustedScanTool(tool, { policy, platform = process.platform, ops } = {}) {
464
+ const o = { ...DEFAULT_TOOL_OPS, ...ops };
465
+ const where = `ci/rk-pipeline.json tools.${tool}.${platform}`;
466
+ const entry = policy && typeof policy === "object" && Object.hasOwn(policy, tool) ? policy[tool] : null;
467
+ const untrusted = (why) => ({ ok: false, code: "TOOL_UNTRUSTED", why: `${why}; a binary found on PATH is never trusted and an unenrolled scan tool is never passed (the host's scan-tool policy owner enrols it: ${where} with an absolute path, the file's sha256 and its version, handed to the gate as options.toolPolicy)` });
468
+ if (!entry || typeof entry !== "object") return untrusted(`${tool} is not enrolled in the scan-tool policy`);
469
+ const paths = platform === "win32" ? path.win32 : path.posix;
470
+ if (typeof entry.path !== "string" || !paths.isAbsolute(entry.path) || entry.path.split(/[\\/]/).includes("..") || entry.path.includes("\0")) return untrusted(`the ${tool} enrolment has no absolute path without '..'`);
471
+ if (typeof entry.sha256 !== "string" || !SHA256_HEX.test(entry.sha256)) return untrusted(`the ${tool} enrolment has no sha256`);
472
+ if (typeof entry.version !== "string" || !entry.version.trim()) return untrusted(`the ${tool} enrolment has no version`);
473
+ let real; let meta;
474
+ try { real = o.realpath(entry.path); meta = o.stat(real); } catch (e) { return { ok: false, code: "TOOL_MISSING", why: `the enrolled ${tool} ${entry.path} cannot be resolved (${e.code ?? e.message})` }; }
475
+ if (!meta.isFile?.()) return { ok: false, code: "TOOL_MISSING", why: `the enrolled ${tool} ${real} is not a regular file` };
476
+ if (platform !== "win32" && (meta.mode & 0o111) === 0) return { ok: false, code: "TOOL_MISSING", why: `the enrolled ${tool} ${real} is not executable` };
477
+ let digest;
478
+ try { digest = o.digestFile(real); } catch (e) { return { ok: false, code: "TOOL_MISSING", why: `the enrolled ${tool} ${real} cannot be read for its identity (${e.code ?? e.message})` }; }
479
+ if (digest !== entry.sha256.toLowerCase()) return { ok: false, code: "TOOL_REPLACED", why: `the ${tool} at ${real} has digest ${digest}, not the enrolled ${entry.sha256.toLowerCase()}` };
480
+ return { ok: true, identity: Object.freeze({ tool, path: real, enrolledPath: entry.path, sha256: digest, version: entry.version.trim(), source: "policy" }) };
481
+ }
482
+
483
+ /** null while the executable still is the one resolved (same real path, same digest), else `{ code: "TOOL_REPLACED", detail }`: read again immediately before a scan runs it. */
484
+ export function trustedScanToolProblem(identity, { ops } = {}) {
485
+ const o = { ...DEFAULT_TOOL_OPS, ...ops };
486
+ try {
487
+ const real = o.realpath(identity.enrolledPath);
488
+ if (real !== identity.path) return { code: "TOOL_REPLACED", detail: `${identity.tool}: ${identity.enrolledPath} now resolves to ${real}, not the ${identity.path} that was verified` };
489
+ const digest = o.digestFile(real);
490
+ if (digest !== identity.sha256) return { code: "TOOL_REPLACED", detail: `${identity.tool}: ${real} was replaced after it was verified (digest ${digest}, verified ${identity.sha256})` };
491
+ } catch (e) { return { code: "TOOL_REPLACED", detail: `${identity.tool}: ${identity.path} can no longer be verified (${e.code ?? e.message})` }; }
492
+ return null;
493
+ }
494
+
495
+ /** null when the probe's answer names the enrolled version (a whole token of its first line, a leading `v` ignored), else the problem. */
496
+ export function enrolledVersionProblem(identity, probeText) {
497
+ const bare = (text) => String(text).trim().replace(/^v(?=\d)/i, "");
498
+ const line = String(probeText ?? "").split(/\r?\n/).find((l) => l.trim()) ?? "";
499
+ if (line.split(/\s+/).some((token) => bare(token) === bare(identity.version))) return null;
500
+ return `${identity.tool} at ${identity.path} answers ${line.trim() ? `"${line.trim().slice(0, 120)}"` : "with no readable version"}, the enrolment names ${identity.version}`;
501
+ }
502
+
426
503
  function commandText(d) {
427
504
  if (d.internal) return `internal:${d.internal}`;
428
505
  const env = Object.keys(d.env ?? {}).sort().map((k) => `${k}=${d.env[k]} `).join("");
@@ -766,9 +843,11 @@ export function planScans({
766
843
  // Which scans this plan owes (Adrian, 2026-10-26; app-check.mjs TIERS): "compile" leaves out the scans added after @rightkit/release
767
844
  // 0.6.6 (shellcheck, and actionlint's shellcheck requirement) until every app builds through the door again; "release" plans everything.
768
845
  tier = "compile",
769
- // A broker host (rustright): cargo scans run in this primary checkout instead of the content root, because the RustRight client refuses
770
- // every cargo command but a locked metadata outside the app's primary checkout (RK_NOT_PRIMARY_CHECKOUT). The gate sets it only after
771
- // proving the checkout's Cargo inputs are blob-identical to the snapshot's (cargoInputsDiffer); null keeps the content/live roots.
846
+ // A broker host (rustright): the primary checkout's directory. The RustRight client refuses to start anywhere that is not a Cargo workspace
847
+ // of the app's primary checkout (RK_NOT_PRIMARY_CHECKOUT), so (a) every CLASS B cargo scan (a compile the snapshot carrier admits) is
848
+ // started from here, naming the snapshot as its source, and (b) the client's version probe runs here. CLASS A cargo scans (metadata,
849
+ // package list, cargo-deny, cargo-audit) never read the checkout: they keep the content root (the snapshot) and run there as carried
850
+ // read-only requests (snapshot-metadata.mjs), so an uncommitted edit in the checkout cannot reach them. null on a direct host.
772
851
  cargoLiveRoot = null,
773
852
  // { [cargoDir]: absolute directory }: the checkout's resolved target directory per Cargo workspace, set as CARGO_TARGET_DIR on
774
853
  // that workspace's cargo scans when the scans read a snapshot (the door's commit compile, joint plan step 4c).
@@ -856,7 +935,7 @@ export function planScans({
856
935
  const under = (prefix) => files.filter((f) => f.startsWith(prefix));
857
936
  if (!["compile", "release"].includes(tier)) throw new Error(`planScans: tier must be compile or release, not ${JSON.stringify(tier)}`);
858
937
  let scans = []; const notes = [];
859
- if (cargoLiveRoot) notes.push(`broker host: cargo scans run in the primary checkout ${cargoLiveRoot}, whose Cargo inputs match the checked snapshot (rustright refuses cargo outside the primary checkout)`);
938
+ if (cargoLiveRoot) notes.push(`broker host: class A cargo scans run in the snapshot as carried read-only requests, class B compiles are carried requests started from the primary checkout ${cargoLiveRoot} (uncommitted edits there never reach a scan)`);
860
939
  // A snapshot compile's scans carry its target map (item 7): the map is part of every scan's cache key, so no result cached by a
861
940
  // live-checkout run (or a compile with another map) is taken for one that writes to these directories.
862
941
  const snapshotTargets = targetDirs ? { RK_SNAPSHOT_TARGETS: canonicalJson(targetDirs) } : {};
@@ -880,7 +959,13 @@ export function planScans({
880
959
  const plainDir = (d) => !ABSOLUTE_PATH.test(d) && !d.split("/").includes("..") && SAFE_UNIT_DIR.test(d);
881
960
  const cargoWs = cleanDir(cargoDir);
882
961
  if (!plainDir(cargoWs)) throw new TypeError("planScans: cargoDir must be a plain repository-relative directory");
883
- const cargoIn = (ws, ...args) => ({ tool: cargoCommand[0], args: [...cargoCommand.slice(1), ...everyFailure(args)], ...(ws !== "." ? { cwd: ws } : {}), ...(cargoLiveRoot ? { root: "cargoLive" } : {}), ...(targetDirs?.[ws] ? { env: { CARGO_TARGET_DIR: targetDirs[ws] } } : {}) });
962
+ const cargoBase = (ws, args) => ({ tool: cargoCommand[0], args: [...cargoCommand.slice(1), ...everyFailure(args)], ...(ws !== "." ? { cwd: ws } : {}), ...(targetDirs?.[ws] ? { env: { CARGO_TARGET_DIR: targetDirs[ws] } } : {}) });
963
+ // Class B (compiles): started from the primary checkout on a broker host, the snapshot carrier names the source.
964
+ const cargoIn = (ws, ...args) => ({ ...cargoBase(ws, args), ...(cargoLiveRoot ? { root: "cargoLive" } : {}) });
965
+ // Class A (reads Cargo inputs, compiles nothing): always the content root, the snapshot or the commit's own tree. These still execute
966
+ // cargo (through the broker on a broker host), so a unit-scoped check follows the Rust closure for them (app-closure.mjs scopeScans reads
967
+ // the ids `cargo-metadata|cargo-deny|cargo-audit[:<label>]` and `cargo-package-list:<unit>` and the scan's `cwd`; rename one there too).
968
+ const cargoReadIn = (ws, ...args) => cargoBase(ws, args);
884
969
  const unitMap = (units, kind) => {
885
970
  if (units === null) return null;
886
971
  if (!Array.isArray(units)) throw new TypeError(`planScans: ${kind}Units must be an array of { name, dir }`);
@@ -1079,14 +1164,16 @@ export function planScans({
1079
1164
  const actionlintConfig = exists(path.join(contentRoot, ".github/actionlint.yaml")) ? ["-config-file", ".github/actionlint.yaml"] : [];
1080
1165
  // actionlint lints every run: script with shellcheck only when shellcheck is found, and otherwise passes silently; class A
1081
1166
  // requires it (`requires`), so a host without it fails the scan instead of skipping the shell checks.
1082
- if (workflowFiles.length) add({ id: "actionlint", class: A, tool: "actionlint", requires: tier === "release" ? ["shellcheck"] : [], args: [...actionlintConfig, ...workflowFiles], root: "content", runScriptLimit: { files: workflowFiles, bytes: RUN_SCRIPT_LIMIT_BYTES } });
1167
+ if (workflowFiles.length) add({ id: "actionlint", class: A, tool: "actionlint", requires: ["shellcheck"], args: [...actionlintConfig, ...workflowFiles], root: "content", runScriptLimit: { files: workflowFiles, bytes: RUN_SCRIPT_LIMIT_BYTES } });
1083
1168
  // Standalone shell scripts (SellRight's deploy scripts failed SC2154, SC2030/2031 and SC2016 in CI): every tracked .sh/.bash file,
1084
1169
  // with ShellCheck's own defaults and the repository's .shellcheckrc (data, not code). actionlint covers only workflow run: blocks.
1085
1170
  // A script whose shebang names another shell (zsh, fish) is not ShellCheck's to judge, so it is left out, never failed.
1086
1171
  const otherShell = (f) => { try { return /^#!.*\b(zsh|fish|pwsh|powershell)\b/.test(String(readFile(path.join(contentRoot, f))).split("\n", 1)[0]); } catch { return false; } };
1087
1172
  const shellFiles = files.filter((f) => /\.(sh|bash)$/.test(f) && !/(^|\/)node_modules\//.test(f) && !otherShell(f)).sort();
1088
- if (shellFiles.length && tier !== "release") notes.push("compile tier: shellcheck (added after 0.6.6) is not planned; it runs in the release tier (--to qualify|release)");
1089
- if (shellFiles.length && tier === "release") add({ id: "shellcheck", class: A, tool: "shellcheck", tier: "release", args: ["--format=gcc", "--external-sources", ...shellFiles], root: "content" });
1173
+ // GB-83 (GATE rule-tier correction, E1/E17, B1/B14): the deterministic scan inventory does not depend on the app-rule tier. Only the app's
1174
+ // release rules (approvals, ratified rulings, notice digests) are deferred at the compile tier; every approved Class A scan, shellcheck
1175
+ // and the locked package listing included, is planned for dev, test, package, qualify and release alike.
1176
+ if (shellFiles.length) add({ id: "shellcheck", class: A, tool: "shellcheck", args: ["--format=gcc", "--external-sources", ...shellFiles], root: "content" });
1090
1177
  // No right-git drift scan: the one workflow right-git renders here, ci.yml, is hand-owned (its header;
1091
1178
  // docs/CI.md) and held by scripts/ci/ci-scans-contract.mjs instead, so drift is always reported by design.
1092
1179
 
@@ -1118,18 +1205,18 @@ export function planScans({
1118
1205
  else notes.push("suite-api: RightKit's own generator contract runs from the in-tree gate and CI, not from this installed copy");
1119
1206
  }
1120
1207
  if (rustTouched) {
1121
- for (const ws of touchedWs) add({ id: `cargo-metadata${wsIdSuffix(ws)}`, class: A, ...cargoIn(ws, ...metadataArgs()) });
1208
+ for (const ws of touchedWs) add({ id: `cargo-metadata${wsIdSuffix(ws)}`, class: A, ...cargoReadIn(ws, ...metadataArgs()) });
1122
1209
  // Locked package listings are observations in RustRight (#1216). They run through the selected executor
1123
1210
  // from the checked Cargo inputs, just like metadata; an executor refusal remains a failed scan.
1124
- if (tier === "release") for (const name of crateNames.filter((n) => rustMap?.get(n).publish !== false)) add({ id: rustScanId.packageList(name), class: A, tier: "release", ...cargoIn(wsOf(name), "package", "-p", crateOf(name), "--list", "--allow-dirty", "--locked") });
1211
+ for (const name of crateNames.filter((n) => rustMap?.get(n).publish !== false)) add({ id: rustScanId.packageList(name), class: A, ...cargoReadIn(wsOf(name), "package", "-p", crateOf(name), "--list", "--allow-dirty", "--locked") });
1125
1212
  }
1126
1213
  for (const ws of [...configWs].sort()) {
1127
1214
  // Offline against the pinned database. The pin commit is part of the command (env), so a pin change is a different scan, and the
1128
1215
  // placeholders are filled at run time from the materialised checkout (runScans).
1129
1216
  const pin = advisoryPin ?? (advisoryPin = readAdvisoryPin());
1130
1217
  const pinEnv = { RK_ADVISORY_DB_COMMIT: pin.commit };
1131
- add({ id: `cargo-deny${wsIdSuffix(ws)}`, class: A, ...cargoIn(ws, "deny", "--frozen", "--metadata-path", "{advisoryMetadata}", "--config", "{advisoryDenyConfig}", "check"), env: pinEnv, advisoryDb: "deny", advisoryPin: pin });
1132
- add({ id: `cargo-audit${wsIdSuffix(ws)}`, class: A, ...cargoIn(ws, "audit", "--db", "{advisoryDb}", "--no-fetch"), env: pinEnv, advisoryDb: "audit", advisoryPin: pin });
1218
+ add({ id: `cargo-deny${wsIdSuffix(ws)}`, class: A, ...cargoReadIn(ws, "deny", "--frozen", "--metadata-path", "{advisoryMetadata}", "--config", "{advisoryDenyConfig}", "check"), env: pinEnv, advisoryDb: "deny", advisoryPin: pin });
1219
+ add({ id: `cargo-audit${wsIdSuffix(ws)}`, class: A, ...cargoReadIn(ws, "audit", "--db", "{advisoryDb}", "--no-fetch"), env: pinEnv, advisoryDb: "audit", advisoryPin: pin });
1133
1220
  }
1134
1221
  // The audit policy is the consumer's own: every package.json script that is exactly `pnpm audit <flags>` (only --prod, --dev,
1135
1222
  // --audit-level <low|moderate|high|critical> are accepted) declares one, and the scan runs the strictest of them all: a dependency
@@ -1669,13 +1756,29 @@ export function laneOf(scan) {
1669
1756
  // The lines a reader needs from a failed scan: every line that names a failure (a failed test, a panic, a compiler or tool error,
1670
1757
  // cargo test's `failures:` block header, pnpm's ERR_ codes), at most twelve of them, then the last four lines of stdout and of
1671
1758
  // stderr. cargo test writes results to stdout and "Running ..." to stderr, so a plain tail showed only harness chatter.
1672
- const FAILURE_LINE = /(^|[\s\[])(FAILED|panicked at|error(\[E\d+\])?:|error\[|^failures:|ERR_PNPM_\w+|Error:|assertion `?left|^thread '.*' panicked)/;
1759
+ const FAILURE_LINE = /(^|[\s\[])(FAILED|panicked at|error(\[E\d+\])?:|error\[|^failures:|ERR_PNPM_\w+|Error:|assertion `?left|^thread '.*' panicked)|^\[ERROR\]|^\w+Error:|error \(E[A-Z]+\)/;
1673
1760
  export function failureDetail(r) {
1674
1761
  const lines = (text) => String(text ?? "").split(/\r?\n/).map((l) => l.trimEnd()).filter((l) => l.trim());
1675
1762
  const out = lines(r?.stdout); const err = lines(r?.stderr);
1676
1763
  const named = [...out, ...err].filter((l) => FAILURE_LINE.test(l)).slice(0, 12);
1677
1764
  const ends = [...out.slice(-4), ...err.slice(-4)];
1678
- return [...new Set([...named, ...ends])].join(" | ");
1765
+ // `cargo audit` / `cargo deny` without the cargo-audit / cargo-deny binary: cargo's own "no such command" text told a reader to
1766
+ // search crates.io; the scan's tool is required and missing, which is what actionlint's row already says (E2E L9-02).
1767
+ const missing = [...out, ...err].map((l) => l.match(/^error: no such command: `(audit|deny)`/)?.[1]).find(Boolean);
1768
+ const lead = missing ? [`required tool cargo-${missing} is missing or not runnable (cargo has no \`${missing}\` subcommand); a required scan is never skipped`] : [];
1769
+ return [...new Set([...lead, ...auditVulnerabilities(r?.stdout), ...named, ...ends])].join(" | ");
1770
+ }
1771
+ // cargo-audit prints one `Crate:/Version:/.../ID:` block per advisory: a vulnerability has no `Warning:` line, an unmaintained or unsound
1772
+ // crate has one and does not fail the scan. The failing blocks lead the detail (E2E L9-01); a tail of the output alone named a warning.
1773
+ function auditVulnerabilities(text) {
1774
+ const out = [];
1775
+ for (const block of String(text ?? "").split(/\r?\n[ \t]*\r?\n/)) {
1776
+ const m = block.match(/^Crate:[ \t]+(\S+)[ \t]*\r?\nVersion:[ \t]+(\S+)/m);
1777
+ if (!m || /^Warning:/m.test(block)) continue;
1778
+ const id = block.match(/^ID:[ \t]+(\S+)/m)?.[1];
1779
+ out.push(`vulnerable: ${m[1]} ${m[2]}${id ? ` ${id}` : ""}`);
1780
+ }
1781
+ return out.slice(0, 6);
1679
1782
  }
1680
1783
  const tail = failureDetail;
1681
1784
 
@@ -1894,10 +1997,55 @@ const INTERNAL = {
1894
1997
  },
1895
1998
  };
1896
1999
 
2000
+ /**
2001
+ * SwiftPM's build directory for one Swift package scan: `<tmpRoot>/rightkit-swift-scratch/<sha256(repository root)[:16]>/<scan id>`, outside every root
2002
+ * the scan reads (the commit snapshot, the checkout), the same path every run (so the build stays warm). Throws when it would land inside one of them.
2003
+ */
2004
+ /**
2005
+ * GB-52: a carried scan's client gets an operation deadline BEFORE the scan's own limit, so the client cancels the job by id and watches its terminal state (within a 15 s cancel wait)
2006
+ * instead of being killed with the job still running; a user-set RUSTRIGHT_OPERATION_DEADLINE_MS or cancel wait is never replaced.
2007
+ */
2008
+ export function scanOperationEnv(env, limitMs) {
2009
+ const deadline = operationDeadlineEnv({ env, deadlineMs: Math.max(Math.floor(limitMs / 2), limitMs - 20_000) });
2010
+ if (Object.keys(deadline).length === 0) return {};
2011
+ return { ...deadline, ...(Object.keys(env ?? {}).some((k) => k.toUpperCase() === CANCEL_WAIT_ENV) ? {} : { [CANCEL_WAIT_ENV]: "15000" }) };
2012
+ }
2013
+
2014
+ export function swiftScratchPath({ tmpRoot, roots, id }) {
2015
+ const real = (p) => { try { return fs.realpathSync.native(p); } catch { return path.resolve(p); } };
2016
+ const repo = real(roots.find(Boolean));
2017
+ const dir = path.join(real(tmpRoot), "rightkit-swift-scratch", crypto.createHash("sha256").update(repo).digest("hex").slice(0, 16), id.replace(/[^A-Za-z0-9._-]/g, "_"));
2018
+ for (const root of roots.filter(Boolean)) {
2019
+ const rel = path.relative(real(root), dir);
2020
+ if (rel === "" || (rel !== ".." && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel))) throw new Error(`the SwiftPM build directory ${dir} would be inside ${real(root)}; set RK_GATE_TMPDIR outside the repository`);
2021
+ }
2022
+ return dir;
2023
+ }
2024
+
2025
+ /**
2026
+ * The output directory of ONE Class A Cargo scan that runs natively (no broker carries it): a fresh directory under `tmpRoot`, outside every
2027
+ * root the scan reads (the commit snapshot, the checkout). Cargo writes `target/.rustc_info.json` (and CACHEDIR.TAG) beside the manifest it
2028
+ * reads, so a scan run with no CARGO_TARGET_DIR leaves a `target/` in the snapshot, which must stay exactly the commit's tree (E6). The caller
2029
+ * removes the directory when the scan ends. Throws, removing it, when it would land inside one of `roots`.
2030
+ */
2031
+ export function cargoReadTargetDir({ tmpRoot, roots }) {
2032
+ const real = (p) => { try { return fs.realpathSync.native(p); } catch { return path.resolve(p); } };
2033
+ const dir = real(fs.mkdtempSync(path.join(real(tmpRoot), "rk-cargo-read-")));
2034
+ for (const root of roots.filter(Boolean)) {
2035
+ const rel = path.relative(real(root), dir);
2036
+ if (rel === "" || (rel !== ".." && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel))) {
2037
+ fs.rmSync(dir, { recursive: true, force: true });
2038
+ throw new Error(`the Cargo output directory ${dir} would be inside ${real(root)}; set RK_GATE_TMPDIR outside the repository`);
2039
+ }
2040
+ }
2041
+ return dir;
2042
+ }
2043
+
1897
2044
  /**
1898
2045
  * Run a plan. Options: classes (default ["A","B"]), exclude(scan) => boolean (an explicit, recorded
1899
2046
  * omission such as a caller that runs its own tests), plus the injectable spawn/exists/readFile/readdir/env.
1900
2047
  * Class B runs only when every selected class A scan passed; otherwise each B scan is "not-run".
2048
+ * `cargoReadTargetRoot` (default `tmpRoot`) is where a natively run Class A Cargo scan's own output directory is made (cargoReadTargetDir).
1901
2049
  */
1902
2050
  export async function runScans(plan, options = {}) {
1903
2051
  const deps = {
@@ -1914,11 +2062,15 @@ export async function runScans(plan, options = {}) {
1914
2062
  // An AbortSignal: once aborted, no further scan starts, running children are killed (process tree) and every unfinished scan is "not-run" with code CANCELLED.
1915
2063
  const signal = options.signal; const sig = signal ? { signal } : {};
1916
2064
  const { contentRoot, liveRoot, cargoLiveRoot = null } = plan.roots;
2065
+ // Where the gate's own temporary files live (cargo-deny's metadata file and config overlay): a directory the gate owns, never inside the
2066
+ // checkout or the snapshot; the broker reads them by absolute path (options.tmpRoot, else the system temp directory).
2067
+ const tmpRoot = options.tmpRoot ?? os.tmpdir();
1917
2068
  const rootDir = (r) => (r === "content" ? contentRoot : r === "parent" ? path.dirname(contentRoot) : r === "cargoLive" ? (cargoLiveRoot ?? liveRoot) : liveRoot);
1918
2069
  const selected = plan.scans.filter((s) => classes.includes(s.class) && !exclude(s));
1919
2070
  const ordered = [...selected.filter((s) => s.class === SCAN_CLASS.A), ...selected.filter((s) => s.class === SCAN_CLASS.B)];
1920
2071
  const ctx = { deps, contentRoot, read: (f) => deps.readFile(path.join(contentRoot, f)) };
1921
- const toolVersions = {}; const probed = new Map(); const advisoryDbs = new Map();
2072
+ const cargoReadsAreCarried = Boolean(deps.snapshotBroker) || options.cargoReadsCarried === true;
2073
+ const toolVersions = {}; const toolIdentities = {}; const probed = new Map(); const advisoryDbs = new Map();
1922
2074
  const results = [];
1923
2075
  // The gate-owned cargo ledger of a wrapped snapshot compile (options.cargoLedger = { dir }): every call the wrapper decided, filed
1924
2076
  // under its scan's token. `ledgerSeen` is the run's one-use record of carriers and request ids across every scan.
@@ -1951,8 +2103,11 @@ export async function runScans(plan, options = {}) {
1951
2103
  async function probe(tool, scanDir = null) {
1952
2104
  if (probed.has(tool)) return probed.get(tool);
1953
2105
  let outcome;
1954
- const command = toolCommand(tool);
1955
- if (!command) outcome = { ok: false, why: "not found" };
2106
+ // A trusted analyzer is resolved ONCE, from its enrolment (never PATH), and its bytes verified before this probe runs it.
2107
+ const trust = TRUSTED_SCAN_TOOLS.includes(tool) ? resolveTrustedScanTool(tool, { policy: options.toolPolicy, platform: deps.platform, ops: options.toolOps }) : null;
2108
+ const command = trust ? (trust.ok ? trust.identity.path : null) : toolCommand(tool);
2109
+ if (trust && !trust.ok) outcome = { ok: false, code: trust.code, why: trust.why };
2110
+ else if (!command) outcome = { ok: false, why: "not found" };
1956
2111
  else {
1957
2112
  // A version probe never runs inside the repository: a tool started there reads the repository's own configuration first
1958
2113
  // (pnpm's .npmrc and pnpmfile, corepack's packageManager pin, cargo's .cargo/config), before any scan's protections apply.
@@ -1973,7 +2128,9 @@ export async function runScans(plan, options = {}) {
1973
2128
  else {
1974
2129
  const text = String(r.stdout || r.stderr || "");
1975
2130
  const version = PROBE_VERSION[tool] ? PROBE_VERSION[tool](text) : text.split(/\r?\n/).find((l) => l.trim())?.trim() ?? "unknown";
1976
- outcome = { ok: true, command, version: version ?? "unknown", pinProblem: pinnedVersionProblem(tool, version) };
2131
+ const identity = trust?.identity ?? null;
2132
+ outcome = { ok: true, command, version: version ?? "unknown", pinProblem: pinnedVersionProblem(tool, version) ?? (identity ? enrolledVersionProblem(identity, version ?? text) : null), ...(identity ? { identity } : {}) };
2133
+ if (identity) toolIdentities[tool] = { ...identity, probed: version ?? "unknown" };
1977
2134
  }
1978
2135
  }
1979
2136
  toolVersions[tool] = outcome.ok ? outcome.version : null;
@@ -2114,12 +2271,17 @@ export async function runScans(plan, options = {}) {
2114
2271
  // which allows no licence at all.
2115
2272
  denyToml = deps.defaultDenyToml ?? fs.readFileSync(new URL("./deny-default.toml", import.meta.url), "utf8");
2116
2273
  }
2117
- const overlay = prepare({ denyToml, dbDir: db.dir, pin: s.advisoryPin });
2274
+ const overlay = prepare({ denyToml, dbDir: db.dir, pin: s.advisoryPin, tmpRoot });
2118
2275
  return { dbDir: db.dir, configPath: overlay.configPath, cleanup: overlay.cleanup };
2119
2276
  }
2120
2277
 
2121
2278
  // `extraEnv` (the quiet lease id) reaches the child but never the cache key: a lease changes nothing a scan proves.
2122
2279
  async function scanRow(s, extraEnv = null) {
2280
+ // What one scan makes outside the plan and must remove when it ends: a Class A Cargo scan's output directory (cargoReadTargetDir).
2281
+ const owned = { cargoReadTarget: null };
2282
+ try { return await scanRowIn(s, extraEnv, owned); } finally { if (owned.cargoReadTarget) fs.rmSync(owned.cargoReadTarget, { recursive: true, force: true }); }
2283
+ }
2284
+ async function scanRowIn(s, extraEnv, owned) {
2123
2285
  const row = { id: s.id, class: s.class, command: s.command, tool: s.tool ?? null, toolVersion: null, status: "fail" };
2124
2286
  if (s.class === SCAN_CLASS.B && classBBlocked) {
2125
2287
  return { ...row, status: "not-run", code: "CLASS_A_REQUIRED", detail: "class B refused: class A did not run in this call and no valid prior class A evidence for the same scan set was supplied" };
@@ -2140,17 +2302,35 @@ export async function runScans(plan, options = {}) {
2140
2302
  else if (out.length) { status = "fail"; detail = out.join("; "); }
2141
2303
  } else {
2142
2304
  const scanDir = path.join(rootDir(s.root), s.cwd ?? "");
2143
- const p = await probe(s.tool, scanDir);
2305
+ // The RustRight client's version probe starts in a Cargo workspace of the primary checkout; a class A cargo scan's own directory is
2306
+ // in the snapshot, so its probe uses the checkout's counterpart (reading no file of it), or the checkout when it has none.
2307
+ let probeAt = scanDir;
2308
+ if (cargoLiveRoot && s.root !== "cargoLive" && (s.tool === "rustright" || s.tool === "cargo")) {
2309
+ const counterpart = path.join(cargoLiveRoot, s.cwd ?? "");
2310
+ let isDir = false; try { isDir = fs.statSync(counterpart).isDirectory(); } catch { isDir = false; }
2311
+ probeAt = isDir ? counterpart : cargoLiveRoot;
2312
+ }
2313
+ const p = await probe(s.tool, probeAt);
2144
2314
  row.toolVersion = p.ok ? p.version : null;
2145
- let missingDep = null; let depVersionProblem = null; const depVersions = [];
2146
- for (const dep of s.requires ?? []) {
2315
+ // The broker runs the cargo-deny / cargo-audit a carried scan names: that identity is the broker's tool registration, not the gate's to bind.
2316
+ // `options.cargoReadsCarried === true` (the gate, when its executor is the broker and cargo reads go through carried requests, as in the check stage that has no snapshotBroker) makes them carried exactly as snapshotBroker does; it waives nothing else (actionlint and shellcheck stay locally enrolled).
2317
+ const brokerCarried = cargoReadsAreCarried && s.class === SCAN_CLASS.A && (s.tool === "rustright" || s.tool === "cargo");
2318
+ const advisoryTool = s.advisoryDb === "deny" ? "cargo-deny" : s.advisoryDb === "audit" ? "cargo-audit" : null;
2319
+ const trusts = advisoryTool && !brokerCarried ? [advisoryTool] : [];
2320
+ const identities = []; if (p.identity) identities.push(p.identity);
2321
+ let missingDep = null; let depVersionProblem = null; let depTrust = null; const depVersions = [];
2322
+ for (const dep of [...(s.requires ?? []), ...trusts]) {
2147
2323
  const d = await probe(dep, scanDir);
2148
- if (!d.ok) { missingDep = `${dep} (${d.why})`; break; }
2324
+ if (!d.ok) { if (d.code && d.code !== "TOOL_MISSING") depTrust = { code: d.code, why: d.why, dep }; else missingDep = `${dep} (${d.why})`; break; }
2149
2325
  if (d.pinProblem) { depVersionProblem = d.pinProblem; break; }
2150
- depVersions.push(d.version);
2326
+ depVersions.push(d.identity ? `${d.version}@${d.identity.sha256}` : d.version);
2327
+ if (d.identity) identities.push(d.identity);
2151
2328
  }
2152
- if (!p.ok) { status = "fail"; code = "TOOL_MISSING"; detail = `required tool ${s.tool} is missing or not runnable (${p.why}); a required scan is never skipped`; }
2329
+ if (advisoryTool && brokerCarried) row.identities = [{ tool: advisoryTool, source: "broker", note: "the broker runs its own registered tool; its identity is the broker's tool registration, not bound by the gate" }];
2330
+ else if (identities.length) row.identities = identities.map((i) => ({ tool: i.tool, path: i.path, sha256: i.sha256, version: i.version, source: i.source }));
2331
+ if (!p.ok) { status = "fail"; code = p.code ?? "TOOL_MISSING"; detail = p.code && p.code !== "TOOL_MISSING" ? `${p.code}: ${p.why}` : `required tool ${s.tool} is missing or not runnable (${p.why}); a required scan is never skipped`; }
2153
2332
  else if (p.pinProblem) { status = "fail"; code = "TOOL_VERSION"; detail = `TOOL_VERSION: ${p.pinProblem} (at ${p.command})`; }
2333
+ else if (depTrust) { status = "fail"; code = depTrust.code; detail = `${depTrust.code}: ${s.tool} needs ${depTrust.dep}: ${depTrust.why}`; }
2154
2334
  else if (missingDep) { status = "fail"; code = "TOOL_MISSING"; detail = `${s.tool} needs ${missingDep}, which is missing or not runnable; without it the scan would pass unchecked, so it fails`; }
2155
2335
  else if (depVersionProblem) { status = "fail"; code = "TOOL_VERSION"; detail = `TOOL_VERSION: ${s.tool} runs ${depVersionProblem}`; }
2156
2336
  else {
@@ -2158,11 +2338,12 @@ export async function runScans(plan, options = {}) {
2158
2338
  // A cargo request that builds the commit snapshot through the broker (options.snapshotBroker) is never served from or written
2159
2339
  // to the scan cache: each run must carry its own request and verify the broker's own signed evidence.
2160
2340
  const viaSnapshotBroker = Boolean(deps.snapshotBroker) && s.root === "cargoLive" && (s.tool === "rustright" || s.tool === "cargo");
2161
- if (cache && !viaSnapshotBroker) {
2341
+ const carriedRead = cargoReadsAreCarried && s.class === SCAN_CLASS.A && (s.tool === "rustright" || s.tool === "cargo"); // a snapshot compile never reuses a cargo read either
2342
+ if (cache && !viaSnapshotBroker && !carriedRead) {
2162
2343
  try {
2163
2344
  const digest = cache.digest(s);
2164
2345
  // The analyzers a scan runs through (actionlint's shellcheck) judge it too, so their versions key it as well.
2165
- const toolVersion = [p.version, ...depVersions].join(" + ");
2346
+ const toolVersion = [p.identity ? `${p.version}@${p.identity.sha256}` : p.version, ...depVersions].join(" + ");
2166
2347
  if (digest) cacheKey = scanCacheKey({ id: s.id, command: s.command, toolVersion, env: s.env ?? {}, inheritedEnv: inheritedEnvDigest({ ...deps.env, ...s.env }), digest });
2167
2348
  } catch { cacheKey = null; }
2168
2349
  const hit = cacheKey ? cache.read(cacheKey) : null;
@@ -2174,6 +2355,22 @@ export async function runScans(plan, options = {}) {
2174
2355
  return { ...row, status: "pass", cached: true, detail: `cached ${String(hit.at ?? "").slice(0, 10) || "earlier"}`, ...(s.kind === "test" ? { results: hit.row.results } : {}) };
2175
2356
  }
2176
2357
  }
2358
+ // Every cargo call the wrapper decides during this scan is filed under this token in the gate's ledger: the scan's own call AND the
2359
+ // calls the scan itself makes before it (the cargo-deny row's prefetch and its full metadata), so none is filed "unscoped" and fails the run.
2360
+ const ledgerToken = cargoLedger ? scanTokenOf(s.id) : null;
2361
+ if (ledgerToken) claimedTokens.add(ledgerToken);
2362
+ const ledgerEnv = ledgerToken ? { [SCAN_TOKEN_ENV]: ledgerToken } : {};
2363
+ // A Class A Cargo scan that runs here, not through the broker, never writes into the tree it reads: cargo puts target/.rustc_info.json
2364
+ // beside the manifest, and the snapshot must stay exactly the commit's tree after class A. It gets an output directory of its own
2365
+ // outside every root, removed when the scan ends (scanRow). Left alone: a scan that names its own CARGO_TARGET_DIR (a compile's
2366
+ // checkout-pinned output), a wrapped snapshot compile (the wrapper binds every cargo call; the sentinel asserts none escapes), a
2367
+ // carried read (the broker owns the output and a carried child gets no CARGO_TARGET_DIR) and the broker client itself.
2368
+ let readTargetEnv = {};
2369
+ if (s.class === SCAN_CLASS.A && s.tool === "cargo" && !cargoReadsAreCarried && !cargoLedger && !options.cargoSentinel && !Object.hasOwn(s.env ?? {}, "CARGO_TARGET_DIR")) {
2370
+ try { owned.cargoReadTarget = cargoReadTargetDir({ tmpRoot: options.cargoReadTargetRoot ?? tmpRoot, roots: [contentRoot, liveRoot, cargoLiveRoot] }); }
2371
+ catch (e) { return { ...row, status: "fail", code: "CARGO_READ_TARGET", detail: `${s.id}: ${e.message}` }; }
2372
+ readTargetEnv = { CARGO_TARGET_DIR: owned.cargoReadTarget };
2373
+ }
2177
2374
  let advisory = null; let args = s.args;
2178
2375
  if (s.advisoryDb) {
2179
2376
  try { advisory = prepareAdvisory(s); } catch (e) { return { ...row, status: "fail", code: "ADVISORY_DB", detail: e.message }; }
@@ -2192,16 +2389,16 @@ export async function runScans(plan, options = {}) {
2192
2389
  const fetchTimer = new AbortController();
2193
2390
  const fetchClock = setTimeout(() => fetchTimer.abort(), fetchLimit);
2194
2391
  let fetched;
2195
- try { fetched = await deps.spawn(p.command, [...args.slice(0, at), "fetch", "--locked"], { cwd: path.join(rootDir(s.root), s.cwd ?? ""), env: { ...deps.env, ...s.env }, signal: signal ? AbortSignal.any([signal, fetchTimer.signal]) : fetchTimer.signal, timeout: fetchLimit }); } finally { clearTimeout(fetchClock); }
2392
+ try { fetched = await deps.spawn(p.command, [...args.slice(0, at), "fetch", "--locked"], { cwd: path.join(rootDir(s.root), s.cwd ?? ""), env: { ...deps.env, ...readTargetEnv, ...s.env, ...ledgerEnv }, signal: signal ? AbortSignal.any([signal, fetchTimer.signal]) : fetchTimer.signal, timeout: fetchLimit }); } finally { clearTimeout(fetchClock); }
2196
2393
  if (!fetched || fetched.error || fetched.status !== 0) {
2197
2394
  advisory.cleanup?.();
2198
2395
  if (signal?.aborted) return { ...row, status: "not-run", code: "CANCELLED", detail: "cancelled while running" };
2199
2396
  if (fetchTimer.signal.aborted || fetched?.error?.code === "ETIMEDOUT") return { ...row, status: "fail", code: "TIMEOUT", detail: `cargo fetch --locked for cargo-deny ran longer than ${Math.round(fetchLimit / 60000)} min and was killed with its process tree` };
2200
2397
  return { ...row, status: "fail", code: "FETCH", detail: `cargo fetch --locked for cargo-deny failed (crate sources must be local before deny): ${fetched?.error?.message ?? (tail(fetched) || `exit ${fetched?.status}`)}` };
2201
2398
  }
2202
- const metadataDir = fs.mkdtempSync(path.join(os.tmpdir(), "rk-deny-metadata-"));
2399
+ const metadataDir = fs.mkdtempSync(path.join(tmpRoot, "rk-deny-metadata-"));
2203
2400
  const metadataFile = path.join(metadataDir, "metadata.json");
2204
- const metadata = await deps.spawn(p.command, [...args.slice(0, at), ...metadataArgs({ noDeps: false })], { cwd: path.join(rootDir(s.root), s.cwd ?? ""), env: { ...deps.env, ...s.env }, stdoutFile: metadataFile, ...sig });
2401
+ const metadata = await deps.spawn(p.command, [...args.slice(0, at), ...metadataArgs({ noDeps: false })], { cwd: path.join(rootDir(s.root), s.cwd ?? ""), env: { ...deps.env, ...readTargetEnv, ...s.env, ...ledgerEnv }, stdoutFile: metadataFile, ...sig });
2205
2402
  // AUX_METADATA: the auxiliary metadata that feeds deny could not be produced; not a deny finding, so never a deterministic red (rightkit#1257).
2206
2403
  if (!metadata || metadata.error || metadata.status !== 0) { fs.rmSync(metadataDir, { recursive: true, force: true }); advisory.cleanup?.(); return { ...row, status: "fail", code: "AUX_METADATA", detail: `cargo metadata for cargo-deny failed: ${metadata?.error?.message ?? (tail(metadata) || `exit ${metadata?.status}`)}` }; }
2207
2404
  if (!fs.existsSync(metadataFile)) fs.writeFileSync(metadataFile, String(metadata.stdout ?? ""));
@@ -2239,6 +2436,17 @@ export async function runScans(plan, options = {}) {
2239
2436
  // The broker snapshot carrier (joint plan step 4d): this one cargo request names the commit, the snapshot, the ownership
2240
2437
  // handle and its own argv and working directory, under a fresh nonce; the ownership records the submit first.
2241
2438
  const finalArgs = args.map((a) => (a === "{contentRoot}" ? contentRoot : a));
2439
+ // SwiftPM writes its `.build/` inside the package by default: that must never land in the commit snapshot or the checkout, so the
2440
+ // build directory is the gate's own, keyed by the repository and the scan so it stays warm.
2441
+ // GB-67: on a broker host the scan is the carried `rightkit-swift` adapter job and the broker owns SwiftPM's scratch (it appends the one managed path and the
2442
+ // product location is `build --show-bin-path`): the gate sends no scratch or build path, and never adds one. Only a host with no broker (Linux direct) keeps its own.
2443
+ const swiftBrokered = s.tool === "swift" && Boolean(deps.snapshotBroker);
2444
+ // The adapter takes exactly one relative --package-path (adapter.rs check_swift_package); the scan runs in its package directory, so that is `.`.
2445
+ if (swiftBrokered && finalArgs[0] === "build" && !finalArgs.some((a) => a === "--package-path" || a.startsWith("--package-path="))) finalArgs.splice(1, 0, "--package-path", ".");
2446
+ if (s.tool === "swift" && finalArgs[0] === "build" && !swiftBrokered && !finalArgs.some((a) => a === "--scratch-path" || a === "--build-path" || a.startsWith("--scratch-path=") || a.startsWith("--build-path="))) {
2447
+ try { finalArgs.push("--scratch-path", swiftScratchPath({ tmpRoot, roots: [contentRoot, liveRoot, cargoLiveRoot], id: s.id })); }
2448
+ catch (e) { advisory?.cleanup?.(); return { ...row, status: "fail", code: "SWIFT_SCRATCH", detail: `${s.id}: ${e.message}` }; }
2449
+ }
2242
2450
  let carrier = null;
2243
2451
  if (viaSnapshotBroker) {
2244
2452
  const sb = deps.snapshotBroker;
@@ -2246,11 +2454,6 @@ export async function runScans(plan, options = {}) {
2246
2454
  const refDir = fs.mkdtempSync(path.join(os.tmpdir(), "rk-snapreq-"));
2247
2455
  try {
2248
2456
  const cargoArgv = s.tool === "rustright" ? finalArgs.slice(1) : finalArgs;
2249
- // The ownership must record the submit, under this gate's handle, before anything is launched: a missing hook or a
2250
- // mark that did not land refuses the request (the reclaim proof reads broker.submitted).
2251
- if (typeof sb.markBrokerSubmit !== "function") throw new Error("no build-ownership submit hook: the broker submit cannot be recorded");
2252
- const marked = sb.markBrokerSubmit();
2253
- if (marked?.broker?.submitted !== true || marked.handle !== sb.handle) throw new Error(`the build ownership did not record the broker submit under ${sb.handle}`);
2254
2457
  const made = writeSnapshotRequest({ controlRoot: sb.controlRoot, commit: sb.commit, snapshot: sb.snapshot, declaredInputs: sb.declaredInputs ?? [], handle: sb.handle, primaryCwd: fs.realpathSync.native(cwd), argv: cargoArgv, requestIdFile: path.join(refDir, "request.json") });
2255
2458
  carrier = { made, refDir, cargoArgv };
2256
2459
  } catch (e) { fs.rmSync(refDir, { recursive: true, force: true }); clearTimeout(clock); advisory?.cleanup?.(); return { ...row, status: "fail", code: "SNAPSHOT_REQUEST", detail: `the snapshot request for ${s.id} could not be written: ${e.message}` }; }
@@ -2268,12 +2471,38 @@ export async function runScans(plan, options = {}) {
2268
2471
  fs.mkdirSync(sentinel, { recursive: true });
2269
2472
  }
2270
2473
  const sentinelEnv = sentinel ? { CARGO_TARGET_DIR: sentinel, CARGO_BUILD_TARGET_DIR: sentinel } : {};
2271
- // Every cargo call the wrapper decides during this scan is filed under this token in the gate's ledger.
2272
- const ledgerToken = cargoLedger ? scanTokenOf(s.id) : null;
2273
- if (ledgerToken) claimedTokens.add(ledgerToken);
2274
- const ledgerEnv = ledgerToken ? { [SCAN_TOKEN_ENV]: ledgerToken } : {};
2275
2474
  try {
2276
- try { r = await deps.spawn(p.command, finalArgs, { cwd: path.join(rootDir(s.root), s.cwd ?? ""), env: { ...deps.env, ...sentinelEnv, ...s.env, ...(extraEnv ?? {}), ...guardEnv, ...ledgerEnv, ...(carrier ? carrier.made.env : {}) }, signal: scanSignal, timeout: limitMs }); } finally { clearTimeout(clock); advisory?.cleanup?.(); }
2475
+ // The ownership records this request's sub-handle immediately before it is sent (the reclaim proof reads broker.submitted): a
2476
+ // missing hook or a mark that did not land sends nothing.
2477
+ if (carrier) {
2478
+ const sub = carrier.made.carrier.ownership.handle;
2479
+ let marked = null; let why = null;
2480
+ try { if (typeof deps.snapshotBroker.markBrokerSubmit !== "function") throw new Error("no build-ownership submit hook"); marked = deps.snapshotBroker.markBrokerSubmit(sub); } catch (e) { why = e.message; }
2481
+ if (marked?.broker?.submitted !== true || marked.handle !== deps.snapshotBroker.handle || !marked.broker.handles?.includes(sub)) {
2482
+ clearTimeout(clock); advisory?.cleanup?.();
2483
+ return { ...row, status: "fail", code: "SNAPSHOT_REQUEST", detail: `the snapshot request for ${s.id} was not sent: the build ownership did not record the broker submit under ${deps.snapshotBroker.handle}${why ? ` (${why})` : ""}` };
2484
+ }
2485
+ }
2486
+ if (swiftBrokered) {
2487
+ // One signed, ledgered adapter request in the commit's snapshot (the ownership records its sub-handle before it is sent); a refused or unverified call fails the scan.
2488
+ try {
2489
+ const carried = carryBrokerAdapter({ adapter: "rightkit-swift", words: finalArgs, cwd: path.join(rootDir(s.root), s.cwd ?? ""), env: { ...deps.env, ...s.env, ...(extraEnv ?? {}) }, section: brokerSection(deps.snapshotBroker), record: (entry) => { if (cargoLedger && ledgerToken) appendLedger(cargoLedger.dir, ledgerToken, entry); }, err: () => {}, stdio: "capture" });
2490
+ r = { status: carried.status, stdout: carried.stdout, stderr: carried.stderr };
2491
+ } catch (e) { r = { error: e }; } finally { clearTimeout(clock); advisory?.cleanup?.(); }
2492
+ } else {
2493
+ // The executables this scan runs are the ones resolved and verified above: read their bytes again now, immediately before the launch
2494
+ // (a replaced binary is TOOL_REPLACED), and hand the scan only those paths: actionlint its shellcheck, cargo its cargo-deny / cargo-audit.
2495
+ for (const identity of identities) {
2496
+ const swapped = trustedScanToolProblem(identity, { ops: options.toolOps });
2497
+ if (swapped) { clearTimeout(clock); advisory?.cleanup?.(); return { ...row, status: "fail", code: swapped.code, detail: `${swapped.code}: ${swapped.detail}; the scan did not run` }; }
2498
+ }
2499
+ const shellcheckIdentity = s.tool === "actionlint" ? identities.find((i) => i.tool === "shellcheck") : null;
2500
+ const spawnArgs = shellcheckIdentity ? ["-shellcheck", shellcheckIdentity.path, ...finalArgs] : finalArgs;
2501
+ const trustedDirs = trusts.map((tool) => identities.find((i) => i.tool === tool)).filter(Boolean).map((i) => path.dirname(i.path));
2502
+ const pathKey = Object.keys({ ...deps.env, ...s.env }).find((k) => k.toLowerCase() === "path") ?? "PATH";
2503
+ const trustEnv = trustedDirs.length ? { [pathKey]: [...trustedDirs, ({ ...deps.env, ...s.env })[pathKey]].filter(Boolean).join(deps.platform === "win32" ? ";" : ":") } : {};
2504
+ try { r = await deps.spawn(p.command, spawnArgs, { cwd: path.join(rootDir(s.root), s.cwd ?? ""), env: { ...deps.env, ...sentinelEnv, ...readTargetEnv, ...s.env, ...(extraEnv ?? {}), ...guardEnv, ...ledgerEnv, ...(carrier ? carrier.made.env : {}), ...(carrier ? scanOperationEnv({ ...deps.env, ...s.env }, limitMs) : {}), ...trustEnv }, signal: scanSignal, timeout: limitMs }); } finally { clearTimeout(clock); advisory?.cleanup?.(); }
2505
+ }
2277
2506
  if (signal?.aborted && (!r || r.error || r.status !== 0)) return { ...row, status: "not-run", code: "CANCELLED", detail: "cancelled while running" };
2278
2507
  if ((timer.signal.aborted || r?.error?.code === "ETIMEDOUT") && (!r || r.error || r.status !== 0)) return { ...row, status: "fail", code: "TIMEOUT", detail: `${s.id} ran longer than ${Math.round(limitMs / 60000)} min and was killed with its process tree; a scan that does not finish fails` };
2279
2508
  if (!r || r.error) { status = "fail"; code = "SPAWN_ERROR"; detail = r?.error?.message ?? "no result"; }
@@ -2317,7 +2546,7 @@ export async function runScans(plan, options = {}) {
2317
2546
  }
2318
2547
  }
2319
2548
  } finally {
2320
- if (carrier) { fs.rmSync(carrier.made.file, { force: true }); fs.rmSync(carrier.refDir, { recursive: true, force: true }); }
2549
+ if (carrier) { removeCarrier(carrier.made.file); fs.rmSync(carrier.refDir, { recursive: true, force: true }); }
2321
2550
  if (tscProject) fs.rmSync(tscProject.dir, { recursive: true, force: true });
2322
2551
  }
2323
2552
  if (status === "fail" && options.scanLogDir && r) {
@@ -2361,7 +2590,7 @@ export async function runScans(plan, options = {}) {
2361
2590
  schema: SCAN_RESULT_SCHEMA, ok: !cancelled && results.every((r) => r.status === "pass"), complete: !cancelled && results.every((r) => r.status === "pass"), classes, checker, ...(cancelled ? { cancelled: true } : {}),
2362
2591
  inputDigest: plan.inputDigest ?? null,
2363
2592
  deferred: results.filter((r) => r.status === "deferred").map((r) => r.id),
2364
- scans: results, toolVersions, cacheHits: [...cacheHits].sort(), notes: [...(plan.notes ?? []), ...(cargoLedger ? [UNOBSERVED_NESTED_CARGO_NOTE] : [])],
2593
+ scans: results, toolVersions, toolIdentities, cacheHits: [...cacheHits].sort(), notes: [...(plan.notes ?? []), ...(cargoLedger ? [UNOBSERVED_NESTED_CARGO_NOTE] : [])],
2365
2594
  scanSetDigest: digestScanSet(results),
2366
2595
  classDigests: { A: digestScanSet(results.filter((r) => r.class === "A")), B: digestScanSet(results.filter((r) => r.class === "B")) },
2367
2596
  };
@@ -6,6 +6,7 @@ import path from "node:path";
6
6
  import { spawnSync } from "./background-process.mjs";
7
7
  import { adhocSafeEntitlements } from "./adhoc-entitlements.mjs";
8
8
  import { runMacCodesignCommands } from "./macos-signing-session.mjs";
9
+ import { DEFAULT_TEAM } from "./signing-identity.mjs";
9
10
  import { bundleRelativePath, compileBundlePathMap, globToRegExp, isGlobPattern, lookupBundlePath, resolveBundleIdentifier, validateBundleRelativeKey, validateIdentifierTemplate, validateSigningIdentifier } from "./mac-bundle-signing.mjs";
10
11
 
11
12
  export class MacosDevelopmentSigningError extends Error {
@@ -17,7 +18,7 @@ export class MacosDevelopmentSigningError extends Error {
17
18
  }
18
19
  }
19
20
 
20
- const DEFAULT_TEAM = "6KLGD3LLKF";
21
+ export { DEFAULT_TEAM };
21
22
  const output = (result) => `${result.stdout ?? ""}\n${result.stderr ?? ""}`.trim();
22
23
  const captureOptions = (root, env) => ({ cwd: root, env, encoding: "utf8", stdio: ["inherit", "pipe", "pipe"], windowsHide: true, shell: false });
23
24
 
@@ -387,6 +388,18 @@ export function signMacosDevelopmentBundle(options) {
387
388
  } finally { rmSync(scratch, { recursive: true, force: true }); }
388
389
  }
389
390
 
391
+ /**
392
+ * The designated requirement a Developer ID Application signature of `bundleId` by `team` carries, exactly as `codesign -d -r-` prints it:
393
+ * the signing identifier, Apple's anchor, the Developer ID intermediate and leaf OIDs, and the team. A suite-signed QA binary and a release
394
+ * build of the same product have this same requirement, so macOS keeps one set of privacy grants for both. The team is printed bare when it
395
+ * starts with a letter and quoted when it starts with a digit (what `csreq -t` prints; a bare digit-first word does not parse).
396
+ */
397
+ export function expectedDesignatedRequirement(bundleId, team = DEFAULT_TEAM) {
398
+ if (typeof bundleId !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,254}$/.test(bundleId)) throw new MacosDevelopmentSigningError("DEV_MAC_SIGNING_CONFIG_INVALID", `designated requirement: ${JSON.stringify(bundleId)} is not a signing identifier`);
399
+ if (typeof team !== "string" || !/^[A-Z0-9]{10}$/.test(team)) throw new MacosDevelopmentSigningError("DEV_MAC_SIGNING_CONFIG_INVALID", `designated requirement: ${JSON.stringify(team)} is not a ten-character Apple team identifier`);
400
+ return `identifier "${bundleId}" and anchor apple generic and certificate 1[field.1.2.840.113635.100.6.2.6] /* exists */ and certificate leaf[field.1.2.840.113635.100.6.1.13] /* exists */ and certificate leaf[subject.OU] = ${/^[A-Za-z]/.test(team) ? team : `"${team}"`}`;
401
+ }
402
+
390
403
  /**
391
404
  * One stable designated requirement across dev installs. macOS keys privacy grants (TCC) to the code identity, so a dev
392
405
  * build whose designated requirement differs from the installed copy's would silently drop every grant. Fails with