Attackers have stopped needing headcount to scale capability. Cheap, capable open-weight models let a small crew generate, adapt, and fire exploits at a rate that used to require a team — so a defense that scales by hiring more triage engineers is bringing a linear cost curve to an exponential fight, and losing. The only durable answer is to decouple security outcomes from headcount: automate the work end to end, with AI that is controlled and measurable — where every decision is bounded, on the record, and cheaper than the human hour it replaces.
Dependency triage is one of the clearest places to prove that, because it is where an enormous amount of skilled human effort currently goes to die. A security engineer we spoke with told us about a moment that captures it. Their scanner had done something genuinely useful: it grouped every CVE affecting a single package version into one finding, instead of firing a dozen separate alerts for the same upgrade. Good. Then they looked closer. The finding was labeled Informational, but one of the CVEs bundled inside it had a relatively high EPSS score — a real signal that exploitation is being observed in the wild. The label and the CVE seemed to flatly contradict each other. Was the tool broken? Was it hiding something?
Neither. The finding was labeled that way because that specific high-EPSS CVE was not reachable — the application never enters the code path that touches the vulnerable function. But nothing in the finding said so until someone from our team explained it. That gap — between "here is a package with vulnerabilities" and "here is what your code actually exposes" — is where most triage time goes to die, and it is exactly the kind of skilled, repetitive judgment that should be automated rather than staffed.
Modern SCA tools have mostly figured out that firing one alert per CVE per package is noise. So they bundle: all the CVEs against some-package@1.24.1 collapse into a single finding with a single suggested upgrade. That is the right call for the queue. But it creates a second, subtler problem in how severity gets computed.
Take one finding we worked through: a Go 1.24.1 finding that bundles 32 CVEs in one package. Each of those CVEs typically affects a different function inside Go's standard library. The application calls a small subset of those functions. But the finding's severity label is the max across all 32 CVEs. So the finding pages someone on Critical — even when every Critical CVE in the bundle is for an encoding/asn1 code path the app never enters, and the only vulnerable function the app actually reaches is a Medium-severity bug in net/http.
The engineer who picks up that ticket does the work anyway. They read the advisory, they trace the imports, they figure out that the Critical is irrelevant and the real exposure is Medium. That is real, careful analysis — and the tool made them redo it by hand for a conclusion the tool could have reached itself. Multiply that across every bundled stdlib finding in every repo and you have the reason AppSec queues are where good engineers burn out.
The point is not that max-severity rollup is stupid. Absent any reachability data, taking the max is the only safe thing to do — you cannot under-report a Critical you have not ruled out. The problem is that the tool never tries to rule it out. As one of our engineers put it: the triage agent had been making the same worst-case call for every CVE under a finding, because with no scope or reachability data it had to assume worst-case. The result was a noisy queue, engineers triaging false positives, and autofix budget burned on vulnerabilities that were never shipping.
Reachability is not one question. It is two, and they cost very different amounts to answer.
import or require statements reference the package at all.We build the answer in that order, and the split is deliberate — it is the core of keeping the AI controlled and cheap. The deterministic layer does the filtering; the expensive AI layer only runs when the deterministic layer leaves something genuinely ambiguous.
For the first layer, we extract imported packages straight from the source AST using tree-sitter. The extractor walks every .js/.ts/.jsx/.tsx file — skipping vendored and test paths — and pulls the npm package names out of import, export, require, and dynamic import() nodes, with regex fallbacks because grammars model the import keyword inconsistently. It normalizes correctly, too: node: scheme imports and #-prefixed subpath imports (which always resolve intra-package) get stripped, scoped names like @aws-sdk/client-s3 are preserved, and lodash/fp collapses to lodash. There is even a fix for imports that resolve through tsconfig.json path aliases, because those point at internal source, and leaving them in would cause silent false-negative reachability. The same machinery exists for Rust, filtering stdlib crates and workspace-local references.
When the deterministic substrate gives a confident negative — the package simply is not imported anywhere — the AI subagent is skipped entirely. Every CVE under that finding gets an UNREACHED / HIGH-confidence / package-only verdict, at zero LLM cost. That is the discipline in miniature: the least AI token spend for the same correct answer, with the model reserved for the cases where its judgment actually earns its cost.
Here is the part that most reachability marketing skips. A positive signal and a negative signal are not equally trustworthy, and pretending they are is how you silently bury real vulnerabilities.
If tree-sitter finds Called=true, that proves an import edge exists in the source. HIGH confidence is appropriate. But Called=false is ambiguous. It might mean "we scanned the code and confirmed no static import." Or it might mean the scanner missed a dynamic import — require(varName), importlib.import_module, a plugin loader, framework auto-wiring, generated or transpiled code. Static analysis cannot see those.
So we deliberately downgrade negatives to MEDIUM confidence. A false is never trusted enough on its own to crush a finding's severity; the triage path still has to investigate before it can act on a "no." This asymmetry runs through the whole system. It is the difference between a tool that reduces your queue and a tool that quietly deletes findings you needed to see.
For the CVEs that survive the cheap filter — package is imported, so the vulnerable symbol might be reached — we run a per-CVE reachability subagent (Haiku 4.5). It emits a structured verdict per CVE: reached, unreached, or unknown; a confidence level; and a method that records how it decided, ranked by evidentiary strength — an LSP reference to the symbol, a textual grep match, package-only import, or pure AI judgment. Every verdict carries an evidence string.
Two design choices keep the token spend honest:
And when there is no data to reason over at all — the scanner never produced a reachability dossier because the build was unsupported or population failed — the agent does not spend money guessing. Every CVE gets UNKNOWN / LOW-confidence, synthesized for free. Spending real LLM budget to produce the same "unknown" verdicts we can write down for nothing would be exactly the waste we are trying to eliminate.
Recomputing severity is where a reachability system either earns trust or destroys it. Ours is built so that every failure mode fails toward more attention, never toward silently dropping a finding.
unreached demotes a CVE all the way toward Informational. A medium-confidence "no" demotes by a single band, and a low-confidence one leaves the severity untouched.unreached override the scanner's authoritative HIGH-confidence package-level "this dependency is used" signal. When the symbol analysis did not genuinely run and prove absence — an unbuildable repo, a missing lockfile, a gopls timeout — the unreached is demoted to unknown, which the severity math treats as fully reachable. Without this guardrail, one flaky language-server run would silently reclassify a reachable vulnerability as a false positive.UNKNOWN for it. If the model hallucinates a CVE that does not belong to the finding, we drop the hallucination, not the real one.We learned the importance of this the direct way. An internal audit of an earlier iteration caught the exact inverse failure — three genuinely vulnerable pinned dependencies were auto-marked false-positive because reachability was being treated as a suppression signal rather than a prioritization signal. We fixed it so that high-band CVEs are never auto-dismissed on reachability alone. Reachability retriages. It does not suppress.
This is the load-bearing distinction, so we will be blunt about it: per-CVE reachability does not remove findings. It reprioritizes them.
Every per-CVE verdict is persisted on the CVE row in Postgres — confidence, method, and evidence columns alongside the reachability flag — not held in transient agent state. The individual CVE record and its true CVSS severity are never deleted or altered. What changes is the finding-level rollup: instead of blindly taking the max across all 32 CVEs, the recomputed severity takes the max across the CVEs that are reached or unknown.
To make the rollup math concrete, here is how the 32 CVEs in a bundle like that sort into buckets:
So 21 of the 32 CVEs drop to Informational for the purposes of that rollup, 6 move down one band, and the rollup recomputes off the CVEs that were genuinely reached — the 3 reachable ones being the Medium-severity net/http bug, so the finding correctly lands at Medium rather than the original max-rollup Critical. We do not hide anything. We stop the irrelevant CVEs from drowning the real signal, and we let confidence, not optimism, decide how far each one moves.
And the recomputation is auditable end to end. For every CVE judged reachable, the system builds an evidence graph node for each distinct in-app call site, with a CAN_REACH edge to the finding and a human-readable explanation naming the vulnerable symbol and the CVE. You do not get a mysterious new number; you get the concrete file-and-line call sites that justify it, rendered in the platform alongside the rest of the code-context evidence. When we roll this out, we watch the recomputed severity and priority distributions against the pre-change baseline on the same tenant — so the effect of the feature is itself measured, not asserted.
The severity math is intentionally asymmetric in one more way that matters for trust. Triage-proven severity can rise above the classifier's original draft — if we prove a specific CVE is reachable at Critical, that is new ground truth the classifier did not have. But priority, which drives who gets paged, stays capped at the draft. We never let a reachability judgment silently page someone out of band.
The whole pipeline is gated behind a feature flag that defaults off. It rolls out on a per-stack canary before going global, and can be flipped back without losing the reachability data already collected — so a team can turn the severity math off and keep the evidence, or turn it on once they trust it, without re-running anything. And because the AI only ever adjudicates the genuinely ambiguous CVEs — the deterministic substrate settles the clear-cut ones for free — a security engineer who wants to review a specific verdict has the full evidence chain in front of them, not a black box to argue with.
That is the shape of the whole thing, and it is the shape of the larger bet: outcomes decoupled from headcount. The default is autonomous efficiency — the least human triage time and the least AI token spend to get the queue correctly prioritized. The override is always available. And every decision the system made is on the record, with the call sites to back it up. A CVE that is present but unreachable is still tracked, still queryable, still on the row where you left it — it is just no longer the reason a good engineer spent an afternoon confirming what the tool already knew.