diff --git a/.gitignore b/.gitignore index 69268a40..576fbb39 100644 --- a/.gitignore +++ b/.gitignore @@ -139,3 +139,6 @@ dmypy.json tests/e2e/attacks/ .tmp + +# pytest-textual-snapshot report artifact +snapshot_report.html diff --git a/hackagent/cli/tui/app.py b/hackagent/cli/tui/app.py index 15a6b252..05d51c90 100644 --- a/hackagent/cli/tui/app.py +++ b/hackagent/cli/tui/app.py @@ -14,6 +14,7 @@ from textual.widgets import Footer, TabbedContent, TabPane from hackagent.cli.config import CLIConfig +from hackagent.cli.tui.theme import css_variables from hackagent.cli.tui.views.agents import AgentsTab from hackagent.cli.tui.views.attacks import AttacksTab from hackagent.cli.tui.views.config import ConfigTab @@ -29,19 +30,19 @@ class HackAgentTUI(App): } Header { - background: #8b0000; /* dark red - HackAgent brand color */ - color: #ffffff; + background: $brand-dark; /* dark red - HackAgent brand color */ + color: $brand-text; height: 3; } Footer { - background: #2b0000; /* darker red */ - color: #ffffff; + background: $brand-darker; /* darker red */ + color: $brand-text; } TabbedContent { height: 100%; - border: solid #ff0000; /* red - HackAgent brand color */ + border: solid $brand; /* red - HackAgent brand color */ } TabPane { @@ -53,35 +54,35 @@ class HackAgentTUI(App): } Tabs { - background: #2b0000; + background: $brand-darker; } Tab { - color: #cccccc; - background: #2b0000; + color: $brand-text-muted; + background: $brand-darker; } Tab.-active { - color: #ffffff; - background: #8b0000; /* dark red when active */ + color: $brand-text; + background: $brand-dark; /* dark red when active */ text-style: bold; } Tab:hover { - background: #5b0000; + background: $brand-hover; } .title-bar { dock: top; width: 100%; - background: #8b0000; - color: #ffffff; + background: $brand-dark; + color: $brand-text; height: 3; content-align: center middle; } .section { - border: solid #ff0000; + border: solid $brand; padding: 1; margin: 1; height: auto; @@ -89,7 +90,7 @@ class HackAgentTUI(App): .info-box { background: $panel; - border: solid #ff0000; + border: solid $brand; padding: 1; margin: 1; } @@ -99,12 +100,12 @@ class HackAgentTUI(App): } Button.-primary { - background: #8b0000; - color: #ffffff; + background: $brand-dark; + color: $brand-text; } Button.-primary:hover { - background: #ff0000; + background: $brand; } DataTable { @@ -112,18 +113,18 @@ class HackAgentTUI(App): } DataTable > .datatable--header { - background: #8b0000; - color: #ffffff; + background: $brand-dark; + color: $brand-text; text-style: bold; } DataTable > .datatable--cursor { - background: #5b0000; + background: $brand-hover; } /* Results tab specific styles - horizontal split 20-80 */ ResultsTab #results-left-panel { - border-right: solid #ff0000; + border-right: solid $brand; background: $panel; } @@ -135,8 +136,8 @@ class HackAgentTUI(App): height: 3; width: 100%; text-align: center; - background: #8b0000; - color: #ffffff; + background: $brand-dark; + color: $brand-text; padding: 1; } @@ -144,8 +145,8 @@ class HackAgentTUI(App): height: 3; width: 100%; text-align: center; - background: #8b0000; - color: #ffffff; + background: $brand-dark; + color: $brand-text; padding: 1; } @@ -188,6 +189,10 @@ def __init__( self.initial_data = initial_data or {} self.dark = True # Use dark theme by default + def get_css_variables(self) -> dict[str, str]: + """Expose the HackAgent brand palette as CSS variables.""" + return {**super().get_css_variables(), **css_variables()} + def compose(self) -> ComposeResult: """Compose the UI layout.""" with TabbedContent(initial=self.initial_tab): diff --git a/hackagent/cli/tui/base.py b/hackagent/cli/tui/base.py index d17c490a..910148db 100644 --- a/hackagent/cli/tui/base.py +++ b/hackagent/cli/tui/base.py @@ -28,7 +28,7 @@ class HackAgentHeader(Container): } HackAgentHeader Static { - color: #ff0000; + color: $brand; text-style: bold; width: 100%; content-align: center middle; diff --git a/hackagent/cli/tui/theme.py b/hackagent/cli/tui/theme.py new file mode 100644 index 00000000..8d41044c --- /dev/null +++ b/hackagent/cli/tui/theme.py @@ -0,0 +1,187 @@ +# Copyright 2026 - AI4I. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +""" +TUI Theme and Terminology + +Single source of truth for the HackAgent TUI's colour palette and for the +vocabulary used to describe evaluation outcomes and run states. + +Two rules keep the interface coherent: + +1. **Defender polarity.** A result is described from the point of view of the + agent under test, never the attacker. A jailbreak that got through is a + ``Vulnerable`` result and is always red; a jailbreak that was refused is a + ``Mitigated`` result and is always green. "Successful attack" wording (which + would paint a vulnerability green) is not used anywhere in the TUI. +2. **One palette.** Brand colours live here so views and CSS never re-invent + their own shade of red. +""" + +from dataclasses import dataclass +from typing import Any + +# -------------------------------------------------------------------------- +# Brand palette +# -------------------------------------------------------------------------- + +BRAND_RED = "#ff0000" +"""Primary brand red — borders, logo, accents.""" + +BRAND_RED_DARK = "#8b0000" +"""Dark red — headers, active tabs, primary buttons.""" + +BRAND_RED_DARKER = "#2b0000" +"""Darkest red — footer and inactive tab strip backgrounds.""" + +BRAND_RED_HOVER = "#5b0000" +"""Mid red — hover and cursor highlights.""" + +TEXT_ON_BRAND = "#ffffff" +"""Foreground colour on top of any brand red background.""" + +TEXT_MUTED = "#cccccc" +"""Foreground colour for de-emphasised text on dark backgrounds.""" + + +def css_variables() -> dict[str, str]: + """Return the brand palette as Textual CSS variables. + + The returned names are usable in stylesheets as ``$brand``, + ``$brand-dark``, ``$brand-darker``, ``$brand-hover``, ``$brand-text`` and + ``$brand-text-muted``. + """ + return { + "brand": BRAND_RED, + "brand-dark": BRAND_RED_DARK, + "brand-darker": BRAND_RED_DARKER, + "brand-hover": BRAND_RED_HOVER, + "brand-text": TEXT_ON_BRAND, + "brand-text-muted": TEXT_MUTED, + } + + +# -------------------------------------------------------------------------- +# Evaluation outcomes +# -------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class Outcome: + """Presentation vocabulary for a single evaluation outcome. + + Attributes: + key: Stable identifier, also used as the CSS modifier class. + label: Human-readable label shown to the user. + color: Rich colour name used to render the label. + icon: Emoji shown next to the label. + """ + + key: str + label: str + color: str + icon: str + + def render(self) -> str: + """Return the outcome as Rich markup, e.g. ``[red]🔓 Vulnerable[/red]``.""" + return f"[{self.color}]{self.icon} {self.label}[/{self.color}]" + + @property + def css_class(self) -> str: + """Return the CSS modifier class for this outcome.""" + return f"-{self.key}" + + +VULNERABLE = Outcome("vulnerable", "Vulnerable", "red", "🔓") +"""The attack got through: the target agent is vulnerable.""" + +MITIGATED = Outcome("mitigated", "Mitigated", "green", "🛡") +"""The attack was refused or blocked by the target agent.""" + +ERRORED = Outcome("errored", "Error", "yellow", "⚠") +"""The attempt could not be evaluated because something went wrong.""" + +NOT_EVALUATED = Outcome("not-evaluated", "Not Evaluated", "dim", "âŗ") +"""No verdict is available yet.""" + +OUTCOMES = (VULNERABLE, MITIGATED, ERRORED, NOT_EVALUATED) + + +def classify_evaluation_status(status: Any) -> Outcome: + """Map a raw evaluation status onto the TUI outcome vocabulary. + + Args: + status: An ``EvaluationStatus`` enum member, a string, or anything + coercible to a string. ``None`` is treated as unevaluated. + + Returns: + The matching :class:`Outcome`. + """ + if status is None: + return NOT_EVALUATED + + raw = getattr(status, "value", status) + text = str(raw).upper() + + if "JAILBREAK" in text: + if "SUCCESSFUL" in text: + return VULNERABLE + if "FAILED" in text: + return MITIGATED + if "ERROR" in text: + return ERRORED + return NOT_EVALUATED + + +# -------------------------------------------------------------------------- +# Run states +# -------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class RunState: + """Presentation vocabulary for a run's lifecycle state.""" + + key: str + label: str + color: str + icon: str + + def render(self) -> str: + """Return the state as Rich markup.""" + return f"[{self.color}]{self.icon} {self.label}[/{self.color}]" + + def render_icon(self) -> str: + """Return just the coloured icon, for compact table cells.""" + return f"[{self.color}]{self.icon}[/{self.color}]" + + +RUN_COMPLETED = RunState("completed", "Completed", "green", "✅") +RUN_RUNNING = RunState("running", "Running", "cyan", "🔄") +RUN_FAILED = RunState("failed", "Failed", "red", "❌") +RUN_PENDING = RunState("pending", "Pending", "yellow", "âŗ") +RUN_UNKNOWN = RunState("unknown", "Unknown", "dim", "❓") + +RUN_STATES = (RUN_COMPLETED, RUN_RUNNING, RUN_FAILED, RUN_PENDING, RUN_UNKNOWN) + + +def classify_run_status(status: Any) -> RunState: + """Map a raw run status onto the TUI run-state vocabulary. + + Args: + status: A status enum member, a string, or anything coercible to a + string. ``None`` is treated as unknown. + + Returns: + The matching :class:`RunState`. + """ + if status is None: + return RUN_UNKNOWN + + raw = getattr(status, "value", status) + text = str(raw).upper() + + for state in (RUN_COMPLETED, RUN_RUNNING, RUN_FAILED, RUN_PENDING): + if state.key.upper() == text: + return state + return RUN_UNKNOWN diff --git a/hackagent/cli/tui/views/attacks/layout.py b/hackagent/cli/tui/views/attacks/layout.py index 35069cf1..a3c06c56 100644 --- a/hackagent/cli/tui/views/attacks/layout.py +++ b/hackagent/cli/tui/views/attacks/layout.py @@ -177,9 +177,9 @@ def compose(self) -> ComposeResult: # order in attack_specs.py), not the desired campaign order # (h4rm3l → TAP → PAIR). `on_mount` selects the default # campaign attacks explicitly, in the right order, instead. - yield Static("[bold]Attack Strategy[/bold]", classes="section-title") + yield Static("[bold]Attacks[/bold]", classes="section-title") yield Static( - "[dim]Select one attack, or check multiple to chain them " + "[dim]Select one attack, or check multiple to chain them. " "Check order sets the chain order. Defaults to the Jailbreak " "evaluation campaign (h4rm3l → TAP → PAIR).[/dim]" ) @@ -189,18 +189,18 @@ def compose(self) -> ComposeResult: yield Checkbox( "Escalate only mitigated goals to the next attack", id="escalate-only-mitigated", - value=False, + value=True, ) yield Static( "[dim]Chain mode (2+ attacks checked): a goal moves to " "the next attack only if the previous one mitigated it; " - "goals that already succeeded are dropped. Uncheck to " + "goals that are already vulnerable are dropped. Uncheck to " "instead run every checked attack against every goal.[/dim]", id="escalate-only-mitigated-help", ) yield Static("") - yield Label("Configuring:") + yield Label("Configuring attack:") yield Select( strategy_choices, id="attack-strategy-focus", diff --git a/hackagent/cli/tui/views/attacks/runner.py b/hackagent/cli/tui/views/attacks/runner.py index fda78df3..8744f780 100644 --- a/hackagent/cli/tui/views/attacks/runner.py +++ b/hackagent/cli/tui/views/attacks/runner.py @@ -231,7 +231,7 @@ def _reject(message: str) -> None: [bold]Agent:[/bold] {_escape(agent_name)} [bold]Type:[/bold] {_escape(agent_type)} [bold]Endpoint:[/bold] {_escape(endpoint)} -[bold]Strategy:[/bold] {_escape(strategy_label)} +[bold]Attacks:[/bold] {_escape(strategy_label)} [bold]Goals:[/bold] {_escape(goals)} [bold]Timeout:[/bold] {timeout}s{chain_note} @@ -248,7 +248,7 @@ def _reject(message: str) -> None: [bold]Agent:[/bold] {_escape(agent_name)} [bold]Type:[/bold] {_escape(agent_type)} [bold]Endpoint:[/bold] {_escape(endpoint)} -[bold]Strategy:[/bold] {_escape(strategy_label)} +[bold]Attacks:[/bold] {_escape(strategy_label)} [bold]Goals:[/bold] {_escape(goals)} [bold]Timeout:[/bold] {timeout}s diff --git a/hackagent/cli/tui/views/attacks/tab.py b/hackagent/cli/tui/views/attacks/tab.py index ba0c6b1c..68de6ed8 100644 --- a/hackagent/cli/tui/views/attacks/tab.py +++ b/hackagent/cli/tui/views/attacks/tab.py @@ -99,7 +99,7 @@ class AttacksTab( } AttacksTab RadioButton.-on > .toggle--label { - color: #ffffff; + color: $brand-text; } AttacksTab RadioButton:hover > .toggle--label, diff --git a/hackagent/cli/tui/views/results/details.py b/hackagent/cli/tui/views/results/details.py index c661c5c8..d9bc713d 100644 --- a/hackagent/cli/tui/views/results/details.py +++ b/hackagent/cli/tui/views/results/details.py @@ -10,11 +10,14 @@ from textual.containers import Vertical from textual.widgets import Collapsible, Static +from hackagent.cli.tui.theme import ( + classify_evaluation_status, + classify_run_status, +) from hackagent.cli.tui.views.results.formatters import ( _escape, _format_local_datetime, _format_result_full_details, - _get_result_status_info, ) from hackagent.cli.tui.views.results.formatters.run_report import ( build_run_report_header, @@ -184,21 +187,10 @@ def _show_result_details(self) -> None: else: status_display = str(status_val) - # Status color and icon based on status - status_color = "yellow" - status_icon = "🔄" - if status_display.upper() == "COMPLETED": - status_color = "green" - status_icon = "✅" - elif status_display.upper() == "FAILED": - status_color = "red" - status_icon = "❌" - elif status_display.upper() == "RUNNING": - status_color = "cyan" - status_icon = "⚡" - elif status_display.upper() == "PENDING": - status_color = "yellow" - status_icon = "âŗ" + # Status colour and icon from the shared run-state vocabulary + run_state = classify_run_status(getattr(run, "status", None)) + status_color = run_state.color + status_icon = run_state.icon # Attack config from attack record attack_config = {} @@ -272,48 +264,18 @@ def _show_result_details(self) -> None: # Create collapsible for each result for idx, result in enumerate(run_results, 1): - # Get status info for CSS class - eval_status, status_color, _ = _get_result_status_info(result) - - # Determine CSS class for status coloring - css_class = "result-collapsible" - if ( - "SUCCESSFUL" in eval_status.upper() - and "JAILBREAK" in eval_status.upper() - ): - css_class += " -success" - elif ( - "FAILED" in eval_status.upper() - and "JAILBREAK" in eval_status.upper() - ): - css_class += " -failed" - elif "ERROR" in eval_status.upper(): - css_class += " -failed" - else: - css_class += " -pending" + outcome = classify_evaluation_status( + getattr(result, "evaluation_status", None) + ) + css_class = f"result-collapsible {outcome.css_class}" # Resolve traces — embedded or pre-fetched result_traces = _traces_by_result.get(str(result.id)) or [] - # Create the title - eval_status_short = "" - if ( - "SUCCESSFUL" in eval_status.upper() - and "JAILBREAK" in eval_status.upper() - ): - eval_status_short = "[red]Vulnerable[/red]" - elif ( - "FAILED" in eval_status.upper() - and "JAILBREAK" in eval_status.upper() - ): - eval_status_short = "[green]Safe[/green]" - elif "ERROR" in eval_status.upper(): - eval_status_short = "[yellow]Error[/yellow]" - else: - eval_status_short = f"[dim]{_escape(eval_status)}[/dim]" - - trace_count_str = f" 🔍 {len(result_traces)}" if result_traces else "" - title = f"Test #{idx}{trace_count_str} {eval_status_short}" + trace_count_str = ( + f" \U0001f50d {len(result_traces)}" if result_traces else "" + ) + title = f"Result #{idx}{trace_count_str} {outcome.render()}" # Create collapsible with full details inside collapsible = Collapsible( diff --git a/hackagent/cli/tui/views/results/formatters/run_report.py b/hackagent/cli/tui/views/results/formatters/run_report.py index c6767016..1b8a9157 100644 --- a/hackagent/cli/tui/views/results/formatters/run_report.py +++ b/hackagent/cli/tui/views/results/formatters/run_report.py @@ -5,6 +5,13 @@ from typing import Any +from hackagent.cli.tui.theme import ( + ERRORED, + MITIGATED, + NOT_EVALUATED, + VULNERABLE, + classify_evaluation_status, +) from hackagent.cli.tui.views.results.formatters.text import _escape @@ -40,34 +47,13 @@ def build_run_report_header( """ results_count = len(run_results) - # Count evaluation statuses + # Count outcomes using the shared vocabulary eval_summary = { - "SUCCESSFUL_JAILBREAK": 0, - "FAILED_JAILBREAK": 0, - "NOT_EVALUATED": 0, - "ERROR": 0, - "OTHER": 0, + outcome.key: 0 for outcome in (VULNERABLE, MITIGATED, ERRORED, NOT_EVALUATED) } for result in run_results: - if hasattr(result, "evaluation_status"): - eval_status = ( - result.evaluation_status.value - if hasattr(result.evaluation_status, "value") - else str(result.evaluation_status) - ) - if ( - "SUCCESSFUL" in eval_status.upper() - and "JAILBREAK" in eval_status.upper() - ): - eval_summary["SUCCESSFUL_JAILBREAK"] += 1 - elif "FAILED" in eval_status.upper() and "JAILBREAK" in eval_status.upper(): - eval_summary["FAILED_JAILBREAK"] += 1 - elif "NOT_EVALUATED" in eval_status.upper(): - eval_summary["NOT_EVALUATED"] += 1 - elif "ERROR" in eval_status.upper(): - eval_summary["ERROR"] += 1 - else: - eval_summary["OTHER"] += 1 + outcome = classify_evaluation_status(getattr(result, "evaluation_status", None)) + eval_summary[outcome.key] += 1 header = f"""[bold cyan]╔{"═" * 50}╗[/bold cyan] [bold cyan]║[/bold cyan] [bold bright_white]📊 Report Details[/bold bright_white]{" " * 33}[bold cyan]║[/bold cyan] @@ -75,14 +61,14 @@ def build_run_report_header( """ # ── Summary Stats Bar ─────────────────────────────────────────── - vuln_count = eval_summary["SUCCESSFUL_JAILBREAK"] - mitigated_count = eval_summary["FAILED_JAILBREAK"] - error_count = eval_summary["ERROR"] + vuln_count = eval_summary[VULNERABLE.key] + mitigated_count = eval_summary[MITIGATED.key] + error_count = eval_summary[ERRORED.key] header += ( - f" [bold bright_cyan]{results_count}[/bold bright_cyan] [dim]Total Tests[/dim]" - f" [bold red]{vuln_count}[/bold red] [dim]Vulnerabilities[/dim]" - f" [bold green]{mitigated_count}[/bold green] [dim]Mitigated[/dim]" - f" [bold yellow]{error_count}[/bold yellow] [dim]Errors[/dim]\n" + f" [bold bright_cyan]{results_count}[/bold bright_cyan] [dim]Total Results[/dim]" + f" [bold {VULNERABLE.color}]{vuln_count}[/bold {VULNERABLE.color}] [dim]{VULNERABLE.label}[/dim]" + f" [bold {MITIGATED.color}]{mitigated_count}[/bold {MITIGATED.color}] [dim]{MITIGATED.label}[/dim]" + f" [bold {ERRORED.color}]{error_count}[/bold {ERRORED.color}] [dim]{ERRORED.label}[/dim]\n" ) header += f" [dim]{'─' * 50}[/dim]\n\n" @@ -177,7 +163,7 @@ def build_run_report_header( header += f" 🤖 [bold]Agent:[/bold] [bright_cyan]{_escape(agent_display)}[/bright_cyan]\n" header += f" đŸĸ [bold]Org:[/bold] [bright_cyan]{_escape(org_display)}[/bright_cyan]\n" header += f" 📅 [bold]Time:[/bold] {_escape(created)}\n" - header += f" {status_icon} [bold]Status:[/bold] [bright_{status_color}]{_escape(status_display)}[/bright_{status_color}]\n" + header += f" {status_icon} [bold]Status:[/bold] [{status_color}]{_escape(status_display)}[/{status_color}]\n" if attack_type_display: header += f" âš”ī¸ [bold]Attack:[/bold] [bright_yellow]{_escape(str(attack_type_display).upper())}[/bright_yellow]\n" diff --git a/hackagent/cli/tui/views/results/formatters/summaries.py b/hackagent/cli/tui/views/results/formatters/summaries.py index e6742bfc..0f4eb7b5 100644 --- a/hackagent/cli/tui/views/results/formatters/summaries.py +++ b/hackagent/cli/tui/views/results/formatters/summaries.py @@ -6,6 +6,7 @@ from datetime import datetime from typing import Any +from hackagent.cli.tui.theme import classify_evaluation_status from hackagent.cli.tui.views.results.formatters.text import _escape from hackagent.cli.tui.views.results.formatters.traces import _format_trace_block @@ -13,35 +14,18 @@ def _get_result_status_info(result: Any) -> tuple[str, str, str]: """Get status display info for a result. + The label, colour and icon come from the shared defender-polarity + vocabulary in :mod:`hackagent.cli.tui.theme`: a jailbreak that got through + is a red ``Vulnerable`` result, not a green success. + Args: result: Result object with evaluation_status Returns: - Tuple of (eval_status, status_color, status_icon) + Tuple of (status_label, status_color, status_icon) """ - eval_status = "N/A" - if hasattr(result, "evaluation_status"): - eval_status = ( - result.evaluation_status.value - if hasattr(result.evaluation_status, "value") - else str(result.evaluation_status) - ) - - # Determine color and icon based on status - if "SUCCESSFUL" in eval_status.upper() and "JAILBREAK" in eval_status.upper(): - status_color = "green" - status_icon = "✅" - elif "FAILED" in eval_status.upper() and "JAILBREAK" in eval_status.upper(): - status_color = "red" - status_icon = "❌" - elif "ERROR" in eval_status.upper(): - status_color = "red" - status_icon = "âš ī¸" - else: - status_color = "yellow" - status_icon = "â„šī¸" - - return eval_status, status_color, status_icon + outcome = classify_evaluation_status(getattr(result, "evaluation_status", None)) + return outcome.label, outcome.color, outcome.icon def _format_result_summary(result: Any, index: int) -> str: @@ -54,7 +38,7 @@ def _format_result_summary(result: Any, index: int) -> str: Returns: Formatted summary string for the collapsible title """ - eval_status, status_color, status_icon = _get_result_status_info(result) + status_label, status_color, status_icon = _get_result_status_info(result) # Goal text — prefer result.goal, fall back to metadata goal_text = "" @@ -85,7 +69,7 @@ def _format_result_summary(result: Any, index: int) -> str: except (TypeError, ValueError): score_str = "" - return f"{status_icon} [bold]#{index}[/bold] [{status_color}]{_escape(eval_status)}[/]{goal_text}{timing}{score_str}" + return f"{status_icon} [bold]#{index}[/bold] [{status_color}]{_escape(status_label)}[/]{goal_text}{timing}{score_str}" def _format_result_full_details( @@ -104,7 +88,7 @@ def _format_result_full_details( Returns: Formatted details string """ - eval_status, status_color, status_icon = _get_result_status_info(result) + status_label, status_color, status_icon = _get_result_status_info(result) meta: dict = getattr(result, "metadata", None) or {} details = "" @@ -115,7 +99,7 @@ def _format_result_full_details( details += "[bold bright_cyan]┌─ 📋 Result ──────────────────────────────────┐[/bold bright_cyan]\n\n" # Status + timing - details += f" {status_icon} [bold {status_color}]{_escape(eval_status)}[/bold {status_color}]" + details += f" {status_icon} [bold {status_color}]{_escape(status_label)}[/bold {status_color}]" elapsed = meta.get("elapsed_s") if elapsed is not None: try: diff --git a/hackagent/cli/tui/views/results/formatters/traces.py b/hackagent/cli/tui/views/results/formatters/traces.py index f5d91015..d40cb0c5 100644 --- a/hackagent/cli/tui/views/results/formatters/traces.py +++ b/hackagent/cli/tui/views/results/formatters/traces.py @@ -6,6 +6,7 @@ import json from typing import Any +from hackagent.cli.tui.theme import MITIGATED, VULNERABLE from hackagent.cli.tui.views.results.formatters.text import _escape @@ -141,11 +142,7 @@ def _format_trace_block( num_results = result_inner.get("num_results", "?") best_score = result_inner.get("best_score", 0.0) is_success = result_inner.get("is_success", False) - jb_icon = ( - "[bright_green]✓ JAILBREAK[/bright_green]" - if is_success - else "[red]✗ REFUSED[/red]" - ) + jb_icon = VULNERABLE.render() if is_success else MITIGATED.render() score_color = "bright_green" if best_score > 0 else "dim" header = f" [bold green]{_step_num_circle(step_num)} 📋 SUMMARY[/bold green]{ts_str}" body = ( diff --git a/hackagent/cli/tui/views/results/tab.py b/hackagent/cli/tui/views/results/tab.py index aabe7770..828c39de 100644 --- a/hackagent/cli/tui/views/results/tab.py +++ b/hackagent/cli/tui/views/results/tab.py @@ -20,6 +20,11 @@ from hackagent.cli.config import CLIConfig from hackagent.cli.tui.base import BaseTab +from hackagent.cli.tui.theme import ( + MITIGATED, + VULNERABLE, + classify_evaluation_status, +) from hackagent.cli.tui.views.results.details import ResultsDetailsMixin from hackagent.cli.tui.views.results.export import ResultsExportMixin from hackagent.cli.tui.views.results.formatters import _escape @@ -72,20 +77,25 @@ class ResultsTab( background: $surface; } - ResultsTab .result-collapsible.-success > CollapsibleTitle { - background: $success-darken-3; + ResultsTab .result-collapsible.-vulnerable > CollapsibleTitle { + background: $error-darken-3; color: $text; } - - ResultsTab .result-collapsible.-failed > CollapsibleTitle { - background: $error-darken-3; + + ResultsTab .result-collapsible.-mitigated > CollapsibleTitle { + background: $success-darken-3; color: $text; } - - ResultsTab .result-collapsible.-pending > CollapsibleTitle { + + ResultsTab .result-collapsible.-errored > CollapsibleTitle { background: $warning-darken-3; color: $text; } + + ResultsTab .result-collapsible.-not-evaluated > CollapsibleTitle { + background: $surface-darken-1; + color: $text; + } ResultsTab .result-details { padding: 1 2; @@ -210,7 +220,14 @@ def on_mount(self) -> None: try: table = self.query_one("#results-table", DataTable) table.clear(columns=True) - table.add_columns("#", "⚡", "Agent", "Attack", "✅/❌", "Created") + table.add_columns( + "#", + "State", + "Agent", + "Attack", + f"{VULNERABLE.icon}/{MITIGATED.icon}", + "Created", + ) except Exception as e: self.app.notify(f"Failed to initialize table: {str(e)}", severity="error") @@ -325,25 +342,15 @@ def refresh_data(self) -> None: res_page = backend.list_results( run_id=rid, page=1, page_size=500 ) - success = sum( - 1 - for r in res_page.items - if "SUCCESSFUL" - in str(getattr(r, "evaluation_status", "")).upper() - and "JAILBREAK" - in str(getattr(r, "evaluation_status", "")).upper() - ) - fail = sum( - 1 + outcomes = [ + classify_evaluation_status( + getattr(r, "evaluation_status", None) + ) for r in res_page.items - if "FAILED" - in str(getattr(r, "evaluation_status", "")).upper() - and "JAILBREAK" - in str(getattr(r, "evaluation_status", "")).upper() - ) + ] self._result_counts[str(run.id)] = ( - success, - fail, + sum(1 for o in outcomes if o is VULNERABLE), + sum(1 for o in outcomes if o is MITIGATED), len(res_page.items), ) except Exception: diff --git a/hackagent/cli/tui/views/results/table.py b/hackagent/cli/tui/views/results/table.py index f646499b..cc11cd44 100644 --- a/hackagent/cli/tui/views/results/table.py +++ b/hackagent/cli/tui/views/results/table.py @@ -9,6 +9,13 @@ from textual.containers import Vertical from textual.widgets import DataTable, Static +from hackagent.cli.tui.theme import ( + MITIGATED, + NOT_EVALUATED, + VULNERABLE, + classify_evaluation_status, + classify_run_status, +) from hackagent.cli.tui.views.results.formatters import ( _coerce_datetime, _escape, @@ -43,27 +50,10 @@ def get_timestamp(run): numbered_runs = list(enumerate(sorted_runs, start=1)) for idx, run in numbered_runs: - # Get status with color coding from Run.status - status_display = "Unknown" - if hasattr(run, "status"): - status_val = run.status - if hasattr(status_val, "value"): - status_display = status_val.value - else: - status_display = str(status_val) - - # Color code based on status - show only emoji - status_upper = status_display.upper() - if status_upper == "COMPLETED": - status_display = "[green]✅[/green]" - elif status_upper == "RUNNING": - status_display = "[cyan]🔄[/cyan]" - elif status_upper == "FAILED": - status_display = "[red]❌[/red]" - elif status_upper == "PENDING": - status_display = "[yellow]âŗ[/yellow]" - else: - status_display = "[dim]❓[/dim]" + # Run state icon, colour-coded from the shared vocabulary + status_display = classify_run_status( + getattr(run, "status", None) + ).render_icon() # Get agent name — prefer explicit name, otherwise resolve agent_id if hasattr(run, "agent_name") and run.agent_name: @@ -102,33 +92,29 @@ def get_timestamp(run): ts, fmt="%m/%d %H:%M", fallback=str(ts)[:10] ) - # Calculate success/failure ratio — prefer nested results, fall back to cache + # Vulnerable/mitigated ratio — prefer nested results, fall back to cache if hasattr(run, "results") and run.results: total_results = len(run.results) - success_count = sum( - 1 - for r in run.results - if "SUCCESSFUL" - in str(getattr(r, "evaluation_status", "")).upper() - and "JAILBREAK" - in str(getattr(r, "evaluation_status", "")).upper() - ) - fail_count = sum( - 1 + outcomes = [ + classify_evaluation_status( + getattr(r, "evaluation_status", None) + ) for r in run.results - if "FAILED" in str(getattr(r, "evaluation_status", "")).upper() - and "JAILBREAK" - in str(getattr(r, "evaluation_status", "")).upper() - ) + ] + vulnerable_count = sum(1 for o in outcomes if o is VULNERABLE) + mitigated_count = sum(1 for o in outcomes if o is MITIGATED) else: - success_count, fail_count, total_results = self._result_counts.get( - str(run.id), (0, 0, 0) - ) + ( + vulnerable_count, + mitigated_count, + total_results, + ) = self._result_counts.get(str(run.id), (0, 0, 0)) - # Format results as success/fail ratio with colors + # Format as vulnerable/mitigated ratio with matching colours if total_results > 0: results_display = ( - f"[green]{success_count}[/green]/[red]{fail_count}[/red]" + f"[{VULNERABLE.color}]{vulnerable_count}[/{VULNERABLE.color}]" + f"/[{MITIGATED.color}]{mitigated_count}[/{MITIGATED.color}]" ) else: results_display = "[dim]0/0[/dim]" @@ -139,7 +125,7 @@ def get_timestamp(run): # Store in mapping for later lookup self._run_id_map[run_id_str] = run - # Add row with columns: #, Status, Agent, Success/Fail, Created + # Columns: #, State, Agent, Attack, Vulnerable/Mitigated, Created # Use the full run ID string as the row key for stable selection table.add_row( str(idx), @@ -152,55 +138,53 @@ def get_timestamp(run): ) # Calculate overall statistics — use cached counts when results are not embedded - total_success = 0 - total_failed = 0 - total_pending = 0 + total_vulnerable = 0 + total_mitigated = 0 + total_unevaluated = 0 for run in self.results_data: if hasattr(run, "results") and run.results: for result in run.results: - eval_status = str(getattr(result, "evaluation_status", "")) - if hasattr(getattr(result, "evaluation_status", None), "value"): - eval_status = result.evaluation_status.value - if ( - "SUCCESSFUL" in eval_status.upper() - and "JAILBREAK" in eval_status.upper() - ): - total_success += 1 - elif ( - "FAILED" in eval_status.upper() - and "JAILBREAK" in eval_status.upper() - ): - total_failed += 1 + outcome = classify_evaluation_status( + getattr(result, "evaluation_status", None) + ) + if outcome is VULNERABLE: + total_vulnerable += 1 + elif outcome is MITIGATED: + total_mitigated += 1 else: - total_pending += 1 + total_unevaluated += 1 else: - s, f, t = self._result_counts.get(str(run.id), (0, 0, 0)) - total_success += s - total_failed += f - total_pending += max(0, t - s - f) - - total_results = total_success + total_failed + total_pending - success_rate = ( - (total_success / total_results * 100) if total_results > 0 else 0 + v, m, t = self._result_counts.get(str(run.id), (0, 0, 0)) + total_vulnerable += v + total_mitigated += m + total_unevaluated += max(0, t - v - m) + + total_results = total_vulnerable + total_mitigated + total_unevaluated + robustness = ( + (total_mitigated / total_results * 100) if total_results > 0 else 0 ) - # Show enhanced summary with visual success bar + # Show enhanced summary with a visual outcome bar header_widget = self.query_one("#run-header-static", Static) - # Create visual progress bar + # Create visual outcome bar bar_width = 30 - success_blocks = int( - (total_success / total_results * bar_width) if total_results > 0 else 0 + vulnerable_blocks = int( + (total_vulnerable / total_results * bar_width) + if total_results > 0 + else 0 ) - failed_blocks = int( - (total_failed / total_results * bar_width) if total_results > 0 else 0 + mitigated_blocks = int( + (total_mitigated / total_results * bar_width) + if total_results > 0 + else 0 ) - pending_blocks = bar_width - success_blocks - failed_blocks + unevaluated_blocks = bar_width - vulnerable_blocks - mitigated_blocks - progress_bar = ( - f"[green]{'█' * success_blocks}[/green]" - f"[red]{'█' * failed_blocks}[/red]" - f"[yellow]{'░' * pending_blocks}[/yellow]" + outcome_bar = ( + f"[{VULNERABLE.color}]{'█' * vulnerable_blocks}[/{VULNERABLE.color}]" + f"[{MITIGATED.color}]{'█' * mitigated_blocks}[/{MITIGATED.color}]" + f"[{NOT_EVALUATED.color}]{'░' * unevaluated_blocks}[/{NOT_EVALUATED.color}]" ) header_widget.update( @@ -208,11 +192,11 @@ def get_timestamp(run): f"[dim]{'─' * 40}[/dim]\n\n" f" [bold]Runs:[/bold] [bright_white]{len(self.results_data)}[/bright_white] " f"[bold]Total Results:[/bold] [bright_white]{total_results}[/bright_white]\n\n" - f" {progress_bar}\n" - f" [green]✅ {total_success}[/green] successful " - f"[red]❌ {total_failed}[/red] failed " - f"[yellow]âŗ {total_pending}[/yellow] pending\n\n" - f" [bold]Success Rate:[/bold] [{'green' if success_rate >= 50 else 'yellow' if success_rate >= 25 else 'red'}]{success_rate:.1f}%[/]\n\n" + f" {outcome_bar}\n" + f" [{VULNERABLE.color}]{VULNERABLE.icon} {total_vulnerable}[/{VULNERABLE.color}] {VULNERABLE.label.lower()} " + f"[{MITIGATED.color}]{MITIGATED.icon} {total_mitigated}[/{MITIGATED.color}] {MITIGATED.label.lower()} " + f"[{NOT_EVALUATED.color}]{NOT_EVALUATED.icon} {total_unevaluated} not evaluated[/{NOT_EVALUATED.color}]\n\n" + f" [bold]Robustness:[/bold] [{'green' if robustness >= 75 else 'yellow' if robustness >= 50 else 'red'}]{robustness:.1f}%[/]\n\n" f"[dim]💡 Click a row to view detailed results[/dim]" ) diff --git a/hackagent/cli/tui/widgets/actions.py b/hackagent/cli/tui/widgets/actions.py index 2b5b647c..36e2deb4 100644 --- a/hackagent/cli/tui/widgets/actions.py +++ b/hackagent/cli/tui/widgets/actions.py @@ -15,6 +15,8 @@ from textual.containers import Container from textual.widgets import Button, RichLog, Static +from hackagent.cli.tui.theme import MITIGATED, VULNERABLE + def _escape(value: Any) -> str: """Escape a value for safe Rich markup rendering. @@ -432,11 +434,7 @@ def _handle_event(self, event: Any) -> None: elif et == "goal_finalized": success = bool(payload.get("success")) actions_widget = self.query_one("#actions-display", RichLog) - icon = ( - "[bright_green]✓ JAILBREAK[/bright_green]" - if success - else "[red]✗ REFUSED[/red]" - ) + outcome_markup = VULNERABLE.render() if success else MITIGATED.render() elapsed = payload.get("elapsed_s") elapsed_s = ( f" [dim]({elapsed:.1f}s)[/dim]" @@ -444,7 +442,7 @@ def _handle_event(self, event: Any) -> None: else "" ) actions_widget.write( - f"[dim]── Goal #{payload.get('goal_index', '?') + 1 if isinstance(payload.get('goal_index'), int) else '?'} {icon}{elapsed_s} ──[/dim]" + f"[dim]── Goal #{payload.get('goal_index', '?') + 1 if isinstance(payload.get('goal_index'), int) else '?'} {outcome_markup}{elapsed_s} ──[/dim]" ) elif et == "trace_added": diff --git a/tests/integration/tui/attacks/test_multi_select_chain.py b/tests/integration/tui/attacks/test_multi_select_chain.py index 74264d01..762acd00 100644 --- a/tests/integration/tui/attacks/test_multi_select_chain.py +++ b/tests/integration/tui/attacks/test_multi_select_chain.py @@ -19,6 +19,7 @@ from textual.widgets import Checkbox, Input, Select, SelectionList, Static from hackagent.cli.config import CLIConfig +from hackagent.cli.tui.theme import css_variables from hackagent.cli.tui.views.attacks import AttacksTab, _default_campaign_attack_keys @@ -30,6 +31,22 @@ def cli_config(): return config +class AttacksHostApp(App): + """Mounts AttacksTab standalone, mirroring HackAgentTUI's brand palette + so AttacksTab's CSS (which references $brand-* variables) resolves the + same way it does under the real app.""" + + def get_css_variables(self) -> dict[str, str]: + return {**super().get_css_variables(), **css_variables()} + + def __init__(self, cli_config): + super().__init__() + self._cli_config = cli_config + + def compose(self): + yield AttacksTab(self._cli_config) + + def _fill_required_fields(tab: AttacksTab) -> None: tab.query_one("#agent-name", Input).value = "my-agent" tab.query_one("#endpoint-url", Input).value = "http://localhost:8000" @@ -46,11 +63,7 @@ def _select_only(tab: AttacksTab, keys) -> None: class TestStrategySelectionDefaults: @pytest.mark.asyncio async def test_defaults_to_jailbreak_campaign_in_order(self, cli_config): - class TestApp(App): - def compose(self): - yield AttacksTab(cli_config) - - app = TestApp() + app = AttacksHostApp(cli_config) async with app.run_test() as pilot: tab = app.query_one(AttacksTab) await pilot.pause() @@ -63,11 +76,7 @@ async def test_escalate_toggle_visible_by_default(self, cli_config): """The campaign default has 3 attacks selected, so the escalate toggle (only relevant for 2+ attacks) is visible out of the box.""" - class TestApp(App): - def compose(self): - yield AttacksTab(cli_config) - - app = TestApp() + app = AttacksHostApp(cli_config) async with app.run_test() as pilot: tab = app.query_one(AttacksTab) await pilot.pause() @@ -77,12 +86,19 @@ def compose(self): ) @pytest.mark.asyncio - async def test_escalate_toggle_hidden_with_single_selection(self, cli_config): - class TestApp(App): - def compose(self): - yield AttacksTab(cli_config) + async def test_escalate_toggle_defaults_to_enabled(self, cli_config): + """The initial checkbox state matches the library default and the + state ``_clear_form`` resets to.""" + + app = AttacksHostApp(cli_config) + async with app.run_test() as pilot: + tab = app.query_one(AttacksTab) + await pilot.pause() + assert tab.query_one("#escalate-only-mitigated", Checkbox).value is True - app = TestApp() + @pytest.mark.asyncio + async def test_escalate_toggle_hidden_with_single_selection(self, cli_config): + app = AttacksHostApp(cli_config) async with app.run_test() as pilot: tab = app.query_one(AttacksTab) await pilot.pause() @@ -96,11 +112,7 @@ def compose(self): @pytest.mark.asyncio async def test_clear_form_resets_to_default_campaign(self, cli_config): - class TestApp(App): - def compose(self): - yield AttacksTab(cli_config) - - app = TestApp() + app = AttacksHostApp(cli_config) async with app.run_test() as pilot: tab = app.query_one(AttacksTab) await pilot.pause() @@ -122,11 +134,7 @@ class TestConfiguringDropdownRestrictedToSelection: @pytest.mark.asyncio async def test_dropdown_lists_only_checked_strategies_by_default(self, cli_config): - class TestApp(App): - def compose(self): - yield AttacksTab(cli_config) - - app = TestApp() + app = AttacksHostApp(cli_config) async with app.run_test() as pilot: tab = app.query_one(AttacksTab) await pilot.pause() @@ -138,11 +146,7 @@ def compose(self): @pytest.mark.asyncio async def test_dropdown_shrinks_when_selection_shrinks(self, cli_config): - class TestApp(App): - def compose(self): - yield AttacksTab(cli_config) - - app = TestApp() + app = AttacksHostApp(cli_config) async with app.run_test() as pilot: tab = app.query_one(AttacksTab) await pilot.pause() @@ -160,11 +164,7 @@ def compose(self): async def test_focus_switches_away_when_focused_strategy_is_unchecked( self, cli_config ): - class TestApp(App): - def compose(self): - yield AttacksTab(cli_config) - - app = TestApp() + app = AttacksHostApp(cli_config) async with app.run_test() as pilot: tab = app.query_one(AttacksTab) await pilot.pause() @@ -198,11 +198,7 @@ async def test_selecting_in_configuring_then_unchecking_it_does_not_crash( through `on_select_changed`, triggering a spurious extra re-render. """ - class TestApp(App): - def compose(self): - yield AttacksTab(cli_config) - - app = TestApp() + app = AttacksHostApp(cli_config) async with app.run_test() as pilot: tab = app.query_one(AttacksTab) await pilot.pause() @@ -225,11 +221,7 @@ def compose(self): class TestFocusedStrategyValueCaching: @pytest.mark.asyncio async def test_switching_focus_away_and_back_preserves_values(self, cli_config): - class TestApp(App): - def compose(self): - yield AttacksTab(cli_config) - - app = TestApp() + app = AttacksHostApp(cli_config) async with app.run_test() as pilot: tab = app.query_one(AttacksTab) await pilot.pause() @@ -259,11 +251,7 @@ class TestExecuteAttackChainBuilding: async def test_dry_run_single_strategy_uses_singular_attack_config( self, cli_config ): - class TestApp(App): - def compose(self): - yield AttacksTab(cli_config) - - app = TestApp() + app = AttacksHostApp(cli_config) async with app.run_test() as pilot: tab = app.query_one(AttacksTab) await pilot.pause() @@ -279,11 +267,7 @@ def compose(self): @pytest.mark.asyncio async def test_dry_run_default_campaign_builds_chain_preview(self, cli_config): - class TestApp(App): - def compose(self): - yield AttacksTab(cli_config) - - app = TestApp() + app = AttacksHostApp(cli_config) async with app.run_test() as pilot: tab = app.query_one(AttacksTab) await pilot.pause() @@ -304,11 +288,7 @@ class TestExecuteAttackDispatch: @pytest.mark.asyncio async def test_single_strategy_calls_hack_not_hack_chain(self, cli_config): - class TestApp(App): - def compose(self): - yield AttacksTab(cli_config) - - app = TestApp() + app = AttacksHostApp(cli_config) async with app.run_test() as pilot: tab = app.query_one(AttacksTab) await pilot.pause() @@ -332,11 +312,7 @@ def compose(self): @pytest.mark.asyncio async def test_default_campaign_calls_hack_chain_not_hack(self, cli_config): - class TestApp(App): - def compose(self): - yield AttacksTab(cli_config) - - app = TestApp() + app = AttacksHostApp(cli_config) async with app.run_test() as pilot: tab = app.query_one(AttacksTab) await pilot.pause() diff --git a/tests/unit/cli/tui/__snapshots__/test_view_snapshots/test_view_renders[results-large].svg b/tests/unit/cli/tui/__snapshots__/test_view_snapshots/test_view_renders[results-large].svg index 527d6694..3c215630 100644 --- a/tests/unit/cli/tui/__snapshots__/test_view_snapshots/test_view_renders[results-large].svg +++ b/tests/unit/cli/tui/__snapshots__/test_view_snapshots/test_view_renders[results-large].svg @@ -207,12 +207,12 @@ - + đŸŽ¯ Attack Results│📋 Result Details ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔│No runs found. Execute an attack to see results here. Filter:▊▔▔▔▔▔▔▔▔▔▔▔▔▔▔▎Limit:▊▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▎│ - #  âšĄ  Agent  Attack  âœ…/❌  Created â”‚đŸ’Ą Tip: Press F5 or click đŸ”„ Refresh to retry + #  State  Agent  Attack  đŸ”“/🛡  Created â”‚đŸ’Ą Tip: Press F5 or click đŸ”„ Refresh to retry │ │ │ diff --git a/tests/unit/cli/tui/snapshot_apps/attacks_tab_app.py b/tests/unit/cli/tui/snapshot_apps/attacks_tab_app.py index e5019e8d..7b2bfed5 100644 --- a/tests/unit/cli/tui/snapshot_apps/attacks_tab_app.py +++ b/tests/unit/cli/tui/snapshot_apps/attacks_tab_app.py @@ -13,6 +13,7 @@ from textual.app import App, ComposeResult from hackagent.cli.config import CLIConfig +from hackagent.cli.tui.theme import css_variables from hackagent.cli.tui.views.attacks import AttacksTab @@ -26,6 +27,10 @@ def _stub_config() -> CLIConfig: class AttacksTabApp(App): """Minimal host app rendering only the Attacks tab.""" + def get_css_variables(self) -> dict[str, str]: + """Mirror ``HackAgentTUI``'s brand palette so ``$brand-*`` resolve.""" + return {**super().get_css_variables(), **css_variables()} + def compose(self) -> ComposeResult: yield AttacksTab(_stub_config()) diff --git a/tests/unit/cli/tui/snapshot_apps/results_tab_app.py b/tests/unit/cli/tui/snapshot_apps/results_tab_app.py index 53a0e469..e62b9167 100644 --- a/tests/unit/cli/tui/snapshot_apps/results_tab_app.py +++ b/tests/unit/cli/tui/snapshot_apps/results_tab_app.py @@ -12,6 +12,7 @@ from textual.app import App, ComposeResult from hackagent.cli.config import CLIConfig +from hackagent.cli.tui.theme import css_variables from hackagent.cli.tui.views.results import ResultsTab @@ -25,6 +26,10 @@ def _stub_config() -> CLIConfig: class ResultsTabApp(App): """Minimal host app rendering only the Results tab.""" + def get_css_variables(self) -> dict[str, str]: + """Mirror ``HackAgentTUI``'s brand palette so ``$brand-*`` resolve.""" + return {**super().get_css_variables(), **css_variables()} + def compose(self) -> ComposeResult: yield ResultsTab(_stub_config()) diff --git a/tests/unit/cli/tui/test_theme.py b/tests/unit/cli/tui/test_theme.py new file mode 100644 index 00000000..54f59f36 --- /dev/null +++ b/tests/unit/cli/tui/test_theme.py @@ -0,0 +1,98 @@ +# Copyright 2026 - AI4I. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for the shared TUI theme and terminology module.""" + +from hackagent.cli.tui import theme + + +class TestOutcomeVocabulary: + """Evaluation outcomes use a single, defender-polarity vocabulary.""" + + def test_successful_jailbreak_is_vulnerable(self) -> None: + assert theme.classify_evaluation_status("SUCCESSFUL_JAILBREAK") is ( + theme.VULNERABLE + ) + + def test_failed_jailbreak_is_mitigated(self) -> None: + assert theme.classify_evaluation_status("FAILED_JAILBREAK") is theme.MITIGATED + + def test_error_is_errored(self) -> None: + assert theme.classify_evaluation_status("ERROR") is theme.ERRORED + + def test_unknown_and_none_are_not_evaluated(self) -> None: + assert theme.classify_evaluation_status(None) is theme.NOT_EVALUATED + assert theme.classify_evaluation_status("NOT_EVALUATED") is ( + theme.NOT_EVALUATED + ) + assert theme.classify_evaluation_status("something else") is ( + theme.NOT_EVALUATED + ) + + def test_enum_like_status_is_unwrapped(self) -> None: + class _Status: + value = "SUCCESSFUL_JAILBREAK" + + assert theme.classify_evaluation_status(_Status()) is theme.VULNERABLE + + def test_vulnerable_is_red_and_mitigated_is_green(self) -> None: + """A vulnerability must never be painted green, and vice versa.""" + assert theme.VULNERABLE.color == "red" + assert theme.MITIGATED.color == "green" + + def test_render_wraps_label_in_its_own_colour(self) -> None: + rendered = theme.VULNERABLE.render() + assert rendered.startswith("[red]") + assert rendered.endswith("[/red]") + assert "Vulnerable" in rendered + + def test_css_classes_are_unique(self) -> None: + classes = [outcome.css_class for outcome in theme.OUTCOMES] + assert len(set(classes)) == len(classes) + assert all(css.startswith("-") for css in classes) + + +class TestRunStateVocabulary: + """Run lifecycle states map onto a single set of icons and colours.""" + + def test_known_states(self) -> None: + assert theme.classify_run_status("COMPLETED") is theme.RUN_COMPLETED + assert theme.classify_run_status("running") is theme.RUN_RUNNING + assert theme.classify_run_status("FAILED") is theme.RUN_FAILED + assert theme.classify_run_status("PENDING") is theme.RUN_PENDING + + def test_unknown_and_none(self) -> None: + assert theme.classify_run_status(None) is theme.RUN_UNKNOWN + assert theme.classify_run_status("whatever") is theme.RUN_UNKNOWN + + def test_render_icon_is_colour_wrapped(self) -> None: + assert theme.RUN_RUNNING.render_icon() == ( + f"[{theme.RUN_RUNNING.color}]{theme.RUN_RUNNING.icon}" + f"[/{theme.RUN_RUNNING.color}]" + ) + + +class TestCssVariables: + """The brand palette is exposed once, as Textual CSS variables.""" + + def test_variables_cover_the_palette(self) -> None: + variables = theme.css_variables() + assert variables["brand"] == theme.BRAND_RED + assert variables["brand-dark"] == theme.BRAND_RED_DARK + assert variables["brand-darker"] == theme.BRAND_RED_DARKER + assert variables["brand-hover"] == theme.BRAND_RED_HOVER + assert variables["brand-text"] == theme.TEXT_ON_BRAND + assert variables["brand-text-muted"] == theme.TEXT_MUTED + + def test_app_exposes_brand_variables_and_uses_no_raw_hex(self) -> None: + import re + + from hackagent.cli.config import CLIConfig + from hackagent.cli.tui.app import HackAgentTUI + + app = HackAgentTUI(CLIConfig()) + variables = app.get_css_variables() + assert variables["brand"] == theme.BRAND_RED + assert variables["brand-dark"] == theme.BRAND_RED_DARK + # Brand colours are referenced through the palette, not re-declared. + assert not re.search(r"#[0-9a-fA-F]{6}", HackAgentTUI.CSS) diff --git a/tests/unit/cli/tui/test_tui_results_and_logs.py b/tests/unit/cli/tui/test_tui_results_and_logs.py index d4e1a334..2a889e49 100644 --- a/tests/unit/cli/tui/test_tui_results_and_logs.py +++ b/tests/unit/cli/tui/test_tui_results_and_logs.py @@ -1034,8 +1034,8 @@ def test_format_trace_with_brackets(self, console: Console) -> None: class TestGetResultStatusInfo: """Tests for _get_result_status_info helper function.""" - def test_successful_jailbreak_status(self) -> None: - """Test SUCCESSFUL_JAILBREAK returns green and check icon.""" + def test_successful_jailbreak_is_vulnerable(self) -> None: + """A jailbreak that got through is reported as a red vulnerability.""" from hackagent.cli.tui.views.results import _get_result_status_info from unittest.mock import MagicMock @@ -1043,14 +1043,14 @@ def test_successful_jailbreak_status(self) -> None: result.evaluation_status = MagicMock() result.evaluation_status.value = "SUCCESSFUL_JAILBREAK" - status, color, icon = _get_result_status_info(result) + label, color, icon = _get_result_status_info(result) - assert "SUCCESSFUL" in status.upper() - assert color == "green" - assert icon == "✅" + assert label == "Vulnerable" + assert color == "red" + assert icon == "\U0001f513" - def test_failed_jailbreak_status(self) -> None: - """Test FAILED_JAILBREAK returns red and cross icon.""" + def test_failed_jailbreak_is_mitigated(self) -> None: + """A refused jailbreak is reported as a green mitigation.""" from hackagent.cli.tui.views.results import _get_result_status_info from unittest.mock import MagicMock @@ -1058,14 +1058,14 @@ def test_failed_jailbreak_status(self) -> None: result.evaluation_status = MagicMock() result.evaluation_status.value = "FAILED_JAILBREAK" - status, color, icon = _get_result_status_info(result) + label, color, icon = _get_result_status_info(result) - assert "FAILED" in status.upper() - assert color == "red" - assert icon == "❌" + assert label == "Mitigated" + assert color == "green" + assert icon == "\U0001f6e1" def test_error_status(self) -> None: - """Test ERROR status returns red.""" + """Test ERROR status is reported as a yellow error.""" from hackagent.cli.tui.views.results import _get_result_status_info from unittest.mock import MagicMock @@ -1073,23 +1073,22 @@ def test_error_status(self) -> None: result.evaluation_status = MagicMock() result.evaluation_status.value = "ERROR" - status, color, icon = _get_result_status_info(result) + label, color, icon = _get_result_status_info(result) - assert color == "red" - assert icon == "âš ī¸" + assert label == "Error" + assert color == "yellow" def test_no_evaluation_status(self) -> None: - """Test result without evaluation_status returns N/A.""" + """Test result without evaluation_status is reported as unevaluated.""" from hackagent.cli.tui.views.results import _get_result_status_info from unittest.mock import MagicMock result = MagicMock(spec=[]) # No evaluation_status attribute - status, color, icon = _get_result_status_info(result) + label, color, icon = _get_result_status_info(result) - assert status == "N/A" - assert color == "yellow" - assert icon == "â„šī¸" + assert label == "Not Evaluated" + assert color == "dim" class TestFormatResultSummary: @@ -1115,7 +1114,7 @@ def test_basic_summary(self, console: Console) -> None: summary = _format_result_summary(result, 1) assert "#1" in summary # Compact format uses #1 instead of Result #1 - assert "SUCCESSFUL_JAILBREAK" in summary + assert "Vulnerable" in summary console.print(summary) def test_summary_without_optional_fields(self, console: Console) -> None: @@ -1130,7 +1129,7 @@ def test_summary_without_optional_fields(self, console: Console) -> None: summary = _format_result_summary(result, 3) assert "#3" in summary # Compact format now uses #3 instead of Result #3 - assert "NOT_EVALUATED" in summary + assert "Not Evaluated" in summary console.print(summary) @@ -1162,7 +1161,7 @@ def test_full_details_with_all_fields(self, console: Console) -> None: details = _format_result_full_details(result, 1) - assert "SUCCESSFUL_JAILBREAK" in details + assert "Vulnerable" in details assert "Attack succeeded" in details console.print(details) @@ -1178,7 +1177,7 @@ def test_full_details_minimal(self, console: Console) -> None: details = _format_result_full_details(result, 5) - assert "NOT_EVALUATED" in details + assert "Not Evaluated" in details console.print(details)