feat(model-grounded-detection): group 1, subtract the noise architecture

99 source modules -> 81. 832 tests -> 646. The whole learning/ package (16
modules, ~4,100 lines) and six semantic/ modules removed, with the three gates
byte-identical to 2026-09-06-gate-baseline.txt at every step.

The sequence is what made it safe: vocabularies ported first (1.1), importers
detached second (1.2-1.3), deletion third (1.4). Every import break during the
deletion was therefore a detachment miss in the importer and was fixed there --
no module was restored to satisfy one. Both misses were the same shape and were
fixed as a class: operations.py and shapes.py each imported trailing_name from
the deleted variants.py, so it moved to semantic/ir.py, its natural home, and
both importers changed in one edit.

Ported before deleting: the closed guard vocabulary (verbatim, nothing else
holds it) into model/specs.py, which derives TaintSpec and DominanceSpec from
the retained ruleset facts rather than copying them. Re-homed: families ->
model/taxonomy, candidates -> model/candidates, endpoint -> model/endpoint,
plus Region, labeled_family and labeled_spans. labeled_family no longer falls
back to the LLM classifier -- an unlabeled pair is honestly unknown.

Four deviations, each recorded in the spec notes with its reason. improve/
evolve.py and validator.py were stripped, not deleted: each mixes the mechanism
lever (gone) with the deterministic rule-status loop `ousast improve` still
runs, which has no LLM in it; a test now asserts the lever's absence by symbol
so it cannot creep back. The learning CLI group became `model candidates`,
because deleting it outright would have orphaned the retained enumerator by
removing its only entry point. thinking moved from [learning] to [models].

A gap named rather than papered over: no retained sink fact routes by CWE to
path, output_encoding, untrusted_destination or prototype -- the same hole the
entailment ceiling diagnosed. The test asserts the seeded set and the gap set
exactly, so task 4.2 must update it when it authors those sinks.

The audit found exactly one genuine orphan: vectorstore.py, a bakeoff harness
with no production caller and no CLI, alive only through its own test. The five
named subsystems each carry an explicit decision. Manifest committed, with a
test asserting the tree still matches it.

Group 2 (the Joern go/no-go) has not started.

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:15:54 +02:00
co-authored by Claude Opus 5
parent fc4dab97fb
commit 9ed0e45c5f
86 changed files with 1710 additions and 12158 deletions
+7
View File
@@ -0,0 +1,7 @@
"""The model layer: a deterministic program model that arbitrates detector claims.
The taxonomy routes work, the candidate enumerator feeds the `suspicion` band, the specs carry the security
vocabularies, and (from group 2) the CPG-backed taint and dominance checks establish the rungs above it.
"""
from __future__ import annotations
+166
View File
@@ -0,0 +1,166 @@
"""Module audit: what is load-bearing, what stands alone, and what is orphaned (Req 3).
The tool outgrew itself — 99 modules and 832 tests at the point this feature began, a large share of them
serving an architecture its own measurements retired. This classifier exists so outgrowth is visible and
checkable rather than a thing someone notices two specs later.
A module is:
load_bearing reachable from a gate, the scan CLI, or a surviving requirement
standalone_capability its own entry point and users, not on the scan path
orphaned no importer, no entry point, no requirement — it should not be in the tree
Pure stdlib, no imports of the package it audits: it reads the import graph with ``ast`` so a broken module
is still classifiable.
"""
from __future__ import annotations
import ast
from collections.abc import Iterable, Mapping
from dataclasses import dataclass
from pathlib import Path
PACKAGE = "openultrasast"
# Entry points a module can be reachable from. The three gates are the contract; the CLI is the product.
ROOTS = ("gate", "map_gate", "pair_gate", "cli", "__main__")
# Modules that stand alone: their own CLI subcommand or external protocol, deliberately off the scan path.
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",
}
STANDALONE = {
"mcp": "narrow MCP server over stdio; its own `ousast mcp` entry point and OpenCode integration",
"skills": "skill router; consumed by mcp and by the OpenCode integration, not by the scan path",
"fusion": "two-panel adjudication engine; reached from the scan's report stage and used standalone",
"harness_ext": "HarnessX extension surface; loaded by name when the optional harness is present",
"hunter_harness": "harness wiring for the tool hunter; used by the hunter path and by mcp",
"stage_processors": "HarnessX slot-contracted scan stages; pinned by the zero-dependency guard in test_gate",
"slot_contract": "the slot/contract protocol stage_processors implements",
}
@dataclass(frozen=True)
class ModuleAudit:
module: str
classification: str # load_bearing | standalone_capability | orphaned
reason: str
importers: tuple[str, ...]
def module_name(path: Path, root: Path) -> str:
relative = path.relative_to(root).with_suffix("")
parts = [part for part in relative.parts if part != "__init__"]
return ".".join(parts)
def import_graph(root: Path) -> dict[str, set[str]]:
"""``module -> the modules it imports`` for every module under ``root``, resolved to package-relative names."""
graph: dict[str, set[str]] = {}
for path in sorted(root.rglob("*.py")):
if "__pycache__" in path.parts:
continue
name = module_name(path, root)
# For a package's `__init__`, the containing package *is* this module, so `from .x import y` resolves to
# `<name>.x`; for a plain module it resolves against the parent. Getting this backwards made every module
# a package re-exports look unimported.
is_package = path.name == "__init__.py"
imported: set[str] = set()
try:
tree = ast.parse(path.read_text())
except (OSError, SyntaxError):
graph[name] = imported
continue
package_parts = name.split(".") if is_package else name.split(".")[:-1]
package_parts = [part for part in package_parts if part]
for node in ast.walk(tree):
if isinstance(node, ast.ImportFrom):
if node.level:
base = package_parts[: len(package_parts) - node.level + 1]
target = ".".join([*base, node.module]) if node.module else ".".join(base)
else:
target = node.module or ""
if target.startswith(f"{PACKAGE}."):
target = target[len(PACKAGE) + 1 :]
elif target == PACKAGE:
target = ""
else:
continue
if target:
imported.add(target)
elif isinstance(node, ast.Import):
for alias in node.names:
if alias.name.startswith(f"{PACKAGE}."):
imported.add(alias.name[len(PACKAGE) + 1 :])
graph[name] = imported
return graph
def _reachable(graph: Mapping[str, set[str]], roots: Iterable[str]) -> set[str]:
seen: set[str] = set()
stack = [root for root in roots if root in graph]
while stack:
current = stack.pop()
if current in seen:
continue
seen.add(current)
for target in graph.get(current, ()):
# an import of `a.b` also keeps the package `a` alive
for candidate in (target, target.rsplit(".", 1)[0]):
if candidate in graph and candidate not in seen:
stack.append(candidate)
return seen
def audit(root: Path) -> tuple[ModuleAudit, ...]:
"""Classify every module under ``root``."""
graph = import_graph(root)
importers: dict[str, set[str]] = {name: set() for name in graph}
for name, targets in graph.items():
for target in targets:
for candidate in (target, target.rsplit(".", 1)[0]):
if candidate in importers and candidate != name:
importers[candidate].add(name)
live = _reachable(graph, ROOTS)
results: list[ModuleAudit] = []
for name in sorted(graph):
if not name: # the package __init__ itself
continue
who = tuple(sorted(importers[name]))
if name in SPEC_OWNED:
results.append(ModuleAudit(name, "load_bearing", SPEC_OWNED[name], who))
elif name in ROOTS:
results.append(ModuleAudit(name, "load_bearing", "entry point", who))
elif name in STANDALONE:
results.append(ModuleAudit(name, "standalone_capability", STANDALONE[name], who))
elif name in live:
results.append(ModuleAudit(name, "load_bearing", f"reachable from {', '.join(ROOTS[:3])} or the CLI", who))
elif who:
results.append(ModuleAudit(name, "load_bearing", f"imported by {', '.join(who)}", who))
else:
results.append(ModuleAudit(name, "orphaned", "no importer, no entry point, no surviving requirement", who))
return tuple(results)
def to_dict(results: Iterable[ModuleAudit]) -> dict[str, object]:
rows = list(results)
counts: dict[str, int] = {}
for row in rows:
counts[row.classification] = counts.get(row.classification, 0) + 1
return {
"modules": len(rows),
"counts": dict(sorted(counts.items())),
"orphaned": [row.module for row in rows if row.classification == "orphaned"],
"standalone": {row.module: row.reason for row in rows if row.classification == "standalone_capability"},
"rows": [
{"module": row.module, "classification": row.classification, "reason": row.reason, "importers": list(row.importers)}
for row in rows
],
}
__all__ = ["ModuleAudit", "SPEC_OWNED", "STANDALONE", "audit", "import_graph", "module_name", "to_dict"]
+388
View File
@@ -0,0 +1,388 @@
"""What a detector is asked to judge, enumerated deterministically (constrained-detector, Req 1).
The detector stops searching. Measured on 2026-09-06, 120 runs of the unconstrained loop over the same twelve pairs
produced 120 distinct tool-call trajectories, and outcome disagreement tracked trajectory divergence rather than
judgement: the model was spending its budget guessing grep patterns to find sinks the semantic IR already knows.
So something deterministic decides what the model looks at, and that enumerator becomes a hard ceiling on recall.
Which is why it was measured before any of this was built: the pattern ruleset finds a candidate inside the labeled
function for **12.1%** of the corpus — below the 64.9% the unconstrained loop already reaches — while the IR's call
sites and bindings reach **96.6%**, a median of ten sites per function. The ruleset is never the generator here.
Ordering is `(path, line, name)` and the bound is a constant, so the batches the judgment step forms, and therefore
the sequence of model calls, are determined by the input alone.
"""
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from pathlib import Path
from typing import Literal
CandidateKind = Literal["call", "bind", "operation", "config"]
# What can carry each family's bug. An injection lives at a call; an access-control bug is the *absence* of a guard
# around an operation, so its candidate is the operation and what the model needs is the guards in scope.
FAMILY_SHAPE: dict[str, tuple[CandidateKind, ...]] = {
"injection": ("call",),
"path": ("call",),
"deserialization": ("call",),
"untrusted_destination": ("call",),
"output_encoding": ("call",),
"memory": ("call",),
"prototype": ("call", "bind"),
"access_control": ("operation",),
# A permissive default is as often an argument to a call — `cors({origin: "*"})`, `app.use(session({secure:
# false}))` — as it is a binding. Measured: with bindings alone the shape missed `createApplication` and
# `getAllowedHosts`, whose whole bug is inside a call argument.
"config_secrets": ("call", "bind", "config"),
"unknown": ("call", "bind"),
}
# The p90 of the measured distribution is 22 sites per labeled function, so two batches cover all but the tail.
MAX_CANDIDATES_PER_CALL = 12
_MAX_TEXT = 400
_LANGUAGE = {"python": "python", "javascript": "javascript", "typescript": "typescript", "c_cpp": "c", "java": "java"}
@dataclass(frozen=True)
class Candidate:
"""One site the model will be asked a single bounded question about."""
path: str
line: int
kind: CandidateKind
name: str
text: str
function: str
arg_texts: tuple[str, ...] = ()
arg_names: tuple[tuple[str, ...], ...] = ()
# What the IR resolved for the arguments, nearest binding first. This is the work the model used to do by
# grepping, and the reason it no longer has to.
binding_chain: tuple[str, ...] = ()
guards: tuple[str, ...] = ()
@property
def id(self) -> str:
return f"{self.path}:{self.line}:{self.name}"
def to_dict(self) -> dict[str, object]:
return {
"id": self.id,
"kind": self.kind,
"name": self.name,
"line": self.line,
"function": self.function,
"text": self.text,
"arguments": list(self.arg_texts),
"binding_chain": list(self.binding_chain),
"guards": list(self.guards),
}
@dataclass(frozen=True)
class CandidateSet:
"""The sites to judge, what was dropped past the bound, and why there is nothing when there is nothing."""
candidates: tuple[Candidate, ...] = ()
discarded: int = 0
reason: str = "" # "", unsupported_language, no_candidate
function_found: bool = True
def enumerate_candidates(
root: Path,
region: object,
family: str,
*,
taxonomy: object,
limit: int = MAX_CANDIDATES_PER_CALL,
) -> CandidateSet:
"""Every site in the region that could carry ``family``'s bug, ordered and bounded."""
del taxonomy # the family id is the key; the taxonomy validates it elsewhere
from ..semantic.ir import parse_file
path = str(getattr(region, "path", "") or "")
wanted = str(getattr(region, "function", "") or "")
target = root / path
if not target.is_file():
return CandidateSet(reason="unsupported_language", function_found=False)
text = target.read_text(errors="ignore")
ir = parse_file(path, text, _language_of(path))
if not ir.parse_ok:
return CandidateSet(reason="unsupported_language", function_found=False)
functions = [item for item in ir.functions if item.name != "<module>" and (not wanted or item.name == wanted)]
if wanted and not functions:
return CandidateSet(reason="no_candidate", function_found=False)
kinds = FAMILY_SHAPE.get(family, ("call", "bind"))
lines = text.splitlines()
found: list[Candidate] = []
for function in functions:
guards = _guards(ir, function, text, path) if "operation" in kinds else ()
found.extend(_from_function(path, function, kinds, lines, guards))
found.sort(key=lambda item: (item.path, item.line, item.name))
# One question per site: a chained expression reaches the IR as several calls at the same line and name — one
# bare, one carrying the arguments — and asking twice would spend budget and double-count the answer. Keep the
# richest, because `filter_by(user_id=owner)` and a bare `filter_by` are the same site and only one of them
# says what the constraint was.
best: dict[str, Candidate] = {}
for item in found:
current = best.get(item.id)
if current is None or (len(item.arg_texts), len(item.binding_chain)) > (len(current.arg_texts), len(current.binding_chain)):
best[item.id] = item
found = sorted(best.values(), key=lambda item: (item.path, item.line, item.name))
if not found:
return CandidateSet(reason="no_candidate")
return CandidateSet(candidates=tuple(found[:limit]), discarded=max(len(found) - limit, 0))
def _from_function(path: str, function: object, kinds: Sequence[str], lines: Sequence[str], guards: tuple[str, ...]): # type: ignore[no-untyped-def]
binds = tuple(getattr(function, "binds", ()) or ())
name = str(getattr(function, "name", ""))
out: list[Candidate] = []
if "call" in kinds or "operation" in kinds:
kind: CandidateKind = "operation" if "operation" in kinds else "call"
for site in getattr(function, "calls", ()) or ():
out.append(
Candidate(
path=path,
line=site.line,
kind=kind,
name=site.name,
text=_line_text(lines, site.line),
function=name,
arg_texts=tuple(site.arg_texts),
arg_names=tuple(tuple(names) for names in site.arg_names),
binding_chain=_chain(binds, [n for names in site.arg_names for n in names], site.line),
guards=guards,
)
)
if "bind" in kinds or "config" in kinds:
for bind in binds:
if "config" in kinds and "bind" not in kinds and not bind.is_constant:
continue
out.append(
Candidate(
path=path,
line=bind.line,
kind="config" if (bind.is_constant and "config" in kinds) else "bind",
name=bind.name,
text=_line_text(lines, bind.line),
function=name,
arg_texts=(bind.value_text[:_MAX_TEXT],),
arg_names=(tuple(bind.names),),
binding_chain=_chain(binds, list(bind.names), bind.line),
guards=guards,
)
)
return out
def _chain(binds: Sequence[object], names: Sequence[str], before: int, depth: int = 4) -> tuple[str, ...]:
"""Walk the bindings backwards from the names an argument mentions, nearest first.
This is exactly the work the unconstrained loop was doing with `grep_repo`, and it is deterministic."""
chain: list[str] = []
seen: set[str] = set()
wanted = [name for name in names if name]
for _ in range(depth):
step = None
for bind in sorted(binds, key=lambda item: -int(getattr(item, "line", 0))):
line = int(getattr(bind, "line", 0))
bound = str(getattr(bind, "name", ""))
if line >= before or bound not in wanted or bound in seen:
continue
step = bind
break
if step is None:
break
seen.add(str(step.name)) # type: ignore[attr-defined]
chain.append(f"{step.name} = {str(step.value_text)[:_MAX_TEXT]}") # type: ignore[attr-defined]
before = int(step.line) # type: ignore[attr-defined]
wanted = [name for name in getattr(step, "names", ()) or () if name]
if not wanted:
break
return tuple(chain)
def _guards(ir: object, function: object, text: str, path: str) -> tuple[str, ...]:
"""Decorators and in-body witnesses that discharge an obligation on this function, by identifier only."""
try:
from ..semantic.facts import load_facts
from ..semantic.obligations import load_obligation_facts
from ..semantic.obligations.operations import find_discharges
except ImportError: # pragma: no cover - the obligations facts are shipped, this is belt and braces
return ()
name = str(getattr(function, "name", ""))
try:
witnesses = find_discharges(ir, load_obligation_facts(), load_facts(), text=text) # type: ignore[arg-type]
except Exception: # noqa: BLE001 - a missing fact file must not stop enumeration
return ()
del path
return tuple(sorted({item.detail or item.fact_id for item in witnesses if item.function == name}))
def _line_text(lines: Sequence[str], line: int) -> str:
return lines[line - 1].strip()[:_MAX_TEXT] if 0 < line <= len(lines) else ""
def _language_of(path: str) -> str:
from ..preprocess import detect_language
return _LANGUAGE.get(detect_language(Path(path)), detect_language(Path(path)))
@dataclass(frozen=True)
class CandidateReport:
"""The ceiling: what a candidate-driven detector could find before any model is asked anything.
This is the number that decides whether the design continues. A generator that cannot put a candidate inside
the labeled function can never find that pair's bug however good the judgement is, so the ceiling is published
as the limit on recall rather than discovered later as a disappointment (Req 1.3)."""
generator: str = "ir"
per_family: dict[str, dict[str, object]] = field(default_factory=dict)
per_slice: dict[str, dict[str, object]] = field(default_factory=dict)
gaps: tuple[tuple[str, str, str], ...] = () # (slice, pair, reason)
def to_dict(self) -> dict[str, object]:
return {
"generator": self.generator,
"per_family": {name: dict(block) for name, block in sorted(self.per_family.items())},
"per_slice": {name: dict(block) for name, block in sorted(self.per_slice.items())},
"gaps": [list(row) for row in self.gaps],
}
def ceiling(cases: Sequence[object], *, taxonomy: object, generator: str = "ir") -> CandidateReport:
"""Per family and per slice, the fraction of labeled functions a generator can put a candidate inside.
``generator`` is ``ir`` or ``ruleset``. The ruleset arm exists so the comparison that rejected it — 12.1%
against 96.6% on the web slices — stays a measurement rather than a remembered number."""
import tempfile
from ..pairs import _materialize_side, _overlay_scan, _quick_scan
families: dict[str, dict[str, int]] = {}
slices: dict[str, dict[str, int]] = {}
gaps: list[tuple[str, str, str]] = []
sites: dict[str, list[int]] = {}
for case in cases:
family = labeled_family(case)
slice_name = str(getattr(case, "slice", "") or "")
region_path = str(getattr(case, "relpath", "") or "")
with tempfile.TemporaryDirectory(prefix="ousast-ceiling-") as scratch:
root = _materialize_side(Path(scratch) / "vuln", case, side="vuln") # type: ignore[arg-type]
if generator == "ruleset":
ranges = _overlay_scan(root).ranges
spans = labeled_spans(case, ranges)
found = [
finding
for finding in _quick_scan(root)
if finding.line is not None and any(path == finding.path and start <= finding.line <= end for path, start, end in spans)
]
count, reason = len(found), ("" if found else "no_candidate")
else:
labeled = [str(getattr(row, "function", "") or "") for row in getattr(case, "expected", ()) or ()]
result = enumerate_candidates(
root,
Region(region_path, next((name for name in labeled if name), None)),
family,
taxonomy=taxonomy,
limit=10**6, # the ceiling is about coverage, never about the batch bound
)
count, reason = len(result.candidates), result.reason
for bucket, key in ((families, family), (slices, slice_name)):
block = bucket.setdefault(key, {"pairs": 0, "with_candidate": 0})
block["pairs"] += 1
block["with_candidate"] += 1 if count else 0
if count:
sites.setdefault(family, []).append(count)
else:
gaps.append((slice_name, str(getattr(case, "name", "")), reason or "no_candidate"))
per_family: dict[str, dict[str, object]] = {}
per_slice: dict[str, dict[str, object]] = {}
for bucket, out in ((families, per_family), (slices, per_slice)):
for key, block in bucket.items():
pairs = block["pairs"]
row: dict[str, object] = {
"pairs": pairs,
"with_candidate": block["with_candidate"],
"ceiling": (block["with_candidate"] / pairs) if pairs else 0.0,
}
if bucket is families:
row["sites"] = _distribution(sites.get(key, []))
out[key] = row
return CandidateReport(generator=generator, per_family=per_family, per_slice=per_slice, gaps=tuple(sorted(gaps)))
def _distribution(counts: list[int]) -> dict[str, int]:
if not counts:
return {"min": 0, "median": 0, "p90": 0, "max": 0}
counts = sorted(counts)
return {
"min": counts[0],
"median": counts[len(counts) // 2],
"p90": counts[min(int(len(counts) * 0.9), len(counts) - 1)],
"max": counts[-1],
}
def labeled_family(case: object) -> str:
"""The family a pair is labeled with, or ``unknown``.
The deleted ``learning/rounds.py`` fell back to the LLM classifier here. Nothing does now: an unlabeled
pair is honestly ``unknown`` rather than guessed, which is the same discipline the evidence ladder applies
to a claim the model cannot arbitrate.
"""
for row in getattr(case, "expected", ()) or ():
family = getattr(row, "family", None)
if family:
return str(family)
return "unknown"
def labeled_spans(
case: object, ranges: Mapping[str, Sequence[tuple[str, int, int]]], *, function: str = ""
) -> tuple[tuple[str, int, int], ...]:
"""The (path, start, end) spans of the labeled functions; ``function`` overrides the label for the fixed side.
Re-homed from the deleted ``learning/scoring.py`` — the enumerator's ceiling needs it to know which region a
candidate had to fall inside, and it carries none of the K-run machinery that surrounded it.
"""
wanted = {function} if function else {str(getattr(row, "function", "") or "") for row in getattr(case, "expected", ()) or ()}
wanted.discard("")
found: list[tuple[str, int, int]] = []
for path, entries in ranges.items():
for name, start, end in entries:
if name in wanted:
found.append((path, start, end))
return tuple(found)
@dataclass(frozen=True)
class Region:
"""The two fields ``enumerate_candidates`` reads: where to look, and which function bounds the search.
Public since the detector module that used to own this type was removed with the noise architecture — the
enumerator still needs a name for its input, and callers still need one to construct.
"""
path: str
function: str | None
__all__ = [
"FAMILY_SHAPE",
"ceiling",
"MAX_CANDIDATES_PER_CALL",
"Candidate",
"CandidateKind",
"CandidateReport",
"CandidateSet",
"Region",
"enumerate_candidates",
"labeled_family",
"labeled_spans",
]
+228
View File
@@ -0,0 +1,228 @@
"""The chat endpoint, resolved independently of embeddings (learning-harness, Req 6.6).
Detectors, the classifier's model tier, the proposer and the judge all talk to one chat endpoint;
prove-order retrieval keeps its own embedding endpoint, because the chat provider we default to has
none. Resolution order is explicit override, the scripted client flag, an endpoint named in the
config, DeepSeek, then OpenRouter. ``DeepSeekChatClient`` adapts the provider's quirks: thinking is
on by default there and silently ignores ``temperature``, so it is disabled explicitly; the reasoning
field is kept on the reply for the caller to replay across tool turns; JSON mode has no schema and
occasionally returns empty content, so one retry is built in. Prices are data, and an unpriced model
reports no cost rather than a made-up one.
"""
from __future__ import annotations
import os
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from typing import Literal
from ..config import ResolvedConfig
from ..provider.openrouter import OpenRouterChatClient, OpenRouterError
from ..tool_hunter import CLIENT_ENV, ChatClient, ChatResponse, chat_response_from_message, scripted_hunter_client
DEEPSEEK_BASE_URL = "https://api.deepseek.com" # no version suffix: the platform rejects /v1
DEEPSEEK_KEY_ENV = "DEEPSEEK_API_KEY"
DEEPSEEK_BASE_ENV = "DEEPSEEK_BASE_URL"
OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
DEFAULT_DETECTOR_MODEL = "deepseek-v4-flash"
DEFAULT_JUDGE_MODEL = "deepseek-v4-pro"
Provider = Literal["deepseek", "openrouter", "custom", "scripted"]
_SCRIPTED_FLAGS = frozenset({"scripted", "script", "dump", "dump-only", "unsafe", "unsafe-snippet"})
@dataclass(frozen=True)
class Prices:
"""USD per million tokens. DeepSeek platform pricing, peak rate, recorded 2026-09-06."""
cache_hit_per_m: float
input_per_m: float
output_per_m: float
_PRICES: dict[str, Prices] = {
"deepseek-v4-flash": Prices(cache_hit_per_m=0.014, input_per_m=0.44, output_per_m=1.32),
"deepseek-v4-flash-vision-exp": Prices(cache_hit_per_m=0.014, input_per_m=0.44, output_per_m=1.32),
"deepseek-v4-pro": Prices(cache_hit_per_m=0.044, input_per_m=1.32, output_per_m=3.96),
}
def price_of(model: str) -> Prices | None:
return _PRICES.get(model.strip())
@dataclass(frozen=True)
class ChatEndpoint:
"""What the run should record about where the model calls went. Never holds the key."""
provider: Provider
base_url: str
thinking: bool # the provider's models reason by default, so the adapter disables it explicitly
prices: Prices | None = None
def cost(self, usage: Mapping[str, object] | None) -> float | None:
"""Dollars for one call from the provider's own usage fields, or None when the model has no recorded price."""
if self.prices is None or not isinstance(usage, Mapping):
return None
cache_hit = _tokens(usage.get("prompt_cache_hit_tokens"))
miss = usage.get("prompt_cache_miss_tokens")
input_tokens = _tokens(miss) if miss is not None else max(_tokens(usage.get("prompt_tokens")) - cache_hit, 0)
output_tokens = _tokens(usage.get("completion_tokens"))
return (
cache_hit * self.prices.cache_hit_per_m + input_tokens * self.prices.input_per_m + output_tokens * self.prices.output_per_m
) / 1_000_000
class DeepSeekChatClient:
"""Adapt an OpenAI-shaped chat client to what detectors and the classifier need."""
def __init__(self, client: OpenRouterChatClient, *, disable_thinking: bool = True, endpoint: ChatEndpoint | None = None) -> None:
self._client = client
self._disable_thinking = disable_thinking
self.endpoint = endpoint
self.usage: list[dict[str, object]] = [] # one entry per call, for the round's cost meter
def complete(
self,
*,
model: str,
messages: list[dict[str, object]],
tools: list[dict[str, object]],
timeout_seconds: int = 60,
json_object: bool = False,
logprobs: bool = False,
) -> ChatResponse:
response = self._call(
model=model, messages=messages, tools=tools, timeout_seconds=timeout_seconds, json_object=json_object, logprobs=logprobs
)
if json_object and not (response.content or "").strip():
response = self._call(
model=model, messages=messages, tools=tools, timeout_seconds=timeout_seconds, json_object=json_object, logprobs=logprobs
) # documented behaviour: JSON mode occasionally returns empty content. One retry, never a loop.
return response
def cost_usd(self) -> float:
"""Recorded cost of the calls made so far; calls whose model has no price contribute nothing."""
if self.endpoint is None:
return 0.0
return sum(self.endpoint.cost(entry) or 0.0 for entry in self.usage)
def _call(
self,
*,
model: str,
messages: Sequence[Mapping[str, object]],
tools: Sequence[Mapping[str, object]],
timeout_seconds: int,
json_object: bool,
logprobs: bool,
) -> ChatResponse:
extra: dict[str, object] = {}
if self._disable_thinking:
extra["thinking"] = {"type": "disabled"} # otherwise temperature is silently ignored
if json_object:
extra["response_format"] = {"type": "json_object"}
if logprobs:
extra["logprobs"] = True
extra["top_logprobs"] = 1
raw = getattr(self._client, "complete_chat_raw", None)
if callable(raw):
payload = raw(
model=model, messages=list(messages), tools=list(tools) or None, timeout_seconds=timeout_seconds, extra_body=extra
)
usage = payload.get("usage") if isinstance(payload, dict) else None
if isinstance(usage, dict):
self.usage.append(dict(usage))
message = _message_of(payload)
else: # a stub or a client without the raw call
message = self._client.complete_chat(
model=model, messages=list(messages), tools=list(tools) or None, timeout_seconds=timeout_seconds, extra_body=extra
)
return chat_response_from_message(message)
def resolve_chat_endpoint(config: ResolvedConfig, *, override: ChatClient | None = None) -> tuple[ChatClient, ChatEndpoint] | None:
"""The chat client for detectors, classifier, proposer and judge, with what to record about it."""
if override is not None:
return override, ChatEndpoint(provider="scripted", base_url="", thinking=False)
flag = os.environ.get(CLIENT_ENV, "").strip().lower()
if flag in _SCRIPTED_FLAGS:
return scripted_hunter_client(flag), ChatEndpoint(provider="scripted", base_url="", thinking=False)
model = resolve_models(config)[0]
configured = config.models.chat_base_url
key_env = config.models.chat_api_key_env
if configured or key_env:
key = os.environ.get(key_env or DEEPSEEK_KEY_ENV, "")
base_url = configured or DEEPSEEK_BASE_URL
return _deepseek_client(key, base_url, model, provider=_provider_of(base_url), thinking=config.models.thinking)
deepseek_key = os.environ.get(DEEPSEEK_KEY_ENV)
if deepseek_key:
return _deepseek_client(
deepseek_key,
os.environ.get(DEEPSEEK_BASE_ENV, DEEPSEEK_BASE_URL),
model,
provider="deepseek",
thinking=config.models.thinking,
)
if os.environ.get("OPENROUTER_API_KEY"):
try:
client = OpenRouterChatClient.from_env()
except OpenRouterError:
return None
endpoint = ChatEndpoint(provider="openrouter", base_url=client.base_url, thinking=False, prices=price_of(model))
return DeepSeekChatClient(client, disable_thinking=False, endpoint=endpoint), endpoint
return None
def resolve_models(config: ResolvedConfig) -> tuple[str, str]:
"""(detector model, judge model) from `[models]`, then the shipped defaults."""
detector = config.models.hunter or DEFAULT_DETECTOR_MODEL
judge = config.models.judge or DEFAULT_JUDGE_MODEL
return detector, judge
def _deepseek_client(key: str, base_url: str, model: str, *, provider: Provider, thinking: bool = False) -> tuple[ChatClient, ChatEndpoint]:
endpoint = ChatEndpoint(provider=provider, base_url=base_url.rstrip("/"), thinking=thinking, prices=price_of(model))
client = OpenRouterChatClient(api_key=key, base_url=endpoint.base_url)
return DeepSeekChatClient(client, disable_thinking=not thinking, endpoint=endpoint), endpoint
def _provider_of(base_url: str) -> Provider:
host = base_url.lower()
if "deepseek" in host:
return "deepseek"
return "openrouter" if "openrouter" in host else "custom"
def _message_of(payload: object) -> Mapping[str, object]:
"""The reply message, carrying the choice's `logprobs` with it.
OpenAI-shaped providers put `logprobs` on the *choice*, beside the message rather than inside it. Reading only
the message meant the confidence signal was always absent, so a knob meant to let the classifier abstain when
it is unsure read as "never unsure" whatever the model said."""
if isinstance(payload, Mapping):
choices = payload.get("choices")
if isinstance(choices, Sequence) and choices and isinstance(choices[0], Mapping):
choice = choices[0]
message = choice.get("message")
if isinstance(message, Mapping):
logprobs = choice.get("logprobs")
return {**message, "logprobs": logprobs} if logprobs is not None else message
return {}
def _tokens(value: object) -> int:
return int(value) if isinstance(value, int | float) else 0
__all__ = [
"DEEPSEEK_BASE_URL",
"DEFAULT_DETECTOR_MODEL",
"DEFAULT_JUDGE_MODEL",
"ChatEndpoint",
"DeepSeekChatClient",
"Prices",
"price_of",
"resolve_chat_endpoint",
"resolve_models",
]
+156
View File
@@ -0,0 +1,156 @@
"""The security vocabularies the model layer arbitrates with (model-grounded-detection, Req 7.2-7.4).
Two kinds of knowledge live here, and they are held differently on purpose.
The **guard vocabulary** is ported verbatim from ``semantic/variants.py``, whose optimisation loop this
feature deleted. Nothing else holds it: it is the closed set of ways a fix discharges a flow, and it tells
the model what a *fixed* twin looks like. Order matters — first match wins.
The **taint and dominance specs** are *derived* from data that survives: the source/sink/sanitizer facts in
``ruleset/semantic`` and the obligated operations and dischargers in ``ruleset/obligations``. Copying them
here would create a second place to update, so this module reads them through the retained loaders and
groups them by family via the taxonomy's CWE map.
Read-only by construction (Req 7.4, the verifier boundary): this module exposes no writer, opens no file for
writing, and holds no mutable module state. An LLM or a future optimiser has nothing here to rewrite.
"""
from __future__ import annotations
import re
from collections.abc import Mapping
from dataclasses import dataclass
from ..semantic.facts import SemanticFacts, load_facts
from ..semantic.obligations.facts import ObligationFacts, load_obligation_facts
from .taxonomy import FamilyTaxonomy, load_families
# --- The guard vocabulary (ported verbatim; nothing else holds it) ----------------------------------------
GUARD_KINDS = ("null_test", "bounds_test", "allowlist_test", "auth_check", "parameterized_call", "type_change", "none")
# Closed guard classification over the statements a fix added inside the labeled function.
# Order matters: the first matching kind wins. These classify the *fix*, they never detect.
GUARD_PATTERNS: tuple[tuple[str, re.Pattern[str]], ...] = (
("parameterized_call", re.compile(r"execute\w*\([^,]+,\s*[\(\[]|prepareStatement\(|\?\s*['\"]?\s*[,)]|\$\d\b|%s['\"]\s*,\s*[\(\[]")),
(
"auth_check",
re.compile(
r"\b(request\.user|req\.user|current_user|request\.state|session\[|is_authenticated|authoriz|require_auth"
r"|login_required|verify_token|check_permission|owner_id\s*[!=]=)"
),
),
(
"allowlist_test",
re.compile(
r"\bnot in\b|\bin\s*[\(\{\[]|\.includes\(|\.has\(|\bstartswith\(|\bstartsWith\(|\bendswith\(|\bendsWith\("
r"|re\.(?:match|fullmatch)\(|\.test\(|allow_?list|whitelist|\bALLOWED\b|\bsafe_join\(|secure_filename\("
r"|\brealpath\(|\bnormpath\(|\bresolve\("
),
),
("bounds_test", re.compile(r"\blen\(|\.length\b|\bsizeof\(|\bstrn?len\(|\bsnprintf\(|\bstrlcpy\(|\bmin\(|\bmax\(|\s(?:<|>|<=|>=)\s")),
(
"null_test",
re.compile(
r"\bis None\b|\bis not None\b|[!=]= *NULL\b|[!=]==? *null\b|\bnullptr\b|\bif\s*\(\s*!\s*\w|\bif not \w+\s*:"
r"|\bif\s+\w+\s+is\s+None|\?\?|\?\."
),
),
(
"type_change",
re.compile(r"\bint\(|\bparseInt\(|\bNumber\(|\(int\)|\(size_t\)|\(unsigned\b|\bstr\(|\.toString\(|\bstatic_cast<|\bBigInt\("),
),
)
def classify_guard_text(text: str) -> str:
"""The guard kind the given statements express, or ``none`` when no known guard is recognised."""
for kind, pattern in GUARD_PATTERNS:
if pattern.search(text):
return kind
return "none"
# --- The specs the CPG queries are parameterised by ------------------------------------------------------
@dataclass(frozen=True)
class TaintSpec:
"""What a flow family's source→sink−sanitizer query matches on, for one language."""
family: str
language: str
sources: tuple[str, ...]
sinks: tuple[str, ...]
sanitizers: tuple[str, ...]
@dataclass(frozen=True)
class DominanceSpec:
"""What an absence family's guard-dominates-operation query matches on, for one language.
``dischargers`` are the guards that discharge an obligation; the query asks whether one of them dominates
the operation on every path, which is the arbiter for the classes that never crash.
"""
family: str
language: str
operations: tuple[str, ...]
dischargers: tuple[str, ...]
def taint_specs(
*,
language: str,
facts: SemanticFacts | None = None,
taxonomy: FamilyTaxonomy | None = None,
) -> Mapping[str, TaintSpec]:
"""One ``TaintSpec`` per flow family that has a sink in this language, grouped through the taxonomy's CWE map.
Sources and sanitizers are language-wide: a family does not get its own notion of what untrusted input is,
and a sanitizer that breaks one flow breaks it whatever the sink's family.
"""
scoped = (facts if facts is not None else load_facts()).for_language(language)
families = taxonomy if taxonomy is not None else load_families()
sources = tuple(sorted({pattern for fact in scoped.sources for pattern in fact.patterns}))
sanitizers = tuple(sorted({call for fact in scoped.sanitizers for call in fact.calls}))
by_family: dict[str, set[str]] = {}
for sink in scoped.sinks:
family = families.family_of_cwe(sink.cwe)
if family is None:
continue
by_family.setdefault(family.id, set()).update(sink.calls)
return {
family_id: TaintSpec(family=family_id, language=language, sources=sources, sinks=tuple(sorted(calls)), sanitizers=sanitizers)
for family_id, calls in sorted(by_family.items())
}
def dominance_specs(*, language: str, facts: ObligationFacts | None = None) -> Mapping[str, DominanceSpec]:
"""One ``DominanceSpec`` per absence family for this language.
The obligation facts are authored for authorization, so every obligated operation routes to
``access_control``; the shape generalises when a second absence family is added to the taxonomy.
"""
scoped = (facts if facts is not None else load_obligation_facts()).for_language(language)
if not scoped.operations:
return {}
operations = tuple(sorted({call for fact in scoped.operations for call in fact.calls}))
dischargers = tuple(
sorted(
{call for fact in scoped.dischargers for call in fact.calls}
| {decorator for fact in scoped.dischargers for decorator in fact.decorators}
)
)
return {"access_control": DominanceSpec(family="access_control", language=language, operations=operations, dischargers=dischargers)}
__all__ = [
"GUARD_KINDS",
"GUARD_PATTERNS",
"DominanceSpec",
"TaintSpec",
"classify_guard_text",
"dominance_specs",
"taint_specs",
]
+189
View File
@@ -0,0 +1,189 @@
"""The closed family taxonomy (learning-harness, Req 1).
Ten families, loaded from ``ruleset/families.toml``. A family is the only key that routes work and
scores a pair: CWE ids and mechanism ids are attributes reached *through* a family, so no caller may
group by them. Every family names what can verify a claim in it (a sandbox canary, static
corroboration, or nothing), which is what keeps reporting honest one layer up. An unknown field, a
family outside the closed set, a missing verifier kind or a CWE claimed twice is a data bug and fails
loud, naming the offender.
"""
from __future__ import annotations
import tomllib
from dataclasses import dataclass
from pathlib import Path
from typing import Literal
FAMILY_IDS = (
"injection",
"path",
"deserialization",
"access_control",
"output_encoding",
"untrusted_destination",
"prototype",
"config_secrets",
"memory",
"unknown",
)
VERIFIER_KINDS = ("canary", "static", "none")
Relation = Literal["same", "parent_child", "lateral", "fabricated"]
DEFAULT_FAMILIES_PATH = Path(__file__).resolve().parents[1] / "ruleset" / "families.toml"
_FAMILY_FIELDS = frozenset({"id", "description", "cwes", "mechanisms", "verifier", "parent"})
_TOP_FIELDS = frozenset({"version", "family"})
class FamiliesError(ValueError):
"""Raised when the taxonomy file names a family, field, verifier or CWE outside the closed sets."""
@dataclass(frozen=True)
class Family:
id: str # FAMILY_IDS
description: str
cwes: frozenset[str]
mechanisms: frozenset[str]
verifier: str # VERIFIER_KINDS
parent: str | None = None # a family this one specializes; partial credit when one side is the other's parent
@dataclass(frozen=True)
class FamilyTaxonomy:
version: str
families: tuple[Family, ...]
def by_id(self, family_id: str) -> Family:
for family in self.families:
if family.id == family_id:
return family
raise FamiliesError(f"unknown family {family_id!r}; expected one of {', '.join(f.id for f in self.families)}")
def family_of_cwe(self, cwe: str) -> Family | None:
"""The family that claims this CWE, or None when nobody has placed it yet (a data gap, not `unknown`)."""
token = cwe.strip().upper()
return next((family for family in self.families if token in family.cwes), None)
def family_of_mechanism(self, mechanism: str) -> Family | None:
return next((family for family in self.families if mechanism in family.mechanisms), None)
@property
def hierarchical(self) -> bool:
"""True when some family declares a parent. While this is False, partial credit can never be earned."""
return any(family.parent for family in self.families)
def related(self, left: str, right: str) -> Relation:
"""How two family answers relate. Abstention is not partial credit: ``unknown`` against a real
family is ``lateral``, never ``parent_child``."""
known = {family.id: family for family in self.families}
if left not in known or right not in known:
return "fabricated"
if left == right:
return "same"
if known[left].parent == right or known[right].parent == left:
return "parent_child"
return "lateral"
def load_families(path: Path | None = None, *, require_all: bool = True) -> FamilyTaxonomy:
"""Load the taxonomy. ``require_all`` demands the full closed set in order (the shipped file);
a scoped fixture may declare a subset, but never a family outside ``FAMILY_IDS``."""
source = path if path is not None else DEFAULT_FAMILIES_PATH
try:
payload = tomllib.loads(source.read_text(encoding="utf-8"))
except OSError as exc:
raise FamiliesError(f"families file unreadable: {source}") from exc
except tomllib.TOMLDecodeError as exc:
raise FamiliesError(f"families file is not valid TOML: {source}: {exc}") from exc
unknown_top = sorted(set(payload) - _TOP_FIELDS)
if unknown_top:
raise FamiliesError(f"{source}: unknown top-level field(s) {', '.join(unknown_top)}")
version = str(payload.get("version", "")).strip()
if not version:
raise FamiliesError(f"{source}: version is required")
families: list[Family] = []
claimed: dict[str, str] = {}
claimed_mechanisms: dict[str, str] = {}
for entry in payload.get("family", []):
if not isinstance(entry, dict):
raise FamiliesError(f"{source}: every [[family]] entry must be a table")
family = _family(source, entry)
if any(existing.id == family.id for existing in families):
raise FamiliesError(f"{source}: family {family.id!r} declared twice")
for cwe in sorted(family.cwes):
if cwe in claimed:
raise FamiliesError(f"{source}: {cwe} is claimed by both {claimed[cwe]!r} and {family.id!r}")
claimed[cwe] = family.id
for mechanism in sorted(family.mechanisms):
if mechanism in claimed_mechanisms:
raise FamiliesError(
f"{source}: mechanism {mechanism} is claimed by both {claimed_mechanisms[mechanism]!r} and {family.id!r}"
)
claimed_mechanisms[mechanism] = family.id
families.append(family)
_check_parents(source, families)
if require_all and tuple(family.id for family in families) != FAMILY_IDS:
raise FamiliesError(f"{source}: expected exactly {', '.join(FAMILY_IDS)} in that order, got {', '.join(f.id for f in families)}")
if not families:
raise FamiliesError(f"{source}: no families declared")
return FamilyTaxonomy(version=version, families=tuple(families))
def _family(source: Path, entry: dict[str, object]) -> Family:
unknown_fields = sorted(set(entry) - _FAMILY_FIELDS)
if unknown_fields:
raise FamiliesError(f"{source}: family {entry.get('id')!r} has unknown field(s) {', '.join(unknown_fields)}")
family_id = str(entry.get("id", "")).strip()
if family_id not in FAMILY_IDS:
raise FamiliesError(f"{source}: unknown family {family_id!r}; expected one of {', '.join(FAMILY_IDS)}")
verifier = str(entry.get("verifier", "")).strip()
if verifier not in VERIFIER_KINDS:
raise FamiliesError(f"{source}: family {family_id!r} has verifier {verifier!r}; expected one of {', '.join(VERIFIER_KINDS)}")
description = str(entry.get("description", "")).strip()
if not description:
raise FamiliesError(f"{source}: family {family_id!r} needs a description")
cwes = _tokens(source, family_id, entry.get("cwes", ()), "cwes", upper=True)
mechanisms = _tokens(source, family_id, entry.get("mechanisms", ()), "mechanisms")
parent = entry.get("parent")
return Family(
id=family_id,
description=description,
cwes=frozenset(cwes),
mechanisms=frozenset(mechanisms),
verifier=verifier,
parent=str(parent) if parent else None,
)
def _tokens(source: Path, family_id: str, value: object, field: str, *, upper: bool = False) -> list[str]:
if not isinstance(value, list) or any(not isinstance(item, str) for item in value):
raise FamiliesError(f"{source}: family {family_id!r} field {field} must be a list of strings")
tokens = [item.strip().upper() if upper else item.strip() for item in value]
duplicates = sorted({token for token in tokens if tokens.count(token) > 1})
if duplicates:
raise FamiliesError(f"{source}: family {family_id!r} lists {', '.join(duplicates)} twice in {field}")
return tokens
def _check_parents(source: Path, families: list[Family]) -> None:
ids = {family.id for family in families}
for family in families:
if family.parent is None:
continue
if family.parent not in ids:
raise FamiliesError(f"{source}: family {family.id!r} declares unknown parent {family.parent!r}")
if family.parent == family.id:
raise FamiliesError(f"{source}: family {family.id!r} is its own parent")
__all__ = [
"DEFAULT_FAMILIES_PATH",
"FAMILY_IDS",
"VERIFIER_KINDS",
"FamiliesError",
"Family",
"FamilyTaxonomy",
"Relation",
"load_families",
]