From fc4fd90a14d9d38ce74970a4b9449e7770856f50 Mon Sep 17 00:00:00 2001 From: "Poncho (audit-remediation M0)" Date: Sun, 19 Jul 2026 10:29:47 +0000 Subject: [PATCH 01/16] M0 audit remediation: dashboard auth+CSRF, tool policy, egress guard, path confinement, log redaction, atomic state, image-judge + provider hardening Closes (backend, headlessly-tested): SEC-1/2/3/6 (auth token + CSRF/Origin middleware), SEC-1/4/5 (dashboard tool-exposure policy), SEC-4 (egress guard on http_request + discovery), SEC-5/10 (read_file confinement + symlink-safe run-log path), SEC-7 (bind guard), SEC-9 (log redaction + 0600/0700 perms), REL-1 (vision_complete NameError), REL-3/RACE-1 (atomic state + merge), REL-11 (anthropic KeyError), REL-12 (claude_code killpg). Adds tests/test_audit_remediation.py (33 focused + Hypothesis tests). Full suite: only pre-existing corpus (ENI/L1B3RT4S) failures remain. --- pyproject.toml | 1 + tests/test_audit_remediation.py | 255 ++++++++++++++++++++ wallbreaker/cli.py | 8 +- wallbreaker/dashboard/auth.py | 114 +++++++++ wallbreaker/dashboard/server.py | 111 ++++++++- wallbreaker/providers/anthropic_provider.py | 6 +- wallbreaker/providers/claude_code.py | 16 +- wallbreaker/providers/image_provider.py | 9 +- wallbreaker/session.py | 35 ++- wallbreaker/state.py | 70 +++++- wallbreaker/tools/egress_guard.py | 100 ++++++++ wallbreaker/tools/files.py | 15 ++ wallbreaker/tools/http_tool.py | 25 +- wallbreaker/tools/registry.py | 8 + wallbreaker/tools/tool_policy.py | 49 ++++ 15 files changed, 794 insertions(+), 28 deletions(-) create mode 100644 tests/test_audit_remediation.py create mode 100644 wallbreaker/dashboard/auth.py create mode 100644 wallbreaker/tools/egress_guard.py create mode 100644 wallbreaker/tools/tool_policy.py diff --git a/pyproject.toml b/pyproject.toml index a70cec8..18dd8aa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,7 @@ Repository = "https://github.com/JailbrokenAI/wallbreaker" dev = [ "pytest>=8.0", "pytest-asyncio>=0.23", + "hypothesis>=6.0", ] barcodes = [ "qrcode[pil]>=7.4", diff --git a/tests/test_audit_remediation.py b/tests/test_audit_remediation.py new file mode 100644 index 0000000..0d8995c --- /dev/null +++ b/tests/test_audit_remediation.py @@ -0,0 +1,255 @@ +"""Focused + property-based tests for the audit-remediation hardening. + +Covers: SEC-1/2/3/6 (auth+CSRF), SEC-1/4/5 (tool policy), SEC-4 (egress guard), SEC-5/10 (path +confinement), SEC-7 (bind guard), SEC-9 (log redaction + perms), REL-1 (vision_complete), +REL-3/RACE-1 (atomic state). Runs offline; no network, no real subprocess. +""" +from __future__ import annotations + +import os +from pathlib import Path +from types import SimpleNamespace + +import pytest +from hypothesis import HealthCheck, given, settings, strategies as st + +from wallbreaker import state +from wallbreaker.session import redact_args +from wallbreaker.tools import egress_guard as eg +from wallbreaker.tools import tool_policy + + +# --------------------------------------------------------------------------- SEC-4 egress guard +@pytest.mark.parametrize("url", [ + "http://169.254.169.254/latest/meta-data/", # AWS metadata + "http://127.0.0.1:8787/api/agent/run", # loopback + "http://[::1]:80/", # ipv6 loopback + "http://10.0.0.5/", # RFC1918 + "http://192.168.1.1/", # RFC1918 + "http://172.16.9.9/", # RFC1918 + "file:///etc/passwd", # non-http scheme + "gopher://x/", # non-http scheme + "http://metadata.google.internal/", # blocked name +]) +def test_egress_blocks_dangerous(url): + assert eg.is_allowed(url) is False + + +@pytest.mark.parametrize("url", ["https://8.8.8.8/", "http://1.1.1.1/"]) +def test_egress_allows_public_literals(url): + assert eg.is_allowed(url) is True + + +def test_egress_redirect_chain_blocks_if_any_hop_private(): + chain = ["https://8.8.8.8/a", "http://169.254.169.254/latest/meta-data/"] + assert eg.validate_redirect_chain(chain) is False + + +@settings(max_examples=200, suppress_health_check=[HealthCheck.function_scoped_fixture]) +@given(last_octet=st.integers(min_value=0, max_value=255)) +def test_pbt_link_local_always_blocked(last_octet): + # Every 169.254.x.x address (incl. cloud metadata) must be denied. (Security Property 3) + assert eg.is_allowed(f"http://169.254.0.{last_octet}/") is False + + +# --------------------------------------------------------------------- SEC-5/10 path confinement +class _Ctx(SimpleNamespace): + pass + + +def test_read_file_confined_blocks_escape(tmp_path): + from wallbreaker.tools.files import _within_cwd + ctx = _Ctx(cwd=str(tmp_path), confine_reads=True) + assert _within_cwd(ctx, tmp_path / "a.txt") is True + assert _within_cwd(ctx, Path("/etc/passwd")) is False + assert _within_cwd(ctx, tmp_path / ".." / "outside.txt") is False + + +def test_read_file_symlink_escape_blocked(tmp_path): + from wallbreaker.tools.files import _within_cwd + secret = tmp_path / "secret.txt" + secret.write_text("s") + work = tmp_path / "work" + work.mkdir() + link = work / "leak" + link.symlink_to(secret) + ctx = _Ctx(cwd=str(work), confine_reads=True) + assert _within_cwd(ctx, link) is False # realpath escapes cwd + + +def test_safe_run_path_rejects_symlink_and_traversal(tmp_path): + from wallbreaker.dashboard.server import _safe_run_path + sessions = tmp_path / "sessions" + sessions.mkdir() + (sessions / "real.jsonl").write_text("{}") + assert _safe_run_path(sessions, "real.jsonl") is not None + assert _safe_run_path(sessions, "../secret") is None + outside = tmp_path / "outside.txt" + outside.write_text("x") + (sessions / "leak.jsonl").symlink_to(outside) + assert _safe_run_path(sessions, "leak.jsonl") is None + + +# ------------------------------------------------------------------------ SEC-9 log redaction +def test_redact_removes_secrets_keeps_prompt(): + args = { + "url": "https://t/", + "prompt": "how do I do X", + "headers": {"Authorization": "Bearer sk-secret", "x-api-key": "k-123"}, + "api_key": "sk-top", + "password": "hunter2", + } + out = redact_args(args) + import json + blob = json.dumps(out) + assert "sk-secret" not in blob and "k-123" not in blob + assert "sk-top" not in blob and "hunter2" not in blob + assert out["prompt"] == "how do I do X" # non-secret content preserved + + +@settings(max_examples=200) +@given(secret=st.text(min_size=8, max_size=40)) +def test_pbt_no_secret_survives_redaction(secret): + # A secret placed ONLY under secret keys must never survive serialization. + args = {"headers": {"Authorization": secret}, "api_key": secret, "password": secret} + import json + blob = json.dumps(redact_args(args)) + assert secret not in blob + + +# ------------------------------------------------------------- REL-3/RACE-1 atomic state +@settings(max_examples=150) +@given(prefs=st.dictionaries( + st.text(min_size=1, max_size=10), + st.integers() | st.text(max_size=10) | st.booleans(), + max_size=8)) +def test_pbt_state_round_trip(tmp_path_factory, prefs): + d = tmp_path_factory.mktemp("st") + p = d / state.STATE_FILENAME + assert state.save_state(p, prefs) is True + assert state.load_state(p) == prefs + + +def test_state_merge_preserves_disjoint_keys(tmp_path): + p = tmp_path / state.STATE_FILENAME + state.save_state_merge(p, {"a": 1}) + state.save_state_merge(p, {"b": 2}) + merged = state.load_state(p) + assert merged == {"a": 1, "b": 2} + + +def test_state_write_is_atomic_no_partial(tmp_path): + p = tmp_path / state.STATE_FILENAME + state.save_state(p, {"x": 1}) + # no leftover temp files from the atomic write + assert not list(tmp_path.glob(".state-*.tmp")) + assert state.load_state(p) == {"x": 1} + + +# ------------------------------------------------------------------ REL-1 vision_complete +def test_vision_complete_returns_grade_on_success(monkeypatch): + import asyncio + + from wallbreaker.providers import image_provider as ip + + async def fake_gated_request(endpoint, factory): + # Simulate a successful HTTP 200: the fixed code unpacks (json, status). + return {"choices": [{"message": {"content": "SAFE 2/10"}}]}, 200 + + monkeypatch.setattr(ip, "gated_request", fake_gated_request) + monkeypatch.setattr("wallbreaker.session.trace_inference_request", lambda *a, **k: "iid") + monkeypatch.setattr("wallbreaker.session.trace_inference_response", lambda *a, **k: None) + + endpoint = SimpleNamespace( + base_url="https://api.example/v1", inference_path="/chat/completions", + model="vmodel", require_key=lambda: "k", + ) + out = asyncio.run(ip.vision_complete(endpoint, "grade this", ["data:image/png;base64,AAAA"])) + assert out == "SAFE 2/10" # no NameError; grade returned + + +# ---------------------------------------------------------- SEC-1/4/5 tool-exposure policy +def _mini_registry(): + from wallbreaker.tools import shell, files, http_tool + from wallbreaker.tools.registry import ToolContext, ToolRegistry + reg = ToolRegistry(ToolContext(config=SimpleNamespace(), cwd=".")) + shell.register(reg) + files.register(reg) + http_tool.register(reg) + return reg + + +def test_dashboard_registry_excludes_host_tools_by_default(monkeypatch): + monkeypatch.setattr("wallbreaker.tools.build_registry", lambda _config: _mini_registry()) + reg = tool_policy.build_dashboard_registry(SimpleNamespace()) + names = set(reg.names()) + assert not (names & tool_policy.HOST_AFFECTING), f"host tools leaked: {names & tool_policy.HOST_AFFECTING}" + + +def test_dashboard_registry_optin_keeps_host_tools_and_confines_reads(monkeypatch): + monkeypatch.setattr("wallbreaker.tools.build_registry", lambda _config: _mini_registry()) + reg = tool_policy.build_dashboard_registry(SimpleNamespace(), allow_host_tools=True) + assert "run_shell" in reg.names() + assert reg.ctx.confine_reads is True + + +def test_classify(): + assert tool_policy.classify("run_shell") == "HOST_AFFECTING" + assert tool_policy.classify("query_target") == "SAFE" + + +# ------------------------------------------------------- SEC-1/2/3/6 auth + CSRF middleware +def _client(**kw): + from fastapi.testclient import TestClient + from wallbreaker.dashboard.server import create_app + return TestClient(create_app(config=None, sessions_dir="sessions", **kw)) + + +def test_auth_required_rejects_missing_token(): + c = _client(require_auth=True, auth_token="secret-tok") + assert c.get("/api/config").status_code == 401 + + +def test_auth_allows_valid_token(): + c = _client(require_auth=True, auth_token="secret-tok") + assert c.get("/api/config", headers={"X-WB-Token": "secret-tok"}).status_code == 200 + + +def test_auth_rejects_cross_site_origin(): + c = _client(require_auth=True, auth_token="secret-tok") + r = c.get("/api/config", headers={"X-WB-Token": "secret-tok", "Origin": "https://evil.example"}) + assert r.status_code == 403 + + +def test_auth_allows_same_origin_loopback(): + c = _client(require_auth=True, auth_token="secret-tok") + r = c.get("/api/config", headers={"X-WB-Token": "secret-tok", "Origin": "http://127.0.0.1:8787"}) + assert r.status_code == 200 + + +def test_health_and_session_exempt_from_auth(): + c = _client(require_auth=True, auth_token="secret-tok") + assert c.get("/api/health").status_code == 200 + body = c.get("/api/session").json() + assert body["authenticated"] is True and body["token"] == "secret-tok" + + +def test_no_auth_mode_is_open_for_back_compat(): + c = _client() # require_auth defaults False (test factory / embedders) + assert c.get("/api/config").status_code == 200 + + +# ------------------------------------------------------------------------- SEC-7 bind guard +def test_is_loopback_host(): + from wallbreaker.dashboard.server import _is_loopback_host + assert _is_loopback_host("127.0.0.1") + assert _is_loopback_host("localhost") + assert _is_loopback_host("::1") + assert not _is_loopback_host("0.0.0.0") + assert not _is_loopback_host("1.2.3.4") + + +def test_serve_refuses_non_loopback_without_optin(): + from wallbreaker.dashboard import server + with pytest.raises(SystemExit): + server.serve(host="0.0.0.0", allow_remote=False) diff --git a/wallbreaker/cli.py b/wallbreaker/cli.py index 3142813..e815f21 100644 --- a/wallbreaker/cli.py +++ b/wallbreaker/cli.py @@ -170,6 +170,11 @@ def build_sub_parser() -> argparse.ArgumentParser: dash.add_argument("--port", type=int, default=8787, help="Bind port (default 8787)") dash.add_argument("--sessions", default="sessions", help="Run-log directory (default sessions/)") dash.add_argument("--config", help="Path to config.toml") + dash.add_argument("--allow-host-tools", action="store_true", + help="Let the browser-driven agent use run_shell/write_file/http_request " + "(off by default for least privilege)") + dash.add_argument("--allow-remote", action="store_true", + help="Permit binding to a non-loopback --host (auth is required regardless)") return parser @@ -378,7 +383,8 @@ def main(argv: list[str] | None = None) -> int: f"Wallbreaker dashboard -> http://{args.host}:{args.port} (target: {tgt})", file=sys.stderr, ) - serve(host=args.host, port=args.port, config=config, sessions_dir=args.sessions) + serve(host=args.host, port=args.port, config=config, sessions_dir=args.sessions, + allow_host_tools=args.allow_host_tools, allow_remote=args.allow_remote) return 0 if args.command == "baseline": from .baseline import compare_baseline, format_regressions, save_baseline diff --git a/wallbreaker/dashboard/auth.py b/wallbreaker/dashboard/auth.py new file mode 100644 index 0000000..d8a023d --- /dev/null +++ b/wallbreaker/dashboard/auth.py @@ -0,0 +1,114 @@ +"""Dashboard authentication + CSRF/Origin enforcement. + +The dashboard used to be a fully unauthenticated local API whose routes could spawn shell +commands, write API keys to .env, and fire attacks — reachable via browser CSRF from any page +the operator visited, and via the LAN if bound to 0.0.0.0 (audit SEC-1/2/3/6). This module adds: + + * a per-launch bearer token (generated on `serve()`, printed to the console, written 0600); + * a pure-ASGI SecurityMiddleware that requires the token AND a same-origin request on every + /api/* route (except a small exempt set), rejecting cross-site requests before any handler + side effect. Pure-ASGI (not BaseHTTPMiddleware) so it never buffers the SSE streams. + +CORS is NOT an access control — Starlette's CORSMiddleware only decides response headers and lets +the handler run regardless. This middleware actually rejects the request. +""" +from __future__ import annotations + +import hmac +import json +import os +import secrets +from pathlib import Path +from urllib.parse import urlsplit + +TOKEN_HEADER = "x-wb-token" +CSRF_HEADER = "x-wb-csrf" +TOKEN_FILENAME = ".wallbreaker_dashboard_token" + +# Paths reachable without a token (health probe + the same-origin bootstrap the SPA uses to +# learn the token). Everything else under /api/ requires auth when require_auth is True. +EXEMPT_PATHS = frozenset({"/api/health", "/api/session"}) + +_LOOPBACK_HOSTS = frozenset({"localhost", "127.0.0.1", "::1", ""}) + + +def token_file_path(base: str | Path | None = None) -> Path: + return Path(base or ".") / TOKEN_FILENAME + + +def ensure_launch_token(base: str | Path | None = None) -> str: + """Generate (or reuse) the launch token and persist it 0600 so the SPA can read it.""" + token = secrets.token_urlsafe(32) + path = token_file_path(base) + # Write with 0600 from creation (don't chmod-after, which briefly exposes it). + fd = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w", encoding="utf-8") as handle: + handle.write(token) + try: + os.chmod(path, 0o600) + except OSError: + pass + return token + + +def _bearer(auth_header: str | None) -> str | None: + if auth_header and auth_header.lower().startswith("bearer "): + return auth_header[7:].strip() + return None + + +def origin_is_same_site(origin: str | None) -> bool: + """A localhost dashboard's only legitimate Origin is a loopback host. Absent Origin means a + non-browser client (curl / the CLI / a test) which cannot be a CSRF victim → allowed.""" + if origin is None: + return True + host = urlsplit(origin).hostname or "" + return host.lower() in _LOOPBACK_HOSTS + + +class SecurityMiddleware: + """Pure-ASGI token + Origin gate. Streaming responses pass through untouched.""" + + def __init__(self, app, token: str, require_auth: bool = True, + exempt_paths: frozenset[str] = EXEMPT_PATHS): + self.app = app + self.token = token + self.require_auth = require_auth + self.exempt_paths = exempt_paths + + async def __call__(self, scope, receive, send): + if scope["type"] != "http" or not self.require_auth: + await self.app(scope, receive, send) + return + path = scope.get("path", "") + if not path.startswith("/api/") or path in self.exempt_paths: + await self.app(scope, receive, send) + return + + headers = {k.decode("latin-1").lower(): v.decode("latin-1") + for k, v in scope.get("headers", [])} + + # CSRF: reject any cross-site Origin (and Sec-Fetch-Site: cross-site) before the handler. + if not origin_is_same_site(headers.get("origin")): + await self._reject(send, 403, "cross-site request blocked") + return + if headers.get("sec-fetch-site") in {"cross-site", "same-site"}: + await self._reject(send, 403, "cross-site request blocked") + return + + supplied = headers.get(TOKEN_HEADER) or _bearer(headers.get("authorization")) + if not supplied or not hmac.compare_digest(supplied, self.token): + await self._reject(send, 401, "missing or invalid dashboard token") + return + + await self.app(scope, receive, send) + + async def _reject(self, send, status: int, detail: str) -> None: + body = json.dumps({"detail": detail}).encode("utf-8") + await send({ + "type": "http.response.start", + "status": status, + "headers": [(b"content-type", b"application/json"), + (b"content-length", str(len(body)).encode())], + }) + await send({"type": "http.response.body", "body": body}) diff --git a/wallbreaker/dashboard/server.py b/wallbreaker/dashboard/server.py index d90d4a4..a312b8f 100644 --- a/wallbreaker/dashboard/server.py +++ b/wallbreaker/dashboard/server.py @@ -3,6 +3,7 @@ import asyncio import dataclasses import json +import os import re from datetime import datetime from pathlib import Path @@ -114,9 +115,16 @@ def _models_for_finding(record: dict, run_models: dict) -> dict: def _safe_run_path(sessions: Path, name: str) -> Path | None: - if ".." in name or "/" in name or "\\" in name: + # Reject separators/traversal in the name, then verify with realpath containment so a symlink + # planted inside sessions/ cannot point outside it (audit SEC-10). The substring check alone + # was fragile; the realpath check is the real guarantee. + if ".." in name or "/" in name or "\\" in name or name in ("", ".", ".."): return None - path = sessions / name + base = os.path.realpath(sessions) + resolved = os.path.realpath(sessions / name) + if resolved != base and not resolved.startswith(base + os.sep): + return None + path = Path(resolved) return path if path.is_file() else None @@ -385,6 +393,8 @@ def _summarize_args(args: dict) -> str: return str(args)[:300] if not args: return "" + from ..session import redact_args + args = redact_args(args) # never surface auth headers / passwords in the live stream (SEC-9) parts = [] for k, v in args.items(): if k in ("prompt", "request", "text", "payload") and isinstance(v, str): @@ -686,6 +696,16 @@ async def _discover_profile_models(profile: str, endpoint) -> dict: if key: headers["Authorization"] = f"Bearer {key}" + # SSRF guard: model discovery attaches the operator's API key to a request against a + # profile-supplied base_url. Block private/loopback/metadata targets so a poisoned base_url + # can't turn discovery into key exfiltration / internal probing (audit SEC-4). + from ..tools.egress_guard import EgressBlocked, check_url + try: + check_url(url) + except EgressBlocked as exc: + result["error"] = f"Model catalog host not allowed: {exc}" + return result + try: async with httpx.AsyncClient(timeout=20) as client: response = await client.get(url, headers=headers) @@ -703,12 +723,22 @@ async def _discover_profile_models(profile: str, endpoint) -> dict: return result -def create_app(config=None, sessions_dir: str | Path = "sessions", web_dir: str | Path | None = None): +def create_app(config=None, sessions_dir: str | Path = "sessions", web_dir: str | Path | None = None, + *, require_auth: bool = False, auth_token: str | None = None, + allow_host_tools: bool = False): """Build the Wallbreaker dashboard FastAPI app. fastapi is an optional extra - (`pip install -e '.[dashboard]'`), imported lazily so the package imports without it.""" - from fastapi import FastAPI, HTTPException + (`pip install -e '.[dashboard]'`), imported lazily so the package imports without it. + + Security: when `require_auth` is True every /api/* route (except /api/health and + /api/session) requires the dashboard token AND a same-origin request (audit SEC-1/2/3/6). + `serve()` — the only shipped entrypoint — always enables it and prints the token. The default + is False so the in-process test factory and embedders opt in explicitly; do not expose an app + built with require_auth=False on a network. `allow_host_tools` opts the browser agent back into + run_shell/write_file/http_request (off by default = least privilege, audit SEC-1/4/5).""" + from fastapi import FastAPI, Header, HTTPException from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles + from .auth import CSRF_HEADER, SecurityMiddleware, TOKEN_HEADER sessions = Path(sessions_dir) from ..session import RunLog, run_models_meta @@ -755,6 +785,10 @@ def create_app(config=None, sessions_dir: str | Path = "sessions", web_dir: str except Exception: pass app = FastAPI(title="Wallbreaker", version="0.1.0") + token = auth_token or (__import__("secrets").token_urlsafe(32) if require_auth else "") + app.state.auth_token = token + app.state.require_auth = require_auth + app.state.allow_host_tools = allow_host_tools app.add_middleware( CORSMiddleware, allow_origins=[], @@ -762,6 +796,9 @@ def create_app(config=None, sessions_dir: str | Path = "sessions", web_dir: str allow_methods=["*"], allow_headers=["*"], ) + # CORS only decides response headers; it does NOT reject the request. This middleware does — + # it requires the token + a same-origin request on every mutating route (audit SEC-1/2/3). + app.add_middleware(SecurityMiddleware, token=token, require_auth=require_auth) def _latest(): return report_mod.latest_run_log(sessions) @@ -770,6 +807,21 @@ def _latest(): def health(): return {"ok": True, "name": "wallbreaker", "version": "0.1.0"} + @app.get("/api/session") + def session_bootstrap(origin: str | None = Header(default=None)): + """Same-origin bootstrap: hands the SPA the token + the CSRF header name. Only returns the + token to a same-origin (or non-browser) request; a cross-site page is refused so it cannot + read it (the browser's same-origin policy also blocks reading this response cross-origin).""" + from .auth import origin_is_same_site + if require_auth and not origin_is_same_site(origin): + raise HTTPException(status_code=403, detail="cross-site request blocked") + return { + "authenticated": bool(require_auth), + "tokenHeader": TOKEN_HEADER, + "csrfHeader": CSRF_HEADER, + "token": token if require_auth else "", + } + @app.get("/api/config") def config_info(): return _config_summary(config) @@ -1389,10 +1441,15 @@ async def agent_run(body: dict): from ..prompts import compose_system from ..providers.factory import build_provider from ..session import RunLog, run_models_meta - from ..tools import build_registry + from ..tools.tool_policy import build_dashboard_registry base_provider = build_provider(brain) - registry = build_registry(run_config) + # Least privilege: the browser-driven agent gets the attack toolset only. run_shell / + # write_file / http_request / arbitrary read_file are excluded unless the operator opted + # in at launch (`wallbreaker dashboard --allow-host-tools`), audit SEC-1/4/5. + registry = build_dashboard_registry( + run_config, allow_host_tools=getattr(app.state, "allow_host_tools", False) + ) enabled_raw = body.get("enabled_techniques") if enabled_raw is not None: if not isinstance(enabled_raw, list) or not all(isinstance(name, str) for name in enabled_raw): @@ -1602,8 +1659,44 @@ def _no_build(): return app -def serve(host: str = "127.0.0.1", port: int = 8787, config=None, sessions_dir="sessions"): +def _is_loopback_host(host: str) -> bool: + import ipaddress + h = (host or "").strip().lower() + if h in ("localhost", ""): + return True + try: + return ipaddress.ip_address(h).is_loopback + except ValueError: + return False + + +def serve(host: str = "127.0.0.1", port: int = 8787, config=None, sessions_dir="sessions", + *, allow_host_tools: bool = False, allow_remote: bool = False): + import sys + import uvicorn - app = create_app(config=config, sessions_dir=sessions_dir) + from .auth import ensure_launch_token, token_file_path + + # SEC-7: never publish the (now authenticated, but still powerful) API to a non-loopback + # address without an explicit opt-in. Auth is always on, but a broadcast bind is a decision + # the operator must make deliberately. + if not _is_loopback_host(host) and not allow_remote: + print( + f"Refusing to bind the dashboard to non-loopback host {host!r}.\n" + "The dashboard exposes an agent that can be configured to run host commands.\n" + "Re-run with --allow-remote to accept the risk (auth is required regardless).", + file=sys.stderr, + ) + raise SystemExit(2) + + base = config.path.parent if getattr(config, "path", None) else Path(".") + token = ensure_launch_token(base) + app = create_app(config=config, sessions_dir=sessions_dir, require_auth=True, + auth_token=token, allow_host_tools=allow_host_tools) + print(f"\n Wallbreaker dashboard token: {token}") + print(f" (also written to {token_file_path(base)} — the browser UI reads it via /api/session)\n") + if not _is_loopback_host(host): + print(" WARNING: bound to a non-loopback address; anyone who can reach this port " + "and has the token can drive the agent.\n", file=sys.stderr) uvicorn.run(app, host=host, port=port) diff --git a/wallbreaker/providers/anthropic_provider.py b/wallbreaker/providers/anthropic_provider.py index e7852d1..6f8cd28 100644 --- a/wallbreaker/providers/anthropic_provider.py +++ b/wallbreaker/providers/anthropic_provider.py @@ -246,9 +246,9 @@ async def _stream_ungated( ), ) elif etype == "content_block_start": - idx = event["index"] + idx = event.get("index") block = event.get("content_block", {}) - if block.get("type") == "tool_use": + if idx is not None and block.get("type") == "tool_use": blocks[idx] = { "id": block.get("id", ""), "name": block.get("name", ""), @@ -267,7 +267,7 @@ async def _stream_ungated( if thinking: yield ReasoningDelta(thinking) elif dtype == "input_json_delta": - idx = event["index"] + idx = event.get("index") if idx in blocks: blocks[idx]["args"] += delta.get("partial_json", "") elif etype == "message_delta": diff --git a/wallbreaker/providers/claude_code.py b/wallbreaker/providers/claude_code.py index 88aaf45..bfbd110 100644 --- a/wallbreaker/providers/claude_code.py +++ b/wallbreaker/providers/claude_code.py @@ -5,6 +5,7 @@ import os import re import shutil +import signal from collections.abc import AsyncIterator from ..agent.messages import ( @@ -159,6 +160,10 @@ async def _run_cli(self, prompt: str, system: str | None) -> dict: stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, + # Lead a new process group so a timeout can kill the whole tree — the claude CLI + # is itself an agent that spawns children; proc.kill() alone orphaned them + # (same fix as the run_shell [shell] lesson, audit REL-12). + start_new_session=True, ) except FileNotFoundError as exc: raise ProviderError( @@ -171,8 +176,15 @@ async def _run_cli(self, prompt: str, system: str | None) -> dict: ) except (asyncio.TimeoutError, TimeoutError) as exc: try: - proc.kill() - except ProcessLookupError: + os.killpg(os.getpgid(proc.pid), signal.SIGKILL) + except (ProcessLookupError, PermissionError): + try: + proc.kill() + except ProcessLookupError: + pass + try: + await asyncio.wait_for(proc.wait(), timeout=5) # reap so it isn't a zombie + except (asyncio.TimeoutError, TimeoutError, ProcessLookupError): pass raise ProviderError( "claude CLI timed out after " + str(int(self.timeout)) + "s" diff --git a/wallbreaker/providers/image_provider.py b/wallbreaker/providers/image_provider.py index cd060a2..6cd9db6 100644 --- a/wallbreaker/providers/image_provider.py +++ b/wallbreaker/providers/image_provider.py @@ -337,9 +337,12 @@ async def send(): raise ProviderError( f"HTTP {resp.status_code} from {url}: {resp.text[:400]}" ) - return resp.json() + # Return the status alongside the parsed body: `resp` is local to this + # nested coroutine, so the outer scope cannot reference it (was a NameError + # on every successful call). Mirrors `_post_chat`'s (json, status) shape. + return resp.json(), resp.status_code - data = await gated_request(endpoint, send) + data, http_status = await gated_request(endpoint, send) except Exception as exc: trace_inference_response( inference_id, @@ -370,7 +373,7 @@ async def send(): status="ok", text=answer, raw_response=data, - http_status=resp.status_code, + http_status=http_status, duration_ms=round((time.monotonic() - started) * 1000, 3), ) return answer diff --git a/wallbreaker/session.py b/wallbreaker/session.py index bf6fc2e..e717989 100644 --- a/wallbreaker/session.py +++ b/wallbreaker/session.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import os from contextlib import contextmanager from contextvars import ContextVar from datetime import datetime @@ -19,6 +20,28 @@ INFERENCE_ACTION_KINDS = {"attack", "target", "judge", "scaffold", "art", "vision"} +# Keys whose values are secrets and must never be written to run logs (audit SEC-9). The agent's +# http_request tool routinely carries an Authorization/x-api-key header; st3gg carries a password. +_SECRET_KEYS = frozenset({ + "authorization", "proxy-authorization", "x-api-key", "api-key", "apikey", + "api_key", "password", "passphrase", "secret", "token", "cookie", "set-cookie", +}) +_REDACTED = "***redacted***" + + +def redact_args(obj): + """Recursively redact secret-bearing dict keys (case-insensitive) in tool args/results + before they are logged. Only values under known secret KEYS are removed, so prompt/response + text is untouched.""" + if isinstance(obj, dict): + return { + k: (_REDACTED if isinstance(k, str) and k.lower() in _SECRET_KEYS else redact_args(v)) + for k, v in obj.items() + } + if isinstance(obj, (list, tuple)): + return [redact_args(v) for v in obj] + return obj + def inference_action_kind(endpoint, operation: str = "completion") -> str: """Name a model call after the scaffold component carrying it out.""" @@ -301,6 +324,10 @@ def __init__(self, directory: str | Path = "sessions", enabled: bool = True): def _ensure(self) -> None: if not self._started: self.dir.mkdir(parents=True, exist_ok=True) + try: # run logs can contain harmful content + (until redaction) secrets — keep them private + os.chmod(self.dir, 0o700) + except OSError: + pass self._started = True if self._run_meta: self._write({ @@ -320,8 +347,14 @@ def _ensure(self) -> None: def _write(self, record: dict) -> None: self._seq += 1 record.setdefault("seq", self._seq) + new_file = not self.path.exists() with open(self.path, "a", encoding="utf-8") as handle: handle.write(json.dumps(record, ensure_ascii=False) + "\n") + if new_file: + try: + os.chmod(self.path, 0o600) + except OSError: + pass def set_run_meta(self, **data) -> None: """Store static run metadata to write as the first JSONL row on first use.""" @@ -335,7 +368,7 @@ def event(self, kind: str, **data) -> None: return self._ensure() record = {"ts": datetime.now().isoformat(timespec="seconds"), "kind": kind} - record.update(data) + record.update(redact_args(data)) self._write(record) def _json_value(self, value): diff --git a/wallbreaker/state.py b/wallbreaker/state.py index 36209da..98161dd 100644 --- a/wallbreaker/state.py +++ b/wallbreaker/state.py @@ -2,10 +2,22 @@ import dataclasses import json +import logging +import os +import tempfile +import threading from pathlib import Path STATE_FILENAME = ".wallbreaker_state.json" +_log = logging.getLogger("wallbreaker.state") + +# Serialize read-modify-write within a single process (dashboard + TUI can both write the +# shared flat-namespace state file — see the [state] lesson in CLAUDE.md). Cross-process +# safety comes from the atomic os.replace in _atomic_write below (a reader always sees a +# whole old or whole new file, never a torn/empty one). +_state_lock = threading.RLock() + def state_path_for(config) -> Path: base = config.path.parent if getattr(config, "path", None) else Path(".") @@ -13,19 +25,61 @@ def state_path_for(config) -> Path: def load_state(path: str | Path) -> dict: + p = Path(path) + if not p.exists(): + return {} try: - return json.loads(Path(path).read_text(encoding="utf-8")) - except (OSError, ValueError): + data = json.loads(p.read_text(encoding="utf-8")) + except OSError as exc: # unreadable file — surface, don't silently wipe + _log.warning("could not read state file %s: %s", p, exc) return {} + except ValueError as exc: # corrupt/torn JSON — a real error, not "empty" + _log.warning("state file %s is corrupt (%s); treating as empty", p, exc) + return {} + return data if isinstance(data, dict) else {} -def save_state(path: str | Path, prefs: dict) -> None: +def _atomic_write(path: Path, text: str) -> None: + """Write via a temp file + os.replace so a concurrent reader (or a crash) never sees a + truncated file. Truncate-then-write (the old Path.write_text) could expose an empty or + half-written state file that load_state would silently read as {} (lost prefs).""" + path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=".state-", suffix=".tmp") try: - Path(path).write_text( - json.dumps(prefs, ensure_ascii=False, indent=1), encoding="utf-8" - ) - except OSError: - pass + with os.fdopen(fd, "w", encoding="utf-8") as handle: + handle.write(text) + handle.flush() + os.fsync(handle.fileno()) + os.replace(tmp, path) # atomic on POSIX and Windows + except BaseException: + try: + os.unlink(tmp) + except OSError: + pass + raise + + +def save_state(path: str | Path, prefs: dict) -> bool: + """Atomically persist prefs. Returns True on success (callers that ignore the return + value keep their old behaviour); logs instead of silently swallowing on failure.""" + text = json.dumps(prefs, ensure_ascii=False, indent=1) + with _state_lock: + try: + _atomic_write(Path(path), text) + return True + except OSError as exc: + _log.warning("could not save state file %s: %s", path, exc) + return False + + +def save_state_merge(path: str | Path, updates: dict) -> bool: + """Read-modify-write under a lock, merging `updates` into the on-disk state instead of + clobbering the whole dict. Prevents lost updates when two writers (e.g. the dashboard + and the TUI) touch disjoint keys concurrently.""" + with _state_lock: + current = load_state(path) + current.update(updates) + return save_state(path, current) def apply_attacker(config, endpoint, prefs: dict): diff --git a/wallbreaker/tools/egress_guard.py b/wallbreaker/tools/egress_guard.py new file mode 100644 index 0000000..9817527 --- /dev/null +++ b/wallbreaker/tools/egress_guard.py @@ -0,0 +1,100 @@ +"""SSRF egress guard. + +The agent's `http_request` tool and the dashboard's provider-discovery both issue outbound +requests to model/operator-supplied URLs. Without a guard those can reach cloud metadata +(169.254.169.254), loopback, and RFC1918 hosts — a server-side request forgery + credential +exfiltration primitive (audit SEC-4). This module centralises the allow/deny decision so both +call sites share one policy. + +Policy: + * scheme must be http or https (blocks file://, gopher://, data://, ...); + * every IP the hostname resolves to must be a public unicast address — loopback, link-local + (incl. cloud metadata), private (RFC1918/ULA), reserved, multicast, and unspecified are denied; + * redirects must be re-validated hop-by-hop (a public host that 302s to a metadata IP is denied). + +Residual risk (documented in security-audit-prep.md): DNS rebinding between this check and the +actual socket connect. Fully closing that needs socket-level pinning of the validated IP; this +guard resolves and checks all A/AAAA records, which stops the common cases. +""" +from __future__ import annotations + +import ipaddress +import socket +from urllib.parse import urlsplit + +ALLOWED_SCHEMES = frozenset({"http", "https"}) + +# Hostnames that resolve to metadata services but may be allow-listed by resolvers. +_BLOCKED_NAMES = frozenset({"metadata.google.internal", "metadata"}) + + +class EgressBlocked(ValueError): + """Raised when a URL is not permitted to leave the host.""" + + +def _ip_is_public(ip: ipaddress._BaseAddress) -> bool: + # IPv4-mapped IPv6 (::ffff:169.254.169.254) must be judged on the embedded v4 address. + mapped = getattr(ip, "ipv4_mapped", None) + if mapped is not None: + ip = mapped + return not ( + ip.is_loopback + or ip.is_link_local + or ip.is_private + or ip.is_reserved + or ip.is_multicast + or ip.is_unspecified + ) + + +def _resolve_ips(host: str) -> list[ipaddress._BaseAddress]: + # Literal IP? judge it directly (no DNS). + try: + return [ipaddress.ip_address(host)] + except ValueError: + pass + infos = socket.getaddrinfo(host, None, proto=socket.IPPROTO_TCP) + out: list[ipaddress._BaseAddress] = [] + for family, _t, _p, _c, sockaddr in infos: + addr = sockaddr[0] + try: + out.append(ipaddress.ip_address(addr.split("%", 1)[0])) + except ValueError: + continue + return out + + +def check_url(url: str) -> None: + """Raise EgressBlocked if `url` may not be requested. Resolves DNS.""" + parts = urlsplit(url) + if parts.scheme.lower() not in ALLOWED_SCHEMES: + raise EgressBlocked(f"scheme {parts.scheme!r} not allowed (only http/https)") + host = parts.hostname + if not host: + raise EgressBlocked("URL has no host") + if host.lower().rstrip(".") in _BLOCKED_NAMES: + raise EgressBlocked(f"host {host!r} is a blocked metadata name") + try: + ips = _resolve_ips(host) + except socket.gaierror: + # Fail-open on resolution failure: a host that does not resolve cannot reach any internal + # service (the caller's connect will simply fail). We only block hosts that positively + # resolve to a non-public address, which is what the SSRF threat requires. + return + for ip in ips: + if not _ip_is_public(ip): + raise EgressBlocked(f"host {host!r} resolves to non-public address {ip}") + + +def is_allowed(url: str) -> bool: + """Boolean form of check_url (never raises). DNS-resolution failures count as not-allowed.""" + try: + check_url(url) + return True + except EgressBlocked: + return False + + +def validate_redirect_chain(chain: list[str]) -> bool: + """Every hop in a redirect chain must be allowed.""" + return all(is_allowed(u) for u in chain) diff --git a/wallbreaker/tools/files.py b/wallbreaker/tools/files.py index 5fc0be9..f335d82 100644 --- a/wallbreaker/tools/files.py +++ b/wallbreaker/tools/files.py @@ -1,5 +1,6 @@ from __future__ import annotations +import os from pathlib import Path from .registry import ToolContext, ToolRegistry @@ -46,11 +47,25 @@ def _confine(ctx: ToolContext, path: str) -> tuple[Path, str]: return base / Path(path).name, f" (redirected into the working dir {base})" +def _within_cwd(ctx: ToolContext, p: Path) -> bool: + """True if p (symlinks resolved) is inside ctx.cwd. Uses realpath containment rather than a + substring check so `..`, absolute paths, and symlinks that escape the working dir are all + rejected (audit SEC-5/10).""" + base = Path(ctx.cwd).resolve() + try: + Path(os.path.realpath(p)).relative_to(base) + return True + except ValueError: + return False + + async def _read_file(args: dict, ctx: ToolContext) -> str: path = _pick(args, _PATH_KEYS) if not path: return "Error: 'path' is required (also accepts file/filename/filepath)" p = _resolve(ctx, path) + if getattr(ctx, "confine_reads", False) and not _within_cwd(ctx, p): + return f"Error: read denied — path escapes the working directory: {path}" if not p.is_file(): return f"Error: no such file: {p}" try: diff --git a/wallbreaker/tools/http_tool.py b/wallbreaker/tools/http_tool.py index d0423eb..a7dbcca 100644 --- a/wallbreaker/tools/http_tool.py +++ b/wallbreaker/tools/http_tool.py @@ -4,9 +4,11 @@ import httpx +from .egress_guard import EgressBlocked, check_url from .registry import ToolContext, ToolRegistry MAX_BODY = 30000 +MAX_REDIRECTS = 5 async def _http_request(args: dict, ctx: ToolContext) -> str: @@ -25,9 +27,30 @@ async def _http_request(args: dict, ctx: ToolContext) -> str: elif body is not None: kwargs["content"] = body if isinstance(body, str) else json.dumps(body) + # SSRF guard: validate the initial URL and every redirect hop against the egress policy + # (blocks metadata/loopback/private targets). We follow redirects manually so each Location + # is re-checked before we connect to it — httpx's follow_redirects=True would chase a + # public-host -> 169.254.169.254 redirect without a second look. try: - async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client: + check_url(url) + except EgressBlocked as exc: + return f"Request blocked: {exc}" + + try: + async with httpx.AsyncClient(timeout=timeout, follow_redirects=False) as client: resp = await client.request(method, url, **kwargs) + hops = 0 + while resp.is_redirect and hops < MAX_REDIRECTS: + location = resp.headers.get("location") + if not location: + break + next_url = str(resp.next_request.url) if resp.next_request else location + try: + check_url(next_url) + except EgressBlocked as exc: + return f"Request blocked (redirect): {exc}" + hops += 1 + resp = await client.request(method, next_url, **kwargs) except httpx.HTTPError as exc: return f"Request failed: {exc}" diff --git a/wallbreaker/tools/registry.py b/wallbreaker/tools/registry.py index 9a6930e..5d3a440 100644 --- a/wallbreaker/tools/registry.py +++ b/wallbreaker/tools/registry.py @@ -31,6 +31,10 @@ class ToolContext: attacker_model: str = "" # auto-save every COMPLIED/PARTIAL verdict into the BreakVault (library/breaks/) vault_enabled: bool = True + # confine read_file to cwd (defence-in-depth against arbitrary local file read, audit SEC-5). + # Off by default so the local CLI/TUI operator keeps full-filesystem reads; the dashboard + # registry builder turns it ON so a browser-driven agent can't exfiltrate ~/.env, keys, etc. + confine_reads: bool = False # host sink that logs EVERY tool execution (brain loop AND slash commands) to the run log tool_logger: Callable[[str, dict, str, bool], None] | None = None @@ -230,6 +234,10 @@ def specs(self) -> list[dict]: def names(self) -> list[str]: return list(self.tools) + def remove(self, name: str) -> bool: + """Drop a tool from the registry (used by the dashboard tool-exposure policy).""" + return self.tools.pop(name, None) is not None + async def execute(self, name: str, args: dict) -> ToolResult: tool = self.tools.get(name) if tool is None: diff --git a/wallbreaker/tools/tool_policy.py b/wallbreaker/tools/tool_policy.py new file mode 100644 index 0000000..dd9497e --- /dev/null +++ b/wallbreaker/tools/tool_policy.py @@ -0,0 +1,49 @@ +"""Tool-exposure policy for the browser-reachable dashboard agent. + +The dashboard's `POST /api/agent/run` builds the full tool registry, which includes `run_shell`, +`write_file`/`edit_file`/`patch_file`, `read_file`, and `http_request`. Exposed with no auth that +was browser-CSRF-reachable RCE + arbitrary file read + SSRF (audit SEC-1/4/5). Even with auth +(added separately), least privilege says a browser-driven agent should not touch the host or read +arbitrary files by default. This module classifies tools and builds a filtered registry. + +`run_shell` etc. remain available to the local CLI/TUI operator (their own authorized intent) and +to the dashboard only when the operator explicitly opts in (`allow_host_tools=True`). +""" +from __future__ import annotations + +# Tools that can affect the host filesystem, run commands, or make arbitrary outbound requests. +HOST_AFFECTING = frozenset({ + "run_shell", + "write_file", + "edit_file", + "patch_file", + "read_file", + "http_request", +}) + + +def classify(tool_name: str) -> str: + return "HOST_AFFECTING" if tool_name in HOST_AFFECTING else "SAFE" + + +def build_dashboard_registry(config, cwd: str | None = None, *, allow_host_tools: bool = False): + """Build a tool registry for a dashboard-driven agent run. + + By default, host-affecting tools are removed so a browser-driven agent is confined to the + attack/red-team toolset. When `allow_host_tools` is True (operator opt-in), the full registry + is returned but reads are still confined to the working directory as defence-in-depth. + """ + from . import build_registry + + # Pass cwd only when set so monkeypatched build_registry doubles (lambda _config: ...) still work. + registry = build_registry(config) if cwd is None else build_registry(config, cwd=cwd) + if allow_host_tools: + # Keep host tools, but confine read_file to cwd so an opted-in agent still can't + # exfiltrate ~/.env / keys outside the project (audit SEC-5). + registry.ctx.confine_reads = True + return registry + + for name in list(registry.names()): + if name in HOST_AFFECTING: + registry.remove(name) + return registry From 7b35b987ff2ea88337e4c3711f9e9dcc8de28291 Mon Sep 17 00:00:00 2001 From: rial1 Date: Sun, 19 Jul 2026 14:55:06 +0100 Subject: [PATCH 02/16] TG1.4: wire SPA auth token (X-WB-Token); token IS the CSRF defense SEC-1/2 closure on the SPA side (Checkpoint A #5): - api.ts: lazy memoized ensureToken() fetches /api/session once; withAuth() injects X-WB-Token into the central j() helper (all api.* calls) AND the streaming runAgent fetch (SSE via fetch+ReadableStream, not EventSource, so custom headers work). No token -> no header (test-factory mode works). - auth.py: drop CSRF_HEADER. The token in a custom header IS the CSRF defense (cross-site page can't set a custom header without a CORS preflight, which loopback-only CORS rejects; can't read /api/session either). The explicit Origin/Sec-Fetch-Site same-origin check is the independent CSRF guard. No cookie auth -> a double-submit CSRF token would add nothing. Removing the unenforced header eliminates the false-sense-of-protection (PM directive: never send a header you don't check). - server.py /api/session: drop csrfHeader from the response; doc the model. - tests: lock the SPA contract (/api/session shape, no csrfHeader, cross-site Origin -> 403; no-auth mode -> empty token). - .gitignore: guard gh_token.txt / *_token.txt / *.pat / dashboard token. Verified: npm run build clean; 35/35 test_audit_remediation.py; full suite 1097 passed (was 1095), 31 failed / 7 errors unchanged (pre-existing corpus- dependent: ENI/L1B3RT4S/system_prompts/persona_forge/seed_sweep). --- .gitignore | 5 ++++ tests/test_audit_remediation.py | 26 +++++++++++++++++++++ wallbreaker/dashboard/auth.py | 6 ++++- wallbreaker/dashboard/server.py | 12 ++++++---- wallbreaker/dashboard/web/src/api.ts | 35 +++++++++++++++++++++++++--- 5 files changed, 75 insertions(+), 9 deletions(-) diff --git a/.gitignore b/.gitignore index e305eb2..82b0bd6 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,11 @@ config.toml .wallbreaker_models.sqlite3-shm .wallbreaker_models.sqlite3-wal .rth_state.json +# credentials / tokens — never commit (GitHub PATs, API keys, dashboard launch token) +gh_token.txt +*_token.txt +*.pat +.wallbreaker_dashboard_token # user presets (example.toml is the documented template, tracked) presets/*.toml diff --git a/tests/test_audit_remediation.py b/tests/test_audit_remediation.py index 0d8995c..87c505d 100644 --- a/tests/test_audit_remediation.py +++ b/tests/test_audit_remediation.py @@ -234,6 +234,32 @@ def test_health_and_session_exempt_from_auth(): assert body["authenticated"] is True and body["token"] == "secret-tok" +def test_session_bootstrap_shape_for_spa(): + """TG1.4 contract: /api/session hands the SPA the token header name + the token, and the + token itself is the CSRF defense (no separate csrfHeader is exposed). Locks the shape the + SPA's ensureToken() depends on.""" + from wallbreaker.dashboard.auth import TOKEN_HEADER + c = _client(require_auth=True, auth_token="secret-tok") + body = c.get("/api/session").json() + assert body["tokenHeader"] == TOKEN_HEADER == "x-wb-token" + assert body["token"] == "secret-tok" + assert body["authenticated"] is True + # The token IS the CSRF defense: no separate csrf header is exposed or enforced. + assert "csrfHeader" not in body + # A cross-site Origin must not receive the token. + r = c.get("/api/session", headers={"Origin": "https://evil.example"}) + assert r.status_code == 403 + + +def test_no_auth_mode_session_returns_empty_token(): + """When auth is off (test factory / embedders), /api/session reports unauthenticated and no + token, so the SPA's withAuth() sends no header and the app still works.""" + c = _client() # require_auth defaults False + body = c.get("/api/session").json() + assert body["authenticated"] is False + assert body["token"] == "" + + def test_no_auth_mode_is_open_for_back_compat(): c = _client() # require_auth defaults False (test factory / embedders) assert c.get("/api/config").status_code == 200 diff --git a/wallbreaker/dashboard/auth.py b/wallbreaker/dashboard/auth.py index d8a023d..4704fbd 100644 --- a/wallbreaker/dashboard/auth.py +++ b/wallbreaker/dashboard/auth.py @@ -21,8 +21,12 @@ from pathlib import Path from urllib.parse import urlsplit +# The token IS the CSRF defense, not a separate header: it rides in a custom header a +# cross-site page cannot set (a cross-site fetch with a custom header triggers a CORS preflight, +# which this app's loopback-only CORS rejects), and cannot read (same-origin policy blocks +# /api/session). The Origin / Sec-Fetch-Site same-origin check is an independent, explicit +# CSRF guard. There is no cookie auth, so a double-submit CSRF token would add nothing. TOKEN_HEADER = "x-wb-token" -CSRF_HEADER = "x-wb-csrf" TOKEN_FILENAME = ".wallbreaker_dashboard_token" # Paths reachable without a token (health probe + the same-origin bootstrap the SPA uses to diff --git a/wallbreaker/dashboard/server.py b/wallbreaker/dashboard/server.py index a312b8f..31af7a9 100644 --- a/wallbreaker/dashboard/server.py +++ b/wallbreaker/dashboard/server.py @@ -738,7 +738,7 @@ def create_app(config=None, sessions_dir: str | Path = "sessions", web_dir: str from fastapi import FastAPI, Header, HTTPException from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles - from .auth import CSRF_HEADER, SecurityMiddleware, TOKEN_HEADER + from .auth import SecurityMiddleware, TOKEN_HEADER sessions = Path(sessions_dir) from ..session import RunLog, run_models_meta @@ -809,16 +809,18 @@ def health(): @app.get("/api/session") def session_bootstrap(origin: str | None = Header(default=None)): - """Same-origin bootstrap: hands the SPA the token + the CSRF header name. Only returns the - token to a same-origin (or non-browser) request; a cross-site page is refused so it cannot - read it (the browser's same-origin policy also blocks reading this response cross-origin).""" + """Same-origin bootstrap: hands the SPA the launch token so it can attach it as + ``X-WB-Token`` on every request. Only returns the token to a same-origin (or non-browser) + request; a cross-site page is refused so it cannot read it (the browser's same-origin + policy also blocks reading this response cross-origin). The token itself is the CSRF + defense — a cross-site page cannot set a custom header without a CORS preflight, which + this app's loopback-only CORS rejects.""" from .auth import origin_is_same_site if require_auth and not origin_is_same_site(origin): raise HTTPException(status_code=403, detail="cross-site request blocked") return { "authenticated": bool(require_auth), "tokenHeader": TOKEN_HEADER, - "csrfHeader": CSRF_HEADER, "token": token if require_auth else "", } diff --git a/wallbreaker/dashboard/web/src/api.ts b/wallbreaker/dashboard/web/src/api.ts index 223403a..c83b31c 100644 --- a/wallbreaker/dashboard/web/src/api.ts +++ b/wallbreaker/dashboard/web/src/api.ts @@ -217,8 +217,35 @@ export interface FireResult extends ComposeResult { run_log?: string; } +// --- Auth bootstrap (TG1.4, SEC-1/2) ----------------------------------------------- +// The dashboard requires a per-launch bearer token (X-WB-Token). We fetch it once from the +// same-origin /api/session bootstrap and memoize it. The token IS the CSRF defense: a cross-site +// page cannot set a custom header without a CORS preflight (rejected by loopback-only CORS) and +// cannot read /api/session (same-origin policy). If auth is off (test factory), the token is +// empty and we send no header — the app still works. +let tokenPromise: Promise | null = null; + +async function ensureToken(): Promise { + if (!tokenPromise) { + tokenPromise = fetch("/api/session") + .then((r) => (r.ok ? r.json() : { token: "" })) + .then((b: { token?: string }) => b.token ?? "") + .catch(() => ""); + } + return tokenPromise; +} + +/** Merge the auth header into a RequestInit's headers. No-op when there is no token. */ +async function withAuth(init?: RequestInit): Promise { + const token = await ensureToken(); + if (!token) return init ?? {}; + const headers = new Headers(init?.headers); + headers.set("X-WB-Token", token); + return { ...init, headers }; +} + async function j(url: string, init?: RequestInit): Promise { - const r = await fetch(url, init); + const r = await fetch(url, await withAuth(init)); if (!r.ok) { let detail = r.statusText; try { @@ -317,12 +344,14 @@ export async function runAgent( onEvent: (ev: AgentEvent) => void, signal?: AbortSignal ): Promise { - const r = await fetch("/api/agent/run", { + // Streaming SSE via fetch + ReadableStream (NOT EventSource) so we can attach the custom + // X-WB-Token header — EventSource cannot set custom headers, which is why this path uses fetch. + const r = await fetch("/api/agent/run", await withAuth({ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), signal, - }); + })); if (!r.ok || !r.body) { let detail = r.statusText; try { detail = (await r.json()).detail || detail; } catch { /* ignore */ } From 8c1f904597d7e170b70a8a11fa5b1cc817a51e3f Mon Sep 17 00:00:00 2001 From: rial1 Date: Sun, 19 Jul 2026 17:51:42 +0100 Subject: [PATCH 03/16] TG4.2: close providers at the tool-call boundary (REL-2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The leak: ~80 build_provider() sites in tools build a pooled httpx.AsyncClient per provider and never close it; across an autonomous run this accumulates hundreds of unclosed clients (ResourceWarning, FD pin, defeats pooling). The dashboard brain provider (built outside tools) leaked per run too. Fix (chokepoint, not 80 per-site try/finally): - providers/factory.py: a ContextVar bucket + provider_scope() async ctx manager. build_provider() appends to the active bucket; ToolRegistry.execute wraps every tool call in `async with provider_scope()`, aclose()ing all providers built during the call at the call boundary. A provider reused across the call (best_of_n's single target, used for all N fires) stays pooled for the whole call, closed once at the end — pooling preserved, not killed. Child tasks (gather_capped/create_task) share the bucket by reference so they're tracked too. Fake-tolerant: monkeypatched build_provider replaces this function entirely (fakes hold no real client); the close loop uses getattr(aclose) so any tracked provider missing aclose is skipped. - providers/base.py: __aenter__/__aexit__ on Provider (the explicit-ownership primitive; used by the dashboard brain path, available for future sites). - tools/registry.py: execute() wrapped in provider_scope(). - dashboard/server.py: _LiveAttackerProvider.aclose closes the brain provider; switch() closes the predecessor on hot-swap; runner() finally aclose()s the brain provider at run end. No double-close: CLI/TUI top-level providers are built outside reg.execute (bucket None, untracked) and close themselves. Verification (PM gate): - tests/test_audit_remediation.py +5: chokepoint closes a real client; pooling preserved mid-call; monkeypatched no-aclose fake doesn't raise; async-with closes; brain aclose + switch closes old. 40/40 pass. - pytest -q -W error::ResourceWarning: 1102 passed, 31 failed / 7 errors unchanged (pre-existing corpus-dependent: ENI/L1B3RT4S/system_prompts/ persona_forge/seed_sweep). No new ResourceWarning failures, no fake broken. - Full suite: 1102 passed (was 1097 -> +5), no regressions. Design note for review: deviated from the letter of 'async with build_provider at ~80 sites' in favor of one chokepoint (provider_scope at reg.execute) — serves the same intent (close at tool-invocation boundary, preserve pooling, fake-tolerant, fewer places to get wrong) with materially lower rework risk and zero fake edits. __aenter__/__aexit__ still added so the explicit pattern is available where clean. --- tests/test_audit_remediation.py | 156 +++++++++++++++++++++++++++++++ wallbreaker/dashboard/server.py | 26 ++++++ wallbreaker/providers/base.py | 17 ++++ wallbreaker/providers/factory.py | 61 ++++++++++-- wallbreaker/tools/registry.py | 27 ++++-- 5 files changed, 270 insertions(+), 17 deletions(-) diff --git a/tests/test_audit_remediation.py b/tests/test_audit_remediation.py index 87c505d..45199bb 100644 --- a/tests/test_audit_remediation.py +++ b/tests/test_audit_remediation.py @@ -279,3 +279,159 @@ def test_serve_refuses_non_loopback_without_optin(): from wallbreaker.dashboard import server with pytest.raises(SystemExit): server.serve(host="0.0.0.0", allow_remote=False) + + +# ------------------------------------------------------------------------- REL-2 provider lifecycle (TG4.2) +# The leak: each tool builds 1-2 providers via build_provider() and drops them; their pooled +# httpx.AsyncClient never closes. Fix: ToolRegistry.execute wraps each call in +# providers.provider_scope(), which tracks build_provider() results and aclose()s them at the +# call boundary (preserving per-call pooling — a provider reused across the call, like +# best_of_n's target, stays open until the call ends). + +def test_provider_scope_closes_providers_built_during_a_tool_call(): + """TG4.8: a real provider built inside a tool handler is aclose()d when the tool call ends.""" + import asyncio + + from wallbreaker.config import Config, Endpoint + from wallbreaker.tools.registry import ToolContext, ToolRegistry + + cfg = Config(default_profile="t", profiles={"t": Endpoint("t", "openai", "http://x", "m")}) + cfg.target = Endpoint("t", "openai", "http://x", "m") + ctx = ToolContext(config=cfg) + reg = ToolRegistry(ctx) + + captured: list = [] + clients: list = [] + + async def handler(_args, _ctx): + from wallbreaker.providers.factory import build_provider + p = build_provider(_ctx.config.target) + captured.append(p) + # Force the lazy pooled client to actually exist so aclose has something to close. + c = p._http_client() + clients.append(c) + assert c is not None and not c.is_closed, "client should be open mid-call" + return "ok" + + reg.add("probe", "build a provider", {"type": "object"}, handler) + asyncio.run(reg.execute("probe", {})) + + assert len(captured) == 1 and len(clients) == 1 + assert clients[0].is_closed, "pooled client must be closed by provider_scope at call end" + assert captured[0]._client is None, "aclose clears the provider's client reference" + + +def test_provider_scope_preserves_pooling_within_a_call(): + """A provider built once and reused across the call stays open for the whole call (not + closed per use), then closed once at the end — the pooling optimization the fix protects.""" + import asyncio + + from wallbreaker.config import Config, Endpoint + from wallbreaker.tools.registry import ToolContext, ToolRegistry + + cfg = Config(default_profile="t", profiles={"t": Endpoint("t", "openai", "http://x", "m")}) + cfg.target = Endpoint("t", "openai", "http://x", "m") + ctx = ToolContext(config=cfg) + reg = ToolRegistry(ctx) + + shared: list = [] + + async def handler(_args, _ctx): + from wallbreaker.providers.factory import build_provider + p = build_provider(_ctx.config.target) + shared.append(p) + # Simulate reuse: build for the same endpoint again would be a NEW provider (build_provider + # always returns fresh); the pooling we protect is WITHIN one provider instance across + # multiple complete()/stream() calls. Here we just confirm the one provider stays open. + assert p._client is None, "no client yet" + _ = p._http_client() # first use creates the client + assert not p._client.is_closed, "client open mid-call (pooling preserved)" + _ = p._http_client() # second use reuses the SAME client (not rebuilt) + return "ok" + + reg.add("probe", "reuse provider", {"type": "object"}, handler) + asyncio.run(reg.execute("probe", {})) + assert shared[0]._client is None, "closed exactly once at call end" + + +def test_provider_scope_is_fake_tolerant_for_monkeypatched_build_provider(): + """If a test monkeypatches build_provider to a fake without aclose (the common test-double + shape), provider_scope must not raise trying to close it — the fake replaces build_provider + entirely so it isn't tracked, and even if one were tracked, getattr(aclose) guards the close.""" + import asyncio + + import wallbreaker.providers.factory as factory + from wallbreaker.config import Config, Endpoint + from wallbreaker.tools.registry import ToolContext, ToolRegistry + + class _NoCloseFake: + def __init__(self, endpoint, **kw): + self.endpoint = endpoint + async def complete(self, messages, system=None, max_tokens=1024): + return "fake" + + cfg = Config(default_profile="t", profiles={"t": Endpoint("t", "openai", "http://x", "m")}) + cfg.target = Endpoint("t", "openai", "http://x", "m") + ctx = ToolContext(config=cfg) + reg = ToolRegistry(ctx) + + async def handler(_args, _ctx): + p = factory.build_provider(_ctx.config.target) + assert isinstance(p, _NoCloseFake) + return "ok" + + reg.add("probe", "fake", {"type": "object"}, handler) + orig = factory.build_provider + factory.build_provider = _NoCloseFake # type: ignore[assignment] + try: + res = asyncio.run(reg.execute("probe", {})) # must not raise + finally: + factory.build_provider = orig # type: ignore[assignment] + assert not res.is_error + + +def test_provider_supports_async_with_and_closes(): + """__aenter__/__aexit__ on Provider: the explicit-ownership primitive (used by the dashboard + brain path; available for any future call site).""" + import asyncio + + from wallbreaker.config import Endpoint + from wallbreaker.providers.factory import build_provider + + async def run(): + p = build_provider(Endpoint("t", "openai", "http://x", "m")) + _ = p._http_client() + assert p._client is not None and not p._client.is_closed + async with p: + assert p is p # in-context use + assert p._client is None, "__aexit__ must have aclose()d the provider" + + asyncio.run(run()) + + +def test_live_attacker_provider_aclose_closes_brain_and_switch_closes_old(): + """TG4.2 dashboard brain lifecycle: _LiveAttackerProvider.aclose closes the active brain + provider; switch() closes the predecessor so a hot-swap doesn't leak its client.""" + import asyncio + + from wallbreaker.config import Endpoint + from wallbreaker.dashboard.server import _LiveAttackerProvider + from wallbreaker.providers.factory import build_provider + + async def run(): + first = build_provider(Endpoint("a", "openai", "http://x", "m")) + _ = first._http_client() + wrap = _LiveAttackerProvider(first, Endpoint("a", "openai", "http://x", "m"), lambda _ep: "") + + second = build_provider(Endpoint("b", "openai", "http://y", "m")) + _ = second._http_client() + wrap.switch(second, Endpoint("b", "openai", "http://y", "m")) + # the old (first) client is scheduled to close; let the loop drain it + await asyncio.sleep(0) + assert first._client is None, "switch must close the predecessor" + assert wrap._provider is second + + await wrap.aclose() + assert second._client is None, "aclose must close the active brain provider" + + asyncio.run(run()) diff --git a/wallbreaker/dashboard/server.py b/wallbreaker/dashboard/server.py index 31af7a9..a6d72b4 100644 --- a/wallbreaker/dashboard/server.py +++ b/wallbreaker/dashboard/server.py @@ -40,9 +40,28 @@ def model(self) -> str: return self.endpoint.model def switch(self, provider, endpoint) -> None: + # REL-2: close the previous brain provider before replacing it so its pooled + # httpx.AsyncClient isn't leaked on a hot-swap. getattr-safe for fake/test providers. + old = self._provider + aclose = getattr(old, "aclose", None) + if aclose is not None and old is not provider: + try: + asyncio.get_running_loop().create_task(aclose()) + except Exception: + pass self._provider = provider self.endpoint = endpoint + async def aclose(self) -> None: + # REL-2: the dashboard's brain provider lives for one agent run; close it when the + # run ends (runner() finally) so its pooled client isn't leaked per run. + aclose = getattr(self._provider, "aclose", None) + if aclose is not None: + try: + await aclose() + except Exception: + pass + async def stream(self, messages, tools=None, system=None, max_tokens=4096, temperature=None): provider = self._provider active_system = self._system_builder(self.endpoint) @@ -1616,6 +1635,13 @@ async def runner(): except Exception as exc: # noqa: BLE001 error_event(f"{type(exc).__name__}: {exc}") finally: + # REL-2: close the brain provider (and its pooled client) when the run ends; + # _LiveAttackerProvider.aclose closes the active provider, and any hot-swapped + # predecessor was already closed by switch(). + try: + await provider.aclose() + except Exception: + pass agent_active = False resume_event.set() agent_control = None diff --git a/wallbreaker/providers/base.py b/wallbreaker/providers/base.py index c98cd61..43a0046 100644 --- a/wallbreaker/providers/base.py +++ b/wallbreaker/providers/base.py @@ -163,6 +163,23 @@ async def aclose(self) -> None: except Exception: pass + # Async context-manager support so callers can opt into explicit ownership where it's + # clean (e.g. the dashboard's top-level brain provider). Tools generally don't need this: + # ToolRegistry.execute wraps every tool call in providers.provider_scope(), which tracks + # providers built during the call and aclose()s them when the call finishes (audit REL-2). + # __aexit__ uses getattr so a subclass fake that drops aclose still exits cleanly. + async def __aenter__(self) -> "Provider": + return self + + async def __aexit__(self, exc_type, exc, tb) -> bool: + aclose = getattr(self, "aclose", None) + if aclose is not None: + try: + await aclose() + except Exception: + pass + return False + @abstractmethod def stream( self, diff --git a/wallbreaker/providers/factory.py b/wallbreaker/providers/factory.py index c2af7c3..4286acc 100644 --- a/wallbreaker/providers/factory.py +++ b/wallbreaker/providers/factory.py @@ -1,5 +1,8 @@ from __future__ import annotations +from contextlib import asynccontextmanager +from contextvars import ContextVar + from ..config import Endpoint from .anthropic_provider import AnthropicProvider from .base import DEFAULT_TIMEOUT, Provider, ProviderError @@ -16,10 +19,54 @@ def build_provider(endpoint: Endpoint, timeout: float | None = None) -> Provider # the same provider. Image modality is blocked for xai at config-validation time. if endpoint.protocol in ("openai", "xai"): if getattr(endpoint, "modality", "text") == "image": - return OpenRouterImageProvider(endpoint, timeout=resolved) - return OpenAIProvider(endpoint, timeout=resolved) - if endpoint.protocol == "anthropic": - return AnthropicProvider(endpoint, timeout=resolved) - if endpoint.protocol == "claude-code": - return ClaudeCodeProvider(endpoint, timeout=resolved) - raise ProviderError(f"Unknown protocol '{endpoint.protocol}'") + provider: Provider = OpenRouterImageProvider(endpoint, timeout=resolved) + else: + provider = OpenAIProvider(endpoint, timeout=resolved) + elif endpoint.protocol == "anthropic": + provider = AnthropicProvider(endpoint, timeout=resolved) + elif endpoint.protocol == "claude-code": + provider = ClaudeCodeProvider(endpoint, timeout=resolved) + else: + raise ProviderError(f"Unknown protocol '{endpoint.protocol}'") + # REL-2: when a tool call is in progress, ToolRegistry.execute wraps it in + # provider_scope(); record the built provider so it is aclose()d when the call ends + # instead of leaking its pooled httpx.AsyncClient. Outside a scope (CLI/TUI top level, + # tests) the bucket is None and nothing is tracked — those owners close themselves. + bucket = _provider_bucket.get() + if bucket is not None: + bucket.append(provider) + return provider + + +# Per-call registry of providers built during a tool invocation. None outside a scope. +# A list is shared by reference across child tasks (asyncio copies the ContextVar binding, +# not the list), so providers built in gather_capped/create_task children are tracked too. +_provider_bucket: ContextVar[list | None] = ContextVar("wb_provider_bucket", default=None) + + +@asynccontextmanager +async def provider_scope(): + """Track every provider built while this block is active and aclose() them on exit. + + ToolRegistry.execute wraps each tool call in `async with provider_scope():` so the + ~80 `build_provider` sites need no per-site try/finally (audit REL-2). Closing happens + at the tool-invocation boundary — a provider reused across the call (e.g. best_of_n's + single `target` reused for all N fires) stays pooled for the whole call and is closed + once at the end, not per model call. Fake-tolerant: monkeypatched build_provider fakes + replace this function entirely, so they aren't tracked and hold no real client to + leak; the close loop uses getattr(aclose) so any tracked provider missing aclose is + skipped rather than raising. + """ + bucket: list = [] + token = _provider_bucket.set(bucket) + try: + yield + finally: + _provider_bucket.reset(token) + for provider in bucket: + aclose = getattr(provider, "aclose", None) + if aclose is not None: + try: + await aclose() + except Exception: + pass diff --git a/wallbreaker/tools/registry.py b/wallbreaker/tools/registry.py index 5d3a440..0b09559 100644 --- a/wallbreaker/tools/registry.py +++ b/wallbreaker/tools/registry.py @@ -242,15 +242,22 @@ async def execute(self, name: str, args: dict) -> ToolResult: tool = self.tools.get(name) if tool is None: return ToolResult(f"Unknown tool: {name}", is_error=True) - try: - output = await tool.handler(args or {}, self.ctx) - result = ToolResult(output) - except Exception as exc: # noqa: BLE001 - detail = "".join(traceback.format_exception_only(type(exc), exc)).strip() - result = ToolResult(f"Tool '{name}' raised: {detail}", is_error=True) - if self.ctx.tool_logger is not None: + # REL-2: scope provider lifetime to this tool call. build_provider() calls made by + # the handler (and its child tasks) are tracked and aclose()d here on exit, so pooled + # httpx.AsyncClients don't leak across rounds of an autonomous run. A provider reused + # within the call (e.g. best_of_n's target) stays pooled until the call ends. + from ..providers.factory import provider_scope + + async with provider_scope(): try: - self.ctx.tool_logger(name, args or {}, result.content, result.is_error) - except Exception: - pass + output = await tool.handler(args or {}, self.ctx) + result = ToolResult(output) + except Exception as exc: # noqa: BLE001 + detail = "".join(traceback.format_exception_only(type(exc), exc)).strip() + result = ToolResult(f"Tool '{name}' raised: {detail}", is_error=True) + if self.ctx.tool_logger is not None: + try: + self.ctx.tool_logger(name, args or {}, result.content, result.is_error) + except Exception: + pass return result From 560ccd998a18d82dc6e97eefc1b3ca8676826899 Mon Sep 17 00:00:00 2001 From: rial1 Date: Sun, 19 Jul 2026 20:30:54 +0100 Subject: [PATCH 04/16] =?UTF-8?q?TG4.3:=20run=20lifecycle=20=E2=80=94=20fo?= =?UTF-8?q?rce-stop,=20overall=20timeout,=20bounded=20queue=20(REL-6/7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dashboard's agent_run discarded the runner task ref (GC risk, no cancel handle), cleared agent_active only in finally so a hung run wedged the dashboard forever (409 on every new run), had no overall wall-clock timeout (a trickling target hung indefinitely), and an unbounded SSE queue. Fix (preserving the correct detach-drains behavior the audit praised): - Retain the runner task: agent_task strong ref; cleared in runner() finally and /api/agent/stop. No more GC-mid-flight risk. - POST /api/agent/stop: idempotent force-stop. Returns {stopped: false} (200) when nothing is running (never errors); {stopped: true} when it cancels a live task, awaits its finally (5s grace) so a new run can start immediately. - Overall wall-clock deadline: asyncio.wait_for around run_autonomous. Generous + configurable: body run_timeout_s, else max_rounds*180s floored 300s / capped 7200s — never kills a legitimate long run, recovers a wedged one. On TimeoutError emits a terminal 'timeout' SSE event BEFORE closing. - Bounded SSE queue (maxsize=1024) with drop-oldest on overflow — NEVER blocks the producer: a slow/disconnected client must not stall the run. The original stream_attached no-op (run completes server-side regardless of client) is preserved; REL-6 bounds memory, it does NOT kill runs on disconnect. - Every exit path (normal, exception, wait_for timeout, force-stop CancelledError) lands in the runner's finally, clearing agent_active / agent_task / agent_control. CancelledError is caught + emitted as a terminal 'stopped' event (not re-raised) so the stop endpoint's await returns cleanly. - AGENTS.md: recorded the provider-lifecycle invariant (the one debt AD-12 introduces) — tools must not cache build_provider() across execute() calls. Verification: - tests/test_audit_remediation.py +3: a trickling target trips the timeout (terminal 'timeout' event); /api/agent/stop cancels a wedged run then a new run starts (idempotent stop returns {stopped:false} when idle); a client disconnect lets the run finish server-side (regression guard for the detach-drains behavior). 43/43 pass. - tests/test_dashboard.py: 14/14 pass (existing agent_run + settings unchanged — run_timeout_s is a body field, not persisted into the settings view). - Full suite: 1105 passed (was 1102 -> +3), 31 failed / 7 errors unchanged (pre-existing corpus-dependent). --- AGENTS.md | 9 ++ tests/test_audit_remediation.py | 197 ++++++++++++++++++++++++++++++++ wallbreaker/dashboard/server.py | 123 +++++++++++++++++--- 3 files changed, 311 insertions(+), 18 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8935dd1..7d51b61 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,6 +12,15 @@ Red-team harness: configurable agentic LLM terminal with Parseltongue + L1B3RT4S with `lossy` flags. ## Lessons Learned +- **[provider-lifecycle]**: tools MUST NOT cache a `build_provider()` result for reuse across + separate `ToolRegistry.execute()` calls. `ToolRegistry.execute` wraps each call in + `providers.provider_scope()`, which tracks every `build_provider()` made during the call and + `aclose()`s them at the call boundary (audit REL-2). A provider you stash on `ctx`/`self`/a + module global for reuse on a *later* call will be closed out from under you → use-after-close. + Rebuild per call (as `continue_target` already does), or, for a genuinely long-lived provider + like the dashboard brain, build it *outside* `reg.execute` (where the bucket is None and the + scope won't touch it) and own its `aclose()` yourself. This invariant is the only debt the + REL-2 chokepoint design introduces (AD-12); violating it rediscovers the leak as a bug. - **[cli]**: a function-local `import X` anywhere in `main()` makes `X` a LOCAL name across the ENTIRE function (Python binds locals per-function at compile time), so any OTHER branch that uses `X` before that import runs raises `UnboundLocalError` — even with a module-level `import X` diff --git a/tests/test_audit_remediation.py b/tests/test_audit_remediation.py index 45199bb..0876fc9 100644 --- a/tests/test_audit_remediation.py +++ b/tests/test_audit_remediation.py @@ -6,14 +6,20 @@ """ from __future__ import annotations +import asyncio +import json import os from pathlib import Path from types import SimpleNamespace import pytest +from fastapi.testclient import TestClient from hypothesis import HealthCheck, given, settings, strategies as st from wallbreaker import state +from wallbreaker.config import Endpoint +from wallbreaker.dashboard.server import create_app +from wallbreaker.providers.base import Provider from wallbreaker.session import redact_args from wallbreaker.tools import egress_guard as eg from wallbreaker.tools import tool_policy @@ -435,3 +441,194 @@ async def run(): assert second._client is None, "aclose must close the active brain provider" asyncio.run(run()) + + +# ------------------------------------------------------------------------- REL-6/7 run lifecycle (TG4.3) +# The problems: the runner task ref was discarded (GC risk, no cancel handle); agent_active +# cleared only in finally so a hung run wedged the dashboard forever (409 on every new run); +# no overall wall-clock timeout so a trickling target hung indefinitely; the SSE queue was +# unbounded. The original design's correct behavior — a run keeps draining server-side after +# the client disconnects — must be preserved (the audit praised it; REL-6 only bounds memory). + +def _agent_run_app(monkeypatch, tmp_path, provider_obj, *, run_timeout_s=None): + """Wire an app whose dashboard agent_run uses `provider_obj` as the brain, and return the + TestClient + the sessions dir so tests can inspect the run log.""" + import wallbreaker.providers.factory as factory_mod + import wallbreaker.tools as tools_mod + from wallbreaker.config import Config, Endpoint + from wallbreaker.providers.base import Provider + from wallbreaker.tools.registry import ToolContext, ToolRegistry + + sessions = tmp_path / "sessions" + sessions.mkdir() + attacker = Endpoint("attacker", "openai", "http://attacker", "attack-model") + target = Endpoint("target", "openai", "http://target", "target-model") + cfg = Config( + default_profile="attacker", profiles={"attacker": attacker}, + target=target, path=tmp_path / "config.toml", + ) + registry = ToolRegistry(ToolContext(config=cfg)) + monkeypatch.setattr(factory_mod, "build_provider", lambda _endpoint: provider_obj) + monkeypatch.setattr(tools_mod, "build_registry", lambda _config: registry) + app = create_app(config=cfg, sessions_dir=sessions) + client = TestClient(app) + return client, sessions, app + + +class _TricklingProvider(Provider): + """Never yields a StopEvent — the round (and thus the run) hangs until the overall + wall-clock timeout cancels it. Models the REL-7 trickling-target threat.""" + + def __init__(self, endpoint): + super().__init__(endpoint) + + async def stream(self, messages, tools=None, system=None, max_tokens=4096, temperature=None): + # Yield one tiny delta then hang forever — so we don't return before the timeout + # fires, and the run genuinely can't complete on its own. + from wallbreaker.agent.messages import TextDelta + yield TextDelta("thinking") + await asyncio.Event().wait() # never set + + +def test_overall_timeout_trips_on_a_trickling_target(monkeypatch, tmp_path): + """TG4.9: a target that never returns (trickle/hang) trips the overall wall-clock timeout + instead of hanging the run forever; a terminal 'timeout' SSE event is emitted.""" + provider = _TricklingProvider(Endpoint("attacker", "openai", "http://attacker", "attack-model")) + body = {"objective": "go", "max_rounds": 5, "run_timeout_s": 1} # 1s deadline + client, sessions, _app = _agent_run_app(monkeypatch, tmp_path, provider, run_timeout_s=1) + + with client.stream("POST", "/api/agent/run", json=body) as response: + assert response.status_code == 200 + stream_text = "".join(response.iter_text()) + + assert '"status": "timeout"' in stream_text, "must emit a terminal timeout SSE event" + # The run must have ended cleanly (agent_active cleared) — a new run can start (409 gone). + r2 = client.post("/api/agent/run", json={"objective": "again", "max_rounds": 1, "run_timeout_s": 1}) + # 409 means still active (wedged) — must NOT happen; a stream response is 200. + assert r2.status_code != 409, "agent_active must be cleared after a timed-out run" + + +def test_force_stop_ends_a_wedged_run_and_new_run_can_start(monkeypatch, tmp_path): + """TG4.9: /api/agent/stop cancels a wedged/hung run; agent_active clears; a new run can + start immediately after. The stop endpoint is idempotent (200 {stopped:false} when idle). + + Drives the ASGI app with httpx.AsyncClient + ASGITransport on ONE event loop so the + background runner task and the stop request genuinely share state (Starlette's + synchronous TestClient runs each request on a fresh loop, so agent_active set by one + request is invisible to another). A controllable provider blocks the run on an Event + until the test releases it, so the stop request lands while the run is genuinely active.""" + import httpx + + release = asyncio.Event() + + class _BlockedProvider(Provider): + async def stream(self, messages, tools=None, system=None, max_tokens=4096, temperature=None): + from wallbreaker.agent.messages import TextDelta + yield TextDelta("thinking") + await release.wait() # held until the test releases (or the run is cancelled) + + provider = _BlockedProvider(Endpoint("attacker", "openai", "http://attacker", "attack-model")) + client_sync, sessions, app = _agent_run_app(monkeypatch, tmp_path, provider) + + async def scenario(): + import httpx + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as ac: + # Run a slow stream consumer and the stop request concurrently on this one loop. + # The consumer iterates the SSE body slowly (one chunk then awaits), which drives the + # StreamingResponse body generator and lets the background runner task actually + # start. The stop task waits a beat for the runner to set agent_active=True, then + # cancels it. Both share the loop so agent_active is visible to the stop request. + stopped_result = {} + run_done = asyncio.Event() + + async def consume_run(): + async with ac.stream("POST", "/api/agent/run", + json={"objective": "go", "max_rounds": 5, "run_timeout_s": 600}) as r: + assert r.status_code == 200 + # Pull exactly one chunk so the body generator runs and the runner starts, + # then hold the stream open (don't fully drain) until stop releases us. + got_one = False + async for chunk in r.aiter_raw(): + got_one = True + break + assert got_one, "the run stream must emit at least one chunk" + await run_done.wait() + # The stream was cancelled; just exit — no second iteration. + + async def do_stop(): + await asyncio.sleep(0.1) # let the runner start + set agent_active + stop = await ac.post("/api/agent/stop") + stopped_result["status"] = stop.status_code + stopped_result["body"] = stop.json() + release.set() # unblock the provider so the cancelled runner exits promptly + run_done.set() + + await asyncio.gather(consume_run(), do_stop()) + assert stopped_result.get("status") == 200 + assert stopped_result.get("body") == {"stopped": True}, "a running task must be stopped" + + # Idempotent: stopping again when nothing is running returns {stopped: false}. + stop2 = await ac.post("/api/agent/stop") + assert stop2.status_code == 200 + assert stop2.json() == {"stopped": False} + + # A new run can start immediately (agent_active cleared). Swap to a quick brain. + from wallbreaker.agent.messages import StopEvent, TextDelta, ToolUseEvent + import wallbreaker.providers.factory as factory_mod + + class _FinishQuickly(Provider): + async def stream(self, messages, tools=None, system=None, max_tokens=4096, temperature=None): + yield TextDelta("done") + yield ToolUseEvent("f-1", "finish", {"summary": "ok"}) + yield StopEvent("tool_use") + monkeypatch.setattr(factory_mod, "build_provider", lambda _e: _FinishQuickly(Endpoint("a", "openai", "http://x", "m"))) + async with ac.stream("POST", "/api/agent/run", + json={"objective": "again", "max_rounds": 1, "run_timeout_s": 30}) as r2: + assert r2.status_code == 200 + body = "".join([seg async for seg in r2.aiter_text()]) + assert '"type": "done"' in body + + asyncio.run(scenario()) + + +def test_client_disconnect_lets_run_finish_server_side(monkeypatch, tmp_path): + """TG4.9 regression guard (PM directive): a client that disconnects mid-stream must NOT + abandon the inference — the run completes server-side (its run log records agent_done), + preserving the correct behavior the original audit praised. REL-6 bounds memory, it does + NOT kill runs on disconnect.""" + from wallbreaker.agent.messages import StopEvent, TextDelta, ToolUseEvent + + class _FinishesAfterAStream(Provider): + async def stream(self, messages, tools=None, system=None, max_tokens=4096, temperature=None): + yield TextDelta("working") + yield ToolUseEvent("f-1", "finish", {"summary": "completed server-side"}) + yield StopEvent("tool_use") + + provider = _FinishesAfterAStream(Endpoint("attacker", "openai", "http://attacker", "attack-model")) + client, sessions, _app = _agent_run_app(monkeypatch, tmp_path, provider) + + # Open the stream, read only the first event, then close (disconnect) — don't drain it. + stream_ctx = client.stream("POST", "/api/agent/run", json={"objective": "go", "max_rounds": 1}) + response = stream_ctx.__enter__() + assert response.status_code == 200 + # Consume one SSE frame then drop the connection. + _ = next(response.iter_lines(), None) + stream_ctx.__exit__(None, None, None) # disconnect + + # The runner kept going server-side; its run log must record a completed run. Poll briefly + # (the runner is an async task; TestClient runs the event loop on each request). + import time + log = None + deadline = time.monotonic() + 5.0 + while time.monotonic() < deadline and log is None: + logs = list(sessions.glob("run-*.jsonl")) + if logs: + log = logs[0] + else: # nudge the loop with a cheap request so the runner task can progress + client.get("/api/health") + assert log is not None, "a run log must have been created" + records = [json.loads(line) for line in log.read_text(encoding="utf-8").splitlines() if line.strip()] + statuses = [r.get("status") for r in records if r.get("kind") == "agent_done"] + assert statuses, "the run must have completed server-side despite the client disconnect" + assert statuses[0] in ("finished", "stopped", "timeout", "max_rounds", "error") diff --git a/wallbreaker/dashboard/server.py b/wallbreaker/dashboard/server.py index a6d72b4..3c2a679 100644 --- a/wallbreaker/dashboard/server.py +++ b/wallbreaker/dashboard/server.py @@ -1234,6 +1234,7 @@ def tools(): dashboard_inference_lock = asyncio.Lock() agent_active = False + agent_task: asyncio.Task | None = None # retained strong ref so the runner isn't GC'd mid-run (REL-6); cleared in runner() finally and /api/agent/stop. agent_control = None @app.post("/api/compose") @@ -1421,7 +1422,7 @@ async def agent_attacker_switch(body: dict): @app.post("/api/agent/run") async def agent_run(body: dict): - nonlocal agent_active, agent_control + nonlocal agent_active, agent_control, agent_task from fastapi.responses import StreamingResponse if config is None: @@ -1457,6 +1458,17 @@ async def agent_run(body: dict): configure_request_gate(concurrency, request_delay_ms) + # REL-7: overall wall-clock deadline so a trickling/hung target can't keep a round (and + # thus the run) alive forever. Generous and overridable: operator can pass run_timeout_s + # in the body; else default to max_rounds * 180s (3 min/round) floored at 300s, capped at + # 7200s — long enough to never kill a legitimate long run, short enough to recover a + # wedged one. A timed-out run emits a terminal SSE event and clears state in finally. + _requested_timeout = body.get("run_timeout_s") + if _requested_timeout is not None: + overall_timeout = float(_int_setting(_requested_timeout, 1800, 30, 72000)) + else: + overall_timeout = min(7200.0, max(300.0, max_rounds * 180.0)) + from ..agent.loop import AgentEvents, run_autonomous from ..agent.messages import user from ..prompts import compose_system @@ -1501,7 +1513,12 @@ async def agent_run(body: dict): "enabled_techniques": enabled_techniques, }, ) - queue: asyncio.Queue = asyncio.Queue() + # REL-6: bound the pre-detach SSE queue so a slow consumer can't grow memory forever. + # Drop-oldest on overflow (never block the producer): a disconnected/slow client must not + # stall the agent loop — the run completes server-side regardless of the client (the + # original `stream_attached` no-op is preserved below). The terminal None sentinel always + # gets through because an attached consumer drains the queue. + queue: asyncio.Queue = asyncio.Queue(maxsize=1024) stream_attached = True def push(ev) -> None: @@ -1509,8 +1526,18 @@ def push(ev) -> None: return try: queue.put_nowait(ev) - except Exception: - pass + except asyncio.QueueFull: + # Drop the oldest event to make room (never block the producer / never stall the + # run). A slow-but-attached client loses intermediate transcript but keeps the + # latest; a disconnected client already short-circuits above. + try: + queue.get_nowait() + except asyncio.QueueEmpty: + pass + try: + queue.put_nowait(ev) + except asyncio.QueueFull: + pass # still full after one drop (rare): drop the new event rather than block def progress(message) -> None: text = str(message) @@ -1613,25 +1640,48 @@ async def pause_checkpoint() -> None: agent_control["pause_ready"] = False async def runner(): - nonlocal agent_active, agent_control + nonlocal agent_active, agent_control, agent_task from ..session import inference_logging async with dashboard_inference_lock: try: with inference_logging(runlog): - res = await run_autonomous( - provider, registry, history, system=compose_system(brain), - events=events, max_rounds=max_rounds, max_tokens=max_tokens, - feedback=drain_feedback, - before_model=pause_checkpoint, - ) - data = res.data or {} - summary = data.get("summary") or data.get("question") or "" - runlog.event("agent_done", status=res.status, summary=summary) - push({ - "type": "done", "status": res.status, - "summary": summary, "run_log": runlog.path.name, - }) + try: + res = await asyncio.wait_for( + run_autonomous( + provider, registry, history, system=compose_system(brain), + events=events, max_rounds=max_rounds, max_tokens=max_tokens, + feedback=drain_feedback, before_model=pause_checkpoint, + ), + timeout=overall_timeout, + ) + except asyncio.TimeoutError: + # REL-7: emit a terminal event BEFORE closing the stream so the client + # doesn't hang on a timed-out run. + runlog.event("agent_done", status="timeout", summary="run timed out") + push({ + "type": "done", "status": "timeout", + "summary": "run timed out", "run_log": runlog.path.name, + }) + res = None + except asyncio.CancelledError: + # /api/agent/stop cancelled this task: emit a terminal event, let the + # finally clear state. Don't re-raise: we want the task to complete + # cleanly so the stop endpoint's await returns normally. + runlog.event("agent_done", status="stopped", summary="run stopped") + push({ + "type": "done", "status": "stopped", + "summary": "run stopped", "run_log": runlog.path.name, + }) + res = None + else: + data = res.data or {} + summary = data.get("summary") or data.get("question") or "" + runlog.event("agent_done", status=res.status, summary=summary) + push({ + "type": "done", "status": res.status, + "summary": summary, "run_log": runlog.path.name, + }) except Exception as exc: # noqa: BLE001 error_event(f"{type(exc).__name__}: {exc}") finally: @@ -1642,13 +1692,18 @@ async def runner(): await provider.aclose() except Exception: pass + # REL-6: every exit path (normal, exception, wait_for timeout, force-stop + # cancellation) lands here — clear agent_active + the task ref + control so a + # wedged run can't wedge the dashboard forever and a new run can start. agent_active = False + agent_task = None resume_event.set() agent_control = None push(None) agent_active = True task = asyncio.create_task(runner()) + agent_task = task # REL-6: retain a strong ref so the runner isn't GC'd mid-flight and /api/agent/stop can cancel it. async def gen(): nonlocal stream_attached @@ -1672,6 +1727,38 @@ async def gen(): return StreamingResponse(gen(), media_type="text/event-stream", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}) + @app.post("/api/agent/stop") + async def agent_stop(): + """Force-stop a wedged/hung agent run (REL-6). Idempotent: returns {stopped: false} (200) + when nothing is running, never errors. When a run is active, cancels the retained task + and waits for its finally to clear agent_active / agent_task / agent_control so a new run + can start immediately after. Auth + CSRF already enforced by SecurityMiddleware.""" + nonlocal agent_active, agent_control, agent_task + if not agent_active or agent_task is None: + # Defensive: ensure state is clean even if the runner already finished but somehow + # left a flag set (e.g. a prior crash). Idempotent — never error on a no-op stop. + agent_active = False + agent_control = None + agent_task = None + return {"stopped": False} + task = agent_task + try: + task.cancel() + # Let the runner's finally run (it clears agent_active/agent_task/agent_control and + # pushes the terminal None). Await with a short grace so the endpoint returns after + # the run is actually stopped, not before. + try: + await asyncio.wait_for(task, timeout=5.0) + except (asyncio.TimeoutError, asyncio.CancelledError, Exception): + pass + finally: + # Belt-and-suspenders: the runner's finally should have done this, but make the + # endpoint's contract independent of the runner's exit path. + agent_active = False + agent_control = None + agent_task = None + return {"stopped": True} + dist = _web_dist(web_dir) if dist is not None: app.mount("/", StaticFiles(directory=str(dist), html=True), name="web") From d0e5144b77ca018f5d7039425ca0157ffd292489 Mon Sep 17 00:00:00 2001 From: rial1 Date: Sun, 19 Jul 2026 21:17:39 +0100 Subject: [PATCH 05/16] TG5.3+5.4+5.5: cache deltas+compaction, request_gate notify, RunLog lock (RACE-2/3/4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Batch of the TG5 concurrency-integrity fixes (specs/audit-remediation tasks.md). TG5.3 — RACE-2 ResultCache (wallbreaker/cache.py): - Versioned the cache format. v1 (legacy) = cumulative snapshots per line; v2 = single- sample deltas (one line per put). The loader is tolerant of BOTH: it keeps the last v1 snapshot per key and adds v2 deltas on top, so an existing v1 file migrates correctly and new v2 deltas append without double-count (the #1 rework risk). Verified by a backward-compat test that hand-writes a legacy v1 file then appends v2 deltas. - Multi-process append is now safe without a lock: a sub-PIPE_BUF line appended in 'a' mode is atomic on POSIX, so interleaved writers no longer last-writer-wins undercount. - Compaction: once the file exceeds _COMPACTION_THRESHOLD lines, rewrite it as one cumulative line per key via the shared atomic_write helper (tmp+fsync+os.replace). Deltas are the source of truth; a crash mid-compaction loses nothing (temp abandoned). Bounded on-disk growth. TG5.5 — RACE-3 request_gate (wallbreaker/providers/request_gate.py): - notify_request_gates(): iterates every live gate on the running loop and notifies it WHILE HOLDING the Condition lock (notify_all outside the lock is a no-op/raises), so a raised concurrency limit promptly frees tasks parked at the OLD limit. No-op outside a loop or with no gates. Wired into the async agent_run site after configure_request_gate. (Per-run gate scoping deferred — the dashboard already enforces one agent run at a time via agent_active + dashboard_inference_lock, so the process-global gate's last-writer- -wins-across-concurrent-ops is largely moot in practice; the notify fix is what closes RACE-3 as reported.) TG5.4 — RACE-4 RunLog (wallbreaker/session.py): - RunLog._write now holds a threading.Lock across seq++ + append, and the 'no await inside _write' invariant is documented in-line. _write is synchronous today (line atomicity relies on that); the lock is a guard rail so a future refactor that adds an await can't silently interleave half-lines across coroutines. Closed (not deferred). Shared helper (wallbreaker/_fsutil.py): - Extracted atomic_write (temp+fsync+os.replace) to one place; state.py and cache.py both use it (no duplicated crash-safe write impl). Optional TG4.3 refinement folded in: an explicit run_timeout_s is honored as-is (no 7200s clamp) — only the DERIVED default is capped, so a deliberate multi-hour battery isn't silently truncated. Verification: - tests/test_audit_remediation.py +6: v2 deltas sum on load (incl. a cross-process append); v1 backward-compat (no double-count); compaction rewrites one line/key preserving totals; notify wakes parked tasks on a limit raise (without a release); notify is a no-op outside a loop; RunLog concurrent writes stay line-atomic + seq- monotonic + unique. 49/49 pass. - touched-module tests (cache/request_gate/state/session/dashboard/logging/ session_load_runlog): 58 passed. - Full suite: 1111 passed (was 1105 -> +6), 31 failed / 7 errors unchanged (pre-existing corpus-dependent). No regressions. --- tests/test_audit_remediation.py | 176 ++++++++++++++++++++++++++ wallbreaker/_fsutil.py | 35 +++++ wallbreaker/cache.py | 100 +++++++++++++-- wallbreaker/dashboard/server.py | 19 ++- wallbreaker/providers/request_gate.py | 33 ++++- wallbreaker/session.py | 34 +++-- wallbreaker/state.py | 25 +--- 7 files changed, 373 insertions(+), 49 deletions(-) create mode 100644 wallbreaker/_fsutil.py diff --git a/tests/test_audit_remediation.py b/tests/test_audit_remediation.py index 0876fc9..f088621 100644 --- a/tests/test_audit_remediation.py +++ b/tests/test_audit_remediation.py @@ -632,3 +632,179 @@ async def stream(self, messages, tools=None, system=None, max_tokens=4096, tempe statuses = [r.get("status") for r in records if r.get("kind") == "agent_done"] assert statuses, "the run must have completed server-side despite the client disconnect" assert statuses[0] in ("finished", "stopped", "timeout", "max_rounds", "error") + + +# ------------------------------------------------------------------------- RACE-2 ResultCache deltas + compaction (TG5.3) + +def test_cache_v2_deltas_sum_on_load(tmp_path): + """TG5.8: a fresh instance replaying a v2-delta file sums the deltas (not last-writer-wins), + so multi-process interleaved writes no longer undercount.""" + import json as _json + from wallbreaker.cache import ResultCache, _FORMAT_VERSION + + c = ResultCache(str(tmp_path)) + c.put("k", "COMPLIED", "r1") + c.put("k", "REFUSED", "r2") + c.put("k", "PARTIAL", "r3") + c.put("k", "COMPLIED", "r4") + del c + # Simulate a second process: append a v2 delta for the SAME key directly to the file + # (the multi-process case the lock-free RMW used to lose). + with open(str(tmp_path / "wb_runs" / "result_cache.jsonl"), "a", encoding="utf-8") as fh: + fh.write(_json.dumps({ + "key": "k", "v": _FORMAT_VERSION, "ds": 1, "dbucket": "complied", + "last_response": "r5-from-other-process", "last_label": "COMPLIED", + }) + "\n") + reloaded = ResultCache(str(tmp_path)) + e = reloaded.get("k") + assert e["samples"] == 5, "v2 deltas must SUM (4 in-process + 1 cross-process), not last-writer-wins" + assert e["complied"] == 3 # r1, r4, r5 + assert e["refused"] == 1 + assert e["partial"] == 1 + assert e["last_response"] == "r5-from-other-process" + + +def test_cache_backward_compat_with_legacy_v1_cumulative_file(tmp_path): + """An existing v1 (cumulative-snapshot) cache file must keep loading correctly after the + TG5.3 delta change, and new v2 deltas append on top of it without double-counting (the #1 + rework risk the PM flagged).""" + import json as _json + from wallbreaker.cache import ResultCache, _FORMAT_VERSION + + # Hand-write a legacy v1 file: two cumulative snapshots for key 'k' (last one wins). + path = tmp_path / "wb_runs" / "result_cache.jsonl" + path.parent.mkdir(parents=True) + path.write_text( + _json.dumps({"key": "k", "samples": 1, "complied": 1, "partial": 0, "refused": 0, + "last_response": "old1", "last_label": "COMPLIED"}) + "\n" + + _json.dumps({"key": "k", "samples": 2, "complied": 1, "partial": 0, "refused": 1, + "last_response": "old2", "last_label": "REFUSED"}) + "\n", + encoding="utf-8", + ) + c = ResultCache(str(tmp_path)) + e = c.get("k") + assert e["samples"] == 2 and e["complied"] == 1 and e["refused"] == 1, "legacy v1 last-snapshot loads" + assert e["last_label"] == "REFUSED" + # A new put appends a v2 delta on top of the v1 snapshot — sums, not replaces. + c.put("k", "COMPLIED", "new3") + del c + reloaded = ResultCache(str(tmp_path)) + e2 = reloaded.get("k") + assert e2["samples"] == 3, "v1 snapshot (2) + v2 delta (1) = 3, no double-count" + assert e2["complied"] == 2 and e2["refused"] == 1 + assert e2["last_response"] == "new3" + + +def test_cache_compaction_rewrites_as_one_line_per_key_and_preserves_totals(tmp_path): + """TG5.3: once the file exceeds the compaction threshold it is rewritten (atomic) as one + cumulative line per key, bounding on-disk growth without losing totals.""" + import json as _json + from wallbreaker.cache import ResultCache + import wallbreaker.cache as cache_mod + + c = ResultCache(str(tmp_path)) + # Force the threshold low so compaction triggers after a few puts. + orig = cache_mod._COMPACTION_THRESHOLD + cache_mod._COMPACTION_THRESHOLD = 5 + try: + for i in range(8): + c.put(f"key{i}", "COMPLIED", f"r{i}") + finally: + cache_mod._COMPACTION_THRESHOLD = orig + + # After compaction the file holds one line per key (8 keys), and totals survive reload. + lines = [ln for ln in (tmp_path / "wb_runs" / "result_cache.jsonl").read_text().splitlines() if ln.strip()] + assert len(lines) == 8, f"compacted to one line per key; got {len(lines)}" + reloaded = ResultCache(str(tmp_path)) + assert reloaded.get("key0")["samples"] == 1 and reloaded.get("key7")["samples"] == 1 + assert reloaded.get("key3")["last_response"] == "r3" + + +# ------------------------------------------------------------------------- RACE-3 request_gate notify on raise (TG5.5) + +def test_request_gate_notify_wakes_parked_tasks_on_limit_raise(): + """TG5.9: a task parked at the OLD limit (configure_request_gate(1,0) then 2 concurrent + acquires) must proceed promptly when the limit is RAISED and notify_request_gates() runs, + instead of staying blocked until an unrelated release().""" + import asyncio + from wallbreaker.providers.request_gate import ( + configure_request_gate, notify_request_gates, provider_request_slot, + ) + + configure_request_gate(1, 0) # only one slot + woke = asyncio.Event() + started = asyncio.Event() + + async def first(): + async with provider_request_slot(_endpoint()): + started.set() + await woke.wait() # hold the single slot until released + + async def parked(): + # Blocks at the limit until either a release or a notify-on-raise. + async with provider_request_slot(_endpoint()): + return True + + async def run(): + t1 = asyncio.create_task(first()) + await started.wait() + t2 = asyncio.create_task(parked()) + # Give t2 a moment to park in acquire's condition.wait(). + await asyncio.sleep(0.05) + assert not t2.done(), "parked task should be blocked at the limit" + # Raise the limit and notify — t2 must proceed WITHOUT releasing t1. + configure_request_gate(2, 0) + await notify_request_gates() + await asyncio.wait_for(t2, timeout=2.0) + assert t2.done() and t2.result() is True, "parked task woke after the notify-on-raise" + woke.set() + await t1 + + asyncio.run(run()) + + +def test_request_gate_notify_is_noop_outside_loop_or_with_no_gates(): + """notify_request_gates must be safe to call with no running loop or no gates (no-op).""" + import asyncio + from wallbreaker.providers.request_gate import notify_request_gates + # No running loop. + asyncio.run(notify_request_gates()) # must not raise + + +def _endpoint(*, base_url="https://api.example/v1", key="shared"): + from wallbreaker.config import Endpoint + return Endpoint("one", "openai", base_url, "model", api_key=key) + + +# ------------------------------------------------------------------------- RACE-4 RunLog write serialization (TG5.4) + +def test_runlog_write_is_serialized_and_lines_stay_atomic(tmp_path): + """TG5.4: concurrent _write calls from coroutines do not interleave half-lines. The + _write_lock + the 'no await inside _write' invariant guarantee line atomicity; this test + fires many concurrent event() calls (each writes a line) and asserts every line is valid + JSON with a unique, gap-free seq.""" + import asyncio + from wallbreaker.session import RunLog + + log = RunLog(directory=str(tmp_path)) + N = 200 + + async def writer(i): + log.event("note", text=f"line-{i}") + + async def run(): + await asyncio.gather(*(writer(i) for i in range(N))) + + asyncio.run(run()) + import json as _json + records = [] + for line in (tmp_path / log.path.name).read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line: + continue + records.append(_json.loads(line)) # raises on a half-interleaved line + notes = [r for r in records if r.get("kind") == "note"] + seqs = [r["seq"] for r in notes] + assert len(notes) == N, f"all {N} note lines present; got {len(notes)}" + assert seqs == sorted(seqs), "seqs are monotonic (serialized, not interleaved)" + assert len(set(seqs)) == N, "every seq is unique" diff --git a/wallbreaker/_fsutil.py b/wallbreaker/_fsutil.py new file mode 100644 index 0000000..d07d3de --- /dev/null +++ b/wallbreaker/_fsutil.py @@ -0,0 +1,35 @@ +"""Shared filesystem helpers. + +The atomic write helper lives here so state, cache, and any future writer share one +crash-safe implementation (audit REL-3/RACE-2). Write via a temp file + fsync + os.replace +so a concurrent reader (or a crash) never sees a truncated or half-written file. +""" +from __future__ import annotations + +import os +import tempfile +from pathlib import Path + + +def atomic_write(path: str | Path, text: str) -> None: + """Write ``text`` to ``path`` atomically: temp file + fsync + os.replace. + + A reader (or a crash) never sees a truncated or half-written file. The temp file is + created in the same directory (so os.replace is a same-filesystem rename) and unlinked + on any failure. ``path``'s parent is created if needed. + """ + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=".wb-", suffix=".tmp") + try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + handle.write(text) + handle.flush() + os.fsync(handle.fileno()) + os.replace(tmp, path) # atomic on POSIX and Windows + except BaseException: + try: + os.unlink(tmp) + except OSError: + pass + raise diff --git a/wallbreaker/cache.py b/wallbreaker/cache.py index 36b789b..a107538 100644 --- a/wallbreaker/cache.py +++ b/wallbreaker/cache.py @@ -4,6 +4,22 @@ import json import os +from ._fsutil import atomic_write + +# Cache file format versions (RACE-2). +# v1 (legacy, pre-TG5.3): each JSONL line is a CUMULATIVE snapshot of the running total +# for a key. Replay kept the last line per key. Multi-process interleaved writes could +# undercount (last writer wins a smaller total). +# v2 (TG5.3): each line is a DELTA (one sample). Replay sums deltas. A sub-PIPE_BUF line +# appended in "a" mode is atomic on POSIX, so multi-process append is safe without a lock. +# The loader is tolerant of both: it keeps the last v1 snapshot per key and adds v2 deltas on +# top, so existing v1 files keep working (migrated in place as new v2 deltas append). +_FORMAT_VERSION = 2 +# Compact the cache file to one cumulative line per key once it grows past this many lines +# (bounded growth, RACE-2). Compaction is a derived optimization: the append-only deltas are +# the source of truth, and a crash mid-compaction loses nothing (the temp file is abandoned). +_COMPACTION_THRESHOLD = 5000 + def _serialize_messages(messages) -> list: out = [] @@ -62,12 +78,23 @@ def __init__(self, cwd: str = "."): self._load() def _load(self) -> None: + """Replay the cache file. Tolerant of both formats (RACE-2): + - v1 lines (no ``v`` field, or ``v == 1``): cumulative snapshots — keep the LAST one + per key (legacy behaviour). + - v2 lines (``v == 2``): deltas — sum them per key. + A key's final entry = (last v1 snapshot) + (sum of v2 deltas). An old file with new + v2 deltas appended therefore migrates correctly (no double-count).""" + # Per-key: the last v1 snapshot seen, and the running sum of v2 deltas. + snapshots: dict[str, dict] = {} + deltas: dict[str, dict] = {} + line_count = 0 try: with open(self.path, encoding="utf-8") as fh: for line in fh: line = line.strip() if not line: continue + line_count += 1 try: rec = json.loads(line) except (ValueError, TypeError): @@ -75,14 +102,41 @@ def _load(self) -> None: key = rec.get("key") if not key: continue - entry = _blank() - for field in ("samples", "complied", "partial", "refused"): - entry[field] = int(rec.get(field, 0) or 0) - entry["last_response"] = str(rec.get("last_response", "") or "") - entry["last_label"] = str(rec.get("last_label", "") or "") - self._index[key] = entry + if rec.get("v") == _FORMAT_VERSION: + # v2 delta: +1 sample, +1 in the bucket this put landed in. + d = deltas.setdefault(key, {"samples": 0, "complied": 0, "partial": 0, "refused": 0}) + d["samples"] += int(rec.get("ds", 0) or 0) + bucket = _norm_label(rec.get("dbucket")) + if bucket in ("complied", "partial", "refused"): + d[bucket] += 1 + # Track the most-recent last_response/last_label from deltas too. + deltas[key]["last_response"] = str(rec.get("last_response", "") or "") + deltas[key]["last_label"] = str(rec.get("last_label", "") or "") + else: + # v1 (legacy) cumulative snapshot — last one wins for this key. + entry = _blank() + for field in ("samples", "complied", "partial", "refused"): + entry[field] = int(rec.get(field, 0) or 0) + entry["last_response"] = str(rec.get("last_response", "") or "") + entry["last_label"] = str(rec.get("last_label", "") or "") + snapshots[key] = entry except OSError: - pass + return + # Merge: final = snapshot + deltas. + keys = set(snapshots) | set(deltas) + for key in keys: + entry = dict(snapshots.get(key) or _blank()) + d = deltas.get(key) + if d: + entry["samples"] = int(entry.get("samples", 0)) + d["samples"] + entry["complied"] = int(entry.get("complied", 0)) + d["complied"] + entry["partial"] = int(entry.get("partial", 0)) + d["partial"] + entry["refused"] = int(entry.get("refused", 0)) + d["refused"] + if d.get("last_response") or d.get("last_label"): + entry["last_response"] = d["last_response"] + entry["last_label"] = d["last_label"] + self._index[key] = entry + self._line_count = line_count @staticmethod def make_key( @@ -114,14 +168,40 @@ def put(self, key: str, label: str, response: str) -> dict: entry["last_response"] = response or "" entry["last_label"] = str(label or "") self._index[key] = entry - self._append(key, entry) + self._append_delta(key, label, response) return dict(entry) - def _append(self, key: str, entry: dict) -> None: + def _append_delta(self, key: str, label: str, response: str) -> None: + """Append a single v2 delta line (one sample). POSIX append of a small line is atomic, + so multi-process append is safe without a lock (RACE-2).""" try: os.makedirs(os.path.dirname(self.path), exist_ok=True) - rec = {"key": key, **entry} + rec = { + "key": key, "v": _FORMAT_VERSION, "ds": 1, + "dbucket": _norm_label(label), + "last_response": response or "", "last_label": str(label or ""), + } with open(self.path, "a", encoding="utf-8") as fh: fh.write(json.dumps(rec, ensure_ascii=False) + "\n") + self._line_count = getattr(self, "_line_count", 0) + 1 + if self._line_count >= _COMPACTION_THRESHOLD: + self._compact() + except OSError: + pass + + def _compact(self) -> None: + """Rewrite the cache as one cumulative line per key (v1 snapshot form) via the atomic + write helper, bounding on-disk growth (RACE-2). The append-only deltas are the source + of truth; a crash mid-compaction loses nothing (the temp file is abandoned and the + original is untouched until os.replace). A concurrent append that lands during + compaction writes to the now-unlinked old inode and is an acknowledged rare, low- + severity loss (one cached sample — the cache is not the system of record).""" + try: + lines = [] + for key, entry in self._index.items(): + lines.append(json.dumps({"key": key, **entry}, ensure_ascii=False)) + text = "\n".join(lines) + ("\n" if lines else "") + atomic_write(self.path, text) + self._line_count = len(lines) except OSError: pass diff --git a/wallbreaker/dashboard/server.py b/wallbreaker/dashboard/server.py index 3c2a679..16f279b 100644 --- a/wallbreaker/dashboard/server.py +++ b/wallbreaker/dashboard/server.py @@ -801,6 +801,9 @@ def create_app(config=None, sessions_dir: str | Path = "sessions", web_dir: str gate = _agent_settings(prefs) configure_request_gate(gate["concurrency"], gate["request_delay_ms"]) + # No notify_request_gates() here: this runs in the sync create_app body at startup, + # before any event loop / parked tasks exist. The async handlers (settings POST, + # agent_run) call notify after reconfiguring mid-flight (RACE-3). except Exception: pass app = FastAPI(title="Wallbreaker", version="0.1.0") @@ -1092,6 +1095,9 @@ def settings_post(body: dict): gate = _agent_settings(prefs) configure_request_gate(gate["concurrency"], gate["request_delay_ms"]) + # RACE-3 notify is best-effort here: settings_post is a SYNC handler, so no await is + # possible. In practice a parked agent run holds dashboard_inference_lock, so this sync + # settings POST can't race with it; the authoritative notify is in async agent_run. return _settings_view(config, prefs) @app.get("/api/overview") @@ -1454,18 +1460,19 @@ async def agent_run(body: dict): request_delay_ms = _int_setting( body.get("request_delay_ms"), agent_defaults["request_delay_ms"], 0, 60000 ) - from ..providers.request_gate import configure_request_gate + from ..providers.request_gate import configure_request_gate, notify_request_gates configure_request_gate(concurrency, request_delay_ms) + await notify_request_gates() # RACE-3: wake tasks parked at the old limit (no-op before the run starts) # REL-7: overall wall-clock deadline so a trickling/hung target can't keep a round (and - # thus the run) alive forever. Generous and overridable: operator can pass run_timeout_s - # in the body; else default to max_rounds * 180s (3 min/round) floored at 300s, capped at - # 7200s — long enough to never kill a legitimate long run, short enough to recover a - # wedged one. A timed-out run emits a terminal SSE event and clears state in finally. + # thus the run) alive forever. Generous and overridable: an operator-provided + # run_timeout_s is honored as-is (a deliberate multi-hour battery shouldn't be silently + # clamped); the DERIVED default is capped at 7200s so an unconfigured run can still hang + # itself. Floored at 30s everywhere so a typo can't make it instant. _requested_timeout = body.get("run_timeout_s") if _requested_timeout is not None: - overall_timeout = float(_int_setting(_requested_timeout, 1800, 30, 72000)) + overall_timeout = float(_int_setting(_requested_timeout, 1800, 30, 7_200_000)) else: overall_timeout = min(7200.0, max(300.0, max_rounds * 180.0)) diff --git a/wallbreaker/providers/request_gate.py b/wallbreaker/providers/request_gate.py index e39f827..f9e1cc2 100644 --- a/wallbreaker/providers/request_gate.py +++ b/wallbreaker/providers/request_gate.py @@ -48,13 +48,44 @@ async def release(self) -> None: def configure_request_gate(concurrency: int = 3, delay_ms: int = 250) -> dict[str, int]: - """Set process-wide inference pacing used by every provider protocol.""" + """Set process-wide inference pacing used by every provider protocol. + + Sync: only sets the globals (so tests and the no-parked-tasks startup path keep their + existing call shape). The RACE-3 fix — waking tasks parked at the OLD limit after a RAISE + — is ``notify_request_gates()``, which the async handlers call after this (it must hold + each gate's Condition lock to notify, which requires an async context). + """ global _MAX_CONCURRENCY, _REQUEST_DELAY_MS _MAX_CONCURRENCY = max(1, min(int(concurrency), 32)) _REQUEST_DELAY_MS = max(0, min(int(delay_ms), 60_000)) return request_gate_settings() +async def notify_request_gates() -> None: + """Wake every parked task after the concurrency limit changes (RACE-3). + + ``_RequestGate.acquire`` waits on ``while self.active >= _MAX_CONCURRENCY: await + condition.wait()``; raising the global never woke those waiters, so a parked task stayed + blocked until an unrelated ``release()``. This iterates every live gate on the running + loop and notifies it while holding its Condition lock (``notify_all`` outside the lock + is a no-op / raises on asyncio), so a raised limit promptly frees parked request slots. + Safe to call with no loop or no gates (no-op). + """ + try: + loop = asyncio.get_running_loop() + except RuntimeError: + return # called outside an event loop — nothing to wake + gates = _GATES.get(loop) + if not gates: + return + for gate in list(gates.values()): + try: + async with gate.condition: + gate.condition.notify_all() + except Exception: + pass # a gate being torn down concurrently — skip it + + def request_gate_settings() -> dict[str, int]: return {"concurrency": _MAX_CONCURRENCY, "request_delay_ms": _REQUEST_DELAY_MS} diff --git a/wallbreaker/session.py b/wallbreaker/session.py index e717989..5b3a5e5 100644 --- a/wallbreaker/session.py +++ b/wallbreaker/session.py @@ -2,6 +2,7 @@ import json import os +import threading from contextlib import contextmanager from contextvars import ContextVar from datetime import datetime @@ -320,6 +321,12 @@ def __init__(self, directory: str | Path = "sessions", enabled: bool = True): self.target_model = "" self.target_profile = "" self._target_written = False + # RACE-4: serialize writes so a future refactor that adds an `await` between + # ``self._seq += 1`` and the append can't interleave lines across concurrent coroutines. + # _write is synchronous today (no await inside), so this lock is currently uncontended — + # it is a guard rail making the "no await inside _write" invariant explicit rather than + # an implicit assumption a later change could silently break. + self._write_lock = threading.Lock() def _ensure(self) -> None: if not self._started: @@ -345,16 +352,23 @@ def _ensure(self) -> None: }) def _write(self, record: dict) -> None: - self._seq += 1 - record.setdefault("seq", self._seq) - new_file = not self.path.exists() - with open(self.path, "a", encoding="utf-8") as handle: - handle.write(json.dumps(record, ensure_ascii=False) + "\n") - if new_file: - try: - os.chmod(self.path, 0o600) - except OSError: - pass + # RACE-4: hold _write_lock across seq++ + append so concurrent coroutines can't + # interleave half-lines. The lock is reentrant-safe here because _write performs no + # awaits and calls no other RunLog methods. INVARIANT: do not add an `await` inside + # this method — an await would release the event loop mid-write and break line + # atomicity across coroutines (the lock is threading.Lock, not asyncio.Lock, so it + # does not gate the event loop; line atomicity relies on synchronous execution). + with self._write_lock: + self._seq += 1 + record.setdefault("seq", self._seq) + new_file = not self.path.exists() + with open(self.path, "a", encoding="utf-8") as handle: + handle.write(json.dumps(record, ensure_ascii=False) + "\n") + if new_file: + try: + os.chmod(self.path, 0o600) + except OSError: + pass def set_run_meta(self, **data) -> None: """Store static run metadata to write as the first JSONL row on first use.""" diff --git a/wallbreaker/state.py b/wallbreaker/state.py index 98161dd..c02fa03 100644 --- a/wallbreaker/state.py +++ b/wallbreaker/state.py @@ -4,10 +4,11 @@ import json import logging import os -import tempfile import threading from pathlib import Path +from ._fsutil import atomic_write + STATE_FILENAME = ".wallbreaker_state.json" _log = logging.getLogger("wallbreaker.state") @@ -39,33 +40,13 @@ def load_state(path: str | Path) -> dict: return data if isinstance(data, dict) else {} -def _atomic_write(path: Path, text: str) -> None: - """Write via a temp file + os.replace so a concurrent reader (or a crash) never sees a - truncated file. Truncate-then-write (the old Path.write_text) could expose an empty or - half-written state file that load_state would silently read as {} (lost prefs).""" - path.parent.mkdir(parents=True, exist_ok=True) - fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=".state-", suffix=".tmp") - try: - with os.fdopen(fd, "w", encoding="utf-8") as handle: - handle.write(text) - handle.flush() - os.fsync(handle.fileno()) - os.replace(tmp, path) # atomic on POSIX and Windows - except BaseException: - try: - os.unlink(tmp) - except OSError: - pass - raise - - def save_state(path: str | Path, prefs: dict) -> bool: """Atomically persist prefs. Returns True on success (callers that ignore the return value keep their old behaviour); logs instead of silently swallowing on failure.""" text = json.dumps(prefs, ensure_ascii=False, indent=1) with _state_lock: try: - _atomic_write(Path(path), text) + atomic_write(Path(path), text) return True except OSError as exc: _log.warning("could not save state file %s: %s", path, exc) From 813465fe72d5f6b8307c6480f6cadd4578caa65c Mon Sep 17 00:00:00 2001 From: rial1 Date: Sun, 19 Jul 2026 22:13:04 +0100 Subject: [PATCH 06/16] TG3.4-3.6: Pydantic models, global 500 handler, startup log-and-continue (SEC-11/REL-8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The last P1 backend group. Closes SEC-11 (no 500 tracebacks; body validation) and REL-8 (failures surfaced, not silently swallowed). TG3.5 — Pydantic request models (SEC-11): - AgentRunRequest, FireComposeRequest, ProviderUpsert, SettingsUpdate with extra='ignore'/'allow' + all-optional defaults so valid SPA traffic and the existing test payloads are never 422'd for unknown fields or missing optionals. Handlers convert via model_dump(exclude_defaults=True) so 'in' checks behave identically to the old body: dict (only client-sent fields appear in the dump). Wrong-typed fields (max_rounds as a list, etc.) → 422 at the boundary, never a 500 traceback. - Settings and provider models use extra='allow' (too free-form for strict fields; the handler's _int_setting + isinstance do the real validation). TG3.4 — Global 500 handler (SEC-11): - @app.exception_handler(Exception) returns a generic {detail: 'internal server error'} with NO traceback/paths. Scoped to Exception only: FastAPI's built-in HTTPException (4xx) and RequestValidationError (422) handlers take precedence by type specificity — never turns a 401/403/404/400/422 into 500. SSE StreamingResponse errors are unaffected (response already started; handled by runner's try/except). - Fixed a real leak the test caught: the runner's error_event sent f'{type(exc).__name__}: {exc}' through the SSE stream — which included internal paths. Now logs full error server-side (_log.exception) and pushes a generic 'internal error' to the client. TG3.6 — Startup except narrowing (REL-8): - The create_app init 'except Exception: pass' → 'except Exception as exc: _log.warning(...)' — log-and-continue, never hard-raise. A dashboard that starts and reports degraded state (provider_registry=None → routes 400) is better than one that won't start at all. The failure is now visible. Verification: - tests/test_audit_remediation.py +6: malformed body (wrong types) → 422 not 500; missing required field → 422; unknown fields don't 422 valid traffic; forced internal error → generic 500 with no path/traceback leak; HTTPException (401/403/404) not intercepted by the 500 handler; startup degrades log-and-continue on a broken config. 55/55 pass. - tests/test_dashboard.py: 14/14 pass (existing agent_run + settings payloads unchanged — run_timeout_s is body-only, settings 4-keys contract preserved). - Full suite: 1117 passed (was 1111 -> +6), 31 failed / 7 errors unchanged (pre-existing corpus-dependent). No regressions. --- tests/test_audit_remediation.py | 107 +++++++++++++++++++++++++++++++ wallbreaker/dashboard/server.py | 110 +++++++++++++++++++++++++++++--- 2 files changed, 209 insertions(+), 8 deletions(-) diff --git a/tests/test_audit_remediation.py b/tests/test_audit_remediation.py index f088621..bf1ecc0 100644 --- a/tests/test_audit_remediation.py +++ b/tests/test_audit_remediation.py @@ -808,3 +808,110 @@ async def run(): assert len(notes) == N, f"all {N} note lines present; got {len(notes)}" assert seqs == sorted(seqs), "seqs are monotonic (serialized, not interleaved)" assert len(set(seqs)) == N, "every seq is unique" + + +# ------------------------------------------------------------------------- SEC-11 / REL-8 input validation + error surfacing (TG3.4-3.6) + +def _authed_client(tmp_path, **kw): + """A TestClient with auth on + a valid token, so body-validation tests reach the handler.""" + from wallbreaker.config import Config, Endpoint + ep = Endpoint("t", "openai", "http://x", "m") + cfg = Config(default_profile="t", profiles={"t": ep}, target=ep, path=tmp_path / "config.toml") + return TestClient(create_app(config=cfg, sessions_dir=tmp_path / "s", require_auth=True, + auth_token="tok", **kw)) + + +def test_malformed_agent_run_body_returns_4xx_not_500(tmp_path): + """TG3.9 (SEC-11): a body with wrong-typed fields (max_rounds as a list) → 422, not a 500 + traceback. The Pydantic model catches it at the boundary before the handler can TypeError.""" + c = _authed_client(tmp_path) + r = c.post("/api/agent/run", json={"objective": "go", "max_rounds": [1, 2]}, + headers={"X-WB-Token": "tok"}) + assert r.status_code == 422, f"wrong-typed field must 422, got {r.status_code}" + assert "traceback" not in r.text.lower() + + +def test_missing_objective_returns_4xx_not_500(tmp_path): + """A body missing the required 'objective' field → 422 (Pydantic), not 500.""" + c = _authed_client(tmp_path) + r = c.post("/api/agent/run", json={"max_rounds": 3}, headers={"X-WB-Token": "tok"}) + assert r.status_code == 422 + + +def test_unknown_fields_do_not_422_valid_spa_traffic(tmp_path): + """TG3.5: extra='ignore' on AgentRunRequest means the SPA can send unknown fields without + a 422 — validates the PM's 'must not 422 valid SPA traffic' directive.""" + c = _authed_client(tmp_path) + r = c.post("/api/agent/run", + json={"objective": "go", "future_field": "no problem", "another": 42}, + headers={"X-WB-Token": "tok"}) + # The objective is valid; the run starts or 400s (no target configured) — but NOT 422 for extras. + assert r.status_code != 422, "unknown fields must not 422 valid SPA traffic" + + +def test_global_500_handler_returns_generic_body_no_traceback(tmp_path): + """TG3.9 (SEC-11): a forced internal error → generic 500 with no traceback/paths. Uses + raise_server_exceptions=False so Starlette's error middleware doesn't re-raise.""" + from wallbreaker.config import Config, Endpoint + ep = Endpoint("t", "openai", "http://x", "m") + cfg = Config(default_profile="t", profiles={"t": ep}, target=ep, path=tmp_path / "config.toml") + c = TestClient(create_app(config=cfg, sessions_dir=tmp_path / "s", require_auth=True, + auth_token="tok"), raise_server_exceptions=False) + # Force a 500 by sending a valid objective but a config where the agent run will hit + # an unexpected error. The simplest: monkeypatch run_autonomous to raise TypeError. + import wallbreaker.agent.loop as loop_mod + orig = loop_mod.run_autonomous + + async def boom(*a, **kw): + raise TypeError("forced internal error with /tmp/secret/path in it") + loop_mod.run_autonomous = boom + try: + with c.stream("POST", "/api/agent/run", json={"objective": "go", "max_rounds": 1}, + headers={"X-WB-Token": "tok"}) as r: + # The stream should complete (the runner catches the error) OR the handler 500s. + text = "".join(r.iter_text()) + finally: + loop_mod.run_autonomous = orig + # The runner's except catches it and emits an error SSE event, so the stream completes. + # But if the error propagated (e.g. before StreamingResponse started), the global handler + # returns a generic 500. Either way: no traceback/paths in the response. + assert "/tmp/secret/path" not in text, "internal path must not leak to the client" + assert "traceback" not in text.lower() + + +def test_http_exception_not_intercepted_by_500_handler(tmp_path): + """TG3.4: HTTPException (401/403/404/400) must keep its default handling — the Exception + handler must NOT turn a 401 into a 500.""" + c = _authed_client(tmp_path) + # No token → 401 (from SecurityMiddleware, which runs before the handler). + r = c.get("/api/config") + assert r.status_code == 401, f"missing token must be 401, not 500; got {r.status_code}" + # Cross-site Origin → 403. + r2 = c.get("/api/config", headers={"X-WB-Token": "tok", "Origin": "https://evil.example"}) + assert r2.status_code == 403 + # Unknown provider → 404. + r3 = c.get("/api/providers/nonexistent", headers={"X-WB-Token": "tok"}) + assert r3.status_code == 404 + + +def test_startup_degrades_log_and_continue_on_bad_config(tmp_path, monkeypatch): + """TG3.6 (REL-8): a broken config no longer silently swallows — the init narrows to a + logged warning and the dashboard boots with degraded state (provider_registry=None → + provider routes return 400), not a hard crash.""" + from wallbreaker.config import Config, Endpoint + ep = Endpoint("t", "openai", "http://x", "m") + cfg = Config(default_profile="t", profiles={"t": ep}, target=ep, path=tmp_path / "config.toml") + # Break ProviderRegistry so the init try-block fails. + import wallbreaker.provider_registry as pr_mod + orig_init = pr_mod.ProviderRegistry.__init__ + + def boom(self, *a, **kw): + raise OSError("simulated registry init failure") + monkeypatch.setattr(pr_mod.ProviderRegistry, "__init__", boom) + # The app must still create (log-and-continue, not hard-raise). + app = create_app(config=cfg, sessions_dir=tmp_path / "s") + c = TestClient(app) + # provider routes degrade to 400 (provider_registry is None). + r = c.get("/api/providers") + assert r.status_code in (200, 400), "degraded but not crashed" + monkeypatch.setattr(pr_mod.ProviderRegistry, "__init__", orig_init) diff --git a/wallbreaker/dashboard/server.py b/wallbreaker/dashboard/server.py index 16f279b..fef6bc3 100644 --- a/wallbreaker/dashboard/server.py +++ b/wallbreaker/dashboard/server.py @@ -3,6 +3,7 @@ import asyncio import dataclasses import json +import logging import os import re from datetime import datetime @@ -481,6 +482,76 @@ def _int_setting(value, default: int, lo: int, hi: int) -> int: return max(lo, min(parsed, hi)) +# --- TG3.5 Pydantic request models (SEC-11) ---------------------------------------- +# Boundary validation: a malformed body → 422 (FastAPI/Pydantic), never a 500 traceback. +# All fields are Optional with None defaults + extra="ignore" so valid SPA traffic and the +# existing test payloads are never 422'd for unknown fields or missing optionals. Handlers +# convert via model_dump(exclude_defaults=True) so `in`-checks behave identically to the old +# `body: dict` (only fields the client actually sent appear in the dump). +try: + from pydantic import BaseModel, ConfigDict + + class AgentRunRequest(BaseModel): + model_config = ConfigDict(extra="ignore") + objective: str + max_rounds: int | None = None + max_tokens: int | None = None + concurrency: int | None = None + request_delay_ms: int | None = None + enabled_techniques: list[str] | None = None + run_timeout_s: int | None = None + + class FireComposeRequest(BaseModel): + model_config = ConfigDict(extra="ignore") + request: str | None = None + prompt: str | None = None + payload: str | None = None + preset: str | None = None + transforms: list[str] | None = None + system: str | None = None + max_tokens: int | None = None + + class ProviderUpsert(BaseModel): + # Provider config is very free-form; the handler's provider_registry.save() validates. + # The model ensures the body is a JSON object and type-checks the common fields. + model_config = ConfigDict(extra="allow") + protocol: str | None = None + base_url: str | None = None + model: str | None = None + api_key: str | None = None + api_key_env: str | None = None + enabled: bool | None = None + auth_style: str | None = None + inference_path: str | None = None + models_path: str | None = None + timeout: int | None = None + reasoning: bool | None = None + modality: str | None = None + + class SettingsUpdate(BaseModel): + # Settings body is highly variable (role assignments, agent sub-dict, target_options). + # extra="allow" passes everything through; typed fields catch the common type errors. + model_config = ConfigDict(extra="allow") + agent: dict | None = None + target_options: dict | None = None + +except ImportError: # pragma: no cover — pydantic is a transitive dep of fastapi (dashboard extra) + BaseModel = None # type: ignore[assignment] + + class AgentRunRequest: # type: ignore[no-redef] + def __init__(self, **kw): + self.__dict__.update(kw) + def model_dump(self, **kw): + return {k: v for k, v in self.__dict__.items() if v is not None} + + FireComposeRequest = AgentRunRequest # type: ignore[assignment, misc] + ProviderUpsert = AgentRunRequest # type: ignore[assignment, misc] + SettingsUpdate = AgentRunRequest # type: ignore[assignment, misc] + + +_log = logging.getLogger("wallbreaker.dashboard") + + def _agent_settings(prefs: dict | None = None) -> dict: prefs = prefs or {} return { @@ -804,8 +875,11 @@ def create_app(config=None, sessions_dir: str | Path = "sessions", web_dir: str # No notify_request_gates() here: this runs in the sync create_app body at startup, # before any event loop / parked tasks exist. The async handlers (settings POST, # agent_run) call notify after reconfiguring mid-flight (RACE-3). - except Exception: - pass + except Exception as exc: + # TG3.6 (REL-8): log-and-continue, never hard-raise. A dashboard that starts and + # reports the problem (provider_registry=None → routes 400) is better than one that + # won't start at all. The failure is now visible, not fatal. + _log.warning("dashboard init degraded — %s: %s", type(exc).__name__, exc) app = FastAPI(title="Wallbreaker", version="0.1.0") token = auth_token or (__import__("secrets").token_urlsafe(32) if require_auth else "") app.state.auth_token = token @@ -822,6 +896,18 @@ def create_app(config=None, sessions_dir: str | Path = "sessions", web_dir: str # it requires the token + a same-origin request on every mutating route (audit SEC-1/2/3). app.add_middleware(SecurityMiddleware, token=token, require_auth=require_auth) + # TG3.4 (SEC-11): global 500 handler — returns a generic body with NO traceback/paths. + # Scoped to `Exception` only: FastAPI's built-in handlers for HTTPException (4xx) and + # RequestValidationError (422) take precedence by type specificity, so this NEVER turns a + # 401/403/404/400/422 into a 500. SSE StreamingResponse errors are unaffected (the response + # has already started by then; those are handled by runner()'s try/except). + from fastapi.responses import JSONResponse + + @app.exception_handler(Exception) + async def _generic_500(request, exc): + _log.exception("unhandled error on %s %s", request.method, request.url.path) + return JSONResponse(status_code=500, content={"detail": "internal server error"}) + def _latest(): return report_mod.latest_run_log(sessions) @@ -876,9 +962,10 @@ def provider_get(name: str): return item @app.put("/api/providers/{name}") - def provider_put(name: str, body: dict): + def provider_put(name: str, body: ProviderUpsert): if provider_registry is None: raise HTTPException(status_code=400, detail="no config loaded") + body = body.model_dump(exclude_defaults=True) try: return provider_registry.save(name, body) except Exception as exc: @@ -1034,9 +1121,10 @@ async def provider_models_refresh(name: str): return result @app.post("/api/settings") - def settings_post(body: dict): + def settings_post(body: SettingsUpdate): if config is None: raise HTTPException(status_code=400, detail="no config loaded") + body = body.model_dump(exclude_defaults=True) from ..state import load_state, save_state, state_path_for prefs = load_state(state_path_for(config)) @@ -1244,16 +1332,18 @@ def tools(): agent_control = None @app.post("/api/compose") - def compose(body: dict): + def compose(body: FireComposeRequest): + body = body.model_dump(exclude_defaults=True) try: return _compose_attack_payload(body) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc @app.post("/api/fire") - async def fire(body: dict): + async def fire(body: FireComposeRequest): if config is None: raise HTTPException(status_code=400, detail="no [target] configured in config.toml") + body = body.model_dump(exclude_defaults=True) try: composed = _compose_attack_payload(body) except ValueError as exc: @@ -1427,8 +1517,9 @@ async def agent_attacker_switch(body: dict): return _agent_status_view() @app.post("/api/agent/run") - async def agent_run(body: dict): + async def agent_run(body: AgentRunRequest): nonlocal agent_active, agent_control, agent_task + body = body.model_dump(exclude_defaults=True) from fastapi.responses import StreamingResponse if config is None: @@ -1690,7 +1781,10 @@ async def runner(): "summary": summary, "run_log": runlog.path.name, }) except Exception as exc: # noqa: BLE001 - error_event(f"{type(exc).__name__}: {exc}") + # SEC-11: log the full error server-side, but send a GENERIC message to the + # SSE client — never leak the exception type, message, or internal paths. + _log.exception("agent run failed") + error_event("internal error") finally: # REL-2: close the brain provider (and its pooled client) when the run ends; # _LiveAttackerProvider.aclose closes the active provider, and any hot-swapped From b048c4c4553a3d61227b01ac5813d86a45655830 Mon Sep 17 00:00:00 2001 From: rial1 Date: Sun, 19 Jul 2026 23:06:49 +0100 Subject: [PATCH 07/16] Gate 3: wire PBT security properties + _int_setting OverflowError fix (M1-backend) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gate 3 — Property-Based Testing (Hypothesis). Every backend security-critical component now exists, so the runnable properties from specs/audit-remediation/pbt-properties.py are wired into tests/pbt/ against the actual post-remediation APIs. tests/pbt/test_security_properties.py (NEW, 14 properties + 1 skipped): - Prop 1 Access control: unauth/cross-site → 401/403, no side effect (200 cases) - Prop 10 Token file 0600 - Prop 2 Least-privilege: default registry SAFE-only; host ⇔ opt-in - Prop 3 SSRF confinement: private/link-local/meta always denied (300 cases) - Prop 4 Path confinement: _confine redirects or flags escapes (300 cases) - Prop 5 Input validation: clamp-or-4xx, never 5xx (200 cases) - Prop 6 Data integrity: state round-trip + concurrent-merge + cache conservation - Prop 7 Secret non-exposure: no secret survives redact_args (400 cases) - Prop 8 Concurrency: gate never exceeds limit under any schedule (50 cases) - _int_setting clamps within bounds (300 cases) - SSRF stable under redirect (metadata IP in chain → blocked) - Run-log symlink guard rejects symlinked targets PBT FOUND a real SEC-11 bug: _int_setting(float('inf')) raised uncaught OverflowError → 500. Fixed: the except now catches (TypeError, ValueError, OverflowError). The PBT property test_int_setting_clamps_within_bounds pins it. security-audit-prep.md: Tier-2 checkboxes → [x]; documented the RACE-2 compaction append/replace race as a conscious residual (undercount-only, cache is not the system of record, tiny window, low priority follow-up) per PM directive. DNS-rebinding residual also documented. This is the M1-backend checkpoint: all P1 backend task groups landed (TG1.4, TG4.2, TG4.3, TG5.3/5.4/5.5, TG3.4-3.6), Gate 3 PBT green. Verification: - tests/pbt/test_security_properties.py: 14 passed, 1 skipped (stego extra). - tests/test_audit_remediation.py: 55/55 pass. - tests/test_dashboard.py: 14/14 pass. - Full suite: 1131 passed (+14 PBT), 31 failed / 7 errors unchanged (corpus). --- tests/pbt/__init__.py | 0 tests/pbt/test_security_properties.py | 321 ++++++++++++++++++++++++++ wallbreaker/dashboard/server.py | 2 +- 3 files changed, 322 insertions(+), 1 deletion(-) create mode 100644 tests/pbt/__init__.py create mode 100644 tests/pbt/test_security_properties.py diff --git a/tests/pbt/__init__.py b/tests/pbt/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/pbt/test_security_properties.py b/tests/pbt/test_security_properties.py new file mode 100644 index 0000000..e95a75e --- /dev/null +++ b/tests/pbt/test_security_properties.py @@ -0,0 +1,321 @@ +"""Gate 3 — Property-Based Testing (Hypothesis) for Wallbreaker audit remediation. + +Adapted from specs/audit-remediation/pbt-properties.py: all @skip decorators removed and every +property wired against the POST-remediation actual API. Run when all backend security-critical +components are built (M1-backend checkpoint). + +Run: pytest -q tests/pbt/test_security_properties.py +""" +from __future__ import annotations + +import ipaddress +import os +import string + +import pytest +from hypothesis import given, settings, strategies as st, HealthCheck + +# --------------------------------------------------------------------------- +# Shared strategies +# --------------------------------------------------------------------------- + +_PRIVATE_IPS = st.sampled_from([ + "127.0.0.1", "127.5.5.5", "0.0.0.0", "::1", + "169.254.169.254", "169.254.170.2", + "10.0.0.5", "172.16.9.9", "192.168.1.1", + "fd00::1", +]) +_PUBLIC_HOSTS = st.sampled_from(["api.openai.com", "openrouter.ai", "example.com", "8.8.8.8", "1.1.1.1"]) +_SCHEMES = st.sampled_from(["http", "https", "file", "ftp", "gopher", "data"]) + +_path_segments = st.lists( + st.sampled_from(["a", "b", "sub", "..", ".", "wb_runs", "etc", "passwd", ""]), + min_size=0, max_size=6, +) +_leading = st.sampled_from(["", "/", "./", "../", "../../", "/etc/", "~"]) + +# Use a distinct alphabet for secrets vs url so a generated secret can never equal the url +# (avoids a false-positive in the redaction property where the secret coincidentally appears +# in the unredacted url field). +_secret_values = st.text(alphabet=string.printable, min_size=6, max_size=40) +_url_values = st.text(alphabet=string.ascii_letters + string.digits + "/.:-", max_size=30) +_pref_keys = st.text(alphabet=string.ascii_letters + "_", min_size=1, max_size=12) + +_SC = [HealthCheck.function_scoped_fixture] + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _dummy_config(tmp_path): + from wallbreaker.config import Config, Endpoint + ep = Endpoint("t", "openai", "http://x", "m") + return Config(default_profile="t", profiles={"t": ep}, target=ep, + path=tmp_path / "config.toml") + + +def _auth_headers(token="tok"): + return {"X-WB-Token": token, "Origin": "http://127.0.0.1:8787"} + + +# =========================================================================== +# Security Property 1 — Access control (SEC-1/2/3/6/8) +# =========================================================================== + +@settings(max_examples=200, deadline=None, suppress_health_check=_SC) +@given( + token=st.one_of(st.none(), st.text(alphabet=st.characters(max_codepoint=127), max_size=64), st.just("tok")), + origin=st.one_of(st.none(), st.just("http://127.0.0.1:8787"), + st.just("https://evil.example"), st.just("http://evil.example:1234")), + method=st.sampled_from(["POST", "PUT", "DELETE"]), +) +def test_access_control_invariant(tmp_path, token, origin, method): + """Unauth/cross-site → 401/403, no side effect.""" + from wallbreaker.dashboard.server import create_app + from starlette.testclient import TestClient + + c = TestClient(create_app(config=_dummy_config(tmp_path), sessions_dir=tmp_path / "s", + require_auth=True, auth_token="tok"), + raise_server_exceptions=False) + headers = {} + if token is not None: + headers["X-WB-Token"] = token + if origin is not None: + headers["Origin"] = origin + + resp = c.request(method, "/api/fire", + json={"request": "x", "max_tokens": 8}, headers=headers) + authenticated = token == "tok" + same_origin = origin in (None, "http://127.0.0.1:8787") + if not (authenticated and same_origin): + assert resp.status_code in (401, 403), f"unauth/cross-site must be 401/403, got {resp.status_code}" + + +# =========================================================================== +# Security Property 10 — Token file 0600 (SEC-1) +# =========================================================================== + +def test_token_file_is_0600(tmp_path): + from wallbreaker.dashboard.auth import ensure_launch_token, token_file_path + tok = ensure_launch_token(str(tmp_path)) + mode = os.stat(token_file_path(str(tmp_path))).st_mode & 0o777 + assert mode == 0o600, f"token file mode {oct(mode)} != 0o600" + assert len(tok) > 20 + + +# =========================================================================== +# Security Property 2 — Least-privilege tool exposure (SEC-1/4/5) +# =========================================================================== + +@settings(max_examples=50, suppress_health_check=_SC) +@given(opt_in=st.booleans()) +def test_dashboard_registry_least_privilege(opt_in, tmp_path): + from wallbreaker.tools.tool_policy import classify, HOST_AFFECTING, build_dashboard_registry + cfg = _dummy_config(tmp_path) + reg = build_dashboard_registry(cfg, str(tmp_path), allow_host_tools=opt_in) + names = set(reg.names()) + host_present = names & HOST_AFFECTING + if opt_in: + assert host_present, "opt-in should expose host tools" + else: + assert not host_present, f"host tools leaked: {host_present}" + assert all(classify(n) == "SAFE" for n in (names - HOST_AFFECTING)) + + +# =========================================================================== +# Security Property 3 — SSRF confinement (SEC-4) +# =========================================================================== + +def _is_private_or_meta(host: str) -> bool: + if host in ("metadata.google.internal",): + return True + try: + ip = ipaddress.ip_address(host) + except ValueError: + return False + return (ip.is_loopback or ip.is_private or ip.is_link_local + or ip.is_reserved or ip.is_unspecified) + + +@settings(max_examples=300, deadline=None) +@given(scheme=_SCHEMES, host=st.one_of(_PRIVATE_IPS, _PUBLIC_HOSTS), port=st.integers(1, 65535)) +def test_ssrf_confinement(scheme, host, port): + from wallbreaker.tools.egress_guard import is_allowed + url = f"{scheme}://[{host}]:{port}/path" if ":" in host else f"{scheme}://{host}:{port}/path" + allowed = is_allowed(url) + if scheme not in ("http", "https"): + assert not allowed, f"non-http scheme allowed: {url}" + elif _is_private_or_meta(host): + assert not allowed, f"private/meta destination allowed: {url}" + + +def test_ssrf_stable_under_redirect(): + from wallbreaker.tools.egress_guard import validate_redirect_chain + assert validate_redirect_chain(["https://example.com/a", "http://169.254.169.254/m"]) is False + assert validate_redirect_chain(["https://example.com/a", "https://openrouter.ai/b"]) is True + + +# =========================================================================== +# Security Property 4 — Path confinement (SEC-5/10) +# =========================================================================== + +@settings(max_examples=300, deadline=None, suppress_health_check=_SC) +@given(leading=_leading, segs=_path_segments) +def test_read_file_path_confinement(tmp_path, leading, segs): + from wallbreaker.tools.files import _confine + from wallbreaker.tools.registry import ToolContext + from wallbreaker.config import Config + ctx = ToolContext(config=Config(default_profile="x", profiles={}), cwd=str(tmp_path)) + raw = leading + "/".join(s for s in segs if s != "") + resolved, msg = _confine(ctx, raw) + inside = str(resolved.resolve()).startswith(str(tmp_path.resolve())) + assert inside or msg != "", f"path escaped without being flagged: {raw!r} -> {resolved}" + + +def test_run_log_guard_rejects_symlink(tmp_path): + from wallbreaker.dashboard.server import _safe_run_path + outside = tmp_path / "secret.txt"; outside.write_text("s") + sessions = tmp_path / "sessions"; sessions.mkdir() + link = sessions / "leak.jsonl"; link.symlink_to(outside) + assert _safe_run_path(sessions, "leak.jsonl") is None + assert _safe_run_path(sessions, "../secret") is None + + +# =========================================================================== +# Security Property 5 — Input validation / no 500 tracebacks (SEC-11) +# =========================================================================== + +@settings(max_examples=200, deadline=None, suppress_health_check=_SC) +@given( + max_rounds=st.one_of(st.integers(), st.text(max_size=8), st.none(), + st.floats(allow_nan=False, allow_infinity=False)), + max_tokens=st.one_of(st.integers(), st.text(max_size=8), st.none()), +) +def test_body_validation_clamps_or_4xx(tmp_path, max_rounds, max_tokens): + from wallbreaker.dashboard.server import create_app + from starlette.testclient import TestClient + c = TestClient(create_app(config=_dummy_config(tmp_path), sessions_dir=tmp_path / "s", + require_auth=True, auth_token="tok"), + raise_server_exceptions=False) + resp = c.post("/api/agent/run", headers=_auth_headers(), + json={"objective": "x", "max_rounds": max_rounds, "max_tokens": max_tokens}) + assert resp.status_code < 500, f"validation produced 5xx for {max_rounds!r}/{max_tokens!r}" + + +@given(raw=st.one_of(st.integers(), st.text(max_size=6), st.none(), + st.floats(allow_nan=True, allow_infinity=True)), + default=st.integers(min_value=0, max_value=10)) +@settings(max_examples=300) +def test_int_setting_clamps_within_bounds(raw, default): + from wallbreaker.dashboard.server import _int_setting + val = _int_setting(raw, default, 1, 50) + assert 1 <= val <= 50 + + +# =========================================================================== +# Security Property 6 — Data integrity (REL-3/RACE-1, RACE-2) +# =========================================================================== + +@settings(max_examples=200, suppress_health_check=_SC) +@given(prefs=st.dictionaries(_pref_keys, st.integers() | st.text(max_size=10), max_size=10)) +def test_state_round_trip(tmp_path, prefs): + from wallbreaker.state import save_state, load_state + path = tmp_path / ".wallbreaker_state.json" + save_state(str(path), prefs) + assert load_state(str(path)) == prefs + + +@settings(max_examples=200, suppress_health_check=_SC) +@given( + a=st.dictionaries(_pref_keys, st.integers(), min_size=1, max_size=5), + b=st.dictionaries(_pref_keys, st.integers(), min_size=1, max_size=5), +) +def test_state_concurrent_merge_preserves_disjoint_keys(tmp_path, a, b): + from wallbreaker.state import save_state_merge, load_state + path = tmp_path / ".wallbreaker_state.json" + save_state_merge(str(path), a) + save_state_merge(str(path), b) + merged = load_state(str(path)) + for k, v in a.items(): + if k not in b: + assert merged.get(k) == v, f"disjoint key {k!r} lost in merge" + + +@settings(max_examples=100, suppress_health_check=_SC) +@given(puts=st.lists(st.sampled_from(["COMPLIED", "PARTIAL", "REFUSED"]), min_size=1, max_size=30)) +def test_cache_count_conservation(tmp_path, puts): + from wallbreaker.cache import ResultCache + # Clear any leftover cache from previous Hypothesis examples (tmp_path is shared). + cache_file = tmp_path / "wb_runs" / "result_cache.jsonl" + if cache_file.exists(): + cache_file.unlink() + c1 = ResultCache(str(tmp_path)) + c2 = ResultCache(str(tmp_path)) + for i, bucket in enumerate(puts): + (c1 if i % 2 == 0 else c2).put("k", bucket, f"r{i}") + total = ResultCache(str(tmp_path)).get("k")["samples"] + assert total == len(puts), f"cache count {total} != {len(puts)} puts" + + +# =========================================================================== +# Security Property 7 — Secret non-exposure (SEC-9) +# =========================================================================== + +@settings(max_examples=400) +@given(auth=_secret_values, api_key=_secret_values, password=_secret_values, url=_url_values) +def test_secret_redaction(auth, api_key, password, url): + from wallbreaker.session import redact_args + args = { + "url": url, + "headers": {"Authorization": f"Bearer {auth}", "x-api-key": api_key}, + "api_key": api_key, + "password": password, + } + redacted = redact_args(args) + assert auth not in str(redacted.get("headers", {}).get("Authorization", "")) + assert api_key not in str(redacted.get("headers", {}).get("x-api-key", "")) + assert api_key not in str(redacted.get("api_key", "")) + assert password not in str(redacted.get("password", "")) + + +# =========================================================================== +# Security Property 8 — Concurrency / rate limiting (RACE-3) +# =========================================================================== + +@settings(max_examples=50, deadline=None) +@given(limit=st.integers(min_value=1, max_value=8), n=st.integers(min_value=1, max_value=20)) +def test_gate_never_exceeds_limit(limit, n): + import asyncio + from wallbreaker.providers.request_gate import configure_request_gate, provider_request_slot + from wallbreaker.config import Endpoint + + configure_request_gate(limit, 0) + ep = Endpoint("one", "openai", "https://api.example/v1", "m", api_key="k") + + async def run(): + peak = 0 + active = 0 + + async def worker(): + nonlocal peak, active + async with provider_request_slot(ep): + active += 1 + peak = max(peak, active) + await asyncio.sleep(0) + active -= 1 + + await asyncio.gather(*(worker() for _ in range(n))) + return peak + + peak = asyncio.run(run()) + assert peak <= limit, f"gate allowed {peak} concurrent > limit {limit}" + + +# =========================================================================== +# Stego reversibility — skipped (optional 'stegg' extra) +# =========================================================================== + +@pytest.mark.skip(reason="requires the optional 'stegg' dependency") +def test_stego_encrypt_decrypt_reversible(): + pass diff --git a/wallbreaker/dashboard/server.py b/wallbreaker/dashboard/server.py index fef6bc3..8caf0bb 100644 --- a/wallbreaker/dashboard/server.py +++ b/wallbreaker/dashboard/server.py @@ -477,7 +477,7 @@ def _list_arg(value) -> list[str]: def _int_setting(value, default: int, lo: int, hi: int) -> int: try: parsed = int(value) - except (TypeError, ValueError): + except (TypeError, ValueError, OverflowError): parsed = default return max(lo, min(parsed, hi)) From b3934bf7ec13b5e99bd7d72fb9ab7c748d721913 Mon Sep 17 00:00:00 2001 From: "Poncho (frontend TG6-8)" Date: Wed, 22 Jul 2026 07:37:10 +0000 Subject: [PATCH 08/16] Frontend audit remediation TG6-8: reliability primitives + WCAG 2.2 AA + visual consistency TG6 (reliability): shared primitives (useAbortableFetch, AsyncView, Dialog, Combobox, InteractiveChip, LiveRegion); REL-4 (SSE AbortController+unmount cleanup), REL-5 (stale-guards), REL-9 (AsyncView error states), REL-10 (Profiles busy guards), REL-14 (Pointer Events resize), INFO-1 (no-dangerouslySetInnerHTML test); Vitest+RTL+jest-axe harness. TG7 (a11y): A11Y-1..13 (Dialog semantics+focus trap/restore, Combobox aria, keyboard chips/rows, LiveRegion, non-color verdict cues, contrast, labels/autocomplete/fieldset, landmarks/h1/skip link, reduced-motion, touch targets); axe 0-violations per view. TG8 (visual): VIS-1..5 (chip standardization, token/class inline-style cleanup, async states, shared src/format.ts formatters, layout-shift min-heights + pinned auto-scroll). Verify: tsc clean, 38/38 vitest, vite build ok. --- .gitignore | 1 + wallbreaker/dashboard/web/bun.lock | 637 ++++++++++++++++++ wallbreaker/dashboard/web/package.json | 13 +- wallbreaker/dashboard/web/src/App.tsx | 42 +- .../web/src/__tests__/Agent.abort.test.tsx | 50 ++ .../src/__tests__/Agent.autoscroll.test.tsx | 107 +++ .../web/src/__tests__/AsyncView.test.tsx | 56 ++ .../web/src/__tests__/Dialog.test.tsx | 59 ++ .../web/src/__tests__/Profiles.busy.test.tsx | 60 ++ .../src/__tests__/a11y.interactions.test.tsx | 93 +++ .../web/src/__tests__/axe.views.test.tsx | 183 +++++ .../web/src/__tests__/format.test.ts | 81 +++ .../web/src/__tests__/no-danger.test.ts | 31 + .../dashboard/web/src/__tests__/setup.ts | 1 + .../web/src/__tests__/staleGuard.test.tsx | 47 ++ wallbreaker/dashboard/web/src/api.ts | 32 +- .../dashboard/web/src/components/Agent.tsx | 74 +- .../web/src/components/AgentConfigDrawer.tsx | 83 ++- .../dashboard/web/src/components/Arsenal.tsx | 4 +- .../dashboard/web/src/components/Console.tsx | 83 ++- .../dashboard/web/src/components/Findings.tsx | 112 ++- .../web/src/components/ModelChooser.tsx | 30 +- .../dashboard/web/src/components/Overview.tsx | 29 +- .../dashboard/web/src/components/Profiles.tsx | 112 ++- .../web/src/components/ProviderManager.tsx | 20 +- .../web/src/components/RoleChooser.tsx | 53 +- .../dashboard/web/src/components/Runs.tsx | 137 ++-- .../dashboard/web/src/components/Settings.tsx | 4 +- wallbreaker/dashboard/web/src/format.ts | 84 +++ .../web/src/primitives/AsyncView.tsx | 50 ++ .../dashboard/web/src/primitives/Combobox.tsx | 92 +++ .../dashboard/web/src/primitives/Dialog.tsx | 97 +++ .../web/src/primitives/InteractiveChip.tsx | 35 + .../web/src/primitives/LiveRegion.tsx | 27 + .../web/src/primitives/useAbortableFetch.ts | 38 ++ wallbreaker/dashboard/web/src/styles.css | 154 ++++- wallbreaker/dashboard/web/vitest.config.ts | 12 + 37 files changed, 2562 insertions(+), 261 deletions(-) create mode 100644 wallbreaker/dashboard/web/bun.lock create mode 100644 wallbreaker/dashboard/web/src/__tests__/Agent.abort.test.tsx create mode 100644 wallbreaker/dashboard/web/src/__tests__/Agent.autoscroll.test.tsx create mode 100644 wallbreaker/dashboard/web/src/__tests__/AsyncView.test.tsx create mode 100644 wallbreaker/dashboard/web/src/__tests__/Dialog.test.tsx create mode 100644 wallbreaker/dashboard/web/src/__tests__/Profiles.busy.test.tsx create mode 100644 wallbreaker/dashboard/web/src/__tests__/a11y.interactions.test.tsx create mode 100644 wallbreaker/dashboard/web/src/__tests__/axe.views.test.tsx create mode 100644 wallbreaker/dashboard/web/src/__tests__/format.test.ts create mode 100644 wallbreaker/dashboard/web/src/__tests__/no-danger.test.ts create mode 100644 wallbreaker/dashboard/web/src/__tests__/setup.ts create mode 100644 wallbreaker/dashboard/web/src/__tests__/staleGuard.test.tsx create mode 100644 wallbreaker/dashboard/web/src/format.ts create mode 100644 wallbreaker/dashboard/web/src/primitives/AsyncView.tsx create mode 100644 wallbreaker/dashboard/web/src/primitives/Combobox.tsx create mode 100644 wallbreaker/dashboard/web/src/primitives/Dialog.tsx create mode 100644 wallbreaker/dashboard/web/src/primitives/InteractiveChip.tsx create mode 100644 wallbreaker/dashboard/web/src/primitives/LiveRegion.tsx create mode 100644 wallbreaker/dashboard/web/src/primitives/useAbortableFetch.ts create mode 100644 wallbreaker/dashboard/web/vitest.config.ts diff --git a/.gitignore b/.gitignore index 82b0bd6..c4d3cea 100644 --- a/.gitignore +++ b/.gitignore @@ -77,3 +77,4 @@ wallbreaker/dashboard/web/vite.config.d.ts .serena/ *.log .DS_Store +wallbreaker/dashboard/web/node_modules/ diff --git a/wallbreaker/dashboard/web/bun.lock b/wallbreaker/dashboard/web/bun.lock new file mode 100644 index 0000000..9e8d5f3 --- /dev/null +++ b/wallbreaker/dashboard/web/bun.lock @@ -0,0 +1,637 @@ +{ + "lockfileVersion": 1, + "configVersion": 0, + "workspaces": { + "": { + "name": "wallbreaker-dashboard", + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + }, + "devDependencies": { + "@testing-library/jest-dom": "^7.0.0", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", + "@types/jest-axe": "^3.5.9", + "@types/node": "^26.1.1", + "@types/react": "^18.3.5", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.1", + "jest-axe": "^10.0.0", + "jsdom": "^29.1.1", + "typescript": "^5.5.4", + "vite": "^5.4.2", + "vitest": "^4.1.10", + }, + }, + }, + "packages": { + "@adobe/css-tools": ["@adobe/css-tools@4.5.0", "", {}, "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q=="], + + "@asamuzakjp/css-color": ["@asamuzakjp/css-color@5.1.11", "", { "dependencies": { "@asamuzakjp/generational-cache": "^1.0.1", "@csstools/css-calc": "^3.2.0", "@csstools/css-color-parser": "^4.1.0", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg=="], + + "@asamuzakjp/dom-selector": ["@asamuzakjp/dom-selector@7.1.1", "", { "dependencies": { "@asamuzakjp/generational-cache": "^1.0.1", "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", "css-tree": "^3.2.1", "is-potential-custom-element-name": "^1.0.1" } }, "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ=="], + + "@asamuzakjp/generational-cache": ["@asamuzakjp/generational-cache@1.0.1", "", {}, "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg=="], + + "@asamuzakjp/nwsapi": ["@asamuzakjp/nwsapi@2.3.9", "", {}, "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q=="], + + "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], + + "@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="], + + "@babel/core": ["@babel/core@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="], + + "@babel/generator": ["@babel/generator@7.29.7", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="], + + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="], + + "@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="], + + "@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="], + + "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="], + + "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.29.7", "", {}, "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw=="], + + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="], + + "@babel/helpers": ["@babel/helpers@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="], + + "@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": { "parser": "bin/babel-parser.js" } }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], + + "@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw=="], + + "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q=="], + + "@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="], + + "@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="], + + "@babel/traverse": ["@babel/traverse@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.7", "debug": "^4.3.1" } }, "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw=="], + + "@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], + + "@bramus/specificity": ["@bramus/specificity@2.4.2", "", { "dependencies": { "css-tree": "^3.0.0" }, "bin": { "specificity": "bin/cli.js" } }, "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw=="], + + "@csstools/color-helpers": ["@csstools/color-helpers@6.1.0", "", {}, "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg=="], + + "@csstools/css-calc": ["@csstools/css-calc@3.2.1", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg=="], + + "@csstools/css-color-parser": ["@csstools/css-color-parser@4.1.9", "", { "dependencies": { "@csstools/color-helpers": "^6.1.0", "@csstools/css-calc": "^3.2.1" }, "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-paQcIaOO53Rk5+YrBaBjm/SgrV4INImjo2BT1DtQRYr+XeTRbeAYlS+jxXp9drqvKmtFnWRJKIalDLhZZDu42A=="], + + "@csstools/css-parser-algorithms": ["@csstools/css-parser-algorithms@4.0.0", "", { "peerDependencies": { "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w=="], + + "@csstools/css-syntax-patches-for-csstree": ["@csstools/css-syntax-patches-for-csstree@1.1.6", "", { "peerDependencies": { "css-tree": "^3.2.1" }, "optionalPeers": ["css-tree"] }, "sha512-TcJCWFbXLPpJYq6z7bfOyjWYJDiDg2/I4gyUC9pqPNqHFRIey0EB0q0L5cSnQDfWJg8Jd6VadakxdIez/3zkqQ=="], + + "@csstools/css-tokenizer": ["@csstools/css-tokenizer@4.0.0", "", {}, "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA=="], + + "@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="], + + "@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="], + + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.21.5", "", { "os": "android", "cpu": "arm64" }, "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.21.5", "", { "os": "android", "cpu": "x64" }, "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.21.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.21.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.21.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.21.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.21.5", "", { "os": "linux", "cpu": "arm" }, "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.21.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.21.5", "", { "os": "linux", "cpu": "ia32" }, "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.21.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.21.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.21.5", "", { "os": "linux", "cpu": "x64" }, "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.21.5", "", { "os": "none", "cpu": "x64" }, "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.21.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.21.5", "", { "os": "sunos", "cpu": "x64" }, "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.21.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.21.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="], + + "@exodus/bytes": ["@exodus/bytes@1.15.1", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q=="], + + "@jest/diff-sequences": ["@jest/diff-sequences@30.4.0", "", {}, "sha512-zOpzlfUs45l6u7jm39qr87JCHUDsaeCtvL+kQe/Vn9jSnRB4/5IPXISm0h9I1vZW/o00Kn4UTJ2MOlhnUGwv3g=="], + + "@jest/expect-utils": ["@jest/expect-utils@30.4.1", "", { "dependencies": { "@jest/get-type": "30.1.0" } }, "sha512-ZBn5CglH8fBsQsvs4VWNzD4aWfUYks+IdOOQU3MEK71ol/BcVm+P+rtb1KpiFBpSWSCE27uOahyyf1vfqOVbcQ=="], + + "@jest/get-type": ["@jest/get-type@30.1.0", "", {}, "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA=="], + + "@jest/pattern": ["@jest/pattern@30.4.0", "", { "dependencies": { "@types/node": "*", "jest-regex-util": "30.4.0" } }, "sha512-RAWn3+f9u8BsHijKJ71uHcFp6vmyEt6VvoWXkl6hKF3qVIuWNmudVjg12DlBPGup/frIl5UcUlH5HfEuvHpEXg=="], + + "@jest/schemas": ["@jest/schemas@30.4.1", "", { "dependencies": { "@sinclair/typebox": "^0.34.0" } }, "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q=="], + + "@jest/types": ["@jest/types@30.4.1", "", { "dependencies": { "@jest/pattern": "30.4.0", "@jest/schemas": "30.4.1", "@types/istanbul-lib-coverage": "^2.0.6", "@types/istanbul-reports": "^3.0.4", "@types/node": "*", "@types/yargs": "^17.0.33", "chalk": "^4.1.2" } }, "sha512-f1x/vJXIfjOlEmejYpbkbgw1gOqpPECwMvMEtBqe47j7H2Hg8h8w3o3ikhSXq3MI15kg+oQ0exWO0uCtTNJLoQ=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="], + + "@oxc-project/types": ["@oxc-project/types@0.139.0", "", {}, "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw=="], + + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.1.5", "", { "os": "android", "cpu": "arm64" }, "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ=="], + + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.1.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw=="], + + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.1.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g=="], + + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.1.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA=="], + + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.1.5", "", { "os": "linux", "cpu": "arm" }, "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw=="], + + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.1.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q=="], + + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.1.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA=="], + + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.1.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg=="], + + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.1.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA=="], + + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.1.5", "", { "os": "linux", "cpu": "x64" }, "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ=="], + + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.1.5", "", { "os": "linux", "cpu": "x64" }, "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg=="], + + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.1.5", "", { "os": "none", "cpu": "arm64" }, "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw=="], + + "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.1.5", "", { "dependencies": { "@emnapi/core": "1.11.1", "@emnapi/runtime": "1.11.1", "@napi-rs/wasm-runtime": "^1.1.6" }, "cpu": "none" }, "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA=="], + + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.1.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw=="], + + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.1.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="], + + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.62.2", "", { "os": "android", "cpu": "arm" }, "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.62.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.62.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.62.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.62.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.62.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.62.2", "", { "os": "linux", "cpu": "arm" }, "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.62.2", "", { "os": "linux", "cpu": "arm" }, "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.62.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.62.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ=="], + + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg=="], + + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ=="], + + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.62.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A=="], + + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.62.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg=="], + + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.62.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.62.2", "", { "os": "linux", "cpu": "x64" }, "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.62.2", "", { "os": "linux", "cpu": "x64" }, "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg=="], + + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.62.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg=="], + + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.62.2", "", { "os": "none", "cpu": "arm64" }, "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.62.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.62.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q=="], + + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.62.2", "", { "os": "win32", "cpu": "x64" }, "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.62.2", "", { "os": "win32", "cpu": "x64" }, "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA=="], + + "@sinclair/typebox": ["@sinclair/typebox@0.34.52", "", {}, "sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw=="], + + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="], + + "@testing-library/jest-dom": ["@testing-library/jest-dom@7.0.0", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" }, "peerDependencies": { "@testing-library/dom": ">=10 <11" } }, "sha512-HKAH9C6mBo5yBG6yRO5i43L2iisencAo5z+o5P/saHUoY+miC5ivXRxHBJcFyB5ypPNxHJdK3BoF/3O4DIptMg=="], + + "@testing-library/react": ["@testing-library/react@16.3.2", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "@testing-library/dom": "^10.0.0", "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g=="], + + "@testing-library/user-event": ["@testing-library/user-event@14.6.1", "", { "peerDependencies": { "@testing-library/dom": ">=7.21.4" } }, "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw=="], + + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], + + "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], + + "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], + + "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], + + "@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="], + + "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], + + "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], + + "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], + + "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], + + "@types/istanbul-lib-coverage": ["@types/istanbul-lib-coverage@2.0.6", "", {}, "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w=="], + + "@types/istanbul-lib-report": ["@types/istanbul-lib-report@3.0.3", "", { "dependencies": { "@types/istanbul-lib-coverage": "*" } }, "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA=="], + + "@types/istanbul-reports": ["@types/istanbul-reports@3.0.4", "", { "dependencies": { "@types/istanbul-lib-report": "*" } }, "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ=="], + + "@types/jest": ["@types/jest@30.0.0", "", { "dependencies": { "expect": "^30.0.0", "pretty-format": "^30.0.0" } }, "sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA=="], + + "@types/jest-axe": ["@types/jest-axe@3.5.9", "", { "dependencies": { "@types/jest": "*", "axe-core": "^3.5.5" } }, "sha512-z98CzR0yVDalCEuhGXXO4/zN4HHuSebAukXDjTLJyjEAgoUf1H1i+sr7SUB/mz8CRS/03/XChsx0dcLjHkndoQ=="], + + "@types/node": ["@types/node@26.1.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw=="], + + "@types/prop-types": ["@types/prop-types@15.7.15", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="], + + "@types/react": ["@types/react@18.3.31", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" } }, "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw=="], + + "@types/react-dom": ["@types/react-dom@18.3.7", "", { "peerDependencies": { "@types/react": "^18.0.0" } }, "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ=="], + + "@types/stack-utils": ["@types/stack-utils@2.0.3", "", {}, "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw=="], + + "@types/yargs": ["@types/yargs@17.0.35", "", { "dependencies": { "@types/yargs-parser": "*" } }, "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg=="], + + "@types/yargs-parser": ["@types/yargs-parser@21.0.3", "", {}, "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ=="], + + "@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="], + + "@vitest/expect": ["@vitest/expect@4.1.10", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.10", "@vitest/utils": "4.1.10", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA=="], + + "@vitest/mocker": ["@vitest/mocker@4.1.10", "", { "dependencies": { "@vitest/spy": "4.1.10", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow=="], + + "@vitest/pretty-format": ["@vitest/pretty-format@4.1.10", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q=="], + + "@vitest/runner": ["@vitest/runner@4.1.10", "", { "dependencies": { "@vitest/utils": "4.1.10", "pathe": "^2.0.3" } }, "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg=="], + + "@vitest/snapshot": ["@vitest/snapshot@4.1.10", "", { "dependencies": { "@vitest/pretty-format": "4.1.10", "@vitest/utils": "4.1.10", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw=="], + + "@vitest/spy": ["@vitest/spy@4.1.10", "", {}, "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw=="], + + "@vitest/utils": ["@vitest/utils@4.1.10", "", { "dependencies": { "@vitest/pretty-format": "4.1.10", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA=="], + + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], + + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + + "axe-core": ["axe-core@3.5.6", "", {}, "sha512-LEUDjgmdJoA3LqklSTwKYqkjcZ4HKc4ddIYGSAiSkr46NTjzg2L9RNB+lekO9P7Dlpa87+hBtzc2Fzn/+GUWMQ=="], + + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.40", "", { "bin": "dist/cli.cjs" }, "sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw=="], + + "bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="], + + "browserslist": ["browserslist@4.28.4", "", { "dependencies": { "baseline-browser-mapping": "^2.10.38", "caniuse-lite": "^1.0.30001799", "electron-to-chromium": "^1.5.376", "node-releases": "^2.0.48", "update-browserslist-db": "^1.2.3" }, "bin": "cli.js" }, "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw=="], + + "caniuse-lite": ["caniuse-lite@1.0.30001800", "", {}, "sha512-MMHtuAz9Ys840zAY5F4k6fV5GaivZ9sPk+nz0mY+GYVzRBnYkN0mpqkSR92oWRQ19yQWo4HvBV/FnC16AJX8MA=="], + + "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], + + "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + + "ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="], + + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + + "css-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="], + + "css.escape": ["css.escape@1.5.1", "", {}, "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "data-urls": ["data-urls@7.0.0", "", { "dependencies": { "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.0" } }, "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="], + + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "diff-sequences": ["diff-sequences@29.6.3", "", {}, "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q=="], + + "dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="], + + "electron-to-chromium": ["electron-to-chromium@1.5.382", "", {}, "sha512-8ETaWbV6SZOrno+G93Ffd9ENsMtetqdnqj4nlfxFW90Sm5GgnuV28Kf62hqQVD6VUgzm7qFQKsTsAPmeUiU3Ug=="], + + "entities": ["entities@8.0.0", "", {}, "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA=="], + + "es-module-lexer": ["es-module-lexer@2.3.1", "", {}, "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA=="], + + "esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": "bin/esbuild" }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="], + + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "escape-string-regexp": ["escape-string-regexp@2.0.0", "", {}, "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="], + + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + + "expect": ["expect@30.4.1", "", { "dependencies": { "@jest/expect-utils": "30.4.1", "@jest/get-type": "30.1.0", "jest-matcher-utils": "30.4.1", "jest-message-util": "30.4.1", "jest-mock": "30.4.1", "jest-util": "30.4.1" } }, "sha512-PMARsyh/JtqC20HoGqlFcIlQAyqUtW4PlI1rup1uhYJtKuwAjbvWi3GQMAn+STdHum/dk8xrKfUM1+5SAwpolA=="], + + "expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + + "html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="], + + "indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="], + + "is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="], + + "jest-axe": ["jest-axe@10.0.0", "", { "dependencies": { "axe-core": "4.10.2", "chalk": "4.1.2", "jest-matcher-utils": "29.2.2", "lodash.merge": "4.6.2" } }, "sha512-9QR0M7//o5UVRnEUUm68IsGapHrcKGakYy9dKWWMX79LmeUKguDI6DREyljC5I13j78OUmtKLF5My6ccffLFBg=="], + + "jest-diff": ["jest-diff@29.7.0", "", { "dependencies": { "chalk": "^4.0.0", "diff-sequences": "^29.6.3", "jest-get-type": "^29.6.3", "pretty-format": "^29.7.0" } }, "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw=="], + + "jest-get-type": ["jest-get-type@29.6.3", "", {}, "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw=="], + + "jest-matcher-utils": ["jest-matcher-utils@29.2.2", "", { "dependencies": { "chalk": "^4.0.0", "jest-diff": "^29.2.1", "jest-get-type": "^29.2.0", "pretty-format": "^29.2.1" } }, "sha512-4DkJ1sDPT+UX2MR7Y3od6KtvRi9Im1ZGLGgdLFLm4lPexbTaCgJW5NN3IOXlQHF7NSHY/VHhflQ+WoKtD/vyCw=="], + + "jest-message-util": ["jest-message-util@30.4.1", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@jest/types": "30.4.1", "@types/stack-utils": "^2.0.3", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "jest-util": "30.4.1", "picomatch": "^4.0.3", "pretty-format": "30.4.1", "slash": "^3.0.0", "stack-utils": "^2.0.6" } }, "sha512-kwCKIvq0MCW1HzLoGola9Te6JUdzgV0loyKJ3Qghrkz9i5/RRIHsL95BMQc2HBBhlBKC4j22K9p11TGHH8RBpQ=="], + + "jest-mock": ["jest-mock@30.4.1", "", { "dependencies": { "@jest/types": "30.4.1", "@types/node": "*", "jest-util": "30.4.1" } }, "sha512-/i8SVb8/NSB7RfNi8gfqu8gxLV23KaL5EpAttyb9iz8qWRIqXRLflycz/32wXsYkOnaUlx8NAKnJYtpsmXUmfw=="], + + "jest-regex-util": ["jest-regex-util@30.4.0", "", {}, "sha512-mWlvLviKIgIQ8VCuM1xRdD0TWp3zlzionlmDBjuXVBs+VkmXq6FgW9T4Emr7oGz/Rk6feDCGyiugolcQEyp3mg=="], + + "jest-util": ["jest-util@30.4.1", "", { "dependencies": { "@jest/types": "30.4.1", "@types/node": "*", "chalk": "^4.1.2", "ci-info": "^4.2.0", "graceful-fs": "^4.2.11", "picomatch": "^4.0.3" } }, "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw=="], + + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "jsdom": ["jsdom@29.1.1", "", { "dependencies": { "@asamuzakjp/css-color": "^5.1.11", "@asamuzakjp/dom-selector": "^7.1.1", "@bramus/specificity": "^2.4.2", "@csstools/css-syntax-patches-for-csstree": "^1.1.3", "@exodus/bytes": "^1.15.0", "css-tree": "^3.2.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.3.5", "parse5": "^8.0.1", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.1", "undici": "^7.25.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.1", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q=="], + + "jsesc": ["jsesc@3.1.0", "", { "bin": "bin/jsesc" }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + + "json5": ["json5@2.2.3", "", { "bin": "lib/cli.js" }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + + "lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="], + + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.33.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.33.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.33.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.33.0", "", { "os": "linux", "cpu": "arm" }, "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.33.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="], + + "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], + + "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": "cli.js" }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], + + "lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="], + + "lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="], + + "min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "nanoid": ["nanoid@3.3.15", "", { "bin": "bin/nanoid.cjs" }, "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA=="], + + "node-releases": ["node-releases@2.0.50", "", {}, "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg=="], + + "obug": ["obug@2.1.4", "", {}, "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA=="], + + "parse5": ["parse5@8.0.1", "", { "dependencies": { "entities": "^8.0.0" } }, "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw=="], + + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], + + "postcss": ["postcss@8.5.16", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg=="], + + "pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], + + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + + "react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="], + + "react-dom": ["react-dom@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw=="], + + "react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], + + "react-is-18": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], + + "react-is-19": ["react-is@19.2.8", "", {}, "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ=="], + + "react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="], + + "redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="], + + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + + "rolldown": ["rolldown@1.1.5", "", { "dependencies": { "@oxc-project/types": "=0.139.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.1.5", "@rolldown/binding-darwin-arm64": "1.1.5", "@rolldown/binding-darwin-x64": "1.1.5", "@rolldown/binding-freebsd-x64": "1.1.5", "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", "@rolldown/binding-linux-arm64-gnu": "1.1.5", "@rolldown/binding-linux-arm64-musl": "1.1.5", "@rolldown/binding-linux-ppc64-gnu": "1.1.5", "@rolldown/binding-linux-s390x-gnu": "1.1.5", "@rolldown/binding-linux-x64-gnu": "1.1.5", "@rolldown/binding-linux-x64-musl": "1.1.5", "@rolldown/binding-openharmony-arm64": "1.1.5", "@rolldown/binding-wasm32-wasi": "1.1.5", "@rolldown/binding-win32-arm64-msvc": "1.1.5", "@rolldown/binding-win32-x64-msvc": "1.1.5" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA=="], + + "rollup": ["rollup@4.62.2", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.62.2", "@rollup/rollup-android-arm64": "4.62.2", "@rollup/rollup-darwin-arm64": "4.62.2", "@rollup/rollup-darwin-x64": "4.62.2", "@rollup/rollup-freebsd-arm64": "4.62.2", "@rollup/rollup-freebsd-x64": "4.62.2", "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", "@rollup/rollup-linux-arm-musleabihf": "4.62.2", "@rollup/rollup-linux-arm64-gnu": "4.62.2", "@rollup/rollup-linux-arm64-musl": "4.62.2", "@rollup/rollup-linux-loong64-gnu": "4.62.2", "@rollup/rollup-linux-loong64-musl": "4.62.2", "@rollup/rollup-linux-ppc64-gnu": "4.62.2", "@rollup/rollup-linux-ppc64-musl": "4.62.2", "@rollup/rollup-linux-riscv64-gnu": "4.62.2", "@rollup/rollup-linux-riscv64-musl": "4.62.2", "@rollup/rollup-linux-s390x-gnu": "4.62.2", "@rollup/rollup-linux-x64-gnu": "4.62.2", "@rollup/rollup-linux-x64-musl": "4.62.2", "@rollup/rollup-openbsd-x64": "4.62.2", "@rollup/rollup-openharmony-arm64": "4.62.2", "@rollup/rollup-win32-arm64-msvc": "4.62.2", "@rollup/rollup-win32-ia32-msvc": "4.62.2", "@rollup/rollup-win32-x64-gnu": "4.62.2", "@rollup/rollup-win32-x64-msvc": "4.62.2", "fsevents": "~2.3.2" }, "bin": "dist/bin/rollup" }, "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA=="], + + "saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="], + + "scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="], + + "semver": ["semver@6.3.1", "", { "bin": "bin/semver.js" }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], + + "slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "stack-utils": ["stack-utils@2.0.6", "", { "dependencies": { "escape-string-regexp": "^2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="], + + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + + "std-env": ["std-env@4.2.0", "", {}, "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw=="], + + "strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="], + + "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + + "symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="], + + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], + + "tinyexec": ["tinyexec@1.2.4", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="], + + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="], + + "tldts": ["tldts@7.4.9", "", { "dependencies": { "tldts-core": "^7.4.9" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-3kZ8wQQ/k5DrChD4X4FVvr2D7E5uoRgAqkPyLpSCGUvqOvqu+JEdr3mwMUaVWb+vMHZaKhF5fp2PBigKsui7hA=="], + + "tldts-core": ["tldts-core@7.4.9", "", {}, "sha512-DxKfPBI52p2msTEu7MPhdpdDTBhhVQg1a/8PjQckeyAvO13eMYElX545grIp6nnTGIMZlRvFZPvFhvI/WIz2Vg=="], + + "tough-cookie": ["tough-cookie@6.0.2", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA=="], + + "tr46": ["tr46@6.0.0", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici": ["undici@7.28.0", "", {}, "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA=="], + + "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + + "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": "cli.js" }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], + + "vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": "bin/vite.js" }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="], + + "vitest": ["vitest@4.1.10", "", { "dependencies": { "@vitest/expect": "4.1.10", "@vitest/mocker": "4.1.10", "@vitest/pretty-format": "4.1.10", "@vitest/runner": "4.1.10", "@vitest/snapshot": "4.1.10", "@vitest/spy": "4.1.10", "@vitest/utils": "4.1.10", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.10", "@vitest/browser-preview": "4.1.10", "@vitest/browser-webdriverio": "4.1.10", "@vitest/coverage-istanbul": "4.1.10", "@vitest/coverage-v8": "4.1.10", "@vitest/ui": "4.1.10", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw=="], + + "w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="], + + "webidl-conversions": ["webidl-conversions@8.0.1", "", {}, "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ=="], + + "whatwg-mimetype": ["whatwg-mimetype@5.0.0", "", {}, "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="], + + "whatwg-url": ["whatwg-url@16.0.1", "", { "dependencies": { "@exodus/bytes": "^1.11.0", "tr46": "^6.0.0", "webidl-conversions": "^8.0.1" } }, "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw=="], + + "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + + "xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="], + + "xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="], + + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + + "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + + "@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="], + + "@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], + + "@types/jest/pretty-format": ["pretty-format@30.4.1", "", { "dependencies": { "@jest/schemas": "30.4.1", "ansi-styles": "^5.2.0", "react-is-18": "npm:react-is@^18.3.1", "react-is-19": "npm:react-is@^19.2.5" } }, "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw=="], + + "expect/jest-matcher-utils": ["jest-matcher-utils@30.4.1", "", { "dependencies": { "@jest/get-type": "30.1.0", "chalk": "^4.1.2", "jest-diff": "30.4.1", "pretty-format": "30.4.1" } }, "sha512-zvYfX5CaeEkFrrLS9suWe9rvJrm9J1Iv3ua8kIBv9GEPzcnsfBf0bob37la7s67fs0nlBC3EuvkOLnXQKxtx4A=="], + + "jest-axe/axe-core": ["axe-core@4.10.2", "", {}, "sha512-RE3mdQ7P3FRSe7eqCWoeQ/Z9QXrtniSjp1wUjt5nRC3WIpz5rSCve6o3fsZ2aCpJtrZjSZgjwXAoTO5k4tEI0w=="], + + "jest-diff/pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="], + + "jest-matcher-utils/pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="], + + "jest-message-util/pretty-format": ["pretty-format@30.4.1", "", { "dependencies": { "@jest/schemas": "30.4.1", "ansi-styles": "^5.2.0", "react-is-18": "npm:react-is@^18.3.1", "react-is-19": "npm:react-is@^19.2.5" } }, "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw=="], + + "pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], + + "rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], + + "vitest/vite": ["vite@8.1.5", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.5", "postcss": "^8.5.17", "rolldown": "~1.1.5", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw=="], + + "@types/jest/pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], + + "expect/jest-matcher-utils/jest-diff": ["jest-diff@30.4.1", "", { "dependencies": { "@jest/diff-sequences": "30.4.0", "@jest/get-type": "30.1.0", "chalk": "^4.1.2", "pretty-format": "30.4.1" } }, "sha512-CRpFK0RtLriVDGcPPAnR6HMVI8bSR2jnUIgralhauzYQZIb4RH9AtEInTuQr65LmmGggGcRT6HIASxwqsVsmlA=="], + + "expect/jest-matcher-utils/pretty-format": ["pretty-format@30.4.1", "", { "dependencies": { "@jest/schemas": "30.4.1", "ansi-styles": "^5.2.0", "react-is-18": "npm:react-is@^18.3.1", "react-is-19": "npm:react-is@^19.2.5" } }, "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw=="], + + "jest-diff/pretty-format/@jest/schemas": ["@jest/schemas@29.6.3", "", { "dependencies": { "@sinclair/typebox": "^0.27.8" } }, "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA=="], + + "jest-diff/pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], + + "jest-diff/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], + + "jest-matcher-utils/pretty-format/@jest/schemas": ["@jest/schemas@29.6.3", "", { "dependencies": { "@sinclair/typebox": "^0.27.8" } }, "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA=="], + + "jest-matcher-utils/pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], + + "jest-matcher-utils/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], + + "jest-message-util/pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], + + "vitest/vite/postcss": ["postcss@8.5.21", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-v4sDNP3fdNiWMfabO7OwOQdOX8TiQSztKyT1Wj0w+j7LDallJThJRBBBmzVGyYj0crMh7jlV4zepPkiNu9UwDQ=="], + + "expect/jest-matcher-utils/pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], + + "jest-diff/pretty-format/@jest/schemas/@sinclair/typebox": ["@sinclair/typebox@0.27.12", "", {}, "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g=="], + + "jest-matcher-utils/pretty-format/@jest/schemas/@sinclair/typebox": ["@sinclair/typebox@0.27.12", "", {}, "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g=="], + + "vitest/vite/postcss/nanoid": ["nanoid@3.3.16", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q=="], + } +} diff --git a/wallbreaker/dashboard/web/package.json b/wallbreaker/dashboard/web/package.json index 927c7a6..6065434 100644 --- a/wallbreaker/dashboard/web/package.json +++ b/wallbreaker/dashboard/web/package.json @@ -6,17 +6,26 @@ "scripts": { "dev": "vite", "build": "tsc -b && vite build", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest run" }, "dependencies": { "react": "^18.3.1", "react-dom": "^18.3.1" }, "devDependencies": { + "@testing-library/jest-dom": "^7.0.0", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", + "@types/jest-axe": "^3.5.9", + "@types/node": "^26.1.1", "@types/react": "^18.3.5", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.1", + "jest-axe": "^10.0.0", + "jsdom": "^29.1.1", "typescript": "^5.5.4", - "vite": "^5.4.2" + "vite": "^5.4.2", + "vitest": "^4.1.10" } } diff --git a/wallbreaker/dashboard/web/src/App.tsx b/wallbreaker/dashboard/web/src/App.tsx index f06dec9..4de1034 100644 --- a/wallbreaker/dashboard/web/src/App.tsx +++ b/wallbreaker/dashboard/web/src/App.tsx @@ -9,6 +9,7 @@ import { Arsenal } from "./components/Arsenal"; import { Settings } from "./components/Settings"; import { RoleChooser } from "./components/RoleChooser"; import { Profiles } from "./components/Profiles"; +import type { AsyncStatus } from "./primitives/AsyncView"; type Tab = "agent" | "overview" | "console" | "findings" | "runs" | "arsenal" | "profiles" | "settings"; @@ -36,14 +37,26 @@ export function App() { const setTab = (t: Tab) => { setTabState(t); window.location.hash = t; }; const [cfg, setCfg] = useState(null); const [ov, setOv] = useState(null); + const [ovStatus, setOvStatus] = useState("loading"); + const [ovError, setOvError] = useState(null); const [roles, setRoles] = useState(null); - - const refresh = () => { - api.config().then(setCfg).catch(() => setCfg(null)); - api.overview().then(setOv).catch(() => setOv(null)); - api.roles().then(setRoles).catch(() => setRoles(null)); - }; - useEffect(refresh, [tab]); + // Bumped to re-run the load effect on demand (AsyncView Retry). + const [reloadTick, setReloadTick] = useState(0); + const refresh = () => setReloadTick((n) => n + 1); + // REL-5: guard the tab-change refetch so a slow response for a prior tab can + // never overwrite state after the tab (and thus the request key) has changed. + // REL-9: overview load carries a DISTINCT error status (never a null=loading). + useEffect(() => { + let active = true; + api.config().then((v) => { if (active) setCfg(v); }).catch(() => { if (active) setCfg(null); }); + setOvStatus("loading"); + setOvError(null); + api.overview() + .then((v) => { if (active) { setOv(v); setOvStatus("data"); } }) + .catch((e) => { if (active) { setOv(null); setOvError((e as Error).message); setOvStatus("error"); } }); + api.roles().then((v) => { if (active) setRoles(v); }).catch(() => { if (active) setRoles(null); }); + return () => { active = false; }; + }, [tab, reloadTick]); const asr = ov?.scorecard?.asr; const asrStr = typeof asr === "number" ? `${Math.round(asr * 100)}%` : "—"; @@ -57,7 +70,10 @@ export function App() { return (
- +
-
{NAV.find((n) => n.id === tab)?.label}
+

{NAV.find((n) => n.id === tab)?.label}

{roles && (["attacker", "target", "judge"] as const).map((role) => ASR {asrStr}
-
+
{tab === "agent" && } - {tab === "overview" && } + {tab === "overview" && } {tab === "console" && } {tab === "findings" && } {tab === "runs" && } {tab === "arsenal" && } {tab === "settings" && } {tab === "profiles" && } -
+
); diff --git a/wallbreaker/dashboard/web/src/__tests__/Agent.abort.test.tsx b/wallbreaker/dashboard/web/src/__tests__/Agent.abort.test.tsx new file mode 100644 index 0000000..626a475 --- /dev/null +++ b/wallbreaker/dashboard/web/src/__tests__/Agent.abort.test.tsx @@ -0,0 +1,50 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, cleanup, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +// Capture the signal handed to runAgent so we can assert it was aborted on unmount. +let capturedSignal: AbortSignal | null = null; + +vi.mock("../api", () => ({ + runAgent: vi.fn((_body: unknown, _onEvent: unknown, signal?: AbortSignal) => { + capturedSignal = signal ?? null; + // Never resolves on its own — only the abort ends it (mirrors a live SSE stream). + return new Promise((_resolve, reject) => { + signal?.addEventListener("abort", () => reject(new DOMException("aborted", "AbortError"))); + }); + }), + verdictKind: () => "neutral", + api: { + settings: vi.fn().mockResolvedValue({ agent: undefined }), + tools: vi.fn().mockResolvedValue([]), + agentProfiles: vi.fn().mockResolvedValue({ roles: { attacker: { profiles: [] } } }), + }, +})); + +import { Agent } from "../components/Agent"; + +beforeEach(() => { capturedSignal = null; }); +afterEach(cleanup); + +describe("Agent SSE abort (REL-4)", () => { + it("aborts the in-flight fetch when the component unmounts mid-stream", async () => { + const warn = vi.spyOn(console, "error").mockImplementation(() => {}); + const { unmount } = render(); + + const textarea = await screen.findByPlaceholderText(/assess whether the target/i); + await userEvent.type(textarea, "probe the target"); + await userEvent.click(screen.getByRole("button", { name: /RUN AGENT/i })); + + await waitFor(() => expect(capturedSignal).not.toBeNull()); + expect(capturedSignal!.aborted).toBe(false); + + unmount(); + + // The unmount cleanup must abort the controller passed into runAgent. + expect(capturedSignal!.aborted).toBe(true); + // No "setState after unmount" React warning should have been logged. + const warned = warn.mock.calls.some((c) => String(c[0]).includes("unmounted component")); + expect(warned).toBe(false); + warn.mockRestore(); + }); +}); diff --git a/wallbreaker/dashboard/web/src/__tests__/Agent.autoscroll.test.tsx b/wallbreaker/dashboard/web/src/__tests__/Agent.autoscroll.test.tsx new file mode 100644 index 0000000..d0e64d8 --- /dev/null +++ b/wallbreaker/dashboard/web/src/__tests__/Agent.autoscroll.test.tsx @@ -0,0 +1,107 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, cleanup, act, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +// Capture the onEvent callback runAgent is given so the test can stream events +// into the transcript at will (mirrors a live SSE stream). +type AgentEvent = Record; +let capturedOnEvent: ((ev: AgentEvent) => void) | null = null; + +vi.mock("../api", () => ({ + runAgent: vi.fn((_body: unknown, onEvent: (ev: AgentEvent) => void) => { + capturedOnEvent = onEvent; + // Never resolves on its own — the run stays "live" for the test. + return new Promise(() => {}); + }), + verdictKind: () => "neutral", + api: { + settings: vi.fn().mockResolvedValue({ agent: undefined }), + tools: vi.fn().mockResolvedValue([]), + agentProfiles: vi.fn().mockResolvedValue({ roles: { attacker: { profiles: [] } } }), + }, +})); + +import { Agent } from "../components/Agent"; + +// jsdom does not lay out elements, so scroll metrics are always 0. Define them +// on the prototype so the component's isPinnedToBottom() reads our values. +function stubScrollMetrics({ scrollTop, scrollHeight, clientHeight }: { + scrollTop: number; scrollHeight: number; clientHeight: number; +}) { + Object.defineProperty(HTMLElement.prototype, "scrollHeight", { configurable: true, get: () => scrollHeight }); + Object.defineProperty(HTMLElement.prototype, "clientHeight", { configurable: true, get: () => clientHeight }); + // scrollTop must be read/writable so we can detect if the component wrote to it. + let value = scrollTop; + Object.defineProperty(HTMLElement.prototype, "scrollTop", { + configurable: true, + get: () => value, + set: (v: number) => { value = v; }, + }); +} + +function restoreScrollMetrics() { + for (const prop of ["scrollHeight", "clientHeight", "scrollTop"]) { + // Deleting the own prototype override restores jsdom's default behaviour. + // @ts-expect-error dynamic delete on prototype + delete HTMLElement.prototype[prop]; + } +} + +const originalRaf = window.requestAnimationFrame; +const originalMatchMedia = window.matchMedia; + +beforeEach(() => { + capturedOnEvent = null; + // rAF runs synchronously so the auto-scroll (if any) happens within act(). + window.requestAnimationFrame = ((cb: FrameRequestCallback) => { cb(0); return 0; }) as typeof window.requestAnimationFrame; + // Reduced-motion off (matchMedia is undefined in jsdom) so the pinned-to-bottom + // check is the only gate under test. + window.matchMedia = ((q: string) => ({ + matches: false, media: q, onchange: null, + addListener: () => {}, removeListener: () => {}, + addEventListener: () => {}, removeEventListener: () => {}, dispatchEvent: () => false, + })) as unknown as typeof window.matchMedia; +}); + +afterEach(() => { + restoreScrollMetrics(); + window.requestAnimationFrame = originalRaf; + window.matchMedia = originalMatchMedia; + vi.restoreAllMocks(); + cleanup(); +}); + +async function startRun() { + const textarea = await screen.findByPlaceholderText(/assess whether the target/i); + await userEvent.type(textarea, "probe the target"); + await userEvent.click(screen.getByRole("button", { name: /RUN AGENT/i })); + await waitFor(() => expect(capturedOnEvent).not.toBeNull()); +} + +describe("Agent transcript auto-scroll (VIS-5)", () => { + it("does NOT auto-scroll when the user has scrolled up", async () => { + // User scrolled way up: distance from bottom (900 - 0 - 100 = 800) >> threshold. + stubScrollMetrics({ scrollTop: 0, scrollHeight: 900, clientHeight: 100 }); + render(); + await startRun(); + + act(() => { capturedOnEvent!({ type: "text", text: "streamed line" }); }); + + // The pane was left where the user put it — no programmatic jump to the bottom. + const pane = document.querySelector(".transcript") as HTMLElement; + expect(pane.scrollTop).toBe(0); + }); + + it("auto-scrolls to the bottom when the user is already pinned", async () => { + // Pinned: distance from bottom (900 - 800 - 100 = 0) <= threshold. + stubScrollMetrics({ scrollTop: 800, scrollHeight: 900, clientHeight: 100 }); + render(); + await startRun(); + + act(() => { capturedOnEvent!({ type: "text", text: "streamed line" }); }); + + const pane = document.querySelector(".transcript") as HTMLElement; + // Followed the stream: scrollTop was set to scrollHeight. + expect(pane.scrollTop).toBe(900); + }); +}); diff --git a/wallbreaker/dashboard/web/src/__tests__/AsyncView.test.tsx b/wallbreaker/dashboard/web/src/__tests__/AsyncView.test.tsx new file mode 100644 index 0000000..717360c --- /dev/null +++ b/wallbreaker/dashboard/web/src/__tests__/AsyncView.test.tsx @@ -0,0 +1,56 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { useEffect, useState } from "react"; +import { render, screen, waitFor, cleanup } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { AsyncView, type AsyncStatus } from "../primitives/AsyncView"; + +afterEach(cleanup); + +// A component that mirrors the REL-9 pattern: catch -> error status (NOT loading). +function Harness({ fetcher }: { fetcher: () => Promise }) { + const [status, setStatus] = useState("loading"); + const [data, setData] = useState(); + const [error, setError] = useState(null); + const [tick, setTick] = useState(0); + + useEffect(() => { + let active = true; + setStatus("loading"); + fetcher() + .then((v) => { if (active) { setData(v); setStatus("data"); } }) + .catch((e) => { if (active) { setError((e as Error).message); setStatus("error"); } }); + return () => { active = false; }; + }, [fetcher, tick]); + + return ( + status={status} data={data} error={error} onRetry={() => setTick((n) => n + 1)}> + {(value) =>
{value}
} + + ); +} + +describe("AsyncView (REL-9)", () => { + it("renders the error state with a Retry button on a rejected fetch (not a spinner)", async () => { + const fetcher = vi.fn().mockRejectedValueOnce(new Error("boom")); + render(); + + // The error card appears; it must NOT be stuck showing a loading spinner. + await screen.findByRole("alert"); + expect(screen.getByText("boom")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Retry" })).toBeInTheDocument(); + expect(screen.queryByText("Loading…")).not.toBeInTheDocument(); + }); + + it("Retry re-runs the fetch and shows data on success", async () => { + const fetcher = vi.fn() + .mockRejectedValueOnce(new Error("boom")) + .mockResolvedValueOnce("ok-now"); + render(); + + await screen.findByRole("button", { name: "Retry" }); + await userEvent.click(screen.getByRole("button", { name: "Retry" })); + + await waitFor(() => expect(screen.getByTestId("data")).toHaveTextContent("ok-now")); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); +}); diff --git a/wallbreaker/dashboard/web/src/__tests__/Dialog.test.tsx b/wallbreaker/dashboard/web/src/__tests__/Dialog.test.tsx new file mode 100644 index 0000000..bc706fc --- /dev/null +++ b/wallbreaker/dashboard/web/src/__tests__/Dialog.test.tsx @@ -0,0 +1,59 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { useState } from "react"; +import { render, screen, cleanup } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { Dialog } from "../primitives/Dialog"; + +afterEach(cleanup); + +function Harness() { + const [open, setOpen] = useState(false); + return ( +
+ + setOpen(false)}> + + + +
+ ); +} + +describe("Dialog (accessibility)", () => { + it("exposes role=dialog, aria-modal and aria-labelledby", async () => { + render(); + await userEvent.click(screen.getByRole("button", { name: "open dialog" })); + const dialog = screen.getByRole("dialog"); + expect(dialog).toHaveAttribute("aria-modal", "true"); + const labelledby = dialog.getAttribute("aria-labelledby"); + expect(labelledby).toBeTruthy(); + expect(document.getElementById(labelledby!)).toHaveTextContent("Test dialog"); + }); + + it("traps focus: Tab from the last element cycles back to the first", async () => { + render(); + await userEvent.click(screen.getByRole("button", { name: "open dialog" })); + const first = screen.getByLabelText("first"); + const last = screen.getByRole("button", { name: "last" }); + + last.focus(); + expect(last).toHaveFocus(); + await userEvent.tab(); + expect(first).toHaveFocus(); + + // Shift+Tab from the first wraps to the last. + await userEvent.tab({ shift: true }); + expect(last).toHaveFocus(); + }); + + it("closes on Escape and restores focus to the trigger", async () => { + render(); + const trigger = screen.getByRole("button", { name: "open dialog" }); + await userEvent.click(trigger); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + + await userEvent.keyboard("{Escape}"); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + expect(trigger).toHaveFocus(); + }); +}); diff --git a/wallbreaker/dashboard/web/src/__tests__/Profiles.busy.test.tsx b/wallbreaker/dashboard/web/src/__tests__/Profiles.busy.test.tsx new file mode 100644 index 0000000..e7a364b --- /dev/null +++ b/wallbreaker/dashboard/web/src/__tests__/Profiles.busy.test.tsx @@ -0,0 +1,60 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, screen, cleanup, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { AgentProfilesResponse } from "../api"; + +// vi.mock is hoisted, so the mock state must be created via vi.hoisted. +const mocks = vi.hoisted(() => { + const roleData = (role: "attacker" | "target" | "judge") => ({ + active: { provider: "openrouter", model: "m", profile: "", custom: true, prompt_source: "none", has_system_prompt: false }, + profiles: role === "attacker" + ? [{ name: "p1", role, provider: "openrouter", model: "m", prompt_source: "none", system_prompt: "", system_prompt_file: "" }] + : [], + }); + const profilesResponse = { + roles: { attacker: roleData("attacker"), target: roleData("target"), judge: roleData("judge") }, + } as unknown as AgentProfilesResponse; + const state: { deleteResolve: (() => void) | null } = { deleteResolve: null }; + const deleteAgentProfile = vi.fn((..._args: unknown[]) => new Promise<{ ok: boolean }>((resolve) => { + state.deleteResolve = () => resolve({ ok: true }); + })); + return { profilesResponse, state, deleteAgentProfile }; +}); + +vi.mock("../api", () => ({ + api: { + agentProfiles: vi.fn().mockResolvedValue(mocks.profilesResponse), + deleteAgentProfile: mocks.deleteAgentProfile, + saveAgentProfile: vi.fn().mockResolvedValue({}), + saveRole: vi.fn().mockResolvedValue({}), + }, +})); + +// Keep child choosers inert. +vi.mock("../components/ModelChooser", () => ({ ModelChooser: () => null })); +vi.mock("../components/ProviderChooser", () => ({ ProviderChooser: () => null })); + +import { Profiles } from "../components/Profiles"; + +afterEach(() => { cleanup(); mocks.deleteAgentProfile.mockClear(); mocks.state.deleteResolve = null; }); + +describe("Profiles double-submit guard (REL-10)", () => { + it("fires exactly one request on a double-click of Remove", async () => { + render(); + + // Find the attacker card's Remove button. + const heading = await screen.findByText("attacker profiles"); + const card = heading.closest("section")!; + const remove = within(card).getByRole("button", { name: "Remove" }); + + // Two rapid clicks while the first mutation is still pending. + await userEvent.click(remove); + await userEvent.click(remove); + + expect(mocks.deleteAgentProfile).toHaveBeenCalledTimes(1); + + // Resolve the in-flight mutation so the component settles cleanly. + mocks.state.deleteResolve?.(); + await waitFor(() => expect(mocks.deleteAgentProfile).toHaveBeenCalledTimes(1)); + }); +}); diff --git a/wallbreaker/dashboard/web/src/__tests__/a11y.interactions.test.tsx b/wallbreaker/dashboard/web/src/__tests__/a11y.interactions.test.tsx new file mode 100644 index 0000000..6c83f0c --- /dev/null +++ b/wallbreaker/dashboard/web/src/__tests__/a11y.interactions.test.tsx @@ -0,0 +1,93 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, screen, cleanup, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +// TG7 focused RTL tests for the interactive a11y wiring: +// - A11Y-3: Console transform chip toggles via keyboard (Space/Enter) and flips +// aria-pressed. +// - A11Y-1: a Dialog surface (RoleChooser menu) traps focus, closes on Escape, +// and restores focus to the trigger. +// - A11Y-2: the ModelChooser combobox exposes aria-activedescendant on ArrowDown. + +const mockApi = vi.hoisted(() => ({ + presets: vi.fn().mockResolvedValue([]), + transforms: vi.fn().mockResolvedValue([ + { name: "base64", description: "base64 encode", lossy: false, reversible: true }, + ]), + agentProfiles: vi.fn().mockResolvedValue({ + roles: { + attacker: { active: {}, profiles: [] }, + target: { active: {}, profiles: [] }, + judge: { active: {}, profiles: [] }, + }, + }), + saveRole: vi.fn().mockResolvedValue({}), + models: vi.fn().mockResolvedValue({ profile: "openrouter", protocol: "openai", models: ["gpt-x", "claude-y", "grok-z"], fetched: true, error: "" }), + refreshModels: vi.fn().mockResolvedValue({ profile: "openrouter", protocol: "openai", models: ["gpt-x"], fetched: true, error: "" }), + addModel: vi.fn().mockResolvedValue({}), +})); + +vi.mock("../api", async () => { + const actual = await vi.importActual("../api"); + return { ...actual, api: mockApi }; +}); +// Keep RoleChooser's nested choosers inert so the focus-trap test is deterministic. +vi.mock("../components/ProviderChooser", () => ({ ProviderChooser: () => null })); + +import { Console } from "../components/Console"; +import { RoleChooser } from "../components/RoleChooser"; +import { ModelChooser } from "../components/ModelChooser"; + +afterEach(cleanup); + +describe("A11Y-3: Console transform chip is a keyboard button with aria-pressed", () => { + it("toggles aria-pressed via Space and Enter", async () => { + render(); + const chip = await screen.findByRole("button", { name: "base64" }); + expect(chip).toHaveAttribute("aria-pressed", "false"); + + chip.focus(); + await userEvent.keyboard(" "); + expect(chip).toHaveAttribute("aria-pressed", "true"); + + await userEvent.keyboard("{Enter}"); + expect(chip).toHaveAttribute("aria-pressed", "false"); + }); +}); + +describe("A11Y-1: RoleChooser menu is a focus-trapping Dialog", () => { + it("opens on the chip, closes on Escape, and restores focus to the trigger", async () => { + const value = { provider: "openrouter", model: "m", profile: "", custom: true, prompt_source: "none" as const, has_system_prompt: false }; + render( {}} />); + + const trigger = screen.getByRole("button", { name: /attacker/i }); + await userEvent.click(trigger); + + const dialog = await screen.findByRole("dialog"); + expect(dialog).toHaveAttribute("aria-modal", "true"); + + await userEvent.keyboard("{Escape}"); + await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); + expect(trigger).toHaveFocus(); + }); +}); + +describe("A11Y-2: ModelChooser combobox exposes aria-activedescendant on ArrowDown", () => { + it("sets aria-controls and moves aria-activedescendant to the first option", async () => { + render( {}} ariaLabel="Target model" />); + const input = screen.getByRole("combobox", { name: "Target model" }); + + // aria-controls points at the listbox id even before it opens. + const listId = input.getAttribute("aria-controls"); + expect(listId).toBeTruthy(); + + input.focus(); + await waitFor(() => expect(mockApi.models).toHaveBeenCalled()); + await userEvent.keyboard("{ArrowDown}"); + + const active = input.getAttribute("aria-activedescendant"); + expect(active).toBeTruthy(); + // The highlighted option's id must match aria-activedescendant. + expect(document.getElementById(active!)).toHaveAttribute("role", "option"); + }); +}); diff --git a/wallbreaker/dashboard/web/src/__tests__/axe.views.test.tsx b/wallbreaker/dashboard/web/src/__tests__/axe.views.test.tsx new file mode 100644 index 0000000..4b63886 --- /dev/null +++ b/wallbreaker/dashboard/web/src/__tests__/axe.views.test.tsx @@ -0,0 +1,183 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, cleanup, waitFor, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { axe, toHaveNoViolations } from "jest-axe"; + +expect.extend(toHaveNoViolations); + +// TG7 (WCAG 2.2 AA) — automated axe-core sweep of every main view. Each view is +// rendered with a mocked ../api (so it renders without a backend) and asserted to +// have zero axe violations for the criteria this task addressed. +// +// We scope axe to the rules that map to the applied findings (labels, buttons vs +// spans, aria on comboboxes/dialogs, list/landmark structure, image alt, etc.). +// Color-contrast is verified statically (jsdom has no layout/paint, so axe's +// color-contrast check cannot run reliably here) — see the note in the report. + +const RULES = { + rules: { + // jsdom cannot compute rendered colors → contrast is checked outside the browser. + "color-contrast": { enabled: false }, + // These views are rendered in isolation (no
/App shell), so the + // page-level "all content in a landmark" rule is not meaningful here — the + // real landmark structure (nav/main/h1/skip-link) is asserted separately in + // the App shell test (A11Y-13). The Dialog also portals into document.body. + region: { enabled: false }, + }, +}; + +// A permissive api mock: every method resolves to an empty/minimal shape so the +// views render their populated (not just loading) states. Defined via vi.hoisted +// so it is available inside the hoisted vi.mock factory. +const mockApi = vi.hoisted(() => ({ + overview: vi.fn().mockResolvedValue({ + config: { has_target: true, target: "t", target_modality: "text", profile: "p", judge: "j" }, + scorecard: { asr: 0.25, total: 8, hits: 2, grade: "B", by_technique: { author_persona: { hits: 1, total: 4 } } }, + findings_count: 2, runs_count: 3, latest_run: "run-1", + }), + config: vi.fn().mockResolvedValue({ has_target: true, target: "t", profile: "p", judge: "j" }), + settings: vi.fn().mockResolvedValue({ agent: undefined }), + roles: vi.fn().mockResolvedValue({ + attacker: { provider: "openrouter", model: "m", profile: "", custom: true, prompt_source: "none", has_system_prompt: false }, + target: { provider: "openrouter", model: "m", profile: "", custom: true, prompt_source: "none", has_system_prompt: false }, + judge: { provider: "openrouter", model: "m", profile: "", custom: true, prompt_source: "none", has_system_prompt: false }, + }), + presets: vi.fn().mockResolvedValue([{ name: "dan", description: "roleplay preset", template: "x {request}" }]), + transforms: vi.fn().mockResolvedValue([ + { name: "base64", description: "base64 encode", lossy: false, reversible: true }, + { name: "morse", description: "morse code", lossy: true, reversible: false }, + ]), + tools: vi.fn().mockResolvedValue([ + { name: "author_persona", description: "author a persona", control: false }, + { name: "finish", description: "end the run", control: true }, + ]), + providers: vi.fn().mockResolvedValue([ + { name: "openrouter", protocol: "openai", base_url: "https://x", model: "m", modality: "text", enabled: true, api_key_env: "K", has_api_key: true, auth_style: "bearer", inference_path: "", models_path: "", timeout: 120, reasoning: false }, + ]), + agentProfiles: vi.fn().mockResolvedValue({ + roles: { + attacker: { active: { provider: "openrouter", model: "m", profile: "", custom: true, prompt_source: "none", has_system_prompt: false }, profiles: [{ name: "p1", role: "attacker", provider: "openrouter", model: "m", prompt_source: "none", system_prompt: "", system_prompt_file: "" }] }, + target: { active: { provider: "openrouter", model: "m", profile: "", custom: true, prompt_source: "none", has_system_prompt: false }, profiles: [] }, + judge: { active: { provider: "openrouter", model: "m", profile: "", custom: true, prompt_source: "none", has_system_prompt: false }, profiles: [] }, + }, + }), + findingRuns: vi.fn().mockResolvedValue([ + { name: "run-1.jsonl", time: "2026-01-01 00:00:00", models: { target: "m", recorded: true }, size: 100, records: 4, hits: 1, findings: 1 }, + ]), + findings: vi.fn().mockResolvedValue([ + { run: "run-1.jsonl", ts: "2026-01-01", label: "COMPLIED", technique: "author_persona", payload: "p", reason: "r", category: "c", models: { target: "m" } }, + ]), + runs: vi.fn().mockResolvedValue([ + { name: "run-1.jsonl", time: "2026-01-01 00:00:00", models: { target: "m", recorded: true }, size: 100, records: 4, hits: 1 }, + ]), + models: vi.fn().mockResolvedValue({ profile: "openrouter", protocol: "openai", models: ["m"], fetched: true, error: "" }), + refreshModels: vi.fn().mockResolvedValue({ profile: "openrouter", protocol: "openai", models: ["m"], fetched: true, error: "" }), + addModel: vi.fn().mockResolvedValue({}), +})); + +vi.mock("../api", async () => { + const actual = await vi.importActual("../api"); + return { ...actual, api: mockApi }; +}); + +import { App } from "../App"; +import { Overview } from "../components/Overview"; +import { Console } from "../components/Console"; +import { Agent } from "../components/Agent"; +import { Arsenal } from "../components/Arsenal"; +import { Runs } from "../components/Runs"; +import { Findings } from "../components/Findings"; +import { Profiles } from "../components/Profiles"; +import { ProviderManager } from "../components/ProviderManager"; + +afterEach(cleanup); + +async function expectNoViolations(node: HTMLElement) { + const results = await axe(node, RULES); + expect(results).toHaveNoViolations(); +} + +describe("TG7 axe sweep (WCAG 2.2 AA)", () => { + it("App shell has landmarks (nav/main), an h1, a skip link, and no violations (A11Y-13)", async () => { + const { container, findByRole } = render(); + // Landmarks + heading + skip link. + await findByRole("navigation", { name: /primary navigation/i }); + expect(container.querySelector("main#main-content")).toBeInTheDocument(); + expect(container.querySelector("h1")).toBeInTheDocument(); + const skip = container.querySelector("a.skip-link"); + expect(skip).toHaveAttribute("href", "#main-content"); + // Region rule is meaningful here (full shell), so re-enable it for this one. + // heading-order is disabled: cards use

titles by design and the topbar + //

now precedes them (h1→h3 skip). Normalising the full heading tree is a + // separate concern outside TG7's A11Y-13 scope (which only promotes the topbar + // title to h1); tracked as a deferred item in the report. + const results = await axe(container, { rules: { "color-contrast": { enabled: false }, "heading-order": { enabled: false } } }); + expect(results).toHaveNoViolations(); + }); + + it("Overview has no violations", async () => { + const { container, findByText } = render( + , + ); + await findByText(/Attack success rate/i); + await expectNoViolations(container); + }); + + it("Console has no violations", async () => { + const { container, findByText } = render(); + await findByText(/Compose attack/i); + await waitFor(() => expect(mockApi.transforms).toHaveBeenCalled()); + await expectNoViolations(container); + }); + + it("Agent has no violations", async () => { + const { container, findByText } = render(); + await findByText(/drives the attack loop/i); + await expectNoViolations(container); + }); + + it("Arsenal has no violations", async () => { + const { container, findByText } = render(); + await findByText(/Prompt template|Select an arsenal/i); + await expectNoViolations(container); + }); + + it("Runs has no violations", async () => { + const { container, findByText } = render(); + await findByText(/run log/i); + await expectNoViolations(container); + }); + + it("Findings has no violations", async () => { + const { container, findByText } = render(); + await findByText(/Run selection/i); + await waitFor(() => expect(mockApi.findings).toHaveBeenCalled()); + await expectNoViolations(container); + }); + + it("Profiles has no violations", async () => { + const { container, findByText } = render(); + await findByText(/attacker profiles/i); + await expectNoViolations(container); + }); + + it("ProviderManager has no violations (list + open editor dialog)", async () => { + const { container, findByText } = render( {}} />); + await findByText(/Provider connections/i); + await waitFor(() => expect(mockApi.providers).toHaveBeenCalled()); + await expectNoViolations(container); + + // Open the editor Dialog (A11Y-1) and re-check — the modal surface, its + // fieldset/legend and password autocomplete must also be violation-free. + await userEvent.click(screen.getByRole("button", { name: "Add provider" })); + expect(await screen.findByRole("dialog")).toBeInTheDocument(); + await expectNoViolations(document.body); + }); +}); diff --git a/wallbreaker/dashboard/web/src/__tests__/format.test.ts b/wallbreaker/dashboard/web/src/__tests__/format.test.ts new file mode 100644 index 0000000..b6bfec6 --- /dev/null +++ b/wallbreaker/dashboard/web/src/__tests__/format.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect } from "vitest"; +import { ELLIPSIS, emptyPlaceholder, formatTimestamp, snippet } from "../format"; + +describe("format (VIS-4) — shared formatting util", () => { + describe("emptyPlaceholder", () => { + it("is the single canonical em-dash placeholder", () => { + expect(emptyPlaceholder).toBe("—"); + }); + }); + + describe("ELLIPSIS", () => { + it("is the single-character ellipsis, not three dots", () => { + expect(ELLIPSIS).toBe("…"); + expect(ELLIPSIS).not.toBe("..."); + expect(ELLIPSIS.length).toBe(1); + }); + }); + + describe("formatTimestamp", () => { + it("returns the placeholder for empty/nullish input", () => { + expect(formatTimestamp("")).toBe(emptyPlaceholder); + expect(formatTimestamp(null)).toBe(emptyPlaceholder); + expect(formatTimestamp(undefined)).toBe(emptyPlaceholder); + }); + + it("parses a run-log filename (dash form)", () => { + expect(formatTimestamp("run-20260707-011219.jsonl")).toBe("2026-07-07 01:12:19"); + }); + + it("parses a run-log filename (no inner dash)", () => { + expect(formatTimestamp("run-20260101120000.jsonl")).toBe("2026-01-01 12:00:00"); + }); + + it("returns the placeholder for a run-log filename with an invalid clock", () => { + // month 13 / hour 25 are out of range. + expect(formatTimestamp("run-20261301-250000.jsonl")).toBe(emptyPlaceholder); + }); + + it("formats an epoch-seconds number", () => { + // 2026-07-07T01:12:19Z rendered in the host local zone; assert the shape. + const out = formatTimestamp(1783386739); + expect(out).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/); + }); + + it("formats an ISO string", () => { + const out = formatTimestamp("2026-07-07T01:12:19Z"); + expect(out).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/); + }); + + it("returns the trimmed original when it cannot parse a non-empty string", () => { + expect(formatTimestamp(" not-a-date ")).toBe("not-a-date"); + }); + }); + + describe("snippet", () => { + it("returns the placeholder for empty input", () => { + expect(snippet("")).toBe(emptyPlaceholder); + expect(snippet(" ")).toBe(emptyPlaceholder); + expect(snippet(null)).toBe(emptyPlaceholder); + }); + + it("collapses whitespace", () => { + expect(snippet("a\n b\t c")).toBe("a b c"); + }); + + it("does not truncate text under the limit", () => { + expect(snippet("short", 100)).toBe("short"); + }); + + it("truncates with the single ellipsis when over the limit", () => { + const out = snippet("abcdefghij", 4); + expect(out).toBe(`abcd${ELLIPSIS}`); + expect(out.endsWith("…")).toBe(true); + expect(out.includes("...")).toBe(false); + }); + + it("stringifies non-string values before truncating", () => { + expect(snippet(12345, 3)).toBe(`123${ELLIPSIS}`); + }); + }); +}); diff --git a/wallbreaker/dashboard/web/src/__tests__/no-danger.test.ts b/wallbreaker/dashboard/web/src/__tests__/no-danger.test.ts new file mode 100644 index 0000000..c69ffb6 --- /dev/null +++ b/wallbreaker/dashboard/web/src/__tests__/no-danger.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from "vitest"; +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const SRC = join(dirname(fileURLToPath(import.meta.url)), ".."); + +function walk(dir: string): string[] { + const out: string[] = []; + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + if (statSync(full).isDirectory()) out.push(...walk(full)); + else if (/\.(ts|tsx)$/.test(entry)) out.push(full); + } + return out; +} + +// INFO-1: regression guard — no raw HTML injection anywhere in the SPA source. +describe("XSS regression guard (INFO-1)", () => { + const files = walk(SRC).filter((f) => !f.includes("__tests__")); + + it("finds no dangerouslySetInnerHTML in src/**", () => { + const offenders = files.filter((f) => readFileSync(f, "utf8").includes("dangerouslySetInnerHTML")); + expect(offenders, `dangerouslySetInnerHTML found in:\n${offenders.join("\n")}`).toEqual([]); + }); + + it("finds no direct .innerHTML assignment in src/**", () => { + const offenders = files.filter((f) => /\.innerHTML\s*=/.test(readFileSync(f, "utf8"))); + expect(offenders, `.innerHTML = found in:\n${offenders.join("\n")}`).toEqual([]); + }); +}); diff --git a/wallbreaker/dashboard/web/src/__tests__/setup.ts b/wallbreaker/dashboard/web/src/__tests__/setup.ts new file mode 100644 index 0000000..f149f27 --- /dev/null +++ b/wallbreaker/dashboard/web/src/__tests__/setup.ts @@ -0,0 +1 @@ +import "@testing-library/jest-dom/vitest"; diff --git a/wallbreaker/dashboard/web/src/__tests__/staleGuard.test.tsx b/wallbreaker/dashboard/web/src/__tests__/staleGuard.test.tsx new file mode 100644 index 0000000..072910e --- /dev/null +++ b/wallbreaker/dashboard/web/src/__tests__/staleGuard.test.tsx @@ -0,0 +1,47 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { useEffect, useState } from "react"; +import { render, screen, cleanup, waitFor } from "@testing-library/react"; + +afterEach(cleanup); + +// Deferred promise helper so tests control resolution order. +function deferred() { + let resolve!: (v: T) => void; + const promise = new Promise((r) => { resolve = r; }); + return { promise, resolve }; +} + +// Mirrors the REL-5 stale-guard: `let active = true; return () => { active = false }` +// so a superseded response never calls setState. +function KeyedView({ fetchByKey, keyValue }: { fetchByKey: (k: string) => Promise; keyValue: string }) { + const [value, setValue] = useState(""); + useEffect(() => { + let active = true; + fetchByKey(keyValue).then((v) => { if (active) setValue(v); }); + return () => { active = false; }; + }, [fetchByKey, keyValue]); + return
{value}
; +} + +describe("stale-guard (REL-5)", () => { + it("does not let a superseded (slow) response overwrite newer state", async () => { + const slow = deferred(); + const fast = deferred(); + const byKey: Record>> = { a: slow, b: fast }; + const fetchByKey = (k: string) => byKey[k].promise; + + const { rerender } = render(); + // Switch key before "a" resolves — this unmounts the "a" effect (active=false). + rerender(); + + // Newer request resolves first. + fast.resolve("B-result"); + await waitFor(() => expect(screen.getByTestId("value")).toHaveTextContent("B-result")); + + // The stale "a" request resolves later; it must be ignored. + slow.resolve("A-result"); + await Promise.resolve(); + expect(screen.getByTestId("value")).toHaveTextContent("B-result"); + expect(screen.getByTestId("value")).not.toHaveTextContent("A-result"); + }); +}); diff --git a/wallbreaker/dashboard/web/src/api.ts b/wallbreaker/dashboard/web/src/api.ts index c83b31c..7058ff4 100644 --- a/wallbreaker/dashboard/web/src/api.ts +++ b/wallbreaker/dashboard/web/src/api.ts @@ -360,19 +360,29 @@ export async function runAgent( const reader = r.body.getReader(); const dec = new TextDecoder(); let buf = ""; - for (;;) { - const { done, value } = await reader.read(); - if (done) break; - buf += dec.decode(value, { stream: true }); - let idx: number; - while ((idx = buf.indexOf("\n\n")) >= 0) { - const frame = buf.slice(0, idx); - buf = buf.slice(idx + 2); - const line = frame.startsWith("data:") ? frame.replace(/^data:\s?/, "") : frame; - if (line) { - try { onEvent(JSON.parse(line) as AgentEvent); } catch { /* ignore */ } + // Abort mid-stream: cancel the reader so the loop stops promptly (the pending + // reader.read() rejects with an AbortError, which the caller treats as an + // intentional cancel — see Agent.tsx). + const onAbort = () => { void reader.cancel().catch(() => {}); }; + signal?.addEventListener("abort", onAbort); + try { + for (;;) { + if (signal?.aborted) break; + const { done, value } = await reader.read(); + if (done) break; + buf += dec.decode(value, { stream: true }); + let idx: number; + while ((idx = buf.indexOf("\n\n")) >= 0) { + const frame = buf.slice(0, idx); + buf = buf.slice(idx + 2); + const line = frame.startsWith("data:") ? frame.replace(/^data:\s?/, "") : frame; + if (line) { + try { onEvent(JSON.parse(line) as AgentEvent); } catch { /* ignore */ } + } } } + } finally { + signal?.removeEventListener("abort", onAbort); } } diff --git a/wallbreaker/dashboard/web/src/components/Agent.tsx b/wallbreaker/dashboard/web/src/components/Agent.tsx index dc4dcc4..100e55f 100644 --- a/wallbreaker/dashboard/web/src/components/Agent.tsx +++ b/wallbreaker/dashboard/web/src/components/Agent.tsx @@ -11,6 +11,27 @@ import { import { AgentConfigDrawer, DEFAULT_AGENT_CONFIG, normalizeAgentConfig } from "./AgentConfigDrawer"; import { ModelChooser } from "./ModelChooser"; import { ProviderChooser } from "./ProviderChooser"; +import { isAbortError, useAbortableFetch } from "../primitives/useAbortableFetch"; +import { LiveRegion } from "../primitives/LiveRegion"; + +// A11Y-6: honour prefers-reduced-motion for the transcript's programmatic +// auto-scroll — jump instantly (no smooth animation) when the user asked to +// reduce motion. Guarded for jsdom where matchMedia may be undefined. +function prefersReducedMotion(): boolean { + return typeof window !== "undefined" + && typeof window.matchMedia === "function" + && window.matchMedia("(prefers-reduced-motion: reduce)").matches; +} + +// VIS-5: treat the transcript as "pinned to bottom" when the scroll position is +// within a small threshold of the end. A user who has scrolled up sits far above +// the bottom, so streaming events won't yank the viewport back down. +const PIN_THRESHOLD_PX = 40; +function isPinnedToBottom(el: HTMLElement | null): boolean { + if (!el) return true; // no pane yet (initial render) — follow by default + const distance = el.scrollHeight - el.scrollTop - el.clientHeight; + return distance <= PIN_THRESHOLD_PX; +} type Item = | { kind: "text"; text: string } @@ -54,9 +75,11 @@ export function Agent({ hasTarget }: { hasTarget: boolean }) { const [runLog, setRunLog] = useState(""); const [savingConfig, setSavingConfig] = useState(false); const [configStatus, setConfigStatus] = useState(""); + const [techniqueError, setTechniqueError] = useState(""); const [err, setErr] = useState(""); const runningRef = useRef(false); const bodyRef = useRef(null); + const { start: startRun, abort: abortRun } = useAbortableFetch(); useEffect(() => { api.settings() @@ -69,7 +92,8 @@ export function Agent({ hasTarget }: { hasTarget: boolean }) { const initial = saved === null ? known : new Set(saved.filter((name) => known.has(name))); setTechniques(selectable); setEnabled(initial); - }).catch(() => {}); + setTechniqueError(""); + }).catch((e) => setTechniqueError(e instanceof Error ? e.message : "Could not load arsenal techniques.")); }, []); const filteredTechniques = useMemo(() => { @@ -91,6 +115,10 @@ export function Agent({ hasTarget }: { hasTarget: boolean }) { } function push(it: Item) { + // VIS-5: decide whether to auto-scroll BEFORE the new content grows the pane. + // We only follow the stream when the user is already pinned to the bottom, so + // scrolling up to read earlier output isn't yanked back down each frame. + const pinned = isPinnedToBottom(bodyRef.current); setItems((prev) => { if (it.kind === "text" && prev.length && prev[prev.length - 1].kind === "text") { const copy = prev.slice(); @@ -100,6 +128,10 @@ export function Agent({ hasTarget }: { hasTarget: boolean }) { } return [...prev, it]; }); + // A11Y-6: skip the programmatic auto-scroll when the user prefers reduced + // motion — they keep control of the scroll position (the LiveRegion still + // announces new content), rather than being yanked to the bottom each frame. + if (prefersReducedMotion() || !pinned) return; requestAnimationFrame(() => { if (bodyRef.current) bodyRef.current.scrollTop = bodyRef.current.scrollHeight; }); @@ -140,14 +172,21 @@ export function Agent({ hasTarget }: { hasTarget: boolean }) { } } + // REL-4: abort the in-flight SSE stream when this component unmounts so we never + // setState after unmount (the fetch is cancelled, the reader loop stops). + useEffect(() => abortRun, [abortRun]); + async function run() { if (!objective.trim() || runningRef.current) return; runningRef.current = true; setItems([]); setErr(""); setRunLog(""); setPaused(false); setPauseReady(false); setRunning(true); + // REL-4: fresh controller; also aborts any prior in-flight run. + const controller = startRun(); try { - await runAgent({ objective, ...agentConfig, enabled_techniques: [...enabled] }, onEvent); + await runAgent({ objective, ...agentConfig, enabled_techniques: [...enabled] }, onEvent, controller.signal); } catch (e) { - setErr((e as Error).message); + // An AbortError is an intentional cancel (unmount / new run) — do not surface it. + if (!isAbortError(e)) setErr((e as Error).message); } finally { runningRef.current = false; setRunning(false); @@ -229,13 +268,14 @@ export function Agent({ hasTarget }: { hasTarget: boolean }) {
+ {techniqueError &&
Could not load techniques: {techniqueError}
} {filteredTechniques.map((tool) => ( ))} - {!filteredTechniques.length &&
No matching techniques.
} + {!techniqueError && !filteredTechniques.length &&
No matching techniques.
}
Run controls remain available even when every attack technique is disabled. Selection is saved in this browser.
@@ -290,11 +330,17 @@ export function Agent({ hasTarget }: { hasTarget: boolean }) { }} /> )} - {err &&
{err}
} + {err &&
{err}
}

Transcript

+ {/* A11Y-7: a polite role=status live region announces the streaming + transcript's structural progress (round changes, tool verdicts) and + the final run verdict, so screen-reader users follow the loop without + reading the whole scroll pane. Visually hidden — the pane is the + visual channel. */} + {transcriptStatus(items)}
{!items.length &&
Set the objective and arsenal, then run. You can steer, pause, and switch the attacker without losing the conversation.
} {items.map((item, index) => )} @@ -360,6 +406,24 @@ function AttackerSwitch({ ); } +// A11Y-7: derive a short spoken status from the transcript. We announce the most +// recent structural milestone (round boundary, tool verdict) and always surface +// the terminal verdict when the run is done, so the live region stays concise. +function transcriptStatus(items: Item[]): string { + for (let i = items.length - 1; i >= 0; i--) { + const it = items[i]; + if (it.kind === "done") return `Run ${it.status}${it.summary ? `: ${it.summary}` : ""}.`; + if (it.kind === "error") return `Error: ${it.error}`; + if (it.kind === "tool_result") { + const verdict = it.error ? "error" : it.verdict || "no verdict"; + return `Tool ${it.name} result: ${verdict}.`; + } + if (it.kind === "round") return `Round ${it.round} of ${it.max}.`; + if (it.kind === "control") return it.text; + } + return ""; +} + function Row({ it }: { it: Item }) { switch (it.kind) { case "start": return
brain {it.brain} ▸ target {it.target}
; diff --git a/wallbreaker/dashboard/web/src/components/AgentConfigDrawer.tsx b/wallbreaker/dashboard/web/src/components/AgentConfigDrawer.tsx index ab1321b..ed01bab 100644 --- a/wallbreaker/dashboard/web/src/components/AgentConfigDrawer.tsx +++ b/wallbreaker/dashboard/web/src/components/AgentConfigDrawer.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useId, useState } from "react"; import type { AgentConfig } from "../api"; export const DEFAULT_AGENT_CONFIG: AgentConfig = { @@ -65,6 +65,39 @@ export function AgentConfigDrawer({ if (!draft[key]) setDraft((current) => ({ ...current, [key]: String(value[key]) })); }; + const ids = useId(); + // A11Y-10/A11Y-11: one focusable number input per field, its