hirann commited on
Commit
34c6dfc
·
verified ·
1 Parent(s): 2f59eb7

Upload server/war_room_debate.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. server/war_room_debate.py +604 -0
server/war_room_debate.py ADDED
@@ -0,0 +1,604 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ ImmunoOrg 2.0 — Standalone War Room debate runner (LLM API).
3
+
4
+ Supports multiple backends (no paid Anthropic required):
5
+
6
+ - **Groq** (free tier): set ``GROQ_API_KEY`` — used automatically in ``auto`` mode.
7
+ - **OpenAI-compatible** (OpenRouter, Together, local Ollama, etc.):
8
+ ``OPENAI_API_KEY`` + optional ``OPENAI_API_BASE`` + ``WAR_ROOM_MODEL``.
9
+ - **Anthropic**: ``ANTHROPIC_API_KEY`` + optional ``WAR_ROOM_MODEL`` for Claude.
10
+
11
+ Orchestrates 3 parallel initial-position calls, then 3 parallel cross-examination
12
+ calls, vote tally, and lightweight hallucination checks. Used from Gradio **/demo**
13
+ (War Room accordion) and by POST /api/war-room.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import asyncio
19
+ import json
20
+ import os
21
+ import re
22
+ from dataclasses import dataclass, field
23
+ from typing import Any
24
+
25
+ import requests
26
+
27
+ ANTHROPIC_URL = "https://api.anthropic.com/v1/messages"
28
+ ANTHROPIC_VERSION = "2023-06-01"
29
+ DEFAULT_CLAUDE_MODEL = "claude-sonnet-4-20250514"
30
+ DEFAULT_GROQ_MODEL = "llama-3.3-70b-versatile"
31
+ DEFAULT_OPENAI_MODEL = "gpt-4o-mini"
32
+ GROQ_BASE_URL = "https://api.groq.com/openai/v1"
33
+ OPENAI_CHAT_PATH = "/chat/completions"
34
+
35
+ HIPAA_RESIDENCY = "us-east-1"
36
+ PROTECTED_IPS = ("10.0.0.1", "10.0.0.2")
37
+
38
+ SYSTEM_CISO = (
39
+ "You are the CISO Agent in the ImmunoOrg 2.0 War Room. Your primary "
40
+ "objective is to ELIMINATE THE THREAT AT ALL COSTS. You ALWAYS cite "
41
+ "MITRE ATT&CK technique IDs (e.g., T1195.002) when describing threats. "
42
+ "Respond in professional English prose only. No JSON, no code, no symbols."
43
+ )
44
+
45
+ SYSTEM_DEVOPS = (
46
+ "You are the DevOps Lead Agent in the ImmunoOrg 2.0 War Room. Your "
47
+ "primary objective is to MAINTAIN UPTIME ABOVE 99.9 PERCENT. You RESIST "
48
+ "any action that drops services. You can offer SIDE DEALS such as accepting "
49
+ "a firewall block if it is delayed by 15 minutes to drain connections. "
50
+ "Respond in professional English prose only. No JSON, no code, no symbols."
51
+ )
52
+
53
+ SYSTEM_ARCHITECT = (
54
+ "You are the Lead Architect Agent in the ImmunoOrg 2.0 War Room. "
55
+ "Compliance overrides all other proposals. If HIPAA is invoked, cite "
56
+ "45 CFR Part 164. If a board directive is detected, you MUST pivot "
57
+ "instantly, invalidating previous votes. Respond in professional English "
58
+ "prose only. No JSON, no code, no symbols."
59
+ )
60
+
61
+ USER_SUFFIX_POSITION = (
62
+ "\n\nAt the end of your response, on its own line, write exactly:\n"
63
+ "PROPOSED_ACTION: <a short label for your recommended action>\n"
64
+ "Choose a label that reflects your stance (e.g. Block Source IP, "
65
+ "Negotiate Connection Drain, Pivot to Compliance Enclave)."
66
+ )
67
+
68
+ USER_SUFFIX_CROSS = (
69
+ "\n\nAt the end of your response, on its own line, write exactly:\n"
70
+ "PROPOSED_ACTION: <a short label echoing or challenging the action you discuss>\n"
71
+ )
72
+
73
+
74
+ def format_threat_briefing(
75
+ threat_type: str,
76
+ severity: int,
77
+ source_ip: str,
78
+ target_service: str,
79
+ description: str,
80
+ preference_injection: str | None,
81
+ ) -> str:
82
+ lines = [
83
+ "=== THREAT BRIEFING ===",
84
+ f"Threat type: {threat_type}",
85
+ f"Severity (1-10): {severity}",
86
+ f"Source IP: {source_ip}",
87
+ f"Target service: {target_service}",
88
+ f"Description: {description}",
89
+ f"HIPAA / data residency context: primary region is {HIPAA_RESIDENCY}.",
90
+ ]
91
+ if preference_injection and preference_injection.strip():
92
+ lines.append(
93
+ f"Board directive (preference injection): {preference_injection.strip()}"
94
+ )
95
+ lines.append("=== END BRIEFING ===")
96
+ return "\n".join(lines)
97
+
98
+
99
+ def _extract_proposed_action(text: str) -> str:
100
+ if not text:
101
+ return ""
102
+ m = re.search(
103
+ r"PROPOSED_ACTION:\s*(.+?)(?:\s*$)",
104
+ text,
105
+ flags=re.IGNORECASE | re.MULTILINE,
106
+ )
107
+ if m:
108
+ return m.group(1).strip()
109
+ return ""
110
+
111
+
112
+ def _strip_proposed_line(text: str) -> str:
113
+ return re.sub(
114
+ r"\n*PROPOSED_ACTION:\s*.+$",
115
+ "",
116
+ text,
117
+ flags=re.IGNORECASE | re.MULTILINE,
118
+ ).strip()
119
+
120
+
121
+ @dataclass(frozen=True)
122
+ class _WarRoomLLM:
123
+ """Resolved backend for one debate run."""
124
+
125
+ label: str
126
+ anthropic: bool
127
+ model: str
128
+ api_key: str
129
+ openai_base: str | None = None
130
+
131
+
132
+ def _resolve_war_room_llm() -> _WarRoomLLM:
133
+ """
134
+ Pick provider from ``WAR_ROOM_PROVIDER`` (``auto`` | ``groq`` | ``openai`` | ``anthropic``)
135
+ and env keys. Default ``auto`` prefers Groq, then OpenAI-compatible, then Anthropic.
136
+ """
137
+ prov = (os.environ.get("WAR_ROOM_PROVIDER") or "auto").strip().lower()
138
+ groq_key = (os.environ.get("GROQ_API_KEY") or "").strip()
139
+ oa_key = (os.environ.get("OPENAI_API_KEY") or "").strip()
140
+ ant_key = (os.environ.get("ANTHROPIC_API_KEY") or "").strip()
141
+
142
+ if prov == "groq":
143
+ if not groq_key:
144
+ raise RuntimeError(
145
+ "WAR_ROOM_PROVIDER=groq but GROQ_API_KEY is not set. "
146
+ "Get a free key at https://console.groq.com/"
147
+ )
148
+ model = (os.environ.get("WAR_ROOM_MODEL") or DEFAULT_GROQ_MODEL).strip()
149
+ return _WarRoomLLM(
150
+ label="groq",
151
+ anthropic=False,
152
+ model=model,
153
+ api_key=groq_key,
154
+ openai_base=GROQ_BASE_URL,
155
+ )
156
+ if prov in ("openai", "openai_compatible", "openrouter"):
157
+ if not oa_key:
158
+ raise RuntimeError(
159
+ "WAR_ROOM_PROVIDER=openai but OPENAI_API_KEY is not set."
160
+ )
161
+ base = (os.environ.get("OPENAI_API_BASE") or "https://api.openai.com/v1").strip().rstrip("/")
162
+ model = (os.environ.get("WAR_ROOM_MODEL") or DEFAULT_OPENAI_MODEL).strip()
163
+ return _WarRoomLLM(
164
+ label="openai_compatible",
165
+ anthropic=False,
166
+ model=model,
167
+ api_key=oa_key,
168
+ openai_base=base,
169
+ )
170
+ if prov == "anthropic":
171
+ if not ant_key:
172
+ raise RuntimeError(
173
+ "WAR_ROOM_PROVIDER=anthropic but ANTHROPIC_API_KEY is not set."
174
+ )
175
+ model = (os.environ.get("WAR_ROOM_MODEL") or DEFAULT_CLAUDE_MODEL).strip()
176
+ return _WarRoomLLM(
177
+ label="anthropic",
178
+ anthropic=True,
179
+ model=model,
180
+ api_key=ant_key,
181
+ openai_base=None,
182
+ )
183
+
184
+ # auto
185
+ if groq_key:
186
+ model = (os.environ.get("WAR_ROOM_MODEL") or DEFAULT_GROQ_MODEL).strip()
187
+ return _WarRoomLLM(
188
+ label="groq",
189
+ anthropic=False,
190
+ model=model,
191
+ api_key=groq_key,
192
+ openai_base=GROQ_BASE_URL,
193
+ )
194
+ if oa_key:
195
+ base = (os.environ.get("OPENAI_API_BASE") or "https://api.openai.com/v1").strip().rstrip("/")
196
+ model = (os.environ.get("WAR_ROOM_MODEL") or DEFAULT_OPENAI_MODEL).strip()
197
+ return _WarRoomLLM(
198
+ label="openai_compatible",
199
+ anthropic=False,
200
+ model=model,
201
+ api_key=oa_key,
202
+ openai_base=base,
203
+ )
204
+ if ant_key:
205
+ model = (os.environ.get("WAR_ROOM_MODEL") or DEFAULT_CLAUDE_MODEL).strip()
206
+ return _WarRoomLLM(
207
+ label="anthropic",
208
+ anthropic=True,
209
+ model=model,
210
+ api_key=ant_key,
211
+ openai_base=None,
212
+ )
213
+ raise RuntimeError(
214
+ "No LLM API key configured for the War Room. Use one of:\n"
215
+ " • GROQ_API_KEY — free tier at https://console.groq.com/ (recommended)\n"
216
+ " • OPENAI_API_KEY — optional OPENAI_API_BASE for OpenRouter / local OpenAI-compatible APIs\n"
217
+ " • ANTHROPIC_API_KEY — Claude (paid)\n"
218
+ "Optional: WAR_ROOM_PROVIDER=auto|groq|openai|anthropic and WAR_ROOM_MODEL=…"
219
+ )
220
+
221
+
222
+ def _call_anthropic_sync(
223
+ api_key: str, model: str, system: str, user: str, max_tokens: int
224
+ ) -> str:
225
+ payload = {
226
+ "model": model,
227
+ "max_tokens": max_tokens,
228
+ "system": system,
229
+ "messages": [{"role": "user", "content": user}],
230
+ }
231
+ r = requests.post(
232
+ ANTHROPIC_URL,
233
+ headers={
234
+ "x-api-key": api_key,
235
+ "anthropic-version": ANTHROPIC_VERSION,
236
+ "content-type": "application/json",
237
+ },
238
+ data=json.dumps(payload),
239
+ timeout=120,
240
+ )
241
+ if r.status_code >= 400:
242
+ raise RuntimeError(
243
+ f"Anthropic API error {r.status_code}: {r.text[:800]}"
244
+ )
245
+ data = r.json()
246
+ blocks = data.get("content") or []
247
+ parts = []
248
+ for b in blocks:
249
+ if isinstance(b, dict) and b.get("type") == "text":
250
+ parts.append(b.get("text") or "")
251
+ return "".join(parts).strip()
252
+
253
+
254
+ def _call_openai_compatible_sync(
255
+ base_url: str,
256
+ api_key: str,
257
+ model: str,
258
+ system: str,
259
+ user: str,
260
+ max_tokens: int,
261
+ ) -> str:
262
+ url = base_url.rstrip("/") + OPENAI_CHAT_PATH
263
+ payload = {
264
+ "model": model,
265
+ "max_tokens": max_tokens,
266
+ "messages": [
267
+ {"role": "system", "content": system},
268
+ {"role": "user", "content": user},
269
+ ],
270
+ }
271
+ r = requests.post(
272
+ url,
273
+ headers={
274
+ "authorization": f"Bearer {api_key}",
275
+ "content-type": "application/json",
276
+ },
277
+ data=json.dumps(payload),
278
+ timeout=120,
279
+ )
280
+ if r.status_code >= 400:
281
+ raise RuntimeError(
282
+ f"Chat API error ({base_url}) {r.status_code}: {r.text[:800]}"
283
+ )
284
+ data = r.json()
285
+ choices = data.get("choices") or []
286
+ if not choices:
287
+ return ""
288
+ msg = choices[0].get("message") or {}
289
+ content = msg.get("content")
290
+ if isinstance(content, str):
291
+ return content.strip()
292
+ if isinstance(content, list):
293
+ parts = []
294
+ for part in content:
295
+ if isinstance(part, dict) and part.get("type") == "text":
296
+ parts.append(part.get("text") or "")
297
+ return "".join(parts).strip()
298
+ return ""
299
+
300
+
301
+ def _call_llm_sync(
302
+ backend: _WarRoomLLM, system: str, user: str, max_tokens: int = 1200
303
+ ) -> str:
304
+ if backend.anthropic:
305
+ return _call_anthropic_sync(
306
+ backend.api_key, backend.model, system, user, max_tokens
307
+ )
308
+ assert backend.openai_base
309
+ return _call_openai_compatible_sync(
310
+ backend.openai_base,
311
+ backend.api_key,
312
+ backend.model,
313
+ system,
314
+ user,
315
+ max_tokens,
316
+ )
317
+
318
+
319
+ async def _call_llm(
320
+ backend: _WarRoomLLM, system: str, user: str, max_tokens: int = 1200
321
+ ) -> str:
322
+ return await asyncio.to_thread(
323
+ _call_llm_sync, backend, system, user, max_tokens
324
+ )
325
+
326
+
327
+ def _hallucination_flags_for_text(text: str, proposed: str) -> list[str]:
328
+ flags: list[str] = []
329
+ combined = f"{text}\n{proposed}".lower()
330
+ blockish = "block" in combined
331
+ if blockish:
332
+ for ip in PROTECTED_IPS:
333
+ if ip in text or ip in proposed:
334
+ flags.append(
335
+ f"Possible hallucination: blocking protected infra IP {ip}."
336
+ )
337
+ if HIPAA_RESIDENCY == "us-east-1":
338
+ migration_hint = re.search(
339
+ r"migrat|relocate|re-?host|move\s+(workloads|data|traffic)|failover\s+to",
340
+ combined,
341
+ )
342
+ for region in ("eu-west", "ap-southeast"):
343
+ if region in combined and migration_hint:
344
+ flags.append(
345
+ f"Possible hallucination: suggests migration to {region} while "
346
+ f"HIPAA residency is {HIPAA_RESIDENCY}."
347
+ )
348
+ return flags
349
+
350
+
351
+ def _normalize_vote(s: str) -> str:
352
+ return re.sub(r"\s+", " ", (s or "").strip().lower())
353
+
354
+
355
+ @dataclass
356
+ class AgentOutput:
357
+ agent_id: str
358
+ display_name: str
359
+ role: str
360
+ color_class: str
361
+ position_text: str
362
+ proposed_action: str
363
+ hallucination_flags: list[str] = field(default_factory=list)
364
+
365
+
366
+ @dataclass
367
+ class CrossExamOutput:
368
+ examiner_id: str
369
+ examiner_name: str
370
+ target_id: str
371
+ target_name: str
372
+ text: str
373
+ proposed_action: str
374
+ hallucination_flags: list[str] = field(default_factory=list)
375
+
376
+
377
+ def tally_votes(
378
+ ciso_a: str,
379
+ devops_a: str,
380
+ arch_a: str,
381
+ preference_injection: str | None,
382
+ ) -> dict[str, Any]:
383
+ actions = [
384
+ ("ciso", ciso_a or "Block Source IP"),
385
+ ("devops", devops_a or "Negotiate Connection Drain"),
386
+ ("architect", arch_a or "Pivot to Compliance Enclave"),
387
+ ]
388
+ normalized = [(aid, a, _normalize_vote(a)) for aid, a in actions]
389
+ by_norm: dict[str, list[str]] = {}
390
+ for aid, raw, norm in normalized:
391
+ if not norm:
392
+ norm = _normalize_vote(raw)
393
+ by_norm.setdefault(norm, []).append(aid)
394
+
395
+ winner_norm = None
396
+ for norm, ids in by_norm.items():
397
+ if len(ids) >= 2:
398
+ winner_norm = norm
399
+ break
400
+
401
+ if winner_norm is not None:
402
+ # Use first raw spelling from an agent in the majority
403
+ majority_ids = set(by_norm[winner_norm])
404
+ consensus_raw = next(
405
+ (raw for aid, raw, n in normalized if aid in majority_ids),
406
+ actions[0][1],
407
+ )
408
+ return {
409
+ "status": "Consensus Reached",
410
+ "consensus_action": consensus_raw,
411
+ "votes_detail": [
412
+ {"agent": aid, "action": raw} for aid, raw, _ in normalized
413
+ ],
414
+ }
415
+
416
+ if preference_injection and preference_injection.strip():
417
+ arch_raw = next((r for aid, r, _ in normalized if aid == "architect"), arch_a)
418
+ return {
419
+ "status": "Consensus Reached via Board Directive",
420
+ "consensus_action": arch_raw or "Pivot to Compliance Enclave",
421
+ "votes_detail": [
422
+ {"agent": aid, "action": raw} for aid, raw, _ in normalized
423
+ ],
424
+ }
425
+
426
+ return {
427
+ "status": "Deadlock",
428
+ "consensus_action": None,
429
+ "votes_detail": [{"agent": aid, "action": raw} for aid, raw, _ in normalized],
430
+ }
431
+
432
+
433
+ async def run_war_room_debate(
434
+ threat_type: str,
435
+ severity: int,
436
+ source_ip: str,
437
+ target_service: str,
438
+ description: str,
439
+ preference_injection: str | None,
440
+ ) -> dict[str, Any]:
441
+ backend = _resolve_war_room_llm()
442
+ briefing = format_threat_briefing(
443
+ threat_type,
444
+ severity,
445
+ source_ip,
446
+ target_service,
447
+ description,
448
+ preference_injection,
449
+ )
450
+ pos_user = briefing + USER_SUFFIX_POSITION
451
+
452
+ ciso_t, devops_t, arch_t = await asyncio.gather(
453
+ _call_llm(backend, SYSTEM_CISO, pos_user),
454
+ _call_llm(backend, SYSTEM_DEVOPS, pos_user),
455
+ _call_llm(backend, SYSTEM_ARCHITECT, pos_user),
456
+ )
457
+
458
+ ciso_action = _extract_proposed_action(ciso_t) or "Block Source IP"
459
+ devops_action = _extract_proposed_action(devops_t) or "Negotiate Connection Drain"
460
+ arch_action = _extract_proposed_action(arch_t) or "Pivot to Compliance Enclave"
461
+
462
+ cross_ciso_user = (
463
+ f"{briefing}\n\nYou are cross-examining the DevOps Lead Agent. "
464
+ f"Their initial position was:\n{devops_t}\n"
465
+ f"Challenge their proposal with your security priorities."
466
+ f"{USER_SUFFIX_CROSS}"
467
+ )
468
+ cross_devops_user = (
469
+ f"{briefing}\n\nYou are cross-examining the Lead Architect Agent. "
470
+ f"Their initial position was:\n{arch_t}\n"
471
+ f"Challenge their proposal with uptime and operational constraints."
472
+ f"{USER_SUFFIX_CROSS}"
473
+ )
474
+ cross_arch_user = (
475
+ f"{briefing}\n\nYou are cross-examining the CISO Agent. "
476
+ f"Their initial position was:\n{ciso_t}\n"
477
+ f"Challenge their proposal with compliance and architecture framing."
478
+ f"{USER_SUFFIX_CROSS}"
479
+ )
480
+
481
+ cross_ciso, cross_devops, cross_arch = await asyncio.gather(
482
+ _call_llm(backend, SYSTEM_CISO, cross_ciso_user),
483
+ _call_llm(backend, SYSTEM_DEVOPS, cross_devops_user),
484
+ _call_llm(backend, SYSTEM_ARCHITECT, cross_arch_user),
485
+ )
486
+
487
+ cross_outputs: list[CrossExamOutput] = [
488
+ CrossExamOutput(
489
+ examiner_id="ciso",
490
+ examiner_name="CISO",
491
+ target_id="devops",
492
+ target_name="DevOps Lead",
493
+ text=cross_ciso,
494
+ proposed_action=_extract_proposed_action(cross_ciso),
495
+ hallucination_flags=_hallucination_flags_for_text(
496
+ cross_ciso, _extract_proposed_action(cross_ciso)
497
+ ),
498
+ ),
499
+ CrossExamOutput(
500
+ examiner_id="devops",
501
+ examiner_name="DevOps Lead",
502
+ target_id="architect",
503
+ target_name="Lead Architect",
504
+ text=cross_devops,
505
+ proposed_action=_extract_proposed_action(cross_devops),
506
+ hallucination_flags=_hallucination_flags_for_text(
507
+ cross_devops, _extract_proposed_action(cross_devops)
508
+ ),
509
+ ),
510
+ CrossExamOutput(
511
+ examiner_id="architect",
512
+ examiner_name="Lead Architect",
513
+ target_id="ciso",
514
+ target_name="CISO",
515
+ text=cross_arch,
516
+ proposed_action=_extract_proposed_action(cross_arch),
517
+ hallucination_flags=_hallucination_flags_for_text(
518
+ cross_arch, _extract_proposed_action(cross_arch)
519
+ ),
520
+ ),
521
+ ]
522
+
523
+ examiner_flags: dict[str, list[str]] = {c.examiner_id: c.hallucination_flags for c in cross_outputs}
524
+
525
+ agents = [
526
+ AgentOutput(
527
+ agent_id="ciso",
528
+ display_name="CISO",
529
+ role="Risk eliminator · MITRE ATT&CK",
530
+ color_class="agent-ciso",
531
+ position_text=_strip_proposed_line(ciso_t),
532
+ proposed_action=ciso_action,
533
+ hallucination_flags=examiner_flags.get("ciso", []),
534
+ ),
535
+ AgentOutput(
536
+ agent_id="devops",
537
+ display_name="DevOps Lead",
538
+ role="Uptime maximizer · drain windows",
539
+ color_class="agent-devops",
540
+ position_text=_strip_proposed_line(devops_t),
541
+ proposed_action=devops_action,
542
+ hallucination_flags=examiner_flags.get("devops", []),
543
+ ),
544
+ AgentOutput(
545
+ agent_id="architect",
546
+ display_name="Lead Architect",
547
+ role="Compliance arbiter · HIPAA / SOC2 / GDPR",
548
+ color_class="agent-architect",
549
+ position_text=_strip_proposed_line(arch_t),
550
+ proposed_action=arch_action,
551
+ hallucination_flags=examiner_flags.get("architect", []),
552
+ ),
553
+ ]
554
+
555
+ verdict = tally_votes(
556
+ ciso_action, devops_action, arch_action, preference_injection
557
+ )
558
+
559
+ transcript: list[dict[str, Any]] = [
560
+ {"phase": "briefing", "content": briefing},
561
+ ]
562
+ for aid, raw_text in (
563
+ ("ciso", ciso_t),
564
+ ("devops", devops_t),
565
+ ("architect", arch_t),
566
+ ):
567
+ transcript.append(
568
+ {
569
+ "phase": "initial_position",
570
+ "agent": aid,
571
+ "content": raw_text,
572
+ }
573
+ )
574
+ for c in cross_outputs:
575
+ transcript.append(
576
+ {
577
+ "phase": "cross_exam",
578
+ "examiner": c.examiner_id,
579
+ "target": c.target_id,
580
+ "content": c.text,
581
+ "hallucination_flags": c.hallucination_flags,
582
+ }
583
+ )
584
+ transcript.append({"phase": "verdict", "content": verdict})
585
+
586
+ return {
587
+ "agents": [a.__dict__ for a in agents],
588
+ "cross_examination": [
589
+ {**c.__dict__, "text": _strip_proposed_line(c.text)} for c in cross_outputs
590
+ ],
591
+ "verdict": verdict,
592
+ "transcript": transcript,
593
+ "model": backend.model,
594
+ "llm_provider": backend.label,
595
+ }
596
+
597
+
598
+ __all__ = [
599
+ "run_war_room_debate",
600
+ "format_threat_briefing",
601
+ "SYSTEM_CISO",
602
+ "SYSTEM_DEVOPS",
603
+ "SYSTEM_ARCHITECT",
604
+ ]