2026-06-17 21:13:51 +02:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import argparse
|
|
|
|
|
import json
|
2026-06-22 17:36:58 +02:00
|
|
|
import tempfile
|
2026-09-05 14:55:00 +02:00
|
|
|
from collections.abc import Sequence
|
2026-09-04 00:04:36 +02:00
|
|
|
from dataclasses import dataclass, replace
|
2026-06-17 21:13:51 +02:00
|
|
|
from pathlib import Path
|
2026-06-21 11:49:31 +02:00
|
|
|
from time import perf_counter
|
2026-06-17 21:13:51 +02:00
|
|
|
|
2026-09-04 00:04:36 +02:00
|
|
|
from . import tool_hunter
|
2026-06-21 11:49:31 +02:00
|
|
|
from .benchmark import (
|
|
|
|
|
create_benchmark_run,
|
|
|
|
|
evaluate_benchmark,
|
|
|
|
|
load_baseline_findings,
|
|
|
|
|
load_benchmark_manifest,
|
|
|
|
|
load_findings,
|
|
|
|
|
resolve_benchmark_source,
|
|
|
|
|
write_benchmark_artifacts,
|
|
|
|
|
)
|
2026-06-21 20:44:08 +02:00
|
|
|
from .calibration import (
|
|
|
|
|
FalsePositiveLearning,
|
|
|
|
|
calibrate_rankings,
|
2026-06-22 15:41:17 +02:00
|
|
|
fp_reachability_overrides,
|
2026-06-21 20:44:08 +02:00
|
|
|
learnings_from_verifications,
|
|
|
|
|
load_false_positive_learnings,
|
|
|
|
|
merge_false_positive_learnings,
|
|
|
|
|
write_false_positive_learnings,
|
|
|
|
|
write_ranking_calibrations,
|
|
|
|
|
)
|
2026-09-03 23:26:54 +02:00
|
|
|
from .complexity import map as complexity_map
|
2026-09-04 00:18:33 +02:00
|
|
|
from .complexity.ledger import persist_verdicts
|
2026-09-05 14:40:15 +02:00
|
|
|
from .complexity.map import Hotspot
|
2026-09-04 14:06:18 +02:00
|
|
|
from .config import load_config, load_dotenv
|
2026-06-22 15:27:16 +02:00
|
|
|
from .findings import StaticFinding, quick_scan_findings, write_findings
|
2026-06-22 18:57:03 +02:00
|
|
|
from .fusion import FusionDecision, fuse_findings_dispatch
|
2026-06-22 17:36:58 +02:00
|
|
|
from .gate import FALSE_POSITIVE_CEILING, RECALL_FLOOR
|
2026-06-18 07:57:06 +02:00
|
|
|
from .harness import HarnessRuntime, HarnessTraceWriter, write_harness_config
|
2026-06-22 16:29:52 +02:00
|
|
|
from .harness_ext import has_harnessx
|
2026-06-18 10:59:25 +02:00
|
|
|
from .hunter import run_hunter_pool, write_hunter_trajectories
|
2026-06-22 16:29:52 +02:00
|
|
|
from .hunter_harness import HxScanOrchestrator
|
2026-06-22 17:36:58 +02:00
|
|
|
from .improve import RoundOutcome, run_improvement
|
2026-06-17 21:13:51 +02:00
|
|
|
from .index import build_code_chunks
|
2026-06-18 07:57:06 +02:00
|
|
|
from .mapping import analyze_entry_points, attach_reachability_hints, ingest_sarif, write_entry_points, write_static_hints
|
2026-09-04 13:57:33 +02:00
|
|
|
from .pair_gate import print_pair_metrics
|
2026-09-05 09:27:40 +02:00
|
|
|
from .pairs import (
|
|
|
|
|
DEFAULT_CATALOG,
|
|
|
|
|
SLICE_NAMES,
|
2026-09-05 14:55:00 +02:00
|
|
|
PairCase,
|
2026-09-05 09:27:40 +02:00
|
|
|
evaluate_catalog,
|
|
|
|
|
load_pair_catalog,
|
|
|
|
|
make_hunter_scan,
|
|
|
|
|
result_payload,
|
|
|
|
|
select_profile,
|
|
|
|
|
select_slice,
|
|
|
|
|
select_split,
|
2026-09-05 14:40:15 +02:00
|
|
|
select_vendored,
|
2026-09-05 09:27:40 +02:00
|
|
|
)
|
2026-06-22 12:31:35 +02:00
|
|
|
from .policy import assert_rules_resolve, load_policy
|
2026-09-05 14:40:15 +02:00
|
|
|
from .preprocess import FileTarget, preprocess_repository, write_preprocess_artifact
|
2026-09-05 09:27:50 +02:00
|
|
|
from .provenance import fingerprint
|
2026-09-04 13:57:33 +02:00
|
|
|
from .provider.openrouter import OpenRouterEmbeddingClient, OpenRouterError
|
2026-06-17 21:13:51 +02:00
|
|
|
from .rank import rank_targets, write_rankings
|
2026-09-04 13:57:33 +02:00
|
|
|
from .regress import TRIGGERABLE, CandidateVerdict, run_regression, write_verdicts
|
2026-09-05 14:55:00 +02:00
|
|
|
from .regress.candidate import hotspot_from_finding
|
2026-06-18 10:16:25 +02:00
|
|
|
from .reports import scan_exit_code, write_manifest, write_markdown_report, write_sarif_report
|
2026-06-22 15:27:16 +02:00
|
|
|
from .ruleset import DEFAULT_RULESET_DIR, load_ruleset
|
2026-06-21 20:44:08 +02:00
|
|
|
from .run import ScanRun, create_scan_run
|
2026-09-03 23:43:15 +02:00
|
|
|
from .sandbox import resolve_sandbox_probe, resolve_sandbox_runner
|
2026-06-22 12:31:35 +02:00
|
|
|
from .scoring import build_score_artifact
|
2026-09-04 13:57:33 +02:00
|
|
|
from .semantic import (
|
|
|
|
|
MechanismStore,
|
|
|
|
|
OverlayRecord,
|
|
|
|
|
adjudicate,
|
|
|
|
|
append_mechanism,
|
|
|
|
|
filter_promoted_hotspots,
|
|
|
|
|
finding_from_coverage,
|
|
|
|
|
load_facts,
|
|
|
|
|
order_promotions,
|
|
|
|
|
write_overlay,
|
|
|
|
|
)
|
|
|
|
|
from .semantic.facts import FactLoadError, SemanticFacts
|
2026-09-03 23:43:15 +02:00
|
|
|
from .stages import Stage, plan_for_mode, record_completed, record_skip, skip_as_degradation, stages_payload
|
2026-09-04 00:04:36 +02:00
|
|
|
from .verification import VerificationResult, verify_findings, write_verification_results
|
2026-06-22 16:29:52 +02:00
|
|
|
from .verify_judge import verify_findings_dispatch
|
2026-06-21 20:44:08 +02:00
|
|
|
|
|
|
|
|
CALIBRATION_DIR = ".openultrasast/calibration"
|
2026-06-17 21:13:51 +02:00
|
|
|
|
|
|
|
|
|
2026-06-21 11:49:31 +02:00
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
class ScanOutcome:
|
|
|
|
|
scan_id: str
|
|
|
|
|
run_dir: Path
|
|
|
|
|
file_target_count: int
|
|
|
|
|
ranked_target_count: int
|
|
|
|
|
finding_count: int
|
2026-06-21 20:44:08 +02:00
|
|
|
calibrations_applied: int
|
2026-06-21 11:49:31 +02:00
|
|
|
exit_code: int
|
|
|
|
|
|
|
|
|
|
|
2026-06-17 21:13:51 +02:00
|
|
|
def main(argv: list[str] | None = None) -> int:
|
2026-09-04 14:06:18 +02:00
|
|
|
load_dotenv()
|
2026-06-17 21:13:51 +02:00
|
|
|
parser = argparse.ArgumentParser(prog="ousast")
|
|
|
|
|
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
|
|
|
|
|
|
|
|
scan = subparsers.add_parser("scan", help="scan a local repository")
|
|
|
|
|
scan.add_argument("path", type=Path)
|
|
|
|
|
scan.add_argument("--mode", choices=("quick", "standard", "deep"), default="quick")
|
|
|
|
|
scan.add_argument("--config", type=Path, default=Path("openultrasast.toml"))
|
2026-09-03 19:44:04 +02:00
|
|
|
scan.add_argument("--fail-on", choices=("never", "findings", "verified", "worth-fixing"), default="never")
|
2026-06-17 21:13:51 +02:00
|
|
|
|
|
|
|
|
index = subparsers.add_parser("index", help="chunk a local repository for embedding index construction")
|
|
|
|
|
index.add_argument("path", type=Path)
|
|
|
|
|
index.add_argument("--config", type=Path, default=Path("openultrasast.toml"))
|
|
|
|
|
index.add_argument("--chunk-lines", type=int, default=80)
|
|
|
|
|
|
2026-06-21 11:49:31 +02:00
|
|
|
benchmark = subparsers.add_parser("benchmark", help="run a benchmark manifest and write scoreboard artifacts")
|
|
|
|
|
benchmark.add_argument("manifest", type=Path)
|
|
|
|
|
benchmark.add_argument("--mode", choices=("quick", "standard", "deep"), default="quick")
|
|
|
|
|
benchmark.add_argument("--config", type=Path, default=Path("openultrasast.toml"))
|
|
|
|
|
|
2026-06-22 17:36:58 +02:00
|
|
|
improve = subparsers.add_parser(
|
|
|
|
|
"improve",
|
|
|
|
|
help="run the bounded self-improvement loop against a benchmark manifest and update the loop-owned ruleset ledger",
|
|
|
|
|
)
|
|
|
|
|
improve.add_argument("manifest", type=Path)
|
|
|
|
|
improve.add_argument("--max-rounds", type=int, default=5)
|
|
|
|
|
improve.add_argument("--recall-floor", type=float, default=RECALL_FLOOR)
|
|
|
|
|
improve.add_argument("--fp-ceiling", type=float, default=FALSE_POSITIVE_CEILING)
|
|
|
|
|
improve.add_argument(
|
|
|
|
|
"--ledger", type=Path, default=None, help="ruleset ledger path (default: <target>/.openultrasast/calibration/rule_policy.json)"
|
|
|
|
|
)
|
|
|
|
|
improve.add_argument("--journal", type=Path, default=None, help="improvement journal path (default: alongside the ledger)")
|
|
|
|
|
improve.add_argument(
|
|
|
|
|
"--ruleset-dir", type=Path, default=DEFAULT_RULESET_DIR, help="ruleset directory to improve (default: the bundled ruleset)"
|
|
|
|
|
)
|
|
|
|
|
improve.add_argument("--dry-run", action="store_true", help="run rounds against a throwaway ledger; never touch the target's ledger")
|
2026-09-05 09:28:00 +02:00
|
|
|
improve.add_argument(
|
|
|
|
|
"--pair-catalog",
|
|
|
|
|
type=Path,
|
|
|
|
|
default=DEFAULT_CATALOG,
|
|
|
|
|
help="pair catalog whose holdout split gates every round per provenance profile",
|
|
|
|
|
)
|
|
|
|
|
improve.add_argument(
|
|
|
|
|
"--no-pair-gate", action="store_true", help="skip the per-profile holdout clause (faster; not for accepted ledgers)"
|
|
|
|
|
)
|
|
|
|
|
improve.add_argument(
|
|
|
|
|
"--profile-tolerance", type=float, default=0.0, help="allowed per-profile drop in pair_correct/Youden before rejecting"
|
|
|
|
|
)
|
|
|
|
|
improve.add_argument("--min-holdout-pairs", type=int, default=5, help="profiles with fewer holdout pairs are reported, not gated")
|
2026-09-05 15:35:08 +02:00
|
|
|
improve.add_argument(
|
|
|
|
|
"--mechanism-candidates",
|
|
|
|
|
type=Path,
|
|
|
|
|
default=None,
|
2026-09-05 15:37:32 +02:00
|
|
|
help=(
|
|
|
|
|
"exporter candidates the mechanisms lever may admit (default: <target>/.openultrasast/calibration/"
|
|
|
|
|
"mechanism-candidates.jsonl, then ./.openultrasast/calibration/mechanism-candidates.jsonl)"
|
|
|
|
|
),
|
2026-09-05 15:35:08 +02:00
|
|
|
)
|
|
|
|
|
improve.add_argument(
|
|
|
|
|
"--mechanism-store",
|
|
|
|
|
type=Path,
|
|
|
|
|
default=None,
|
|
|
|
|
help="scan-time mechanisms.jsonl the lever writes (default: <target>/.openultrasast/calibration/mechanisms.jsonl)",
|
|
|
|
|
)
|
|
|
|
|
improve.add_argument("--no-mechanisms", action="store_true", help="disable the mechanisms lever for this run")
|
2026-06-22 17:36:58 +02:00
|
|
|
|
2026-09-04 13:57:33 +02:00
|
|
|
pairs = subparsers.add_parser(
|
|
|
|
|
"pairs",
|
|
|
|
|
help="scan isolated vuln-vs-fixed pairs (TP on vuln, silent on fix) and emit improve-loop signals",
|
|
|
|
|
)
|
|
|
|
|
pairs.add_argument("--catalog", type=Path, default=DEFAULT_CATALOG)
|
2026-09-05 09:27:40 +02:00
|
|
|
pairs.add_argument("--slice", choices=SLICE_NAMES, default="all")
|
|
|
|
|
pairs.add_argument(
|
|
|
|
|
"--profile", choices=("all", "human", "agent", "mixed", "synthetic"), default="all", help="filter pairs by provenance profile"
|
|
|
|
|
)
|
|
|
|
|
pairs.add_argument("--split", choices=("all", "train", "holdout"), default="all", help="filter pairs by declared split")
|
|
|
|
|
pairs.add_argument("--hunter", action="store_true", help="also score the LLM tool hunter on overlay slices (needs a hunter model)")
|
|
|
|
|
pairs.add_argument("--hunter-model", default=None, help="model id for --hunter (default: [models].hunter from openultrasast.toml)")
|
2026-09-05 10:17:50 +02:00
|
|
|
pairs.add_argument("--pointers", action="store_true", help="allow network for non-vendored pointer pairs this run (nightly; CI never)")
|
2026-09-05 14:55:00 +02:00
|
|
|
pairs.add_argument(
|
|
|
|
|
"--loo", action="store_true", help="leave-one-out: score each pair against a store seeded from the other trusted pairs"
|
|
|
|
|
)
|
|
|
|
|
pairs.add_argument("--loo-out", type=Path, default=None, help="where to write loo.json (default: reports/loo.json)")
|
2026-09-04 13:57:33 +02:00
|
|
|
pairs.add_argument("--json", action="store_true", help="print the pair scoreboard as JSON")
|
|
|
|
|
|
2026-06-22 19:42:01 +02:00
|
|
|
subparsers.add_parser("mcp", help="run the narrow MCP server over stdio for OpenCode integration")
|
2026-09-05 14:40:15 +02:00
|
|
|
mechanisms = subparsers.add_parser("mechanisms", help="maintainer: mechanism memory seeded from trusted pairs")
|
|
|
|
|
mechanisms_sub = mechanisms.add_subparsers(dest="mechanisms_command", required=True)
|
|
|
|
|
export = mechanisms_sub.add_parser("export", help="derive mechanism records from seeded/reviewed pairs (offline)")
|
|
|
|
|
export.add_argument("--slice", choices=SLICE_NAMES, default="all")
|
|
|
|
|
export.add_argument("--catalog", type=Path, default=DEFAULT_CATALOG)
|
|
|
|
|
export.add_argument(
|
2026-09-05 15:35:08 +02:00
|
|
|
"--store",
|
|
|
|
|
type=Path,
|
|
|
|
|
default=Path(CALIBRATION_DIR) / "mechanism-candidates.jsonl",
|
|
|
|
|
help="append-only candidates file the improve lever admits from (the scan reads mechanisms.jsonl next to it; never written here)",
|
2026-09-05 14:40:15 +02:00
|
|
|
)
|
|
|
|
|
export.add_argument("--json", action="store_true")
|
2026-06-22 19:42:01 +02:00
|
|
|
|
2026-06-17 21:13:51 +02:00
|
|
|
args = parser.parse_args(argv)
|
|
|
|
|
if args.command == "scan":
|
2026-06-18 10:16:25 +02:00
|
|
|
return _scan(args.path, args.config, args.mode, args.fail_on)
|
2026-06-17 21:13:51 +02:00
|
|
|
if args.command == "index":
|
|
|
|
|
return _index(args.path, args.config, args.chunk_lines)
|
2026-06-21 11:49:31 +02:00
|
|
|
if args.command == "benchmark":
|
|
|
|
|
return _benchmark(args.manifest, args.config, args.mode)
|
2026-06-22 17:36:58 +02:00
|
|
|
if args.command == "improve":
|
|
|
|
|
return _improve(
|
|
|
|
|
args.manifest,
|
|
|
|
|
max_rounds=args.max_rounds,
|
|
|
|
|
recall_floor=args.recall_floor,
|
|
|
|
|
fp_ceiling=args.fp_ceiling,
|
|
|
|
|
ledger=args.ledger,
|
|
|
|
|
journal=args.journal,
|
|
|
|
|
ruleset_dir=args.ruleset_dir,
|
|
|
|
|
dry_run=args.dry_run,
|
2026-09-05 09:28:00 +02:00
|
|
|
pair_catalog=None if args.no_pair_gate else args.pair_catalog,
|
|
|
|
|
profile_tolerance=args.profile_tolerance,
|
|
|
|
|
min_holdout_pairs=args.min_holdout_pairs,
|
2026-09-05 15:35:08 +02:00
|
|
|
mechanism_candidates=args.mechanism_candidates,
|
|
|
|
|
mechanism_store=args.mechanism_store,
|
|
|
|
|
mechanisms_enabled=not args.no_mechanisms,
|
2026-06-22 17:36:58 +02:00
|
|
|
)
|
2026-09-04 13:57:33 +02:00
|
|
|
if args.command == "pairs":
|
2026-09-05 09:27:40 +02:00
|
|
|
return _pairs(
|
|
|
|
|
args.catalog,
|
|
|
|
|
args.slice,
|
|
|
|
|
json_out=args.json,
|
|
|
|
|
profile=args.profile,
|
|
|
|
|
split=args.split,
|
|
|
|
|
hunter=args.hunter,
|
|
|
|
|
hunter_model=args.hunter_model,
|
2026-09-05 10:17:50 +02:00
|
|
|
pointers=args.pointers,
|
2026-09-05 14:55:00 +02:00
|
|
|
loo=args.loo,
|
|
|
|
|
loo_out=args.loo_out,
|
2026-09-05 09:27:40 +02:00
|
|
|
)
|
2026-09-05 14:40:15 +02:00
|
|
|
if args.command == "mechanisms":
|
|
|
|
|
return _mechanisms_export(args.catalog, args.slice, args.store, json_out=args.json)
|
2026-06-22 19:42:01 +02:00
|
|
|
if args.command == "mcp":
|
|
|
|
|
from .mcp import serve # lazy: keeps the import cycle (mcp -> cli) one-directional
|
|
|
|
|
|
|
|
|
|
return serve()
|
2026-06-17 21:13:51 +02:00
|
|
|
return 2
|
|
|
|
|
|
|
|
|
|
|
2026-06-18 10:16:25 +02:00
|
|
|
def _scan(path: Path, config_path: Path, mode: str, fail_on: str) -> int:
|
2026-06-21 11:49:31 +02:00
|
|
|
outcome = _run_scan(path, config_path, mode, fail_on)
|
|
|
|
|
_print_scan_outcome(outcome)
|
|
|
|
|
return outcome.exit_code
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _run_scan(path: Path, config_path: Path, mode: str, fail_on: str) -> ScanOutcome:
|
2026-06-17 21:13:51 +02:00
|
|
|
if not path.exists() or not path.is_dir():
|
|
|
|
|
raise SystemExit(f"scan path is not a directory: {path}")
|
|
|
|
|
|
2026-09-03 23:14:00 +02:00
|
|
|
plan = plan_for_mode(mode)
|
2026-06-17 21:13:51 +02:00
|
|
|
config = load_config(config_path if config_path.exists() else None)
|
2026-06-22 16:29:52 +02:00
|
|
|
# Capability gates for the optional HarnessX agentic plane. When the extra is
|
|
|
|
|
# absent (default/CI), both stay False and the deterministic path runs unchanged.
|
|
|
|
|
hunter_model = config.models.hunter
|
|
|
|
|
verifier_model = config.models.verifier
|
|
|
|
|
harnessx_present = has_harnessx()
|
|
|
|
|
hx_hunter = mode == "standard" and bool(hunter_model) and harnessx_present
|
|
|
|
|
hx_verify = mode == "standard" and bool(verifier_model) and harnessx_present
|
2026-06-17 21:13:51 +02:00
|
|
|
run = create_scan_run(path, config)
|
2026-06-18 07:57:06 +02:00
|
|
|
runtime = HarnessRuntime(
|
|
|
|
|
scan_id=run.scan_id,
|
|
|
|
|
config=config,
|
2026-06-22 20:00:42 +02:00
|
|
|
trace_writer=HarnessTraceWriter(run.root / "trace" / "events.jsonl", redact=config.hardening.redact_secrets),
|
2026-06-18 07:57:06 +02:00
|
|
|
)
|
|
|
|
|
write_harness_config(config=config, processors=[], contract_mode="strict", path=run.root / "harness.json")
|
|
|
|
|
runtime.start(mode=mode, target=run.target)
|
2026-06-22 12:31:35 +02:00
|
|
|
policy = runtime.run_stage("policy_load", load_policy)
|
2026-06-22 15:41:17 +02:00
|
|
|
ledger_path = run.target / CALIBRATION_DIR / "false_positive_learnings.json"
|
|
|
|
|
rule_ledger_path = run.target / CALIBRATION_DIR / "rule_policy.json"
|
|
|
|
|
ruleset = runtime.run_stage("ruleset_load", lambda: load_ruleset(DEFAULT_RULESET_DIR, rule_ledger_path))
|
2026-06-22 15:27:16 +02:00
|
|
|
runtime.run_stage("policy_check", lambda: assert_rules_resolve(ruleset, policy))
|
2026-06-18 07:57:06 +02:00
|
|
|
static_hints = runtime.run_stage("static_mapping", lambda: _load_static_hints(config.static_analysis.sarif_paths))
|
|
|
|
|
write_static_hints(static_hints, run.root / "mapping" / "static_hints.json")
|
|
|
|
|
snapshot, targets = runtime.run_stage(
|
|
|
|
|
"preprocess",
|
|
|
|
|
lambda: preprocess_repository(run.target, run.root / "preprocess" / "file_targets.json", static_hints),
|
|
|
|
|
)
|
|
|
|
|
entry_points = runtime.run_stage("entry_point_mapping", lambda: analyze_entry_points(run.target, targets))
|
|
|
|
|
write_entry_points(entry_points, run.root / "mapping" / "entry_points.json")
|
|
|
|
|
targets = attach_reachability_hints(targets, entry_points)
|
|
|
|
|
write_preprocess_artifact(snapshot, targets, run.root / "preprocess" / "file_targets.json")
|
2026-09-05 09:27:50 +02:00
|
|
|
provenance = runtime.run_stage("provenance", lambda: fingerprint(run.target))
|
2026-06-18 07:57:06 +02:00
|
|
|
rankings = runtime.run_stage("rank", lambda: rank_targets(targets))
|
2026-06-21 20:44:08 +02:00
|
|
|
prior_learnings = load_false_positive_learnings(ledger_path)
|
|
|
|
|
rankings, calibrations = runtime.run_stage("calibrate", lambda: calibrate_rankings(rankings, prior_learnings))
|
|
|
|
|
applied_calibrations = [calibration for calibration in calibrations if calibration.applied_learning_ids]
|
2026-06-17 21:13:51 +02:00
|
|
|
write_rankings(rankings, run.root / "rank" / "ranking.json")
|
2026-06-21 20:44:08 +02:00
|
|
|
write_ranking_calibrations(calibrations, run.root / "calibration" / "applied_calibrations.json")
|
2026-06-18 10:59:25 +02:00
|
|
|
if mode == "standard":
|
2026-06-22 16:29:52 +02:00
|
|
|
if hx_hunter and hunter_model:
|
2026-06-22 17:59:44 +02:00
|
|
|
orchestrator = HxScanOrchestrator(
|
|
|
|
|
provider_model=hunter_model,
|
|
|
|
|
provider=config.harnessx.provider,
|
|
|
|
|
max_cost_usd=config.harnessx.max_cost_usd,
|
|
|
|
|
token_threshold=config.harnessx.token_threshold,
|
|
|
|
|
)
|
2026-06-22 16:29:52 +02:00
|
|
|
hunter_result = runtime.run_stage(
|
|
|
|
|
"hunter_pool",
|
|
|
|
|
lambda: orchestrator.run_pool(
|
|
|
|
|
run.target, targets, rankings, scan_id=run.scan_id, ruleset=ruleset, policy=policy, emit=runtime.emit
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
if bool(hunter_model) and not harnessx_present:
|
|
|
|
|
runtime.state["degradations"].append(
|
|
|
|
|
{"stage": "hunter_pool", "requested": "harnessx", "reason": "harnessx_extra_unavailable", "fallback": "run_hunter_pool"}
|
|
|
|
|
)
|
|
|
|
|
hunter_result = runtime.run_stage(
|
|
|
|
|
"hunter_pool",
|
|
|
|
|
lambda: run_hunter_pool(run.target, targets, rankings, scan_id=run.scan_id, ruleset=ruleset, policy=policy),
|
|
|
|
|
)
|
2026-06-18 10:59:25 +02:00
|
|
|
findings = hunter_result.findings
|
|
|
|
|
trajectories = hunter_result.trajectories
|
|
|
|
|
else:
|
2026-06-22 15:27:16 +02:00
|
|
|
findings = runtime.run_stage(
|
|
|
|
|
"quick_findings",
|
|
|
|
|
lambda: quick_scan_findings(
|
|
|
|
|
run.target,
|
|
|
|
|
targets,
|
|
|
|
|
rankings,
|
|
|
|
|
ruleset,
|
|
|
|
|
policy,
|
|
|
|
|
min_emit_priority=config.ruleset.min_emit_priority,
|
|
|
|
|
min_emit_precision=config.ruleset.min_emit_precision,
|
|
|
|
|
),
|
|
|
|
|
)
|
2026-06-18 10:59:25 +02:00
|
|
|
trajectories = []
|
2026-06-22 15:27:16 +02:00
|
|
|
# Shadow-status rules fire but are excluded from the report and the score;
|
|
|
|
|
# their outcomes are preserved separately for precision tracking.
|
|
|
|
|
shadow_findings = [finding for finding in findings if finding.status == "shadow"]
|
|
|
|
|
if shadow_findings:
|
|
|
|
|
findings = [finding for finding in findings if finding.status != "shadow"]
|
|
|
|
|
write_findings(shadow_findings, run.root / "shadow_findings.json")
|
2026-06-22 20:00:42 +02:00
|
|
|
# Bounded-CI budget: cap the reported finding count (0 = unlimited). Findings are
|
|
|
|
|
# severity/priority-sorted, so truncation keeps the most severe and is disclosed.
|
|
|
|
|
max_findings = config.hardening.max_findings
|
|
|
|
|
if max_findings and len(findings) > max_findings:
|
|
|
|
|
runtime.state["degradations"].append(
|
|
|
|
|
{"stage": "budget", "reason": "max_findings_exceeded", "requested": max_findings, "actual": len(findings)}
|
|
|
|
|
)
|
|
|
|
|
findings = findings[:max_findings]
|
2026-06-18 10:16:25 +02:00
|
|
|
findings_path = run.root / "findings.json"
|
|
|
|
|
verification_path = run.root / "verification.json"
|
|
|
|
|
markdown_path = run.root / "report.md"
|
|
|
|
|
sarif_path = run.root / "report.sarif"
|
|
|
|
|
manifest_path = run.root / "manifest.json"
|
2026-06-18 10:59:25 +02:00
|
|
|
trajectories_path = run.root / "traces" / "hunter_trajectories.jsonl"
|
2026-06-18 10:16:25 +02:00
|
|
|
write_findings(findings, findings_path)
|
2026-06-18 10:59:25 +02:00
|
|
|
if trajectories:
|
|
|
|
|
write_hunter_trajectories(trajectories, trajectories_path)
|
2026-06-22 16:29:52 +02:00
|
|
|
if mode == "standard" and bool(verifier_model) and not harnessx_present:
|
|
|
|
|
runtime.state["degradations"].append(
|
|
|
|
|
{"stage": "verify", "requested": "harnessx", "reason": "harnessx_extra_unavailable", "fallback": "structural_verifier"}
|
|
|
|
|
)
|
|
|
|
|
verifications = runtime.run_stage(
|
|
|
|
|
"verify",
|
2026-06-22 17:59:44 +02:00
|
|
|
lambda: verify_findings_dispatch(
|
|
|
|
|
findings,
|
|
|
|
|
verifier_model=verifier_model,
|
|
|
|
|
verifier_provider=config.harnessx.provider,
|
|
|
|
|
use_harnessx=hx_verify,
|
|
|
|
|
),
|
2026-06-22 16:29:52 +02:00
|
|
|
)
|
2026-06-18 10:16:25 +02:00
|
|
|
write_verification_results(verifications, verification_path)
|
2026-06-21 20:44:08 +02:00
|
|
|
runtime.run_stage(
|
|
|
|
|
"record_calibration",
|
|
|
|
|
lambda: _persist_calibration_feedback(run, ledger_path, prior_learnings, findings, verifications),
|
|
|
|
|
)
|
2026-06-22 18:57:03 +02:00
|
|
|
# Fusion: two-panel adjudication for triggered findings (standard mode). Runs
|
|
|
|
|
# deterministically by default; routes panels through the configured provider when
|
|
|
|
|
# a panel model is set and the extra is present, else falls back + records a degradation.
|
|
|
|
|
fusion_decisions: list[FusionDecision] = []
|
|
|
|
|
fusion_path = run.root / "fusion.json"
|
|
|
|
|
if mode == "standard" and config.fusion.enabled:
|
|
|
|
|
hx_fusion = bool(config.fusion.panel_model) and harnessx_present
|
|
|
|
|
if bool(config.fusion.panel_model) and not harnessx_present:
|
|
|
|
|
runtime.state["degradations"].append(
|
|
|
|
|
{"stage": "fusion", "requested": "harnessx", "reason": "harnessx_extra_unavailable", "fallback": "deterministic_panels"}
|
|
|
|
|
)
|
|
|
|
|
fusion_decisions = runtime.run_stage(
|
|
|
|
|
"fusion",
|
|
|
|
|
lambda: fuse_findings_dispatch(
|
|
|
|
|
findings,
|
|
|
|
|
verifications,
|
|
|
|
|
panel_model=config.fusion.panel_model,
|
|
|
|
|
decider_model=config.fusion.decider_model,
|
|
|
|
|
provider=config.harnessx.provider,
|
|
|
|
|
use_harnessx=hx_fusion,
|
|
|
|
|
high_assurance=config.fusion.high_assurance,
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
if fusion_decisions:
|
|
|
|
|
fusion_path.write_text(json.dumps([decision.to_dict() for decision in fusion_decisions], indent=2, sort_keys=True) + "\n")
|
2026-06-22 15:27:16 +02:00
|
|
|
cwe_by_rule = {rule.rule_id: rule.cwe for rule in ruleset}
|
2026-06-22 12:31:35 +02:00
|
|
|
rule_cwe_by_id = {finding.finding_id: cwe_by_rule.get(finding.finding_id.split(":", 1)[0], "") for finding in findings}
|
2026-06-22 15:41:17 +02:00
|
|
|
# A confirmed false positive (prior-scan learning) lowers a finding's effective
|
|
|
|
|
# reachability multiplier in the score instead of deleting the rule.
|
|
|
|
|
reachability_override = fp_reachability_overrides(findings, prior_learnings)
|
2026-06-22 12:31:35 +02:00
|
|
|
score_artifact = runtime.run_stage(
|
|
|
|
|
"score",
|
|
|
|
|
lambda: build_score_artifact(
|
|
|
|
|
findings,
|
|
|
|
|
rule_cwe_by_id,
|
|
|
|
|
policy,
|
|
|
|
|
k=config.score.k,
|
|
|
|
|
min_score=config.score.min_score,
|
|
|
|
|
block_severity_reachable=config.score.block_severity_reachable,
|
|
|
|
|
blocking=config.score.blocking,
|
2026-06-22 15:41:17 +02:00
|
|
|
reachability_override=reachability_override,
|
2026-06-22 12:31:35 +02:00
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
score_path = run.root / "score.json"
|
|
|
|
|
score_path.write_text(json.dumps(score_artifact.to_dict(), indent=2, sort_keys=True) + "\n")
|
2026-09-03 23:14:00 +02:00
|
|
|
plan = record_completed(plan, Stage.STATIC)
|
2026-09-03 23:26:54 +02:00
|
|
|
complexity_payload: dict[str, object] | None = None
|
|
|
|
|
complexity_map_path = run.root / "complexity_map.json"
|
2026-09-04 13:57:33 +02:00
|
|
|
overlay_path = run.root / "overlay.json"
|
2026-09-03 23:43:15 +02:00
|
|
|
built_map = None
|
2026-09-04 13:57:33 +02:00
|
|
|
overlay_records: list[OverlayRecord] = []
|
|
|
|
|
wrote_overlay = False
|
2026-09-05 14:40:15 +02:00
|
|
|
variants_payload: dict[str, object] | None = None
|
|
|
|
|
variant_findings: list[StaticFinding] = []
|
2026-09-05 15:35:08 +02:00
|
|
|
mechanisms_cited: dict[str, dict[str, object]] = {}
|
2026-09-03 23:26:54 +02:00
|
|
|
if Stage.MAP in plan.requested:
|
|
|
|
|
built_map = runtime.run_stage(
|
|
|
|
|
"map",
|
|
|
|
|
lambda: complexity_map.build_complexity_map(
|
|
|
|
|
targets,
|
|
|
|
|
findings,
|
|
|
|
|
complexity_map_path,
|
|
|
|
|
repo_files=[target.path for target in targets],
|
|
|
|
|
ledger_path=run.target / CALIBRATION_DIR / "complexity_ledger.json",
|
|
|
|
|
),
|
|
|
|
|
)
|
2026-09-04 13:57:33 +02:00
|
|
|
facts_or_error: SemanticFacts | FactLoadError
|
|
|
|
|
try:
|
|
|
|
|
facts_or_error = load_facts()
|
|
|
|
|
except FactLoadError as exc:
|
|
|
|
|
facts_or_error = exc
|
|
|
|
|
runtime.state["degradations"].append({"stage": "overlay", "reason": "facts_unavailable", "detail": str(exc)})
|
|
|
|
|
overlay_records = list(
|
|
|
|
|
runtime.run_stage(
|
|
|
|
|
"overlay",
|
|
|
|
|
lambda: adjudicate(root=run.target, targets=targets, findings=findings, facts=facts_or_error),
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
write_overlay(overlay_records, overlay_path)
|
|
|
|
|
wrote_overlay = True
|
|
|
|
|
coverage_findings = [finding_from_coverage(record) for record in overlay_records if record.disposition == "coverage"]
|
|
|
|
|
if coverage_findings:
|
|
|
|
|
seen = {finding.finding_id for finding in findings}
|
|
|
|
|
extra = [item for item in coverage_findings if item.finding_id not in seen]
|
|
|
|
|
if extra:
|
|
|
|
|
findings = findings + extra
|
|
|
|
|
write_findings(findings, findings_path)
|
2026-09-05 14:40:15 +02:00
|
|
|
if config.variants.enabled:
|
2026-09-05 15:35:08 +02:00
|
|
|
variant_findings, overlay_records, variants_payload, mechanisms_cited = _search_variants(
|
2026-09-05 14:40:15 +02:00
|
|
|
root=run.target,
|
|
|
|
|
targets=targets,
|
|
|
|
|
overlay_records=overlay_records,
|
|
|
|
|
facts=facts_or_error,
|
|
|
|
|
max_mechanisms=config.variants.max_mechanisms,
|
|
|
|
|
runtime=runtime,
|
|
|
|
|
)
|
|
|
|
|
if variant_findings:
|
|
|
|
|
findings = findings + variant_findings
|
|
|
|
|
write_findings(findings, findings_path)
|
|
|
|
|
write_overlay(overlay_records, overlay_path)
|
2026-09-03 23:26:54 +02:00
|
|
|
plan = record_completed(plan, Stage.MAP)
|
|
|
|
|
if not hunter_model:
|
|
|
|
|
runtime.state["degradations"].append(skip_as_degradation(Stage.MAP, "hunter_model_unavailable"))
|
2026-09-04 00:04:36 +02:00
|
|
|
else:
|
|
|
|
|
built_map, findings, verifications = _attach_tool_hunter(
|
|
|
|
|
root=run.target,
|
|
|
|
|
built_map=built_map,
|
|
|
|
|
findings=findings,
|
|
|
|
|
verifications=verifications,
|
|
|
|
|
hunter_model=hunter_model,
|
|
|
|
|
max_hotspots=config.complexity.max_hunter_hotspots,
|
|
|
|
|
max_findings=max_findings,
|
|
|
|
|
findings_path=findings_path,
|
|
|
|
|
verification_path=verification_path,
|
|
|
|
|
complexity_map_path=complexity_map_path,
|
|
|
|
|
runtime=runtime,
|
|
|
|
|
)
|
2026-09-03 23:26:54 +02:00
|
|
|
complexity_payload = {
|
|
|
|
|
"hotspot_count": len(built_map.hotspots),
|
|
|
|
|
"heuristic_only": built_map.heuristic_only,
|
|
|
|
|
}
|
2026-09-03 23:43:15 +02:00
|
|
|
verdicts_path = run.root / "verdicts.json"
|
|
|
|
|
wrote_verdicts = False
|
2026-09-04 00:18:33 +02:00
|
|
|
verdict_records: tuple[CandidateVerdict, ...] = ()
|
2026-09-03 23:43:15 +02:00
|
|
|
if Stage.REGRESS in plan.requested:
|
|
|
|
|
probe = resolve_sandbox_probe()
|
|
|
|
|
if not probe.available():
|
|
|
|
|
plan = record_skip(plan, Stage.REGRESS, "sandbox_unavailable")
|
|
|
|
|
runtime.state["degradations"].append(skip_as_degradation(Stage.REGRESS, "sandbox_unavailable"))
|
|
|
|
|
else:
|
|
|
|
|
sandbox = resolve_sandbox_runner()
|
|
|
|
|
hotspots = built_map.hotspots if built_map is not None else ()
|
2026-09-04 13:57:33 +02:00
|
|
|
hotspots = filter_promoted_hotspots(hotspots, overlay_records, findings)
|
|
|
|
|
store = MechanismStore(run.target / CALIBRATION_DIR / "mechanisms.jsonl")
|
|
|
|
|
embed_client = None
|
|
|
|
|
embed_model = config.embeddings.model
|
|
|
|
|
if embed_model:
|
|
|
|
|
try:
|
|
|
|
|
embed_client = OpenRouterEmbeddingClient.from_env()
|
|
|
|
|
except OpenRouterError:
|
|
|
|
|
embed_client = None
|
|
|
|
|
hotspots, budget_degradation = order_promotions(
|
|
|
|
|
hotspots,
|
|
|
|
|
overlay_records,
|
|
|
|
|
findings,
|
|
|
|
|
store=store,
|
|
|
|
|
client=embed_client,
|
|
|
|
|
model=embed_model,
|
|
|
|
|
)
|
|
|
|
|
if budget_degradation:
|
|
|
|
|
runtime.state["degradations"].append({"stage": "prove_budget", "reason": budget_degradation})
|
|
|
|
|
promoted = [
|
|
|
|
|
finding
|
|
|
|
|
for finding in findings
|
|
|
|
|
if finding.finding_id in {record.proposal_id for record in overlay_records if record.disposition == "promote"}
|
|
|
|
|
]
|
2026-09-05 14:40:15 +02:00
|
|
|
# Variant findings are candidates after the promotions (Req 3.4): same safety check, same cap, lowest rank.
|
|
|
|
|
variant_ids = {finding.finding_id for finding in variant_findings}
|
|
|
|
|
promoted = promoted + [finding for finding in findings if finding.finding_id in variant_ids]
|
|
|
|
|
hotspots = hotspots + tuple(_hotspot_from_variant(finding) for finding in findings if finding.finding_id in variant_ids)
|
2026-09-03 23:43:15 +02:00
|
|
|
cwe_by_rule_id = {rule.rule_id: rule.cwe for rule in ruleset}
|
2026-09-04 13:57:33 +02:00
|
|
|
rule_cwe_by_finding = {finding.finding_id: cwe_by_rule_id.get(finding.finding_id.split(":", 1)[0], "") for finding in promoted}
|
2026-09-03 23:43:15 +02:00
|
|
|
languages_by_path = {target.path: target.language for target in targets}
|
|
|
|
|
records = runtime.run_stage(
|
|
|
|
|
"regress",
|
|
|
|
|
lambda: run_regression(
|
|
|
|
|
hotspots,
|
2026-09-04 13:57:33 +02:00
|
|
|
promoted,
|
2026-09-03 23:43:15 +02:00
|
|
|
max_candidates=config.regress.max_candidates,
|
|
|
|
|
policy=policy,
|
|
|
|
|
rule_cwe=rule_cwe_by_finding,
|
|
|
|
|
languages_by_path=languages_by_path,
|
|
|
|
|
repo_root=run.target,
|
|
|
|
|
sandbox=sandbox,
|
|
|
|
|
sandbox_limits=config.sandbox,
|
|
|
|
|
images=dict(config.regress.images),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
write_verdicts(records, verdicts_path)
|
2026-09-04 00:18:33 +02:00
|
|
|
persist_verdicts(run.target / CALIBRATION_DIR / "complexity_ledger.json", records)
|
2026-09-04 13:57:33 +02:00
|
|
|
_append_proven_mechanisms(store, records, overlay_records, findings, run.target)
|
2026-09-04 00:18:33 +02:00
|
|
|
verdict_records = records
|
2026-09-03 23:43:15 +02:00
|
|
|
wrote_verdicts = True
|
|
|
|
|
plan = record_completed(plan, Stage.REGRESS)
|
2026-09-04 00:18:33 +02:00
|
|
|
worth_fixing_payload = _worth_fixing_payload(verdict_records) if wrote_verdicts else None
|
2026-06-22 20:00:42 +02:00
|
|
|
runtime.run_stage(
|
2026-09-04 00:18:33 +02:00
|
|
|
"report",
|
|
|
|
|
lambda: write_markdown_report(
|
|
|
|
|
findings,
|
|
|
|
|
markdown_path,
|
|
|
|
|
verifications,
|
|
|
|
|
redact=config.hardening.redact_secrets,
|
|
|
|
|
complexity_map=built_map,
|
|
|
|
|
verdicts=verdict_records if wrote_verdicts else None,
|
2026-09-04 13:57:33 +02:00
|
|
|
overlay=overlay_records if wrote_overlay else None,
|
2026-09-05 15:35:08 +02:00
|
|
|
mechanisms=mechanisms_cited or None,
|
2026-09-04 13:57:33 +02:00
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
runtime.run_stage(
|
|
|
|
|
"sarif",
|
|
|
|
|
lambda: write_sarif_report(
|
|
|
|
|
findings,
|
|
|
|
|
verifications,
|
|
|
|
|
sarif_path,
|
|
|
|
|
overlay=overlay_records if wrote_overlay else None,
|
2026-09-05 15:35:08 +02:00
|
|
|
mechanisms=mechanisms_cited or None,
|
2026-09-04 00:18:33 +02:00
|
|
|
),
|
2026-06-22 20:00:42 +02:00
|
|
|
)
|
2026-06-18 10:16:25 +02:00
|
|
|
runtime.run_stage(
|
|
|
|
|
"manifest",
|
|
|
|
|
lambda: write_manifest(
|
|
|
|
|
run=run,
|
|
|
|
|
findings=findings,
|
|
|
|
|
verifications=verifications,
|
2026-06-18 10:59:25 +02:00
|
|
|
artifact_paths=_artifact_paths(
|
|
|
|
|
findings=findings_path,
|
|
|
|
|
verification=verification_path,
|
|
|
|
|
markdown=markdown_path,
|
|
|
|
|
sarif=sarif_path,
|
2026-06-22 12:31:35 +02:00
|
|
|
score=score_path,
|
2026-06-18 10:59:25 +02:00
|
|
|
trajectories=trajectories_path if trajectories else None,
|
2026-06-22 18:57:03 +02:00
|
|
|
fusion=fusion_path if fusion_decisions else None,
|
2026-09-03 23:26:54 +02:00
|
|
|
complexity_map=complexity_map_path if complexity_payload is not None else None,
|
2026-09-04 13:57:33 +02:00
|
|
|
overlay=overlay_path if wrote_overlay else None,
|
2026-09-03 23:43:15 +02:00
|
|
|
verdicts=verdicts_path if wrote_verdicts else None,
|
2026-06-18 10:59:25 +02:00
|
|
|
),
|
2026-06-18 10:16:25 +02:00
|
|
|
path=manifest_path,
|
2026-06-22 12:31:35 +02:00
|
|
|
score=score_artifact.to_dict(),
|
2026-06-22 16:29:52 +02:00
|
|
|
degradations=runtime.state["degradations"] or None,
|
2026-06-22 18:57:03 +02:00
|
|
|
fusion=[_fusion_summary(decision) for decision in fusion_decisions] or None,
|
2026-09-03 23:14:00 +02:00
|
|
|
stages=stages_payload(plan),
|
2026-09-03 23:26:54 +02:00
|
|
|
complexity=complexity_payload,
|
2026-09-04 00:18:33 +02:00
|
|
|
worth_fixing=worth_fixing_payload,
|
2026-09-05 09:27:50 +02:00
|
|
|
provenance=provenance.to_dict(),
|
2026-09-05 14:40:15 +02:00
|
|
|
variants=variants_payload,
|
2026-06-18 10:16:25 +02:00
|
|
|
),
|
|
|
|
|
)
|
2026-06-18 07:57:06 +02:00
|
|
|
runtime.finish(status="succeeded")
|
2026-06-21 11:49:31 +02:00
|
|
|
return ScanOutcome(
|
|
|
|
|
scan_id=run.scan_id,
|
|
|
|
|
run_dir=run.root,
|
|
|
|
|
file_target_count=len(targets),
|
|
|
|
|
ranked_target_count=len(rankings),
|
|
|
|
|
finding_count=len(findings),
|
2026-06-21 20:44:08 +02:00
|
|
|
calibrations_applied=len(applied_calibrations),
|
2026-09-04 00:18:33 +02:00
|
|
|
exit_code=scan_exit_code(findings, verifications, fail_on, worth_fixing_verdicts=verdict_records),
|
2026-06-21 11:49:31 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-09-04 13:57:33 +02:00
|
|
|
def _append_proven_mechanisms(
|
|
|
|
|
store: MechanismStore,
|
|
|
|
|
verdicts: tuple[CandidateVerdict, ...],
|
|
|
|
|
overlay_records: list[OverlayRecord],
|
|
|
|
|
findings: list[StaticFinding],
|
|
|
|
|
target: Path,
|
|
|
|
|
) -> None:
|
|
|
|
|
overlay_by_id = {record.proposal_id: record for record in overlay_records}
|
|
|
|
|
finding_by_id = {finding.finding_id: finding for finding in findings}
|
|
|
|
|
for verdict in verdicts:
|
|
|
|
|
if verdict.verdict != TRIGGERABLE:
|
|
|
|
|
continue
|
|
|
|
|
for finding_id in verdict.inventory_finding_ids:
|
|
|
|
|
record = overlay_by_id.get(finding_id)
|
|
|
|
|
if record is None or record.disposition != "promote":
|
|
|
|
|
continue
|
|
|
|
|
finding = finding_by_id.get(finding_id)
|
|
|
|
|
append_mechanism(
|
|
|
|
|
store,
|
|
|
|
|
summary=record.reason,
|
|
|
|
|
cwe=record.cwe,
|
|
|
|
|
language=record.language or verdict.language,
|
|
|
|
|
tags=finding.tags if finding is not None else (),
|
|
|
|
|
what_made_it_exploitable=record.reason,
|
|
|
|
|
source_finding_id=finding_id,
|
|
|
|
|
source_repo=str(target),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-09-04 00:04:36 +02:00
|
|
|
def _attach_tool_hunter(
|
|
|
|
|
*,
|
|
|
|
|
root: Path,
|
|
|
|
|
built_map: complexity_map.ComplexityMap,
|
|
|
|
|
findings: list[StaticFinding],
|
|
|
|
|
verifications: list[VerificationResult],
|
|
|
|
|
hunter_model: str,
|
|
|
|
|
max_hotspots: int,
|
|
|
|
|
max_findings: int,
|
|
|
|
|
findings_path: Path,
|
|
|
|
|
verification_path: Path,
|
|
|
|
|
complexity_map_path: Path,
|
|
|
|
|
runtime: HarnessRuntime,
|
|
|
|
|
) -> tuple[complexity_map.ComplexityMap, list[StaticFinding], list[VerificationResult]]:
|
|
|
|
|
client = tool_hunter.resolve_hunter_client()
|
|
|
|
|
if client is None:
|
|
|
|
|
return built_map, findings, verifications
|
|
|
|
|
hunter_hotspots = list(built_map.hotspots[: max(max_hotspots, 0)])
|
|
|
|
|
hunter_findings = runtime.run_stage(
|
|
|
|
|
"tool_hunter",
|
|
|
|
|
lambda: tool_hunter.run_tool_hunter(
|
|
|
|
|
root,
|
|
|
|
|
hunter_hotspots,
|
|
|
|
|
client=client,
|
|
|
|
|
model=hunter_model,
|
|
|
|
|
max_steps=tool_hunter.DEFAULT_MAX_STEPS,
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
seen = {finding.finding_id for finding in findings}
|
|
|
|
|
extra = [finding for finding in hunter_findings if finding.finding_id not in seen]
|
|
|
|
|
if extra:
|
|
|
|
|
findings = findings + extra
|
|
|
|
|
if max_findings and len(findings) > max_findings:
|
|
|
|
|
if not any(entry.get("reason") == "max_findings_exceeded" for entry in runtime.state["degradations"]):
|
|
|
|
|
runtime.state["degradations"].append(
|
|
|
|
|
{"stage": "budget", "reason": "max_findings_exceeded", "requested": max_findings, "actual": len(findings)}
|
|
|
|
|
)
|
|
|
|
|
findings = findings[:max_findings]
|
|
|
|
|
kept = {finding.finding_id for finding in findings}
|
|
|
|
|
extra = [finding for finding in extra if finding.finding_id in kept]
|
|
|
|
|
if extra:
|
|
|
|
|
verifications = verifications + verify_findings(extra)
|
|
|
|
|
write_verification_results(verifications, verification_path)
|
|
|
|
|
write_findings(findings, findings_path)
|
|
|
|
|
built_map = replace(built_map, heuristic_only=False)
|
|
|
|
|
complexity_map.write_complexity_map(built_map, complexity_map_path)
|
|
|
|
|
return built_map, findings, verifications
|
|
|
|
|
|
|
|
|
|
|
2026-06-21 20:44:08 +02:00
|
|
|
def _persist_calibration_feedback(
|
|
|
|
|
run: ScanRun,
|
|
|
|
|
ledger_path: Path,
|
|
|
|
|
prior_learnings: list[FalsePositiveLearning],
|
|
|
|
|
findings: list[StaticFinding],
|
|
|
|
|
verifications: list[VerificationResult],
|
|
|
|
|
) -> list[FalsePositiveLearning]:
|
2026-06-21 21:02:18 +02:00
|
|
|
# Store rejected or held findings as scoped learnings for the next scan.
|
2026-06-21 20:44:08 +02:00
|
|
|
new_learnings = learnings_from_verifications(findings, verifications)
|
|
|
|
|
merged = merge_false_positive_learnings(prior_learnings, new_learnings)
|
|
|
|
|
write_false_positive_learnings(merged, ledger_path)
|
|
|
|
|
write_false_positive_learnings(new_learnings, run.root / "calibration" / "false_positive_learnings.json")
|
|
|
|
|
return merged
|
|
|
|
|
|
|
|
|
|
|
2026-06-21 11:49:31 +02:00
|
|
|
def _print_scan_outcome(outcome: ScanOutcome) -> None:
|
|
|
|
|
print(f"scan_id={outcome.scan_id}")
|
|
|
|
|
print(f"run_dir={outcome.run_dir}")
|
|
|
|
|
print(f"file_targets={outcome.file_target_count}")
|
|
|
|
|
print(f"ranked_targets={outcome.ranked_target_count}")
|
|
|
|
|
print(f"findings={outcome.finding_count}")
|
2026-06-21 20:44:08 +02:00
|
|
|
print(f"calibrations_applied={outcome.calibrations_applied}")
|
2026-06-17 21:13:51 +02:00
|
|
|
|
|
|
|
|
|
2026-06-18 07:57:06 +02:00
|
|
|
def _load_static_hints(sarif_paths: tuple[str, ...]) -> list[object]:
|
|
|
|
|
hints: list[object] = []
|
|
|
|
|
for sarif_path in sarif_paths:
|
|
|
|
|
path = Path(sarif_path)
|
|
|
|
|
if path.exists():
|
|
|
|
|
hints.extend(ingest_sarif(path))
|
|
|
|
|
return hints
|
|
|
|
|
|
|
|
|
|
|
2026-09-04 00:18:33 +02:00
|
|
|
def _worth_fixing_payload(verdicts: tuple[CandidateVerdict, ...]) -> dict[str, object]:
|
|
|
|
|
worth = [record for record in verdicts if record.worth_fixing]
|
|
|
|
|
return {
|
|
|
|
|
"count": len(worth),
|
|
|
|
|
"verdicts": [
|
|
|
|
|
{
|
|
|
|
|
"path": record.path,
|
|
|
|
|
"function_name": record.function_name,
|
|
|
|
|
"verdict": record.verdict,
|
|
|
|
|
"reason": record.reason,
|
|
|
|
|
"worth_fixing": True,
|
|
|
|
|
}
|
|
|
|
|
for record in worth
|
|
|
|
|
],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-06-18 10:59:25 +02:00
|
|
|
def _artifact_paths(**paths: Path | None) -> dict[str, Path]:
|
|
|
|
|
return {name: path for name, path in paths.items() if path is not None}
|
|
|
|
|
|
|
|
|
|
|
2026-06-22 18:57:03 +02:00
|
|
|
def _fusion_summary(decision: FusionDecision) -> dict[str, object]:
|
|
|
|
|
return {
|
|
|
|
|
"finding_id": decision.finding_id,
|
|
|
|
|
"disposition": str(decision.disposition),
|
|
|
|
|
"decision_source": decision.decision_source,
|
|
|
|
|
"triggers": decision.triggers,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-06-21 11:49:31 +02:00
|
|
|
def _benchmark(manifest_path: Path, config_path: Path, mode: str) -> int:
|
|
|
|
|
if not manifest_path.exists() or not manifest_path.is_file():
|
|
|
|
|
raise SystemExit(f"benchmark manifest is not a file: {manifest_path}")
|
|
|
|
|
manifest = load_benchmark_manifest(manifest_path)
|
|
|
|
|
target = resolve_benchmark_source(manifest_path, manifest)
|
|
|
|
|
run = create_benchmark_run(target, manifest)
|
|
|
|
|
started_at = perf_counter()
|
|
|
|
|
outcome = _run_scan(target, config_path, mode, "never")
|
|
|
|
|
runtime_seconds = perf_counter() - started_at
|
|
|
|
|
findings = _load_scan_findings(outcome.run_dir)
|
|
|
|
|
baseline_findings = load_baseline_findings(manifest_path, manifest)
|
|
|
|
|
result = evaluate_benchmark(
|
|
|
|
|
run=run,
|
|
|
|
|
mode=mode,
|
|
|
|
|
findings=findings,
|
|
|
|
|
scan_id=outcome.scan_id,
|
|
|
|
|
scan_run_dir=outcome.run_dir,
|
|
|
|
|
runtime_seconds=runtime_seconds,
|
|
|
|
|
baseline_findings=baseline_findings,
|
|
|
|
|
)
|
|
|
|
|
write_benchmark_artifacts(run, manifest_path, result)
|
|
|
|
|
_print_scan_outcome(outcome)
|
|
|
|
|
print(f"benchmark_run_id={run.benchmark_run_id}")
|
|
|
|
|
print(f"benchmark_run_dir={run.root}")
|
|
|
|
|
print(f"benchmark_expected={result.metrics.expected_findings_total}")
|
|
|
|
|
print(f"benchmark_matched={result.metrics.matched_findings_total}")
|
|
|
|
|
print(f"benchmark_missed={result.metrics.missed_findings_total}")
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
2026-06-22 17:36:58 +02:00
|
|
|
def _improve(
|
|
|
|
|
manifest_path: Path,
|
|
|
|
|
*,
|
|
|
|
|
max_rounds: int,
|
|
|
|
|
recall_floor: float,
|
|
|
|
|
fp_ceiling: float,
|
|
|
|
|
ledger: Path | None,
|
|
|
|
|
journal: Path | None,
|
|
|
|
|
ruleset_dir: Path,
|
|
|
|
|
dry_run: bool,
|
2026-09-05 09:28:00 +02:00
|
|
|
pair_catalog: Path | None = None,
|
|
|
|
|
profile_tolerance: float = 0.0,
|
|
|
|
|
min_holdout_pairs: int = 5,
|
2026-09-05 15:35:08 +02:00
|
|
|
mechanism_candidates: Path | None = None,
|
|
|
|
|
mechanism_store: Path | None = None,
|
|
|
|
|
mechanisms_enabled: bool = True,
|
2026-06-22 17:36:58 +02:00
|
|
|
) -> int:
|
|
|
|
|
if not manifest_path.exists() or not manifest_path.is_file():
|
|
|
|
|
raise SystemExit(f"benchmark manifest is not a file: {manifest_path}")
|
2026-09-05 09:28:00 +02:00
|
|
|
pair_cases: tuple[object, ...] = ()
|
|
|
|
|
if pair_catalog is not None:
|
|
|
|
|
if not pair_catalog.is_file():
|
|
|
|
|
raise SystemExit(f"pair catalog is not a file: {pair_catalog} (pass --no-pair-gate to skip the per-profile holdout clause)")
|
|
|
|
|
pair_cases = select_split(load_pair_catalog(pair_catalog), "holdout")
|
2026-06-22 17:36:58 +02:00
|
|
|
manifest = load_benchmark_manifest(manifest_path)
|
|
|
|
|
target = resolve_benchmark_source(manifest_path, manifest)
|
2026-09-05 15:35:08 +02:00
|
|
|
# corpus-seeded-mechanisms Req 5: the lever runs when a candidates file exists (exporter output) and the pair gate is on.
|
|
|
|
|
# Candidates: an explicit path, else the target's calibration dir, else the working directory's (where `mechanisms export`
|
|
|
|
|
# writes by default). The store always lives where a scan of this target reads it unless overridden.
|
|
|
|
|
calibration = target / CALIBRATION_DIR
|
|
|
|
|
candidates_path: Path | None = mechanism_candidates
|
|
|
|
|
if candidates_path is None:
|
|
|
|
|
for candidate in (calibration / "mechanism-candidates.jsonl", Path(CALIBRATION_DIR) / "mechanism-candidates.jsonl"):
|
|
|
|
|
if candidate.is_file():
|
|
|
|
|
candidates_path = candidate
|
|
|
|
|
break
|
|
|
|
|
if not mechanisms_enabled or not pair_cases or candidates_path is None or not candidates_path.is_file():
|
|
|
|
|
candidates_path = None
|
|
|
|
|
store_path = mechanism_store if mechanism_store is not None else calibration / "mechanisms.jsonl"
|
2026-06-22 17:36:58 +02:00
|
|
|
policy = load_policy()
|
|
|
|
|
|
|
|
|
|
# The loop writes its accepted ledger where `scan`/`benchmark` read it, so a
|
|
|
|
|
# subsequent scan of this target automatically picks up the improved ruleset.
|
|
|
|
|
default_ledger = target / CALIBRATION_DIR / "rule_policy.json"
|
|
|
|
|
with tempfile.TemporaryDirectory(prefix="ousast-improve-") as scratch:
|
|
|
|
|
if dry_run:
|
|
|
|
|
ledger_path = Path(scratch) / "rule_policy.json"
|
|
|
|
|
journal_path = Path(scratch) / "improve_journal.json"
|
2026-09-05 15:35:08 +02:00
|
|
|
if candidates_path is not None: # a dry run must not mutate the scan-time store either
|
|
|
|
|
scratch_store = Path(scratch) / "mechanisms.jsonl"
|
|
|
|
|
if store_path.is_file():
|
|
|
|
|
scratch_store.write_bytes(store_path.read_bytes())
|
|
|
|
|
store_path = scratch_store
|
2026-06-22 17:36:58 +02:00
|
|
|
else:
|
|
|
|
|
ledger_path = ledger or default_ledger
|
|
|
|
|
journal_path = journal or ledger_path.with_name("improve_journal.json")
|
|
|
|
|
|
|
|
|
|
outcomes = run_improvement(
|
|
|
|
|
target,
|
|
|
|
|
manifest,
|
|
|
|
|
ledger_path=ledger_path,
|
|
|
|
|
journal_path=journal_path,
|
|
|
|
|
ruleset_dir=ruleset_dir,
|
|
|
|
|
policy=policy,
|
|
|
|
|
max_rounds=max_rounds,
|
|
|
|
|
recall_floor=recall_floor,
|
|
|
|
|
fp_ceiling=fp_ceiling,
|
2026-09-05 09:28:00 +02:00
|
|
|
pair_cases=pair_cases,
|
|
|
|
|
profile_tolerance=profile_tolerance,
|
|
|
|
|
min_holdout_pairs=min_holdout_pairs,
|
2026-09-05 15:35:08 +02:00
|
|
|
mechanism_candidates=candidates_path,
|
|
|
|
|
mechanism_store=store_path if candidates_path is not None else None,
|
2026-06-22 17:36:58 +02:00
|
|
|
)
|
|
|
|
|
_print_improve_outcomes(outcomes, manifest_path, target, ledger_path, dry_run=dry_run)
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
2026-09-05 09:27:40 +02:00
|
|
|
def _pairs(
|
|
|
|
|
catalog: Path,
|
|
|
|
|
slice_name: str,
|
|
|
|
|
*,
|
|
|
|
|
json_out: bool,
|
|
|
|
|
profile: str = "all",
|
|
|
|
|
split: str = "all",
|
|
|
|
|
hunter: bool = False,
|
|
|
|
|
hunter_model: str | None = None,
|
2026-09-05 10:17:50 +02:00
|
|
|
pointers: bool = False,
|
2026-09-05 14:55:00 +02:00
|
|
|
loo: bool = False,
|
|
|
|
|
loo_out: Path | None = None,
|
2026-09-05 09:27:40 +02:00
|
|
|
) -> int:
|
2026-09-04 13:57:33 +02:00
|
|
|
if not catalog.exists() or not catalog.is_file():
|
|
|
|
|
raise SystemExit(f"pair catalog is not a file: {catalog}")
|
2026-09-05 09:27:40 +02:00
|
|
|
cases = select_split(select_profile(select_slice(load_pair_catalog(catalog), slice_name), profile), split)
|
2026-09-05 14:55:00 +02:00
|
|
|
if loo:
|
|
|
|
|
return _pairs_loo(cases, loo_out or Path("reports") / "loo.json", json_out=json_out)
|
2026-09-05 09:27:40 +02:00
|
|
|
scan = None
|
|
|
|
|
if hunter:
|
|
|
|
|
config_path = Path("openultrasast.toml")
|
|
|
|
|
config = load_config(config_path if config_path.exists() else None)
|
|
|
|
|
model = hunter_model or config.models.hunter or ""
|
|
|
|
|
client = tool_hunter.resolve_hunter_client()
|
|
|
|
|
if model and client is not None:
|
|
|
|
|
scan = make_hunter_scan(client, model)
|
2026-09-05 10:17:50 +02:00
|
|
|
result = evaluate_catalog(cases, hunter=scan, pointers=True if pointers else None)
|
2026-09-04 13:57:33 +02:00
|
|
|
if json_out:
|
|
|
|
|
print(json.dumps(result_payload(result), indent=2, sort_keys=True))
|
|
|
|
|
return 0
|
|
|
|
|
print_pair_metrics("pair eval", result)
|
|
|
|
|
print(f"signals={len(result.signals)}")
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
2026-09-05 14:40:15 +02:00
|
|
|
def _search_variants(
|
|
|
|
|
*,
|
|
|
|
|
root: Path,
|
|
|
|
|
targets: list[FileTarget],
|
|
|
|
|
overlay_records: list[OverlayRecord],
|
|
|
|
|
facts: SemanticFacts | FactLoadError,
|
|
|
|
|
max_mechanisms: int,
|
|
|
|
|
runtime: HarnessRuntime,
|
2026-09-05 15:35:08 +02:00
|
|
|
) -> tuple[list[StaticFinding], list[OverlayRecord], dict[str, object], dict[str, dict[str, object]]]:
|
|
|
|
|
"""Third MAP proposer (corpus-seeded-mechanisms Req 3): shapes from the mechanism store, merged with overlay flows.
|
|
|
|
|
|
|
|
|
|
Also returns the mechanisms the findings cite (summary, guard, pairs, cwe) for the reports (Req 6.1).
|
|
|
|
|
"""
|
2026-09-05 14:40:15 +02:00
|
|
|
from .semantic.mechanisms import corpus_mechanisms
|
|
|
|
|
from .semantic.variant_search import hits_to_findings, search_tree
|
|
|
|
|
|
|
|
|
|
store = MechanismStore(root / CALIBRATION_DIR / "mechanisms.jsonl")
|
|
|
|
|
empty: dict[str, object] = {"mechanisms_searched": 0, "files_searched": 0, "findings": 0, "merged_into_overlay": 0}
|
|
|
|
|
if isinstance(facts, FactLoadError):
|
2026-09-05 15:35:08 +02:00
|
|
|
return [], overlay_records, empty, {}
|
2026-09-05 14:40:15 +02:00
|
|
|
result = runtime.run_stage("variants", lambda: search_tree(root, targets, store, facts, max_mechanisms=max_mechanisms))
|
|
|
|
|
for degradation in result.degradations:
|
|
|
|
|
runtime.state["degradations"].append(degradation)
|
2026-09-05 15:35:08 +02:00
|
|
|
records_by_id = {record.id: record for record in corpus_mechanisms(store.load())}
|
|
|
|
|
summaries = {record.id: (record.summary, record.pairs, record.cwe) for record in records_by_id.values()}
|
2026-09-05 14:40:15 +02:00
|
|
|
findings, records = hits_to_findings(result.hits, overlay_records, summaries)
|
|
|
|
|
merged = sum(1 for before, after in zip(overlay_records, records, strict=True) if before.mechanism_id is None and after.mechanism_id)
|
|
|
|
|
payload: dict[str, object] = {
|
|
|
|
|
"mechanisms_searched": result.mechanisms_searched,
|
|
|
|
|
"files_searched": result.files_searched,
|
|
|
|
|
"findings": len(findings),
|
|
|
|
|
"merged_into_overlay": merged,
|
|
|
|
|
}
|
2026-09-05 15:35:08 +02:00
|
|
|
cited_ids = {tag.split(":", 1)[1] for finding in findings for tag in finding.tags if tag.startswith("mechanism:")} | {
|
|
|
|
|
str(record.mechanism_id) for record in records if record.mechanism_id
|
|
|
|
|
}
|
|
|
|
|
cited: dict[str, dict[str, object]] = {
|
|
|
|
|
mechanism_id: {"summary": record.summary, "guard": record.guard, "pairs": list(record.pairs), "cwe": record.cwe}
|
|
|
|
|
for mechanism_id, record in records_by_id.items()
|
|
|
|
|
if mechanism_id in cited_ids
|
|
|
|
|
}
|
|
|
|
|
return findings, records, payload, cited
|
2026-09-05 14:40:15 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def _hotspot_from_variant(finding: StaticFinding) -> Hotspot:
|
2026-09-05 14:55:00 +02:00
|
|
|
return hotspot_from_finding(finding, rationale="variant of a known mechanism (suspicion; sandbox may raise)")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _pairs_loo(cases: Sequence[PairCase], out: Path, *, json_out: bool) -> int:
|
|
|
|
|
"""Req 4: the corpus's own detection rate; written as an artifact, never a gate."""
|
|
|
|
|
from .semantic.loo import evaluate_loo
|
|
|
|
|
|
|
|
|
|
result = evaluate_loo(select_vendored(cases))
|
|
|
|
|
payload = result.to_dict()
|
|
|
|
|
out.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
out.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n")
|
|
|
|
|
if json_out:
|
|
|
|
|
print(json.dumps(payload, indent=2, sort_keys=True))
|
|
|
|
|
return 0
|
|
|
|
|
for slice_name, metrics in result.per_slice.items():
|
|
|
|
|
print(
|
|
|
|
|
f"loo {slice_name}: pairs={metrics['pairs']} detected={metrics['detected']} silent={metrics['silent']} "
|
|
|
|
|
f"youden={metrics['youden']:+.3f} teaching={metrics['teaching']}"
|
|
|
|
|
)
|
|
|
|
|
print(f"artifact={out}")
|
|
|
|
|
return 0
|
2026-09-05 14:40:15 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def _mechanisms_export(catalog: Path, slice_name: str, store_path: Path, *, json_out: bool) -> int:
|
|
|
|
|
from .semantic.mechanisms import MechanismStore
|
|
|
|
|
from .semantic.seed import export_mechanisms
|
|
|
|
|
|
|
|
|
|
if not catalog.is_file():
|
|
|
|
|
raise SystemExit(f"pair catalog is not a file: {catalog}")
|
|
|
|
|
cases = select_vendored(select_slice(load_pair_catalog(catalog), slice_name))
|
|
|
|
|
report = export_mechanisms(cases, MechanismStore(store_path))
|
|
|
|
|
payload = {"slice": slice_name, "store": str(store_path), "pairs": len(cases), **report.to_dict()}
|
|
|
|
|
if json_out:
|
|
|
|
|
print(json.dumps(payload, indent=2, sort_keys=True))
|
|
|
|
|
return 0
|
|
|
|
|
print(f"mechanisms export slice={slice_name} pairs={len(cases)} seeded={report.seeded} records={report.records} -> {store_path}")
|
|
|
|
|
for pair, reason in report.skipped:
|
|
|
|
|
print(f" skip {pair}: {reason}")
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
2026-06-22 17:36:58 +02:00
|
|
|
def _print_improve_outcomes(outcomes: list[RoundOutcome], manifest_path: Path, target: Path, ledger_path: Path, *, dry_run: bool) -> None:
|
|
|
|
|
print(f"manifest={manifest_path}")
|
|
|
|
|
print(f"target={target}")
|
|
|
|
|
accepted = [o for o in outcomes if o.accepted]
|
|
|
|
|
for outcome in outcomes:
|
2026-09-05 15:35:08 +02:00
|
|
|
edits = (
|
|
|
|
|
", ".join(
|
|
|
|
|
[f"{e.rule_id}:{e.from_status}->{e.to_status}" for e in outcome.edits]
|
|
|
|
|
+ [f"{m.action} {m.mechanism_id}" for m in outcome.mechanism_edits]
|
|
|
|
|
)
|
|
|
|
|
or "-"
|
|
|
|
|
)
|
2026-06-22 17:36:58 +02:00
|
|
|
print(
|
|
|
|
|
f"round {outcome.round}: {outcome.reason} | "
|
|
|
|
|
f"recall {outcome.recall_before:.2%}->{outcome.recall_after:.2%} "
|
|
|
|
|
f"fp {outcome.fp_before:.2%}->{outcome.fp_after:.2%} "
|
|
|
|
|
f"score {outcome.score_before}->{outcome.score_after} | edits: {edits}"
|
|
|
|
|
)
|
2026-09-05 09:28:00 +02:00
|
|
|
if outcome.profile_regressions or outcome.profiles_under_minimum:
|
|
|
|
|
print(f" profiles: regressed={outcome.profile_regressions or '-'} under_minimum={outcome.profiles_under_minimum or '-'}")
|
2026-06-22 17:36:58 +02:00
|
|
|
print(f"rounds={len(outcomes)} accepted={len(accepted)}")
|
|
|
|
|
if dry_run:
|
2026-09-05 15:35:08 +02:00
|
|
|
print("dry_run=true (no ledger or mechanism store written)")
|
2026-06-22 17:36:58 +02:00
|
|
|
elif accepted:
|
|
|
|
|
print(f"ledger={ledger_path}")
|
|
|
|
|
else:
|
|
|
|
|
print("ledger=unchanged (no round accepted)")
|
|
|
|
|
|
|
|
|
|
|
2026-06-21 11:49:31 +02:00
|
|
|
def _load_scan_findings(run_dir: Path) -> list[StaticFinding]:
|
|
|
|
|
return load_findings(run_dir / "findings.json")
|
|
|
|
|
|
|
|
|
|
|
2026-06-17 21:13:51 +02:00
|
|
|
def _index(path: Path, config_path: Path, chunk_lines: int) -> int:
|
|
|
|
|
if not path.exists() or not path.is_dir():
|
|
|
|
|
raise SystemExit(f"index path is not a directory: {path}")
|
|
|
|
|
config = load_config(config_path if config_path.exists() else None)
|
|
|
|
|
run = create_scan_run(path, config)
|
|
|
|
|
snapshot, targets = preprocess_repository(run.target, run.root / "preprocess" / "file_targets.json")
|
|
|
|
|
chunks = build_code_chunks(run.target, targets, max_lines=chunk_lines)
|
|
|
|
|
payload = {
|
|
|
|
|
"store": config.embeddings.store or "json-local",
|
|
|
|
|
"embedding_model": config.embeddings.model,
|
|
|
|
|
"repo_root": snapshot.root,
|
|
|
|
|
"repo_commit": snapshot.commit,
|
|
|
|
|
"chunk_count": len(chunks),
|
|
|
|
|
"chunks": [chunk.__dict__ for chunk in chunks],
|
|
|
|
|
}
|
|
|
|
|
path_out = run.root / "index" / "chunks.json"
|
|
|
|
|
path_out.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
path_out.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n")
|
|
|
|
|
print(f"scan_id={run.scan_id}")
|
|
|
|
|
print(f"run_dir={run.root}")
|
|
|
|
|
print(f"chunks={len(chunks)}")
|
|
|
|
|
print(f"index_artifact={path_out}")
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
raise SystemExit(main())
|