@rightkit/release 0.2.135 → 0.2.137

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 +20 -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 +215 -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,9 +2,10 @@ import { createHash } from "node:crypto";
2
2
  import { closeSync, constants, fchmodSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
3
3
  import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
- import { spawnSync } from "./background-process.mjs";
5
+ import { spawnSync, windowsPowerShellEnvironment } from "./background-process.mjs";
6
6
  import { canonicalJson, canonicalRepositoryIdentity, __testOnly as approvalTestOnly, evaluateWindowsAcl, validateTrustRegistry } from "./ownership-approval.mjs";
7
7
  import { verifyKeyLog } from "./ownership-key-log.mjs";
8
+ import { SCANNER_VERSION } from "./ownership.mjs";
8
9
 
9
10
  const HEX = /^[a-f0-9]{64}$/;
10
11
  const MAX_REQUEST_BYTES = 2 * 1024 * 1024;
@@ -149,7 +150,7 @@ function ensureNoLinks(file) {
149
150
 
150
151
  function assertElevated() {
151
152
  if (process.platform === "win32") {
152
- const r = spawnSync("C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", "$p=New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent()); if (!$p.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { exit 9 }"]);
153
+ const r = spawnSync("C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", "$p=New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent()); if (!$p.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { exit 9 }"], { env: windowsPowerShellEnvironment() });
153
154
  if (r.status !== 0) throw new Error("protected trust updater requires an elevated Administrator process");
154
155
  } else if (process.platform === "darwin") {
155
156
  if (process.getuid?.() !== 0) throw new Error("protected trust updater requires root");
@@ -158,7 +159,7 @@ function assertElevated() {
158
159
 
159
160
  function hardenWindows(directory, file, runner = spawnSync) {
160
161
  const script = "$ErrorActionPreference='Stop'; $sidSys=New-Object Security.Principal.SecurityIdentifier('S-1-5-18'); $sidAdm=New-Object Security.Principal.SecurityIdentifier('S-1-5-32-544'); $sidUsers=New-Object Security.Principal.SecurityIdentifier('S-1-5-32-545'); foreach($p in @($env:RK_TRUST_DIR,$env:RK_TRUST_FILE)){ $a=New-Object Security.AccessControl.DirectorySecurity; if($p -eq $env:RK_TRUST_FILE){$a=New-Object Security.AccessControl.FileSecurity}; $a.SetAccessRuleProtection($true,$false); $a.SetOwner($sidAdm); $inherit=if($p -eq $env:RK_TRUST_FILE){[Security.AccessControl.InheritanceFlags]::None}else{[Security.AccessControl.InheritanceFlags]'ContainerInherit,ObjectInherit'}; foreach($sid in @($sidSys,$sidAdm)){ $rule=New-Object Security.AccessControl.FileSystemAccessRule($sid,'FullControl',$inherit,[Security.AccessControl.PropagationFlags]::None,'Allow'); $a.AddAccessRule($rule) }; $read=New-Object Security.AccessControl.FileSystemAccessRule($sidUsers,'ReadAndExecute',$inherit,[Security.AccessControl.PropagationFlags]::None,'Allow'); $a.AddAccessRule($read); Set-Acl -LiteralPath $p -AclObject $a }; $a=Get-Acl -LiteralPath $env:RK_TRUST_DIR; $a.SetOwner($sidSys); Set-Acl -LiteralPath $env:RK_TRUST_DIR -AclObject $a";
161
- const r = runner("C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { encoding: "utf8", windowsHide: true, env: { ...process.env, RK_TRUST_DIR: directory, RK_TRUST_FILE: file } });
162
+ const r = runner("C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { encoding: "utf8", windowsHide: true, env: windowsPowerShellEnvironment({ RK_TRUST_DIR: directory, RK_TRUST_FILE: file }) });
162
163
  if (r.status !== 0) throw new Error("could not secure protected trust ACL");
163
164
  }
164
165
 
@@ -172,7 +173,7 @@ export const __testOnly = Object.freeze({
172
173
  });
173
174
 
174
175
  function runWindowsAclScript(script, environment) {
175
- const r = spawnSync("C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { encoding: "utf8", windowsHide: true, env: { ...process.env, ...environment } });
176
+ const r = spawnSync("C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { encoding: "utf8", windowsHide: true, env: windowsPowerShellEnvironment(environment) });
176
177
  if (r.status !== 0) throw new Error("could not create or secure protected ownership path");
177
178
  }
178
179
 
@@ -223,7 +224,7 @@ function auditProtected(file) {
223
224
  const problems = [];
224
225
  for (const target of targets) {
225
226
  try { if (lstatSync(target).isSymbolicLink()) problems.push(`trust path contains a reparse/symlink: ${target}`); } catch { problems.push(`cannot inspect trust path: ${target}`); continue; }
226
- 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 } });
227
+ const r = spawnSync("C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { encoding: "utf8", windowsHide: true, env: windowsPowerShellEnvironment({ RK_TRUST_CHECK_TARGET: target }) });
227
228
  if (r.status !== 0) { problems.push(`cannot inspect trust ACL: ${target}`); continue; }
228
229
  try { problems.push(...evaluateWindowsAcl(JSON.parse(r.stdout.trim()), { target, requireNonElevated: false }).problems.map((problem) => `${problem}: ${target}`)); }
229
230
  catch { problems.push(`malformed ACL result: ${target}`); }
@@ -283,6 +284,89 @@ function atomicProtectedWrite(file, trust) {
283
284
  }
284
285
 
285
286
  /** Elevated child entrypoint. Writes only the fixed native trust path. */
287
+ const versionParts = (value) => String(value).split(".").map(Number);
288
+ function versionAtLeast(value, floor) {
289
+ const a = versionParts(value), b = versionParts(floor);
290
+ for (let i = 0; i < Math.max(a.length, b.length); i += 1) { const x = a[i] ?? 0, y = b[i] ?? 0; if (x !== y) return x > y; }
291
+ return true;
292
+ }
293
+ /**
294
+ * Floors follow the installed (digest-verified) RightKit release this elevated code runs from:
295
+ * its own catalog digest/version and scanner version. Floors only ever rise; an older installed
296
+ * release leaves the existing floors untouched. The request never supplies floors.
297
+ */
298
+ export function raiseFloorsToInstalledRelease(trust, { manifest = installedCatalog(), scannerVersion = SCANNER_VERSION } = {}) {
299
+ const catalogVersion = String(manifest?.version ?? "");
300
+ if (!/^\d+(?:\.\d+)*$/.test(catalogVersion) || !/^\d+(?:\.\d+)*$/.test(scannerVersion)) return trust;
301
+ if (!versionAtLeast(catalogVersion, trust.minimumCatalogVersion ?? "0") || !versionAtLeast(scannerVersion, trust.minimumScannerVersion ?? "0")) return trust;
302
+ 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;
305
+ }
306
+ function installedCatalog() { return JSON.parse(readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), "rightkit-ownership.json"), "utf8")); }
307
+
308
+ /** Typed, non-blocking warnings from the registry merge. The trust update still applies everything else. */
309
+ export const REPOSITORY_DRIFT_CODE = "RK_OWNERSHIP_REPOSITORY_DRIFT";
310
+ export const REPOSITORY_REGISTRY_MALFORMED_CODE = "RK_OWNERSHIP_REPOSITORY_REGISTRY_MALFORMED";
311
+ const warning = (code, message, repositoryIdentity) => ({ code, type: code, message, ...(repositoryIdentity ? { repositoryIdentity } : {}) });
312
+ const REPOSITORY_REGISTRY_FILE = "ownership-repositories.json";
313
+ const MAX_REGISTRY_BYTES = 256 * 1024;
314
+ const REPOSITORY_FIELDS = ["repositoryIdentity", "roots", "visibility"];
315
+ function installedRepositoryRegistry() {
316
+ const file = path.join(path.dirname(fileURLToPath(import.meta.url)), REPOSITORY_REGISTRY_FILE);
317
+ const stat = lstatSync(file);
318
+ if (!stat.isFile() || stat.isSymbolicLink() || (process.platform === "win32" && (stat.attributes & 0x400)) || stat.size > MAX_REGISTRY_BYTES) throw new Error("ownership repository registry must be a bounded regular file");
319
+ try { return JSON.parse(readFileSync(file, "utf8")); }
320
+ catch { throw new Error("ownership repository registry is malformed JSON"); }
321
+ }
322
+ /** Fail closed: every entry must be exact, canonical, unique, and pass validateTrustRegistry's repository rules. */
323
+ function validatedRegistryEntries(registry) {
324
+ if (!exactObject(registry, ["schema", "repositories"]) || registry.schema !== 1 || !Array.isArray(registry.repositories) || !registry.repositories.length) throw new Error("ownership repository registry schema is malformed");
325
+ const identities = new Set();
326
+ for (const entry of registry.repositories) {
327
+ if (!exactObject(entry, REPOSITORY_FIELDS)) throw new Error("ownership repository registry entries must have exactly repositoryIdentity, roots and visibility");
328
+ let identity;
329
+ try { identity = canonicalRepositoryIdentity(entry.repositoryIdentity); }
330
+ catch { throw new Error("ownership repository registry identity is invalid"); }
331
+ if (identity !== entry.repositoryIdentity) throw new Error(`ownership repository registry identity is not canonical: ${entry.repositoryIdentity}`);
332
+ if (identities.has(identity)) throw new Error(`ownership repository registry duplicates ${identity}`);
333
+ identities.add(identity);
334
+ }
335
+ // Floors are placeholders here: only the repository rules of validateTrustRegistry are being checked.
336
+ const probe = validateTrustRegistry({ schema: 1, minimumScannerVersion: "0", minimumCatalogVersion: "0", revokedKeyIds: [], verifiedTrackingEvidence: [], repositories: registry.repositories, provenance: [] });
337
+ if (!probe.ok) throw new Error(`ownership repository registry is invalid: ${probe.problems.join("; ")}`);
338
+ return registry.repositories.map(copyJson);
339
+ }
340
+ /**
341
+ * Additions only, from the committed sibling registry. The trust request never supplies registrations.
342
+ * Returns { trust, warnings }; it never throws for registry problems, so revocations and floors always apply.
343
+ * - An identity already registered with different roots or visibility keeps its protected entry unchanged,
344
+ * that registry entry is skipped, and a RK_OWNERSHIP_REPOSITORY_DRIFT warning is returned.
345
+ * - A malformed registry file skips the whole registry merge with a RK_OWNERSHIP_REPOSITORY_REGISTRY_MALFORMED warning.
346
+ * Provenance, exceptions, and every other field are untouched. firstInstall starts from no registrations.
347
+ */
348
+ export function mergeRepositoriesFromInstalledRelease(trust, { firstInstall = false, registry } = {}) {
349
+ if (!Array.isArray(trust?.repositories)) throw new Error("protected repository registry missing");
350
+ const existing = firstInstall ? [] : trust.repositories.map(copyJson);
351
+ let incoming;
352
+ try { incoming = validatedRegistryEntries(registry ?? installedRepositoryRegistry()); }
353
+ catch (error) { return { trust: { ...trust, repositories: existing }, warnings: [warning(REPOSITORY_REGISTRY_MALFORMED_CODE, `repository registration merge skipped: ${error.message}`)] }; }
354
+ const byIdentity = new Map(existing.map((entry) => [entry.repositoryIdentity, entry]));
355
+ const additions = [];
356
+ const warnings = [];
357
+ for (const entry of incoming) {
358
+ const current = byIdentity.get(entry.repositoryIdentity);
359
+ if (!current) { additions.push(entry); continue; }
360
+ if (canonicalJson(current.roots) !== canonicalJson(entry.roots) || current.visibility !== entry.visibility) {
361
+ warnings.push(warning(REPOSITORY_DRIFT_CODE, `protected repository registration differs from the installed release; kept protected entry, skipped registry entry: ${entry.repositoryIdentity}`, entry.repositoryIdentity));
362
+ }
363
+ }
364
+ const merged = { ...trust, repositories: [...existing, ...additions] };
365
+ const check = validateTrustRegistry(merged);
366
+ if (!check.ok) throw new Error(`merged protected repository registry is invalid: ${check.problems.join("; ")}`);
367
+ return { trust: merged, warnings };
368
+ }
369
+
286
370
  export function performProtectedTrustUpdate({ log, rootFingerprint, baseline, authority } = {}) {
287
371
  assertElevated();
288
372
  const file = OWNERSHIP_TRUST_PATH[process.platform];
@@ -295,11 +379,13 @@ export function performProtectedTrustUpdate({ log, rootFingerprint, baseline, au
295
379
  // Re-read under the protected lock so every accepted checkpoint follows latest disk state.
296
380
  let existing = null;
297
381
  try { lstatSync(file); auditProtected(file); existing = readExisting(file); } catch (error) { if (error.code !== "ENOENT") throw error; existing = null; }
298
- const trust = prepareTrustUpdate(existing, { log, rootFingerprint, baseline, authority });
382
+ const prepared = prepareTrustUpdate(existing, { log, rootFingerprint, baseline, authority });
383
+ const merged = mergeRepositoriesFromInstalledRelease(prepared, { firstInstall: existing == null });
384
+ const trust = raiseFloorsToInstalledRelease(merged.trust);
299
385
  if (trust.keyAuthority) validateCommittedAuthority(trust.keyAuthority, log, trust.rootFingerprint, trust.keyCheckpoint);
300
386
  atomicProtectedWrite(file, trust);
301
387
  auditProtected(file);
302
- return { path: file, trust };
388
+ return { path: file, trust, warnings: merged.warnings };
303
389
  } finally { releaseLock(); }
304
390
  }
305
391
 
@@ -315,12 +401,14 @@ function readPinnedRequest(requestPath, expectedDigest) {
315
401
  } finally { closeSync(fd); }
316
402
  }
317
403
 
318
- if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url))) {
404
+ const realOrSelf = (file) => { try { return realpathSync(file); } catch { return path.resolve(file); } };
405
+ if (process.argv[1] && realOrSelf(process.argv[1]) === realOrSelf(fileURLToPath(import.meta.url))) {
319
406
  try {
320
407
  if (process.argv.length !== 6 || process.argv[2] !== "--request" || process.argv[4] !== "--sha256") throw new Error("usage: ownership-key-trust.mjs --request <absolute-json> --sha256 <digest>");
321
408
  const request = readPinnedRequest(process.argv[3], process.argv[5]);
322
409
  if (!exactObject(request, ["schema", "log", "rootFingerprint", ...(Object.hasOwn(request ?? {}, "baseline") ? ["baseline"] : []), ...(Object.hasOwn(request ?? {}, "authority") ? ["authority"] : [])]) || request.schema !== 1 || !Array.isArray(request.log?.events) || request.log.schema !== 1) throw new Error("key trust request schema is malformed");
323
410
  const result = performProtectedTrustUpdate({ log: request.log, rootFingerprint: request.rootFingerprint, baseline: request.baseline, authority: request.authority });
324
- process.stdout.write(`${JSON.stringify({ path: result.path, checkpoint: result.trust.keyCheckpoint })}\n`);
411
+ for (const item of result.warnings) process.stderr.write(`${item.code}: ${item.message}\n`);
412
+ process.stdout.write(`${JSON.stringify({ path: result.path, checkpoint: result.trust.keyCheckpoint, warnings: result.warnings })}\n`);
325
413
  } catch (error) { process.stderr.write(`${error.message}\n`); process.exitCode = 2; }
326
414
  }
@@ -0,0 +1,311 @@
1
+ // Stage 1: resolved lock graphs, without feature or production-only pruning.
2
+ import { parse as parseToml } from "smol-toml";
3
+ import { existsSync, lstatSync, readFileSync, realpathSync } from "node:fs";
4
+ import path from "node:path";
5
+ import { sha256 } from "./ownership-approval.mjs";
6
+
7
+ export const OWNERSHIP_LOCKFILES = ["Cargo.lock", "pnpm-lock.yaml", "package-lock.json", "npm-shrinkwrap.json", "yarn.lock"];
8
+ const sections = ["dependencies", "devDependencies", "optionalDependencies"];
9
+ class LockError extends Error {
10
+ constructor(reason, message) { super(message); this.reason = reason; }
11
+ }
12
+ const fail = (message) => { throw new LockError("lockfile-parse-failed", message); };
13
+ const object = (value) => value && typeof value === "object" && !Array.isArray(value);
14
+
15
+ // pnpm emits a small YAML subset. Reject unsupported syntax rather than guess.
16
+ // Split only outside quotes/flow collections (URLs, scoped names & peer suffixes are scalars).
17
+ function splitFlow(text, delimiter) {
18
+ const parts = []; let quote = null, depth = 0, start = 0;
19
+ for (let i = 0; i < text.length; i++) {
20
+ const c = text[i];
21
+ if (quote) {
22
+ if (c === "\\" && quote === '"') { i++; continue; }
23
+ if (c === quote) { if (quote === "'" && text[i + 1] === "'") i++; else quote = null; }
24
+ } else if (c === '"' || c === "'") quote = c;
25
+ else if (c === "{" || c === "[") depth++;
26
+ else if (c === "}" || c === "]") depth--;
27
+ else if (!depth && c === delimiter && (delimiter !== ":" || !text[i + 1] || /\s/.test(text[i + 1]))) {
28
+ parts.push(text.slice(start, i)); start = i + 1;
29
+ if (delimiter === ":") { parts.push(text.slice(start)); return parts; }
30
+ }
31
+ if (depth < 0) fail("unbalanced YAML flow value");
32
+ }
33
+ if (quote || depth) fail("unterminated YAML value");
34
+ parts.push(text.slice(start)); return parts;
35
+ }
36
+ function scalar(raw) {
37
+ const value = raw.trim();
38
+ if (/^[&*!|>]/.test(value)) throw new LockError("lockfile-unsupported-format", "YAML anchors, tags or block scalars");
39
+ if (value.startsWith('"')) { try { return JSON.parse(value); } catch { fail("invalid YAML quoted scalar"); } }
40
+ if (value.startsWith("'")) {
41
+ if (!value.endsWith("'") || value.length < 2) fail("invalid YAML quoted scalar");
42
+ return value.slice(1, -1).replaceAll("''", "'");
43
+ }
44
+ if (value.startsWith("{")) {
45
+ if (!value.endsWith("}")) fail("invalid YAML flow map");
46
+ const out = Object.create(null);
47
+ if (value.slice(1, -1).trim()) for (const item of splitFlow(value.slice(1, -1), ",")) {
48
+ const pair = splitFlow(item.trim(), ":");
49
+ if (pair.length !== 2) fail("invalid YAML flow entry");
50
+ const key = scalar(pair[0]); if (Object.hasOwn(out, key)) fail("duplicate YAML key");
51
+ out[key] = scalar(pair[1]);
52
+ }
53
+ return out;
54
+ }
55
+ if (value.startsWith("[")) {
56
+ if (!value.endsWith("]")) fail("invalid YAML flow sequence");
57
+ return value.slice(1, -1).trim() ? splitFlow(value.slice(1, -1), ",").map(scalar) : [];
58
+ }
59
+ if (/[\[\]{}]/.test(value)) fail("invalid YAML scalar");
60
+ return value.replace(/\s+#.*$/, "");
61
+ }
62
+ function parsePnpmYaml(text) {
63
+ const root = Object.create(null), stack = [{ indent: -1, value: root }];
64
+ for (const line of text.split(/\r?\n/)) {
65
+ if (!line.trim() || /^\s*#/.test(line)) continue;
66
+ if (/^\s*\t/.test(line)) fail("YAML tab indentation");
67
+ const indent = line.match(/^ */)[0].length;
68
+ while (stack.length > 1 && indent <= stack.at(-1).indent) stack.pop();
69
+ const parent = stack.at(-1);
70
+ if (indent !== parent.indent + 2 && parent.indent !== -1 || parent.indent === -1 && indent !== 0) fail("invalid YAML indentation");
71
+ // Block sequences occur in package metadata (os/cpu/bundledDependencies), never graph maps.
72
+ const content = line.slice(indent);
73
+ if (content.startsWith("- ")) {
74
+ if (!parent.sequence) {
75
+ if (Object.keys(parent.value).length) fail("mixed YAML collection");
76
+ parent.sequence = []; parent.owner[parent.key] = parent.sequence;
77
+ }
78
+ parent.sequence.push(scalar(content.slice(2))); continue;
79
+ }
80
+ if (parent.sequence) fail("mixed YAML collection");
81
+ const pair = splitFlow(content, ":");
82
+ if (pair.length !== 2) fail("invalid YAML mapping");
83
+ const key = scalar(pair[0]);
84
+ if (typeof key !== "string" || !key || Object.hasOwn(parent.value, key)) fail("invalid or duplicate YAML key");
85
+ const value = pair[1].trim();
86
+ parent.value[key] = value ? scalar(value) : Object.create(null);
87
+ if (!value) stack.push({ indent, value: parent.value[key], owner: parent.value, key });
88
+ }
89
+ return root;
90
+ }
91
+
92
+ function graph() {
93
+ const nodes = new Map(), roots = [];
94
+ return { nodes, roots, diagnostics: [], add(id, name, version, source, local = false) {
95
+ if (typeof name !== "string" || !name || typeof version !== "string" || !version) fail("package name/version missing");
96
+ const node = { id, name, version, source, local, edges: [] }; nodes.set(id, node); return node;
97
+ } };
98
+ }
99
+ function connect(node, target, reference, optional = false) {
100
+ if (!target) {
101
+ if (optional) return; // Optional platform packages can be absent from a resolved install.
102
+ (node.unresolved ??= []).push(`${node.name}: ${reference}`); return;
103
+ }
104
+ node.edges.push(target.id);
105
+ }
106
+ function sourceFor(value, fallback = "registry") {
107
+ if (typeof value === "string" && /^(git\+|git:|https?:.*\.git)/.test(value)) return value.startsWith("git+") ? value : `git+${value}`;
108
+ return value || fallback;
109
+ }
110
+ function cargoGraph(text, manifests) {
111
+ const lock = parseToml(text), g = graph();
112
+ if (!Array.isArray(lock.package) || ![1, 2, 3, 4].includes(lock.version ?? 1)) throw new LockError("lockfile-unsupported-format", "Cargo lock version/package list");
113
+ for (const [i, pkg] of lock.package.entries()) g.add(String(i), pkg.name, pkg.version, pkg.source ?? `path:${pkg.name}`, !pkg.source);
114
+ for (const [i, pkg] of lock.package.entries()) {
115
+ if (pkg.dependencies !== undefined && !Array.isArray(pkg.dependencies)) fail("Cargo dependencies must be a list");
116
+ for (const ref of pkg.dependencies ?? []) {
117
+ if (typeof ref !== "string") fail("invalid Cargo dependency reference");
118
+ const match = /^(\S+)(?: (\S+))?(?: \((.+)\))?$/.exec(ref);
119
+ if (!match) fail("invalid Cargo dependency reference");
120
+ const candidates = [...g.nodes.values()].filter((n) => n.name === match[1] && (!match[2] || n.version === match[2]) && (!match[3] || n.source === match[3]));
121
+ connect(g.nodes.get(String(i)), candidates.length === 1 ? candidates[0] : null, ref);
122
+ }
123
+ }
124
+ for (const { rel, bytes } of manifests) {
125
+ const pkg = parseToml(bytes.toString("utf8")).package;
126
+ if (!pkg) continue; // Virtual workspace: member manifests supply roots.
127
+ const candidates = [...g.nodes.values()].filter((n) => n.name === pkg.name && n.local && (typeof pkg.version !== "string" || n.version === pkg.version));
128
+ if (candidates.length !== 1) { g.diagnostics.push({ reason: "lockfile-root-unresolved", detail: rel }); continue; }
129
+ g.roots.push(candidates[0].id);
130
+ }
131
+ return g;
132
+ }
133
+ function packageLockGraph(text, manifests, lockDir) {
134
+ const lock = JSON.parse(text), g = graph();
135
+ if (![2, 3].includes(lock.lockfileVersion) || !object(lock.packages)) throw new LockError("lockfile-unsupported-format", "npm requires v2/v3 packages");
136
+ for (const [id, pkg] of Object.entries(lock.packages)) {
137
+ if (!object(pkg)) fail("invalid npm package");
138
+ if (pkg.link) continue;
139
+ const name = pkg.name ?? (id.includes("node_modules/") ? id.split("node_modules/").at(-1) : id || lock.name || "app");
140
+ g.add(id, name, pkg.version ?? (!id.includes("node_modules/") ? "0.0.0" : undefined), sourceFor(pkg.resolved), !id.includes("node_modules/") || /^(?:file:|link:)/.test(pkg.resolved ?? ""));
141
+ }
142
+ const resolve = (from, name) => {
143
+ let dir = from;
144
+ while (true) {
145
+ const id = path.posix.join(dir, "node_modules", name);
146
+ const linked = lock.packages[id];
147
+ const target = linked?.link ? g.nodes.get(path.posix.normalize(linked.resolved)) : g.nodes.get(id);
148
+ if (target) return target;
149
+ if (!dir || dir === ".") return null;
150
+ dir = path.posix.dirname(dir); if (dir === ".") dir = "";
151
+ }
152
+ };
153
+ for (const [id, node] of g.nodes) {
154
+ const pkg = lock.packages[id];
155
+ for (const section of [...(node.local ? sections : ["dependencies", "optionalDependencies"]), "peerDependencies"]) {
156
+ if (pkg[section] !== undefined && !object(pkg[section])) fail("invalid npm dependencies");
157
+ for (const name of Object.keys(pkg[section] ?? {})) connect(node, resolve(id, name), name, section === "optionalDependencies" || section === "peerDependencies" && pkg.peerDependenciesMeta?.[name]?.optional === true);
158
+ }
159
+ }
160
+ for (const { rel, bytes } of manifests) {
161
+ const id = path.posix.relative(lockDir, path.posix.dirname(rel));
162
+ const node = g.nodes.get(id);
163
+ if (!node) { g.diagnostics.push({ reason: "lockfile-root-unresolved", detail: rel }); continue; }
164
+ // Root display names come from app manifests, not declarations in ownership files.
165
+ const pkg = JSON.parse(bytes.toString("utf8"));
166
+ node.name = pkg.name ?? node.name;
167
+ for (const section of [...sections, "peerDependencies"]) for (const name of Object.keys(pkg[section] ?? {})) connect(node, resolve(id, name), name, section === "optionalDependencies" || section === "peerDependencies" && pkg.peerDependenciesMeta?.[name]?.optional === true);
168
+ g.roots.push(id);
169
+ }
170
+ return g;
171
+ }
172
+ function pnpmGraph(text, manifests, lockDir) {
173
+ const lock = parsePnpmYaml(text), version = String(lock.lockfileVersion), g = graph();
174
+ if (!/^(?:9|6)(?:\.\d+)?$/.test(version) || !object(lock.importers) || !object(lock.packages ?? {})) throw new LockError("lockfile-unsupported-format", "pnpm requires v6/v9 importers");
175
+ const v9 = version.startsWith("9"), packages = lock.packages ?? {}, snapshots = v9 ? lock.snapshots ?? {} : packages;
176
+ if (!object(snapshots)) fail("invalid pnpm snapshots");
177
+ const packageIdentity = (id) => {
178
+ const bare = id.replace(/^\//, ""), match = /^(@[^/]+\/[^@]+|[^@]+)@(.+)$/.exec(bare);
179
+ if (!match) throw new LockError("lockfile-unsupported-format", `pnpm package key: ${id}`);
180
+ return { name: match[1], version: match[2].split(/[(_]/)[0] };
181
+ };
182
+ for (const [id, pkg] of Object.entries(snapshots)) {
183
+ if (!object(pkg)) fail("invalid pnpm snapshot");
184
+ const identity = packageIdentity(id), metadata = packages[id] ?? packages[id.split("(")[0]] ?? {};
185
+ const resolved = metadata.resolution?.tarball ?? (metadata.resolution?.directory ? `path:${metadata.resolution.directory}` : undefined);
186
+ g.add(id, identity.name, metadata.version ?? identity.version, sourceFor(resolved), metadata.resolution?.type === "directory" || identity.version.startsWith("file:"));
187
+ }
188
+ for (const [id, importer] of Object.entries(lock.importers)) {
189
+ if (!object(importer)) fail("invalid pnpm importer");
190
+ g.add(`importer:${id}`, id, "0.0.0", `path:${id}`, true);
191
+ }
192
+ const resolve = (node, name, raw) => {
193
+ const ref = object(raw) ? raw.version : raw;
194
+ if (typeof ref !== "string") fail("invalid pnpm dependency version");
195
+ if (/^(?:link:|workspace:)/.test(ref)) {
196
+ if (!ref.startsWith("link:")) throw new LockError("lockfile-unresolved-dependency", ref);
197
+ const importer = node.id.replace(/^importer:/, "");
198
+ return g.nodes.get(`importer:${path.posix.normalize(path.posix.join(importer, ref.slice(5)))}`);
199
+ }
200
+ const candidates = [ref, `${name}@${ref}`, `/${name}@${ref}`];
201
+ const exact = candidates.map((id) => g.nodes.get(id)).find(Boolean);
202
+ if (exact) return exact;
203
+ // Aliases encode the actual package name in the version reference.
204
+ const normalized = ref.replace(/^npm:/, "");
205
+ return g.nodes.get(normalized) ?? g.nodes.get(`/${normalized}`);
206
+ };
207
+ for (const node of g.nodes.values()) {
208
+ const isImporter = node.id.startsWith("importer:"), pkg = isImporter ? lock.importers[node.id.slice(9)] : snapshots[node.id];
209
+ for (const section of isImporter ? sections : ["dependencies", "optionalDependencies"]) {
210
+ if (pkg[section] !== undefined && !object(pkg[section])) fail("invalid pnpm dependency map");
211
+ for (const [name, raw] of Object.entries(pkg[section] ?? {})) connect(node, resolve(node, name, raw), name, section === "optionalDependencies");
212
+ }
213
+ }
214
+ for (const { rel, bytes } of manifests) {
215
+ const id = path.posix.relative(lockDir, path.posix.dirname(rel)) || ".", node = g.nodes.get(`importer:${id}`);
216
+ if (!node) { g.diagnostics.push({ reason: "lockfile-root-unresolved", detail: rel }); continue; }
217
+ node.name = JSON.parse(bytes.toString("utf8")).name ?? id;
218
+ const pkg = JSON.parse(bytes.toString("utf8")), importer = lock.importers[id];
219
+ for (const section of sections) for (const name of Object.keys(pkg[section] ?? {})) if (!Object.hasOwn(importer[section] ?? {}, name)) g.diagnostics.push({ reason: "lockfile-root-unresolved", detail: `${rel}: ${section}.${name}` });
220
+ g.roots.push(node.id);
221
+ }
222
+ return g;
223
+ }
224
+
225
+ /** Lock bytes stay bound to the scan inventory or committed snapshot; no installed graph reads. */
226
+ export function transitiveDependencies(appRoot, inventory, manifests, catalogOwners, coverage, contentDigests, targets) {
227
+ const result = [], cache = new Map(), groups = new Map(), files = new Map(inventory.files.map((file) => [file.rel, file])), targetNames = new Set(targets);
228
+ const diagnostic = (rel, reason, detail) => coverage.push({ path: rel, status: "unscanned", reason, ...(detail ? { detail } : {}) });
229
+ const read = (rel) => {
230
+ if (cache.has(rel)) return cache.get(rel);
231
+ let file = files.get(rel), bytes = file?.bytes;
232
+ if (!file && !inventory.committed) {
233
+ const absolute = path.resolve(appRoot, rel);
234
+ if (existsSync(absolute)) {
235
+ const st = lstatSync(absolute), root = realpathSync(appRoot), real = realpathSync(absolute);
236
+ if (!st.isFile() || st.isSymbolicLink() || !real.startsWith(`${root}${path.sep}`) || st.size > 64 * 1024 * 1024) throw new LockError("lockfile-unreadable", rel);
237
+ bytes = readFileSync(absolute); file = { rel, bytes };
238
+ diagnostic(rel, "lockfile-not-in-inventory", "lock bytes are absent from Git-listed scan inputs");
239
+ }
240
+ }
241
+ if (file && !bytes) throw new LockError("lockfile-unreadable", rel);
242
+ cache.set(rel, bytes);
243
+ if (bytes && !contentDigests.some((item) => item.path === rel)) contentDigests.push({ path: rel, digest: sha256(bytes) });
244
+ return bytes;
245
+ };
246
+ for (const manifest of manifests) {
247
+ const base = path.posix.basename(manifest.rel), ecosystem = base === "Cargo.toml" ? "cargo" : base === "package.json" ? "npm" : null;
248
+ if (!ecosystem || !manifest.bytes) continue;
249
+ const names = ecosystem === "cargo" ? ["Cargo.lock"] : OWNERSHIP_LOCKFILES.slice(1);
250
+ let dir = path.posix.dirname(manifest.rel), found = false;
251
+ try {
252
+ while (true) {
253
+ const locks = names.map((name) => path.posix.join(dir, name)).filter((rel) => read(rel) !== undefined);
254
+ if (locks.length) {
255
+ for (const rel of locks) {
256
+ if (!groups.has(rel)) groups.set(rel, { ecosystem, manifests: [] });
257
+ groups.get(rel).manifests.push(manifest);
258
+ }
259
+ found = true; break;
260
+ }
261
+ if (dir === ".") break;
262
+ dir = path.posix.dirname(dir);
263
+ }
264
+ if (!found) diagnostic(manifest.rel, "lockfile-missing", ecosystem);
265
+ } catch (error) { diagnostic(manifest.rel, error.reason ?? "lockfile-unreadable", error.message); }
266
+ }
267
+ for (const [rel, group] of groups) {
268
+ try {
269
+ const text = read(rel).toString("utf8"), base = path.posix.basename(rel), dir = path.posix.dirname(rel);
270
+ if (base === "yarn.lock") throw new LockError("lockfile-unsupported-format", "yarn transitive graph");
271
+ const g = group.ecosystem === "cargo" ? cargoGraph(text, group.manifests) : base === "pnpm-lock.yaml" ? pnpmGraph(text, group.manifests, dir) : packageLockGraph(text, group.manifests, dir);
272
+ for (const entry of g.diagnostics) diagnostic(rel, entry.reason, entry.detail);
273
+ for (const node of g.nodes.values()) for (const detail of node.unresolved ?? []) diagnostic(rel, "lockfile-unresolved-dependency", detail);
274
+ // Retain only nodes leading to block fingerprints. Unrelated lock inventory cannot
275
+ // multiply traversal work; owner boundaries cannot authorize app/workspace nodes.
276
+ const reverse = new Map(), viable = new Set();
277
+ for (const node of g.nodes.values()) {
278
+ if (!node.local && catalogOwners.has(node.name)) continue;
279
+ if (targetNames.has(`${group.ecosystem}:${node.name}`)) viable.add(node.id);
280
+ for (const id of node.edges) {
281
+ if (!reverse.has(id)) reverse.set(id, []);
282
+ reverse.get(id).push(node.id);
283
+ }
284
+ }
285
+ const ancestors = [...viable];
286
+ for (let at = 0; at < ancestors.length; at++) for (const id of reverse.get(ancestors[at]) ?? []) if (!viable.has(id)) { viable.add(id); ancestors.push(id); }
287
+ const seen = new Set(); let steps = 0;
288
+ for (const root of new Set(g.roots)) {
289
+ const queue = [[root]];
290
+ for (let at = 0; at < queue.length; at++) {
291
+ if (++steps > 20000) throw new LockError("lockfile-traversal-limit", "more than 20000 dependency paths");
292
+ const ids = queue[at], node = g.nodes.get(ids.at(-1));
293
+ // App/workspace names & app ownership declarations never grant an owner boundary.
294
+ if (ids.length > 1 && !node.local && catalogOwners.has(node.name)) continue;
295
+ const dependencyPath = ids.map((id) => g.nodes.get(id).name);
296
+ if (ids.length > 2 && targetNames.has(`${group.ecosystem}:${node.name}`)) {
297
+ const key = JSON.stringify([node.name, node.version, node.source, dependencyPath]);
298
+ if (!seen.has(key)) {
299
+ seen.add(key); result.push({ ecosystem: group.ecosystem, name: node.name, resolvedVersion: node.version, source: node.source, file: rel, lockfile: rel, dependencyPath });
300
+ }
301
+ }
302
+ for (const id of node.edges) if (viable.has(id) && !ids.includes(id)) {
303
+ if (queue.length >= 20000) throw new LockError("lockfile-traversal-limit", "more than 20000 dependency paths");
304
+ queue.push([...ids, id]);
305
+ }
306
+ }
307
+ }
308
+ } catch (error) { diagnostic(rel, error.reason ?? "lockfile-parse-failed", error.message); }
309
+ }
310
+ return result.sort((a, b) => a.dependencyPath.length - b.dependencyPath.length || JSON.stringify(a.dependencyPath).localeCompare(JSON.stringify(b.dependencyPath)));
311
+ }
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { existsSync, readFileSync, writeFileSync, realpathSync } from "node:fs";
3
3
  import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { spawn } from "./background-process.mjs";
@@ -63,7 +63,15 @@ export async function main(args, io = {}) {
63
63
  const scanned = scanResults.get(app.key);
64
64
  if (!scanned) throw new Error("configured app root missing; marked unscanned");
65
65
  if (scanned.error) throw new Error(scanned.error);
66
- return scanned.value;
66
+ // The protected worker returns { inputs, trust, report, verdict } (or { error }); never treat
67
+ // that envelope itself as a report, which would read as a false zero-finding scan.
68
+ if (scanned.value?.error) throw new Error(`${scanned.value.code ?? "RK_OWNERSHIP_UNVERIFIED"}: ${scanned.value.error}`);
69
+ // A protected preflight refusal returns without evidence: nothing was scanned.
70
+ if (!scanned.value?.evidence && scanned.value?.verdict && !scanned.value.verdict.admit)
71
+ throw new Error(`${scanned.value.verdict.code}: ${(scanned.value.verdict.problems ?? []).join("; ") || "protected preflight refused"}; marked unscanned`);
72
+ const scan = scanned.value?.report;
73
+ if (!scan || typeof scan !== "object" || !Array.isArray(scan.violations) || !Array.isArray(scan.problems)) throw new Error("scanner worker returned no ownership report; marked unscanned");
74
+ return scan;
67
75
  } });
68
76
  const markdown = renderOwnershipMarkdown(report);
69
77
  const json = formatOwnershipJson(report);
@@ -81,7 +89,8 @@ export async function main(args, io = {}) {
81
89
  return report.apps.some((app) => app.status === "blocked" || app.status === "unscanned" || app.rows.some((row) => row.status === "blocked" || row.status === "unscanned")) ? 1 : 0;
82
90
  }
83
91
 
84
- if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
92
+ const realOrSelf = (file) => { try { return realpathSync(file); } catch { return path.resolve(file); } };
93
+ if (process.argv[1] && realOrSelf(process.argv[1]) === realOrSelf(fileURLToPath(import.meta.url))) {
85
94
  try { process.exitCode = await main(process.argv.slice(2)); }
86
95
  catch (error) { console.error(`[right-release] ownership report: ${error.message}`); process.exitCode = 1; }
87
96
  }
@@ -4,6 +4,7 @@ import { checkApp, EXCEPTIONS_FILE } from "./ownership.mjs";
4
4
  import { DEFAULT_APPS } from "./standalone-clone-verify.mjs";
5
5
  import { sha256 } from "./ownership-approval.mjs";
6
6
  import { findInstallerCopyScripts, findLargeRepositorySubdirectories } from "./installer-storage-doctor.mjs";
7
+ import { ownershipAvailability, ownershipDetection } from "./ownership-availability.mjs";
7
8
 
8
9
  export const OWNERSHIP_APPS = Object.freeze([
9
10
  ...DEFAULT_APPS.map(({ key, appDir }) => ({ key, root: key === "coderight" ? key : path.posix.join(key, appDir === "." ? "" : appDir), scope: key === "coderight" ? "repository" : appDir })),
@@ -11,6 +12,9 @@ export const OWNERSHIP_APPS = Object.freeze([
11
12
  { key: "genright", root: "genright" },
12
13
  { key: "rightsites", root: "rightsites" },
13
14
  { key: "voiceright", root: "voiceright" },
15
+ { key: "pulse", root: "cockpit", scope: "repository" },
16
+ { key: "postright", root: "postright", scope: "repository" },
17
+ { key: "legion", root: "legion", scope: "repository" },
14
18
  { key: "membrane", root: "coderight/context/apps/membrane-hub", scope: "context/apps/membrane-hub" },
15
19
  ]);
16
20
 
@@ -22,23 +26,13 @@ function catalogVersion(manifest) {
22
26
  }
23
27
 
24
28
  export function publishedAvailability(capability, versions) {
25
- const npmPackage = capability.npmPackage ?? (capability.package?.startsWith("@rightkit/") ? capability.package : undefined);
26
- const cargoPackage = capability.cargoPackage ?? (capability.package?.startsWith("rightkit-") ? capability.package : undefined);
27
- const npmVersion = npmPackage && (versions.npm?.[npmPackage] ?? versions.stagedNpm?.[npmPackage]);
28
- const cargoVersion = cargoPackage && (versions.cargo?.[cargoPackage] ?? versions.stagedCargo?.[cargoPackage]);
29
- const swift = capability.package === "rightkit-swift" ? (versions.swift?.["rightkit-swift"] ?? versions.stagedSwift?.["rightkit-swift"]) : undefined;
30
- const published = [];
31
- if (npmVersion) published.push({ registry: "npm", package: npmPackage, version: npmVersion, state: versions.npm?.[npmPackage] ? "registry-pinned" : "staged-unverified" });
32
- if (cargoVersion) published.push({ registry: "crates.io", package: cargoPackage, version: cargoVersion, state: versions.cargo?.[cargoPackage] ? "registry-pinned" : "staged-unverified" });
33
- if (swift) published.push({ registry: "SwiftPM", package: "rightkit-swift", version: swift.version, url: swift.url, state: versions.swift?.["rightkit-swift"] ? "registry-pinned" : "staged-unverified" });
34
- return published;
29
+ return ownershipAvailability(capability, versions);
35
30
  }
36
31
 
37
32
  function statusFor(row, report) {
38
33
  if (report.enrollmentPending && (row.status === "blocked" || row.status === "blocked-by-findings")) return "enrollment-pending";
39
34
  if (row.status === "blocked" || row.status === "blocked-by-findings") return "blocked";
40
35
  if (row.status === "excepted") return "excepted";
41
- if (row.status === "qa-only") return "qa-only";
42
36
  if (row.status === "verified-integration") return "verified-integration";
43
37
  if (row.status === "declared-dependency" || row.uses === true) return "declared-dependency";
44
38
  if (report.coverage?.length) return "unscanned";
@@ -60,14 +54,22 @@ function exceptionRequests(root, app, report) {
60
54
  }
61
55
 
62
56
  function asRecord(app, root, scope, report, manifest, versions, scannedAt, storageFindings = []) {
57
+ const policies = new Map(manifest.capabilities.flatMap((entry) => entry.fingerprints.map((fingerprint) => [fingerprint.id, fingerprint])));
58
+ const isAdvice = (finding) => policies.get(finding.fingerprint)?.tier === "advise";
59
+ const advisories = (report.findings ?? report.violations ?? []).filter(isAdvice);
60
+ report = { ...report, violations: (report.violations ?? []).filter((finding) => !isAdvice(finding)) };
63
61
  const meta = report.metadata ?? {};
64
- const findings = (report.findings ?? []).map((f) => ({ fingerprint: f.fingerprint, capability: f.capability, file: f.file, line: f.line ?? 0, occurrenceHash: f.occurrenceHash ?? sha256(canonicalJson(f)), ...(f.qaOnly ? { qaOnly: true } : {}) }));
62
+ const findings = (report.findings ?? []).map((f) => ({ fingerprint: f.fingerprint, capability: f.capability, file: f.file, line: f.line ?? 0, occurrenceHash: f.occurrenceHash ?? sha256(canonicalJson(f)) }));
65
63
  const files = [...new Set(findings.map((f) => f.file))].sort();
66
64
  const contentDigestSource = meta.contentDigests ? "scanner-inputs" : "finding-occurrences-only";
67
65
  const contentDigests = meta.contentDigests ?? findings.map((f) => ({ path: f.file, digest: f.occurrenceHash, kind: "finding-occurrence" }));
68
66
  const rows = (report.rows ?? []).map((row) => {
69
- const catalogEntry = manifest.capabilities.find((c) => c.capability === row.capability || c.package === row.owner);
70
- return { ...row, status: statusFor(row, report), available: catalogEntry ? publishedAvailability(catalogEntry, versions) : [] };
67
+ const catalogEntries = manifest.capabilities.filter((c) => c.capability === row.capability);
68
+ const declarationOnly = catalogEntries.length > 0 && catalogEntries.every((entry) => ownershipDetection(entry) === "declaration-only");
69
+ let status = statusFor(row, report);
70
+ if (declarationOnly && status === "verified-integration") status = row.uses ? "declared-dependency" : "not-detected";
71
+ if (status === "blocked" && !report.violations.some((finding) => finding.capability === row.capability) && advisories.some((finding) => finding.capability === row.capability)) status = "advisory";
72
+ return { ...row, status, detection: declarationOnly ? "declaration-only" : "fingerprinted", available: catalogEntries.flatMap((entry) => publishedAvailability(entry, versions)) };
71
73
  });
72
74
  const enrollmentIsPending = report.enrollmentPending === true;
73
75
  const record = {
@@ -90,13 +92,14 @@ function asRecord(app, root, scope, report, manifest, versions, scannedAt, stora
90
92
  coverage: report.coverage ?? [],
91
93
  rows,
92
94
  findings,
95
+ advisories,
93
96
  violations: report.violations ?? [],
94
97
  covered: report.covered ?? [],
95
98
  problems: report.problems ?? [],
96
99
  warnings: report.warnings ?? [],
97
100
  storageFindings,
98
101
  };
99
- record.resultDigest = sha256(canonicalJson({ app: record.app, commit: record.commit, catalogDigest: record.catalogDigest, contentDigests: record.contentDigests, coverage: record.coverage, rows: record.rows, problems: record.problems, warnings: record.warnings, storageFindings: record.storageFindings, enrollmentPending: record.enrollmentPending }));
102
+ record.resultDigest = sha256(canonicalJson({ app: record.app, commit: record.commit, catalogDigest: record.catalogDigest, contentDigests: record.contentDigests, coverage: record.coverage, rows: record.rows, advisories: record.advisories, problems: record.problems, warnings: record.warnings, storageFindings: record.storageFindings, enrollmentPending: record.enrollmentPending }));
100
103
  record.exceptionRequests = exceptionRequests(root, app, report);
101
104
  return record;
102
105
  }
@@ -143,8 +146,8 @@ export function renderOwnershipMarkdown(report) {
143
146
  const state = app.status ?? (states.has("blocked") ? "blocked" : states.has("unscanned") || app.coverage.length ? "unscanned" : "scanned");
144
147
  lines.push(`| ${app.app} | ${app.scope ?? "."} | ${state} | ${app.commit ?? "unknown"} | ${app.host ?? "unknown"} | ${app.scanAgeSeconds}s | ${app.findings.length} | ${app.coverage.length} |`);
145
148
  }
146
- lines.push("", "## Capability status", "", "| App | Capability | Status | Published availability |", "|---|---|---|---|");
147
- for (const app of report.apps) for (const row of app.rows) lines.push(`| ${app.app} | ${row.capability} | ${row.status} | ${row.available.map((x) => `${x.package}@${x.version} (${x.state})`).join(", ") || "none recorded"} |`);
149
+ lines.push("", "## Capability status", "", "| App | Capability | Status | Detection | Availability |", "|---|---|---|---|---|");
150
+ for (const app of report.apps) for (const row of app.rows) lines.push(`| ${app.app} | ${row.capability} | ${row.status} | ${row.detection} | ${row.available.map((x) => `${x.package}@${x.version} (${x.state})`).join(", ") || "none recorded"} |`);
148
151
  lines.push("", `Blocks: ${report.blocks.length} · Exception requests awaiting verification: ${report.exceptionRequests.length}`, "", "Exception requests are unverified records; only scanner-validated signed exceptions are reported as excepted.", "");
149
152
  if (report.warnings?.length) {
150
153
  lines.splice(-2, 0, "## Warnings", "", ...report.warnings.map((warning) => `- ${warning.app}: WARNING: ${warning.message}; ${warning.findings.length} finding(s), ${warning.problems.length} problem(s) retained as diagnostics`), "");