@rightkit/release 0.2.143 → 0.2.145
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/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/pe-imports.mjs +3 -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 +10 -6
- package/swift-package-command.mjs +1 -1
- package/swift-windows-stage.mjs +11 -1
- package/upload-release.mjs +8 -1
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// Default-deny direct-dependency allowlist: pure, dependency-free logic shared by the release scanner
|
|
2
|
+
// (ownership.mjs), the trust updater and the agent write hook. This file is byte-copied into
|
|
3
|
+
// packages/build-control-policy/src by rightkit-sync, so it must import nothing.
|
|
4
|
+
//
|
|
5
|
+
// Data file: ownership-dependency-allowlist.json
|
|
6
|
+
// { schema: 1, global: ["npm:typescript", ...], repositories: [{ repositoryIdentity, dependencies: ["cargo:serde", "cargo:x#git"] }] }
|
|
7
|
+
// Entry grammar: "<ecosystem>:<name>[#<kind>]"; kind is registry (default), git or path.
|
|
8
|
+
// Local path/workspace dependencies are first-party code inside the guarded repository and are never
|
|
9
|
+
// gated here (source scanning and local-path coverage already cover them).
|
|
10
|
+
|
|
11
|
+
export const ALLOWLIST_FILE = "ownership-dependency-allowlist.json";
|
|
12
|
+
export const ALLOWLIST_FINDING_ID = "dependency-not-allowlisted";
|
|
13
|
+
const ECOSYSTEMS = ["cargo", "npm", "swiftpm"];
|
|
14
|
+
const KINDS = ["registry", "git", "path"];
|
|
15
|
+
const MAX_ENTRIES = 20000;
|
|
16
|
+
|
|
17
|
+
export const entryKey = (ecosystem, name, kind = "registry") => `${ecosystem}:${name}${kind === "registry" ? "" : `#${kind}`}`;
|
|
18
|
+
|
|
19
|
+
/** Parse "<ecosystem>:<name>[#<kind>]"; returns null when malformed. */
|
|
20
|
+
export function parseEntry(value) {
|
|
21
|
+
if (typeof value !== "string") return null;
|
|
22
|
+
const colon = value.indexOf(":");
|
|
23
|
+
if (colon < 1) return null;
|
|
24
|
+
const ecosystem = value.slice(0, colon);
|
|
25
|
+
let name = value.slice(colon + 1), kind = "registry";
|
|
26
|
+
const hash = name.lastIndexOf("#");
|
|
27
|
+
if (hash >= 0) { kind = name.slice(hash + 1); name = name.slice(0, hash); }
|
|
28
|
+
if (!ECOSYSTEMS.includes(ecosystem) || !KINDS.includes(kind) || !name || /\s/.test(name)) return null;
|
|
29
|
+
return { ecosystem, name, kind };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Source string from the scanner ("registry", "path:..", "git+..") to its kind. */
|
|
33
|
+
export function sourceKind(source) {
|
|
34
|
+
if (typeof source !== "string" || source === "registry" || source === "") return "registry";
|
|
35
|
+
if (source.startsWith("path:")) return "path";
|
|
36
|
+
if (source.startsWith("git+")) return "git";
|
|
37
|
+
return "registry";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const listProblems = (list, label, problems) => {
|
|
41
|
+
if (!Array.isArray(list)) { problems.push(`${label} must be an array`); return; }
|
|
42
|
+
if (list.length > MAX_ENTRIES) problems.push(`${label} exceeds ${MAX_ENTRIES} entries`);
|
|
43
|
+
const seen = new Set();
|
|
44
|
+
for (const item of list) {
|
|
45
|
+
if (!parseEntry(item)) { problems.push(`${label} has a malformed entry: ${JSON.stringify(item)}`); continue; }
|
|
46
|
+
if (seen.has(item)) problems.push(`${label} has a duplicate entry: ${item}`);
|
|
47
|
+
seen.add(item);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export function validateAllowlist(value) {
|
|
52
|
+
const problems = [];
|
|
53
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return { ok: false, problems: ["dependency allowlist must be an object"] };
|
|
54
|
+
for (const key of Object.keys(value)) if (!["schema", "global", "repositories"].includes(key)) problems.push(`dependency allowlist has an unknown field: ${key}`);
|
|
55
|
+
if (value.schema !== 1) problems.push("dependency allowlist schema must be 1");
|
|
56
|
+
listProblems(value.global, "global", problems);
|
|
57
|
+
if (!Array.isArray(value.repositories)) problems.push("repositories must be an array");
|
|
58
|
+
else {
|
|
59
|
+
const seen = new Set();
|
|
60
|
+
for (const entry of value.repositories) {
|
|
61
|
+
const identity = entry?.repositoryIdentity;
|
|
62
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry) || typeof identity !== "string" || !/^https:\/\/[^/\s]+\/[^\s]+$/.test(identity) || /\/$|\.git$/i.test(identity)) { problems.push(`repository baseline has an invalid identity: ${JSON.stringify(identity)}`); continue; }
|
|
63
|
+
for (const key of Object.keys(entry)) if (!["repositoryIdentity", "dependencies", "note"].includes(key)) problems.push(`repository baseline has an unknown field: ${key}`);
|
|
64
|
+
if (seen.has(identity)) problems.push(`repository baseline is duplicated: ${identity}`);
|
|
65
|
+
seen.add(identity);
|
|
66
|
+
listProblems(entry.dependencies, `repository ${identity}`, problems);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return { ok: problems.length === 0, problems };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const indexCache = new WeakMap();
|
|
73
|
+
function index(allowlist) {
|
|
74
|
+
let built = indexCache.get(allowlist);
|
|
75
|
+
if (built) return built;
|
|
76
|
+
const global = new Set(allowlist.global ?? []);
|
|
77
|
+
const repositories = new Map((allowlist.repositories ?? []).map((entry) => [entry.repositoryIdentity, new Set(entry.dependencies ?? [])]));
|
|
78
|
+
// Name-only view for inherited (workspace = true) cargo dependencies whose source kind the hook cannot see.
|
|
79
|
+
const names = (set) => new Set([...set].map((item) => { const p = parseEntry(item); return p ? `${p.ecosystem}:${p.name}` : item; }));
|
|
80
|
+
built = { global, repositories, globalNames: names(global), repositoryNames: new Map([...repositories].map(([id, set]) => [id, names(set)])) };
|
|
81
|
+
indexCache.set(allowlist, built);
|
|
82
|
+
return built;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** RightKit-owned packages are always allowed: catalog owners, internal owners and the rightkit naming scheme. */
|
|
86
|
+
export function isRightKitPackage(name, owners = []) {
|
|
87
|
+
return owners.includes(name) || /^(?:@rightkit\/|rightkit[-_])/i.test(name) || name === "RightKitSwift" || /^rightkit(?:swift)?$/i.test(name);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* @param {object} allowlist validated allowlist
|
|
92
|
+
* @param {string|null} repositoryIdentity canonical identity of the guarded repository
|
|
93
|
+
* @param {{ecosystem:string,name:string,kind?:string}} dependency kind "any" matches by name regardless of source kind
|
|
94
|
+
* @returns {{allowed:boolean, reason:string}}
|
|
95
|
+
*/
|
|
96
|
+
export function evaluateDependency(allowlist, repositoryIdentity, { ecosystem, name, kind = "registry" }, { owners = [] } = {}) {
|
|
97
|
+
if (kind === "path") return { allowed: true, reason: "first-party local path dependency" };
|
|
98
|
+
if (isRightKitPackage(name, owners)) return { allowed: true, reason: "RightKit package" };
|
|
99
|
+
const { global, repositories, globalNames, repositoryNames } = index(allowlist);
|
|
100
|
+
if (kind === "any") {
|
|
101
|
+
const key = `${ecosystem}:${name}`;
|
|
102
|
+
if (globalNames.has(key)) return { allowed: true, reason: "global allowlist" };
|
|
103
|
+
if (repositoryNames.get(repositoryIdentity)?.has(key)) return { allowed: true, reason: "repository baseline" };
|
|
104
|
+
return { allowed: false, reason: "not in repository baseline or global list" };
|
|
105
|
+
}
|
|
106
|
+
const key = entryKey(ecosystem, name, kind);
|
|
107
|
+
if (global.has(key)) return { allowed: true, reason: "global allowlist" };
|
|
108
|
+
if (repositories.get(repositoryIdentity)?.has(key)) return { allowed: true, reason: "repository baseline" };
|
|
109
|
+
return { allowed: false, reason: "not in repository baseline or global list" };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Semantic diff between two allowlists (either may be null). */
|
|
113
|
+
export function diffAllowlists(before, after) {
|
|
114
|
+
const setDiff = (a, b) => ({ added: [...b].filter((x) => !a.has(x)).sort(), removed: [...a].filter((x) => !b.has(x)).sort() });
|
|
115
|
+
const global = setDiff(new Set(before?.global ?? []), new Set(after?.global ?? []));
|
|
116
|
+
const was = new Map((before?.repositories ?? []).map((entry) => [entry.repositoryIdentity, new Set(entry.dependencies ?? [])]));
|
|
117
|
+
const now = new Map((after?.repositories ?? []).map((entry) => [entry.repositoryIdentity, new Set(entry.dependencies ?? [])]));
|
|
118
|
+
const repositories = [];
|
|
119
|
+
for (const identity of [...new Set([...was.keys(), ...now.keys()])].sort()) {
|
|
120
|
+
const change = setDiff(was.get(identity) ?? new Set(), now.get(identity) ?? new Set());
|
|
121
|
+
if (change.added.length || change.removed.length || !was.has(identity) || !now.has(identity)) repositories.push({ repositoryIdentity: identity, ...change, baseline: was.has(identity) ? (now.has(identity) ? "changed" : "removed") : "added" });
|
|
122
|
+
}
|
|
123
|
+
return { global, repositories, changed: global.added.length > 0 || global.removed.length > 0 || repositories.length > 0 };
|
|
124
|
+
}
|
package/ownership-evidence.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import { spawnSync } from "./background-process.mjs";
|
|
|
6
6
|
import { catalogDigest as scanCatalogDigest, checkApp, checkAppForTest, checkCommittedApp, checkCommittedAppForTest, MANIFEST_FILE, SCANNER_VERSION } from "./ownership.mjs";
|
|
7
7
|
import { acquireCommittedSource, assertCommittedCheckoutClean, committedSourceDataForInternalUse } from "./committed-source.mjs";
|
|
8
8
|
import { readCommittedKeyAuthority } from "./ownership-key-authority.mjs";
|
|
9
|
+
import { runningPolicyBundle } from "./ownership-policy-bundle.mjs";
|
|
9
10
|
import { canonicalRepositoryIdentity, loadTrustedExternal, resolveKeyLogTrust, sha256, validateOwnershipFloor, verifyRepositoryRegistration } from "./ownership-approval.mjs";
|
|
10
11
|
|
|
11
12
|
const digestFile = (file) => createHash("sha256").update(readFileSync(file)).digest("hex");
|
|
@@ -29,6 +30,7 @@ function createEvidence(repoRoot, {
|
|
|
29
30
|
committed = false,
|
|
30
31
|
requireCleanCheckout = false,
|
|
31
32
|
allowPendingAdvisory = false,
|
|
33
|
+
policyBundleProvider = runningPolicyBundle,
|
|
32
34
|
} = {}, readAuthority = readCommittedKeyAuthority, scanCommitted = checkCommittedApp) {
|
|
33
35
|
const root = path.resolve(repoRoot);
|
|
34
36
|
const manifestBytes = readFileSync(manifestPath);
|
|
@@ -75,9 +77,18 @@ function createEvidence(repoRoot, {
|
|
|
75
77
|
const catalogDigest = scanCatalogDigest(manifest);
|
|
76
78
|
// When a protected registry exists, key enrollment controls signing
|
|
77
79
|
// authority; scanner and catalog floors still apply during pending enrollment.
|
|
80
|
+
// Step 2: the protected trust pins the policy bundle (catalog + registry + dependency allowlist + scanner version +
|
|
81
|
+
// scanner source digests). Trust without a pinned bundle (written before it existed) is unpinned until trust-sync.
|
|
82
|
+
let policyBundleDigest;
|
|
83
|
+
let bundleProblem = null;
|
|
84
|
+
if (trustState.trust?.policyBundle?.digest) {
|
|
85
|
+
try { policyBundleDigest = policyBundleProvider({ scannerVersion, manifest }).digest; }
|
|
86
|
+
catch (error) { policyBundleDigest = null; bundleProblem = `ownership policy bundle could not be computed: ${error.message}`; }
|
|
87
|
+
}
|
|
78
88
|
const floor = trustState.trust
|
|
79
|
-
? validateOwnershipFloor(trustState.trust, { scannerVersion, catalogVersion, catalogDigest })
|
|
89
|
+
? validateOwnershipFloor(trustState.trust, { scannerVersion, catalogVersion, catalogDigest, ...(policyBundleDigest !== undefined ? { policyBundleDigest } : {}) })
|
|
80
90
|
: { ok: false, problems: ["protected ownership floor is unavailable until trust registry enrollment"] };
|
|
91
|
+
if (bundleProblem) { floor.ok = false; floor.problems = [bundleProblem, ...floor.problems.filter((problem) => !problem.startsWith("ownership policy bundle"))]; }
|
|
81
92
|
// Real paths on both sides: Git's root is resolved (macOS /private/var), callers may pass /var or /tmp.
|
|
82
93
|
const real = (p) => { try { return realpathSync(p); } catch { return path.resolve(p); } };
|
|
83
94
|
const requestedRoot = path.relative(real(root), real(scanRoot)).replaceAll("\\", "/") || ".";
|
|
@@ -141,7 +152,7 @@ function createEvidence(repoRoot, {
|
|
|
141
152
|
ok: scans.every(({ scanned }) => scanned.ok),
|
|
142
153
|
findings: scans.flatMap(({ scanned, prefix }) => (scanned.findings ?? []).map((entry) => rebase(entry, prefix))),
|
|
143
154
|
violations: scans.flatMap(({ scanned, prefix }) => (scanned.violations ?? []).map((entry) => rebase(entry, prefix))),
|
|
144
|
-
problems: [...scans.flatMap(({ scanned }) => scanned.problems ?? []), ...evidenceDiagnostics, ...(enrollmentPending ? floor.problems :
|
|
155
|
+
problems: [...scans.flatMap(({ scanned }) => scanned.problems ?? []), ...evidenceDiagnostics, ...(enrollmentPending ? floor.problems : floor.problems.filter((problem) => problem.startsWith("ownership policy bundle")))],
|
|
145
156
|
rows: scans.flatMap(({ scanned, root: registered }) => (scanned.rows ?? []).map((row) => ({ ...row, root: registered }))),
|
|
146
157
|
covered: scans.flatMap(({ scanned, prefix }) => (scanned.covered ?? []).map((entry) => rebase(entry, prefix))),
|
|
147
158
|
coverage: contentCoverage,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import { accessSync, closeSync, existsSync, lstatSync, mkdirSync, mkdtempSync, openSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { accessSync, closeSync, existsSync, lstatSync, mkdirSync, mkdtempSync, openSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import os from "node:os";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
@@ -7,7 +7,8 @@ import { spawnSync, spawnSyncInteractive, windowsPowerShellEnvironment } from ".
|
|
|
7
7
|
import { createKeyEvent, keyFingerprint, verifyKeyLog, verifyKeyRequest } from "./ownership-key-log.mjs";
|
|
8
8
|
import { canonicalRepositoryIdentity, DEFAULT_TRUST_PATH, loadTrustedExternal, resolveKeyLogTrust, __testOnly as approvalTestOnly } from "./ownership-approval.mjs";
|
|
9
9
|
import { readCommittedKeyAuthority } from "./ownership-key-authority.mjs";
|
|
10
|
-
import { buildMacInvocation, delegateToDesktop, hasTerminal, needsDesktopDelegation } from "./ownership-key-elevation.mjs";
|
|
10
|
+
import { buildMacInvocation, confirmTrustChange, delegateToDesktop, hasTerminal, needsDesktopDelegation } from "./ownership-key-elevation.mjs";
|
|
11
|
+
import { summarizeRequestFile } from "./ownership-trust-summary.mjs";
|
|
11
12
|
|
|
12
13
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
13
14
|
const LOG = ".rightkit/ownership-keys.json";
|
|
@@ -319,7 +320,7 @@ function buildWindowsElevationInvocation(nodeExe, trustScript, requestPath, dige
|
|
|
319
320
|
const parentScript = `$ErrorActionPreference='Stop'; try { $env:NODE_OPTIONS=$null; $env:NODE_PATH=$null; $p=Start-Process -FilePath ${quote(powershell)} -ArgumentList ${quote(`-NoProfile -EncodedCommand ${childEncoded}`)} -Verb RunAs -WindowStyle Hidden -Wait -PassThru; exit $p.ExitCode } catch { exit 1 }`;
|
|
320
321
|
return { parentExecutable: powershell, parentArgs: ["-NoProfile", "-EncodedCommand", Buffer.from(parentScript, "utf16le").toString("base64")], childScript };
|
|
321
322
|
}
|
|
322
|
-
export const __testOnly = Object.freeze({ elevate, buildWindowsElevationInvocation, assertSafeMetadataParents, detectMinimizeHook });
|
|
323
|
+
export const __testOnly = Object.freeze({ elevate, buildWindowsElevationInvocation, assertSafeMetadataParents, detectMinimizeHook, trustSyncGate, installCheckpoint });
|
|
323
324
|
function trustState() { return loadTrustedExternal(); }
|
|
324
325
|
function currentLogTrust(root, log, trust = trustState(), authority, injected = false) {
|
|
325
326
|
if (!trust.trust?.rootFingerprint) throw new Error("protected root trust is not enrolled on this host");
|
|
@@ -353,10 +354,12 @@ export async function main(args, deps = {}) {
|
|
|
353
354
|
const root = deps.root ?? repoRoot(app, run);
|
|
354
355
|
try {
|
|
355
356
|
if (!platform) throw new Error(`ownership key management unsupported platform: ${process.platform}`);
|
|
357
|
+
// Delegate BEFORE taking the command lock: the desktop-session child needs that same lock.
|
|
358
|
+
if (args[0] === "trust-sync" && needsDesktopDelegation({ hostPlatform: deps.hostPlatform, env: deps.env, run, elevatedCheck: deps.elevatedCheck })) return delegateTrustSync(deps, run, print);
|
|
356
359
|
const helper = deps.helper ?? helperFor(run);
|
|
357
360
|
const lockPath = deps.lockPath ?? path.join(os.tmpdir(), "rightkit-key-command.lock");
|
|
358
361
|
let lockFd;
|
|
359
|
-
|
|
362
|
+
lockFd = acquireCommandLock(lockPath);
|
|
360
363
|
try {
|
|
361
364
|
commandTemp = mkdtempSync(path.join(os.tmpdir(), "rightkit-key-command-"));
|
|
362
365
|
const authority = resolveAuthority(root, run, deps.authority);
|
|
@@ -377,6 +380,27 @@ export async function main(args, deps = {}) {
|
|
|
377
380
|
}
|
|
378
381
|
} catch (error) { print(`[right-release] ownership ${args[0] ?? "keys"} failed: ${error.message}`); return 1; }
|
|
379
382
|
}
|
|
383
|
+
const pidAlive = (pid) => { try { process.kill(pid, 0); return true; } catch (e) { return e.code === "EPERM"; } };
|
|
384
|
+
/** Exclusive lock file holding the owner pid; a lock whose pid is dead (killed run) is reclaimed. */
|
|
385
|
+
export function acquireCommandLock(lockPath, { alive = pidAlive, now = Date.now } = {}) {
|
|
386
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
387
|
+
try {
|
|
388
|
+
const fd = openSync(lockPath, "wx", 0o600);
|
|
389
|
+
writeSync(fd, String(process.pid));
|
|
390
|
+
return fd;
|
|
391
|
+
} catch (error) {
|
|
392
|
+
if (error.code !== "EEXIST") throw new Error("another ownership key command is active for this repository");
|
|
393
|
+
let stale = false;
|
|
394
|
+
try {
|
|
395
|
+
const pid = Number.parseInt(readFileSync(lockPath, "utf8").trim(), 10);
|
|
396
|
+
stale = Number.isInteger(pid) && pid > 0 ? !alive(pid) : now() - lstatSync(lockPath).mtimeMs > 60_000;
|
|
397
|
+
} catch { stale = false; }
|
|
398
|
+
if (!stale) throw new Error("another ownership key command is active for this repository");
|
|
399
|
+
try { unlinkSync(lockPath); } catch {}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
throw new Error("another ownership key command is active for this repository");
|
|
403
|
+
}
|
|
380
404
|
function flag(args, name) { const i = args.indexOf(name); return i < 0 ? undefined : args[i + 1]; }
|
|
381
405
|
|
|
382
406
|
async function enroll(args, d) {
|
|
@@ -483,15 +507,15 @@ async function resumeRootEnrollment(log, d) {
|
|
|
483
507
|
print(`Ownership root enrollment recovery completed: ${pin}; active keys: ${state.keys.length}`);
|
|
484
508
|
return 0;
|
|
485
509
|
}
|
|
510
|
+
function delegateTrustSync(deps, run, print) {
|
|
511
|
+
const result = delegateToDesktop({ run, command: deps.desktopCommand ?? [process.execPath, ...(process.argv[1] ? [path.resolve(process.argv[1])] : []), ...process.argv.slice(2)], ...(deps.desktopOptions ?? {}) });
|
|
512
|
+
if (result.output) print(result.output.trimEnd());
|
|
513
|
+
if (result.status !== 0) throw new Error(`desktop-session trust-sync failed (exit ${result.status}); see output above`);
|
|
514
|
+
return 0;
|
|
515
|
+
}
|
|
486
516
|
/** Advance this host's protected checkpoint to the committed key log (after another host approved or revoked a key). */
|
|
487
517
|
async function trustSync(_args, d) {
|
|
488
|
-
|
|
489
|
-
const result = delegateToDesktop({ run: d.run, command: d.desktopCommand ?? [process.execPath, ...process.argv.slice(1)], ...(d.desktopOptions ?? {}) });
|
|
490
|
-
if (result.output) d.print(result.output.trimEnd());
|
|
491
|
-
if (result.status !== 0) throw new Error(`desktop-session trust-sync failed (exit ${result.status}); see output above`);
|
|
492
|
-
return 0;
|
|
493
|
-
}
|
|
494
|
-
d = { ...d, label: "RightKit ownership trust-sync" };
|
|
518
|
+
d = { ...d, label: "RightKit ownership trust-sync", gate: trustSyncGate(d) };
|
|
495
519
|
const log = readLog(d.root);
|
|
496
520
|
const pin = log.events[0]?.payload?.rootFingerprint;
|
|
497
521
|
const state = verifyKeyLog(log, { rootFingerprint: pin });
|
|
@@ -500,14 +524,32 @@ async function trustSync(_args, d) {
|
|
|
500
524
|
if (!trust?.trust?.rootFingerprint) throw new Error("protected root trust is not enrolled on this host; run ownership enroll");
|
|
501
525
|
if (trust.trust.rootFingerprint !== pin) throw new Error("committed key log root does not match this host's protected trust");
|
|
502
526
|
const baseline = d.baseline ?? parse(readFileSync(path.join(here, "ownership-default-policy.json"), "utf8"), "ownership default policy");
|
|
503
|
-
await installCheckpoint(log, pin, baseline, d);
|
|
527
|
+
const installed = await installCheckpoint(log, pin, baseline, d);
|
|
528
|
+
if (installed === false) { d.print("trust already current"); return 0; }
|
|
504
529
|
postVerify(d.root, log, d);
|
|
505
530
|
d.print(`Ownership trust synced to key-log sequence ${log.events.length - 1}; active keys: ${state.keys.length}`);
|
|
506
531
|
return 0;
|
|
507
532
|
}
|
|
533
|
+
/**
|
|
534
|
+
* Informed-consent gate: summarize the exact request bytes (digest-checked) against the installed trust,
|
|
535
|
+
* skip when nothing changes, otherwise require explicit approval before any elevation prompt.
|
|
536
|
+
*/
|
|
537
|
+
function trustSyncGate(d) {
|
|
538
|
+
return (request) => {
|
|
539
|
+
const existing = (d.trustLoader ?? trustState)()?.trust ?? null;
|
|
540
|
+
const summary = (d.summarize ?? summarizeRequestFile)({ file: request.file, digest: request.digest, existing, ...(d.summaryInputs ?? {}) });
|
|
541
|
+
if (!summary.changed) return false;
|
|
542
|
+
(d.confirm ?? confirmTrustChange)({ lines: summary.lines, run: d.run, interactiveRun: d.run.interactive, print: d.print, hostPlatform: d.hostPlatform, ...(d.confirmOptions ?? {}) });
|
|
543
|
+
return true;
|
|
544
|
+
};
|
|
545
|
+
}
|
|
508
546
|
async function installCheckpoint(log, rootFingerprint, baseline, d) {
|
|
509
547
|
const request = makeTrustRequest(log, rootFingerprint, baseline, d.authority);
|
|
510
|
-
try {
|
|
548
|
+
try {
|
|
549
|
+
if (d.gate && (await d.gate(request)) === false) return false;
|
|
550
|
+
await (d.elevate ?? elevate)(d.run, request.file, request.digest, d.label ? { label: d.label } : undefined);
|
|
551
|
+
return true;
|
|
552
|
+
}
|
|
511
553
|
finally { await import("node:fs").then(({ unlinkSync }) => unlinkSync(request.file)).catch(() => {}); }
|
|
512
554
|
}
|
|
513
555
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Launch helpers for the protected ownership trust update. These only change HOW the
|
|
2
2
|
// privileged step is authorized or started; the verified trust child itself is unchanged.
|
|
3
|
-
import { mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync } from "node:fs";
|
|
3
|
+
import { mkdtempSync, readFileSync, readSync, rmSync, writeFileSync, existsSync } from "node:fs";
|
|
4
4
|
import os from "node:os";
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import { randomBytes } from "node:crypto";
|
|
@@ -55,25 +55,47 @@ export function windowsProcessIsElevated(run) {
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
export function needsDesktopDelegation({ hostPlatform = process.platform, env = process.env, run, elevatedCheck = windowsProcessIsElevated }) {
|
|
58
|
-
if (hostPlatform !== "win32"
|
|
59
|
-
|
|
58
|
+
if (hostPlatform !== "win32") return false;
|
|
59
|
+
const remoteOrElevated = isSshSession(env) || elevatedCheck(run) === true;
|
|
60
|
+
// The delegated-child marker is an env var any caller can set. Honoring it from an elevated or SSH process
|
|
61
|
+
// would skip the human gate entirely, so there it is refused outright.
|
|
62
|
+
if (env[DESKTOP_DELEGATED_ENV] === "1") {
|
|
63
|
+
if (remoteOrElevated) throw new Error(`${DESKTOP_DELEGATED_ENV} is not honored in an elevated or SSH process; trust-sync must run in a normal interactive desktop session`);
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
return remoteOrElevated;
|
|
60
67
|
}
|
|
61
68
|
|
|
62
69
|
const psq = (value) => `'${String(value).replaceAll("'", "''")}'`;
|
|
63
70
|
|
|
64
|
-
export function buildDesktopWrapper(command, logPath, codePath) {
|
|
71
|
+
export function buildDesktopWrapper(command, logPath, codePath, cwd) {
|
|
65
72
|
const invocation = command.map(psq).join(" ");
|
|
73
|
+
const log = (expr) => `${expr} | Out-File -Append -Encoding utf8 -FilePath ${psq(logPath)}`;
|
|
66
74
|
return [
|
|
67
75
|
"$ErrorActionPreference='Continue'",
|
|
68
76
|
`$env:${DESKTOP_DELEGATED_ENV}='1'`,
|
|
69
77
|
"$c=1",
|
|
70
|
-
`try {
|
|
78
|
+
...(cwd ? [`try { Set-Location -LiteralPath ${psq(cwd)} -ErrorAction Stop } catch { ${log("$_")}; Set-Content -LiteralPath ${psq(codePath)} -Value 1; exit 1 }`] : []),
|
|
79
|
+
log(`"desktop task cwd: $((Get-Location).Path)"`),
|
|
80
|
+
log(`"desktop task argv: " + ${psq(invocation)}`),
|
|
81
|
+
`try { & ${invocation} *>&1 | ForEach-Object { "$_" } | Out-File -Append -Encoding utf8 -FilePath ${psq(logPath)}; $c=$LASTEXITCODE } catch { ${log("$_")}; $c=1 }`,
|
|
71
82
|
`Set-Content -LiteralPath ${psq(codePath)} -Value $c`,
|
|
72
83
|
"exit $c",
|
|
73
84
|
"",
|
|
74
85
|
].join("\r\n");
|
|
75
86
|
}
|
|
76
87
|
|
|
88
|
+
/** Decode a relayed task log: UTF-16LE (BOM or NUL-interleaved) or UTF-8, never mojibake. */
|
|
89
|
+
export function decodeLog(buffer) {
|
|
90
|
+
const b = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer);
|
|
91
|
+
if (b.length >= 2 && b[0] === 0xff && b[1] === 0xfe) return b.subarray(2).toString("utf16le");
|
|
92
|
+
if (b.length >= 2 && b[0] === 0xfe && b[1] === 0xff) return Buffer.from(b.subarray(2)).swap16().toString("utf16le");
|
|
93
|
+
const sample = b.subarray(0, 512);
|
|
94
|
+
let nuls = 0; for (const x of sample) if (x === 0) nuls += 1;
|
|
95
|
+
if (sample.length >= 2 && nuls * 4 >= sample.length) return b.toString("utf16le");
|
|
96
|
+
return b.toString("utf8").replace(/^\uFEFF/, "");
|
|
97
|
+
}
|
|
98
|
+
|
|
77
99
|
export function buildSchtasksCreate(taskName, wrapperPath, user) {
|
|
78
100
|
return [SCHTASKS, ["/Create", "/TN", taskName, "/TR", `"${POWERSHELL}" -NoProfile -ExecutionPolicy Bypass -File "${wrapperPath}"`, "/SC", "ONCE", "/ST", "00:00", "/RU", user, "/IT", "/RL", "LIMITED", "/F"]];
|
|
79
101
|
}
|
|
@@ -84,7 +106,7 @@ const USER_RE = /^[^\\/:*?"<>|\s][^\\/:*?"<>|]*\\[^\\/:*?"<>|]+$/;
|
|
|
84
106
|
* Run `command` once in the logged-on user's interactive, non-elevated desktop session via a
|
|
85
107
|
* one-shot scheduled task, wait for it, then clean up. Returns { status, output }.
|
|
86
108
|
*/
|
|
87
|
-
export function delegateToDesktop({ run, command, tmp = os.tmpdir(), sleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms), now = Date.now, timeoutMs = 10 * 60 * 1000, pollMs = 1000, startGraceMs = 15000, suffix = randomBytes(6).toString("hex") }) {
|
|
109
|
+
export function delegateToDesktop({ run, command, tmp = os.tmpdir(), sleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms), now = Date.now, cwd = process.cwd(), timeoutMs = 10 * 60 * 1000, pollMs = 1000, startGraceMs = 15000, suffix = randomBytes(6).toString("hex") }) {
|
|
88
110
|
const env = windowsPowerShellEnvironment();
|
|
89
111
|
const who = run(POWERSHELL, ["-NoProfile", "-NonInteractive", "-Command", "(Get-CimInstance -ClassName Win32_ComputerSystem).UserName"], { env, encoding: "utf8" });
|
|
90
112
|
const user = String(who.stdout ?? "").trim();
|
|
@@ -97,7 +119,7 @@ export function delegateToDesktop({ run, command, tmp = os.tmpdir(), sleep = (ms
|
|
|
97
119
|
const taskName = `RightKitOwnershipTrustSync-${suffix}`;
|
|
98
120
|
let created = false;
|
|
99
121
|
try {
|
|
100
|
-
writeFileSync(wrapperPath, buildDesktopWrapper(command, logPath, codePath), { flag: "wx", mode: 0o600 });
|
|
122
|
+
writeFileSync(wrapperPath, buildDesktopWrapper(command, logPath, codePath, path.resolve(cwd)), { flag: "wx", mode: 0o600 });
|
|
101
123
|
const [createCmd, createArgs] = buildSchtasksCreate(taskName, wrapperPath, user);
|
|
102
124
|
const c = run(createCmd, createArgs, { env, encoding: "utf8" });
|
|
103
125
|
if (c.status !== 0) throw new Error(`could not create the desktop-session task: ${String(c.stderr || c.stdout || "").trim()}`);
|
|
@@ -115,10 +137,94 @@ export function delegateToDesktop({ run, command, tmp = os.tmpdir(), sleep = (ms
|
|
|
115
137
|
sleep(pollMs);
|
|
116
138
|
}
|
|
117
139
|
const status = Number.parseInt(readFileSync(codePath, "utf8").replace(/^\uFEFF/, "").trim(), 10);
|
|
118
|
-
const output = existsSync(logPath) ? readFileSync(logPath
|
|
140
|
+
const output = existsSync(logPath) ? decodeLog(readFileSync(logPath)) : "";
|
|
119
141
|
return { status: Number.isInteger(status) ? status : 1, output, user };
|
|
120
142
|
} finally {
|
|
143
|
+
if (created) { try { run(SCHTASKS, ["/End", "/TN", taskName], { env, encoding: "utf8" }); } catch {} }
|
|
121
144
|
if (created) { try { run(SCHTASKS, ["/Delete", "/TN", taskName, "/F"], { env, encoding: "utf8" }); } catch {} }
|
|
122
145
|
try { rmSync(dir, { recursive: true, force: true }); } catch {}
|
|
123
146
|
}
|
|
124
147
|
}
|
|
148
|
+
|
|
149
|
+
// ---- Informed consent: show WHAT is about to be installed before any password/UAC prompt ----
|
|
150
|
+
export const CONFIRM_TITLE = "RightKit ownership trust-sync";
|
|
151
|
+
|
|
152
|
+
/** Escape free text for an AppleScript string expression; newlines become `return` concatenations. */
|
|
153
|
+
export function appleScriptText(value) {
|
|
154
|
+
const text = String(value).replaceAll("\0", "").replaceAll("\r\n", "\n").replaceAll("\r", "\n");
|
|
155
|
+
return text.split("\n").map((line) => `"${line.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`).join(" & return & ");
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Cap a summary for a dialog; the dropped tail is reported as "...and N more". */
|
|
159
|
+
export function capSummary(lines, { maxLines = 16, maxChars = 1600 } = {}) {
|
|
160
|
+
const out = [];
|
|
161
|
+
let used = 0;
|
|
162
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
163
|
+
const line = lines[i].length > 300 ? `${lines[i].slice(0, 299)}\u2026` : lines[i];
|
|
164
|
+
if (out.length >= maxLines - 1 || used + line.length > maxChars) { out.push(`\u2026and ${lines.length - i} more`); return out.join("\n"); }
|
|
165
|
+
out.push(line); used += line.length + 1;
|
|
166
|
+
}
|
|
167
|
+
return out.join("\n");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function buildMacConfirmScript(text) {
|
|
171
|
+
// The text field takes keyboard focus, so stray typing never presses a button; approval needs "yes" typed.
|
|
172
|
+
return `display dialog ${appleScriptText(`${text}\n\nType yes below, then click Approve.`)} default answer "" with title ${appleScriptText(CONFIRM_TITLE)} buttons {"Cancel","Approve\u2026"} default button "Approve\u2026" cancel button "Cancel" with icon caution`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export const WINDOWS_APPROVE_PREFIX = "RK_CONFIRM_APPROVED:";
|
|
176
|
+
export const MAC_APPROVE_BUTTON = "Approve\u2026";
|
|
177
|
+
export const CONFIRM_TIMEOUT_MS = 10 * 60 * 1000;
|
|
178
|
+
|
|
179
|
+
export function buildWindowsConfirmScript(text, nonce = "") {
|
|
180
|
+
const lines = String(text).replaceAll("\0", "").split(/\r?\n/).map(psq).join(", ");
|
|
181
|
+
// user32 MessageBoxW with no owner: OKCANCEL | ICONWARNING | SETFOREGROUND | TOPMOST so it is drawn in front.
|
|
182
|
+
return [
|
|
183
|
+
"Add-Type -Namespace RK -Name Dlg -MemberDefinition '[DllImport(\"user32.dll\", CharSet=CharSet.Unicode)] public static extern int MessageBoxW(System.IntPtr h, string t, string c, uint f);'",
|
|
184
|
+
`$r=[RK.Dlg]::MessageBoxW([IntPtr]::Zero, ([string]::Join([Environment]::NewLine, @(${lines}))), ${psq(CONFIRM_TITLE)}, 0x50031)`,
|
|
185
|
+
// Only the explicit OK branch prints the per-run token; every other outcome (Cancel, close, error, kill) prints nothing.
|
|
186
|
+
`if ($r -eq 1) { [Console]::Out.WriteLine(${psq(`${WINDOWS_APPROVE_PREFIX}${nonce}`)}); exit 0 } else { exit 1 }`,
|
|
187
|
+
].join("; ");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function readLineSync() {
|
|
191
|
+
const buf = Buffer.alloc(1); let line = "";
|
|
192
|
+
for (;;) { let n = 0; try { n = readSync(0, buf, 0, 1, null); } catch { return line; } if (n === 0 || buf[0] === 10) return line; line += String.fromCharCode(buf[0]); }
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Ask the human to approve the summarized change. Prints the summary, then: TTY -> type `yes`;
|
|
197
|
+
* macOS GUI -> osascript dialog; Windows GUI -> MessageBox. Throws "cancelled by user" unless approved.
|
|
198
|
+
*/
|
|
199
|
+
export function confirmTrustChange({ lines, run, interactiveRun, print = console.log, hostPlatform = process.platform, tty = hasTerminal(), readLine = readLineSync, nonce = randomBytes(16).toString("hex") }) {
|
|
200
|
+
const text = capSummary(lines);
|
|
201
|
+
print(lines.join("\n"));
|
|
202
|
+
const cancelled = () => new Error("cancelled by user");
|
|
203
|
+
if (tty) {
|
|
204
|
+
print("Type 'yes' to approve this trust update (anything else cancels):");
|
|
205
|
+
if (String(readLine() ?? "").trim() !== "yes") throw cancelled();
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (hostPlatform === "darwin") {
|
|
209
|
+
const r = run("/usr/bin/osascript", ["-e", buildMacConfirmScript(text)], { encoding: "utf8", timeout: CONFIRM_TIMEOUT_MS, killSignal: "SIGKILL" });
|
|
210
|
+
// Affirmative result only: exit 0 AND the Approve button reported. Anything else (cancel, kill, timeout, error) cancels.
|
|
211
|
+
const out = String(r?.stdout ?? "");
|
|
212
|
+
if (r?.status === 0 && out.includes(`button returned:${MAC_APPROVE_BUTTON}`)) {
|
|
213
|
+
if (/text returned:\s*yes\s*$/i.test(out.trim())) return;
|
|
214
|
+
throw new Error("not approved: type yes in the dialog's field before clicking Approve");
|
|
215
|
+
}
|
|
216
|
+
const stderr = String(r?.stderr ?? "").trim();
|
|
217
|
+
// osascript reports Cancel (button or Escape) as error -128; everything else is a failure to ask, not a refusal.
|
|
218
|
+
if (/\(-128\)/.test(stderr)) throw new Error("cancelled by user (Cancel clicked or Escape pressed)");
|
|
219
|
+
throw new Error(`approval dialog could not be shown or read (exit ${r?.status ?? "none"}${r?.signal ? `, ${r.signal}` : ""}${r?.error ? `, ${r.error.message}` : ""}): ${stderr || String(r?.stdout ?? "").trim() || "no output"}`);
|
|
220
|
+
}
|
|
221
|
+
if (hostPlatform === "win32") {
|
|
222
|
+
const encoded = Buffer.from(buildWindowsConfirmScript(text, nonce), "utf16le").toString("base64");
|
|
223
|
+
// Must be an explicitly SHOWN process: a hidden STARTUPINFO would hide the dialog's first window.
|
|
224
|
+
const r = (interactiveRun ?? run)(POWERSHELL, ["-NoProfile", "-Sta", "-EncodedCommand", encoded], { env: windowsPowerShellEnvironment(), encoding: "utf8", windowsHide: false, timeout: CONFIRM_TIMEOUT_MS, killSignal: "SIGKILL" });
|
|
225
|
+
// Affirmative result only: exit 0 AND the exact per-run token only the OK branch emits. Kill/timeout/error/empty = cancel.
|
|
226
|
+
if (r?.status !== 0 || r.error || !String(r.stdout ?? "").split(/\r?\n/).includes(`${WINDOWS_APPROVE_PREFIX}${nonce}`)) throw cancelled();
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
throw new Error("no way to ask for approval on this platform; run from a terminal");
|
|
230
|
+
}
|
package/ownership-key-trust.mjs
CHANGED
|
@@ -6,6 +6,9 @@ import { spawnSync, windowsPowerShellEnvironment } from "./background-process.mj
|
|
|
6
6
|
import { canonicalJson, canonicalRepositoryIdentity, __testOnly as approvalTestOnly, evaluateWindowsAcl, validateTrustRegistry } from "./ownership-approval.mjs";
|
|
7
7
|
import { verifyKeyLog } from "./ownership-key-log.mjs";
|
|
8
8
|
import { SCANNER_VERSION } from "./ownership.mjs";
|
|
9
|
+
import { presenceSummary, requireWindowsPresence } from "./ownership-windows-presence.mjs";
|
|
10
|
+
import { ALLOWLIST_FILE, validateAllowlist } from "./ownership-dependency-allowlist.mjs";
|
|
11
|
+
import { computePolicyBundle } from "./ownership-policy-bundle.mjs";
|
|
9
12
|
|
|
10
13
|
const HEX = /^[a-f0-9]{64}$/;
|
|
11
14
|
const MAX_REQUEST_BYTES = 2 * 1024 * 1024;
|
|
@@ -295,13 +298,28 @@ function versionAtLeast(value, floor) {
|
|
|
295
298
|
* its own catalog digest/version and scanner version. Floors only ever rise; an older installed
|
|
296
299
|
* release leaves the existing floors untouched. The request never supplies floors.
|
|
297
300
|
*/
|
|
298
|
-
export function raiseFloorsToInstalledRelease(trust, { manifest = installedCatalog(), scannerVersion = SCANNER_VERSION } = {}) {
|
|
301
|
+
export function raiseFloorsToInstalledRelease(trust, { manifest = installedCatalog(), scannerVersion = SCANNER_VERSION, policy } = {}) {
|
|
299
302
|
const catalogVersion = String(manifest?.version ?? "");
|
|
300
303
|
if (!/^\d+(?:\.\d+)*$/.test(catalogVersion) || !/^\d+(?:\.\d+)*$/.test(scannerVersion)) return trust;
|
|
301
304
|
if (!versionAtLeast(catalogVersion, trust.minimumCatalogVersion ?? "0") || !versionAtLeast(scannerVersion, trust.minimumScannerVersion ?? "0")) return trust;
|
|
302
305
|
const raised = { ...trust, minimumCatalogVersion: catalogVersion, minimumScannerVersion: scannerVersion, minimumCatalogDigest: createHash("sha256").update(canonicalJson(manifest)).digest("hex") };
|
|
303
|
-
|
|
304
|
-
|
|
306
|
+
// Step 2/3: pin the policy bundle (catalog + registry + dependency allowlist + scanner version + scanner source
|
|
307
|
+
// digests) and copy the proposed allowlist into protected trust. A malformed allowlist leaves trust untouched.
|
|
308
|
+
let pinned;
|
|
309
|
+
try { pinned = pinInstalledPolicy(raised, { manifest, scannerVersion, policy }); } catch { return trust; }
|
|
310
|
+
if (!pinned || !validateTrustRegistry(pinned).ok) return trust;
|
|
311
|
+
return pinned;
|
|
312
|
+
}
|
|
313
|
+
/** Allowlist and registry bytes of the installed release; the request never supplies them. */
|
|
314
|
+
export function installedPolicyInputs() {
|
|
315
|
+
const dir = path.dirname(fileURLToPath(import.meta.url));
|
|
316
|
+
return { allowlist: JSON.parse(readFileSync(path.join(dir, ALLOWLIST_FILE), "utf8")), registry: installedRepositoryRegistry() };
|
|
317
|
+
}
|
|
318
|
+
function pinInstalledPolicy(trust, { manifest, scannerVersion, policy }) {
|
|
319
|
+
const inputs = policy ?? installedPolicyInputs();
|
|
320
|
+
if (!validateAllowlist(inputs.allowlist).ok) return null;
|
|
321
|
+
const bundle = inputs.bundle ?? computePolicyBundle({ manifest, registry: inputs.registry, allowlist: inputs.allowlist, scannerVersion });
|
|
322
|
+
return { ...trust, policyBundle: bundle, dependencyAllowlist: copyJson(inputs.allowlist) };
|
|
305
323
|
}
|
|
306
324
|
function installedCatalog() { return JSON.parse(readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), "rightkit-ownership.json"), "utf8")); }
|
|
307
325
|
|
|
@@ -367,7 +385,7 @@ export function mergeRepositoriesFromInstalledRelease(trust, { firstInstall = fa
|
|
|
367
385
|
return { trust: merged, warnings };
|
|
368
386
|
}
|
|
369
387
|
|
|
370
|
-
export function performProtectedTrustUpdate({ log, rootFingerprint, baseline, authority } = {}) {
|
|
388
|
+
export function performProtectedTrustUpdate({ log, rootFingerprint, baseline, authority } = {}, { presence } = {}) {
|
|
371
389
|
assertElevated();
|
|
372
390
|
const file = OWNERSHIP_TRUST_PATH[process.platform];
|
|
373
391
|
const directory = path.dirname(file);
|
|
@@ -383,6 +401,9 @@ export function performProtectedTrustUpdate({ log, rootFingerprint, baseline, au
|
|
|
383
401
|
const merged = mergeRepositoriesFromInstalledRelease(prepared, { firstInstall: existing == null });
|
|
384
402
|
const trust = raiseFloorsToInstalledRelease(merged.trust);
|
|
385
403
|
if (trust.keyAuthority) validateCommittedAuthority(trust.keyAuthority, log, trust.rootFingerprint, trust.keyCheckpoint);
|
|
404
|
+
// Windows: elevation is not a human gate (silent-elevation policy, already-elevated SSH sessions), so the
|
|
405
|
+
// updater itself demands an on-screen password verification by the signed-in user before any write.
|
|
406
|
+
if (process.platform === "win32") (presence ?? requireWindowsPresence)({ summary: presenceSummary({ existing, next: trust }) });
|
|
386
407
|
atomicProtectedWrite(file, trust);
|
|
387
408
|
auditProtected(file);
|
|
388
409
|
return { path: file, trust, warnings: merged.warnings };
|