@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
@@ -15,8 +15,8 @@ import { spawnSync } from "./background-process.mjs";
15
15
  import { hardenQaReceipt } from "./release-qa-hardening.mjs";
16
16
  import { admissionModeFor, loadAdmissionPolicy } from "./release-qa-policy.mjs";
17
17
  import { assertInstalledQaAdmission, assertSourceQaAdmission, loadQaFloor, QaAdmissionError, qaReceiptPath, readQaReceipt } from "./release-qa-receipt.mjs";
18
+ import { platformDirOf, platformOfDir } from "./platform-dir.mjs";
18
19
 
19
- const platformDirOf = (platform) => (platform === "win" ? "windows" : platform);
20
20
 
21
21
  /** "missing" when no receipt exists; "rejected" for anything else the gate refused (bad receipt, no key, wrong commit). */
22
22
  function refusalStatus(error) {
@@ -106,7 +106,7 @@ function installedStatus({ repoRoot, releaseId, platformDir }) {
106
106
  if (record) return { uploaded: true, status: record.status, ...(record.reason ? { reason: record.reason } : {}), ...(record.checkedAt ? { checkedAt: record.checkedAt } : {}) };
107
107
  if (!published) return { uploaded: false, status: "not-uploaded" };
108
108
  // Uploaded before outcomes were recorded: fall back to the receipt file the old gate required.
109
- const receipt = readQaReceipt(qaReceiptPath({ repoRoot, releaseId, platform: platformDir === "windows" ? "win" : platformDir, stage: "installed" }));
109
+ const receipt = readQaReceipt(qaReceiptPath({ repoRoot, releaseId, platform: platformOfDir(platformDir), stage: "installed" }));
110
110
  if (receipt?.status === "passed") return { uploaded: true, status: "passed", reason: "legacy upload; receipt file present, not re-verified" };
111
111
  return { uploaded: true, status: "missing", reason: "uploaded with no recorded installed QA outcome" };
112
112
  }
@@ -36,6 +36,7 @@ import {
36
36
  buildQaReceipt, currentPolicyBundleDigest, dependencyClosure, ensureQaReceiptKey, evaluateRequiredChecks, loadQaFloor,
37
37
  qaReceiptPath, QA_STAGES, sealQaReceipt, sourceIdentity, validateAppQaConfig, writeQaReceipt,
38
38
  } from "./release-qa-receipt.mjs";
39
+ import { platformDirOf } from "./platform-dir.mjs";
39
40
 
40
41
  const sha256File = (file) => createHash("sha256").update(readFileSync(file)).digest("hex");
41
42
 
@@ -126,7 +127,7 @@ export async function runQa({ stage, platform, releaseIdArg, configPath, carryTo
126
127
  if (stage === "source") releaseId = `${config.app}-${config.version}-${source.commit.slice(0, 8)}`;
127
128
  else {
128
129
  if (!releaseId) throw new Error("--release <sealed-id> is required for the installed stage");
129
- sealed = verifySealedRelease(path.join(repoRoot, ".right-release", "sealed", releaseId, platform === "win" ? "windows" : "mac"));
130
+ sealed = verifySealedRelease(path.join(repoRoot, ".right-release", "sealed", releaseId, platformDirOf(platform)));
130
131
  // R4: a split binds the sealed release to the selected commit (and its tree), never to the checkout's HEAD.
131
132
  if (split) assertBindsSource(release, sealed.manifest, "the sealed release");
132
133
  else if (sealed.manifest.commit !== source.commit) throw new Error("sealed release was built from a different commit than the current checkout");
@@ -9,6 +9,7 @@
9
9
  // runner binary and the evidence bundle it produced, evaluates the protected QA floor, and MACs the result with a
10
10
  // key held in the protected credential store (Keychain / protected file / CI secret) — not app-writable JSON.
11
11
  // The floor, this verifier, and the receipt schema are pinned in the protected policy bundle.
12
+ import { writeFileAtomic } from "./atomic-write.mjs";
12
13
  import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
13
14
  import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
14
15
  import path from "node:path";
@@ -266,8 +267,8 @@ export function readQaReceipt(file) {
266
267
  }
267
268
 
268
269
  export function writeQaReceipt(file, receipt) {
269
- mkdirSync(path.dirname(file), { recursive: true });
270
- writeFileSync(file, `${JSON.stringify(receipt, null, 2)}\n`);
270
+ // GB-26: the authoritative QA receipt is replaced atomically (temp, fsync, rename), never overwritten in place.
271
+ writeFileAtomic(file, `${JSON.stringify(receipt, null, 2)}\n`);
271
272
  }
272
273
 
273
274
  function loadKeyOrRefuse(opts, command) {
package/release-state.mjs CHANGED
@@ -7,6 +7,7 @@ import { resolveNativeCargoLayout } from "./native-cargo-layout.mjs";
7
7
  import { bufferQaFindings } from "./qa-control-scan.mjs";
8
8
  import { sealedManifestAllowsContentDevtools } from "./devtools-policy.mjs";
9
9
  import { assertManifestRegistryEvidence } from "./rightkit-registry-resolver.mjs";
10
+ import { platformDirOf } from "./platform-dir.mjs";
10
11
 
11
12
  export function cacheFingerprint({ cargoLockSha256, rustc, target, architecture, features = [] }) {
12
13
  const payload = JSON.stringify({
@@ -157,7 +158,7 @@ export async function runBuildStateMachine({
157
158
  ops,
158
159
  }) {
159
160
  const releaseId = `${app}-${version}-${commit.slice(0, 8)}`;
160
- const platformDir = platform === "win" ? "windows" : platform === "mac" ? "mac" : platform;
161
+ const platformDir = platformDirOf(platform);
161
162
  const sealedDir = path.join(root, ".right-release", "sealed", releaseId, platformDir);
162
163
  const sealedRoot = path.join(root, ".right-release", "sealed");
163
164
  if (existsSync(sealedRoot)) {
@@ -202,7 +203,7 @@ export async function runBuildStateMachine({
202
203
  export async function runUploadStateMachine({ root, releaseId, platform, tier, ops = {} }) {
203
204
  if (tier !== "patch" && tier !== "update") throw new Error("upload tier is required (patch|update)");
204
205
  if (!/^[A-Za-z0-9][A-Za-z0-9._+-]{0,159}$/.test(String(releaseId))) throw new Error(`invalid release id: ${releaseId}`);
205
- const platformDir = platform === "win" ? "windows" : platform === "mac" ? "mac" : platform;
206
+ const platformDir = platformDirOf(platform);
206
207
  const sealedDir = path.join(root, ".right-release", "sealed", releaseId, platformDir);
207
208
  const sealed = verifySealedRelease(sealedDir);
208
209
  if (sealed.manifest.platform !== platform) throw new Error("sealed release platform mismatch");
package/release.mjs CHANGED
@@ -3,7 +3,8 @@ import { expandPackageRecipeTarget } from "./package-recipe-targets.mjs";
3
3
  import { access, readFile, readdir } from "node:fs/promises";
4
4
  import { createHash } from "node:crypto";
5
5
  import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
6
- import { buildLaunch } from "./build-launch.mjs";
6
+ import { buildLaunch, createNativeRunner } from "./build-launch.mjs";
7
+ import { NATIVE_SIGNING_FILE_ENV, nativeToolOf } from "./native-adapters.mjs";
7
8
  import { createRequire } from "node:module";
8
9
  import path from "node:path";
9
10
  import { fileURLToPath, pathToFileURL } from "node:url";
@@ -735,6 +736,9 @@ if (opts.platform === "win" && target.signingContract === MSIX_SIGNING_CONTRACT)
735
736
  await runAndroidRelease({
736
737
  root, app: config.app ?? path.basename(root), target: androidTarget, dryRun: opts.dryRun,
737
738
  runGradle: (gradle, env) => runCommand(gradle, root, { env }),
739
+ // RA-07/RA-08: the production runner of the native compile phases (build-launch.mjs createNativeRunner): the carrier or the resolved executor's client, never a spawn of its own, a fixed result shape with the
740
+ // receipt's output mapping, and the signing-properties file as a typed path handoff (`signingFile`), never an environment. An injected executor is not accepted here.
741
+ native: createNativeRunner({ cwd: root, env: process.env }),
738
742
  resolveArtifact: (declared) => resolveReleaseArtifactPath(root, declared),
739
743
  scanFiles: (label, files, scanOptions) => assertNoQaControlBinaries(label, files, scanOptions),
740
744
  receiptDir: path.resolve(process.env.RIGHT_RELEASE_STATE_ROOT ?? path.join(root, ".right-release", "receipts")),
@@ -745,6 +749,8 @@ if (opts.platform === "win" && target.signingContract === MSIX_SIGNING_CONTRACT)
745
749
  // --dry-run prints the plan anywhere). Upload is a separate command: `right-release upload --platform ios`.
746
750
  await runIosPackageRecipe({
747
751
  root, app: config.app ?? path.basename(root), spec: iosSpec, dryRun: opts.dryRun,
752
+ // RA-09: the production runner of the native compile phases (xcodegen, xcodebuild archive/export/test), never an injected executor or a spawn: build-launch.mjs createNativeRunner.
753
+ native: createNativeRunner({ cwd: root, env: process.env }),
748
754
  receiptDir: path.resolve(process.env.RIGHT_RELEASE_STATE_ROOT ?? path.join(root, ".right-release", "receipts")),
749
755
  scanFiles: (label, files, scanOptions) => assertNoQaControlBinaries(label, files, scanOptions),
750
756
  // The build number counter outlives every release id: it lives in the vault state root that right-release build exports.
@@ -1198,8 +1204,13 @@ async function runCommands(value, root, options) {
1198
1204
  // on a broker-managed host (rightkit#1241).
1199
1205
  async function runCommand(command, root, { env = {}, captureStdout = false } = {}) {
1200
1206
  const cwd = path.resolve(root, command.cwd ?? ".");
1201
- const commandEnvironment = { ...(await commandEnv(command, root)), ...env };
1202
- const target = buildLaunch(command.cmd, command.args ?? [], { env: { ...process.env, ...commandEnvironment }, cwd, exists: existsSync });
1207
+ // RA-08: the dynamic signing-properties file path of a native tool (Gradle) is a typed FILE handoff of the provisioned signer session, not a step environment: it is taken out of the environment the door
1208
+ // sees (so it never reaches RUSTRIGHT_ADAPTER_ENV or a client's environment) and handed to the door as `signingFile`. A broker route refuses it before launch when no broker can carry it (build-launch.mjs,
1209
+ // nativeSigningFileRefusal); a direct host's launch puts the same path back in its own Gradle's environment (`target.env`). No password is in it: only the file's path.
1210
+ const nativeCommand = nativeToolOf(command.cmd) !== null;
1211
+ const { [NATIVE_SIGNING_FILE_ENV]: signingFile, ...dynamicEnv } = env;
1212
+ const commandEnvironment = { ...(await commandEnv(command, root)), ...(nativeCommand ? dynamicEnv : env) };
1213
+ const target = buildLaunch(command.cmd, command.args ?? [], { env: { ...process.env, ...commandEnvironment }, stepEnv: command.env, cwd, exists: existsSync, ...(nativeCommand && signingFile ? { signingFile } : {}) });
1203
1214
  // A direct (Linux) launch carries PATH without RustRight shim directories (build-launch `env`).
1204
1215
  return run(target.command, target.args, cwd, { ...commandEnvironment, ...(target.env ?? {}) }, {
1205
1216
  ...command,
@@ -62,14 +62,36 @@ export function rightkitDrift(appRoot, versions = loadRightkitVersions()) {
62
62
  return drift;
63
63
  }
64
64
 
65
- function run(command, args, cwd) {
66
- const result = spawnSync(command, args, { cwd, stdio: "inherit", windowsHide: true, shell: process.platform === "win32" });
65
+ const SHIM_EXE_LINE = /^"([A-Za-z]:\\[^"%\r\n<>|&^!*?]+\.exe)" %\*$/m;
66
+
67
+ /**
68
+ * How a command starts (GB-08, BR11/B31). `rustright` on Windows is the managed agent shim `rustright.cmd`, which node cannot start without
69
+ * a shell (EINVAL; a shell would re-quote the argv): its target .exe is read from the shim and started directly, argv array, no shell.
70
+ * Anything else keeps the old launch (pnpm on Windows is a .cmd and keeps its shell). A client that cannot be resolved throws before anything runs.
71
+ */
72
+ export function launchOf(command, args, { platform = process.platform, spawn = spawnSync, readFile = (f) => readFileSync(f, "utf8") } = {}) {
73
+ if (command !== "rustright") return { command, args, shell: platform === "win32" };
74
+ if (platform !== "win32") return { command, args, shell: false };
75
+ const where = spawn("where", ["rustright"], { encoding: "utf8", windowsHide: true, shell: false, timeout: 20_000 });
76
+ const found = String(where?.stdout ?? "").split(/\r?\n/).map((l) => l.trim()).filter((l) => /^[A-Za-z]:\\/.test(l));
77
+ const exe = found.find((l) => /\.exe$/i.test(l));
78
+ if (exe) return { command: exe, args, shell: false };
79
+ const shim = found.find((l) => /\.(cmd|bat)$/i.test(l));
80
+ let target = null;
81
+ if (shim) { try { target = SHIM_EXE_LINE.exec(readFile(shim))?.[1] ?? null; } catch { target = null; } }
82
+ if (!target) throw new Error("RK_BROKER_CLIENT_UNRESOLVED: the RustRight client (rustright.exe) cannot be resolved from PATH; nothing was started");
83
+ return { command: target, args, shell: false };
84
+ }
85
+
86
+ function run(command, args, cwd, { spawn = spawnSync, platform = process.platform, readFile } = {}) {
87
+ const launch = launchOf(command, args, { platform, spawn, readFile });
88
+ const result = spawn(launch.command, launch.args, { cwd, stdio: "inherit", windowsHide: true, shell: launch.shell });
67
89
  if (result.status !== 0) throw new Error(`${command} ${args.join(" ")} failed in ${cwd} (exit ${result.status ?? result.error?.message})`);
68
90
  }
69
91
 
70
92
  /** Move drifted pins to the latest version (keeping their = ^ ~ prefix), then refresh lockfiles.
71
93
  * Breaking bumps need code changes, so they are adopted only with includeBreaking. */
72
- export function applyRightkitAdoption(appRoot, versions = loadRightkitVersions(), { refreshLocks = true, includeBreaking = false } = {}) {
94
+ export function applyRightkitAdoption(appRoot, versions = loadRightkitVersions(), { refreshLocks = true, includeBreaking = false, runOptions = {} } = {}) {
73
95
  const drift = rightkitDrift(appRoot, versions).filter((item) => includeBreaking || !item.breaking);
74
96
  const byFile = Map.groupBy(drift, (item) => item.file);
75
97
  for (const [file, items] of byFile) {
@@ -90,8 +112,8 @@ export function applyRightkitAdoption(appRoot, versions = loadRightkitVersions()
90
112
  if (refreshLocks) {
91
113
  for (const file of byFile.keys()) {
92
114
  const dir = path.join(appRoot, path.dirname(file));
93
- if (file.endsWith("package.json")) run("pnpm", ["install", "--lockfile-only", "--ignore-scripts"], dir);
94
- else run("rustright", ["cargo", "metadata", "--format-version", "1", "--manifest-path", path.join(appRoot, file)], dir);
115
+ if (file.endsWith("package.json")) run("pnpm", ["install", "--lockfile-only", "--ignore-scripts"], dir, runOptions);
116
+ else run("rustright", ["cargo", "metadata", "--format-version", "1", "--manifest-path", path.join(appRoot, file)], dir, runOptions);
95
117
  }
96
118
  }
97
119
  return drift;
@@ -32,7 +32,7 @@
32
32
  "@rightkit/qa": "0.6.2",
33
33
  "@rightkit/qa-chrome": "0.1.12",
34
34
  "@rightkit/react-shell": "0.7.2",
35
- "@rightkit/release": "0.6.12",
35
+ "@rightkit/release": "0.6.14",
36
36
  "@rightkit/settings-ui": "0.7.0",
37
37
  "@rightkit/tauri": "0.3.0",
38
38
  "@rightkit/tauri-shell": "0.1.3",
@@ -379,7 +379,9 @@
379
379
  "0.6.7",
380
380
  "0.6.8",
381
381
  "0.6.10",
382
- "0.6.11"
382
+ "0.6.11",
383
+ "0.6.12",
384
+ "0.6.13"
383
385
  ],
384
386
  "@rightkit/settings-ui": [
385
387
  "0.1.0",
@@ -469,7 +471,7 @@
469
471
  "rightkit-crash": "0.3.6",
470
472
  "rightkit-credentials": "0.4.2",
471
473
  "rightkit-crypto": "0.1.0",
472
- "rightkit-daemon": "0.4.0",
474
+ "rightkit-daemon": "0.5.0",
473
475
  "rightkit-db": "0.5.3",
474
476
  "rightkit-device": "0.4.0",
475
477
  "rightkit-docs": "0.1.3",
@@ -484,7 +486,7 @@
484
486
  "rightkit-http": "0.3.9",
485
487
  "rightkit-identity": "0.1.0",
486
488
  "rightkit-job-queue": "0.5.1",
487
- "rightkit-license": "0.2.5",
489
+ "rightkit-license": "0.2.6",
488
490
  "rightkit-llm": "0.3.1",
489
491
  "rightkit-logs": "0.3.6",
490
492
  "rightkit-mcp": "0.7.1",
@@ -499,7 +501,7 @@
499
501
  "rightkit-pdf": "0.3.4",
500
502
  "rightkit-platform-rules": "0.1.0",
501
503
  "rightkit-playback": "0.3.0",
502
- "rightkit-qa": "0.9.2",
504
+ "rightkit-qa": "0.10.0",
503
505
  "rightkit-qa-driver": "0.7.0",
504
506
  "rightkit-record": "0.3.3",
505
507
  "rightkit-review": "0.4.0",
@@ -507,7 +509,7 @@
507
509
  "rightkit-search": "0.4.1",
508
510
  "rightkit-send": "0.3.0",
509
511
  "rightkit-sidecar": "0.3.0",
510
- "rightkit-speech-client": "0.4.0",
512
+ "rightkit-speech-client": "0.4.1",
511
513
  "rightkit-spend": "0.2.0",
512
514
  "rightkit-sync": "0.2.2",
513
515
  "rightkit-tauri-shell": "0.7.0",
@@ -641,7 +643,7 @@
641
643
  "@rightkit/qa": "0.6.1",
642
644
  "@rightkit/qa-chrome": "0.1.12",
643
645
  "@rightkit/react-shell": "0.7.2",
644
- "@rightkit/release": "0.6.11",
646
+ "@rightkit/release": "0.6.13",
645
647
  "@rightkit/settings-ui": "0.7.0",
646
648
  "@rightkit/shell": "0.1.1",
647
649
  "@rightkit/tauri": "0.3.0",
@@ -890,7 +892,8 @@
890
892
  "0.3.2",
891
893
  "0.3.4",
892
894
  "0.3.5",
893
- "0.3.6"
895
+ "0.3.6",
896
+ "0.4.0"
894
897
  ],
895
898
  "rightkit-crash": [
896
899
  "0.1.0",
@@ -964,7 +967,8 @@
964
967
  "0.2.2",
965
968
  "0.2.3",
966
969
  "0.2.7",
967
- "0.3.0"
970
+ "0.3.0",
971
+ "0.4.0"
968
972
  ],
969
973
  "rightkit-job-queue": [
970
974
  "0.3.0",
@@ -1049,7 +1053,8 @@
1049
1053
  "0.2.0",
1050
1054
  "0.2.2",
1051
1055
  "0.2.3",
1052
- "0.2.4"
1056
+ "0.2.4",
1057
+ "0.2.5"
1053
1058
  ],
1054
1059
  "rightkit-credentials": [
1055
1060
  "0.2.0",
@@ -0,0 +1,32 @@
1
+ // GB-09: the names the gate emits at the RustRight interface. The client reads the current `RUSTRIGHT_*` spellings and, for migration only, the legacy
2
+ // `RIGHTKIT_*` ones (rustright-cli shims.rs LEGACY_ENV_SPELLINGS, removed by RustRight's task L-1; `RUSTRIGHT_LEGACY_ALIASES=off` makes it read none).
3
+ // Every outbound production call carries the current spelling only: a legacy interface name found in a call's environment is translated to its current
4
+ // spelling (the current one wins when both are set) and the legacy name is dropped, so no route depends on an alias the broker may stop reading. Gate-internal
5
+ // RIGHTKIT_* names that are not part of this interface (RIGHTKIT_MANAGED_ROOT, RIGHTKIT_QA_*, ...) are never touched.
6
+ export const LEGACY_INTERFACE_ENV = Object.freeze({
7
+ RIGHTKIT_BUILD_PRIORITY: "RUSTRIGHT_BUILD_PRIORITY", RIGHTKIT_PARENT_REQUEST_ID: "RUSTRIGHT_PARENT_REQUEST_ID", RIGHTKIT_EXCLUSIVE: "RUSTRIGHT_EXCLUSIVE",
8
+ RIGHTKIT_CANCEL_ON_DISCONNECT: "RUSTRIGHT_CANCEL_ON_DISCONNECT", RIGHTKIT_CALLING_GATE: "RUSTRIGHT_CALLING_GATE", RIGHTKIT_SNAPSHOT_REQUEST: "RUSTRIGHT_SNAPSHOT_REQUEST",
9
+ RIGHTKIT_SNAPSHOT_ADMISSION: "RUSTRIGHT_SNAPSHOT_ADMISSION", RIGHTKIT_CHECK_ADMISSION: "RUSTRIGHT_CHECK_ADMISSION", RIGHTKIT_GATE_RUN: "RUSTRIGHT_GATE_RUN",
10
+ RIGHTKIT_ATTEST_NONCE: "RUSTRIGHT_ATTEST_NONCE", RIGHTKIT_REQUEST_ID_FILE: "RUSTRIGHT_REQUEST_ID_FILE", RIGHTKIT_INPUT_MANIFEST: "RUSTRIGHT_INPUT_MANIFEST",
11
+ RIGHTKIT_OUTPUT_ROOTS: "RUSTRIGHT_OUTPUT_ROOTS", RIGHTKIT_ADAPTER_ENV: "RUSTRIGHT_ADAPTER_ENV", RIGHTKIT_FORWARD_ENV: "RUSTRIGHT_FORWARD_ENV",
12
+ RIGHTKIT_PARENT_CAPABILITY_TOKEN: "RUSTRIGHT_PARENT_CAPABILITY_TOKEN", RIGHTKIT_CUTOVER_WAIT_MS: "RUSTRIGHT_CUTOVER_WAIT_MS", RIGHTKIT_RECONNECT_WAIT_MS: "RUSTRIGHT_RECONNECT_WAIT_MS",
13
+ RIGHTKIT_RUN_ROOT: "RUSTRIGHT_RUN_ROOT", RIGHTKIT_MANAGED_AGENT_SHIM: "RUSTRIGHT_MANAGED_AGENT_SHIM",
14
+ RIGHTKIT_BUILD_BROKER_SOCKET: "RUSTRIGHT_BUILD_BROKER_SOCKET",
15
+ });
16
+
17
+ /** The legacy interface names set in `env` (case-insensitive, as Windows environments are). */
18
+ export const legacyInterfaceNamesIn = (env) => Object.keys(env ?? {}).filter((key) => Object.hasOwn(LEGACY_INTERFACE_ENV, key.toUpperCase()));
19
+
20
+ /** `env` with every legacy interface name translated to the current spelling (the current value wins) and removed; the same object when there is none. */
21
+ export function currentInterfaceEnv(env) {
22
+ const legacy = legacyInterfaceNamesIn(env);
23
+ if (legacy.length === 0) return env;
24
+ const out = { ...env };
25
+ const present = new Set(Object.keys(out).map((key) => key.toUpperCase()));
26
+ for (const key of legacy) {
27
+ const current = LEGACY_INTERFACE_ENV[key.toUpperCase()];
28
+ if (!present.has(current)) { out[current] = out[key]; present.add(current); }
29
+ delete out[key];
30
+ }
31
+ return out;
32
+ }
@@ -0,0 +1,167 @@
1
+ // The gate's own record of the scan tools it trusts on this host (RA-18 / L9-03; the broker-pins.mjs pattern, for analyzers instead of a broker).
2
+ //
3
+ // deterministic-scans.mjs runs shellcheck, actionlint, cargo-deny and cargo-audit only from an ENROLLED executable: an absolute path, the file's
4
+ // sha256 and its version (`options.toolPolicy`, `{ [tool]: { path, sha256, version } }`), never a binary found on PATH. This module is where that
5
+ // policy comes from, and from nowhere else:
6
+ //
7
+ // record `<managed data root>/scan-tools/<platform>.json`, `{schema, platform, tools: {<tool>: {path, sha256, version}}, enrolledAt}`,
8
+ // outside every repository and snapshot, written only by `enrollScanTool` (an explicit operation, one tool per call; a second
9
+ // enrollment of the same tool is a rotation and says so). At enrol time the file named by `path` must be a regular, executable
10
+ // file whose sha256 IS the one enrolled: the operator's claim is checked against the bytes, nothing is computed on their behalf.
11
+ // fallback RightKit's own checkout only: `ci/rk-pipeline.json` `tools.<name>.<platform>`, read from the revision of the RUNNING gate
12
+ // (`git show <gate repository HEAD>:ci/rk-pipeline.json`, readGateRevisionPipeline), never the checked repository, the selected
13
+ // commit, the snapshot or the working tree. The enrolled record decides a tool it names; the fallback only fills the tools it does not.
14
+ //
15
+ // Never read here: an environment variable (apart from the managed data root's own location, which the caller resolves), a flag of the run, the
16
+ // checked repository's files, a receipt. A tool with no entry anywhere is not enrolled: deterministic-scans.mjs answers TOOL_UNTRUSTED.
17
+ // Platforms are those whose host may run the gate's class A: darwin, win32 and linux.
18
+ import crypto from "node:crypto";
19
+ import fs from "node:fs";
20
+ import path from "node:path";
21
+ import { fileURLToPath } from "node:url";
22
+ import { readGateRevisionPipeline } from "./broker-pins.mjs";
23
+ import { spawnSync } from "./background-process.mjs";
24
+
25
+ export const SCAN_TOOLS_SCHEMA = "rightkit.scan-tools/1";
26
+ export const SCAN_TOOLS_DIR = "scan-tools";
27
+ /** The analyzers class A accepts only from an enrolment (deterministic-scans.mjs TRUSTED_SCAN_TOOLS; kept in step by the tests). */
28
+ export const SCAN_TOOL_NAMES = Object.freeze(["actionlint", "shellcheck", "cargo-deny", "cargo-audit"]);
29
+ export const SCAN_TOOL_PLATFORMS = Object.freeze(["darwin", "win32", "linux"]);
30
+
31
+ export class ScanToolEnrolmentError extends Error {
32
+ constructor(code, message) { super(message); this.name = "ScanToolEnrolmentError"; this.code = code; }
33
+ }
34
+ const fail = (code, message) => { throw new ScanToolEnrolmentError(code, message); };
35
+ const SHA256 = /^[0-9a-f]{64}$/;
36
+ const isLink = (meta) => meta.isSymbolicLink();
37
+
38
+ /** The record of one platform under the managed data root. */
39
+ export const scanToolsFile = ({ dataRoot, platform = process.platform }) => {
40
+ if (!dataRoot || !path.isAbsolute(dataRoot)) fail("RK_SCAN_TOOLS_ROOT", "the managed data root must be an absolute path");
41
+ if (!SCAN_TOOL_PLATFORMS.includes(platform)) fail("RK_SCAN_TOOLS_PLATFORM", `${platform} is not a scan-tool host (${SCAN_TOOL_PLATFORMS.join(", ")} only)`);
42
+ return path.join(dataRoot, SCAN_TOOLS_DIR, `${platform}.json`);
43
+ };
44
+
45
+ /** `{path, sha256, version}` or a throw naming what is wrong: a trusted tool, an absolute path without `..`, a lowercase sha256, a version. */
46
+ export function validateScanTool(tool, entry, { platform = process.platform, label = "the scan-tool enrolment" } = {}) {
47
+ const bad = (why) => fail("RK_SCAN_TOOLS_INVALID", `${label}: ${tool}: ${why}`);
48
+ if (!SCAN_TOOL_NAMES.includes(tool)) fail("RK_SCAN_TOOLS_INVALID", `${label}: ${JSON.stringify(tool)} is not a trusted scan tool (${SCAN_TOOL_NAMES.join(", ")})`);
49
+ if (entry === null || typeof entry !== "object" || Array.isArray(entry)) bad("not an object");
50
+ const { path: file, sha256, version } = entry;
51
+ const paths = platform === "win32" ? path.win32 : path.posix;
52
+ if (typeof file !== "string" || !paths.isAbsolute(file) || file.split(/[\\/]/).includes("..") || file.includes("\0")) bad("path is not an absolute path without '..'");
53
+ if (typeof sha256 !== "string" || !SHA256.test(sha256)) bad("sha256 is not a sha256 in lowercase hex");
54
+ if (typeof version !== "string" || !version.trim() || version !== version.trim()) bad("version is missing");
55
+ return { path: file, sha256, version };
56
+ }
57
+
58
+ const digestOf = (file) => {
59
+ const hash = crypto.createHash("sha256"); const fd = fs.openSync(file, "r");
60
+ 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); }
61
+ return hash.digest("hex");
62
+ };
63
+
64
+ /** The enrolled record `{ platform, tools, source: "enrolled", file }`, or null when none exists. A link, a non-file, another platform's record, an unknown tool or a malformed entry is a refusal, never null. */
65
+ export function readEnrolledScanTools({ dataRoot, platform = process.platform }) {
66
+ const file = scanToolsFile({ dataRoot, platform });
67
+ let meta;
68
+ try { meta = fs.lstatSync(file); } catch (e) { if (e.code === "ENOENT") return null; throw e; }
69
+ if (isLink(meta) || !meta.isFile()) fail("RK_SCAN_TOOLS_INVALID", `${file} is not a regular file`);
70
+ const dir = fs.lstatSync(path.dirname(file));
71
+ if (isLink(dir) || !dir.isDirectory()) fail("RK_SCAN_TOOLS_INVALID", `${path.dirname(file)} is not a real directory`);
72
+ if (process.platform !== "win32" && (meta.mode & 0o022 || dir.mode & 0o022)) fail("RK_SCAN_TOOLS_INVALID", `${file} or its directory is writable by group or others`);
73
+ let record;
74
+ try { record = JSON.parse(fs.readFileSync(file, "utf8")); } catch (e) { fail("RK_SCAN_TOOLS_INVALID", `${file} is not JSON (${e.message})`); }
75
+ if (record?.schema !== SCAN_TOOLS_SCHEMA) fail("RK_SCAN_TOOLS_INVALID", `${file} is not a ${SCAN_TOOLS_SCHEMA} record`);
76
+ if (record.platform !== platform) fail("RK_SCAN_TOOLS_INVALID", `${file} is the record of ${record.platform}, not ${platform}`);
77
+ if (record.tools === null || typeof record.tools !== "object" || Array.isArray(record.tools)) fail("RK_SCAN_TOOLS_INVALID", `${file} has no tools object`);
78
+ const tools = {};
79
+ for (const [name, entry] of Object.entries(record.tools)) tools[name] = validateScanTool(name, entry, { platform, label: file });
80
+ return { platform, tools, source: "enrolled", file };
81
+ }
82
+
83
+ /**
84
+ * RightKit's own scan-tool pipeline entries, from the revision of the running gate: the `tools` object of `ci/rk-pipeline.json` there (raw: the
85
+ * caller turns it into a policy with deterministic-scans.mjs scanToolPolicyFromPipeline). Null when the gate is not run from a RightKit checkout
86
+ * or the file has no `tools`.
87
+ */
88
+ export function readGateRevisionScanTools({ gateFile, checkout, spawn = spawnSync, realpath }) {
89
+ const read = readGateRevisionPipeline({ gateFile, checkout, spawn, ...(realpath ? { realpath } : {}) });
90
+ if (!read || read.pipeline.tools === null || typeof read.pipeline.tools !== "object") return null;
91
+ return { tools: read.pipeline.tools, revision: read.revision };
92
+ }
93
+
94
+ /**
95
+ * The scan-tool policy a gate run hands runScans as `options.toolPolicy`: `{ policy, sources, enrolledFile, revision }` where `policy` is
96
+ * `{ [tool]: { path, sha256, version } }`, the enrolled tools over the running gate's own revision's, and `sources[tool]` says which decided
97
+ * (`"enrolled"` | `"rk-pipeline"`). `fromPipeline` is deterministic-scans.mjs scanToolPolicyFromPipeline (passed in so this module adds no
98
+ * import of the scan engine). An empty policy is a valid answer: each tool no one enrolled is then TOOL_UNTRUSTED where a scan needs it.
99
+ */
100
+ export function loadScanToolPolicy({ dataRoot, platform = process.platform, gateFile, checkout, spawn, realpath, fromPipeline }) {
101
+ if (typeof fromPipeline !== "function") fail("RK_SCAN_TOOLS_INVALID", "the pipeline policy reader is required (scanToolPolicyFromPipeline)");
102
+ // A platform that is not a scan-tool host has no enrolment to read: nothing is trusted there (every analyzer answers TOOL_UNTRUSTED).
103
+ if (!SCAN_TOOL_PLATFORMS.includes(platform)) return { policy: {}, sources: {} };
104
+ const enrolled = readEnrolledScanTools({ dataRoot, platform });
105
+ const fallback = gateFile && checkout ? readGateRevisionScanTools({ gateFile, checkout, spawn, realpath }) : null;
106
+ const policy = {}; const sources = {};
107
+ if (fallback) {
108
+ for (const [name, entry] of Object.entries(fromPipeline(fallback.tools, platform))) { policy[name] = entry; sources[name] = "rk-pipeline"; }
109
+ }
110
+ if (enrolled) for (const [name, entry] of Object.entries(enrolled.tools)) { policy[name] = entry; sources[name] = "enrolled"; }
111
+ return { policy, sources, ...(enrolled ? { enrolledFile: enrolled.file } : {}), ...(fallback ? { revision: fallback.revision } : {}) };
112
+ }
113
+
114
+ /**
115
+ * The explicit enrol operation, one tool per call. The file at `path` must exist, be a regular executable file and hash to `sha256` now
116
+ * (the operator's value is verified, never filled in); the record under the data root gains the tool. An already enrolled tool is replaced
117
+ * only with `rotate: true`. Returns `{ file, record, rotated }`.
118
+ */
119
+ export function enrollScanTool({ dataRoot, platform = process.platform, tool, path: toolPath, sha256, version, rotate = false, now = () => new Date() }) {
120
+ const file = scanToolsFile({ dataRoot, platform });
121
+ const entry = validateScanTool(tool, { path: toolPath, sha256, version }, { platform, label: "the enrolment" });
122
+ // The claim is judged against the bytes it names, on the host that enrols (a record for another platform cannot be checked from here).
123
+ if (platform !== process.platform) fail("RK_SCAN_TOOLS_PLATFORM", `${platform} tools are enrolled on a ${platform} host (this is ${process.platform}): the file's bytes cannot be checked from here`);
124
+ let real; let meta;
125
+ try { real = fs.realpathSync.native(entry.path); meta = fs.statSync(real); } catch (e) { fail("RK_SCAN_TOOLS_INVALID", `the ${tool} at ${entry.path} cannot be resolved (${e.code ?? e.message})`); }
126
+ if (!meta.isFile()) fail("RK_SCAN_TOOLS_INVALID", `the ${tool} at ${real} is not a regular file`);
127
+ if (platform !== "win32" && (meta.mode & 0o111) === 0) fail("RK_SCAN_TOOLS_INVALID", `the ${tool} at ${real} is not executable`);
128
+ const actual = digestOf(real);
129
+ if (actual !== entry.sha256) fail("RK_SCAN_TOOLS_DIGEST", `the ${tool} at ${real} has sha256 ${actual}, not the ${entry.sha256} being enrolled; nothing was written`);
130
+ const dir = path.dirname(file);
131
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
132
+ const dirMeta = fs.lstatSync(dir);
133
+ if (isLink(dirMeta) || !dirMeta.isDirectory()) fail("RK_SCAN_TOOLS_INVALID", `${dir} is not a real directory`);
134
+ let existing = null;
135
+ try { existing = readEnrolledScanTools({ dataRoot, platform }); } catch (e) { if (!rotate) throw e; }
136
+ const had = existing?.tools[tool] ?? null;
137
+ if (had && !rotate) fail("RK_SCAN_TOOLS_EXISTS", `${file} already enrolls ${tool} ${had.version} (sha256 ${had.sha256.slice(0, 12)}); replacing it is a rotation (pass rotate: true)`);
138
+ const record = { schema: SCAN_TOOLS_SCHEMA, platform, tools: { ...(existing?.tools ?? {}), [tool]: entry }, enrolledAt: now().toISOString() };
139
+ const tmp = `${file}.${crypto.randomUUID()}.tmp`;
140
+ fs.writeFileSync(tmp, `${JSON.stringify(record, null, 2)}\n`, { flag: "wx", mode: 0o600 });
141
+ try { fs.renameSync(tmp, file); } finally { fs.rmSync(tmp, { force: true }); }
142
+ return { file, record, rotated: Boolean(had) };
143
+ }
144
+
145
+ /** `node scan-tool-enrolment.mjs enroll --platform <darwin|win32|linux> --data-root <dir> --tool <name> --path P --sha256 D --version V [--rotate]` or `show --platform <p> --data-root <dir>`. */
146
+ export function main(argv, { out = (m) => process.stdout.write(`${m}\n`), err = (m) => process.stderr.write(`${m}\n`) } = {}) {
147
+ const [command, ...rest] = argv;
148
+ const opt = (name) => { const i = rest.indexOf(name); return i < 0 ? undefined : rest[i + 1]; };
149
+ try {
150
+ const dataRoot = opt("--data-root"); const platform = opt("--platform") ?? process.platform;
151
+ if (command === "enroll") {
152
+ const r = enrollScanTool({ dataRoot, platform, tool: opt("--tool"), path: opt("--path"), sha256: opt("--sha256"), version: opt("--version"), rotate: rest.includes("--rotate") });
153
+ const tool = opt("--tool");
154
+ out(`enrolled ${tool} ${r.record.tools[tool].version} for ${r.record.platform} (sha256 ${r.record.tools[tool].sha256}) in ${r.file}${r.rotated ? " (rotation)" : ""}`);
155
+ return 0;
156
+ }
157
+ if (command === "show") {
158
+ const enrolled = readEnrolledScanTools({ dataRoot, platform });
159
+ out(enrolled ? JSON.stringify(enrolled) : "no enrollment");
160
+ return enrolled ? 0 : 1;
161
+ }
162
+ err(`usage: scan-tool-enrolment.mjs enroll|show --platform <darwin|win32|linux> --data-root <dir> [--tool <${SCAN_TOOL_NAMES.join("|")}> --path P --sha256 D --version V [--rotate]]`);
163
+ return 2;
164
+ } catch (e) { err(`${e.code ?? "error"}: ${e.message}`); return 1; }
165
+ }
166
+
167
+ if (process.argv[1] && (() => { try { return fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url)); } catch { return false; } })()) process.exit(main(process.argv.slice(2)));