"""learning-harness task 2.5: per-family detector configurations and the family runner (offline).""" from __future__ import annotations import json from pathlib import Path import pytest from openultrasast.learning.families import load_families from openultrasast.tool_hunter import ChatResponse, ScriptedChatClient, ToolCall APP = ( "from flask import request\n\n\n" "@app.route('/books/')\n@login_required\ndef guarded(title):\n" " return Book.query.filter_by(book_title=title).first()\n\n\n" "@app.route('/books/any/<title>')\ndef leaky(title):\n" " owner = request.args.get('owner')\n" " return Book.query.filter_by(user_id=owner, book_title=title).first()\n" ) REPLY = '[{"path": "app.py", "line": 12, "title": "unconstrained read", "rationale": "no owner check"}]' @pytest.fixture def repo(tmp_path: Path) -> Path: (tmp_path / "app.py").write_text(APP) return tmp_path def _write_config(root: Path, family: str, **overrides: object) -> Path: directory = root / family directory.mkdir(parents=True, exist_ok=True) (directory / "prompt.md").write_text(str(overrides.get("prompt", f"You hunt {family} only."))) (directory / "checklist.md").write_text(str(overrides.get("checklist", "- did the handler check ownership?"))) tools = overrides.get("tools", ["read_definition", "obligations"]) (directory / "family.toml").write_text( f'version = "{overrides.get("version", "1")}"\ntools = {json.dumps(tools)}\n' f"max_steps = {overrides.get('max_steps', 3)}\nmax_cost_usd = {overrides.get('max_cost_usd', 0.5)}\n" f"max_chars = {overrides.get('max_chars', 4000)}\n" ) (directory / "hard_negatives.jsonl").write_text(json.dumps({"note": "a guarded handler is not a finding"}) + "\n") (directory / "counterexamples.jsonl").write_text("") return directory def test_a_configuration_loads_every_part_it_needs(tmp_path: Path) -> None: from openultrasast.learning.detectors import load_family_configs _write_config(tmp_path, "access_control") configs = load_family_configs(tmp_path, load_families()) config = configs["access_control"] assert config.family == "access_control" and config.version == "1" assert config.prompt.startswith("You hunt access_control") and "ownership" in config.checklist assert config.tools == ("read_definition", "obligations") assert config.max_steps == 3 and config.max_cost_usd == 0.5 and config.max_chars == 4000 assert config.hard_negatives[0]["note"].startswith("a guarded handler") assert config.counterexamples == () assert load_family_configs(tmp_path, load_families())["access_control"] == config # loading twice is the same config @pytest.mark.parametrize( ("overrides", "needle"), [ ({"prompt": "x" * 9000}, "length"), ({"tools": ["read_file", "run_shell"]}, "run_shell"), ({"version": ""}, "version"), ], ids=["over_cap_prompt", "unknown_tool", "no_version"], ) def test_the_loader_refuses_a_broken_configuration_by_name(tmp_path: Path, overrides: dict[str, object], needle: str) -> None: from openultrasast.learning.detectors import DetectorConfigError, load_family_configs _write_config(tmp_path, "access_control", **overrides) with pytest.raises(DetectorConfigError, match=needle): load_family_configs(tmp_path, load_families()) def test_a_directory_that_is_not_a_family_is_refused_by_name(tmp_path: Path) -> None: from openultrasast.learning.detectors import DetectorConfigError, load_family_configs _write_config(tmp_path, "access_control") (tmp_path / "sql_injection").mkdir() (tmp_path / "sql_injection" / "prompt.md").write_text("x") with pytest.raises(DetectorConfigError, match="sql_injection"): load_family_configs(tmp_path, load_families()) def test_the_runner_uses_the_family_prompt_tools_and_tags(repo: Path, tmp_path: Path) -> None: from openultrasast.learning.detectors import Region, load_family_configs, run_family_detector configs_dir = tmp_path / "configs" _write_config(configs_dir, "access_control") config = load_family_configs(configs_dir, load_families())["access_control"] client = ScriptedChatClient( [ ChatResponse(tool_calls=(ToolCall(id="1", name="obligations", arguments={"path": "app.py", "function": "leaky"}),)), ChatResponse(content=REPLY), ] ) findings = run_family_detector(repo, Region(path="app.py", function="leaky"), config, client=client, model="m") assert [finding.tags for finding in findings] == [["family:access_control", "detector:access_control@1"]] assert findings[0].evidence_level == "suspicion" call = client.calls[0] assert call["messages"][0]["content"] == "You hunt access_control only." assert [tool["function"]["name"] for tool in call["tools"]] == ["read_definition", "obligations"] user = str(call["messages"][1]["content"]) assert "def leaky" in user and "def guarded" in user # the whole file, not an excerpt around a hotspot assert "ownership" in user and "a guarded handler is not a finding" in user def test_a_region_runs_every_admitted_family_plus_the_generalist(repo: Path, tmp_path: Path) -> None: from openultrasast.learning.detectors import Region, load_family_configs, run_region_detectors configs_dir = tmp_path / "configs" for family in ("access_control", "injection", "unknown"): _write_config(configs_dir, family, tools=["read_file"]) configs = load_family_configs(configs_dir, load_families()) client = ScriptedChatClient( [ ChatResponse(tool_calls=(ToolCall(id="1", name="read_file", arguments={"path": "app.py"}),)), ChatResponse(content=REPLY), ] * 4 ) region = Region(path="app.py", function="leaky", families=("access_control",)) findings = run_region_detectors(repo, region, configs, client=client, model="m") families = sorted({tag for finding in findings for tag in finding.tags if tag.startswith("family:")}) assert families == ["family:access_control", "family:unknown"] # the admitted family and the generalist, not injection assert len(client.calls) == 4 # two turns each, and nothing else was run def test_the_runner_never_offers_a_tool_outside_the_curated_set(repo: Path, tmp_path: Path) -> None: from openultrasast.learning.detectors import Region, load_family_configs, run_family_detector from openultrasast.tool_hunter import HUNTER_TOOLS configs_dir = tmp_path / "configs" _write_config( configs_dir, "injection", tools=["read_file", "grep_repo", "find_refs", "read_definition", "entry_points", "flows", "obligations"] ) config = load_family_configs(configs_dir, load_families())["injection"] client = ScriptedChatClient([ChatResponse(content="")]) run_family_detector(repo, Region(path="app.py", function="leaky"), config, client=client, model="m") offered = {tool["function"]["name"] for tool in client.calls[0]["tools"]} assert offered == {str(tool["function"]["name"]) for tool in HUNTER_TOOLS} assert "bash" not in offered and "run_shell" not in offered def test_round_zero_can_write_a_configuration_for_every_family(tmp_path: Path) -> None: from openultrasast.learning.detectors import load_family_configs, write_default_configs taxonomy = load_families() written = write_default_configs(tmp_path, taxonomy, prompt="You are a security hunter.", version="0") assert set(written) == {family.id for family in taxonomy.families} configs = load_family_configs(tmp_path, taxonomy) assert set(configs) == {family.id for family in taxonomy.families} assert all(config.version == "0" for config in configs.values()) assert all(config.prompt.startswith("You are a security hunter.") for config in configs.values()) assert configs["access_control"].prompt != configs["injection"].prompt # each carries its own family's definition