@clear-capabilities/agentic-security-scanner 0.161.0 → 0.163.0

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 (182) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/bin/agentic-security.js +98 -2
  3. package/dist/1340.index.js +1871 -0
  4. package/dist/3477.index.js +727 -0
  5. package/dist/3499.index.js +23 -3
  6. package/dist/4113.index.js +59 -8
  7. package/dist/5014.index.js +833 -0
  8. package/dist/5756.index.js +2 -2
  9. package/dist/6257.index.js +1 -1
  10. package/dist/6907.index.js +248 -0
  11. package/dist/6994.index.js +1 -1
  12. package/dist/7033.index.js +33 -5
  13. package/dist/7439.index.js +1822 -0
  14. package/dist/7838.index.js +38 -2
  15. package/dist/8798.index.js +1 -1
  16. package/dist/9207.index.js +5 -0
  17. package/dist/9560.index.js +2336 -27
  18. package/dist/957.index.js +1 -1
  19. package/dist/{1905.index.js → 9999.index.js} +306 -13
  20. package/dist/agentic-security.mjs +12 -12
  21. package/dist/agentic-security.mjs.sha256 +1 -1
  22. package/package.json +39 -7
  23. package/src/capabilities/CLAUDE.md +114 -0
  24. package/src/capabilities/address.js +73 -0
  25. package/src/capabilities/attack-coverage.js +93 -0
  26. package/src/capabilities/decide.js +256 -0
  27. package/src/capabilities/delegate.js +69 -0
  28. package/src/capabilities/egress.js +20 -0
  29. package/src/capabilities/hook-advice.js +50 -0
  30. package/src/capabilities/index.js +32 -0
  31. package/src/capabilities/manifest.js +344 -0
  32. package/src/capabilities/outbound.js +189 -0
  33. package/src/capabilities/paths.js +57 -0
  34. package/src/capabilities/probes.js +211 -0
  35. package/src/capabilities/proxy.js +182 -0
  36. package/src/capabilities/reasons.js +67 -0
  37. package/src/capabilities/receipts.js +356 -0
  38. package/src/capabilities/records.js +58 -0
  39. package/src/capabilities/recovery.js +367 -0
  40. package/src/capabilities/report.js +81 -0
  41. package/src/capabilities/runner.js +236 -0
  42. package/src/capabilities/secrets.js +26 -0
  43. package/src/capabilities/tool-gate.js +119 -0
  44. package/src/capabilities/tool-registry.js +60 -0
  45. package/src/dataflow/CLAUDE.md +29 -0
  46. package/src/dataflow/catalog.js +21 -0
  47. package/src/dataflow/engine.js +20 -0
  48. package/src/dataflow/guard-dominance.js +352 -0
  49. package/src/discovery/CLAUDE.md +18 -0
  50. package/src/discovery/index.js +43 -1
  51. package/src/discovery/lenses.js +18 -0
  52. package/src/discovery/memory.js +7 -5
  53. package/src/engine.js +80 -15
  54. package/src/fix/apply-fix-service.js +33 -3
  55. package/src/integrations/index.js +5 -2
  56. package/src/ir/CLAUDE.md +14 -0
  57. package/src/ir/implicit-handler-params.js +74 -0
  58. package/src/ir/parser-cs.js +20 -0
  59. package/src/ir/parser-go.js +30 -13
  60. package/src/ir/parser-java.js +11 -0
  61. package/src/language/nix-eval-isolation.js +33 -5
  62. package/src/lineage/CLAUDE.md +31 -0
  63. package/src/lineage/deployment/adapter-cloud.js +202 -0
  64. package/src/lineage/deployment/adapter-compose.js +90 -0
  65. package/src/lineage/deployment/adapter-kit.js +142 -0
  66. package/src/lineage/deployment/adapter-kubernetes.js +247 -0
  67. package/src/lineage/deployment/attack-paths.js +443 -0
  68. package/src/lineage/deployment/boundaries-run.js +223 -0
  69. package/src/lineage/deployment/boundary-graph.js +567 -0
  70. package/src/lineage/deployment/cross-repo.js +253 -0
  71. package/src/lineage/deployment/drift.js +383 -0
  72. package/src/lineage/deployment/ingest.js +221 -0
  73. package/src/lineage/deployment/projection.js +244 -0
  74. package/src/lineage/deployment/trace-correlation.js +541 -0
  75. package/src/lineage/source-registry.js +19 -0
  76. package/src/mcp/CLAUDE.md +14 -3
  77. package/src/mcp/invariant-tools.js +64 -0
  78. package/src/mcp/portfolio-tools.js +79 -0
  79. package/src/mcp/redact.js +15 -0
  80. package/src/mcp/server.js +24 -3
  81. package/src/mcp/tools.js +30 -5
  82. package/src/posture/CLAUDE.md +505 -0
  83. package/src/posture/accuracy-scorecard.js +95 -4
  84. package/src/posture/assurance/baseline-inventory.js +218 -0
  85. package/src/posture/assurance/baseline.js +322 -0
  86. package/src/posture/assurance/bounded-io.js +145 -0
  87. package/src/posture/assurance/config.js +299 -0
  88. package/src/posture/assurance/contracts.js +315 -0
  89. package/src/posture/assurance/identity.js +62 -0
  90. package/src/posture/assurance/migrations.js +259 -0
  91. package/src/posture/assurance/rollback.js +214 -0
  92. package/src/posture/assurance/rollout.js +200 -0
  93. package/src/posture/assurance/schema-kit.js +111 -0
  94. package/src/posture/assurance/verification-record.js +258 -0
  95. package/src/posture/attestation.js +5 -0
  96. package/src/posture/autopilot.js +28 -1
  97. package/src/posture/business-logic.js +26 -4
  98. package/src/posture/evaluation/bakeoff.js +148 -0
  99. package/src/posture/evaluation/custody.js +200 -0
  100. package/src/posture/evaluation/deployment-ablation.js +416 -0
  101. package/src/posture/evaluation/dev-cases.js +65 -0
  102. package/src/posture/evaluation/economics.js +85 -0
  103. package/src/posture/evaluation/gates.js +161 -0
  104. package/src/posture/evaluation/grouping.js +101 -0
  105. package/src/posture/evaluation/interval.js +132 -0
  106. package/src/posture/evaluation/invariant-ablation.js +325 -0
  107. package/src/posture/evaluation/labels.js +200 -0
  108. package/src/posture/evaluation/protocol.js +308 -0
  109. package/src/posture/evaluation/release-gate.js +209 -0
  110. package/src/posture/evaluation/report.js +158 -0
  111. package/src/posture/evaluation/runner.js +273 -0
  112. package/src/posture/evaluation/scan-child.js +80 -0
  113. package/src/posture/evaluation/score.js +174 -0
  114. package/src/posture/evaluation/synthetic.js +90 -0
  115. package/src/posture/evaluation/why-missed.js +219 -0
  116. package/src/posture/evidence-bundle.js +93 -7
  117. package/src/posture/execution-proof.js +11 -1
  118. package/src/posture/fix-verify-loop.js +20 -6
  119. package/src/posture/fix-verify.js +37 -2
  120. package/src/posture/fleet.js +11 -2
  121. package/src/posture/invariants/cli.js +95 -0
  122. package/src/posture/invariants/coverage.js +164 -0
  123. package/src/posture/invariants/export.js +178 -0
  124. package/src/posture/invariants/expressions.js +46 -0
  125. package/src/posture/invariants/lifecycle.js +289 -0
  126. package/src/posture/invariants/project-input.js +55 -0
  127. package/src/posture/invariants/repair.js +340 -0
  128. package/src/posture/invariants/run.js +45 -0
  129. package/src/posture/invariants/scenarios.js +311 -0
  130. package/src/posture/invariants/schema.js +167 -0
  131. package/src/posture/invariants/shrink.js +407 -0
  132. package/src/posture/invariants/state-assertions.js +165 -0
  133. package/src/posture/logic-claims.js +12 -0
  134. package/src/posture/model-routing.js +40 -0
  135. package/src/posture/oracles/adapters.js +531 -0
  136. package/src/posture/oracles/conformance.js +276 -0
  137. package/src/posture/oracles/oracle.js +453 -0
  138. package/src/posture/oracles/registry.js +76 -0
  139. package/src/posture/oracles/scenario-classes.js +223 -0
  140. package/src/posture/portfolio/backend.js +279 -0
  141. package/src/posture/portfolio/bundle.js +314 -0
  142. package/src/posture/portfolio/cli.js +142 -0
  143. package/src/posture/portfolio/manifest.js +263 -0
  144. package/src/posture/portfolio/progress.js +267 -0
  145. package/src/posture/portfolio/resume.js +130 -0
  146. package/src/posture/portfolio/retention.js +207 -0
  147. package/src/posture/portfolio/scheduler.js +488 -0
  148. package/src/posture/portfolio/signing.js +155 -0
  149. package/src/posture/portfolio/wording.js +59 -0
  150. package/src/posture/portfolio/work-units.js +405 -0
  151. package/src/posture/replay/replay.js +250 -0
  152. package/src/posture/routing/adapter.js +140 -0
  153. package/src/posture/routing/calibration.js +278 -0
  154. package/src/posture/routing/canary.js +141 -0
  155. package/src/posture/routing/control.js +35 -0
  156. package/src/posture/routing/decide.js +211 -0
  157. package/src/posture/routing/drift.js +137 -0
  158. package/src/posture/routing/economics.js +185 -0
  159. package/src/posture/routing/feedback.js +207 -0
  160. package/src/posture/routing/index.js +31 -0
  161. package/src/posture/routing/outcomes.js +281 -0
  162. package/src/posture/routing/promotion.js +291 -0
  163. package/src/posture/routing/receipts.js +59 -0
  164. package/src/posture/routing/report.js +142 -0
  165. package/src/posture/routing/shadow.js +53 -0
  166. package/src/posture/specification-mining.js +26 -0
  167. package/src/posture/verification/advisory-state.js +71 -0
  168. package/src/posture/verification/emit.js +257 -0
  169. package/src/posture/verification/hypothesis-promotion.js +211 -0
  170. package/src/posture/verification/patch-negative.js +226 -0
  171. package/src/posture/verification/patch-promotion.js +113 -0
  172. package/src/posture/verification/projection.js +183 -0
  173. package/src/posture/verification/repair-records.js +124 -0
  174. package/src/posture/verifier.js +16 -0
  175. package/src/report/index.js +100 -1
  176. package/src/sandbox/CLAUDE.md +51 -0
  177. package/src/sandbox/backend-namespace.js +7 -0
  178. package/src/sandbox/backend-userspace.js +151 -34
  179. package/src/sandbox/control-probes.js +215 -0
  180. package/src/sandbox/supervise.js +201 -0
  181. package/src/sandbox/trust-boundary.js +113 -0
  182. package/src/sandbox/trust-domains.js +171 -0
@@ -0,0 +1,488 @@
1
+ // Bounded portfolio scheduler: budgets, fairness and scoped cancellation (X-706).
2
+ //
3
+ // work-units.js gives durable, leased units but hands out the next pending unit in id order, with no notion of cost and no notion of
4
+ // who is waiting. This module decides WHICH unit is leased next, and whether ANY unit may be leased at all, from five limits declared
5
+ // at two levels (the whole portfolio, and each repository): wall time, provider spend, concurrency, request count and storage.
6
+ //
7
+ // ADMISSION (X-706.AC01). The check happens BEFORE the lease, inside the same locked store transaction that creates the lease, so two
8
+ // processes cannot both squeeze under a limit. A unit must carry an ESTIMATE of what it can cost (an unknown cost is never zero: a unit
9
+ // with no estimate is not leased, and is reported `no-estimate`). Admission counts what has been used, plus what in-flight attempts have
10
+ // RESERVED, plus this unit's estimate. A refusal is one of two kinds, and they are reported separately:
11
+ // at-capacity would fit once an in-flight attempt settles (a wait)
12
+ // exhausted would not fit even with nothing in flight (will not recover without a larger budget)
13
+ // An attempt that ends without a usage report (expired, failed, canceled) is charged its whole reservation, because what it spent is
14
+ // unknown. A reported overrun of the estimate is recorded and shrinks what remains; it is detected, not rolled back. Wall time is also
15
+ // enforced while the unit runs: the executor's signal aborts at the unit's wall estimate (capped by what the portfolio has left).
16
+ //
17
+ // FAIRNESS (X-706.AC02). Priority is a WEIGHT, not an order. Among repositories that currently have an admissible unit, the next lease
18
+ // goes to the one with the lowest `leasesGranted / weight` (weights are integers 1..8, default 1; ties break on repository name, and
19
+ // within a repository on unit id). Consequences, each tested:
20
+ // - no starvation: a repository that is admissible is served within a bounded number of leases whatever the others hold, so a
21
+ // repository with 300 units cannot hold back one with 1;
22
+ // - priority shifts the SHARE, it never removes it: weight 8 against weight 1 is served about eight times as often, and the
23
+ // weight-1 repository is still served;
24
+ // - a repository that was blocked for a long time is owed service and is served first when it is admissible again (a bounded burst);
25
+ // - a blocked repository (declared blocked, over its own budget, or with every unit blocked) is SKIPPED, never waited on. The skip
26
+ // reason is returned per repository, so a stall is visible instead of silent.
27
+ // A unit whose estimate cannot fit the remaining budget is never leased and never blocks a smaller sibling.
28
+ //
29
+ // CANCELLATION (X-706.AC03). A cancellation names a scope (one unit, one repository, or everything) and is recorded in the ledger, so a
30
+ // process that did not receive the call still honours it. For attempts running in THIS process the scope's AbortController is aborted;
31
+ // work started through `ctx.spawn` runs under sandbox/supervise.js, which terminates the whole process tree and reports survivors. A
32
+ // lease is RELEASED only when the attempt has settled and nothing survived; otherwise it is left to EXPIRE (so no other worker can start
33
+ // the same unit while a descendant may still be running) and the report says so. Verified units and their receipts are never touched.
34
+ // The report is always `incomplete` with a reason when anything in scope was not verified.
35
+ //
36
+ // SPEND (routing). `routingBudgetFor` gives the routing policy the remaining dollars (portfolio and repository, less reservations), and
37
+ // `chargeModelSpend` refuses a routing decision that is blocked, has no upper cost bound, or would exceed the unit's reservation.
38
+ // No provider is called here; the executor does that through an injected transport.
39
+ //
40
+ // Heartbeats are recorded with the lease renewal. Nothing here reads a clock except where a function takes `clock`/`now`.
41
+
42
+ import * as fs from 'node:fs';
43
+ import * as path from 'node:path';
44
+ import { superviseSpawn } from '../../sandbox/supervise.js';
45
+ import {
46
+ leaseUnit, startUnit, completeUnit, failUnit, cancelUnits, recoverExpired, renewLease, mutateStore, readStore, progressOf, typedError,
47
+ } from './work-units.js';
48
+
49
+ export const LEDGER_SCHEMA = 'agentic-security/portfolio-ledger';
50
+ const LEDGER_VERSION = 1;
51
+ export const DIMENSIONS = Object.freeze(['wallMs', 'spendUsd', 'requests', 'storageBytes']);
52
+ export const WEIGHT_RANGE = Object.freeze({ min: 1, max: 8, default: 1 });
53
+ export const HEARTBEAT = Object.freeze({ intervalMs: 5000, staleAfterMs: 15000 });
54
+ export const SKIP_CODES = Object.freeze(['repository-blocked', 'no-estimate', 'at-capacity', 'exhausted', 'canceled']);
55
+ const MAX_LEDGER_BYTES = 16 * 1024 * 1024;
56
+
57
+ const zero = () => ({ wallMs: 0, spendUsd: 0, requests: 0, storageBytes: 0 });
58
+ const isNum = (v) => typeof v === 'number' && Number.isFinite(v) && v >= 0;
59
+
60
+ // ---------------------------------------------------------------- budgets
61
+
62
+ /**
63
+ * Budgets: `{ portfolio: { concurrency, wallMs, spendUsd, requests, storageBytes }, repositories?: { default?, [name]: partial limits },
64
+ * priorities?: { [name]: weight } }`. Every portfolio limit is REQUIRED (an unlimited portfolio is a decision to make out loud, not a
65
+ * default); a repository limit that is absent is not enforced for that repository and the view says so.
66
+ */
67
+ export function validateBudgets(b) {
68
+ const errors = [];
69
+ const bad = (p, message) => errors.push({ code: 'BAD_BUDGETS', path: p, message });
70
+ if (!b || typeof b !== 'object') return { ok: false, errors: [{ code: 'BAD_BUDGETS', path: '', message: 'not an object' }] };
71
+ const lim = (o, p, required) => {
72
+ if (!o || typeof o !== 'object') { if (required) bad(p, 'required'); return; }
73
+ if (o.concurrency !== undefined && !(Number.isInteger(o.concurrency) && o.concurrency >= 1 && o.concurrency <= 64)) bad(`${p}.concurrency`, 'an integer in 1..64');
74
+ else if (required && o.concurrency === undefined) bad(`${p}.concurrency`, 'required');
75
+ for (const d of DIMENSIONS) {
76
+ if (o[d] === undefined) { if (required) bad(`${p}.${d}`, 'required'); } else if (!isNum(o[d])) bad(`${p}.${d}`, 'a finite non-negative number');
77
+ }
78
+ };
79
+ lim(b.portfolio, 'portfolio', true);
80
+ for (const [name, o] of Object.entries(b.repositories ?? {})) lim(o, `repositories.${name}`, false);
81
+ for (const [name, w] of Object.entries(b.priorities ?? {})) {
82
+ if (!(Number.isInteger(w) && w >= WEIGHT_RANGE.min && w <= WEIGHT_RANGE.max)) bad(`priorities.${name}`, `an integer weight in ${WEIGHT_RANGE.min}..${WEIGHT_RANGE.max}`);
83
+ }
84
+ return { ok: errors.length === 0, errors };
85
+ }
86
+
87
+ const limitsFor = (budgets, repo) => ({ ...(budgets.repositories?.default ?? {}), ...(budgets.repositories?.[repo] ?? {}) });
88
+ const weightOf = (budgets, repo) => budgets.priorities?.[repo] ?? WEIGHT_RANGE.default;
89
+
90
+ // ---------------------------------------------------------------- ledger
91
+
92
+ export const ledgerPathFor = (storeFile) => `${storeFile}.ledger.json`;
93
+
94
+ export function newLedger() {
95
+ return { schema: LEDGER_SCHEMA, version: LEDGER_VERSION, usage: { portfolio: zero(), repositories: {} }, reservations: {}, grants: {}, heartbeats: {}, cancellations: [], overruns: [], settled: {} };
96
+ }
97
+
98
+ export function readLedger(storeFile) {
99
+ const file = ledgerPathFor(storeFile);
100
+ let raw;
101
+ try {
102
+ const st = fs.lstatSync(file);
103
+ if (st.isSymbolicLink() || !st.isFile() || st.size > MAX_LEDGER_BYTES) throw typedError('LEDGER_CORRUPT', 'ledger is not a regular file within the size limit');
104
+ raw = fs.readFileSync(file, 'utf8');
105
+ } catch (e) { if (e.code === 'ENOENT') return newLedger(); throw e; }
106
+ let l;
107
+ try { l = JSON.parse(raw); } catch { throw typedError('LEDGER_CORRUPT', 'ledger is not valid JSON'); }
108
+ if (!l || l.schema !== LEDGER_SCHEMA || l.version !== LEDGER_VERSION || typeof l.reservations !== 'object' || typeof l.usage !== 'object') throw typedError('LEDGER_CORRUPT', 'not a portfolio ledger of a supported version');
109
+ return l;
110
+ }
111
+
112
+ function writeLedger(storeFile, ledger) {
113
+ const file = ledgerPathFor(storeFile);
114
+ const tmp = `${file}.tmp-${process.pid}`;
115
+ fs.mkdirSync(path.dirname(file), { recursive: true });
116
+ fs.writeFileSync(tmp, JSON.stringify(ledger), { mode: 0o600 });
117
+ fs.renameSync(tmp, file);
118
+ }
119
+
120
+ const repoUsage = (ledger, repo) => (ledger.usage.repositories[repo] ??= zero());
121
+ const add = (a, b) => { for (const d of DIMENSIONS) a[d] += b[d] ?? 0; };
122
+
123
+ /** Charge an attempt's reservation to usage and release it. `usage` null charges the whole reservation (what it spent is unknown). Idempotent. */
124
+ function settle(ledger, attemptId, usage, how) {
125
+ const r = ledger.reservations[attemptId];
126
+ if (!r) return null;
127
+ const charge = {};
128
+ const over = [];
129
+ for (const d of DIMENSIONS) {
130
+ charge[d] = usage && isNum(usage[d]) ? usage[d] : r[d];
131
+ if (usage && isNum(usage[d]) && usage[d] > r[d]) over.push(d);
132
+ }
133
+ add(ledger.usage.portfolio, charge); add(repoUsage(ledger, r.repository), charge);
134
+ if (over.length) ledger.overruns.push({ attemptId, unitId: r.unitId, repository: r.repository, dimensions: over });
135
+ delete ledger.reservations[attemptId];
136
+ delete ledger.heartbeats[attemptId];
137
+ ledger.settled[attemptId] = how;
138
+ return { charge, over };
139
+ }
140
+
141
+ const inScope = (unit, scope) => !!scope && (scope.all === true || (scope.unitId && scope.unitId === unit.id) || (scope.repository && scope.repository === unit.repository));
142
+
143
+ /** Settle reservations whose lease ended and cancel pending units inside a recorded cancellation scope. Returns what changed. */
144
+ function reconcile(store, ledger, now) {
145
+ const recovered = recoverExpired(store, now);
146
+ const settledNow = [];
147
+ for (const [attemptId, r] of Object.entries(ledger.reservations)) {
148
+ const u = store.units[r.unitId];
149
+ if (!u || !u.lease || u.lease.attemptId !== attemptId) { settle(ledger, attemptId, null, 'lease-ended'); settledNow.push(attemptId); }
150
+ }
151
+ const canceled = [];
152
+ for (const c of ledger.cancellations) {
153
+ for (const u of Object.values(store.units)) {
154
+ if ((u.state === 'pending' || u.state === 'blocked') && inScope(u, c.scope)) { cancelUnits(store, { unitId: u.id, reason: c.reason, now }); canceled.push(u.id); }
155
+ }
156
+ }
157
+ return { recovered, settled: settledNow, canceled };
158
+ }
159
+
160
+ // ---------------------------------------------------------------- admission and selection
161
+
162
+ function checkLimits(limits, used, flight, flightCount, est, scope) {
163
+ const out = [];
164
+ if (Number.isInteger(limits.concurrency) && flightCount >= limits.concurrency) out.push({ code: 'at-capacity', scope, dimension: 'concurrency', limit: limits.concurrency, inFlight: flightCount });
165
+ for (const d of DIMENSIONS) {
166
+ if (!isNum(limits[d])) continue;
167
+ if (used[d] + est[d] > limits[d]) out.push({ code: 'exhausted', scope, dimension: d, limit: limits[d], used: used[d], needed: est[d] });
168
+ else if (used[d] + flight[d] + est[d] > limits[d]) out.push({ code: 'at-capacity', scope, dimension: d, limit: limits[d], used: used[d], reserved: flight[d], needed: est[d] });
169
+ }
170
+ return out;
171
+ }
172
+
173
+ function normalizeEstimate(raw) {
174
+ if (!raw || typeof raw !== 'object') return null;
175
+ const e = {};
176
+ for (const d of DIMENSIONS) { if (!isNum(raw[d])) return null; e[d] = raw[d]; }
177
+ return e.wallMs > 0 ? e : null;
178
+ }
179
+
180
+ /** What each repository and the portfolio currently have reserved by in-flight attempts. */
181
+ function flightOf(ledger) {
182
+ const portfolio = { ...zero(), count: 0 };
183
+ const repos = {};
184
+ for (const r of Object.values(ledger.reservations)) {
185
+ const rr = (repos[r.repository] ??= { ...zero(), count: 0 });
186
+ for (const d of DIMENSIONS) { portfolio[d] += r[d]; rr[d] += r[d]; }
187
+ portfolio.count += 1; rr.count += 1;
188
+ }
189
+ return { portfolio, repos };
190
+ }
191
+
192
+ /**
193
+ * Pure selection over a reconciled store and ledger: the unit that would be leased next, or why none can be.
194
+ * @returns {{ unit: object|null, estimate: object|null, skipped: Array, idle: boolean, waiting: boolean }}
195
+ */
196
+ export function selectUnit({ store, ledger, budgets, estimateOf, blockedRepositories = [] }) {
197
+ const flight = flightOf(ledger);
198
+ const pendingByRepo = new Map();
199
+ for (const u of Object.values(store.units).sort((a, b) => (a.id < b.id ? -1 : 1))) {
200
+ if (u.state === 'pending') { if (!pendingByRepo.has(u.repository)) pendingByRepo.set(u.repository, []); pendingByRepo.get(u.repository).push(u); }
201
+ }
202
+ const skipped = [];
203
+ const blocked = new Set(blockedRepositories);
204
+ const candidates = [];
205
+ for (const [repo, units] of [...pendingByRepo].sort(([a], [b]) => (a < b ? -1 : 1))) {
206
+ if (blocked.has(repo)) { skipped.push({ repository: repo, code: 'repository-blocked', detail: 'declared blocked; its units are skipped and independent repositories continue' }); continue; }
207
+ const repoFlight = flight.repos[repo] ?? { ...zero(), count: 0 };
208
+ const repoLimits = limitsFor(budgets, repo);
209
+ let pick = null; const unitSkips = [];
210
+ for (const u of units) {
211
+ const est = normalizeEstimate(estimateOf(u));
212
+ if (!est) { unitSkips.push({ repository: repo, unitId: u.id, code: 'no-estimate', detail: 'no cost estimate, and an unknown cost is never treated as zero' }); continue; }
213
+ const refusals = [
214
+ ...checkLimits(budgets.portfolio, ledger.usage.portfolio, flight.portfolio, flight.portfolio.count, est, 'portfolio'),
215
+ ...checkLimits(repoLimits, repoUsage(ledger, repo), repoFlight, repoFlight.count, est, 'repository'),
216
+ ];
217
+ if (refusals.length) {
218
+ const code = refusals.some((r) => r.code === 'exhausted') ? 'exhausted' : 'at-capacity';
219
+ unitSkips.push({ repository: repo, unitId: u.id, code, detail: refusals.map((r) => `${r.scope} ${r.dimension}`).join(', '), refusals });
220
+ continue;
221
+ }
222
+ pick = { unit: u, estimate: est };
223
+ break;
224
+ }
225
+ skipped.push(...unitSkips.filter((s) => !pick || s.unitId !== pick.unit.id));
226
+ if (pick) candidates.push({ repo, ...pick });
227
+ }
228
+ let chosen = null;
229
+ for (const c of candidates) {
230
+ const score = (ledger.grants[c.repo] ?? 0) / weightOf(budgets, c.repo);
231
+ if (!chosen || score < chosen.score) chosen = { ...c, score };
232
+ }
233
+ const idle = pendingByRepo.size === 0;
234
+ return { unit: chosen ? chosen.unit : null, estimate: chosen ? chosen.estimate : null, skipped, idle, waiting: !chosen && skipped.some((s) => s.code === 'at-capacity') };
235
+ }
236
+
237
+ /**
238
+ * Admit and lease the next unit in ONE locked transaction. Returns `{ lease, ... }` or `{ lease: null, idle, waiting, skipped }`.
239
+ * A repeat `requestId` from the same holder returns the existing lease and reserves nothing more.
240
+ */
241
+ export function scheduleNext(file, { budgets, estimateOf, holder, now, ttlMs, requestId = null, blockedRepositories = [] }) {
242
+ const bv = validateBudgets(budgets);
243
+ if (!bv.ok) throw typedError('BAD_BUDGETS', `invalid budgets: ${bv.errors[0].path} ${bv.errors[0].message}`, { errors: bv.errors });
244
+ return mutateStore(file, (store) => {
245
+ const ledger = readLedger(file);
246
+ const changed = reconcile(store, ledger, now);
247
+ const finish = (out) => { writeLedger(file, ledger); return { ...out, reconciled: changed }; };
248
+ if (requestId) {
249
+ for (const u of Object.values(store.units)) {
250
+ if (u.lease && u.lease.requestId === requestId && u.lease.holder === holder) {
251
+ return finish({ lease: { unitId: u.id, attemptId: u.lease.attemptId, expiresAt: u.lease.expiresAt, repository: u.repository, duplicate: true }, idle: false, waiting: false, skipped: [] });
252
+ }
253
+ }
254
+ }
255
+ const sel = selectUnit({ store, ledger, budgets, estimateOf, blockedRepositories });
256
+ if (!sel.unit) return finish({ lease: null, idle: sel.idle, waiting: sel.waiting, skipped: sel.skipped });
257
+ const l = leaseUnit(store, { holder, now, ttlMs, unitId: sel.unit.id, requestId });
258
+ ledger.reservations[l.attemptId] = { unitId: sel.unit.id, repository: sel.unit.repository, holder, at: now, ...sel.estimate, used: { spendUsd: 0, requests: 0 } };
259
+ ledger.grants[sel.unit.repository] = (ledger.grants[sel.unit.repository] ?? 0) + 1;
260
+ return finish({ lease: { ...l, repository: sel.unit.repository, reservation: sel.estimate }, idle: false, waiting: false, skipped: sel.skipped });
261
+ });
262
+ }
263
+
264
+ /** Settle an attempt's reservation (idempotent). `usage` null charges the whole reservation. */
265
+ export function settleAttempt(file, { attemptId, usage = null, how = 'reported' }) {
266
+ return mutateStore(file, () => {
267
+ const ledger = readLedger(file);
268
+ const r = settle(ledger, attemptId, usage, how);
269
+ writeLedger(file, ledger);
270
+ return r;
271
+ });
272
+ }
273
+
274
+ /** Renew the lease and record a heartbeat in one transaction. */
275
+ export function heartbeat(file, { unitId, attemptId, now, ttlMs }) {
276
+ return mutateStore(file, (store) => {
277
+ const ledger = readLedger(file);
278
+ const r = renewLease(store, { unitId, attemptId, now, ttlMs });
279
+ if (r.ok) {
280
+ const u = store.units[unitId];
281
+ ledger.heartbeats[attemptId] = { holder: u.lease.holder, unitId, repository: u.repository, at: now };
282
+ writeLedger(file, ledger);
283
+ }
284
+ return r;
285
+ });
286
+ }
287
+
288
+ // ---------------------------------------------------------------- routing and spend
289
+
290
+ /** What the routing policy may spend for a task in `repository` right now: the smaller of the portfolio and repository remainders, less reservations. */
291
+ export function routingBudgetFor({ budgets, ledger, repository }) {
292
+ const flight = flightOf(ledger);
293
+ const left = (limit, used, reserved) => (isNum(limit) ? Math.max(0, limit - used - reserved) : Infinity);
294
+ const p = left(budgets.portfolio.spendUsd, ledger.usage.portfolio.spendUsd, flight.portfolio.spendUsd);
295
+ const r = left(limitsFor(budgets, repository).spendUsd, repoUsage(ledger, repository).spendUsd, (flight.repos[repository] ?? zero()).spendUsd);
296
+ const remainingUsd = Math.min(p, r);
297
+ return { remainingUsd: Number.isFinite(remainingUsd) ? remainingUsd : 0 };
298
+ }
299
+
300
+ /** The dollar upper bound of a routing decision, or null when the decision selects nothing or carries no bound. Unknown is never free. */
301
+ export function spendBoundOf(decision) {
302
+ if (!decision || (decision.status !== 'routed' && decision.status !== 'fallback') || !decision.selected) return null;
303
+ const c = decision.expectedBounds?.costUsd;
304
+ const bound = Math.max(isNum(c?.upperBound) ? c.upperBound : 0, isNum(c?.measuredMedian) ? c.measuredMedian : 0);
305
+ return bound > 0 ? bound : null;
306
+ }
307
+
308
+ /** Charge one model request (a routing decision) against the attempt's reservation. Refuses when blocked, unbounded or over the reservation. */
309
+ export function chargeModelSpend(file, { attemptId, decision }) {
310
+ return mutateStore(file, () => {
311
+ const ledger = readLedger(file);
312
+ const r = ledger.reservations[attemptId];
313
+ if (!r) return { ok: false, code: 'no-reservation', reason: 'this attempt holds no reservation (it ended, or was never admitted)' };
314
+ const bound = spendBoundOf(decision);
315
+ if (bound === null) return { ok: false, code: 'no-spend-bound', reason: 'the routing decision selects nothing or carries no cost bound; an unknown cost is not charged as zero and the request is not made' };
316
+ if (r.used.requests + 1 > r.requests) return { ok: false, code: 'request-limit', reason: `the unit reserved ${r.requests} request(s)` };
317
+ if (r.used.spendUsd + bound > r.spendUsd) return { ok: false, code: 'unit-spend-exceeded', reason: `needs up to ${bound} against ${r.spendUsd - r.used.spendUsd} remaining in the unit's reservation` };
318
+ r.used.spendUsd += bound; r.used.requests += 1;
319
+ writeLedger(file, ledger);
320
+ return { ok: true, charged: bound };
321
+ });
322
+ }
323
+
324
+ // ---------------------------------------------------------------- cancellation
325
+
326
+ /** Tracks the attempts running in this process so a scope can be cancelled. */
327
+ export function createCancelScope() {
328
+ const running = new Map();
329
+ return {
330
+ running,
331
+ register(attemptId, entry) { running.set(attemptId, entry); },
332
+ unregister(attemptId) { running.delete(attemptId); },
333
+ /**
334
+ * Cancel everything in `scope` ({ unitId } | { repository } | { all: true }).
335
+ * @returns {Promise<object>} an `incomplete` report with the reason, what was released and what is left to expire
336
+ */
337
+ async cancel({ file, scope, reason, now, waitMs = 5000 }) {
338
+ if (!scope || !(scope.all === true || scope.unitId || scope.repository)) throw typedError('BAD_SCOPE', 'a cancellation needs a scope: unitId, repository or all');
339
+ const why = String(reason ?? 'canceled').slice(0, 300);
340
+ mutateStore(file, (store) => {
341
+ const ledger = readLedger(file);
342
+ ledger.cancellations.push({ scope: { ...scope }, reason: why, at: now });
343
+ reconcile(store, ledger, now);
344
+ writeLedger(file, ledger);
345
+ });
346
+ const mine = [...running.entries()].filter(([, e]) => inScope({ id: e.unitId, repository: e.repository }, scope));
347
+ for (const [, e] of mine) e.controller.abort(Object.assign(new Error(why), { code: 'CANCELED' }));
348
+ await Promise.race([Promise.all(mine.map(([, e]) => e.settled)), new Promise((r) => setTimeout(r, waitMs))]);
349
+ return mutateStore(file, (store) => {
350
+ const ledger = readLedger(file);
351
+ const report = { status: 'nothing-to-cancel', incomplete: false, reason: why, scope: { ...scope }, canceled: [], leasesReleased: [], leasesLeftToExpire: [], verifiedPreserved: [], survivors: [] };
352
+ for (const id of Object.keys(store.units).sort()) {
353
+ const u = store.units[id];
354
+ if (!inScope(u, scope)) continue;
355
+ if (u.state === 'verified') { report.verifiedPreserved.push(id); continue; }
356
+ if (u.state === 'leased' || u.state === 'running') {
357
+ const attemptId = u.lease.attemptId;
358
+ const e = running.get(attemptId) ?? mine.find(([a]) => a === attemptId)?.[1];
359
+ if (e && e.finished === true && e.survivors.length === 0) {
360
+ cancelUnits(store, { unitId: id, reason: why, now });
361
+ settle(ledger, attemptId, null, 'canceled');
362
+ report.leasesReleased.push(id); report.canceled.push(id);
363
+ } else {
364
+ report.leasesLeftToExpire.push({ unitId: id, attemptId, expiresAt: u.lease.expiresAt, why: e ? (e.survivors.length ? 'a descendant survived termination' : 'the attempt did not settle in time') : 'the attempt runs in another process' });
365
+ if (e?.survivors.length) report.survivors.push({ unitId: id, pids: e.survivors });
366
+ }
367
+ } else if (u.state === 'canceled') report.canceled.push(id);
368
+ }
369
+ if (report.canceled.length || report.leasesLeftToExpire.length) { report.status = 'incomplete'; report.incomplete = true; }
370
+ writeLedger(file, ledger);
371
+ return report;
372
+ });
373
+ },
374
+ };
375
+ }
376
+
377
+ // ---------------------------------------------------------------- the driver
378
+
379
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
380
+
381
+ /**
382
+ * Drive a store with budgets. `executor(unit, ctx)` returns `{ resultDigest, dependencies, usage? }` where `usage` may report
383
+ * `{ spendUsd, requests, storageBytes }` (wall time is measured). `ctx` carries `attemptId`, `signal`, `heartbeat()`, `reservation`,
384
+ * `spawn(bin, args, opts)` (supervised: the process tree ends with the attempt) and `requestModel(decision)` (charges the routing decision).
385
+ *
386
+ * With `backend`, the backend is probed before every lease; an unavailable one stops the run with `stopped: 'blocked'` and the typed probe
387
+ * result in `blocked`. @returns {Promise<{ progress: object|null, stopped: string, order: string[], skipped: Array, blocked?: object }>}
388
+ */
389
+ export async function runScheduled({
390
+ file, budgets, estimateOf, executor, holder = 'worker', clock = () => Date.now(), ttlMs, maxLeases = Infinity, cancelScope = null,
391
+ blockedRepositories = [], heartbeatMs = HEARTBEAT.intervalMs, pollMs = 25, maxWaitMs = 2000, workers = null, backend = null,
392
+ }) {
393
+ const bv = validateBudgets(budgets);
394
+ if (!bv.ok) throw typedError('BAD_BUDGETS', `invalid budgets: ${bv.errors[0].path} ${bv.errors[0].message}`, { errors: bv.errors });
395
+ const order = [];
396
+ let leased = 0; let stopped = 'drained'; let lastSkipped = []; let blocked = null;
397
+ const limit = Math.max(1, Math.min(workers ?? budgets.portfolio.concurrency, 16));
398
+ let inFlight = 0;
399
+
400
+ async function worker(n) {
401
+ let waited = 0;
402
+ for (;;) {
403
+ if (leased >= maxLeases) { stopped = 'max-leases'; return; }
404
+ // a shared backend that went away stops the run: nothing is written anywhere else instead (X-708.AC03)
405
+ if (backend) { const p = backend.probe(); if (!p.ok) { stopped = 'blocked'; blocked = p; return; } }
406
+ let r;
407
+ try { r = scheduleNext(file, { budgets, estimateOf, holder: `${holder}-${n}`, now: clock(), ttlMs, blockedRepositories }); } catch (e) {
408
+ if (backend && !backend.probe().ok) { stopped = 'blocked'; blocked = backend.probe(); return; }
409
+ throw e;
410
+ }
411
+ lastSkipped = r.skipped;
412
+ if (!r.lease) {
413
+ if (r.idle) return;
414
+ if (r.waiting) {
415
+ if (inFlight === 0 && waited >= maxWaitMs) { stopped = 'waiting-timeout'; return; }
416
+ await sleep(pollMs); waited += pollMs; continue;
417
+ }
418
+ stopped = 'nothing-schedulable'; return;
419
+ }
420
+ waited = 0;
421
+ leased += 1; inFlight += 1;
422
+ order.push(r.lease.unitId);
423
+ try { await runOne(r.lease); } catch (e) {
424
+ // the attempt could not record its outcome because the backend is gone: its lease is left to expire, never re-homed
425
+ if (backend && !backend.probe().ok) { stopped = 'blocked'; blocked = backend.probe(); return; }
426
+ throw e;
427
+ } finally { inFlight -= 1; }
428
+ }
429
+ }
430
+
431
+ async function runOne(lease) {
432
+ const { unitId, attemptId } = lease;
433
+ const controller = new AbortController();
434
+ const entry = { unitId, repository: lease.repository, controller, finished: false, survivors: [], terminations: [], settled: null };
435
+ let resolveSettled; entry.settled = new Promise((r) => { resolveSettled = r; });
436
+ cancelScope?.register(attemptId, entry);
437
+ const startedAt = clock();
438
+ let timer = null; let beat = null;
439
+ try {
440
+ mutateStore(file, (s) => startUnit(s, { unitId, attemptId, now: clock() }));
441
+ const unit = readStore(file).units[unitId];
442
+ const wallCap = lease.reservation.wallMs;
443
+ timer = setTimeout(() => controller.abort(Object.assign(new Error('wall-time budget for the unit was reached'), { code: 'WALL_TIMEOUT' })), wallCap);
444
+ const doBeat = () => { try { heartbeat(file, { unitId, attemptId, now: clock(), ttlMs }); } catch { /* the lease is gone; the result will be refused */ } };
445
+ beat = setInterval(doBeat, heartbeatMs); doBeat();
446
+ const ctx = {
447
+ attemptId, signal: controller.signal, reservation: lease.reservation, heartbeat: doBeat,
448
+ async spawn(bin, args, opts = {}) {
449
+ const r = await superviseSpawn(bin, args, { ...opts, signal: controller.signal });
450
+ entry.terminations.push(r.termination);
451
+ if (r.termination?.survivors?.length) entry.survivors.push(...r.termination.survivors);
452
+ return r;
453
+ },
454
+ requestModel: (decision) => chargeModelSpend(file, { attemptId, decision }),
455
+ };
456
+ let res = null; let err = null;
457
+ try { res = await executor(unit, ctx); } catch (e) { err = e; }
458
+ clearTimeout(timer); clearInterval(beat);
459
+ const canceled = controller.signal.aborted && controller.signal.reason?.code === 'CANCELED';
460
+ if (canceled) return; // cancel() performs the store transition once this attempt has settled
461
+ const timedOut = controller.signal.aborted && controller.signal.reason?.code === 'WALL_TIMEOUT';
462
+ const spent = mutateStore(file, (s) => {
463
+ const ledger = readLedger(file);
464
+ const tracked = ledger.reservations[attemptId]?.used ?? { spendUsd: 0, requests: 0 };
465
+ const failed = err || !res || timedOut;
466
+ // a failed attempt spent an unknown amount: it is charged its reservation; a reported success is charged what it reported
467
+ const usage = failed ? null : { wallMs: Math.max(1, clock() - startedAt), spendUsd: tracked.spendUsd, requests: tracked.requests, ...(res.usage ?? {}) };
468
+ settle(ledger, attemptId, usage, failed ? 'failed' : 'reported');
469
+ writeLedger(file, ledger);
470
+ if (failed) failUnit(s, { unitId, attemptId, reason: timedOut ? 'wall-time budget reached' : err ? err.message : 'executor returned nothing', now: clock() });
471
+ else completeUnit(s, { unitId, attemptId, resultDigest: res.resultDigest, dependencies: res.dependencies, now: clock() });
472
+ return true;
473
+ });
474
+ void spent;
475
+ } finally {
476
+ clearTimeout(timer); clearInterval(beat);
477
+ entry.finished = true;
478
+ resolveSettled();
479
+ cancelScope?.unregister(attemptId);
480
+ }
481
+ }
482
+
483
+ await Promise.all(Array.from({ length: limit }, (_, i) => worker(i)));
484
+ let progress = null;
485
+ try { progress = progressOf(readStore(file)); } catch (e) { if (!blocked) throw e; }
486
+ return { progress, stopped, order, skipped: lastSkipped, ...(blocked ? { blocked } : {}) };
487
+ }
488
+
@@ -0,0 +1,155 @@
1
+ // Sign and verify bounded assurance claims (X-703).
2
+ //
3
+ // WHAT IS SIGNED. Not the manifest bytes alone: a small claim that names the manifest id and digest, the portable bundle digest, the
4
+ // subject commit, the blocking-finding policy, the coverage counts and the bounded headline, plus the signer identity, key id and the
5
+ // assurance policy the signer applied. Verification re-derives every one of those from the bundle on disk, so a signature cannot be
6
+ // carried over to different evidence.
7
+ //
8
+ // WHO MAY SIGN (X-703.AC01). Signing is a SIGNER-domain act (sandbox/trust-domains.js, default deny): a worker, target or verifier
9
+ // domain asking to sign is refused before any key is touched. The key lives in its own directory (`assurance-signing/` beside, never
10
+ // inside, the scan evidence keys) so the assurance domain has a separate key identity, and the signer refuses a key whose id equals the
11
+ // evidence key it is told to stay independent of. The key is read from that directory by the signer; nothing here returns, logs or
12
+ // embeds private key material, and `signAssuranceClaim` checks its own output for a PEM block before returning it.
13
+ //
14
+ // WHAT VERIFICATION DOES (X-703.AC02). Offline, with a trust-root policy the verifier is handed: unknown roots, revoked roots, a trust
15
+ // basis this build cannot verify, a tampered claim, a tampered or missing bundle, and a claim that does not match the bundle's manifest
16
+ // are each a typed failure. The only basis this build can verify is `self-issued-local-key`; a policy that names any other basis is
17
+ // rejected, never displayed as if it were certification.
18
+ //
19
+ // WHAT IT NEVER CLAIMS (X-703.AC03). `independentlyCertified` is always false and is part of the signed bytes. A valid signature shows
20
+ // the manifest and bundle are unmodified since the named key signed them. It is not third-party certification and not a statement that
21
+ // no vulnerability exists.
22
+
23
+ import * as crypto from 'node:crypto';
24
+ import * as path from 'node:path';
25
+ import { canonicalJson, ensureKeyPair, keyFingerprint, TRUST_BASES } from '../evidence-bundle.js';
26
+ import { RESOURCES, mayDo, keyDirectory } from '../../sandbox/trust-domains.js';
27
+ import { digestOf } from '../assurance/identity.js';
28
+ import { validateManifest, manifestDigest } from './manifest.js';
29
+ import { verifyBundle } from './bundle.js';
30
+ import { assuranceStatement, HEADLINE } from './wording.js';
31
+
32
+ const CLAIM_SCHEMA = 'agentic-security/assurance-claim';
33
+ const CLAIM_VERSION = '1.0.0';
34
+ const TRUST_POLICY_SCHEMA = 'agentic-security/assurance-trust-roots';
35
+ const ASSURANCE_POLICY = Object.freeze({
36
+ id: 'bounded-claim-v1',
37
+ statement: `${HEADLINE}, with visible scope and gaps. Not a statement of safety, vulnerability absence or independent certification.`,
38
+ });
39
+ export const SIGNING_DIR_NAME = 'assurance-signing';
40
+
41
+ /** The signing key directory for the assurance domain: separate from the scan evidence keys. */
42
+ export function assuranceKeyDirectory(env = process.env) { return path.join(keyDirectory(env), SIGNING_DIR_NAME); }
43
+
44
+ /** Load, or create on first use, the assurance signing key pair. Only the signer domain may call this. */
45
+ export function ensureAssuranceKeys({ domain, dir = assuranceKeyDirectory() } = {}) {
46
+ if (!mayDo(domain, RESOURCES.SIGNING_KEY, 'read')) throw signerRefusal(domain);
47
+ const k = ensureKeyPair(dir);
48
+ return { privateKeyPem: k.privateKeyPem, publicKeyPem: k.publicKeyPem, keyId: keyFingerprint(k.publicKeyPem), created: k.created };
49
+ }
50
+
51
+ function signerRefusal(domain) {
52
+ return Object.assign(new Error(`the '${domain}' domain may not sign assurance claims: signing is a signer-domain act and the key is out of its reach`), { code: 'SIGNER_NOT_AUTHORIZED' });
53
+ }
54
+
55
+ /**
56
+ * Sign a bounded claim for a verified bundle. Throws a typed error (`code`) on a refused domain, an invalid manifest, a bundle that does
57
+ * not verify, or a key that is not independent of `independentOfKeyId`.
58
+ *
59
+ * @param {object} a
60
+ * @param {string} a.domain the calling trust domain (must be 'signer')
61
+ * @param {string} a.bundleDir an exported portable bundle
62
+ * @param {string} a.privateKeyPem the signer's Ed25519 key
63
+ * @param {string} a.signerId stable signer identity, e.g. 'release-signer@org'
64
+ * @param {string} [a.independentOfKeyId] fingerprint of a key (the scan evidence key) this domain must not share
65
+ */
66
+ export function signAssuranceClaim({ domain, bundleDir, privateKeyPem, signerId, independentOfKeyId = null }) {
67
+ if (!mayDo(domain, RESOURCES.SIGNING_KEY, 'read')) throw signerRefusal(domain);
68
+ if (typeof signerId !== 'string' || !signerId.trim()) throw Object.assign(new Error('a signer identity is required'), { code: 'SIGNER_ID_REQUIRED' });
69
+ const vb = verifyBundle(bundleDir);
70
+ if (!vb.ok || !vb.manifest) throw Object.assign(new Error(`refusing to sign: the bundle does not verify (${vb.errors[0]?.code ?? 'no manifest'})`), { code: 'BUNDLE_INVALID', errors: vb.errors });
71
+ const vm = validateManifest(vb.manifest);
72
+ if (!vm.ok) throw Object.assign(new Error('refusing to sign: the manifest is invalid'), { code: 'MANIFEST_INVALID' });
73
+ let publicKeyPem;
74
+ try { publicKeyPem = crypto.createPublicKey(privateKeyPem).export({ type: 'spki', format: 'pem' }); } catch {
75
+ throw Object.assign(new Error('the signing key could not be parsed'), { code: 'BAD_KEY' });
76
+ }
77
+ const keyId = keyFingerprint(publicKeyPem);
78
+ if (independentOfKeyId && keyId === independentOfKeyId) throw Object.assign(new Error('the assurance signing key must be independent of the scan evidence key'), { code: 'KEY_NOT_INDEPENDENT' });
79
+
80
+ const m = vb.manifest;
81
+ const body = {
82
+ schema: CLAIM_SCHEMA, schemaVersion: CLAIM_VERSION,
83
+ claim: {
84
+ manifestId: m.id, manifestDigest: manifestDigest(m), bundleDigest: vb.bundleDigest,
85
+ subject: { repository: m.subject.repository, commit: m.subject.commit },
86
+ blockingPolicy: { id: m.policy.id, digest: m.policy.digest, blockingSeverity: m.policy.blockingSeverity },
87
+ blockingFindings: m.findings.blocking, coverage: m.coverage, complete: m.complete,
88
+ headline: assuranceStatement(m).headline,
89
+ ...(m.synthetic === true ? { synthetic: true } : {}),
90
+ },
91
+ signer: { id: signerId, keyId },
92
+ assurancePolicy: { id: ASSURANCE_POLICY.id, digest: digestOf(ASSURANCE_POLICY), statement: ASSURANCE_POLICY.statement },
93
+ issuance: { trustBasis: 'self-issued-local-key', independentlyCertified: false, statement: TRUST_BASES['self-issued-local-key'].statement },
94
+ };
95
+ const value = crypto.sign(null, Buffer.from(canonicalJson(body)), privateKeyPem).toString('base64');
96
+ const envelope = { ...body, signature: { algorithm: 'ed25519', value } };
97
+ if (/PRIVATE KEY/.test(JSON.stringify(envelope))) throw Object.assign(new Error('private key material reached the output; refusing to return it'), { code: 'KEY_LEAK' });
98
+ return envelope;
99
+ }
100
+
101
+ /** A trust-root policy naming the keys a verifier accepts. Only `self-issued-local-key` is a basis this build can verify. */
102
+ export function buildTrustPolicy({ roots = [], revoked = [] } = {}) {
103
+ return {
104
+ schema: TRUST_POLICY_SCHEMA, schemaVersion: '1.0.0',
105
+ roots: roots.map((r) => ({ keyId: keyFingerprint(r.publicKeyPem), signerId: r.signerId, basis: r.basis ?? 'self-issued-local-key', publicKeyPem: r.publicKeyPem })),
106
+ revoked: revoked.map((r) => ({ keyId: r.keyId, reason: r.reason ?? 'revoked' })),
107
+ };
108
+ }
109
+
110
+ const fail = (code, reason, extra = {}) => ({ ok: false, code, reason, independentlyCertified: false, ...extra });
111
+
112
+ /**
113
+ * Verify a signed claim offline against a trust-root policy and the bundle it names. Never throws. Returns `{ ok, ... }`; on failure
114
+ * `code` is one of: NOT_A_CLAIM, UNSUPPORTED_ALGORITHM, NO_TRUST_POLICY, UNKNOWN_TRUST_ROOT, REVOKED_TRUST_ROOT, UNSUPPORTED_BASIS,
115
+ * BAD_TRUST_ROOT, OVER_CLAIM, SIGNATURE_INVALID, BUNDLE_INVALID, EVIDENCE_MISMATCH.
116
+ */
117
+ export function verifyAssuranceClaim({ envelope, trustPolicy, bundleDir }) {
118
+ try {
119
+ if (!envelope || envelope.schema !== CLAIM_SCHEMA || !envelope.claim || !envelope.signer || !envelope.signature || !envelope.issuance) return fail('NOT_A_CLAIM', 'not a signed assurance claim');
120
+ if (envelope.signature.algorithm !== 'ed25519') return fail('UNSUPPORTED_ALGORITHM', `unsupported algorithm: ${envelope.signature.algorithm}`);
121
+ if (!trustPolicy || trustPolicy.schema !== TRUST_POLICY_SCHEMA || !Array.isArray(trustPolicy.roots)) return fail('NO_TRUST_POLICY', 'no usable trust-root policy was supplied; a signature is meaningless without one');
122
+ const keyId = envelope.signer.keyId;
123
+ const revoked = (trustPolicy.revoked ?? []).find((r) => r.keyId === keyId);
124
+ if (revoked) return fail('REVOKED_TRUST_ROOT', `key ${String(keyId).slice(0, 12)} is revoked: ${revoked.reason}`);
125
+ const root = trustPolicy.roots.find((r) => r.keyId === keyId);
126
+ if (!root) return fail('UNKNOWN_TRUST_ROOT', `key ${String(keyId).slice(0, 12)} is not a trust root in this policy`);
127
+ if (!TRUST_BASES[root.basis]) return fail('UNSUPPORTED_BASIS', `trust basis '${root.basis}' cannot be verified by this build; it is rejected, not displayed as certification`);
128
+ if (keyFingerprint(root.publicKeyPem) !== root.keyId) return fail('BAD_TRUST_ROOT', 'the trust root public key does not match its key id');
129
+ if (envelope.issuance.independentlyCertified !== false || envelope.issuance.trustBasis !== root.basis) return fail('OVER_CLAIM', 'a self-issued claim may not state independent certification, and its basis must match the trust root');
130
+
131
+ const { signature, ...body } = envelope;
132
+ let good = false;
133
+ try { good = crypto.verify(null, Buffer.from(canonicalJson(body)), root.publicKeyPem, Buffer.from(signature.value, 'base64')); } catch { good = false; }
134
+ if (!good) return fail('SIGNATURE_INVALID', 'the signature does not match the claim: it was modified after signing, or signed by another key');
135
+
136
+ const vb = verifyBundle(bundleDir);
137
+ if (!vb.ok || !vb.manifest) return fail('BUNDLE_INVALID', `the evidence bundle does not verify: ${vb.errors[0]?.code ?? 'no manifest'}`, { errors: vb.errors });
138
+ const m = vb.manifest;
139
+ const c = envelope.claim;
140
+ const mismatch = [];
141
+ if (c.bundleDigest !== vb.bundleDigest) mismatch.push('bundleDigest');
142
+ if (c.manifestDigest !== manifestDigest(m)) mismatch.push('manifestDigest');
143
+ if (c.manifestId !== m.id) mismatch.push('manifestId');
144
+ if (c.subject?.commit !== m.subject.commit) mismatch.push('subject.commit');
145
+ if (digestOf(c.coverage) !== digestOf(m.coverage)) mismatch.push('coverage');
146
+ if (c.blockingFindings !== m.findings.blocking) mismatch.push('blockingFindings');
147
+ if (c.blockingPolicy?.digest !== m.policy.digest) mismatch.push('blockingPolicy');
148
+ if (mismatch.length) return fail('EVIDENCE_MISMATCH', `the signed claim does not match the evidence: ${mismatch.join(', ')}`, { mismatch });
149
+
150
+ const verified = { ok: true, trustBasis: root.basis, independentlyCertified: false, signer: envelope.signer.id, keyId, assurancePolicy: envelope.assurancePolicy?.id ?? null, replay: vb.replay };
151
+ return { ...verified, statement: assuranceStatement(m, { signature: verified }).text, note: TRUST_BASES[root.basis].statement };
152
+ } catch (e) {
153
+ return fail('NOT_A_CLAIM', `verification could not complete: ${String(e?.message ?? e).split('\n')[0]}`);
154
+ }
155
+ }