@rightkit/release 0.2.120 → 0.2.122

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/build-release.mjs CHANGED
@@ -239,6 +239,9 @@ try {
239
239
  if (preflightBlockers.length > 0) {
240
240
  fail(`preflight found ${preflightBlockers.length} blocking problem(s):\n${formatPreflight(preflightBlockers)}`);
241
241
  }
242
+ const ownershipEvidence = preflight.find((check) => check.name === "ownership")?.evidence;
243
+ if (!ownershipEvidence) fail("ownership evidence was not produced by release preflight");
244
+ if (ownershipEvidence.commit !== commit) fail("ownership scan source differs from release commit");
242
245
  for (const check of preflight) {
243
246
  if (check.status !== "ok") console.error(`[right-release] preflight ${check.name}: ${check.detail}`);
244
247
  }
@@ -379,7 +382,7 @@ try {
379
382
  });
380
383
  },
381
384
  seal: async ({ sealedDir }) => {
382
- sealRelease({ configRoot: appRoot, managedCargoTarget, nativeLayout, sealedDir, releaseId, config, target, platform, commit, inputHashes, toolVersions, cacheKey, signingIdentity });
385
+ sealRelease({ configRoot: appRoot, managedCargoTarget, nativeLayout, sealedDir, releaseId, config, target, platform, commit, inputHashes, toolVersions, cacheKey, signingIdentity, ownershipEvidence });
383
386
  verifySealedRelease(sealedDir);
384
387
  if (cacheMode === "shared") markCacheEntrySuccessful({ layout: sharedLayout });
385
388
  checkpoint(stateRoot, "sealed");
@@ -419,7 +422,7 @@ function sqlCipherFeatures(text) {
419
422
  .filter((value) => /sqlcipher|openssl/i.test(value));
420
423
  }
421
424
 
422
- function sealRelease({ configRoot, managedCargoTarget, nativeLayout, sealedDir, releaseId, config, target, platform, commit, inputHashes, toolVersions, cacheKey, signingIdentity }) {
425
+ function sealRelease({ configRoot, managedCargoTarget, nativeLayout, sealedDir, releaseId, config, target, platform, commit, inputHashes, toolVersions, cacheKey, signingIdentity, ownershipEvidence }) {
423
426
  // Every app's right-release.config.mjs names installer/updater artifacts
424
427
  // relative to its resolved native target. On a broker-managed host that
425
428
  // directory is never populated (the broker owns CARGO_TARGET_DIR and
@@ -482,6 +485,7 @@ function sealRelease({ configRoot, managedCargoTarget, nativeLayout, sealedDir,
482
485
  app: config.app,
483
486
  version: config.version,
484
487
  commit,
488
+ ownershipEvidence,
485
489
  platform,
486
490
  cacheKey,
487
491
  // Portable contracts sign only raw executables — installer/embedding
@@ -23,6 +23,8 @@ const commands = new Map([
23
23
  ["github", "github-release.mjs"],
24
24
  ["cargo-target", "cargo-target.mjs"],
25
25
  ["ownership", "ownership-command.mjs"],
26
+ ["ownership-approval", "ownership-approval-command.mjs"],
27
+ ["ownership-report", "ownership-report-command.mjs"],
26
28
  ]);
27
29
 
28
30
  const args = process.argv.slice(2);
@@ -171,6 +173,16 @@ Commands:
171
173
  doctor --all Verify all Right Suite app release contracts
172
174
  suite-doctor Verify all local Right Suite repositories
173
175
  ownership [--app <dir>] [--json] Fail on app copies of RightKit-owned capabilities
176
+ ownership enroll [--root] [--name <device>] [--root-fingerprint <sha256>] [--app <dir>]
177
+ Enroll one native key or create a signed-device request
178
+ ownership approve-key <fingerprint|request.json> Approve one pending device key from an active host
179
+ ownership revoke-key <fingerprint> Revoke one active device key
180
+ ownership-approval --draft <input.json> [--out <draft.json>]
181
+ Draft an exception for native owner approval
182
+ ownership-approval --verify <signed.json> --context <scan.json>
183
+ Verify exact signed exception coverage
184
+ ownership-report --workspace <dir> [--markdown <file>] [--json-out <file>]
185
+ Report cross-app ownership & pending requests
174
186
  cache status [--json] Inspect the shared local build cache
175
187
  cache prune [--dry-run|--apply] [--json] Plan or apply safe shared-target pruning
176
188
  cache migrate --config <file> [--dry-run|--apply] [--json]
@@ -0,0 +1,200 @@
1
+ import { createHash } from "node:crypto";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { spawnSync } from "./background-process.mjs";
5
+
6
+ const MAX_TREE_BYTES = 32 * 1024 * 1024;
7
+ const MAX_TOTAL_BLOB_BYTES = 128 * 1024 * 1024;
8
+ const MAX_BLOB_BYTES = 64 * 1024 * 1024;
9
+ const COMMAND_TIMEOUT_MS = 15_000;
10
+ const BLOB_TIMEOUT_MS = 30_000;
11
+ const acquiredSources = new WeakMap();
12
+
13
+ export class CommittedSourceIncompleteError extends Error {
14
+ constructor(message, cause) {
15
+ super(message, cause ? { cause } : undefined);
16
+ this.name = "CommittedSourceIncompleteError";
17
+ this.code = "COMMITTED_SOURCE_INCOMPLETE";
18
+ this.coverage = [{ path: ".", status: "unscanned", reason: "committed-source-acquisition-failed" }];
19
+ }
20
+ }
21
+
22
+ function nativeGitPath(platform = process.platform) {
23
+ if (platform === "win32") return "C:\\Program Files\\Git\\cmd\\git.exe";
24
+ if (platform === "darwin") return "/usr/bin/git";
25
+ return "/usr/bin/git";
26
+ }
27
+
28
+ function gitEnv(env = process.env, platform = process.platform) {
29
+ const clean = Object.fromEntries(Object.entries(env).filter(([key]) => !/^GIT_/i.test(key)));
30
+ clean.GIT_CONFIG_NOSYSTEM = "1";
31
+ clean.GIT_CONFIG_GLOBAL = platform === "win32" ? "NUL" : "/dev/null";
32
+ clean.GIT_OPTIONAL_LOCKS = "0";
33
+ clean.GIT_TERMINAL_PROMPT = "0";
34
+ return clean;
35
+ }
36
+
37
+ function runGit(root, args, { encoding = "buffer", maxBuffer = 4 * 1024 * 1024, timeout = COMMAND_TIMEOUT_MS, runner = spawnSync, env = gitEnv(), input } = {}) {
38
+ const result = runner(nativeGitPath(), ["-C", root, "--no-replace-objects", "-c", "core.fsmonitor=false", "-c", `core.hooksPath=${os.devNull}`, ...args], {
39
+ encoding, env, windowsHide: true, maxBuffer, timeout, ...(input ? { input } : {}),
40
+ });
41
+ if (result?.error || result?.status !== 0) throw new Error(`committed ownership: git ${args[0] ?? "command"} failed${result?.error?.code ? ` (${result.error.code})` : ""}`);
42
+ return result.stdout;
43
+ }
44
+
45
+ function validatePath(value) {
46
+ if (!value || value.includes("\\") || value.startsWith("/") || /^[A-Za-z]:/.test(value)) return false;
47
+ const parts = value.split("/");
48
+ return parts.every((part) => part && part !== "." && part !== "..") && path.posix.normalize(value) === value;
49
+ }
50
+
51
+ function hashGitObject(type, bytes, algorithm) {
52
+ return createHash(algorithm).update(`${type} ${bytes.length}\0`).update(bytes).digest("hex");
53
+ }
54
+
55
+ function parseTree(bytes, objectFormat) {
56
+ if (bytes.length > MAX_TREE_BYTES) throw new Error("committed ownership: tree listing exceeds 32MiB");
57
+ const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
58
+ const rows = [];
59
+ for (const record of text.split("\0")) {
60
+ if (!record) continue;
61
+ const tab = record.indexOf("\t");
62
+ if (tab < 0) throw new Error("committed ownership: malformed tree entry");
63
+ const [mode, type, oid] = record.slice(0, tab).split(" ");
64
+ const rel = record.slice(tab + 1);
65
+ const digestLength = objectFormat === "sha256" ? 64 : 40;
66
+ if (!validatePath(rel) || !new RegExp(`^[0-9a-f]{${digestLength}}$`).test(oid ?? "")) throw new Error("committed ownership: invalid tree path or object id");
67
+ if (type !== "blob" && type !== "commit") throw new Error(`committed ownership: unsupported tree object type at ${rel}`);
68
+ if (!new Set(["100644", "100755", "120000", "160000"]).has(mode)) throw new Error(`committed ownership: unsupported tree mode at ${rel}`);
69
+ if ((type === "commit") !== (mode === "160000") || (mode === "160000" && type !== "commit")) throw new Error(`committed ownership: mismatched tree mode and object type at ${rel}`);
70
+ rows.push({ rel, mode, type, oid });
71
+ }
72
+ rows.sort((a, b) => a.rel.localeCompare(b.rel));
73
+ return rows;
74
+ }
75
+
76
+ function parseBatchCheck(bytes, expectedOids) {
77
+ if (!expectedOids.length) {
78
+ if (bytes.length) throw new Error("committed ownership: unexpected blob size response");
79
+ return [];
80
+ }
81
+ const lines = bytes.toString("utf8").trimEnd().split("\n");
82
+ if (lines.length !== expectedOids.length) throw new Error("committed ownership: malformed blob size response");
83
+ return lines.map((line, index) => {
84
+ const [oid, type, sizeText] = line.trim().split(" ");
85
+ const size = Number(sizeText);
86
+ if (oid !== expectedOids[index] || type !== "blob" || !Number.isSafeInteger(size) || size < 0) throw new Error("committed ownership: invalid blob size response");
87
+ return size;
88
+ });
89
+ }
90
+
91
+ function readBatch(root, oids, totalBytes, runner) {
92
+ if (!oids.length) return new Map();
93
+ const input = Buffer.from(`${oids.join("\n")}\n`);
94
+ const outputLimit = totalBytes + oids.length * 128;
95
+ const output = runGit(root, ["cat-file", "--batch"], { encoding: "buffer", maxBuffer: outputLimit, timeout: BLOB_TIMEOUT_MS, runner, env: gitEnv() , input });
96
+ if (!Buffer.isBuffer(output)) throw new Error("committed ownership: Git returned non-binary blob data");
97
+ const result = new Map();
98
+ let offset = 0;
99
+ for (const expected of oids) {
100
+ const endHeader = output.indexOf(0x0a, offset);
101
+ if (endHeader < 0) throw new Error("committed ownership: malformed blob header");
102
+ const [oid, type, sizeText] = output.subarray(offset, endHeader).toString("ascii").split(" ");
103
+ const size = Number(sizeText);
104
+ if (oid !== expected || type !== "blob" || !Number.isSafeInteger(size) || size < 0 || size > MAX_BLOB_BYTES) throw new Error("committed ownership: invalid blob object");
105
+ const start = endHeader + 1, end = start + size;
106
+ if (end >= output.length || output[end] !== 0x0a) throw new Error("committed ownership: truncated blob object");
107
+ const bytes = output.subarray(start, end);
108
+ const algorithm = expected.length === 64 ? "sha256" : "sha1";
109
+ if (hashGitObject("blob", bytes, algorithm) !== expected) throw new Error(`committed ownership: blob hash mismatch ${expected}`);
110
+ result.set(expected, Buffer.from(bytes));
111
+ offset = end + 1;
112
+ }
113
+ if (offset !== output.length) throw new Error("committed ownership: unexpected blob output");
114
+ return result;
115
+ }
116
+
117
+ function acquire(root, { expectedCommit, expectedTree, requiredPaths = [], runner = spawnSync, env = gitEnv() } = {}) {
118
+ const text = (args) => runGit(root, args, { encoding: "utf8", runner, env }).trim();
119
+ const repositoryRoot = path.resolve(text(["rev-parse", "--show-toplevel"]));
120
+ const pathPrefix = text(["rev-parse", "--show-prefix"]).replaceAll("\\", "/");
121
+ const originResult = runner(nativeGitPath(), ["-C", repositoryRoot, "--no-replace-objects", "-c", "core.fsmonitor=false", "-c", `core.hooksPath=${os.devNull}`, "config", "--get", "remote.origin.url"], {
122
+ encoding: "utf8", env, windowsHide: true, maxBuffer: 4 * 1024 * 1024, timeout: COMMAND_TIMEOUT_MS,
123
+ });
124
+ const origin = originResult?.status === 0 ? String(originResult.stdout).trim() : null;
125
+ const objectFormat = text(["rev-parse", "--show-object-format"]);
126
+ if (objectFormat !== "sha1" && objectFormat !== "sha256") throw new Error("committed ownership: unsupported Git object format");
127
+ const commit = text(["rev-parse", "--verify", "HEAD^{commit}"]);
128
+ const digestLength = objectFormat === "sha256" ? 64 : 40;
129
+ if (!new RegExp(`^[0-9a-f]{${digestLength}}$`).test(commit) || (expectedCommit && commit !== expectedCommit)) throw new Error("committed ownership: expected commit is not current HEAD");
130
+ const tree = text(["rev-parse", "--verify", `${commit}^{tree}`]);
131
+ if (!new RegExp(`^[0-9a-f]{${digestLength}}$`).test(tree) || (expectedTree && tree !== expectedTree)) throw new Error("committed ownership: expected tree does not match commit");
132
+ const rows = parseTree(runGit(root, ["ls-tree", "-r", "-z", "--full-tree", tree], { encoding: "buffer", maxBuffer: MAX_TREE_BYTES, timeout: COMMAND_TIMEOUT_MS, runner, env }), objectFormat);
133
+ const wanted = new Set(requiredPaths.filter(validatePath));
134
+ const shouldRead = (row) => row.type === "blob" && row.mode !== "120000" && (
135
+ wanted.has(row.rel) || ["Cargo.toml", "package.json", "Package.swift", "ownership-exceptions.json"].includes(path.posix.basename(row.rel)) ||
136
+ /\.(?:rs|ts|tsx|js|mjs|cjs|jsx|swift|m|mm|c|cc|cpp|h|py|go|kt|java|cs)$/i.test(row.rel)
137
+ );
138
+ const candidates = rows.filter(shouldRead);
139
+ const uniqueOids = [...new Set(candidates.map((row) => row.oid))];
140
+ const sizeInput = Buffer.from(`${uniqueOids.join("\n")}\n`);
141
+ if (sizeInput.length > MAX_TREE_BYTES) throw new Error("committed ownership: blob size query exceeds 32MiB");
142
+ const sizesOutput = uniqueOids.length ? runGit(root, ["cat-file", "--batch-check=%(objectname) %(objecttype) %(objectsize)"], {
143
+ encoding: "buffer", maxBuffer: Math.min(MAX_TREE_BYTES, uniqueOids.length * 128 + 1), timeout: COMMAND_TIMEOUT_MS, runner, env, input: sizeInput,
144
+ }) : Buffer.alloc(0);
145
+ const sizes = parseBatchCheck(sizesOutput, uniqueOids);
146
+ const sizeByOid = new Map(uniqueOids.map((oid, i) => [oid, sizes[i]]));
147
+ const blobOids = [], coverage = [], blobsTotal = new Set();
148
+ let totalBlobBytes = 0;
149
+ for (const row of candidates) {
150
+ const size = sizeByOid.get(row.oid);
151
+ if (size > MAX_BLOB_BYTES) { coverage.push({ path: row.rel, status: "unscanned", reason: "committed-blob-over-64MiB" }); continue; }
152
+ if (!blobsTotal.has(row.oid) && totalBlobBytes + size > MAX_TOTAL_BLOB_BYTES) {
153
+ coverage.push({ path: row.rel, status: "unscanned", reason: "committed-blobs-over-128MiB" }); continue;
154
+ }
155
+ if (!blobsTotal.has(row.oid)) { blobsTotal.add(row.oid); totalBlobBytes += size; }
156
+ blobOids.push(row.oid);
157
+ }
158
+ const blobs = readBatch(root, [...new Set(blobOids)], totalBlobBytes, runner);
159
+ const entries = rows.map((row) => ({ ...row, size: row.type === "blob" ? sizeByOid.get(row.oid) : 0, bytes: blobs.get(row.oid) }));
160
+ return Object.freeze({ repositoryRoot, pathPrefix, origin, commit, tree, objectFormat, entries, blobs, coverage });
161
+ }
162
+
163
+ export function acquireCommittedSource(root, options = {}) {
164
+ const { expectedCommit, expectedTree, requiredPaths = [] } = options;
165
+ try { return createSourceHandle(acquire(path.resolve(root), { expectedCommit, expectedTree, requiredPaths })); }
166
+ catch (error) { if (error instanceof CommittedSourceIncompleteError) throw error; throw new CommittedSourceIncompleteError(error.message, error); }
167
+ }
168
+
169
+ export function acquireCommittedSourceForTest(root, options = {}) {
170
+ try { return createSourceHandle(acquire(path.resolve(root), options)); }
171
+ catch (error) { if (error instanceof CommittedSourceIncompleteError) throw error; throw new CommittedSourceIncompleteError(error.message, error); }
172
+ }
173
+
174
+ function createSourceHandle(data) {
175
+ const handle = Object.freeze({
176
+ repositoryRoot: data.repositoryRoot, pathPrefix: data.pathPrefix, origin: data.origin,
177
+ commit: data.commit, tree: data.tree, objectFormat: data.objectFormat,
178
+ });
179
+ acquiredSources.set(handle, data);
180
+ return handle;
181
+ }
182
+
183
+ export function committedSourceDataForInternalUse(handle) {
184
+ return acquiredSources.get(handle) ?? null;
185
+ }
186
+
187
+ export function assertCommittedCheckoutClean(root, { expectedCommit, expectedTree, runner = spawnSync } = {}) {
188
+ if (expectedCommit || expectedTree) {
189
+ const currentCommit = runGit(path.resolve(root), ["rev-parse", "--verify", "HEAD^{commit}"], { encoding: "utf8", timeout: COMMAND_TIMEOUT_MS, runner, env: gitEnv() }).trim();
190
+ const currentTree = runGit(path.resolve(root), ["rev-parse", "--verify", `${currentCommit}^{tree}`], { encoding: "utf8", timeout: COMMAND_TIMEOUT_MS, runner, env: gitEnv() }).trim();
191
+ if ((expectedCommit && currentCommit !== expectedCommit) || (expectedTree && currentTree !== expectedTree)) throw new CommittedSourceIncompleteError("committed ownership: HEAD changed during protected source acquisition");
192
+ }
193
+ const output = runGit(path.resolve(root), ["status", "--porcelain=v1", "-z", "--untracked-files=all"], {
194
+ encoding: "buffer", maxBuffer: MAX_TREE_BYTES, timeout: COMMAND_TIMEOUT_MS, runner, env: gitEnv(),
195
+ });
196
+ if (output.length) throw new CommittedSourceIncompleteError("committed ownership: protected release checkout has tracked or untracked changes");
197
+ return true;
198
+ }
199
+
200
+ export const committedOwnershipLimitsForTest = Object.freeze({ MAX_TREE_BYTES, MAX_TOTAL_BLOB_BYTES, MAX_BLOB_BYTES });
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/env node
2
+ // Draft/import/verify utilities. Signing is intentionally absent; native prompts own signing.
3
+ import { readFileSync, writeFileSync } from "node:fs";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { createDraft, loadTrustedExternal, verifyException } from "./ownership-approval.mjs";
7
+
8
+ function arg(args, name) { const i = args.indexOf(name); return i < 0 ? undefined : args[i + 1]; }
9
+ function json(file) { return JSON.parse(readFileSync(path.resolve(file), "utf8")); }
10
+ export function main(args) {
11
+ if (args.includes("--draft")) {
12
+ const input = json(arg(args, "--draft"));
13
+ const draft = createDraft(input);
14
+ const out = arg(args, "--out");
15
+ const data = `${JSON.stringify(draft, null, 2)}\n`;
16
+ if (out) writeFileSync(path.resolve(out), data, { flag: "wx" }); else process.stdout.write(data);
17
+ return 0;
18
+ }
19
+ if (args.includes("--import")) {
20
+ const record = json(arg(args, "--import"));
21
+ const out = arg(args, "--out");
22
+ if (!out) throw new Error("--import requires --out");
23
+ writeFileSync(path.resolve(out), `${JSON.stringify(record, null, 2)}\n`, { flag: "wx" });
24
+ return 0;
25
+ }
26
+ if (args.includes("--verify")) {
27
+ const record = json(arg(args, "--verify"));
28
+ const context = json(arg(args, "--context"));
29
+ const loaded = loadTrustedExternal();
30
+ const result = verifyException(record, { ...context, trust: loaded.trust });
31
+ process.stdout.write(`${JSON.stringify({ ...result, pending: loaded.pending }, null, 2)}\n`);
32
+ return result.ok ? 0 : 1;
33
+ }
34
+ throw new Error("usage: node ownership-approval-command.mjs --draft input.json [--out draft.json] | --import signed.json --out record.json | --verify signed.json --context scan.json");
35
+ }
36
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
37
+ try { process.exitCode = main(process.argv.slice(2)); }
38
+ catch (error) { console.error(`[right-release] ownership approval: ${error.message}`); process.exitCode = 1; }
39
+ }