echelongraph-mcp 2.6.8 → 2.7.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.
package/dist/prompts.js CHANGED
@@ -42,21 +42,29 @@ const text = (t) => ({ messages: [{ role: "user", content: { type: "text", text:
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
44
  // Patch for a branch's fix.
45
+ //
46
+ // Its remediation (#2840, #2841): cve_remediation gives, for one CVE, each vendor advisory's
47
+ // remediation as the vendor categorises it (remediation_kinds, remediation_state) and the record's
48
+ // references tagged Patch or Mitigation, each text relayed as its source states it. The Patch line
49
+ // gives them as the vendor's, untested, and a not_parsed or none_in_source state as no finding that
50
+ // 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.
45
52
  export function triageCveText(cve) {
46
53
  return [
47
54
  `Triage ${cve} using the EchelonGraph MCP tools. Call them in this order, each with cve_id ${cve}:`,
48
- "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.",
55
+ "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.",
49
56
  "2. cve_intel: weaknesses (cwes), public exploit code (exploits and exploits_total; 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.",
50
- "3. vendor_advisories_for_cve: the vendor advisories that name it. Call get_vendor_advisory for one whose remediation you need in full.",
51
- "4. 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.",
52
- "5. 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.",
57
+ "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).",
58
+ "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.",
59
+ "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.",
60
+ "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.",
53
61
  ENVELOPE_RULES,
54
62
  "Then answer with this template, one line each, naming the tool and the measured_at behind every line:",
55
63
  "- Exploited? CISA-KEV listing and its date, known ransomware use, and public exploit code on record.",
56
64
  "- Likelihood: EPSS score and percentile, and how it has moved.",
57
65
  "- Reachable? exposure_state and the count on record, or not assessed.",
58
- `- Patch: fixed versions and vendor advisories; 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.`,
59
- "- 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.",
66
+ `- 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.`,
67
+ "- 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.",
60
68
  "- Decision: patch now, schedule, or monitor, with a one-line reason drawn from the lines above.",
61
69
  ].join("\n");
62
70
  }
@@ -73,6 +81,10 @@ export function kevWeeklyBriefText(days) {
73
81
  "- If total is 0, write that no CVE was added in that range in the catalog copy fetched at measured_at. If kev_recent failed, write that the catalog could not be read: never that nothing was added.",
74
82
  ].join("\n");
75
83
  }
84
+ // am_i_affected (#2834): a registry match carries fixed_in, the fixed bound of the advisory interval
85
+ // that holds the version asked about (core-backend cve/pkgmatch.go; check_affected keeps it at every
86
+ // cut level), so the registry text gives it, and a null or absent one as no fixed version on record
87
+ // for that range. A CPE match carries none, so the product text names none.
76
88
  export function amIAffectedText(a) {
77
89
  const subject = a.product ? `the product ${JSON.stringify(a.product)} (an NVD CPE product token)` : `the package ${JSON.stringify(a.package)} in the ${JSON.stringify(a.ecosystem)} ecosystem`;
78
90
  const args = a.product ? { product: a.product, version: a.version } : { ecosystem: a.ecosystem, package: a.package, version: a.version };
@@ -82,6 +94,11 @@ export function amIAffectedText(a) {
82
94
  ENVELOPE_RULES,
83
95
  "Read coverage.assessed before count, and answer with the case that applies:",
84
96
  "- assessed true and count above 0: affected. List each match's cve_id with kev_listed, ransomware, epss_score and effective_score (score_assessed false: not yet scored, not a score of 0), CISA-KEV-listed CVEs first.",
97
+ ...(a.product
98
+ ? []
99
+ : [
100
+ " For each match, give its fixed_in where it is a version: the fixed bound of the advisory interval that holds this version. Where fixed_in is null or absent, write that the result gives no fixed version for this version's range, which is not a finding that no fix exists.",
101
+ ]),
85
102
  "- assessed true and count 0: no matching CVE in EchelonGraph's data at this version. Give undetermined_count too: an advisory that could not be decided at this version is not safe.",
86
103
  "- assessed false: NOT ASSESSED. Write that the lookup did not evaluate this version, give not_assessed_reason, and never report it as not affected or clean.",
87
104
  "- failed or invalid_input: the check did not run. Say why, from the result.",
@@ -119,6 +136,17 @@ export function amIAffectedText(a) {
119
136
  // the three kinds osvDistro reads, Alpine cut to major.minor and Debian to its major), the types
120
137
  // purlNameVersion names with their namespace, and says any other is unknown, with no fixed version.
121
138
  // test/prompt-fields.test.mjs fromPurl holds the same tables.
139
+ //
140
+ // fixed_in (#2834): each check_sbom match carries fixed_in, the fixed bound of the advisory
141
+ // interval that holds the component's version, which core-backend asserts is above it, or null
142
+ // where that interval records no fix (it ends at last_affected, or has no end) or no interval holds
143
+ // the version. check_sbom's text keeps fixed_in on every match it keeps, and keeps fewer fields than
144
+ // match_reason's 200 characters, so the rule reads fixed_in first. The match_reason and cve_intel
145
+ // rules below it are for a match without fixed_in (an API before #2834) and for a component whose
146
+ // matches the cut text leaves out (its leanest level keeps cve_ids, not matches).
147
+ //
148
+ // Remediation (#2841): for the first CVEs of the list, cve_remediation gives each vendor's own
149
+ // remediation, as the vendor categorises it; the list gives it as the vendor's, untested.
122
150
  export function sbomReviewText(sbom) {
123
151
  return [
124
152
  "Review the SBOM below for known vulnerabilities, using the EchelonGraph MCP tools.",
@@ -128,7 +156,8 @@ export function sbomReviewText(sbom) {
128
156
  ENVELOPE_RULES,
129
157
  "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).",
130
158
  "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.",
131
- "For each line give the component (purl), its cve_ids, the key values used, and for each CVE a fixed version where the results support one, by this rule. Never give a fixed version that is not strictly greater than the component's installed version (the version in its 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. Prefer the match's match_reason where the text keeps it: \"falls inside the advisory interval [A, B)\", closed by a parenthesis, says B is the advisory's fixed version for the range that holds the installed version, so give B as the fixed version, quoting it (\"[A, ∞)\": the range has no end on record and names no fixed version). \"falls inside the advisory interval [A, B]\", closed by a square bracket, says B is the range's last affected version, still affected, and the range records no fix (core-backend adds \"(B is the last affected version, not a fix)\", which a cut text can clip, so the bracket alone decides): never give B, or any version at or below it, as a fixed version. Where the text is cut before the interval's closing bracket (\"[A, B…\", neither \")\" nor \"]\"), it does not say which bound B is: give no fixed version from that match_reason. Otherwise, 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; a range with last_affected and no fixed has no fix on record, 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 (or the recorded fix a match_reason names, the same value): 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 (\"listed verbatim in the advisory's affected versions\" names none), 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.",
159
+ "For each line give the component (purl), its cve_ids, the key values used, and for each CVE a fixed version where the results support one, by this rule. First, where the text keeps the component's match for the CVE and the match carries fixed_in: a version there is the fixed bound of the advisory interval that holds the installed version, so give it as the fixed version; null says the advisory records no fixed version for the installed version's range, so give none for that CVE on that component, and write that the results give no fixed version for its branch, which is not a finding that no fix exists. Where the match has no fixed_in field, or the text keeps no match for the component, the rest of this rule applies. Never give a fixed version that is not strictly greater than the component's installed version (the version in its 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. Prefer the match's match_reason where the text keeps it: \"falls inside the advisory interval [A, B)\", closed by a parenthesis, says B is the advisory's fixed version for the range that holds the installed version, so give B as the fixed version, quoting it (\"[A, ∞)\": the range has no end on record and names no fixed version). \"falls inside the advisory interval [A, B]\", closed by a square bracket, says B is the range's last affected version, still affected, and the range records no fix (core-backend adds \"(B is the last affected version, not a fix)\", which a cut text can clip, so the bracket alone decides): never give B, or any version at or below it, as a fixed version. Where the text is cut before the interval's closing bracket (\"[A, B…\", neither \")\" nor \"]\"), it does not say which bound B is: give no fixed version from that match_reason. Otherwise, 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; a range with last_affected and no fixed has no fix on record, 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 (or the recorded fix a match_reason names, the same value): 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 (\"listed verbatim in the advisory's affected versions\" names none), 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.",
160
+ "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.",
132
161
  "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.",
133
162
  "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.",
134
163
  "",
@@ -138,10 +167,63 @@ export function sbomReviewText(sbom) {
138
167
  "```",
139
168
  ].join("\n");
140
169
  }
170
+ // workload_triage (#2846): the agent's own pods or images, ranked against the advisory corpus and
171
+ // CISA's KEV catalog, exposed workloads first. The prompt takes no workload data (its one argument
172
+ // says where the workloads are listed), and has the agent send check_sbom purls alone: no workload,
173
+ // namespace, image or registry name, no credential and no exposure label goes into any argument,
174
+ // and the image's own purl (pkg:oci, pkg:docker), which names it, is left out. Exposure is the
175
+ // agent's reading of the user's own cluster or CI data, labelled caller-asserted, never an
176
+ // EchelonGraph measurement; the prompt names none of EchelonGraph's internet-exposure tools, whose
177
+ // counts are not about the user's workloads (test/prompts-resources.test.mjs holds both). The order
178
+ // is KEV, then exposure (unknown is never sorted with not exposed, #644), then EPSS, then score.
179
+ //
180
+ // What it reads is what check_sbom's text keeps: kev_listed, ransomware, epss_score,
181
+ // effective_score, score_assessed and fixed_in on each match while matches are kept; past its
182
+ // leanest cut a row keeps cve_ids alone, so the agent reads those CVEs from get_cve
183
+ // (test/prompt-fields.test.mjs). OS packages: a deb or apk purl without an upstream qualifier is
184
+ // looked up by its binary name while Debian and Alpine file advisories under the source package, so
185
+ // such rows can come back not_assessed (production, 2026-10-05: libssl3 at Debian:12 answers
186
+ // package_not_in_advisory_corpus); the prompt has the agent count and state them, never as clean.
187
+ export const WORKLOAD_SOURCES = ["kubernetes", "github_actions", "images"];
188
+ // A CVE count past which the agent stops calling get_cve, as in sbom_review.
189
+ const WORKLOAD_GET_CVE_MAX = 30;
190
+ const WORKLOAD_LIST = {
191
+ kubernetes: "Kubernetes: kubectl get pods -A -o json (or a Kubernetes MCP server). Each pod's .status.containerStatuses[].imageID, and .status.initContainerStatuses[].imageID, holds the digest of the image the container runs (…@sha256:…). Keep each pod's namespace, name and owner (its Deployment, StatefulSet, DaemonSet or Job) for your own table.",
192
+ github_actions: "GitHub Actions: the repository's workflow files (.github/workflows/*.yml), read with the GitHub MCP server or a checkout. Each job's container: and services: entries name the images it runs. Where an entry gives a tag, resolve it to its digest (for example crane digest, skopeo inspect or docker buildx imagetools inspect) and say that you did.",
193
+ 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.",
194
+ };
195
+ const WORKLOAD_EXPOSURE = {
196
+ 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).",
197
+ 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.",
198
+ 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.",
199
+ };
200
+ const WORKLOAD_ROW = {
201
+ kubernetes: "for a pod, its namespace and owner, or the pod where it has no owner",
202
+ github_actions: "for a CI job, its workflow and job",
203
+ images: "for an image the user names, the image",
204
+ };
205
+ export function workloadTriageText(source) {
206
+ const sources = source ? [source] : WORKLOAD_SOURCES;
207
+ const each = (m) => sources.map((k) => m[k]).join(" ");
208
+ return [
209
+ "Triage the container workloads you can reach with your own tools against EchelonGraph's advisory corpus and CISA's Known Exploited Vulnerabilities (KEV) catalog, using the EchelonGraph MCP tools, and rank them by CISA-KEV listing, then exposure.",
210
+ "What you send EchelonGraph is package URLs (purls) and CVE IDs: put no workload, namespace, image or registry name, no credential and no exposure label into any argument of any EchelonGraph tool.",
211
+ `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.`,
212
+ `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.`,
213
+ "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.",
214
+ `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, the distinct purls you sent from the position the note gives on, in the order you sent them.`,
215
+ `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.`,
216
+ ENVELOPE_RULES,
217
+ "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.",
218
+ `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.`,
219
+ "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.",
220
+ "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.",
221
+ ].join("\n");
222
+ }
141
223
  export function registerPrompts(server, kit) {
142
224
  server.registerPrompt("triage_cve", {
143
225
  title: "Triage a CVE",
144
- description: "Triage one CVE: get_cve (with CISA's due date when the CVE is KEV-listed), cve_intel, vendor_advisories_for_cve, epss_history and cve_exposure, then a decision template: exploited, likelihood, reachable, patch, deadline. Each line cites its tool and measured_at, and not_assessed is never reported as clean.",
226
+ description: "Triage one CVE: get_cve (with CISA's due date when the CVE is KEV-listed), cve_intel, cve_remediation, vendor_advisories_for_cve, epss_history and cve_exposure, then a decision template: exploited, likelihood, reachable, patch, deadline. Each line cites its tool and measured_at, and not_assessed is never reported as clean.",
145
227
  argsSchema: z.object({
146
228
  cve_id: z
147
229
  .string()
@@ -186,7 +268,7 @@ export function registerPrompts(server, kit) {
186
268
  }, (a) => text(amIAffectedText(a)));
187
269
  server.registerPrompt("sbom_review", {
188
270
  title: "SBOM review",
189
- description: `Review an SBOM (CycloneDX JSON or SPDX JSON text, up to ${MAX_SBOM_CHARS} characters): it is passed to check_sbom, which checks up to ${MAX_PURLS} distinct purls per call in batches of ${MAX_COMPONENTS}, then get_cve and cve_intel for each affected CVE, and a prioritised fix list ordered by CISA-KEV listing, then EPSS, then score, each key stated. undetermined and not_assessed components are listed as not checked, never as clean.`,
271
+ description: `Review an SBOM (CycloneDX JSON or SPDX JSON text, up to ${MAX_SBOM_CHARS} characters): it is passed to check_sbom, which checks up to ${MAX_PURLS} distinct purls per call in batches of ${MAX_COMPONENTS}, then get_cve and cve_intel for each affected CVE, and a prioritised fix list ordered by CISA-KEV listing, then EPSS, then score, each key stated, with each fixed version read from check_sbom's fixed_in where a match carries it, and cve_remediation's vendor remediation for the first 5 CVEs. undetermined and not_assessed components are listed as not checked, never as clean.`,
190
272
  argsSchema: z.object({
191
273
  sbom: z
192
274
  .string()
@@ -195,4 +277,15 @@ export function registerPrompts(server, kit) {
195
277
  .describe(`a CycloneDX JSON or SPDX JSON document, as JSON text, up to ${MAX_SBOM_CHARS} characters; it is passed to check_sbom`),
196
278
  }),
197
279
  }, ({ sbom }) => text(sbomReviewText(sbom)));
280
+ server.registerPrompt("workload_triage", {
281
+ title: "Triage my workloads",
282
+ description: "Rank your own pods, CI job containers or images against EchelonGraph's advisory corpus and CISA's KEV catalog: you list the workloads and decide their exposure with your own tools, make an SBOM of each image digest, and pass check_sbom the purls alone; rows are ordered by CISA-KEV listing, then caller-asserted exposure, then EPSS, then score. not_assessed rows are counted and never reported as clean. The prompt takes no workload data.",
283
+ // A client may send no arguments at all for a prompt whose arguments are all optional.
284
+ argsSchema: z.preprocess((v) => v ?? {}, z.object({
285
+ source: z
286
+ .enum(WORKLOAD_SOURCES)
287
+ .optional()
288
+ .describe("where the workloads are listed: kubernetes, github_actions or images (default: all three ways are described)"),
289
+ })),
290
+ }, ({ source }) => text(workloadTriageText(source)));
198
291
  }
@@ -0,0 +1,118 @@
1
+ // What every client must hand the model whole (#2842): the server instructions and each tool
2
+ // description fit CLIENT_DESCRIPTION_CUT, and the sentences REQUIRED_IN_HEAD names sit inside it.
3
+ // test/tools.test.mjs ("client 2,048-character cut") holds both, for every tool tools/list serves:
4
+ // a description or the instructions grown past the cut, a required sentence moved past it or
5
+ // dropped, a head naming Shodan without SHODAN_OWNERSHIP, or a tool registered with no entry here
6
+ // fails it. A tool added later adds its entry here.
7
+ //
8
+ // The cut. Claude Code sends each MCP tool description and each server's instructions to the model
9
+ // cut at 2,048 characters, with "… [truncated]" appended:
10
+ // - Claude Code docs, environment variables (code.claude.com/docs/en/env-vars, read 2026-10-05):
11
+ // CLAUDE_CODE_MAX_MCP_DESCRIPTION_LENGTH, "Maximum length in characters of each MCP tool
12
+ // description and each MCP server's instructions that Claude Code sends to the model (default:
13
+ // 2048)", v2.1.280 or later;
14
+ // - anthropics/claude-code#87650 (closed, not planned): the minified constant Tpe=2048 gates the
15
+ // cut in v2.1.234; /mcp shows the whole text while the model receives the cut one, and only the
16
+ // instructions' cut is logged ("Server instructions truncated from X to 2048 chars").
17
+ // The other clients looked at cut later or not at all: Cursor cuts tool descriptions at 6,500
18
+ // characters (Cursor staff, forum.cursor.com/t/147465); VS Code and Claude Desktop document no
19
+ // description cap (VS Code caps a tool's name at 64 characters and a request at 128 tools). So
20
+ // Claude Code's default is the budget: text that fits it reaches every one of them whole.
21
+ //
22
+ // Kept under src/ so marketing-site lib/mcpToolClaims.test.ts's resolver can fold the two Shodan
23
+ // sentences, which the descriptions and notes take from here.
24
+ export const CLIENT_DESCRIPTION_CUT = 2048;
25
+ export const SHODAN_ATTRIBUTION = "Exposure counts are derived from Shodan data.";
26
+ // Shodan's terms (https://static.shodan.io/legal/terms.html, read 2026-09-27) ask for two
27
+ // things: "You must attribute such usage to Shodan", and materials "referencing, including or
28
+ // otherwise based on Shodan information or materials, must clearly indicate Shodan's ownership
29
+ // and copyright in the applicable Shodan materials" (#2306). The attribution above does the
30
+ // first; this sentence, placed beside it wherever Shodan data is named, does the second.
31
+ export const SHODAN_OWNERSHIP = "Shodan data is owned by Shodan, which holds its copyright (© Shodan).";
32
+ // Sentences the instructions must carry inside the cut: the licence condition, and the rules that
33
+ // keep a zero or a failure from reading as a finding.
34
+ export const REQUIRED_IN_INSTRUCTIONS = [
35
+ `${SHODAN_ATTRIBUTION} ${SHODAN_OWNERSHIP}`,
36
+ "not_assessed means the answer holds no dated measurement of what was asked, so no count in it is presented as one.",
37
+ "A zero is not a finding of no exposure where the answer holds no measurement for that CVE",
38
+ "failed and invalid_input mean nothing was measured",
39
+ ];
40
+ // Per tool, the sentences its description must carry inside the cut: attribution and ownership
41
+ // where it names Shodan data, the rule that keeps a 0 or a false from reading as a finding, the
42
+ // score_assessed rule, and what leaves the caller's machine.
43
+ export const REQUIRED_IN_HEAD = {
44
+ cve_summary: [
45
+ "summary.none is not a severity rating of None",
46
+ "provenance, never EchelonGraph's severity band",
47
+ "they are withdrawn records, none of them a vulnerability",
48
+ "they describe that instance, never the feed's size, intake, reliability or freshness",
49
+ ],
50
+ search_cves: [
51
+ "echelongraph_score, echelongraph_severity and echelongraph_risk are EchelonGraph's score only when score_assessed is true.",
52
+ "With score_assessed false the CVE is NOT YET SCORED, not scored 0",
53
+ "patch_available false means no fix evidence is on record, which is not a finding that no fix exists.",
54
+ ],
55
+ get_cve: [
56
+ "echelongraph_score, echelongraph_severity and echelongraph_risk are EchelonGraph's score only when score_assessed is true.",
57
+ "With score_assessed false the CVE is NOT YET SCORED, not scored 0",
58
+ "patch_available false means no fix evidence is on record, which is not a finding that no fix exists.",
59
+ "which is not a finding that no product is affected",
60
+ "CISA's own text relayed verbatim (kev_required_action, not EchelonGraph's advice",
61
+ ],
62
+ cve_exposure: [
63
+ "exposure counts are derived from Shodan data.",
64
+ SHODAN_OWNERSHIP,
65
+ "for a CVE outside that set the note says NOT ASSESSED (exposure_state not_assessed), and its 0 is not a measurement.",
66
+ ],
67
+ exposure_radar: [
68
+ "derived from Shodan data",
69
+ SHODAN_OWNERSHIP,
70
+ "(no Shodan data)",
71
+ "its 0 is not one",
72
+ ],
73
+ kev_recent: ["catalog.catalog_count is CISA's own count"],
74
+ epss_history: [
75
+ "a day without a point is not a recorded value",
76
+ "Between series_starts_at and complete_since the record misses changes, so a missing point there does not mean unchanged either",
77
+ ],
78
+ check_affected: [
79
+ "a count of 0 there is not a finding of not affected",
80
+ "never as safe",
81
+ "travel in request headers, never in the URL",
82
+ ],
83
+ check_sbom: [
84
+ "only they are sent to the API",
85
+ "over the hosted endpoint (mcp.echelongraph.io) it is EchelonGraph's",
86
+ "not_affected is the only clean verdict",
87
+ "is refused, not truncated",
88
+ ],
89
+ // #2835: what leaves the caller's machine, the hosted endpoint's part, and the rules that keep
90
+ // an entry not checked, or a refused go.sum, from reading as clean.
91
+ scan_manifest: [
92
+ "only the purls are sent to the API",
93
+ "over the hosted endpoint (mcp.echelongraph.io) it is EchelonGraph's",
94
+ "filenames and file contents are not sent on",
95
+ "not_affected is the only clean verdict",
96
+ "not_checked entries are not clean",
97
+ "go.sum is refused",
98
+ ],
99
+ // #2841: the untested-source sentence, the not-a-zero rule and whose action CISA's is.
100
+ cve_remediation: [
101
+ "Every text is relayed as stated by its source; EchelonGraph has not tested it.",
102
+ "An empty list or a remediation_state of none_in_source or not_parsed is not a finding that no fix exists.",
103
+ "CISA's required_action is directed at US federal civilian agencies (BOD 22-01) and is not EchelonGraph's advice.",
104
+ ],
105
+ cve_intel: [
106
+ "An empty exploits list is not evidence that no public exploit exists",
107
+ "not the fix for every affected range",
108
+ ],
109
+ get_cwe: ["echelongraph_score is EchelonGraph's score only when score_assessed is true", "not that none exists"],
110
+ vendor_advisories_for_cve: [
111
+ "not that no vendor published one",
112
+ "no advisory from a vendor is not a finding that it published none",
113
+ "rejected_cve_ids: the CVE IDs it names whose CVE record was rejected (withdrawn) by its numbering authority, which are not active vulnerabilities",
114
+ "cve_rejected is true when the CVE record of the CVE asked for was rejected",
115
+ ],
116
+ get_vendor_advisory: ["withdrawn (true: the vendor rescinded it", "which are not active vulnerabilities"],
117
+ search_vendor_advisories: ["never exactly 1,000", "The query is sent in a request header, never in the URL.", "which are not active vulnerabilities"],
118
+ };
package/dist/resources.js CHANGED
@@ -62,7 +62,9 @@ A count of 0 is a finding of none when the answer measured it. Under assessed: f
62
62
  - epss_history: measured_at is current.epss_updated_at, when EchelonGraph last wrote a changed EPSS value; the series is change-only, so a missing point is not recorded, not unchanged.
63
63
  - check_affected: measured when the answer says assessed true; not_assessed when it says false or does not say. measured_at and freshness are null.
64
64
  - check_sbom: measured when at least one component got a verdict, else not_assessed; measured_at null. not_affected is the one clean verdict; undetermined and not_assessed are not clean.
65
+ - scan_manifest: as check_sbom, on the purls this server reads from the lockfiles passed; not_assessed, with no API call, when no entry pins one registry version. Each entry not sent (a range, an unresolved version, a local path, a VCS checkout or a line not read) is listed in data.not_checked with its reason and is not clean; a refused file (package.json, go.sum and the other manifests of ranges) is named in the note, and nothing in it is checked.
65
66
  - cve_intel: measured_at null: each row carries its own first_seen_at or enriched_at. An empty exploits list is not evidence that no public exploit exists.
67
+ - cve_remediation: measured with measured_at null, or not_assessed for a CVE record rejected by its numbering authority. Every text is its source's, verbatim and untested by EchelonGraph; each vendor item's kind is the vendor's own category. remediation_state none_in_source or not_parsed, and an empty list, are not a finding that no fix exists.
66
68
  - get_cwe: measured_at null: each row carries its own published date. A total of 0 says no CVE in EchelonGraph's feed is classified under that CWE.
67
69
  - vendor_advisories_for_cve, search_vendor_advisories: measured_at null (each row carries vendor_published_at and our_first_seen_at). get_vendor_advisory: measured_at is our_first_seen_at, when EchelonGraph first recorded the advisory.
68
70
 
@@ -74,6 +74,10 @@ const Match = z.looseObject({
74
74
  vendor_unknown: opt(z.boolean()).describe("true: the vendor was not verified, so the match is real but its attribution to your vendor is not."),
75
75
  match_reason: opt(z.string()),
76
76
  version_evidence: opt(z.string()),
77
+ // #2834: registry matches only (core-backend cve/pkgmatch_fixedin.go registryFixedIn).
78
+ interval: opt(z.looseObject({ introduced: opt(z.string()), fixed: opt(z.string()), last_affected: opt(z.string()) })).describe("Registry match: the advisory interval that holds this version (fixed is exclusive, last_affected inclusive); absent when no interval holds it."),
79
+ fixed_in: opt(z.string()).describe("Registry match: the fixed bound of the advisory interval that holds this version, always above it. null when that interval records no fix (it ends at last_affected, or has no end) or no interval holds the version, with fixed_in_reason saying which. Absent on a CPE match."),
80
+ fixed_in_reason: opt(z.string()).describe("Why fixed_in is null; absent when fixed_in is a version."),
77
81
  });
78
82
  const Undetermined = z.looseObject({
79
83
  cve_id: opt(z.string()),
@@ -91,20 +95,20 @@ const DATA = z.looseObject({
91
95
  package: opt(z.string()),
92
96
  assessed: opt(z.boolean()).describe("count depends on it. true: the lookup evaluated this component. false: it did not, and count 0 says nothing about whether this version is affected."),
93
97
  not_assessed_reason: opt(REASON),
94
- match_layer: opt(z.string()),
98
+ match_layer: opt(z.string()).describe("Which lookup path answered."),
95
99
  cve_ids: opt(z.array(z.string())),
96
100
  matches: opt(z.array(Match)),
97
101
  count: opt(z.number()),
98
- capped: opt(z.boolean()),
99
- candidates_capped: opt(z.boolean()),
102
+ capped: opt(z.boolean()).describe("true: the match list stopped at its cap."),
103
+ candidates_capped: opt(z.boolean()).describe("true: not every candidate CVE was loaded."),
100
104
  candidate_count: opt(z.number()),
101
105
  advisories_considered: opt(z.number()),
102
- excluded_count: opt(z.number()),
106
+ excluded_count: opt(z.number()).describe("CPE candidates suppressed by the vendor or platform gate."),
103
107
  excluded: z.unknown().optional(),
104
108
  undetermined_count: opt(z.number()),
105
109
  undetermined: opt(z.array(Undetermined)),
106
- not_affected_count: opt(z.number()),
107
- degraded: opt(z.boolean()),
110
+ not_affected_count: opt(z.number()).describe("Advisories decided as not affecting this version."),
111
+ degraded: opt(z.boolean()).describe("true: the lookup ran out of time."),
108
112
  undecidable_excluded_count: opt(z.number()),
109
113
  undecided_candidate_count: opt(z.number()),
110
114
  product_named_count: opt(z.number()),
@@ -126,10 +130,10 @@ const COVERAGE = z.strictObject({
126
130
  // Constant strings, so marketing-site lib/mcpToolClaims.test.ts can fold them should it read
127
131
  // this file.
128
132
  export const CHECK_AFFECTED_TITLE = "Am I affected? (product or package at a version)";
129
- export const CHECK_AFFECTED_DESCRIPTION = "Whether a product or package at a given version is affected by known CVEs, from the same matcher as echelongraph.io/am-i-affected. Two lookup paths. The CPE path takes product (the NVD CPE product token, such as openssl or nginx) and version, and returns the CVEs whose NVD CPE match criteria name that product with a version range that includes the version; it matches the token across vendors, so each match names the vendor NVD asserts (cpe_vendor) and whether that vendor was verified (vendor_unknown). The registry path takes ecosystem (npm, PyPI, Maven and other OSV ecosystem names), package and version, and decides each OSV advisory record EchelonGraph holds for that package as affected, not affected or undetermined. count depends on assessed: assessed false means the lookup did not evaluate this component, not_assessed_reason says why (product_not_in_cpe_corpus, package_not_cpe_nameable, candidate_load_pending, candidate_window_truncated, package_not_in_advisory_corpus, no_decidable_advisory), the structured result's state is not_assessed, and a count of 0 there is not a finding of not affected. An advisory whose version range cannot be decided at this version is reported as undetermined (undetermined_count, and up to 50 of them in undetermined), never as safe. The answer also carries match_layer (which path answered), capped (the match list stopped at its cap), candidates_capped (not every candidate CVE was loaded), excluded_count (CPE candidates suppressed by the vendor or platform gate), not_affected_count (advisories decided in your favour) and degraded (the lookup ran out of time). Each match carries cve_id, kev_listed, ransomware, epss_score, effective_score, effective_severity and score_assessed (false: EchelonGraph has not scored the CVE yet, so its echelongraph_score is withheld). Product, version, ecosystem and package travel in request headers, never in the URL. Its structured result carries state (measured only when the answer says assessed true), measured_at (null), method (which matcher answered), coverage (assessed and not_assessed_reason first, then the lookup path and the counts above), freshness (null) and notes, with data equal to the API's JSON." +
133
+ export const CHECK_AFFECTED_DESCRIPTION = "Whether a product or package at a given version is affected by known CVEs, from the same matcher as echelongraph.io/am-i-affected. The CPE path takes product (the NVD CPE product token, such as openssl or nginx) and version, and returns the CVEs whose NVD CPE match criteria name that product with a version range that includes the version; each match names the vendor NVD asserts (cpe_vendor) and whether that vendor was verified (vendor_unknown). The registry path takes ecosystem (npm, PyPI, Maven and other OSV ecosystem names), package and version, and decides each OSV advisory record EchelonGraph holds for that package as affected, not affected or undetermined. count depends on assessed: assessed false means the lookup did not evaluate this component, not_assessed_reason says why, and a count of 0 there is not a finding of not affected. An advisory whose version range cannot be decided at this version is reported as undetermined (undetermined_count, and up to 50 of them in undetermined), never as safe. Each match carries cve_id, kev_listed, ransomware, epss_score, effective_score, effective_severity and score_assessed (false: not yet scored, so echelongraph_score is withheld). A registry match also carries interval, the advisory interval holding this version, and fixed_in, its fixed bound, or null with fixed_in_reason; a CPE match carries no fixed_in. Product, version, ecosystem and package travel in request headers, never in the URL." +
130
134
  " " +
131
135
  TEXT_BUDGET_DESCRIPTION +
132
- " Cut, the excluded and undetermined samples keep their first 10 entries, each its cve_id and reason, cve_ids keeps its first 10 (each match carries its cve_id), and each match keeps fewer fields, cve_id, kev_listed, ransomware, epss_score, effective_score and score_assessed at least, so that every match stays in the text.";
136
+ " Cut, the excluded and undetermined samples keep their first 10 entries, each its cve_id and reason, cve_ids keeps its first 10, and each match keeps fewer fields, cve_id, kev_listed, ransomware, epss_score, effective_score, score_assessed and fixed_in at least. Every CPE match stays in the text; near the cap, a registry list's last matches can leave it, and the note says how many.";
133
137
  export const CHECK_AFFECTED_INPUT = z.object({
134
138
  product: z.string().optional().describe("CPE path: the NVD CPE product token, such as openssl, nginx or linux_kernel. Leave out for the registry path."),
135
139
  ecosystem: z.string().optional().describe("Registry path: the package's ecosystem, such as npm, PyPI or Maven. Give with package, not with product."),
@@ -168,6 +172,17 @@ function matchesNote(matches) {
168
172
  }
169
173
  return out;
170
174
  }
175
+ // #2834: what the registry matches say about a fix. fixed_in is the fixed bound of the interval
176
+ // that holds this version (core-backend cve/pkgmatch_fixedin.go), so it is per match: an advisory
177
+ // with several branches (Log4Shell: 2.3.1, 2.12.2, 2.15.0) names the one for this version.
178
+ function fixedInNote(matches) {
179
+ const registry = matches.filter((m) => m !== null && typeof m === "object" && "fixed_in" in m);
180
+ if (registry.length === 0)
181
+ return "";
182
+ const fixed = registry.filter((m) => typeof field(m, "fixed_in") === "string").length;
183
+ const none = registry.length - fixed;
184
+ return ` fixed_in, the fixed bound of the advisory interval that holds this version: ${fixed === registry.length ? `set on every match` : `set on ${fixed} of ${registry.length} matches`}${none > 0 ? `; null on ${none}, where the advisory records no fix for this version (fixed_in_reason says why)` : ""}.`;
185
+ }
171
186
  // #2783: a production match is about 2,100 characters as pretty JSON, most of it the CVE's
172
187
  // description and match_reason, so openssl 3.0.0 (71 matches) was 156,058 characters of text and
173
188
  // linux_kernel 5.10.0 (200, the API's cap) 364,408. Past DATA_TEXT_BUDGET each match in the first
@@ -189,36 +204,44 @@ function matchesNote(matches) {
189
204
  // 200 matches left the last level 120 characters past the budget for flash_player 10.0.0, and
190
205
  // 1,071 for a registry answer at the cap with 50 undetermined); and the registry path's cve_ids,
191
206
  // which is each match's cve_id in the order of matches (core-backend cve/handler.go), keeps its
192
- // first 10, so that every match stays in the text, 200 at the cap: this tool has no page to read
193
- // more from. excluded_count and undetermined_count count every entry. structuredContent.data keeps
194
- // every list whole.
207
+ // first 10, so that every CPE match stays in the text, 200 at the cap: this tool has no page to
208
+ // read more from. A registry list at the cap does not fit even so (#2834): fixed_in, which every
209
+ // level keeps, costs about 20 characters a match, and the last level's rows alone, 200 of them at
210
+ // about 155 characters each, are some 31,000 characters, past the budget before anything beside
211
+ // them. At 200 django matches carrying fixed_in, fixed_in_reason and interval, beside 50
212
+ // undetermined, the text keeps 185 (29,863 characters, 2026-10-05), and the last 15 in order, the
213
+ // lowest ranked, are in structuredContent.data only; the description says so, and the note names
214
+ // how many. No field of the last level can go: the description and the am_i_affected prompt read
215
+ // each of them per match. excluded_count and undetermined_count count every entry.
216
+ // structuredContent.data keeps every list whole.
195
217
  const CHECK_AFFECTED_TEXT = {
196
218
  rows: "matches",
197
219
  levels: [
198
220
  {
199
221
  keep: [
200
222
  "cve_id", "severity", "cvss_v3_score", "description", "kev_listed", "ransomware", "epss_score", "echelongraph_score", "echelongraph_severity", "effective_score", "effective_severity",
201
- "score_assessed", "score_unassessed_reason", "matched_criteria", "cpe_vendor", "vendor_unknown", "match_confidence", "match_reason", "version_evidence",
223
+ "score_assessed", "score_unassessed_reason", "matched_criteria", "cpe_vendor", "vendor_unknown", "match_confidence", "match_reason", "version_evidence", "fixed_in",
224
+ "fixed_in_reason",
202
225
  ],
203
226
  clip: 200,
204
227
  },
205
228
  {
206
229
  keep: [
207
230
  "cve_id", "severity", "cvss_v3_score", "kev_listed", "ransomware", "epss_score", "echelongraph_score", "effective_score", "effective_severity", "score_assessed", "matched_criteria",
208
- "cpe_vendor", "vendor_unknown", "match_reason",
231
+ "cpe_vendor", "vendor_unknown", "match_reason", "fixed_in",
209
232
  ],
210
233
  clip: 200,
211
234
  },
212
235
  {
213
236
  keep: [
214
237
  "cve_id", "severity", "cvss_v3_score", "kev_listed", "ransomware", "epss_score", "echelongraph_score", "effective_score", "effective_severity", "score_assessed", "matched_criteria",
215
- "cpe_vendor", "vendor_unknown",
238
+ "cpe_vendor", "vendor_unknown", "fixed_in",
216
239
  ],
217
240
  clip: 100,
218
241
  },
219
- { keep: ["cve_id", "kev_listed", "ransomware", "epss_score", "effective_score", "effective_severity", "score_assessed", "cpe_vendor", "vendor_unknown"], clip: 100 },
220
- { keep: ["cve_id", "kev_listed", "ransomware", "epss_score", "effective_score", "effective_severity", "score_assessed"], clip: 100 },
221
- { keep: ["cve_id", "kev_listed", "ransomware", "epss_score", "effective_score", "score_assessed"], clip: 100 },
242
+ { keep: ["cve_id", "kev_listed", "ransomware", "epss_score", "effective_score", "effective_severity", "score_assessed", "cpe_vendor", "vendor_unknown", "fixed_in"], clip: 100 },
243
+ { keep: ["cve_id", "kev_listed", "ransomware", "epss_score", "effective_score", "effective_severity", "score_assessed", "fixed_in"], clip: 100 },
244
+ { keep: ["cve_id", "kev_listed", "ransomware", "epss_score", "effective_score", "score_assessed", "fixed_in"], clip: 100 },
222
245
  ],
223
246
  sides: [
224
247
  { list: "cve_ids", cap: 10, said: () => "cve_ids lists each match's cve_id, in the order of matches." },
@@ -279,7 +302,7 @@ async function checkAffected(kit, a) {
279
302
  note += ` (state: not_assessed) The answer carries no assessed field (an API older than it sends none), so it does not say whether ${what} was evaluated, and a count of 0 here does not mean this version is not affected.`;
280
303
  }
281
304
  if (count !== null && count > 0) {
282
- note += ` AFFECTED: ${plural(count, "CVE matches", "CVEs match")} this version${capped === true ? " (capped: true, so the list stopped at its cap and more may match)" : ""}.${matchesNote(matches)}`;
305
+ note += ` AFFECTED: ${plural(count, "CVE matches", "CVEs match")} this version${capped === true ? " (capped: true, so the list stopped at its cap and more may match)" : ""}.${matchesNote(matches)}${fixedInNote(matches)}`;
283
306
  }
284
307
  else if (count === 0 && assessed === true) {
285
308
  note += ` 0 CVEs match this version, a measured result (we looked and found nothing that matches), not a lookup failure.`;