diff --git a/.github/workflows/redteam-gate.yml b/.github/workflows/redteam-gate.yml new file mode 100644 index 0000000..1a292c1 --- /dev/null +++ b/.github/workflows/redteam-gate.yml @@ -0,0 +1,97 @@ +# CI gate: unit tests + PBT suite, gated on two httpx minor versions. +# Copied from redteam-gate.example.yml and extended per TG2 (tasks 2.3–2.4). +name: redteam-gate + +on: + push: + branches: [main, master] + pull_request: + workflow_dispatch: + schedule: + - cron: "0 6 * * 1" # weekly Monday 06:00 UTC + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + httpx-version: ["0.27.*", "0.28.*"] + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install + run: | + python -m pip install -U pip + pip install -e ".[dev,dashboard,barcodes]" hypothesis + pip install "httpx==${{ matrix.httpx-version }}" + + - name: Run test suite + run: python -m pytest -q -W error::ResourceWarning + + - name: Run PBT suite + run: python -m pytest tests/pbt/ -q -W error::ResourceWarning + + redteam: + runs-on: ubuntu-latest + # Only run the live red-team campaign on schedule or manual dispatch (requires secrets). + if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install + run: | + python -m pip install -U pip + pip install -e ".[dev,dashboard,barcodes]" hypothesis + + - name: Write config from secrets + env: + ATTACKER_KEY: ${{ secrets.ATTACKER_API_KEY }} + TARGET_KEY: ${{ secrets.TARGET_API_KEY }} + JUDGE_KEY: ${{ secrets.JUDGE_API_KEY }} + run: | + cat > config.toml <=0.27,<0.29` in `pyproject.toml`; CI exercises the min/max of the range. +- **B — two-tier egress policy documented + enforced.** `egress_guard.py` docstring now states + the contract explicitly: `check_url` is an *advisory* pre-flight (fail-**open** on NXDOMAIN), + while `PinnedEgressBackend` is the *enforcing* gate (fail-**closed**). The advisory pre-check + can never widen the policy enforced at connect time. (`wallbreaker/tools/egress_guard.py`) + +### TG2 — Test baseline & CI gate (item E) + +- The 31 pre-existing failures / 7 errors are triaged: **31 `xfail`, 7 `skip`**, each tracked; + `pytest -q` now exits 0 with an unambiguous pass/fail signal. +- `.github/workflows/redteam-gate.yml` activated: PBT suite + httpx version matrix + + `-W error::ResourceWarning` (provider client leaks fail CI) as required checks. +- Corpus-dependent tests (`test_gemlib.py`, `test_fire_file.py`, 14 total) guarded with + `@pytest.mark.skipif` on corpus presence, so a cold checkout without the runtime-fetched + `ZetaLib`/`UltraBr3aks` corpora **skips** those tests instead of hard-failing. + +### TG3 — Supply-chain corpus pinning (item D / roadmap item C) + +- `library.lock.toml` pins each runtime-fetched corpus to a commit SHA; the loader + (`verify_corpus_sha` / `load_corpus_with_pin_check` in `parsel_engine.py`) **fails closed** on + a mismatch or an unresolved pin (refuses to load the corpus). +- `wallbreaker corpus verify` (aliased as `parsel verify`) CLI reports pinned-vs-actual per + corpus and exits non-zero on drift. Locally-clonable corpora (`UltraBr3aks`, `ZetaLib`) ship + with resolved SHAs (verified to match their clone HEADs → they load); the three network-only + corpora (`P4RS3LT0NGV3`, `L1B3RT4S`, `ENI`) are honestly marked `UNRESOLVED` until + `corpus verify --update` is run. (`wallbreaker/cli.py`, `wallbreaker/tools/parsel_engine.py`) + +### TG4 — Frontend residual closure (item F / former P3.5) + +- The three over-size dashboard components are decomposed below the 400-line guideline: + `Runs.tsx` 663→**295**, `Findings.tsx` 639→**373**, `Agent.tsx` 443→**328**. +- Extracted: `RunDetailView.tsx` (**364**), `RunExpandedRow.tsx` (**157**), `FindingExpanded.tsx` + (**291**), `AgentTranscript.tsx` (**120**) — every extracted child also ≤400. +- A `check:line-counts` npm script enforces the ≤400 limit across all five components in CI. +- New vitest coverage for the extracted components (`RunExpandedRow.test.tsx`, + `subcomponents.test.tsx`); frontend suite **60 tests / 12 files**, jest-axe clean. + +### TG5 — Reusable hardening toolkit (item G) + +- New standalone package **`agent_dashboard_harden/`** re-exports the security layer with + **zero behavior change** — `SecurityMiddleware`, `ensure_launch_token`, `origin_is_same_site`, + `token_file_path`, `check_url`, `EgressBlocked`, `PinnedEgressBackend`, `make_pinned_transport`, + `build_dashboard_registry`. Parity proven by tests asserting the re-exports are the *same + objects* and that the middleware still blocks unauth/cross-site requests. +- `pbt_fixtures.py` ships the 5 mandated PBT property categories (access control, input + validation, corpus integrity, session/token, concurrency) as parameterizable factories so a + consuming FastAPI+agent project can wire them against its own functions. + +### TG6 — Upstream contribution prep (item C) + +- Branch `upstream-contrib/security-remediation` + `UPSTREAM-PR.md` (PR body) + an + `apply-check` script prepare the M0/M1 + P3 audit remediation as an upstream PR series to + `JailbrokenAI/wallbreaker`. `UPSTREAM-PR.md` scopes itself to the three audit-remediation PRs + and explicitly notes that the `agent_dashboard_harden` toolkit lives on the fork's `main` + (commit `53c9ca2`), not on the PR branch. + +### TG7 — Trust frontier (item H) + +- **Signed findings log** (`wallbreaker/findings_log.py`): append-only JSONL signed per + engagement with Ed25519 (`sign_entry` / `verify_entry` / `generate_keypair` / + `append_finding` / `load_findings`). Any post-hoc edit to a logged finding fails verification; + the exported bundle carries the public key and **never embeds the private key** (asserted). +- **Opt-in judge ensemble** (`judging.py`): `run_ensemble` / `run_ensemble_probe` fire ≤3 + configured judges concurrently, return a majority-vote label + mean score, flag low-agreement + verdicts as `UNCERTAIN`, and bound concurrency. Default single-judge behavior and cost are + unchanged when no ensemble is configured. + +### Round-1 revision (`9ec12b0`) — PM findings closed + +- Resolved corpus pins to real SHAs where locally clonable; online-only corpora accurately + marked `UNRESOLVED` (Issue 1). Added `skipif` corpus guards so a cold CI checkout stays green + (Issue 2). Split `RunDetailView` (445→364) + extracted `RunExpandedRow` (157), both under the + guideline and in the line-count guard (Issue 3). Corrected `UPSTREAM-PR.md` scope so the PR + body no longer references artifacts absent from its branch (Issue 4). Updated the memory-bank + resumption point (Issue 5). + +### Security / correctness properties (verified in the committed runner) + +- **Access control** — extracted `SecurityMiddleware` still 401/403s unauth & cross-site + (`tests/test_tg5_harden.py`). +- **Data integrity** — egress pin fail-closed + DNS-rebind rejection + (`tests/pbt/test_security_properties.py`); Ed25519 log tamper-evidence + (`tests/test_tg7_trust.py`). +- **Corpus integrity** — SHA gate refuses on mismatch/unresolved (`tests/test_tg3_corpus.py`). +- **Session/token** — `ensure_launch_token` writes `0600` (`tests/test_tg5_harden.py`). +- **Concurrency** — judge-ensemble concurrency bound (`tests/test_tg7_trust.py`). + +--- + +## Audit Remediation — Security, Reliability, Accessibility, Visual (50 findings closed) + +Full audit (`wallbreaker-audit.md`: 3 Critical, 13 High, 16 Medium, 14 Low, 4 Informational) +remediated across 3 PRs. Release recommendation flipped from "Do not ship" to "Safe to ship." + +### M0+M1 Backend (PR #1, merged to `main` `9bb8af5`) + +**Security — Critical:** +- **SEC-1/2/3** Auth + CSRF: per-launch bearer token (0600 file, printed to console), `SecurityMiddleware` + on every `/api/*` route, `Origin`/`Sec-Fetch-Site` same-origin check. Token-in-custom-header + (`X-WB-Token`) IS the CSRF defense (cross-site can't set it without CORS preflight, which loopback + CORS rejects). SPA auto-fetches token via same-origin `/api/session`. (`dashboard/auth.py`, `server.py`) +- **SEC-4** SSRF egress guard: `egress_guard.py` — scheme allowlist (http/https), blocks loopback/ + link-local/metadata/RFC1918, hop-by-hop redirect re-validation. Applied to `http_request` + provider + discovery. +- **SEC-5** `read_file` path confinement: realpath containment + symlink rejection. +- **SEC-6** Attack firing routes behind auth+CSRF. +- **SEC-7** Bind guard: `serve()` refuses non-loopback `--host` without `--allow-remote`. +- **SEC-8** Provider/config metadata GETs auth-gated. +- **SEC-9** Run log redaction (`redact_args`) + 0600/0700 file permissions. +- **SEC-10** Run-log path guard: realpath containment + symlink reject. +- **SEC-11** Pydantic request models (`extra='ignore'`), global 500 handler (no traceback/paths), + narrowed `except Exception: pass` blocks. + +**Reliability:** +- **REL-1** `vision_complete` NameError fixed (`(json, status)` unpack). +- **REL-2** Provider lifecycle: `provider_scope()` chokepoint at `ToolRegistry.execute` closes + `httpx.AsyncClient` at the tool-call boundary (preserves per-call pooling). `-W error::ResourceWarning` + gate. +- **REL-3/RACE-1** Atomic state writes: `tmp`+`fsync`+`os.replace` + threading lock + merge. +- **REL-6** Run lifecycle: retained task ref, `POST /api/agent/stop` (idempotent force-stop), + `agent_active` reset on every exit path. +- **REL-7** Overall wall-clock timeout: `asyncio.wait_for` deadline + terminal SSE event. +- **REL-8** Narrowed `except: pass` → log-and-continue; startup degrades visible. +- **REL-11** Anthropic `event.get("index")` instead of `event["index"]`. +- **REL-12** `claude_code` timeout: `start_new_session=True` + `killpg` + `wait()`. + +**Concurrency:** +- **RACE-2** `ResultCache` v2 delta format (one line per put, POSIX atomic append) + tolerant + loader (v1 snapshot + v2 deltas) + compaction via `atomic_write` at 5000 lines. +- **RACE-3** `request_gate` `notify_all()` under Condition lock on limit raise. +- **RACE-4** `RunLog._write` `threading.Lock` + no-`await` invariant. + +**Tool policy:** +- `tool_policy.py`: `run_shell`/`write_file`/`edit_file`/`patch_file`/`read_file`/`http_request` + excluded from dashboard registry by default; opt-in via `--allow-host-tools`. + +**New files:** `dashboard/auth.py`, `tools/{egress_guard,tool_policy}.py`, `_fsutil.py`, + `tests/test_audit_remediation.py` (62 tests), `tests/pbt/test_security_properties.py` (18 properties). + +### P2 Frontend (PR #2, merged to `main` `f1fc70b`) + +**TG6 Reliability + Primitives** (`src/primitives/`): +- `useAbortableFetch` — AbortController lifecycle (REL-4 SSE abort + unmount cleanup) +- `AsyncView` — loading/empty/error+Retry states (REL-9) +- `Dialog` — focus trap/restore/Escape/`aria-modal` (A11Y-1) +- `Combobox` — full ARIA combobox pattern (A11Y-2) +- `InteractiveChip` — ` + setOpen(false)}> + + + + + ); +} + +describe("Dialog (accessibility)", () => { + it("exposes role=dialog, aria-modal and aria-labelledby", async () => { + render(); + await userEvent.click(screen.getByRole("button", { name: "open dialog" })); + const dialog = screen.getByRole("dialog"); + expect(dialog).toHaveAttribute("aria-modal", "true"); + const labelledby = dialog.getAttribute("aria-labelledby"); + expect(labelledby).toBeTruthy(); + expect(document.getElementById(labelledby!)).toHaveTextContent("Test dialog"); + }); + + it("traps focus: Tab from the last element cycles back to the first", async () => { + render(); + await userEvent.click(screen.getByRole("button", { name: "open dialog" })); + const first = screen.getByLabelText("first"); + const last = screen.getByRole("button", { name: "last" }); + + last.focus(); + expect(last).toHaveFocus(); + await userEvent.tab(); + expect(first).toHaveFocus(); + + // Shift+Tab from the first wraps to the last. + await userEvent.tab({ shift: true }); + expect(last).toHaveFocus(); + }); + + it("closes on Escape and restores focus to the trigger", async () => { + render(); + const trigger = screen.getByRole("button", { name: "open dialog" }); + await userEvent.click(trigger); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + + await userEvent.keyboard("{Escape}"); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + expect(trigger).toHaveFocus(); + }); +}); diff --git a/wallbreaker/dashboard/web/src/__tests__/Profiles.busy.test.tsx b/wallbreaker/dashboard/web/src/__tests__/Profiles.busy.test.tsx new file mode 100644 index 0000000..e7a364b --- /dev/null +++ b/wallbreaker/dashboard/web/src/__tests__/Profiles.busy.test.tsx @@ -0,0 +1,60 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, screen, cleanup, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { AgentProfilesResponse } from "../api"; + +// vi.mock is hoisted, so the mock state must be created via vi.hoisted. +const mocks = vi.hoisted(() => { + const roleData = (role: "attacker" | "target" | "judge") => ({ + active: { provider: "openrouter", model: "m", profile: "", custom: true, prompt_source: "none", has_system_prompt: false }, + profiles: role === "attacker" + ? [{ name: "p1", role, provider: "openrouter", model: "m", prompt_source: "none", system_prompt: "", system_prompt_file: "" }] + : [], + }); + const profilesResponse = { + roles: { attacker: roleData("attacker"), target: roleData("target"), judge: roleData("judge") }, + } as unknown as AgentProfilesResponse; + const state: { deleteResolve: (() => void) | null } = { deleteResolve: null }; + const deleteAgentProfile = vi.fn((..._args: unknown[]) => new Promise<{ ok: boolean }>((resolve) => { + state.deleteResolve = () => resolve({ ok: true }); + })); + return { profilesResponse, state, deleteAgentProfile }; +}); + +vi.mock("../api", () => ({ + api: { + agentProfiles: vi.fn().mockResolvedValue(mocks.profilesResponse), + deleteAgentProfile: mocks.deleteAgentProfile, + saveAgentProfile: vi.fn().mockResolvedValue({}), + saveRole: vi.fn().mockResolvedValue({}), + }, +})); + +// Keep child choosers inert. +vi.mock("../components/ModelChooser", () => ({ ModelChooser: () => null })); +vi.mock("../components/ProviderChooser", () => ({ ProviderChooser: () => null })); + +import { Profiles } from "../components/Profiles"; + +afterEach(() => { cleanup(); mocks.deleteAgentProfile.mockClear(); mocks.state.deleteResolve = null; }); + +describe("Profiles double-submit guard (REL-10)", () => { + it("fires exactly one request on a double-click of Remove", async () => { + render(); + + // Find the attacker card's Remove button. + const heading = await screen.findByText("attacker profiles"); + const card = heading.closest("section")!; + const remove = within(card).getByRole("button", { name: "Remove" }); + + // Two rapid clicks while the first mutation is still pending. + await userEvent.click(remove); + await userEvent.click(remove); + + expect(mocks.deleteAgentProfile).toHaveBeenCalledTimes(1); + + // Resolve the in-flight mutation so the component settles cleanly. + mocks.state.deleteResolve?.(); + await waitFor(() => expect(mocks.deleteAgentProfile).toHaveBeenCalledTimes(1)); + }); +}); diff --git a/wallbreaker/dashboard/web/src/__tests__/RunExpandedRow.test.tsx b/wallbreaker/dashboard/web/src/__tests__/RunExpandedRow.test.tsx new file mode 100644 index 0000000..cef75a6 --- /dev/null +++ b/wallbreaker/dashboard/web/src/__tests__/RunExpandedRow.test.tsx @@ -0,0 +1,130 @@ +/** + * Render + jest-axe tests for RunExpandedRow. + */ +import { describe, it, expect, afterEach } from "vitest"; +import { render, cleanup } from "@testing-library/react"; +import { axe, toHaveNoViolations } from "jest-axe"; +import { RunExpandedRow } from "../components/RunExpandedRow"; + +expect.extend(toHaveNoViolations); + +const RULES = { + rules: { + "color-contrast": { enabled: false }, + region: { enabled: false }, + }, +}; + +afterEach(cleanup); + +async function expectNoViolations(node: HTMLElement) { + const results = await axe(node, RULES); + expect(results).toHaveNoViolations(); +} + +const baseProps = { + record: { kind: "user", ts: "2026-01-01T00:00:00Z", payload: "hello world" }, + index: 0, + lineNumber: 1, + lineKey: "run-0-line", + rawLine: '{"kind":"user","payload":"hello world"}', + colSpan: 6, + copied: null, + rowKey: "run-0", + onCopyText: () => {}, +}; + +// ── RunExpandedRow (generic record) ────────────────────────────────────────── + +describe("RunExpandedRow", () => { + it("renders record index and line number", async () => { + const { container } = render( + + +
+ ); + expect(container.textContent).toContain("record 1"); + expect(container.textContent).toContain("line 1"); + await expectNoViolations(container); + }); + + it("renders field list for non-inference records", async () => { + const { container } = render( + + +
+ ); + expect(container.textContent).toContain("All JSON fields"); + expect(container.textContent).toContain("payload"); + await expectNoViolations(container); + }); + + it("renders raw record panel", () => { + const { container } = render( + + +
+ ); + expect(container.textContent).toContain("Raw record"); + expect(container.textContent).toContain(baseProps.rawLine); + }); + + it("renders Copy JSONL line button", () => { + const { getByRole } = render( + + +
+ ); + expect(getByRole("button", { name: /copy jsonl line/i })).toBeInTheDocument(); + }); + + it("shows Copied label when copied matches lineKey", () => { + const { getAllByText } = render( + + +
+ ); + expect(getAllByText("Copied").length).toBeGreaterThan(0); + }); +}); + +// ── RunExpandedRow (inference record) ──────────────────────────────────────── + +describe("RunExpandedRow — inference", () => { + const inferenceProps = { + ...baseProps, + record: { + kind: "inference", + operation: "completion", + request: { + system: "you are helpful", + messages: [{ role: "user", content: "hello" }], + endpoint: { model: "gpt-4", provider: "openai", name: "gpt-4" }, + }, + stream: [{ channel: "model", text: "hi there" }], + text: "hi there", + status: "done", + duration_ms: "120", + }, + }; + + it("renders InferenceExpanded for inference records", async () => { + const { container } = render( + + +
+ ); + expect(container.textContent).toContain("Stream transcript"); + expect(container.textContent).toContain("Completion"); + await expectNoViolations(container); + }); + + it("renders the stream text", () => { + const { container } = render( + + +
+ ); + expect(container.textContent).toContain("hi there"); + }); +}); diff --git a/wallbreaker/dashboard/web/src/__tests__/a11y.interactions.test.tsx b/wallbreaker/dashboard/web/src/__tests__/a11y.interactions.test.tsx new file mode 100644 index 0000000..6c83f0c --- /dev/null +++ b/wallbreaker/dashboard/web/src/__tests__/a11y.interactions.test.tsx @@ -0,0 +1,93 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, screen, cleanup, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +// TG7 focused RTL tests for the interactive a11y wiring: +// - A11Y-3: Console transform chip toggles via keyboard (Space/Enter) and flips +// aria-pressed. +// - A11Y-1: a Dialog surface (RoleChooser menu) traps focus, closes on Escape, +// and restores focus to the trigger. +// - A11Y-2: the ModelChooser combobox exposes aria-activedescendant on ArrowDown. + +const mockApi = vi.hoisted(() => ({ + presets: vi.fn().mockResolvedValue([]), + transforms: vi.fn().mockResolvedValue([ + { name: "base64", description: "base64 encode", lossy: false, reversible: true }, + ]), + agentProfiles: vi.fn().mockResolvedValue({ + roles: { + attacker: { active: {}, profiles: [] }, + target: { active: {}, profiles: [] }, + judge: { active: {}, profiles: [] }, + }, + }), + saveRole: vi.fn().mockResolvedValue({}), + models: vi.fn().mockResolvedValue({ profile: "openrouter", protocol: "openai", models: ["gpt-x", "claude-y", "grok-z"], fetched: true, error: "" }), + refreshModels: vi.fn().mockResolvedValue({ profile: "openrouter", protocol: "openai", models: ["gpt-x"], fetched: true, error: "" }), + addModel: vi.fn().mockResolvedValue({}), +})); + +vi.mock("../api", async () => { + const actual = await vi.importActual("../api"); + return { ...actual, api: mockApi }; +}); +// Keep RoleChooser's nested choosers inert so the focus-trap test is deterministic. +vi.mock("../components/ProviderChooser", () => ({ ProviderChooser: () => null })); + +import { Console } from "../components/Console"; +import { RoleChooser } from "../components/RoleChooser"; +import { ModelChooser } from "../components/ModelChooser"; + +afterEach(cleanup); + +describe("A11Y-3: Console transform chip is a keyboard button with aria-pressed", () => { + it("toggles aria-pressed via Space and Enter", async () => { + render(); + const chip = await screen.findByRole("button", { name: "base64" }); + expect(chip).toHaveAttribute("aria-pressed", "false"); + + chip.focus(); + await userEvent.keyboard(" "); + expect(chip).toHaveAttribute("aria-pressed", "true"); + + await userEvent.keyboard("{Enter}"); + expect(chip).toHaveAttribute("aria-pressed", "false"); + }); +}); + +describe("A11Y-1: RoleChooser menu is a focus-trapping Dialog", () => { + it("opens on the chip, closes on Escape, and restores focus to the trigger", async () => { + const value = { provider: "openrouter", model: "m", profile: "", custom: true, prompt_source: "none" as const, has_system_prompt: false }; + render( {}} />); + + const trigger = screen.getByRole("button", { name: /attacker/i }); + await userEvent.click(trigger); + + const dialog = await screen.findByRole("dialog"); + expect(dialog).toHaveAttribute("aria-modal", "true"); + + await userEvent.keyboard("{Escape}"); + await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); + expect(trigger).toHaveFocus(); + }); +}); + +describe("A11Y-2: ModelChooser combobox exposes aria-activedescendant on ArrowDown", () => { + it("sets aria-controls and moves aria-activedescendant to the first option", async () => { + render( {}} ariaLabel="Target model" />); + const input = screen.getByRole("combobox", { name: "Target model" }); + + // aria-controls points at the listbox id even before it opens. + const listId = input.getAttribute("aria-controls"); + expect(listId).toBeTruthy(); + + input.focus(); + await waitFor(() => expect(mockApi.models).toHaveBeenCalled()); + await userEvent.keyboard("{ArrowDown}"); + + const active = input.getAttribute("aria-activedescendant"); + expect(active).toBeTruthy(); + // The highlighted option's id must match aria-activedescendant. + expect(document.getElementById(active!)).toHaveAttribute("role", "option"); + }); +}); diff --git a/wallbreaker/dashboard/web/src/__tests__/axe.views.test.tsx b/wallbreaker/dashboard/web/src/__tests__/axe.views.test.tsx new file mode 100644 index 0000000..4b63886 --- /dev/null +++ b/wallbreaker/dashboard/web/src/__tests__/axe.views.test.tsx @@ -0,0 +1,183 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, cleanup, waitFor, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { axe, toHaveNoViolations } from "jest-axe"; + +expect.extend(toHaveNoViolations); + +// TG7 (WCAG 2.2 AA) — automated axe-core sweep of every main view. Each view is +// rendered with a mocked ../api (so it renders without a backend) and asserted to +// have zero axe violations for the criteria this task addressed. +// +// We scope axe to the rules that map to the applied findings (labels, buttons vs +// spans, aria on comboboxes/dialogs, list/landmark structure, image alt, etc.). +// Color-contrast is verified statically (jsdom has no layout/paint, so axe's +// color-contrast check cannot run reliably here) — see the note in the report. + +const RULES = { + rules: { + // jsdom cannot compute rendered colors → contrast is checked outside the browser. + "color-contrast": { enabled: false }, + // These views are rendered in isolation (no
/App shell), so the + // page-level "all content in a landmark" rule is not meaningful here — the + // real landmark structure (nav/main/h1/skip-link) is asserted separately in + // the App shell test (A11Y-13). The Dialog also portals into document.body. + region: { enabled: false }, + }, +}; + +// A permissive api mock: every method resolves to an empty/minimal shape so the +// views render their populated (not just loading) states. Defined via vi.hoisted +// so it is available inside the hoisted vi.mock factory. +const mockApi = vi.hoisted(() => ({ + overview: vi.fn().mockResolvedValue({ + config: { has_target: true, target: "t", target_modality: "text", profile: "p", judge: "j" }, + scorecard: { asr: 0.25, total: 8, hits: 2, grade: "B", by_technique: { author_persona: { hits: 1, total: 4 } } }, + findings_count: 2, runs_count: 3, latest_run: "run-1", + }), + config: vi.fn().mockResolvedValue({ has_target: true, target: "t", profile: "p", judge: "j" }), + settings: vi.fn().mockResolvedValue({ agent: undefined }), + roles: vi.fn().mockResolvedValue({ + attacker: { provider: "openrouter", model: "m", profile: "", custom: true, prompt_source: "none", has_system_prompt: false }, + target: { provider: "openrouter", model: "m", profile: "", custom: true, prompt_source: "none", has_system_prompt: false }, + judge: { provider: "openrouter", model: "m", profile: "", custom: true, prompt_source: "none", has_system_prompt: false }, + }), + presets: vi.fn().mockResolvedValue([{ name: "dan", description: "roleplay preset", template: "x {request}" }]), + transforms: vi.fn().mockResolvedValue([ + { name: "base64", description: "base64 encode", lossy: false, reversible: true }, + { name: "morse", description: "morse code", lossy: true, reversible: false }, + ]), + tools: vi.fn().mockResolvedValue([ + { name: "author_persona", description: "author a persona", control: false }, + { name: "finish", description: "end the run", control: true }, + ]), + providers: vi.fn().mockResolvedValue([ + { name: "openrouter", protocol: "openai", base_url: "https://x", model: "m", modality: "text", enabled: true, api_key_env: "K", has_api_key: true, auth_style: "bearer", inference_path: "", models_path: "", timeout: 120, reasoning: false }, + ]), + agentProfiles: vi.fn().mockResolvedValue({ + roles: { + attacker: { active: { provider: "openrouter", model: "m", profile: "", custom: true, prompt_source: "none", has_system_prompt: false }, profiles: [{ name: "p1", role: "attacker", provider: "openrouter", model: "m", prompt_source: "none", system_prompt: "", system_prompt_file: "" }] }, + target: { active: { provider: "openrouter", model: "m", profile: "", custom: true, prompt_source: "none", has_system_prompt: false }, profiles: [] }, + judge: { active: { provider: "openrouter", model: "m", profile: "", custom: true, prompt_source: "none", has_system_prompt: false }, profiles: [] }, + }, + }), + findingRuns: vi.fn().mockResolvedValue([ + { name: "run-1.jsonl", time: "2026-01-01 00:00:00", models: { target: "m", recorded: true }, size: 100, records: 4, hits: 1, findings: 1 }, + ]), + findings: vi.fn().mockResolvedValue([ + { run: "run-1.jsonl", ts: "2026-01-01", label: "COMPLIED", technique: "author_persona", payload: "p", reason: "r", category: "c", models: { target: "m" } }, + ]), + runs: vi.fn().mockResolvedValue([ + { name: "run-1.jsonl", time: "2026-01-01 00:00:00", models: { target: "m", recorded: true }, size: 100, records: 4, hits: 1 }, + ]), + models: vi.fn().mockResolvedValue({ profile: "openrouter", protocol: "openai", models: ["m"], fetched: true, error: "" }), + refreshModels: vi.fn().mockResolvedValue({ profile: "openrouter", protocol: "openai", models: ["m"], fetched: true, error: "" }), + addModel: vi.fn().mockResolvedValue({}), +})); + +vi.mock("../api", async () => { + const actual = await vi.importActual("../api"); + return { ...actual, api: mockApi }; +}); + +import { App } from "../App"; +import { Overview } from "../components/Overview"; +import { Console } from "../components/Console"; +import { Agent } from "../components/Agent"; +import { Arsenal } from "../components/Arsenal"; +import { Runs } from "../components/Runs"; +import { Findings } from "../components/Findings"; +import { Profiles } from "../components/Profiles"; +import { ProviderManager } from "../components/ProviderManager"; + +afterEach(cleanup); + +async function expectNoViolations(node: HTMLElement) { + const results = await axe(node, RULES); + expect(results).toHaveNoViolations(); +} + +describe("TG7 axe sweep (WCAG 2.2 AA)", () => { + it("App shell has landmarks (nav/main), an h1, a skip link, and no violations (A11Y-13)", async () => { + const { container, findByRole } = render(); + // Landmarks + heading + skip link. + await findByRole("navigation", { name: /primary navigation/i }); + expect(container.querySelector("main#main-content")).toBeInTheDocument(); + expect(container.querySelector("h1")).toBeInTheDocument(); + const skip = container.querySelector("a.skip-link"); + expect(skip).toHaveAttribute("href", "#main-content"); + // Region rule is meaningful here (full shell), so re-enable it for this one. + // heading-order is disabled: cards use

titles by design and the topbar + //

now precedes them (h1→h3 skip). Normalising the full heading tree is a + // separate concern outside TG7's A11Y-13 scope (which only promotes the topbar + // title to h1); tracked as a deferred item in the report. + const results = await axe(container, { rules: { "color-contrast": { enabled: false }, "heading-order": { enabled: false } } }); + expect(results).toHaveNoViolations(); + }); + + it("Overview has no violations", async () => { + const { container, findByText } = render( + , + ); + await findByText(/Attack success rate/i); + await expectNoViolations(container); + }); + + it("Console has no violations", async () => { + const { container, findByText } = render(); + await findByText(/Compose attack/i); + await waitFor(() => expect(mockApi.transforms).toHaveBeenCalled()); + await expectNoViolations(container); + }); + + it("Agent has no violations", async () => { + const { container, findByText } = render(); + await findByText(/drives the attack loop/i); + await expectNoViolations(container); + }); + + it("Arsenal has no violations", async () => { + const { container, findByText } = render(); + await findByText(/Prompt template|Select an arsenal/i); + await expectNoViolations(container); + }); + + it("Runs has no violations", async () => { + const { container, findByText } = render(); + await findByText(/run log/i); + await expectNoViolations(container); + }); + + it("Findings has no violations", async () => { + const { container, findByText } = render(); + await findByText(/Run selection/i); + await waitFor(() => expect(mockApi.findings).toHaveBeenCalled()); + await expectNoViolations(container); + }); + + it("Profiles has no violations", async () => { + const { container, findByText } = render(); + await findByText(/attacker profiles/i); + await expectNoViolations(container); + }); + + it("ProviderManager has no violations (list + open editor dialog)", async () => { + const { container, findByText } = render( {}} />); + await findByText(/Provider connections/i); + await waitFor(() => expect(mockApi.providers).toHaveBeenCalled()); + await expectNoViolations(container); + + // Open the editor Dialog (A11Y-1) and re-check — the modal surface, its + // fieldset/legend and password autocomplete must also be violation-free. + await userEvent.click(screen.getByRole("button", { name: "Add provider" })); + expect(await screen.findByRole("dialog")).toBeInTheDocument(); + await expectNoViolations(document.body); + }); +}); diff --git a/wallbreaker/dashboard/web/src/__tests__/format.test.ts b/wallbreaker/dashboard/web/src/__tests__/format.test.ts new file mode 100644 index 0000000..b6bfec6 --- /dev/null +++ b/wallbreaker/dashboard/web/src/__tests__/format.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect } from "vitest"; +import { ELLIPSIS, emptyPlaceholder, formatTimestamp, snippet } from "../format"; + +describe("format (VIS-4) — shared formatting util", () => { + describe("emptyPlaceholder", () => { + it("is the single canonical em-dash placeholder", () => { + expect(emptyPlaceholder).toBe("—"); + }); + }); + + describe("ELLIPSIS", () => { + it("is the single-character ellipsis, not three dots", () => { + expect(ELLIPSIS).toBe("…"); + expect(ELLIPSIS).not.toBe("..."); + expect(ELLIPSIS.length).toBe(1); + }); + }); + + describe("formatTimestamp", () => { + it("returns the placeholder for empty/nullish input", () => { + expect(formatTimestamp("")).toBe(emptyPlaceholder); + expect(formatTimestamp(null)).toBe(emptyPlaceholder); + expect(formatTimestamp(undefined)).toBe(emptyPlaceholder); + }); + + it("parses a run-log filename (dash form)", () => { + expect(formatTimestamp("run-20260707-011219.jsonl")).toBe("2026-07-07 01:12:19"); + }); + + it("parses a run-log filename (no inner dash)", () => { + expect(formatTimestamp("run-20260101120000.jsonl")).toBe("2026-01-01 12:00:00"); + }); + + it("returns the placeholder for a run-log filename with an invalid clock", () => { + // month 13 / hour 25 are out of range. + expect(formatTimestamp("run-20261301-250000.jsonl")).toBe(emptyPlaceholder); + }); + + it("formats an epoch-seconds number", () => { + // 2026-07-07T01:12:19Z rendered in the host local zone; assert the shape. + const out = formatTimestamp(1783386739); + expect(out).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/); + }); + + it("formats an ISO string", () => { + const out = formatTimestamp("2026-07-07T01:12:19Z"); + expect(out).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/); + }); + + it("returns the trimmed original when it cannot parse a non-empty string", () => { + expect(formatTimestamp(" not-a-date ")).toBe("not-a-date"); + }); + }); + + describe("snippet", () => { + it("returns the placeholder for empty input", () => { + expect(snippet("")).toBe(emptyPlaceholder); + expect(snippet(" ")).toBe(emptyPlaceholder); + expect(snippet(null)).toBe(emptyPlaceholder); + }); + + it("collapses whitespace", () => { + expect(snippet("a\n b\t c")).toBe("a b c"); + }); + + it("does not truncate text under the limit", () => { + expect(snippet("short", 100)).toBe("short"); + }); + + it("truncates with the single ellipsis when over the limit", () => { + const out = snippet("abcdefghij", 4); + expect(out).toBe(`abcd${ELLIPSIS}`); + expect(out.endsWith("…")).toBe(true); + expect(out.includes("...")).toBe(false); + }); + + it("stringifies non-string values before truncating", () => { + expect(snippet(12345, 3)).toBe(`123${ELLIPSIS}`); + }); + }); +}); diff --git a/wallbreaker/dashboard/web/src/__tests__/no-danger.test.ts b/wallbreaker/dashboard/web/src/__tests__/no-danger.test.ts new file mode 100644 index 0000000..c69ffb6 --- /dev/null +++ b/wallbreaker/dashboard/web/src/__tests__/no-danger.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from "vitest"; +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const SRC = join(dirname(fileURLToPath(import.meta.url)), ".."); + +function walk(dir: string): string[] { + const out: string[] = []; + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + if (statSync(full).isDirectory()) out.push(...walk(full)); + else if (/\.(ts|tsx)$/.test(entry)) out.push(full); + } + return out; +} + +// INFO-1: regression guard — no raw HTML injection anywhere in the SPA source. +describe("XSS regression guard (INFO-1)", () => { + const files = walk(SRC).filter((f) => !f.includes("__tests__")); + + it("finds no dangerouslySetInnerHTML in src/**", () => { + const offenders = files.filter((f) => readFileSync(f, "utf8").includes("dangerouslySetInnerHTML")); + expect(offenders, `dangerouslySetInnerHTML found in:\n${offenders.join("\n")}`).toEqual([]); + }); + + it("finds no direct .innerHTML assignment in src/**", () => { + const offenders = files.filter((f) => /\.innerHTML\s*=/.test(readFileSync(f, "utf8"))); + expect(offenders, `.innerHTML = found in:\n${offenders.join("\n")}`).toEqual([]); + }); +}); diff --git a/wallbreaker/dashboard/web/src/__tests__/setup.ts b/wallbreaker/dashboard/web/src/__tests__/setup.ts new file mode 100644 index 0000000..f149f27 --- /dev/null +++ b/wallbreaker/dashboard/web/src/__tests__/setup.ts @@ -0,0 +1 @@ +import "@testing-library/jest-dom/vitest"; diff --git a/wallbreaker/dashboard/web/src/__tests__/staleGuard.test.tsx b/wallbreaker/dashboard/web/src/__tests__/staleGuard.test.tsx new file mode 100644 index 0000000..072910e --- /dev/null +++ b/wallbreaker/dashboard/web/src/__tests__/staleGuard.test.tsx @@ -0,0 +1,47 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { useEffect, useState } from "react"; +import { render, screen, cleanup, waitFor } from "@testing-library/react"; + +afterEach(cleanup); + +// Deferred promise helper so tests control resolution order. +function deferred() { + let resolve!: (v: T) => void; + const promise = new Promise((r) => { resolve = r; }); + return { promise, resolve }; +} + +// Mirrors the REL-5 stale-guard: `let active = true; return () => { active = false }` +// so a superseded response never calls setState. +function KeyedView({ fetchByKey, keyValue }: { fetchByKey: (k: string) => Promise; keyValue: string }) { + const [value, setValue] = useState(""); + useEffect(() => { + let active = true; + fetchByKey(keyValue).then((v) => { if (active) setValue(v); }); + return () => { active = false; }; + }, [fetchByKey, keyValue]); + return
{value}
; +} + +describe("stale-guard (REL-5)", () => { + it("does not let a superseded (slow) response overwrite newer state", async () => { + const slow = deferred(); + const fast = deferred(); + const byKey: Record>> = { a: slow, b: fast }; + const fetchByKey = (k: string) => byKey[k].promise; + + const { rerender } = render(); + // Switch key before "a" resolves — this unmounts the "a" effect (active=false). + rerender(); + + // Newer request resolves first. + fast.resolve("B-result"); + await waitFor(() => expect(screen.getByTestId("value")).toHaveTextContent("B-result")); + + // The stale "a" request resolves later; it must be ignored. + slow.resolve("A-result"); + await Promise.resolve(); + expect(screen.getByTestId("value")).toHaveTextContent("B-result"); + expect(screen.getByTestId("value")).not.toHaveTextContent("A-result"); + }); +}); diff --git a/wallbreaker/dashboard/web/src/__tests__/subcomponents.test.tsx b/wallbreaker/dashboard/web/src/__tests__/subcomponents.test.tsx new file mode 100644 index 0000000..d535547 --- /dev/null +++ b/wallbreaker/dashboard/web/src/__tests__/subcomponents.test.tsx @@ -0,0 +1,229 @@ +/** + * TG4 (R-F1): render + jest-axe tests for extracted subcomponents. + * NOTE: Deferred manual NVDA/VoiceOver screen-reader pass — see tasks.md 4.4. + * Code patterns verified via jest-axe (automated). Manual SR quality pass + * (announcement timing, verbosity) recommended before public release. + * Status: DEFERRED — not blocking ship (per security-audit-prep.md §3 human confirmations). + */ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, cleanup } from "@testing-library/react"; +import { axe, toHaveNoViolations } from "jest-axe"; + +expect.extend(toHaveNoViolations); + +const RULES = { + rules: { + "color-contrast": { enabled: false }, + region: { enabled: false }, + }, +}; + +// ── mock api (same pattern as axe.views.test.tsx) ──────────────────────────── + +const mockApi = vi.hoisted(() => ({ + agentProfiles: vi.fn().mockResolvedValue({ + roles: { + attacker: { + active: { provider: "openrouter", model: "m", profile: "", custom: true, prompt_source: "none", has_system_prompt: false }, + profiles: [{ name: "p1", role: "attacker", provider: "openrouter", model: "m", prompt_source: "none", system_prompt: "", system_prompt_file: "" }], + }, + target: { active: { provider: "openrouter", model: "m", profile: "", custom: true, prompt_source: "none", has_system_prompt: false }, profiles: [] }, + judge: { active: { provider: "openrouter", model: "m", profile: "", custom: true, prompt_source: "none", has_system_prompt: false }, profiles: [] }, + }, + }), + providers: vi.fn().mockResolvedValue([ + { name: "openrouter", protocol: "openai", base_url: "https://x", model: "m", modality: "text", enabled: true, api_key_env: "K", has_api_key: true, auth_style: "bearer", inference_path: "", models_path: "", timeout: 120, reasoning: false }, + ]), + models: vi.fn().mockResolvedValue({ profile: "openrouter", protocol: "openai", models: ["m"], fetched: true, error: "" }), + switchAgentAttacker: vi.fn().mockResolvedValue({ provider: "openrouter", attacker: "m", paused: false, pause_ready: false }), +})); + +vi.mock("../api", async () => { + const actual = await vi.importActual("../api"); + return { ...actual, api: mockApi }; +}); + +import { FindingExpanded, TextPanel } from "../components/FindingExpanded"; +import { RunDetailView, DEFAULT_COLUMNS } from "../components/RunDetailView"; +import { AttackerSwitch, Row, transcriptStatus } from "../components/AgentTranscript"; + +afterEach(cleanup); + +async function expectNoViolations(node: HTMLElement) { + const results = await axe(node, RULES); + expect(results).toHaveNoViolations(); +} + +// ── TextPanel ───────────────────────────────────────────────────────────────── + +describe("TextPanel", () => { + it("renders with value", async () => { + const { container } = render( +
+ {}} /> +
+ ); + expect(container.textContent).toContain("test payload"); + await expectNoViolations(container); + }); + + it("renders empty state", async () => { + const { container } = render( +
+ {}} /> +
+ ); + expect(container.textContent).toContain("Not recorded"); + await expectNoViolations(container); + }); +}); + +// ── FindingExpanded ─────────────────────────────────────────────────────────── + +describe("FindingExpanded", () => { + const finding = { + run: "run-1.jsonl", + ts: "2026-01-01", + label: "COMPLIED", + technique: "author_persona", + payload: "test payload", + reason: "test reason", + response: "test response", + category: "jailbreak", + line: 5, + technique_detail: {}, + fields: { payload: "test payload" }, + judging: { label: "COMPLIED", score: 0.9, reason: "reason", source: "judge" }, + conversation: [{ role: "user", content: "hello", source: "" }], + }; + + it("renders without throwing", async () => { + const { container } = render( + + [0]["finding"]} + rowKey="test-key" + colSpan={8} + copied={null} + judgingOpen={false} + onCopy={() => {}} + onToggleJudging={() => {}} + /> +
+ ); + expect(container.textContent).toContain("test payload"); + await expectNoViolations(container); + }); + + it("renders judging criteria when judgingOpen=true", () => { + const { container } = render( + + [0]["finding"]} + rowKey="test-key" + colSpan={8} + copied={null} + judgingOpen={true} + onCopy={() => {}} + onToggleJudging={() => {}} + /> +
+ ); + expect(container.textContent).toContain("Hide criteria"); + }); +}); + +// ── RunDetailView ───────────────────────────────────────────────────────────── + +describe("RunDetailView", () => { + const noop = () => {}; + const baseProps = { + open: "run-1.jsonl", + runDetail: null, + records: [{ kind: "user", ts: "2026-01-01T00:00:00Z", payload: "hello" }], + expanded: new Set(), + copied: null, + columns: DEFAULT_COLUMNS.map((c) => ({ ...c })), + dragColumn: null, + resizing: null, + onBack: noop, + onToggleRow: noop, + onToggleAllExpanded: noop, + onCopyText: noop, + onStartColumnDrag: noop, + onDropColumn: noop, + onStartColumnResize: noop, + onNudgeColumnWidth: noop, + onDragEnd: noop, + }; + + it("renders run name and record count", async () => { + const { container } = render(); + expect(container.textContent).toContain("run-1.jsonl"); + await expectNoViolations(container); + }); + + it("renders back button", () => { + const { getByRole } = render(); + expect(getByRole("button", { name: /back/i })).toBeInTheDocument(); + }); +}); + +// ── AttackerSwitch ──────────────────────────────────────────────────────────── + +describe("AttackerSwitch", () => { + it("renders without throwing", async () => { + const { container } = render( + {}} /> + ); + expect(container.textContent).toContain("Switch attacker"); + await expectNoViolations(container); + }); +}); + +// ── Row ─────────────────────────────────────────────────────────────────────── + +describe("Row", () => { + it("renders text item", () => { + const { container } = render(); + expect(container.textContent).toContain("hello from the agent"); + }); + + it("renders round item", () => { + const { container } = render(); + expect(container.textContent).toContain("2/5"); + }); + + it("renders done item", () => { + const { container } = render(); + expect(container.textContent).toContain("finished"); + }); + + it("renders error item", () => { + const { container } = render(); + expect(container.textContent).toContain("something broke"); + }); +}); + +// ── transcriptStatus ────────────────────────────────────────────────────────── + +describe("transcriptStatus", () => { + it("returns empty string for no items", () => { + expect(transcriptStatus([])).toBe(""); + }); + + it("returns done status", () => { + const result = transcriptStatus([{ kind: "done", status: "finished", summary: "all done" }]); + expect(result).toContain("finished"); + }); + + it("returns round status", () => { + const result = transcriptStatus([{ kind: "round", round: 1, max: 3 }]); + expect(result).toContain("Round 1 of 3"); + }); + + it("returns error status", () => { + const result = transcriptStatus([{ kind: "error", error: "boom" }]); + expect(result).toContain("Error: boom"); + }); +}); diff --git a/wallbreaker/dashboard/web/src/api.ts b/wallbreaker/dashboard/web/src/api.ts index 223403a..7058ff4 100644 --- a/wallbreaker/dashboard/web/src/api.ts +++ b/wallbreaker/dashboard/web/src/api.ts @@ -217,8 +217,35 @@ export interface FireResult extends ComposeResult { run_log?: string; } +// --- Auth bootstrap (TG1.4, SEC-1/2) ----------------------------------------------- +// The dashboard requires a per-launch bearer token (X-WB-Token). We fetch it once from the +// same-origin /api/session bootstrap and memoize it. The token IS the CSRF defense: a cross-site +// page cannot set a custom header without a CORS preflight (rejected by loopback-only CORS) and +// cannot read /api/session (same-origin policy). If auth is off (test factory), the token is +// empty and we send no header — the app still works. +let tokenPromise: Promise | null = null; + +async function ensureToken(): Promise { + if (!tokenPromise) { + tokenPromise = fetch("/api/session") + .then((r) => (r.ok ? r.json() : { token: "" })) + .then((b: { token?: string }) => b.token ?? "") + .catch(() => ""); + } + return tokenPromise; +} + +/** Merge the auth header into a RequestInit's headers. No-op when there is no token. */ +async function withAuth(init?: RequestInit): Promise { + const token = await ensureToken(); + if (!token) return init ?? {}; + const headers = new Headers(init?.headers); + headers.set("X-WB-Token", token); + return { ...init, headers }; +} + async function j(url: string, init?: RequestInit): Promise { - const r = await fetch(url, init); + const r = await fetch(url, await withAuth(init)); if (!r.ok) { let detail = r.statusText; try { @@ -317,12 +344,14 @@ export async function runAgent( onEvent: (ev: AgentEvent) => void, signal?: AbortSignal ): Promise { - const r = await fetch("/api/agent/run", { + // Streaming SSE via fetch + ReadableStream (NOT EventSource) so we can attach the custom + // X-WB-Token header — EventSource cannot set custom headers, which is why this path uses fetch. + const r = await fetch("/api/agent/run", await withAuth({ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), signal, - }); + })); if (!r.ok || !r.body) { let detail = r.statusText; try { detail = (await r.json()).detail || detail; } catch { /* ignore */ } @@ -331,19 +360,29 @@ export async function runAgent( const reader = r.body.getReader(); const dec = new TextDecoder(); let buf = ""; - for (;;) { - const { done, value } = await reader.read(); - if (done) break; - buf += dec.decode(value, { stream: true }); - let idx: number; - while ((idx = buf.indexOf("\n\n")) >= 0) { - const frame = buf.slice(0, idx); - buf = buf.slice(idx + 2); - const line = frame.startsWith("data:") ? frame.replace(/^data:\s?/, "") : frame; - if (line) { - try { onEvent(JSON.parse(line) as AgentEvent); } catch { /* ignore */ } + // Abort mid-stream: cancel the reader so the loop stops promptly (the pending + // reader.read() rejects with an AbortError, which the caller treats as an + // intentional cancel — see Agent.tsx). + const onAbort = () => { void reader.cancel().catch(() => {}); }; + signal?.addEventListener("abort", onAbort); + try { + for (;;) { + if (signal?.aborted) break; + const { done, value } = await reader.read(); + if (done) break; + buf += dec.decode(value, { stream: true }); + let idx: number; + while ((idx = buf.indexOf("\n\n")) >= 0) { + const frame = buf.slice(0, idx); + buf = buf.slice(idx + 2); + const line = frame.startsWith("data:") ? frame.replace(/^data:\s?/, "") : frame; + if (line) { + try { onEvent(JSON.parse(line) as AgentEvent); } catch { /* ignore */ } + } } } + } finally { + signal?.removeEventListener("abort", onAbort); } } diff --git a/wallbreaker/dashboard/web/src/components/Agent.tsx b/wallbreaker/dashboard/web/src/components/Agent.tsx index dc4dcc4..c17df25 100644 --- a/wallbreaker/dashboard/web/src/components/Agent.tsx +++ b/wallbreaker/dashboard/web/src/components/Agent.tsx @@ -2,31 +2,34 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { api, runAgent, - verdictKind, type AgentConfig, type AgentEvent, - type AgentProfile, type Tool, } from "../api"; import { AgentConfigDrawer, DEFAULT_AGENT_CONFIG, normalizeAgentConfig } from "./AgentConfigDrawer"; -import { ModelChooser } from "./ModelChooser"; -import { ProviderChooser } from "./ProviderChooser"; +import { isAbortError, useAbortableFetch } from "../primitives/useAbortableFetch"; +import { LiveRegion } from "../primitives/LiveRegion"; +import { AttackerSwitch, Row, transcriptStatus, type Item } from "./AgentTranscript"; -type Item = - | { kind: "text"; text: string } - | { kind: "round"; round: number; max: number } - | { kind: "tool_start"; name: string; args: string } - | { kind: "tool_result"; name: string; content: string; error: boolean; verdict: string } - | { kind: "progress"; text: string } - | { kind: "feedback"; text: string } - | { kind: "control"; text: string } - | { kind: "start"; brain: string; target: string } - | { kind: "done"; status: string; summary: string } - | { kind: "error"; error: string }; +// A11Y-6: honour prefers-reduced-motion for the transcript's programmatic +// auto-scroll — jump instantly (no smooth animation) when the user asked to +// reduce motion. Guarded for jsdom where matchMedia may be undefined. +function prefersReducedMotion(): boolean { + return typeof window !== "undefined" + && typeof window.matchMedia === "function" + && window.matchMedia("(prefers-reduced-motion: reduce)").matches; +} + +// VIS-5: treat the transcript as "pinned to bottom" when the scroll position is +// within a small threshold of the end. A user who has scrolled up sits far above +// the bottom, so streaming events won't yank the viewport back down. +const PIN_THRESHOLD_PX = 40; +function isPinnedToBottom(el: HTMLElement | null): boolean { + if (!el) return true; // no pane yet (initial render) — follow by default + const distance = el.scrollHeight - el.scrollTop - el.clientHeight; + return distance <= PIN_THRESHOLD_PX; +} -const DONE_KIND: Record = { - finished: "bypass", ask: "neutral", stuck: "neutral", max_rounds: "held", error: "error", -}; const TECHNIQUE_STORE = "wallbreaker.agentTechniques"; function storedTechniques(): string[] | null { @@ -54,9 +57,11 @@ export function Agent({ hasTarget }: { hasTarget: boolean }) { const [runLog, setRunLog] = useState(""); const [savingConfig, setSavingConfig] = useState(false); const [configStatus, setConfigStatus] = useState(""); + const [techniqueError, setTechniqueError] = useState(""); const [err, setErr] = useState(""); const runningRef = useRef(false); const bodyRef = useRef(null); + const { start: startRun, abort: abortRun } = useAbortableFetch(); useEffect(() => { api.settings() @@ -69,7 +74,8 @@ export function Agent({ hasTarget }: { hasTarget: boolean }) { const initial = saved === null ? known : new Set(saved.filter((name) => known.has(name))); setTechniques(selectable); setEnabled(initial); - }).catch(() => {}); + setTechniqueError(""); + }).catch((e) => setTechniqueError(e instanceof Error ? e.message : "Could not load arsenal techniques.")); }, []); const filteredTechniques = useMemo(() => { @@ -91,6 +97,8 @@ export function Agent({ hasTarget }: { hasTarget: boolean }) { } function push(it: Item) { + // VIS-5: decide whether to auto-scroll BEFORE the new content grows the pane. + const pinned = isPinnedToBottom(bodyRef.current); setItems((prev) => { if (it.kind === "text" && prev.length && prev[prev.length - 1].kind === "text") { const copy = prev.slice(); @@ -100,6 +108,8 @@ export function Agent({ hasTarget }: { hasTarget: boolean }) { } return [...prev, it]; }); + // A11Y-6: skip the programmatic auto-scroll when the user prefers reduced motion. + if (prefersReducedMotion() || !pinned) return; requestAnimationFrame(() => { if (bodyRef.current) bodyRef.current.scrollTop = bodyRef.current.scrollHeight; }); @@ -140,14 +150,19 @@ export function Agent({ hasTarget }: { hasTarget: boolean }) { } } + // REL-4: abort the in-flight SSE stream when this component unmounts. + useEffect(() => abortRun, [abortRun]); + async function run() { if (!objective.trim() || runningRef.current) return; runningRef.current = true; setItems([]); setErr(""); setRunLog(""); setPaused(false); setPauseReady(false); setRunning(true); + // REL-4: fresh controller; also aborts any prior in-flight run. + const controller = startRun(); try { - await runAgent({ objective, ...agentConfig, enabled_techniques: [...enabled] }, onEvent); + await runAgent({ objective, ...agentConfig, enabled_techniques: [...enabled] }, onEvent, controller.signal); } catch (e) { - setErr((e as Error).message); + if (!isAbortError(e)) setErr((e as Error).message); } finally { runningRef.current = false; setRunning(false); @@ -229,13 +244,14 @@ export function Agent({ hasTarget }: { hasTarget: boolean }) {
+ {techniqueError &&
Could not load techniques: {techniqueError}
} {filteredTechniques.map((tool) => ( ))} - {!filteredTechniques.length &&
No matching techniques.
} + {!techniqueError && !filteredTechniques.length &&
No matching techniques.
}
Run controls remain available even when every attack technique is disabled. Selection is saved in this browser.
@@ -290,11 +306,17 @@ export function Agent({ hasTarget }: { hasTarget: boolean }) { }} /> )} - {err &&
{err}
} + {err &&
{err}
}

Transcript

+ {/* A11Y-7: a polite role=status live region announces the streaming + transcript's structural progress (round changes, tool verdicts) and + the final run verdict, so screen-reader users follow the loop without + reading the whole scroll pane. Visually hidden — the pane is the + visual channel. */} + {transcriptStatus(items)}
{!items.length &&
Set the objective and arsenal, then run. You can steer, pause, and switch the attacker without losing the conversation.
} {items.map((item, index) => )} @@ -303,77 +325,3 @@ export function Agent({ hasTarget }: { hasTarget: boolean }) {
); } - -function AttackerSwitch({ - current, - onSwitched, -}: { - current: { provider: string; model: string }; - onSwitched: (next: { provider: string; model: string }) => void; -}) { - const [profiles, setProfiles] = useState([]); - const [profile, setProfile] = useState(""); - const [provider, setProvider] = useState(current.provider); - const [model, setModel] = useState(current.model); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(""); - - useEffect(() => { - api.agentProfiles().then((data) => setProfiles(data.roles.attacker?.profiles || [])).catch(() => {}); - }, []); - useEffect(() => { setProvider(current.provider); setModel(current.model); }, [current]); - - async function apply() { - if (!profile && (!provider || !model.trim())) return; - setBusy(true); setError(""); - try { - const status = await api.switchAgentAttacker(profile ? { profile } : { provider, model: model.trim() }); - onSwitched({ provider: status.provider, model: status.attacker }); - } catch (e) { - setError((e as Error).message); - } finally { - setBusy(false); - } - } - - return ( -
-
- Switch attackerConversation and tool results stay intact - current: {current.model || "unknown"} -
-
- - {!profile && <> - - - } - -
- {error &&
{error}
} -
- ); -} - -function Row({ it }: { it: Item }) { - switch (it.kind) { - case "start": return
brain {it.brain} ▸ target {it.target}
; - case "round": return
round {it.round}/{it.max}
; - case "text": return
{it.text}
; - case "tool_start": return
▸ call {it.name} {it.args}
; - case "tool_result": { - const kind = it.error ? "bypass" : it.verdict ? verdictKind(it.verdict) : "neutral"; - return
{it.name} {it.error ? ERROR : it.verdict ? {it.verdict} : null}
{it.content.length > 1400 ? `${it.content.slice(0, 1400)}…` : it.content}
; - } - case "progress": return
{it.text}
; - case "feedback": return
steering applied: {it.text}
; - case "control": return
{it.text}
; - case "done": return
● {it.status}{it.summary ? ` — ${it.summary}` : ""}
; - case "error": return
{it.error}
; - } -} diff --git a/wallbreaker/dashboard/web/src/components/AgentConfigDrawer.tsx b/wallbreaker/dashboard/web/src/components/AgentConfigDrawer.tsx index ab1321b..ed01bab 100644 --- a/wallbreaker/dashboard/web/src/components/AgentConfigDrawer.tsx +++ b/wallbreaker/dashboard/web/src/components/AgentConfigDrawer.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useId, useState } from "react"; import type { AgentConfig } from "../api"; export const DEFAULT_AGENT_CONFIG: AgentConfig = { @@ -65,6 +65,39 @@ export function AgentConfigDrawer({ if (!draft[key]) setDraft((current) => ({ ...current, [key]: String(value[key]) })); }; + const ids = useId(); + // A11Y-10/A11Y-11: one focusable number input per field, its