nihalaninihal Claude Opus 4.6 commited on
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>

Files changed (7) hide show
  1. README.md +11 -0
  2. app.py +169 -114
  3. chart_helpers.py +166 -0
  4. inspector.py +162 -0
  5. replay_html.py +518 -0
  6. requirements.txt +1 -1
  7. 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
- html += "</div>"
70
- return html
 
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
- return html, scores_text
 
 
 
 
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
- comparison = {
 
 
 
 
 
 
 
 
 
 
 
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 untrained_html, trained_html, json.dumps(comparison, indent=2)
 
 
 
 
 
 
 
 
106
 
107
 
108
  def inspect_state(seed):
109
- """Show environment state after reset."""
110
  env = SentinelOpsArena()
111
- obs = env.reset(seed=int(seed))
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
- sample_customer = env.crm.lookup_customer("C000")
126
- sample_task = env.tasks[0].model_dump() if env.tasks else {}
 
 
 
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
- gr.Markdown(
141
- """
142
- # SentinelOps Arena
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
- replay_output = gr.HTML(label="Episode Replay")
171
- scores_output = gr.Code(label="Final Scores", language="json")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 Comparison", language="json"
197
  )
198
 
199
  comp_btn.click(
200
  run_before_after,
201
  inputs=[comp_seed],
202
- outputs=[untrained_output, trained_output, comparison_output],
 
 
 
 
 
 
 
 
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
- state_output = gr.Code(
214
- label="Environment State", language="json"
215
- )
216
- customer_output = gr.Code(
217
- label="Sample Customer (C000)", language="json"
218
- )
219
- task_output = gr.Code(
220
- label="First Task (TASK-000)", language="json"
221
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
222
 
223
  inspect_btn.click(
224
  inspect_state,
225
  inputs=[inspect_seed],
226
- outputs=[state_output, customer_output, task_output],
 
 
 
 
 
 
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 -> Worker acts -> Oversight 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(server_name="0.0.0.0", server_port=7860, theme=gr.themes.Soft())
 
 
 
 
 
 
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 &rarr; Worker acts &rarr; 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>=5.0.0
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
+ """