@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
package/README.md CHANGED
@@ -293,13 +293,53 @@ tests: {
293
293
  }
294
294
  ```
295
295
 
296
- `right-release test` runs every suite in order and stops at the first failure, exiting with that suite's code; each suite prints
296
+ `right-release test` runs every suite in order, collects every reachable failure (see below) and exits with the first failing suite's code; each suite prints
297
297
  a JSON line before and after (`{"event":"e2e_suite_completed","suite":"tooling","exitCode":0,"durationMs":1234}`). `--suite a,b`
298
298
  (repeatable) selects suites; a group name such as `journeys` selects all its chunks. `--runner vitest -- run --project unit`
299
+ `right-release test` collects every reachable failure instead of stopping at the first: a failed (or unstartable) suite does not hide an independent one, and the run ends non-zero. A suite may declare `requires: ["<earlier suite>"]`; it is skipped, with the reason in the output and an `_suite_skipped` event, when a required suite failed or was skipped. A `sign` step requires the suites it signs, and any other suite after a `sign` step is treated as a consumer of the signed output (nothing that may consume signed bytes runs after a sign step that failed); declare `requires: []` (or the suites it truly needs) to mark a suite independent. Cancellation (a signal) still stops the whole run. A sign step also exports `RIGHTKIT_QA_SIGNED_FILES`, a JSON object of every signed file's declared path to its signed copy.
299
300
  runs a runner once with the arguments after `--` (watch mode, one project); `-- <args>` after `--suite` is allowed when exactly
300
301
  one suite is selected. `--list [--json]` prints the resolved commands and runs nothing. Wrap it as the repository needs, for
301
302
  example `rustright storage run --app <app> -- right-release test`.
302
303
 
304
+ #### The `sign` step: one signing identity per product, every build (rightkit#1303)
305
+
306
+ The route probe (`--to <stage> --route`) judges JSON configs only; a legacy `right-release.config.mjs` is enforced at run time instead (the suite runner before any suite, the dev runner before a target builds). A successful sign step exports `RIGHTKIT_QA_APP_BINARY` and `RIGHTKIT_QA_SIGNING_EVIDENCE` (path of `signing-evidence.json`, kept until the stage ends). On Windows a missing silent Azure token refuses first with `SIGN_WINDOWS_TOKEN_UNAVAILABLE`.
307
+
308
+ A suite that builds a product (a `tauri build` or `tauri bundle`, or a `cargo build` of the Tauri package or a sidecar package) must be followed by a `sign` step that names it, so a QA run exercises the code identity the release carries (macOS keys privacy grants to the designated requirement):
309
+
310
+ ```js
311
+ suites: [
312
+ { name: "build-app", cmd: "pnpm", args: ["exec", "tauri", "build", "--target", "aarch64-apple-darwin", "--no-bundle", "--", "--profile", "dev", "--locked"] },
313
+ // `from` names an EARLIER suite; `path` is relative to the output directory of that suite's carried receipt. The main binary is the file `mainBinaryName` names, else the first.
314
+ { name: "sign-app", sign: { files: [{ from: "build-app", path: "aarch64-apple-darwin/debug/heardright" }] } },
315
+ { name: "native-journeys", cmd: "rightkit-qa", args: ["run-tests", "--app", "heardright"] }, // runs with RIGHTKIT_QA_APP_BINARY = the signed copy
316
+ ],
317
+ ```
318
+
319
+ - **The identity is derived, never declared.** The product's `tauri.conf.json` `identifier` is the one id (a `tauri.<platform>.conf.json` that changes it is refused; the Mac `development.targets.mac.install.bundleId`, when set, must equal it; a dev or test Tauri `--config` that sets `identifier` is refused). A `sign` step takes no identity, identifier, team or thumbprint key. The Mac signs with the Developer ID Application identity of team `6KLGD3LLKF` through `resolveMacosDevelopmentSigning`, `signMacosEntries` and the signing agent (`runMacCodesignCommands`): there is no fallback to Apple Development or ad hoc signing. The main binary (the file named by `tauri.conf.json` `mainBinaryName` when it declares one, else the first file) is signed with `--identifier <bundle id>`; every other file is a sidecar signed with the identifier `resolveBundleIdentifier` gives it from `sign.identifiers` or `sign.identifierTemplate` (keyed `Contents/MacOS/<name>`; a sidecar the release signs with none is signed with no `--identifier`, as the release does, and must read back as the file name codesign derives). Windows signs through `sign-windows.mjs` (Azure Artifact Signing) and the identity is the signer subject `CN=Damned Ventures LLC`, never a thumbprint.
320
+ - **It runs in-process, in the gate's own session, outside the broker** (like QA: the signing agent, the Keychain and the Azure signer are the session's), and a `sign` step is never a carried step: `build-launch.mjs` refuses it on every route. It verifies the file is in the output directory of the named suite's verified receipts (a path that leaves it, by `..` or a link, is refused), copies it with its sha256 checked into a gate-managed directory outside the snapshot, the checkout and every broker output directory, signs the copy and never the receipt's file, and reads the signature back: Mac `Identifier`, `TeamIdentifier` and the designated requirement, which must equal the release-shaped `expectedDesignatedRequirement(bundleId, team)` (`assertStableDesignatedRequirement`; an unreadable, ad hoc or cdhash-bound requirement is refused too); Windows Authenticode with the signer subject.
321
+ - **Evidence, then the variable.** Only after every file is signed and read back does the step write its `signing` evidence (file, sha256 before and after, identifier, team or subject, requirement) to the carrier's ledger and an evidence file, print a `<prefix>_suite_signed` event, and set `RIGHTKIT_QA_APP_BINARY` to the main binary's signed copy for the suites after it (a step that signs only sidecars sets none). A failed step sets nothing and stops the run; its copies are removed when the run ends.
322
+ - **The gate refuses before it builds, on the Mac and the Dell** (`routePlan`, so `rightkit-gate --to test --route` answers the same; Linux and hosted CI are exempt): a suite that builds a product with no later `sign` step naming it (`RK_SUITE_UNSIGNED_BUILD`), `sign-qa-bundle` or `RIGHTKIT_DEV_SIGNING` ad hoc signing (`RK_SUITE_ADHOC_SIGNING`), an identifier override (`RK_SUITE_IDENTIFIER_OVERRIDE`), a Mac `install.bundleId` that is not the Tauri identifier (`RK_SUITE_IDENTIFIER_MISMATCH`) and a Windows development `install.kind: "tauri"` (`RK_DEV_WIN_UNSIGNED_INSTALL`). A suite that launches a product bin (a `cargo run`, or an integration test that reads `CARGO_BIN_EXE_<bin>`) needs an EARLIER sign step that names that executable: a `sign.files` entry whose `from` is the suite that builds the bin and whose `path` is `<triple>/<profile>/<bin>[.exe]`; a sign step that covers only another binary (the shell, while the test launches the engine) is refused with `RK_SUITE_LAUNCH_UNBOUND`.
323
+
324
+ `nativeHelpers` entries declare `package` (the app's own SwiftPM package directory, committed in the app) and `product`. `sharedPackage` and `RIGHTKIT_SWIFT_ROOT` are retired (rightkit#1302): a config that uses `sharedPackage` is refused (`NATIVE_HELPERS_INVALID`) with the replacement, `package: "<app SwiftPM package>"` and `product: "rightkit-cloudkit-helper"`, the shared helper pinned by URL and tag through that package's own `Package.swift`.
325
+
326
+ #### Windows development: `install.kind: "tauri-authenticode"`
327
+
328
+ The unsigned Windows `tauri` install kind is refused on the Dell. `tauri-authenticode` signs what it installs, in the release's order: `target.build` runs `tauri build --no-bundle` (a build that bundles, or names `--bundles`, is refused), then the bundle marker is patched (before signing), the exe and `install.sidecars` are signed and verified, the carried `tauri bundle --bundles nsis --debug` runs, the installer's payload is checked to embed the signed exe byte for byte, the installer is signed and verified, and only then is it installed and the installed exe verified.
329
+
330
+ ```json
331
+ "install": {
332
+ "kind": "tauri-authenticode",
333
+ "productName": "RightApp",
334
+ "targetTriple": "x86_64-pc-windows-msvc",
335
+ "manifestPath": "src-tauri/Cargo.toml",
336
+ "expectedInstalledPath": "{{localAppData}}/RightApp/rightapp.exe",
337
+ "sidecars": ["rightapp-engine.exe"]
338
+ }
339
+ ```
340
+
341
+ `sidecars` (optional) are paths relative to the build's output directory; no other key is accepted.
342
+
303
343
  ### `right-release build --web` (or `web-build`)
304
344
 
305
345
  ```js
@@ -490,6 +530,8 @@ enforcement. D4 is ratified; pending activation does not mean pending ratificati
490
530
 
491
531
  ## QA-only ad hoc bundle signing (`sign-qa-bundle`)
492
532
 
533
+ On the Mac and the Dell the gate refuses a gate-run suite or dev build whose config calls this command (`RK_SUITE_ADHOC_SIGNING`, rightkit#1303): a gate-run build is signed with the product's one identity (see the `sign` step in the declared test suites section).
534
+
493
535
  `right-release sign-qa-bundle --app <abs .app> --qa-root <abs dir> [--entitlements <abs plist>] [--out <evidence.json>]` ad hoc signs an assembled QA bundle (for example an Accessibility QA copy) that lives inside a QA scratch root. It exists because `codesign --force --deep --sign -` sent through the signing agent (SSH or non-Aqua session) is refused by design; those refusals are unchanged and this command never calls the agent.
494
536
 
495
537
  - macOS only. Refused before any command runs: a relative path, `/Applications` or `~/Applications` (lexical or resolved), a bundle whose real path is not inside the real QA root (links are resolved first), a QA root that is `/`, the home directory or an install location, and a target that is not a `.app` directory.
@@ -521,6 +563,12 @@ pnpm add -D @rightkit/release
521
563
  pnpm exec right-release deps --check
522
564
  ```
523
565
 
566
+ Refresh one Cargo.lock after adopting a newly published RightKit crate (rightkit#1305; no build, writes only that workspace's `Cargo.lock`, one request per pin, in the primary checkout):
567
+
568
+ ```sh
569
+ pnpm exec right-release deps --cargo-lock --manifest src-tauri/Cargo.toml --pin rightkit-qa=0.10.0 --pin rightkit-asr=0.4.0
570
+ ```
571
+
524
572
  ## MSIX target and Swift app type
525
573
 
526
574
  Swift (SwiftPM) apps are a first-class project type. `projectType: "swiftpm"` (config or target; auto-detected from a `Package.swift` with no Cargo manifest) replaces the cargo/rustc requirement with `swift`, and skips the Rust target-bridge, sccache and Perl checks. Tauri/Rust targets are unchanged. `makeappx` and `signtool` are required only when `targets.win.msix` is configured.
@@ -0,0 +1,165 @@
1
+ // The request environment of a carried adapter call (rustright#190; rustright-cli shims.rs adapter_environment, rustright-policy adapter.rs
2
+ // check_environment). A `rustright run <adapter> -- <args>` request carries exactly the variables named by RUSTRIGHT_ADAPTER_ENV (a comma
3
+ // list), each set in the client's own environment; the broker accepts a name only when the adapter's registration lists it in `env` and it is
4
+ // not reserved. The forwarded map is the request core's `env` (rustright-protocol attestation.rs request_core), so its values are bound into the
5
+ // attested request digest the gate recomputes (snapshot-request.mjs snapshotRequestCore).
6
+ //
7
+ // The door (build-launch.mjs) sets RUSTRIGHT_ADAPTER_ENV for a step from the env the step itself declares (`adapterEnvLaunch`); the carrier
8
+ // (snapshot-broker.mjs carryBrokerAdapter) reads back what the client will forward (`forwardedAdapterEnv`), with the client's own refusals.
9
+ import path from "node:path";
10
+
11
+ export const ADAPTER_ENV_VAR = "RUSTRIGHT_ADAPTER_ENV";
12
+
13
+ /**
14
+ * RA-08: the path of the transient Android signing-properties file (android-release.mjs ANDROID_SIGNING_PROPERTIES_ENV). It is a typed FILE handoff of the provisioned signer session (native-adapters.mjs
15
+ * `signingFileProblem`, `nativeSigningFileRefusal`), never an adapter request environment: no adapter registers it (it is `RIGHTKIT_`-prefixed, so reserved), the door refuses a step that declares it, the
16
+ * carrier's client never receives it, and `RUSTRIGHT_ADAPTER_ENV` never names it. Only the path exists in the environment of a direct host's own Gradle; the file holds the secrets.
17
+ */
18
+ export const SIGNING_FILE_ENV = "RIGHTKIT_ANDROID_SIGNING_PROPERTIES";
19
+ export const ADAPTER_ENV_SIGNING_FILE = "RK_ADAPTER_ENV_SIGNING_FILE";
20
+ /** Whether `key` names the signing-properties file handoff (case-insensitive, as RustRight compares). */
21
+ export const isSigningFileEnvKey = (key) => String(key).toUpperCase() === SIGNING_FILE_ENV;
22
+
23
+ export const ADAPTER_ENV_RESERVED = "RK_ADAPTER_ENV_RESERVED";
24
+ export const ADAPTER_ENV_UNSET = "RK_ADAPTER_ENV_UNSET";
25
+ export const ADAPTER_ENV_INVALID = "RK_ADAPTER_ENV_INVALID";
26
+
27
+ // rustright-policy adapter.rs RESERVED_ENV_KEYS and RESERVED_ENV_PREFIXES at rustright 7b24288, compared upper-case. The prefix list still holds
28
+ // RIGHTKIT_ (marked legacy, "removed by task L-1"): until RustRight drops it, a RIGHTKIT_* name is refused by the broker and cannot be registered.
29
+ const RESERVED_EXACT = new Set([
30
+ "PATH", "PATHEXT", "HOME", "USERPROFILE", "TMPDIR", "TMP", "TEMP", "SYSTEMROOT", "COMSPEC", "SHELL", "BASH_ENV", "ENV", "PYTHONPATH", "PYTHONHOME",
31
+ "PYTHONSTARTUP", "PYTHONUSERBASE", "PYTHONEXECUTABLE", "PYTHONINSPECT", "PYTHONSAFEPATH", "NODE_OPTIONS", "NODE_PATH", "RUBYOPT", "RUBYLIB", "GEM_HOME",
32
+ "GEM_PATH", "PERL5LIB", "PERL5OPT", "PERLLIB", "JAVA_TOOL_OPTIONS", "_JAVA_OPTIONS", "JDK_JAVA_OPTIONS", "CLASSPATH", "GOFLAGS", "GOROOT", "DOTNET_ROOT",
33
+ "DOTNET_STARTUP_HOOKS", "DOTNET_ADDITIONAL_DEPS", "ERL_FLAGS", "ERL_AFLAGS", "ERL_ZFLAGS", "ELIXIR_ERL_OPTIONS",
34
+ ]);
35
+ const RESERVED_PREFIXES = ["RUSTRIGHT_", "RIGHTKIT_", "LD_", "DYLD_", "CARGO", "RUST", "SCCACHE", "COR_", "CORECLR_", "DOTNET_HOST"];
36
+
37
+ /**
38
+ * GB-06 (BROKER-CONTRACT section 16): signing material never rides an adapter request. RustRight reserves `TAURI_SIGNING_*` (the empty value that suppresses signing included), the legacy
39
+ * `TAURI_PRIVATE_KEY` and `TAURI_KEY_PASSWORD` (adapter.rs RESERVED_ENV_PREFIXES): they cannot be registered under an adapter's `env` and a request that names one is refused
40
+ * (RK_ADAPTER_ENV_DENIED). Signing, the updater seal and their read-back stay in the provisioned signer session, so a carried Tauri build runs unsigned and RightKit seals outside the broker.
41
+ * `TAURI_DEBUG` and the other Tauri variables are not signing material.
42
+ */
43
+ export const SIGNING_ENV_PREFIXES = Object.freeze(["TAURI_SIGNING_", "TAURI_PRIVATE_KEY", "TAURI_KEY_PASSWORD"]);
44
+ export const ADAPTER_ENV_SIGNING = "RK_ADAPTER_ENV_SIGNING";
45
+ /** Whether `key` is signing material (case-insensitive, as RustRight compares). */
46
+ export const isSigningEnvKey = (key) => { const upper = String(key).toUpperCase(); return SIGNING_ENV_PREFIXES.some((prefix) => upper.startsWith(prefix)); };
47
+ /** `env` without any signing variable: what a carried call's client process receives (the client forwards only what RUSTRIGHT_ADAPTER_ENV names, but it never needs these to exist). */
48
+ export function withoutSigningEnv(env) {
49
+ const out = { ...env };
50
+ for (const key of Object.keys(out)) if (isSigningEnvKey(key) || isSigningFileEnvKey(key)) delete out[key];
51
+ return out;
52
+ }
53
+
54
+ /**
55
+ * The signed-artifact handoff (GB-05; adapter.rs QA_HANDOFF_ENV): the sign step's two outputs reach a consumer under exactly these two names. They are
56
+ * not secrets and not broker-owned, so the broker exempts exactly these two from the reserved RIGHTKIT_ prefix; they must be registered in the adapter's
57
+ * `env` and carry an absolute path with no `..`.
58
+ */
59
+ export const QA_HANDOFF_ENV = Object.freeze(["RIGHTKIT_QA_APP_BINARY", "RIGHTKIT_QA_SIGNING_EVIDENCE"]);
60
+ export const isQaHandoffKey = (key) => QA_HANDOFF_ENV.includes(String(key).toUpperCase());
61
+ export const ADAPTER_ENV_UNREGISTERED = "RK_ADAPTER_ENV_UNREGISTERED";
62
+ export const ADAPTER_ENV_HANDOFF_PATH = "RK_ADAPTER_ENV_HANDOFF_PATH";
63
+
64
+ /** Whether the broker refuses `key` in a request environment (adapter.rs is_reserved_env_key, case-insensitive; the QA handoff names are exempt). */
65
+ export function isReservedAdapterEnvKey(key) {
66
+ const upper = String(key).toUpperCase();
67
+ if (isQaHandoffKey(upper)) return false;
68
+ return upper === "" || upper.includes("=") || RESERVED_EXACT.has(upper) || RESERVED_PREFIXES.some((prefix) => upper.startsWith(prefix)) || isSigningEnvKey(upper);
69
+ }
70
+
71
+ /**
72
+ * The candidate registration manifest of the request-environment keys, per adapter (GB-04): the inventory of every non-reserved key the gate's planners
73
+ * send, by step. Each adapter's registration must list exactly these in its `env`; the installed registration must equal this manifest, and a key outside
74
+ * it refuses at planning, before any process. `pnpm` is the reference registration RustRight published (docs/contracts/adapter-argv.fixtures.json
75
+ * `adapter.env`); `node` takes the same keys (a step script reads the test knob and the signed handoff); `swift` takes none (the gate sends no environment
76
+ * to a SwiftPM job).
77
+ * RK_ISOLATED_CHECKOUT test knob of a broker-admitted test row (right-suite-contract.test.mjs, release-exports.test.mjs): "1" runs it from an isolated checkout
78
+ * RIGHTKIT_QA_APP_BINARY / RIGHTKIT_QA_SIGNING_EVIDENCE the signed-artifact handoff (GB-05), set by the sign step only
79
+ */
80
+ export const ADAPTER_ENV_MANIFEST = Object.freeze({
81
+ "rightkit-pnpm": Object.freeze(["RK_ISOLATED_CHECKOUT", ...QA_HANDOFF_ENV]),
82
+ "rightkit-node": Object.freeze(["RK_ISOLATED_CHECKOUT", ...QA_HANDOFF_ENV]),
83
+ "rightkit-swift": Object.freeze([]),
84
+ // GB-51: the native-tool adapters take no request environment (a step's own environment is refused before launch, naming the key).
85
+ "rightkit-xcodegen": Object.freeze([]), "rightkit-xcodebuild": Object.freeze([]), "rightkit-gradle": Object.freeze([]), "rightkit-cmake": Object.freeze([]),
86
+ });
87
+
88
+ /**
89
+ * Why the broker would refuse this request environment for an adapter whose registration lists `registered` (adapter.rs `check_environment`), or null: a
90
+ * reserved key (broker-controlled, loader or interpreter path), a signed-artifact handoff key whose value is not an absolute path with no `..`, and any key the
91
+ * registration does not list. One refusal names every unregistered key and the registered set; nothing is dropped silently.
92
+ */
93
+ export function adapterEnvRefusal(env, registered) {
94
+ const keys = Object.keys(env ?? {});
95
+ const reserved = keys.find((key) => isReservedAdapterEnvKey(key));
96
+ if (reserved !== undefined) return { code: ADAPTER_ENV_RESERVED, message: `environment key ${JSON.stringify(reserved)} is broker-controlled or redirects code loading, so no adapter request may carry it (reserved: RUSTRIGHT_*, RIGHTKIT_* except the two signed-artifact handoff names, CARGO*, RUST*, PATH, loader and interpreter-path variables)` };
97
+ const listed = new Set((registered ?? []).map(String));
98
+ for (const key of keys) {
99
+ const value = String(env[key]);
100
+ if (isQaHandoffKey(key) && listed.has(key) && !(isAbsolutePath(value) && !value.split(/[\\/]/).includes(".."))) return { code: ADAPTER_ENV_HANDOFF_PATH, message: `environment key ${JSON.stringify(key)} must be an absolute path with no '..' (the signed artifact's exact path)` };
101
+ }
102
+ const unregistered = keys.filter((key) => !listed.has(key));
103
+ if (unregistered.length === 0) return null;
104
+ return { code: ADAPTER_ENV_UNREGISTERED, message: `environment key(s) ${unregistered.map((k) => JSON.stringify(k)).join(", ")} are not registered for this adapter (registered: ${[...listed].sort().join(", ") || "none"}); register the key in the adapter's \`env\` (the candidate manifest is ADAPTER_ENV_MANIFEST) or remove it from the step; nothing is dropped to make the call pass` };
105
+ }
106
+
107
+ function fail(code, message) {
108
+ const error = new Error(`${code}: ${message}`);
109
+ error.code = code;
110
+ throw error;
111
+ }
112
+
113
+ const isAbsolutePath = (value) => path.isAbsolute(value) || path.posix.isAbsolute(value) || path.win32.isAbsolute(value);
114
+ const validName = (name) => typeof name === "string" && name !== "" && !/[,=\0\s]/.test(name);
115
+ const validValue = (value) => typeof value === "string" && !value.includes("\0");
116
+
117
+ /**
118
+ * What the door adds to a carried adapter step's launch environment for the env the step declares: `{}` for none (no RUSTRIGHT_ADAPTER_ENV at
119
+ * all), else RUSTRIGHT_ADAPTER_ENV naming exactly the step's variables, plus those variables with their values. A reserved name, a name that
120
+ * cannot ride a comma list, a declared name with no value, or a value that is not a string is refused with a coded error.
121
+ */
122
+ export function adapterEnvLaunch(stepEnv, { registered = null } = {}) {
123
+ // GB-06: signing material is never forwarded. A signing variable the step declares with a value is material and refuses the step at planning (the name, never the value, is reported); one the
124
+ // recipe sets EMPTY only suppresses signing in a direct session, carries nothing, and is not forwarded (the carried build runs unsigned; the seal is made outside the broker).
125
+ const declared = Object.keys(stepEnv ?? {});
126
+ const handoff = declared.find((name) => isSigningFileEnvKey(name));
127
+ if (handoff !== undefined) fail(ADAPTER_ENV_SIGNING_FILE, `step env ${JSON.stringify(handoff)} is the signing-properties file handoff: its path travels only as the typed signing-file handoff of a native job (native-adapters.mjs nativeSigningFileRefusal), never as an adapter request environment, and no adapter registers it`);
128
+ const material = declared.filter((name) => isSigningEnvKey(name) && String(stepEnv[name] ?? "") !== "");
129
+ if (material.length) fail(ADAPTER_ENV_SIGNING, `step env ${material.map((n) => JSON.stringify(n)).join(", ")} is signing material: it never rides an adapter request (RustRight reserves TAURI_SIGNING_*, TAURI_PRIVATE_KEY and TAURI_KEY_PASSWORD, RK_ADAPTER_ENV_DENIED); signing and the updater seal stay in the provisioned signer session, and a carried Tauri build runs unsigned`);
130
+ const droppedSigning = declared.filter((name) => isSigningEnvKey(name)).sort();
131
+ const names = declared.filter((name) => !isSigningEnvKey(name)).sort();
132
+ const launch = (value) => { if (droppedSigning.length) Object.defineProperty(value, "signingEnvDropped", { value: droppedSigning, enumerable: false }); return value; };
133
+ if (names.length === 0) return launch({});
134
+ for (const name of names) {
135
+ if (isReservedAdapterEnvKey(name)) fail(ADAPTER_ENV_RESERVED, `step env ${JSON.stringify(name)} is broker-controlled or redirects code loading, so no adapter request may carry it (reserved: RUSTRIGHT_*, RIGHTKIT_* except the two signed-artifact handoff names, CARGO*, RUST*, loader and interpreter-path variables)`);
136
+ if (!validName(name)) fail(ADAPTER_ENV_INVALID, `step env name ${JSON.stringify(name)} cannot be forwarded to an adapter`);
137
+ const value = stepEnv[name];
138
+ if (value === undefined || value === null) fail(ADAPTER_ENV_UNSET, `step env ${name} is declared but has no value`);
139
+ if (!validValue(String(value))) fail(ADAPTER_ENV_INVALID, `step env ${name} has a value that cannot be forwarded`);
140
+ }
141
+ // GB-04: judged against the adapter's registration (its `env`) before anything is launched; one refusal names every unregistered key.
142
+ if (registered !== null) {
143
+ const refusal = adapterEnvRefusal(Object.fromEntries(names.map((name) => [name, String(stepEnv[name])])), registered);
144
+ if (refusal) fail(refusal.code, refusal.message);
145
+ }
146
+ return launch({ [ADAPTER_ENV_VAR]: names.join(","), ...Object.fromEntries(names.map((name) => [name, String(stepEnv[name])])) });
147
+ }
148
+
149
+ /**
150
+ * The map the RustRight client forwards for a call run with `env` (shims.rs adapter_environment): each name in RUSTRIGHT_ADAPTER_ENV, its value
151
+ * from `env`. A reserved name, or a named variable that is not set, is refused (the client refuses the same two ways).
152
+ */
153
+ export function forwardedAdapterEnv(env) {
154
+ const raw = env?.[ADAPTER_ENV_VAR] ?? "";
155
+ const forwarded = {};
156
+ for (const name of String(raw).split(",").map((part) => part.trim()).filter(Boolean)) {
157
+ if (isSigningFileEnvKey(name)) fail(ADAPTER_ENV_SIGNING_FILE, `${ADAPTER_ENV_VAR} names ${name}, the signing-properties file handoff: its path is never forwarded as an adapter request environment`);
158
+ if (isSigningEnvKey(name)) fail(ADAPTER_ENV_SIGNING, `${ADAPTER_ENV_VAR} names ${name}, which is signing material and is never forwarded to an adapter (signing stays in the provisioned signer session)`);
159
+ if (isReservedAdapterEnvKey(name)) fail(ADAPTER_ENV_RESERVED, `${ADAPTER_ENV_VAR} names ${name}, which is broker-controlled and cannot be forwarded to an adapter`);
160
+ const value = env[name];
161
+ if (value === undefined) fail(ADAPTER_ENV_UNSET, `${ADAPTER_ENV_VAR} names ${name}, which is not set`);
162
+ forwarded[name] = String(value);
163
+ }
164
+ return forwarded;
165
+ }
package/android-ndk.mjs CHANGED
@@ -35,6 +35,8 @@ export function validateAndroidNdk(ndk, { root, kind = "release" } = {}) {
35
35
  throw new Error(`ndk.abis must be a non-empty list of distinct ABIs from ${ANDROID_ABIS.join(", ")}`);
36
36
  }
37
37
  if (!nonEmpty(ndk.jniLibs) || path.isAbsolute(ndk.jniLibs) || path.win32.isAbsolute(ndk.jniLibs)) throw new Error("ndk.jniLibs must be a relative path inside the app");
38
+ // The directory is emptied before every build and holds only generated .so files: a `.git` segment would aim that cleanup (and cargo ndk -o) into repository metadata.
39
+ if (String(ndk.jniLibs).split(/[\\/]/).some((part) => part.toLowerCase() === ".git")) throw new Error("ndk.jniLibs must not contain a .git path segment: the generated jniLibs directory is emptied before every build and never lives in repository metadata");
38
40
  const jniLibs = path.resolve(root, ndk.jniLibs);
39
41
  if (!inside(root, jniLibs) || path.basename(jniLibs) !== "jniLibs") throw new Error("ndk.jniLibs must name a generated directory called jniLibs inside the app (it is emptied before every build)");
40
42
  if (ndk.manifestPath !== undefined && (!nonEmpty(ndk.manifestPath) || path.isAbsolute(ndk.manifestPath) || path.win32.isAbsolute(ndk.manifestPath) || ndk.manifestPath.split(/[\\/]/).includes(".."))) {
@@ -58,7 +60,9 @@ export function androidNdkCommand(ndk, { profile = "release" } = {}) {
58
60
  const args = ["ndk"];
59
61
  if (ndk.apiLevel !== undefined) args.push("--platform", String(ndk.apiLevel));
60
62
  for (const abi of ndk.abis) args.push("-t", abi);
61
- args.push("-o", ndk.jniLibs, "build");
63
+ // GB-62 (GATE commit exactness; BR20): `--locked` goes at the INNER Cargo build boundary, right after `build`, where cargo-ndk hands its remaining
64
+ // arguments to Cargo; the outer ABI/API/output options stay as written (`--locked` before `build` would be cargo-ndk's own option to reject).
65
+ args.push("-o", ndk.jniLibs, "build", "--locked");
62
66
  if (profile === "release") args.push("--release");
63
67
  if (ndk.manifestPath) args.push("--manifest-path", ndk.manifestPath);
64
68
  if (ndk.package) args.push("-p", ndk.package);
@@ -24,7 +24,12 @@
24
24
  // on Windows and Linux) or from a file outside the repository (mode 0600 on POSIX), then written to a transient
25
25
  // 0600 `signing.properties` in a private temp directory. Gradle gets only the file's PATH, in
26
26
  // RIGHTKIT_ANDROID_SIGNING_PROPERTIES; the app's signingConfig reads storeFile/storePassword/keyAlias/keyPassword from it.
27
- // - The file is zeroed and removed when Gradle ends, on failure, and on process exit.
27
+ // - The file is zeroed and removed when Gradle ends, on failure, on cancellation (an AbortSignal, SIGINT, SIGTERM, SIGHUP) and on process exit. It lives in a
28
+ // private 0700 directory this lane created and owns (`tmpRoot`); signingFileProblem (native-adapters.mjs, the broker's own file rule) must accept it before Gradle sees its path, and a
29
+ // file that survives cleanup is a refusal of its own (ANDROID_SIGNING_CLEANUP_FAILED), never a warning.
30
+ // - Gradle runs through the door (release.mjs runCommand -> build-launch.mjs): the path is the typed `signingFile` handoff, never an environment of a broker job. A broker route has no
31
+ // request field that carries it yet (capability native-signing-file-v1, open policy: the signed Android release phase is OPEN and off), so `native.plan` is asked FIRST, before any
32
+ // secret is read or any file written, and the typed refusal (RK_NATIVE_SIGNING_FILE_UNSUPPORTED, or the grammar's refusal of bundle<Variant>Release) ends the lane. A direct host's own Gradle gets the path in its environment.
28
33
  //
29
34
  // After the build the AAB is read in-process (android-aab-scan.mjs): QA/debug markers in every .so, web-bundle mocks,
30
35
  // one upload-key signature whose certificate matches expectedCertSha256 (never the Android debug certificate). A receipt
@@ -36,9 +41,14 @@ import path from "node:path";
36
41
  import { spawnSync } from "./background-process.mjs";
37
42
  import { describeAabProblems, extractNativeLibs, scanAab } from "./android-aab-scan.mjs";
38
43
  import { runAndroidNdk, validateAndroidNdk } from "./android-ndk.mjs";
44
+ import { NATIVE_SIGNING_FILE_ENV, signingFileProblem } from "./native-adapters.mjs";
39
45
 
40
46
  export const ANDROID_SIGNING_CONTRACT = "android-aab-upload-key-v1";
41
- export const ANDROID_SIGNING_PROPERTIES_ENV = "RIGHTKIT_ANDROID_SIGNING_PROPERTIES";
47
+ export const ANDROID_SIGNING_PROPERTIES_ENV = NATIVE_SIGNING_FILE_ENV; // "RIGHTKIT_ANDROID_SIGNING_PROPERTIES": the one name, shared with the door
48
+ export const ANDROID_SIGNING_FILE_REFUSED = "ANDROID_SIGNING_FILE_REFUSED";
49
+ export const ANDROID_SIGNING_CLEANUP_FAILED = "ANDROID_SIGNING_CLEANUP_FAILED";
50
+ export const ANDROID_SIGNING_ROUTE_REFUSED = "ANDROID_SIGNING_ROUTE_REFUSED";
51
+ export const ANDROID_RELEASE_CANCELLED = "ANDROID_RELEASE_CANCELLED";
42
52
  export const ANDROID_RECEIPT_NAME = "android-aab.json";
43
53
  export const ANDROID_ENV_SECRET_REFUSED = "ANDROID_ENV_SECRET_REFUSED";
44
54
  export const ANDROID_SECRET_MISSING = "ANDROID_SECRET_MISSING";
@@ -245,6 +255,46 @@ export function createSigningProperties({ keystore, alias, storePassword, keyPas
245
255
  return { file, dir, cleanup };
246
256
  }
247
257
 
258
+ // ---- lifetime of the signing file ---------------------------------------------------------------------------------
259
+ // The file must not outlive the signing phase on any exit: normal end, a thrown step, process.exit() (release.mjs ends a failed step with it) or a signal. A signal handler suppresses the
260
+ // default action, so after the cleanup the handlers are removed and the signal is re-raised: the process still dies of the signal, with no secret file left behind.
261
+ const CANCEL_SIGNALS = process.platform === "win32" ? ["SIGINT", "SIGTERM"] : ["SIGINT", "SIGTERM", "SIGHUP"];
262
+ export function holdSigningFile(props, host = process) {
263
+ const signalHandlers = new Map();
264
+ const detach = () => {
265
+ host.removeListener("exit", onExit);
266
+ for (const [signal, handler] of signalHandlers) host.removeListener(signal, handler);
267
+ signalHandlers.clear();
268
+ };
269
+ const onExit = () => props.cleanup();
270
+ host.on("exit", onExit);
271
+ for (const signal of CANCEL_SIGNALS) {
272
+ const handler = () => { props.cleanup(); detach(); host.kill(host.pid, signal); };
273
+ signalHandlers.set(signal, handler);
274
+ host.on(signal, handler);
275
+ }
276
+ // release(): cleanup, then stop guarding. Returns true when nothing of the owned directory is left.
277
+ return () => { props.cleanup(); detach(); return !existsSync(props.dir); };
278
+ }
279
+
280
+ // Asks the production native runner (build-launch.mjs createNativeRunner) whether the door can carry this Gradle step WITH the signing-file handoff, before any secret is read.
281
+ // The probe path names a file that does not exist and is never created; plan() spawns nothing. Returns the launch, or { refused } with the door's typed reason.
282
+ export function planNativeSigningRoute({ native, gradle, root, tmpRoot }) {
283
+ if (!native) return null;
284
+ const probe = path.join(tmpRoot, "rightkit-android-sign-probe", "signing.properties");
285
+ let launch;
286
+ try { launch = native.plan(gradle.cmd, gradle.args, { cwd: path.resolve(root, gradle.cwd ?? "."), signingFile: probe }); }
287
+ catch (error) {
288
+ if (error?.code === "RK_BUILD_LAUNCH_REFUSED") return { refused: { code: String(error.reason ?? ANDROID_SIGNING_ROUTE_REFUSED), message: String(error.message) } };
289
+ throw error;
290
+ }
291
+ // A direct host launches Gradle itself: its environment must carry exactly the handoff path. A broker route that accepts the file carries it by its own typed capability.
292
+ if (!launch.native && launch.env?.[NATIVE_SIGNING_FILE_ENV] !== probe) {
293
+ return { refused: { code: ANDROID_SIGNING_ROUTE_REFUSED, message: `the door's launch of ${gradle.cmd} would not carry ${NATIVE_SIGNING_FILE_ENV}; the signing file is not handed over` } };
294
+ }
295
+ return { launch };
296
+ }
297
+
248
298
  // ---- verification, receipt and seal entry ------------------------------------------------------------------------
249
299
  const sha256File = (file) => createHash("sha256").update(readFileSync(file)).digest("hex");
250
300
 
@@ -284,12 +334,18 @@ export function verifyAndroidReceipt(receiptPath, aabFile) {
284
334
  export async function runAndroidRelease({
285
335
  root, app = "app", target, dryRun = false, env = process.env, platform = process.platform,
286
336
  runGradle, runNdk = runGradle, resolveArtifact = (file) => path.resolve(root, file), scanFiles, receiptDir,
287
- allowContentOnlyDevtools = false, credentialStore, tmpRoot = os.tmpdir(), now = () => new Date(), log = console.log,
337
+ allowContentOnlyDevtools = false, credentialStore, tmpRoot = os.tmpdir(), now = () => new Date(), log = console.log, native, signal,
288
338
  }) {
339
+ const cancelled = () => { if (signal?.aborted) refuse(ANDROID_RELEASE_CANCELLED, "the Android release was cancelled before the signing phase ended; no signing file remains"); };
340
+ cancelled();
289
341
  refuseAndroidEnvSecrets(env);
290
342
  const aab = resolveArtifact(target.aab.file);
291
343
  const { signing } = target;
344
+ // RA-08: the door's verdict on the signing-file handoff comes first, before any secret is read or any file or directory is created (a refusal here has nothing to clean up).
345
+ const route = planNativeSigningRoute({ native, gradle: target.gradle, root, tmpRoot });
346
+ if (route?.refused && !dryRun) refuse(route.refused.code, `${route.refused.message}; the signed Android release phase did not start: no secret was read and no signing file was written`);
292
347
  if (dryRun) {
348
+ if (route?.refused) log(`right-release: dry-run: the door refuses this Gradle step with the signing-file handoff (${route.refused.code}); a real run stops there before any secret is read`);
293
349
  log(`right-release: dry-run: android ${target.task} -> ${aab}; signing passwords would come from ${signing.storePassword.credential ? "the credential store" : "a file"}, never the environment`);
294
350
  if (target.ndk) await runAndroidNdk({ ndk: target.ndk, profile: "release", dryRun: true, run: (command) => runNdk(command, {}), log });
295
351
  await runGradle(target.gradle, {});
@@ -304,16 +360,25 @@ export async function runAndroidRelease({
304
360
  rmSync(aab, { force: true });
305
361
  // Native libraries first (no secret involved): cargo-ndk --release per ABI into the generated jniLibs directory.
306
362
  if (target.ndk) await runAndroidNdk({ ndk: target.ndk, profile: "release", run: (command) => runNdk(command, {}), log });
363
+ cancelled();
307
364
  const props = createSigningProperties({ keystore: signing.keystorePath, alias: signing.alias, storePassword, keyPassword, tmpRoot });
308
- const onExit = () => props.cleanup();
309
- process.on("exit", onExit); // release.mjs ends a failed step with process.exit(); the secret file must not outlive it
365
+ // From here the file is owned by this phase: exit, process.exit() and the cancelling signals all remove it (holdSigningFile).
366
+ const release = holdSigningFile(props);
367
+ let built = false;
310
368
  try {
369
+ // The broker's own file rule (signingFileProblem) must accept the file, inside this lane's tmpRoot, before Gradle is handed its path.
370
+ const shape = signingFileProblem(props.file, { signerRoot: tmpRoot });
371
+ if (shape) refuse(ANDROID_SIGNING_FILE_REFUSED, `the transient signing file is not acceptable (${shape.replaceAll(props.file, "<signing file>")}); Gradle was not started`);
372
+ cancelled();
311
373
  log(`right-release: android ${target.task} (signing properties passed by path only)`);
312
- await runGradle(target.gradle, { [ANDROID_SIGNING_PROPERTIES_ENV]: props.file });
374
+ await runGradle(target.gradle, { [ANDROID_SIGNING_PROPERTIES_ENV]: props.file }, ...(signal ? [{ signal }] : []));
375
+ built = true;
313
376
  } finally {
314
- props.cleanup();
315
- process.removeListener("exit", onExit);
377
+ const clean = release();
378
+ if (!clean && built) refuse(ANDROID_SIGNING_CLEANUP_FAILED, "the transient signing directory could not be removed after Gradle; the release is refused (the file holds only zeroes if the overwrite succeeded)");
379
+ if (!clean) log("right-release: WARNING: the transient signing directory could not be removed; remove it by hand (it holds no password if the overwrite succeeded)");
316
380
  }
381
+ cancelled();
317
382
  if (!existsSync(aab) || !statSync(aab).isFile()) refuse("ANDROID_AAB_MISSING", `Gradle finished but ${target.aab.file} was not produced`);
318
383
 
319
384
  const scan = scanAab(aab, { allowContentOnlyDevtools });
package/app-check.mjs CHANGED
@@ -1206,6 +1206,11 @@ export function exportCommit(repo, commit, dest, { batchBytes = BATCH_BYTES } =
1206
1206
  return entries.map((entry) => entry.path);
1207
1207
  }
1208
1208
 
1209
+ /** The repository paths of `commit` as `git ls-tree -r` lists them (files and submodule directories), without reading a blob or writing anything. */
1210
+ export function listCommitFiles(repo, commit) {
1211
+ return git(["ls-tree", "-r", "-z", "--name-only", commit], { cwd: repo }).split("\0").filter(Boolean);
1212
+ }
1213
+
1209
1214
  /**
1210
1215
  * The tree a compile of `repo` would see, written under `dest`: HEAD plus every tracked and untracked, non-ignored file as it is
1211
1216
  * on disk, put through a throwaway index (the repository's own index is never touched; the blobs land in its object store).
@@ -1368,7 +1373,7 @@ function loadBaseline({ explicit, root, central, repoKey, today }) {
1368
1373
  * `refuse` lists rules whose findings the baseline cannot excuse here, because the build refuses them whatever the baseline says
1369
1374
  * (BUILD_REFUSES); a listed one fails like a new one.
1370
1375
  */
1371
- export function runAppCheck({ repo, mode = "commit", commit, only = null, baseline, printBaseline = false, rules = RULES, tier = null, label = "check", repoKey, central = rightkitCheckout(), now = new Date(), refuse = [] } = {}) {
1376
+ export function runAppCheck({ repo, mode = "commit", commit, only = null, baseline, printBaseline = false, rules = RULES, tier = null, label = "check", repoKey, central = rightkitCheckout(), now = new Date(), refuse = [], snapshot = null } = {}) {
1372
1377
  const lines = [];
1373
1378
  const errLines = [];
1374
1379
  if (tier !== null && !TIERS.includes(tier)) { errLines.push(`${label}: --rule-tier must be compile or release, not ${JSON.stringify(tier)}`); return { findings: [], known: [], stale: [], expired: [], found: [], exitCode: 2, lines, text: "", errLines, errText: errLines.join("\n") }; }
@@ -1387,15 +1392,24 @@ export function runAppCheck({ repo, mode = "commit", commit, only = null, baseli
1387
1392
  const key = repoKey ?? (central && sameDirectory(repo, central) ? "rightkit" : path.basename(repo).toLowerCase());
1388
1393
  const started = Date.now();
1389
1394
  let checked, files, root;
1395
+ // `snapshot` (the gate's own directory holding exactly `commit`'s tree, verified by the caller) is read in place: no export, no copy of the
1396
+ // source, nothing written under the temporary directory, and the directory is never removed here (docs/GATE-CONTRACT.md, 2026-10-29).
1397
+ const inPlace = snapshot !== null && mode === "commit";
1390
1398
  try {
1391
- root = fs.mkdtempSync(path.join(os.tmpdir(), "rk-check-"));
1392
- if (mode === "worktree") ({ tree: checked, files } = exportWorktree(repo, root));
1393
- else { checked = resolveCommit(repo, commit ?? "HEAD"); files = exportCommit(repo, checked, root); }
1399
+ if (inPlace) {
1400
+ root = path.resolve(snapshot);
1401
+ checked = resolveCommit(repo, commit ?? "HEAD");
1402
+ files = listCommitFiles(repo, checked);
1403
+ } else {
1404
+ root = fs.mkdtempSync(path.join(os.tmpdir(), "rk-check-"));
1405
+ if (mode === "worktree") ({ tree: checked, files } = exportWorktree(repo, root));
1406
+ else { checked = resolveCommit(repo, commit ?? "HEAD"); files = exportCommit(repo, checked, root); }
1407
+ }
1394
1408
  } catch (error) {
1395
1409
  // HEAD is what both modes start from: a repository that has no commit yet has nothing to read, and "commit first" is the fix.
1396
1410
  const unborn = (mode === "worktree" || (commit ?? "HEAD") === "HEAD") && hasNoCommit(repo);
1397
1411
  errLines.push(unborn ? `${label}: the repository has no commit yet; commit first` : `${label}: could not export the commit to check: ${error.message}`);
1398
- if (root) fs.rmSync(root, { recursive: true, force: true });
1412
+ if (root && !inPlace) fs.rmSync(root, { recursive: true, force: true });
1399
1413
  return finish(2);
1400
1414
  }
1401
1415
  try {
@@ -1440,7 +1454,7 @@ export function runAppCheck({ repo, mode = "commit", commit, only = null, baseli
1440
1454
  lines.push(`${label}: ${checked.slice(0, 12)} passed ${ruleCount} rules (${tierLabel}) in ${seconds}s${known.length ? ` (${known.length} known finding(s) in ${listed.listedFile}; fix them, then remove their entries)` : ""}`);
1441
1455
  return finish(0, result);
1442
1456
  } finally {
1443
- fs.rmSync(root, { recursive: true, force: true });
1457
+ if (!inPlace) fs.rmSync(root, { recursive: true, force: true });
1444
1458
  }
1445
1459
  }
1446
1460
 
@@ -1602,7 +1616,7 @@ export function guardCommand({ args, cwd = process.cwd(), run = runAppCheck, sou
1602
1616
  * `--rule-tier` defaults to compile (see TIERS); `--list` prints every rule with its tier. (`--tier patch|update` is the upload tier, elsewhere.)
1603
1617
  * `--repo` defaults to the git top-level of `cwd` (or `defaultRepo`). Returns the exit code: 0 pass, 1 findings, 2 could not run.
1604
1618
  */
1605
- export async function main(argv, { out = (s) => process.stdout.write(`${s}\n`), err = (s) => process.stderr.write(`${s}\n`), label = "check", rules = RULES, defaultRepo = null, cwd = process.cwd(), central, now } = {}) {
1619
+ export async function main(argv, { out = (s) => process.stdout.write(`${s}\n`), err = (s) => process.stderr.write(`${s}\n`), label = "check", rules = RULES, defaultRepo = null, cwd = process.cwd(), central, now, snapshot = null } = {}) {
1606
1620
  const arg = (flag) => { const i = argv.indexOf(flag); return i >= 0 ? argv[i + 1] : undefined; };
1607
1621
  if (argv.includes("--list")) { for (const r of rules) out(`${r.id} (${r.gap}) ${tierOf(r)}`); return 0; }
1608
1622
  const tier = arg("--rule-tier") ?? "compile";
@@ -1615,7 +1629,7 @@ export async function main(argv, { out = (s) => process.stdout.write(`${s}\n`),
1615
1629
  only: arg("--only")?.split(",").filter(Boolean) ?? null,
1616
1630
  baseline: arg("--baseline"),
1617
1631
  printBaseline: argv.includes("--print-baseline"),
1618
- rules, tier, label, ...(central === undefined ? {} : { central }), ...(now ? { now } : {}),
1632
+ rules, tier, label, ...(central === undefined ? {} : { central }), ...(now ? { now } : {}), ...(snapshot ? { snapshot } : {}),
1619
1633
  });
1620
1634
  for (const line of result.lines) out(line);
1621
1635
  for (const line of result.errLines) err(line);