@rightkit/release 0.2.142 → 0.2.144
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 +13 -2
- package/cli/right-release.mjs +3 -0
- package/github-release.mjs +9 -0
- package/install-app.mjs +132 -22
- package/ownership-approval.mjs +14 -1
- package/ownership-command.mjs +48 -2
- package/ownership-declaration.json +175 -4
- package/ownership-dependency-allowlist-seed.mjs +86 -0
- package/ownership-dependency-allowlist.json +1087 -0
- package/ownership-dependency-allowlist.mjs +124 -0
- package/ownership-evidence.mjs +13 -2
- package/ownership-key-command.mjs +55 -13
- package/ownership-key-elevation.mjs +114 -8
- package/ownership-key-trust.mjs +25 -4
- package/ownership-policy-bundle.mjs +155 -0
- package/ownership-report-command.mjs +1 -1
- package/ownership-trust-summary.mjs +119 -0
- package/ownership-windows-presence.mjs +171 -0
- package/ownership.mjs +42 -6
- package/package.json +1 -1
- package/protected-credentials.mjs +6 -1
- package/publish-update.mjs +8 -1
- package/release-qa-command.mjs +145 -0
- package/release-qa-floor.json +9 -0
- package/release-qa-receipt.mjs +276 -0
- package/release.mjs +1 -1
- package/rightkit-drift.mjs +1 -1
- package/rightkit-ownership.json +2276 -429
- package/rightkit-sync.mjs +3 -0
- package/rightkit-versions.json +9 -6
- package/upload-release.mjs +8 -1
package/build-release.mjs
CHANGED
|
@@ -24,6 +24,7 @@ import { spawn, spawnSync } from "./background-process.mjs";
|
|
|
24
24
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
25
25
|
import { assertPrimaryReleaseCheckout, dirtyBuildInputs, resolveReleaseBuildInputs } from "./release-invocation.mjs";
|
|
26
26
|
import { resolveNativeCargoLayout } from "./native-cargo-layout.mjs";
|
|
27
|
+
import { assertSourceQaAdmission } from "./release-qa-receipt.mjs";
|
|
27
28
|
import { commandOutputPortable, releaseEnvironment, resolveReleaseLayout, runBuildStateMachine, verifySealedRelease, watchProgress } from "./release-state.mjs";
|
|
28
29
|
import { acquireCacheLease, acquireSuiteBuildSlot, applyCachePrune, assertWriteVolumeFloors, inspectCache, inspectWriteVolumes, markCacheEntrySuccessful, planCachePrune, readCachePolicy, resolveCacheLayout, resolveSharedCacheIdentity, resolveSharedCacheRoot } from "./cache-policy.mjs";
|
|
29
30
|
import { createTargetBridge } from "./target-bridge.mjs";
|
|
@@ -253,6 +254,15 @@ try {
|
|
|
253
254
|
for (const check of preflight) {
|
|
254
255
|
if (check.status !== "ok") console.error(`[right-release] preflight ${check.name}: ${check.detail}`);
|
|
255
256
|
}
|
|
257
|
+
// Hardening step 4: source QA admits the candidate build/sign. The receipt digest is sealed into the manifest so
|
|
258
|
+
// upload/publication can require installed QA chained to it. Dev lanes (install-app --dev) never reach this file.
|
|
259
|
+
let qaAdmission;
|
|
260
|
+
try {
|
|
261
|
+
qaAdmission = assertSourceQaAdmission({ repoRoot: layout.repoRoot, releaseId, platform, configName: path.relative(process.cwd(), configPath) || path.basename(configPath), releaseConfigSha256: hashFile(configPath) });
|
|
262
|
+
} catch (error) {
|
|
263
|
+
if (error?.code !== "QA_ADMISSION_REFUSED") throw error;
|
|
264
|
+
fail(error.message);
|
|
265
|
+
}
|
|
256
266
|
|
|
257
267
|
// A tool being INSTALLED is not the same as it being the one PATH picks. The
|
|
258
268
|
// vendored-OpenSSL build shells out to `perl`, and under Git Bash/MSYS that
|
|
@@ -396,7 +406,7 @@ try {
|
|
|
396
406
|
});
|
|
397
407
|
},
|
|
398
408
|
seal: async ({ sealedDir }) => {
|
|
399
|
-
sealRelease({ configRoot: appRoot, managedCargoTarget, nativeLayout, sealedDir, releaseId, config, target, platform, commit, inputHashes, toolVersions, cacheKey, signingIdentity, ownershipEvidence, managedRoot });
|
|
409
|
+
sealRelease({ configRoot: appRoot, managedCargoTarget, nativeLayout, sealedDir, releaseId, config, target, platform, commit, inputHashes, toolVersions, cacheKey, signingIdentity, ownershipEvidence, qaAdmission, managedRoot });
|
|
400
410
|
verifySealedRelease(sealedDir);
|
|
401
411
|
if (cacheMode === "shared") markCacheEntrySuccessful({ layout: sharedLayout });
|
|
402
412
|
checkpoint(stateRoot, "sealed");
|
|
@@ -436,7 +446,7 @@ function sqlCipherFeatures(text) {
|
|
|
436
446
|
.filter((value) => /sqlcipher|openssl/i.test(value));
|
|
437
447
|
}
|
|
438
448
|
|
|
439
|
-
function sealRelease({ configRoot, managedCargoTarget, nativeLayout, sealedDir, releaseId, config, target, platform, commit, inputHashes, toolVersions, cacheKey, signingIdentity, ownershipEvidence, managedRoot }) {
|
|
449
|
+
function sealRelease({ configRoot, managedCargoTarget, nativeLayout, sealedDir, releaseId, config, target, platform, commit, inputHashes, toolVersions, cacheKey, signingIdentity, ownershipEvidence, qaAdmission, managedRoot }) {
|
|
440
450
|
// Every app's right-release.config.mjs names installer/updater artifacts
|
|
441
451
|
// relative to its resolved native target. On a broker-managed host that
|
|
442
452
|
// directory is never populated (the broker owns CARGO_TARGET_DIR and
|
|
@@ -507,6 +517,7 @@ function sealRelease({ configRoot, managedCargoTarget, nativeLayout, sealedDir,
|
|
|
507
517
|
version: config.version,
|
|
508
518
|
commit,
|
|
509
519
|
ownershipEvidence,
|
|
520
|
+
qa: { sourceReceiptDigest: qaAdmission.digest, floorDigest: qaAdmission.floorDigest },
|
|
510
521
|
platform,
|
|
511
522
|
cacheKey,
|
|
512
523
|
// Portable contracts sign only raw executables — installer/embedding
|
package/cli/right-release.mjs
CHANGED
|
@@ -23,6 +23,7 @@ const commands = new Map([
|
|
|
23
23
|
["mirror-root-artifact", "mirror-root-artifact.mjs"],
|
|
24
24
|
["github", "github-release.mjs"],
|
|
25
25
|
["cargo-target", "cargo-target.mjs"],
|
|
26
|
+
["qa", "release-qa-command.mjs"],
|
|
26
27
|
["ownership", "ownership-command.mjs"],
|
|
27
28
|
["ownership-approval", "ownership-approval-command.mjs"],
|
|
28
29
|
["ownership-report", "ownership-report-command.mjs"],
|
|
@@ -183,6 +184,7 @@ Commands:
|
|
|
183
184
|
Stage SwiftPM app; build+sign MSIX (Windows) or assemble+sign+notarize .app (macOS); never uploads
|
|
184
185
|
upload --release <id> --platform mac|win --tier patch|update
|
|
185
186
|
Upload one sealed release; never builds
|
|
187
|
+
qa --stage source|installed --platform mac|win [--release <id>] Run rightkit-qa and write the sealed QA receipt (required to build/upload)
|
|
186
188
|
publish-update --release <id> --platform mac|win --tier patch|update
|
|
187
189
|
Upload sealed bytes to R2 & register RightApps
|
|
188
190
|
release Compatibility alias for build
|
|
@@ -212,6 +214,7 @@ Commands:
|
|
|
212
214
|
ownership approve-key <fingerprint|request.json> Approve one pending device key from an active host
|
|
213
215
|
ownership revoke-key <fingerprint> Revoke one active device key
|
|
214
216
|
ownership trust-sync Advance this host's protected trust to the committed key log
|
|
217
|
+
ownership trust-compare [--json] Print this host's protected-trust digest (Mac is the reference)
|
|
215
218
|
ownership-approval --draft <input.json> [--out <draft.json>]
|
|
216
219
|
Draft an exception for native owner approval
|
|
217
220
|
ownership-approval --verify <signed.json> --context <scan.json>
|
package/github-release.mjs
CHANGED
|
@@ -7,6 +7,7 @@ import { readVerifiedArtifact, assertArtifactUnchanged, digestBytes, verifyArtif
|
|
|
7
7
|
import { spawnSync } from "./background-process.mjs";
|
|
8
8
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
9
9
|
import { verifySealedRelease } from "./release-state.mjs";
|
|
10
|
+
import { assertInstalledQaAdmission } from "./release-qa-receipt.mjs";
|
|
10
11
|
import { addonManifestSha256, validateAddonConfig, validateAddonManifest } from "./addon-contract.mjs";
|
|
11
12
|
import { RIGHTRELEASE_MANIFEST_SIGNER, verifyManifestCatalogSignature } from "./sign-release-manifest.mjs";
|
|
12
13
|
|
|
@@ -394,6 +395,14 @@ if (process.argv[1] && realOrSelf(process.argv[1]) === realOrSelf(fileURLToPath(
|
|
|
394
395
|
plan = prepareGitHubAddonRelease({ repoRoot, config, configRoot: path.dirname(configPath), ...options });
|
|
395
396
|
} else {
|
|
396
397
|
options.repo ||= repositoryFromRemote(repoRoot);
|
|
398
|
+
// Hardening step 4: the direct GitHub lane is a publication path too; it needs installed QA like `upload` does.
|
|
399
|
+
if (!options.dryRun) {
|
|
400
|
+
const sealed = verifySealedRelease(path.join(repoRoot, ".right-release", "sealed", options.releaseId, options.platform === "win" ? "windows" : "mac"));
|
|
401
|
+
const configPath = path.resolve("right-release.config.mjs");
|
|
402
|
+
try {
|
|
403
|
+
assertInstalledQaAdmission({ repoRoot, manifest: sealed.manifest, platform: options.platform, configName: "right-release.config.mjs", releaseConfigSha256: existsSync(configPath) ? createHash("sha256").update(readFileSync(configPath)).digest("hex") : undefined });
|
|
404
|
+
} catch (error) { if (error?.code !== "QA_ADMISSION_REFUSED") throw error; console.error(`right-release github: ${error.message}`); process.exit(1); }
|
|
405
|
+
}
|
|
397
406
|
plan = prepareGitHubRelease({ repoRoot, ...options });
|
|
398
407
|
}
|
|
399
408
|
const result = publishGitHubRelease(plan, options);
|
package/install-app.mjs
CHANGED
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
// Sanctioned, non-intrusive local app install from a signed DMG (macOS); --dev also accepts a
|
|
3
3
|
// Developer ID-signed, not-notarized zip or .app.
|
|
4
4
|
// Never uses a shell, osascript, AppleEvents, or foreground activation.
|
|
5
|
-
import { execFile as nodeExecFile } from "./background-process.mjs";
|
|
5
|
+
import { execFile as nodeExecFile, execFileSync as nodeExecFileSync } from "./background-process.mjs";
|
|
6
6
|
import { detachWithRetry } from "./hdiutil-detach.mjs";
|
|
7
7
|
import fs from "node:fs";
|
|
8
8
|
import os from "node:os";
|
|
9
9
|
import path from "node:path";
|
|
10
|
-
import { pathToFileURL } from "node:url";
|
|
10
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
11
11
|
|
|
12
12
|
const SIGTERM_WAIT_MS = 15_000;
|
|
13
13
|
const POLL_MS = 250;
|
|
@@ -194,6 +194,59 @@ export function writeJournal(fsImpl, file, data) {
|
|
|
194
194
|
} catch { /* directory fsync is best effort */ }
|
|
195
195
|
}
|
|
196
196
|
|
|
197
|
+
// --- atomic swap helper (rightkit-swap: renamex_np(RENAME_SWAP)) -------------
|
|
198
|
+
|
|
199
|
+
// Errors that mean "no atomic swap here": fall back to the two-rename + journal path.
|
|
200
|
+
const SWAP_FALLBACK_CODES = new Set(["ENOHELPER", "ENOTSUP", "EINVAL", "ENOSYS"]);
|
|
201
|
+
const SWAP_HELPER_NAME = "rightkit-swap";
|
|
202
|
+
|
|
203
|
+
// Resolve order: env RIGHTKIT_SWAP, then <package>/bin/darwin/rightkit-swap, then PATH.
|
|
204
|
+
export function resolveSwapHelper({ env = process.env, fsImpl = fs, pkgDir = path.dirname(fileURLToPath(import.meta.url)) } = {}) {
|
|
205
|
+
const usable = (p) => { try { return fsImpl.statSync(p).isFile(); } catch { return false; } };
|
|
206
|
+
if (env.RIGHTKIT_SWAP && usable(env.RIGHTKIT_SWAP)) return env.RIGHTKIT_SWAP;
|
|
207
|
+
const shipped = path.join(pkgDir, "bin", "darwin", SWAP_HELPER_NAME);
|
|
208
|
+
if (usable(shipped)) return shipped;
|
|
209
|
+
for (const dir of String(env.PATH || "").split(path.delimiter)) {
|
|
210
|
+
if (dir && path.isAbsolute(dir) && usable(path.join(dir, SWAP_HELPER_NAME))) return path.join(dir, SWAP_HELPER_NAME);
|
|
211
|
+
}
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function coded(code, message) {
|
|
216
|
+
const error = new Error(message);
|
|
217
|
+
error.code = code;
|
|
218
|
+
return error;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Sync on purpose: signal-handler rollback must be able to swap back. Throws an error whose `code`
|
|
222
|
+
// is in SWAP_FALLBACK_CODES when no atomic swap is possible here (no helper, or exit 3 / ENOTSUP / EINVAL).
|
|
223
|
+
export function createSwapPaths({ env = process.env, resolve = resolveSwapHelper, execFileSync = nodeExecFileSync } = {}) {
|
|
224
|
+
return (a, b) => {
|
|
225
|
+
const helper = resolve({ env });
|
|
226
|
+
if (!helper) throw coded("ENOHELPER", `${SWAP_HELPER_NAME} helper not found`);
|
|
227
|
+
try { execFileSync(helper, [a, b], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); }
|
|
228
|
+
catch (error) {
|
|
229
|
+
const text = `${error?.stderr || ""}`.trim();
|
|
230
|
+
if (error?.code === "ENOENT" || error?.code === "EACCES") throw coded("ENOHELPER", `${helper} cannot run: ${error.code}`);
|
|
231
|
+
if (error?.status === 3 || /SWAP_UNSUPPORTED/.test(text)) throw coded("ENOTSUP", text || "atomic swap unsupported on this volume");
|
|
232
|
+
throw coded("EIO", text || error?.message || "rightkit-swap failed");
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Exchange two paths, falling back to three renames only when no atomic swap exists (roll-back/recovery).
|
|
238
|
+
function swapBack(swapPaths, fsImpl, a, b) {
|
|
239
|
+
try { swapPaths(a, b); return "atomic"; }
|
|
240
|
+
catch (error) {
|
|
241
|
+
if (!SWAP_FALLBACK_CODES.has(error?.code)) throw error;
|
|
242
|
+
const tmp = `${a}.swapback`;
|
|
243
|
+
fsImpl.renameSync(a, tmp);
|
|
244
|
+
fsImpl.renameSync(b, a);
|
|
245
|
+
fsImpl.renameSync(tmp, b);
|
|
246
|
+
return "two-rename";
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
197
250
|
const defaultIsAlive = (pid) => {
|
|
198
251
|
try { process.kill(pid, 0); return true; } catch (e) { return e.code === "EPERM"; }
|
|
199
252
|
};
|
|
@@ -207,7 +260,7 @@ async function trashBundle(run, fsImpl, home, now, bundle, appName) {
|
|
|
207
260
|
}
|
|
208
261
|
|
|
209
262
|
// Deterministic roll-forward/back of any interrupted install journal in `dest`. Idempotent.
|
|
210
|
-
export async function recoverInstalls({ dest, appName = null, run, fsImpl, home, now, event, sleep, isAlive = defaultIsAlive, selfPid = process.pid }) {
|
|
263
|
+
export async function recoverInstalls({ dest, appName = null, run, fsImpl, home, now, event, sleep, isAlive = defaultIsAlive, selfPid = process.pid, swapPaths = createSwapPaths() }) {
|
|
211
264
|
let names = [];
|
|
212
265
|
try { names = fsImpl.readdirSync(dest).filter((n) => JOURNAL_RE.test(n)); } catch { return []; }
|
|
213
266
|
if (appName) names = names.filter((n) => n === path.basename(journalPathFor(dest, appName)));
|
|
@@ -225,8 +278,28 @@ export async function recoverInstalls({ dest, appName = null, run, fsImpl, home,
|
|
|
225
278
|
const rm = (p) => { if (p) { try { fsImpl.rmSync(p, { recursive: true, force: true }); } catch { /* best effort */ } } };
|
|
226
279
|
let action;
|
|
227
280
|
let keepJournal = false;
|
|
281
|
+
let keepStaged = false;
|
|
228
282
|
try {
|
|
229
|
-
if (
|
|
283
|
+
if (j.swap === "atomic") {
|
|
284
|
+
// Atomic mode: `installed` is never absent. After the exchange, `staged` holds the OLD bundle;
|
|
285
|
+
// before it, `staged` holds the new copy. The staged copy's inode (journaled) tells them apart.
|
|
286
|
+
const swapped = has(j.installed) && Number.isFinite(j.stagedIno) && fsImpl.statSync(j.installed).ino === j.stagedIno;
|
|
287
|
+
if (!has(j.installed)) {
|
|
288
|
+
if (has(j.staged)) { fsImpl.renameSync(j.staged, j.installed); action = "restored-backup"; }
|
|
289
|
+
else action = "cleared";
|
|
290
|
+
} else if (!swapped || !has(j.staged)) action = "cleared";
|
|
291
|
+
else {
|
|
292
|
+
let ok = j.phase === "verified";
|
|
293
|
+
if (!ok) { try { await verifyBundle(run, j.installed, { assess: !j.dev, staple: false }); ok = true; } catch { ok = false; } }
|
|
294
|
+
if (ok) {
|
|
295
|
+
try { await trashBundle(run, fsImpl, home, now, j.staged, path.basename(j.installed)); action = "rolled-forward"; }
|
|
296
|
+
catch { action = "rolled-forward-trash-pending"; keepJournal = true; keepStaged = true; }
|
|
297
|
+
} else {
|
|
298
|
+
swapBack(swapPaths, fsImpl, j.staged, j.installed); // staged now holds the bad new bundle
|
|
299
|
+
action = "rolled-back";
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
} else if (!has(j.installed)) {
|
|
230
303
|
if (has(j.backup)) { fsImpl.renameSync(j.backup, j.installed); action = "restored-backup"; }
|
|
231
304
|
else action = "cleared";
|
|
232
305
|
} else if (has(j.backup)) {
|
|
@@ -245,7 +318,7 @@ export async function recoverInstalls({ dest, appName = null, run, fsImpl, home,
|
|
|
245
318
|
event("recovery-failed", { journal: file, reason: errText(error) });
|
|
246
319
|
continue; // leave the journal for the next run
|
|
247
320
|
}
|
|
248
|
-
rm(j.staged);
|
|
321
|
+
if (!keepStaged) rm(j.staged);
|
|
249
322
|
if (j.mount && has(j.mount)) {
|
|
250
323
|
const out = await detachWithRetry({ mount: j.mount, log: (e) => event(e.event, e), sleep, detach: (args) => run("/usr/bin/hdiutil", args) });
|
|
251
324
|
if (out.ok) rm(j.mountRoot);
|
|
@@ -258,9 +331,9 @@ export async function recoverInstalls({ dest, appName = null, run, fsImpl, home,
|
|
|
258
331
|
}
|
|
259
332
|
|
|
260
333
|
export async function recoverApp(opts, deps = {}) {
|
|
261
|
-
const { execFile = realExecFile, fsImpl = fs, home = os.homedir(), now = () => new Date(), sleep = (ms) => new Promise((r) => setTimeout(r, ms)), emit = () => {}, isAlive, platform = process.platform } = deps;
|
|
334
|
+
const { execFile = realExecFile, fsImpl = fs, home = os.homedir(), now = () => new Date(), sleep = (ms) => new Promise((r) => setTimeout(r, ms)), emit = () => {}, isAlive, platform = process.platform, env = process.env, swapPaths = createSwapPaths({ env }) } = deps;
|
|
262
335
|
if (platform !== "darwin") throw new InstallAppError("unsupported", `install-app is not implemented on ${platform}`);
|
|
263
|
-
const results = await recoverInstalls({ dest: opts.dest, appName: opts.appName, run: (c, a) => execFile(c, a), fsImpl, home, now, event: (name, data = {}) => emit({ event: name, ...data }), sleep, isAlive });
|
|
336
|
+
const results = await recoverInstalls({ dest: opts.dest, appName: opts.appName, run: (c, a) => execFile(c, a), fsImpl, home, now, event: (name, data = {}) => emit({ event: name, ...data }), sleep, isAlive, swapPaths });
|
|
264
337
|
emit({ event: "recover-done", dest: opts.dest, recovered: results.length });
|
|
265
338
|
return { ok: true, recovered: results };
|
|
266
339
|
}
|
|
@@ -280,6 +353,8 @@ export async function installApp(opts, deps = {}) {
|
|
|
280
353
|
isAlive = defaultIsAlive,
|
|
281
354
|
signalTarget = process,
|
|
282
355
|
exit = (code) => process.exit(code),
|
|
356
|
+
env = process.env,
|
|
357
|
+
swapPaths = createSwapPaths({ env }),
|
|
283
358
|
} = deps;
|
|
284
359
|
const dev = opts.dev === true;
|
|
285
360
|
const event = (name, data = {}) => emit({ event: name, ...(dev ? { dev: true } : {}), ...data });
|
|
@@ -298,7 +373,16 @@ export async function installApp(opts, deps = {}) {
|
|
|
298
373
|
const s = swap;
|
|
299
374
|
swap = null;
|
|
300
375
|
try {
|
|
301
|
-
if (
|
|
376
|
+
if (s.mode === "atomic") {
|
|
377
|
+
// installed was never absent. Before the exchange only the staged copy needs removing; after it,
|
|
378
|
+
// exchange back (installed = old again, staged = the new copy) and drop the new copy. Once final,
|
|
379
|
+
// staged holds the verified-old bundle awaiting the Trash, so it must not be touched.
|
|
380
|
+
if (!s.final) {
|
|
381
|
+
if (s.swapped) swapBack(swapPaths, fsImpl, s.staged, s.installed);
|
|
382
|
+
removeQuiet(s.staged);
|
|
383
|
+
removeQuiet(s.journal);
|
|
384
|
+
}
|
|
385
|
+
} else if (!s.final) {
|
|
302
386
|
if (s.stagedIn) removeQuiet(s.installed);
|
|
303
387
|
if (s.backedUp && !fsImpl.existsSync(s.installed)) fsImpl.renameSync(s.backup, s.installed);
|
|
304
388
|
removeQuiet(s.staged);
|
|
@@ -346,7 +430,7 @@ export async function installApp(opts, deps = {}) {
|
|
|
346
430
|
if (!fsImpl.existsSync(inputPath)) throw new InstallAppError(`${inputKind}-missing`, `${inputKind.toUpperCase()} not found: ${inputPath}`);
|
|
347
431
|
if (!fsImpl.existsSync(opts.dest)) throw new InstallAppError("dest-missing", `destination not found: ${opts.dest}`);
|
|
348
432
|
|
|
349
|
-
await recoverInstalls({ dest: opts.dest, appName: opts.appName, run, fsImpl, home, now, event, sleep, isAlive, selfPid: pid });
|
|
433
|
+
await recoverInstalls({ dest: opts.dest, appName: opts.appName, run, fsImpl, home, now, event, sleep, isAlive, selfPid: pid, swapPaths });
|
|
350
434
|
|
|
351
435
|
let sourceApp = null;
|
|
352
436
|
const pickApp = (dir, label) => {
|
|
@@ -408,7 +492,7 @@ export async function installApp(opts, deps = {}) {
|
|
|
408
492
|
const journalData = { schema: 1, appName, pid, phase: "staging", dev, installed, backup, staged, mount: mountPoint, mountRoot };
|
|
409
493
|
removeQuiet(staged);
|
|
410
494
|
writeJournal(fsImpl, journal, journalData);
|
|
411
|
-
swap = { installed, backup, staged, journal, backedUp: false, stagedIn: false, final: false };
|
|
495
|
+
swap = { installed, backup, staged, journal, mode: null, swapped: false, backedUp: false, stagedIn: false, final: false };
|
|
412
496
|
try { await run("/usr/bin/ditto", [sourceApp, staged]); }
|
|
413
497
|
catch (error) {
|
|
414
498
|
rollbackSwap();
|
|
@@ -426,12 +510,37 @@ export async function installApp(opts, deps = {}) {
|
|
|
426
510
|
const quit = hadInstalled ? await quitApp(run, kill, sleep, installed) : { pids: [], killed: [] };
|
|
427
511
|
event("quit", { wasRunning, pids: quit.pids, forced: quit.killed });
|
|
428
512
|
|
|
429
|
-
// Swap:
|
|
513
|
+
// Swap. Preferred: one atomic exchange (renamex_np RENAME_SWAP), so `installed` is never absent even
|
|
514
|
+
// under SIGKILL; the old bundle then sits at the staged path. Fallback (no helper, or a volume without
|
|
515
|
+
// RENAME_SWAP): old -> backup, staged -> installed, journaled. The old bundle is never trashed before
|
|
516
|
+
// the new one is verified.
|
|
430
517
|
failedStage = "replace";
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
518
|
+
let jd = journalData;
|
|
519
|
+
let swapMode;
|
|
520
|
+
if (!hadInstalled) {
|
|
521
|
+
swapMode = "rename"; // nothing to exchange: a single rename onto an absent name is atomic
|
|
522
|
+
swap.mode = "two-rename";
|
|
523
|
+
writeJournal(fsImpl, journal, { ...jd, phase: "swapping", swap: swapMode });
|
|
524
|
+
fsImpl.renameSync(staged, installed);
|
|
525
|
+
swap.stagedIn = true;
|
|
526
|
+
} else {
|
|
527
|
+
jd = { ...journalData, swap: "atomic", backup: null, stagedIno: fsImpl.statSync(staged).ino };
|
|
528
|
+
writeJournal(fsImpl, journal, { ...jd, phase: "swapping" });
|
|
529
|
+
swap.mode = "atomic";
|
|
530
|
+
try { swapPaths(staged, installed); swap.swapped = true; swapMode = "atomic"; }
|
|
531
|
+
catch (error) {
|
|
532
|
+
if (!SWAP_FALLBACK_CODES.has(error?.code)) throw new InstallAppError("swap-failed", `atomic swap failed (${errText(error)}); previous app untouched`);
|
|
533
|
+
event("swap-fallback", { reason: error.code, detail: errText(error) });
|
|
534
|
+
swapMode = "two-rename";
|
|
535
|
+
swap.mode = "two-rename";
|
|
536
|
+
jd = { ...journalData, swap: "two-rename" };
|
|
537
|
+
writeJournal(fsImpl, journal, { ...jd, phase: "swapping" });
|
|
538
|
+
fsImpl.renameSync(installed, backup); swap.backedUp = true;
|
|
539
|
+
fsImpl.renameSync(staged, installed);
|
|
540
|
+
swap.stagedIn = true;
|
|
541
|
+
}
|
|
542
|
+
if (swapMode === "atomic") writeJournal(fsImpl, journal, { ...jd, phase: "swapped" });
|
|
543
|
+
}
|
|
435
544
|
// Dev builds are not notarized: clear quarantine on the NEW bundle only so Gatekeeper does not block it.
|
|
436
545
|
if (dev) {
|
|
437
546
|
try { await run("/usr/bin/xattr", ["-dr", "com.apple.quarantine", installed]); }
|
|
@@ -443,18 +552,19 @@ export async function installApp(opts, deps = {}) {
|
|
|
443
552
|
// Re-verify installed copy; on failure the catch below rolls back to the backup.
|
|
444
553
|
failedStage = "reverify";
|
|
445
554
|
await verifyBundle(run, installed, { assess: !dev, staple: false });
|
|
446
|
-
writeJournal(fsImpl, journal, { ...
|
|
555
|
+
writeJournal(fsImpl, journal, { ...jd, phase: "verified", mount: null, mountRoot: null });
|
|
447
556
|
swap.final = true;
|
|
448
557
|
|
|
449
558
|
// Only now does the old bundle go to the Trash.
|
|
450
559
|
let trashed = null;
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
560
|
+
const oldBundle = swapMode === "atomic" ? staged : backup; // atomic: the exchange left the old bundle at the staged path
|
|
561
|
+
if (hadInstalled && oldBundle) {
|
|
562
|
+
try { trashed = await trashBundle(run, fsImpl, home, now, oldBundle, appName); }
|
|
563
|
+
catch (error) { event("backup-trash-failed", { backup: oldBundle, reason: errText(error) }); trashed = oldBundle; }
|
|
454
564
|
}
|
|
455
|
-
if (!
|
|
565
|
+
if (!oldBundle || trashed !== oldBundle) removeQuiet(journal);
|
|
456
566
|
swap = null;
|
|
457
|
-
event("replaced", { installed, previous: trashed });
|
|
567
|
+
event("replaced", { installed, previous: trashed, swap: swapMode });
|
|
458
568
|
|
|
459
569
|
// Relaunch hidden and in the background only.
|
|
460
570
|
const relaunch = opts.relaunch === null ? wasRunning : opts.relaunch;
|
|
@@ -463,7 +573,7 @@ export async function installApp(opts, deps = {}) {
|
|
|
463
573
|
await run("/usr/bin/open", ["-g", "-j", "-b", bundleId]);
|
|
464
574
|
event("relaunched", { bundleId });
|
|
465
575
|
}
|
|
466
|
-
const result = { ok: true, app: appName, installed, bundleId, teamId: newTeam, previous: trashed, relaunched: relaunch, ...(dev ? { dev: true } : {}) };
|
|
576
|
+
const result = { ok: true, app: appName, installed, bundleId, teamId: newTeam, previous: trashed, swap: swapMode, relaunched: relaunch, ...(dev ? { dev: true } : {}) };
|
|
467
577
|
event("done", result);
|
|
468
578
|
return result;
|
|
469
579
|
} catch (error) {
|
package/ownership-approval.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import { existsSync, lstatSync, readFileSync, statSync } from "node:fs";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { spawnSync, windowsPowerShellEnvironment } from "./background-process.mjs";
|
|
6
6
|
import { verifyKeyLog, verifyOwnershipSignature } from "./ownership-key-log.mjs";
|
|
7
|
+
import { validateAllowlist } from "./ownership-dependency-allowlist.mjs";
|
|
7
8
|
|
|
8
9
|
export const APPROVAL_SCHEMA = 2;
|
|
9
10
|
export const REQUIRED_HOSTS = Object.freeze(["windows", "macos"]);
|
|
@@ -171,6 +172,16 @@ export function validateTrustRegistry(trust) {
|
|
|
171
172
|
if (!Array.isArray(trust?.revokedKeyIds) || !Array.isArray(trust?.verifiedTrackingEvidence) || trust.verifiedTrackingEvidence.some((x) => !HEX.test(x))) problems.push("trust revocation or tracking evidence lists are malformed");
|
|
172
173
|
if (!/^\d+(?:\.\d+)*$/.test(trust?.minimumScannerVersion ?? "") || !/^\d+(?:\.\d+)*$/.test(trust?.minimumCatalogVersion ?? "")) problems.push("scanner/catalog floors are missing or malformed");
|
|
173
174
|
if (trust?.minimumCatalogDigest !== undefined && !HEX.test(trust.minimumCatalogDigest)) problems.push("minimum catalog digest is malformed");
|
|
175
|
+
// Policy bundle + allowlist are optional so trust written before they existed stays valid (treated as unpinned).
|
|
176
|
+
if (trust?.policyBundle !== undefined) {
|
|
177
|
+
const bundle = trust.policyBundle;
|
|
178
|
+
if (!bundle || typeof bundle !== "object" || Array.isArray(bundle) || bundle.schema !== 1 || !HEX.test(bundle.digest ?? "") || !HEX.test(bundle.catalogDigest ?? "")
|
|
179
|
+
|| !/^\d+(?:\.\d+)*$/.test(bundle.scannerVersion ?? "") || !Array.isArray(bundle.files) || bundle.files.some((file) => typeof file?.path !== "string" || !HEX.test(file.sha256 ?? ""))) problems.push("protected policy bundle is malformed");
|
|
180
|
+
}
|
|
181
|
+
if (trust?.dependencyAllowlist !== undefined) {
|
|
182
|
+
const allowlist = validateAllowlist(trust.dependencyAllowlist);
|
|
183
|
+
if (!allowlist.ok) problems.push(`protected dependency allowlist is malformed: ${allowlist.problems[0]}`);
|
|
184
|
+
}
|
|
174
185
|
if (trust?.rootFingerprint !== undefined || trust?.keyCheckpoint !== undefined) {
|
|
175
186
|
if (!HEX.test(trust.rootFingerprint ?? "") || !trust.keyCheckpoint || !Number.isSafeInteger(trust.keyCheckpoint.sequence) || trust.keyCheckpoint.sequence < 0 || !HEX.test(trust.keyCheckpoint.head ?? "")) problems.push("protected key-log root pin or checkpoint is malformed");
|
|
176
187
|
}
|
|
@@ -274,12 +285,14 @@ function versionAtLeast(actual, minimum) {
|
|
|
274
285
|
}
|
|
275
286
|
return true;
|
|
276
287
|
}
|
|
277
|
-
export function validateOwnershipFloor(trust, { scannerVersion, catalogVersion, catalogDigest } = {}) {
|
|
288
|
+
export function validateOwnershipFloor(trust, { scannerVersion, catalogVersion, catalogDigest, policyBundleDigest } = {}) {
|
|
278
289
|
const problems = [];
|
|
279
290
|
if (!trust) return { ok: false, problems: ["external ownership trust unavailable"] };
|
|
280
291
|
if (!versionAtLeast(scannerVersion, trust.minimumScannerVersion)) problems.push("scanner version missing, malformed, or below protected minimum");
|
|
281
292
|
if (!versionAtLeast(catalogVersion, trust.minimumCatalogVersion)) problems.push("catalog version missing, malformed, or below protected minimum");
|
|
282
293
|
if (trust.minimumCatalogDigest && trust.minimumCatalogDigest !== catalogDigest) problems.push("catalog digest below protected pinned digest");
|
|
294
|
+
// Trust written before the bundle existed has no policyBundle and stays unpinned until the next trust-sync.
|
|
295
|
+
if (trust.policyBundle?.digest && policyBundleDigest !== undefined && trust.policyBundle.digest !== policyBundleDigest) problems.push("ownership policy bundle (scanner source, catalog, registry or dependency allowlist) differs from the protected pinned bundle; run trust-sync");
|
|
283
296
|
return { ok: problems.length === 0, problems };
|
|
284
297
|
}
|
|
285
298
|
function protectedLocation(file, platform, { runner = spawnSync, inspect = lstatSync, stat = statSync, requireNonElevated = true } = {}) {
|
package/ownership-command.mjs
CHANGED
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
// the shipped manifest from each package's declaration.
|
|
4
4
|
//
|
|
5
5
|
// right-release ownership [--app <dir>] [--json]
|
|
6
|
-
// right-release ownership
|
|
6
|
+
// right-release ownership [--app <dir>] --audit-json (Legion governance.capability-ownership provider)
|
|
7
|
+
// right-release ownership trust-compare [--json] (print this host's protected-trust digest)
|
|
8
|
+
// right-release ownership --write-manifest [--rightkit <rightkit>] [--approve-new-capabilities]
|
|
7
9
|
// --approve-new-capabilities permits new ids during local generation only.
|
|
8
10
|
// Existing owner changes are always refused; installation needs protected authorization.
|
|
9
11
|
import { realpathSync } from "node:fs";
|
|
@@ -14,6 +16,8 @@ import { writeOwnershipCatalog } from "./ownership-catalog.mjs";
|
|
|
14
16
|
import { createOwnershipEvidence } from "./ownership-evidence.mjs";
|
|
15
17
|
import { spawnSync } from "./background-process.mjs";
|
|
16
18
|
import { main as stagedMain } from "./ownership-staged.mjs";
|
|
19
|
+
import { loadTrustedExternal } from "./ownership-approval.mjs";
|
|
20
|
+
import { trustComparison } from "./ownership-policy-bundle.mjs";
|
|
17
21
|
|
|
18
22
|
const packageRoot = path.dirname(fileURLToPath(import.meta.url));
|
|
19
23
|
|
|
@@ -22,11 +26,23 @@ function flag(args, name) {
|
|
|
22
26
|
return i >= 0 ? args[i + 1] : undefined;
|
|
23
27
|
}
|
|
24
28
|
|
|
25
|
-
|
|
29
|
+
/** Print this host's protected-trust digest. The Mac's digest is the reference; see docs/OWNERSHIP-ENFORCEMENT.md. */
|
|
30
|
+
export function trustCompare(args, { print, trustLoader = loadTrustedExternal } = {}) {
|
|
31
|
+
let state;
|
|
32
|
+
try { state = trustLoader(); } catch (error) { print(`[right-release] trust-compare: protected trust unreadable (${error.message})`); return 1; }
|
|
33
|
+
if (!state?.trust) { print("[right-release] trust-compare: no protected trust on this host (not enrolled)"); return 1; }
|
|
34
|
+
const { digest, components } = trustComparison(state.trust);
|
|
35
|
+
if (args.includes("--json")) print(JSON.stringify({ platform: process.platform, digest, components }, null, 2));
|
|
36
|
+
else { print(`trust digest (${process.platform}): ${digest}`); for (const [name, value] of Object.entries(components)) print(` ${name}: ${typeof value === "object" && value !== null ? JSON.stringify(value) : value ?? "none"}`); }
|
|
37
|
+
return 0;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function main(args, { evidenceFactory = createOwnershipEvidence, gitRunner = spawnSync, print = (value) => console.log(value), trustLoader = loadTrustedExternal } = {}) {
|
|
26
41
|
if (args.includes("--help")) {
|
|
27
42
|
print("right-release ownership [--app <dir>] [--json]\nright-release ownership --staged [--app <dir>] [--json] (pre-commit gate: scans the index, not the working tree)\nright-release ownership --write-manifest [--rightkit <dir>] [--approve-new-capabilities]\n--approve-new-capabilities permits new capability ids during local generation only. Existing owner conflicts are always refused. Protected installation authorization remains separate.");
|
|
28
43
|
return 0;
|
|
29
44
|
}
|
|
45
|
+
if (args[0] === "trust-compare") return trustCompare(args, { print, trustLoader });
|
|
30
46
|
if (args.includes("--staged")) return stagedMain(args, { print });
|
|
31
47
|
if (["enroll", "approve-key", "revoke-key", "trust-sync"].includes(args[0])) {
|
|
32
48
|
const { main: keyMain } = await import("./ownership-key-command.mjs");
|
|
@@ -48,6 +64,7 @@ export async function main(args, { evidenceFactory = createOwnershipEvidence, gi
|
|
|
48
64
|
try { checked = evidenceFactory(repoRoot, app); }
|
|
49
65
|
catch (error) { return failClosed(args, "protected-evidence", error.message, print); }
|
|
50
66
|
const result = checked.result;
|
|
67
|
+
if (args.includes("--audit-json")) return printAuditJson(result, print);
|
|
51
68
|
if (args.includes("--json")) {
|
|
52
69
|
const error = checked.enrollmentPending ? undefined : !checked.floor.ok
|
|
53
70
|
? { type: "ownership-floor", message: checked.floor.problems.join("; ") }
|
|
@@ -70,7 +87,36 @@ export async function main(args, { evidenceFactory = createOwnershipEvidence, gi
|
|
|
70
87
|
return checked.ok ? 0 : 1;
|
|
71
88
|
}
|
|
72
89
|
|
|
90
|
+
// Audit provider output: every scan violation (block and advise tiers) as a repo-relative finding.
|
|
91
|
+
// Exit 0 whenever the scan completes; findings never change the exit code.
|
|
92
|
+
export function auditFindings(result) {
|
|
93
|
+
return {
|
|
94
|
+
schemaVersion: 1,
|
|
95
|
+
findings: result.violations.map((v) => {
|
|
96
|
+
const tier = v.tier ?? "block";
|
|
97
|
+
const file = String(v.file ?? "").replaceAll("\\", "/");
|
|
98
|
+
if (!file || file.startsWith("/") || /^[A-Za-z]:/.test(file) || file === ".." || file.startsWith("../") || file.split("/").includes("..")) throw new Error(`audit finding path is outside the audited root: ${v.file}`);
|
|
99
|
+
const body = v.text ?? `${v.ecosystem ?? "dependency"} ${v.name ?? ""}`.trim();
|
|
100
|
+
const finding = { rule: String(v.fingerprint), path: file };
|
|
101
|
+
if (Number.isSafeInteger(v.line) && v.line > 0) finding.line = v.line;
|
|
102
|
+
finding.message = `${tier === "advise" ? "advise: " : ""}${v.capability} duplicates RightKit owner ${v.package} (${tier}): ${body}`;
|
|
103
|
+
return finding;
|
|
104
|
+
}),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function printAuditJson(result, print) {
|
|
109
|
+
try {
|
|
110
|
+
print(JSON.stringify(auditFindings(result)));
|
|
111
|
+
return 0;
|
|
112
|
+
} catch (error) {
|
|
113
|
+
console.error(`[right-release] ownership audit failed: ${error.message}`);
|
|
114
|
+
return 1;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
73
118
|
function failClosed(args, type, message, print = (value) => console.log(value)) {
|
|
119
|
+
if (args.includes("--audit-json")) { console.error(`[right-release] ownership audit failed: ${message}`); return 1; }
|
|
74
120
|
if (args.includes("--json")) print(JSON.stringify({ ok: false, error: { type, message }, rows: [], violations: [], covered: [], problems: [message], coverage: [], metadata: null }, null, 2));
|
|
75
121
|
else print(`[right-release] ownership FAILED: ${message}`);
|
|
76
122
|
return 1;
|