Tool: score_risk
Score a batch of software-vulnerability alerts into an action: FIX_TODAY, REVIEW, SCHEDULE, or DEFER. score_risk composes the KEV and EPSS catalogs with the reachability and asset context you supply, applies the k9 risk-scoring rubric, and returns a verdict for each context an alert runs in, plus a NIST SP 800-30 risk score.
This page uses three words precisely:
- Alert: one vulnerability, in one dependency, in one place you declare it, identified by one
finding_key. This is the unit k9 meters. - Binding: one execution context an alert's vulnerable code runs in, sent in
context_bindings. - Verdict: the result for one alert in one binding. An alert sent with three bindings comes back with three verdicts.
The API says "finding" in several field names, where it means the alert in findings and finding_key but the verdict in findings_total. Below, "finding" appears only as part of a field name.
Ask your agent:
Score this project's dependency alerts for reachable risk.
Your agent reads the rubric once per session before scoring, so it analyzes your code correctly and supplies the right reachability basis and asset context. The rubric is published on three MCP surfaces, so use whichever your client supports:
- Tool:
get_risk_scoring_rubric, callable mid-task with no user action. - Resource:
k9://rubric/risk-scoring, discoverable vialistResources. - Prompt:
risk_scoring_rubric, discoverable vialistPrompts.
The tool surface exists because some clients, VS Code among them, do not bring MCP prompts and resources into an agent session. If yours is one, call the tool.
Your agent also reads the shorter basis-selection procedure, published as the get_basis_procedure tool and the k9://rubric/basis-procedure resource. It says how to determine reachability, and it returns the basis_procedure_token every binding must carry.
For why the verdicts look the way they do, including why a DEFER can still carry a Moderate NIST score, see How to Prioritize CVEs by Risk, Not Severity.
Request
score_risk(findings, scored_by_model?, risk_context_version?)
findings: one object per alert, max 20 per call. The context bindings inside an alert are not capped. The limit counts the objects you send, before duplicatefinding_keys collapse.scored_by_model: optional string naming the model that drove the scoring. Use the exact model identifier, e.g.claude-opus-4-8, or"unknown"if you cannot attest to it.risk_context_version: optional string naming the revision of the risk-context file you read to assembleasset_context. Itsreviewed.dateandreviewed.commitjoined with a+, e.g.2026-07-29+87af4dc, or just the date when the file records no commit. Omit it when the project has no such file, or when you could not parse the one it has.
Neither optional argument affects a verdict. Both are recorded so a triage report and audit trail can cite what produced the verdicts.
risk_context_version takes an identifier and nothing else: max 64 characters, from letters, digits, and . + _ -. A path, a description, or the file's contents is rejected with an error rather than persisted, since the value is stored with every verdict in the call.
Each alert:
| Field | Required | Type | Notes |
|---|---|---|---|
kind |
yes | "software_vuln" |
"software_vuln" is the only kind supported currently. |
finding_key |
yes | string | Your unique key for this alert. Returned as the response key. Keep it stable across re-scans so usage dedupes. <ecosystem>:<package>:<vuln_id> works well, e.g. npm:dompurify:GHSA-h8r8-wccr-v5f2. Do not encode the execution context here; that is what context_bindings is for. Max 160 characters, from letters, digits and . _ : / @ + -. |
vuln_id |
yes | string | CVE-…, GHSA-…, GO-…, or ALAS-…, exactly as your scanner reports it. k9 resolves the advisory's CVEs itself and reads the threat evidence from whichever carries the most, so there is nothing to resolve first. When the evidence came from a different id than you sent, the verdict names it in threat_vuln_id. An advisory with no CVE at all still scores; do not block triage on it. |
context_bindings |
yes | list | One entry per execution context where this alert's vulnerable code runs. At least one. See below. |
project_id |
no | string | Groups alerts by project. Does not affect a verdict. |
Note: severity is not an input for risk scoring (why).
context_bindings
One dependency alert often runs in more than one place, and carries different risk in each. A package pulled into both a public production service and a build script is one alert, but could be a critical exposure in the first and close to none in the second.
context_bindings is how you say so: one entry per execution context where the vulnerable code runs, and one verdict back per entry. Send it even when there is only one context, because it is the only way to supply context.
| Field | Required | Type | Notes |
|---|---|---|---|
reachable |
yes | object | {value, basis, basis_procedure_token}. All three required, and specific to this context. See below. |
asset_context |
no | object | Environment context for this binding. Omit and the rubric assumes production / tier_2 / internal. See below. |
execution_context_id |
see below | string | Names this context. Optional with one binding, required and unique with more than one. |
Four rules govern how you fill this in:
- Score every context the alert runs in. Picking one and dropping the rest under-reports the others. Picking the build context for a package that also runs in production under-reports the risk that matters most.
- Metering counts alerts. Scoring one alert against three contexts costs what scoring it against one costs, so never drop a context to save budget.
- Reachability is per binding. It is a property of the code path: different entry points mean a different call graph and possibly a different answer, so analyze each context separately. One read of the basis procedure covers all of an alert's bindings, so you can reuse its token across them.
- Name the contexts once you send more than one. Two unnamed bindings would produce verdicts you cannot tell apart, so the call is rejected instead of answered ambiguously. Two bindings sharing an id are also rejected, since a context takes one binding. Use the
idof the matching entry in your risk-context file when you have one.
execution_context_id is stored with your verdicts, so keep it to a bare identifier. No filesystem paths, hostnames, internal service names, URLs, or ticket references.
One alert in two contexts:
{
"kind": "software_vuln",
"finding_key": "npm:dompurify:GHSA-h8r8-wccr-v5f2",
"vuln_id": "GHSA-h8r8-wccr-v5f2",
"context_bindings": [
{
"execution_context_id": "web-frontend",
"asset_context": {
"asset_criticality": "tier_1",
"network_exposure": "public_unauth",
"lifecycle": "production"
},
"reachable": {
"value": true,
"basis": "manual_call_graph",
"basis_procedure_token": "2026.08.01-v3-a39688b8-5d34e636"
}
},
{
"execution_context_id": "build",
"asset_context": {
"asset_criticality": "tier_2",
"network_exposure": "internal",
"lifecycle": "dev"
},
"reachable": {
"value": false,
"basis": "unreachable_from_entrypoint",
"basis_procedure_token": "2026.08.01-v3-a39688b8-5d34e636"
}
}
]
}
That returns two verdicts under npm:dompurify:GHSA-h8r8-wccr-v5f2, likely FIX_TODAY for the frontend and DEFER for the build, and counts as one scored alert.
reachable
Whether the vulnerable code is reachable in this context, and the basis for that call. value is true, false, or null; basis names why and must match the value. Your agent can determine reachability using the rubric's guidance or supply it from a specialized tool.
basis |
Pairs with | Meaning |
|---|---|---|
tool_confirmed |
true or false |
A reachability tool (e.g. call-graph analysis) produced this determination. |
manual_call_graph |
true |
Your own analysis confirmed the vulnerable symbol is reachable from an entry point with attacker-controlled input. |
code_not_loaded |
false |
The vulnerable package or module is not imported anywhere. |
unreachable_from_entrypoint |
false |
The code is loaded, but no path from any entry point reaches it. |
no_attacker_controlled_input |
false |
The code is reachable, but only with first-party / trusted inputs. |
undetermined |
null |
Neither a tool nor manual analysis could conclude. Use it when you analyzed this context and could not reach an answer, not as a default for analysis you have not done. Unless another factor is determined, the binding then scores REVIEW via missing_evidence_floor instead of being quietly deferred. |
Some advisories name no vulnerable symbol in the package's own API, because the flaw lives in vendored or statically linked code: a bundled OpenSSL, vendored C in an npm package. There is no call site to enumerate, so undetermined is the right basis, chosen deliberately rather than by default. Do not read the absent symbol as code_not_loaded. The advisory named nothing, so a quiet grep says nothing about whether the vulnerable code runs.
Before you settle for undetermined, read the full advisory with lookup_vulns. A scanner summary often omits the precondition the whole determination turns on, and the body usually names it.
basis_procedure_token
Required on every binding. Call the get_basis_procedure tool, or read the k9://rubric/basis-procedure resource; either returns a token, and you echo that token back in reachable.basis_procedure_token.
The token records that the reachability analysis ran against the current procedure. It proves the procedure was fetched, not that it was followed.
Every call mints a fresh token. One read covers all the bindings of the alerts you are about to score, but do not carry a token over from a prior session.
If you delegate reachability analysis, each worker fetches the procedure and supplies its own token. Do not paste one worker's token across a batch, and do not hand a worker a paraphrase of the procedure in place of the real thing.
A missing or invalid token fails the whole call with an error naming the offending finding_keys and the remedy, so a batch is never silently mis-scored. Fetch the procedure again and resubmit.
asset_context
All fields optional. Unknown fields are treated as unknown, not guessed. Unrecognized keys are rejected, so a typo surfaces as an error rather than a silent mis-score.
| Field | Values |
|---|---|
asset_criticality |
tier_1, tier_2, tier_3 |
data_classification |
public, internal, confidential, regulated |
network_exposure |
public_unauth, public_auth, internal, isolated |
lifecycle |
production, staging, dev |
regulatory_scope |
list of strings, e.g. ["PCI", "SOC2"] |
Response
The response is an object with the risk-scoring rubric_version, batch statistics (batch_stats), and a dictionary of verdicts keyed by finding_key.
Each finding_key maps to a list of verdicts, one per context binding, in the order you sent the bindings. An alert with one binding returns a one-element list, so the shape holds either way and your code does not branch on how many contexts an alert had.
Each verdict contains:
| Field | Description |
|---|---|
execution_context_id |
Which context this verdict is about, echoing the binding's value. Absent when you did not name one, so test for absence rather than for null. |
threat_vuln_id |
The CVE the KEV and EPSS evidence was read from, when that is not the id you sent. You get it when you send a GHSA, and when you send a CVE whose advisory carries a sibling with stronger evidence. Absent when the evidence came from the id you sent, so test for absence rather than for null. |
bucket |
FIX_TODAY, REVIEW, SCHEDULE, or DEFER, in descending order of urgency. The action recommendation. Drive your work from this. |
rationale |
One-line explanation of the verdict. |
factors |
threat, exposure, impact, each present / absent / unknown with a confidence and rationale. |
activated_floors |
Policy floors this verdict triggered. Empty means the factor assessment alone produced the verdict. A listed floor did not necessarily set the bucket; a floor can trigger and still be outranked by a higher recommendation. |
evidence |
The KEV / EPSS / reachability inputs echoed back, plus default_context_applied. |
risk_assessment |
NIST SP 800-30 rendering: a qualitative band and a 0 to 100 composite, as inherent, residual, and retired (inherent minus residual). Report residual; retired is the risk the analysis accounted for. |
Batch statistics
batch_stats summarizes the whole call so you can sanity-check a run before trusting it. The statistics primarily measure three quality dimensions: reachability discipline, how much scoring ran on defaulted context, and the batch size behind any noise-reduction claim.
Watch the two counts: alerts_total counts alerts and findings_total counts verdicts. They are equal until an alert carries more than one context binding. Reconcile the alert count you fetched against alerts_total, because comparing it to findings_total looks like a mismatch on any project that scores alerts in several contexts.
| Field | Type | Description |
|---|---|---|
alerts_total |
int | Alerts scored in this call, i.e. distinct finding_keys. Reconcile this against the alert count you fetched, and use it as the denominator for a noise-reduction figure. |
findings_total |
int | Verdicts produced, one per context binding. Equal to alerts_total until an alert carries more than one binding, and higher after that. Do not reconcile this against a fetched alert count. |
bindings_per_alert |
float | findings_total / alerts_total. 1.0 means every alert was scored in exactly one context. |
findings_without_context_binding |
list of strings | Alerts scored without naming any execution context, i.e. with a single unnamed binding. Read it together with risk_context_version: unnamed alerts while a version is present is an authoring gap worth fixing, usually a paths glob that matched no manifest, while unnamed alerts with no version just means the project has no risk-context file yet. An empty list is not proof that your paths are right, because this reports a missing binding and never a mis-routed one. Verify globs against the paths your real alerts report. |
buckets |
object | Count per verdict, e.g. {"FIX_TODAY": 1, "REVIEW": 2, "SCHEDULE": 3, "DEFER": 36}. These sum to findings_total, not alerts_total. |
reachability_basis_counts |
object | Per-basis histogram of the reachable.basis values you supplied. |
undetermined_fraction |
float | Share of verdicts scored on undetermined reachability. Above ~0.10 usually means reachability analysis was skipped, not that it was genuinely inconclusive. |
default_context_fraction |
float | Share of verdicts scored without caller-supplied asset_context. |
distinct_basis_tokens |
int | How many distinct basis_procedure_token values the batch carried, i.e. how many separate reads of the basis procedure. 1 across a large batch means a single read, which is expected when one agent worked alone and worth a look when the work was delegated. |
alias_resolved_count |
int | Alerts whose threat evidence came from a CVE other than the id you sent, counted per alert because resolution happens once per alert. There is nothing here for you to fix, which is why it is not a quality flag; it tells you how much of the batch's evidence k9 found under a different id than your scanner reported. |
risk_context_version |
string | The risk_context_version you sent, echoed back. |
risk_context_version_omitted |
bool | true when no risk-context file produced these verdicts. Distinct from default_context_fraction, which counts verdicts whose binding supplied no asset_context at all: an agent can pass an ad-hoc asset_context and never open the file. Report it to the reader rather than re-scoring for it, which is why it is not a quality flag. |
quality_flags |
list of strings | Threshold-derived flags that name a remedy, e.g. undetermined_above_10_percent, default_context_above_25_percent. Resolve these before reporting results. |
Example:
"batch_stats": {
"alerts_total": 38,
"findings_total": 42,
"bindings_per_alert": 1.1053,
"findings_without_context_binding": [],
"buckets": { "FIX_TODAY": 1, "REVIEW": 2, "SCHEDULE": 3, "DEFER": 36 },
"reachability_basis_counts": {
"code_not_loaded": 25,
"no_attacker_controlled_input": 8,
"tool_confirmed": 5,
"undetermined": 4
},
"undetermined_fraction": 0.095,
"default_context_fraction": 0.0,
"distinct_basis_tokens": 1,
"alias_resolved_count": 12,
"risk_context_version": "2026-07-29+87af4dc",
"risk_context_version_omitted": false,
"quality_flags": []
}
An empty quality_flags means the batch met the quality thresholds. A non-empty list is a signal to fix the inputs and re-score before acting on the verdicts.
Buckets
The four buckets are recommended actions, ordered by urgency: FIX_TODAY > REVIEW > SCHEDULE > DEFER.
REVIEW is the only bucket that makes no remediation claim. FIX_TODAY and SCHEDULE say the alert needs fixing on a given horizon, and DEFER says it is safe not to fix. When the evidence supports none of those, the tool returns REVIEW rather than guessing, and an engineer decides what to do next.
REVIEW ranks above SCHEDULE because an unresolved unknown on a material asset may turn out to be a FIX_TODAY, while a SCHEDULE verdict is already understood and planned. Do not dismiss a REVIEW verdict. Dismissing it records "we decided this is fine" when the truth is "we do not know yet." Leave it open and note what evidence would resolve it.
Policy floors
Floors raise the recommendation when a condition is serious enough. activated_floors lists every floor the verdict triggered, including any that were outranked by a higher recommendation.
kev_emergency: a KEV-listed vulnerability is never left at DEFER.ransomware_floor: known ransomware-campaign use forces FIX_TODAY.tier_1_asset: a real threat against a tier-1 asset is never left at DEFER.public_asset_floor: code determined unreachable on an internet-facing asset with real impact is raised to SCHEDULE, in case the reachability call is wrong.missing_evidence_floor: a verdict with an unknown factor and nothing known to rule the risk out is raised to REVIEW.
missing_evidence_floor fires when at least one of threat / exposure / impact is unknown and none of the following is true: threat confidently absent (a real EPSS reading below the p90 mark; the p90–p99 band is a signal to monitor, not a determination, so it does not count), reachability determined false, or impact determined absent. Any one of those is a reason the alert does not matter in this context, and the verdict rests on it.
It also fires on a verdict that would otherwise be SCHEDULE. That is deliberate: if the missing factor turned out to be present, the verdict would be FIX_TODAY, so the unknown may be hiding one. A verdict with all three factors determined can never trip this floor.
Example: an unreachable alert on a public asset
An illustrative unreachable alert on a public, critical asset. The values are representative of that case, not a captured run.
Request (one alert, unreachable but on a public, tier-1 service):
{
"findings": [
{
"kind": "software_vuln",
"finding_key": "pypi:examplelib:CVE-2025-12345",
"vuln_id": "CVE-2025-12345",
"context_bindings": [
{
"execution_context_id": "payments-api",
"reachable": {
"value": false,
"basis": "code_not_loaded",
"basis_procedure_token": "2026.08.01-v3-a39688b8-5d34e636"
},
"asset_context": {
"asset_criticality": "tier_1",
"network_exposure": "public_auth",
"lifecycle": "production"
}
}
]
}
]
}
Response (illustrative, abridged):
{
"rubric_version": "2026.08.01-v23",
"verdicts": {
"pypi:examplelib:CVE-2025-12345": [
{
"execution_context_id": "payments-api",
"bucket": "SCHEDULE",
"activated_floors": ["public_asset_floor"],
"factors": {
"threat": "absent",
"exposure": "absent",
"impact": "present"
},
"risk_assessment": {
"nist_800_30_qualitative": {
"inherent": { "band": "very_high", "label": "Very High" },
"residual": { "band": "moderate", "label": "Moderate" }
},
"nist_800_30_semi_quantitative": {
"inherent": { "composite": 100 },
"residual": { "composite": 50 },
"retired": 50
}
}
}
]
},
"batch_stats": { ... }
}
The code is not reachable, so exposure is honestly absent, but the public-asset floor keeps an alert on an internet-facing, material asset in the SCHEDULE queue. The residual score reflects the tail risk without inflating the exposure factor.
Example: a REVIEW verdict
An illustrative advisory that names no vulnerable symbol, so there is no call site to enumerate and no CVE alias to carry exploitation data. The values are representative of that case, not a captured run.
Request (one alert, reachability undetermined, on a confidential tier-1 service):
{
"findings": [
{
"kind": "software_vuln",
"finding_key": "pypi:cryptography:GHSA-537c-gmf6-5ccf",
"vuln_id": "GHSA-537c-gmf6-5ccf",
"context_bindings": [
{
"execution_context_id": "payments-api",
"reachable": {
"value": null,
"basis": "undetermined",
"basis_procedure_token": "2026.08.01-v3-a39688b8-5d34e636"
},
"asset_context": {
"asset_criticality": "tier_1",
"data_classification": "confidential",
"network_exposure": "internal",
"lifecycle": "production"
}
}
]
}
]
}
Response (the single verdict in that alert's list, abridged):
{
"execution_context_id": "payments-api",
"bucket": "REVIEW",
"activated_floors": ["missing_evidence_floor"],
"factors": {
"threat": "unknown",
"exposure": "unknown",
"impact": "present"
},
"risk_assessment": {
"nist_800_30_semi_quantitative": {
"inherent": { "composite": 100 },
"residual": { "composite": 100 },
"retired": 0,
"confidence": "low"
}
}
}
Two factors are unknown and nothing determined defeats the risk, so no remediation claim is supportable and the verdict is REVIEW. The analysis reached no conclusion, so there is nothing to retire: retired is 0 and the residual equals the inherent risk. Leave the alert open and record what evidence would resolve it. Where a fix is already available, upgrading may cost less than investigating, though that depends on your code and deployment, which this analysis cannot see.
Related
- Concept and worked examples: How to Prioritize CVEs by Risk, Not Severity.
- The rubric:
get_risk_scoring_rubrictool,k9://rubric/risk-scoringresource, orrisk_scoring_rubricprompt. - Look up the CVE behind an advisory id, to read the right advisory while you analyze reachability: resolve_vuln_ids.
- Where
asset_contextand context names come from: Configure risk context. - Set up the server: Configure the k9 MCP server.