Files
Open_Ultra_SAST/src/openultrasast/config.py
T
Marius CiepluchandClaude Opus 4.8 e255eb264f feat(harnessx): configurable provider + per-task budget caps
Add a [harnessx] config block so the agentic plane can be steered from
openultrasast.toml, and make the LLM provider selectable (the hunter and verifier
previously hardcoded AnthropicProvider).

- config.HarnessxConfig: provider ("anthropic"|"openai"|"litellm", default
  anthropic), max_cost_usd (2.0), token_threshold (120000); parsed from [harnessx].
- harness_ext.build_provider: single lazy, capability-guarded provider factory.
  The provider name is validated before any import, so an unknown choice fails with
  a clear ValueError even when the extra is absent; each provider reads its own
  standard key env var (openai -> OPENAI_API_KEY, anthropic -> ANTHROPIC_API_KEY).
- HxScanOrchestrator and verify_findings_dispatch take a provider and route through
  build_provider; cli threads config.harnessx.{provider,max_cost_usd,token_threshold}
  into both the hunter and the verifier.
- README: "Configuring the HarnessX agentic plane" section (install, models,
  provider, budgets, fallback/degradation).

Test isolation: convert the "harnessx not in sys.modules" import-guards to a
hermetic subprocess fixture (tests/conftest.py assert_cold_of_harnessx) and
simulate extra-absent in the CLI dispatch tests, so the suite is deterministic
whether or not the optional extra is installed (it now is, locally). Verified the
OpenAI path constructs end-to-end (build_provider -> OpenAIProvider; orchestrator
-> Harness) with the budget threaded through.

137 passed, 3 skipped; ruff + mypy clean; detection gate PASS.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-22 17:59:44 +02:00

224 lines
6.9 KiB
Python

from __future__ import annotations
import json
import os
import tomllib
from dataclasses import asdict, dataclass
from pathlib import Path
DEFAULT_VECTOR_STORE = "json-local"
@dataclass(frozen=True)
class ModelConfig:
ranker: str | None = None
hunter: str | None = None
verifier: str | None = None
patcher: str | None = None
@dataclass(frozen=True)
class EmbeddingConfig:
model: str | None = None
store: str = DEFAULT_VECTOR_STORE
@dataclass(frozen=True)
class SandboxConfig:
network: bool = False
workspace_readonly: bool = True
memory_mb: int = 2048
timeout_seconds: int = 300
pids_limit: int = 512
@dataclass(frozen=True)
class DynamicConfig:
enabled: bool = False
network_scope: tuple[str, ...] = ()
@dataclass(frozen=True)
class EvidenceConfig:
minimum_report_verified: str = "static_corroboration"
minimum_exploit: str = "crash_reproduced"
minimum_patch: str = "root_cause_explained"
@dataclass(frozen=True)
class StaticAnalysisConfig:
sarif_paths: tuple[str, ...] = ()
@dataclass(frozen=True)
class ScoreConfig:
k: float = 60.0
min_score: int = 80
block_severity_reachable: int = 5
blocking: bool = False
@dataclass(frozen=True)
class RulesetConfig:
# Emission floors that replace "every regex match becomes a finding". The
# defaults (0.0) emit everything, keeping output byte-identical by default.
min_emit_priority: float = 0.0
min_emit_precision: float = 0.0
@dataclass(frozen=True)
class HarnessxConfig:
# Optional agentic plane (the openultrasast[harnessx] extra). `provider`
# selects the HarnessX LLM provider used by both the hunter pool and the
# llm-judge verifier; the budget caps bound per-task cost and token spend.
# Inert unless the extra is installed and a model is configured in [models].
provider: str = "anthropic"
max_cost_usd: float = 2.0
token_threshold: int = 120_000
@dataclass(frozen=True)
class ResolvedConfig:
models: ModelConfig = ModelConfig()
embeddings: EmbeddingConfig = EmbeddingConfig()
sandbox: SandboxConfig = SandboxConfig()
dynamic: DynamicConfig = DynamicConfig()
evidence: EvidenceConfig = EvidenceConfig()
static_analysis: StaticAnalysisConfig = StaticAnalysisConfig()
score: ScoreConfig = ScoreConfig()
ruleset: RulesetConfig = RulesetConfig()
harnessx: HarnessxConfig = HarnessxConfig()
runs_dir: str = ".openultrasast/runs"
def load_config(config_path: Path | None = None) -> ResolvedConfig:
data: dict[str, object] = {}
if config_path is not None and config_path.exists():
with config_path.open("rb") as handle:
data = tomllib.load(handle)
return ResolvedConfig(
models=_load_models(data.get("models", {})),
embeddings=_load_embeddings(data.get("embeddings", {})),
sandbox=_load_sandbox(data.get("sandbox", {})),
dynamic=_load_dynamic(data.get("dynamic", {})),
evidence=_load_evidence(data.get("evidence", {})),
static_analysis=_load_static_analysis(data.get("static_analysis", {})),
score=_load_score(data.get("score", {})),
ruleset=_load_ruleset_config(data.get("ruleset", {})),
harnessx=_load_harnessx(data.get("harnessx", {})),
runs_dir=os.environ.get("OPENULTRASAST_RUNS_DIR", ".openultrasast/runs"),
)
def write_resolved_config(config: ResolvedConfig, path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(asdict(config), indent=2, sort_keys=True) + "\n")
def _section(value: object) -> dict[str, object]:
return value if isinstance(value, dict) else {}
def _string(value: object) -> str | None:
return value if isinstance(value, str) and value else None
def _load_models(value: object) -> ModelConfig:
data = _section(value)
return ModelConfig(
ranker=_string(data.get("ranker")),
hunter=_string(data.get("hunter")),
verifier=_string(data.get("verifier")),
patcher=_string(data.get("patcher")),
)
def _load_embeddings(value: object) -> EmbeddingConfig:
data = _section(value)
return EmbeddingConfig(model=_string(data.get("model")), store=_string(data.get("store")) or DEFAULT_VECTOR_STORE)
def _load_sandbox(value: object) -> SandboxConfig:
data = _section(value)
return SandboxConfig(
network=bool(data.get("network", False)),
workspace_readonly=bool(data.get("workspace_readonly", True)),
memory_mb=_int_value(data.get("memory_mb"), 2048),
timeout_seconds=_int_value(data.get("timeout_seconds"), 300),
pids_limit=_int_value(data.get("pids_limit"), 512),
)
def _load_dynamic(value: object) -> DynamicConfig:
data = _section(value)
scope = data.get("network_scope", [])
if not isinstance(scope, list):
scope = []
return DynamicConfig(enabled=bool(data.get("enabled", False)), network_scope=tuple(str(item) for item in scope))
def _load_evidence(value: object) -> EvidenceConfig:
data = _section(value)
return EvidenceConfig(
minimum_report_verified=str(data.get("minimum_report_verified", "static_corroboration")),
minimum_exploit=str(data.get("minimum_exploit", "crash_reproduced")),
minimum_patch=str(data.get("minimum_patch", "root_cause_explained")),
)
def _int_value(value: object, default: int) -> int:
if isinstance(value, bool):
return default
if isinstance(value, int | str | bytes | bytearray):
return int(value)
return default
def _load_static_analysis(value: object) -> StaticAnalysisConfig:
data = _section(value)
paths = data.get("sarif_paths", [])
if not isinstance(paths, list):
paths = []
return StaticAnalysisConfig(sarif_paths=tuple(str(item) for item in paths))
def _load_score(value: object) -> ScoreConfig:
data = _section(value)
return ScoreConfig(
k=_float_value(data.get("k"), 60.0),
min_score=_int_value(data.get("min_score"), 80),
block_severity_reachable=_int_value(data.get("block_severity_reachable"), 5),
blocking=bool(data.get("blocking", False)),
)
def _load_ruleset_config(value: object) -> RulesetConfig:
data = _section(value)
return RulesetConfig(
min_emit_priority=_float_value(data.get("min_emit_priority"), 0.0),
min_emit_precision=_float_value(data.get("min_emit_precision"), 0.0),
)
def _load_harnessx(value: object) -> HarnessxConfig:
data = _section(value)
return HarnessxConfig(
provider=_string(data.get("provider")) or "anthropic",
max_cost_usd=_float_value(data.get("max_cost_usd"), 2.0),
token_threshold=_int_value(data.get("token_threshold"), 120_000),
)
def _float_value(value: object, default: float) -> float:
if isinstance(value, bool):
return default
if isinstance(value, int | float):
return float(value)
if isinstance(value, str):
try:
return float(value)
except ValueError:
return default
return default