mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-16 11:49:28 +00:00
src/openultrasast/source.py replaces the copies in semantic/functions.py, plane/tasks/repo_facts.py, learn/features.py, learn/excerpt.py and plane/tasks/verify.py. Two engines share one sentinel (<module>), one DECLARATION/KEYWORDS/RESERVED set and one alias map: the parser engine (ranges, enclosing, bounds, call_sites, callers_of, facts_for) for the scorer, the overlay and the hunter's annotations; the line-pattern engine (declared_functions, enclosing_at, function_span, function_bounds, the brace matcher) that labels, features, roles, excerpts and plane facts were built on. learn no longer imports plane.tasks. Frozen records that store the old sentinel are mapped on read (module_function). Gate: labels rebuilt before and after are identical, 7,921 rows, after sentinel normalisation (benchmarks/measurements/2026-10-13-source-module- label-diff/record.json). 490 targeted tests pass; 3,312 collect. Net: 657 lines deleted, 212 added. benchmarks/unseen keeps <global> for its own frozen manifests. Co-Authored-By: Claude Fable 5.1 <[email protected]>
315 lines
17 KiB
Python
315 lines
17 KiB
Python
"""verify and agree tasks (ai-service-plane task 6): callers in the prompt, per-unit resume, 402 -> failed,
|
|
budget -> unfinished, the bound model and summed usage in summary.json, agreement and metrics over two passes."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from openultrasast.plane.budget import MeteredClient
|
|
from openultrasast.plane.tasks import agree, verify
|
|
from openultrasast.tool_hunter import ChatResponse, ToolCall
|
|
|
|
FLASH = {"cache_hit_per_m": 0.014, "input_per_m": 0.44, "output_per_m": 1.32}
|
|
ROW = {"prompt_tokens": 1000, "prompt_cache_hit_tokens": 200, "completion_tokens": 50}
|
|
|
|
|
|
class HuntClient:
|
|
"""A scripted hunter that answers from the conversation: a read_file turn first, then a JSON verdict naming
|
|
the candidates in `flag` for the file the prompt asked about. Records provider usage rows like
|
|
`DeepSeekChatClient`, so `MeteredClient` can price it; a step index in `raise_at` raises instead."""
|
|
|
|
def __init__(self, flag: set[str], *, raise_at: dict[int, Exception] | None = None) -> None:
|
|
self.flag = flag
|
|
self.raise_at = raise_at or {}
|
|
self.usage: list[dict[str, object]] = []
|
|
self.calls: list[list[dict[str, object]]] = []
|
|
|
|
def complete(self, *, model: str, messages: list[dict[str, object]], tools: list[dict[str, object]], **_: object) -> ChatResponse:
|
|
index = len(self.calls)
|
|
self.calls.append(list(messages))
|
|
if index in self.raise_at:
|
|
raise self.raise_at[index]
|
|
self.usage.append(dict(ROW))
|
|
prompt = str(messages[1]["content"])
|
|
path = re.search(r"all in `([^`]+)`", prompt).group(1) # type: ignore[union-attr]
|
|
if not any(m.get("role") == "tool" for m in messages):
|
|
return ChatResponse(tool_calls=(ToolCall(id="c1", name="read_file", arguments={"path": path}),))
|
|
names = re.findall(r"- function `([^`]+)` around line (\d+)", prompt)
|
|
found = [
|
|
{
|
|
"path": path,
|
|
"line": int(line),
|
|
"function_name": fn,
|
|
"title": f"{fn} sink",
|
|
"rationale": "request -> sink",
|
|
"family": "injection",
|
|
}
|
|
for fn, line in names
|
|
if f"{path}::{fn}" in self.flag
|
|
]
|
|
return ChatResponse(content=json.dumps(found))
|
|
|
|
def prompts(self) -> list[str]:
|
|
return [str(call[1]["content"]) for call in self.calls]
|
|
|
|
|
|
def _workspace(tmp_path: Path) -> Path:
|
|
root = tmp_path / "repo"
|
|
root.mkdir()
|
|
(root / "app.py").write_text("def run(q):\n return eval(q)\n\ndef other(x):\n return exec(x)\n")
|
|
(root / "db.py").write_text("def query(sql):\n return cursor.execute(sql)\n")
|
|
return root
|
|
|
|
|
|
CANDIDATES = {
|
|
"id": "case",
|
|
"family": "injection",
|
|
"candidates": [["app.py", "run", 2], ["app.py", "other", 5], ["db.py", "query", 2], ["app.py", "run", 2]],
|
|
}
|
|
FACTS = {
|
|
"files": {"app.py": {"functions": ["run", "other"]}, "db.py": {"functions": ["query"]}},
|
|
"callers": {
|
|
"app.py::run": [{"path": "web.py", "line": 12, "enclosing": "handler"}, {"path": "cli.py", "line": 40, "enclosing": ""}],
|
|
"db.py::query": [{"path": f"m{i}.py", "line": i, "enclosing": f"f{i}"} for i in range(12)],
|
|
},
|
|
"counts": {},
|
|
}
|
|
|
|
|
|
def _run(root: Path, out: Path, client: HuntClient, *, facts: dict | None = FACTS, **budget: object) -> dict:
|
|
metered = MeteredClient(client, prices=FLASH, **budget) # type: ignore[arg-type]
|
|
return verify.run(root, out, CANDIDATES, facts, client=client, budget=metered, model="deepseek-chat", pass_label="a")
|
|
|
|
|
|
def test_prompt_carries_the_known_callers_line_capped_at_eight(tmp_path: Path) -> None:
|
|
client = HuntClient({"app.py::run"})
|
|
summary = _run(_workspace(tmp_path), tmp_path / "out", client)
|
|
assert summary["status"] == "done", summary
|
|
app = next(p for p in client.prompts() if "all in `app.py`" in p)
|
|
# candidates in the reference's sorted order; the callers line follows the candidate it belongs to
|
|
assert (
|
|
"- function `other` around line 5\n- function `run` around line 2\n"
|
|
" Known callers: web.py:12 in handler(), cli.py:40\nThe file is above." in app
|
|
)
|
|
assert (
|
|
"Known callers" not in app.split("- function `other`")[1].split("- function `run`")[0]
|
|
) # nothing known: no line, no claim of absence
|
|
db = next(p for p in client.prompts() if "all in `db.py`" in p)
|
|
assert db.count("m") >= 8 and "and 4 more" in db and "m8.py" not in db
|
|
assert verify.SYSTEM in str(client.calls[0][0]["content"]) # the reference's system prompt, unchanged
|
|
|
|
|
|
def test_without_facts_the_prompt_is_the_reference_prompt(tmp_path: Path) -> None:
|
|
client = HuntClient(set())
|
|
_run(_workspace(tmp_path), tmp_path / "out", client, facts=None)
|
|
assert all("Known callers" not in p for p in client.prompts())
|
|
assert any(
|
|
p.endswith("The file is above. For each, trace where the operation's data comes from and whether it is guarded.")
|
|
for p in client.prompts()
|
|
)
|
|
|
|
|
|
def test_units_are_one_row_per_file_hunt_with_anchored_sites(tmp_path: Path) -> None:
|
|
client = HuntClient({"app.py::other", "db.py::query"})
|
|
out = tmp_path / "out"
|
|
summary = _run(_workspace(tmp_path), out, client)
|
|
rows = [json.loads(t) for t in (out / "units.jsonl").read_text().splitlines()]
|
|
assert [(r["path"], [c[1] for c in r["candidates"]], r["turns"], r["pass"]) for r in rows] == [
|
|
("app.py", ["other", "run"], 1, "a"),
|
|
("db.py", ["query"], 1, "a"),
|
|
]
|
|
assert [f["site"] for r in rows for f in r["flagged"]] == ["app.py:5:other", "db.py:2:query"]
|
|
assert rows[0]["usage"] == {**ROW, "prompt_tokens": 2000, "prompt_cache_hit_tokens": 400, "completion_tokens": 100, "calls": 2}
|
|
assert summary["units_done"] == summary["units_total"] == 2
|
|
|
|
|
|
def test_resume_asks_only_the_unfinished_file(tmp_path: Path) -> None:
|
|
root, out = _workspace(tmp_path), tmp_path / "out"
|
|
out.mkdir()
|
|
done_row = {
|
|
"path": "app.py",
|
|
"candidates": [["app.py", "other", 5], ["app.py", "run", 2]],
|
|
"flagged": [],
|
|
"turns": 3,
|
|
"usage": {**ROW, "calls": 4},
|
|
"usd": 0.01,
|
|
"pass": "a",
|
|
}
|
|
errored = {
|
|
"path": "db.py",
|
|
"candidates": [["db.py", "query", 2]],
|
|
"error": "TimeoutError: x",
|
|
"usage": {**ROW, "calls": 1},
|
|
"usd": 0.001,
|
|
"pass": "a",
|
|
}
|
|
(out / "units.jsonl").write_text(json.dumps(done_row) + "\n" + json.dumps(errored) + "\n")
|
|
client = HuntClient({"db.py::query"})
|
|
summary = _run(root, out, client)
|
|
assert [p for p in client.prompts() if "all in `" in p] and all("all in `db.py`" in p for p in client.prompts())
|
|
assert summary["status"] == "done" and summary["units_done"] == 2 and summary["units_total"] == 2
|
|
assert summary["calls"] == 4 + 1 + 2 # the finished row, the errored attempt and this run's hunt all cost money
|
|
rows = [json.loads(t) for t in (out / "units.jsonl").read_text().splitlines()]
|
|
assert len(rows) == 3 and rows[-1]["path"] == "db.py" and "error" not in rows[-1]
|
|
|
|
|
|
def test_a_scripted_402_yields_failed_with_the_provider_message(tmp_path: Path) -> None:
|
|
client = HuntClient(set(), raise_at={1: RuntimeError("HTTP Error 402: Insufficient Balance")})
|
|
out = tmp_path / "out"
|
|
summary = _run(_workspace(tmp_path), out, client)
|
|
assert summary["status"] == "failed" and "402" in summary["reason"] and summary["units_done"] == 0
|
|
assert len(client.calls) == 2 # nothing further was asked
|
|
rows = [json.loads(t) for t in (out / "units.jsonl").read_text().splitlines()]
|
|
assert len(rows) == 1 and rows[0]["error"].startswith("AccountError") and rows[0]["usage"]["calls"] == 1
|
|
assert json.loads((out / "summary.json").read_text())["status"] == "failed"
|
|
|
|
|
|
def test_a_budget_stop_yields_unfinished_and_resumes_under_a_larger_one(tmp_path: Path) -> None:
|
|
root, out = _workspace(tmp_path), tmp_path / "out"
|
|
client = HuntClient({"db.py::query"})
|
|
summary = _run(root, out, client, budget_calls=3)
|
|
assert summary["status"] == "unfinished" and "calls budget exhausted" in summary["reason"]
|
|
assert summary["units_done"] == 1 and summary["units_total"] == 2 and summary["calls"] == 3
|
|
rows = [json.loads(t) for t in (out / "units.jsonl").read_text().splitlines()]
|
|
assert [("error" in r) for r in rows] == [False, True] # the cut-short hunt is on record, marked for a re-ask
|
|
again = HuntClient({"db.py::query"})
|
|
summary = _run(root, out, again, budget_calls=10)
|
|
assert summary["status"] == "done" and all("all in `db.py`" in p for p in again.prompts())
|
|
assert summary["units_done"] == 2
|
|
|
|
|
|
def test_summary_records_the_bound_model_and_summed_usage(tmp_path: Path) -> None:
|
|
client = HuntClient(set())
|
|
out = tmp_path / "out"
|
|
summary = _run(_workspace(tmp_path), out, client)
|
|
assert summary["model"] == "deepseek-chat" and summary["pass"] == "a" and summary["priced"] is True
|
|
assert summary["usage"] == {"prompt_tokens": 4000, "prompt_cache_hit_tokens": 800, "completion_tokens": 200} and summary["calls"] == 4
|
|
per_call = (200 * 0.014 + 800 * 0.44 + 50 * 1.32) / 1_000_000
|
|
assert summary["usd"] == pytest.approx(4 * per_call, abs=1e-6)
|
|
on_disk = json.loads((out / "summary.json").read_text())
|
|
assert on_disk == summary
|
|
|
|
|
|
def test_an_unpriced_model_reports_usd_none_not_zero(tmp_path: Path) -> None:
|
|
client = HuntClient(set())
|
|
summary = verify.run(_workspace(tmp_path), tmp_path / "out", CANDIDATES, None, client=client, budget=MeteredClient(client), model="m")
|
|
assert summary["usd"] is None and summary["priced"] is False and summary["calls"] == 4
|
|
|
|
|
|
def test_main_runs_the_env_contract_with_the_scripted_seam(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
root, out = _workspace(tmp_path), tmp_path / "out"
|
|
(tmp_path / "candidates.json").write_text(json.dumps(CANDIDATES))
|
|
(tmp_path / "facts.json").write_text(json.dumps(FACTS))
|
|
env = {
|
|
"OUSAST_WORKSPACE_DIR": str(root), "OUSAST_OUTPUT_DIR": str(out), "OUSAST_INPUT_CANDIDATES": str(tmp_path / "candidates.json"),
|
|
"OUSAST_INPUT_FACTS": str(tmp_path / "facts.json"), "OUSAST_MODEL": "deepseek-chat", "OUSAST_MODEL_PARAMS": json.dumps(FLASH),
|
|
"OUSAST_BUDGET_USD": "1.0", "OUSAST_BUDGET_CALLS": "100", "OUSAST_PASS": "b", "OUSAST_CLIENT": "scripted",
|
|
} # fmt: skip
|
|
for key, value in env.items():
|
|
monkeypatch.setenv(key, value)
|
|
assert verify.main() == 0
|
|
summary = json.loads((out / "summary.json").read_text())
|
|
assert summary["status"] == "done" and summary["model"] == "deepseek-chat" and summary["pass"] == "b" and summary["units_total"] == 2
|
|
monkeypatch.delenv("OUSAST_MODEL")
|
|
assert verify.main() == 2
|
|
assert "OUSAST_MODEL" in json.loads((out / "summary.json").read_text())["reason"]
|
|
|
|
|
|
def _two_passes(tmp_path: Path) -> tuple[Path, Path, Path]:
|
|
root = _workspace(tmp_path)
|
|
a, b = tmp_path / "pass-a", tmp_path / "pass-b"
|
|
_run(root, a, HuntClient({"app.py::run", "db.py::query"}))
|
|
_run(root, b, HuntClient({"app.py::run", "app.py::other"}))
|
|
return root, a, b
|
|
|
|
|
|
def test_agree_over_two_scripted_passes_computes_agreement_and_metrics(tmp_path: Path) -> None:
|
|
_, a, b = _two_passes(tmp_path)
|
|
out = tmp_path / "agree"
|
|
sites = {"id": "case", "family": "injection", "sites": ["app.py::run", "db.py::query"], "ranges": {"app.py": [[40, 41]]}}
|
|
summary = agree.run(out, CANDIDATES, agree.load_pass(a), agree.load_pass(b), sites=sites)
|
|
assert summary["status"] == "done" and summary["usd"] == 0 and summary["calls"] == 0 and summary["model"] is None
|
|
report = json.loads((out / "agreed.json").read_text())
|
|
by_name = {r["candidate"]: r for r in report["candidates"]}
|
|
assert {n: (r["a"], r["b"], r["agreed"], r["site_match"]) for n, r in by_name.items()} == {
|
|
"app.py::run": (True, True, True, True),
|
|
"app.py::other": (False, True, False, False),
|
|
"db.py::query": (True, False, False, True),
|
|
}
|
|
assert [f["candidate"] for f in report["agreed"]] == ["app.py::run"] and report["agreed"][0]["passes"] == 2
|
|
assert sorted(f["candidate"] for f in report["disputed"]) == ["app.py::other", "db.py::query"]
|
|
assert json.loads((out / "disputed.json").read_text()) == report["disputed"]
|
|
metrics = summary["metrics"]
|
|
per_call = (200 * 0.014 + 800 * 0.44 + 50 * 1.32) / 1_000_000
|
|
assert metrics["candidates"] == 3 and metrics["agreed"] == 1 and metrics["disputed"] == 2 and metrics["hunts"] == 4
|
|
assert metrics["declared_sites"] == 2 and metrics["declared_sites_matched"] == 2 and metrics["declared_sites_matched_agreed"] == 1
|
|
assert metrics["usd_total"] == pytest.approx(8 * per_call, abs=1e-6)
|
|
assert metrics["cost_per_candidate"] == pytest.approx(8 * per_call / 3, abs=1e-6)
|
|
assert metrics["turns_per_hunt"] == 1.0 and metrics["calls_per_hunt"] == 2.0 and metrics["site_matching"] == "assessed"
|
|
# a hunt's usd is split over its candidates: app.py's two share one hunt per pass, db.py's one has a hunt alone
|
|
assert by_name["db.py::query"]["usd"] == pytest.approx(4 * per_call, abs=1e-6)
|
|
assert by_name["app.py::run"]["usd"] == pytest.approx(2 * per_call, abs=1e-6)
|
|
|
|
|
|
def test_agree_reports_cost_with_the_recorded_triage_and_needs_fix_ranges_to_assess_sites(tmp_path: Path) -> None:
|
|
_, a, b = _two_passes(tmp_path)
|
|
cost = {"candidates_before_triage": 4, "recorded_usd": 1.0, "recorded_triage_usd": 0.01, "recorded_triage_basis": "derived"}
|
|
sites = {"id": "case", "family": "injection", "sites": ["app.py::run"], "sites_in_set": ["app.py::run"], "cost": cost}
|
|
metrics = agree.run(tmp_path / "agree", CANDIDATES, agree.load_pass(a), agree.load_pass(b), sites=sites)["metrics"]
|
|
spend = metrics["usd_total"]
|
|
assert metrics["candidates"] == 3 and metrics["candidates_before_triage"] == 4 and metrics["declared_sites_in_set"] == 1
|
|
assert metrics["cost_per_candidate"] == pytest.approx(spend / 4, abs=1e-6)
|
|
assert metrics["usd_total_with_recorded_triage"] == pytest.approx(spend + 0.01, abs=1e-6)
|
|
assert metrics["cost_per_candidate_with_recorded_triage"] == pytest.approx((spend + 0.01) / 4, abs=1e-6)
|
|
# without the fix ranges a miss cannot be told from a match: not assessed, never a silent zero
|
|
assert metrics["site_matching"] == "not assessed: no fix ranges given" and metrics["declared_sites_matched"] == 0
|
|
|
|
|
|
def test_matches_declared_is_the_protocol_v2_rule() -> None:
|
|
case = {"family": "injection", "sites": ["a.py::run", "b.py::<module>"]}
|
|
ranges = {"a.py": [[50, 52]], "c.py": [[5, 5]]}
|
|
hit = lambda site, family="injection": agree.matches_declared({"site": site, "family": family}, case, ranges) # noqa: E731
|
|
assert hit("a.py:0:run") and hit("b.py:0:<module>") # a declared path::function, whether or not the fix touched it
|
|
assert not hit("a.py:0:run", "ssrf") # another family never matches
|
|
assert hit("a.py:62:helper") and not hit("a.py:63:helper") and hit("a.py:40:x") and not hit("a.py:39:x") # 10-line window
|
|
assert hit("c.py:900:run") and not hit("d.py:1:run") # a declared function name in a changed file only
|
|
assert (
|
|
not hit("a.py:x:helper") and hit("c.py:x:helper") and agree.anchor_site("a.py::run", "other.py:7:run") == "a.py:0:run"
|
|
) # a non-numeric line is -1, as in the reference
|
|
assert agree.anchor_site("a.py::run", "a.py:7:renamed") == "a.py:7:run"
|
|
|
|
|
|
def test_agree_without_sites_leaves_site_matching_unassessed_and_flags_unasked_candidates(tmp_path: Path) -> None:
|
|
_, a, b = _two_passes(tmp_path)
|
|
rows_b = [r for r in agree.load_pass(b) if r["path"] != "db.py"]
|
|
summary = agree.run(tmp_path / "agree", CANDIDATES, agree.load_pass(a), rows_b)
|
|
assert summary["status"] == "unfinished" and "db.py::query" in summary["reason"]
|
|
report = json.loads((tmp_path / "agree" / "agreed.json").read_text())
|
|
assert {r["candidate"]: (r["b"], r["site_match"]) for r in report["candidates"]}["db.py::query"] == (None, None)
|
|
assert summary["metrics"]["site_matching"] == "not assessed: no sites given"
|
|
|
|
|
|
def test_agree_main_reads_the_env_contract(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
_, a, b = _two_passes(tmp_path)
|
|
(tmp_path / "candidates.json").write_text(json.dumps(CANDIDATES))
|
|
(tmp_path / "case.toml").write_text(
|
|
'[[case]]\nid = "case"\nfamily = "injection"\nsites = ["app.py::run"]\nranges = { "app.py" = [[40, 41]] }\n\n'
|
|
'[[case]]\nid = "x"\nfamily = "injection"\nsites = []\n'
|
|
)
|
|
env = {
|
|
"OUSAST_OUTPUT_DIR": str(tmp_path / "agree"), "OUSAST_INPUT_PASS_A": str(a), "OUSAST_INPUT_PASS_B": str(b / "units.jsonl"),
|
|
"OUSAST_INPUT_CANDIDATES": str(tmp_path / "candidates.json"), "OUSAST_INPUT_SITES": str(tmp_path / "case.toml"),
|
|
"OUSAST_CASE_ID": "case",
|
|
} # fmt: skip
|
|
for key, value in env.items():
|
|
monkeypatch.setenv(key, value)
|
|
assert agree.main() == 0
|
|
summary = json.loads((tmp_path / "agree" / "summary.json").read_text())
|
|
assert summary["metrics"]["declared_sites_matched"] == 1 and summary["metrics"]["agreed"] == 1
|
|
monkeypatch.delenv("OUSAST_CASE_ID")
|
|
assert agree.main() == 2 # two cases match: the record is ambiguous, not silently the first
|