@clear-capabilities/agentic-security-scanner 0.162.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 (153) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/bin/agentic-security.js +87 -0
  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 +1 -1
  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 +1 -1
  15. package/dist/9560.index.js +2318 -23
  16. package/dist/957.index.js +1 -1
  17. package/dist/9999.index.js +43 -7
  18. package/dist/agentic-security.mjs +12 -12
  19. package/dist/agentic-security.mjs.sha256 +1 -1
  20. package/package.json +33 -6
  21. package/src/capabilities/CLAUDE.md +114 -0
  22. package/src/capabilities/address.js +73 -0
  23. package/src/capabilities/attack-coverage.js +93 -0
  24. package/src/capabilities/decide.js +256 -0
  25. package/src/capabilities/delegate.js +69 -0
  26. package/src/capabilities/egress.js +20 -0
  27. package/src/capabilities/hook-advice.js +50 -0
  28. package/src/capabilities/index.js +32 -0
  29. package/src/capabilities/manifest.js +344 -0
  30. package/src/capabilities/outbound.js +189 -0
  31. package/src/capabilities/paths.js +57 -0
  32. package/src/capabilities/probes.js +211 -0
  33. package/src/capabilities/proxy.js +182 -0
  34. package/src/capabilities/reasons.js +67 -0
  35. package/src/capabilities/receipts.js +356 -0
  36. package/src/capabilities/records.js +58 -0
  37. package/src/capabilities/recovery.js +367 -0
  38. package/src/capabilities/report.js +81 -0
  39. package/src/capabilities/runner.js +236 -0
  40. package/src/capabilities/secrets.js +26 -0
  41. package/src/capabilities/tool-gate.js +119 -0
  42. package/src/capabilities/tool-registry.js +60 -0
  43. package/src/dataflow/CLAUDE.md +29 -0
  44. package/src/dataflow/catalog.js +21 -0
  45. package/src/dataflow/engine.js +20 -0
  46. package/src/dataflow/guard-dominance.js +352 -0
  47. package/src/discovery/CLAUDE.md +9 -0
  48. package/src/discovery/index.js +15 -1
  49. package/src/discovery/lenses.js +18 -0
  50. package/src/engine.js +80 -15
  51. package/src/integrations/index.js +5 -2
  52. package/src/ir/CLAUDE.md +14 -0
  53. package/src/ir/implicit-handler-params.js +74 -0
  54. package/src/ir/parser-cs.js +20 -0
  55. package/src/ir/parser-go.js +30 -13
  56. package/src/ir/parser-java.js +11 -0
  57. package/src/language/nix-eval-isolation.js +33 -5
  58. package/src/lineage/CLAUDE.md +31 -0
  59. package/src/lineage/deployment/adapter-cloud.js +202 -0
  60. package/src/lineage/deployment/adapter-compose.js +90 -0
  61. package/src/lineage/deployment/adapter-kit.js +142 -0
  62. package/src/lineage/deployment/adapter-kubernetes.js +247 -0
  63. package/src/lineage/deployment/attack-paths.js +443 -0
  64. package/src/lineage/deployment/boundaries-run.js +223 -0
  65. package/src/lineage/deployment/boundary-graph.js +567 -0
  66. package/src/lineage/deployment/cross-repo.js +253 -0
  67. package/src/lineage/deployment/drift.js +383 -0
  68. package/src/lineage/deployment/ingest.js +221 -0
  69. package/src/lineage/deployment/projection.js +244 -0
  70. package/src/lineage/deployment/trace-correlation.js +541 -0
  71. package/src/lineage/source-registry.js +19 -0
  72. package/src/mcp/CLAUDE.md +5 -2
  73. package/src/mcp/invariant-tools.js +64 -0
  74. package/src/mcp/portfolio-tools.js +79 -0
  75. package/src/mcp/redact.js +15 -0
  76. package/src/mcp/server.js +24 -3
  77. package/src/mcp/tools.js +13 -1
  78. package/src/posture/CLAUDE.md +332 -1
  79. package/src/posture/accuracy-scorecard.js +95 -4
  80. package/src/posture/assurance/rollback.js +214 -0
  81. package/src/posture/assurance/rollout.js +200 -0
  82. package/src/posture/business-logic.js +26 -4
  83. package/src/posture/evaluation/bakeoff.js +148 -0
  84. package/src/posture/evaluation/custody.js +200 -0
  85. package/src/posture/evaluation/deployment-ablation.js +416 -0
  86. package/src/posture/evaluation/dev-cases.js +65 -0
  87. package/src/posture/evaluation/economics.js +85 -0
  88. package/src/posture/evaluation/gates.js +161 -0
  89. package/src/posture/evaluation/grouping.js +101 -0
  90. package/src/posture/evaluation/interval.js +132 -0
  91. package/src/posture/evaluation/invariant-ablation.js +325 -0
  92. package/src/posture/evaluation/labels.js +200 -0
  93. package/src/posture/evaluation/protocol.js +308 -0
  94. package/src/posture/evaluation/release-gate.js +209 -0
  95. package/src/posture/evaluation/report.js +158 -0
  96. package/src/posture/evaluation/runner.js +273 -0
  97. package/src/posture/evaluation/scan-child.js +80 -0
  98. package/src/posture/evaluation/score.js +174 -0
  99. package/src/posture/evaluation/synthetic.js +90 -0
  100. package/src/posture/evaluation/why-missed.js +219 -0
  101. package/src/posture/fleet.js +11 -2
  102. package/src/posture/invariants/cli.js +95 -0
  103. package/src/posture/invariants/coverage.js +164 -0
  104. package/src/posture/invariants/export.js +178 -0
  105. package/src/posture/invariants/expressions.js +46 -0
  106. package/src/posture/invariants/lifecycle.js +289 -0
  107. package/src/posture/invariants/project-input.js +55 -0
  108. package/src/posture/invariants/repair.js +340 -0
  109. package/src/posture/invariants/run.js +45 -0
  110. package/src/posture/invariants/scenarios.js +311 -0
  111. package/src/posture/invariants/schema.js +167 -0
  112. package/src/posture/invariants/shrink.js +407 -0
  113. package/src/posture/invariants/state-assertions.js +165 -0
  114. package/src/posture/logic-claims.js +12 -0
  115. package/src/posture/model-routing.js +40 -0
  116. package/src/posture/oracles/adapters.js +172 -1
  117. package/src/posture/oracles/conformance.js +3 -2
  118. package/src/posture/oracles/oracle.js +23 -3
  119. package/src/posture/oracles/registry.js +1 -1
  120. package/src/posture/portfolio/backend.js +279 -0
  121. package/src/posture/portfolio/bundle.js +314 -0
  122. package/src/posture/portfolio/cli.js +142 -0
  123. package/src/posture/portfolio/manifest.js +263 -0
  124. package/src/posture/portfolio/progress.js +267 -0
  125. package/src/posture/portfolio/resume.js +130 -0
  126. package/src/posture/portfolio/retention.js +207 -0
  127. package/src/posture/portfolio/scheduler.js +488 -0
  128. package/src/posture/portfolio/signing.js +155 -0
  129. package/src/posture/portfolio/wording.js +59 -0
  130. package/src/posture/portfolio/work-units.js +405 -0
  131. package/src/posture/replay/replay.js +1 -0
  132. package/src/posture/routing/adapter.js +140 -0
  133. package/src/posture/routing/calibration.js +278 -0
  134. package/src/posture/routing/canary.js +141 -0
  135. package/src/posture/routing/control.js +35 -0
  136. package/src/posture/routing/decide.js +211 -0
  137. package/src/posture/routing/drift.js +137 -0
  138. package/src/posture/routing/economics.js +185 -0
  139. package/src/posture/routing/feedback.js +207 -0
  140. package/src/posture/routing/index.js +31 -0
  141. package/src/posture/routing/outcomes.js +281 -0
  142. package/src/posture/routing/promotion.js +291 -0
  143. package/src/posture/routing/receipts.js +59 -0
  144. package/src/posture/routing/report.js +142 -0
  145. package/src/posture/routing/shadow.js +53 -0
  146. package/src/posture/specification-mining.js +26 -0
  147. package/src/posture/verification/patch-negative.js +16 -4
  148. package/src/posture/verification/patch-promotion.js +10 -2
  149. package/src/report/index.js +51 -1
  150. package/src/sandbox/CLAUDE.md +10 -0
  151. package/src/sandbox/backend-userspace.js +91 -13
  152. package/dist/5613.index.js +0 -500
  153. package/dist/6239.index.js +0 -469
@@ -0,0 +1,263 @@
1
+ // Release assurance manifest (X-701).
2
+ //
3
+ // A manifest binds one release claim to the exact identities it is about, and says out loud what was NOT checked. It is a
4
+ // closed-world record built on the assurance schema kit (assurance/schema-kit.js) and the deterministic identity helpers
5
+ // (assurance/identity.js), the same pair the release-evidence contract uses, so a manifest id is a refactor-stable hash over an
6
+ // allowlist of semantic fields and never covers a clock.
7
+ //
8
+ // What it binds (X-701.AC01): the subject repository and commit, every dependency revision, every build artifact digest, the scan
9
+ // scope, the graph snapshot digest, the invariant versions and the verification receipts the claim rests on.
10
+ //
11
+ // What it discloses (X-701.AC02): every mandatory check lands in exactly one of completed / incomplete / unsupported / waived, and
12
+ // the exact blocking-finding policy (id, digest, minimum blocking severity) is part of the record. A waiver names who approved it and
13
+ // why; an incomplete or unsupported check names what is missing.
14
+ //
15
+ // What validation REFUSES (X-701.AC03): evidence that is not bound to a declared receipt, a receipt whose commit is not the commit of
16
+ // the repository it claims to be about, a mandatory check that appears nowhere, and a coverage block that disagrees with the check
17
+ // lists. Residual risks and coverage are preserved as machine-readable fields, never folded into prose.
18
+ //
19
+ // Pure: no fs, no clock, no network. Synthetic fixtures set `synthetic: true`; that flag is carried, never inferred.
20
+
21
+ import {
22
+ SCHEMA_VERSION, isPlainObject, isDigest, checkHeader, checkFields, checkDigest, checkCommit, checkString, checkEnum, checkId, result, guardObject,
23
+ } from '../assurance/schema-kit.js';
24
+ import { semanticId, digestOf } from '../assurance/identity.js';
25
+
26
+ export const MANIFEST_SCHEMA = 'agentic-security/release-assurance-manifest';
27
+ const MANIFEST_ID_PREFIX = 'ram';
28
+ const BLOCKING_SEVERITIES = Object.freeze(['critical', 'high', 'medium', 'low']);
29
+ const CHECK_GROUPS = Object.freeze(['completed', 'incomplete', 'unsupported', 'waived']);
30
+
31
+ // Hard ceilings: a manifest is attacker-influenced input at verify time.
32
+ const MANIFEST_LIMITS = Object.freeze({ maxChecks: 512, maxReceipts: 2048, maxArtifacts: 512, maxDependencies: 512, maxInvariants: 512, maxRisks: 256 });
33
+
34
+ const ALLOWED = [
35
+ 'schema', 'schemaVersion', 'id', 'synthetic', 'subject', 'dependencies', 'artifacts', 'scope', 'graphSnapshot', 'invariantVersions',
36
+ 'verificationReceipts', 'checks', 'policy', 'findings', 'residualRisks', 'coverage', 'complete', 'createdAt',
37
+ ];
38
+ const REQUIRED = [
39
+ 'schema', 'schemaVersion', 'id', 'subject', 'dependencies', 'artifacts', 'scope', 'graphSnapshot', 'invariantVersions',
40
+ 'verificationReceipts', 'checks', 'policy', 'findings', 'residualRisks', 'coverage', 'complete',
41
+ ];
42
+ const ID_FIELDS = Object.freeze([
43
+ 'subject', 'dependencies', 'artifacts', 'scope', 'graphSnapshot', 'invariantVersions', 'verificationReceipts', 'checks', 'policy',
44
+ 'findings', 'residualRisks', 'coverage', 'complete',
45
+ ]);
46
+
47
+ export const manifestId = (m) => semanticId(MANIFEST_ID_PREFIX, m, ID_FIELDS);
48
+ /** The digest a signature binds: the whole manifest, key-order independent. */
49
+ export const manifestDigest = (m) => digestOf(m);
50
+
51
+ /** Counts derived from the check lists. The stored `coverage` must equal this, so it cannot drift from the lists it summarizes. */
52
+ export function deriveCoverage(checks, scope) {
53
+ const n = (g) => (Array.isArray(checks?.[g]) ? checks[g].length : 0);
54
+ const mandatory = Array.isArray(scope?.mandatory) ? scope.mandatory.length : 0;
55
+ return {
56
+ mandatory, completed: n('completed'), incomplete: n('incomplete'), unsupported: n('unsupported'), waived: n('waived'),
57
+ complete: mandatory > 0 && n('incomplete') === 0 && n('unsupported') === 0 && n('completed') + n('waived') === mandatory,
58
+ };
59
+ }
60
+
61
+ /** Build a manifest from the bound facts; coverage, completeness and id are computed, never supplied. */
62
+ export function buildManifest(f) {
63
+ const checks = {
64
+ completed: f.checks?.completed ?? [], incomplete: f.checks?.incomplete ?? [], unsupported: f.checks?.unsupported ?? [], waived: f.checks?.waived ?? [],
65
+ };
66
+ const m = {
67
+ schema: MANIFEST_SCHEMA, schemaVersion: SCHEMA_VERSION,
68
+ ...(f.synthetic === true ? { synthetic: true } : {}),
69
+ subject: f.subject, dependencies: f.dependencies ?? [], artifacts: f.artifacts ?? [], scope: f.scope,
70
+ graphSnapshot: f.graphSnapshot, invariantVersions: f.invariantVersions ?? [], verificationReceipts: f.verificationReceipts ?? [],
71
+ checks, policy: f.policy, findings: f.findings, residualRisks: f.residualRisks ?? [],
72
+ coverage: deriveCoverage(checks, f.scope),
73
+ };
74
+ m.complete = m.coverage.complete;
75
+ m.id = manifestId(m);
76
+ return m;
77
+ }
78
+
79
+ function checkList(ctx, p, list, max) {
80
+ if (!Array.isArray(list)) { ctx.err('BAD_TYPE', p, 'must be an array'); return false; }
81
+ if (list.length > max) { ctx.err('RULE_VIOLATION', p, `has ${list.length} entries; the limit is ${max}`); return false; }
82
+ return true;
83
+ }
84
+
85
+ function closedKeys(ctx, p, obj, allowed, what) {
86
+ for (const k of Object.keys(obj)) if (!allowed.includes(k)) ctx.err('UNKNOWN_FIELD', `${p}.${k}`, `not part of ${what}`);
87
+ }
88
+
89
+ function isCommitLike(v) { return typeof v === 'string' && /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(v); }
90
+
91
+ export function validateManifest(m) {
92
+ const g = guardObject(m);
93
+ const ctx = g.ctx;
94
+ if (!g.ok) return result(ctx);
95
+ if (!checkHeader(ctx, m, MANIFEST_SCHEMA)) return result(ctx);
96
+ checkFields(ctx, m, ALLOWED, REQUIRED);
97
+ if (m.synthetic !== undefined && m.synthetic !== true) ctx.err('BAD_TYPE', 'synthetic', 'must be true when present');
98
+
99
+ // subject
100
+ const s = m.subject;
101
+ const repoCommits = new Map();
102
+ if (!isPlainObject(s)) ctx.err('BAD_TYPE', 'subject', 'must be an object');
103
+ else {
104
+ closedKeys(ctx, 'subject', s, ['repository', 'commit', 'bundleDigest', 'policyDigest'], 'subject');
105
+ checkString(ctx, 'subject.repository', s.repository);
106
+ checkCommit(ctx, 'subject.commit', s.commit ?? null, { nullable: false });
107
+ checkDigest(ctx, 'subject.bundleDigest', s.bundleDigest ?? null);
108
+ checkDigest(ctx, 'subject.policyDigest', s.policyDigest ?? null);
109
+ if (typeof s.repository === 'string') repoCommits.set(s.repository, s.commit);
110
+ }
111
+
112
+ // dependencies
113
+ if (checkList(ctx, 'dependencies', m.dependencies, MANIFEST_LIMITS.maxDependencies)) {
114
+ m.dependencies.forEach((d, i) => {
115
+ const p = `dependencies[${i}]`;
116
+ if (!isPlainObject(d)) { ctx.err('BAD_TYPE', p, 'must be an object'); return; }
117
+ closedKeys(ctx, p, d, ['name', 'revision'], 'a dependency');
118
+ if (checkString(ctx, `${p}.name`, d.name)) {
119
+ if (repoCommits.has(d.name)) ctx.err('DUPLICATE_ID', `${p}.name`, `dependency '${d.name}' is declared twice or collides with the subject repository`);
120
+ else repoCommits.set(d.name, d.revision);
121
+ }
122
+ checkString(ctx, `${p}.revision`, d.revision);
123
+ });
124
+ }
125
+
126
+ // artifacts
127
+ if (checkList(ctx, 'artifacts', m.artifacts, MANIFEST_LIMITS.maxArtifacts)) {
128
+ if (m.artifacts.length === 0) ctx.err('RULE_VIOLATION', 'artifacts', 'a release manifest must bind at least one build artifact digest');
129
+ m.artifacts.forEach((a, i) => {
130
+ const p = `artifacts[${i}]`;
131
+ if (!isPlainObject(a)) { ctx.err('BAD_TYPE', p, 'must be an object'); return; }
132
+ closedKeys(ctx, p, a, ['name', 'digest'], 'an artifact');
133
+ checkString(ctx, `${p}.name`, a.name);
134
+ checkDigest(ctx, `${p}.digest`, a.digest);
135
+ });
136
+ }
137
+
138
+ // scope
139
+ const mandatory = new Set();
140
+ if (!isPlainObject(m.scope)) ctx.err('BAD_TYPE', 'scope', 'must be an object');
141
+ else {
142
+ closedKeys(ctx, 'scope', m.scope, ['description', 'mandatory'], 'scope');
143
+ checkString(ctx, 'scope.description', m.scope.description);
144
+ if (checkList(ctx, 'scope.mandatory', m.scope.mandatory, MANIFEST_LIMITS.maxChecks)) {
145
+ if (m.scope.mandatory.length === 0) ctx.err('RULE_VIOLATION', 'scope.mandatory', 'a manifest must name at least one mandatory check');
146
+ m.scope.mandatory.forEach((id, i) => {
147
+ if (!checkString(ctx, `scope.mandatory[${i}]`, id)) return;
148
+ if (mandatory.has(id)) ctx.err('DUPLICATE_ID', `scope.mandatory[${i}]`, `duplicate mandatory check '${id}'`);
149
+ mandatory.add(id);
150
+ });
151
+ }
152
+ }
153
+
154
+ // graph snapshot
155
+ if (!isPlainObject(m.graphSnapshot)) ctx.err('BAD_TYPE', 'graphSnapshot', 'must be an object');
156
+ else {
157
+ closedKeys(ctx, 'graphSnapshot', m.graphSnapshot, ['digest'], 'graphSnapshot');
158
+ checkDigest(ctx, 'graphSnapshot.digest', m.graphSnapshot.digest ?? null);
159
+ }
160
+
161
+ if (checkList(ctx, 'invariantVersions', m.invariantVersions, MANIFEST_LIMITS.maxInvariants)) {
162
+ m.invariantVersions.forEach((v, i) => {
163
+ const p = `invariantVersions[${i}]`;
164
+ if (!isPlainObject(v)) { ctx.err('BAD_TYPE', p, 'must be an object'); return; }
165
+ closedKeys(ctx, p, v, ['id', 'version'], 'an invariant version');
166
+ checkString(ctx, `${p}.id`, v.id); checkString(ctx, `${p}.version`, v.version);
167
+ });
168
+ }
169
+
170
+ // receipts: each must be about a declared repository and carry THAT repository's commit
171
+ const receipts = new Map();
172
+ if (checkList(ctx, 'verificationReceipts', m.verificationReceipts, MANIFEST_LIMITS.maxReceipts)) {
173
+ m.verificationReceipts.forEach((r, i) => {
174
+ const p = `verificationReceipts[${i}]`;
175
+ if (!isPlainObject(r)) { ctx.err('BAD_TYPE', p, 'must be an object'); return; }
176
+ closedKeys(ctx, p, r, ['id', 'digest', 'repository', 'commit'], 'a receipt');
177
+ if (checkString(ctx, `${p}.id`, r.id)) {
178
+ if (receipts.has(r.id)) ctx.err('DUPLICATE_ID', `${p}.id`, `duplicate receipt '${r.id}'`);
179
+ receipts.set(r.id, r);
180
+ }
181
+ checkDigest(ctx, `${p}.digest`, r.digest);
182
+ checkCommit(ctx, `${p}.commit`, r.commit ?? null, { nullable: false });
183
+ if (checkString(ctx, `${p}.repository`, r.repository)) {
184
+ if (!repoCommits.has(r.repository)) ctx.err('DANGLING_REF', `${p}.repository`, `receipt is about '${r.repository}', which is neither the subject repository nor a declared dependency`);
185
+ else if (isCommitLike(repoCommits.get(r.repository)) && r.commit !== repoCommits.get(r.repository)) {
186
+ ctx.err('RULE_VIOLATION', `${p}.commit`, `commit mismatch: receipt commit ${String(r.commit).slice(0, 12)} is not the bound commit ${String(repoCommits.get(r.repository)).slice(0, 12)} of '${r.repository}'`);
187
+ }
188
+ }
189
+ });
190
+ }
191
+
192
+ // checks
193
+ const seenIds = new Map();
194
+ if (!isPlainObject(m.checks)) ctx.err('BAD_TYPE', 'checks', 'must be an object');
195
+ else {
196
+ closedKeys(ctx, 'checks', m.checks, CHECK_GROUPS, 'a check group');
197
+ for (const grp of CHECK_GROUPS) {
198
+ if (!checkList(ctx, `checks.${grp}`, m.checks[grp], MANIFEST_LIMITS.maxChecks)) continue;
199
+ m.checks[grp].forEach((c, i) => {
200
+ const p = `checks.${grp}[${i}]`;
201
+ if (!isPlainObject(c)) { ctx.err('BAD_TYPE', p, 'must be an object'); return; }
202
+ closedKeys(ctx, p, c, ['id', 'statement', 'evidenceRefs', 'gaps', 'reason', 'approvedBy'], 'a check');
203
+ if (checkString(ctx, `${p}.id`, c.id)) {
204
+ if (seenIds.has(c.id)) ctx.err('DUPLICATE_ID', `${p}.id`, `check '${c.id}' is already listed under ${seenIds.get(c.id)}`);
205
+ else seenIds.set(c.id, grp);
206
+ if (mandatory.size && !mandatory.has(c.id)) ctx.err('RULE_VIOLATION', `${p}.id`, `check '${c.id}' is not part of the declared mandatory scope`);
207
+ }
208
+ checkString(ctx, `${p}.statement`, c.statement);
209
+ const refs = c.evidenceRefs;
210
+ if (!Array.isArray(refs) || !refs.every((x) => typeof x === 'string')) ctx.err('BAD_TYPE', `${p}.evidenceRefs`, 'must be an array of receipt ids');
211
+ else {
212
+ if (grp === 'completed' && refs.length === 0) ctx.err('RULE_VIOLATION', `${p}.evidenceRefs`, 'a completed check must cite evidence');
213
+ refs.forEach((ref, j) => { if (!receipts.has(ref)) ctx.err('DANGLING_REF', `${p}.evidenceRefs[${j}]`, `unbound evidence: '${ref}' is not a declared verification receipt`); });
214
+ }
215
+ const gapsOk = Array.isArray(c.gaps) && c.gaps.every((x) => typeof x === 'string' && x.trim());
216
+ if (grp === 'incomplete' || grp === 'unsupported') {
217
+ if (!gapsOk || c.gaps.length === 0) ctx.err('RULE_VIOLATION', `${p}.gaps`, `a ${grp} check must say what is missing`);
218
+ } else if (c.gaps !== undefined && (!gapsOk || c.gaps.length > 0)) ctx.err('RULE_VIOLATION', `${p}.gaps`, `a ${grp} check cannot list gaps`);
219
+ if (grp === 'waived') { checkString(ctx, `${p}.reason`, c.reason); checkString(ctx, `${p}.approvedBy`, c.approvedBy); }
220
+ });
221
+ }
222
+ for (const id of mandatory) if (!seenIds.has(id)) ctx.err('RULE_VIOLATION', 'checks', `omitted mandatory scope: '${id}' is not completed, incomplete, unsupported or waived`);
223
+ }
224
+
225
+ // policy
226
+ if (!isPlainObject(m.policy)) ctx.err('BAD_TYPE', 'policy', 'must be an object');
227
+ else {
228
+ closedKeys(ctx, 'policy', m.policy, ['id', 'digest', 'blockingSeverity'], 'policy');
229
+ checkString(ctx, 'policy.id', m.policy.id);
230
+ checkDigest(ctx, 'policy.digest', m.policy.digest);
231
+ checkEnum(ctx, 'policy.blockingSeverity', m.policy.blockingSeverity, BLOCKING_SEVERITIES);
232
+ if (isPlainObject(s) && isDigest(m.policy.digest) && m.policy.digest !== s.policyDigest) ctx.err('RULE_VIOLATION', 'policy.digest', 'policy digest does not match subject.policyDigest');
233
+ }
234
+
235
+ // findings summary
236
+ if (!isPlainObject(m.findings)) ctx.err('BAD_TYPE', 'findings', 'must be an object');
237
+ else {
238
+ closedKeys(ctx, 'findings', m.findings, ['total', 'blocking'], 'findings');
239
+ for (const k of ['total', 'blocking']) if (!Number.isInteger(m.findings[k]) || m.findings[k] < 0) ctx.err('BAD_TYPE', `findings.${k}`, 'must be a non-negative integer');
240
+ if (Number.isInteger(m.findings.total) && Number.isInteger(m.findings.blocking) && m.findings.blocking > m.findings.total) ctx.err('RULE_VIOLATION', 'findings.blocking', 'blocking cannot exceed total');
241
+ }
242
+
243
+ // residual risks
244
+ if (checkList(ctx, 'residualRisks', m.residualRisks, MANIFEST_LIMITS.maxRisks)) {
245
+ m.residualRisks.forEach((r, i) => {
246
+ const p = `residualRisks[${i}]`;
247
+ if (!isPlainObject(r)) { ctx.err('BAD_TYPE', p, 'must be an object'); return; }
248
+ closedKeys(ctx, p, r, ['id', 'statement', 'severity'], 'a residual risk');
249
+ checkString(ctx, `${p}.id`, r.id); checkString(ctx, `${p}.statement`, r.statement);
250
+ checkEnum(ctx, `${p}.severity`, r.severity, [...BLOCKING_SEVERITIES, 'info']);
251
+ });
252
+ }
253
+
254
+ // coverage and completeness must agree with the lists
255
+ if (isPlainObject(m.checks) && isPlainObject(m.scope)) {
256
+ const want = deriveCoverage(m.checks, m.scope);
257
+ if (!isPlainObject(m.coverage) || digestOf(m.coverage) !== digestOf(want)) ctx.err('RULE_VIOLATION', 'coverage', `coverage disagrees with the check lists (expected ${JSON.stringify(want)})`);
258
+ if (typeof m.complete !== 'boolean') ctx.err('BAD_TYPE', 'complete', 'must be a boolean');
259
+ else if (m.complete !== want.complete) ctx.err('RULE_VIOLATION', 'complete', `claimed complete=${m.complete} but the check lists support complete=${want.complete}`);
260
+ }
261
+ checkId(ctx, m, manifestId(m));
262
+ return result(ctx);
263
+ }
@@ -0,0 +1,267 @@
1
+ // Coverage-aware portfolio progress and review views (X-707).
2
+ //
3
+ // ONE projection for every surface, in the pattern of lineage/deployment/projection.js: the CLI (`agentic-security portfolio progress`),
4
+ // the MCP tool (`portfolio_progress`) and the fleet summary all call `buildProgressView`, so none formats its own progress text and the
5
+ // surfaces cannot disagree. It is a VIEW: it reads a verified store and ledger and invents nothing.
6
+ //
7
+ // What it keeps apart (X-707.AC01), because adding them together is how a portfolio looks finished when it is not:
8
+ // verified units whose result is current and complete. The only count that is progress.
9
+ // problems blocked, failed, canceled and stale units, each listed with its reason. A stale unit is one whose earlier result was
10
+ // invalidated by a changed input; it is pending again and its old result does not count.
11
+ // coverage per repository, how many of its units are verified, and whether the repository is fully verified, partial or not started.
12
+ // budget per limit: the limit (or `null` when none is enforced), what is used, what in-flight attempts hold, what remains.
13
+ // review what a human still has to look at: blocked units, contracts awaiting approval, boundaries that could not be resolved.
14
+ //
15
+ // Aggregate findings (X-707.AC02) deduplicate by STABLE IDENTITY only. Two findings with the same `stableId` are one aggregate finding
16
+ // with every occurrence kept (repository, environment, release, commit, file, line, severity). The affected releases are listed
17
+ // separately, per finding and per release. A finding with no stable id is never merged by guesswork: it stays its own row, listed apart.
18
+ //
19
+ // Liveness (X-707.AC03). A worker's last sign of life is the newest of its heartbeat and its lease events. Beyond the declared
20
+ // `HEARTBEAT.staleAfterMs` it is `stale` and stays in the view with its age: a stale worker is never dropped from the report to make it
21
+ // look healthier, and its output is irrelevant (nothing here reads stdout). A controller that has finished is reported as FINISHED, which
22
+ // is not the same as every unit verified, which is not the same as every repository passing; the view says each separately and never
23
+ // states that the portfolio passed.
24
+ //
25
+ // Strings from the store (failure reasons) are checked for secret shapes before they are shown. Pure given its inputs: no clock (`now`
26
+ // is an argument), no randomness, fixed key order.
27
+
28
+ import { featureStatus } from '../assurance/config.js';
29
+ import { findSecret } from './bundle.js';
30
+ import { NOT_A_GUARANTEE, FEATURE_ID } from './wording.js';
31
+ import { DIMENSIONS, HEARTBEAT } from './scheduler.js';
32
+ import { UNIT_STATES } from './work-units.js';
33
+
34
+ export const PROGRESS_SCHEMA = 'agentic-security/portfolio-progress';
35
+ export const PROGRESS_VERSION = '1.0.0';
36
+ export const FINDINGS_SCHEMA = 'agentic-security/portfolio-findings';
37
+ const SEVERITY_RANK = Object.freeze({ info: 0, low: 1, medium: 2, high: 3, critical: 4 });
38
+ const MAX_STRING = 300;
39
+ const MAX_LIST = 200;
40
+
41
+ const safe = (s) => {
42
+ const t = String(s ?? '').slice(0, MAX_STRING);
43
+ return findSecret(t) ? '[withheld: secret-shaped text]' : t;
44
+ };
45
+ const byKey = (k) => (a, b) => (a[k] < b[k] ? -1 : a[k] > b[k] ? 1 : 0);
46
+ const cap = (list) => ({ items: list.slice(0, MAX_LIST), truncated: list.length > MAX_LIST, total: list.length });
47
+
48
+ // ---------------------------------------------------------------- aggregate findings (X-707.AC02)
49
+
50
+ /**
51
+ * @param {Array<{ repository: string, environment?: string, release?: string, commit?: string, evidenceRef?: string, finding: object }>} entries
52
+ * each occurrence of a finding in one repository/environment/release
53
+ */
54
+ export function aggregateFindings(entries, { blockingSeverity = 'high' } = {}) {
55
+ const rank = (s) => SEVERITY_RANK[s] ?? -1;
56
+ const groups = new Map();
57
+ const unidentified = [];
58
+ let occurrences = 0;
59
+ for (const e of Array.isArray(entries) ? entries : []) {
60
+ const f = e?.finding;
61
+ if (!e || typeof e.repository !== 'string' || !f || typeof f !== 'object') continue;
62
+ occurrences += 1;
63
+ const occ = {
64
+ repository: e.repository, environment: e.environment ?? null, release: e.release ?? null, commit: e.commit ?? null,
65
+ file: typeof f.file === 'string' ? f.file : null, line: Number.isInteger(f.line) ? f.line : null, severity: f.severity ?? null, evidenceRef: e.evidenceRef ?? null,
66
+ };
67
+ if (typeof f.stableId !== 'string' || !f.stableId) { unidentified.push({ ...occ, vuln: safe(f.vuln), cwe: f.cwe ?? null, note: 'no stable id: not merged with any other finding' }); continue; }
68
+ if (!groups.has(f.stableId)) groups.set(f.stableId, { identity: f.stableId, vuln: safe(f.vuln), cwe: f.cwe ?? null, family: f.family ?? null, occurrences: [] });
69
+ groups.get(f.stableId).occurrences.push(occ);
70
+ }
71
+ const items = [...groups.values()].sort(byKey('identity')).map((g) => {
72
+ g.occurrences.sort((a, b) => `${a.repository}|${a.environment}|${a.release}|${a.file}|${a.line}`.localeCompare(`${b.repository}|${b.environment}|${b.release}|${b.file}|${b.line}`));
73
+ const sev = g.occurrences.reduce((m, o) => (rank(o.severity) > rank(m) ? o.severity : m), null);
74
+ const uniq = (k) => [...new Set(g.occurrences.map((o) => o[k]).filter((v) => v !== null))].sort();
75
+ return { ...g, severity: sev, severities: uniq('severity'), repositories: uniq('repository'), environments: uniq('environment'), affectedReleases: uniq('release'), occurrenceCount: g.occurrences.length };
76
+ });
77
+ const byRelease = {};
78
+ for (const it of items) for (const r of it.affectedReleases) (byRelease[r] ??= []).push(it.identity);
79
+ const bySeverity = {};
80
+ for (const it of items) bySeverity[it.severity ?? 'unknown'] = (bySeverity[it.severity ?? 'unknown'] ?? 0) + 1;
81
+ const blockingRepos = new Set();
82
+ for (const it of items) for (const o of it.occurrences) if (rank(o.severity) >= rank(blockingSeverity)) blockingRepos.add(o.repository);
83
+ for (const o of unidentified) if (rank(o.severity) >= rank(blockingSeverity)) blockingRepos.add(o.repository);
84
+ return {
85
+ schema: FINDINGS_SCHEMA, schemaVersion: PROGRESS_VERSION, occurrences, unique: items.length, blockingSeverity,
86
+ items, unidentified: unidentified.sort((a, b) => `${a.repository}|${a.file}|${a.line}`.localeCompare(`${b.repository}|${b.file}|${b.line}`)),
87
+ byRelease: Object.fromEntries(Object.keys(byRelease).sort().map((k) => [k, byRelease[k].sort()])),
88
+ bySeverity: Object.fromEntries(Object.keys(bySeverity).sort().map((k) => [k, bySeverity[k]])),
89
+ repositoriesWithBlockingFindings: [...blockingRepos].sort(),
90
+ };
91
+ }
92
+
93
+ // ---------------------------------------------------------------- review queue sources
94
+
95
+ /** Review items from a business coverage report (posture/invariants/coverage.js): contracts that are not approved. */
96
+ export function reviewItemsFromInvariantCoverage(coverage) {
97
+ const out = [];
98
+ for (const i of coverage?.invariants ?? []) {
99
+ if (i.state !== 'approved') out.push({ kind: 'contract-approval', id: i.id ?? i.key, repository: null, reason: `contract '${safe(i.key)}' is ${safe(i.state)}: a violation of it is advisory until a reviewer approves it`, source: 'invariant-coverage' });
100
+ }
101
+ return out;
102
+ }
103
+
104
+ /** Review items from boundary contexts (lineage/deployment/projection.js): findings whose deployment exposure is unresolved or unbound. */
105
+ export function reviewItemsFromBoundaryContexts(contexts) {
106
+ const out = [];
107
+ for (const c of Array.isArray(contexts) ? contexts : []) {
108
+ if (!c || typeof c !== 'object') continue;
109
+ const unresolved = c.exposure?.state === 'unresolved';
110
+ const unbound = c.binding && c.binding.status !== 'bound';
111
+ if (unresolved || unbound) out.push({ kind: 'boundary-resolution', id: c.id ?? null, repository: c.finding?.repository ?? null, reason: unresolved ? 'deployment reachability is unresolved: a person must decide whether the path is real' : 'the finding is not bound to a deployed service', source: 'deployment-boundaries' });
112
+ }
113
+ return out;
114
+ }
115
+
116
+ // ---------------------------------------------------------------- the view
117
+
118
+ const lastSignal = (u, ledger) => {
119
+ const attemptId = u.lease?.attemptId;
120
+ let at = 0;
121
+ for (const e of u.events) if (e.attemptId === attemptId && typeof e.at === 'number' && e.at > at) at = e.at;
122
+ const hb = attemptId ? ledger.heartbeats?.[attemptId]?.at : null;
123
+ return Math.max(at, typeof hb === 'number' ? hb : 0);
124
+ };
125
+
126
+ /**
127
+ * @param {object} p
128
+ * @param {object} p.store a verified portfolio store
129
+ * @param {object} [p.ledger] the scheduler ledger (usage, reservations, heartbeats, cancellations)
130
+ * @param {object} [p.budgets] the budgets the scheduler runs under
131
+ * @param {number} p.now milliseconds
132
+ * @param {Array} [p.reviewItems]
133
+ * @param {Array} [p.findings] occurrences for `aggregateFindings`
134
+ * @param {string} [p.blockingSeverity]
135
+ * @param {{ intervalMs: number, staleAfterMs: number }} [p.heartbeat]
136
+ */
137
+ export function buildProgressView({ store, ledger = null, budgets = null, now, reviewItems = [], findings = null, blockingSeverity = 'high', heartbeat = HEARTBEAT } = {}) {
138
+ if (!store || typeof store.units !== 'object') return { ok: false, errors: [{ code: 'NO_STORE', message: 'a verified portfolio store is required' }] };
139
+ if (!Number.isFinite(now)) return { ok: false, errors: [{ code: 'NO_NOW', message: 'now (milliseconds) is required; the view reads no clock' }] };
140
+ const led = ledger ?? { usage: { portfolio: {}, repositories: {} }, reservations: {}, heartbeats: {}, cancellations: [], overruns: [] };
141
+ const units = Object.values(store.units).sort(byKey('id'));
142
+ const ref = (u) => ({ unitId: u.id, repository: u.repository, taskType: u.taskType });
143
+
144
+ const count = Object.fromEntries(UNIT_STATES.map((s) => [s, 0]));
145
+ for (const u of units) count[u.state] += 1;
146
+ const lastEvent = (u, type) => [...u.events].reverse().find((e) => e.type === type);
147
+ const blocked = units.filter((u) => u.state === 'blocked').map((u) => ({ ...ref(u), reason: safe(u.blockedReason) }));
148
+ const failed = units.filter((u) => u.state === 'failed').map((u) => ({ ...ref(u), retryCount: u.retryCount, reason: safe(lastEvent(u, 'failed')?.reason ?? (lastEvent(u, 'expired') ? 'lease expired' : 'failed')) }));
149
+ const canceled = units.filter((u) => u.state === 'canceled').map((u) => ({ ...ref(u), reason: safe(lastEvent(u, 'canceled')?.reason) }));
150
+ const stale = units.filter((u) => u.stale.length && u.state !== 'verified').map((u) => ({ ...ref(u), state: u.state, staleResults: u.stale.length, reason: 'an earlier result was invalidated by a changed input and does not count', changedDimensions: u.stale.at(-1)?.changedDimensions ?? null }));
151
+
152
+ // workers: every lease holder, live or stale
153
+ const workers = units.filter((u) => u.state === 'leased' || u.state === 'running').map((u) => {
154
+ const last = lastSignal(u, led);
155
+ const age = Math.max(0, now - last);
156
+ return { ...ref(u), holder: u.lease.holder, attemptId: u.lease.attemptId, state: u.state, lastSignalAt: last || null, ageMs: age, status: !last ? 'no-heartbeat' : age > heartbeat.staleAfterMs ? 'stale' : 'live', leaseExpiresAt: u.lease.expiresAt };
157
+ }).sort(byKey('attemptId'));
158
+
159
+ // coverage by repository
160
+ const repos = new Map();
161
+ for (const u of units) {
162
+ if (!repos.has(u.repository)) repos.set(u.repository, { repository: u.repository, commit: u.commit, units: 0, verified: 0, problems: 0, inFlight: 0, pending: 0 });
163
+ const r = repos.get(u.repository);
164
+ r.units += 1;
165
+ if (u.state === 'verified') r.verified += 1;
166
+ else if (u.state === 'blocked' || u.state === 'failed' || u.state === 'canceled') r.problems += 1;
167
+ else if (u.state === 'leased' || u.state === 'running') r.inFlight += 1;
168
+ else r.pending += 1;
169
+ }
170
+ const coverageRepos = [...repos.values()].sort(byKey('repository')).map((r) => ({ ...r, status: r.verified === r.units ? 'fully-verified' : r.verified > 0 ? 'partial' : r.problems > 0 ? 'incomplete' : 'not-started' }));
171
+ const fully = coverageRepos.filter((r) => r.status === 'fully-verified').length;
172
+
173
+ // budget
174
+ const reserved = { portfolio: {}, repositories: {} };
175
+ for (const r of Object.values(led.reservations ?? {})) {
176
+ reserved.portfolio.count = (reserved.portfolio.count ?? 0) + 1;
177
+ for (const d of DIMENSIONS) reserved.portfolio[d] = (reserved.portfolio[d] ?? 0) + r[d];
178
+ reserved.repositories[r.repository] ??= { count: 0 };
179
+ reserved.repositories[r.repository].count += 1;
180
+ for (const d of DIMENSIONS) reserved.repositories[r.repository][d] = (reserved.repositories[r.repository][d] ?? 0) + r[d];
181
+ }
182
+ const dimView = (limits, used, held) => {
183
+ const o = {};
184
+ for (const d of [...DIMENSIONS, 'concurrency']) {
185
+ const limit = Number.isFinite(limits?.[d]) ? limits[d] : null;
186
+ const u = d === 'concurrency' ? (held?.count ?? 0) : (used?.[d] ?? 0);
187
+ const h = d === 'concurrency' ? 0 : (held?.[d] ?? 0);
188
+ o[d] = { limit, used: u, reserved: h, remaining: limit === null ? null : Math.max(0, limit - u - h), enforced: limit !== null };
189
+ }
190
+ return o;
191
+ };
192
+ const repoLimits = (name) => ({ ...(budgets?.repositories?.default ?? {}), ...(budgets?.repositories?.[name] ?? {}) });
193
+ const budget = {
194
+ declared: !!budgets,
195
+ portfolio: dimView(budgets?.portfolio, led.usage?.portfolio, reserved.portfolio),
196
+ repositories: Object.fromEntries(coverageRepos.map((r) => [r.repository, dimView(repoLimits(r.repository), led.usage?.repositories?.[r.repository], reserved.repositories[r.repository])])),
197
+ overruns: (led.overruns ?? []).length,
198
+ };
199
+
200
+ // review queue
201
+ const review = [
202
+ ...blocked.map((b) => ({ kind: 'blocked-unit', id: b.unitId, repository: b.repository, reason: b.reason, source: 'portfolio' })),
203
+ ...(Array.isArray(reviewItems) ? reviewItems : []).map((i) => ({ kind: String(i.kind ?? 'review'), id: i.id ?? null, repository: i.repository ?? null, reason: safe(i.reason), source: String(i.source ?? 'supplied') })),
204
+ ];
205
+ const reviewByKind = {};
206
+ for (const r of review) reviewByKind[r.kind] = (reviewByKind[r.kind] ?? 0) + 1;
207
+
208
+ const aggregate = Array.isArray(findings) ? aggregateFindings(findings, { blockingSeverity }) : null;
209
+ const inFlight = count.leased + count.running;
210
+ const total = units.length;
211
+ const controllerFinished = inFlight === 0 && count.pending === 0;
212
+ const allVerified = total > 0 && count.verified === total;
213
+ const nonVerified = total - count.verified;
214
+ const completion = {
215
+ controllerFinished,
216
+ allUnitsVerified: allVerified,
217
+ repositoriesFullyVerified: { n: fully, of: coverageRepos.length },
218
+ findingsSupplied: aggregate !== null,
219
+ repositoriesWithBlockingFindings: aggregate ? aggregate.repositoriesWithBlockingFindings : null,
220
+ passAssessment: 'not-implied',
221
+ statement: controllerFinished && !allVerified
222
+ ? `The controller has finished, but ${nonVerified} of ${total} unit(s) are not verified (${count.blocked} blocked, ${count.failed} failed, ${count.canceled} canceled${count.pending ? `, ${count.pending} pending` : ''}). Finished does not mean every repository was checked, and it does not mean any repository passed.`
223
+ : controllerFinished && allVerified
224
+ ? `Every planned unit is verified (${total} of ${total}) in ${fully} of ${coverageRepos.length} repositories. That means the planned checks completed; it does not mean every repository passed${aggregate ? `: ${aggregate.repositoriesWithBlockingFindings.length} repositor${aggregate.repositoriesWithBlockingFindings.length === 1 ? 'y has' : 'ies have'} ${blockingSeverity}-or-worse findings in the supplied results` : ' (findings were not supplied to this view)'}.`
225
+ : `The controller has not finished: ${count.verified} of ${total} unit(s) verified, ${inFlight} in flight, ${count.pending} pending.`,
226
+ };
227
+
228
+ const staleWorkers = workers.filter((w) => w.status !== 'live');
229
+ const lines = [
230
+ `Portfolio progress (${store.synthetic ? 'SYNTHETIC; ' : ''}as of ${now}): ${count.verified}/${total} unit(s) verified; ${fully}/${coverageRepos.length} repositor${coverageRepos.length === 1 ? 'y' : 'ies'} fully verified`,
231
+ ` Not verified: ${count.blocked} blocked, ${count.failed} failed, ${count.canceled} canceled, ${stale.length} stale, ${count.pending} pending, ${inFlight} in flight`,
232
+ ` Workers: ${workers.length - staleWorkers.length} live, ${staleWorkers.length} stale or silent (stale after ${heartbeat.staleAfterMs} ms without a heartbeat)`,
233
+ ` Budget: ${budgets ? DIMENSIONS.filter((d) => budget.portfolio[d].enforced).map((d) => `${d} ${budget.portfolio[d].remaining} left`).join(', ') || 'no portfolio limit enforced' : 'not declared to this view'}`,
234
+ ` Pending human review: ${review.length}${review.length ? ` (${Object.keys(reviewByKind).sort().map((k) => `${k}: ${reviewByKind[k]}`).join(', ')})` : ''}`,
235
+ ` ${completion.statement}`,
236
+ ` ${NOT_A_GUARANTEE}`,
237
+ ];
238
+ return {
239
+ ok: true,
240
+ view: {
241
+ schema: PROGRESS_SCHEMA, schemaVersion: PROGRESS_VERSION, ...(store.synthetic ? { synthetic: true } : {}), planId: store.planId, asOf: now,
242
+ heartbeat: { intervalMs: heartbeat.intervalMs, staleAfterMs: heartbeat.staleAfterMs },
243
+ units: { total, verified: count.verified, pending: count.pending, inFlight, blocked: cap(blocked), failed: cap(failed), canceled: cap(canceled), stale: cap(stale), counts: count },
244
+ workers: { items: workers, stale: staleWorkers.length, live: workers.length - staleWorkers.length },
245
+ coverage: { repositories: coverageRepos, fullyVerified: fully, total: coverageRepos.length },
246
+ budget,
247
+ review: { pending: review.length, byKind: Object.fromEntries(Object.keys(reviewByKind).sort().map((k) => [k, reviewByKind[k]])), ...cap(review) },
248
+ cancellations: (led.cancellations ?? []).map((c) => ({ scope: c.scope, reason: safe(c.reason), at: c.at })),
249
+ findings: aggregate,
250
+ completion,
251
+ lines,
252
+ },
253
+ };
254
+ }
255
+
256
+ /** The additive field a surface asks for: `{ portfolioProgress }`, or `{}` when the feature is off, so flag-off output is unchanged. */
257
+ export function portfolioProgressFields({ config, ...input } = {}) {
258
+ if (!config || featureStatus(config, FEATURE_ID).status !== 'ok') return {};
259
+ const r = buildProgressView(input);
260
+ return r.ok ? { portfolioProgress: r.view } : { portfolioProgressErrors: r.errors };
261
+ }
262
+
263
+ /** Attach the view to a fleet rollup. Flag off: the SAME object comes back untouched. */
264
+ export function attachPortfolioProgress(rollup, input) {
265
+ const extra = portfolioProgressFields(input);
266
+ return Object.keys(extra).length ? { ...rollup, ...extra } : rollup;
267
+ }