@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.
@@ -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
+ }
@@ -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
- try { lockFd = openSync(lockPath, "wx", 0o600); } catch { throw new Error("another ownership key command is active for this repository"); }
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
- if (needsDesktopDelegation({ hostPlatform: d.hostPlatform, env: d.env, run: d.run, elevatedCheck: d.elevatedCheck })) {
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 { await (d.elevate ?? elevate)(d.run, request.file, request.digest, d.label ? { label: d.label } : undefined); }
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" || env[DESKTOP_DELEGATED_ENV] === "1") return false;
59
- return isSshSession(env) || elevatedCheck(run) === true;
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 { & ${invocation} *>&1 | Tee-Object -FilePath ${psq(logPath)}; $c=$LASTEXITCODE } catch { $_ | Out-File -Append -FilePath ${psq(logPath)}; $c=1 }`,
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, "utf8") : "";
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
+ }
@@ -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
- if (!validateTrustRegistry(raised).ok) return trust;
304
- return raised;
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 };