feat(skills): Phase 14 — Trail of Bits skill index + router

Replace the hardcoded skill-name stub with a real, routable skill index.

- skills.py (zero-dep): SkillDescriptor inventory (~22 ToB skills with routing
  metadata — languages, tags, vulnerability classes, stages — plus a short operating
  checklist each), aligned with the spec's routing examples. route_skills scores by
  vulnerability-class/tag/stage signal with a language filter+bonus, and never selects
  on a pure language match. route_skills_for_target derives vuln classes from tags;
  select_skill_context packs checklists into a char budget; skill_chunks chunks the
  guidance into the `skills` retrieval namespace.
- hunter.select_skill_snippets now delegates to the router (existing C-memory and
  crypto+auth routing expectations preserved).
- hunter_harness._describe feeds the budget-capped routed skill context into the
  HarnessX hunter prompt (the genuine consumer of skill_budget_chars).
- 8.4 zero-dep guard extended to cover skills.py.

tests/test_skills.py (9 tests: C parser → memory/fuzzing, crypto → constant-time/
vector, language-mismatch + language-only exclusion, stage filtering, budget cap,
namespace chunking). openrouter-sast-harness Phase 14 checked off.

188 passed, 4 skipped; ruff + mypy clean; detection gate PASS.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Marius Ciepluch
2026-06-22 19:51:04 +02:00
co-authored by Claude Opus 4.8
parent 9c917dd6aa
commit 3fd0057e5f
6 changed files with 476 additions and 19 deletions
+1
View File
@@ -147,6 +147,7 @@ GOVERNANCE_SCORING_BENCHMARK_PLANES = (
"openultrasast.slot_contract",
"openultrasast.stage_processors",
"openultrasast.fusion",
"openultrasast.skills",
)
+85
View File
@@ -0,0 +1,85 @@
"""Trail of Bits skill index + router (Phase 14)."""
from openultrasast.preprocess import FileTarget
from openultrasast.skills import (
SKILL_INVENTORY,
route_skill_ids,
route_skills,
route_skills_for_target,
select_skill_context,
skill_chunks,
)
def _target(language: str, tags: list[str]) -> FileTarget:
return FileTarget(path="t", absolute_path="/repo/t", language=language, loc=10, tags=tags, has_fuzz_entry_point=False)
# ---- routing by language / tags / vulnerability class -----------------------
def test_c_parser_target_routes_to_memory_and_fuzzing_skills() -> None:
ids = route_skill_ids(_target("c", ["memory_unsafe", "parser"]))
assert {"address-sanitizer", "harness-writing"} <= set(ids) # memory + fuzzing
assert "libfuzzer" in ids
def test_crypto_target_routes_to_constant_time_and_vector_skills() -> None:
ids = route_skill_ids(_target("python", ["crypto"]))
assert "constant-time-analysis" in ids
assert "vector-forge" in ids
def test_language_mismatch_excludes_other_language_skills() -> None:
# A C target must not pull Python-only (atheris) or Rust-only (cargo-fuzz) skills.
ids = route_skill_ids(_target("c", ["memory_unsafe", "parser", "fuzzable"]))
assert "atheris" not in ids
assert "cargo-fuzz" not in ids
def test_language_only_match_does_not_select() -> None:
# python + crypto must not surface atheris (python but no crypto signal).
ids = route_skills_for_target(_target("python", ["crypto"]), max_snippets=10)
assert "atheris" not in [skill.id for skill in ids]
# ---- routing by stage -------------------------------------------------------
def test_stage_filter_restricts_to_stage_skills() -> None:
# fuzzing-dictionary is verify-only; at the map stage it must not appear, while a
# map-stage skill (semgrep) does for an injection signal.
verify = route_skills(tags=("parser",), vulnerability_classes=("parsing",), stage="verify", max_snippets=10)
mapped = route_skills(tags=("parser",), vulnerability_classes=("parsing",), stage="map", max_snippets=10)
assert "fuzzing-dictionary" in {s.id for s in verify}
assert "fuzzing-dictionary" not in {s.id for s in mapped}
# ---- prompt budget caps -----------------------------------------------------
def test_select_skill_context_respects_budget() -> None:
target = _target("c", ["memory_unsafe", "parser"])
full = select_skill_context(target, budget_chars=10_000)
capped = select_skill_context(target, budget_chars=80)
assert len(capped) <= 80
assert len(full) > len(capped)
def test_zero_budget_yields_no_context() -> None:
assert select_skill_context(_target("c", ["memory_unsafe"]), budget_chars=0) == ""
# ---- retrieval-namespace chunking + inventory integrity ---------------------
def test_skill_chunks_land_in_the_skills_namespace() -> None:
chunks = skill_chunks()
assert chunks
assert all(chunk.namespace == "skills" for chunk in chunks)
assert any(chunk.metadata.get("skill_id") == "address-sanitizer" for chunk in chunks)
def test_inventory_ids_are_unique() -> None:
ids = [skill.id for skill in SKILL_INVENTORY]
assert len(ids) == len(set(ids))