echelongraph-mcp 3.0.2 → 3.1.1

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.
@@ -0,0 +1,38 @@
1
+ // #3345 (CVE-2023-54404, GHSA-mrhx-c7xc-m3w5): every list and every record in a tool's
2
+ // inputSchema has an upper bound, checked before any element of it is parsed.
3
+ //
4
+ // zod through 4.6.5 parsed every element of a container before checking its size, so a huge list
5
+ // or object cost its full parse whatever its bound. 4.6.6 checks a leading .max(), .length() or
6
+ // .size() of a z.array, z.set or z.map first, and refuses an oversized one before parsing any
7
+ // element (core/schemas.ts capSize; only the checks before any other check, so .max() comes first
8
+ // in each chain). A container without a bound is still parsed whole, and z.record has no size
9
+ // check at all. So each list here is .max()'d first, and a record is boundedRecord(): an identity
10
+ // transform whose check counts the object's own members and refuses past `max` before the record
11
+ // parses a key or value, piped into the record, the way zod's own fromJSONSchema enforces
12
+ // maxProperties (classic/from-json-schema.ts checkObjectGuards). tools/list publishes maxItems and
13
+ // maxProperties; test/input-bounds.test.mjs walks every tool's inputSchema and fails on a list or
14
+ // record without one.
15
+ //
16
+ // The SDK's element limit (index.ts MAX_TOOL_INPUT_ELEMENTS, CVE-2026-111768) bounds a call's
17
+ // arguments as a whole; these bound each container to what its tool documents.
18
+ import * as z from "zod";
19
+ /**
20
+ * A z.record(z.string(), value) of at most `max` own members, refused past `max` before any key or
21
+ * value is parsed (as a too_big issue of origin "object"), and published with maxProperties.
22
+ */
23
+ export function boundedRecord(value, max) {
24
+ const guard = z.transform((v) => v).check((payload) => {
25
+ const v = payload.value;
26
+ if (typeof v !== "object" || v === null || Array.isArray(v))
27
+ return;
28
+ // Counted up to max + 1 only: an oversized object costs no more than that.
29
+ let n = 0;
30
+ for (const k in v)
31
+ if (Object.prototype.hasOwnProperty.call(v, k) && ++n > max)
32
+ break;
33
+ if (n > max) {
34
+ payload.issues.push({ origin: "object", code: "too_big", maximum: max, inclusive: true, message: `Too big: expected object to have <=${max} properties`, input: v, continue: false });
35
+ }
36
+ });
37
+ return guard.pipe(z.record(z.string(), value)).meta({ maxProperties: max });
38
+ }
@@ -0,0 +1,86 @@
1
+ // Every line the hosted endpoint (http.ts) writes to stdout, and exactly the keys each carries besides `severity`
2
+ // and `message` (#2863). /privacy Section 11 and the README's "Privacy: what is sent where" say what the hosted
3
+ // endpoint logs, and through 2.7 only a marketing-site test (privacyMcpEndpoint.test.ts) held http.ts to them: a
4
+ // client address and the whole User-Agent added to mcp_request passed this package's tests, its npm publish and
5
+ // the mcp-remote deploy.
6
+ //
7
+ // This list is the one place a line or a key is added:
8
+ // * http.ts's log() is typed against it, so a log call whose message is not here, or whose fields are not
9
+ // exactly its keys, does not compile (npm test builds first);
10
+ // * test/http.test.mjs requires every stdout line its servers write to carry exactly these keys, every line
11
+ // here to be written by them (or to be one of the two it names that no request can make), no other writer of
12
+ // stdout or stderr under dist/, and the README to name every key of the access line.
13
+ // A key added here is a change to what EchelonGraph logs about a request: say it in /privacy Section 11 and in the
14
+ // README first (marketing-site/lib/privacyMcpEndpoint.test.ts's ACCESS_FIELDS / OTHER_LINES review the same calls).
15
+ //
16
+ // `optional` keys may be absent from a line: tool and prompt come only with tools/call and prompts/get (BodyFacts),
17
+ // and a handler error's error_code only when the error carries a JSON-RPC code (JSON.stringify drops undefined).
18
+ export const LOG_LINES = {
19
+ // The access line: one per request (http.ts finish()).
20
+ mcp_request: {
21
+ keys: [
22
+ // The HTTP method, one of httpPolicy.ts HTTP_METHODS (GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS, CONNECT,
23
+ // TRACE), else "(other)": over h2c the client's own :method token, never logged (3.1.0's final review).
24
+ "method",
25
+ "path",
26
+ "status",
27
+ "duration_ms",
28
+ // A method MCP defines for a client (httpPolicy.ts MCP_METHODS), else "(other)"; "(none)", "(unparseable)",
29
+ // "(batch)" or "(response)" for a body with no one method. Never the client's own string (#2923's review).
30
+ "rpc_method",
31
+ "protocol_version",
32
+ "origin_present",
33
+ "body_bytes",
34
+ "throttled",
35
+ "refused",
36
+ "ua_family",
37
+ "client_public",
38
+ "client_closed",
39
+ "in_flight",
40
+ "queued_ms",
41
+ // #2775: how long a large body waited for a large-body slot (0 when it found one free, or needed none).
42
+ "slot_wait_ms",
43
+ ],
44
+ optional: ["tool", "prompt"],
45
+ },
46
+ // Start-up, before any request.
47
+ mcp_remote_refusing_to_start: { keys: ["reason"] },
48
+ mcp_remote_heap_unbounded: { keys: ["heap_limit_mb", "memory_limit_mb", "reason"] },
49
+ mcp_remote_listening: {
50
+ keys: [
51
+ "port",
52
+ "protocol",
53
+ "name",
54
+ "version",
55
+ "api_base",
56
+ "api_timeout_ms",
57
+ "forward_token_configured",
58
+ "rate_limit_per_min",
59
+ "max_body_bytes",
60
+ "max_sbom_body_bytes",
61
+ "large_body_slots",
62
+ "large_body_wait_ms",
63
+ "max_in_flight",
64
+ "admission_wait_ms",
65
+ "heap_limit_mb",
66
+ "memory_limit_mb",
67
+ ],
68
+ },
69
+ // #2923: a fixed-vocabulary class and a ProtocolError's numeric code; never the SDK's message. And the request it
70
+ // arose in, by its fixed labels (runtime.ts requestLabels): its JSON-RPC method (httpPolicy.ts MCP_METHODS, else
71
+ // "(other)"), its protocol version.
72
+ mcp_handler_error: { keys: ["error_name", "error_class", "rpc_method", "protocol_version"], optional: ["error_code"] },
73
+ mcp_request_failed: { keys: ["error_name"] },
74
+ mcp_large_body_busy: { keys: ["slots"] },
75
+ mcp_busy: { keys: ["max_in_flight", "waiting"] },
76
+ // The one line that names a client: its rate-limit key (an IPv4 address, or an IPv6 /64), which the README and
77
+ // /privacy Section 11 both say.
78
+ mcp_rate_limited: { keys: ["client", "limit"] },
79
+ // #2775: when the server learned its client went, whether the answer had started, an h2 stream's reset code (one
80
+ // RFC 9113 defines, else "(other)": httpPolicy.ts rstCodeLabel; null over HTTP/1.1).
81
+ mcp_client_gone: { keys: ["after_ms", "answer_started", "rst_code"] },
82
+ // #2948: one per failed API call: the class, the API route as a template (never the path or query), the status,
83
+ // how long the call took, the request's shape-checked tool and its JSON-RPC method (MCP_METHODS or "(other)"), and
84
+ // whether it was the synthetic's.
85
+ mcp_upstream_failed: { keys: ["tool", "rpc_method", "route", "failure_class", "status", "after_ms", "synthetic"] },
86
+ };
package/dist/prompts.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import * as z from "zod";
2
- import { MAX_COMPONENTS, MAX_PURLS, MAX_SBOM_CHARS } from "./tools/check_sbom.js";
2
+ import { MAX_COMPONENTS, MAX_PURL_ENTRIES, MAX_PURLS, MAX_SBOM_CHARS, RECHECK_CALLS } from "./tools/check_sbom.js";
3
3
  export const KEV_BRIEF_DEFAULT_DAYS = 7;
4
4
  export const KEV_BRIEF_MAX_DAYS = 365;
5
5
  // The page the brief reads kev_recent in (#2799). The brief puts kev_vuln_name and kev_due_date on
@@ -41,20 +41,31 @@ const text = (t) => ({ messages: [{ role: "user", content: { type: "text", text:
41
41
  // cve/fixed_branches.go), so the line gives those, range by range, and gives fixed_version only
42
42
  // where a row has no fixed_branches, as what cve_intel records for the package, never as the fix
43
43
  // for every affected branch, sending the reader to get_cve's references tagged Vendor Advisory or
44
- // Patch for a branch's fix.
44
+ // Patch for a branch's fix. #3285 (3.1.0's lane P): cve_intel lists a package once per stored
45
+ // ecosystem string, so CVE-2025-48013 has drupal/quick_node_block under
46
+ // Packagist:https://packages.drupal.org/8 ([0, 2.0.1)) and Packagist ([0, 2.0.0)), and a line
47
+ // giving each row's ranges apart called 2.0.0 the Packagist fix, which the Drupal row still holds
48
+ // affected. The line now reads such rows as one package, as the matcher does (core-backend
49
+ // registryRecordKey, src/tools/packageRecords.ts) and as cve_intel's note says, and never a
50
+ // release suffix so.
45
51
  //
46
52
  // Its remediation (#2840, #2841): cve_remediation gives, for one CVE, each vendor advisory's
47
53
  // remediation as the vendor categorises it (remediation_kinds, remediation_state) and the record's
48
54
  // references tagged Patch or Mitigation, each text relayed as its source states it. The Patch line
49
55
  // gives them as the vendor's, untested, and a not_parsed or none_in_source state as no finding that
50
56
  // the vendor lists no fix. vendor_advisories_for_cve rows carry remediation_kinds too, so step 4
51
- // sends the model to get_vendor_advisory where a row lists a workaround or a mitigation.
57
+ // sends the model to get_vendor_advisory where a row lists a workaround or a mitigation. #3011: it
58
+ // names the field to pass, the row's vendor_advisory_id. It named none, and a row's advisory_id is
59
+ // EchelonGraph's internal UUID, which get_vendor_advisory does not take (the API answered 404).
52
60
  //
53
61
  // Its exploit lines (#3039): cve_intel's exploits hold Nuclei detection templates beside exploit
54
62
  // code, and a template checks whether a system is affected; it is not necessarily exploit code
55
63
  // (core-backend egkev.ExploitCodeKinds, #3038). Until #3039 step 2 called the whole list "public
56
64
  // exploit code" and the template asked for "public exploit code on record", so an agent reported
57
- // exploit code for the ~2,800 CVEs EchelonGraph holds only a template for.
65
+ // exploit code for the ~2,800 CVEs EchelonGraph holds only a template for. #3221: a github_checker
66
+ // row, a GitHub repository named or described as a checker or scanner (#3040), is the other
67
+ // detection kind, and the Exploited? line's "GitHub or vendor proofs of concept" read as taking
68
+ // one in; both lines now name it beside the template, in /pulse's words.
58
69
  // #3013's review: the Patch line has the model state a vendor's workaround or mitigation step,
59
70
  // whose wording is each item's text, and cve_remediation's cut can leave that text out of data
60
71
  // (its second thinning level) or shorten it in the text (its TEXT CUT); no string is shortened in
@@ -64,17 +75,17 @@ export function triageCveText(cve) {
64
75
  return [
65
76
  `Triage ${cve} using the EchelonGraph MCP tools. Call them in this order, each with cve_id ${cve}:`,
66
77
  "1. get_cve: the record. Read score_assessed before echelongraph_score (false: not yet scored, which is not a score of 0); read severity, cvss_v3_score, epss_score and epss_percentile, and the CISA-KEV fields kev_listed, kev_added_date, kev_due_date and kev_ransomware, and, where the record carries it, kev_required_action, CISA's own text.",
67
- "2. cve_intel: weaknesses (cwes), public exploit code and detection templates (exploits, exploits_total and exploits_by_kind; a nuclei_template row is a Nuclei detection template, which checks whether a system is affected and is not necessarily exploit code; verified_status is the label stored with each reference, not a guarantee that it works, and an empty list is not evidence that no exploit exists), affected_packages (each package's affected ranges with their fixes in fixed_branches) and fixed_versions.",
78
+ "2. cve_intel: weaknesses (cwes), public exploit code and detection checks (exploits, exploits_total and exploits_by_kind; a nuclei_template row is a Nuclei detection template, and a github_checker row a GitHub repository named or described as a checker or scanner: each checks whether a system is affected and is not necessarily exploit code; verified_status is the label stored with each reference, not a guarantee that it works, and an empty list is not evidence that no exploit exists), affected_packages (each package's affected ranges with their fixes in fixed_branches) and fixed_versions.",
68
79
  "3. cve_remediation: how it is fixed, as its sources state it: fixed_branches (each affected package's ranges with their fixes), vendor_remediations (each vendor advisory naming the CVE, with its vendor, vendor_advisory_id, remediation_state and remediation_kinds, each kind the vendor's own category) and fix_references (the record's references tagged Patch or Mitigation).",
69
- "4. vendor_advisories_for_cve: the vendor advisories that name it, with remediation_kinds and remediation_state where a row carries them. Call get_vendor_advisory for one whose remediation you need in full, such as one whose remediation_kinds include workaround or mitigation.",
80
+ "4. vendor_advisories_for_cve: the vendor advisories that name it, with remediation_kinds and remediation_state where a row carries them. Call get_vendor_advisory with a row's vendor and its vendor_advisory_id as advisory_id (never the row's advisory_id, EchelonGraph's internal ID) for one whose remediation you need in full, such as one whose remediation_kinds include workaround or mitigation.",
70
81
  "5. epss_history: how its EPSS score changed, one point per recorded change (series_kind change_only): before series_starts_at a missing point means not recorded, not unchanged.",
71
82
  "6. cve_exposure: internet exposure on record. exposure_state says what the count is; exposed_hosts counts distinct ip:port services, not machines; the result is not_assessed with measured_at null, so present any count as undated and on record, and exposure_state not_assessed as not assessed.",
72
83
  ENVELOPE_RULES,
73
84
  "Then answer with this template, one line each, naming the tool and the measured_at behind every line:",
74
- "- Exploited? CISA-KEV listing and its date, known ransomware use, and public exploit code on record (Metasploit modules, Exploit-DB entries, GitHub or vendor proofs of concept); give Nuclei detection templates apart, as detection checks, never as exploit code.",
85
+ "- Exploited? CISA-KEV listing and its date, known ransomware use, and public exploit code on record (Metasploit modules, Exploit-DB entries, GitHub or vendor proofs of concept); give Nuclei detection templates and GitHub checker repositories apart, as detection checks, never as exploit code.",
75
86
  "- Likelihood: EPSS score and percentile, and how it has moved.",
76
87
  "- Reachable? exposure_state and the count on record, or not assessed.",
77
- `- Patch: fixed versions, vendor remediation and fix references; where the results hold none, write "none on record", which is not a finding that no fix exists. For each affected package whose cve_intel row carries fixed_branches, give every range with its fix: from introduced ("0" is the first version) up to its fixed, or up to and including last_affected, where no fix is on record for that range; never one range's fix as the fix for every affected branch or the version every install should move to. cve_intel keeps one fixed_version per ecosystem and package, its advisory's last range's as a rule, which need not be the fix for every affected version of the package: where a row has no fixed_branches (null or absent), give fixed_version as the fixed version cve_intel records for that package, never as the fix for every affected branch, and for the fix on a given branch point to get_cve's references tagged Vendor Advisory or Patch, where it has them, and to the vendor advisories. From cve_remediation, give each vendor_remediations advisory by vendor and vendor_advisory_id with its remediation_kinds, a workaround or mitigation as the step that vendor states, which EchelonGraph has not tested and which is not a fix, and its fix_references; a remediation_state of not_parsed or none_in_source is not a finding that the vendor lists no fix. ${VENDOR_STEP_ELSEWHERE}`,
88
+ `- Patch: fixed versions, vendor remediation and fix references; where the results hold none, write "none on record", which is not a finding that no fix exists. For each affected package whose cve_intel row carries fixed_branches, give every range with its fix: from introduced ("0" is the first version) up to its fixed, or up to and including last_affected, where no fix is on record for that range; never one range's fix as the fix for every affected branch or the version every install should move to. cve_intel rows of one package_name under Packagist or Maven, bare or with a repository URL after ":" (such as Packagist and Packagist:https://packages.drupal.org/8), are one package, as cve_intel's note says: give their ranges together, and never a range's fixed that another of those ranges still holds as a fix, since that version is still affected; rows of two releases (Debian:11 and Debian:12) are two packages. cve_intel keeps one fixed_version per ecosystem and package, its advisory's last range's as a rule, which need not be the fix for every affected version of the package: where a row has no fixed_branches (null or absent), give fixed_version as the fixed version cve_intel records for that package, never as the fix for every affected branch, and for the fix on a given branch point to get_cve's references tagged Vendor Advisory or Patch, where it has them, and to the vendor advisories. From cve_remediation, give each vendor_remediations advisory by vendor and vendor_advisory_id with its remediation_kinds, a workaround or mitigation as the step that vendor states, which EchelonGraph has not tested and which is not a fix, and its fix_references; a remediation_state of not_parsed or none_in_source is not a finding that the vendor lists no fix. ${VENDOR_STEP_ELSEWHERE}`,
78
89
  "- Deadline: get_cve's kev_due_date when the CVE is KEV-listed (CISA's due date for US federal civilian agencies); otherwise write that the results give no CISA deadline. Where get_cve carries kev_required_action, quote it, attributed to CISA, as the action CISA requires of those agencies, not as EchelonGraph's advice.",
79
90
  "- Decision: patch now, schedule, or monitor, with a one-line reason drawn from the lines above.",
80
91
  ].join("\n");
@@ -138,7 +149,13 @@ export function amIAffectedText(a) {
138
149
  // walks, as core-backend's fixed_in does: while another range holds the fixed version, it moves to
139
150
  // that range's fixed, and a range on the way with no fixed ends it with none. So the version given
140
151
  // is the first one above the installed version that no range of the row holds, never simply the
141
- // fixed of the range that holds it.
152
+ // fixed of the range that holds it. #3285 (3.1.0's lane P): the row "for the component's ecosystem
153
+ // and package" was one row, while cve_intel lists a package once per stored ecosystem string:
154
+ // CVE-2025-48013's Packagist drupal/quick_node_block row ([0, 2.0.0)) gave 1.0.0 the fix 2.0.0,
155
+ // which its Packagist:https://packages.drupal.org/8 row ([0, 2.0.1)) still holds affected. For a
156
+ // Packagist or Maven component the rule now takes those rows as one, their ranges together, as
157
+ // the matcher does (core-backend registryRecordKey; packageRecords.ts) and cve_intel's note says,
158
+ // which gives 2.0.1; rows of two releases (Debian:11, Debian:12) never are.
142
159
  // A cut text keeps index, purl, verdict, not_assessed_reason and cve_ids on a row, not its
143
160
  // ecosystem, package or version, so the model reads those from the purl, as core-backend's
144
161
  // matcher does (cve/matchbatch.go purlNameVersion, shared/pkg/purlosv): step 2 lists every type
@@ -159,12 +176,18 @@ export function amIAffectedText(a) {
159
176
  // #2834 replaced with fixed_in, so the rule no longer reads it (and since #3074 a "[A, B)" names one
160
177
  // interval's B, which another can still hold). The cve_intel rule is for a component whose matches
161
178
  // the cut text leaves out (its leanest level keeps cve_ids, not matches), and for a match without
162
- // fixed_in (an API before #2834). fixed_in_reason, which says why fixed_in is null, and
163
- // fixed_in_detail (#3074), which names the overlapping intervals fixed_in moved past, are not
164
- // named yet: through 2.7.x check_sbom's cut levels kept fixed_in and neither of them; since #3013
165
- // they keep fixed_in_reason too (check_sbom.ts sbomText, as the structured result's thinned rows
166
- // do), so the rule can name it, and still not fixed_in_detail. test/prompt-fields.test.mjs holds
167
- // every field a prompt names to the text its tools return.
179
+ // fixed_in (an API before #2834). fixed_in_reason says why fixed_in is null: through 2.7.x
180
+ // check_sbom's cut levels kept fixed_in alone; since #3013 every level of the text that keeps a
181
+ // match keeps fixed_in_reason too (check_sbom.ts sbomText), so a null fixed_in is written with its
182
+ // fixed_in_reason, relayed as given and never read for a version (#2834's last review: the rule
183
+ // named no reason). The structured result's compact level (#3269, check_sbom.ts SBOM_ROW_COMPACT)
184
+ // keeps fixed_in without fixed_in_reason: kept, the reason cost the 201-purl probe a row and the
185
+ // image mixes affected rows at their floor (test/check_sbom-large-rows.test.mjs). So the clause
186
+ // applies where a match carries fixed_in_reason, and a null fixed_in without it is read as a
187
+ // reason the cut left out, never as no fix: the agent checks the purl again with full: true, or
188
+ // reads the CVE's fix from cve_remediation or get_cve. fixed_in_detail (#3074), which names the
189
+ // overlapping intervals fixed_in moved past, is not named: no cut level of check_sbom's text keeps
190
+ // it. test/prompt-fields.test.mjs holds every field a prompt names to the text its tools return.
168
191
  //
169
192
  // Upstream rows (#2836): a deb, apk or alpine purl with an upstream qualifier is matched by the
170
193
  // source package it names, which is how Debian and Alpine file advisories (core-backend
@@ -174,22 +197,40 @@ export function amIAffectedText(a) {
174
197
  // purl. The leanest cut leaves matched_package and matched_via out of a row, and step 2 says how to
175
198
  // read both from the purl.
176
199
  //
200
+ // The rows a DATA CUT leaves out (#3269's third review): sbom_review had no step for the affected
201
+ // and undetermined rows the structured cut leaves out of data (Claude Code reads data, not the
202
+ // text), so a cut SBOM's fix list lacked them unsaid. Step 1 now checks them again by index, as
203
+ // workload_triage's step 6 does, at most RECHECK_CALLS calls, and lists the rest as unchecked.
204
+ //
205
+ // #3269's fourth review: step 1's index rule counted the purls check_sbom reads, and said nothing of
206
+ // metadata.component, which fromCycloneDX does not walk (Trivy puts the image's pkg:oci purl there),
207
+ // so an agent counting it had every index one off; it now says the subject is not counted and has
208
+ // the agent check its mapping against the rows data keeps, which carry index and purl, before any
209
+ // re-check call. 3.1.0's phase B review: read as general, "the document's own subject is not
210
+ // counted" had an agent skip an SPDX image SBOM's root package, which fromSPDX counts (it walks
211
+ // doc.packages, every package with a purl): the clause now says an SPDX document has no
212
+ // metadata.component and its root package is counted. And past 30 distinct CVEs step 3 looked up "30 of them", any 30, so on a large SBOM
213
+ // the KEV-listed ones could be among those not looked up, while the matches already say which they
214
+ // are: it now takes those whose matches carry kev_listed true, or that matches_truncated's
215
+ // kev_listed_cve_ids names, first, then the highest epss_score, read from the matches, not a
216
+ // get_cve per CVE.
217
+ //
177
218
  // Remediation (#2841): for the first CVEs of the list, cve_remediation gives each vendor's own
178
219
  // remediation, as the vendor categorises it; the list gives it as the vendor's, untested, and reads
179
220
  // a step whose text a cut leaves out from get_vendor_advisory (VENDOR_STEP_ELSEWHERE, #3013's review).
180
221
  export function sbomReviewText(sbom) {
181
222
  return [
182
223
  "Review the SBOM below for known vulnerabilities, using the EchelonGraph MCP tools.",
183
- `1. Call check_sbom with sbom set to the document below, as given. It reads the document's purls and checks up to ${MAX_PURLS} distinct purls per call, sending them in batches of ${MAX_COMPONENTS}; a document with more is refused, not truncated. If it is refused, say why from the result and do not guess at the components. If coverage.not_sent is above 0, the API's rate limit or the call's time budget stopped it early: say how many purls were not sent and coverage.not_sent_reason, and that they are not checked (never clean). After a minute, call check_sbom once more with purls set to the purls not sent, and merge what it returns. data.not_sent_purls lists them whole with full: true; where the note says the first text block cuts not_sent_purls to its first entries, or a DATA CUT cuts that list in data, rebuild the list from the document instead, as the note says: the document's distinct purls from the position the note gives on, counted in the order it gives.`,
224
+ `1. Call check_sbom with sbom set to the document below, as given. It reads the document's purls and checks up to ${MAX_PURLS} distinct purls per call, sending them in batches of ${MAX_COMPONENTS}; a document with more is refused, not truncated. If it is refused, say why from the result and do not guess at the components. If coverage.not_sent is above 0, the API's rate limit or the call's time budget stopped it early: say how many purls were not sent and coverage.not_sent_reason, and that they are not checked (never clean). After a minute, call check_sbom once more with purls set to the purls not sent, and merge what it returns. data.not_sent_purls lists them whole with full: true; where the note says the first text block cuts not_sent_purls to its first entries, or a DATA CUT cuts that list in data, rebuild the list from the document instead, as the note says: the document's distinct purls from the position the note gives on, counted in the order it gives. Where a DATA CUT sentence names affected or undetermined rows of results left out of data, by index, those with a KEV-listed or ransomware match first and apart, call check_sbom again with purls set to the purls at those indices (each index the position, from 0, of a purl among the document's distinct purls, counted in the order check_sbom reads them: components or packages in document order, each component's nested components right after it, each purl at its first place; metadata.component, the document's own subject, is not counted, so its purl, such as an image's pkg:oci, has no index; an SPDX document has no metadata.component: each of its packages with a purl is counted, its root package among them; check your mapping against the rows data keeps, each of which carries its index and its purl, before you send these calls), in the order the note names them and in calls of the sizes its re-check sentence gives ("Re-check these K purls in this order, in calls of …"; a purl it sends to a call of its own goes alone), at most ${RECHECK_CALLS} such calls, and review their rows with the others; list the affected and undetermined rows not checked again, by index and by count, as unchecked, never as clean.`,
184
225
  "2. Read each component's ecosystem, package and version from its purl, which every row of data.results in the text carries (the leanest cut of the text leaves out the row's own ecosystem, package and version), as check_sbom maps them. A purl is pkg:type/namespace/name@version?qualifiers, each part percent-decoded (%40 is @), its type and distro qualifier in any case. The version is what follows the last @, up to any ? or #. The ecosystem follows from the type: npm is npm, pypi PyPI, maven Maven, golang Go, cargo crates.io, gem RubyGems, nuget NuGet, composer Packagist, hex Hex, pub Pub, swift SwiftURL, hackage Hackage, cran CRAN, bitnami Bitnami, conan ConanCenter and githubact GitHub Actions. For deb, apk, alpine and rpm it is the release the distro qualifier names, which must be one of these three kinds: distro=debian-N is Debian:N, the major release alone (debian-12 and debian-12.5 are Debian:12); distro=ubuntu-V is Ubuntu:V as given (ubuntu-22.04 is Ubuntu:22.04); distro=alpine-X.Y is Alpine:vX.Y, cut to major.minor (alpine-3.20 and alpine-3.20.10 are Alpine:v3.20; alpine-3 names none). For any other type, and a deb, apk, alpine or rpm purl without such a qualifier, the ecosystem is unknown: check_sbom does not check the component (not_assessed, never clean), and the fix list gives no version for it. The package is namespace:name for maven (pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1 is the package org.apache.logging.log4j:log4j-core in Maven, at version 2.14.1), namespace/name for npm, golang, composer, swift and githubact, and the name alone for every other type (pkg:deb/debian/openssl@3.0.15-1~deb12u1?distro=debian-12 is openssl in Debian:12). A deb, apk or alpine purl with an upstream qualifier (upstream=name, or upstream=name@version) is checked as the source package it names, which is how Debian and Alpine file advisories: its package is that name, and its version is the qualifier's where it gives one, else the purl's, and its row says matched_via upstream, with that name as matched_package (pkg:apk/alpine/libcrypto3@3.3.6-r0?distro=alpine-3.20&upstream=openssl is openssl in Alpine:v3.20, at 3.3.6-r0). For any other purl check_sbom checks, matched_via is purl_name and matched_package is the package read as above.",
185
- "3. For each distinct cve_id on a component whose verdict is affected, call get_cve, to read kev_listed, epss_score, score_assessed, echelongraph_score and cvss_v3_score, and cve_intel, to read fixed_branches and fixed_version from its affected_packages rows, and fixed_version from its fixed_versions rows, for the component's ecosystem and package (package_name). If there are more than 30 distinct CVEs, do this for 30 of them and list the rest as not looked up, unranked.",
226
+ "3. For each distinct cve_id on a component whose verdict is affected, call get_cve, to read kev_listed, epss_score, score_assessed, echelongraph_score and cvss_v3_score, and cve_intel, to read fixed_branches and fixed_version from its affected_packages rows, and fixed_version from its fixed_versions rows, for the component's ecosystem and package (package_name). If there are more than 30 distinct CVEs, do this for 30 of them, chosen KEV-listed first: those whose check_sbom matches carry kev_listed true, or that a row's matches_truncated.kev_listed_cve_ids names, then the others by highest epss_score, each read for this choice from the check_sbom matches where the text or data keeps them, not from a get_cve call per CVE; list the rest as not looked up, unranked.",
186
227
  ENVELOPE_RULES,
187
228
  "Read each component's verdict: not_affected is the one clean verdict; undetermined and not_assessed are not clean, and not_assessed_reason says why (for example distro_release_unknown: a deb, apk or rpm purl without a distro qualifier, which EchelonGraph does not guess).",
188
229
  "Then write a prioritised fix list of the affected components. State the ordering keys, in this order: 1. CISA-KEV listed (kev_listed true) first; 2. then EPSS score, highest first; 3. then echelongraph_score where score_assessed is true, else cvss_v3_score, highest first. A CVE with no value for a key sorts after those with one, and its line says which key was missing.",
189
- "For each line give the component (its purl, and, where its row's matched_via is upstream, its matched_package: the source package its CVEs were matched by), its cve_ids, the key values used, and for each CVE a fixed version where the results support one, by this rule. Where the text keeps the component's match for the CVE and the match carries fixed_in, read fixed_in alone: a version there is the first version above the installed one that no affected interval of the advisory holds, so give it as the fixed version; null says no fixed version is on record for the installed version's range, so give none for that CVE on that component and write \"no fix on record\", which is not a finding that no fix exists. Where the text keeps no match for the component (a cut text can keep its cve_ids alone), or the match has no fixed_in field, the rest of this rule applies. Never give a fixed version that is not strictly greater than the component's installed version (its version, as step 2 reads it from the purl), compared in the ecosystem's version order: release numbers part by part, as numbers, not as text (2.9.1 is below 2.12.2, and 2.12.2 is below 2.14.1); where you are not sure of the order (pre-releases, qualifiers and distro revisions have their own rules), say so and do not give it. Where cve_intel's affected_packages row for the component's ecosystem and package carries fixed_branches, give the fixed of the range that holds the installed version: at or above its introduced (\"0\" is the first version) and below its fixed, or at or below its last_affected, in the same version order; while another range of the row holds that fixed version, it is still affected, so move to that range's fixed; a range on the way with last_affected and no fixed, or with no end, has no fix on record, so give none, and a version in no range is given no fixed version from them; where fixed_branches is there, fixed_version is not used. Where it is not (null or absent), give cve_intel's fixed_version for the component's ecosystem and package: cve_intel keeps one fixed_version per package, its advisory's last range's, which need not be the fix on the component's branch, so say so beside it. Where no version meets the rule, write that the results give no fixed version for the component's branch, which is not a finding that no fix exists, and point to the CVE's advisory for it: get_cve's references tagged Vendor Advisory or Patch, where it has them.",
230
+ "For each line give the component (its purl, and, where its row's matched_via is upstream, its matched_package: the source package its CVEs were matched by), its cve_ids, the key values used, and for each CVE a fixed version where the results support one, by this rule. Where the text keeps the component's match for the CVE and the match carries fixed_in, read fixed_in alone: a version there is the first version above the installed one that no affected interval of the advisory holds, so give it as the fixed version; null says no fixed version is on record for the installed version's range, so give none for that CVE on that component: where the match carries fixed_in_reason, write \"no fix on record\" and that fixed_in_reason, which says why, as given (a version it names is not a fix); this is not a finding that no fix exists. A null fixed_in on a match without fixed_in_reason is one whose reason a DATA CUT left out for size (its matches keep fixed_in, not fixed_in_reason): never write \"no fix\" for it; say its reason was cut, and call check_sbom again for that purl with full: true, or read the CVE's fix from cve_remediation or get_cve. Where the text or data keeps no match for that CVE on the component (a cut can keep a row's cve_ids alone, or fewer matches than cve_ids), or the match has no fixed_in field, the rest of this rule applies. Never give a fixed version that is not strictly greater than the component's installed version (its version, as step 2 reads it from the purl), compared in the ecosystem's version order: release numbers part by part, as numbers, not as text (2.9.1 is below 2.12.2, and 2.12.2 is below 2.14.1); where you are not sure of the order (pre-releases, qualifiers and distro revisions have their own rules), say so and do not give it. Where cve_intel's affected_packages row for the component's ecosystem and package carries fixed_branches (for a Packagist or Maven component, the rows of its package_name under that ecosystem, bare and with a repository URL after \":\", such as Packagist and Packagist:https://packages.drupal.org/8, are one row, their ranges all together, as cve_intel's note says; rows of two releases, such as Debian:11 and Debian:12, never are), give the fixed of the range that holds the installed version: at or above its introduced (\"0\" is the first version) and below its fixed, or at or below its last_affected, in the same version order; while another range of the row holds that fixed version, it is still affected, so move to that range's fixed; a range on the way with last_affected and no fixed, or with no end, has no fix on record, so give none, and a version in no range is given no fixed version from them; where fixed_branches is there, fixed_version is not used. Where it is not (null or absent), give cve_intel's fixed_version for the component's ecosystem and package: cve_intel keeps one fixed_version per package, its advisory's last range's, which need not be the fix on the component's branch, so say so beside it. Where no version meets the rule, write that the results give no fixed version for the component's branch, which is not a finding that no fix exists, and point to the CVE's advisory for it: get_cve's references tagged Vendor Advisory or Patch, where it has them.",
190
231
  `Then, for the first 5 CVEs of the list (fewer if it has fewer), call cve_remediation with the CVE's cve_id, and under the CVE's line give each vendor_remediations advisory by vendor and vendor_advisory_id with its remediation_kinds, a workaround or mitigation as the step that vendor states, which EchelonGraph has not tested and which is not a fix; a remediation_state of not_parsed or none_in_source is not a finding that the vendor lists no fix. ${VENDOR_STEP_ELSEWHERE}`,
191
232
  "After the list, give data.summary's counts, and list the undetermined and not_assessed components with their reasons as not checked, never as clean.",
192
- "Where the note says that rows of data.results are left out of the first text block, list the components its rows carry, say how many affected components the text leaves out (data.summary.affected, less those listed), and give the undetermined and not_assessed components it leaves out as data.summary's counts, by not_assessed_reason, as not checked, never as clean.",
233
+ "Where the note says that rows of data.results are left out of the first text block, list the components its rows carry, say how many affected components the text leaves out (data.summary.affected, less those listed), and give the undetermined and not_assessed components it leaves out as data.summary.undetermined and data.summary.not_assessed (by not_assessed_by_reason), less those listed, as not checked, never as clean. The components with a KEV-listed or ransomware match leave the text last, and the note names by index each of them it leaves out (\"Of the rows left out of it, K have a KEV-listed or ransomware match: at index …\"): list those as KEV-bearing components not checked, by index, never as clean.",
193
234
  "",
194
235
  "SBOM:",
195
236
  "```json",
@@ -210,10 +251,66 @@ export function sbomReviewText(sbom) {
210
251
  // What it reads is what check_sbom's text keeps: kev_listed, ransomware, epss_score,
211
252
  // effective_score, score_assessed and fixed_in on each match while matches are kept; past its
212
253
  // leanest cut a row keeps cve_ids alone, so the agent reads those CVEs from get_cve
213
- // (test/prompt-fields.test.mjs). OS packages: a deb or apk purl without an upstream qualifier is
254
+ // (test/prompt-fields.test.mjs). #3270: the structured result's cut (3.0.2, #3132) can also keep a
255
+ // row's matches capped, which step 5 read as the row's whole: a KEV-listed CVE past the cap was
256
+ // named only in matches_truncated.kev_listed_cve_ids, which no prompt read, and the rows still had
257
+ // matches, so the get_cve fallback never fired. Step 5 now reads matches_truncated's KEV-listed and
258
+ // ransomware IDs, and calls get_cve for every CVE kept without its match and every match without a
259
+ // field it ranks by (check_sbom.ts keeps them at every level that keeps matches, and its capped
260
+ // rows keep KEV-listed and ransomware matches first). #3269's review: at the leanest cut
261
+ // matches_truncated keeps total and returned alone, so step 5 says that neither list is there and
262
+ // the get_cve fallback reads those CVEs; and since #3269 the clean and not-assessed rows leave data
263
+ // before an affected row's matches are capped, so a workload's counts of not_affected and
264
+ // not_assessed rows can lack rows the cut left out: step 6 takes the call's totals from
265
+ // data.summary, which no cut touches. #3269's second review: round 2's step 6 then had the agent
266
+ // check every purl left out again, at the page size the note names, which is often small (332 calls
267
+ // on a realistic Debian SBOM, nearly all of them for not_affected rows). Now the counts are
268
+ // data.summary's, a call per image where several images' rows were left out; a left-out
269
+ // not_affected or not_assessed row is not checked again (summary counts it, and it holds no CVE);
270
+ // only the affected and undetermined rows that left data, which the DATA CUT sentence names by
271
+ // index, are checked again, in calls of those purls alone, and past RECHECK_CALLS (check_sbom.ts)
272
+ // such calls the agent stops and reports the rest as unchecked. #3269's third review: round 3's
273
+ // step 6 checked them again in index order, at the page sentence's N, and both were wrong for it:
274
+ // on old unpatched Debian images the rows with a KEV-listed match came last, so 119 of 255 were in
275
+ // view after its 5 calls, and N holds for the first N purls sent, while the rows left out are the
276
+ // heavy ones, so its calls dropped 504 of 2,540 rows on realistic images. The note now names the
277
+ // rows with a KEV-listed or ransomware match first and apart, and gives the sizes of calls of them
278
+ // that keep every row, counted for those rows (textBudget.ts PageAt.againFit): 238 of 255 in view,
279
+ // none dropped. sbom_review checks the same rows again the same way. #3269's fourth review: the
280
+ // first text block cuts its rows on its own terms (textBudget.ts dataText: rows leave from the end,
281
+ // in input order), while data keeps the KEV-bearing rows first, so a client that reads the text
282
+ // alone lost the rows the text left out, KEV-bearing ones among them, unsaid: workload_triage had no
283
+ // step for them. Its last line now says, as sbom_review's does, that they are in
284
+ // structuredContent.data, and, read from the text alone, has them counted from data.summary as
285
+ // unchecked. 3.1.0's phase B review: "those rows are in structuredContent.data" was false where a
286
+ // DATA CUT applies too, the large-image case the line is for (the text is cut from the whole
287
+ // answer, FIRST_TEXT_BLOCK_BESIDE_A_CUT): through the stdio server, 700 affected purls with the
288
+ // KEV-listed ones late kept 186 rows in the text and 56 in data, and 458 of the 514 the text left
289
+ // out were not in default data, 44 of them with a KEV-listed match. The line now says such rows
290
+ // are named in the note by index for step 6's re-check, and sends the agent to steps 5 and 6.
291
+ // The same review: read from the text alone, it gave the undetermined and not_assessed rows the
292
+ // text leaves out "as data.summary's counts", which count every such row, those the text still
293
+ // lists among them (undetermined rows leave from the end mixed with affected ones); it now gives
294
+ // data.summary.undetermined and data.summary.not_assessed less those listed, as it gives the
295
+ // affected count, and sbom_review's last line does the same. #3284: the first text block now keeps
296
+ // the rows with a KEV-listed or ransomware match last to leave, as data does, and names by index
297
+ // each of them it leaves out (textBudget.ts leaveOutLast), so a reader of the text alone no longer
298
+ // loses them unsaid (on the reviewer's 36 realistic images, 95 of 413 KEV-bearing rows at
299
+ // d42d4697, none since): both last lines quote that sentence and list those rows as KEV-bearing and
300
+ // not checked, where the line said a row the text leaves out "can hold a KEV-listed CVE that the
301
+ // text does not name". OS packages: a deb or apk purl without an upstream qualifier is
214
302
  // looked up by its binary name while Debian and Alpine file advisories under the source package, so
215
303
  // such rows can come back not_assessed (production, 2026-10-05: libssl3 at Debian:12 answers
216
304
  // package_not_in_advisory_corpus); the prompt has the agent count and state them, never as clean.
305
+ // #3283: the kubernetes exposure rule named neither of two standard paths to a pod: a container's
306
+ // hostPort (spec.containers[].ports[].hostPort, which binds the port on the node's IP: a hostPort
307
+ // ingress controller or agent DaemonSet) and a Service's spec.externalIPs (nodes accept the
308
+ // Service's traffic on them whatever its type, ClusterIP included). An agent that read the pod's
309
+ // spec and found none of the listed conditions wrote "not exposed (caller-asserted)", not
310
+ // "unknown", and the pod sorted last within its KEV group. Both are now exposed, and "not exposed"
311
+ // requires having read the containers' ports and the Services' externalIPs. The blog's "Triage
312
+ // your pods" section states the same rule (marketing-site lib/mcpPromptClaims.test.ts holds it to
313
+ // this text).
217
314
  export const WORKLOAD_SOURCES = ["kubernetes", "github_actions", "images"];
218
315
  // A CVE count past which the agent stops calling get_cve, as in sbom_review.
219
316
  const WORKLOAD_GET_CVE_MAX = 30;
@@ -223,7 +320,7 @@ const WORKLOAD_LIST = {
223
320
  images: "Images: the image references the user gives you. Resolve each tag to its digest (for example crane digest or skopeo inspect), and say that you did.",
224
321
  };
225
322
  const WORKLOAD_EXPOSURE = {
226
- kubernetes: "In a cluster, exposed means a Service of type LoadBalancer or NodePort selects the pod, an Ingress (or a Gateway route) sends traffic to a Service that selects it, or the pod sets hostNetwork: true; not exposed means you read the pod's spec and the cluster's Services, Ingresses and routes, and none of these holds; unknown means you could not read them, or the user tells you of a path you cannot see (a load balancer outside the cluster, for example).",
323
+ kubernetes: "In a cluster, exposed means a Service of type LoadBalancer or NodePort selects the pod, a Service that selects it lists externalIPs (spec.externalIPs, on which nodes accept its traffic whatever its type, ClusterIP included), an Ingress (or a Gateway route) sends traffic to a Service that selects it, a container of the pod sets a hostPort (spec.containers[].ports[].hostPort, which binds that port on the node's IP), or the pod sets hostNetwork: true; not exposed means you read the pod's spec, its containers' ports among it, and the cluster's Services, with their type and externalIPs, Ingresses and routes, and none of these holds; unknown means you could not read them, or the user tells you of a path you cannot see (a load balancer outside the cluster, for example).",
227
324
  github_actions: "For a CI job's container and services, exposure is unknown unless the user tells you otherwise; write what the user told you, as caller-asserted.",
228
325
  images: "For an image the user names, exposure is unknown unless the user tells you how it is reached; write what the user told you, as caller-asserted.",
229
326
  };
@@ -241,13 +338,15 @@ export function workloadTriageText(source) {
241
338
  `1. List the workloads and the digest of each image they run, with your own tools. ${each(WORKLOAD_LIST)} An image you cannot resolve to a digest is listed with its tag as not pinned: a tag can later point at another image.`,
242
339
  `2. Decide each workload's exposure yourself, from the same data, as exposed, not exposed or unknown, and label it caller-asserted. ${each(WORKLOAD_EXPOSURE)} Unknown is never treated as not exposed. EchelonGraph measured none of this: no EchelonGraph tool reports the exposure of your workloads.`,
243
340
  "3. Make an SBOM of each distinct digest with your own tools, for example syft <image>@sha256:<digest> -o cyclonedx-json, or trivy image --format cyclonedx <image>@sha256:<digest>. Take the purls of its components, leave out the purl of the image itself (pkg:oci or pkg:docker), and keep your own map from each purl to the images that carry it.",
244
- `4. Call check_sbom with purls set to one image's purls, or to several images' purls together, up to ${MAX_PURLS} distinct purls per call. Pass purls, not the SBOM document: a document's metadata can name the image. data.results holds one row per purl sent, each with its purl, which your map ties to its images. If coverage.not_sent is above 0, say how many purls were not sent and coverage.not_sent_reason, and that they are not checked (never clean); after a minute, call check_sbom once more with purls set to the purls not sent: data.not_sent_purls, or, where the note says the first text block cuts that list or a DATA CUT cuts it in data, the distinct purls you sent from the position the note gives on, in the order you sent them.`,
245
- `5. For each affected row, read kev_listed, ransomware, epss_score, effective_score, score_assessed and fixed_in from its matches where the text keeps them. Where the text keeps a row's cve_ids without its matches, call get_cve for each of those CVEs, to read kev_listed, kev_ransomware, epss_score, score_assessed, echelongraph_score and cvss_v3_score; past ${WORKLOAD_GET_CVE_MAX} such CVEs, list the rest as not looked up, unranked.`,
341
+ `4. Call check_sbom with purls set to one image's purls, or to several images' purls together, up to ${MAX_PURLS} distinct purls per call, each purl once (a list may hold at most ${MAX_PURL_ENTRIES} entries, repeats included). Pass purls, not the SBOM document: a document's metadata can name the image. data.results holds one row per purl sent, each with its purl, which your map ties to its images. If coverage.not_sent is above 0, say how many purls were not sent and coverage.not_sent_reason, and that they are not checked (never clean); after a minute, call check_sbom once more with purls set to the purls not sent: data.not_sent_purls, or, where the note says the first text block cuts that list or a DATA CUT cuts it in data, the distinct purls you sent from the position the note gives on, in the order you sent them.`,
342
+ `5. For each affected row, read kev_listed, ransomware, epss_score, effective_score, score_assessed and fixed_in from its matches where the text or data keeps them. A row whose matches a DATA CUT shortened or left out carries matches_truncated. Where matches_truncated has kev_listed_cve_ids and ransomware_cve_ids, they name every KEV-listed and every ransomware CVE among all of the row's matches, those cut included, so count each of them as a KEV-listed (or ransomware) CVE of the row; where it has total and returned and neither list (the leanest cut), nothing in the answer names the row's KEV-listed or ransomware CVEs, and the get_cve calls below read them. Call get_cve for each CVE of an affected row that the text or data keeps without its match (a row with cve_ids and no matches, or with fewer matches than cve_ids, as matches_truncated says), and for each match that leaves out one of the fields above, to read kev_listed, kev_ransomware, epss_score, score_assessed, echelongraph_score and cvss_v3_score; past ${WORKLOAD_GET_CVE_MAX} such CVEs, list the rest as not looked up, unranked.`,
343
+ `6. data.summary counts every row of a call whatever the cut: affected, not_affected, undetermined and not_assessed, and not_assessed_by_reason. Take each image's counts from it, and a workload's from its images': where the note says rows of results are left out of data (a DATA CUT sentence saying "results keeps K of its N entries") and the call carried several images' purls, call check_sbom with one image's purls per call for its counts. A row left out whose verdict is not_affected or not_assessed is not checked again: data.summary counts it, and it holds no CVE; where not_assessed rows are left out, state the image's not_assessed count and not_assessed_by_reason from data.summary instead of its OS-package rows, and say so. The affected and undetermined rows that left data are the ones checked again: the DATA CUT sentence names them by index, those with a KEV-listed or ransomware match first and apart ("affected or undetermined rows: the K with a KEV-listed or ransomware match at index …; the other K at index …"), each index the position, from 0, of a purl among the distinct purls you sent, in the order you sent them. Call check_sbom again with those purls alone as purls, in the order the note names them, so those with a KEV-listed or ransomware match first, in calls of the sizes its re-check sentence gives ("Re-check these K purls in this order, in calls of …"; a purl it sends to a call of its own goes alone), not of the page sentence's N, which holds for the first N purls you sent and not for these; read their rows as in step 5; each call's own note says what its data leaves out. If that takes more than ${RECHECK_CALLS} calls, stop after ${RECHECK_CALLS} and report the affected and undetermined rows not checked again, by purl where the note names them and by count, as unchecked, never as clean.`,
246
344
  ENVELOPE_RULES,
247
345
  "Read each row's verdict: not_affected is the one clean verdict; undetermined and not_assessed are not clean, and not_assessed_reason says why. Packages from an image's operating system (deb, apk and rpm purls) can come back not_assessed: one without a distro qualifier (distro_release_unknown), and a deb or apk purl without an upstream qualifier, which is looked up by its binary package name while Debian and Alpine file advisories under the source package. For each image, state how many of its OS-package rows are not_assessed, and never count them as clean.",
248
346
  `Then write one row per workload (${sources.map((k) => WORKLOAD_ROW[k]).join("; ")}), with: the image digests it runs; its KEV-listed CVEs, each with RANSOMWARE where ransomware (or get_cve's kev_ransomware) is true, and the match's fixed_in where it is a version (null or absent: the results give no fixed version for that branch, which is not a finding that no fix exists); its highest epss_score; its exposure, labelled caller-asserted, with the line of the user's data you read it from; and its counts of affected, undetermined and not_assessed rows.`,
249
347
  "Order the rows by these keys, and state them: 1. a workload with a KEV-listed CVE (kev_listed true) first; 2. then exposure: exposed, then unknown, then not exposed; 3. then the highest epss_score, highest first; 4. then the highest score: effective_score, or get_cve's echelongraph_score, where score_assessed is true, else cvss_v3_score, highest first. A workload with no value for a key sorts after those with one, and its row says which key was missing.",
250
348
  "Say plainly that every exposure label is caller-asserted: it came from the user's own cluster or CI data, read by you, and EchelonGraph measured none of it. List the images you could not resolve, scan or check, with why, as not checked, never as clean.",
349
+ "Where the note says that rows of data.results are left out of the first text block, those rows are in structuredContent.data, or, where a DATA CUT leaves rows out of data too, named there by index for step 6's re-check (the note says data holds them whole with full: true): where you can read it, read them there as in steps 5 and 6. Where you read the text alone, list the rows it carries, say how many affected rows it leaves out (data.summary.affected, less those listed), and give the undetermined and not_assessed rows it leaves out as data.summary.undetermined and data.summary.not_assessed (by not_assessed_by_reason), less those listed, as unchecked, never as clean. The rows with a KEV-listed or ransomware match leave the text last, and the note names by index each of them it leaves out (\"Of the rows left out of it, K have a KEV-listed or ransomware match: at index …\"): list those as KEV-bearing rows not checked, by index, never as clean.",
251
350
  ].join("\n");
252
351
  }
253
352
  export function registerPrompts(server, kit) {
@@ -0,0 +1,140 @@
1
+ import { largeBodyAdmissible } from "./httpPolicy.js";
2
+ export function header(req, name) {
3
+ const v = req.headers[name];
4
+ return Array.isArray(v) ? v.join(", ") : v;
5
+ }
6
+ // Whether the request has closed or is closing, so that a 'close' listener attached now may never be called (#2775).
7
+ // HTTP/1.1: IncomingMessage.destroyed is set the moment it is destroyed; .closed only once that has finished, with
8
+ // 'close' on a later tick (Node 20 and 22 alike). h2c: node:http2's compatibility request is never destroyed itself
9
+ // (after a reset its .destroyed and .closed stay false), so its stream is read.
10
+ function closedAlready(req) {
11
+ const stream = req.stream;
12
+ return req.destroyed || stream?.destroyed === true || stream?.closed === true;
13
+ }
14
+ // readBody over these slots (http.ts: the instance's large-body slots) and these limits.
15
+ export function bodyReader(largeBodies, limits) {
16
+ const { maxBodyBytes: MAX_BODY_BYTES, maxSbomBodyBytes: MAX_SBOM_BODY_BYTES, largeBodyWaitMs: LARGE_BODY_WAIT_MS } = limits;
17
+ return readBody;
18
+ async function readBody(req, gone, timing) {
19
+ const mayBeLarge = largeBodyAdmissible(header(req, "mcp-method"), header(req, "mcp-name"));
20
+ const cap = mayBeLarge ? MAX_SBOM_BODY_BYTES : MAX_BODY_BYTES;
21
+ const declared = Number(req.headers["content-length"]);
22
+ if (Number.isFinite(declared) && declared > cap) {
23
+ req.resume();
24
+ return "too_large";
25
+ }
26
+ // The wait for a slot stops when the client goes (the response closes) or the request closes.
27
+ const waitOver = new AbortController();
28
+ const stopWaiting = () => waitOver.abort();
29
+ gone.addEventListener("abort", stopWaiting, { once: true });
30
+ req.once("close", stopWaiting);
31
+ // #2775 (3.1.0's release dry run, 2026-10-25): an abort or a close that came before these listeners is never
32
+ // emitted again, so it is read from the signal and the request as they are now. Without this, a reader started
33
+ // after its client went would take a place among the waiters (to the end of its wait), or a free slot, which
34
+ // readBodyHolding would then hold for good. http.ts never starts one late: it calls readBody in the request's own
35
+ // event, and a reset or a close sent with the request's headers still reaches these listeners (3.1.0's review).
36
+ // test/read-body.test.mjs calls it late.
37
+ if (gone.aborted || closedAlready(req))
38
+ stopWaiting();
39
+ const waitForSlot = async () => {
40
+ // A free slot is no wait (slot_wait_ms stays 0), however the clock falls; nor is a refusal at once (two already
41
+ // wait): only a real wait is timed. Timing the at-once refusal across its await logged slot_wait_ms 1 when the
42
+ // clock's millisecond turned under load (a full npm test run, 2026-10-18).
43
+ if (largeBodies.tryAcquire())
44
+ return true;
45
+ if (!largeBodies.wouldWait(LARGE_BODY_WAIT_MS) || waitOver.signal.aborted)
46
+ return false;
47
+ const t = Date.now();
48
+ const got = await largeBodies.acquire(LARGE_BODY_WAIT_MS, waitOver.signal);
49
+ timing.slotWaitMs += Date.now() - t;
50
+ return got;
51
+ };
52
+ let slot = false;
53
+ try {
54
+ if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) {
55
+ if (!(await waitForSlot())) {
56
+ if (waitOver.signal.aborted)
57
+ return "gone";
58
+ req.resume();
59
+ return "busy";
60
+ }
61
+ slot = true;
62
+ }
63
+ return await readBodyHolding(req, cap, slot, waitForSlot, waitOver.signal);
64
+ }
65
+ finally {
66
+ gone.removeEventListener("abort", stopWaiting);
67
+ req.off("close", stopWaiting);
68
+ }
69
+ }
70
+ function readBodyHolding(req, cap, holding, waitForSlot, waitOver) {
71
+ let slot = holding;
72
+ return new Promise((resolve, reject) => {
73
+ const chunks = [];
74
+ let size = 0;
75
+ let done = false;
76
+ const stop = (r) => {
77
+ done = true;
78
+ chunks.length = 0;
79
+ if (slot)
80
+ largeBodies.release();
81
+ slot = false;
82
+ resolve(r);
83
+ };
84
+ // The client went (waitOver: readBody's listeners, which also read whether it had gone before they were
85
+ // attached), however long ago: a request that closed first emits no 'data', 'end' or 'close' for the listeners
86
+ // below, and would hold its slot for good (#2775).
87
+ const onGone = () => {
88
+ if (!done)
89
+ stop("gone");
90
+ };
91
+ if (waitOver.aborted)
92
+ return onGone();
93
+ waitOver.addEventListener("abort", onGone, { once: true });
94
+ req.on("data", (chunk) => {
95
+ if (done)
96
+ return;
97
+ size += chunk.length;
98
+ if (size > cap)
99
+ return stop("too_large");
100
+ if (size > MAX_BODY_BYTES && !slot) {
101
+ // A body with no Content-Length crossed MAX_BODY_BYTES: it waits for a slot holding what it has read (at most
102
+ // MAX_BODY_BYTES and this chunk), and reads on once it has one.
103
+ req.pause();
104
+ chunks.push(chunk);
105
+ void waitForSlot().then((got) => {
106
+ if (done) {
107
+ if (got)
108
+ largeBodies.release();
109
+ return;
110
+ }
111
+ if (!got)
112
+ return stop(waitOver.aborted ? "gone" : "busy");
113
+ slot = true;
114
+ req.resume();
115
+ });
116
+ return;
117
+ }
118
+ chunks.push(chunk);
119
+ });
120
+ req.on("end", () => {
121
+ if (done)
122
+ return;
123
+ done = true;
124
+ resolve({ body: Buffer.concat(chunks), slot });
125
+ });
126
+ req.on("close", () => {
127
+ if (!done)
128
+ stop("gone");
129
+ });
130
+ req.on("error", (e) => {
131
+ if (done)
132
+ return;
133
+ done = true;
134
+ if (slot)
135
+ largeBodies.release();
136
+ reject(e);
137
+ });
138
+ });
139
+ }
140
+ }