feat(benchmark): Phase 2 (task 4) — actionable per-rule signals, ledger, FP knob

Complete Phase 2: make benchmark output drive per-rule improvement, wire the
loop-owned ledger, and turn a confirmed false positive into a score knob. Zero
new dependencies; detection stays byte-identical (recall 44/47 = 93.6%, FP 0%)
and the 90/10 gate holds.

- Per-rule attribution (4.1): BenchmarkMiss/BenchmarkFalsePositive carry rule_id;
  BenchmarkMetrics.per_rule aggregates matched/missed/false_positives per rule.
- Recommendation delta (4.2): evaluate_benchmark emits RuleRecommendation
  (keep/shadow/loosen) written to rule_policy_delta.json; the inert
  next_improvement_candidate stub is now rule-aware ("rule:<id>" or
  "language_hunter").
- Loop ledger (4.3): cli loads the ruleset with the rule_policy.json overlay
  (empty -> byte-identical; an entry overlays a rule's status/floors at load);
  read_rule_ledger/write_rule_ledger added for the evolve loop. A disabled rule
  in the ledger no longer fires (covered by an integration test).
- FP -> reachability knob (4.4): a confirmed false-positive learning lowers a
  finding's effective reachability multiplier in the score
  (fp_reachability_overrides + scoring reachability_override) instead of deleting
  the rule, so recall stays high while the score stops over-penalizing.

93 tests pass; ruff + mypy clean. Spec tasks 4.1-4.4 checked off — Phase 2
(Core: rules-as-data + benchmark signals) is complete. The loop-driven decision
to apply ledger flips remains the gated evolve loop (Phase 3/4).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Marius Ciepluch
2026-06-22 15:41:17 +02:00
co-authored by Claude Opus 4.8
parent 444d8c9490
commit 2d7ef48e52
11 changed files with 316 additions and 16 deletions
+68 -1
View File
@@ -322,15 +322,82 @@ evidence = "memcpy copies attacker-controlled input"
"cwe": "CWE-120",
"evidence": "memcpy copies attacker-controlled input",
"failed_stage": "benchmark_ground_truth_matching",
"next_improvement_candidate": "rules_or_language_hunter",
"next_improvement_candidate": "rule:c-unsafe-memory-copy",
"path": "src/vuln.cpp",
"reason": "no OpenUltraSAST finding matched the expected benchmark vulnerability",
"rule_id": "c-unsafe-memory-copy",
"vulnerability_class": "unsafe memory copy",
}
]
assert deltas == [{"extra_findings_total": 1, "matched_expected_total": 1, "missed_expected_total": 0, "tool": "clearwing"}]
def test_benchmark_attributes_per_rule_signals_and_recommendations(tmp_path: Path) -> None:
manifest_path = tmp_path / "benchmark.toml"
repo = tmp_path / "repo"
repo.mkdir()
manifest_path.write_text(
"""
name = "cpp-smoke"
language = "c_cpp"
[source]
path = "repo"
[[expected]]
cwe = "CWE-120"
class = "buffer overflow"
path = "src/vuln.cpp"
line = 9
rule_id = "c-unsafe-memory-copy"
sink = "memcpy"
evidence = "memcpy copies attacker-controlled input"
[[expected]]
cwe = "CWE-78"
class = "command injection"
path = "src/vuln.cpp"
line = 20
rule_id = "c-shell-exec"
sink = "system"
evidence = "system runs attacker input"
""".strip()
+ "\n"
)
manifest = load_benchmark_manifest(manifest_path)
run = create_benchmark_run(repo, manifest)
findings = [
_finding(
finding_id="c-unsafe-memory-copy:src/vuln.cpp:9",
path="src/vuln.cpp",
line=9,
rationale="Static pattern c-unsafe-memory-copy matched memcpy with attacker-controlled input.",
),
_finding(
finding_id="js-eval:src/app.js:3",
path="src/app.js",
line=3,
rationale="Static pattern js-eval matched eval.",
),
]
result = evaluate_benchmark(run=run, mode="quick", findings=findings, scan_id="scan-1", scan_run_dir=repo / ".runs" / "scan-1")
write_benchmark_artifacts(run, manifest_path, result)
per_rule = result.metrics.per_rule
assert per_rule["c-unsafe-memory-copy"] == {"matched": 1, "missed": 0, "false_positives": 0}
assert per_rule["c-shell-exec"]["missed"] == 1
assert per_rule["js-eval"]["false_positives"] == 1
actions = {rec.rule_id: rec.action for rec in result.rule_recommendations}
assert actions["c-unsafe-memory-copy"] == "keep"
assert actions["c-shell-exec"] == "loosen"
assert actions["js-eval"] == "shadow"
delta = json.loads((run.root / "rule_policy_delta.json").read_text())
assert {item["rule_id"] for item in delta} == {"c-unsafe-memory-copy", "c-shell-exec", "js-eval"}
def _finding(
*,
finding_id: str,