Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions hackagent/server/dashboard/_reports_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,8 @@ def _render_history_goal_detail(
self._render_fc_goal_card(row, data, detail_mode=True) # type: ignore[arg-type]
else:
self._render_tfc_goal_card(row, data, detail_mode=True) # type: ignore[arg-type]
elif ha in ("indirect_prompt_injection", "rag"):
self._render_indirect_injection_view(row, data) # type: ignore[arg-type]
else:
_req, _resp, _gr_evt = data # type: ignore[misc]
self._render_generic_goal_card(
Expand Down
113 changes: 69 additions & 44 deletions hackagent/server/dashboard/_run_history_results_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,68 @@
class DashboardRunHistoryResultsMixin:
"""History run results view (_open_run_history_results)."""

def _build_history_goal_detail_data(
self,
attack_type_str: str,
rows: list[dict],
static_template_traces_map: dict[str, list[dict]],
bon_traces_map: dict[str, list[dict]],
generic_traces_map: dict[str, list[dict]],
) -> dict[str, object]:
"""Pre-parse per-row detail data keyed by result id for History goals.

Dispatches on the run's attack type to the matching ``_parse_*_traces``
helper. RAG (and its legacy ``indirect_prompt_injection`` alias) skip
parsing and keep the raw serialized traces, since
``_render_indirect_injection_view`` consumes them directly. Anything
else falls back to a generic request/response extraction.
"""
atk = attack_type_str.lower()
detail_data: dict[str, object] = {}
for row in rows:
rid = str(row.get("id") or "")
if atk in ("static_template", "statictemplate"):
t = static_template_traces_map.get(rid, [])
detail_data[rid] = self._parse_static_template_traces(
t, str(row.get("goal") or "")
)
elif atk == "bon":
t = bon_traces_map.get(rid, [])
detail_data[rid] = self._parse_bon_traces(t)
elif atk == "pap":
t = generic_traces_map.get(rid, [])
detail_data[rid] = self._parse_pap_traces(t)
elif atk == "pair":
t = generic_traces_map.get(rid, [])
detail_data[rid] = self._parse_pair_traces(t)
elif atk == "crescendo":
t = generic_traces_map.get(rid, [])
detail_data[rid] = self._parse_crescendo_traces(t)
elif atk == "tap":
t = generic_traces_map.get(rid, [])
detail_data[rid] = self._parse_tap_traces(t)
elif atk == "advprefix":
t = generic_traces_map.get(rid, [])
detail_data[rid] = self._parse_advprefix_traces(t)
elif atk == "autodanturbo":
t = generic_traces_map.get(rid, [])
detail_data[rid] = self._parse_autodan_traces(t)
elif atk == "mml":
t = generic_traces_map.get(rid, [])
detail_data[rid] = self._parse_mml_traces(t)
elif atk in ("fc", "tfc"):
t = generic_traces_map.get(rid, [])
if atk == "fc":
detail_data[rid] = self._parse_fc_traces(t)
else:
detail_data[rid] = self._parse_tfc_traces(t)
elif atk in ("indirect_prompt_injection", "rag"):
detail_data[rid] = generic_traces_map.get(rid, [])
else:
t = generic_traces_map.get(rid, [])
detail_data[rid] = self._extract_prompt_response_from_traces(t)
return detail_data

async def _open_run_history_results(self, run: dict) -> None:
"""Open the compact results list in a non-modal side dialog."""
run_id_raw = str(run.get("id") or "")
Expand Down Expand Up @@ -1431,50 +1493,13 @@ async def _dl_hcr():

if all_items and self.history_results_list_area is not None:
# ── Pre-parse detail data for all rows ─────────────
_h_atk = attack_type_str.lower()
_h_detail_data: dict[str, object] = {}
for _row in new_rows:
_rid = str(_row.get("id") or "")
if _h_atk in ("static_template", "statictemplate"):
_t = static_template_traces_map_hr.get(_rid, [])
_h_detail_data[_rid] = self._parse_static_template_traces(
_t, str(_row.get("goal") or "")
)
elif _h_atk == "bon":
_t = bon_traces_map_hr.get(_rid, [])
_h_detail_data[_rid] = self._parse_bon_traces(_t)
elif _h_atk == "pap":
_t = generic_traces_map_hr.get(_rid, [])
_h_detail_data[_rid] = self._parse_pap_traces(_t)
elif _h_atk == "pair":
_t = generic_traces_map_hr.get(_rid, [])
_h_detail_data[_rid] = self._parse_pair_traces(_t)
elif _h_atk == "crescendo":
_t = generic_traces_map_hr.get(_rid, [])
_h_detail_data[_rid] = self._parse_crescendo_traces(_t)
elif _h_atk == "tap":
_t = generic_traces_map_hr.get(_rid, [])
_h_detail_data[_rid] = self._parse_tap_traces(_t)
elif _h_atk == "advprefix":
_t = generic_traces_map_hr.get(_rid, [])
_h_detail_data[_rid] = self._parse_advprefix_traces(_t)
elif _h_atk == "autodanturbo":
_t = generic_traces_map_hr.get(_rid, [])
_h_detail_data[_rid] = self._parse_autodan_traces(_t)
elif _h_atk == "mml":
_t = generic_traces_map_hr.get(_rid, [])
_h_detail_data[_rid] = self._parse_mml_traces(_t)
elif _h_atk in ("fc", "tfc"):
_t = generic_traces_map_hr.get(_rid, [])
if _h_atk == "fc":
_h_detail_data[_rid] = self._parse_fc_traces(_t)
else:
_h_detail_data[_rid] = self._parse_tfc_traces(_t)
else:
_t = generic_traces_map_hr.get(_rid, [])
_h_detail_data[_rid] = (
self._extract_prompt_response_from_traces(_t)
) # returns (req, resp, guardrail_event)
_h_detail_data = self._build_history_goal_detail_data(
attack_type_str,
new_rows,
static_template_traces_map_hr,
bon_traces_map_hr,
generic_traces_map_hr,
)

# Store for filter re-rendering
self._history_goal_rows = new_rows
Expand Down
2 changes: 2 additions & 0 deletions hackagent/server/dashboard/_trace_analysis_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -557,6 +557,8 @@ async def _load_attack_specific_traces(
else:
detail_data = self._parse_tfc_traces(serialized_traces)
self._render_tfc_goal_card(row, detail_data, detail_mode=True)
elif atk in ("indirect_prompt_injection", "rag"):
self._render_indirect_injection_view(row, serialized_traces)
else:
req_text, resp_text, _generic_guardrail = (
self._extract_prompt_response_from_traces(serialized_traces)
Expand Down
53 changes: 26 additions & 27 deletions tests/unit/cli/tui/test_view_snapshots.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,17 @@
layout. Regenerate the snapshots with::

uv run pytest tests/unit/cli/tui --snapshot-update

``snap_compare`` also accepts a file path, but ``pytest-textual-snapshot``
resolves it to an absolute path and hands it to Textual's ``import_app``,
which runs it through ``shlex.split``. When the repository checkout lives
under a directory containing a space (e.g. ``.../VS Code/hackagent``), that
split mangles the path and the import fails with ``No module named ...``.
Loading the app class ourselves and passing a fresh instance sidesteps
``import_app``/``shlex`` entirely.
"""

import shlex
import importlib.util
from pathlib import Path

import pytest
Expand All @@ -24,38 +32,29 @@
_NARROW_TERMINAL = (80, 24)


@pytest.fixture(autouse=True)
def _quote_app_path_for_import(monkeypatch):
"""Work around a ``textual`` bug when the repo path contains spaces.
def _load_app_instance(app_file: str, class_name: str):
"""Import ``app_file`` fresh and return a new instance of ``class_name``.

``pytest_textual_snapshot.snap_compare`` resolves the app to an absolute
path and hands the raw string to ``textual._import_app.import_app``,
which immediately runs it through ``shlex.split``. Any space in the path
(e.g. a checkout under a directory like ``.../VS Code/...``) is then
parsed as an argument separator, so the importer looks for a module
named after just the first path fragment. Quoting the path before it
reaches ``shlex.split`` keeps it intact as a single token.
A fresh module/instance per call avoids reusing an already-run Textual
``App`` object across parametrized cases that share the same file.
"""
from textual import _import_app as textual_import_app

original_import_app = textual_import_app.import_app

def _patched_import_app(import_name: str):
if import_name.endswith(".py") and " " in import_name:
import_name = shlex.quote(import_name)
return original_import_app(import_name)

monkeypatch.setattr(textual_import_app, "import_app", _patched_import_app)
path = _APPS / app_file
spec = importlib.util.spec_from_file_location(path.stem, path)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return getattr(module, class_name)()
Comment thread
marcorusso97 marked this conversation as resolved.


@pytest.mark.parametrize(
("app_file", "terminal_size"),
("app_file", "class_name", "terminal_size"),
[
("attacks_tab_app.py", _LARGE_TERMINAL),
("results_tab_app.py", _LARGE_TERMINAL),
("attacks_tab_app.py", _NARROW_TERMINAL),
("attacks_tab_app.py", "AttacksTabApp", _LARGE_TERMINAL),
("results_tab_app.py", "ResultsTabApp", _LARGE_TERMINAL),
("attacks_tab_app.py", "AttacksTabApp", _NARROW_TERMINAL),
],
ids=["attacks-large", "results-large", "attacks-narrow"],
)
def test_view_renders(snap_compare, app_file, terminal_size):
assert snap_compare(_APPS / app_file, terminal_size=terminal_size)
def test_view_renders(snap_compare, app_file, class_name, terminal_size):
app_instance = _load_app_instance(app_file, class_name)
assert snap_compare(app_instance, terminal_size=terminal_size)
Loading
Loading