@rightkit/release 0.2.164 → 0.2.172

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 (77) hide show
  1. package/README.md +121 -4
  2. package/addon-command.mjs +6 -13
  3. package/android-aab-scan.mjs +265 -0
  4. package/android-dev.mjs +111 -0
  5. package/android-ndk.mjs +77 -0
  6. package/android-play-upload.mjs +262 -0
  7. package/android-release.mjs +344 -0
  8. package/android-test-fixtures.mjs +79 -0
  9. package/apple-profile-create.mjs +363 -0
  10. package/bin/darwin/rightkit-swap +0 -0
  11. package/bin/darwin/rightkit-swap.sign.json +6 -0
  12. package/build-release.mjs +73 -13
  13. package/cargo-guard.mjs +2 -0
  14. package/cargo-profile-contract.mjs +183 -0
  15. package/cargo-target.mjs +8 -0
  16. package/cli/right-release.mjs +14 -4
  17. package/committed-source.mjs +1 -1
  18. package/dev-artifact-refresh.mjs +176 -0
  19. package/dev-artifact-support.mjs +61 -0
  20. package/development-signing.mjs +71 -4
  21. package/development.mjs +78 -46
  22. package/direct-bootstrap.mjs +26 -41
  23. package/disk-command.mjs +315 -13
  24. package/disk-policy.json +7 -5
  25. package/disk-policy.mjs +141 -22
  26. package/disk-stable-root.mjs +19 -16
  27. package/engine-locator.mjs +116 -0
  28. package/external-bin.mjs +133 -0
  29. package/github-release.mjs +2 -2
  30. package/github-updater-release.mjs +154 -0
  31. package/hardeningscan.mjs +1 -1
  32. package/in-place-install.mjs +56 -0
  33. package/install-app-windows.mjs +63 -0
  34. package/install-app.mjs +53 -23
  35. package/local-publish.mjs +11 -1
  36. package/mac-signing-matrix.mjs +23 -11
  37. package/managed-cargo.mjs +6 -0
  38. package/media-sidecar-catalog.mjs +45 -0
  39. package/media-sidecar.mjs +306 -0
  40. package/ownership-approval-command.mjs +10 -44
  41. package/ownership-approval.mjs +3 -42
  42. package/ownership-catalog-config.json +2 -1
  43. package/ownership-catalog.mjs +4 -2
  44. package/ownership-command.mjs +7 -9
  45. package/ownership-dependency-allowlist.json +25 -1
  46. package/ownership-evidence.mjs +8 -5
  47. package/ownership-git-inventory.mjs +41 -7
  48. package/ownership-lockfiles.mjs +91 -8
  49. package/ownership-mandatory-set.json +36 -16
  50. package/ownership-report.mjs +5 -6
  51. package/ownership-scan-worker.mjs +2 -2
  52. package/ownership-verdict.mjs +10 -7
  53. package/ownership.mjs +47 -29
  54. package/package.json +7 -4
  55. package/portable-development.mjs +32 -115
  56. package/preflight.mjs +4 -6
  57. package/publication-artifacts.mjs +34 -0
  58. package/publish-update.mjs +72 -11
  59. package/qa-adoption-exemptions.json +5 -0
  60. package/qa-build-features.mjs +7 -0
  61. package/qa-contract.mjs +182 -0
  62. package/qa-control-scan.mjs +3 -3
  63. package/release-qa-command.mjs +38 -12
  64. package/release-qa-installed.mjs +136 -0
  65. package/release-qa-receipt.mjs +23 -2
  66. package/release.mjs +283 -14
  67. package/rightkit-ownership.json +537 -5
  68. package/rightkit-sync.mjs +26 -0
  69. package/rightkit-versions.json +110 -62
  70. package/runtime-budget-classes.json +28 -0
  71. package/runtime-budgets.mjs +4 -1
  72. package/sign-candidate.mjs +161 -0
  73. package/sign-dev-artifact.mjs +198 -0
  74. package/stage-binary.mjs +60 -0
  75. package/swap-helper-bundle.mjs +105 -0
  76. package/templates/cargo-profiles.toml +28 -0
  77. package/upload-release.mjs +11 -1
@@ -41,7 +41,7 @@ function createEvidence(repoRoot, {
41
41
  if (trustState.protection?.ok !== true && !pendingWithoutTrust) throw new Error("protected ownership trust location was not verified");
42
42
  if (!trustState.trust && !pendingWithoutTrust) throw new Error("protected ownership trust registry is missing");
43
43
  let enrollmentPending = trustState.enrolled !== true;
44
- if (committed && enrollmentPending && !allowPendingAdvisory && !trustState.trust?.rootFingerprint) throw new Error("protected ownership evidence requires both distinct, unrevoked approval keys to be enrolled");
44
+ // Pending approval enrollment is a reported warning, never a failure: enrollment existed only for retired signed approvals (#262).
45
45
  // A valid protected registry with incomplete key enrollment can still drive
46
46
  // a useful local backlog scan. It cannot assert a protected source binding or verify
47
47
  // approval signatures, so this result is deliberately advisory.
@@ -88,7 +88,7 @@ function createEvidence(repoRoot, {
88
88
  }
89
89
  const floor = trustState.trust
90
90
  ? validateOwnershipFloor(trustState.trust, { scannerVersion, catalogVersion, catalogDigest, ...(policyBundleDigest !== undefined ? { policyBundleDigest } : {}) })
91
- : { ok: false, problems: ["protected ownership floor is unavailable until trust registry enrollment"] };
91
+ : { ok: true, problems: [], warnings: ["protected ownership floor is unavailable: no protected trust registry is enrolled on this host (warning only)"] };
92
92
  floor.warnings = [...(floor.warnings ?? []), ...(bundleProblem ? [bundleProblem] : [])];
93
93
  // Real paths on both sides: Git's root is resolved (macOS /private/var), callers may pass /var or /tmp.
94
94
  const real = (p) => { try { return realpathSync.native(p); } catch { return path.resolve(p); } };
@@ -149,13 +149,16 @@ function createEvidence(repoRoot, {
149
149
  catch (error) { if (error?.code !== "ENOENT") throw error; return { root: registered, digest: sha256([]) }; }
150
150
  });
151
151
  const approvalsDigest = enrollmentPending ? null : sha256(approvals);
152
+ const resultWarnings = [...scans.flatMap(({ scanned, root: registered }) => (scanned.warnings ?? []).filter((w) => w.startsWith("exception #")).map((w) => (registered === "." ? w : `${registered}: ${w}`))), ...evidenceDiagnostics];
152
153
  const result = {
153
154
  ok: scans.every(({ scanned }) => scanned.ok),
154
155
  findings: scans.flatMap(({ scanned, prefix }) => (scanned.findings ?? []).map((entry) => rebase(entry, prefix))),
155
156
  violations: scans.flatMap(({ scanned, prefix }) => (scanned.violations ?? []).map((entry) => rebase(entry, prefix))),
156
- problems: [...scans.flatMap(({ scanned }) => scanned.problems ?? []), ...evidenceDiagnostics, ...(enrollmentPending ? floor.problems : [])],
157
+ problems: scans.flatMap(({ scanned }) => scanned.problems ?? []),
157
158
  rows: scans.flatMap(({ scanned, root: registered }) => (scanned.rows ?? []).map((row) => ({ ...row, root: registered }))),
158
159
  covered: scans.flatMap(({ scanned, prefix }) => (scanned.covered ?? []).map((entry) => rebase(entry, prefix))),
160
+ // Stale plain exception entries (match no finding) are warnings, never blockers (Orthic-Labs/rightkit#262).
161
+ ...(resultWarnings.length ? { warnings: resultWarnings } : {}),
159
162
  coverage: contentCoverage,
160
163
  metadata: {
161
164
  repositoryIdentity, commit, tree, catalogDigest, contentDigests, coverage: contentCoverage,
@@ -182,8 +185,8 @@ function createEvidence(repoRoot, {
182
185
  contentDigest: enrollmentPending ? null : contentDigest,
183
186
  checkedAt: new Date().toISOString(),
184
187
  };
185
- const warning = enrollmentPending ? "WARNING: ownership approval enrollment is pending; findings remain backlog diagnostics and no protected evidence or approvals are asserted" : undefined;
186
- return { ok: !enrollmentPending && result.ok && floor.ok && contentCoverage.length === 0, ...(enrollmentPending ? { enrollmentPending: true, warning } : {}), ...(floor.warnings.length ? { warnings: floor.warnings } : {}), evidence, result, floor, contentCoverage };
188
+ const warning = enrollmentPending ? "WARNING: ownership approval enrollment is pending (warning only: exceptions are plain git-reviewed entries); the advisory evidence asserts no protected source binding" : undefined;
189
+ return { ok: result.ok && floor.ok && contentCoverage.length === 0, ...(enrollmentPending ? { enrollmentPending: true, warning } : {}), ...(floor.warnings.length ? { warnings: floor.warnings } : {}), evidence, result, floor, contentCoverage };
187
190
  }
188
191
 
189
192
  /** Create scan evidence bound to committed source, catalog, and signed exceptions. */
@@ -12,6 +12,38 @@ const DEFAULT_MAX_FILE_BYTES = 2 * 1024 * 1024;
12
12
  const CACHE_LIMIT = 128 * 1024 * 1024;
13
13
  const blobCache = new Map();
14
14
  let cacheBytes = 0;
15
+ // Dirty inputs kept for reuse. Reused only while size & mtime are unchanged; bounded like the Rust scanner.
16
+ const HASH_CACHE_BYTES = 64 * 1024 * 1024;
17
+ // A file modified more recently than this is never kept: a same-size rewrite inside one timestamp tick is indistinguishable.
18
+ const RACY_WINDOW_MS = 2000;
19
+ const hashCache = new Map();
20
+ let hashCacheBytes = 0;
21
+
22
+ function signatureOf(abs) {
23
+ try {
24
+ const stat = lstatSync(abs, { bigint: true });
25
+ return stat.isFile() ? { key: `${stat.size}:${stat.mtimeNs}`, size: Number(stat.size), mtimeMs: Number(stat.mtimeNs / 1000000n) } : undefined;
26
+ } catch { return undefined; }
27
+ }
28
+
29
+ // Read & hash one dirty input. A kept copy is reused only when size & mtime are unchanged; a file whose size or
30
+ // mtime moved while it was read is returned but never kept. This scanner is single-threaded, so one worker at a time.
31
+ function readHashed(abs) {
32
+ const before = signatureOf(abs);
33
+ if (before) {
34
+ const kept = hashCache.get(abs);
35
+ if (kept?.key === before.key) return { bytes: kept.bytes, sum: kept.sum };
36
+ if (kept) { hashCacheBytes -= kept.bytes.length; hashCache.delete(abs); }
37
+ }
38
+ const bytes = readFileSync(abs), sum = sha256(bytes);
39
+ if (before) {
40
+ const after = signatureOf(abs);
41
+ if (after?.key === before.key && bytes.length === before.size && before.mtimeMs + RACY_WINDOW_MS <= Date.now() && hashCacheBytes + before.size <= HASH_CACHE_BYTES) {
42
+ hashCache.set(abs, { key: before.key, bytes, sum }); hashCacheBytes += before.size;
43
+ }
44
+ }
45
+ return { bytes, sum };
46
+ }
15
47
 
16
48
  function gitEnv() {
17
49
  const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !/^GIT_/i.test(key)));
@@ -19,10 +51,10 @@ function gitEnv() {
19
51
  return env;
20
52
  }
21
53
 
22
- function runGit(root, repositoryRoot, args, { input, maxBuffer = MAX_LIST_BYTES } = {}) {
54
+ function runGit(root, repositoryRoot, args, { input, maxBuffer = MAX_LIST_BYTES, timeout = 2500 } = {}) {
23
55
  const executable = process.platform === "win32" ? "C:\\Program Files\\Git\\cmd\\git.exe" : "/usr/bin/git";
24
56
  const result = spawnSync(executable, ["-C", root, "--no-replace-objects", "-c", "core.fsmonitor=false", "-c", `core.hooksPath=${os.devNull}`, "-c", `safe.directory=${repositoryRoot}`, ...args], {
25
- encoding: "buffer", env: gitEnv(), windowsHide: true, maxBuffer, timeout: 2500, ...(input ? { input } : {}),
57
+ encoding: "buffer", env: gitEnv(), windowsHide: true, maxBuffer, timeout, ...(input ? { input } : {}),
26
58
  });
27
59
  if (result?.error || result?.status !== 0) throw new Error(`ownership inventory: git ${args[0]} failed${result?.error?.code ? ` (${result.error.code})` : ""}`);
28
60
  return result.stdout;
@@ -95,7 +127,9 @@ export function gitListedInventory(appRoot, { relevant = () => true, protectedPa
95
127
  catch (error) { return { files: [], coverage: [{ path: ".", status: "unscanned", reason: `repository-root-unavailable:${error.code ?? "error"}` }] }; }
96
128
  let listing, dirtyBytes;
97
129
  try {
98
- listing = runGit(root, safeRoot, ["ls-files", "--stage", "--cached", "--others", "--exclude-standard", "-z"]);
130
+ // Listing walks the whole worktree (--others), which takes seconds on a CI checkout; the short
131
+ // default stays for the per-object calls.
132
+ listing = runGit(root, safeRoot, ["ls-files", "--stage", "--cached", "--others", "--exclude-standard", "-z"], { timeout: 60_000 });
99
133
  dirtyBytes = runGit(root, safeRoot, ["diff-files", "--name-only", "-z", "--no-ext-diff", "--no-textconv", "--relative"]);
100
134
  } catch (error) { return { files: [], coverage: [{ path: ".", status: "unscanned", reason: `git-inventory-failed:${error.message}` }] }; }
101
135
  const dirty = new Set(dirtyBytes.length ? dirtyBytes.subarray(0, dirtyBytes.length - (dirtyBytes.at(-1) === 0 ? 1 : 0)).toString("utf8").split("\0") : []);
@@ -193,7 +227,7 @@ export function gitListedInventory(appRoot, { relevant = () => true, protectedPa
193
227
  if (!stat.isFile()) { coverage.push({ path: rel, status: "unscanned", reason: "not-regular-file" }); continue; }
194
228
  }
195
229
  const inNodeModules = rel.split("/").includes("node_modules");
196
- let bytes, oid = indexed?.oid, digest, cacheKey;
230
+ let bytes, oid = indexed?.oid, digest, cacheKey, prehashed;
197
231
  if (isRelevant && entry.overLimit) {
198
232
  // Size-limit coverage was already recorded before any blob or file read.
199
233
  } else if (isRelevant) {
@@ -203,7 +237,7 @@ export function gitListedInventory(appRoot, { relevant = () => true, protectedPa
203
237
  else if (isDirty) {
204
238
  if (stat.size > entry.maxFileBytes) {
205
239
  coverage.push({ path: rel, status: "unscanned", reason: "file-over-size-limit" });
206
- } else bytes = readFileSync(abs);
240
+ } else ({ bytes, sum: prehashed } = readHashed(abs));
207
241
  } else {
208
242
  coverage.push({ path: rel, status: "unscanned", reason: "git-blob-cache-miss" });
209
243
  }
@@ -212,7 +246,7 @@ export function gitListedInventory(appRoot, { relevant = () => true, protectedPa
212
246
  coverage.push({ path: rel, status: "unscanned", reason: "file-over-size-limit" });
213
247
  bytes = undefined;
214
248
  }
215
- if (bytes && isDirty) { digest = sha256(bytes); cacheKey = `sha256:${digest}`; }
249
+ if (bytes && isDirty) { digest = prehashed ?? sha256(bytes); cacheKey = `sha256:${digest}`; }
216
250
  else if (bytes) { digest = `git:${oid}`; cacheKey = `gitblob:${oid}`; }
217
251
  } else if (!isDirty && oid) { digest = `git:${oid}`; cacheKey = `gitblob:${oid}`; }
218
252
  files.push({ rel, abs, size: bytes?.length ?? entry.cleanBlobSize ?? stat?.size, bytes, oid, digest, cacheKey, inNodeModules });
@@ -220,4 +254,4 @@ export function gitListedInventory(appRoot, { relevant = () => true, protectedPa
220
254
  return { files, coverage };
221
255
  }
222
256
 
223
- export function clearGitListedInventoryCacheForTests() { blobCache.clear(); cacheBytes = 0; }
257
+ export function clearGitListedInventoryCacheForTests() { blobCache.clear(); cacheBytes = 0; hashCache.clear(); hashCacheBytes = 0; }
@@ -89,9 +89,79 @@ function parsePnpmYaml(text) {
89
89
  return root;
90
90
  }
91
91
 
92
+ // Workspace membership: a manifest is a root of a lock only when the lock's workspace owns it.
93
+ // Membership is decided only from a definition we fully understand; anything else (no
94
+ // definition, unsupported glob syntax, unparsable file) returns null = "every manifest is a
95
+ // member", which keeps the unresolved-root gap rather than hiding it.
96
+ const UNSUPPORTED_GLOB = /[\[\]{}\\]/;
97
+ const globPath = (value) => path.posix.normalize(String(value).trim().replace(/^(?:\.\/)+/, "").replace(/\/+$/, "") || ".").replace(/^\.$/, "");
98
+ function matchSegments(pattern, parts) {
99
+ if (!pattern.length) return !parts.length;
100
+ if (pattern[0] === "**") { for (let i = 0; i <= parts.length; i++) if (matchSegments(pattern.slice(1), parts.slice(i))) return true; return false; }
101
+ if (!parts.length) return false;
102
+ const source = [...pattern[0]].map((c) => c === "*" ? "[^/]*" : c === "?" ? "[^/]" : c.replace(/[.+^$|()]/g, "\\$&")).join("");
103
+ return new RegExp(`^${source}$`).test(parts[0]) && matchSegments(pattern.slice(1), parts.slice(1));
104
+ }
105
+ function globMatches(glob, rel) {
106
+ if (UNSUPPORTED_GLOB.test(glob)) throw new Error("unsupported glob syntax");
107
+ const pattern = globPath(glob), segments = pattern ? pattern.split("/") : [];
108
+ return matchSegments(segments, rel ? rel.split("/") : []);
109
+ }
110
+ /** A directory or any of its ancestors matches the glob. */
111
+ const underGlob = (glob, rel) => { const parts = rel ? rel.split("/") : []; for (let n = parts.length; n >= 1; n--) if (globMatches(glob, parts.slice(0, n).join("/"))) return true; return false; };
112
+ const stringList = (value) => { if (value === undefined) return []; if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) throw new Error("not a string list"); return value; };
113
+ const dependencyTables = ["dependencies", "dev-dependencies", "build-dependencies"];
114
+ function cargoPathDependencies(doc) {
115
+ const out = [], take = (table) => { if (object(table)) for (const dep of Object.values(table)) if (object(dep) && typeof dep.path === "string") out.push(dep.path); };
116
+ for (const name of dependencyTables) take(doc[name]);
117
+ take(doc.workspace?.dependencies);
118
+ if (object(doc.target)) for (const target of Object.values(doc.target)) if (object(target)) for (const name of dependencyTables) take(target[name]);
119
+ return out;
120
+ }
121
+ /** Predicate over a manifest directory relative to the lock directory ("" is the root), or null when undecidable. */
122
+ function cargoMembership(manifests, lockDir) {
123
+ try {
124
+ const rootRel = path.posix.join(lockDir, "Cargo.toml"), docs = new Map();
125
+ for (const { rel, bytes } of manifests) { try { docs.set(path.posix.relative(lockDir, path.posix.dirname(rel)), parseToml(bytes.toString("utf8"))); } catch { /* a bad manifest fails its own parse later */ } }
126
+ const root = docs.get("");
127
+ if (!manifests.some((m) => m.rel === rootRel) || !root || !object(root.workspace)) return null;
128
+ const patterns = [...stringList(root.workspace.members), ...stringList(root.workspace["default-members"])], excludes = stringList(root.workspace.exclude);
129
+ const excluded = (rel) => excludes.some((glob) => underGlob(glob, rel));
130
+ const members = new Set([""]), queue = [""];
131
+ for (const rel of docs.keys()) if (rel && !excluded(rel) && patterns.some((glob) => globMatches(glob, rel))) { members.add(rel); queue.push(rel); }
132
+ // Cargo adds path dependencies that live inside the workspace directory as members too.
133
+ for (let at = 0; at < queue.length; at++) for (const dep of cargoPathDependencies(docs.get(queue[at]) ?? {})) {
134
+ const target = path.posix.normalize(path.posix.join(queue[at] || ".", dep)).replace(/^\.$/, "");
135
+ if (target.startsWith("..") || path.posix.isAbsolute(target) || members.has(target) || excluded(target)) continue;
136
+ members.add(target); queue.push(target);
137
+ }
138
+ return (rel) => members.has(rel);
139
+ } catch { return null; }
140
+ }
141
+ /** pnpm-workspace.yaml `packages:` globs; the root importer is always a member. */
142
+ function pnpmMembership(bytes) {
143
+ if (!bytes) return null;
144
+ try {
145
+ const lines = bytes.toString("utf8").split(/\r?\n/), globs = [];
146
+ const start = lines.findIndex((line) => /^packages\s*:/.test(line));
147
+ if (start < 0) return null;
148
+ const inline = lines[start].replace(/^packages\s*:/, "").replace(/\s+#.*$/, "").trim();
149
+ if (inline) { const list = scalar(inline); if (!Array.isArray(list)) return null; globs.push(...list); }
150
+ else for (const line of lines.slice(start + 1)) {
151
+ if (!line.trim() || /^\s*#/.test(line)) continue;
152
+ if (!/^\s+-\s/.test(line)) break;
153
+ globs.push(scalar(line.replace(/^\s+-\s+/, "").replace(/\s+#.*$/, "")));
154
+ }
155
+ if (!globs.length || globs.some((glob) => typeof glob !== "string" || !glob.replace(/^!/, ""))) return null;
156
+ const positive = globs.filter((glob) => !glob.startsWith("!")), negative = globs.filter((glob) => glob.startsWith("!")).map((glob) => glob.slice(1));
157
+ for (const glob of globs) globMatches(glob.replace(/^!/, ""), ""); // throws on unsupported syntax
158
+ return (rel) => !rel || positive.some((glob) => globMatches(glob, rel)) && !negative.some((glob) => underGlob(glob, rel));
159
+ } catch { return null; }
160
+ }
161
+
92
162
  function graph() {
93
163
  const nodes = new Map(), roots = [];
94
- return { nodes, roots, diagnostics: [], add(id, name, version, source, local = false) {
164
+ return { nodes, roots, diagnostics: [], info: [], add(id, name, version, source, local = false) {
95
165
  if (typeof name !== "string" || !name || typeof version !== "string" || !version) fail("package name/version missing");
96
166
  const node = { id, name, version, source, local, edges: [] }; nodes.set(id, node); return node;
97
167
  } };
@@ -107,7 +177,7 @@ function sourceFor(value, fallback = "registry") {
107
177
  if (typeof value === "string" && /^(git\+|git:|https?:.*\.git)/.test(value)) return value.startsWith("git+") ? value : `git+${value}`;
108
178
  return value || fallback;
109
179
  }
110
- function cargoGraph(text, manifests) {
180
+ function cargoGraph(text, manifests, lockDir) {
111
181
  const lock = parseToml(text), g = graph();
112
182
  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
183
  for (const [i, pkg] of lock.package.entries()) g.add(String(i), pkg.name, pkg.version, pkg.source ?? `path:${pkg.name}`, !pkg.source);
@@ -121,11 +191,16 @@ function cargoGraph(text, manifests) {
121
191
  connect(g.nodes.get(String(i)), candidates.length === 1 ? candidates[0] : null, ref);
122
192
  }
123
193
  }
194
+ const membership = cargoMembership(manifests, lockDir);
124
195
  for (const { rel, bytes } of manifests) {
125
196
  const pkg = parseToml(bytes.toString("utf8")).package;
126
197
  if (!pkg) continue; // Virtual workspace: member manifests supply roots.
127
198
  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; }
199
+ if (candidates.length !== 1) {
200
+ // Only a manifest the workspace does not own is informational; a member that fails to resolve is a gap.
201
+ const outside = membership && !membership(path.posix.relative(lockDir, path.posix.dirname(rel)));
202
+ (outside ? g.info : g.diagnostics).push({ reason: outside ? "manifest-outside-workspace" : "lockfile-root-unresolved", detail: rel }); continue;
203
+ }
129
204
  g.roots.push(candidates[0].id);
130
205
  }
131
206
  return g;
@@ -169,7 +244,7 @@ function packageLockGraph(text, manifests, lockDir) {
169
244
  }
170
245
  return g;
171
246
  }
172
- function pnpmGraph(text, manifests, lockDir) {
247
+ function pnpmGraph(text, manifests, lockDir, workspaceBytes) {
173
248
  const lock = parsePnpmYaml(text), version = String(lock.lockfileVersion), g = graph();
174
249
  if (!/^(?:9|6)(?:\.\d+)?$/.test(version) || !object(lock.importers) || !object(lock.packages ?? {})) throw new LockError("lockfile-unsupported-format", "pnpm requires v6/v9 importers");
175
250
  const v9 = version.startsWith("9"), packages = lock.packages ?? {}, snapshots = v9 ? lock.snapshots ?? {} : packages;
@@ -211,9 +286,13 @@ function pnpmGraph(text, manifests, lockDir) {
211
286
  for (const [name, raw] of Object.entries(pkg[section] ?? {})) connect(node, resolve(node, name, raw), name, section === "optionalDependencies");
212
287
  }
213
288
  }
289
+ const membership = pnpmMembership(workspaceBytes);
214
290
  for (const { rel, bytes } of manifests) {
215
291
  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; }
292
+ if (!node) {
293
+ const outside = membership && !membership(id === "." ? "" : id);
294
+ (outside ? g.info : g.diagnostics).push({ reason: outside ? "manifest-outside-workspace" : "lockfile-root-unresolved", detail: rel }); continue;
295
+ }
217
296
  node.name = JSON.parse(bytes.toString("utf8")).name ?? id;
218
297
  const pkg = JSON.parse(bytes.toString("utf8")), importer = lock.importers[id];
219
298
  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}` });
@@ -224,7 +303,7 @@ function pnpmGraph(text, manifests, lockDir) {
224
303
 
225
304
  /** Lock bytes stay bound to the scan inventory or committed snapshot; no installed graph reads. */
226
305
  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);
306
+ const result = [], info = [], cache = new Map(), groups = new Map(), files = new Map(inventory.files.map((file) => [file.rel, file])), targetNames = new Set(targets);
228
307
  const diagnostic = (rel, reason, detail) => coverage.push({ path: rel, status: "unscanned", reason, ...(detail ? { detail } : {}) });
229
308
  const read = (rel) => {
230
309
  if (cache.has(rel)) return cache.get(rel);
@@ -268,8 +347,10 @@ export function transitiveDependencies(appRoot, inventory, manifests, catalogOwn
268
347
  try {
269
348
  const text = read(rel).toString("utf8"), base = path.posix.basename(rel), dir = path.posix.dirname(rel);
270
349
  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);
350
+ const g = group.ecosystem === "cargo" ? cargoGraph(text, group.manifests, dir) : base === "pnpm-lock.yaml" ? pnpmGraph(text, group.manifests, dir, read(path.posix.join(dir, "pnpm-workspace.yaml"))) : packageLockGraph(text, group.manifests, dir);
272
351
  for (const entry of g.diagnostics) diagnostic(rel, entry.reason, entry.detail);
352
+ // Non-blocking: manifests outside the lock's workspace are reported, never counted as coverage gaps.
353
+ for (const entry of g.info) info.push({ path: rel, status: "info", reason: entry.reason, detail: entry.detail });
273
354
  for (const node of g.nodes.values()) for (const detail of node.unresolved ?? []) diagnostic(rel, "lockfile-unresolved-dependency", detail);
274
355
  // Retain only nodes leading to block fingerprints. Unrelated lock inventory cannot
275
356
  // multiply traversal work; owner boundaries cannot authorize app/workspace nodes.
@@ -307,5 +388,7 @@ export function transitiveDependencies(appRoot, inventory, manifests, catalogOwn
307
388
  }
308
389
  } catch (error) { diagnostic(rel, error.reason ?? "lockfile-parse-failed", error.message); }
309
390
  }
310
- return result.sort((a, b) => a.dependencyPath.length - b.dependencyPath.length || JSON.stringify(a.dependencyPath).localeCompare(JSON.stringify(b.dependencyPath)));
391
+ result.sort((a, b) => a.dependencyPath.length - b.dependencyPath.length || JSON.stringify(a.dependencyPath).localeCompare(JSON.stringify(b.dependencyPath)));
392
+ Object.defineProperty(result, "info", { value: info, enumerable: false });
393
+ return result;
311
394
  }
@@ -66,7 +66,7 @@
66
66
  "name": "@rightkit/platform-ui",
67
67
  "ecosystem": "npm",
68
68
  "min": "0.2.8",
69
- "registryMax": "0.2.7"
69
+ "registryMax": "0.2.8"
70
70
  },
71
71
  {
72
72
  "name": "@rightkit/theme",
@@ -155,7 +155,7 @@
155
155
  "name": "rightkit-fs",
156
156
  "ecosystem": "cargo",
157
157
  "min": "0.4.0",
158
- "registryMax": "0.3.3"
158
+ "registryMax": "0.4.1"
159
159
  }
160
160
  ]
161
161
  },
@@ -168,7 +168,7 @@
168
168
  "name": "rightkit-db",
169
169
  "ecosystem": "cargo",
170
170
  "min": "0.4.0",
171
- "registryMax": "0.3.2"
171
+ "registryMax": "0.4.0"
172
172
  }
173
173
  ]
174
174
  },
@@ -186,7 +186,7 @@
186
186
  "name": "@rightkit/settings-ui",
187
187
  "ecosystem": "npm",
188
188
  "min": "0.4.4",
189
- "registryMax": "0.4.3"
189
+ "registryMax": "0.4.4"
190
190
  }
191
191
  ]
192
192
  },
@@ -211,7 +211,7 @@
211
211
  "name": "rightkit-license",
212
212
  "ecosystem": "cargo",
213
213
  "min": "0.2.0",
214
- "registryMax": "0.1.3"
214
+ "registryMax": "0.2.0"
215
215
  },
216
216
  {
217
217
  "name": "rightkit-device",
@@ -222,7 +222,7 @@
222
222
  "name": "@rightkit/license",
223
223
  "ecosystem": "npm",
224
224
  "min": "0.2.0",
225
- "registryMax": "0.1.8"
225
+ "registryMax": "0.2.0"
226
226
  }
227
227
  ]
228
228
  },
@@ -235,7 +235,7 @@
235
235
  "name": "rightkit-license",
236
236
  "ecosystem": "cargo",
237
237
  "min": "0.2.0",
238
- "registryMax": "0.1.3"
238
+ "registryMax": "0.2.0"
239
239
  }
240
240
  ]
241
241
  },
@@ -260,7 +260,7 @@
260
260
  "name": "@rightkit/onboarding",
261
261
  "ecosystem": "npm",
262
262
  "min": "0.3.3",
263
- "registryMax": "0.3.2"
263
+ "registryMax": "0.3.3"
264
264
  }
265
265
  ]
266
266
  },
@@ -272,8 +272,8 @@
272
272
  {
273
273
  "name": "rightkit-logs",
274
274
  "ecosystem": "cargo",
275
- "min": "0.2.2",
276
- "registryMax": "0.2.1"
275
+ "min": "0.3.0",
276
+ "registryMax": "0.3.0"
277
277
  },
278
278
  {
279
279
  "name": "@rightkit/logs",
@@ -292,7 +292,7 @@
292
292
  "name": "rightkit-crash",
293
293
  "ecosystem": "cargo",
294
294
  "min": "0.2.2",
295
- "registryMax": "0.2.1"
295
+ "registryMax": "0.3.0"
296
296
  }
297
297
  ]
298
298
  },
@@ -305,7 +305,7 @@
305
305
  "name": "rightkit-http",
306
306
  "ecosystem": "cargo",
307
307
  "min": "0.3.0",
308
- "registryMax": "0.2.5"
308
+ "registryMax": "0.3.0"
309
309
  }
310
310
  ]
311
311
  },
@@ -330,7 +330,7 @@
330
330
  "name": "@rightkit/release",
331
331
  "ecosystem": "npm",
332
332
  "min": "0.2.153",
333
- "registryMax": "0.2.151",
333
+ "registryMax": "0.2.165",
334
334
  "allowDev": true
335
335
  }
336
336
  ]
@@ -344,20 +344,21 @@
344
344
  "name": "rightkit-qa",
345
345
  "ecosystem": "cargo",
346
346
  "min": "0.2.16",
347
- "registryMax": "0.2.14",
347
+ "registryMax": "0.6.1",
348
348
  "allowDev": true
349
349
  },
350
350
  {
351
351
  "name": "rightkit-qa-driver",
352
352
  "ecosystem": "cargo",
353
353
  "min": "0.3.0",
354
+ "note": "deprecated shim; the QA owner is rightkit-qa with the `control` feature (Ember #280)",
354
355
  "allowDev": true
355
356
  },
356
357
  {
357
358
  "name": "@rightkit/qa",
358
359
  "ecosystem": "npm",
359
360
  "min": "0.4.8",
360
- "registryMax": "0.4.7",
361
+ "registryMax": "0.4.8",
361
362
  "allowDev": true
362
363
  },
363
364
  {
@@ -378,7 +379,7 @@
378
379
  "name": "rightkit-agent-tools",
379
380
  "ecosystem": "cargo",
380
381
  "min": "0.1.2",
381
- "registryMax": "0.1.2"
382
+ "registryMax": "0.1.4"
382
383
  }
383
384
  ]
384
385
  }
@@ -399,6 +400,25 @@
399
400
  "ownerPending": "No selected owner (D4 unratified); never fails."
400
401
  }
401
402
  ],
403
+ "appAmendments": {
404
+ "ember": {
405
+ "issue": "Orthic-Labs/rightkit#280",
406
+ "source": "Orthic-Labs/ember#240 port to the mandatory set",
407
+ "profile": "desktop",
408
+ "note": "App-specific not-applicable rulings, ratified for Ember. An app declares them in rightkit-mandatory.json notApplicable with the reason given here. Free and open source apps (MIT OR Apache-2.0, no paid features, activation or signer) and apps with no daemon, no SQLite state and no restart-surviving jobs owe none of these rows.",
409
+ "notApplicable": {
410
+ "M3": "No engine child process and no restart-surviving jobs: exports and imports are in-process jobs on the activity stack. The ffmpeg sidecar already runs through rightkit-ffmpeg.",
411
+ "M4": "No engine child process and no restart-surviving jobs: exports and imports are in-process jobs on the activity stack.",
412
+ "M6": "No local service or daemon-discovered data location. The control channel and MCP are opt-in developer and agent ports.",
413
+ "M7": "No local service or daemon-discovered data location. The control channel and MCP are opt-in developer and agent ports.",
414
+ "M9": "No SQLite app state. Preferences are JSON through rightkit-app-settings; the photo catalog is a JSON-lines journal.",
415
+ "M12": "Free and open source (MIT OR Apache-2.0): no paid features, activation or signer. rightkit-device identity is adopted. Applies to OSS apps.",
416
+ "M13": "Free and open source (MIT OR Apache-2.0): no paid features, activation or signer. Applies to OSS apps.",
417
+ "M14": "@rightkit/legal needs six proprietary roles and imports node:crypto, so it cannot run in a webview; @rightkit/onboarding mandates a legal step first. Ember ships @rightkit/legal-ui for its licences and notices and holds its first-run notice until its text is approved. OSS profile; the legal and onboarding owners need an OSS mode and a mode without a legal step.",
418
+ "M22": "rightkit-agent-tools needs a daemon-backed app control service. Ember's agent surface is its own MCP server over engine command ids, being ported to rightkit-mcp. Applies to non-daemon apps."
419
+ }
420
+ }
421
+ },
402
422
  "amendments": {
403
423
  "v2.1": {
404
424
  "description": "TABLE1-AMENDMENT-v2.1 (draft): @rightkit/tauri-shell joins M1. Opt-in via --amendment v2.1.",
@@ -39,16 +39,15 @@ function statusFor(row, report) {
39
39
  return "not-detected";
40
40
  }
41
41
 
42
+ // Plain exception entries (Orthic-Labs/rightkit#262) that matched no finding in this scan are stale and listed for cleanup.
42
43
  function exceptionRequests(root, app, report) {
43
44
  const file = path.join(root, EXCEPTIONS_FILE);
44
45
  if (!existsSync(file)) return [];
45
46
  let records;
46
47
  try { records = JSON.parse(readFileSync(file, "utf8")).exceptions ?? []; } catch { return []; }
47
- const payload = (record) => record?.payload ?? record;
48
- const confirmed = new Set((report.covered ?? []).map((finding) => payload(finding.exception)?.requestId).filter(Boolean));
49
- return records.filter((record) => !confirmed.has(payload(record)?.requestId)).map((record) => {
50
- const details = payload(record);
51
- const request = { app, requestId: details.requestId ?? null, fingerprint: details.fingerprint ?? null, paths: details.paths ?? [], reason: details.reason ?? null, owner: details.owner ?? null, expires: details.expires ?? null, tracking: details.trackingRef ?? details.tracking ?? null, state: "request-pending-verification" };
48
+ const matched = new Set((report.covered ?? []).map((finding) => finding.exception).filter(Boolean).map((entry) => JSON.stringify([entry.capability, entry.package ?? entry.crate, entry.fingerprint ?? null, entry.path ?? null])));
49
+ return records.filter((record) => !matched.has(JSON.stringify([record?.capability, record?.package ?? record?.crate, record?.fingerprint ?? null, record?.path ?? null]))).map((record) => {
50
+ const request = { app, capability: record?.capability ?? null, package: record?.package ?? record?.crate ?? null, reason: record?.reason ?? null, owner: record?.owner ?? null, date: record?.date ?? null, state: "stale-exception-entry" };
52
51
  return { ...request, contentHash: sha256(canonicalJson(request)) };
53
52
  });
54
53
  }
@@ -148,7 +147,7 @@ export function renderOwnershipMarkdown(report) {
148
147
  }
149
148
  lines.push("", "## Capability status", "", "| App | Capability | Status | Detection | Availability |", "|---|---|---|---|---|");
150
149
  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"} |`);
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.", "");
150
+ lines.push("", `Blocks: ${report.blocks.length} · Stale exception entries: ${report.exceptionRequests.length}`, "", "Exception entries are plain records reviewed through git; an entry that matches no finding is stale and should be removed.", "");
152
151
  if (report.warnings?.length) {
153
152
  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`), "");
154
153
  }
@@ -68,11 +68,11 @@ function scanProtected(appRoot, options, createEvidence) {
68
68
  const before = scanInputs(appRoot, options);
69
69
  const preliminary = { ok: true, enrollmentPending: before.enrollmentPending, violations: [], problems: [], coverage: before.metadata.coverage, metadata: before.metadata };
70
70
  const preflight = ownershipVerdict(preliminary, before.trust);
71
- if (!preflight.admit && preflight.code !== "RK_OWNERSHIP_PENDING_ENROLLMENT") return { ...before, report: { ...preliminary, ok: false }, verdict: preflight };
71
+ if (!preflight.admit) return { ...before, report: { ...preliminary, ok: false }, verdict: preflight };
72
72
  let checked;
73
73
  try { checked = createEvidence(); }
74
74
  catch (error) {
75
- if (preflight.code !== "RK_OWNERSHIP_PENDING_ENROLLMENT") throw error;
75
+ if (!before.enrollmentPending) throw error;
76
76
  const report = { ...preliminary, ok: false, problems: [error.message] };
77
77
  return { ...before, report, verdict: ownershipVerdict(report, before.trust) };
78
78
  }
@@ -40,19 +40,22 @@ const refuse = (code, problems) => ({ admit: false, code, problems });
40
40
  /** One fail-closed contract for protected scan workers & broker admission. */
41
41
  export function ownershipVerdict(report, trust) {
42
42
  if (!validReport(report)) return refuse("RK_OWNERSHIP_MALFORMED_REPORT", ["ownership report has missing, unknown, or wrong-typed fields"]);
43
- if (report.enrollmentPending) return refuse("RK_OWNERSHIP_PENDING_ENROLLMENT", ["protected ownership approval enrollment is pending", ...report.problems]);
43
+ // Pending approval enrollment is a reported warning, never a refusal (#262): every real finding gate below still applies.
44
+ const warnings = report.enrollmentPending ? ["protected ownership approval enrollment is pending (warning only; it existed only for retired signed approvals)"] : [];
45
+ const withWarnings = (verdict) => (warnings.length ? { ...verdict, warnings } : verdict);
46
+ const noRegistry = report.enrollmentPending && !trust;
44
47
  let registration;
45
- try {
48
+ if (!noRegistry) try {
46
49
  registration = verifyRepositoryRegistration(trust, report.metadata.repositoryIdentity, { files: [
47
50
  ...report.violations.map((entry) => entry.file), ...(report.findings ?? []).map((entry) => entry.file),
48
51
  ...(report.metadata.contentDigests ?? []).map((entry) => entry.path),
49
52
  ] });
50
53
  } catch { return refuse("RK_OWNERSHIP_INVALID_REGISTRATION", ["protected repository registry is missing or malformed"]); }
51
- if (!registration.ok) return refuse("RK_OWNERSHIP_INVALID_REGISTRATION", registration.problems);
54
+ if (!noRegistry && !registration.ok) return refuse("RK_OWNERSHIP_INVALID_REGISTRATION", registration.problems);
52
55
  const scannedRoots = report.metadata.roots?.map((entry) => entry.root) ?? [report.metadata.pathPrefix?.replace(/\/$/, "") || "."];
53
- if (new Set(scannedRoots).size !== scannedRoots.length || sha256([...scannedRoots].sort()) !== sha256([...registration.registration.roots].sort()))
56
+ if (!noRegistry && (new Set(scannedRoots).size !== scannedRoots.length || sha256([...scannedRoots].sort()) !== sha256([...registration.registration.roots].sort())))
54
57
  return refuse("RK_OWNERSHIP_INVALID_REGISTRATION", ["scan roots do not exactly cover protected repository registration"]);
55
- const floor = validateOwnershipFloor(trust, report.metadata);
58
+ const floor = noRegistry ? { ok: true, problems: [] } : validateOwnershipFloor(trust, report.metadata);
56
59
  if (!floor.ok) return refuse("RK_OWNERSHIP_POLICY_DOWNGRADE", floor.problems);
57
60
  const incomplete = [...report.coverage, ...report.metadata.coverage].filter((entry) => entry.status !== "complete");
58
61
  if (incomplete.length) return refuse("RK_OWNERSHIP_INCOMPLETE_COVERAGE", [...new Set(incomplete.map((entry) => entry.path + ": " + entry.reason))]);
@@ -61,8 +64,8 @@ export function ownershipVerdict(report, trust) {
61
64
  if (report.problems.length) return refuse("RK_OWNERSHIP_UNVERIFIED", report.problems);
62
65
  if (!report.ok) return refuse("RK_OWNERSHIP_REPORT_FAILED", ["protected ownership scanner reported ok:false"]);
63
66
  const advisory = report.violations.filter((entry) => !isBlockingFinding(entry));
64
- if (advisory.length) return { admit: true, code: "RK_OWNERSHIP_ADVISORY", problems: advisory.map((entry) => entry.capability + " (" + entry.fingerprint + " in " + entry.file + ") is owned by " + entry.package + " [advise]") };
65
- return { admit: true, code: "RK_OWNERSHIP_CLEAN", problems: [] };
67
+ if (advisory.length) return withWarnings({ admit: true, code: "RK_OWNERSHIP_ADVISORY", problems: advisory.map((entry) => entry.capability + " (" + entry.fingerprint + " in " + entry.file + ") is owned by " + entry.package + " [advise]") });
68
+ return withWarnings({ admit: true, code: "RK_OWNERSHIP_CLEAN", problems: [] });
66
69
  }
67
70
 
68
71
  /** SHA-256 over every required authenticated cache binding; never directory or age. */