@rightkit/release 0.2.135 → 0.2.136

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/README.md +62 -0
  2. package/addon-command.mjs +53 -34
  3. package/apple-profile-renew.mjs +30 -12
  4. package/artifact-storage.mjs +47 -10
  5. package/background-process.mjs +18 -0
  6. package/build-release.mjs +1 -1
  7. package/cache-command.mjs +4 -3
  8. package/cargo-contract.mjs +1 -1
  9. package/cargo-guard.mjs +4 -3
  10. package/cargo-registry-readback.mjs +51 -0
  11. package/cargo-target.mjs +3 -1
  12. package/cli/right-release.mjs +26 -0
  13. package/codesign-environment.mjs +26 -2
  14. package/codesign-shim.mjs +2 -1
  15. package/committed-source.mjs +3 -3
  16. package/development.mjs +7 -7
  17. package/direct-bootstrap.mjs +19 -9
  18. package/dmg-build.mjs +117 -0
  19. package/dmg-layout.mjs +123 -0
  20. package/github-release.mjs +74 -37
  21. package/heavy-command.mjs +3 -2
  22. package/install-app.mjs +358 -0
  23. package/inventory-check.mjs +980 -0
  24. package/macos-signing-session.mjs +3 -0
  25. package/managed-cargo.mjs +23 -19
  26. package/managed-host-secrets.mjs +30 -0
  27. package/managed-roots.mjs +39 -0
  28. package/model-promote.mjs +31 -37
  29. package/notary-auth.mjs +4 -0
  30. package/osascript-guard.mjs +24 -0
  31. package/ownership-approval-command.mjs +15 -4
  32. package/ownership-approval.mjs +114 -49
  33. package/ownership-availability.mjs +25 -0
  34. package/ownership-catalog-config.json +18 -0
  35. package/ownership-catalog.mjs +94 -0
  36. package/ownership-command.mjs +17 -10
  37. package/ownership-declaration.json +15 -0
  38. package/ownership-default-policy.json +1 -11
  39. package/ownership-evidence.mjs +10 -8
  40. package/ownership-git-inventory.mjs +29 -1
  41. package/ownership-helper-paths.json +2 -2
  42. package/ownership-hooks.mjs +107 -0
  43. package/ownership-internal-owners.json +44 -0
  44. package/ownership-key-authority.mjs +112 -7
  45. package/ownership-key-command.mjs +76 -19
  46. package/ownership-key-trust.mjs +97 -9
  47. package/ownership-lockfiles.mjs +311 -0
  48. package/ownership-report-command.mjs +12 -3
  49. package/ownership-report.mjs +20 -17
  50. package/ownership-repositories.json +19 -0
  51. package/ownership-scan-worker.mjs +96 -11
  52. package/ownership-source.mjs +39 -0
  53. package/ownership-staged.mjs +193 -0
  54. package/ownership-verdict.mjs +74 -0
  55. package/ownership.mjs +215 -107
  56. package/package.json +1 -1
  57. package/protected-credentials.mjs +111 -0
  58. package/prune-r2.mjs +4 -3
  59. package/pub-upload.mjs +3 -85
  60. package/publication-artifacts.mjs +108 -0
  61. package/publication-gate.mjs +28 -7
  62. package/publish-cargo.mjs +47 -16
  63. package/publish-swift.mjs +46 -17
  64. package/publish-update.mjs +61 -39
  65. package/r2-verified-upload.mjs +41 -0
  66. package/registry-parity.mjs +5 -3
  67. package/release-state.mjs +1 -0
  68. package/release-token.mjs +2 -0
  69. package/rightkit-drift.mjs +3 -2
  70. package/rightkit-ownership.json +2028 -139
  71. package/rightkit-signing-agent.mjs +5 -3
  72. package/rightkit-sync.mjs +6 -2
  73. package/rightkit-versions.json +213 -103
  74. package/runtime-artifact-manifest.mjs +20 -6
  75. package/sign-updater.mjs +5 -0
  76. package/signing-agent-client.mjs +2 -1
  77. package/signing-agent-install.mjs +3 -2
  78. package/standalone-clone-verify.mjs +3 -2
  79. package/tauri-bundle-marker.mjs +3 -2
  80. package/upload-large.mjs +36 -82
  81. package/verifier.mjs +5 -1
@@ -2,10 +2,10 @@
2
2
  import { createHash, createPublicKey, verify as cryptoVerify } from "node:crypto";
3
3
  import { existsSync, lstatSync, readFileSync, statSync } from "node:fs";
4
4
  import path from "node:path";
5
- import { spawnSync } from "./background-process.mjs";
5
+ import { spawnSync, windowsPowerShellEnvironment } from "./background-process.mjs";
6
6
  import { verifyKeyLog, verifyOwnershipSignature } from "./ownership-key-log.mjs";
7
7
 
8
- export const APPROVAL_SCHEMA = 1;
8
+ export const APPROVAL_SCHEMA = 2;
9
9
  export const REQUIRED_HOSTS = Object.freeze(["windows", "macos"]);
10
10
  export const DEFAULT_TRUST_PATH = Object.freeze({
11
11
  win32: path.join("C:\\ProgramData", "Orthic Labs", "RightKit", "ownership", "trust.json"),
@@ -89,31 +89,75 @@ export function keyIdForPublicKey(host, publicKey) {
89
89
  return prefix + createHash("sha256").update(key.export({ type: "spki", format: "der" })).digest("hex");
90
90
  }
91
91
  export function occurrenceHash(finding) {
92
- return sha256({ fingerprint: finding.fingerprint, file: finding.file, line: finding.line ?? 0, text: finding.text ?? "" });
92
+ if (!finding || !validFingerprint(finding.fingerprint) || !occurrencePath(finding.file)) throw new Error("invalid occurrence fingerprint or path");
93
+ const kind = finding.kind ?? (finding.ecosystem ? "dependency" : "source");
94
+ if (kind === "source") {
95
+ if (typeof finding.lineText !== "string" || /[\r\n]/.test(finding.lineText) || !Number.isSafeInteger(finding.ordinal) || finding.ordinal < 0) throw new Error("source occurrence requires full lineText and nonnegative ordinal");
96
+ return sha256({ v: 2, kind, fingerprint: finding.fingerprint, path: finding.file, lineText: finding.lineText, ordinal: finding.ordinal });
97
+ }
98
+ if (kind === "transitive-dependency") {
99
+ if (!["cargo", "npm"].includes(finding.ecosystem) || typeof finding.name !== "string" || !finding.name || typeof finding.resolvedVersion !== "string" || !finding.resolvedVersion || typeof finding.source !== "string" || !finding.source
100
+ || !Array.isArray(finding.dependencyPath) || finding.dependencyPath.length < 3 || finding.dependencyPath.some((name) => typeof name !== "string" || !name) || finding.dependencyPath.at(-1) !== finding.name) throw new Error("malformed transitive dependency occurrence identity");
101
+ return sha256({ v: 2, kind, fingerprint: finding.fingerprint, path: finding.file, ecosystem: finding.ecosystem, name: finding.name, resolvedVersion: finding.resolvedVersion, source: finding.source, dependencyPath: finding.dependencyPath });
102
+ }
103
+ if (kind !== "dependency") throw new Error("unknown occurrence kind");
104
+ const ecosystem = finding.ecosystem === "swiftpm" ? "swift" : finding.ecosystem;
105
+ if (!["cargo", "npm", "swift"].includes(ecosystem) || typeof finding.name !== "string" || !finding.name || typeof finding.section !== "string" || !finding.section
106
+ || !nullableString(finding.alias) || !nullableString(finding.requirement) || !nullableString(finding.resolvedVersion)
107
+ || !Array.isArray(finding.features) || finding.features.some((x) => typeof x !== "string" || !x)
108
+ || ![true, false, null].includes(finding.defaultFeatures) || typeof finding.source !== "string" || !/^(?:registry|git\+[^\s]+|path:.+)$/.test(finding.source)) throw new Error("malformed dependency occurrence identity");
109
+ return sha256({ v: 2, kind, fingerprint: finding.fingerprint, path: finding.file, ecosystem, name: finding.name, alias: finding.alias, section: finding.section, requirement: finding.requirement,
110
+ features: [...new Set(finding.features)].sort(), defaultFeatures: finding.defaultFeatures, source: finding.source, resolvedVersion: finding.resolvedVersion });
111
+ }
112
+ // Schema-v2 envelopes retain source/dependency kinds; lock routes are dependency occurrences.
113
+ const exceptionOccurrenceKind = (f) => f.kind === "transitive-dependency" ? "dependency" : f.kind ?? (f.ecosystem ? "dependency" : "source");
114
+ function nullableString(value) { return value === null || typeof value === "string" && value.length > 0; }
115
+ function validFingerprint(value) { return typeof value === "string" && /^[a-z0-9][a-z0-9._-]{1,127}$/.test(value) && !HEX.test(value); }
116
+ function occurrencePath(value) { return typeof value === "string" && !!value && value !== "." && exactRepositoryPath(value) && !/^[A-Za-z]:/.test(value); }
117
+ function exactKeys(value, keys) { return !!value && typeof value === "object" && !Array.isArray(value) && Object.keys(value).sort().join("\0") === [...keys].sort().join("\0"); }
118
+ function validDate(value) { return typeof value === "string" && /^\d{4}-\d{2}-\d{2}$/.test(value) && !Number.isNaN(Date.parse(`${value}T00:00:00Z`)) && new Date(`${value}T00:00:00Z`).toISOString().slice(0, 10) === value; }
119
+ export function policyEntryDigest(manifest, fingerprint) {
120
+ if (!validFingerprint(fingerprint) || !Array.isArray(manifest?.capabilities) || manifest.capabilities.some((c) => !Array.isArray(c?.fingerprints))) throw new Error(`missing or malformed catalog policy entry: ${fingerprint}`);
121
+ const matches = (manifest?.capabilities ?? []).flatMap((c) => (c.fingerprints ?? []).filter((f) => f.id === fingerprint).map((f) => ({ ...f, capability: c.capability, package: c.package, tier: f.tier ?? c.tier })));
122
+ if (matches.length !== 1 || !matches[0].capability || !matches[0].package || !matches[0].tier) throw new Error(`missing, ambiguous, or malformed catalog policy entry: ${fingerprint}`);
123
+ return sha256(matches[0]);
93
124
  }
94
125
  export function approvalPayload(record) {
95
126
  return record?.payload;
96
127
  }
97
128
 
98
- function validateRecord(record) {
129
+ export function validateRecord(record) {
99
130
  const p = [];
100
- if (!record || record.schema !== APPROVAL_SCHEMA || !record.payload || !record.signature) return ["malformed or unsigned approval record"];
131
+ if (!exactKeys(record, ["schema", "payload", "signature"]) || record.schema !== APPROVAL_SCHEMA) return ["approval envelope must use schema 2 and exactly schema, payload, signature"];
101
132
  const r = record.payload;
102
- for (const key of ["repositoryIdentity", "catalogDigest", "fingerprint", "paths", "occurrenceHashes", "reason", "owner", "expires", "requestId", "trackingRef"]) {
103
- if (r[key] === undefined || r[key] === "" || (Array.isArray(r[key]) && !r[key].length)) p.push(`missing ${key}`);
104
- }
133
+ if (!exactKeys(r, ["repositoryIdentity", "fingerprint", "policyDigest", "minimumScannerVersion", "minimumCatalogVersion", "occurrences", "reason", "owner", "issued", "expires", "requestId", "trackingRef"])) return ["approval payload has missing or unknown keys"];
105
134
  if (typeof r.repositoryIdentity !== "string" || r.repositoryIdentity.length > 512) p.push("invalid repository identity");
106
- if (!HEX.test(r.catalogDigest ?? "")) p.push("invalid catalog digest");
107
- if (!Array.isArray(r.paths) || r.paths.some((x) => typeof x !== "string" || !x || x.startsWith("/") || x.includes("\\") || x.split("/").some((s) => !s || s === "." || s === "..") || /[*?\[\]{}]/.test(x))) p.push("paths must be exact repository-relative paths");
108
- if (!Array.isArray(r.occurrenceHashes) || r.occurrenceHashes.some((x) => !HEX.test(x)) || new Set(r.occurrenceHashes).size !== r.occurrenceHashes.length) p.push("invalid occurrence hashes");
109
- if (!/^\d{4}-\d{2}-\d{2}$/.test(r.expires ?? "") || Number.isNaN(Date.parse(`${r.expires}T00:00:00Z`)) || new Date(`${r.expires}T00:00:00Z`).toISOString().slice(0, 10) !== r.expires) p.push("invalid expiry date");
135
+ try { if (canonicalRepositoryIdentity(r.repositoryIdentity) !== r.repositoryIdentity) p.push("approval repository identity is not canonical"); } catch { p.push("invalid repository identity"); }
136
+ if (!validFingerprint(r.fingerprint)) p.push("invalid catalog fingerprint id");
137
+ if (typeof r.policyDigest !== "string" || !HEX.test(r.policyDigest)) p.push("invalid policy digest");
138
+ for (const key of ["minimumScannerVersion", "minimumCatalogVersion"]) if (typeof r[key] !== "string" || !/^\d+(?:\.\d+)*$/.test(r[key])) p.push(`invalid ${key}`);
139
+ if (!Array.isArray(r.occurrences) || !r.occurrences.length || r.occurrences.length > 256) p.push("occurrences must contain 1 to 256 entries");
140
+ else {
141
+ const pairs = new Set();
142
+ for (const item of r.occurrences) {
143
+ if (!exactKeys(item, ["path", "kind", "hash"]) || !occurrencePath(item.path) || !["source", "dependency"].includes(item.kind) || typeof item.hash !== "string" || !HEX.test(item.hash)) { p.push("occurrences require exact repository-relative path, known kind, and hex hash"); continue; }
144
+ const pair = `${item.path}\0${item.hash}`;
145
+ if (pairs.has(pair)) p.push("duplicate occurrence path/hash pair");
146
+ pairs.add(pair);
147
+ }
148
+ }
149
+ if (!validDate(r.issued)) p.push("invalid issued date");
150
+ if (!validDate(r.expires)) p.push("invalid expiry date");
110
151
  if (typeof r.requestId !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:-]{2,127}$/.test(r.requestId)) p.push("invalid request id");
111
152
  let trackingUrl;
112
153
  try { trackingUrl = new URL(r.trackingRef?.url); } catch {}
113
- if (!trackingUrl || trackingUrl.protocol !== "https:" || !trackingUrl.hostname || trackingUrl.username || trackingUrl.password || typeof r.trackingRef.id !== "string" || !r.trackingRef.id || r.trackingRef.verified !== true) p.push("tracking reference must be verified and https-backed");
154
+ if (!exactKeys(r.trackingRef, ["id", "url", "verified"]) || typeof r.trackingRef.url !== "string" || !trackingUrl || trackingUrl.protocol !== "https:" || !trackingUrl.hostname || trackingUrl.username || trackingUrl.password || typeof r.trackingRef.id !== "string" || !r.trackingRef.id || r.trackingRef.verified !== true) p.push("tracking reference must be verified and https-backed");
114
155
  if (typeof r.reason !== "string" || !r.reason.trim() || r.reason.length > 4000) p.push("invalid approval reason");
115
156
  if (typeof r.owner !== "string" || !r.owner.trim() || r.owner.length > 256) p.push("invalid owner");
116
- if (!record.signature.keyId || !record.signature.host || typeof record.signature.value !== "string" || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(record.signature.value) || Buffer.from(record.signature.value, "base64").toString("base64") !== record.signature.value) p.push("malformed signature envelope");
157
+ if (record.signature !== null) {
158
+ const s = record.signature;
159
+ if (!exactKeys(s, ["keyId", "host", "value"]) || !REQUIRED_HOSTS.includes(s.host) || typeof s.keyId !== "string" || !new RegExp(`^${s.host === "windows" ? "win" : "mac"}-[a-f0-9]{64}$`).test(s.keyId) || typeof s.value !== "string" || !s.value || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(s.value) || Buffer.from(s.value, "base64").toString("base64") !== s.value) p.push("malformed signature envelope");
160
+ }
117
161
  return p;
118
162
  }
119
163
 
@@ -204,8 +248,12 @@ export function verifyProtectedProvenance(trust, { repositoryIdentity, paths, fi
204
248
  return match ? { ok: true, problems: [], record: match } : { ok: false, problems: ["no protected provenance record matches paths and content"] };
205
249
  }
206
250
 
207
- export function createDraft({ repositoryIdentity, catalogDigest, fingerprint, findings, paths, reason, owner, expires, requestId, trackingRef }) {
208
- const payload = { repositoryIdentity: canonicalRepositoryIdentity(repositoryIdentity), catalogDigest, fingerprint, paths: [...new Set(paths ?? [])].sort(), occurrenceHashes: findings.map(occurrenceHash).sort(), reason, owner, expires, requestId, trackingRef };
251
+ export function createDraft({ repositoryIdentity, policyDigest, fingerprint, findings, minimumScannerVersion, minimumCatalogVersion, reason, owner, issued = new Date().toISOString().slice(0, 10), expires, requestId, trackingRef }) {
252
+ if (!validDate(issued)) throw new Error("invalid issued date");
253
+ if (!Array.isArray(findings)) throw new Error("draft findings must be an array of current occurrences");
254
+ const deadline = new Date(`${issued}T00:00:00Z`); deadline.setUTCDate(deadline.getUTCDate() + 90);
255
+ const occurrences = findings.map((f) => ({ path: f.file, kind: exceptionOccurrenceKind(f), hash: occurrenceHash(f) })).sort((a, b) => a.path.localeCompare(b.path) || a.hash.localeCompare(b.hash));
256
+ const payload = { repositoryIdentity: canonicalRepositoryIdentity(repositoryIdentity), policyDigest, fingerprint, minimumScannerVersion, minimumCatalogVersion, occurrences, reason, owner, issued, expires: expires ?? deadline.toISOString().slice(0, 10), requestId: requestId ?? null, trackingRef: trackingRef ?? null };
209
257
  return { schema: APPROVAL_SCHEMA, payload, signature: null };
210
258
  }
211
259
 
@@ -234,25 +282,25 @@ export function validateOwnershipFloor(trust, { scannerVersion, catalogVersion,
234
282
  if (trust.minimumCatalogDigest && trust.minimumCatalogDigest !== catalogDigest) problems.push("catalog digest below protected pinned digest");
235
283
  return { ok: problems.length === 0, problems };
236
284
  }
237
- function protectedLocation(file, platform) {
285
+ function protectedLocation(file, platform, { runner = spawnSync, inspect = lstatSync, stat = statSync, requireNonElevated = true } = {}) {
238
286
  const problems = [];
239
287
  if (platform === "win32") {
240
288
  const dirs = [];
241
289
  let current = path.dirname(file);
242
290
  while (true) {
243
291
  dirs.push(current);
244
- if (current.toLowerCase() === "c:\\") break;
292
+ if (path.dirname(current) === current) break;
245
293
  current = path.dirname(current);
246
294
  }
247
295
  dirs.push(file);
248
296
  for (const target of dirs) {
249
- try { if (lstatSync(target).isSymbolicLink()) problems.push(`trust path contains a reparse/symlink: ${target}`); } catch { problems.push(`cannot inspect trust path: ${target}`); continue; }
297
+ try { if (inspect(target).isSymbolicLink()) problems.push(`trust path contains a reparse/symlink: ${target}`); } catch { problems.push(`cannot inspect trust path: ${target}`); continue; }
250
298
  const script = "$ErrorActionPreference='Stop'; $a=Get-Acl -LiteralPath $env:RK_TRUST_CHECK_TARGET; $item=Get-Item -Force -LiteralPath $env:RK_TRUST_CHECK_TARGET; $id=[Security.Principal.WindowsIdentity]::GetCurrent(); $p=New-Object Security.Principal.WindowsPrincipal($id); $ownerSid=$a.GetOwner([Security.Principal.SecurityIdentifier]).Value; $ownerName=$a.Owner; $out=[ordered]@{ownerSID=$ownerSid;ownerName=$ownerName;attributes=$item.Attributes.ToString();directory=[bool]$item.PSIsContainer;nonempty=(!$item.PSIsContainer -or [System.IO.Directory]::EnumerateFileSystemEntries($item.FullName).GetEnumerator().MoveNext());elevated=$p.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator);access=@($a.Access | ForEach-Object { [ordered]@{identitySID=$_.IdentityReference.Translate([Security.Principal.SecurityIdentifier]).Value;type=$_.AccessControlType.ToString();mask=[int64]$_.FileSystemRights.value__;propagationFlags=[int]$_.PropagationFlags} })}; $out | ConvertTo-Json -Compress -Depth 5";
251
- const r = spawnSync("C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { encoding: "utf8", windowsHide: true, env: { ...process.env, RK_TRUST_CHECK_TARGET: target } });
299
+ const r = runner("C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { encoding: "utf8", windowsHide: true, env: windowsPowerShellEnvironment({ RK_TRUST_CHECK_TARGET: target }) });
252
300
  if (r.status !== 0) { problems.push(`cannot inspect trust ACL: ${target}`); continue; }
253
301
  try {
254
302
  const acl = JSON.parse(r.stdout.trim());
255
- const result = evaluateWindowsAcl(acl, { target });
303
+ const result = evaluateWindowsAcl(acl, { target, requireNonElevated });
256
304
  problems.push(...result.problems.map((problem) => `${problem}: ${target}`));
257
305
  } catch { problems.push(`malformed ACL result: ${target}`); }
258
306
  }
@@ -260,15 +308,15 @@ function protectedLocation(file, platform) {
260
308
  }
261
309
  let current = file;
262
310
  while (true) {
263
- try { if (lstatSync(current).isSymbolicLink()) problems.push(`trust path contains a symlink: ${current}`); } catch { problems.push(`cannot inspect trust path: ${current}`); }
311
+ try { if (inspect(current).isSymbolicLink()) problems.push(`trust path contains a symlink: ${current}`); } catch { problems.push(`cannot inspect trust path: ${current}`); }
264
312
  try {
265
- const st = statSync(current);
313
+ const st = stat(current);
266
314
  if (st.uid !== 0 || (st.mode & 0o022)) problems.push(`trust location is not root-owned/private: ${current}`);
267
315
  } catch { problems.push(`cannot inspect trust location: ${current}`); }
268
- const acl = spawnSync("/bin/ls", ["-lde", current], { encoding: "utf8", windowsHide: true });
316
+ const acl = runner("/bin/ls", ["-lde", current], { encoding: "utf8", windowsHide: true });
269
317
  if (acl.status !== 0) problems.push(`cannot inspect trust ACL: ${current}`);
270
318
  else for (const line of acl.stdout.split(/\r?\n/).slice(1)) {
271
- if (!/allow/i.test(line) || !/\b(?:write|delete|append|writeattr|writeextattr|chown|delete_child|add_file|add_subdirectory)\b/i.test(line)) continue;
319
+ if (!/allow/i.test(line) || !/\b(?:writesecurity|chown|write|delete|append|writeattr|writeextattr|delete_child|add_file|add_subdirectory)\b/i.test(line)) continue;
272
320
  if (!/^\s*\d+:\s+user:root\s+allow\b/i.test(line)) problems.push(`trust ACL grants write authority to a non-root principal: ${current}`);
273
321
  }
274
322
  const parent = path.dirname(current);
@@ -290,10 +338,12 @@ export function loadTrustedExternal() {
290
338
  if (trust.schema !== 1) throw new Error("ownership trust file schema is unsupported");
291
339
  const trustCheck = validateTrustRegistry(trust);
292
340
  if (!trustCheck.ok) throw new Error(trustCheck.problems.join("; "));
341
+ const normalize = (id) => typeof id === "string" ? id.replace(/^(?:win-|mac-)/i, "").toLowerCase() : "";
342
+ const revoked = new Set((trust.revokedKeyIds ?? []).map(normalize));
293
343
  for (const host of REQUIRED_HOSTS) {
294
344
  const entry = trust.hosts?.[host];
295
345
  if (!entry?.keyId && !entry?.publicKeyPem) continue;
296
- if (!entry.keyId || !entry.publicKeyPem || entry.keyId !== keyIdForPublicKey(host, entry.publicKeyPem)) throw new Error(`invalid or mismatched ${host} approval key enrollment`);
346
+ if (!entry.keyId || !entry.publicKeyPem || normalize(entry.keyId) !== normalize(keyIdForPublicKey(host, entry.publicKeyPem))) throw new Error(`invalid or mismatched ${host} approval key enrollment`);
297
347
  const key = createPublicKey(entry.publicKeyPem);
298
348
  if (key.asymmetricKeyType !== "ec" || key.asymmetricKeyDetails?.namedCurve !== "prime256v1") throw new Error(`${host} approval key must be P-256`);
299
349
  }
@@ -301,21 +351,28 @@ export function loadTrustedExternal() {
301
351
  const hostKeys = REQUIRED_HOSTS.map((host) => trust.hosts?.[host]);
302
352
  const enrolled = trust.rootFingerprint
303
353
  ? Boolean(HEX.test(trust.rootFingerprint) && trust.keyCheckpoint && Number.isSafeInteger(trust.keyCheckpoint.sequence) && trust.keyCheckpoint.sequence >= 0 && HEX.test(trust.keyCheckpoint.head ?? "")) // Provisional; scan paths replace this from verified log history.
304
- : hostKeys.every((host) => host?.keyId && host.publicKeyPem && !trust.revokedKeyIds?.includes(host.keyId))
305
- && hostKeys[0]?.keyId !== hostKeys[1]?.keyId && hostKeys[0]?.publicKeyPem !== hostKeys[1]?.publicKeyPem;
354
+ : hostKeys.every((host) => host?.keyId && host.publicKeyPem && !revoked.has(normalize(host.keyId)))
355
+ && normalize(hostKeys[0]?.keyId) !== normalize(hostKeys[1]?.keyId) && hostKeys[0]?.publicKeyPem !== hostKeys[1]?.publicKeyPem;
306
356
  return { enrolled, pending: !enrolled, protection: protectionResult, trust, path: file };
307
357
  }
308
358
 
309
359
  // Test-only seam: production callers must use loadTrustedExternal's fixed canonical path.
310
- export const __testOnly = Object.freeze({ protectedLocation });
360
+ export const __testOnly = Object.freeze({ protectedLocation, signatureProblems });
311
361
 
312
362
  function signatureProblems(record, trust) {
313
- const s = record.signature;
363
+ const s = record?.signature;
364
+ if (!s || !trust) return ["signature or protected trust is missing"];
365
+ if (!Array.isArray(trust.revokedKeyIds ?? [])) return ["protected revoked key IDs are malformed"];
366
+ if (trust.rootFingerprint && !Array.isArray(trust.ownershipKeys)) return ["protected ownership key authority is malformed"];
367
+ const ids = new Map();
368
+ const normalize = (id) => { if (!ids.has(id)) ids.set(id, typeof id === "string" ? id.replace(/^(?:win-|mac-)/i, "").toLowerCase() : ""); return ids.get(id); };
369
+ const normalizedId = normalize(s.keyId);
370
+ if (!HEX.test(normalizedId)) return ["signing key ID is malformed"];
371
+ const revoked = new Set((trust.revokedKeyIds ?? []).map(normalize));
314
372
  if (!REQUIRED_HOSTS.includes(s.host)) return ["unknown signing host"];
315
- if (trust.revokedKeyIds?.includes(s.keyId)) return ["signing key is revoked"];
373
+ if (revoked.has(normalizedId)) return ["signing key is revoked"];
316
374
  if (trust.rootFingerprint) {
317
- const normalizedId = String(s.keyId).replace(/^(?:win-|mac-)/, "");
318
- const key = trust.ownershipKeys?.find((candidate) => candidate.keyId === normalizedId && candidate.platform === s.host);
375
+ const key = trust.ownershipKeys?.find((candidate) => normalize(candidate.keyId) === normalizedId && candidate.platform === s.host);
319
376
  if (!key) return ["signing key is not active in protected ownership key log"];
320
377
  try {
321
378
  const valid = verifyOwnershipSignature(key.publicKeyPem, Buffer.from(canonicalJson(approvalPayload(record))), Buffer.from(s.value, "base64"));
@@ -323,12 +380,12 @@ function signatureProblems(record, trust) {
323
380
  } catch { return ["malformed signature or public key"]; }
324
381
  }
325
382
  const host = trust.hosts?.[s.host];
326
- if (!host || host.keyId !== s.keyId || !host.publicKeyPem) return ["signing key is not enrolled for host"];
383
+ if (!host || normalize(host.keyId) !== normalizedId || !host.publicKeyPem) return ["signing key is not enrolled for host"];
327
384
  const keys = REQUIRED_HOSTS.map((name) => trust.hosts?.[name]);
328
- if (!keys.every((k) => k?.keyId && k.publicKeyPem && !trust.revokedKeyIds?.includes(k.keyId)) || keys[0]?.keyId === keys[1]?.keyId || keys[0]?.publicKeyPem === keys[1]?.publicKeyPem) return ["pending: distinct Windows and macOS approval keys must be enrolled"];
385
+ if (!keys.every((k) => k?.keyId && k.publicKeyPem && !revoked.has(normalize(k.keyId))) || normalize(keys[0]?.keyId) === normalize(keys[1]?.keyId) || keys[0]?.publicKeyPem === keys[1]?.publicKeyPem) return ["pending: distinct Windows and macOS approval keys must be enrolled"];
329
386
  try {
330
387
  const publicKey = createPublicKey(host.publicKeyPem);
331
- if (s.keyId !== keyIdForPublicKey(s.host, publicKey)) return ["signing key ID does not match enrolled public key"];
388
+ if (normalizedId !== normalize(keyIdForPublicKey(s.host, publicKey))) return ["signing key ID does not match enrolled public key"];
332
389
  const valid = cryptoVerify("sha256", Buffer.from(canonicalJson(approvalPayload(record))), publicKey, Buffer.from(s.value, "base64"));
333
390
  return valid ? [] : ["invalid signature"];
334
391
  } catch { return ["malformed signature or public key"];
@@ -336,13 +393,28 @@ function signatureProblems(record, trust) {
336
393
  }
337
394
 
338
395
  /** Verify signature, protected enrollment, floor, and exact scan coverage. */
339
- export function verifyException(record, { repositoryIdentity, catalogDigest, findings, trust, today = new Date().toISOString().slice(0, 10), scannerVersion, catalogVersion } = {}) {
396
+ export function verifyException(record, { repositoryIdentity, catalogDigest, policyDigest, findings, trust, today = new Date().toISOString().slice(0, 10), scannerVersion, catalogVersion } = {}) {
340
397
  const problems = validateRecord(record);
341
398
  if (problems.length) return { ok: false, problems };
342
- if (!trust) return { ok: false, problems: ["pending: external ownership trust is not enrolled"] };
343
- const p = signatureProblems(record, trust);
344
- if (p.length) return { ok: false, problems: p };
345
399
  const r = record.payload;
400
+ if (!validDate(today)) return { ok: false, problems: ["invalid verification date"] };
401
+ if (r.issued > today) problems.push("approval issued date is in the future");
402
+ const duration = Date.parse(`${r.expires}T00:00:00Z`) - Date.parse(`${r.issued}T00:00:00Z`);
403
+ if (duration <= 0 || duration > 90 * 86400000) problems.push("approval expiry must be after issued and within 90 days");
404
+ if (r.expires < today) problems.push(`approval expired ${r.expires}`);
405
+ if (problems.length) return { ok: false, problems };
406
+ if (!versionAtLeast(scannerVersion, r.minimumScannerVersion)) problems.push("scanner version missing, malformed, or below approval minimum");
407
+ if (!versionAtLeast(catalogVersion, r.minimumCatalogVersion)) problems.push("catalog version missing, malformed, or below approval minimum");
408
+ if (!trust) problems.push("pending: external ownership trust is not enrolled");
409
+ else problems.push(...validateOwnershipFloor(trust, { scannerVersion, catalogVersion, catalogDigest }).problems);
410
+ if (problems.length) return { ok: false, problems };
411
+ if (typeof policyDigest !== "string" || !HEX.test(policyDigest) || r.policyDigest !== policyDigest) return { ok: false, problems: ["ownership fingerprint policy digest missing or changed"] };
412
+ if (!Array.isArray(findings)) return { ok: false, problems: ["current findings missing or malformed"] };
413
+ let current;
414
+ try { current = findings.filter((f) => f.fingerprint === r.fingerprint).map((f) => ({ path: f.file, kind: exceptionOccurrenceKind(f), hash: occurrenceHash(f) })); }
415
+ catch (error) { return { ok: false, problems: [error.message] }; }
416
+ for (const item of r.occurrences) if (!current.some((f) => f.path === item.path && f.hash === item.hash && f.kind === item.kind)) problems.push(`baseline occurrence changed or missing: ${item.path} (${item.hash})`);
417
+ if (problems.length) return { ok: false, problems };
346
418
  const trackingEvidence = sha256({ repositoryIdentity: r.repositoryIdentity, requestId: r.requestId, trackingRef: r.trackingRef });
347
419
  if (!trust.verifiedTrackingEvidence?.includes(trackingEvidence)) problems.push("tracking reference lacks protected verification evidence");
348
420
  let recordIdentity, currentIdentity;
@@ -352,15 +424,8 @@ export function verifyException(record, { repositoryIdentity, catalogDigest, fin
352
424
  if (recordIdentity && currentIdentity && recordIdentity !== currentIdentity) problems.push("repository identity changed");
353
425
  const registration = verifyRepositoryRegistration(trust, r.repositoryIdentity, { files: (findings ?? []).map((f) => f.file) });
354
426
  problems.push(...registration.problems);
355
- if (r.catalogDigest !== catalogDigest) problems.push("ownership catalog digest changed");
356
- if (!/^\d{4}-\d{2}-\d{2}$/.test(today) || Number.isNaN(Date.parse(`${today}T00:00:00Z`)) || new Date(`${today}T00:00:00Z`).toISOString().slice(0, 10) !== today) problems.push("invalid verification date");
357
- else if (r.expires < today) problems.push(`approval expired ${r.expires}`);
358
- const floor = validateOwnershipFloor(trust, { scannerVersion, catalogVersion, catalogDigest });
359
- problems.push(...floor.problems);
360
- const selected = (findings ?? []).filter((f) => f.fingerprint === r.fingerprint && r.paths.includes(f.file));
361
- const actual = selected.map(occurrenceHash).sort();
362
- if (canonicalJson(actual) !== canonicalJson([...r.occurrenceHashes].sort())) problems.push("baseline occurrence set changed");
363
- if (!actual.length) problems.push("approval is stale and matches no current occurrences");
427
+ if (record.signature === null) problems.push("unsigned approval record");
428
+ else if (!problems.length) problems.push(...signatureProblems(record, trust));
364
429
  return { ok: problems.length === 0, problems };
365
430
  }
366
431
 
@@ -0,0 +1,25 @@
1
+ // Publication evidence, rollout staging & adopted pins are separate states.
2
+ export function ownershipAvailability(entry, versions) {
3
+ const name = entry.package;
4
+ const ecosystem = name === "RightKitSwift" || name === "rightkit-swift" ? "swift"
5
+ : name?.startsWith("@rightkit/") ? "npm" : "cargo";
6
+ const key = ecosystem === "swift" ? "rightkit-swift" : name;
7
+ const stagedMap = { npm: "stagedNpm", cargo: "stagedCargo", swift: "stagedSwift" }[ecosystem];
8
+ const pinned = versions[ecosystem]?.[key];
9
+ const recorded = versions.registry?.[ecosystem]?.[key];
10
+ const staged = versions[stagedMap]?.[key];
11
+ const versionOf = (value) => typeof value === "string" ? value : value?.version;
12
+ const publishedVersion = versionOf(recorded ?? pinned);
13
+ const rows = [];
14
+ const add = (value, state) => {
15
+ const version = versionOf(value);
16
+ if (version) rows.push({ registry: { npm: "npm", cargo: "crates.io", swift: "SwiftPM" }[ecosystem], package: key, version, state, ...(value?.url ? { url: value.url } : {}) });
17
+ };
18
+ add(recorded ?? pinned, recorded ? "registry-recorded" : "registry-pinned");
19
+ if (versionOf(staged) && versionOf(staged) !== publishedVersion) add(staged, "staged-unverified");
20
+ return rows;
21
+ }
22
+
23
+ export function ownershipDetection(entry) {
24
+ return entry.detection ?? (entry.fingerprints?.length ? "fingerprinted" : "declaration-only");
25
+ }
@@ -0,0 +1,18 @@
1
+ {
2
+ "version": "3.0.0",
3
+ "newCapabilityApprovalFlag": "--approve-new-capabilities",
4
+ "approvalScope": "Local catalog generation only; installation still requires protected authorization. This flag cannot approve owner changes.",
5
+ "ownerFamilies": [
6
+ { "capability": "agent-room", "packages": ["agent-room-core", "@rightkit/agent-room-mcp"], "reason": "Native room core & MCP adapter share room contract" },
7
+ { "capability": "licensing", "packages": ["rightkit-license", "@rightkit/license"], "reason": "Native entitlement enforcement & renderer/network client have separate roles" },
8
+ { "capability": "structured-logging", "packages": ["rightkit-logs", "@rightkit/logs"], "reason": "Native JSONL writer & typed renderer invoke adapter" },
9
+ { "capability": "text-to-speech", "packages": ["rightkit-qts", "rightkit-qts-ggml", "rightkit-qts-ggml-sys", "rightkit-tts"], "reason": "Speech facade & its QTS inference, safe-wrapper & FFI layers" },
10
+ { "capability": "os-credential-store", "packages": ["rightkit-secrets", "RightKitSwift"], "reason": "Cross-platform Rust & Apple-native Swift credential APIs" },
11
+ { "capability": "desktop-shell", "packages": ["rightkit-shell", "@rightkit/shell"], "reason": "Native Tauri shell & typed renderer bridge" },
12
+ { "capability": "tauri-host-adapters", "packages": ["rightkit-tauri", "@rightkit/tauri"], "reason": "Native license/device services & renderer command adapters" }
13
+ ],
14
+ "testOwners": [
15
+ { "path": "crates/rightkit-control/examples/control-test-app/Cargo.toml", "package": "control-test-app", "reason": "Unpublished desktop-control test fixture" },
16
+ { "path": "crates/rightkit-shell/example-app/Cargo.toml", "package": "shell-example-app", "reason": "Unpublished standalone shell demonstration & test host" }
17
+ ]
18
+ }
@@ -0,0 +1,94 @@
1
+ import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { parse as parseToml } from "smol-toml";
5
+ import { ownershipAvailability } from "./ownership-availability.mjs";
6
+
7
+ const releaseRoot = path.dirname(fileURLToPath(import.meta.url));
8
+ export const CATALOG_CONFIG = JSON.parse(readFileSync(new URL("./ownership-catalog-config.json", import.meta.url)));
9
+ const json = (file) => JSON.parse(readFileSync(file, "utf8"));
10
+
11
+ export function validateCatalogChanges(previous, next, { approveNewCapabilities = false } = {}) {
12
+ const owners = new Map();
13
+ for (const entry of previous.capabilities ?? []) {
14
+ if (!owners.has(entry.capability)) owners.set(entry.capability, new Set());
15
+ owners.get(entry.capability).add(entry.package);
16
+ }
17
+ const newOwners = new Map();
18
+ const declarations = new Set();
19
+ for (const entry of next.capabilities) {
20
+ const declaration = `${entry.capability}:${entry.package}`;
21
+ if (declarations.has(declaration)) throw new Error(`ownership: duplicate owner declaration ${declaration}`);
22
+ declarations.add(declaration);
23
+ const known = owners.get(entry.capability);
24
+ if (known && !known.has(entry.package)) throw new Error(`ownership: ${entry.capability} already belongs to ${[...known].join(", ")}; refusing ${entry.package}`);
25
+ if (!known && !approveNewCapabilities) throw new Error(`ownership: new capability ${entry.capability} requires ${CATALOG_CONFIG.newCapabilityApprovalFlag}`);
26
+ if (!known && newOwners.has(entry.capability) && newOwners.get(entry.capability) !== entry.package) throw new Error(`ownership: conflicting new owners for ${entry.capability}`);
27
+ newOwners.set(entry.capability, entry.package);
28
+ }
29
+ }
30
+
31
+ // Package declarations are canonical. Availability always comes from versions,
32
+ // never from package versions, descriptions, or generated consumer snapshots.
33
+ export function generateOwnershipCatalog(root) {
34
+ const capabilities = [], ids = new Set();
35
+ const versions = json(path.join(root, "packages/release/rightkit-versions.json"));
36
+ for (const group of ["crates", "packages", "swift"]) {
37
+ const parent = path.join(root, group);
38
+ if (!existsSync(parent)) continue;
39
+ for (const name of readdirSync(parent).sort()) {
40
+ const dir = path.join(parent, name);
41
+ const file = path.join(dir, name === "release" && group === "packages" ? "ownership-declaration.json" : "rightkit-ownership.json");
42
+ if (!existsSync(file)) continue;
43
+ const declaration = json(file);
44
+ if (declaration.schema !== undefined) throw new Error(`ownership: generated catalog cannot be an owner declaration: ${file}`);
45
+ const actual = group === "crates" ? parseToml(readFileSync(path.join(dir, "Cargo.toml"), "utf8")).package?.name
46
+ : group === "packages" ? json(path.join(dir, "package.json")).name : existsSync(path.join(dir, "Package.swift")) ? /\bname\s*:\s*"([^"]+)"/.exec(readFileSync(path.join(dir, "Package.swift"), "utf8"))?.[1] : null;
47
+ if (!actual || declaration.package !== actual) throw new Error(`ownership: declaration owner differs from package metadata: ${file}`);
48
+ for (const entry of declaration.capabilities ?? []) {
49
+ if (entry.package !== undefined || !/^[a-z0-9][a-z0-9._-]{1,127}$/.test(entry.capability) || !Array.isArray(entry.fingerprints)) throw new Error(`ownership: malformed declaration: ${file}`);
50
+ const owner = { package: actual, ...entry };
51
+ if (owner.detection !== (owner.fingerprints.length ? "fingerprinted" : "declaration-only")) throw new Error(`ownership: explicit detection required: ${file}`);
52
+ for (const f of owner.fingerprints) {
53
+ if (!/^[a-z0-9][a-z0-9._-]{1,127}$/.test(f.id) || ids.has(f.id)) throw new Error(`ownership: invalid or duplicate fingerprint ${f.id}`);
54
+ ids.add(f.id);
55
+ if (!["block", "advise"].includes(f.tier) || !f.usageContext || !f.tierReason) throw new Error(`ownership: per-fingerprint tier & context required: ${f.id}`);
56
+ if (f.type === "dependency") {
57
+ if (!["cargo", "npm", "swiftpm"].includes(f.ecosystem) || !f.name || /^(@rightkit\/|rightkit-)/.test(f.name)) throw new Error(`ownership: imprecise dependency: ${f.id}`);
58
+ } else if (f.type === "source") {
59
+ if (!f.codeOnly || !f.extensions?.length) throw new Error(`ownership: source fingerprint must be code & extension scoped: ${f.id}`);
60
+ new RegExp(f.pattern);
61
+ } else throw new Error(`ownership: unknown fingerprint type: ${f.id}`);
62
+ // A banned dependency blocks regardless of owner qualification: the ban stands even
63
+ // when no replacement is used. It names the authority that imposed it.
64
+ if (f.tier === "block" && f.policy === "banned") {
65
+ if (f.type !== "dependency" || !f.policyAuthority) throw new Error(`ownership: banned fingerprint needs a dependency & policyAuthority: ${f.id}`);
66
+ } else if (f.tier === "block") {
67
+ const published = ownershipAvailability(owner, versions).find((row) => row.state !== "staged-unverified");
68
+ const q = f.qualification;
69
+ if (!published || q?.version !== published.version || !["macos", "windows"].every((host) => q.hosts?.includes(host)) || !q.evidence?.length) throw new Error(`ownership: block fingerprint lacks published two-host qualification: ${f.id}`);
70
+ }
71
+ }
72
+ capabilities.push(owner);
73
+ }
74
+ }
75
+ }
76
+ const families = new Map();
77
+ for (const entry of capabilities) {
78
+ if (!families.has(entry.capability)) families.set(entry.capability, new Set());
79
+ families.get(entry.capability).add(entry.package);
80
+ }
81
+ for (const [capability, owners] of families) if (owners.size > 1) {
82
+ const family = CATALOG_CONFIG.ownerFamilies.find((entry) => entry.capability === capability);
83
+ if (!family?.reason || owners.size !== family.packages.length || [...owners].some((owner) => !family.packages.includes(owner))) throw new Error(`ownership: conflicting owner family ${capability}`);
84
+ }
85
+ const internalOwners = json(path.join(root, "packages/release/ownership-internal-owners.json")).owners;
86
+ return { schema: 1, version: CATALOG_CONFIG.version, capabilities, internalOwners };
87
+ }
88
+
89
+ export function writeOwnershipCatalog(root, { approveNewCapabilities = false, previous = json(path.join(releaseRoot, "rightkit-ownership.json")), output = path.join(root, "packages/release/rightkit-ownership.json") } = {}) {
90
+ const catalog = generateOwnershipCatalog(root);
91
+ validateCatalogChanges(previous, catalog, { approveNewCapabilities });
92
+ writeFileSync(output, `${JSON.stringify(catalog, null, 2)}\n`);
93
+ return catalog;
94
+ }
@@ -3,16 +3,19 @@
3
3
  // the shipped manifest from each package's declaration.
4
4
  //
5
5
  // right-release ownership [--app <dir>] [--json]
6
- // right-release ownership --write-manifest [--rightkit <tools/rightkit>]
7
- import { readFileSync, realpathSync, writeFileSync } from "node:fs";
6
+ // right-release ownership --write-manifest [--rightkit <tools/rightkit>] [--approve-new-capabilities]
7
+ // --approve-new-capabilities permits new ids during local generation only.
8
+ // Existing owner changes are always refused; installation needs protected authorization.
9
+ import { realpathSync } from "node:fs";
8
10
  import path from "node:path";
9
11
  import { fileURLToPath } from "node:url";
10
- import { aggregateOwnership, formatTable, MANIFEST_FILE } from "./ownership.mjs";
12
+ import { formatTable } from "./ownership.mjs";
13
+ import { writeOwnershipCatalog } from "./ownership-catalog.mjs";
11
14
  import { createOwnershipEvidence } from "./ownership-evidence.mjs";
12
15
  import { spawnSync } from "./background-process.mjs";
16
+ import { main as stagedMain } from "./ownership-staged.mjs";
13
17
 
14
18
  const packageRoot = path.dirname(fileURLToPath(import.meta.url));
15
- const shipped = path.join(packageRoot, MANIFEST_FILE);
16
19
 
17
20
  function flag(args, name) {
18
21
  const i = args.indexOf(name);
@@ -20,17 +23,21 @@ function flag(args, name) {
20
23
  }
21
24
 
22
25
  export async function main(args, { evidenceFactory = createOwnershipEvidence, gitRunner = spawnSync, print = (value) => console.log(value) } = {}) {
23
- if (["enroll", "approve-key", "revoke-key"].includes(args[0])) {
26
+ if (args.includes("--help")) {
27
+ 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
+ return 0;
29
+ }
30
+ if (args.includes("--staged")) return stagedMain(args, { print });
31
+ if (["enroll", "approve-key", "revoke-key", "trust-sync"].includes(args[0])) {
24
32
  const { main: keyMain } = await import("./ownership-key-command.mjs");
25
33
  return keyMain(args, { runner: gitRunner, print });
26
34
  }
27
35
  if (args.includes("--write-manifest")) {
28
36
  const root = path.resolve(flag(args, "--rightkit") ?? path.join(packageRoot, "..", ".."));
29
- const manifest = aggregateOwnership(root);
30
- writeFileSync(shipped, `${JSON.stringify(manifest, null, 2)}\n`);
31
- const { syncOwnershipCatalog } = await import("../build-control-clients/src/ownership-catalog-sync.mjs");
32
- syncOwnershipCatalog({ source: packageRoot });
33
- console.log(`[right-release] ownership manifest: ${manifest.capabilities.length} capabilities -> ${shipped}`);
37
+ const manifest = writeOwnershipCatalog(root, { approveNewCapabilities: args.includes("--approve-new-capabilities") });
38
+ const { syncConsumerCopies } = await import("./rightkit-sync.mjs");
39
+ syncConsumerCopies(root);
40
+ print(`[right-release] ownership manifest: ${manifest.capabilities.length} owner rows -> ${path.join(root, "packages/release/rightkit-ownership.json")}`);
34
41
  return 0;
35
42
  }
36
43
  const app = path.resolve(flag(args, "--app") ?? process.cwd());
@@ -0,0 +1,15 @@
1
+ {
2
+ "package": "@rightkit/release",
3
+ "capabilities": [
4
+ {
5
+ "capability": "release-tooling",
6
+ "summary": "Portable release CLI/SDK with fail-closed public GitHub Releases and private R2 + RightApps distribution.; declaration-only detection (no precise implementation fingerprint)",
7
+ "fingerprints": [],
8
+ "detection": "declaration-only",
9
+ "policyNotes": [
10
+ "Package contract retained; dependency declarations do not verify integration.",
11
+ "No precise implementation fingerprint; declaration-only inventory, never enforced ownership or verified integration."
12
+ ]
13
+ }
14
+ ]
15
+ }
@@ -6,16 +6,6 @@
6
6
  "minimumCatalogDigest": "875594919178efe146b32276cb21e21a715ca787fb1cc39db425b21bafb02df6",
7
7
  "revokedKeyIds": [],
8
8
  "verifiedTrackingEvidence": [],
9
- "repositories": [
10
- { "repositoryIdentity": "https://github.com/bogusyogi/coderight", "roots": ["."], "visibility": "private" },
11
- { "repositoryIdentity": "https://github.com/bogusyogi/cutright", "roots": ["."], "visibility": "private" },
12
- { "repositoryIdentity": "https://github.com/bogusyogi/genright", "roots": ["."], "visibility": "private" },
13
- { "repositoryIdentity": "https://github.com/bogusyogi/heardright", "roots": ["."], "visibility": "private" },
14
- { "repositoryIdentity": "https://github.com/bogusyogi/mailright", "roots": ["."], "visibility": "private" },
15
- { "repositoryIdentity": "https://github.com/bogusyogi/rightsites", "roots": ["."], "visibility": "private" },
16
- { "repositoryIdentity": "https://github.com/bogusyogi/scraperight", "roots": ["."], "visibility": "private" },
17
- { "repositoryIdentity": "https://github.com/bogusyogi/viewright", "roots": ["."], "visibility": "private" },
18
- { "repositoryIdentity": "https://github.com/bogusyogi/voiceright", "roots": ["."], "visibility": "private" }
19
- ],
9
+ "repositories": [],
20
10
  "provenance": []
21
11
  }
@@ -3,7 +3,7 @@ import { readFileSync, realpathSync } from "node:fs";
3
3
  import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { spawnSync } from "./background-process.mjs";
6
- import { catalogDigest as scanCatalogDigest, checkApp, checkCommittedApp, MANIFEST_FILE, SCANNER_VERSION } from "./ownership.mjs";
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
9
  import { canonicalRepositoryIdentity, loadTrustedExternal, resolveKeyLogTrust, sha256, validateOwnershipFloor, verifyRepositoryRegistration } from "./ownership-approval.mjs";
@@ -29,7 +29,7 @@ function createEvidence(repoRoot, {
29
29
  committed = false,
30
30
  requireCleanCheckout = false,
31
31
  allowPendingAdvisory = false,
32
- } = {}) {
32
+ } = {}, readAuthority = readCommittedKeyAuthority, scanCommitted = checkCommittedApp) {
33
33
  const root = path.resolve(repoRoot);
34
34
  const manifestBytes = readFileSync(manifestPath);
35
35
  const manifest = JSON.parse(manifestBytes.toString("utf8"));
@@ -48,7 +48,7 @@ function createEvidence(repoRoot, {
48
48
  const source = useCommittedSource ? acquireCommittedSource(root, { requiredPaths: [...(trustState.trust?.provenance ?? []).flatMap((record) => record.paths ?? []), ...(trustState.trust?.rootFingerprint && !trustState.trust?.keyAuthority ? [".rightkit/ownership-keys.json"] : [])] }) : null;
49
49
  let keyLogEvidence = null;
50
50
  if (trustState.trust?.keyAuthority) {
51
- const resolved = readCommittedKeyAuthority(trustState.trust);
51
+ const resolved = readAuthority(trustState.trust);
52
52
  if (!resolved.ok) throw new Error(`protected ownership key authority failed: ${resolved.problems.join("; ")}`);
53
53
  trustState = { ...trustState, trust: resolved.resolvedTrust, enrolled: resolved.resolvedTrust.enrollmentComplete, pending: !resolved.resolvedTrust.enrollmentComplete };
54
54
  enrollmentPending = trustState.pending;
@@ -116,7 +116,7 @@ function createEvidence(repoRoot, {
116
116
  const scans = (scanRoots.length ? scanRoots : [requestedRoot]).map((registered) => {
117
117
  const absolute = registered === "." ? root : path.resolve(root, ...registered.split("/"));
118
118
  const scanned = useCommittedSource
119
- ? checkCommittedApp(absolute, manifest, { trust: trustState.trust, expectedCommit: commit, expectedTree: tree, source })
119
+ ? scanCommitted(absolute, manifest, { trust: trustState.trust, expectedCommit: commit, expectedTree: tree, source })
120
120
  : scan(absolute, manifest, { trust: { trust: trustState.trust, enrolled: trustState.enrolled, pending: enrollmentPending }, scannerVersion, catalogVersion, ...(enrollmentPending ? { enrollmentPendingAdvisory: true } : {}) });
121
121
  if (repositoryIdentity && canonicalRepositoryIdentity(scanned.metadata?.repositoryIdentity) !== repositoryIdentity) throw new Error(`ownership evidence: scanner observed a different repository identity under ${registered}`);
122
122
  if (useCommittedSource && scanned.metadata?.commit !== commit) throw new Error(`ownership evidence: scanner observed a different source commit under ${registered}`);
@@ -171,7 +171,7 @@ function createEvidence(repoRoot, {
171
171
  checkedAt: new Date().toISOString(),
172
172
  };
173
173
  const warning = enrollmentPending ? "WARNING: ownership approval enrollment is pending; findings remain backlog diagnostics and no protected evidence or approvals are asserted" : undefined;
174
- return { ok: enrollmentPending || (result.ok && floor.ok && contentCoverage.length === 0), ...(enrollmentPending ? { enrollmentPending: true, warning } : {}), evidence, result, floor, contentCoverage };
174
+ return { ok: !enrollmentPending && result.ok && floor.ok && contentCoverage.length === 0, ...(enrollmentPending ? { enrollmentPending: true, warning } : {}), evidence, result, floor, contentCoverage };
175
175
  }
176
176
 
177
177
  /** Create scan evidence bound to committed source, catalog, and signed exceptions. */
@@ -186,12 +186,14 @@ export function createCommittedOwnershipEvidence(repoRoot, scanRoot = repoRoot)
186
186
 
187
187
  // Injection seam is deliberately named for tests and is not used by release commands.
188
188
  export function createOwnershipEvidenceForTest(repoRoot, options) {
189
- return createEvidence(repoRoot, options);
189
+ const reader = options?.readAuthorityForTest;
190
+ return createEvidence(repoRoot, { ...options, scan: options?.scan ?? ((root, manifest, context) => checkAppForTest(root, manifest, { ...context, readAuthorityForTest: reader })) }, reader,
191
+ (root, manifest, context) => checkCommittedAppForTest(root, manifest, { ...context, readAuthorityForTest: reader }));
190
192
  }
191
193
 
192
194
  /** Test-only access to the production committed-tree evidence path. */
193
195
  export function createCommittedOwnershipEvidenceForTest(repoRoot, options = {}) {
194
- return createEvidence(repoRoot, { ...options, committed: true });
196
+ return createOwnershipEvidenceForTest(repoRoot, { ...options, committed: true });
195
197
  }
196
198
 
197
199
  /** Entry point for an OS-protected local hook/adapter on private repositories. */
@@ -207,7 +209,7 @@ export function verifyOwnershipEvidence(expected, repoRoot, scanRoot = repoRoot)
207
209
  }
208
210
 
209
211
  export function verifyOwnershipEvidenceForTest(expected, repoRoot, options) {
210
- return verifyEvidence(expected, repoRoot, options.scanRoot ?? repoRoot, () => createEvidence(repoRoot, options));
212
+ return verifyEvidence(expected, repoRoot, options.scanRoot ?? repoRoot, () => createOwnershipEvidenceForTest(repoRoot, options));
211
213
  }
212
214
 
213
215
  function verifyEvidence(expected, repoRoot, _scanRoot, createCurrent) {