feat(model-grounded-detection): tasks 2.1-2.2, the Joern seam and the taint verdict

The whole engine integration is one subprocess boundary: joern-parse builds a
cpg.bin, joern --script runs a shipped CPGQL query that prints its JSON between
fence markers, because Joern's stdout carries a banner and a REPL prompt around
whatever a script emits. Nothing is imported in-process, so the core install
keeps dependencies = [] -- a test walks the seam's AST to assert it imports
only the standard library and names no JVM binding.

Fail closed everywhere. A build or query that errors, times out, or prints
something unparseable yields no verdict and the caller reports suspicion. The
taint verdict distinguishes this from absence deliberately: a query returning
None means "the engine could not decide", which is not "there is no flow", and
conflating them is how a missing engine silently becomes a clean bill of health.

The verdict is where the ladder earns its keep. An unsanitized source->sink
path is model_entailed -- the graph establishes it and the LLM's claim is no
longer load-bearing. A path a sanitizer may break is model_corroborated: the
flow is real, but whether the sanitizer suffices is the semantic residual the
LLM answers, checked against this verdict rather than averaged over runs. The
chosen flow is picked by a total order (shortest, then lexicographic), so two
runs over one CPG cannot disagree -- Req 6.4 asserted directly.

The query itself is the part our flat IR could not do: reachableByFlows walks
the PDG, so `q = "..." + request.args["x"]; execute(q)` is found, which is
exactly the one-hop limit that held measured entailment at 2.2%.

Capability detection reuses semantic/engines.joern_available rather than
growing a second notion of "installed". The audit manifest is regenerated; the
new modules are named by Req 4/5/6 rather than counted as orphans.

Task 2.3, the go/no-go measurement, needs Joern installed and has not run.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
This commit is contained in:
Marius Ciepluch
2026-09-08 12:23:40 +02:00
co-authored by Claude Opus 5
parent 9ed0e45c5f
commit 2b973dc0db
10 changed files with 632 additions and 5 deletions
+5
View File
@@ -31,6 +31,11 @@ SPEC_OWNED = {
"model.specs": "model-grounded-detection Req 7.2-7.4: the security vocabularies the CPG queries are parameterised by",
"model.endpoint": "model-grounded-detection Req 8: the judge client, wired by model/judge.py in group 3",
"model.audit": "model-grounded-detection Req 3: this classifier; run by the maintainer, not by the scan",
"model.ladder": "model-grounded-detection Req 5: the evidence ladder every finding carries",
"model.taint": "model-grounded-detection Req 6.1: taint reachability over the CPG, the flow-family arbiter",
"cpg": "model-grounded-detection Req 4: the CPG seam package",
"cpg.backend": "model-grounded-detection Req 4.1/4.5: the single Joern subprocess boundary",
"cpg.capability": "model-grounded-detection Req 4.2/4.3: the engine probe that degrades to suspicion",
}
STANDALONE = {
+48
View File
@@ -0,0 +1,48 @@
"""The evidence ladder: what established a finding (model-grounded-detection Req 5).
Every finding carries the rung that established it, so a deterministic model result is never confused with an
LLM suspicion. The two middle rungs are the point of this feature:
suspicion a site was enumerated and something flagged it
model_corroborated the LLM's claim is consistent with the CPG -- the source it names reaches the sink it
names, or a guard its siblings carry is absent
model_entailed the CPG establishes it alone, independent of trusting the LLM
execution_confirmed a sandbox reproduced it (deferred tier, memory/C first)
`model_corroborated` and above are established with no model call, and deterministically: run the checker
twice over the same CPG and the verdict sequence is identical. That is what removes the run-to-run noise the
prior architecture tried to average away.
"""
from __future__ import annotations
from dataclasses import dataclass
from enum import StrEnum
class Rung(StrEnum):
SUSPICION = "suspicion"
CORROBORATED = "model_corroborated"
ENTAILED = "model_entailed"
EXECUTION_CONFIRMED = "execution_confirmed"
# Ascending strength; `_ORDER.index` is the comparison, so a new rung cannot be silently mis-ranked.
_ORDER = (Rung.SUSPICION, Rung.CORROBORATED, Rung.ENTAILED, Rung.EXECUTION_CONFIRMED)
def at_or_above(rung: Rung, threshold: Rung) -> bool:
return _ORDER.index(rung) >= _ORDER.index(threshold)
@dataclass(frozen=True)
class Verdict:
"""What the model established, and the evidence it established it from."""
rung: Rung
family: str
witness: str = "" # the taint path or the dominance fact; empty only at `suspicion`
contradiction: str = "" # why an LLM claim was dropped, when it was
__all__ = ["Rung", "Verdict", "at_or_above"]
+73
View File
@@ -0,0 +1,73 @@
"""Taint reachability over the CPG: the arbiter for the flow families (Req 6.1).
The verdict is the static analogue of a reproduced crash. An unsanitized source→sink path is `ENTAILED` — the
graph establishes it and the LLM's claim is no longer load-bearing. A path a sanitizer may break is
`CORROBORATED`: the flow is real, but whether the sanitizer is *sufficient* is a semantic judgement the model
cannot make, and that residual is the LLM's — checked against this verdict rather than averaged over runs.
No path at all yields `None`, not a negative verdict: the caller reports `suspicion`. And a query that failed
yields `None` too, because "the engine could not decide" is not "there is no flow" — conflating them is how a
missing engine would silently become a clean bill of health.
"""
from __future__ import annotations
from collections.abc import Mapping, Sequence
from ..cpg.backend import CpgResult
from .ladder import Rung, Verdict
from .specs import TaintSpec
def verdict(cpg: CpgResult, spec: TaintSpec, *, function: str = "") -> Verdict | None:
"""The strongest verdict the taint query supports for ``spec`` in ``function``, or ``None`` to stay at suspicion."""
rows = cpg.run(
"taint",
{"sources": spec.sources, "sinks": spec.sinks, "sanitizers": spec.sanitizers, "function": function},
)
flows = _flows(rows, function=function)
if not flows:
return None
unsanitized = [flow for flow in flows if not flow["sanitized"]]
# Deterministic by construction: sort, then take the first. Nothing here depends on engine ordering.
chosen = min(unsanitized or flows, key=_rank)
rung = Rung.ENTAILED if not chosen["sanitized"] else Rung.CORROBORATED
return Verdict(rung=rung, family=spec.family, witness=_witness(chosen))
def _flows(rows: object, *, function: str) -> list[dict[str, object]]:
"""The query's rows, filtered to the labeled function. A non-list (a failure) yields nothing."""
if not isinstance(rows, Sequence) or isinstance(rows, (str, bytes)):
return []
kept: list[dict[str, object]] = []
for row in rows:
if not isinstance(row, Mapping):
continue
if function and str(row.get("sinkMethod", "")) != function:
continue
kept.append({
"sink": str(row.get("sink", "")),
"sinkLine": str(row.get("sinkLine", "")),
"sinkMethod": str(row.get("sinkMethod", "")),
"source": str(row.get("source", "")),
"sanitized": bool(row.get("sanitized", False)),
"length": _as_int(row.get("length")),
})
return kept
def _as_int(value: object) -> int:
return int(value) if isinstance(value, int | float) else 0
def _rank(flow: Mapping[str, object]) -> tuple[int, str, str]:
"""Shortest flow first, then lexicographic — a total order, so two runs cannot disagree."""
return (_as_int(flow["length"]), str(flow["source"]), str(flow["sink"]))
def _witness(flow: Mapping[str, object]) -> str:
line = flow.get("sinkLine") or "?"
return f"{flow['source']} -> {flow['sink']} (line {line}, {flow['length']} steps)"
__all__ = ["verdict"]