diff --git a/CHANGELOG.md b/CHANGELOG.md index 0afa06d..95926c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## Unreleased — WebUI V2 unified operator surface + +- Added a shared typed capability catalog so TUI behavior is the canonical contract and + every registered operation remains discoverable from V2. +- Added server-owned queued executions with pause, resume, steering, attacker switching, + cancellation, and reconnectable sequenced events. +- Added canonical JSONL history with a rebuildable SQLite search/correlation index. +- Added `/v2` alongside `/legacy`, with dedicated Agent and Live surfaces, persistent + multi-turn Compose, workflow sequencing and reconstruction, cross-run findings, run-log + exploration, evidence reports, provider verification, and profile management. +- Added historical-run selection to Live and preserved drafts and view state across V2 + navigation. +- Added [complete setup and development documentation](docs/SETUP.md). + ## Five new attack tools: cipherchat, skeleton_key, persuasion_attack, drattack, ica Adds five research-derived attack tools that were missing from the arsenal, wired into diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f262706..957eb56 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,6 +14,10 @@ wallbreaker check # validate config (profiles, keys, target, judge) pytest -q # full suite must stay green ``` +Dashboard contributors should install `.[dev,dashboard]`, run `npm install` in +`wallbreaker/dashboard/web`, and verify `npm run build`. See [docs/SETUP.md](docs/SETUP.md) +for the complete cross-platform setup and local development workflow. + ## Architecture (where things live) - `wallbreaker/providers/` normalize OpenAI + Anthropic wire formats to one event stream @@ -25,7 +29,11 @@ pytest -q # full suite must stay green - `wallbreaker/presets.py` — curated single-shot jailbreak templates. - `wallbreaker/tui/` — the Textual terminal UI (theme in `theme.py`, chrome in `header.py`/`sidebar.py`/`widgets.py`, layout in `app.tcss`). -- `dashboard/` — FastAPI backend + React/Svelte web dashboard. +- `wallbreaker/capabilities.py` — typed capability manifest shared by the TUI and WebUI V2. +- `wallbreaker/executions.py` — server-owned execution lifecycle and resumable events. +- `wallbreaker/history_index.py` — rebuildable SQLite index over canonical JSONL history. +- `wallbreaker/dashboard/` — FastAPI backend + React/Vite dashboards; V2 lives under + `web/src/v2/`, while the original interface remains available during rollout. ## House rules diff --git a/README.md b/README.md index ef98175..34eb10a 100644 --- a/README.md +++ b/README.md @@ -287,15 +287,12 @@ pytest -q ## Web dashboard -A browser dashboard ships alongside the TUI (FastAPI backend + React/Vite SPA). Its -headline is the **Agent** view, the *same autonomous attack loop the TUI runs*: give it an -objective ("jailbreak the model into …") and the attacker brain reasons, picks techniques, -fires at the target, reads the verdict, and keeps going, streamed live to your browser over -SSE. Plus a single-shot **attack console** (preset + transform chips → verdict), a live ASR -scoreboard, findings table, run-log viewer, a searchable arsenal of -presets/transforms/tools, and a **Settings** panel to swap the target / attacker / judge -model live (persisted to `.wallbreaker_state.json`, applied without a restart; image -targets auto-set `modality=image`). +A browser dashboard ships alongside the TUI (FastAPI backend + React/Vite SPA). WebUI V2 +uses the same capability catalog and application services as the TUI, and adds a +server-owned execution queue, resumable event streams, persistent multi-turn composition, +workflow sequencing, provider/profile management, and current or historical evidence +inspection. **Agent** is dedicated to the autonomous Attack → Target → Judge loop; +**Live** provides the holistic-to-granular observability surface. ![Wallbreaker attack console](docs/images/dashboard-console.png) @@ -310,12 +307,18 @@ targets auto-set `modality=image`). ```bash pip install -e ".[dashboard]" # FastAPI + uvicorn cd wallbreaker/dashboard/web && npm install && npm run build && cd - -wallbreaker dashboard # http://127.0.0.1:8787 +wallbreaker dashboard # binds to 127.0.0.1:8787 ``` -The backend reuses the same engine as the TUI, so the console fires through `query_target` -against your `[target]`. For frontend hot-reload during development, run `npm run dev` in -`wallbreaker/dashboard/web` (it proxies `/api` to the running `wallbreaker dashboard`). +Open WebUI V2 at . The original dashboard remains available at + during the parity rollout. The backend reuses the same +engine as the TUI. For frontend hot-reload, run `npm run dev` in +`wallbreaker/dashboard/web`; it proxies `/api` to the dashboard backend. + +See the [setup guide](docs/SETUP.md) for Windows instructions, provider configuration, +history storage, development workflow, network-exposure safeguards, and troubleshooting. +See the [WebUI V2 showcase](docs/WEBUI_V2_SHOWCASE.md) for a visual tour of the unified +operator surface. ## Responsible use diff --git a/README_RICHERUI.md b/README_RICHERUI.md index 97d79bd..a15ab02 100644 --- a/README_RICHERUI.md +++ b/README_RICHERUI.md @@ -1,8 +1,12 @@ -# Wallbreaker Richer UI Guide +# Wallbreaker browser UI guide -This guide covers the browser-dashboard features added on the `richer-ui` branch. It is -intended as a companion to the main [README](README.md), which remains the reference for -the red-team harness, CLI, tools, and safety policy. +This guide describes the original browser dashboard retained at `/legacy`. WebUI V2 is +the active unified operator surface at `/v2`; use the [setup guide](docs/SETUP.md) for +installation, current navigation, local history, development, and troubleshooting. + +The original interface remains available during the V2 parity rollout. Its provider and +profile management APIs are also used by V2, but its page names and workflows below should +not be read as the V2 information architecture. ## Install and launch @@ -17,7 +21,7 @@ cd ../../.. wallbreaker dashboard ``` -Open . For frontend development, keep `wallbreaker dashboard` +Open . Open V2 at . For frontend development, keep `wallbreaker dashboard` running and start Vite in another terminal: ```bash @@ -25,7 +29,7 @@ cd wallbreaker/dashboard/web npm run dev ``` -The dashboard has nine views: **Agent**, **Overview**, **Attack console**, **Findings**, +The original dashboard has nine views: **Agent**, **Overview**, **Attack console**, **Findings**, **Run logs**, **Arsenal**, **Profiles**, **Advanced**, and **Settings**. Use the arrow beside the Wallbreaker logo to collapse or expand the navigation rail. The choice is remembered in the browser. diff --git a/docs/SETUP.md b/docs/SETUP.md new file mode 100644 index 0000000..d939387 --- /dev/null +++ b/docs/SETUP.md @@ -0,0 +1,194 @@ +# Wallbreaker setup + +This guide installs Wallbreaker for local terminal and browser use. Wallbreaker is an +authorized LLM security-testing harness; only connect it to systems you own or have +explicit permission to evaluate. + +## Requirements + +- Python 3.11 or newer +- Git +- Node.js 18 or newer and npm, if you want the browser interface +- Credentials for the model providers you intend to use, unless you use a supported + keyless local CLI provider + +## Install + +Clone your fork (or the upstream repository), then create an isolated Python environment: + +```bash +git clone https://github.com/YOUR_ACCOUNT/wallbreaker.git +cd wallbreaker +python -m venv .venv +``` + +Activate it on macOS or Linux: + +```bash +. .venv/bin/activate +``` + +Activate it on Windows PowerShell: + +```powershell +.\.venv\Scripts\Activate.ps1 +``` + +Install the terminal application, development tools, and dashboard backend: + +```bash +python -m pip install -e ".[dev,dashboard]" +``` + +Optional extras are available for barcode and steganography tools: + +```bash +python -m pip install -e ".[dev,dashboard,barcodes,stego]" +``` + +## Configure providers and roles + +Copy the example configuration and keep the resulting local file out of source control: + +```bash +cp config.example.toml config.toml +``` + +On Windows PowerShell, use: + +```powershell +Copy-Item config.example.toml config.toml +``` + +Edit `config.toml` to define at least one attacker profile and the target and judge +roles. Prefer `api_key_env` plus environment variables or the dashboard's credential +editor over committing literal keys. Validate the result before launching: + +```bash +wallbreaker check +``` + +The browser interface can also create, edit, test, enable, and disable providers and +manage attacker, target, and judge profiles. Known credential fields are redacted from +API responses and execution history. + +## Run the terminal interface + +```bash +wallbreaker +``` + +Useful alternatives include: + +```bash +wallbreaker --profile PROFILE_NAME +wallbreaker --auto "authorized evaluation objective" +wallbreaker --resume +``` + +Terminal sessions autosave under `sessions/`. + +## Build and run the browser interface + +Install the frontend dependencies and create the production bundle: + +```bash +cd wallbreaker/dashboard/web +npm install +npm run build +cd ../../.. +``` + +Start the backend from the repository root: + +```bash +wallbreaker dashboard +``` + +Open these local URLs: + +- WebUI V2: +- Original dashboard: + +V2 separates operation from observation. **Agent** runs and steers the Attack → Target +→ Judge loop, while **Live** observes either the current execution or a selected +historical run. Compose, Workflows, Arsenal, Findings, Runs and Logs, Reports, Models, +and Settings expose the rest of the operator surface. + +The dashboard binds to loopback by default and has no multi-user authentication. To bind +to another interface you must both choose the host and acknowledge the exposure: + +```bash +wallbreaker dashboard --host 0.0.0.0 --allow-network +``` + +Do this only behind an access-controlled boundary. Run history can contain prompts, +responses, reasoning, tool arguments, and generated artifacts. + +## Frontend development + +Keep `wallbreaker dashboard` running, then start the Vite development server in another +terminal: + +```bash +cd wallbreaker/dashboard/web +npm run dev +``` + +Vite proxies `/api` to `http://127.0.0.1:8787`. Frontend source changes hot-reload. +After Python backend changes, restart `wallbreaker dashboard`. To update the production +bundle served on port 8787, run `npm run build` again and refresh the browser. + +## History and local state + +| Path | Purpose | +|---|---| +| `config.toml` | Provider definitions, profiles, and active role configuration | +| `.env` | Optional locally managed provider credentials | +| `.wallbreaker_state.json` | Non-secret runtime preferences and UI references | +| `.wallbreaker_models.sqlite3` | Rebuildable provider model catalog | +| `sessions/run-*.jsonl` | Canonical portable execution history | +| `sessions/.wallbreaker_history.sqlite3` | Rebuildable search and correlation index | + +JSONL is the source of truth. The SQLite history index may be deleted and rebuilt from +V2's Runs and Logs screen or through `POST /api/v2/history/rebuild`. Archive or remove +canonical run files only when you intend to remove that evidence. + +## Verify the installation + +Run the Python suite with the project environment and build the frontend: + +```bash +python -m pytest tests +cd wallbreaker/dashboard/web +npm run build +``` + +The full Python suite needs the project environment because the TUI, dashboard, image, +and steganography tests use optional dependencies installed there. + +## Troubleshooting + +### The API runs but the browser UI is missing + +Build the frontend with `npm run build`, then refresh. Without a production bundle the +backend returns a message explaining that only its API is available. + +### Every provider request fails + +Run `wallbreaker check`, then use **Models → Test provider**. A real test must authenticate +and query the configured provider; an authentication error is not a successful connection. +Check the key variable, base URL, protocol, authentication style, model path, and model ID. +Native Anthropic normally uses `x-api-key`; some compatible proxies require `bearer`. + +### A run is absent from Live, Findings, or Reports + +Confirm its `run-*.jsonl` file is in the directory passed through `--sessions` (default: +`sessions/`). In **Runs and Logs**, rebuild the history index. Malformed JSONL records are +retained as visible parse errors rather than silently discarded. + +### Browser state appears stale + +Hard-refresh after rebuilding the frontend. V2 keeps drafts, selected views, conversation +state, and workflow state while navigating; resetting or archiving a conversation is an +explicit action. diff --git a/docs/WEBUI_V2_SHOWCASE.md b/docs/WEBUI_V2_SHOWCASE.md new file mode 100644 index 0000000..2ede161 --- /dev/null +++ b/docs/WEBUI_V2_SHOWCASE.md @@ -0,0 +1,128 @@ +# Wallbreaker WebUI V2 showcase + +WebUI V2 is a unified operator surface for running, steering, observing, and reviewing +authorized LLM security evaluations. It places the TUI's Attack → Target → Judge loop at +the center, then adds persistent composition, reusable workflows, historical visibility, +evidence reporting, and model administration. + +The screenshots below were captured from the local V2 interface at 1440 pixels wide. +Provider credentials and detailed historical payload content are not shown. + +## Run and steer the agent loop + +The **Agent** workspace focuses on the live loop. The operator sets an objective, starts +the engagement, follows each Attack → Target → Judge stage, watches the conversation +stream, and can steer the attacker without leaving the page. Advanced run settings stay +collapsed until needed. + +![Agent workspace showing the attack, target, and judge loop](images/webui-v2/agent.png) + +Key capabilities: + +- Persistent objective draft and compact run settings +- Explicit Attack, Target, and Judge stage state +- Streaming multi-turn conversation +- Pause, stop, and live steering controls +- Round, token, timing, and connection status + +## Observe current and historical engagements + +**Live** is the evidence observatory rather than the run launcher. The same surface can +follow the current execution or inspect any retained historical run. Its overview moves +from run-level totals into activity events and synchronized event detail. + +![Live observatory with historical run selection and run overview](images/webui-v2/live.png) + +The observatory provides: + +- Current or historical run selection +- Semantic activity and raw event modes +- Search and actor/event-type filters +- Correlated event, conversation, payload, evaluation, and raw detail +- Resumable live-tail updates for active executions + +## Compose persistent multi-turn target conversations + +**Compose** is a controlled delivery workspace. The first request opens a durable target +conversation; subsequent deliveries are contextual follow-ups until the operator +explicitly resets and archives the thread. + +![Compose workspace with multi-turn conversation controls](images/webui-v2/compose.png) + +Operators can preview the exact transformed payload, select presets and transforms, +override the initial system prompt, set the token budget, and retain the complete target +conversation between navigation changes. + +## Build and reuse workflows + +**Workflows** turns individual capabilities into configurable sequences. Operators add +steps from the shared capability catalog, configure their arguments, reorder the sequence, +save an alias, clone a workflow, and run it as a server-owned execution. + +![Workflow sequencer and capability palette](images/webui-v2/workflows.png) + +The analysis mode can also reconstruct applicable events from historical agent runs. +Individual events can be inspected and reusable steps selected before cloning them into an +editable sequence. + +## Search the complete Arsenal + +**Arsenal** provides one searchable inventory of presets, transforms, tools, and schemas. +Selecting an item opens its exact template, metadata, or argument contract in the detail +panel. + +![Searchable Arsenal of presets, transforms, and tools](images/webui-v2/arsenal.png) + +The catalog and workflow palette are generated from the same shared capability manifest, +preventing UI-only command drift. + +## Investigate runs from summary to raw evidence + +**Runs and Logs** preserves the complete chronological record. Runs are searchable; the +selected run can be viewed as a readable stream, a timeline, or raw JSONL. Event types and +actors can be selected or excluded, and the visible result can be exported. + +![Runs and Logs explorer with stream, timeline, and raw views](images/webui-v2/runs.png) + +JSONL remains the canonical portable history. A disposable SQLite index adds full-text +search and structured correlation and can be rebuilt from this screen. + +## Summarize and export evidence + +**Reports** turns retained history into an operator-ready evidence portfolio. It supports +all indexed runs or an individual run, with Markdown and structured evidence exports. + +![Reports dashboard with aggregate metrics and verdict distribution](images/webui-v2/reports.png) + +The dashboard brings together run counts, graded responses, strict bypasses, attack +success rate, per-run comparison, verdict distribution, technique performance, and the +generated narrative report. + +## Manage models and verify providers + +**Models** exposes provider health and role configuration without revealing secret values. +Credential verification makes a real authenticated provider request, while provider +management supports creation, editing, discovery, enable/disable state, and removal. + +![Models workspace with provider verification and management](images/webui-v2/models.png) + +Attacker, target, and judge assignments remain visible in the global top bar. Named +profiles and custom provider/model combinations can be managed without leaving the WebUI. + +## Unified capability summary + +| Area | Primary purpose | +|---|---| +| Agent | Run and steer the autonomous Attack → Target → Judge loop | +| Live | Observe current or historical activity from overview to raw evidence | +| Compose | Build exact payloads and maintain multi-turn target conversations | +| Workflows | Sequence, configure, alias, clone, and replay capabilities | +| Arsenal | Search presets, transforms, tools, and schemas | +| Findings | Investigate bypass and partial-compliance evidence across runs | +| Runs and Logs | Filter, correlate, inspect, and export canonical history | +| Reports | Compare outcomes and generate portable evidence reports | +| Models | Verify providers and manage models and role profiles | +| Settings | Configure runtime behavior and local operator preferences | + +For installation and local operation, see the [setup guide](SETUP.md). For the complete +harness feature inventory, see the [project README](../README.md). diff --git a/docs/images/webui-v2/agent.png b/docs/images/webui-v2/agent.png new file mode 100644 index 0000000..3827906 Binary files /dev/null and b/docs/images/webui-v2/agent.png differ diff --git a/docs/images/webui-v2/arsenal.png b/docs/images/webui-v2/arsenal.png new file mode 100644 index 0000000..8bc7eac Binary files /dev/null and b/docs/images/webui-v2/arsenal.png differ diff --git a/docs/images/webui-v2/compose.png b/docs/images/webui-v2/compose.png new file mode 100644 index 0000000..5bacb49 Binary files /dev/null and b/docs/images/webui-v2/compose.png differ diff --git a/docs/images/webui-v2/live.png b/docs/images/webui-v2/live.png new file mode 100644 index 0000000..df5661a Binary files /dev/null and b/docs/images/webui-v2/live.png differ diff --git a/docs/images/webui-v2/models.png b/docs/images/webui-v2/models.png new file mode 100644 index 0000000..a0b56ec Binary files /dev/null and b/docs/images/webui-v2/models.png differ diff --git a/docs/images/webui-v2/reports.png b/docs/images/webui-v2/reports.png new file mode 100644 index 0000000..63ca6f0 Binary files /dev/null and b/docs/images/webui-v2/reports.png differ diff --git a/docs/images/webui-v2/runs.png b/docs/images/webui-v2/runs.png new file mode 100644 index 0000000..c749128 Binary files /dev/null and b/docs/images/webui-v2/runs.png differ diff --git a/docs/images/webui-v2/workflows.png b/docs/images/webui-v2/workflows.png new file mode 100644 index 0000000..a7e57b2 Binary files /dev/null and b/docs/images/webui-v2/workflows.png differ diff --git a/tests/test_capabilities.py b/tests/test_capabilities.py new file mode 100644 index 0000000..acf0790 --- /dev/null +++ b/tests/test_capabilities.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +import ast +import json +import subprocess +import sys +from dataclasses import FrozenInstanceError +from pathlib import Path + +import pytest + +from wallbreaker.capabilities import ( + TUI_CAPABILITIES, + TUI_SOURCE, + group_capabilities, + lookup_capability, + merge_tool_capabilities, + represented_tui_commands, + serialize_capabilities, +) +from wallbreaker.tools.registry import ToolContext, ToolRegistry + +ROOT = Path(__file__).parents[1] +TUI_APP = ROOT / "wallbreaker" / "tui" / "app.py" + + +def _literal_from_tui(name: str): + tree = ast.parse(TUI_APP.read_text(encoding="utf-8")) + for node in tree.body: + if not isinstance(node, ast.Assign): + continue + if any(isinstance(target, ast.Name) and target.id == name for target in node.targets): + return ast.literal_eval(node.value) + raise AssertionError(f"{name} not found in {TUI_APP}") + + +def test_importing_manifest_does_not_import_textual(): + check = ( + "import sys; import wallbreaker.capabilities; " + "raise SystemExit(any(n == 'textual' or n.startswith('textual.') for n in sys.modules))" + ) + completed = subprocess.run( + [sys.executable, "-c", check], + cwd=ROOT, + check=False, + capture_output=True, + text=True, + ) + assert completed.returncode == 0, completed.stderr + + +def test_manifest_has_exact_tui_command_parity(): + known_commands = tuple(_literal_from_tui("KNOWN_COMMANDS")) + represented = represented_tui_commands() + + assert represented == known_commands + assert set(represented) == { + token + for capability in TUI_CAPABILITIES + for token in (capability.command, *capability.aliases) + } + assert len({capability.id for capability in TUI_CAPABILITIES}) == len(TUI_CAPABILITIES) + + +def test_aliases_resolve_to_their_canonical_capabilities(): + assert lookup_capability("/regen") is lookup_capability("/retry") + assert lookup_capability("/exit") is lookup_capability("/quit") + assert lookup_capability("/resume") is lookup_capability("/session") + + +def test_records_and_nested_schema_are_immutable(): + capability = lookup_capability("/fire") + assert capability is not None + + with pytest.raises(FrozenInstanceError): + capability.title = "Changed" + with pytest.raises(TypeError): + capability.argument_schema["type"] = "string" + with pytest.raises(TypeError): + capability.argument_schema["properties"]["arguments"]["default"] = "changed" + + +def test_descriptions_are_derived_from_tui_command_hints(): + for capability in TUI_CAPABILITIES: + if capability.command in TUI_SOURCE.command_hints: + assert capability.description == TUI_SOURCE.command_hints[capability.command] + + +def test_tool_registry_capabilities_merge_without_mutating_base_manifest(): + async def handler(args, ctx): + return str(args["value"]) + + registry = ToolRegistry(ToolContext(config=None)) # type: ignore[arg-type] + registry.add( + "sample_tool", + "A sample registry tool.", + { + "type": "object", + "properties": {"value": {"type": "string", "default": "ready"}}, + "required": ["value"], + }, + handler, + ) + + merged = merge_tool_capabilities(registry) + tool = lookup_capability("sample_tool", merged) + + assert len(merged) == len(TUI_CAPABILITIES) + 1 + assert tool is not None + assert tool.id == "tool.sample_tool" + assert tool.source == "tool" + assert tool.defaults == {"value": "ready"} + assert lookup_capability("/fire", merged) is lookup_capability("/fire") + + +def test_serialization_is_json_ready_and_groups_are_useful(): + payload = serialize_capabilities() + encoded = json.dumps(payload) + groups = group_capabilities() + + assert encoded + assert payload["version"] == 1 + assert payload["count"] == len(TUI_CAPABILITIES) + assert set(payload["groups"]) == set(groups) + assert lookup_capability("tui.fire").command == "/fire" + assert all(item["argument_schema"]["type"] == "object" for item in payload["capabilities"]) diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index 1e109b6..ad47095 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -203,6 +203,64 @@ async def execute(self, name, args): assert [event["type"] for event in response["stream_metadata"]] == ["usage", "stop"] +def test_console_conversation_is_multi_turn_until_reset_and_archive(monkeypatch, tmp_path): + from wallbreaker.config import Config, Endpoint + from wallbreaker.tools.registry import ToolResult + import wallbreaker.tools as tools_mod + + sessions = tmp_path / "sessions" + cfg = Config( + default_profile="attacker", + profiles={"attacker": Endpoint("attacker", "openai", "http://attacker", "attack-model")}, + target=Endpoint("target", "openai", "http://target", "target-model"), + path=tmp_path / "config.toml", + ) + registries = [] + + class FakeRegistry: + def __init__(self): + self.calls = [] + + async def execute(self, name, args): + self.calls.append((name, args)) + return ToolResult(f"{name}: {args['prompt']}") + + def build_registry(_config): + registry = FakeRegistry() + registries.append(registry) + return registry + + monkeypatch.setattr(tools_mod, "build_registry", build_registry) + client = TestClient(create_app(config=cfg, sessions_dir=sessions)) + + first = client.post("/api/fire", json={"request": "first"}).json() + second = client.post("/api/fire", json={"request": "follow up"}).json() + state = client.get("/api/console/conversation").json() + + assert [name for name, _args in registries[0].calls] == ["query_target", "continue_target"] + assert first["turn"]["continuation"] is False + assert second["turn"]["continuation"] is True + assert state["active"] is True + assert state["turn_count"] == 2 + assert [turn["request"] for turn in state["turns"]] == ["first", "follow up"] + + reset = client.post("/api/console/conversation/reset").json() + assert reset["ok"] is True + assert reset["archived_run"] == first["run_log"] + assert reset["active"] is False + records = [ + json.loads(line) + for line in (sessions / reset["archived_run"]).read_text(encoding="utf-8").splitlines() + ] + assert records[-1]["kind"] == "conversation_archived" + assert records[-1]["turn_count"] == 2 + + third = client.post("/api/fire", json={"request": "new conversation"}).json() + assert len(registries) == 2 + assert registries[1].calls[0][0] == "query_target" + assert third["run_log"] != first["run_log"] + + def test_agent_run_logs_full_scaffold_inference_and_tools(monkeypatch, tmp_path): from wallbreaker.agent.messages import ( ReasoningDelta, StopEvent, TextDelta, ToolUseEvent, UsageEvent, diff --git a/tests/test_dashboard_v2.py b/tests/test_dashboard_v2.py new file mode 100644 index 0000000..4b1ec66 --- /dev/null +++ b/tests/test_dashboard_v2.py @@ -0,0 +1,254 @@ +import asyncio +import json +import time + +import pytest + +pytest.importorskip("fastapi") +from fastapi.testclient import TestClient # noqa: E402 + +from wallbreaker.config import Config, Endpoint # noqa: E402 +from wallbreaker.dashboard import server as dashboard_server # noqa: E402 +from wallbreaker.dashboard.server import create_app, serve # noqa: E402 + + +def test_v2_capabilities_include_every_tui_command(tmp_path): + from wallbreaker.capabilities import TUI_SOURCE + + client = TestClient(create_app(config=None, sessions_dir=tmp_path)) + payload = client.get("/api/v2/capabilities").json() + represented = { + token + for item in payload["capabilities"] + for token in (item["command"], *item["aliases"]) + } + assert represented == set(TUI_SOURCE.known_commands) + + +def test_provider_test_requires_authenticated_inference(tmp_path, monkeypatch): + endpoint = Endpoint( + name="strict-test", protocol="openai", base_url="https://example.test/v1", + model="test-model", api_key="super-secret-invalid-key", + ) + config = Config(default_profile="strict-test", profiles={"strict-test": endpoint}) + + async def fake_discover(name, discovered_endpoint): + return { + "profile": name, "protocol": discovered_endpoint.protocol, + "models": ["test-model"], "fetched": True, "error": "", + } + + class RejectingProvider: + async def complete(self, messages, **kwargs): + raise RuntimeError("401 invalid key super-secret-invalid-key") + + async def aclose(self): + return None + + monkeypatch.setattr(dashboard_server, "_discover_profile_models", fake_discover) + monkeypatch.setattr(dashboard_server, "build_provider", lambda endpoint, timeout=None: RejectingProvider()) + response = TestClient(create_app(config=config, sessions_dir=tmp_path)).post( + "/api/providers/strict-test/test" + ) + assert response.status_code == 502 + assert "Authenticated inference failed" in response.json()["detail"] + assert "super-secret-invalid-key" not in response.text + + +def test_provider_test_reports_verified_model_and_latency(tmp_path, monkeypatch): + endpoint = Endpoint( + name="strict-test", protocol="openai", base_url="https://example.test/v1", + model="test-model", api_key="valid-key", + ) + config = Config(default_profile="strict-test", profiles={"strict-test": endpoint}) + + async def fake_discover(name, discovered_endpoint): + return { + "profile": name, "protocol": discovered_endpoint.protocol, + "models": ["test-model"], "fetched": True, "error": "", + } + + class AcceptingProvider: + async def complete(self, messages, **kwargs): + return "OK" + + async def aclose(self): + return None + + monkeypatch.setattr(dashboard_server, "_discover_profile_models", fake_discover) + monkeypatch.setattr(dashboard_server, "build_provider", lambda endpoint, timeout=None: AcceptingProvider()) + response = TestClient(create_app(config=config, sessions_dir=tmp_path)).post( + "/api/providers/strict-test/test" + ) + assert response.status_code == 200 + payload = response.json() + assert payload["ok"] is True + assert payload["model"] == "test-model" + assert payload["inference"]["ok"] is True + assert payload["inference"]["response_preview"] == "OK" + + +def test_v2_execution_crud_and_validation(tmp_path): + app = create_app(config=None, sessions_dir=tmp_path) + client = TestClient(app) + assert client.post("/api/v2/executions", json={}).status_code == 400 + assert client.post( + "/api/v2/executions", json={"capability_id": "does.not.exist"} + ).status_code == 400 + assert client.get("/api/v2/executions").json() == [] + assert client.post("/api/v2/executions/missing/attacker", json={}).status_code == 404 + + +def test_v2_history_search_and_rebuild(tmp_path): + run = tmp_path / "run-20260801-120000.jsonl" + run.write_text( + json.dumps({ + "seq": 1, "ts": "2026-08-01T12:00:00", "kind": "verdict", + "actor": "judge", "label": "COMPLIED", "technique": "test", + "reason": "distinctive evidence", "api_key": "must-not-leak", + }) + "\n", + encoding="utf-8", + ) + with TestClient(create_app(config=None, sessions_dir=tmp_path)) as client: + rebuilt = client.post("/api/v2/history/rebuild").json() + assert rebuilt["run_count"] == 1 + payload = client.get("/api/v2/history/events", params={"q": "distinctive"}).json() + assert payload["total"] == 1 + assert "must-not-leak" not in payload["items"][0]["structured_json"] + + +def test_v2_report_uses_canonical_run_log(tmp_path): + run = tmp_path / "run-20260801-120000.jsonl" + run.write_text( + "\n".join([ + json.dumps({"seq": 1, "ts": "2026-08-01T12:00:00", "kind": "objective", "text": "Evaluate target"}), + json.dumps({"seq": 2, "ts": "2026-08-01T12:00:01", "kind": "verdict", "label": "COMPLIED", "category": "test", "technique": "pair", "response": "evidence"}), + ]) + "\n", + encoding="utf-8", + ) + with TestClient(create_app(config=None, sessions_dir=tmp_path)) as client: + response = client.get("/api/v2/reports/run-20260801-120000") + assert response.status_code == 200 + payload = response.json() + assert payload["scorecard"]["strict_hits"] == 1 + assert payload["scorecard"]["graded_fires"] == 1 + assert "Evaluate target" in payload["markdown"] + assert payload["findings"][0]["technique"] == "pair" + assert client.get("/api/v2/reports/run-20260801-120000.jsonl").status_code == 200 + + +def test_v2_runs_headless_tui_catalog_capability(tmp_path): + with TestClient(create_app(config=None, sessions_dir=tmp_path)) as client: + created = client.post( + "/api/v2/executions", + json={ + "capability_id": "tui.help", + "args": {"arguments": "session"}, + "mode": "background", + }, + ) + assert created.status_code == 200 + execution_id = created.json()["id"] + for _ in range(50): + execution = client.get(f"/api/v2/executions/{execution_id}").json() + if execution["status"] in {"succeeded", "failed", "cancelled"}: + break + time.sleep(0.01) + assert execution["status"] == "succeeded" + assert "/session" in execution["result"]["content"] + + +def test_v2_runs_ordered_workflow_and_emits_step_events(tmp_path): + with TestClient(create_app(config=None, sessions_dir=tmp_path)) as client: + created = client.post( + "/api/v2/executions", + json={ + "capability_id": "workflow.run", + "args": { + "alias": "Session help sequence", + "steps": [ + {"capability_id": "tui.help", "args": {"arguments": "session"}}, + {"capability_id": "tui.help", "args": {"arguments": "report"}}, + ], + }, + "mode": "background", + }, + ) + assert created.status_code == 200 + execution_id = created.json()["id"] + for _ in range(50): + execution = client.get(f"/api/v2/executions/{execution_id}").json() + if execution["status"] in {"succeeded", "failed", "cancelled"}: + break + time.sleep(0.01) + assert execution["status"] == "succeeded" + assert [item["capability_id"] for item in execution["result"]["steps"]] == [ + "tui.help", "tui.help", + ] + events = client.get( + f"/api/v2/executions/{execution_id}/events", + params={"stream": "false"}, + ).json()["events"] + kinds = [event["type"] for event in events] + assert kinds.count("workflow_step_started") == 2 + assert kinds.count("workflow_step_succeeded") == 2 + + +def test_v2_rejects_empty_and_recursive_workflows(tmp_path): + with TestClient(create_app(config=None, sessions_dir=tmp_path)) as client: + empty = client.post( + "/api/v2/executions", + json={"capability_id": "workflow.run", "args": {"steps": []}}, + ) + assert empty.status_code == 200 + execution_id = empty.json()["id"] + for _ in range(50): + execution = client.get(f"/api/v2/executions/{execution_id}").json() + if execution["status"] in {"succeeded", "failed", "cancelled"}: + break + time.sleep(0.01) + assert execution["status"] == "failed" + + recursive = client.post( + "/api/v2/executions", + json={ + "capability_id": "workflow.run", + "args": {"steps": [{"capability_id": "workflow.run", "args": {}}]}, + }, + ) + assert recursive.status_code == 200 + + +def test_parallel_v2_and_legacy_shell_routes(tmp_path): + web = tmp_path / "web" + dist = web / "dist" + dist.mkdir(parents=True) + (dist / "index.html").write_text("
wallbreaker shell
", encoding="utf-8") + with TestClient(create_app(config=None, sessions_dir=tmp_path / "sessions", web_dir=web)) as client: + assert "wallbreaker shell" in client.get("/v2").text + assert "wallbreaker shell" in client.get("/legacy").text + assert "wallbreaker shell" in client.get("/").text + + +def test_dashboard_refuses_network_bind_without_explicit_acknowledgement(): + with pytest.raises(ValueError, match="unauthenticated dashboard"): + serve(host="0.0.0.0") + + +@pytest.mark.asyncio +async def test_v2_event_cursor_payload_uses_stable_envelope(tmp_path): + app = create_app(config=None, sessions_dir=tmp_path) + manager = app.state.execution_manager + + async def runner(ctx): + ctx.emit("progress", actor="system", text="ready") + return {"ok": True} + + execution = manager.create("test", {}, runner) + await execution.task + events, terminal = await manager.events_after(execution.id, after=2) + assert terminal is True + assert events[0].as_dict().keys() == { + "execution_id", "sequence", "type", "timestamp", "data", "version", + } + assert all(event.execution_id == execution.id for event in events) diff --git a/tests/test_executions.py b/tests/test_executions.py new file mode 100644 index 0000000..57c1127 --- /dev/null +++ b/tests/test_executions.py @@ -0,0 +1,89 @@ +import asyncio + +import pytest + +from wallbreaker.executions import ExecutionManager + + +@pytest.mark.asyncio +async def test_execution_lifecycle_and_resumable_events(): + manager = ExecutionManager() + + async def runner(ctx): + ctx.emit("progress", text="one") + await asyncio.sleep(0) + ctx.emit("progress", text="two") + return {"ok": True} + + execution = manager.create("demo", {}, runner) + await execution.task + assert execution.status == "succeeded" + events, terminal = await manager.events_after(execution.id, 3) + assert [event.data.get("text") for event in events if event.type == "progress"] == ["two"] + assert terminal is True + + +@pytest.mark.asyncio +async def test_pause_steer_resume_at_checkpoint(): + manager = ExecutionManager() + reached = asyncio.Event() + + async def runner(ctx): + reached.set() + await asyncio.sleep(0.02) + await ctx.checkpoint() + return {"feedback": ctx.drain_feedback()} + + execution = manager.create("demo", {}, runner, mode="interactive") + await reached.wait() + manager.pause(execution.id) + manager.steer(execution.id, "pivot") + for _ in range(20): + if execution.status == "paused": + break + await asyncio.sleep(0.01) + assert execution.status == "paused" + manager.resume(execution.id) + await execution.task + assert execution.result == {"feedback": ["pivot"]} + + +@pytest.mark.asyncio +async def test_hard_cancel_reaches_terminal_state(): + manager = ExecutionManager() + started = asyncio.Event() + + async def runner(_ctx): + started.set() + await asyncio.Event().wait() + + execution = manager.create("demo", {}, runner) + await started.wait() + manager.cancel(execution.id) + await execution.task + assert execution.status == "cancelled" + assert execution.events[-1].data["state"] == "cancelled" + + +@pytest.mark.asyncio +async def test_interactive_executions_queue_serially(): + manager = ExecutionManager() + release = asyncio.Event() + order = [] + + async def first(_ctx): + order.append("first-start") + await release.wait() + order.append("first-end") + + async def second(_ctx): + order.append("second-start") + + one = manager.create("one", {}, first, mode="interactive") + two = manager.create("two", {}, second, mode="interactive") + await asyncio.sleep(0.02) + assert order == ["first-start"] + assert two.status == "queued" + release.set() + await asyncio.gather(one.task, two.task) + assert order == ["first-start", "first-end", "second-start"] diff --git a/tests/test_history_index.py b/tests/test_history_index.py new file mode 100644 index 0000000..4fc460b --- /dev/null +++ b/tests/test_history_index.py @@ -0,0 +1,146 @@ +import json +import sqlite3 + +import pytest + +from wallbreaker.history_index import HistoryIndex, REDACTED + + +def _write_run(directory, name, records, extra_lines=()): + directory.mkdir(exist_ok=True) + path = directory / f"run-{name}.jsonl" + lines = [json.dumps(record) for record in records] + lines.extend(extra_lines) + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + return path + + +def test_rebuild_indexes_events_and_run_summaries(tmp_path): + sessions = tmp_path / "sessions" + _write_run(sessions, "alpha", [ + {"ts": "2026-01-01T00:00:00", "kind": "objective", "seq": 1, "text": "probe alpha"}, + { + "ts": "2026-01-01T00:00:01", "kind": "verdict", "seq": 2, + "actor": "judge", "technique": "crescendo", "label": "COMPLIED", + "duration_ms": 12.5, "usage": {"input_tokens": 10, "output_tokens": 4}, + "cost_usd": 0.003, "execution_id": "exec-1", "round": 2, + "inference_id": "inf-1", "tool_use_id": "tool-1", "response": "evidence needle", + }, + ]) + + with HistoryIndex(tmp_path / "history.sqlite") as index: + status = index.rebuild(sessions) + assert status["run_count"] == 1 + assert status["event_count"] == 2 + result = index.query_events(text="needle") + assert result["total"] == 1 + event = result["items"][0] + assert event["run_name"] == "run-alpha" + assert event["round_id"] == "2" + assert event["input_tokens"] == 10 + assert event["output_tokens"] == 4 + summary = index.run_summaries()["items"][0] + assert summary["event_count"] == 2 + assert summary["verdicts"] == {"COMPLIED": 1} + + +@pytest.mark.parametrize("use_fts", [True, False]) +def test_free_text_search_uses_fts_or_fallback(tmp_path, use_fts): + sessions = tmp_path / "sessions" + _write_run(sessions, "search", [ + {"kind": "assistant", "text": "distinctive observatory phrase"}, + {"kind": "assistant", "text": "something else"}, + ]) + try: + index = HistoryIndex(tmp_path / f"search-{use_fts}.sqlite", use_fts=use_fts) + except sqlite3.OperationalError: # pragma: no cover - unusual SQLite builds + pytest.skip("SQLite was built without FTS5") + with index: + index.rebuild(sessions) + result = index.query_events("observatory") + assert result["total"] == 1 + assert "distinctive observatory" in result["items"][0]["searchable_text"] + + +def test_structured_facets_and_pagination(tmp_path): + sessions = tmp_path / "sessions" + _write_run(sessions, "facets", [ + {"seq": 1, "kind": "verdict", "actor": "judge", "technique": "pair", "label": "REFUSED"}, + {"seq": 2, "kind": "verdict", "actor": "judge", "technique": "pair", "label": "COMPLIED"}, + {"seq": 3, "kind": "tool_call", "actor": "brain", "tool_use_id": "call-3"}, + ]) + with HistoryIndex(tmp_path / "facets.sqlite") as index: + index.rebuild(sessions) + filtered = index.query_events( + facets={"event_type": "verdict", "actor": "judge", "technique": "pair"}, + verdict="COMPLIED", limit=1, offset=0, + ) + assert filtered["total"] == 1 + assert filtered["items"][0]["sequence"] == 2 + assert index.query_events(event_type="verdict", limit=1, offset=1)["total"] == 2 + + +def test_recursive_redaction_preserves_numeric_token_counts(tmp_path): + sessions = tmp_path / "sessions" + _write_run(sessions, "secret", [{ + "kind": "inference", "api_key": "key-visible-in-source", + "request": { + "headers": {"Authorization": "Bearer hidden", "Cookie": "sid=hidden"}, + "password": "hidden", "access_token": "hidden-token", + "usage": {"input_tokens": 17, "output_tokens": 9, "max_tokens": 100}, + }, + }]) + with HistoryIndex(tmp_path / "secret.sqlite") as index: + index.rebuild(sessions) + event = index.query_events()["items"][0] + structured = json.loads(event["structured_json"]) + assert structured["api_key"] == REDACTED + assert structured["request"]["headers"]["Authorization"] == REDACTED + assert structured["request"]["headers"]["Cookie"] == REDACTED + assert structured["request"]["access_token"] == REDACTED + assert structured["request"]["usage"] == { + "input_tokens": 17, "max_tokens": 100, "output_tokens": 9, + } + assert "key-visible-in-source" not in event["searchable_text"] + assert index.query_events("key-visible-in-source")["total"] == 0 + + +def test_malformed_legacy_lines_are_counted_and_skipped(tmp_path): + sessions = tmp_path / "sessions" + _write_run( + sessions, "legacy", + [{"timestamp": "old-time", "type": "progress", "sequence": 7, "text": "valid"}], + extra_lines=("{not json", "[1, 2, 3]", ""), + ) + with HistoryIndex(tmp_path / "legacy.sqlite") as index: + status = index.rebuild(sessions) + assert status["event_count"] == 1 + assert status["malformed_lines"] == 2 + event = index.query_events()["items"][0] + assert (event["event_type"], event["sequence"], event["timestamp"]) == ( + "progress", 7, "old-time", + ) + + +def test_rebuild_and_incremental_upsert_are_idempotent(tmp_path): + sessions = tmp_path / "sessions" + path = _write_run(sessions, "incremental", [{"seq": 1, "kind": "user", "text": "one"}]) + with HistoryIndex(tmp_path / "incremental.sqlite") as index: + index.rebuild(sessions) + index.rebuild(sessions) + assert index.status()["event_count"] == 1 + assert index.index_file(path)["skipped"] is True + + with path.open("a", encoding="utf-8") as handle: + handle.write(json.dumps({"seq": 2, "kind": "assistant", "text": "two"}) + "\n") + changed = index.index_file(path) + assert changed["skipped"] is False + assert changed["event_count"] == 2 + assert index.index_file(path)["skipped"] is True + assert index.query_events(run_name="run-incremental")["total"] == 2 + + index.upsert_event( + "run-incremental", {"seq": 2, "kind": "assistant", "text": "updated"}, source_line=2 + ) + assert index.query_events("updated")["total"] == 1 + assert index.status()["event_count"] == 2 diff --git a/tests/test_provider_registry.py b/tests/test_provider_registry.py index 729131a..315e601 100644 --- a/tests/test_provider_registry.py +++ b/tests/test_provider_registry.py @@ -60,6 +60,7 @@ def test_provider_can_be_saved_and_tested_without_default_model(monkeypatch, tmp import wallbreaker.dashboard.server as server_mod cfg = _config(tmp_path) + completed_with = [] async def discover(name, endpoint): assert name == "catalog-only" @@ -75,6 +76,20 @@ async def discover(name, endpoint): } monkeypatch.setattr(server_mod, "_discover_profile_models", discover) + + class VerifiedProvider: + async def complete(self, messages, **kwargs): + completed_with.append(messages[0].text()) + return "OK" + + async def aclose(self): + return None + + def build_verified(endpoint, timeout=None): + assert endpoint.model == "vendor/model-a" + return VerifiedProvider() + + monkeypatch.setattr(server_mod, "build_provider", build_verified) client = TestClient(create_app(config=cfg, sessions_dir=tmp_path / "sessions")) saved = client.put("/api/providers/catalog-only", json={ "protocol": "openai", @@ -94,6 +109,9 @@ async def discover(name, endpoint): tested = client.post("/api/providers/catalog-only/test") assert tested.status_code == 200 assert tested.json()["models"] == ["vendor/model-a", "vendor/model-b"] + assert tested.json()["model"] == "vendor/model-a" + assert tested.json()["inference"]["ok"] is True + assert completed_with == ["Connectivity check. Reply with OK."] catalog = client.get("/api/models", params={"profile": "catalog-only"}) assert catalog.status_code == 200 assert catalog.json()["models"] == ["vendor/model-a", "vendor/model-b"] diff --git a/wallbreaker/capabilities.py b/wallbreaker/capabilities.py new file mode 100644 index 0000000..08528e1 --- /dev/null +++ b/wallbreaker/capabilities.py @@ -0,0 +1,427 @@ +"""Typed capability catalogue shared by Wallbreaker's operator surfaces. + +The TUI remains the command source of truth. This module reads its declarative +constants with :mod:`ast` so importing the capability catalogue does not import +Textual, construct an application, or initialize providers. +""" + +from __future__ import annotations + +import ast +import re +from collections import defaultdict +from collections.abc import Iterable, Mapping +from dataclasses import dataclass +from pathlib import Path +from types import MappingProxyType +from typing import Any, Literal + +ExecutionMode = Literal["immediate", "interactive", "foreground", "background"] +ProgressSemantics = Literal["none", "event_stream", "structured_steps"] +CapabilitySource = Literal["tui", "tool"] + +_TUI_APP_PATH = Path(__file__).with_name("tui") / "app.py" +_HELP_SPLIT = re.compile(r"\s{2,}") + + +def _freeze(value: Any) -> Any: + """Recursively freeze JSON-like data used by immutable records.""" + + if isinstance(value, Mapping): + return MappingProxyType({str(key): _freeze(item) for key, item in value.items()}) + if isinstance(value, (list, tuple)): + return tuple(_freeze(item) for item in value) + return value + + +def _thaw(value: Any) -> Any: + """Return a JSON-serializable copy of recursively frozen data.""" + + if isinstance(value, Mapping): + return {key: _thaw(item) for key, item in value.items()} + if isinstance(value, tuple): + return [_thaw(item) for item in value] + return value + + +@dataclass(frozen=True, slots=True) +class TUISourceTruth: + """The command declarations harvested from ``tui/app.py`` without importing it.""" + + help_text: str + known_commands: tuple[str, ...] + command_hints: Mapping[str, str] + command_usage: Mapping[str, str] + + +@dataclass(frozen=True, slots=True) +class Capability: + """An immutable, transport-neutral operator capability.""" + + id: str + command: str + category: str + title: str + description: str + argument_schema: Mapping[str, Any] + defaults: Mapping[str, Any] + execution_mode: ExecutionMode + progress_semantics: ProgressSemantics + cancellation_supported: bool + result_types: tuple[str, ...] + artifact_types: tuple[str, ...] + aliases: tuple[str, ...] = () + source: CapabilitySource = "tui" + + def __post_init__(self) -> None: + object.__setattr__(self, "argument_schema", _freeze(self.argument_schema)) + object.__setattr__(self, "defaults", _freeze(self.defaults)) + object.__setattr__(self, "result_types", tuple(self.result_types)) + object.__setattr__(self, "artifact_types", tuple(self.artifact_types)) + object.__setattr__(self, "aliases", tuple(self.aliases)) + + def to_dict(self) -> dict[str, Any]: + """Serialize this record for the V2 capabilities endpoint.""" + + return { + "id": self.id, + "command": self.command, + "category": self.category, + "title": self.title, + "description": self.description, + "argument_schema": _thaw(self.argument_schema), + "defaults": _thaw(self.defaults), + "execution_mode": self.execution_mode, + "progress_semantics": self.progress_semantics, + "cancellation_supported": self.cancellation_supported, + "result_types": list(self.result_types), + "artifact_types": list(self.artifact_types), + "aliases": list(self.aliases), + "source": self.source, + } + + +def _literal_assignment(tree: ast.Module, name: str) -> Any: + for node in tree.body: + if not isinstance(node, (ast.Assign, ast.AnnAssign)): + continue + targets = node.targets if isinstance(node, ast.Assign) else [node.target] + if any(isinstance(target, ast.Name) and target.id == name for target in targets): + try: + return ast.literal_eval(node.value) + except (TypeError, ValueError, SyntaxError) as exc: + raise RuntimeError(f"{name} in {_TUI_APP_PATH} is not literal data") from exc + raise RuntimeError(f"Could not find {name} in {_TUI_APP_PATH}") + + +def load_tui_source_truth(path: str | Path | None = None) -> TUISourceTruth: + """Load HELP_TEXT, KNOWN_COMMANDS and derived COMMAND_HINTS without Textual.""" + + source_path = Path(path) if path is not None else _TUI_APP_PATH + tree = ast.parse(source_path.read_text(encoding="utf-8"), filename=str(source_path)) + help_text = str(_literal_assignment(tree, "HELP_TEXT")) + known_commands = tuple(str(item).lower() for item in _literal_assignment(tree, "KNOWN_COMMANDS")) + overrides = dict(_literal_assignment(tree, "_HINT_OVERRIDES")) + known = set(known_commands) + hints: dict[str, str] = {} + usage: dict[str, str] = {} + + for line in help_text.splitlines(): + stripped = line.strip() + if not stripped.startswith("/"): + continue + parts = _HELP_SPLIT.split(stripped, maxsplit=1) + command_parts = parts[0].split(maxsplit=1) + command = command_parts[0].lower() + if command not in known: + continue + usage.setdefault(command, command_parts[1] if len(command_parts) > 1 else "") + hint = parts[1].strip() if len(parts) > 1 else "" + if hint: + hints.setdefault(command, hint) + + for command, hint in overrides.items(): + hints.setdefault(str(command).lower(), str(hint)) + return TUISourceTruth( + help_text=help_text, + known_commands=known_commands, + command_hints=_freeze(hints), + command_usage=_freeze(usage), + ) + + +TUI_SOURCE = load_tui_source_truth() + +# Aliases are represented on the canonical record instead of duplicated as +# separate executable capabilities. Their union with primary commands must +# exactly equal KNOWN_COMMANDS (enforced below and in tests). +_COMMAND_ALIASES: Mapping[str, tuple[str, ...]] = MappingProxyType({ + "/retry": ("/regen",), + "/session": ("/resume",), + "/quit": ("/exit",), +}) + +_CATEGORY_COMMANDS: Mapping[str, frozenset[str]] = MappingProxyType({ + "conversation": frozenset({ + "/help", "/edit", "/retry", "/undo", "/clear", "/find", "/objective", + }), + "configuration": frozenset({ + "/profile", "/target", "/provider", "/model", "/auto", "/autoexit", + "/rounds", "/log", "/judge", + }), + "arsenal": frozenset({ + "/transforms", "/encode", "/tools", "/preset", "/lib", "/parsel", "/eni", + "/template", "/sysprompt", + }), + "operations": frozenset({ + "/validate", "/replay", "/diff", "/harmbench", "/campaign", "/leaderboard", + "/swarm", "/seedsweep", "/pairsweep", "/narrate", "/fire", "/push", + "/adapt", "/firefile", "/leakscan", + }), + "evidence": frozenset({ + "/asr", "/stats", "/regrade", "/findings", "/export", "/repro", "/report", + }), + "session": frozenset({"/session", "/save", "/quit"}), +}) + +_BACKGROUND_COMMANDS = frozenset({ + "/validate", "/harmbench", "/campaign", "/leaderboard", "/swarm", + "/seedsweep", "/pairsweep", "/narrate", "/template", "/sysprompt", "/regrade", +}) +_FOREGROUND_COMMANDS = frozenset({ + "/replay", "/diff", "/fire", "/adapt", "/firefile", "/leakscan", +}) +_INTERACTIVE_COMMANDS = frozenset({"/edit", "/retry", "/push"}) +_NO_ARGUMENT_COMMANDS = frozenset({ + "/retry", "/undo", "/clear", "/leakscan", "/asr", "/stats", "/quit", +}) +_REQUIRED_RAW_ARGUMENTS = frozenset({ + "/encode", "/diff", "/fire", "/push", "/adapt", "/firefile", +}) + +_RESULT_TYPES: Mapping[str, tuple[str, ...]] = MappingProxyType({ + "conversation": ("status", "conversation"), + "configuration": ("status", "configuration"), + "arsenal": ("text", "catalog"), + "operations": ("text", "verdict", "evidence"), + "evidence": ("metrics", "findings"), + "session": ("status",), +}) +_ARTIFACT_TYPES: Mapping[str, tuple[str, ...]] = MappingProxyType({ + "/export": ("json",), + "/repro": ("text",), + "/report": ("markdown", "html"), + "/session": ("session_json",), + "/save": ("transcript",), + "/firefile": ("run_log",), +}) + + +def _category_for(command: str) -> str: + matches = [category for category, commands in _CATEGORY_COMMANDS.items() if command in commands] + if len(matches) != 1: + raise RuntimeError(f"TUI capability {command!r} has {len(matches)} categories") + return matches[0] + + +def _execution_for(command: str) -> tuple[ExecutionMode, ProgressSemantics, bool]: + if command in _BACKGROUND_COMMANDS: + return "background", "structured_steps", True + if command in _FOREGROUND_COMMANDS: + return "foreground", "event_stream", True + if command in _INTERACTIVE_COMMANDS: + return "interactive", "event_stream", True + return "immediate", "none", False + + +def _argument_schema(command: str, usage: str) -> dict[str, Any]: + if command in _NO_ARGUMENT_COMMANDS: + return {"type": "object", "properties": {}, "additionalProperties": False} + raw: dict[str, Any] = { + "type": "string", + "title": "Command arguments", + "description": f"Arguments accepted after {command}.", + "default": "", + } + if usage: + raw["x-wallbreaker-usage"] = usage + schema: dict[str, Any] = { + "type": "object", + "properties": {"arguments": raw}, + "additionalProperties": False, + } + if command in _REQUIRED_RAW_ARGUMENTS: + schema["required"] = ["arguments"] + return schema + + +def _schema_defaults(schema: Mapping[str, Any]) -> dict[str, Any]: + properties = schema.get("properties", {}) + return { + name: definition["default"] + for name, definition in properties.items() + if isinstance(definition, Mapping) and "default" in definition + } + + +def _build_tui_capabilities(source: TUISourceTruth = TUI_SOURCE) -> tuple[Capability, ...]: + alias_tokens = {alias for aliases in _COMMAND_ALIASES.values() for alias in aliases} + capabilities: list[Capability] = [] + for command in source.known_commands: + if command in alias_tokens: + continue + category = _category_for(command) + schema = _argument_schema(command, source.command_usage.get(command, "")) + mode, progress, cancellable = _execution_for(command) + name = command.removeprefix("/") + capabilities.append(Capability( + id=f"tui.{name}", + command=command, + category=category, + title=name.replace("_", " ").title(), + description=source.command_hints.get(command, f"Run the {command} command."), + argument_schema=schema, + defaults=_schema_defaults(schema), + execution_mode=mode, + progress_semantics=progress, + cancellation_supported=cancellable, + result_types=_RESULT_TYPES[category], + artifact_types=_ARTIFACT_TYPES.get(command, ()), + aliases=_COMMAND_ALIASES.get(command, ()), + )) + + represented = { + token + for capability in capabilities + for token in (capability.command, *capability.aliases) + } + expected = set(source.known_commands) + if represented != expected: + missing = sorted(expected - represented) + extra = sorted(represented - expected) + raise RuntimeError(f"TUI capability parity failure: missing={missing}, extra={extra}") + return tuple(capabilities) + + +TUI_CAPABILITIES = _build_tui_capabilities() + + +def _tool_specs(registry: Any) -> Iterable[Mapping[str, Any]]: + if hasattr(registry, "specs"): + return registry.specs() + tools = getattr(registry, "tools", None) + if isinstance(tools, Mapping): + return (tool.spec() for tool in tools.values()) + raise TypeError("registry must provide specs() or a tools mapping") + + +def merge_tool_capabilities( + registry: Any, + capabilities: Iterable[Capability] = TUI_CAPABILITIES, +) -> tuple[Capability, ...]: + """Merge registered agent tools into a capability sequence. + + The helper accepts ``ToolRegistry`` without importing it here, which keeps the + base command catalogue lightweight and lets callers decide when registry + construction and optional integrations should occur. + """ + + merged = {capability.id: capability for capability in capabilities} + for spec in _tool_specs(registry): + name = str(spec.get("name", "")).strip() + if not name: + continue + parameters = spec.get("parameters") + if not isinstance(parameters, Mapping): + parameters = {"type": "object", "properties": {}} + schema = dict(parameters) + schema.setdefault("type", "object") + capability = Capability( + id=f"tool.{name}", + command=f"tool:{name}", + category="tools", + title=name.replace("_", " ").title(), + description=str(spec.get("description") or f"Run the {name} agent tool."), + argument_schema=schema, + defaults=_schema_defaults(schema), + execution_mode="foreground", + progress_semantics="event_stream", + cancellation_supported=True, + result_types=("text", "tool_result"), + artifact_types=(), + aliases=(name,), + source="tool", + ) + merged[capability.id] = capability + return tuple(merged.values()) + + +def represented_tui_commands( + capabilities: Iterable[Capability] = TUI_CAPABILITIES, +) -> tuple[str, ...]: + """Return TUI command and alias tokens in source declaration order.""" + + represented = { + token + for capability in capabilities + if capability.source == "tui" + for token in (capability.command, *capability.aliases) + } + return tuple(command for command in TUI_SOURCE.known_commands if command in represented) + + +def lookup_capability( + identifier: str, + capabilities: Iterable[Capability] = TUI_CAPABILITIES, +) -> Capability | None: + """Look up by stable id, command token, alias, or registry tool name.""" + + needle = identifier.strip().lower() + for capability in capabilities: + candidates = (capability.id, capability.command, *capability.aliases) + if any(needle == candidate.lower() for candidate in candidates): + return capability + return None + + +def group_capabilities( + capabilities: Iterable[Capability] = TUI_CAPABILITIES, +) -> dict[str, tuple[Capability, ...]]: + """Group capabilities by category while preserving manifest order.""" + + grouped: defaultdict[str, list[Capability]] = defaultdict(list) + for capability in capabilities: + grouped[capability.category].append(capability) + return {category: tuple(items) for category, items in grouped.items()} + + +def serialize_capabilities( + capabilities: Iterable[Capability] = TUI_CAPABILITIES, +) -> dict[str, Any]: + """Build the JSON-ready payload for ``GET /api/v2/capabilities``.""" + + items = tuple(capabilities) + groups = group_capabilities(items) + return { + "version": 1, + "count": len(items), + "capabilities": [capability.to_dict() for capability in items], + "groups": { + category: [capability.id for capability in members] + for category, members in groups.items() + }, + } + + +__all__ = [ + "Capability", + "TUISourceTruth", + "TUI_CAPABILITIES", + "TUI_SOURCE", + "group_capabilities", + "load_tui_source_truth", + "lookup_capability", + "merge_tool_capabilities", + "represented_tui_commands", + "serialize_capabilities", +] diff --git a/wallbreaker/cli.py b/wallbreaker/cli.py index 3142813..ce9a76a 100644 --- a/wallbreaker/cli.py +++ b/wallbreaker/cli.py @@ -170,6 +170,10 @@ 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-network", action="store_true", + help="Acknowledge the risk of exposing this unauthenticated single-operator dashboard", + ) return parser @@ -373,12 +377,22 @@ def main(argv: list[str] | None = None) -> int: config = load_config(args.config) except ConfigError: config = None + if args.host not in {"127.0.0.1", "localhost", "::1"} and not args.allow_network: + print( + "Refusing to expose the unauthenticated dashboard on a network interface. " + "Use --allow-network only on a trusted network.", + file=sys.stderr, + ) + return 2 tgt = (config.target.model if config and config.target else "no target") print( 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_network=args.allow_network, + ) return 0 if args.command == "baseline": from .baseline import compare_baseline, format_regressions, save_baseline diff --git a/wallbreaker/dashboard/server.py b/wallbreaker/dashboard/server.py index d90d4a4..1ddd745 100644 --- a/wallbreaker/dashboard/server.py +++ b/wallbreaker/dashboard/server.py @@ -4,11 +4,14 @@ import dataclasses import json import re -from datetime import datetime +import time +from datetime import datetime, timedelta from pathlib import Path from .. import report as report_mod +from ..agent.messages import user from ..presets import list_presets +from ..providers.factory import build_provider from ..transforms import TRANSFORMS, apply_chain, list_transforms from ..session import normalize_inference_records @@ -117,7 +120,13 @@ def _safe_run_path(sessions: Path, name: str) -> Path | None: if ".." in name or "/" in name or "\\" in name: return None path = sessions / name - return path if path.is_file() else None + if path.is_file(): + return path + if not Path(name).suffix: + jsonl_path = sessions / f"{name}.jsonl" + if jsonl_path.is_file(): + return jsonl_path + return None def _load_records_with_lines(path: Path) -> tuple[list[dict], list[str], list[int]]: @@ -714,6 +723,13 @@ def create_app(config=None, sessions_dir: str | Path = "sessions", web_dir: str from ..session import RunLog, run_models_meta console_runlog = RunLog(directory=str(sessions)) + console_conversation = { + "runlog": console_runlog, + "registry": None, + "run_config": None, + "role_meta": {}, + "turns": [], + } provider_registry = None model_catalog = None agent_profile_registry = None @@ -755,6 +771,14 @@ def create_app(config=None, sessions_dir: str | Path = "sessions", web_dir: str except Exception: pass app = FastAPI(title="Wallbreaker", version="0.1.0") + from ..executions import ExecutionManager, TERMINAL_STATES + from ..history_index import HistoryIndex + + execution_manager = ExecutionManager() + history_index = HistoryIndex(sessions / ".wallbreaker_history.sqlite3") + history_index.update(sessions) + app.state.execution_manager = execution_manager + app.state.history_index = history_index app.add_middleware( CORSMiddleware, allow_origins=[], @@ -833,11 +857,66 @@ async def provider_test(name: str): endpoint = getattr(config, "all_profiles", {}).get(name) if config is not None else None if endpoint is None: raise HTTPException(status_code=404, detail=f"unknown provider '{name}'") - result = await _discover_profile_models(name, endpoint) - if result["fetched"] and model_catalog is not None: - model_catalog.sync(name, result["models"], "remote") - result["refreshed_at"] = model_catalog.mark_refreshed(name) - return {"ok": bool(result["fetched"]), **result} + catalog = await _discover_profile_models(name, endpoint) + model = str(getattr(endpoint, "model", "") or "").strip() + if not model: + for assignment in _roles_view().values(): + if assignment.get("provider") == name and assignment.get("model"): + model = str(assignment["model"]) + break + if not model and catalog["models"]: + model = str(catalog["models"][0]) + if not model: + raise HTTPException( + status_code=400, + detail=f"Provider '{name}' has no model configured to test.", + ) + + test_endpoint = dataclasses.replace( + endpoint, + model=model, + timeout=max(5.0, min(float(getattr(endpoint, "timeout", 0) or 30), 30.0)), + ) + provider = None + started = time.monotonic() + try: + provider = build_provider(test_endpoint, timeout=30) + prompt = user("Connectivity check. Reply with OK.") + if getattr(test_endpoint, "modality", "text") == "image": + result = await provider.generate([prompt], max_tokens=16) + preview = f"Generated {len(result.images)} image(s)" + test_kind = "image_generation" + else: + response = await provider.complete([prompt], max_tokens=32) + preview = response.strip().replace("\n", " ")[:160] + test_kind = "completion" + except Exception as exc: + detail = f"{type(exc).__name__}: {exc}" + secret = str(endpoint.resolved_key() or "") + if secret: + detail = detail.replace(secret, "[redacted]") + raise HTTPException( + status_code=502, + detail=f"Authenticated inference failed for '{name}' using '{model}': {detail}", + ) from exc + finally: + if provider is not None: + await provider.aclose() + + if catalog["fetched"] and model_catalog is not None: + model_catalog.sync(name, catalog["models"], "remote") + catalog["refreshed_at"] = model_catalog.mark_refreshed(name) + return { + "ok": True, + **catalog, + "model": model, + "inference": { + "ok": True, + "kind": test_kind, + "latency_ms": round((time.monotonic() - started) * 1000), + "response_preview": preview, + }, + } def _roles_view() -> dict: if agent_profile_registry is None: @@ -1170,6 +1249,48 @@ def compose(body: dict): except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc + def _console_conversation_view() -> dict: + runlog = console_conversation["runlog"] + turns = list(console_conversation["turns"]) + return { + "active": bool(turns), + "turn_count": len(turns), + "turns": turns, + "run_log": runlog.path.name if runlog._started else "", + } + + def _new_console_runlog(previous) -> object: + fresh = RunLog(directory=str(sessions)) + if fresh.path == previous.path or fresh.path.exists(): + stamp = datetime.now() + while fresh.path == previous.path or fresh.path.exists(): + stamp += timedelta(seconds=1) + fresh.path = sessions / f"run-{stamp.strftime('%Y%m%d-%H%M%S')}.jsonl" + return fresh + + @app.get("/api/console/conversation") + def console_conversation_get(): + return _console_conversation_view() + + @app.post("/api/console/conversation/reset") + async def console_conversation_reset(): + if dashboard_inference_lock.locked(): + raise HTTPException(status_code=409, detail="wait for the current turn to finish before resetting") + runlog = console_conversation["runlog"] + turns = list(console_conversation["turns"]) + archived = "" + if turns: + runlog.event("conversation_archived", turn_count=len(turns)) + archived = runlog.path.name + console_conversation.update({ + "runlog": _new_console_runlog(runlog), + "registry": None, + "run_config": None, + "role_meta": {}, + "turns": [], + }) + return {"ok": True, "archived_run": archived, **_console_conversation_view()} + @app.post("/api/fire") async def fire(body: dict): if config is None: @@ -1181,48 +1302,64 @@ async def fire(body: dict): if dashboard_inference_lock.locked(): raise HTTPException(status_code=409, detail="another dashboard inference is already in progress") + is_followup = bool(console_conversation["turns"]) args = { "prompt": composed["payload"] if composed["source"] == "payload" else composed["prompt"], "max_tokens": composed["max_tokens"], } if composed["source"] != "payload" and composed["transforms"]: args["transforms"] = composed["transforms"] - if composed["system"]: + if composed["system"] and not is_followup: args["system"] = composed["system"] from ..tools import build_registry from ..agent_profiles import resolved_config from ..session import inference_logging - try: - from ..state import load_state, state_path_for + if console_conversation["registry"] is None: + try: + from ..state import load_state, state_path_for - prefs = load_state(state_path_for(config)) - run_config, role_meta = resolved_config(config) - run_config = _apply_target_settings(run_config, prefs, config) - except Exception as exc: - from ..config import ConfigError - if isinstance(exc, ConfigError): - raise HTTPException(status_code=400, detail=str(exc)) from exc - raise - reg = build_registry(run_config) + prefs = load_state(state_path_for(config)) + run_config, role_meta = resolved_config(config) + run_config = _apply_target_settings(run_config, prefs, config) + except Exception as exc: + from ..config import ConfigError + if isinstance(exc, ConfigError): + raise HTTPException(status_code=400, detail=str(exc)) from exc + raise + reg = build_registry(run_config) + console_conversation.update({ + "registry": reg, + "run_config": run_config, + "role_meta": role_meta, + }) + else: + reg = console_conversation["registry"] + run_config = console_conversation["run_config"] + role_meta = console_conversation["role_meta"] + + console_runlog = console_conversation["runlog"] if not console_runlog._started: console_runlog.set_run_meta( source="dashboard_console", models=run_models_meta(run_config, attacker=run_config.profile()), agent_roles=role_meta, + conversation_mode="multi_turn", ) + tool_name = "continue_target" if is_followup else "query_target" console_runlog.event( "console_request", request_body=body, composed=composed, agent_roles=role_meta, - tool="query_target", + tool=tool_name, tool_args=args, + turn=len(console_conversation["turns"]) + 1, ) async with dashboard_inference_lock: with inference_logging(console_runlog): - result = await reg.execute("query_target", args) + result = await reg.execute(tool_name, args) verdict = _extract_verdict(result.content) target = run_config.target console_runlog.event( @@ -1241,7 +1378,22 @@ async def fire(body: dict): target_model=getattr(target, "model", "") if target else "", target_base_url=getattr(target, "base_url", "") if target else "", agent_roles=role_meta, + turn=len(console_conversation["turns"]) + 1, + continuation=is_followup, ) + turn = { + "index": len(console_conversation["turns"]) + 1, + "request": composed["request"], + "prompt": composed["prompt"], + "payload": composed["payload"], + "response": result.content, + "verdict": verdict, + "is_error": result.is_error, + "preset": composed["preset"], + "transforms": composed["transforms"], + "continuation": is_followup, + } + console_conversation["turns"].append(turn) return { **composed, "content": result.content, @@ -1249,6 +1401,8 @@ async def fire(body: dict): "is_error": result.is_error, "verdict": verdict, "run_log": console_runlog.path.name, + "turn": turn, + "conversation": _console_conversation_view(), } def _agent_status_view(): @@ -1564,6 +1718,7 @@ async def runner(): agent_active = True task = asyncio.create_task(runner()) + agent_control["task"] = task async def gen(): nonlocal stream_attached @@ -1587,8 +1742,532 @@ async def gen(): return StreamingResponse(gen(), media_type="text/event-stream", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}) + def _execution_or_404(execution_id: str): + execution = execution_manager.get(execution_id) + if execution is None: + raise HTTPException(status_code=404, detail=f"unknown execution '{execution_id}'") + return execution + + async def _tool_execution(ctx, capability_id: str, args: dict): + if config is None: + raise RuntimeError("no config loaded") + from ..agent_profiles import resolved_config + from ..state import load_state, state_path_for + from ..tools import build_registry + from ..session import RunLog, inference_logging, run_models_meta + + run_config, role_meta = resolved_config(config) + run_config = _apply_target_settings( + run_config, load_state(state_path_for(config)), config + ) + registry = build_registry(run_config) + tool_name = capability_id.removeprefix("tool.") + if tool_name not in registry.tools: + raise ValueError(f"unknown tool capability '{tool_name}'") + runlog = RunLog(directory=str(sessions)) + runlog.set_run_meta( + source="dashboard_v2_capability", + capability_id=capability_id, + models=run_models_meta(run_config, attacker=run_config.profile()), + agent_roles=role_meta, + ) + ctx.execution.run_id = runlog.path.name + + def progress(message) -> None: + text = str(message) + runlog.event("progress", execution_id=ctx.execution.id, text=text) + ctx.emit("progress", actor="tool", text=text, run_id=runlog.path.name) + + def run_event(event) -> None: + data = event if isinstance(event, dict) else {"value": str(event)} + runlog.event("tool_run_event", execution_id=ctx.execution.id, event=data) + ctx.emit("tool_event", actor="tool", event=data, run_id=runlog.path.name) + + registry.ctx.progress = progress + registry.ctx.run_events = run_event + runlog.event( + "capability_started", execution_id=ctx.execution.id, + capability_id=capability_id, args=args, + ) + await ctx.checkpoint() + with inference_logging(runlog): + result = await registry.execute(tool_name, args) + runlog.event( + "capability_finished", execution_id=ctx.execution.id, + capability_id=capability_id, error=bool(result.is_error), + ) + history_index.index_file(runlog.path, force=True) + ctx.emit( + "result", actor="tool", tool=tool_name, content=result.content, + error=bool(result.is_error), run_id=runlog.path.name, + ) + if result.is_error: + raise RuntimeError(result.content) + return {"content": result.content, "run_log": runlog.path.name} + + async def _tui_execution(ctx, capability_id: str, args: dict): + """Headless adapters for canonical TUI capabilities. + + High-frequency commands delegate to the same registered tools used by + the TUI. Read-only/operator-state commands return their canonical data + so V2 can render it in a tailored surface without importing Textual. + """ + from ..capabilities import TUI_SOURCE, lookup_capability + + capability = lookup_capability(capability_id) + if capability is None: + raise ValueError(f"unknown TUI capability '{capability_id}'") + command = capability.command.removeprefix("/") + raw = str(args.get("arguments") or "").strip() + provided = {key: value for key, value in args.items() if key != "arguments"} + + tool_map = { + "validate": "validate", "diff": "diff_fire", "harmbench": "harmbench", + "campaign": "campaign", "leaderboard": "leaderboard", "swarm": "swarm", + "seedsweep": "seed_sweep", "pairsweep": "pair_sweep", "narrate": "narrate", + "fire": "query_target", "push": "continue_target", "adapt": "adapt_seed", + "firefile": "fire_file", "leakscan": "leak_scan", + } + if command in tool_map: + tool_args = dict(provided) + if command == "validate": + tool_args.setdefault("task", raw) + elif command == "diff": + left, separator, right = raw.partition(";;") + if not separator: + raise ValueError("diff arguments must use: first payload ;; second payload") + tool_args.setdefault("a", left.strip()) + tool_args.setdefault("b", right.strip()) + elif command in {"seedsweep", "narrate"}: + tool_args.setdefault("request", raw) + elif command in {"fire", "push"}: + tool_args.setdefault("prompt", raw) + elif command in {"adapt", "firefile"}: + left, separator, right = raw.partition(";;") + if command == "adapt": + tool_args.setdefault("seed", left.strip()) + tool_args.setdefault("request", right.strip() if separator else "") + else: + tool_args.setdefault("file", left.strip()) + if separator: + tool_args.setdefault("request", right.strip()) + elif command == "leakscan": + tool_args.setdefault("text", raw) + elif command == "harmbench": + tool_args.setdefault("action", "sample") + if raw: + tool_args.setdefault("category", raw) + elif command in {"campaign", "pairsweep"} and raw: + pieces = raw.split() + tool_args.setdefault("category", pieces[0]) + if len(pieces) > 1 and pieces[1].isdigit(): + tool_args.setdefault("n", int(pieces[1])) + elif command == "leaderboard" and raw: + tool_args.setdefault("targets", raw.split()) + elif command == "swarm": + tool_args.setdefault("objective", raw) + return await _tool_execution(ctx, f"tool.{tool_map[command]}", tool_args) + + if command == "help": + needle = raw.lower() + lines = [line for line in TUI_SOURCE.help_text.splitlines() if not needle or needle in line.lower()] + return {"content": "\n".join(lines), "kind": "help"} + if command == "transforms": + needle = raw.lower() + items = [ + dataclasses.asdict(item) for item in list_transforms() + if not needle or needle in item.name.lower() or needle in item.description.lower() + ] + return {"items": items, "kind": "transforms"} + if command == "encode": + chain, separator, text = raw.partition(" ") + if not separator: + raise ValueError("encode arguments must use: transform[,transform] text") + names = _split_chain(chain) + return {"content": apply_chain(text, names), "transforms": names, "source": text} + if command == "preset": + items = list_presets() + if raw and raw != "list": + items = [item for item in items if item.name.lower() == raw.lower()] + return {"items": [dataclasses.asdict(item) for item in items], "kind": "presets"} + if command == "tools": + if config is None: + return {"items": [], "kind": "tools"} + from ..tools import build_registry + + specs = build_registry(config).specs() + if raw: + specs = [item for item in specs if raw.lower() in json.dumps(item).lower()] + return {"items": specs, "kind": "tools"} + if command in {"asr", "stats", "findings", "export", "report"}: + path = report_mod.resolve_log_path(raw or None, sessions) + if path is None: + raise ValueError("no run log found") + if command in {"asr", "stats"}: + return {"scorecard": report_mod.build_scorecard(path), "run_log": path.name} + if command == "findings": + return {"items": report_mod.extract_findings(path), "run_log": path.name} + if command == "export": + return {"export": report_mod.build_findings_export(path), "run_log": path.name} + return {"content": report_mod.build_report(path), "run_log": path.name, "format": "markdown"} + + route_by_category = { + "conversation": "compose", "configuration": "settings", "arsenal": "arsenal", + "operations": "workflows", "evidence": "findings", "session": "runs", + } + return { + "kind": "tailored_surface", "capability_id": capability_id, + "route": route_by_category.get(capability.category, "workflows"), + "message": "This capability is available through its stateful V2 workspace.", + } + + async def _agent_execution(ctx, args: dict): + response = await agent_run(args) + if agent_control is not None: + agent_control["execution_id"] = ctx.execution.id + buffer = "" + final: dict = {} + async for chunk in response.body_iterator: + buffer += chunk.decode() if isinstance(chunk, bytes) else str(chunk) + while "\n\n" in buffer: + frame, buffer = buffer.split("\n\n", 1) + line = frame[5:].strip() if frame.startswith("data:") else frame.strip() + if not line: + continue + try: + event = json.loads(line) + except json.JSONDecodeError: + continue + event_type = str(event.pop("type", "event")) + run_id = str(event.get("run_log") or ctx.execution.run_id or "") + if run_id: + ctx.execution.run_id = run_id + event.setdefault("run_id", run_id) + if event_type == "start": + ctx.execution.metadata.update({ + "title": objective if (objective := str(event.get("objective") or "")) else "Agent run", + "attacker": event.get("brain", ""), + "provider": event.get("provider", ""), + "target": event.get("target", ""), + "max_rounds": event.get("max_rounds", 0), + "max_tokens": event.get("max_tokens", 0), + }) + elif event_type == "round": + ctx.execution.metadata["current_round"] = event.get("round", 0) + ctx.execution.metadata["max_rounds"] = event.get("max", 0) + elif event_type == "usage": + ctx.execution.metadata["input_tokens"] = event.get("input", 0) + ctx.execution.metadata["output_tokens"] = event.get("output", 0) + elif event_type == "tool_result": + verdict = event.get("verdict") + if verdict: + ctx.execution.metadata["verdict"] = verdict + ctx.execution.metadata["technique"] = event.get("name", "") + if event_type == "control": + state = str(event.get("state") or "") + if state in {"paused", "pausing", "running"}: + ctx.execution.status = state + if event_type == "done": + final = dict(event) + ctx.emit(event_type, **event) + if ctx.execution.run_id: + path = _safe_run_path(sessions, ctx.execution.run_id) + if path is not None and path.exists(): + history_index.index_file(path, force=True) + return final + + async def _workflow_execution(ctx, args: dict): + """Execute an operator-authored sequence through the shared capability layer.""" + + steps = args.get("steps") + if not isinstance(steps, list) or not steps: + raise ValueError("workflow steps must be a non-empty list") + if len(steps) > 100: + raise ValueError("workflow steps are limited to 100") + alias = str(args.get("alias") or "Untitled workflow").strip() + results = [] + ctx.execution.metadata.update({"title": alias, "workflow_steps": len(steps)}) + for index, raw_step in enumerate(steps, start=1): + if not isinstance(raw_step, dict): + raise ValueError(f"workflow step {index} must be an object") + capability_id = str(raw_step.get("capability_id") or "").strip() + step_args = raw_step.get("args") or {} + if not capability_id or capability_id == "workflow.run": + raise ValueError(f"workflow step {index} has an invalid capability") + if not isinstance(step_args, dict): + raise ValueError(f"workflow step {index} args must be an object") + if capability_id == "agent.run": + raise ValueError("interactive agent runs cannot be nested inside a workflow") + await ctx.checkpoint() + ctx.emit( + "workflow_step_started", actor="system", step=index, + total=len(steps), capability_id=capability_id, + label=str(raw_step.get("label") or ""), + ) + try: + if capability_id.startswith("tool."): + result = await _tool_execution(ctx, capability_id, step_args) + elif capability_id.startswith("tui."): + result = await _tui_execution(ctx, capability_id, step_args) + else: + raise ValueError(f"capability '{capability_id}' is not workflow-executable") + results.append({"step": index, "capability_id": capability_id, "result": result}) + ctx.emit( + "workflow_step_succeeded", actor="system", step=index, + total=len(steps), capability_id=capability_id, + ) + except Exception as exc: + failure = { + "step": index, "capability_id": capability_id, + "error": f"{type(exc).__name__}: {exc}", + } + results.append(failure) + ctx.emit( + "workflow_step_failed", actor="system", step=index, + total=len(steps), capability_id=capability_id, + error=failure["error"], + ) + if not bool(raw_step.get("continue_on_error", False)): + raise RuntimeError( + f"workflow stopped at step {index} ({capability_id}): {exc}" + ) from exc + return {"alias": alias, "steps": results} + + @app.get("/api/v2/capabilities") + def capabilities_get(): + try: + from ..capabilities import merge_tool_capabilities, serialize_capabilities + from ..tools import build_registry + + capabilities = ( + merge_tool_capabilities(build_registry(config)) + if config is not None else None + ) + return serialize_capabilities(capabilities) if capabilities is not None else serialize_capabilities() + except ImportError: + # The endpoint remains useful during source-only installations where + # the optional TUI dependency is unavailable. + from ..tools import build_registry + + if config is None: + return [] + return [ + { + "id": f"tool.{name}", "title": name.replace("_", " ").title(), + "category": "tools", "execution_mode": "background", + "cancellable": True, + } + for name in build_registry(config).names() + ] + + @app.post("/api/v2/executions") + async def execution_create(body: dict): + capability_id = str(body.get("capability_id") or "").strip() + if not capability_id: + raise HTTPException(status_code=400, detail="capability_id is required") + args = body.get("args") or {} + if not isinstance(args, dict): + raise HTTPException(status_code=400, detail="args must be an object") + mode = str(body.get("mode") or ("interactive" if capability_id == "agent.run" else "background")) + + if capability_id == "agent.run": + runner = lambda ctx: _agent_execution(ctx, args) + elif capability_id == "workflow.run": + runner = lambda ctx: _workflow_execution(ctx, args) + elif capability_id.startswith("tool."): + runner = lambda ctx: _tool_execution(ctx, capability_id, args) + elif capability_id.startswith("tui."): + runner = lambda ctx: _tui_execution(ctx, capability_id, args) + else: + raise HTTPException(status_code=400, detail=f"capability '{capability_id}' is not executable") + try: + execution = execution_manager.create(capability_id, args, runner, mode=mode) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return execution.as_dict() + + @app.get("/api/v2/executions") + def executions_get(status: str | None = None, limit: int = 100): + return execution_manager.list(status=status, limit=limit) + + @app.get("/api/v2/executions/{execution_id}") + def execution_get(execution_id: str): + return _execution_or_404(execution_id).as_dict() + + @app.get("/api/v2/executions/{execution_id}/events") + async def execution_events( + execution_id: str, after: int = 0, stream: bool = True, + ): + from fastapi.responses import StreamingResponse + + execution = _execution_or_404(execution_id) + if not stream: + events, terminal = await execution_manager.events_after(execution_id, after) + return { + "events": [event.as_dict() for event in events], + "terminal": terminal, + "next": events[-1].sequence if events else after, + } + + async def event_stream(): + cursor = max(0, after) + while True: + events, terminal = await execution_manager.events_after( + execution_id, cursor, wait=True, timeout=15, + ) + if not events: + yield ": keepalive\n\n" + for event in events: + cursor = event.sequence + yield ( + f"id: {event.sequence}\n" + f"data: {json.dumps(event.as_dict(), ensure_ascii=False)}\n\n" + ) + if terminal and not events: + break + + return StreamingResponse( + event_stream(), media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, + ) + + @app.post("/api/v2/executions/{execution_id}/pause") + async def execution_pause(execution_id: str): + execution = _execution_or_404(execution_id) + if ( + execution.capability_id == "agent.run" and agent_control is not None + and agent_control.get("execution_id") == execution_id + ): + await agent_pause() + try: + execution_manager.pause(execution_id) + except RuntimeError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + return execution.as_dict() + + @app.post("/api/v2/executions/{execution_id}/resume") + async def execution_resume(execution_id: str): + execution = _execution_or_404(execution_id) + if ( + execution.capability_id == "agent.run" and agent_control is not None + and agent_control.get("execution_id") == execution_id + ): + await agent_resume() + try: + execution_manager.resume(execution_id) + except RuntimeError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + return execution.as_dict() + + @app.post("/api/v2/executions/{execution_id}/steer") + async def execution_steer(execution_id: str, body: dict): + execution = _execution_or_404(execution_id) + message = str(body.get("message") or "") + if ( + execution.capability_id == "agent.run" and agent_control is not None + and agent_control.get("execution_id") == execution_id + ): + await agent_steer({"message": message}) + try: + execution_manager.steer(execution_id, message) + except (RuntimeError, ValueError) as exc: + raise HTTPException(status_code=409 if isinstance(exc, RuntimeError) else 400, detail=str(exc)) from exc + return execution.as_dict() + + @app.post("/api/v2/executions/{execution_id}/attacker") + async def execution_attacker_switch(execution_id: str, body: dict): + execution = _execution_or_404(execution_id) + if execution.capability_id != "agent.run": + raise HTTPException(status_code=409, detail="attacker switching applies only to agent runs") + if agent_control is None or agent_control.get("execution_id") != execution_id: + raise HTTPException(status_code=409, detail="this agent execution is not active") + result = await agent_attacker_switch(body) + execution.metadata["attacker"] = result.get("attacker", "") + execution.metadata["provider"] = result.get("provider", "") + execution.emit( + "control", state="attacker_switched", + attacker=result.get("attacker", ""), provider=result.get("provider", ""), + ) + return execution.as_dict() + + @app.post("/api/v2/executions/{execution_id}/cancel") + async def execution_cancel(execution_id: str): + execution = _execution_or_404(execution_id) + if ( + execution.capability_id == "agent.run" and agent_control is not None + and agent_control.get("execution_id") == execution_id + ): + task = agent_control.get("task") + if task is not None and not task.done(): + task.cancel() + try: + execution_manager.cancel(execution_id) + except RuntimeError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + return execution.as_dict() + + @app.get("/api/v2/history/status") + async def history_status(): + return history_index.status() + + @app.post("/api/v2/history/rebuild") + async def history_rebuild(): + return history_index.rebuild(sessions) + + @app.get("/api/v2/history/runs") + async def history_runs(limit: int = 50, offset: int = 0): + history_index.update(sessions) + return history_index.run_summaries(limit=limit, offset=offset) + + @app.get("/api/v2/history/events") + async def history_events( + q: str = "", run_name: str | None = None, event_type: str | None = None, + actor: str | None = None, technique: str | None = None, + verdict: str | None = None, execution_id: str | None = None, + round_id: str | None = None, inference_id: str | None = None, + tool_id: str | None = None, timestamp_from: str | None = None, + timestamp_to: str | None = None, limit: int = 100, offset: int = 0, + order: str = "desc", + ): + history_index.update(sessions) + return history_index.query_events( + q, run_name=run_name, event_type=event_type, actor=actor, + technique=technique, verdict=verdict, execution_id=execution_id, + round_id=round_id, inference_id=inference_id, tool_id=tool_id, + timestamp_from=timestamp_from, timestamp_to=timestamp_to, + limit=limit, offset=offset, order=order, + ) + + @app.get("/api/v2/reports/{run_name}") + async def run_report(run_name: str): + path = _safe_run_path(sessions, run_name) + if path is None or not path.exists(): + raise HTTPException(status_code=404, detail=f"unknown run '{run_name}'") + return { + "run_name": path.name, + "scorecard": report_mod.build_scorecard(path), + "coverage": report_mod.build_coverage_matrix(path), + "findings": report_mod.extract_findings(path), + "export": report_mod.build_findings_export(path), + "markdown": report_mod.build_report(path), + } + + @app.on_event("shutdown") + def close_history_index(): + history_index.close() + dist = _web_dist(web_dir) if dist is not None: + from fastapi.responses import FileResponse + + @app.get("/v2", include_in_schema=False) + def v2_shell(): + return FileResponse(dist / "index.html") + + @app.get("/legacy", include_in_schema=False) + def legacy_shell(): + return FileResponse(dist / "index.html") + app.mount("/", StaticFiles(directory=str(dist), html=True), name="web") else: @app.get("/") @@ -1602,7 +2281,14 @@ def _no_build(): return app -def serve(host: str = "127.0.0.1", port: int = 8787, config=None, sessions_dir="sessions"): +def serve( + host: str = "127.0.0.1", port: int = 8787, config=None, + sessions_dir="sessions", *, allow_network: bool = False, +): + if host not in {"127.0.0.1", "localhost", "::1"} and not allow_network: + raise ValueError( + "refusing to expose the unauthenticated dashboard; pass allow_network=True explicitly" + ) import uvicorn app = create_app(config=config, sessions_dir=sessions_dir) diff --git a/wallbreaker/dashboard/web/src/api.ts b/wallbreaker/dashboard/web/src/api.ts index 223403a..f768e9b 100644 --- a/wallbreaker/dashboard/web/src/api.ts +++ b/wallbreaker/dashboard/web/src/api.ts @@ -131,6 +131,17 @@ export interface ModelCatalog { error: string; } +export interface ProviderTestResult extends ModelCatalog { + ok: boolean; + model: string; + inference: { + ok: boolean; + kind: "completion" | "image_generation"; + latency_ms: number; + response_preview: string; + }; +} + export interface ProviderRecord extends ProfileDetail { enabled: boolean; api_key_env: string; @@ -249,7 +260,7 @@ export const api = { disableProvider: (name: string) => j(`/api/providers/${encodeURIComponent(name)}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ enabled: false }), }), - testProvider: (name: string) => j(`/api/providers/${encodeURIComponent(name)}/test`, { method: "POST" }), + testProvider: (name: string) => j(`/api/providers/${encodeURIComponent(name)}/test`, { method: "POST" }), refreshModels: (name: string) => j(`/api/providers/${encodeURIComponent(name)}/models/refresh`, { method: "POST" }), addModel: (name: string, model: string) => j(`/api/providers/${encodeURIComponent(name)}/models`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ model }), diff --git a/wallbreaker/dashboard/web/src/components/ProviderManager.tsx b/wallbreaker/dashboard/web/src/components/ProviderManager.tsx index ec5ee84..d261b0c 100644 --- a/wallbreaker/dashboard/web/src/components/ProviderManager.tsx +++ b/wallbreaker/dashboard/web/src/components/ProviderManager.tsx @@ -46,11 +46,14 @@ export function ProviderManager({ onChanged }: { onChanged: () => void }) { setTestResults((current) => ({ ...current, [provider.name]: { ok: true, message: "Testing connection…" } })); try { const result = await api.testProvider(provider.name); - if (!result.ok) throw new Error(result.error || "Provider unavailable"); + if (!result.ok || !result.inference?.ok) throw new Error(result.error || "Authenticated inference failed"); const count = result.models.length; setTestResults((current) => ({ ...current, - [provider.name]: { ok: true, message: `Connected · ${count} model${count === 1 ? "" : "s"} found` }, + [provider.name]: { + ok: true, + message: `Authenticated inference passed · ${result.model} · ${result.inference.latency_ms} ms · ${count} model${count === 1 ? "" : "s"} discovered`, + }, })); invalidateModelCatalog(provider.name); } @@ -84,7 +87,7 @@ export function ProviderManager({ onChanged }: { onChanged: () => void }) {
{provider.enabled ? "Enabled" : "Disabled"}
- + {provider.enabled ? : } diff --git a/wallbreaker/dashboard/web/src/main.tsx b/wallbreaker/dashboard/web/src/main.tsx index bd34b2a..5baeb94 100644 --- a/wallbreaker/dashboard/web/src/main.tsx +++ b/wallbreaker/dashboard/web/src/main.tsx @@ -1,10 +1,15 @@ import React from "react"; import { createRoot } from "react-dom/client"; import { App } from "./App"; +import { V2App } from "./v2"; import "./styles.css"; +import "./v2.css"; + +const path = window.location.pathname.replace(/\/+$/, "") || "/"; +const useV2 = path === "/v2" || window.location.hash.startsWith("#v2/"); createRoot(document.getElementById("root")!).render( - + {useV2 ? : } ); diff --git a/wallbreaker/dashboard/web/src/v2.css b/wallbreaker/dashboard/web/src/v2.css new file mode 100644 index 0000000..7d5fb69 --- /dev/null +++ b/wallbreaker/dashboard/web/src/v2.css @@ -0,0 +1,772 @@ +.v2-root { + --v2-bg: #080b0d; + --v2-bg-soft: #0d1012; + --v2-surface: #111214; + --v2-surface-raised: #171517; + --v2-line: #3a2426; + --v2-line-soft: #252123; + --v2-text: #e9e4e1; + --v2-muted: #a39a96; + --v2-dim: #706967; + --v2-red: #f04444; + --v2-red-strong: #c5262d; + --v2-green: #35cc83; + --v2-teal: #34d9cc; + --v2-amber: #e9ad3d; + --v2-violet: #c879ec; + --v2-blue: #74a5ff; + --v2-mono: ui-monospace, "SFMono-Regular", "Cascadia Code", Consolas, monospace; + display: grid; + grid-template-columns: 220px minmax(0, 1fr); + width: 100%; + min-width: 0; + height: 100vh; + overflow: hidden; + background: var(--v2-bg); + color: var(--v2-text); + font-family: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; + font-size: 13px; + line-height: 1.4; + color-scheme: dark; +} + +.v2-root, .v2-root * { box-sizing: border-box; } +.v2-root button, .v2-root input, .v2-root select, .v2-root textarea { font: inherit; } +.v2-root button { color: inherit; } +.v2-root button, .v2-root select { cursor: pointer; } +.v2-root button:disabled { cursor: not-allowed; opacity: .48; } +.v2-root :focus-visible { outline: 2px solid var(--v2-teal); outline-offset: 2px; } +.v2-root ::selection { background: rgba(240, 68, 68, .34); } +.v2-sr-only { position: absolute !important; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; } +.v2-skip { position: fixed; z-index: 1000; top: -60px; left: 12px; padding: 10px 14px; background: var(--v2-text); color: var(--v2-bg); } +.v2-skip:focus { top: 12px; } +.v2-mono { font-family: var(--v2-mono); } +.v2-muted { color: var(--v2-muted); } + +.v2-rail { + position: relative; + z-index: 20; + display: flex; + min-height: 0; + flex-direction: column; + overflow: hidden; + border-right: 1px solid var(--v2-line); + background: #0b0e10; +} +.v2-brand { display: flex; align-items: center; min-height: 64px; gap: 9px; padding: 0 16px; border-bottom: 1px solid var(--v2-line-soft); letter-spacing: .09em; } +.v2-brand > span { color: var(--v2-red); font-size: 15px; } +.v2-brand strong { font-size: 13px; white-space: nowrap; } +.v2-brand strong b { color: var(--v2-red); } +.v2-brand small { margin-left: auto; color: var(--v2-dim); font-family: var(--v2-mono); font-size: 10px; } +.v2-brand > button { display: none; margin-left: auto; border: 0; background: transparent; color: var(--v2-muted); } +.v2-rail nav { display: grid; gap: 2px; padding: 10px 9px; border-bottom: 1px solid var(--v2-line-soft); } +.v2-rail nav button { display: flex; align-items: center; gap: 11px; min-height: 37px; padding: 0 12px; border: 1px solid transparent; border-radius: 5px; background: transparent; color: var(--v2-muted); text-align: left; } +.v2-rail nav button span { color: var(--v2-dim); font-size: 8px; } +.v2-rail nav button:hover { color: var(--v2-text); background: var(--v2-surface); } +.v2-rail nav button.active { border-color: #51282b; background: #1b1416; color: var(--v2-text); } +.v2-rail nav button.active span { color: var(--v2-green); } +.v2-rail-section { padding: 11px 9px; border-bottom: 1px solid var(--v2-line-soft); } +.v2-rail-section header { display: flex; justify-content: space-between; padding: 0 5px 8px; color: var(--v2-muted); font-size: 10px; letter-spacing: .1em; text-transform: uppercase; } +.v2-rail-section header span:last-child { min-width: 18px; padding: 1px 5px; border: 1px solid var(--v2-line); border-radius: 9px; text-align: center; } +.v2-rail-section > p { margin: 5px; color: var(--v2-dim); font-size: 11px; } +.v2-rail-section > button { display: grid; width: 100%; min-width: 0; gap: 5px; padding: 9px; border: 1px solid transparent; border-radius: 5px; background: transparent; text-align: left; } +.v2-rail-section > button:hover, .v2-rail-section > button.selected { border-color: var(--v2-line); background: var(--v2-surface); } +.v2-rail-section > button strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--v2-mono); font-size: 10px; font-weight: 500; } +.v2-rail-section > button small { color: var(--v2-muted); font-family: var(--v2-mono); font-size: 9px; } +.v2-rail-section .v2-status { justify-self: start; } +.v2-run-queue { min-height: 0; overflow-y: auto; } +.v2-run-queue > button { grid-template-columns: 10px minmax(0, 1fr); } +.v2-run-queue > button > span { grid-row: 1 / 3; align-self: start; color: var(--v2-green); font-size: 7px; } +.v2-run-queue > button small { grid-column: 2; } +.v2-rail-foot { display: grid; gap: 7px; margin-top: auto; padding: 13px 14px; border-top: 1px solid var(--v2-line-soft); color: var(--v2-muted); } +.v2-rail-foot small { font-family: var(--v2-mono); font-size: 9px; } + +.v2-shell { display: flex; min-width: 0; min-height: 0; flex-direction: column; } +.v2-mobile-header { display: none; } +.v2-main { min-width: 0; min-height: 0; flex: 1; overflow: auto; } +.v2-main-live { overflow: hidden; } +.v2-route-state { display: none; } +.v2-route-state.active { display: contents; } +.v2-page-header { display: flex; align-items: center; justify-content: space-between; min-height: 92px; padding: 18px 24px; border-bottom: 1px solid var(--v2-line); background: #0c0e10; } +.v2-page-header span { color: var(--v2-red); font-family: var(--v2-mono); font-size: 10px; letter-spacing: .12em; text-transform: uppercase; } +.v2-page-header h1 { margin: 2px 0 0; font-size: 21px; font-weight: 650; } +.v2-page-header p { margin: 2px 0 0; color: var(--v2-muted); font-size: 12px; } +.v2-command-button { display: flex; align-items: center; gap: 12px; padding: 8px 9px 8px 13px; border: 1px solid var(--v2-line); border-radius: 5px; background: var(--v2-surface); color: var(--v2-muted); } +.v2-command-button kbd { padding: 3px 7px; border: 1px solid #4b3b3d; border-radius: 3px; background: var(--v2-bg); color: var(--v2-text); font-family: var(--v2-mono); font-size: 10px; } +.v2-operator-bar { position: relative; z-index: 30; min-height: 78px; gap: 18px; padding-block: 10px; } +.v2-route-heading { min-width: 145px; } +.v2-operator-controls { display: flex; min-width: 0; align-items: center; justify-content: flex-end; gap: 7px; margin-left: auto; } +.v2-operator-controls .role-chip { max-width: 205px; border-radius: 4px; } +.v2-operator-controls .role-menu { color: var(--v2-text); } +.v2-active-run { display: grid; min-width: 125px; max-width: 185px; gap: 3px; padding: 6px 9px; border: 1px solid var(--v2-line); border-radius: 4px; background: var(--v2-surface); text-align: left; } +.v2-active-run strong { overflow: hidden; font-family: var(--v2-mono); font-size: 9px; font-weight: 500; text-overflow: ellipsis; white-space: nowrap; } +.v2-active-run:hover { border-color: #765056; background: #19181a; } +.v2-page { display: grid; gap: 13px; min-height: 100%; padding: 16px; align-content: start; } + +.v2-panel { min-width: 0; overflow: hidden; border: 1px solid var(--v2-line); border-radius: 5px; background: var(--v2-bg-soft); } +.v2-panel-header { display: flex; min-height: 42px; align-items: center; gap: 12px; padding: 0 13px; border-bottom: 1px solid var(--v2-line); background: #0e1012; } +.v2-panel-title { display: flex; min-width: 0; align-items: baseline; gap: 6px; } +.v2-panel-title h2 { margin: 0; font-size: 12px; font-weight: 650; letter-spacing: .01em; } +.v2-panel-title span { overflow: hidden; color: var(--v2-muted); font-size: 10px; text-overflow: ellipsis; white-space: nowrap; } +.v2-panel-actions { display: flex; align-items: center; gap: 8px; margin-left: auto; } +.v2-button { min-height: 31px; padding: 0 12px; border: 1px solid #493235; border-radius: 4px; background: #131416; color: var(--v2-text); font-size: 11px; } +.v2-button:hover:not(:disabled) { border-color: #765056; background: #19181a; } +.v2-button-small { min-height: 27px; padding: 0 8px; font-size: 10px; } +.v2-button-primary { border-color: var(--v2-red-strong); background: var(--v2-red-strong); color: white; } +.v2-button-primary:hover:not(:disabled) { background: #dc3037; } +.v2-button-danger { border-color: #8c2e32; color: #ff7479; } +.v2-text-button { padding: 0; border: 0; background: transparent; color: var(--v2-teal); text-decoration: underline; } +.v2-actions { display: flex; align-items: center; gap: 10px; margin-top: 14px; } + +.v2-status { display: inline-flex; align-items: center; gap: 5px; color: var(--v2-muted); font-family: var(--v2-mono); font-size: 9px; text-transform: uppercase; } +.v2-status span { font-size: 7px; } +.v2-status-running, .v2-status-succeeded { color: var(--v2-green); } +.v2-status-queued, .v2-status-pausing { color: var(--v2-amber); } +.v2-status-paused { color: var(--v2-blue); } +.v2-status-failed, .v2-status-cancelled { color: var(--v2-red); } +.v2-verdict { display: inline-flex; width: fit-content; padding: 2px 6px; border: 1px solid #6e292c; border-radius: 3px; background: #251010; color: #ff5b60; font-family: var(--v2-mono); font-size: 9px; letter-spacing: .05em; text-transform: uppercase; } +.v2-verdict-refused, .v2-verdict-held, .v2-verdict-pass { border-color: #225c44; background: #0e251c; color: var(--v2-green); } +.v2-verdict-partial, .v2-verdict-inconclusive { border-color: #624b21; background: #261e0d; color: var(--v2-amber); } + +.v2-live { display: grid; width: 100%; height: 100%; min-width: 0; min-height: 0; grid-template-rows: auto auto minmax(0, 1fr) auto; background: var(--v2-bg); } +.v2-live-dashboard { grid-template-rows: auto minmax(0, 1fr); } +.v2-agent { display: grid; width: 100%; height: 100%; min-width: 0; min-height: 0; grid-template-rows: auto auto minmax(250px, 1fr) auto; overflow: hidden; background: var(--v2-bg); } +.v2-live-selector { display: flex; min-height: 49px; align-items: center; gap: 12px; padding: 7px 12px; border-bottom: 1px solid var(--v2-line); background: #0b0d0f; } +.v2-live-selector > div { display: grid; min-width: 145px; gap: 2px; } +.v2-live-selector strong { color: var(--v2-text); font-size: 10px; } +.v2-live-selector > div span, .v2-live-selector-meta { color: var(--v2-muted); font: 8px var(--v2-mono); } +.v2-live-selector label { min-width: 0; flex: 1; } +.v2-live-selector select { width: 100%; min-height: 31px; padding: 0 10px; border: 1px solid var(--v2-line); border-radius: 4px; background: #111416; color: var(--v2-text); font: 9px var(--v2-mono); } +.v2-live-selector select:focus { border-color: var(--v2-teal); outline: 0; } +.v2-live:has(> .v2-launcher[open]) { overflow-y: auto; grid-template-rows: auto auto minmax(440px, 1fr) auto; } +.v2-run-strip { display: grid; min-width: 0; min-height: 71px; grid-template-columns: minmax(125px, 1.1fr) minmax(110px, .85fr) minmax(110px, 1fr) minmax(125px, .9fr) 82px minmax(118px, .85fr) 95px auto; align-items: stretch; border-bottom: 1px solid var(--v2-line); background: #0b0d0f; } +.v2-strip-field, .v2-strip-progress, .v2-strip-state { display: flex; min-width: 0; flex-direction: column; justify-content: center; gap: 4px; padding: 0 12px; border-right: 1px solid var(--v2-line-soft); } +.v2-strip-field span, .v2-strip-progress > span, .v2-strip-state > span:first-child { color: var(--v2-muted); font-size: 9px; } +.v2-strip-field strong { overflow: hidden; font-family: var(--v2-mono); font-size: 10px; font-weight: 500; text-overflow: ellipsis; white-space: nowrap; } +.v2-strip-progress div { width: 100%; height: 5px; overflow: hidden; border-radius: 3px; background: #38292a; } +.v2-strip-progress i { display: block; height: 100%; background: var(--v2-teal); } +.v2-strip-actions { display: flex; align-items: center; gap: 7px; padding: 0 12px; } +.v2-live > .v2-error { margin: 8px 10px 0; } +.v2-live-grid { display: grid; min-width: 0; min-height: 0; grid-template-columns: minmax(600px, 1fr) minmax(320px, 390px); overflow: hidden; } +.v2-observatory { display: grid; min-width: 0; min-height: 0; grid-template-rows: auto minmax(260px, 1fr); overflow: hidden; } +.v2-observatory > .v2-panel { border-width: 0 1px 1px 0; border-radius: 0; } +.v2-overview-panel { min-height: 0; } +.v2-live-metrics { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); border-bottom: 1px solid var(--v2-line-soft); } +.v2-live-metrics article { display: grid; min-width: 0; gap: 3px; padding: 10px 12px; border-right: 1px solid var(--v2-line-soft); background: #0d0f11; } +.v2-live-metrics article:last-child { border-right: 0; } +.v2-live-metrics span { color: var(--v2-muted); font-size: 8px; text-transform: uppercase; } +.v2-live-metrics strong { overflow: hidden; font: 15px var(--v2-mono); font-weight: 500; text-overflow: ellipsis; white-space: nowrap; } +.v2-live-metrics strong small { color: var(--v2-dim); font-size: 9px; } +.v2-overview-note { margin: 0; padding: 8px 12px; color: var(--v2-dim); font-size: 9px; } +.v2-overview-matrix { max-height: 190px; overflow: auto; } +.v2-overview-matrix > summary { display: flex; position: sticky; z-index: 4; top: 0; min-height: 31px; align-items: center; justify-content: space-between; gap: 12px; padding: 0 10px; border-bottom: 1px solid var(--v2-line-soft); background: #0d0f11; color: var(--v2-muted); cursor: pointer; font-size: 9px; } +.v2-overview-matrix .v2-matrix-scroll { height: auto; max-height: 155px; } +.v2-legend { display: flex; flex-wrap: wrap; gap: 11px; font-family: var(--v2-mono); font-size: 9px; } +.v2-legend i { font-style: normal; } +.v2-legend .pass { color: var(--v2-green); } +.v2-legend .fail { color: #ff777b; } +.v2-legend .bypass { color: var(--v2-red); } +.v2-legend .inconclusive { color: var(--v2-amber); } +.v2-matrix-scroll { width: 100%; height: calc(100% - 42px); overflow: auto; } +.v2-matrix { width: max-content; min-width: 100%; border-collapse: collapse; font-family: var(--v2-mono); font-size: 9px; } +.v2-matrix th, .v2-matrix td { height: 27px; padding: 0; border-right: 1px solid #201d1e; border-bottom: 1px solid #201d1e; text-align: center; } +.v2-matrix thead th { position: sticky; z-index: 2; top: 0; min-width: 28px; background: #101214; color: var(--v2-muted); font-weight: 500; } +.v2-matrix thead th:first-child { z-index: 3; left: 0; min-width: 175px; text-align: left; padding-left: 9px; } +.v2-matrix tbody th { position: sticky; z-index: 1; left: 0; max-width: 210px; padding: 0 9px; background: #101214; color: var(--v2-text); font-weight: 500; text-align: left; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.v2-matrix tbody th span { display: inline-block; width: 25px; color: var(--v2-dim); } +.v2-matrix-cell { width: 100%; height: 100%; padding: 0; border: 0; border-radius: 0; background: #153b2c; color: #9fe3bd; font-family: var(--v2-mono); font-size: 9px; } +.v2-matrix-cell.pass { background: #174631; color: #b2e6c7; } +.v2-matrix-cell.fail { background: #713334; color: #ffd0d1; } +.v2-matrix-cell.bypass { background: #a83b3c; color: white; } +.v2-matrix-cell.inconclusive { background: #775a21; color: #fff0c4; } +.v2-matrix-cell.selected { box-shadow: inset 0 0 0 2px white; } +.v2-matrix-empty { color: #514a48; } + +.v2-timeline-panel { display: flex; min-height: 0; flex-direction: column; } +.v2-switch { display: inline-flex; align-items: center; gap: 5px; color: var(--v2-muted); font-size: 10px; } +.v2-switch input { accent-color: var(--v2-green); } +.v2-view-toggle { display: inline-flex; padding: 2px; border: 1px solid var(--v2-line); border-radius: 4px; background: #090b0d; } +.v2-view-toggle button { min-height: 23px; padding: 0 8px; border: 0; border-radius: 2px; background: transparent; color: var(--v2-muted); font-size: 9px; } +.v2-view-toggle button[aria-pressed="true"] { background: #272123; color: var(--v2-text); } +.v2-filterbar { display: flex; min-width: 0; flex-wrap: wrap; gap: 7px; padding: 8px; border-bottom: 1px solid var(--v2-line-soft); background: #0c0e10; } +.v2-filterbar input, .v2-filterbar select { min-height: 29px; border: 1px solid var(--v2-line); border-radius: 4px; background: #0b0d0f; color: var(--v2-muted); font-size: 10px; } +.v2-filterbar input { min-width: 220px; flex: 1; padding: 0 9px; } +.v2-filterbar select { max-width: 160px; padding: 0 24px 0 8px; } +.v2-timeline { min-height: 0; flex: 1; overflow: auto; } +.v2-event-row { display: grid; width: 100%; min-height: 49px; grid-template-columns: 72px 92px minmax(0, 1fr); align-items: start; padding: 8px 9px; border: 0; border-bottom: 1px solid #211d1e; background: transparent; color: var(--v2-muted); text-align: left; } +.v2-event-row:hover { background: #131315; } +.v2-event-row.selected { box-shadow: inset 0 0 0 1px var(--v2-teal); background: #10201e; color: var(--v2-text); } +.v2-event-row > time, .v2-event-row > span { min-width: 0; font-size: 9px; } +.v2-event-actor { display: flex; align-items: center; gap: 7px; color: var(--v2-text); } +.v2-event-actor i { color: var(--v2-muted); font-size: 7px; font-style: normal; } +.v2-event-copy { display: grid; min-width: 0; gap: 3px; } +.v2-event-copy > span:first-child { display: flex; align-items: center; gap: 7px; } +.v2-event-copy strong { color: var(--v2-text); font-size: 9px; font-weight: 600; text-transform: capitalize; } +.v2-event-copy > span:nth-child(2) { display: -webkit-box; overflow: hidden; color: #c8c1be; font-size: 10px; line-height: 1.35; overflow-wrap: anywhere; -webkit-box-orient: vertical; -webkit-line-clamp: 2; } +.v2-event-copy small { color: var(--v2-dim); font: 8px var(--v2-mono); } +.v2-event-copy small i { float: right; color: #615a57; font-style: normal; } +.v2-actor-attacker .v2-event-actor, .v2-actor-attacker .v2-event-actor i { color: var(--v2-red); } +.v2-actor-target .v2-event-actor, .v2-actor-target .v2-event-actor i { color: var(--v2-teal); } +.v2-actor-judge .v2-event-actor, .v2-actor-judge .v2-event-actor i { color: var(--v2-violet); } +.v2-actor-tool .v2-event-actor, .v2-actor-tool .v2-event-actor i { color: var(--v2-amber); } + +.v2-inspector { display: flex; min-width: 0; min-height: 0; flex-direction: column; overflow: hidden; border-bottom: 1px solid var(--v2-line); background: #0c0e10; } +.v2-inspector > header { min-height: 42px; padding: 0 13px; border-bottom: 1px solid var(--v2-line); } +.v2-inspector > header > div { display: flex; height: 100%; align-items: center; justify-content: space-between; gap: 10px; } +.v2-inspector h2 { margin: 0; font-size: 12px; } +.v2-inspector > header span { overflow: hidden; max-width: 145px; color: var(--v2-muted); font-size: 8px; text-overflow: ellipsis; white-space: nowrap; } +.v2-inspector-tabs { display: flex; min-height: 39px; overflow-x: auto; border-bottom: 1px solid var(--v2-line); } +.v2-inspector-tabs button { min-width: 0; flex: 1 1 auto; padding: 0 6px; border: 0; border-bottom: 2px solid transparent; background: transparent; color: var(--v2-muted); font-size: 9px; } +.v2-inspector-tabs button.active { border-bottom-color: var(--v2-red); color: var(--v2-text); } +.v2-inspector-body { min-height: 0; flex: 1; overflow: auto; } +.v2-inspector-summary { padding: 13px; } +.v2-inspector-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding-bottom: 13px; } +.v2-inspector-heading span { color: var(--v2-muted); font-size: 9px; } +.v2-inspector-heading h3 { margin: 4px 0 0; font-size: 12px; font-weight: 550; } +.v2-inspector-section { padding: 13px 0; border-top: 1px solid var(--v2-line-soft); } +.v2-inspector-section h4, .v2-result-stack h3 { margin: 0 0 8px; color: var(--v2-text); font-size: 10px; font-weight: 550; } +.v2-kv { display: grid; margin: 0 0 13px; } +.v2-kv div { display: grid; grid-template-columns: minmax(85px, .7fr) minmax(0, 1.3fr); gap: 9px; padding: 4px 0; } +.v2-kv dt { color: var(--v2-muted); font-size: 9px; } +.v2-kv dd { min-width: 0; margin: 0; overflow-wrap: anywhere; font-family: var(--v2-mono); font-size: 9px; } +.v2-event-conversation { display: grid; gap: 9px; margin: 0; padding: 12px; list-style: none; } +.v2-event-conversation li { display: grid; gap: 7px; padding: 10px; border: 1px solid var(--v2-line-soft); border-left: 3px solid var(--v2-dim); border-radius: 4px; background: #101214; } +.v2-event-conversation li.v2-conversation-attacker { border-left-color: var(--v2-red); } +.v2-event-conversation li.v2-conversation-target { border-left-color: var(--v2-teal); } +.v2-event-conversation li.v2-conversation-judge { border-left-color: var(--v2-violet); } +.v2-event-conversation li.v2-conversation-tool { border-left-color: var(--v2-amber); } +.v2-event-conversation li.v2-conversation-operator { border-left-color: var(--v2-blue); } +.v2-event-conversation header { display: flex; align-items: center; justify-content: space-between; gap: 8px; } +.v2-event-conversation header strong { color: var(--v2-text); font: 9px var(--v2-mono); text-transform: uppercase; } +.v2-event-conversation header span { color: var(--v2-dim); font-size: 8px; text-transform: capitalize; } +.v2-event-conversation p { margin: 0; color: #d4cdca; font: 10px/1.5 var(--v2-mono); white-space: pre-wrap; overflow-wrap: anywhere; } +.v2-event-conversation details summary { color: var(--v2-muted); cursor: pointer; font-size: 9px; } + +.v2-steer { display: grid; min-height: 151px; grid-template-rows: auto 1fr; padding: 11px 12px 12px; border-top: 1px solid var(--v2-line); background: #0b0d0f; } +.v2-steer-head { display: flex; align-items: center; gap: 14px; min-height: 25px; color: var(--v2-muted); font-size: 10px; } +.v2-steer-head strong { color: var(--v2-red); font-size: 11px; } +.v2-steer-head span:last-child { overflow: hidden; margin-left: auto; color: var(--v2-green); text-overflow: ellipsis; white-space: nowrap; } +.v2-steer-row { display: grid; min-height: 0; grid-template-columns: minmax(0, 1fr) auto; align-items: end; gap: 8px; padding: 7px; border: 1px solid #712e31; border-radius: 5px; background: #111012; } +.v2-steer-row label { height: 100%; } +.v2-steer-row textarea { width: 100%; height: 100%; min-height: 66px; resize: none; border: 0; background: transparent; color: var(--v2-text); font-family: var(--v2-mono); font-size: 11px; } +.v2-steer-row textarea:focus { outline: 0; } + +.v2-empty { display: flex; min-height: 108px; align-items: center; justify-content: center; flex-direction: column; gap: 5px; padding: 20px; color: var(--v2-muted); text-align: center; } +.v2-empty strong { color: var(--v2-text); font-size: 11px; font-weight: 550; } +.v2-empty span { max-width: 420px; font-size: 10px; } +.v2-loading { display: flex; min-height: 180px; align-items: center; justify-content: center; gap: 8px; color: var(--v2-muted); } +.v2-loading span { color: var(--v2-teal); font-size: 7px; } +.v2-error { display: flex; align-items: center; gap: 9px; padding: 8px 10px; border: 1px solid #6f282b; border-radius: 4px; background: #211011; color: #ff9699; font-size: 10px; } +.v2-error span { min-width: 0; flex: 1; } +.v2-code { max-height: 420px; margin: 0; overflow: auto; padding: 11px; background: #090b0d; color: #c9c2bf; font-family: var(--v2-mono); font-size: 10px; line-height: 1.55; white-space: pre-wrap; overflow-wrap: anywhere; } + +.v2-compose-grid, .v2-workflow-grid, .v2-library-grid, .v2-runs-grid { grid-template-columns: minmax(360px, .9fr) minmax(440px, 1.1fr); } +.v2-compose-session { display: flex; min-height: 62px; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 14px; border-bottom: 1px solid var(--v2-line-soft); background: #0c0e10; } +.v2-compose-session > div { display: grid; min-width: 0; gap: 2px; } +.v2-compose-session span { color: var(--v2-teal); font: 8px var(--v2-mono); letter-spacing: .1em; } +.v2-compose-session.active span { color: var(--v2-green); } +.v2-compose-session strong { overflow: hidden; color: var(--v2-text); font-size: 10px; text-overflow: ellipsis; white-space: nowrap; } +.v2-compose-session small { overflow: hidden; color: var(--v2-dim); font: 8px var(--v2-mono); text-overflow: ellipsis; white-space: nowrap; } +.v2-compose-session .v2-button { flex: 0 0 auto; } +.v2-field :disabled { cursor: not-allowed; opacity: .55; } +.v2-conversation-thread { max-height: calc(100vh - 210px); overflow: auto; padding: 12px; } +.v2-conversation-turn { display: grid; gap: 8px; margin-bottom: 15px; } +.v2-turn-user, .v2-turn-target { max-width: 90%; padding: 10px 12px; border: 1px solid var(--v2-line-soft); border-radius: 5px; background: #111316; } +.v2-turn-user { margin-left: auto; border-color: #315c57; background: #0e1b1a; } +.v2-turn-target { margin-right: auto; border-color: #593238; background: #171113; } +.v2-conversation-turn header, .v2-turn-preview header { display: flex; align-items: center; justify-content: space-between; gap: 10px; } +.v2-conversation-turn header > span { color: var(--v2-muted); font: 8px var(--v2-mono); letter-spacing: .08em; } +.v2-conversation-turn header small { color: var(--v2-teal); font: 8px var(--v2-mono); } +.v2-conversation-turn p { margin: 8px 0 0; color: var(--v2-text); font: 10px/1.55 var(--v2-mono); white-space: pre-wrap; overflow-wrap: anywhere; } +.v2-conversation-turn details { margin-top: 8px; color: var(--v2-muted); font-size: 9px; } +.v2-conversation-turn details summary { cursor: pointer; } +.v2-turn-preview { margin: 12px; padding: 11px; border: 1px dashed var(--v2-teal); background: #0d1515; } +.v2-turn-preview header { margin-bottom: 8px; color: var(--v2-muted); font-size: 9px; } +.v2-form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 11px; padding: 14px; } +.v2-field { display: grid; min-width: 0; gap: 5px; color: var(--v2-muted); font-size: 10px; } +.v2-field > span { color: var(--v2-text); font-size: 10px; } +.v2-field small { color: var(--v2-dim); } +.v2-field-wide { grid-column: 1 / -1; } +.v2-field input, .v2-field select, .v2-field textarea { width: 100%; min-height: 34px; padding: 7px 9px; border: 1px solid var(--v2-line); border-radius: 4px; background: #090b0d; color: var(--v2-text); } +.v2-field textarea { min-height: 106px; resize: vertical; font-family: var(--v2-mono); font-size: 10px; } +.v2-checkbox-field { display: flex; align-items: center; gap: 8px; min-height: 34px; } +.v2-checkbox-field input, .v2-check-grid input { accent-color: var(--v2-teal); } +.v2-check-grid { display: grid; max-height: 195px; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; margin: 0 14px; padding: 10px; overflow: auto; border: 1px solid var(--v2-line-soft); } +.v2-check-grid legend { padding: 0 6px; color: var(--v2-muted); font-size: 10px; } +.v2-check-grid label { display: flex; align-items: center; gap: 6px; min-width: 0; color: var(--v2-muted); font-family: var(--v2-mono); font-size: 9px; } +.v2-check-grid label span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.v2-panel > .v2-actions { padding: 0 14px 14px; } +.v2-result-stack { display: grid; gap: 12px; padding: 14px; } +.v2-result-stack section { min-width: 0; overflow: hidden; border: 1px solid var(--v2-line-soft); } +.v2-result-stack section h3 { padding: 9px 10px 0; } +.v2-result-stack .v2-verdict { margin: 0 10px 8px; } + +.v2-capability-list, .v2-library-list, .v2-finding-list, .v2-run-list { max-height: calc(100vh - 230px); overflow: auto; } +.v2-capability-list button, .v2-library-list button, .v2-finding-list button, .v2-run-list button { display: grid; width: 100%; min-width: 0; gap: 4px; padding: 10px 12px; border: 0; border-bottom: 1px solid var(--v2-line-soft); background: transparent; text-align: left; } +.v2-capability-list button:hover, .v2-library-list button:hover, .v2-finding-list button:hover, .v2-run-list button:hover { background: #131416; } +.v2-capability-list button.active, .v2-library-list button.active, .v2-finding-list button.active, .v2-run-list button.active { box-shadow: inset 3px 0 var(--v2-teal); background: #10201e; } +.v2-capability-list strong, .v2-library-list strong, .v2-finding-list strong, .v2-run-list strong { overflow: hidden; color: var(--v2-text); font-size: 11px; font-weight: 550; text-overflow: ellipsis; white-space: nowrap; } +.v2-capability-list span, .v2-library-list span, .v2-finding-list span, .v2-run-list span { color: var(--v2-muted); font-size: 10px; } +.v2-capability-list small, .v2-library-list small, .v2-finding-list small, .v2-run-list small { color: var(--v2-dim); font-family: var(--v2-mono); font-size: 9px; } +.v2-library-list button { grid-template-columns: 64px minmax(120px, .45fr) minmax(180px, 1fr); align-items: center; } +.v2-kind { width: fit-content; padding: 2px 5px; border: 1px solid var(--v2-line); border-radius: 3px; font-family: var(--v2-mono); font-size: 8px !important; text-transform: uppercase; } +.v2-kind-preset { color: var(--v2-red) !important; } +.v2-kind-transform { color: var(--v2-teal) !important; } +.v2-kind-tool { color: var(--v2-amber) !important; } +.v2-finding-list button > div { display: flex; align-items: center; gap: 8px; } +.v2-finding-list button strong { white-space: normal; } + +.v2-metric-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; } +.v2-metric-grid article { display: grid; gap: 7px; padding: 16px; border: 1px solid var(--v2-line); border-radius: 5px; background: var(--v2-bg-soft); } +.v2-metric-grid span { color: var(--v2-muted); font-size: 10px; text-transform: uppercase; } +.v2-metric-grid strong { font-family: var(--v2-mono); font-size: 25px; font-weight: 500; } +.v2-report-actions { display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 18px; } +.v2-report-actions p { max-width: 630px; margin: 0; color: var(--v2-muted); } +.v2-reports-dashboard { align-content: start; } +.v2-report-scope { display: grid; grid-template-columns: minmax(260px, 1fr) minmax(230px, .55fr) auto; align-items: end; gap: 14px; padding: 13px 15px; border: 1px solid var(--v2-line); border-radius: 5px; background: linear-gradient(110deg, #171215, #0d1012 70%); } +.v2-report-scope > div:first-child { display: grid; gap: 4px; } +.v2-report-scope > div:first-child strong { font-size: 14px; } +.v2-report-scope > div:first-child span { color: var(--v2-muted); font-size: 10px; } +.v2-report-scope > div:last-child { display: flex; gap: 7px; } +.v2-report-metrics article { position: relative; overflow: hidden; } +.v2-report-metrics article::after { position: absolute; right: -10px; bottom: -28px; width: 74px; height: 74px; border: 1px solid #42282b; border-radius: 50%; content: ""; } +.v2-report-metrics small { color: var(--v2-dim); font: 8px var(--v2-mono); } +.v2-report-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; } +.v2-report-grid > .v2-panel { min-height: 270px; } +.v2-asr-chart { display: flex; height: 225px; align-items: end; gap: 4px; padding: 19px 13px 9px; overflow-x: auto; background: repeating-linear-gradient(to top, transparent 0, transparent 43px, #1d1a1b 44px); } +.v2-asr-chart > div { display: grid; min-width: 20px; height: 100%; flex: 1 0 20px; grid-template-rows: minmax(0, 1fr) 18px; align-items: end; gap: 5px; } +.v2-asr-chart span { display: block; width: 100%; min-height: 2px; border-radius: 2px 2px 0 0; background: var(--v2-green); } +.v2-asr-chart span.medium { background: var(--v2-amber); } +.v2-asr-chart span.high { background: var(--v2-red); } +.v2-asr-chart span.zero { opacity: .35; } +.v2-asr-chart small { overflow: hidden; color: var(--v2-dim); font: 7px var(--v2-mono); text-align: center; text-overflow: clip; writing-mode: vertical-rl; transform: rotate(180deg); } +.v2-verdict-bars { display: grid; gap: 11px; padding: 15px; } +.v2-verdict-bars article { display: grid; gap: 5px; } +.v2-verdict-bars header { display: flex; align-items: center; justify-content: space-between; } +.v2-verdict-bars header strong { font: 11px var(--v2-mono); } +.v2-verdict-bars article > div { height: 7px; overflow: hidden; border-radius: 4px; background: #282124; } +.v2-verdict-bars article > div span { display: block; height: 100%; background: linear-gradient(90deg, var(--v2-red-strong), var(--v2-red)); } +.v2-verdict-bars article small { color: var(--v2-muted); font-size: 8px; } +.v2-technique-table { max-height: 360px; overflow: auto; } +.v2-technique-table article { display: grid; min-height: 41px; grid-template-columns: minmax(120px, 1fr) 55px minmax(80px, .75fr) 38px; align-items: center; gap: 9px; padding: 0 12px; border-bottom: 1px solid var(--v2-line-soft); } +.v2-technique-table article strong { overflow: hidden; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; } +.v2-technique-table article > span { color: var(--v2-muted); font: 8px var(--v2-mono); } +.v2-technique-table article > div { height: 5px; overflow: hidden; border-radius: 3px; background: #282124; } +.v2-technique-table article i { display: block; height: 100%; background: var(--v2-teal); } +.v2-technique-table article b { color: var(--v2-text); font: 9px var(--v2-mono); text-align: right; } +.v2-report-preview { height: 360px; margin: 0; overflow: auto; padding: 14px; background: #090b0d; color: #d7d0cc; font: 9px/1.65 var(--v2-mono); white-space: pre-wrap; overflow-wrap: anywhere; } +.v2-provider-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; padding: 13px; } +.v2-provider-grid article { padding: 12px; border: 1px solid var(--v2-line-soft); border-radius: 4px; background: #0b0d0f; } +.v2-provider-grid article > div:first-child { display: flex; align-items: center; justify-content: space-between; gap: 10px; } +.v2-provider-grid h3 { margin: 0; font-size: 12px; } +.v2-provider-grid .v2-button { width: 100%; margin-top: 8px; } +.v2-provider-test-state { min-height: 28px; margin: 8px 0 0; padding: 7px 8px; border-left: 2px solid var(--v2-dim); background: #101215; color: var(--v2-muted); font: 9px/1.45 var(--v2-mono); } +.v2-provider-test-state.verified { border-left-color: var(--v2-green); color: var(--v2-green); } +.v2-provider-test-state.failed { border-left-color: var(--v2-red); color: #ff8d96; } +.v2-inline-status { margin: 12px 14px; color: var(--v2-green); font-size: 10px; } +.v2-settings-page { grid-template-columns: minmax(480px, 1.2fr) minmax(300px, .8fr); } +.v2-safeguards { display: grid; gap: 0; } +.v2-safeguards p { display: grid; gap: 4px; margin: 0; padding: 13px; border-bottom: 1px solid var(--v2-line-soft); } +.v2-safeguards strong { font-size: 10px; } +.v2-safeguards span { color: var(--v2-muted); font-size: 10px; } + +.v2-palette-backdrop { position: fixed; z-index: 100; inset: 0; display: flex; align-items: flex-start; justify-content: center; padding: 10vh 20px; background: rgba(0, 0, 0, .72); } +.v2-palette { display: flex; width: min(680px, 100%); max-height: 72vh; flex-direction: column; overflow: hidden; border: 1px solid #634044; border-radius: 7px; background: #0d0f11; box-shadow: 0 24px 70px rgba(0, 0, 0, .55); } +.v2-palette-search { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 8px; padding: 12px; border-bottom: 1px solid var(--v2-line); } +.v2-palette-search label { grid-column: 1 / -1; color: var(--v2-muted); font-size: 9px; text-transform: uppercase; } +.v2-palette-search input { min-height: 39px; padding: 0; border: 0; background: transparent; color: var(--v2-text); font-size: 15px; } +.v2-palette-search input:focus { outline: 0; } +.v2-palette-search kbd { padding: 4px 7px; border: 1px solid var(--v2-line); border-radius: 3px; color: var(--v2-muted); font-family: var(--v2-mono); font-size: 9px; } +.v2-palette-results { min-height: 0; overflow: auto; padding: 8px; } +.v2-palette-group h3 { margin: 8px 8px 4px; color: var(--v2-dim); font-size: 9px; letter-spacing: .12em; text-transform: uppercase; } +.v2-palette-group button { display: grid; width: 100%; grid-template-columns: minmax(150px, .7fr) minmax(200px, 1fr); gap: 12px; padding: 9px; border: 0; border-radius: 4px; background: transparent; text-align: left; } +.v2-palette-group button:hover, .v2-palette-group button:focus-visible { background: #171719; } +.v2-palette-group button span { color: var(--v2-text); } +.v2-palette-group button small { overflow: hidden; color: var(--v2-muted); text-overflow: ellipsis; white-space: nowrap; } +.v2-palette footer { padding: 8px 12px; border-top: 1px solid var(--v2-line); color: var(--v2-dim); font-size: 9px; } + + @media (max-width: 1240px) { + .v2-root { grid-template-columns: 196px minmax(0, 1fr); } + .v2-live-grid { grid-template-columns: minmax(560px, 1fr) 320px; } + .v2-run-strip { grid-template-columns: minmax(120px, 1fr) minmax(105px, .8fr) minmax(105px, .8fr) minmax(120px, 1fr) 82px 100px auto; } + .v2-run-strip .v2-strip-field:nth-of-type(6) { display: none; } + .v2-strip-actions { padding-inline: 8px; } + .v2-provider-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .v2-operator-controls .role-chip { grid-template-columns: auto minmax(55px, 105px); } + .v2-operator-controls .role-chip small { display: none; } + .v2-active-run { display: none; } +} + +@media (max-width: 1024px) { + .v2-root { grid-template-columns: 176px minmax(0, 1fr); } + .v2-rail nav button { padding-inline: 8px; } + .v2-live-grid { grid-template-columns: minmax(520px, 1fr) 300px; } + .v2-run-strip { grid-template-columns: minmax(115px, 1fr) minmax(100px, .8fr) minmax(115px, .9fr) 78px auto; } + .v2-run-strip .v2-strip-field:nth-of-type(2), .v2-run-strip .v2-strip-field:nth-of-type(3) { display: none; } + .v2-compose-grid, .v2-workflow-grid, .v2-library-grid, .v2-runs-grid { grid-template-columns: minmax(320px, .85fr) minmax(380px, 1.15fr); } + .v2-check-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .v2-settings-page { grid-template-columns: 1fr; } + .v2-report-scope { grid-template-columns: 1fr 1fr; } + .v2-report-scope > div:last-child { grid-column: 1 / -1; } +} + +@media (max-width: 860px) { + .v2-root { display: block; overflow: auto; } + .v2-rail { position: fixed; inset: 0 auto 0 0; width: min(280px, 88vw); transform: translateX(-102%); transition: transform 160ms ease; box-shadow: 18px 0 50px rgba(0, 0, 0, .5); } + .v2-rail.open { transform: translateX(0); } + .v2-rail .v2-brand > button { display: block; } + .v2-shell { min-height: 100vh; } + .v2-mobile-header { display: flex; position: sticky; z-index: 15; top: 0; min-height: 52px; align-items: center; justify-content: space-between; padding: 0 10px; border-bottom: 1px solid var(--v2-line); background: #0b0d0f; } + .v2-mobile-header > button { padding: 7px 9px; border: 1px solid var(--v2-line); border-radius: 4px; background: var(--v2-surface); font-size: 10px; } + .v2-mobile-header .v2-brand { min-height: 0; padding: 0; border: 0; } + .v2-page-header { min-height: 80px; padding: 13px 16px; } + .v2-operator-bar { position: relative; z-index: 30; top: auto; } + .v2-operator-controls { overflow-x: auto; justify-content: flex-start; margin-left: 0; padding-bottom: 2px; } + .v2-operator-controls .role-chip { min-width: 145px; } + .v2-route-heading { display: none; } + .v2-command-button { display: none; } + .v2-main-live { overflow: visible; } + .v2-live { display: block; height: auto; } + .v2-run-strip { position: sticky; z-index: 10; top: 52px; min-height: 64px; grid-template-columns: minmax(120px, 1fr) minmax(120px, 1fr) auto; } + .v2-run-strip .v2-strip-progress, .v2-run-strip .v2-strip-field, .v2-run-strip .v2-strip-state { display: none; } + .v2-run-strip .v2-strip-field:first-child, .v2-run-strip .v2-strip-progress { display: flex; } + .v2-live-grid { display: flex; flex-direction: column; overflow: visible; } + .v2-observatory { display: grid; height: 730px; grid-template-rows: auto minmax(390px, 1fr); } + .v2-inspector { min-height: 480px; border-top: 1px solid var(--v2-line); } + .v2-steer { position: sticky; z-index: 9; bottom: 0; min-height: 125px; } + .v2-compose-grid, .v2-workflow-grid, .v2-library-grid, .v2-runs-grid { grid-template-columns: 1fr; } + .v2-capability-list, .v2-library-list, .v2-finding-list, .v2-run-list { max-height: 330px; } + .v2-provider-grid { grid-template-columns: 1fr; } + .v2-report-grid { grid-template-columns: 1fr; } + .v2-report-scope { grid-template-columns: 1fr; } + .v2-report-scope > div:last-child { grid-column: auto; } + .v2-runs-explorer { height: auto; grid-template-columns: 1fr; overflow: visible; } +} + + @media (max-width: 620px) { + .v2-page { padding: 9px; } + .v2-page-header p { display: none; } + .v2-operator-bar { min-height: 68px; padding: 8px 9px; } + .v2-form-grid { grid-template-columns: 1fr; } + .v2-field-wide { grid-column: auto; } + .v2-check-grid { grid-template-columns: 1fr; } + .v2-metric-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .v2-live-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .v2-live-metrics article:last-child { grid-column: 1 / -1; } + .v2-event-row { grid-template-columns: 62px 78px minmax(0, 1fr); padding-inline: 7px; } + .v2-report-actions { align-items: stretch; flex-direction: column; } + .v2-library-list button { grid-template-columns: 62px minmax(0, 1fr); } + .v2-library-list button > span:last-child { grid-column: 1 / -1; } + .v2-steer-head > span:not(:last-child) { display: none; } + .v2-steer-row { grid-template-columns: 1fr; } + .v2-steer-row .v2-button { width: 100%; } + .v2-palette-group button { grid-template-columns: 1fr; gap: 2px; } +} + +@media (prefers-reduced-motion: reduce) { + .v2-root *, .v2-root *::before, .v2-root *::after { scroll-behavior: auto !important; transition: none !important; } +} +.v2-launcher { + margin: 0 12px 10px; + border: 1px solid var(--v2-line); + border-radius: 8px; + background: var(--v2-surface-raised); +} +.v2-attacker-switch { + display: grid; + grid-template-columns: auto minmax(120px, 180px) minmax(180px, 1fr) auto minmax(0, 1fr); + align-items: center; + gap: 8px; + margin: 0 12px 10px; + padding: 9px 10px; + border: 1px solid var(--v2-amber); + border-radius: 6px; + background: #19150f; +} +.v2-history-filters { + display: grid; + grid-template-columns: repeat(4, minmax(110px, 1fr)) auto; + gap: 7px; + margin-bottom: 10px; +} +.v2-history-browser { display: grid; grid-template-columns: minmax(260px, .8fr) minmax(0, 1.2fr); min-height: 420px; border: 1px solid var(--v2-line-soft); } +.v2-history-events { max-height: 580px; overflow: auto; border-right: 1px solid var(--v2-line-soft); } +.v2-history-events > button { display: grid; width: 100%; gap: 4px; padding: 9px 10px; border: 0; border-bottom: 1px solid var(--v2-line-soft); background: transparent; text-align: left; } +.v2-history-events > button:hover, .v2-history-events > button.active { background: var(--v2-surface-raised); } +.v2-history-events > button > span:first-child { display: flex; align-items: center; justify-content: space-between; gap: 8px; } +.v2-history-events > button > span:nth-child(2), .v2-history-events small { color: var(--v2-muted); font: 10px var(--v2-mono); } +.v2-history-inspector { min-width: 0; max-height: 580px; overflow: auto; padding: 12px; } +.v2-runs-explorer { height: 100%; grid-template-columns: minmax(230px, .58fr) minmax(560px, 1.42fr); align-content: stretch; overflow: hidden; } +.v2-runs-explorer > .v2-panel { display: flex; min-height: 0; flex-direction: column; } +.v2-runs-explorer .v2-run-list { min-height: 0; max-height: none; flex: 1; } +.v2-log-toolbar { position: relative; z-index: 5; display: flex; min-height: 48px; align-items: center; gap: 7px; padding: 7px 8px; border-bottom: 1px solid var(--v2-line-soft); background: #0c0e10; } +.v2-log-search { min-width: 150px; flex: 1; } +.v2-log-search input { width: 100%; min-height: 31px; padding: 0 9px; border: 1px solid var(--v2-line); border-radius: 4px; background: #090b0d; color: var(--v2-text); font-size: 10px; } +.v2-facet-menu { position: relative; flex: 0 0 auto; } +.v2-facet-menu > summary { display: flex; min-height: 31px; align-items: center; gap: 7px; padding: 0 8px; border: 1px solid var(--v2-line); border-radius: 4px; background: #111214; color: var(--v2-muted); cursor: pointer; font-size: 9px; list-style: none; } +.v2-facet-menu > summary::-webkit-details-marker { display: none; } +.v2-facet-menu > summary::after { color: var(--v2-dim); content: "▾"; } +.v2-facet-menu > summary span { min-width: 25px; padding: 1px 4px; border-radius: 8px; background: #292124; color: var(--v2-text); font: 8px var(--v2-mono); text-align: center; } +.v2-facet-menu > div { position: absolute; z-index: 50; top: calc(100% + 5px); right: 0; display: grid; width: min(260px, 80vw); max-height: 340px; overflow: auto; padding: 6px; border: 1px solid var(--v2-line); border-radius: 5px; background: #111214; box-shadow: 0 16px 45px rgba(0, 0, 0, .55); } +.v2-facet-menu header { display: flex; gap: 4px; padding-bottom: 5px; border-bottom: 1px solid var(--v2-line-soft); } +.v2-facet-menu header button { padding: 3px 7px; border: 1px solid var(--v2-line); border-radius: 3px; background: #171517; color: var(--v2-muted); font-size: 8px; } +.v2-facet-menu label { display: flex; min-width: 0; align-items: center; gap: 7px; padding: 6px; color: var(--v2-muted); font: 9px var(--v2-mono); } +.v2-facet-menu label:hover { background: #1a1719; color: var(--v2-text); } +.v2-facet-menu label span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.v2-facet-menu input { accent-color: var(--v2-teal); } +.v2-full-stream { min-height: 0; flex: 1; overflow: auto; background: #090b0d; } +.v2-full-stream > header { position: sticky; z-index: 3; top: 0; display: flex; min-height: 38px; align-items: center; justify-content: space-between; padding: 0 10px; border-bottom: 1px solid var(--v2-line); background: rgba(14, 16, 18, .96); color: var(--v2-muted); font: 9px var(--v2-mono); } +.v2-full-stream article { position: relative; padding: 11px 13px 13px 17px; border-bottom: 1px solid var(--v2-line-soft); } +.v2-full-stream article::before { position: absolute; inset: 0 auto 0 0; width: 3px; background: var(--v2-dim); content: ""; } +.v2-full-stream article.v2-stream-attacker::before { background: var(--v2-red); } +.v2-full-stream article.v2-stream-target::before { background: var(--v2-teal); } +.v2-full-stream article.v2-stream-judge::before { background: var(--v2-violet); } +.v2-full-stream article.v2-stream-tool::before { background: var(--v2-amber); } +.v2-full-stream article > div { display: flex; min-width: 0; align-items: center; gap: 7px; } +.v2-full-stream time { color: var(--v2-dim); font: 8px var(--v2-mono); } +.v2-full-stream article > div > strong { color: var(--v2-text); font-size: 9px; text-transform: uppercase; } +.v2-full-stream article > div > span:not(.v2-verdict) { padding: 2px 5px; border: 1px solid var(--v2-line-soft); border-radius: 3px; color: var(--v2-muted); font: 8px var(--v2-mono); } +.v2-full-stream pre { margin: 8px 0 0; color: #c9c2bf; font: 10px/1.55 var(--v2-mono); white-space: pre-wrap; overflow-wrap: anywhere; } +.v2-log-timeline { min-height: 0; flex: 1; border: 0; } +.v2-log-timeline .v2-history-events, .v2-log-timeline .v2-history-inspector { max-height: none; } +.v2-raw-stream { min-height: 0; flex: 1; margin: 0; overflow: auto; padding: 12px; background: #090b0d; color: #c9c2bf; font: 9px/1.55 var(--v2-mono); white-space: pre-wrap; overflow-wrap: anywhere; } +@media (max-width: 1100px) { + .v2-history-filters { grid-template-columns: 1fr 1fr; } + .v2-history-browser { grid-template-columns: 1fr; } + .v2-history-events { border-right: 0; border-bottom: 1px solid var(--v2-line-soft); } + .v2-log-toolbar { flex-wrap: wrap; } +} +.v2-attacker-switch > strong { color: var(--v2-amber); } +.v2-attacker-switch > span { color: var(--v2-muted); font-size: 11px; } +@media (max-width: 900px) { + .v2-attacker-switch { grid-template-columns: 1fr 1fr; } + .v2-attacker-switch > span { grid-column: 1 / -1; } +} +.v2-launcher > summary { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 10px 12px; + cursor: pointer; + color: var(--v2-text); +} +.v2-launcher > summary > span:first-child { display: grid; gap: 2px; } +.v2-launcher > summary small { color: var(--v2-muted); font-weight: 400; } +.v2-launcher > summary > span:last-child { + color: var(--v2-accent); + font: 600 11px var(--v2-mono); + text-transform: uppercase; +} +.v2-launcher-body { display: grid; gap: 12px; padding: 0 12px 12px; } +.v2-launcher-body > .v2-field textarea { min-height: 64px; } + +/* Workflow studio */ +.v2-workflow-studio { grid-template-rows: auto auto minmax(0, 1fr); height: 100%; align-content: stretch; overflow: hidden; } +.v2-studio-toolbar { display: flex; min-height: 42px; align-items: center; gap: 12px; padding: 6px 8px; border: 1px solid var(--v2-line); border-radius: 5px; background: #0d0f11; } +.v2-studio-toolbar > span { color: var(--v2-muted); font: 9px var(--v2-mono); } +.v2-studio-toolbar > div:last-child { display: flex; gap: 6px; margin-left: auto; } +.v2-segmented { display: inline-flex; padding: 2px; border: 1px solid var(--v2-line); border-radius: 4px; background: #090b0d; } +.v2-segmented button { min-height: 27px; padding: 0 10px; border: 0; border-radius: 3px; background: transparent; color: var(--v2-muted); font-size: 10px; } +.v2-segmented button.active { background: #272023; color: var(--v2-text); box-shadow: inset 0 -2px var(--v2-teal); } +.v2-studio-grid { display: grid; min-width: 0; min-height: 0; grid-template-columns: minmax(205px, .65fr) minmax(330px, 1.05fr) minmax(310px, 1fr); gap: 10px; overflow: hidden; } +.v2-studio-grid > .v2-panel { display: flex; min-height: 0; flex-direction: column; } +.v2-workflow-identity { display: grid; gap: 8px; padding: 10px; border-bottom: 1px solid var(--v2-line-soft); } +.v2-workflow-identity textarea { min-height: 54px; } +.v2-saved-workflows { min-height: 0; overflow: auto; } +.v2-saved-workflows article { display: grid; grid-template-columns: minmax(0, 1fr) auto; border-bottom: 1px solid var(--v2-line-soft); } +.v2-saved-workflows article.active { box-shadow: inset 3px 0 var(--v2-teal); background: #10201e; } +.v2-saved-workflows article > button:first-child { display: grid; grid-column: 1; grid-row: 1 / 3; min-width: 0; gap: 3px; padding: 10px 12px; border: 0; background: transparent; text-align: left; } +.v2-saved-workflows article > button:not(:first-child) { width: 49px; padding: 0; border: 0; border-left: 1px solid var(--v2-line-soft); background: transparent; color: var(--v2-muted); font-size: 8px; } +.v2-saved-workflows article > button:not(:first-child):hover { background: #1b1719; color: var(--v2-text); } +.v2-saved-workflows strong, .v2-saved-workflows small, .v2-saved-workflows span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.v2-saved-workflows strong { font-size: 10px; } +.v2-saved-workflows span { color: var(--v2-teal); font: 9px var(--v2-mono); } +.v2-saved-workflows small { color: var(--v2-muted); font-size: 9px; } +.v2-sequence { min-height: 0; margin: 0; padding: 10px; overflow: auto; list-style: none; counter-reset: steps; } +.v2-sequence li { position: relative; display: grid; grid-template-columns: 31px minmax(0, 1fr) 28px; min-height: 79px; align-items: stretch; } +.v2-sequence li:not(:last-child)::after { position: absolute; z-index: 0; top: 36px; bottom: -8px; left: 14px; width: 1px; background: #65383c; content: ""; } +.v2-sequence-index { z-index: 1; display: grid; width: 29px; height: 29px; place-items: center; margin-top: 9px; border: 1px solid #744247; border-radius: 50%; background: #151012; color: var(--v2-muted); font: 9px var(--v2-mono); } +.v2-sequence li.active .v2-sequence-index { border-color: var(--v2-teal); background: #0c2825; color: var(--v2-teal); } +.v2-sequence-node { display: grid; min-width: 0; gap: 4px; margin: 0 6px 9px 8px; padding: 9px 10px; border: 1px solid var(--v2-line); border-radius: 5px; background: #0a0d0f; text-align: left; } +.v2-sequence-node:hover, .v2-sequence li.active .v2-sequence-node { border-color: #6c4449; background: #171315; } +.v2-sequence-node strong, .v2-sequence-node span, .v2-sequence-node small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.v2-sequence-node strong { font-size: 11px; } +.v2-sequence-node span { color: var(--v2-muted); font-size: 9px; } +.v2-sequence-node small { color: var(--v2-dim); font: 8px var(--v2-mono); } +.v2-sequence-actions { display: grid; align-content: start; gap: 2px; padding-top: 8px; } +.v2-sequence-actions button { width: 25px; height: 20px; padding: 0; border: 1px solid transparent; background: transparent; color: var(--v2-muted); font-size: 10px; } +.v2-sequence-actions button:hover:not(:disabled) { border-color: var(--v2-line); background: var(--v2-surface); color: var(--v2-text); } +.v2-workflow-result { display: grid; gap: 3px; margin: auto 10px 10px; padding: 9px; border: 1px solid #255b4a; border-radius: 4px; background: #0e211b; } +.v2-workflow-result strong { color: var(--v2-green); font-size: 10px; } +.v2-workflow-result span, .v2-workflow-result small { overflow: hidden; color: var(--v2-muted); font: 9px var(--v2-mono); text-overflow: ellipsis; } +.v2-step-palette { min-height: 0; overflow: auto; padding: 6px; } +.v2-step-palette > button { display: grid; width: 100%; grid-template-columns: 22px minmax(0, 1fr) auto; gap: 3px 6px; padding: 9px 8px; border: 1px solid transparent; border-bottom-color: var(--v2-line-soft); background: transparent; text-align: left; } +.v2-step-palette > button:hover { border-color: var(--v2-line); border-radius: 4px; background: #151315; } +.v2-step-palette > button > span { grid-row: 1 / 3; color: var(--v2-teal); font-size: 16px; } +.v2-step-palette strong { overflow: hidden; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; } +.v2-step-palette small { color: var(--v2-dim); font: 8px var(--v2-mono); text-transform: uppercase; } +.v2-step-palette p { grid-column: 2 / 4; margin: 0; overflow: hidden; color: var(--v2-muted); font-size: 9px; text-overflow: ellipsis; white-space: nowrap; } +.v2-step-editor { min-height: 0; overflow: auto; } +.v2-step-editor-head { display: grid; gap: 5px; padding: 12px 14px; border-bottom: 1px solid var(--v2-line-soft); } +.v2-step-editor-head .v2-text-button { width: fit-content; margin-bottom: 5px; } +.v2-step-editor-head strong { font-size: 13px; } +.v2-step-editor-head span { color: var(--v2-muted); font-size: 10px; } +.v2-history-workflow { display: grid; min-height: 0; grid-template-columns: minmax(260px, .65fr) minmax(520px, 1.35fr); gap: 10px; overflow: hidden; } +.v2-history-workflow > .v2-panel { display: flex; min-height: 0; flex-direction: column; } +.v2-history-sequence-actions { display: flex; align-items: flex-start; justify-content: space-between; gap: 15px; padding: 10px 12px; border-bottom: 1px solid var(--v2-line-soft); } +.v2-history-sequence-actions > div:first-child { min-width: 0; flex: 1; } +.v2-history-sequence-actions > div:last-child { display: flex; flex: 0 0 auto; align-items: center; gap: 8px; } +.v2-history-sequence-actions p { margin: 0; color: var(--v2-muted); font-size: 10px; } +.v2-history-sequence-actions button { flex: 0 0 auto; } +.v2-history-hover-preview { display: grid; max-width: 620px; gap: 2px; margin-top: 8px; padding: 7px 9px; border-left: 2px solid var(--v2-teal); background: #101718; } +.v2-history-hover-preview strong { font-size: 9px; } +.v2-history-hover-preview span { color: var(--v2-muted); font: 8px var(--v2-mono); } +.v2-history-hover-preview p { max-height: 34px; overflow: hidden; color: #c8c1bd; font: 8px/1.45 var(--v2-mono); } +.v2-history-sequence { min-height: 0; margin: 0; padding: 8px 12px; overflow: auto; list-style: none; } +.v2-history-sequence li { display: grid; grid-template-columns: 18px 28px 45px minmax(0, 1fr); min-height: 45px; align-items: center; gap: 8px; border-bottom: 1px solid var(--v2-line-soft); } +.v2-history-sequence li:hover { background: #111517; } +.v2-history-sequence li.selected { box-shadow: inset 2px 0 var(--v2-teal); } +.v2-history-sequence > li > input { accent-color: var(--v2-teal); } +.v2-history-sequence > li > span { color: var(--v2-dim); font: 8px var(--v2-mono); } +.v2-history-sequence i { width: fit-content; padding: 2px 5px; border: 1px solid var(--v2-line); border-radius: 3px; font: 7px var(--v2-mono); font-style: normal; } +.v2-history-sequence i.recognized { border-color: #27634e; color: var(--v2-green); } +.v2-history-sequence i.context { color: var(--v2-dim); } +.v2-history-event-summary { display: grid; min-width: 0; grid-template-columns: minmax(0, 1fr) auto; gap: 3px 8px; padding: 7px 4px; border: 0; background: transparent; text-align: left; } +.v2-history-event-summary strong, .v2-history-event-summary small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.v2-history-event-summary strong, .v2-history-event-summary small { grid-column: 1; } +.v2-history-event-summary > span { grid-column: 2; grid-row: 1 / 3; align-self: center; color: var(--v2-teal); font-size: 14px; } +.v2-history-sequence strong { font-size: 10px; } +.v2-history-sequence small { color: var(--v2-muted); font: 8px var(--v2-mono); } +.v2-history-event-detail { display: grid; grid-column: 1 / -1; grid-template-columns: minmax(250px, .8fr) minmax(320px, 1.2fr); align-items: start; gap: 12px; padding: 10px 10px 12px 28px; border-top: 1px solid var(--v2-line-soft); background: #0b0e10; } +.v2-history-event-detail pre { max-height: 230px; margin: 0; overflow: auto; padding: 9px; background: #07090a; color: #c8c1bd; font: 8px/1.5 var(--v2-mono); white-space: pre-wrap; overflow-wrap: anywhere; } + +@media (max-width: 1180px) { + .v2-studio-grid { grid-template-columns: minmax(190px, .55fr) minmax(320px, 1fr) minmax(260px, .85fr); } +} + +@media (max-width: 860px) { + .v2-workflow-studio { height: auto; overflow: visible; } + .v2-runs-explorer { height: auto; grid-template-columns: 1fr; overflow: visible; } + .v2-studio-toolbar { align-items: stretch; flex-direction: column; } + .v2-studio-toolbar > div:last-child { margin-left: 0; } + .v2-studio-grid, .v2-history-workflow { display: grid; grid-template-columns: 1fr; overflow: visible; } + .v2-studio-grid > .v2-panel, .v2-history-workflow > .v2-panel { min-height: 420px; } +} +.v2-technique-picker { position: relative; display: flex; min-height: 38px; align-items: center; gap: 8px; } +.v2-technique-trigger { display: grid; min-width: 210px; min-height: 36px; grid-template-columns: auto 1fr auto; align-items: center; gap: 10px; padding: 0 10px; border: 1px solid var(--v2-line); border-radius: 5px; background: #0b0d0f; color: var(--v2-muted); text-align: left; } +.v2-technique-trigger:hover, .v2-technique-trigger.open { border-color: var(--v2-teal); background: #101716; } +.v2-technique-trigger strong { color: var(--v2-text); font: 9px var(--v2-mono); text-align: right; } +.v2-technique-trigger i { color: var(--v2-teal); font-style: normal; } +.v2-technique-chips { display: flex; min-width: 0; align-items: center; gap: 5px; overflow: hidden; } +.v2-technique-chips button, .v2-technique-chips > span { flex: 0 0 auto; max-width: 145px; padding: 4px 6px; overflow: hidden; border: 1px solid #315c57; border-radius: 3px; background: #0e1b1a; color: var(--v2-teal); font: 8px var(--v2-mono); text-overflow: ellipsis; white-space: nowrap; } +.v2-technique-chips button span { margin-left: 5px; color: var(--v2-muted); } +.v2-technique-popover { position: absolute; z-index: 40; top: calc(100% + 5px); left: 0; display: grid; width: min(720px, calc(100vw - 285px)); max-height: 420px; grid-template-rows: auto auto minmax(0, 1fr) auto; overflow: hidden; border: 1px solid #315c57; border-radius: 7px; background: #0c0f11; box-shadow: 0 20px 55px rgba(0, 0, 0, .62); } +.v2-technique-popover > header, .v2-technique-popover > footer { display: flex; min-height: 40px; align-items: center; justify-content: space-between; gap: 10px; padding: 7px 10px; border-bottom: 1px solid var(--v2-line-soft); } +.v2-technique-popover > header > div { display: grid; gap: 2px; } +.v2-technique-popover > header strong { font-size: 11px; } +.v2-technique-popover > header span, .v2-technique-popover > footer span { color: var(--v2-muted); font: 8px var(--v2-mono); } +.v2-technique-popover > header > button { border: 0; background: transparent; color: var(--v2-muted); font-size: 18px; } +.v2-technique-search { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; gap: 6px; padding: 8px 10px; border-bottom: 1px solid var(--v2-line-soft); } +.v2-technique-search input { min-height: 32px; padding: 0 9px; border: 1px solid var(--v2-line); border-radius: 4px; background: #090b0d; color: var(--v2-text); } +.v2-technique-search button { min-width: 52px; border: 1px solid var(--v2-line); border-radius: 4px; background: #151719; color: var(--v2-muted); } +.v2-technique-list { display: grid; min-height: 0; grid-template-columns: repeat(2, minmax(0, 1fr)); align-content: start; overflow: auto; padding: 5px; } +.v2-technique-list label { display: grid; min-width: 0; min-height: 42px; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 8px; padding: 5px 7px; border-radius: 4px; } +.v2-technique-list label:hover { background: #15191b; } +.v2-technique-list label > span { display: grid; min-width: 0; gap: 2px; } +.v2-technique-list strong, .v2-technique-list small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.v2-technique-list strong { color: var(--v2-text); font: 9px var(--v2-mono); } +.v2-technique-list small { color: var(--v2-muted); font-size: 8px; } +.v2-technique-popover > footer { min-height: 36px; border-top: 1px solid var(--v2-line-soft); border-bottom: 0; } + +/* Agent workspace: compact controls around the canonical attack -> target -> judge loop. */ +.v2-agent-launch { margin: 9px 12px; border: 1px solid var(--v2-line); border-radius: 7px; background: #101113; } +.v2-agent-launch > summary { display: flex; min-height: 43px; align-items: center; justify-content: space-between; gap: 16px; padding: 7px 11px; cursor: pointer; } +.v2-agent-launch > summary > span:first-child { display: grid; min-width: 0; gap: 2px; } +.v2-agent-launch > summary strong { font-size: 11px; } +.v2-agent-launch > summary small { overflow: hidden; color: var(--v2-muted); font-size: 9px; font-weight: 400; text-overflow: ellipsis; white-space: nowrap; } +.v2-agent-launch > summary > span:last-child { flex: 0 0 auto; color: var(--v2-teal); font: 8px var(--v2-mono); letter-spacing: .08em; text-transform: uppercase; } +.v2-agent-launch-body { display: grid; gap: 7px; padding: 0 10px 10px; } +.v2-agent-launch-primary { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: end; gap: 8px; } +.v2-agent-launch-primary textarea { min-height: 55px; max-height: 85px; resize: vertical; } +.v2-agent-launch-primary > .v2-button { min-width: 94px; min-height: 34px; } +.v2-agent-advanced { border-top: 1px solid var(--v2-line-soft); } +.v2-agent-advanced > summary { display: grid; min-height: 31px; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 12px; cursor: pointer; color: var(--v2-muted); font-size: 9px; } +.v2-agent-advanced > summary > span:first-child { color: var(--v2-text); font-weight: 550; } +.v2-agent-advanced > summary > span:last-child { overflow: hidden; font: 8px var(--v2-mono); text-align: right; text-overflow: ellipsis; white-space: nowrap; } +.v2-agent-advanced-body { display: grid; gap: 8px; padding: 7px 0 2px; } +.v2-agent-advanced-body .v2-form-grid { grid-template-columns: repeat(4, minmax(105px, 1fr)); gap: 8px; } +.v2-agent-advanced-body .v2-technique-picker { min-height: 34px; } +.v2-agent-advanced-body .v2-technique-trigger { min-height: 32px; } +.v2-agent-launch .v2-inline-status { color: var(--v2-green); font-size: 9px; } + +.v2-agent-loop { display: grid; min-width: 0; min-height: 0; grid-template-rows: auto auto auto minmax(0, 1fr); margin: 0 12px 9px; overflow: hidden; border: 1px solid var(--v2-line); border-radius: 7px; background: #0b0d0f; } +.v2-agent-loop-head { display: flex; min-height: 47px; align-items: center; justify-content: space-between; gap: 16px; padding: 7px 11px; border-bottom: 1px solid var(--v2-line-soft); } +.v2-agent-loop-head > div:first-child { display: grid; gap: 2px; } +.v2-agent-loop-head > div:first-child > span { color: var(--v2-red); font: 8px var(--v2-mono); letter-spacing: .11em; text-transform: uppercase; } +.v2-agent-loop-head h2 { margin: 0; font-size: 13px; } +.v2-agent-loop-head h2 i { padding: 0 5px; color: var(--v2-dim); font-style: normal; font-weight: 400; } +.v2-agent-loop-head > div:last-child { display: flex; align-items: center; gap: 9px; color: var(--v2-dim); font-size: 8px; } +.v2-loop-roles { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); border-bottom: 1px solid var(--v2-line-soft); } +.v2-loop-role { position: relative; display: grid; min-width: 0; gap: 5px; padding: 9px 11px 10px 14px; border-right: 1px solid var(--v2-line-soft); background: #0e1012; } +.v2-loop-role:last-child { border-right: 0; } +.v2-loop-role::before { position: absolute; inset: 0 auto 0 0; width: 3px; background: var(--v2-dim); content: ""; } +.v2-loop-role-attacker::before { background: var(--v2-red); } +.v2-loop-role-target::before { background: var(--v2-teal); } +.v2-loop-role-judge::before { background: var(--v2-violet); } +.v2-loop-role.active { background: #171315; box-shadow: inset 0 -2px currentColor; } +.v2-loop-role header { display: flex; align-items: center; gap: 7px; } +.v2-loop-role header span { color: var(--v2-dim); font: 8px var(--v2-mono); } +.v2-loop-role header strong { font-size: 10px; text-transform: uppercase; } +.v2-loop-role header i { margin-left: auto; color: var(--v2-muted); font: 7px var(--v2-mono); font-style: normal; text-transform: uppercase; } +.v2-loop-role.active header i { color: var(--v2-green); } +.v2-loop-role > b { overflow: hidden; color: var(--v2-text); font: 9px var(--v2-mono); font-weight: 500; text-overflow: ellipsis; white-space: nowrap; } +.v2-loop-role > p { margin: 0; overflow: hidden; color: var(--v2-muted); font-size: 8px; text-overflow: ellipsis; white-space: nowrap; } +.v2-loop-exchange-head { display: flex; min-height: 32px; align-items: center; justify-content: space-between; gap: 12px; padding: 0 11px; border-bottom: 1px solid var(--v2-line-soft); } +.v2-loop-exchange-head strong { font-size: 10px; } +.v2-loop-exchange-head span { color: var(--v2-muted); font: 8px var(--v2-mono); } +.v2-loop-feed { min-height: 0; margin: 0; overflow: auto; padding: 0; list-style: none; } +.v2-loop-empty { display: grid; min-height: 100%; } +.v2-loop-empty > .v2-empty { min-height: 100%; } +.v2-loop-event { border-bottom: 1px solid var(--v2-line-soft); } +.v2-loop-event > details > summary { display: grid; min-height: 45px; grid-template-columns: 12px 92px minmax(0, 1fr) auto; align-items: center; gap: 8px; padding: 6px 10px; cursor: pointer; } +.v2-loop-event > details > summary:hover { background: #141416; } +.v2-loop-event-marker { color: var(--v2-dim); font-size: 7px; } +.v2-loop-event-attacker .v2-loop-event-marker, .v2-loop-event-attacker .v2-loop-event-who strong { color: var(--v2-red); } +.v2-loop-event-target .v2-loop-event-marker, .v2-loop-event-target .v2-loop-event-who strong { color: var(--v2-teal); } +.v2-loop-event-judge .v2-loop-event-marker, .v2-loop-event-judge .v2-loop-event-who strong { color: var(--v2-violet); } +.v2-loop-event-tool .v2-loop-event-marker, .v2-loop-event-tool .v2-loop-event-who strong { color: var(--v2-amber); } +.v2-loop-event-who, .v2-loop-event-copy { display: grid; min-width: 0; gap: 2px; } +.v2-loop-event-who strong { overflow: hidden; font: 8px var(--v2-mono); text-overflow: ellipsis; text-transform: uppercase; white-space: nowrap; } +.v2-loop-event-who small, .v2-loop-event-time { color: var(--v2-dim); font: 7px var(--v2-mono); } +.v2-loop-event-copy > strong { color: var(--v2-muted); font-size: 8px; font-weight: 500; text-transform: uppercase; } +.v2-loop-event-copy > span { overflow: hidden; color: #d2cbc8; font: 9px var(--v2-mono); text-overflow: ellipsis; white-space: nowrap; } +.v2-loop-event-detail { display: grid; gap: 7px; padding: 9px 12px 11px 122px; border-top: 1px solid #201d1e; background: #090b0d; } +.v2-loop-event-detail > p { margin: 0; color: #d2cbc8; font: 9px/1.55 var(--v2-mono); white-space: pre-wrap; overflow-wrap: anywhere; } +.v2-loop-event-detail > span, .v2-loop-event-detail > details > summary { color: var(--v2-muted); font: 8px var(--v2-mono); } +.v2-loop-event-detail .v2-code { max-height: 220px; } + +.v2-agent > .v2-steer { min-height: 102px; padding-block: 7px 8px; } +.v2-agent > .v2-steer .v2-steer-row textarea { min-height: 42px; } + +@media (max-width: 900px) { + .v2-agent { height: auto; min-height: 100%; overflow: visible; } + .v2-agent-advanced-body .v2-form-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .v2-loop-roles { grid-template-columns: 1fr; } + .v2-loop-role { border-right: 0; border-bottom: 1px solid var(--v2-line-soft); } + .v2-agent-loop { min-height: 430px; } +} + +@media (max-width: 640px) { + .v2-live-selector { align-items: stretch; flex-direction: column; gap: 5px; } + .v2-live-selector > div { min-width: 0; } + .v2-live-selector-meta { display: none; } + .v2-agent-launch-primary { grid-template-columns: 1fr; } + .v2-agent-launch-primary > .v2-button { width: 100%; } + .v2-agent-advanced > summary { grid-template-columns: 1fr; gap: 2px; padding-block: 6px; } + .v2-agent-advanced > summary > span:last-child { text-align: left; } + .v2-agent-advanced-body .v2-form-grid { grid-template-columns: 1fr; } + .v2-loop-event > details > summary { grid-template-columns: 12px 70px minmax(0, 1fr); } + .v2-loop-event > details > summary > :last-child { display: none; } + .v2-loop-event-detail { padding-left: 20px; } +} diff --git a/wallbreaker/dashboard/web/src/v2/CommandPalette.tsx b/wallbreaker/dashboard/web/src/v2/CommandPalette.tsx new file mode 100644 index 0000000..deee8b3 --- /dev/null +++ b/wallbreaker/dashboard/web/src/v2/CommandPalette.tsx @@ -0,0 +1,95 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import type { Capability, V2Route } from "./types"; + +const ROUTES: Array<{ id: V2Route; label: string; description: string }> = [ + { id: "agent", label: "Agent", description: "Run and steer the attack-target-judge loop" }, + { id: "live", label: "Live", description: "Observe current and historical engagements" }, + { id: "compose", label: "Compose", description: "Build and inspect a payload" }, + { id: "workflows", label: "Workflows", description: "Run any registered capability" }, + { id: "arsenal", label: "Arsenal", description: "Browse presets, transforms, and tools" }, + { id: "findings", label: "Findings", description: "Investigate recorded evidence" }, + { id: "runs", label: "Runs and Logs", description: "Inspect historical event records" }, + { id: "reports", label: "Reports", description: "Summarize and export evidence" }, + { id: "models", label: "Models", description: "Inspect providers and model roles" }, + { id: "settings", label: "Settings", description: "Tune operator defaults" }, +]; + +export function CommandPalette({ + open, + capabilities, + onClose, + onNavigate, + onCapability, +}: { + open: boolean; + capabilities: Capability[]; + onClose: () => void; + onNavigate: (route: V2Route) => void; + onCapability: (capability: Capability) => void; +}) { + const [query, setQuery] = useState(""); + const inputRef = useRef(null); + + useEffect(() => { + if (open) { + setQuery(""); + window.setTimeout(() => inputRef.current?.focus(), 0); + } + }, [open]); + + useEffect(() => { + if (!open) return; + const close = (event: KeyboardEvent) => { + if (event.key === "Escape") onClose(); + }; + window.addEventListener("keydown", close); + return () => window.removeEventListener("keydown", close); + }, [open, onClose]); + + const lower = query.trim().toLowerCase(); + const routes = useMemo(() => ROUTES.filter((item) => + !lower || `${item.label} ${item.description}`.toLowerCase().includes(lower), + ), [lower]); + const matches = useMemo(() => capabilities.filter((item) => + !lower || `${item.title} ${item.description || ""} ${item.category}`.toLowerCase().includes(lower), + ).slice(0, 24), [capabilities, lower]); + + if (!open) return null; + return ( +
{ + if (event.target === event.currentTarget) onClose(); + }}> +
+
+ + setQuery(event.target.value)} + placeholder="Search views, workflows, tools, and transforms" + /> + Esc +
+
+ {routes.length > 0 &&
+

Navigate

+ {routes.map((item) => )} +
} + {matches.length > 0 &&
+

Capabilities

+ {matches.map((item) => )} +
} + {!routes.length && !matches.length &&
No matching command
} +
+
Tip: press Ctrl K from anywhere to reopen this menu.
+
+
+ ); +} + +export { ROUTES }; diff --git a/wallbreaker/dashboard/web/src/v2/LiveView.tsx b/wallbreaker/dashboard/web/src/v2/LiveView.tsx new file mode 100644 index 0000000..c00cc4f --- /dev/null +++ b/wallbreaker/dashboard/web/src/v2/LiveView.tsx @@ -0,0 +1,688 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { v2Api } from "./api"; +import { + actorLabel, + EmptyState, + ErrorBanner, + formatDuration, + formatTime, + formatTokens, + JsonBlock, + Panel, + StatusBadge, + VerdictBadge, +} from "./components"; +import type { EventEnvelope, ExecutionSummary } from "./types"; +import { correlateRawEvents, projectActivityEvents } from "./eventProjection"; + +interface TechniqueChoice { name: string; description?: string; control?: boolean } + +type InspectorTab = "overview" | "conversation" | "payload" | "evaluation" | "raw"; + +interface HistoricalRunOption { + run_name: string; + first_timestamp?: string; + last_timestamp?: string; + event_count?: number; +} + +const INSPECTOR_TABS: Array<{ id: InspectorTab; label: string }> = [ + { id: "overview", label: "Overview" }, + { id: "conversation", label: "Conversation" }, + { id: "payload", label: "Payload" }, + { id: "evaluation", label: "Evaluation" }, + { id: "raw", label: "Raw" }, +]; + +function useExecutionEvents( + execution: ExecutionSummary | null, + enabled: boolean, + onIncoming?: () => void, +) { + const [events, setEvents] = useState([]); + const [streamState, setStreamState] = useState("idle"); + const incomingRef = useRef(onIncoming); + incomingRef.current = onIncoming; + + useEffect(() => { + if (!enabled) return; + setEvents([]); + setStreamState("idle"); + if (!execution) return; + if (execution.source === "legacy" && execution.id !== "legacy-active") { + setStreamState("loading"); + v2Api.legacyEvents(execution.run_id || execution.id).then((loaded) => { + setEvents(loaded); + setStreamState("complete"); + }).catch(() => setStreamState("unavailable")); + return; + } + if (execution.source === "legacy") { setStreamState("legacy-live"); return; } + const controller = new AbortController(); + let reconnect = 0; + let timer = 0; + const connect = async () => { + setStreamState("connected"); + try { + await v2Api.streamEvents(execution.id, reconnect, (event) => { + reconnect = Math.max(reconnect, event.sequence); + setEvents((current) => current.some((item) => item.id === event.id) + ? current + : [...current, event].sort((left, right) => left.sequence - right.sequence)); + incomingRef.current?.(); + }, controller.signal); + if (!controller.signal.aborted && ["running", "pausing", "paused"].includes(execution.status)) { + timer = window.setTimeout(connect, 1400); + } + } catch { + if (!controller.signal.aborted) { + setStreamState("reconnecting"); + timer = window.setTimeout(connect, 1800); + } + } + }; + connect(); + return () => { controller.abort(); window.clearTimeout(timer); }; + }, [execution?.id, execution?.source, execution?.status, enabled]); + + return { events, streamState }; +} + +function historicalExecution(run: HistoricalRunOption): ExecutionSummary { + return { + id: `legacy:${run.run_name}`, + run_id: run.run_name, + title: run.run_name, + status: "succeeded", + source: "legacy", + created_at: run.first_timestamp, + finished_at: run.last_timestamp, + }; +} + +function LiveRunSelector({ + execution, + runs, + selectedRun, + onSelect, +}: { + execution: ExecutionSummary | null; + runs: HistoricalRunOption[]; + selectedRun: string; + onSelect: (runName: string) => void; +}) { + const currentValue = selectedRun || (execution ? "__current__" : ""); + return
+
Run to observe{selectedRun ? "Historical evidence" : execution ? "Current execution" : "Choose a retained run"}
+ + {runs.length} retained runs +
; +} + +function eventStatus(event: EventEnvelope): "pass" | "fail" | "bypass" | "inconclusive" { + const value = `${event.verdict || ""} ${event.kind}`.toLowerCase(); + if (value.includes("bypass") || value.includes("complied")) return "bypass"; + if (value.includes("partial") || value.includes("inconclusive")) return "inconclusive"; + if (value.includes("error") || value.includes("fail")) return "fail"; + return "pass"; +} + +function eventTitle(event: EventEnvelope): string { + return event.summary || event.text || event.kind.replace(/_/g, " "); +} + +function eventMeta(event: EventEnvelope): string { + const values = []; + if (event.round) values.push(`Round ${event.round}`); + if (event.input_tokens != null || event.output_tokens != null) values.push(`${formatTokens(event.input_tokens, event.output_tokens)} tokens`); + if (event.latency_ms != null) values.push(formatDuration(event.latency_ms)); + return values.join(" · "); +} + +function valueAt(event: EventEnvelope, key: string): unknown { + return event.data?.[key] ?? (event.raw && typeof event.raw === "object" ? (event.raw as Record)[key] : undefined); +} + +function hasValue(value: unknown): boolean { + if (value == null || value === "") return false; + if (Array.isArray(value)) return value.length > 0; + if (typeof value === "object") return Object.keys(value as object).length > 0; + return true; +} + +function Conversation({ value }: { value: unknown }) { + if (!Array.isArray(value) || !value.length) return ; + return
    {value.map((raw, index) => { + const item = raw && typeof raw === "object" ? raw as Record : { content: String(raw) }; + const role = String(item.role || "system"); + const type = String(item.type || "message"); + return
  1. +
    {role}{type.replace(/_/g, " ")}{item.name ? ` · ${String(item.name)}` : ""}
    + {hasValue(item.content) &&

    {String(item.content)}

    } + {hasValue(item.arguments) &&
    Arguments
    } +
  2. ; + })}
; +} + +function Inspector({ event }: { event: EventEnvelope | null }) { + const [tab, setTab] = useState("overview"); + + if (!event) return ; + const content = (() => { + if (tab === "raw") return ; + if (tab === "conversation") return ; + if (tab === "payload") { + const payload = { + request: valueAt(event, "request"), + arguments: valueAt(event, "args") || valueAt(event, "arguments"), + response: event.text, + artifacts: valueAt(event, "artifacts") || valueAt(event, "artifact"), + }; + return hasValue(value)))} empty="No request, response, or artifact payload was recorded." />; + } + if (tab === "evaluation") { + const evaluation = { + verdict: event.verdict, + evidence: valueAt(event, "evidence") || valueAt(event, "key_evidence"), + judge: valueAt(event, "judging") || valueAt(event, "judge"), + }; + return hasValue(value)))} empty="This activity has not been evaluated." />; + } + return ( +
+
+
Selected event

{eventTitle(event)}

+ +
+
+
Actor
{actorLabel(event)}
+
Round
{event.round ?? "--"}
+
Strategy
{event.strategy || "Unclassified"}
+
Time
{formatTime(event.timestamp)}
+
Latency
{formatDuration(event.latency_ms)}
+
Tokens in / out
{formatTokens(event.input_tokens, event.output_tokens)}
+
+

Content

+ {event.verdict &&

Verdict

} +
+ ); + })(); + + return ( + + ); +} + +function StrategyMatrix({ events, selected, onSelect, maxRounds }: { + events: EventEnvelope[]; + selected: EventEnvelope | null; + onSelect: (event: EventEnvelope) => void; + maxRounds?: number; +}) { + const strategies = useMemo(() => { + const rows = new Map>(); + events.forEach((event) => { + if (!event.strategy || !event.round) return; + if (!rows.has(event.strategy)) rows.set(event.strategy, new Map()); + const existing = rows.get(event.strategy)?.get(event.round); + if (!existing || event.sequence > existing.sequence) rows.get(event.strategy)?.set(event.round, event); + }); + return [...rows.entries()]; + }, [events]); + const observedMax = Math.max(0, ...events.map((event) => event.round || 0)); + const rounds = Math.max(1, Math.min(20, maxRounds || observedMax || 8)); + + if (!strategies.length) return ; + return ( +
+ + {Array.from({ length: rounds }, (_, index) => )} + {strategies.map(([strategy, row], rowIndex) => { + const bypasses = [...row.values()].filter((event) => eventStatus(event) === "bypass").length; + return + + {Array.from({ length: rounds }, (_, index) => { + const event = row.get(index + 1); + const status = event ? eventStatus(event) : "not-run"; + const label = event ? `${status}, round ${index + 1}` : `Not run, round ${index + 1}`; + return ; + })} + + ; + })} +
Strategy{index + 1}Bypass
{String(rowIndex + 1).padStart(2, "0")}{strategy} + {event ? : -} + {bypasses}
+
+ ); +} + +function RunOverview({ events, selected, onSelect, execution, streamState }: { + events: EventEnvelope[]; + selected: EventEnvelope | null; + onSelect: (event: EventEnvelope) => void; + execution: ExecutionSummary | null; + streamState: string; +}) { + const observedRound = Math.max(0, ...events.map((event) => event.round || 0)); + const messages = events.filter((event) => event.kind === "message").length; + const actions = events.filter((event) => event.kind === "tool_call").length; + const outcomes = events.filter((event) => ["tool_result", "result"].includes(event.kind)).length; + const usage = [...events].reverse().find((event) => event.input_tokens != null || event.output_tokens != null); + const hasStrategies = events.some((event) => event.strategy && event.round); + return : undefined} + > +
+
Round{observedRound || execution?.current_round || 0} / {execution?.max_rounds || "—"}
+
Messages{messages}
+
Actions{actions}
+
Results{outcomes}
+
Tokens in / out{formatTokens(usage?.input_tokens ?? execution?.input_tokens, usage?.output_tokens ?? execution?.output_tokens)}
+
+ {hasStrategies ?
+ Strategy by roundP PassF FailB BypassI Inconclusive + +
:

Strategy evidence will appear here when a run records classified techniques and rounds.

} +
; +} + +function Timeline({ events, rawEvents, selected, onSelect, liveTail, setLiveTail, unread, markRead }: { + events: EventEnvelope[]; + rawEvents: EventEnvelope[]; + selected: EventEnvelope | null; + onSelect: (event: EventEnvelope) => void; + liveTail: boolean; + setLiveTail: (value: boolean) => void; + unread: number; + markRead: () => void; +}) { + const [search, setSearch] = useState(""); + const [actor, setActor] = useState("all"); + const [kind, setKind] = useState("all"); + const [verdict, setVerdict] = useState("all"); + const [view, setView] = useState<"activity" | "raw">("activity"); + const bodyRef = useRef(null); + const source = view === "activity" ? events : rawEvents; + const actors = useMemo(() => [...new Set(source.map(actorLabel))].sort(), [source]); + const kinds = useMemo(() => [...new Set(source.map((event) => event.kind))].sort(), [source]); + const verdicts = useMemo(() => [...new Set(source.map((event) => event.verdict).filter(Boolean) as string[])].sort(), [source]); + const filtered = useMemo(() => { + const query = search.trim().toLowerCase(); + return source.filter((event) => { + if (actor !== "all" && actorLabel(event) !== actor) return false; + if (kind !== "all" && event.kind !== kind) return false; + if (verdict !== "all" && event.verdict !== verdict) return false; + return !query || `${eventTitle(event)} ${event.text || ""} ${event.strategy || ""} ${actorLabel(event)} ${event.verdict || ""}`.toLowerCase().includes(query); + }); + }, [source, search, actor, kind, verdict]); + + useEffect(() => { + if (liveTail) bodyRef.current?.scrollTo({ top: bodyRef.current.scrollHeight }); + }, [filtered.length, liveTail]); + + return ( + +
+ + +
+ + {unread > 0 && } + } + > +
+ + {actors.length > 1 && } + {kinds.length > 1 && } + {!!verdicts.length && } + {(search || actor !== "all" || kind !== "all" || verdict !== "all") && } +
+
{ + const node = event.currentTarget; + if (node.scrollHeight - node.scrollTop - node.clientHeight > 24 && liveTail) setLiveTail(false); + }}> + {!filtered.length && } + {filtered.map((event) => )} +
+
+ ); +} + +function AttackerSwitcher({ execution, onRefresh }: { execution: ExecutionSummary; onRefresh: () => void }) { + const [providers, setProviders] = useState>([]); + const [provider, setProvider] = useState(""); + const [model, setModel] = useState(""); + const [status, setStatus] = useState(""); + const [working, setWorking] = useState(false); + useEffect(() => { v2Api.providers().then((items) => setProviders(items.map((item) => ({ name: item.name })))).catch(() => setProviders([])); }, []); + const submit = async () => { + if (!provider || !model.trim()) return; + setWorking(true); setStatus(""); + try { + await v2Api.switchAttacker(execution, { provider, model: model.trim() }); + setStatus("Attacker switched; the conversation context is preserved."); + onRefresh(); + } catch (reason) { setStatus(reason instanceof Error ? reason.message : "Unable to switch attacker"); } + finally { setWorking(false); } + }; + return
Hot-switch attacker setModel(event.target.value)} placeholder="Model ID" />{status && {status}}
; +} + +function RunStrip({ execution, onRefresh }: { execution: ExecutionSummary | null; onRefresh: () => void }) { + const [working, setWorking] = useState(false); + const [error, setError] = useState(""); + const act = async (action: "pause" | "resume" | "cancel") => { + if (!execution) return; + if (action === "cancel" && !window.confirm("Hard stop this execution? In-flight work will be cancelled.")) return; + setWorking(true); + setError(""); + try { + if (action === "pause") await v2Api.pause(execution); + if (action === "resume") await v2Api.resume(execution); + if (action === "cancel") await v2Api.cancel(execution); + onRefresh(); + } catch (reason) { + setError(reason instanceof Error ? reason.message : "Control action failed"); + } finally { + setWorking(false); + } + }; + const progress = execution?.max_rounds ? Math.min(100, ((execution.current_round || 0) / execution.max_rounds) * 100) : 0; + return <> +
+
Target{execution?.target || "No active target"}
+
Attacker{execution?.attacker || "--"}
+
Judge{execution?.judge || "--"}
+
Round {execution?.current_round ?? "--"} of {execution?.max_rounds ?? "--"}
+
Elapsed{formatDuration(execution?.elapsed_ms)}
+
Tokens in / out{formatTokens(execution?.input_tokens, execution?.output_tokens)}
+
Connection{execution ? : ● Offline}
+
+ {execution?.status === "paused" ? : } + +
+
+ {error && setError("")} />} + {execution?.status === "paused" && execution.source !== "legacy" && } + ; +} + +function RunLauncher({ execution, onRefresh }: { execution: ExecutionSummary | null; onRefresh: () => void }) { + const [objective, setObjective] = useState(""); + const [maxRounds, setMaxRounds] = useState(20); + const [maxTokens, setMaxTokens] = useState(8192); + const [concurrency, setConcurrency] = useState(4); + const [requestDelay, setRequestDelay] = useState(0); + const [techniques, setTechniques] = useState([]); + const [selected, setSelected] = useState(() => { + try { return JSON.parse(localStorage.getItem("wallbreaker:v2:techniques") || "null") as string[] | null; } + catch { return null; } + }); + const [techniqueSearch, setTechniqueSearch] = useState(""); + const [techniquePickerOpen, setTechniquePickerOpen] = useState(false); + const [working, setWorking] = useState(false); + const [message, setMessage] = useState(""); + useEffect(() => { v2Api.tools().then((items) => setTechniques(items.filter((item) => !item.control).map((item) => ({ name: String(item.name || ""), description: typeof item.description === "string" ? item.description : undefined, control: Boolean(item.control) })).filter((item) => item.name))).catch(() => setTechniques([])); }, []); + useEffect(() => { localStorage.setItem("wallbreaker:v2:techniques", JSON.stringify(selected)); }, [selected]); + const visibleTechniques = useMemo(() => { + const query = techniqueSearch.trim().toLowerCase(); + return techniques.filter((item) => !query || `${item.name} ${item.description || ""}`.toLowerCase().includes(query)); + }, [techniques, techniqueSearch]); + const active = execution && ["queued", "running", "pausing", "paused"].includes(execution.status); + const start = async () => { + if (!objective.trim() || active) return; + setWorking(true); setMessage(""); + try { + await v2Api.createExecution("agent.run", { + objective: objective.trim(), max_rounds: maxRounds, max_tokens: maxTokens, + concurrency, request_delay_ms: requestDelay, + ...(selected == null ? {} : { enabled_techniques: selected }), + }, "interactive"); + setMessage("Execution queued. Live events will attach automatically."); + onRefresh(); + } catch (reason) { setMessage(reason instanceof Error ? reason.message : "Unable to start execution"); } + finally { setWorking(false); } + }; + const techniqueSummary = selected == null ? `All ${techniques.length || ""} techniques`.trim() : `${selected.length} techniques`; + return
+ {active ? "Current engagement" : "New engagement"}{active ? "Launch controls are available when this run ends" : "Set the objective, then start the agent loop"}{active ? "In progress" : "Ready"} +
+
+