Spaces:
Running
Running
Commit ·
f20603d
1
Parent(s): 0e5a0a6
Revamp Gradio app with Gradio 6, custom cybersecurity theme, and rich visualizations
Browse files- Add SentinelTheme: dark cybersecurity aesthetic with green accents, IBM Plex Mono
- Add rich replay HTML with phase headers, attack banners, recovery badges
- Add score progression LinePlot and attack timeline BarPlot (Gradio 6 native)
- Add comparison verdict stats with large stat cards
- Add full environment inspector with DataFrames in Accordion sections
- Fix Gradio 6 compat: theme/css in launch(), not Blocks()
- Fix HTML escaping on user data fields (XSS prevention)
- Add HuggingFace Spaces YAML frontmatter to README.md
- Update requirements.txt to gradio>=6.0.0
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- README.md +11 -0
- app.py +169 -114
- chart_helpers.py +166 -0
- inspector.py +162 -0
- replay_html.py +518 -0
- requirements.txt +1 -1
- sentinel_theme.py +482 -0
README.md
CHANGED
|
@@ -1,3 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
# SentinelOps Arena
|
| 2 |
|
| 3 |
Multi-agent self-play RL environment for enterprise security training, built on [OpenEnv](https://github.com/meta-pytorch/OpenEnv) for the [OpenEnv Hackathon SF](https://cerebralvalley.ai/e/openenv-hackathon-sf) (March 7-8, 2026).
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: SentinelOps Arena
|
| 3 |
+
emoji: "\U0001F6E1\uFE0F"
|
| 4 |
+
colorFrom: green
|
| 5 |
+
colorTo: red
|
| 6 |
+
sdk: gradio
|
| 7 |
+
sdk_version: 6.9.0
|
| 8 |
+
app_file: app.py
|
| 9 |
+
pinned: false
|
| 10 |
+
---
|
| 11 |
+
|
| 12 |
# SentinelOps Arena
|
| 13 |
|
| 14 |
Multi-agent self-play RL environment for enterprise security training, built on [OpenEnv](https://github.com/meta-pytorch/OpenEnv) for the [OpenEnv Hackathon SF](https://cerebralvalley.ai/e/openenv-hackathon-sf) (March 7-8, 2026).
|
app.py
CHANGED
|
@@ -3,79 +3,50 @@
|
|
| 3 |
Multi-agent self-play RL environment for enterprise security training.
|
| 4 |
Three AI agents (Attacker, Worker, Oversight) interact with simulated
|
| 5 |
enterprise systems (CRM, Billing, Ticketing).
|
|
|
|
|
|
|
| 6 |
"""
|
| 7 |
|
| 8 |
import json
|
| 9 |
|
| 10 |
import gradio as gr
|
|
|
|
| 11 |
|
| 12 |
from sentinelops_arena.demo import run_comparison, run_episode
|
| 13 |
from sentinelops_arena.environment import SentinelOpsArena
|
| 14 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
-
def format_replay_html(log, scores):
|
| 17 |
-
"""Format replay log as styled HTML."""
|
| 18 |
-
colors = {
|
| 19 |
-
"attacker": "#ff4444",
|
| 20 |
-
"worker": "#4488ff",
|
| 21 |
-
"oversight": "#44bb44",
|
| 22 |
-
}
|
| 23 |
-
|
| 24 |
-
html = "<div style='font-family: monospace; font-size: 13px;'>"
|
| 25 |
-
html += "<h3>Episode Replay</h3>"
|
| 26 |
-
|
| 27 |
-
current_tick = -1
|
| 28 |
-
for entry in log:
|
| 29 |
-
if entry["tick"] != current_tick:
|
| 30 |
-
current_tick = entry["tick"]
|
| 31 |
-
html += f"<hr><b>--- Tick {current_tick} ---</b><br>"
|
| 32 |
-
|
| 33 |
-
agent = entry["agent"]
|
| 34 |
-
color = colors.get(agent, "#888")
|
| 35 |
-
reward = entry["reward"]
|
| 36 |
-
reward_str = f" (reward: {reward:.1f})" if reward else ""
|
| 37 |
-
flag_str = " [FLAGGED]" if entry.get("flag") else ""
|
| 38 |
-
|
| 39 |
-
html += (
|
| 40 |
-
f"<span style='color: {color}; font-weight: bold;'>"
|
| 41 |
-
f"[{entry['agent_label']}]</span> "
|
| 42 |
-
)
|
| 43 |
-
html += f"{entry['action_type']}{reward_str}{flag_str}"
|
| 44 |
-
|
| 45 |
-
details = entry.get("details", "")
|
| 46 |
-
if details:
|
| 47 |
-
html += (
|
| 48 |
-
f" -- <span style='color: #888;'>{str(details)[:120]}</span>"
|
| 49 |
-
)
|
| 50 |
-
explanation = entry.get("explanation", "")
|
| 51 |
-
if explanation:
|
| 52 |
-
html += (
|
| 53 |
-
f"<br><span style='color: #666; margin-left: 20px;'>"
|
| 54 |
-
f" {explanation}</span>"
|
| 55 |
-
)
|
| 56 |
-
html += "<br>"
|
| 57 |
-
|
| 58 |
-
html += "<hr><h3>Final Scores</h3>"
|
| 59 |
-
for agent, score in scores.items():
|
| 60 |
-
color = colors.get(agent, "#888")
|
| 61 |
-
bar_width = max(0, min(score * 10, 300))
|
| 62 |
-
html += (
|
| 63 |
-
f"<span style='color: {color}; font-weight: bold;'>"
|
| 64 |
-
f"{agent}</span>: {score:.1f} "
|
| 65 |
-
f"<span style='display:inline-block; background:{color}; "
|
| 66 |
-
f"height:12px; width:{bar_width}px; opacity:0.5;'></span><br>"
|
| 67 |
-
)
|
| 68 |
|
| 69 |
-
|
| 70 |
-
|
|
|
|
| 71 |
|
| 72 |
|
| 73 |
def run_single_episode(seed, trained):
|
| 74 |
-
"""Run a single episode and return formatted replay."""
|
| 75 |
log, scores = run_episode(trained=bool(trained), seed=int(seed))
|
| 76 |
html = format_replay_html(log, scores)
|
| 77 |
scores_text = json.dumps(scores, indent=2)
|
| 78 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
|
| 80 |
|
| 81 |
def run_before_after(seed):
|
|
@@ -89,7 +60,18 @@ def run_before_after(seed):
|
|
| 89 |
result["trained"]["log"], result["trained"]["scores"]
|
| 90 |
)
|
| 91 |
|
| 92 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
"untrained_scores": result["untrained"]["scores"],
|
| 94 |
"trained_scores": result["trained"]["scores"],
|
| 95 |
"improvement": {
|
|
@@ -102,34 +84,29 @@ def run_before_after(seed):
|
|
| 102 |
},
|
| 103 |
}
|
| 104 |
|
| 105 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
|
| 107 |
|
| 108 |
def inspect_state(seed):
|
| 109 |
-
"""Show environment state after reset."""
|
| 110 |
env = SentinelOpsArena()
|
| 111 |
-
|
| 112 |
-
state = env.state
|
| 113 |
-
|
| 114 |
-
state_info = {
|
| 115 |
-
"episode_id": state.episode_id,
|
| 116 |
-
"tick": state.tick,
|
| 117 |
-
"max_ticks": env.MAX_TICKS,
|
| 118 |
-
"num_customers": env.NUM_CUSTOMERS,
|
| 119 |
-
"num_invoices": env.NUM_INVOICES,
|
| 120 |
-
"num_tickets": env.NUM_TICKETS,
|
| 121 |
-
"num_tasks": env.NUM_TASKS,
|
| 122 |
-
"scores": state.scores,
|
| 123 |
-
}
|
| 124 |
|
| 125 |
-
|
| 126 |
-
|
|
|
|
|
|
|
|
|
|
| 127 |
|
| 128 |
-
return
|
| 129 |
-
json.dumps(state_info, indent=2),
|
| 130 |
-
json.dumps(sample_customer, indent=2),
|
| 131 |
-
json.dumps(sample_task, indent=2, default=str),
|
| 132 |
-
)
|
| 133 |
|
| 134 |
|
| 135 |
# -------------------------------------------------------------------
|
|
@@ -137,26 +114,14 @@ def inspect_state(seed):
|
|
| 137 |
# -------------------------------------------------------------------
|
| 138 |
|
| 139 |
with gr.Blocks(title="SentinelOps Arena") as demo:
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
## Multi-Agent Self-Play RL Environment for Enterprise Security
|
| 144 |
-
|
| 145 |
-
Three AI agents compete in a simulated enterprise environment:
|
| 146 |
-
- **RED TEAM (Attacker)**: Launches schema drift, policy drift,
|
| 147 |
-
social engineering, and rate limiting attacks
|
| 148 |
-
- **BLUE TEAM (Worker)**: Handles customer requests across CRM,
|
| 149 |
-
Billing, and Ticketing systems
|
| 150 |
-
- **AUDITOR (Oversight)**: Monitors worker actions and flags
|
| 151 |
-
policy violations
|
| 152 |
-
|
| 153 |
-
Built on [OpenEnv](https://github.com/meta-pytorch/OpenEnv)
|
| 154 |
-
for the OpenEnv Hackathon SF 2026.
|
| 155 |
-
"""
|
| 156 |
-
)
|
| 157 |
|
| 158 |
with gr.Tabs():
|
|
|
|
| 159 |
# Tab 1: Run Episode
|
|
|
|
| 160 |
with gr.TabItem("Run Episode"):
|
| 161 |
with gr.Row():
|
| 162 |
seed_input = gr.Number(
|
|
@@ -167,19 +132,43 @@ with gr.Blocks(title="SentinelOps Arena") as demo:
|
|
| 167 |
)
|
| 168 |
run_btn = gr.Button("Run Episode", variant="primary")
|
| 169 |
|
| 170 |
-
|
| 171 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 172 |
|
| 173 |
run_btn.click(
|
| 174 |
run_single_episode,
|
| 175 |
inputs=[seed_input, trained_toggle],
|
| 176 |
-
outputs=[replay_output, scores_output],
|
| 177 |
)
|
| 178 |
|
|
|
|
| 179 |
# Tab 2: Before/After Comparison
|
|
|
|
| 180 |
with gr.TabItem("Untrained vs Trained"):
|
| 181 |
gr.Markdown(
|
| 182 |
-
"Compare how an untrained worker vs a trained worker "
|
| 183 |
"handles the same attack sequence."
|
| 184 |
)
|
| 185 |
with gr.Row():
|
|
@@ -188,21 +177,58 @@ with gr.Blocks(title="SentinelOps Arena") as demo:
|
|
| 188 |
)
|
| 189 |
comp_btn = gr.Button("Run Comparison", variant="primary")
|
| 190 |
|
|
|
|
|
|
|
|
|
|
| 191 |
with gr.Row():
|
| 192 |
untrained_output = gr.HTML(label="Untrained Worker")
|
| 193 |
trained_output = gr.HTML(label="Trained Worker")
|
| 194 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 195 |
comparison_output = gr.Code(
|
| 196 |
-
label="Score
|
| 197 |
)
|
| 198 |
|
| 199 |
comp_btn.click(
|
| 200 |
run_before_after,
|
| 201 |
inputs=[comp_seed],
|
| 202 |
-
outputs=[
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 203 |
)
|
| 204 |
|
|
|
|
| 205 |
# Tab 3: Environment Inspector
|
|
|
|
| 206 |
with gr.TabItem("Environment Inspector"):
|
| 207 |
with gr.Row():
|
| 208 |
inspect_seed = gr.Number(
|
|
@@ -210,23 +236,47 @@ with gr.Blocks(title="SentinelOps Arena") as demo:
|
|
| 210 |
)
|
| 211 |
inspect_btn = gr.Button("Inspect", variant="primary")
|
| 212 |
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
)
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 222 |
|
| 223 |
inspect_btn.click(
|
| 224 |
inspect_state,
|
| 225 |
inputs=[inspect_seed],
|
| 226 |
-
outputs=[
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 227 |
)
|
| 228 |
|
|
|
|
| 229 |
# Tab 4: About
|
|
|
|
| 230 |
with gr.TabItem("About"):
|
| 231 |
gr.Markdown(
|
| 232 |
"""
|
|
@@ -234,7 +284,7 @@ with gr.Blocks(title="SentinelOps Arena") as demo:
|
|
| 234 |
|
| 235 |
**3 Agents, 3 Systems, 30 Ticks per Episode**
|
| 236 |
|
| 237 |
-
Each tick: Attacker acts
|
| 238 |
|
| 239 |
### Attack Types
|
| 240 |
1. **Schema Drift** -- Renames fields across all records.
|
|
@@ -264,4 +314,9 @@ with gr.Blocks(title="SentinelOps Arena") as demo:
|
|
| 264 |
)
|
| 265 |
|
| 266 |
if __name__ == "__main__":
|
| 267 |
-
demo.launch(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
Multi-agent self-play RL environment for enterprise security training.
|
| 4 |
Three AI agents (Attacker, Worker, Oversight) interact with simulated
|
| 5 |
enterprise systems (CRM, Billing, Ticketing).
|
| 6 |
+
|
| 7 |
+
Built with Gradio 6 -- custom cybersecurity theme, native plots, rich HTML.
|
| 8 |
"""
|
| 9 |
|
| 10 |
import json
|
| 11 |
|
| 12 |
import gradio as gr
|
| 13 |
+
import pandas as pd
|
| 14 |
|
| 15 |
from sentinelops_arena.demo import run_comparison, run_episode
|
| 16 |
from sentinelops_arena.environment import SentinelOpsArena
|
| 17 |
|
| 18 |
+
from sentinel_theme import SentinelTheme, CUSTOM_CSS, HEADER_HTML
|
| 19 |
+
from replay_html import format_replay_html
|
| 20 |
+
from chart_helpers import (
|
| 21 |
+
build_score_progression_df,
|
| 22 |
+
build_attack_timeline_df,
|
| 23 |
+
build_comparison_df,
|
| 24 |
+
build_verdict_html,
|
| 25 |
+
)
|
| 26 |
+
from inspector import (
|
| 27 |
+
get_all_customers,
|
| 28 |
+
get_all_invoices,
|
| 29 |
+
get_all_tickets,
|
| 30 |
+
get_task_queue,
|
| 31 |
+
get_env_config_html,
|
| 32 |
+
)
|
| 33 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
|
| 35 |
+
# -------------------------------------------------------------------
|
| 36 |
+
# Handler functions
|
| 37 |
+
# -------------------------------------------------------------------
|
| 38 |
|
| 39 |
|
| 40 |
def run_single_episode(seed, trained):
|
| 41 |
+
"""Run a single episode and return formatted replay + charts."""
|
| 42 |
log, scores = run_episode(trained=bool(trained), seed=int(seed))
|
| 43 |
html = format_replay_html(log, scores)
|
| 44 |
scores_text = json.dumps(scores, indent=2)
|
| 45 |
+
|
| 46 |
+
score_df = build_score_progression_df(log)
|
| 47 |
+
attack_df = build_attack_timeline_df(log)
|
| 48 |
+
|
| 49 |
+
return html, scores_text, score_df, attack_df
|
| 50 |
|
| 51 |
|
| 52 |
def run_before_after(seed):
|
|
|
|
| 60 |
result["trained"]["log"], result["trained"]["scores"]
|
| 61 |
)
|
| 62 |
|
| 63 |
+
comparison_df = build_comparison_df(
|
| 64 |
+
result["untrained"]["scores"], result["trained"]["scores"]
|
| 65 |
+
)
|
| 66 |
+
verdict_html = build_verdict_html(
|
| 67 |
+
result["untrained"]["log"], result["trained"]["log"]
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
# Score progression for both
|
| 71 |
+
untrained_score_df = build_score_progression_df(result["untrained"]["log"])
|
| 72 |
+
trained_score_df = build_score_progression_df(result["trained"]["log"])
|
| 73 |
+
|
| 74 |
+
comparison_json = {
|
| 75 |
"untrained_scores": result["untrained"]["scores"],
|
| 76 |
"trained_scores": result["trained"]["scores"],
|
| 77 |
"improvement": {
|
|
|
|
| 84 |
},
|
| 85 |
}
|
| 86 |
|
| 87 |
+
return (
|
| 88 |
+
untrained_html,
|
| 89 |
+
trained_html,
|
| 90 |
+
verdict_html,
|
| 91 |
+
comparison_df,
|
| 92 |
+
untrained_score_df,
|
| 93 |
+
trained_score_df,
|
| 94 |
+
json.dumps(comparison_json, indent=2),
|
| 95 |
+
)
|
| 96 |
|
| 97 |
|
| 98 |
def inspect_state(seed):
|
| 99 |
+
"""Show full environment state after reset."""
|
| 100 |
env = SentinelOpsArena()
|
| 101 |
+
env.reset(seed=int(seed))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
|
| 103 |
+
config_html = get_env_config_html(env)
|
| 104 |
+
customers_df = get_all_customers(env)
|
| 105 |
+
invoices_df = get_all_invoices(env)
|
| 106 |
+
tickets_df = get_all_tickets(env)
|
| 107 |
+
tasks_df = get_task_queue(env)
|
| 108 |
|
| 109 |
+
return config_html, customers_df, invoices_df, tickets_df, tasks_df
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
|
| 111 |
|
| 112 |
# -------------------------------------------------------------------
|
|
|
|
| 114 |
# -------------------------------------------------------------------
|
| 115 |
|
| 116 |
with gr.Blocks(title="SentinelOps Arena") as demo:
|
| 117 |
+
|
| 118 |
+
# Header banner
|
| 119 |
+
gr.HTML(HEADER_HTML)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
|
| 121 |
with gr.Tabs():
|
| 122 |
+
# ============================================================
|
| 123 |
# Tab 1: Run Episode
|
| 124 |
+
# ============================================================
|
| 125 |
with gr.TabItem("Run Episode"):
|
| 126 |
with gr.Row():
|
| 127 |
seed_input = gr.Number(
|
|
|
|
| 132 |
)
|
| 133 |
run_btn = gr.Button("Run Episode", variant="primary")
|
| 134 |
|
| 135 |
+
with gr.Row():
|
| 136 |
+
with gr.Column(scale=2):
|
| 137 |
+
replay_output = gr.HTML(label="Episode Replay")
|
| 138 |
+
with gr.Column(scale=1):
|
| 139 |
+
scores_output = gr.Code(
|
| 140 |
+
label="Final Scores", language="json"
|
| 141 |
+
)
|
| 142 |
+
|
| 143 |
+
with gr.Accordion("Score Progression & Attack Timeline", open=True):
|
| 144 |
+
with gr.Row():
|
| 145 |
+
score_plot = gr.LinePlot(
|
| 146 |
+
x="tick",
|
| 147 |
+
y="score",
|
| 148 |
+
color="agent",
|
| 149 |
+
label="Cumulative Score Progression",
|
| 150 |
+
height=300,
|
| 151 |
+
)
|
| 152 |
+
attack_plot = gr.BarPlot(
|
| 153 |
+
x="attack_type",
|
| 154 |
+
y="count",
|
| 155 |
+
color="attack_type",
|
| 156 |
+
label="Attack Timeline",
|
| 157 |
+
height=300,
|
| 158 |
+
)
|
| 159 |
|
| 160 |
run_btn.click(
|
| 161 |
run_single_episode,
|
| 162 |
inputs=[seed_input, trained_toggle],
|
| 163 |
+
outputs=[replay_output, scores_output, score_plot, attack_plot],
|
| 164 |
)
|
| 165 |
|
| 166 |
+
# ============================================================
|
| 167 |
# Tab 2: Before/After Comparison
|
| 168 |
+
# ============================================================
|
| 169 |
with gr.TabItem("Untrained vs Trained"):
|
| 170 |
gr.Markdown(
|
| 171 |
+
"Compare how an **untrained** worker vs a **trained** worker "
|
| 172 |
"handles the same attack sequence."
|
| 173 |
)
|
| 174 |
with gr.Row():
|
|
|
|
| 177 |
)
|
| 178 |
comp_btn = gr.Button("Run Comparison", variant="primary")
|
| 179 |
|
| 180 |
+
# Verdict stats
|
| 181 |
+
verdict_output = gr.HTML(label="Training Impact")
|
| 182 |
+
|
| 183 |
with gr.Row():
|
| 184 |
untrained_output = gr.HTML(label="Untrained Worker")
|
| 185 |
trained_output = gr.HTML(label="Trained Worker")
|
| 186 |
|
| 187 |
+
with gr.Accordion("Score Comparison Charts", open=True):
|
| 188 |
+
comparison_bar = gr.BarPlot(
|
| 189 |
+
x="agent",
|
| 190 |
+
y="score",
|
| 191 |
+
color="type",
|
| 192 |
+
label="Score Comparison: Untrained vs Trained",
|
| 193 |
+
height=300,
|
| 194 |
+
)
|
| 195 |
+
with gr.Row():
|
| 196 |
+
untrained_score_plot = gr.LinePlot(
|
| 197 |
+
x="tick",
|
| 198 |
+
y="score",
|
| 199 |
+
color="agent",
|
| 200 |
+
label="Untrained Score Progression",
|
| 201 |
+
height=250,
|
| 202 |
+
)
|
| 203 |
+
trained_score_plot = gr.LinePlot(
|
| 204 |
+
x="tick",
|
| 205 |
+
y="score",
|
| 206 |
+
color="agent",
|
| 207 |
+
label="Trained Score Progression",
|
| 208 |
+
height=250,
|
| 209 |
+
)
|
| 210 |
+
|
| 211 |
comparison_output = gr.Code(
|
| 212 |
+
label="Score Details", language="json"
|
| 213 |
)
|
| 214 |
|
| 215 |
comp_btn.click(
|
| 216 |
run_before_after,
|
| 217 |
inputs=[comp_seed],
|
| 218 |
+
outputs=[
|
| 219 |
+
untrained_output,
|
| 220 |
+
trained_output,
|
| 221 |
+
verdict_output,
|
| 222 |
+
comparison_bar,
|
| 223 |
+
untrained_score_plot,
|
| 224 |
+
trained_score_plot,
|
| 225 |
+
comparison_output,
|
| 226 |
+
],
|
| 227 |
)
|
| 228 |
|
| 229 |
+
# ============================================================
|
| 230 |
# Tab 3: Environment Inspector
|
| 231 |
+
# ============================================================
|
| 232 |
with gr.TabItem("Environment Inspector"):
|
| 233 |
with gr.Row():
|
| 234 |
inspect_seed = gr.Number(
|
|
|
|
| 236 |
)
|
| 237 |
inspect_btn = gr.Button("Inspect", variant="primary")
|
| 238 |
|
| 239 |
+
config_output = gr.HTML(label="Environment Configuration")
|
| 240 |
+
|
| 241 |
+
with gr.Accordion("Customers (CRM)", open=False):
|
| 242 |
+
customers_table = gr.Dataframe(
|
| 243 |
+
label="All Customers",
|
| 244 |
+
headers=["customer_id", "name", "tier", "region", "lifetime_value"],
|
| 245 |
+
)
|
| 246 |
+
|
| 247 |
+
with gr.Accordion("Invoices (Billing)", open=False):
|
| 248 |
+
invoices_table = gr.Dataframe(
|
| 249 |
+
label="All Invoices",
|
| 250 |
+
headers=["invoice_id", "customer_id", "amount", "status"],
|
| 251 |
+
)
|
| 252 |
+
|
| 253 |
+
with gr.Accordion("Tickets (Support)", open=False):
|
| 254 |
+
tickets_table = gr.Dataframe(
|
| 255 |
+
label="All Tickets",
|
| 256 |
+
headers=["ticket_id", "customer_id", "subject", "priority", "status", "sla_deadline_tick"],
|
| 257 |
+
)
|
| 258 |
+
|
| 259 |
+
with gr.Accordion("Task Queue", open=False):
|
| 260 |
+
tasks_table = gr.Dataframe(
|
| 261 |
+
label="Task Queue",
|
| 262 |
+
headers=["task_id", "customer_id", "task_type", "message", "arrival_tick"],
|
| 263 |
+
)
|
| 264 |
|
| 265 |
inspect_btn.click(
|
| 266 |
inspect_state,
|
| 267 |
inputs=[inspect_seed],
|
| 268 |
+
outputs=[
|
| 269 |
+
config_output,
|
| 270 |
+
customers_table,
|
| 271 |
+
invoices_table,
|
| 272 |
+
tickets_table,
|
| 273 |
+
tasks_table,
|
| 274 |
+
],
|
| 275 |
)
|
| 276 |
|
| 277 |
+
# ============================================================
|
| 278 |
# Tab 4: About
|
| 279 |
+
# ============================================================
|
| 280 |
with gr.TabItem("About"):
|
| 281 |
gr.Markdown(
|
| 282 |
"""
|
|
|
|
| 284 |
|
| 285 |
**3 Agents, 3 Systems, 30 Ticks per Episode**
|
| 286 |
|
| 287 |
+
Each tick: Attacker acts → Worker acts → Oversight acts
|
| 288 |
|
| 289 |
### Attack Types
|
| 290 |
1. **Schema Drift** -- Renames fields across all records.
|
|
|
|
| 314 |
)
|
| 315 |
|
| 316 |
if __name__ == "__main__":
|
| 317 |
+
demo.launch(
|
| 318 |
+
server_name="0.0.0.0",
|
| 319 |
+
server_port=7860,
|
| 320 |
+
theme=SentinelTheme(),
|
| 321 |
+
css=CUSTOM_CSS,
|
| 322 |
+
)
|
chart_helpers.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Chart helper functions for Gradio 6 native plots.
|
| 2 |
+
|
| 3 |
+
Generates pandas DataFrames from episode replay data for use with
|
| 4 |
+
gr.LinePlot, gr.BarPlot, and styled HTML verdicts.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import pandas as pd
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def build_score_progression_df(log: list[dict]) -> pd.DataFrame:
|
| 13 |
+
"""Track cumulative scores for each agent at each tick.
|
| 14 |
+
|
| 15 |
+
Returns a DataFrame with columns: tick, agent, score
|
| 16 |
+
One row per agent per tick, with accumulated rewards.
|
| 17 |
+
"""
|
| 18 |
+
agents = ["attacker", "worker", "oversight"]
|
| 19 |
+
cumulative = {a: 0.0 for a in agents}
|
| 20 |
+
rows: list[dict] = []
|
| 21 |
+
seen_ticks: set[int] = set()
|
| 22 |
+
|
| 23 |
+
for entry in log:
|
| 24 |
+
agent = entry["agent"]
|
| 25 |
+
reward = entry.get("reward", 0) or 0
|
| 26 |
+
cumulative[agent] += reward
|
| 27 |
+
|
| 28 |
+
tick = entry["tick"]
|
| 29 |
+
if tick not in seen_ticks:
|
| 30 |
+
seen_ticks.add(tick)
|
| 31 |
+
for a in agents:
|
| 32 |
+
rows.append({"tick": tick, "agent": a, "score": cumulative[a]})
|
| 33 |
+
|
| 34 |
+
return pd.DataFrame(rows)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def build_attack_timeline_df(log: list[dict]) -> pd.DataFrame:
|
| 38 |
+
"""Extract attack events from the log.
|
| 39 |
+
|
| 40 |
+
Returns a DataFrame with columns: tick, attack_type, target
|
| 41 |
+
Only includes entries where action_type == "launch_attack".
|
| 42 |
+
"""
|
| 43 |
+
rows: list[dict] = []
|
| 44 |
+
for entry in log:
|
| 45 |
+
if entry["action_type"] == "launch_attack":
|
| 46 |
+
details = entry.get("details", "")
|
| 47 |
+
# details is a stringified dict; parse attack_type and target_system
|
| 48 |
+
attack_type = ""
|
| 49 |
+
target = ""
|
| 50 |
+
if isinstance(details, str):
|
| 51 |
+
# Extract from stringified parameters dict
|
| 52 |
+
for token in ["schema_drift", "policy_drift", "social_engineering", "rate_limit"]:
|
| 53 |
+
if token in details:
|
| 54 |
+
attack_type = token
|
| 55 |
+
break
|
| 56 |
+
for sys in ["crm", "billing", "ticketing"]:
|
| 57 |
+
if sys in details:
|
| 58 |
+
target = sys
|
| 59 |
+
break
|
| 60 |
+
rows.append({
|
| 61 |
+
"tick": entry["tick"],
|
| 62 |
+
"attack_type": attack_type,
|
| 63 |
+
"target": target,
|
| 64 |
+
"count": 1,
|
| 65 |
+
})
|
| 66 |
+
|
| 67 |
+
return pd.DataFrame(rows) if rows else pd.DataFrame(columns=["tick", "attack_type", "target", "count"])
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def build_comparison_df(untrained_scores: dict, trained_scores: dict) -> pd.DataFrame:
|
| 71 |
+
"""Format scores for a side-by-side bar chart.
|
| 72 |
+
|
| 73 |
+
Returns a DataFrame with columns: agent, score, type
|
| 74 |
+
where type is "untrained" or "trained".
|
| 75 |
+
"""
|
| 76 |
+
rows: list[dict] = []
|
| 77 |
+
for agent, score in untrained_scores.items():
|
| 78 |
+
rows.append({"agent": agent, "score": score, "type": "untrained"})
|
| 79 |
+
for agent, score in trained_scores.items():
|
| 80 |
+
rows.append({"agent": agent, "score": score, "type": "trained"})
|
| 81 |
+
|
| 82 |
+
return pd.DataFrame(rows)
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def build_verdict_html(untrained_log: list, trained_log: list) -> str:
|
| 86 |
+
"""Build styled HTML verdict comparing untrained vs trained episodes.
|
| 87 |
+
|
| 88 |
+
Counts: attacks launched, attacks detected (get_schema/get_current_policy),
|
| 89 |
+
social engineering resisted. Returns HTML with large numbers showing
|
| 90 |
+
the difference.
|
| 91 |
+
"""
|
| 92 |
+
def _count_stats(log: list) -> dict:
|
| 93 |
+
attacks_launched = 0
|
| 94 |
+
attacks_detected = 0
|
| 95 |
+
social_eng_resisted = 0
|
| 96 |
+
|
| 97 |
+
for entry in log:
|
| 98 |
+
if entry["action_type"] == "launch_attack":
|
| 99 |
+
attacks_launched += 1
|
| 100 |
+
if entry["action_type"] in ("get_schema", "get_current_policy"):
|
| 101 |
+
attacks_detected += 1
|
| 102 |
+
# Social engineering resisted: worker responds with refusal
|
| 103 |
+
if (
|
| 104 |
+
entry["agent"] == "worker"
|
| 105 |
+
and entry["action_type"] == "respond"
|
| 106 |
+
and "social engineering" in str(entry.get("details", "")).lower()
|
| 107 |
+
):
|
| 108 |
+
social_eng_resisted += 1
|
| 109 |
+
|
| 110 |
+
return {
|
| 111 |
+
"attacks_launched": attacks_launched,
|
| 112 |
+
"attacks_detected": attacks_detected,
|
| 113 |
+
"social_eng_resisted": social_eng_resisted,
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
untrained_stats = _count_stats(untrained_log)
|
| 117 |
+
trained_stats = _count_stats(trained_log)
|
| 118 |
+
|
| 119 |
+
def _stat_card(label: str, untrained_val: int, trained_val: int) -> str:
|
| 120 |
+
diff = trained_val - untrained_val
|
| 121 |
+
diff_color = "#44bb44" if diff > 0 else ("#ff4444" if diff < 0 else "#888")
|
| 122 |
+
diff_sign = "+" if diff > 0 else ""
|
| 123 |
+
return (
|
| 124 |
+
f"<div style='flex:1; text-align:center; padding:16px; "
|
| 125 |
+
f"background:#111827; border-radius:12px; margin:4px;'>"
|
| 126 |
+
f"<div style='font-size:12px; color:#888; text-transform:uppercase; "
|
| 127 |
+
f"letter-spacing:1px;'>{label}</div>"
|
| 128 |
+
f"<div style='display:flex; justify-content:center; gap:24px; margin-top:8px;'>"
|
| 129 |
+
f"<div>"
|
| 130 |
+
f"<div style='font-size:28px; font-weight:bold; color:#ff4444;'>{untrained_val}</div>"
|
| 131 |
+
f"<div style='font-size:10px; color:#888;'>Untrained</div>"
|
| 132 |
+
f"</div>"
|
| 133 |
+
f"<div>"
|
| 134 |
+
f"<div style='font-size:28px; font-weight:bold; color:#00ff41;'>{trained_val}</div>"
|
| 135 |
+
f"<div style='font-size:10px; color:#888;'>Trained</div>"
|
| 136 |
+
f"</div>"
|
| 137 |
+
f"</div>"
|
| 138 |
+
f"<div style='font-size:14px; color:{diff_color}; margin-top:6px; "
|
| 139 |
+
f"font-weight:bold;'>{diff_sign}{diff}</div>"
|
| 140 |
+
f"</div>"
|
| 141 |
+
)
|
| 142 |
+
|
| 143 |
+
html = (
|
| 144 |
+
"<div style='font-family:system-ui,sans-serif; padding:12px;'>"
|
| 145 |
+
"<h3 style='text-align:center; color:#e0e0e0; margin-bottom:12px;'>"
|
| 146 |
+
"Training Impact Verdict</h3>"
|
| 147 |
+
"<div style='display:flex; gap:8px;'>"
|
| 148 |
+
)
|
| 149 |
+
html += _stat_card(
|
| 150 |
+
"Attacks Launched",
|
| 151 |
+
untrained_stats["attacks_launched"],
|
| 152 |
+
trained_stats["attacks_launched"],
|
| 153 |
+
)
|
| 154 |
+
html += _stat_card(
|
| 155 |
+
"Attacks Detected",
|
| 156 |
+
untrained_stats["attacks_detected"],
|
| 157 |
+
trained_stats["attacks_detected"],
|
| 158 |
+
)
|
| 159 |
+
html += _stat_card(
|
| 160 |
+
"Social Eng. Resisted",
|
| 161 |
+
untrained_stats["social_eng_resisted"],
|
| 162 |
+
trained_stats["social_eng_resisted"],
|
| 163 |
+
)
|
| 164 |
+
html += "</div></div>"
|
| 165 |
+
|
| 166 |
+
return html
|
inspector.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Enhanced environment inspector functions for Gradio Dataframe components."""
|
| 2 |
+
|
| 3 |
+
import pandas as pd
|
| 4 |
+
|
| 5 |
+
from sentinelops_arena.environment import SentinelOpsArena
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def get_all_customers(env: SentinelOpsArena) -> pd.DataFrame:
|
| 9 |
+
"""Return all CRM customers as a DataFrame."""
|
| 10 |
+
rows = []
|
| 11 |
+
for cid, rec in env.crm.customers.items():
|
| 12 |
+
tier = rec.get("tier", "")
|
| 13 |
+
rows.append({
|
| 14 |
+
"customer_id": rec.get("customer_id", cid),
|
| 15 |
+
"name": rec.get("name", ""),
|
| 16 |
+
"tier": tier.value if hasattr(tier, "value") else str(tier),
|
| 17 |
+
"region": rec.get("region", ""),
|
| 18 |
+
"lifetime_value": rec.get("lifetime_value", 0.0),
|
| 19 |
+
})
|
| 20 |
+
if not rows:
|
| 21 |
+
return pd.DataFrame(columns=["customer_id", "name", "tier", "region", "lifetime_value"])
|
| 22 |
+
return pd.DataFrame(rows)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def get_all_invoices(env: SentinelOpsArena) -> pd.DataFrame:
|
| 26 |
+
"""Return all billing invoices as a DataFrame."""
|
| 27 |
+
rows = []
|
| 28 |
+
for iid, rec in env.billing.invoices.items():
|
| 29 |
+
status = rec.get("status", "")
|
| 30 |
+
rows.append({
|
| 31 |
+
"invoice_id": rec.get("invoice_id", iid),
|
| 32 |
+
"customer_id": rec.get("customer_id", ""),
|
| 33 |
+
"amount": rec.get("amount", 0.0),
|
| 34 |
+
"status": status.value if hasattr(status, "value") else str(status),
|
| 35 |
+
})
|
| 36 |
+
if not rows:
|
| 37 |
+
return pd.DataFrame(columns=["invoice_id", "customer_id", "amount", "status"])
|
| 38 |
+
return pd.DataFrame(rows)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def get_all_tickets(env: SentinelOpsArena) -> pd.DataFrame:
|
| 42 |
+
"""Return all ticketing system tickets as a DataFrame."""
|
| 43 |
+
rows = []
|
| 44 |
+
for tid, rec in env.ticketing.tickets.items():
|
| 45 |
+
priority = rec.get("priority", "")
|
| 46 |
+
status = rec.get("status", "")
|
| 47 |
+
rows.append({
|
| 48 |
+
"ticket_id": rec.get("ticket_id", tid),
|
| 49 |
+
"customer_id": rec.get("customer_id", ""),
|
| 50 |
+
"subject": rec.get("subject", ""),
|
| 51 |
+
"priority": priority.value if hasattr(priority, "value") else str(priority),
|
| 52 |
+
"status": status.value if hasattr(status, "value") else str(status),
|
| 53 |
+
"sla_deadline_tick": rec.get("sla_deadline_tick", 0),
|
| 54 |
+
})
|
| 55 |
+
if not rows:
|
| 56 |
+
return pd.DataFrame(columns=["ticket_id", "customer_id", "subject", "priority", "status", "sla_deadline_tick"])
|
| 57 |
+
return pd.DataFrame(rows)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def get_task_queue(env: SentinelOpsArena) -> pd.DataFrame:
|
| 61 |
+
"""Return the task queue as a DataFrame with truncated messages."""
|
| 62 |
+
rows = []
|
| 63 |
+
for task in env.tasks:
|
| 64 |
+
d = task.model_dump() if hasattr(task, "model_dump") else task
|
| 65 |
+
msg = str(d.get("message", ""))
|
| 66 |
+
task_type = d.get("task_type", "")
|
| 67 |
+
rows.append({
|
| 68 |
+
"task_id": d.get("task_id", ""),
|
| 69 |
+
"customer_id": d.get("customer_id", ""),
|
| 70 |
+
"task_type": task_type.value if hasattr(task_type, "value") else str(task_type),
|
| 71 |
+
"message": msg[:60] + ("..." if len(msg) > 60 else ""),
|
| 72 |
+
"arrival_tick": d.get("arrival_tick", 0),
|
| 73 |
+
})
|
| 74 |
+
if not rows:
|
| 75 |
+
return pd.DataFrame(columns=["task_id", "customer_id", "task_type", "message", "arrival_tick"])
|
| 76 |
+
return pd.DataFrame(rows)
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def get_env_config_html(env: SentinelOpsArena) -> str:
|
| 80 |
+
"""Return styled HTML showing environment configuration."""
|
| 81 |
+
refund = env.billing.refund_policy.model_dump()
|
| 82 |
+
sla = env.ticketing.sla_rules.model_dump()
|
| 83 |
+
|
| 84 |
+
css = (
|
| 85 |
+
"font-family: 'Courier New', monospace;"
|
| 86 |
+
"background: #0d1117;"
|
| 87 |
+
"color: #c9d1d9;"
|
| 88 |
+
"padding: 20px;"
|
| 89 |
+
"border-radius: 10px;"
|
| 90 |
+
"border: 1px solid #30363d;"
|
| 91 |
+
)
|
| 92 |
+
heading_css = (
|
| 93 |
+
"color: #39ff14;"
|
| 94 |
+
"font-size: 14px;"
|
| 95 |
+
"font-weight: bold;"
|
| 96 |
+
"margin: 16px 0 8px 0;"
|
| 97 |
+
"text-transform: uppercase;"
|
| 98 |
+
"letter-spacing: 1.5px;"
|
| 99 |
+
)
|
| 100 |
+
table_css = (
|
| 101 |
+
"width: 100%;"
|
| 102 |
+
"border-collapse: collapse;"
|
| 103 |
+
"margin-bottom: 12px;"
|
| 104 |
+
)
|
| 105 |
+
th_css = (
|
| 106 |
+
"text-align: left;"
|
| 107 |
+
"padding: 6px 12px;"
|
| 108 |
+
"border-bottom: 1px solid #30363d;"
|
| 109 |
+
"color: #58a6ff;"
|
| 110 |
+
"font-size: 12px;"
|
| 111 |
+
)
|
| 112 |
+
td_css = (
|
| 113 |
+
"padding: 6px 12px;"
|
| 114 |
+
"border-bottom: 1px solid #21262d;"
|
| 115 |
+
"font-size: 13px;"
|
| 116 |
+
)
|
| 117 |
+
val_css = (
|
| 118 |
+
"color: #39ff14;"
|
| 119 |
+
"font-weight: bold;"
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
def row(key, value):
|
| 123 |
+
return (
|
| 124 |
+
f"<tr>"
|
| 125 |
+
f"<td style='{td_css}'>{key}</td>"
|
| 126 |
+
f"<td style='{td_css} {val_css}'>{value}</td>"
|
| 127 |
+
f"</tr>"
|
| 128 |
+
)
|
| 129 |
+
|
| 130 |
+
html = f"<div style='{css}'>"
|
| 131 |
+
|
| 132 |
+
# Environment params
|
| 133 |
+
html += f"<div style='{heading_css}'>Environment Parameters</div>"
|
| 134 |
+
html += f"<table style='{table_css}'>"
|
| 135 |
+
html += f"<tr><th style='{th_css}'>Parameter</th><th style='{th_css}'>Value</th></tr>"
|
| 136 |
+
html += row("MAX_TICKS", env.MAX_TICKS)
|
| 137 |
+
html += row("NUM_CUSTOMERS", env.NUM_CUSTOMERS)
|
| 138 |
+
html += row("NUM_INVOICES", env.NUM_INVOICES)
|
| 139 |
+
html += row("NUM_TICKETS", env.NUM_TICKETS)
|
| 140 |
+
html += row("NUM_TASKS", env.NUM_TASKS)
|
| 141 |
+
html += "</table>"
|
| 142 |
+
|
| 143 |
+
# Refund policy
|
| 144 |
+
html += f"<div style='{heading_css}'>Refund Policy</div>"
|
| 145 |
+
html += f"<table style='{table_css}'>"
|
| 146 |
+
html += f"<tr><th style='{th_css}'>Rule</th><th style='{th_css}'>Value</th></tr>"
|
| 147 |
+
html += row("Window (ticks)", refund["window_ticks"])
|
| 148 |
+
html += row("Requires Approval", refund["requires_approval"])
|
| 149 |
+
html += row("Max Amount", f"${refund['max_amount']:,.2f}")
|
| 150 |
+
html += "</table>"
|
| 151 |
+
|
| 152 |
+
# SLA rules
|
| 153 |
+
html += f"<div style='{heading_css}'>SLA Rules (ticks to resolve)</div>"
|
| 154 |
+
html += f"<table style='{table_css}'>"
|
| 155 |
+
html += f"<tr><th style='{th_css}'>Priority</th><th style='{th_css}'>Deadline (ticks)</th></tr>"
|
| 156 |
+
html += row("High", sla["high"])
|
| 157 |
+
html += row("Medium", sla["medium"])
|
| 158 |
+
html += row("Low", sla["low"])
|
| 159 |
+
html += "</table>"
|
| 160 |
+
|
| 161 |
+
html += "</div>"
|
| 162 |
+
return html
|
replay_html.py
ADDED
|
@@ -0,0 +1,518 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Enhanced cybersecurity-themed replay HTML renderer for SentinelOps Arena."""
|
| 2 |
+
|
| 3 |
+
import html as _html
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def _esc(text):
|
| 7 |
+
"""HTML-escape a string."""
|
| 8 |
+
return _html.escape(str(text))
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def format_replay_html(log, scores):
|
| 12 |
+
"""Format replay log as visually stunning cybersecurity-themed HTML."""
|
| 13 |
+
|
| 14 |
+
# --- Phase definitions ---
|
| 15 |
+
PHASES = [
|
| 16 |
+
(0, 6, "RECONNAISSANCE PHASE", "#00ff41", "\u25c9", "Scanning enterprise systems..."),
|
| 17 |
+
(7, 13, "SCHEMA DRIFT ATTACK", "#ff2a2a", "\u26a0", "Database schemas compromised!"),
|
| 18 |
+
(14, 19, "POLICY DRIFT ATTACK", "#ff8c00", "\u2622", "Business policies mutating!"),
|
| 19 |
+
(20, 24, "SOCIAL ENGINEERING", "#bf5fff", "\u2620", "Manipulation attempt detected!"),
|
| 20 |
+
(25, 29, "RATE LIMITING", "#ffd700", "\u26a1", "API throttle engaged!"),
|
| 21 |
+
]
|
| 22 |
+
|
| 23 |
+
def get_phase(tick):
|
| 24 |
+
for start, end, name, color, icon, desc in PHASES:
|
| 25 |
+
if start <= tick <= end:
|
| 26 |
+
return name, color, icon, desc
|
| 27 |
+
return "UNKNOWN PHASE", "#888", "\u2022", ""
|
| 28 |
+
|
| 29 |
+
# Agent colors
|
| 30 |
+
AGENT_COLORS = {
|
| 31 |
+
"attacker": "#ff4444",
|
| 32 |
+
"worker": "#4d9fff",
|
| 33 |
+
"oversight": "#00ff41",
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
AGENT_ICONS = {
|
| 37 |
+
"attacker": "\u2694",
|
| 38 |
+
"worker": "\u2699",
|
| 39 |
+
"oversight": "\u2691",
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
AGENT_BG = {
|
| 43 |
+
"attacker": "rgba(255,68,68,0.08)",
|
| 44 |
+
"worker": "rgba(77,159,255,0.08)",
|
| 45 |
+
"oversight": "rgba(0,255,65,0.08)",
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
# --- Build HTML ---
|
| 49 |
+
html = f"""<div style="
|
| 50 |
+
font-family: 'Fira Code', 'Cascadia Code', 'JetBrains Mono', 'Consolas', monospace;
|
| 51 |
+
font-size: 13px;
|
| 52 |
+
background: #0d1117;
|
| 53 |
+
color: #c9d1d9;
|
| 54 |
+
padding: 24px;
|
| 55 |
+
border-radius: 12px;
|
| 56 |
+
border: 1px solid #00ff4133;
|
| 57 |
+
position: relative;
|
| 58 |
+
overflow: hidden;
|
| 59 |
+
">
|
| 60 |
+
<!-- Scanline overlay -->
|
| 61 |
+
<div style="
|
| 62 |
+
position: absolute; top: 0; left: 0; right: 0; bottom: 0;
|
| 63 |
+
background: repeating-linear-gradient(
|
| 64 |
+
0deg,
|
| 65 |
+
transparent,
|
| 66 |
+
transparent 2px,
|
| 67 |
+
rgba(0,255,65,0.015) 2px,
|
| 68 |
+
rgba(0,255,65,0.015) 4px
|
| 69 |
+
);
|
| 70 |
+
pointer-events: none;
|
| 71 |
+
z-index: 0;
|
| 72 |
+
"></div>
|
| 73 |
+
|
| 74 |
+
<div style="position: relative; z-index: 1;">
|
| 75 |
+
|
| 76 |
+
<!-- Header -->
|
| 77 |
+
<div style="
|
| 78 |
+
text-align: center;
|
| 79 |
+
margin-bottom: 24px;
|
| 80 |
+
padding: 16px;
|
| 81 |
+
border: 1px solid #00ff4144;
|
| 82 |
+
border-radius: 8px;
|
| 83 |
+
background: linear-gradient(135deg, rgba(0,255,65,0.05), rgba(0,255,65,0.02));
|
| 84 |
+
">
|
| 85 |
+
<div style="
|
| 86 |
+
font-size: 22px;
|
| 87 |
+
font-weight: bold;
|
| 88 |
+
color: #00ff41;
|
| 89 |
+
letter-spacing: 4px;
|
| 90 |
+
text-shadow: 0 0 10px rgba(0,255,65,0.4);
|
| 91 |
+
margin-bottom: 4px;
|
| 92 |
+
">\u2588\u2588 SENTINELOPS ARENA \u2588\u2588</div>
|
| 93 |
+
<div style="
|
| 94 |
+
font-size: 11px;
|
| 95 |
+
color: #58a6ff;
|
| 96 |
+
letter-spacing: 2px;
|
| 97 |
+
">EPISODE REPLAY \u2502 MULTI-AGENT SECURITY SIMULATION</div>
|
| 98 |
+
<div style="
|
| 99 |
+
margin-top: 8px;
|
| 100 |
+
font-size: 10px;
|
| 101 |
+
color: #484f58;
|
| 102 |
+
letter-spacing: 1px;
|
| 103 |
+
">\u250c\u2500 RED TEAM vs BLUE TEAM vs AUDITOR \u2500\u2510</div>
|
| 104 |
+
</div>
|
| 105 |
+
|
| 106 |
+
<!-- Agent legend -->
|
| 107 |
+
<div style="
|
| 108 |
+
display: flex;
|
| 109 |
+
justify-content: center;
|
| 110 |
+
gap: 24px;
|
| 111 |
+
margin-bottom: 20px;
|
| 112 |
+
padding: 8px 16px;
|
| 113 |
+
background: rgba(22,27,34,0.8);
|
| 114 |
+
border-radius: 6px;
|
| 115 |
+
border: 1px solid #21262d;
|
| 116 |
+
">"""
|
| 117 |
+
|
| 118 |
+
for agent_key, label in [("attacker", "RED TEAM"), ("worker", "BLUE TEAM"), ("oversight", "AUDITOR")]:
|
| 119 |
+
color = AGENT_COLORS[agent_key]
|
| 120 |
+
icon = AGENT_ICONS[agent_key]
|
| 121 |
+
html += f"""
|
| 122 |
+
<div style="display:flex; align-items:center; gap:6px;">
|
| 123 |
+
<span style="
|
| 124 |
+
display:inline-block; width:10px; height:10px;
|
| 125 |
+
background:{color}; border-radius:50%;
|
| 126 |
+
box-shadow: 0 0 6px {color}88;
|
| 127 |
+
"></span>
|
| 128 |
+
<span style="color:{color}; font-size:11px; font-weight:bold; letter-spacing:1px;">
|
| 129 |
+
{icon} {label}
|
| 130 |
+
</span>
|
| 131 |
+
</div>"""
|
| 132 |
+
|
| 133 |
+
html += """
|
| 134 |
+
</div>
|
| 135 |
+
"""
|
| 136 |
+
|
| 137 |
+
# --- Render log entries grouped by tick ---
|
| 138 |
+
current_tick = -1
|
| 139 |
+
current_phase = None
|
| 140 |
+
|
| 141 |
+
for entry in log:
|
| 142 |
+
tick = entry["tick"]
|
| 143 |
+
phase_name, phase_color, phase_icon, phase_desc = get_phase(tick)
|
| 144 |
+
|
| 145 |
+
# Phase header when phase changes
|
| 146 |
+
if phase_name != current_phase:
|
| 147 |
+
current_phase = phase_name
|
| 148 |
+
# Determine banner style
|
| 149 |
+
if phase_name == "RECONNAISSANCE PHASE":
|
| 150 |
+
banner_bg = "linear-gradient(90deg, rgba(0,255,65,0.12), rgba(0,255,65,0.03))"
|
| 151 |
+
border_col = "#00ff4155"
|
| 152 |
+
elif phase_name == "SCHEMA DRIFT ATTACK":
|
| 153 |
+
banner_bg = "linear-gradient(90deg, rgba(255,42,42,0.15), rgba(255,42,42,0.03))"
|
| 154 |
+
border_col = "#ff2a2a66"
|
| 155 |
+
elif phase_name == "POLICY DRIFT ATTACK":
|
| 156 |
+
banner_bg = "linear-gradient(90deg, rgba(255,140,0,0.15), rgba(255,140,0,0.03))"
|
| 157 |
+
border_col = "#ff8c0066"
|
| 158 |
+
elif phase_name == "SOCIAL ENGINEERING":
|
| 159 |
+
banner_bg = "linear-gradient(90deg, rgba(191,95,255,0.15), rgba(191,95,255,0.03))"
|
| 160 |
+
border_col = "#bf5fff66"
|
| 161 |
+
elif phase_name == "RATE LIMITING":
|
| 162 |
+
banner_bg = "linear-gradient(90deg, rgba(255,215,0,0.15), rgba(255,215,0,0.03))"
|
| 163 |
+
border_col = "#ffd70066"
|
| 164 |
+
else:
|
| 165 |
+
banner_bg = "rgba(50,50,50,0.3)"
|
| 166 |
+
border_col = "#333"
|
| 167 |
+
|
| 168 |
+
html += f"""
|
| 169 |
+
<div style="
|
| 170 |
+
margin: 20px 0 12px 0;
|
| 171 |
+
padding: 10px 16px;
|
| 172 |
+
background: {banner_bg};
|
| 173 |
+
border: 1px solid {border_col};
|
| 174 |
+
border-left: 4px solid {phase_color};
|
| 175 |
+
border-radius: 6px;
|
| 176 |
+
display: flex;
|
| 177 |
+
align-items: center;
|
| 178 |
+
gap: 12px;
|
| 179 |
+
">
|
| 180 |
+
<span style="font-size:20px;">{phase_icon}</span>
|
| 181 |
+
<div>
|
| 182 |
+
<div style="
|
| 183 |
+
font-size: 14px;
|
| 184 |
+
font-weight: bold;
|
| 185 |
+
color: {phase_color};
|
| 186 |
+
letter-spacing: 3px;
|
| 187 |
+
text-shadow: 0 0 8px {phase_color}55;
|
| 188 |
+
">{phase_name}</div>
|
| 189 |
+
<div style="font-size:10px; color:#8b949e; margin-top:2px; letter-spacing:1px;">
|
| 190 |
+
{phase_desc}
|
| 191 |
+
</div>
|
| 192 |
+
</div>
|
| 193 |
+
<div style="margin-left:auto; font-size:10px; color:#484f58;">
|
| 194 |
+
TICKS {next((f'{p[0]:02d}-{p[1]:02d}' for p in PHASES if p[2] == phase_name), '??-??')}
|
| 195 |
+
</div>
|
| 196 |
+
</div>"""
|
| 197 |
+
|
| 198 |
+
# Tick divider
|
| 199 |
+
if tick != current_tick:
|
| 200 |
+
current_tick = tick
|
| 201 |
+
html += f"""
|
| 202 |
+
<div style="
|
| 203 |
+
display: flex;
|
| 204 |
+
align-items: center;
|
| 205 |
+
gap: 8px;
|
| 206 |
+
margin: 12px 0 8px 0;
|
| 207 |
+
color: #484f58;
|
| 208 |
+
font-size: 10px;
|
| 209 |
+
letter-spacing: 1px;
|
| 210 |
+
">
|
| 211 |
+
<div style="flex:1; height:1px; background: linear-gradient(90deg, #21262d, transparent);"></div>
|
| 212 |
+
<span style="color: #58a6ff;">\u25c8 TICK {tick:02d}</span>
|
| 213 |
+
<div style="flex:1; height:1px; background: linear-gradient(90deg, transparent, #21262d);"></div>
|
| 214 |
+
</div>"""
|
| 215 |
+
|
| 216 |
+
agent = entry["agent"]
|
| 217 |
+
color = AGENT_COLORS.get(agent, "#888")
|
| 218 |
+
icon = AGENT_ICONS.get(agent, "\u2022")
|
| 219 |
+
bg = AGENT_BG.get(agent, "rgba(50,50,50,0.1)")
|
| 220 |
+
reward = entry["reward"]
|
| 221 |
+
action_type = entry["action_type"]
|
| 222 |
+
is_flagged = entry.get("flag", False)
|
| 223 |
+
|
| 224 |
+
# Detect special events
|
| 225 |
+
is_attack_launch = action_type == "launch_attack"
|
| 226 |
+
is_recovery = action_type in ("get_schema", "get_current_policy")
|
| 227 |
+
|
| 228 |
+
# Full-width attack launch banner
|
| 229 |
+
if is_attack_launch:
|
| 230 |
+
details_str = str(entry.get("details", ""))
|
| 231 |
+
# Determine attack type from details
|
| 232 |
+
if "schema_drift" in details_str:
|
| 233 |
+
atk_label = "SCHEMA DRIFT"
|
| 234 |
+
atk_icon = "\u26a0"
|
| 235 |
+
atk_color = "#ff2a2a"
|
| 236 |
+
atk_bg = "rgba(255,42,42,0.12)"
|
| 237 |
+
elif "policy_drift" in details_str:
|
| 238 |
+
atk_label = "POLICY DRIFT"
|
| 239 |
+
atk_icon = "\u2622"
|
| 240 |
+
atk_color = "#ff8c00"
|
| 241 |
+
atk_bg = "rgba(255,140,0,0.12)"
|
| 242 |
+
elif "social_engineering" in details_str:
|
| 243 |
+
atk_label = "SOCIAL ENGINEERING"
|
| 244 |
+
atk_icon = "\u2620"
|
| 245 |
+
atk_color = "#bf5fff"
|
| 246 |
+
atk_bg = "rgba(191,95,255,0.12)"
|
| 247 |
+
elif "rate_limit" in details_str:
|
| 248 |
+
atk_label = "RATE LIMIT"
|
| 249 |
+
atk_icon = "\u26a1"
|
| 250 |
+
atk_color = "#ffd700"
|
| 251 |
+
atk_bg = "rgba(255,215,0,0.12)"
|
| 252 |
+
else:
|
| 253 |
+
atk_label = "UNKNOWN ATTACK"
|
| 254 |
+
atk_icon = "\u2753"
|
| 255 |
+
atk_color = "#ff4444"
|
| 256 |
+
atk_bg = "rgba(255,68,68,0.12)"
|
| 257 |
+
|
| 258 |
+
html += f"""
|
| 259 |
+
<div style="
|
| 260 |
+
margin: 8px 0;
|
| 261 |
+
padding: 12px 16px;
|
| 262 |
+
background: {atk_bg};
|
| 263 |
+
border: 1px solid {atk_color}55;
|
| 264 |
+
border-radius: 6px;
|
| 265 |
+
text-align: center;
|
| 266 |
+
">
|
| 267 |
+
<div style="font-size:24px; margin-bottom:4px;">{atk_icon}</div>
|
| 268 |
+
<div style="
|
| 269 |
+
font-size: 16px;
|
| 270 |
+
font-weight: bold;
|
| 271 |
+
color: {atk_color};
|
| 272 |
+
letter-spacing: 4px;
|
| 273 |
+
text-shadow: 0 0 12px {atk_color}66;
|
| 274 |
+
">ATTACK LAUNCHED: {atk_label}</div>
|
| 275 |
+
<div style="font-size:10px; color:#8b949e; margin-top:4px;">
|
| 276 |
+
{_esc(str(entry.get('details', ''))[:100])}
|
| 277 |
+
</div>
|
| 278 |
+
<div style="
|
| 279 |
+
margin-top: 6px;
|
| 280 |
+
display: inline-block;
|
| 281 |
+
padding: 2px 8px;
|
| 282 |
+
background: {atk_color}22;
|
| 283 |
+
border: 1px solid {atk_color}44;
|
| 284 |
+
border-radius: 3px;
|
| 285 |
+
font-size: 10px;
|
| 286 |
+
color: {atk_color};
|
| 287 |
+
">{icon} RED TEAM \u2502 Reward: {reward:.1f}</div>
|
| 288 |
+
</div>"""
|
| 289 |
+
continue
|
| 290 |
+
|
| 291 |
+
# Regular action card
|
| 292 |
+
# Build badges
|
| 293 |
+
badges = ""
|
| 294 |
+
if is_recovery:
|
| 295 |
+
badges += f"""<span style="
|
| 296 |
+
display: inline-block;
|
| 297 |
+
padding: 1px 8px;
|
| 298 |
+
background: rgba(0,255,65,0.15);
|
| 299 |
+
border: 1px solid #00ff4155;
|
| 300 |
+
border-radius: 3px;
|
| 301 |
+
font-size: 9px;
|
| 302 |
+
color: #00ff41;
|
| 303 |
+
font-weight: bold;
|
| 304 |
+
letter-spacing: 1px;
|
| 305 |
+
margin-left: 8px;
|
| 306 |
+
">\u2714 RECOVERY</span>"""
|
| 307 |
+
|
| 308 |
+
if is_flagged:
|
| 309 |
+
badges += f"""<span style="
|
| 310 |
+
display: inline-block;
|
| 311 |
+
padding: 1px 8px;
|
| 312 |
+
background: rgba(255,42,42,0.15);
|
| 313 |
+
border: 1px solid #ff2a2a55;
|
| 314 |
+
border-radius: 3px;
|
| 315 |
+
font-size: 9px;
|
| 316 |
+
color: #ff2a2a;
|
| 317 |
+
font-weight: bold;
|
| 318 |
+
letter-spacing: 1px;
|
| 319 |
+
margin-left: 8px;
|
| 320 |
+
">\u26a0 FLAGGED</span>"""
|
| 321 |
+
|
| 322 |
+
# Reward badge color
|
| 323 |
+
if reward > 0:
|
| 324 |
+
reward_color = "#00ff41"
|
| 325 |
+
reward_bg = "rgba(0,255,65,0.1)"
|
| 326 |
+
elif reward < 0:
|
| 327 |
+
reward_color = "#ff4444"
|
| 328 |
+
reward_bg = "rgba(255,68,68,0.1)"
|
| 329 |
+
else:
|
| 330 |
+
reward_color = "#484f58"
|
| 331 |
+
reward_bg = "rgba(72,79,88,0.1)"
|
| 332 |
+
|
| 333 |
+
reward_str = f"+{reward:.1f}" if reward > 0 else f"{reward:.1f}"
|
| 334 |
+
|
| 335 |
+
html += f"""
|
| 336 |
+
<div style="
|
| 337 |
+
margin: 4px 0;
|
| 338 |
+
padding: 8px 12px;
|
| 339 |
+
background: {bg};
|
| 340 |
+
border-left: 3px solid {color};
|
| 341 |
+
border-radius: 0 6px 6px 0;
|
| 342 |
+
display: flex;
|
| 343 |
+
align-items: flex-start;
|
| 344 |
+
gap: 10px;
|
| 345 |
+
transition: all 0.2s;
|
| 346 |
+
">
|
| 347 |
+
<!-- Agent icon -->
|
| 348 |
+
<div style="
|
| 349 |
+
min-width: 28px;
|
| 350 |
+
height: 28px;
|
| 351 |
+
display: flex;
|
| 352 |
+
align-items: center;
|
| 353 |
+
justify-content: center;
|
| 354 |
+
background: {color}22;
|
| 355 |
+
border: 1px solid {color}44;
|
| 356 |
+
border-radius: 50%;
|
| 357 |
+
font-size: 14px;
|
| 358 |
+
">{icon}</div>
|
| 359 |
+
|
| 360 |
+
<!-- Content -->
|
| 361 |
+
<div style="flex:1; min-width:0;">
|
| 362 |
+
<div style="display:flex; align-items:center; flex-wrap:wrap; gap:4px;">
|
| 363 |
+
<span style="
|
| 364 |
+
color: {color};
|
| 365 |
+
font-weight: bold;
|
| 366 |
+
font-size: 11px;
|
| 367 |
+
letter-spacing: 1px;
|
| 368 |
+
">{entry['agent_label']}</span>
|
| 369 |
+
<span style="color:#484f58; font-size:11px;">\u25b8</span>
|
| 370 |
+
<span style="
|
| 371 |
+
color: #e6edf3;
|
| 372 |
+
font-size: 12px;
|
| 373 |
+
font-weight: 600;
|
| 374 |
+
">{action_type}</span>
|
| 375 |
+
{badges}
|
| 376 |
+
</div>"""
|
| 377 |
+
|
| 378 |
+
details = entry.get("details", "")
|
| 379 |
+
if details:
|
| 380 |
+
html += f"""
|
| 381 |
+
<div style="
|
| 382 |
+
margin-top: 4px;
|
| 383 |
+
padding: 4px 8px;
|
| 384 |
+
background: rgba(22,27,34,0.6);
|
| 385 |
+
border-radius: 4px;
|
| 386 |
+
font-size: 11px;
|
| 387 |
+
color: #8b949e;
|
| 388 |
+
word-break: break-word;
|
| 389 |
+
border: 1px solid #21262d;
|
| 390 |
+
">{_esc(str(details)[:150])}</div>"""
|
| 391 |
+
|
| 392 |
+
explanation = entry.get("explanation", "")
|
| 393 |
+
if explanation:
|
| 394 |
+
exp_color = "#ff4444" if is_flagged else "#00ff41"
|
| 395 |
+
exp_icon = "\u26a0" if is_flagged else "\u2714"
|
| 396 |
+
html += f"""
|
| 397 |
+
<div style="
|
| 398 |
+
margin-top: 4px;
|
| 399 |
+
font-size: 10px;
|
| 400 |
+
color: {exp_color};
|
| 401 |
+
opacity: 0.85;
|
| 402 |
+
padding-left: 4px;
|
| 403 |
+
">{exp_icon} {_esc(explanation)}</div>"""
|
| 404 |
+
|
| 405 |
+
html += f"""
|
| 406 |
+
</div>
|
| 407 |
+
|
| 408 |
+
<!-- Reward -->
|
| 409 |
+
<div style="
|
| 410 |
+
min-width: 52px;
|
| 411 |
+
text-align: right;
|
| 412 |
+
padding: 3px 8px;
|
| 413 |
+
background: {reward_bg};
|
| 414 |
+
border-radius: 4px;
|
| 415 |
+
font-size: 11px;
|
| 416 |
+
font-weight: bold;
|
| 417 |
+
color: {reward_color};
|
| 418 |
+
">{reward_str}</div>
|
| 419 |
+
</div>"""
|
| 420 |
+
|
| 421 |
+
# --- Final Scores ---
|
| 422 |
+
html += """
|
| 423 |
+
<div style="
|
| 424 |
+
margin-top: 28px;
|
| 425 |
+
padding: 16px;
|
| 426 |
+
border: 1px solid #00ff4133;
|
| 427 |
+
border-radius: 8px;
|
| 428 |
+
background: linear-gradient(135deg, rgba(0,255,65,0.04), rgba(22,27,34,0.8));
|
| 429 |
+
">
|
| 430 |
+
<div style="
|
| 431 |
+
text-align: center;
|
| 432 |
+
font-size: 14px;
|
| 433 |
+
font-weight: bold;
|
| 434 |
+
color: #00ff41;
|
| 435 |
+
letter-spacing: 3px;
|
| 436 |
+
margin-bottom: 16px;
|
| 437 |
+
text-shadow: 0 0 8px rgba(0,255,65,0.3);
|
| 438 |
+
">\u2588 FINAL SCORES \u2588</div>
|
| 439 |
+
"""
|
| 440 |
+
|
| 441 |
+
max_score = max(abs(s) for s in scores.values()) if scores else 1
|
| 442 |
+
if max_score == 0:
|
| 443 |
+
max_score = 1
|
| 444 |
+
|
| 445 |
+
for agent_key, score in scores.items():
|
| 446 |
+
color = AGENT_COLORS.get(agent_key, "#888")
|
| 447 |
+
icon = AGENT_ICONS.get(agent_key, "\u2022")
|
| 448 |
+
label_map = {"attacker": "RED TEAM", "worker": "BLUE TEAM", "oversight": "AUDITOR"}
|
| 449 |
+
label = label_map.get(agent_key, agent_key.upper())
|
| 450 |
+
|
| 451 |
+
# Bar width as percentage (handle negative scores)
|
| 452 |
+
bar_pct = max(0, min((score / max_score) * 100, 100)) if score > 0 else 0
|
| 453 |
+
|
| 454 |
+
# Score display color
|
| 455 |
+
if score > 0:
|
| 456 |
+
score_display_color = "#00ff41"
|
| 457 |
+
elif score < 0:
|
| 458 |
+
score_display_color = "#ff4444"
|
| 459 |
+
else:
|
| 460 |
+
score_display_color = "#484f58"
|
| 461 |
+
|
| 462 |
+
html += f"""
|
| 463 |
+
<div style="margin-bottom: 12px;">
|
| 464 |
+
<div style="
|
| 465 |
+
display: flex;
|
| 466 |
+
align-items: center;
|
| 467 |
+
justify-content: space-between;
|
| 468 |
+
margin-bottom: 4px;
|
| 469 |
+
">
|
| 470 |
+
<div style="display:flex; align-items:center; gap:8px;">
|
| 471 |
+
<span style="font-size:14px;">{icon}</span>
|
| 472 |
+
<span style="
|
| 473 |
+
color: {color};
|
| 474 |
+
font-weight: bold;
|
| 475 |
+
font-size: 12px;
|
| 476 |
+
letter-spacing: 1px;
|
| 477 |
+
">{label}</span>
|
| 478 |
+
</div>
|
| 479 |
+
<span style="
|
| 480 |
+
font-size: 16px;
|
| 481 |
+
font-weight: bold;
|
| 482 |
+
color: {score_display_color};
|
| 483 |
+
text-shadow: 0 0 6px {score_display_color}44;
|
| 484 |
+
">{score:.1f}</span>
|
| 485 |
+
</div>
|
| 486 |
+
<div style="
|
| 487 |
+
height: 8px;
|
| 488 |
+
background: #161b22;
|
| 489 |
+
border-radius: 4px;
|
| 490 |
+
border: 1px solid #21262d;
|
| 491 |
+
overflow: hidden;
|
| 492 |
+
">
|
| 493 |
+
<div style="
|
| 494 |
+
height: 100%;
|
| 495 |
+
width: {bar_pct:.1f}%;
|
| 496 |
+
background: linear-gradient(90deg, {color}, {color}88);
|
| 497 |
+
border-radius: 4px;
|
| 498 |
+
box-shadow: 0 0 8px {color}44;
|
| 499 |
+
"></div>
|
| 500 |
+
</div>
|
| 501 |
+
</div>"""
|
| 502 |
+
|
| 503 |
+
html += """
|
| 504 |
+
</div>
|
| 505 |
+
|
| 506 |
+
<!-- Footer -->
|
| 507 |
+
<div style="
|
| 508 |
+
text-align: center;
|
| 509 |
+
margin-top: 16px;
|
| 510 |
+
font-size: 9px;
|
| 511 |
+
color: #30363d;
|
| 512 |
+
letter-spacing: 2px;
|
| 513 |
+
">SENTINELOPS ARENA \u2502 OPENENV HACKATHON SF 2026</div>
|
| 514 |
+
|
| 515 |
+
</div><!-- end relative z-index wrapper -->
|
| 516 |
+
</div><!-- end outer container -->"""
|
| 517 |
+
|
| 518 |
+
return html
|
requirements.txt
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
openenv-core[core]>=0.2.0
|
| 2 |
-
gradio>=
|
| 3 |
fastmcp>=2.14.5
|
| 4 |
pydantic>=2.0
|
| 5 |
mcp>=1.26.0
|
|
|
|
| 1 |
openenv-core[core]>=0.2.0
|
| 2 |
+
gradio>=6.0.0
|
| 3 |
fastmcp>=2.14.5
|
| 4 |
pydantic>=2.0
|
| 5 |
mcp>=1.26.0
|
sentinel_theme.py
ADDED
|
@@ -0,0 +1,482 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""SentinelOps Arena -- Custom Gradio 6 cybersecurity theme."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import gradio as gr
|
| 6 |
+
from gradio.themes.base import Base
|
| 7 |
+
from gradio.themes.utils import colors, fonts, sizes
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class SentinelTheme(Base):
|
| 11 |
+
"""Dark cybersecurity / hacking aesthetic theme for SentinelOps Arena."""
|
| 12 |
+
|
| 13 |
+
def __init__(
|
| 14 |
+
self,
|
| 15 |
+
*,
|
| 16 |
+
primary_hue: colors.Color | str = colors.emerald,
|
| 17 |
+
secondary_hue: colors.Color | str = colors.red,
|
| 18 |
+
neutral_hue: colors.Color | str = colors.slate,
|
| 19 |
+
spacing_size: sizes.Size | str = sizes.spacing_md,
|
| 20 |
+
radius_size: sizes.Size | str = sizes.radius_md,
|
| 21 |
+
text_size: sizes.Size | str = sizes.text_md,
|
| 22 |
+
font: fonts.Font | str | list[fonts.Font | str] = (
|
| 23 |
+
fonts.GoogleFont("IBM Plex Mono"),
|
| 24 |
+
"ui-monospace",
|
| 25 |
+
"SFMono-Regular",
|
| 26 |
+
"monospace",
|
| 27 |
+
),
|
| 28 |
+
font_mono: fonts.Font | str | list[fonts.Font | str] = (
|
| 29 |
+
fonts.GoogleFont("IBM Plex Mono"),
|
| 30 |
+
"ui-monospace",
|
| 31 |
+
"Consolas",
|
| 32 |
+
"monospace",
|
| 33 |
+
),
|
| 34 |
+
):
|
| 35 |
+
super().__init__(
|
| 36 |
+
primary_hue=primary_hue,
|
| 37 |
+
secondary_hue=secondary_hue,
|
| 38 |
+
neutral_hue=neutral_hue,
|
| 39 |
+
spacing_size=spacing_size,
|
| 40 |
+
radius_size=radius_size,
|
| 41 |
+
text_size=text_size,
|
| 42 |
+
font=font,
|
| 43 |
+
font_mono=font_mono,
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
# -- Core surface colors -------------------------------------------------
|
| 47 |
+
self.body_background_fill = "#0a0f1a"
|
| 48 |
+
self.body_background_fill_dark = "#0a0f1a"
|
| 49 |
+
self.body_text_color = "#c9d1d9"
|
| 50 |
+
self.body_text_color_dark = "#c9d1d9"
|
| 51 |
+
self.body_text_color_subdued = "#8b949e"
|
| 52 |
+
self.body_text_color_subdued_dark = "#8b949e"
|
| 53 |
+
|
| 54 |
+
# -- Block / panel colours ------------------------------------------------
|
| 55 |
+
self.block_background_fill = "#111827"
|
| 56 |
+
self.block_background_fill_dark = "#111827"
|
| 57 |
+
self.block_border_color = "#1e3a2f"
|
| 58 |
+
self.block_border_color_dark = "#1e3a2f"
|
| 59 |
+
self.block_border_width = "1px"
|
| 60 |
+
self.block_label_background_fill = "#0d1520"
|
| 61 |
+
self.block_label_background_fill_dark = "#0d1520"
|
| 62 |
+
self.block_label_text_color = "#00ff41"
|
| 63 |
+
self.block_label_text_color_dark = "#00ff41"
|
| 64 |
+
self.block_shadow = "0 0 8px rgba(0, 255, 65, 0.08)"
|
| 65 |
+
self.block_shadow_dark = "0 0 8px rgba(0, 255, 65, 0.08)"
|
| 66 |
+
self.block_title_text_color = "#e6edf3"
|
| 67 |
+
self.block_title_text_color_dark = "#e6edf3"
|
| 68 |
+
|
| 69 |
+
# -- Borders & panels -----------------------------------------------------
|
| 70 |
+
self.border_color_accent = "#00ff41"
|
| 71 |
+
self.border_color_accent_dark = "#00ff41"
|
| 72 |
+
self.border_color_primary = "#1e3a2f"
|
| 73 |
+
self.border_color_primary_dark = "#1e3a2f"
|
| 74 |
+
self.panel_background_fill = "#0d1520"
|
| 75 |
+
self.panel_background_fill_dark = "#0d1520"
|
| 76 |
+
self.panel_border_color = "#1e3a2f"
|
| 77 |
+
self.panel_border_color_dark = "#1e3a2f"
|
| 78 |
+
|
| 79 |
+
# -- Primary button (cyber green) -----------------------------------------
|
| 80 |
+
self.button_primary_background_fill = "#00cc33"
|
| 81 |
+
self.button_primary_background_fill_dark = "#00cc33"
|
| 82 |
+
self.button_primary_background_fill_hover = "#00ff41"
|
| 83 |
+
self.button_primary_background_fill_hover_dark = "#00ff41"
|
| 84 |
+
self.button_primary_text_color = "#0a0f1a"
|
| 85 |
+
self.button_primary_text_color_dark = "#0a0f1a"
|
| 86 |
+
self.button_primary_border_color = "#00ff41"
|
| 87 |
+
self.button_primary_border_color_dark = "#00ff41"
|
| 88 |
+
self.button_primary_shadow = "0 0 12px rgba(0, 255, 65, 0.3)"
|
| 89 |
+
|
| 90 |
+
# -- Secondary button -----------------------------------------------------
|
| 91 |
+
self.button_secondary_background_fill = "#1a1f2e"
|
| 92 |
+
self.button_secondary_background_fill_dark = "#1a1f2e"
|
| 93 |
+
self.button_secondary_background_fill_hover = "#252b3b"
|
| 94 |
+
self.button_secondary_background_fill_hover_dark = "#252b3b"
|
| 95 |
+
self.button_secondary_text_color = "#c9d1d9"
|
| 96 |
+
self.button_secondary_text_color_dark = "#c9d1d9"
|
| 97 |
+
self.button_secondary_border_color = "#30363d"
|
| 98 |
+
self.button_secondary_border_color_dark = "#30363d"
|
| 99 |
+
|
| 100 |
+
# -- Inputs ---------------------------------------------------------------
|
| 101 |
+
self.input_background_fill = "#0d1520"
|
| 102 |
+
self.input_background_fill_dark = "#0d1520"
|
| 103 |
+
self.input_border_color = "#1e3a2f"
|
| 104 |
+
self.input_border_color_dark = "#1e3a2f"
|
| 105 |
+
self.input_border_color_focus = "#00ff41"
|
| 106 |
+
self.input_border_color_focus_dark = "#00ff41"
|
| 107 |
+
self.input_placeholder_color = "#484f58"
|
| 108 |
+
self.input_placeholder_color_dark = "#484f58"
|
| 109 |
+
self.input_text_color = "#c9d1d9"
|
| 110 |
+
|
| 111 |
+
# -- Checkbox / toggle ----------------------------------------------------
|
| 112 |
+
self.checkbox_background_color = "#0d1520"
|
| 113 |
+
self.checkbox_background_color_dark = "#0d1520"
|
| 114 |
+
self.checkbox_background_color_selected = "#00cc33"
|
| 115 |
+
self.checkbox_background_color_selected_dark = "#00cc33"
|
| 116 |
+
self.checkbox_border_color = "#30363d"
|
| 117 |
+
self.checkbox_border_color_dark = "#30363d"
|
| 118 |
+
self.checkbox_border_color_selected = "#00ff41"
|
| 119 |
+
self.checkbox_border_color_selected_dark = "#00ff41"
|
| 120 |
+
self.checkbox_label_text_color = "#c9d1d9"
|
| 121 |
+
|
| 122 |
+
# -- Table / code ---------------------------------------------------------
|
| 123 |
+
self.table_border_color = "#1e3a2f"
|
| 124 |
+
self.table_border_color_dark = "#1e3a2f"
|
| 125 |
+
self.table_even_background_fill = "#111827"
|
| 126 |
+
self.table_even_background_fill_dark = "#111827"
|
| 127 |
+
self.table_odd_background_fill = "#0d1520"
|
| 128 |
+
self.table_odd_background_fill_dark = "#0d1520"
|
| 129 |
+
self.code_background_fill = "#0d1520"
|
| 130 |
+
self.code_background_fill_dark = "#0d1520"
|
| 131 |
+
|
| 132 |
+
# -- Shadows & misc ------------------------------------------------------
|
| 133 |
+
self.shadow_spread = "4px"
|
| 134 |
+
self.shadow_drop = "0 2px 6px rgba(0, 0, 0, 0.4)"
|
| 135 |
+
self.shadow_drop_lg = "0 4px 16px rgba(0, 0, 0, 0.5)"
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
# ---------------------------------------------------------------------------
|
| 139 |
+
# Custom CSS
|
| 140 |
+
# ---------------------------------------------------------------------------
|
| 141 |
+
|
| 142 |
+
CUSTOM_CSS = """
|
| 143 |
+
/* ====================== ROOT OVERRIDES ====================== */
|
| 144 |
+
:root {
|
| 145 |
+
--sentinel-green: #00ff41;
|
| 146 |
+
--sentinel-green-dim: #00cc33;
|
| 147 |
+
--sentinel-red: #ff4444;
|
| 148 |
+
--sentinel-blue: #4488ff;
|
| 149 |
+
--sentinel-bg: #0a0f1a;
|
| 150 |
+
--sentinel-surface: #111827;
|
| 151 |
+
--sentinel-surface-alt: #0d1520;
|
| 152 |
+
--sentinel-border: #1e3a2f;
|
| 153 |
+
--sentinel-text: #c9d1d9;
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
/* ====================== GLOBAL ====================== */
|
| 157 |
+
.gradio-container {
|
| 158 |
+
background: var(--sentinel-bg) !important;
|
| 159 |
+
max-width: 1200px !important;
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
/* ====================== TAB HEADERS ====================== */
|
| 163 |
+
.tab-nav button {
|
| 164 |
+
background: var(--sentinel-surface) !important;
|
| 165 |
+
color: var(--sentinel-text) !important;
|
| 166 |
+
border: 1px solid var(--sentinel-border) !important;
|
| 167 |
+
border-bottom: none !important;
|
| 168 |
+
font-family: 'IBM Plex Mono', monospace !important;
|
| 169 |
+
font-size: 0.85rem !important;
|
| 170 |
+
letter-spacing: 0.05em;
|
| 171 |
+
text-transform: uppercase;
|
| 172 |
+
padding: 10px 20px !important;
|
| 173 |
+
transition: all 0.2s ease;
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
.tab-nav button:hover {
|
| 177 |
+
background: #1a2332 !important;
|
| 178 |
+
color: var(--sentinel-green) !important;
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
.tab-nav button.selected {
|
| 182 |
+
background: var(--sentinel-surface-alt) !important;
|
| 183 |
+
color: var(--sentinel-green) !important;
|
| 184 |
+
border-top: 2px solid var(--sentinel-green) !important;
|
| 185 |
+
box-shadow: 0 -2px 8px rgba(0, 255, 65, 0.15);
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
/* ====================== AGENT COLOR ACCENTS ====================== */
|
| 189 |
+
.attacker-red {
|
| 190 |
+
border-left: 3px solid var(--sentinel-red) !important;
|
| 191 |
+
padding-left: 12px !important;
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
.attacker-red .label-wrap span,
|
| 195 |
+
.attacker-red label span {
|
| 196 |
+
color: var(--sentinel-red) !important;
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
.worker-blue {
|
| 200 |
+
border-left: 3px solid var(--sentinel-blue) !important;
|
| 201 |
+
padding-left: 12px !important;
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
.worker-blue .label-wrap span,
|
| 205 |
+
.worker-blue label span {
|
| 206 |
+
color: var(--sentinel-blue) !important;
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
.oversight-green {
|
| 210 |
+
border-left: 3px solid var(--sentinel-green) !important;
|
| 211 |
+
padding-left: 12px !important;
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
.oversight-green .label-wrap span,
|
| 215 |
+
.oversight-green label span {
|
| 216 |
+
color: var(--sentinel-green) !important;
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
/* ====================== GLOWING CARD BORDERS ====================== */
|
| 220 |
+
.glow-card {
|
| 221 |
+
border: 1px solid var(--sentinel-border) !important;
|
| 222 |
+
border-radius: 8px !important;
|
| 223 |
+
box-shadow: 0 0 10px rgba(0, 255, 65, 0.06),
|
| 224 |
+
inset 0 0 10px rgba(0, 255, 65, 0.02) !important;
|
| 225 |
+
transition: box-shadow 0.3s ease, border-color 0.3s ease;
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
.glow-card:hover {
|
| 229 |
+
border-color: rgba(0, 255, 65, 0.4) !important;
|
| 230 |
+
box-shadow: 0 0 18px rgba(0, 255, 65, 0.12),
|
| 231 |
+
inset 0 0 12px rgba(0, 255, 65, 0.04) !important;
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
/* ====================== PRIMARY BUTTONS GLOW ====================== */
|
| 235 |
+
button.primary {
|
| 236 |
+
box-shadow: 0 0 12px rgba(0, 255, 65, 0.25) !important;
|
| 237 |
+
text-transform: uppercase !important;
|
| 238 |
+
letter-spacing: 0.08em !important;
|
| 239 |
+
font-weight: 700 !important;
|
| 240 |
+
transition: all 0.2s ease !important;
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
button.primary:hover {
|
| 244 |
+
box-shadow: 0 0 20px rgba(0, 255, 65, 0.45) !important;
|
| 245 |
+
transform: translateY(-1px);
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
/* ====================== HEADER BANNER ====================== */
|
| 249 |
+
.sentinel-header {
|
| 250 |
+
text-align: center;
|
| 251 |
+
padding: 32px 20px 24px;
|
| 252 |
+
background: linear-gradient(180deg, #0d1a10 0%, var(--sentinel-bg) 100%);
|
| 253 |
+
border-bottom: 1px solid var(--sentinel-border);
|
| 254 |
+
margin-bottom: 16px;
|
| 255 |
+
position: relative;
|
| 256 |
+
overflow: hidden;
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
.sentinel-header::before {
|
| 260 |
+
content: "";
|
| 261 |
+
position: absolute;
|
| 262 |
+
top: 0;
|
| 263 |
+
left: 0;
|
| 264 |
+
right: 0;
|
| 265 |
+
height: 2px;
|
| 266 |
+
background: linear-gradient(90deg,
|
| 267 |
+
transparent 0%,
|
| 268 |
+
var(--sentinel-green) 20%,
|
| 269 |
+
var(--sentinel-red) 50%,
|
| 270 |
+
var(--sentinel-blue) 80%,
|
| 271 |
+
transparent 100%
|
| 272 |
+
);
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
.sentinel-header h1 {
|
| 276 |
+
font-family: 'IBM Plex Mono', monospace;
|
| 277 |
+
font-size: 2.4rem;
|
| 278 |
+
font-weight: 700;
|
| 279 |
+
background: linear-gradient(135deg, #00ff41 0%, #00cc88 40%, #44ffaa 100%);
|
| 280 |
+
-webkit-background-clip: text;
|
| 281 |
+
-webkit-text-fill-color: transparent;
|
| 282 |
+
background-clip: text;
|
| 283 |
+
margin: 0 0 6px 0;
|
| 284 |
+
letter-spacing: 0.04em;
|
| 285 |
+
text-shadow: none;
|
| 286 |
+
}
|
| 287 |
+
|
| 288 |
+
.sentinel-header .subtitle {
|
| 289 |
+
font-family: 'IBM Plex Mono', monospace;
|
| 290 |
+
font-size: 0.95rem;
|
| 291 |
+
color: #8b949e;
|
| 292 |
+
letter-spacing: 0.06em;
|
| 293 |
+
margin-bottom: 20px;
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
/* ---- Agent badges ---- */
|
| 297 |
+
.agent-badges {
|
| 298 |
+
display: flex;
|
| 299 |
+
justify-content: center;
|
| 300 |
+
gap: 16px;
|
| 301 |
+
flex-wrap: wrap;
|
| 302 |
+
margin-top: 8px;
|
| 303 |
+
}
|
| 304 |
+
|
| 305 |
+
.agent-badge {
|
| 306 |
+
display: inline-flex;
|
| 307 |
+
align-items: center;
|
| 308 |
+
gap: 6px;
|
| 309 |
+
padding: 6px 16px;
|
| 310 |
+
border-radius: 6px;
|
| 311 |
+
font-family: 'IBM Plex Mono', monospace;
|
| 312 |
+
font-size: 0.75rem;
|
| 313 |
+
font-weight: 700;
|
| 314 |
+
letter-spacing: 0.12em;
|
| 315 |
+
text-transform: uppercase;
|
| 316 |
+
border: 1px solid;
|
| 317 |
+
}
|
| 318 |
+
|
| 319 |
+
.badge-red {
|
| 320 |
+
color: var(--sentinel-red);
|
| 321 |
+
border-color: rgba(255, 68, 68, 0.4);
|
| 322 |
+
background: rgba(255, 68, 68, 0.08);
|
| 323 |
+
}
|
| 324 |
+
|
| 325 |
+
.badge-blue {
|
| 326 |
+
color: var(--sentinel-blue);
|
| 327 |
+
border-color: rgba(68, 136, 255, 0.4);
|
| 328 |
+
background: rgba(68, 136, 255, 0.08);
|
| 329 |
+
}
|
| 330 |
+
|
| 331 |
+
.badge-green {
|
| 332 |
+
color: var(--sentinel-green);
|
| 333 |
+
border-color: rgba(0, 255, 65, 0.4);
|
| 334 |
+
background: rgba(0, 255, 65, 0.08);
|
| 335 |
+
}
|
| 336 |
+
|
| 337 |
+
.badge-dot {
|
| 338 |
+
width: 8px;
|
| 339 |
+
height: 8px;
|
| 340 |
+
border-radius: 50%;
|
| 341 |
+
display: inline-block;
|
| 342 |
+
}
|
| 343 |
+
|
| 344 |
+
.badge-red .badge-dot { background: var(--sentinel-red); }
|
| 345 |
+
.badge-blue .badge-dot { background: var(--sentinel-blue); }
|
| 346 |
+
.badge-green .badge-dot { background: var(--sentinel-green); }
|
| 347 |
+
|
| 348 |
+
/* Built-on OpenEnv badge */
|
| 349 |
+
.openenv-badge {
|
| 350 |
+
display: inline-block;
|
| 351 |
+
margin-top: 16px;
|
| 352 |
+
padding: 4px 14px;
|
| 353 |
+
border-radius: 20px;
|
| 354 |
+
font-family: 'IBM Plex Mono', monospace;
|
| 355 |
+
font-size: 0.7rem;
|
| 356 |
+
color: #8b949e;
|
| 357 |
+
border: 1px solid #30363d;
|
| 358 |
+
background: rgba(255, 255, 255, 0.03);
|
| 359 |
+
letter-spacing: 0.06em;
|
| 360 |
+
}
|
| 361 |
+
|
| 362 |
+
.openenv-badge a {
|
| 363 |
+
color: #58a6ff;
|
| 364 |
+
text-decoration: none;
|
| 365 |
+
}
|
| 366 |
+
|
| 367 |
+
.openenv-badge a:hover {
|
| 368 |
+
text-decoration: underline;
|
| 369 |
+
}
|
| 370 |
+
|
| 371 |
+
/* ====================== ATTACK ALERT PULSE ====================== */
|
| 372 |
+
@keyframes alert-pulse {
|
| 373 |
+
0%, 100% { box-shadow: 0 0 4px rgba(255, 68, 68, 0.2); }
|
| 374 |
+
50% { box-shadow: 0 0 16px rgba(255, 68, 68, 0.4); }
|
| 375 |
+
}
|
| 376 |
+
|
| 377 |
+
.attack-alert {
|
| 378 |
+
animation: alert-pulse 2.5s ease-in-out infinite;
|
| 379 |
+
border: 1px solid rgba(255, 68, 68, 0.3) !important;
|
| 380 |
+
}
|
| 381 |
+
|
| 382 |
+
/* ====================== SCAN LINE DECORATION ====================== */
|
| 383 |
+
@keyframes scanline {
|
| 384 |
+
0% { transform: translateY(-100%); }
|
| 385 |
+
100% { transform: translateY(100%); }
|
| 386 |
+
}
|
| 387 |
+
|
| 388 |
+
.sentinel-header::after {
|
| 389 |
+
content: "";
|
| 390 |
+
position: absolute;
|
| 391 |
+
top: 0;
|
| 392 |
+
left: 0;
|
| 393 |
+
right: 0;
|
| 394 |
+
height: 100%;
|
| 395 |
+
background: linear-gradient(
|
| 396 |
+
180deg,
|
| 397 |
+
transparent 0%,
|
| 398 |
+
rgba(0, 255, 65, 0.02) 50%,
|
| 399 |
+
transparent 100%
|
| 400 |
+
);
|
| 401 |
+
animation: scanline 8s linear infinite;
|
| 402 |
+
pointer-events: none;
|
| 403 |
+
}
|
| 404 |
+
|
| 405 |
+
/* ====================== MARKDOWN / TEXT READABILITY ====================== */
|
| 406 |
+
.prose h1, .prose h2, .prose h3 {
|
| 407 |
+
color: #e6edf3 !important;
|
| 408 |
+
}
|
| 409 |
+
|
| 410 |
+
.prose p, .prose li {
|
| 411 |
+
color: #c9d1d9 !important;
|
| 412 |
+
}
|
| 413 |
+
|
| 414 |
+
.prose strong {
|
| 415 |
+
color: #e6edf3 !important;
|
| 416 |
+
}
|
| 417 |
+
|
| 418 |
+
.prose a {
|
| 419 |
+
color: #58a6ff !important;
|
| 420 |
+
}
|
| 421 |
+
|
| 422 |
+
.prose code {
|
| 423 |
+
background: var(--sentinel-surface-alt) !important;
|
| 424 |
+
color: var(--sentinel-green) !important;
|
| 425 |
+
padding: 2px 6px;
|
| 426 |
+
border-radius: 4px;
|
| 427 |
+
}
|
| 428 |
+
|
| 429 |
+
.prose hr {
|
| 430 |
+
border-color: var(--sentinel-border) !important;
|
| 431 |
+
}
|
| 432 |
+
|
| 433 |
+
/* ====================== CODE BLOCKS ====================== */
|
| 434 |
+
.code-wrap, .cm-editor {
|
| 435 |
+
background: var(--sentinel-surface-alt) !important;
|
| 436 |
+
border: 1px solid var(--sentinel-border) !important;
|
| 437 |
+
}
|
| 438 |
+
|
| 439 |
+
/* ====================== SCROLLBAR ====================== */
|
| 440 |
+
::-webkit-scrollbar {
|
| 441 |
+
width: 8px;
|
| 442 |
+
height: 8px;
|
| 443 |
+
}
|
| 444 |
+
|
| 445 |
+
::-webkit-scrollbar-track {
|
| 446 |
+
background: var(--sentinel-bg);
|
| 447 |
+
}
|
| 448 |
+
|
| 449 |
+
::-webkit-scrollbar-thumb {
|
| 450 |
+
background: #30363d;
|
| 451 |
+
border-radius: 4px;
|
| 452 |
+
}
|
| 453 |
+
|
| 454 |
+
::-webkit-scrollbar-thumb:hover {
|
| 455 |
+
background: #484f58;
|
| 456 |
+
}
|
| 457 |
+
"""
|
| 458 |
+
|
| 459 |
+
# ---------------------------------------------------------------------------
|
| 460 |
+
# Header HTML
|
| 461 |
+
# ---------------------------------------------------------------------------
|
| 462 |
+
|
| 463 |
+
HEADER_HTML = """\
|
| 464 |
+
<div class="sentinel-header">
|
| 465 |
+
<h1>SentinelOps Arena</h1>
|
| 466 |
+
<div class="subtitle">Multi-Agent Self-Play RL for Enterprise Security</div>
|
| 467 |
+
<div class="agent-badges">
|
| 468 |
+
<span class="agent-badge badge-red">
|
| 469 |
+
<span class="badge-dot"></span>RED TEAM
|
| 470 |
+
</span>
|
| 471 |
+
<span class="agent-badge badge-blue">
|
| 472 |
+
<span class="badge-dot"></span>BLUE TEAM
|
| 473 |
+
</span>
|
| 474 |
+
<span class="agent-badge badge-green">
|
| 475 |
+
<span class="badge-dot"></span>AUDITOR
|
| 476 |
+
</span>
|
| 477 |
+
</div>
|
| 478 |
+
<div class="openenv-badge">
|
| 479 |
+
Built on <a href="https://github.com/meta-pytorch/OpenEnv" target="_blank">OpenEnv</a>
|
| 480 |
+
</div>
|
| 481 |
+
</div>
|
| 482 |
+
"""
|