@rightkit/release 0.6.6 → 0.6.8

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 (46) hide show
  1. package/README.md +132 -10
  2. package/admission-files.mjs +3 -2
  3. package/advisory-db.json +4 -0
  4. package/advisory-db.mjs +124 -0
  5. package/advisory-exceptions.mjs +13 -10
  6. package/app-check.mjs +257 -38
  7. package/bin/darwin/rightkit-swap +0 -0
  8. package/bin/rightkit-agent-hook.mjs +9 -0
  9. package/bin/rightkit-gate.mjs +524 -345
  10. package/build-release.mjs +13 -6
  11. package/cli/right-release.mjs +29 -6
  12. package/compile-inputs.mjs +66 -0
  13. package/consumer-contracts.json +1044 -0
  14. package/consumer-contracts.mjs +627 -0
  15. package/consumer-input-contracts.mjs +422 -0
  16. package/consumer-policy.mjs +588 -0
  17. package/consumer-source-contracts.mjs +469 -0
  18. package/deny-default.toml +42 -0
  19. package/deterministic-scans.mjs +240 -29
  20. package/disk-command.mjs +270 -30
  21. package/disk-policy.json +50 -10
  22. package/disk-policy.mjs +482 -35
  23. package/disk-report.mjs +129 -0
  24. package/door-policy.mjs +228 -0
  25. package/door.mjs +23 -0
  26. package/gate-executor.mjs +30 -0
  27. package/gate-stages.mjs +110 -0
  28. package/github-updater-release.mjs +48 -8
  29. package/init-command.mjs +41 -0
  30. package/legion-semantic-contracts.mjs +428 -0
  31. package/ownership-class-rules.mjs +277 -0
  32. package/ownership-key-authority.mjs +3 -1
  33. package/ownership-mandatory-set.mjs +3 -2
  34. package/package.json +11 -3
  35. package/publish-cargo.mjs +5 -2
  36. package/publish-update.mjs +14 -11
  37. package/release-config.mjs +51 -6
  38. package/rightkit-versions.json +172 -167
  39. package/shell-contract-bridge.json +251 -0
  40. package/shell-contract.mjs +1069 -0
  41. package/sign-updater.mjs +24 -4
  42. package/source-closure-rules.mjs +204 -0
  43. package/stage-sidecar-command.mjs +2 -1
  44. package/stage-sidecar.mjs +437 -114
  45. package/suite-api.generated.mjs +96 -6
  46. package/suite-trust-roots.mjs +198 -0
package/sign-updater.mjs CHANGED
@@ -1,25 +1,31 @@
1
1
  #!/usr/bin/env node
2
- import { existsSync, rmSync } from "node:fs";
2
+ import { existsSync, readFileSync, rmSync } from "node:fs";
3
3
  import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { signMacUpdaterFiles } from "./macos-signing-session.mjs";
6
6
  import { refuseManagedEnvSecrets } from "./managed-host-secrets.mjs";
7
7
  import { execWithUpdaterCredential } from "./protected-credentials.mjs";
8
8
  import { resolvePnpmCli } from "./development.mjs";
9
+ import { UPDATER_KEY_ID_RE, updaterSignatureKeyId } from "./github-updater-release.mjs";
9
10
 
10
11
  export const UPDATER_SIGNING_SECRET = "updater-signing-key";
11
12
 
12
13
  export function runSignUpdater(args, {
13
14
  env = process.env, platform = process.platform, cwd = process.cwd(),
14
15
  execCredential = execWithUpdaterCredential, resolvePnpm = resolvePnpmCli,
15
- signMac = signMacUpdaterFiles, exists = existsSync, remove = rmSync, log = console.log,
16
+ signMac = signMacUpdaterFiles, exists = existsSync, remove = rmSync, read = (file) => readFileSync(file, "utf8"), log = console.log,
16
17
  } = {}) {
17
18
  const files = [];
18
19
  let dryRun = false;
20
+ let keyId;
19
21
  for (let i = 0; i < args.length; i++) {
20
22
  if (args[i] === "--file") {
21
23
  if (!args[i + 1] || args[i + 1].startsWith("--")) throw new Error("--file requires an artifact path");
22
24
  files.push(path.resolve(cwd, args[++i]));
25
+ } else if (args[i] === "--key-id") {
26
+ if (keyId !== undefined) throw new Error("--key-id may be given only once");
27
+ if (!UPDATER_KEY_ID_RE.test(args[i + 1] ?? "")) throw new Error("--key-id requires the 16 upper-case hex digits of the updater key id");
28
+ keyId = args[++i];
23
29
  } else if (args[i] === "--key-file") throw new Error("--key-file is removed: updater keys come from the OS credential provider");
24
30
  else if (args[i] === "--dry-run") dryRun = true;
25
31
  else throw new Error(`unknown argument: ${args[i]}`);
@@ -38,9 +44,19 @@ export function runSignUpdater(args, {
38
44
  for (const key of Object.keys(signingEnv)) {
39
45
  if (["TAURI_SIGNING_PRIVATE_KEY", "TAURI_SIGNING_PRIVATE_KEY_PASSWORD"].includes(key.toUpperCase())) delete signingEnv[key];
40
46
  }
47
+ // The credential store holds whichever key was last provisioned, so the signature, not the request, says what signed the file.
48
+ const assertSignedByExpectedKey = (file) => {
49
+ if (keyId === undefined) return;
50
+ let found = null;
51
+ try { found = updaterSignatureKeyId(read(`${file}.sig`)); } catch { /* an unreadable signature is refused below */ }
52
+ if (found !== keyId) throw new Error(`updater signature ${file}.sig was not made by updater key ${keyId} (found ${found ?? "unreadable"})`);
53
+ };
41
54
  for (const file of files) {
42
55
  log(`${dryRun ? "[dry-run] " : ""}pnpm exec tauri signer sign ${file}`);
43
- if (dryRun) continue;
56
+ if (dryRun) {
57
+ if (keyId !== undefined) log(`[dry-run] ${file}.sig must be made by updater key ${keyId}`);
58
+ continue;
59
+ }
44
60
  remove(`${file}.sig`, { force: true });
45
61
  if (platform === "darwin") continue;
46
62
  const result = execCredential(process.execPath,
@@ -51,8 +67,12 @@ export function runSignUpdater(args, {
51
67
  error.exitCode = result.status ?? 1;
52
68
  throw error;
53
69
  }
70
+ assertSignedByExpectedKey(file);
71
+ }
72
+ if (!dryRun && platform === "darwin") {
73
+ signMac(files, { env: signingEnv, cwd });
74
+ for (const file of files) assertSignedByExpectedKey(file);
54
75
  }
55
- if (!dryRun && platform === "darwin") signMac(files, { env: signingEnv, cwd });
56
76
  return { files, dryRun };
57
77
  }
58
78
 
@@ -0,0 +1,204 @@
1
+ // Sealed source-closure freshness for the app check. A committed qualification record that claims "these source files, at these
2
+ // digests, produced me" is stale the moment one of those files changes: the claim no longer describes the tree it ships in. Legion's
3
+ // qualification/provider-accuracy/index.json failed exactly so, only after a full Windows build (Orthic-Labs/legion runs 40149636641
4
+ // job 121038474276 and 40158717069 job 121065535787: "AssertionError: source closure stale/incomplete"). This rule reads the same
5
+ // data before any build, as a trusted JSON adapter: no repository code, no network, no clock.
6
+ //
7
+ // source-closure SF-01 in any committed *.json, an object { sourceClosure: { sources: { "<path>": "sha256:<hex>" }, digest } }
8
+ // (a sealed record) where a listed source is missing from the tree, its bytes no longer hash to the recorded
9
+ // digest, or `digest` is not sha256 over the canonical JSON of `sources` (sorted keys, no spaces, UTF-8).
10
+ //
11
+ // What it cannot see: a source the producer would now ADD to the closure. The closure's membership is computed by the producing
12
+ // repository's own code, which a Class A rule never runs; that half stays with the repository's own gate after the build.
13
+ //
14
+ // A digest a producer computes over a projection instead of the raw bytes is declared in .rightkit/source-closures.json by NAME
15
+ // only: { "schema": 1, "normalized": [ { "file": "<sealed json>", "transform": "<identity>", "paths"?: [...] } ] }. The identities
16
+ // are RightKit's (APPROVED_TRANSFORMS below), never the repository's: each binds the exact source path and record families it
17
+ // applies to, the keys it drops, and an input schema of the producer's real semantics, so a field the schema does not know is
18
+ // refused rather than dropped. The rule applies the transform to the CURRENT bytes and compares with the sealed digest: a
19
+ // deleted source fails, a change outside the dropped keys fails, input outside the schema fails. A repository never supplies
20
+ // pointers; an unknown identity, or a declaration that brings its own transform, is a finding and the path is compared raw.
21
+ import { createHash } from "node:crypto";
22
+ import fs from "node:fs";
23
+ import path from "node:path";
24
+
25
+ export const SOURCE_CLOSURE_DECLARATION = ".rightkit/source-closures.json";
26
+ const DIGEST = /^sha256:[0-9a-f]{64}$/;
27
+ const SKIPPED_TREE = /(^|\/)(node_modules|vendor|target)\//;
28
+ const MAX_BYTES = 16 * 1024 * 1024;
29
+
30
+ const sha256 = (bytes) => `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
31
+ const isPlain = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
32
+
33
+ /** JSON with every object's keys sorted, no whitespace, non-ASCII kept: Python's json.dumps(sort_keys=True, ensure_ascii=False, separators=(",", ":")). */
34
+ export function canonicalJson(value) {
35
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
36
+ if (isPlain(value)) return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(",")}}`;
37
+ return JSON.stringify(value);
38
+ }
39
+
40
+ /** A relative, forward-slash, non-escaping path; anything else is never read. */
41
+ export function safeRelative(value) {
42
+ if (typeof value !== "string" || !value || value.length > 240 || value.includes("\\") || value.includes(":") || value.startsWith("/")) return false;
43
+ return value.split("/").every((part) => part && part !== "." && part !== "..");
44
+ }
45
+
46
+ /** Every sealed record in a parsed JSON value, depth first: [{ pointer, record }]. */
47
+ export function* sealedRecords(node, pointer = "") {
48
+ if (Array.isArray(node)) { for (let i = 0; i < node.length; i++) yield* sealedRecords(node[i], `${pointer}/${i}`); return; }
49
+ if (!isPlain(node)) return;
50
+ const closure = node.sourceClosure;
51
+ if (isPlain(closure) && isPlain(closure.sources) && "digest" in closure) yield { pointer, record: node };
52
+ for (const [key, value] of Object.entries(node)) if (key !== "sourceClosure") yield* sealedRecords(value, `${pointer}/${key.replace(/~/g, "~0").replace(/\//g, "~1")}`);
53
+ }
54
+
55
+ // Legion's provider registry, as its producers read it before digesting (Orthic-Labs/legion, scripts/ci). A provider's benchmark
56
+ // holds exactly these keys today (src/registry/providers.json at cfe76b4 and d27a3d4); a new key is refused, not dropped.
57
+ const LEGION_BENCHMARK_KEYS = Object.freeze(["status", "requiredForCleanClaim", "qualificationDigest", "qualification"]);
58
+ const legionRegistryProblems = (value) => {
59
+ if (!isPlain(value) || !Array.isArray(value.providers)) return "the registry is not an object with a providers array";
60
+ for (const [i, provider] of value.providers.entries()) {
61
+ if (!isPlain(provider)) return `providers[${i}] is not an object`;
62
+ if (!isPlain(provider.benchmark)) return `providers[${i}].benchmark is not an object`;
63
+ const unknown = Object.keys(provider.benchmark).filter((k) => !LEGION_BENCHMARK_KEYS.includes(k));
64
+ if (unknown.length) return `providers[${i}].benchmark has key(s) ${unknown.join(", ")} the approved projection does not know`;
65
+ }
66
+ return null;
67
+ };
68
+
69
+ /**
70
+ * The approved transform identities. `path` is the one source each applies to (null: the declaration names text paths, for the
71
+ * lossless line-ending identity only); `families` the record `family` values it applies to (null: any); `schema` refuses input
72
+ * outside the producer's semantics; `project` is the producer's projection.
73
+ */
74
+ export const APPROVED_TRANSFORMS = Object.freeze({
75
+ // validate-compatibility-report-accuracy.py:104-111, audit-jvm-sdk-accuracy.py:84-92, audit-reasoning-runtime-registration.py:301-308:
76
+ // each pops providers[].benchmark whole and digests the canonical JSON of the rest.
77
+ "legion-provider-registry/without-benchmark/v1": {
78
+ path: "src/registry/providers.json", families: ["compatibility-report", "code-jvm-sdk", "reasoning-runtime"], json: true,
79
+ schema: legionRegistryProblems,
80
+ project: (v) => ({ ...v, providers: v.providers.map(({ benchmark: _dropped, ...rest }) => rest) }),
81
+ },
82
+ // audit-mobile-native-enrollment.py:74-89 (registry_semantics, used by audit-mobile-sdk-accuracy.py:10-13) and
83
+ // audit-supply-chain-native-accuracy.py:68-73,94-96 (semantics): each drops benchmark status, qualificationDigest and qualification
84
+ // and keeps requiredForCleanClaim.
85
+ "legion-provider-registry/without-qualification-metadata/v1": {
86
+ path: "src/registry/providers.json", families: ["code-mobile-sdk", "supply-chain-native"], json: true,
87
+ schema: legionRegistryProblems,
88
+ project: (v) => ({ ...v, providers: v.providers.map((p) => {
89
+ const { status: _s, qualificationDigest: _d, qualification: _q, ...benchmark } = p.benchmark;
90
+ return { ...p, benchmark };
91
+ }) }),
92
+ },
93
+ // CRLF -> LF only: it drops no field, so the declaration names the text paths.
94
+ "text-lf/v1": { path: null, families: null, json: false },
95
+ });
96
+
97
+ // The canonical JSON form must equal Python's; a non-integer number would print differently, so it is refused.
98
+ const numbersExact = (v) => Array.isArray(v) ? v.every(numbersExact) : isPlain(v) ? Object.values(v).every(numbersExact) : typeof v !== "number" || Number.isSafeInteger(v);
99
+
100
+ /** The digest of `bytes` under an approved identity: { digest } or { problem } when the input is outside the identity's schema. */
101
+ export function transformedDigest(bytes, identity) {
102
+ const transform = APPROVED_TRANSFORMS[identity];
103
+ if (!transform) return { problem: `transform "${identity}" is not an approved RightKit identity` };
104
+ if (!transform.json) return { digest: sha256(Buffer.from(bytes.toString("latin1").replace(/\r\n/g, "\n"), "latin1")) };
105
+ let value;
106
+ try { value = JSON.parse(bytes.toString("utf8")); } catch (error) { return { problem: `is not JSON (${error.message})` }; }
107
+ const problem = transform.schema(value) ?? (numbersExact(value) ? null : "holds a non-integer number, whose canonical form is not exact");
108
+ if (problem) return { problem: `${problem}; ${identity} refuses it` };
109
+ return { digest: sha256(Buffer.from(canonicalJson(transform.project(value)), "utf8")) };
110
+ }
111
+
112
+ /** Parse and validate the declaration. Returns { normalized: [...], problems: [string] }. */
113
+ export function readDeclaration(text) {
114
+ if (text === null) return { normalized: [], problems: [] };
115
+ let doc;
116
+ try { doc = JSON.parse(text); } catch (error) { return { normalized: [], problems: [`is not valid JSON (${error.message})`] }; }
117
+ const problems = [];
118
+ if (!isPlain(doc) || (doc.schema !== undefined && doc.schema !== 1) || !Array.isArray(doc.normalized ?? [])) return { normalized: [], problems: ['expected { "schema": 1, "normalized": [...] }'] };
119
+ const normalized = [];
120
+ (doc.normalized ?? []).forEach((item, index) => {
121
+ const bad = [];
122
+ if (!isPlain(item)) bad.push("is not an object");
123
+ else {
124
+ if (!safeRelative(item.file)) bad.push("file must be a safe relative path");
125
+ const approved = typeof item.transform === "string" ? APPROVED_TRANSFORMS[item.transform] : undefined;
126
+ if (!approved) bad.push(`transform must name an approved RightKit identity (${Object.keys(APPROVED_TRANSFORMS).join(", ")}); a repository never supplies its own transform or pointers`);
127
+ else if (approved.path !== null && (item.paths !== undefined || item.where !== undefined)) bad.push(`${item.transform} binds its own source and families; drop paths and where`);
128
+ else if (approved.path === null && (!Array.isArray(item.paths) || !item.paths.length || !item.paths.every(safeRelative))) bad.push(`${item.transform} needs paths: a non-empty list of safe relative paths`);
129
+ const unknownKeys = isPlain(item) ? Object.keys(item).filter((k) => !["file", "transform", "paths", "where"].includes(k)) : [];
130
+ if (unknownKeys.length) bad.push(`unknown key(s) ${unknownKeys.join(", ")}`);
131
+ }
132
+ if (bad.length) problems.push(`normalized #${index}: ${bad.join("; ")}`);
133
+ else normalized.push(item);
134
+ });
135
+ return { normalized, problems };
136
+ }
137
+
138
+ const labelOf = (record, pointer) => {
139
+ for (const key of ["provider", "id", "name"]) if (typeof record[key] === "string" && record[key]) return `${record[key]} (${pointer || "/"})`;
140
+ return pointer || "/";
141
+ };
142
+
143
+ /** The source-closure rule. */
144
+ export function sourceClosure({ root, files }) {
145
+ const tracked = new Set(files ?? []);
146
+ const out = [];
147
+ const declarationText = tracked.has(SOURCE_CLOSURE_DECLARATION) ? fs.readFileSync(path.join(root, SOURCE_CLOSURE_DECLARATION), "utf8") : null;
148
+ const declaration = readDeclaration(declarationText);
149
+ for (const problem of declaration.problems) out.push({ file: SOURCE_CLOSURE_DECLARATION, subject: "declaration", problem: `${SOURCE_CLOSURE_DECLARATION} ${problem}`, fix: "repair the declaration; an unreadable exemption exempts nothing" });
150
+ const hashes = new Map();
151
+ // { digest } | { problem } | null (not in the tree), per source and identity.
152
+ const hashOf = (rel, identity = null) => {
153
+ const key = `${rel}\0${identity ?? ""}`;
154
+ if (!hashes.has(key)) {
155
+ const bytes = tracked.has(rel) ? fs.readFileSync(path.join(root, rel)) : null;
156
+ hashes.set(key, bytes === null ? null : identity ? transformedDigest(bytes, identity) : { digest: sha256(bytes) });
157
+ }
158
+ return hashes.get(key);
159
+ };
160
+ for (const file of [...tracked].filter((f) => f.endsWith(".json") && !SKIPPED_TREE.test(f)).sort()) {
161
+ const full = path.join(root, file);
162
+ let stat;
163
+ try { stat = fs.statSync(full); } catch { continue; }
164
+ if (!stat.isFile() || stat.size > MAX_BYTES) continue;
165
+ const text = fs.readFileSync(full, "utf8");
166
+ if (!text.includes('"sourceClosure"')) continue;
167
+ let doc;
168
+ try { doc = JSON.parse(text); } catch { continue; } // json-parse rules own syntax
169
+ for (const { pointer, record } of sealedRecords(doc)) {
170
+ const label = labelOf(record, pointer);
171
+ const { sources, digest } = record.sourceClosure;
172
+ const transforms = new Map();
173
+ for (const n of declaration.normalized.filter((d) => d.file === file)) {
174
+ const approved = APPROVED_TRANSFORMS[n.transform];
175
+ if (approved.path === null) { for (const p of n.paths) transforms.set(p, n.transform); continue; }
176
+ if (approved.families.includes(record.family)) transforms.set(approved.path, n.transform);
177
+ }
178
+ const problems = [];
179
+ const malformed = Object.entries(sources).filter(([rel, value]) => !safeRelative(rel) || !DIGEST.test(value)).map(([rel]) => rel);
180
+ if (malformed.length) problems.push(`unsafe path or malformed digest: ${malformed.slice(0, 5).join(", ")}`);
181
+ const missing = [], stale = [], refused = [];
182
+ for (const [rel, value] of Object.entries(sources)) {
183
+ if (malformed.includes(rel)) continue;
184
+ const identity = transforms.get(rel) ?? null;
185
+ const actual = hashOf(rel, identity);
186
+ if (actual === null) missing.push(rel);
187
+ else if (actual.problem) refused.push(`${rel} ${actual.problem}`);
188
+ else if (actual.digest !== value) stale.push(identity ? `${rel} (under ${identity})` : rel);
189
+ }
190
+ if (refused.length) problems.push(`${refused.length} source(s) outside their approved transform: ${refused.slice(0, 3).join("; ")}`);
191
+ if (missing.length) problems.push(`${missing.length} source(s) no longer in the tree: ${missing.slice(0, 5).join(", ")}${missing.length > 5 ? ", ..." : ""}`);
192
+ if (stale.length) problems.push(`${stale.length} source(s) changed since the record was sealed: ${stale.slice(0, 5).join(", ")}${stale.length > 5 ? ", ..." : ""}`);
193
+ if (digest !== sha256(Buffer.from(canonicalJson(sources), "utf8"))) problems.push("sourceClosure.digest is not sha256 over the canonical JSON of sourceClosure.sources");
194
+ if (problems.length) out.push({
195
+ file, subject: label,
196
+ problem: `${file} ${label}: sealed source closure stale/incomplete: ${problems.join("; ")}`,
197
+ fix: "re-run the qualification that produced this record on the exact source and commit its output (never rewrite recorded digests by hand); or revert the source change",
198
+ });
199
+ }
200
+ }
201
+ return out;
202
+ }
203
+
204
+ export const SOURCE_CLOSURE_RULES = Object.freeze([{ id: "source-closure", gap: "SF-01", run: sourceClosure }]);
@@ -60,7 +60,8 @@ async function main() {
60
60
  return;
61
61
  }
62
62
  const target = resolveTargetTriple({ target: opts.target, cwd: appRoot });
63
- const result = await stageSidecars({ appRoot, manifest: config.sidecars, target });
63
+ // Warnings (a never-run row, a catalog row that is not staged) go to stderr; stdout stays the staged-file list or the --json document.
64
+ const result = await stageSidecars({ appRoot, manifest: config.sidecars, target, deps: { warn: (line) => console.error(line) } });
64
65
  if (opts.json) console.log(JSON.stringify(result, null, 2));
65
66
  else {
66
67
  for (const file of result.files ?? []) console.log(`right-release stage-sidecar: staged ${typeof file === "string" ? file : file.path ?? JSON.stringify(file)}`);