diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d353b3..d216345 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased — WebUI V2 unified operator surface +- Fixed `wallbreaker corpus verify [--update]` (and the `wallbreaker parsel verify` + alias) becoming unreachable when the PR #21 and PR #24 lines merged: the dispatch + and subparser were dropped from `cli.py` while `_run_corpus_verify` survived as + dead code. Load-time pin checks were unaffected; the interactive command works + again. Routing is now regression-tested (`tests/test_corpus_cli_routing.py`). - Added a shared typed capability catalog so TUI behavior is the canonical contract and every registered operation remains discoverable from V2. - Added server-owned queued executions with pause, resume, steering, attacker switching, diff --git a/tests/test_corpus_cli_routing.py b/tests/test_corpus_cli_routing.py new file mode 100644 index 0000000..25a2a49 --- /dev/null +++ b/tests/test_corpus_cli_routing.py @@ -0,0 +1,77 @@ +"""Regression tests: the `corpus verify` CLI routing must stay reachable. + +The dispatch was born in 53c9ca2 (roadmap-implementation TG3) and silently +lost when the PR #21 and PR #24 lines merged in da21689 — _run_corpus_verify +survived as dead code while nothing failed. These tests pin the routing so a +future merge cannot strand the command again. +""" +from __future__ import annotations + +from pathlib import Path + +import pytest + +import wallbreaker.cli as cli +from wallbreaker.cli import SUBCOMMANDS, _run_corpus_verify, build_sub_parser + + +def test_corpus_in_subcommands(): + assert "corpus" in SUBCOMMANDS + + +def test_corpus_verify_parses(): + args = build_sub_parser().parse_args(["corpus", "verify"]) + assert args.command == "corpus" + assert args.corpus_action == "verify" + assert args.update is False + + +def test_corpus_verify_parses_update_and_lock(): + args = build_sub_parser().parse_args( + ["corpus", "verify", "--update", "--lock", "/tmp/other.lock.toml"] + ) + assert args.update is True + assert args.lock == "/tmp/other.lock.toml" + + +def test_parsel_verify_alias_parses(): + args = build_sub_parser().parse_args(["parsel", "verify"]) + assert args.command == "parsel" + assert args.parsel_action == "verify" + + +def test_main_routes_corpus_verify(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + lock = tmp_path / "library.lock.toml" + lock.write_text("", encoding="utf-8") + called = {} + + def fake_verify(args): + called["lock"] = args.lock + return 7 + + monkeypatch.setattr(cli, "_run_corpus_verify", fake_verify) + rc = cli.main(["corpus", "verify", "--lock", str(lock)]) + assert rc == 7 + assert called["lock"] == str(lock) + + +def test_main_routes_parsel_verify_alias(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + called = {} + + def fake_verify(args): + called["via"] = "parsel" + return 0 + + monkeypatch.setattr(cli, "_run_corpus_verify", fake_verify) + rc = cli.main(["parsel", "verify"]) + assert rc == 0 + assert called["via"] == "parsel" + + +def test_main_routes_parsel_update_to_parsel_lib(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + """The verify alias must not swallow the real parsel actions.""" + monkeypatch.setattr( + "wallbreaker.tools.parsel_lib.run_parsel_cli", lambda args: 0 + ) + rc = cli.main(["parsel", "list"]) + assert rc == 0 diff --git a/wallbreaker/cli.py b/wallbreaker/cli.py index f67191e..e2fd24b 100644 --- a/wallbreaker/cli.py +++ b/wallbreaker/cli.py @@ -2,7 +2,9 @@ import argparse import asyncio +import subprocess import sys +from pathlib import Path from dotenv import load_dotenv @@ -168,7 +170,7 @@ def _add_endpoint_flags(parser: argparse.ArgumentParser) -> None: parser.add_argument("--api-key", help="API key literal (prefer --api-key-env)") -SUBCOMMANDS = ("lib", "parsel", "eni", "transform", "findings", "report", "export", "check", "regrade", "baseline", "dashboard") +SUBCOMMANDS = ("lib", "parsel", "eni", "transform", "findings", "report", "export", "check", "regrade", "baseline", "dashboard", "corpus") def build_main_parser() -> argparse.ArgumentParser: @@ -244,7 +246,7 @@ def build_sub_parser() -> argparse.ArgumentParser: parsel = sub.add_parser( "parsel", help="Manage the P4RS3LT0NGV3 transform library (MCP server backend)" ) - parsel.add_argument("parsel_action", choices=["update", "list", "path"]) + parsel.add_argument("parsel_action", choices=["update", "list", "path", "verify"]) eni = sub.add_parser("eni", help="Browse the ENI persona-jailbreak collection") eni.add_argument("eni_action", choices=["list", "update", "path"]) @@ -305,6 +307,19 @@ def build_sub_parser() -> argparse.ArgumentParser: help="Acknowledge the risk of exposing this unauthenticated single-operator dashboard", ) + corpus = sub.add_parser("corpus", help="Manage corpus integrity pins (library.lock.toml)") + corpus_sub = corpus.add_subparsers(dest="corpus_action", required=True) + cv = corpus_sub.add_parser( + "verify", + help="Check corpus SHAs against library.lock.toml; non-zero exit on UNRESOLVED or DRIFT", + ) + cv.add_argument( + "--update", + action="store_true", + help="Attempt to resolve actual HEAD SHAs via git ls-remote and update the lock file", + ) + cv.add_argument("--lock", default=None, help="Path to library.lock.toml (default: repo root)") + return parser @@ -412,7 +427,11 @@ def main(argv: list[str] | None = None) -> int: from .tools.eni import run_eni_cli return run_eni_cli(args) + if args.command == "corpus": + return _run_corpus_verify(args) if args.command == "parsel": + if getattr(args, "parsel_action", None) == "verify": + return _run_corpus_verify(args) from .tools.parsel_lib import run_parsel_cli return run_parsel_cli(args)