mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-16 19:59:26 +00:00
The meta-agent round journalled `no_proposal`. The real cause was an authentication failure: `_model_config` hard-coded the anthropic provider while the detector was talking to DeepSeek and no ANTHROPIC_API_KEY exists here. The composition itself was fine -- MetaAgent constructed, evolve started, budget $5 -- and the trace was thrown away. `no_proposal` covered two different worlds: a proposer that considered the facts and declined, and one that could not start at all. The journal now carries the proposer's own reason, and the vendor comes from `[harnessx] provider` instead of being assumed. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
303 lines
13 KiB
Python
303 lines
13 KiB
Python
"""learning-harness task 3.9: the learning subcommands (offline)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from openultrasast.cli import main
|
|
|
|
VULN = "from flask import request\nimport os\n\n\ndef run():\n os.system(request.args.get('cmd'))\n"
|
|
FIXED = "from flask import request\nimport subprocess\n\n\ndef run():\n subprocess.run(['echo', 'x'], check=False)\n"
|
|
CATALOG = (
|
|
'[[pair]]\nname = "{name}"\nslice = "vibe-py"\nlanguage = "python"\nvuln = "{name}-v.py"\nfixed = "{name}-f.py"\n'
|
|
'relpath = "app.py"\nsplit = "{split}"\nreview_tier = "seeded"\n\n'
|
|
'[[pair.expected]]\ncwe = "CWE-78"\nclass = "command injection"\npath = "app.py"\nfunction = "run"\n'
|
|
'sink = "system"\nmechanism = "source_reaches_sink"\nfamily = "injection"\n'
|
|
)
|
|
|
|
|
|
def _detector(monkeypatch: pytest.MonkeyPatch, line: int = 6) -> None:
|
|
"""A deterministic detector that reads the file it was given and answers differently on the two sides.
|
|
|
|
The shipped `scripted` stub answers twice and then goes quiet, and its finding sits at line 3, outside `run`.
|
|
Both make every family number zero, and a scoreboard assertion against zeros passes with the detector missing
|
|
entirely — which is what these tests used to assert."""
|
|
from openultrasast.learning.endpoint import ChatEndpoint
|
|
from openultrasast.tool_hunter import ChatResponse, ToolCall
|
|
|
|
class Detector:
|
|
def __init__(self) -> None:
|
|
self.turn = 0
|
|
|
|
def complete(self, *, model: str, messages, tools=None, **options): # type: ignore[no-untyped-def]
|
|
del model, tools, options
|
|
self.turn += 1
|
|
if self.turn % 2 == 1:
|
|
return ChatResponse(tool_calls=(ToolCall(id=f"c{self.turn}", name="grep_repo", arguments={"pattern": "system"}),))
|
|
body = "\n".join(str(message.get("content") or "") for message in messages)
|
|
if "os.system" not in body:
|
|
return ChatResponse(content="[]") # the fixed side really is different, and the detector sees it
|
|
return ChatResponse(content=json.dumps([{"path": "app.py", "line": line, "title": "t", "rationale": "r"}]))
|
|
|
|
endpoint = ChatEndpoint(provider="scripted", base_url="", thinking=False)
|
|
monkeypatch.setattr("openultrasast.learning.endpoint.resolve_chat_endpoint", lambda *a, **k: (Detector(), endpoint))
|
|
|
|
|
|
@pytest.fixture
|
|
def workspace(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
|
for name in ("train-a", "hold-a"):
|
|
(tmp_path / f"{name}-v.py").write_text(VULN)
|
|
(tmp_path / f"{name}-f.py").write_text(FIXED)
|
|
(tmp_path / "pairs.toml").write_text(
|
|
CATALOG.format(name="train-a", split="train") + "\n" + CATALOG.format(name="hold-a", split="holdout")
|
|
)
|
|
monkeypatch.setenv("OPENULTRASAST_HUNTER_CLIENT", "scripted")
|
|
return tmp_path # no chdir: the mechanism vocabulary is found relative to the repository root
|
|
|
|
|
|
def test_classify_reports_the_corpus_and_writes_the_queue(workspace: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
|
assert main(["learning", "classify", "--catalog", str(workspace / "pairs.toml"), "--out", str(workspace / "learning")]) == 0
|
|
payload = json.loads((workspace / "learning" / "classifier.json").read_text())
|
|
assert payload["classified"] == 2 and payload["per_family"]["injection"] == 2
|
|
assert payload["hierarchical_credit"] is False
|
|
assert "classified 2" in capsys.readouterr().out
|
|
|
|
|
|
def test_score_reports_family_numbers_for_a_catalog(
|
|
workspace: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""The scripted client detects the vulnerable side and is silent on the fix, so this is a real 2/2, not a zero.
|
|
|
|
An all-zero scoreboard would satisfy "the family key exists"; it is what a generalist detector scoring every
|
|
family produced before round zero ran each family's own configuration."""
|
|
_detector(monkeypatch)
|
|
assert main(["learning", "score", "--catalog", str(workspace / "pairs.toml"), "--out", str(workspace / "learning"), "--json"]) == 0
|
|
payload = json.loads(capsys.readouterr().out)
|
|
block = payload["families"]["injection"]
|
|
assert block["taxonomy_version"] and block["scorable"] == 2
|
|
assert block["outcomes"] == {"pair_correct": 2} and block["recall"] == 1.0 and block["youden"] == 1.0
|
|
|
|
|
|
def test_baseline_writes_the_noise_floor_and_publish_reads_it(
|
|
workspace: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
_detector(monkeypatch)
|
|
assert (
|
|
main(
|
|
[
|
|
"learning",
|
|
"baseline",
|
|
"--catalog",
|
|
str(workspace / "pairs.toml"),
|
|
"--out",
|
|
str(workspace / "learning"),
|
|
"--k-runs",
|
|
"3",
|
|
"--model",
|
|
"scripted",
|
|
]
|
|
)
|
|
== 0
|
|
)
|
|
floors = json.loads((workspace / "learning" / "baseline" / "scripted" / "noise-floors.json").read_text())
|
|
assert floors["k_runs"] == 3 and floors["floors"]["injection"]["pairs"] == 2
|
|
report = json.loads((workspace / "learning" / "baseline" / "scripted" / "report.json").read_text())
|
|
# Measured with the injection configuration, not the generalist: `family:unknown` earns no credit against any
|
|
# real family, so a baseline built from one shared detector reports a structural zero here.
|
|
assert report["metrics"]["injection"]["recall"] == 1.0
|
|
assert report["per_slice"]["vibe-py/injection"]["scorable"] == 2
|
|
capsys.readouterr()
|
|
roadmap = workspace / "roadmap.md"
|
|
roadmap.write_text("# Roadmap\n\n## Overview\n\ntext\n")
|
|
assert (
|
|
main(
|
|
["learning", "publish", "--out", str(workspace / "learning"), "--measurements", str(workspace / "m"), "--roadmap", str(roadmap)]
|
|
)
|
|
== 0
|
|
)
|
|
assert "learning-harness:begin" in roadmap.read_text()
|
|
assert "| injection |" in roadmap.read_text()
|
|
|
|
|
|
def test_a_round_runs_from_the_command_line_and_journals_its_outcome(workspace: Path) -> None:
|
|
assert (
|
|
main(
|
|
[
|
|
"learning",
|
|
"baseline",
|
|
"--catalog",
|
|
str(workspace / "pairs.toml"),
|
|
"--out",
|
|
str(workspace / "learning"),
|
|
"--k-runs",
|
|
"3",
|
|
"--model",
|
|
"scripted",
|
|
]
|
|
)
|
|
== 0
|
|
)
|
|
assert (
|
|
main(
|
|
[
|
|
"learning",
|
|
"round",
|
|
"--catalog",
|
|
str(workspace / "pairs.toml"),
|
|
"--out",
|
|
str(workspace / "learning"),
|
|
"--family",
|
|
"injection",
|
|
"--k-runs",
|
|
"3",
|
|
]
|
|
)
|
|
== 0
|
|
)
|
|
rounds = [json.loads(line) for line in (workspace / "learning" / "journal.jsonl").read_text().splitlines() if line.strip()]
|
|
assert len(rounds) == 1 and rounds[0]["family"] == "injection"
|
|
# Without the extra there is no meta-agent, so the honest outcome is exactly this one. Accepting any member of
|
|
# the enum would have hidden that the round could never propose anything at all.
|
|
# The reason now carries *why* the proposer had nothing: "no proposal" and "could not start" are different
|
|
# worlds, and only one of them is a fixable configuration error.
|
|
assert rounds[0]["outcome"] == "rejected" and rounds[0]["reason"].startswith("no_proposal")
|
|
assert (workspace / "learning" / "rounds" / "1" / "attribution.json").is_file()
|
|
|
|
|
|
def test_a_recorded_proposal_makes_the_round_do_something(workspace: Path) -> None:
|
|
"""`--proposals` is what makes a round runnable at all without the optional extra."""
|
|
for command in (
|
|
["learning", "baseline", "--catalog", str(workspace / "pairs.toml"), "--out", str(workspace / "learning"), "--k-runs", "3"],
|
|
):
|
|
assert main([*command, "--model", "scripted"]) == 0
|
|
proposals = workspace / "proposals.json"
|
|
proposals.write_text(
|
|
json.dumps([{"hypothesis": "ask about quoting", "lever": "checklist", "change": {"checklist.md": "- ask about quoting\n"}}])
|
|
)
|
|
assert (
|
|
main(
|
|
[
|
|
"learning",
|
|
"round",
|
|
"--catalog",
|
|
str(workspace / "pairs.toml"),
|
|
"--out",
|
|
str(workspace / "learning"),
|
|
"--family",
|
|
"injection",
|
|
"--k-runs",
|
|
"3",
|
|
"--proposals",
|
|
str(proposals),
|
|
]
|
|
)
|
|
== 0
|
|
)
|
|
(record,) = [json.loads(line) for line in (workspace / "learning" / "journal.jsonl").read_text().splitlines() if line.strip()]
|
|
assert record["reason"] != "no_proposal", "a recorded proposal reached the round"
|
|
assert json.loads((workspace / "learning" / "rounds" / "1" / "proposal.json").read_text())["lever"] == "checklist"
|
|
|
|
|
|
def test_a_missing_endpoint_is_recorded_and_the_rest_still_runs(workspace: Path, monkeypatch: pytest.MonkeyPatch, capsys) -> None: # type: ignore[no-untyped-def]
|
|
for name in ("OPENULTRASAST_HUNTER_CLIENT", "DEEPSEEK_API_KEY", "OPENROUTER_API_KEY"):
|
|
monkeypatch.delenv(name, raising=False)
|
|
assert (
|
|
main(
|
|
[
|
|
"learning",
|
|
"baseline",
|
|
"--catalog",
|
|
str(workspace / "pairs.toml"),
|
|
"--out",
|
|
str(workspace / "learning"),
|
|
"--k-runs",
|
|
"3",
|
|
"--model",
|
|
"none",
|
|
]
|
|
)
|
|
== 0
|
|
)
|
|
out = capsys.readouterr().out
|
|
assert "learning_endpoint_unavailable" in out
|
|
assert (workspace / "learning" / "baseline" / "none" / "report.json").is_file() # the run still recorded what it could
|
|
|
|
|
|
def test_every_subcommand_is_listed_in_help(capsys: pytest.CaptureFixture[str]) -> None:
|
|
with pytest.raises(SystemExit):
|
|
main(["learning", "--help"])
|
|
out = capsys.readouterr().out
|
|
for name in ("classify", "score", "baseline", "round", "publish"):
|
|
assert name in out
|
|
|
|
|
|
def test_the_pairs_command_scores_k_runs_per_side(workspace: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""Grepping `--help` for the flag passes with the capability entirely absent, which is what it used to be."""
|
|
from openultrasast import pairs as pairs_module
|
|
|
|
calls: list[str] = []
|
|
real = pairs_module.make_hunter_scan
|
|
|
|
def counting(client: object, model: str, **kwargs: object): # type: ignore[no-untyped-def]
|
|
inner = real(client, model, **kwargs) # type: ignore[arg-type]
|
|
|
|
def scan(root: Path): # type: ignore[no-untyped-def]
|
|
calls.append(root.name)
|
|
return inner(root)
|
|
|
|
return scan
|
|
|
|
monkeypatch.setattr(pairs_module, "make_hunter_scan", counting)
|
|
monkeypatch.setattr("openultrasast.cli.make_hunter_scan", counting)
|
|
monkeypatch.setenv("OPENULTRASAST_HUNTER_MODEL", "scripted")
|
|
assert main(["pairs", "--catalog", str(workspace / "pairs.toml"), "--hunter", "--hunter-model", "scripted", "--k-runs", "4"]) == 0
|
|
assert calls.count("vuln") == 8 and calls.count("fixed") == 8 # two pairs, four runs per side
|
|
|
|
|
|
def test_a_baseline_records_what_the_endpoint_actually_spent(workspace: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""Req 8.3: cost per pair is published beside every number, so the run has to read it off the client.
|
|
|
|
The client meters itself; nothing passed the meter to the baseline, so every published cost would have been
|
|
0.0 — and the round's cost cap, which reads the same meter, could never have fired either (Req 9.8)."""
|
|
from openultrasast.learning.endpoint import ChatEndpoint
|
|
from openultrasast.tool_hunter import ChatResponse
|
|
|
|
class Metered:
|
|
def __init__(self) -> None:
|
|
self.calls = 0
|
|
|
|
def complete(self, *, model: str, messages, tools=None, **options): # type: ignore[no-untyped-def]
|
|
del model, messages, tools, options
|
|
self.calls += 1
|
|
return ChatResponse(content="[]")
|
|
|
|
def cost_usd(self) -> float:
|
|
return 0.25 * self.calls
|
|
|
|
client = Metered()
|
|
endpoint = ChatEndpoint(provider="scripted", base_url="", thinking=False)
|
|
monkeypatch.setattr("openultrasast.learning.endpoint.resolve_chat_endpoint", lambda *a, **k: (client, endpoint))
|
|
assert (
|
|
main(
|
|
[
|
|
"learning",
|
|
"baseline",
|
|
"--catalog",
|
|
str(workspace / "pairs.toml"),
|
|
"--out",
|
|
str(workspace / "learning"),
|
|
"--k-runs",
|
|
"3",
|
|
"--model",
|
|
"metered",
|
|
]
|
|
)
|
|
== 0
|
|
)
|
|
report = json.loads((workspace / "learning" / "baseline" / "metered" / "report.json").read_text())
|
|
assert client.calls > 0
|
|
assert report["cost_usd"] == pytest.approx(0.25 * client.calls)
|