feat(learn): per-repository role inference, framework-free (decision-engine task 3)

Wrapper inference (learn/roles.py, no model): a function whose parameter reaches a
call already in the role set becomes a derived sink (or a sanitizer when it returns
the cleaned value) of the same operation, at depth + 1; a function returning a
source's value becomes a derived source. The role set starts from language-level
entries only (priors off by default, optional), grows to depth 3, and reads
vendored dependency source when present, so a vendored database class reaching
mysqli makes `->query` a sink without a framework table. A name the repository
declares more than once becomes a role only when every definition derives it.

Model roles (plane/tasks/roles.py): the model_sinks.py classification moved into
the package and extended to sink/source/sanitizer/guard/dispatch, per file chunk,
metered and resumable, client injected; a failed chunk is unclassified, never
"no roles". vocabulary_overlay merges inferred and model roles over the
language-level facts in the shape taint_specs takes, origin in the entry id.
Features: the roles (static) and model_sinks (plane) instruments are filled.

On the recorded validation-46 facts (15 cases, 14,368 product files, 87 MB read):
1,999 wrapper roles (1,753 sinks, 157 sources, 89 sanitizers; depth 1/2/3 =
973/651/375), 14 of 15 cases with at least one; the WordPress gopay plugin has
none with priors off, which is what model roles are for.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo
This commit is contained in:
Marius Ciepluch
2026-09-30 19:16:54 +02:00
co-authored by Claude Opus 5.5
parent 1fcc31c857
commit 884092e62c
8 changed files with 1161 additions and 28 deletions
+16 -2
View File
@@ -16,7 +16,8 @@ when every instrument it could have used failed.
Env: ``OUSAST_OUTPUT_DIR``, ``OUSAST_INPUT_FACTS`` (and ``OUSAST_INPUT_FACTS_SUMMARY``), ``OUSAST_INPUT_PASS_A``/``_B``/``_C``,
``OUSAST_INPUT_SUMMARY_A``/``_B``/``_C``, ``OUSAST_INPUT_AGREED``, optional ``OUSAST_INPUT_ALERTS`` and
``OUSAST_INPUT_ALERTS_SUMMARY``, optional ``OUSAST_WORKSPACE_DIR``. Exit 0 done, 2 failed. On the host,
``OUSAST_INPUT_ALERTS_SUMMARY``, optional ``OUSAST_INPUT_ROLES`` (the ``roles`` task's ``roles.json``), optional
``OUSAST_WORKSPACE_DIR``. Exit 0 done, 2 failed. On the host,
:func:`case_features` does the same over a recorded run directory.
"""
@@ -41,12 +42,16 @@ from ...learn.features import (
entry_distance,
entry_names,
facts_part,
function_of,
model_sinks_part,
quick_part,
record,
roles_part,
ruleset_digest,
source_part,
verify_part,
)
from ...learn.roles import from_model_roles, infer_for_checkout
from ...model.taxonomy import load_families
from ...preprocess import detect_language
from ...ruleset import DEFAULT_RULESET_DIR, PatternRule, load_ruleset
@@ -117,9 +122,11 @@ def case_features(
workspace: Path | None = None,
rules: Sequence[PatternRule] | None = None,
facts_summary: Mapping[str, Any] | None = None,
model_roles: Mapping[str, Any] | None = None,
) -> list[dict[str, Any]]:
"""The `plane` records of one case, sorted by (candidate, family). Versions: the ruleset digest (quick), the
engine image (engine), the image the facts were computed on (facts), the verify model ids (verify)."""
engine image (engine), the image the facts were computed on (facts), the verify model ids (verify). ``model_roles``
is the ``roles`` task's ``roles.json`` (with its ``model``); wrapper roles are inferred from ``workspace``."""
loaded = tuple(rules) if rules is not None else load_ruleset(DEFAULT_RULESET_DIR)
by_id = {rule.rule_id: rule for rule in loaded}
candidates: dict[tuple[str, str], dict[str, Any]] = {}
@@ -150,6 +157,9 @@ def case_features(
facts_image = (reused.get("image") if isinstance(reused, Mapping) else None) or (facts_summary or {}).get("image")
facts_version = str(facts_image) if facts_image else None
verify_version = "+".join(sorted({m for m in models.values() if m})) or None
inferred = infer_for_checkout(index.root) if index is not None and candidates else None
modelled = from_model_roles(model_roles) if model_roles is not None else None
roles_version = str(model_roles.get("model")) if model_roles is not None and model_roles.get("model") else None
out: list[dict[str, Any]] = []
for (candidate, family), entry in sorted(candidates.items()):
path, _, function = candidate.partition("::")
@@ -162,11 +172,13 @@ def case_features(
"delta": NOT_APPLICABLE,
"verify": verify_part(passes, candidate, entry["line"], verify_version),
"agree": agree_part(entry.get("final")),
"model_sinks": model_sinks_part(modelled, path, function, roles_version),
}
if index is not None:
if entries is None:
entries = entry_names(index.root, {c.partition("::")[0] for c, _ in candidates})
parts["source"] = source_part(index.lines(path), language, function)
parts["roles"] = roles_part(function_of(index.lines(path), path, language, function), inferred, language)
distance = 0 if function == GLOBAL and path in entries[1] else entry_distance(function, entries[0], index.callers())
parts["entry_points"] = Part("ran", {"facts.entry_distance": distance})
out.append(record(candidate, family, language, "plane", parts))
@@ -199,6 +211,7 @@ def recorded_case(base: Path, case: str, workspace: Path | None = None) -> list[
alerts_summary=_json(base / f"{case}-alerts" / "summary.json"),
workspace=workspace,
facts_summary=_json(base / f"{case}-facts" / "summary.json"),
model_roles=_json(base / f"{case}-roles" / "roles.json"),
)
@@ -237,6 +250,7 @@ def main(environ: Mapping[str, str] | None = None) -> int:
alerts_summary=_json(_path(env, "OUSAST_INPUT_ALERTS_SUMMARY")),
workspace=workspace,
facts_summary=_json(_path(env, "OUSAST_INPUT_FACTS_SUMMARY")),
model_roles=_json(_path(env, "OUSAST_INPUT_ROLES")),
)
states = _states(records)
used = {name: n for name, n in states.items() if name not in ("language", "delta") and set(n) - {"none"}}
+241
View File
@@ -0,0 +1,241 @@
"""`roles` (learned-decision-engine design section 2, Req 8.1): the model reads a repository's source and names its
roles -- no vocabulary, no framework table.
The source-only classification of `benchmarks/independent/model_sinks.py` moved into the package and extended
from sinks to every role: per product file chunk of numbered lines, which call sites perform a ``sink`` operation of
a closed kind on non-constant data, read a ``source`` of attacker input, ``sanitizer`` (escape or cleanse) a value,
``guard`` (check and reject) it, or ``dispatch`` it through a string-keyed registry. Each role carries its function,
line, the call, an operation (:data:`..learn.schema.OPERATIONS` for sinks, :data:`..learn.schema.SOURCE_KINDS` for
sources) and a one-line evidence quote. A chunk whose call fails or whose answer does not parse is
**unclassified**, never "no roles" (the `model_sinks.py` rule): its path is listed in ``roles.json``
``unclassified_paths`` and the ``model_sinks`` feature of a function there is ``failed`` unless the model flagged it.
Units are files: a row goes to ``units.jsonl`` as each file finishes, and a restart skips the files already there.
The chat client is injected (``run(client=...)``) and metered (`MeteredClient`): a ceiling ends the task
``unfinished``, an account refusal ``failed``. ``roles.json`` and ``summary.json`` are written last.
Env: ``OUSAST_WORKSPACE_DIR``, ``OUSAST_OUTPUT_DIR``, ``OUSAST_MODEL``, ``OUSAST_MODEL_PARAMS``, ``OUSAST_BUDGET_USD``,
``OUSAST_BUDGET_CALLS``, optional ``OUSAST_INPUT_FILES`` (a JSON list of workspace-relative paths: ``pre-push``
classifies only the changed files and the definitions they call), optional ``OUSAST_CHUNK_LINES``. Exit 0 done,
2 failed, 3 unfinished.
"""
from __future__ import annotations
import json
import os
import sys
import traceback
from collections.abc import Mapping, Sequence
from pathlib import Path
from typing import Any
from ...learn.roles import MODEL_ROLE_KINDS
from ...learn.schema import OPERATIONS, SOURCE_KINDS
from ...tool_hunter import ChatClient
from ..budget import AccountError, BudgetExhausted, MeteredClient
from .repo_facts import GLOBAL, product_files
from .verify import build_client
CHUNK_LINES = 400
EXIT_CODES = {"done": 0, "failed": 2, "unfinished": 3}
USAGE_FIELDS = ("prompt_tokens", "prompt_cache_hit_tokens", "completion_tokens")
MAX_EVIDENCE = 200
PROMPT = (
"You are auditing source code. List every call site in the code below that plays one of these roles, where the "
"value involved is not a compile-time constant:\n"
"- sink: performs an operation of kind " + ", ".join(o for o in OPERATIONS if o != "other") + " (or other);\n"
"- source: reads attacker-controllable input (kind "
+ ", ".join(k for k in SOURCE_KINDS if k not in ("inferred_role", "prior"))
+ ");\n"
"- sanitizer: escapes, encodes or cleanses a value for one of those operations;\n"
"- guard: checks a value and rejects it (allow-list, permission or ownership check);\n"
"- dispatch: registers or invokes a callback through a string-keyed registry (hooks, events, signals).\n"
"Answer with JSON only: "
'{{"roles": [{{"role": "<sink|source|sanitizer|guard|dispatch>", "function": "<enclosing function, or <global>>", '
'"line": <line number>, "call": "<the call or expression>", "operation": "<the kind>", '
'"evidence": "<the line, quoted>"}}]}}. Return {{"roles": []}} if there is none.\n\nFile: {path}\n\n{code}'
)
def chunks(text: str, size: int = CHUNK_LINES) -> list[str]:
"""Numbered chunks of ``size`` lines (line numbers are the file's)."""
lines = text.splitlines()
return [
"\n".join(f"{n + 1:5d}| {line}" for n, line in enumerate(lines[i : i + size], start=i)) for i in range(0, max(len(lines), 1), size)
]
def parse_roles(content: str | None, path: str) -> list[dict[str, Any]]:
"""The roles of one answer, checked: a closed role and operation, an int line, text trimmed. Raises on an answer
that is not the JSON object asked for (the chunk is then unclassified)."""
payload = json.loads(content or "")
items = payload.get("roles") if isinstance(payload, dict) else None
if not isinstance(items, list):
raise ValueError("the answer has no roles list")
out: list[dict[str, Any]] = []
for item in items:
if not isinstance(item, dict) or item.get("role") not in MODEL_ROLE_KINDS or not isinstance(item.get("call"), str):
continue
vocabulary = SOURCE_KINDS if item["role"] == "source" else OPERATIONS
operation = item.get("operation") if item.get("operation") in vocabulary else "other"
named = item.get("function")
function = named.strip() if isinstance(named, str) and named.strip() else GLOBAL
line = item["line"] if isinstance(item.get("line"), int) else 0
out.append(
{
"path": path, "role": item["role"], "function": function, "line": line,
"call": item["call"].strip()[:MAX_EVIDENCE], "operation": operation,
"evidence": str(item.get("evidence") or "")[:MAX_EVIDENCE],
}
) # fmt: skip
return out
def classify(root: Path, path: str, *, client: ChatClient, model: str, chunk_lines: int = CHUNK_LINES) -> dict[str, Any]:
"""One file: its roles, and how many chunks were asked and left unclassified. `BudgetExhausted` and
`AccountError` propagate (the task stops); any other failure of a chunk leaves it unclassified."""
text = (root / path).read_text(encoding="utf-8", errors="replace")
roles: list[dict[str, Any]] = []
asked = unclassified = 0
for code in chunks(text, chunk_lines):
asked += 1
try:
response = client.complete(
model=model, messages=[{"role": "user", "content": PROMPT.format(path=path, code=code)}], tools=[], timeout_seconds=90,
json_object=True,
) # fmt: skip
roles += parse_roles(response.content, path)
except (BudgetExhausted, AccountError):
raise
except Exception: # noqa: BLE001 -- counted as unclassified, never as "no roles"
unclassified += 1
return {"path": path, "roles": roles, "chunks": asked, "unclassified": unclassified}
def _read_units(log: Path) -> dict[str, dict[str, Any]]:
rows: dict[str, dict[str, Any]] = {}
if log.is_file():
for text in log.read_text(encoding="utf-8").splitlines():
if text.strip():
row = json.loads(text)
if isinstance(row, dict) and "error" not in row:
rows[str(row["path"])] = row
return rows
def assemble(rows: Mapping[str, Mapping[str, Any]], model: str) -> dict[str, Any]:
"""``roles.json`` from the per-file rows: roles sorted, the paths with an unclassified chunk, counts."""
roles = sorted((r for row in rows.values() for r in row.get("roles") or ()), key=lambda r: (r["path"], r["line"], r["role"], r["call"]))
unclassified = sorted(path for path, row in rows.items() if int(row.get("unclassified") or 0))
counts: dict[str, int] = {}
for role in roles:
counts[role["role"]] = counts.get(role["role"], 0) + 1
return {
"model": model, "roles": roles, "unclassified_paths": unclassified, "counts": dict(sorted(counts.items())), "files": len(rows),
"chunks": sum(int(r.get("chunks") or 0) for r in rows.values()),
"unclassified_chunks": sum(int(r.get("unclassified") or 0) for r in rows.values()),
} # fmt: skip
def run(
workspace: Path,
output_dir: Path,
*,
client: ChatClient,
budget: MeteredClient | None = None,
model: str = "",
files: Sequence[str] | None = None,
chunk_lines: int = CHUNK_LINES,
) -> dict[str, Any]:
"""Classify every unfinished file (``files``, default every product file), append ``units.jsonl`` per file, write
``roles.json`` and ``summary.json`` last; returns the summary."""
output_dir.mkdir(parents=True, exist_ok=True)
log = output_dir / "units.jsonl"
metered = budget if budget is not None else MeteredClient(client)
model = model or "unbound"
wanted = sorted(set(files)) if files is not None else [p.relative_to(workspace).as_posix() for p in product_files(workspace)]
missing = [p for p in wanted if not (workspace / p).is_file()]
done = {p: row for p, row in _read_units(log).items() if p in wanted}
status, reason = ("failed", f"files not in the workspace: {missing[:5]}") if missing else ("done", "")
with log.open("a", encoding="utf-8") as sink:
for path in wanted if status == "done" else []:
if path in done:
continue
before = (dict(metered.usage), metered.usd, metered.calls)
try:
row = classify(workspace, path, client=metered, model=model, chunk_lines=chunk_lines)
except BudgetExhausted as stop:
status, reason = "unfinished", str(stop)
break
except AccountError as refusal:
status, reason = "failed", refusal.provider_message
break
usage = {k: metered.usage[k] - before[0][k] for k in USAGE_FIELDS} | {"calls": metered.calls - before[2]}
row.update(usage=usage, usd=(round(metered.usd - before[1], 8) if metered.usd is not None and before[1] is not None else None))
sink.write(json.dumps(row, sort_keys=True) + "\n")
sink.flush()
done[path] = row
if status == "done" and len(done) < len(wanted):
status, reason = "unfinished", f"{len(wanted) - len(done)} of {len(wanted)} files not classified"
roles = assemble(done, model)
(output_dir / "roles.json").write_text(json.dumps(roles, indent=1, sort_keys=True) + "\n", encoding="utf-8")
usd_values = [row.get("usd") for row in done.values()]
summary: dict[str, Any] = {
"status": status, "units_done": len(done), "units_total": len(wanted),
"usd": round(sum(float(u) for u in usd_values if u is not None), 6) if all(u is not None for u in usd_values) else None,
"calls": sum(int((row.get("usage") or {}).get("calls") or 0) for row in done.values()), "priced": metered.priced, "model": model,
"roles": roles["counts"], "unclassified_chunks": roles["unclassified_chunks"], "chunks": roles["chunks"],
} # fmt: skip
if reason:
summary["reason"] = reason
(output_dir / "summary.json").write_text(json.dumps(summary, indent=1) + "\n", encoding="utf-8")
return summary
def main() -> int:
output = os.environ.get("OUSAST_OUTPUT_DIR", "").strip()
model = os.environ.get("OUSAST_MODEL", "")
try:
workspace = Path(os.environ.get("OUSAST_WORKSPACE_DIR", "").strip() or "")
if not output:
raise ValueError("OUSAST_OUTPUT_DIR is not set")
if not os.environ.get("OUSAST_WORKSPACE_DIR") or not workspace.is_dir():
raise ValueError(f"OUSAST_WORKSPACE_DIR is not a directory: {workspace}")
if not model:
raise ValueError("OUSAST_MODEL is not set")
params = json.loads(os.environ.get("OUSAST_MODEL_PARAMS", "") or "{}")
files_env = os.environ.get("OUSAST_INPUT_FILES", "").strip()
files = [str(p) for p in json.loads(Path(files_env).read_text(encoding="utf-8"))] if files_env else None
budget_usd, budget_calls = os.environ.get("OUSAST_BUDGET_USD", "").strip(), os.environ.get("OUSAST_BUDGET_CALLS", "").strip()
client = build_client()
metered = MeteredClient(
client,
prices=params,
budget_usd=float(budget_usd) if budget_usd else None,
budget_calls=int(budget_calls) if budget_calls else None,
)
chunk_lines = int(os.environ.get("OUSAST_CHUNK_LINES", "") or CHUNK_LINES)
summary = run(workspace, Path(output), client=client, budget=metered, model=model, files=files, chunk_lines=chunk_lines)
except Exception: # noqa: BLE001 -- a crash is `failed` with its traceback, never a silent zero
summary = {
"status": "failed",
"units_done": 0,
"units_total": 0,
"usd": None,
"calls": 0,
"model": model,
"reason": traceback.format_exc()[-2000:],
}
if output:
Path(output).mkdir(parents=True, exist_ok=True)
(Path(output) / "summary.json").write_text(json.dumps(summary, indent=1) + "\n", encoding="utf-8")
print(json.dumps({k: summary.get(k) for k in ("status", "units_done", "units_total", "usd", "calls", "model")}), file=sys.stderr)
return EXIT_CODES.get(str(summary["status"]), 2)
__all__ = ["CHUNK_LINES", "PROMPT", "assemble", "chunks", "classify", "main", "parse_roles", "run"]
if __name__ == "__main__":
raise SystemExit(main())