diff --git a/.opencode/skills/openultrasast-kiro-impl/SKILL.md b/.opencode/skills/openultrasast-kiro-impl/SKILL.md deleted file mode 100644 index b72c9a3..0000000 --- a/.opencode/skills/openultrasast-kiro-impl/SKILL.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -name: openultrasast-kiro-impl -description: OpenUltraSAST Kiro implementation steering. Use when continuing `.kiro/specs/openrouter-sast-harness/tasks.md`, choosing the next phase, updating checkboxes, or keeping implementation aligned with the spec. ---- - -# OpenUltraSAST Kiro Implementation - -Use this skill when implementing the OpenUltraSAST Kiro spec from opencode. - -## Rules - -1. Read `.kiro/specs/openrouter-sast-harness/tasks.md` before choosing work. -2. Implement the next unchecked task slice unless the user names a different phase. -3. Keep each slice independently verifiable with `uv run pytest`. -4. Update task checkboxes only after implementation and tests pass. -5. Prefer minimal harness-compatible primitives over large framework imports. -6. Keep OpenUltraSAST independent of Clearwing; use Clearwing only as an oracle for design patterns. -7. Use project-local opencode skills to steer scan, triage, and fix workflows. - -## Current Phase Order - -- Phase 5: Harness runtime events, processors, contracts, and traces. -- Phase 5A: Static mapping disciplines for SARIF, Semgrep, CodeQL, differential review, and sharp edges. -- Phase 6: Evidence ladder verifier. -- Phase 7 and 7A: Docker sandbox and dynamic evidence. -- Phase 8 and later: Hunter pool, SARIF, deep mode, patch oracle, MCP, fusion, skill index, hardening. diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 10d308c..f30aaeb 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,5 +1,21 @@ # Unreleased +## Unread config keys retired (2026-10-02) + +These keys loaded but nothing ever read them. They now fail with `RetiredConfigError` (the +CLI prints it and exits 2), naming why. Remove them from your config: + +- `[models] patcher`: no patching stage calls a model. +- `[dynamic] enabled`, `[dynamic] network_scope`: no dynamic or deep tier exists yet. +- `[evidence] minimum_report_verified`, `minimum_exploit`, `minimum_patch`: evidence tiers + come from the program model's ladder, not from configuration. +- `[variants] enabled`, `[variants] max_mechanisms`: the variant search they configured was + deleted earlier. + +**Output shape.** `resolved_config.json` no longer has the `dynamic`, `evidence` and +`variants` sections or `models.patcher`. `harness.json` keeps its shape: +`model_roles.patcher` is always `null`, like `model_roles.verifier`. + ## HarnessX removed; the ax plane is the agentic path (2026-09-30) HarnessX, the optional agentic extra (`openultrasast[harnessx]`), is removed with its code, diff --git a/src/openultrasast/config.py b/src/openultrasast/config.py index ad86781..db06bde 100644 --- a/src/openultrasast/config.py +++ b/src/openultrasast/config.py @@ -20,10 +20,24 @@ _PLANE_RUN = "`ousast plane run`, see ops/ax/README.md" _RETIRED_PANELS = ( f"LLM fusion panels were retired; fusion is deterministic; independent LLM agreement is the plane's `agree` task ({_PLANE_RUN})" ) +# Keys that loaded but were never read (retired 2026-10-02, legacy cleanup): accepting them silently would let +# a user believe they configure something. +_UNREAD_ON = "retired 2026-10-02 because nothing ever read it" +_UNREAD_DYNAMIC = "no dynamic or deep tier exists; deep mode will define its own keys when it is built" +_UNREAD_EVIDENCE = "evidence tiers come from the program model's ladder (model/ladder.py), not from configuration" +_UNREAD_VARIANTS = "the structural variant search it configured was deleted with semantic/variants.py" RETIRED_KEYS: dict[tuple[str, str], str] = { - ("models", "verifier"): f"LLM verification runs on the plane (`verify` + `agree`, {_PLANE_RUN})", - ("fusion", "panel_model"): _RETIRED_PANELS, - ("fusion", "decider_model"): _RETIRED_PANELS, + ("models", "verifier"): f"{_RETIRED_ON}: LLM verification runs on the plane (`verify` + `agree`, {_PLANE_RUN})", + ("fusion", "panel_model"): f"{_RETIRED_ON}: {_RETIRED_PANELS}", + ("fusion", "decider_model"): f"{_RETIRED_ON}: {_RETIRED_PANELS}", + ("models", "patcher"): f"{_UNREAD_ON}: no patching stage calls a model", + ("dynamic", "enabled"): f"{_UNREAD_ON}: {_UNREAD_DYNAMIC}", + ("dynamic", "network_scope"): f"{_UNREAD_ON}: {_UNREAD_DYNAMIC}", + ("evidence", "minimum_report_verified"): f"{_UNREAD_ON}: {_UNREAD_EVIDENCE}", + ("evidence", "minimum_exploit"): f"{_UNREAD_ON}: {_UNREAD_EVIDENCE}", + ("evidence", "minimum_patch"): f"{_UNREAD_ON}: {_UNREAD_EVIDENCE}", + ("variants", "enabled"): f"{_UNREAD_ON}: {_UNREAD_VARIANTS}", + ("variants", "max_mechanisms"): f"{_UNREAD_ON}: {_UNREAD_VARIANTS}", } RETIRED_SECTION = "harnessx" # retired 2026-09-30: loads with one warning and is otherwise ignored (Req 3.3) RETIRED_SECTION_WARNING = ( @@ -38,10 +52,10 @@ class RetiredConfigError(ValueError): def _check_retired(data: dict[str, object]) -> None: problems = [] - for (section, key), replacement in RETIRED_KEYS.items(): + for (section, key), reason in RETIRED_KEYS.items(): table = data.get(section) if isinstance(table, dict) and key in table: - problems.append(f"`[{section}] {key}` was {_RETIRED_ON}: {replacement}. Remove the key.") + problems.append(f"`[{section}] {key}` was {reason}. Remove the key.") if problems: raise RetiredConfigError("\n".join(problems)) if RETIRED_SECTION in data: @@ -70,7 +84,6 @@ def load_dotenv(path: Path | None = None, *, force: bool = False) -> None: class ModelConfig: ranker: str | None = None hunter: str | None = None - patcher: str | None = None judge: str | None = None # second, independent judgement before anything is published as proven chat_base_url: str | None = None # chat endpoint; independent of the embedding endpoint chat_api_key_env: str | None = None # environment variable holding that endpoint's key @@ -96,19 +109,6 @@ class SandboxConfig: 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, ...] = () @@ -160,13 +160,6 @@ class RegressConfig: images: tuple[tuple[str, str], ...] = () -@dataclass(frozen=True) -class VariantsConfig: - # corpus-seeded-mechanisms: structural variant search in MAP (standard/deep only). - enabled: bool = True - max_mechanisms: int = 500 - - @dataclass(frozen=True) class ModelLayerConfig: # contributor-scan: the model layer in MAP. Named for the LAYER, not the model: `ModelConfig` is already @@ -214,8 +207,6 @@ 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() @@ -223,7 +214,6 @@ class ResolvedConfig: hardening: HardeningConfig = HardeningConfig() complexity: ComplexityConfig = ComplexityConfig() regress: RegressConfig = RegressConfig() - variants: VariantsConfig = VariantsConfig() obligations: ObligationsConfig = ObligationsConfig() model: ModelLayerConfig = ModelLayerConfig() push: PushConfig = PushConfig() @@ -243,8 +233,6 @@ def load_config(config_path: Path | None = None, *, dotenv: bool = True) -> Reso 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", {})), @@ -252,7 +240,6 @@ def load_config(config_path: Path | None = None, *, dotenv: bool = True) -> Reso hardening=_load_hardening(data.get("hardening", {})), complexity=_load_complexity(data.get("complexity", {})), regress=_load_regress(data.get("regress", {})), - variants=_load_variants(data.get("variants", {})), obligations=_load_obligations(data.get("obligations", {})), model=_load_model(data.get("model", {})), push=PushConfig.from_payload(data.get("push", {})), @@ -283,7 +270,6 @@ def _load_models(value: object) -> ModelConfig: return ModelConfig( ranker=_string(data.get("ranker")), hunter=_string(data.get("hunter")), - patcher=_string(data.get("patcher")), judge=_string(data.get("judge")), chat_base_url=_string(data.get("chat_base_url")), chat_api_key_env=_string(data.get("chat_api_key_env")), @@ -310,23 +296,6 @@ def _load_sandbox(value: object) -> SandboxConfig: ) -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 @@ -416,14 +385,6 @@ def _float_value(value: object, default: float) -> float: return default -def _load_variants(value: object) -> VariantsConfig: - data = _section(value) - return VariantsConfig( - enabled=bool(data.get("enabled", True)), - max_mechanisms=_int_value(data.get("max_mechanisms"), 500), - ) - - def _load_model(value: object) -> ModelLayerConfig: data = _section(value) return ModelLayerConfig( diff --git a/src/openultrasast/harness.py b/src/openultrasast/harness.py index 742ad48..772b1d3 100644 --- a/src/openultrasast/harness.py +++ b/src/openultrasast/harness.py @@ -181,7 +181,7 @@ def write_harness_config( "ranker": config.models.ranker, "hunter": config.models.hunter, "verifier": None, # schema kept byte for byte; LLM verification runs on the plane - "patcher": config.models.patcher, + "patcher": None, # schema kept; `[models] patcher` was retired 2026-10-02 (never read) }, processor_versions={processor.spec.name: processor.spec.version for processor in processors}, prompt_hashes={name: _hash_text(prompt) for name, prompt in sorted(prompts.items())}, diff --git a/tests/test_config.py b/tests/test_config.py index 7b2e0fd..0e7b8e1 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,3 +1,4 @@ +import json import logging import os from dataclasses import replace @@ -17,9 +18,6 @@ def test_load_config_reads_toml(tmp_path: Path) -> None: 'ranker = "openrouter/test-ranker"', "[sandbox]", "memory_mb = 1024", - "[dynamic]", - "enabled = true", - 'network_scope = ["127.0.0.1:8080"]', ] ) ) @@ -29,8 +27,6 @@ def test_load_config_reads_toml(tmp_path: Path) -> None: assert config.models.ranker == "openrouter/test-ranker" assert config.embeddings.store == "json-local" assert config.sandbox.memory_mb == 1024 - assert config.dynamic.enabled is True - assert config.dynamic.network_scope == ("127.0.0.1:8080",) def test_retired_agentic_section_loads_with_one_warning(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None: @@ -67,6 +63,31 @@ def test_retired_llm_keys_fail_naming_the_replacement(tmp_path: Path, body: str, assert key in message and replacement in message and "ousast plane run" in message and "Remove the key" in message +@pytest.mark.parametrize( + ("body", "key"), + [ + ('[models]\npatcher = "gpt-4o"\n', "`[models] patcher`"), + ("[dynamic]\nenabled = true\n", "`[dynamic] enabled`"), + ('[dynamic]\nnetwork_scope = ["127.0.0.1:8080"]\n', "`[dynamic] network_scope`"), + ('[evidence]\nminimum_report_verified = "static_corroboration"\n', "`[evidence] minimum_report_verified`"), + ('[evidence]\nminimum_exploit = "crash_reproduced"\n', "`[evidence] minimum_exploit`"), + ('[evidence]\nminimum_patch = "root_cause_explained"\n', "`[evidence] minimum_patch`"), + ("[variants]\nenabled = false\n", "`[variants] enabled`"), + ("[variants]\nmax_mechanisms = 10\n", "`[variants] max_mechanisms`"), + ], +) +def test_unread_keys_fail_naming_why(tmp_path: Path, body: str, key: str) -> None: + """Keys that loaded but were never read are retired (2026-10-02) the same way, each naming its reason.""" + config_path = tmp_path / "openultrasast.toml" + config_path.write_text(body) + + with pytest.raises(RetiredConfigError) as raised: + load_config(config_path) + + message = str(raised.value) + assert key in message and "retired 2026-10-02 because nothing ever read it" in message and "Remove the key" in message + + def test_the_cli_exits_2_on_a_retired_key(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: from openultrasast.cli import main @@ -116,7 +137,10 @@ def test_write_resolved_config_creates_json(tmp_path: Path) -> None: write_resolved_config(load_config(None), output) assert output.exists() - assert '"minimum_report_verified": "static_corroboration"' in output.read_text() + payload = json.loads(output.read_text()) + assert payload["sandbox"]["memory_mb"] == 2048 + # retired 2026-10-02 (never read): the unread sections are not echoed as if they configured something + assert not {"dynamic", "evidence", "variants"} & set(payload) and "patcher" not in payload["models"] def test_complexity_and_regress_defaults_when_section_absent() -> None: diff --git a/tests/test_opencode_skills.py b/tests/test_opencode_skills.py index 9c5ddba..8aa3896 100644 --- a/tests/test_opencode_skills.py +++ b/tests/test_opencode_skills.py @@ -9,7 +9,6 @@ def test_project_opencode_skills_have_required_frontmatter() -> None: assert {path.parent.name for path in skill_files} == { "openultrasast-fix-audit", - "openultrasast-kiro-impl", "openultrasast-scan", "openultrasast-triage", }