Aaron Brown Claude Opus 4.6 commited on
Commit
fe0a566
·
1 Parent(s): 61c3a2c

Agent protocols: pluggable Builder, NPC, Validator

Browse files

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Files changed (2) hide show
  1. docs/agent-protocols.md +566 -0
  2. docs/architecture.md +71 -16
docs/agent-protocols.md ADDED
@@ -0,0 +1,566 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Agent Protocols: Pluggable Infrastructure Components
2
+
3
+ ## Design Principle
4
+
5
+ OpenRange has three kinds of "agents" that use LLMs as tools:
6
+
7
+ | Component | Role | Hot Path? | Default |
8
+ |-----------|------|-----------|---------|
9
+ | **Builder** | Generate snapshot specs from manifests | No (async between episodes) | LLM via LiteLLM |
10
+ | **NPC Behavior** | Decide NPC response to stimuli | No (async on NPC schedule) | LLM via LiteLLM |
11
+ | **Validator Checks** | Admission gate checks | No (async between episodes) | Mechanical (no LLM) |
12
+
13
+ These are NOT training agents (Red/Blue are external). They are **infrastructure components** that happen to use LLMs. Each follows the same pluggability pattern:
14
+
15
+ 1. **Protocol** defines the interface (structural subtyping, no inheritance)
16
+ 2. **Default implementation** uses LiteLLM for model-agnostic LLM access
17
+ 3. **Configuration** via YAML manifest (class path + kwargs)
18
+ 4. **Resolution** via dynamic import + Protocol check at startup
19
+
20
+ ## Protocols
21
+
22
+ ```python
23
+ from typing import Protocol, runtime_checkable
24
+
25
+ # ---------------------------------------------------------------------------
26
+ # Builder — generates candidate snapshot specs
27
+ # ---------------------------------------------------------------------------
28
+
29
+ @runtime_checkable
30
+ class SnapshotBuilder(Protocol):
31
+ """Generate a candidate snapshot spec from a manifest.
32
+
33
+ Any class with a matching build() signature satisfies this protocol.
34
+ No base class inheritance required.
35
+
36
+ Example::
37
+
38
+ class MyBuilder:
39
+ async def build(self, manifest, context) -> SnapshotSpec:
40
+ # Your logic — LLM, template, deterministic, whatever
41
+ return SnapshotSpec(...)
42
+
43
+ assert isinstance(MyBuilder(), SnapshotBuilder)
44
+ """
45
+
46
+ async def build(
47
+ self,
48
+ manifest: dict,
49
+ context: BuildContext,
50
+ ) -> SnapshotSpec:
51
+ """Generate a candidate snapshot spec.
52
+
53
+ Args:
54
+ manifest: Parsed YAML manifest (topology, bug_families, etc.)
55
+ context: Runtime context (curriculum stats, previous solve rates)
56
+
57
+ Returns:
58
+ SnapshotSpec with topology, truth_graph, golden_path, etc.
59
+ """
60
+ ...
61
+
62
+
63
+ # ---------------------------------------------------------------------------
64
+ # NPC Behavior — decides NPC response to stimuli
65
+ # ---------------------------------------------------------------------------
66
+
67
+ @runtime_checkable
68
+ class NPCBehavior(Protocol):
69
+ """Decide how an NPC responds to a stimulus.
70
+
71
+ Example::
72
+
73
+ class MyNPCBehavior:
74
+ async def decide(self, persona, stimulus) -> NPCAction:
75
+ if persona.security_awareness > 0.8:
76
+ return NPCAction(action="report_to_IT")
77
+ return NPCAction(action="click_link")
78
+
79
+ assert isinstance(MyNPCBehavior(), NPCBehavior)
80
+ """
81
+
82
+ async def decide(
83
+ self,
84
+ persona: NPCPersona,
85
+ stimulus: Stimulus,
86
+ ) -> NPCAction:
87
+ """Decide NPC response to a stimulus.
88
+
89
+ Args:
90
+ persona: NPC persona card (name, role, security_awareness, etc.)
91
+ stimulus: Incoming stimulus (email, chat message, file access, etc.)
92
+
93
+ Returns:
94
+ NPCAction with action type and optional response content.
95
+ """
96
+ ...
97
+
98
+
99
+ # ---------------------------------------------------------------------------
100
+ # Validator Check — single admission check in the validation pipeline
101
+ # ---------------------------------------------------------------------------
102
+
103
+ @runtime_checkable
104
+ class ValidatorCheck(Protocol):
105
+ """Single check in the validator admission pipeline.
106
+
107
+ Example::
108
+
109
+ class MyCheck:
110
+ async def check(self, snapshot, containers) -> CheckResult:
111
+ # Run your check against live containers
112
+ return CheckResult(passed=True, time_s=2.1)
113
+
114
+ assert isinstance(MyCheck(), ValidatorCheck)
115
+ """
116
+
117
+ async def check(
118
+ self,
119
+ snapshot: SnapshotSpec,
120
+ containers: ContainerSet,
121
+ ) -> CheckResult:
122
+ """Run this admission check.
123
+
124
+ Args:
125
+ snapshot: The candidate snapshot spec (truth_graph, golden_path, etc.)
126
+ containers: Handle to the live Docker containers for this snapshot.
127
+
128
+ Returns:
129
+ CheckResult with pass/fail, timing, and failure details.
130
+ """
131
+ ...
132
+ ```
133
+
134
+ ## Default Implementations
135
+
136
+ ### Builder
137
+
138
+ | Implementation | When to use | LLM? |
139
+ |----------------|------------|------|
140
+ | `LLMSnapshotBuilder` | Production — creative snapshot generation | Yes (LiteLLM) |
141
+ | `TemplateOnlyBuilder` | Testing/CI — deterministic, no API calls | No |
142
+ | `FileBuilder` | Demo — load pre-built snapshot from JSON file | No |
143
+
144
+ ```python
145
+ class LLMSnapshotBuilder:
146
+ """Default builder: uses LiteLLM to generate snapshot specs."""
147
+
148
+ def __init__(
149
+ self,
150
+ model: str | None = None,
151
+ prompt_template: str | None = None,
152
+ temperature: float = 0.7,
153
+ max_retries: int = 3,
154
+ ):
155
+ self.model = model or os.environ.get(
156
+ "OPENRANGE_BUILDER_MODEL", "anthropic/claude-sonnet-4-20250514"
157
+ )
158
+ self.prompt_template = prompt_template or DEFAULT_BUILDER_PROMPT
159
+ self.temperature = temperature
160
+ self.max_retries = max_retries
161
+
162
+ async def build(self, manifest: dict, context: BuildContext) -> SnapshotSpec:
163
+ response = await litellm.acompletion(
164
+ model=self.model,
165
+ messages=[
166
+ {"role": "system", "content": self.prompt_template},
167
+ {"role": "user", "content": json.dumps({
168
+ "manifest": manifest,
169
+ "runtime_context": asdict(context),
170
+ })},
171
+ ],
172
+ response_format={"type": "json_object"},
173
+ temperature=self.temperature,
174
+ )
175
+ return SnapshotSpec.model_validate_json(
176
+ response.choices[0].message.content
177
+ )
178
+
179
+
180
+ class TemplateOnlyBuilder:
181
+ """Deterministic builder for testing. No LLM calls."""
182
+
183
+ def __init__(self, vuln_pool: list[dict] | None = None):
184
+ self.vuln_pool = vuln_pool or DEFAULT_VULN_POOL
185
+
186
+ async def build(self, manifest: dict, context: BuildContext) -> SnapshotSpec:
187
+ # Pick vulns deterministically from pool based on manifest
188
+ vulns = select_vulns(self.vuln_pool, manifest, context.seed)
189
+ return SnapshotSpec(
190
+ topology=manifest["topology"],
191
+ truth_graph=build_truth_graph(vulns),
192
+ golden_path=derive_golden_path(vulns),
193
+ # ... render mechanically from templates
194
+ )
195
+
196
+
197
+ class FileBuilder:
198
+ """Load a pre-built snapshot from disk. For demos and smoke tests."""
199
+
200
+ def __init__(self, snapshot_dir: str):
201
+ self.snapshot_dir = Path(snapshot_dir)
202
+
203
+ async def build(self, manifest: dict, context: BuildContext) -> SnapshotSpec:
204
+ files = sorted(self.snapshot_dir.glob("*.json"))
205
+ chosen = files[context.seed % len(files)] if context.seed else files[0]
206
+ return SnapshotSpec.model_validate_json(chosen.read_text())
207
+ ```
208
+
209
+ ### NPC Behavior
210
+
211
+ | Implementation | When to use | LLM? |
212
+ |----------------|------------|------|
213
+ | `LLMNPCBehavior` | Level 1+ — persona-driven decisions | Yes (LiteLLM) |
214
+ | `RuleBasedNPCBehavior` | Mid-ground — heuristic susceptibility checks | No |
215
+ | `NullNPCBehavior` | Level 0 — shell scripts handle everything | No |
216
+
217
+ ```python
218
+ class LLMNPCBehavior:
219
+ """LLM-driven NPC decisions based on persona cards."""
220
+
221
+ def __init__(self, model: str | None = None, temperature: float = 0.3):
222
+ self.model = model or os.environ.get(
223
+ "OPENRANGE_NPC_MODEL", "anthropic/claude-haiku-4-5-20251001"
224
+ )
225
+ self.temperature = temperature
226
+
227
+ async def decide(self, persona: NPCPersona, stimulus: Stimulus) -> NPCAction:
228
+ response = await litellm.acompletion(
229
+ model=self.model,
230
+ messages=[
231
+ {"role": "system", "content": NPC_SYSTEM_PROMPT},
232
+ {"role": "user", "content": json.dumps({
233
+ "persona": persona.model_dump(),
234
+ "stimulus": stimulus.model_dump(),
235
+ })},
236
+ ],
237
+ response_format={"type": "json_object"},
238
+ temperature=self.temperature,
239
+ )
240
+ return NPCAction.model_validate_json(
241
+ response.choices[0].message.content
242
+ )
243
+
244
+
245
+ class RuleBasedNPCBehavior:
246
+ """Heuristic NPC decisions. No LLM calls."""
247
+
248
+ async def decide(self, persona: NPCPersona, stimulus: Stimulus) -> NPCAction:
249
+ score = stimulus.plausibility * persona.susceptibility[stimulus.type]
250
+ if score > 0.6:
251
+ return NPCAction(action="click_link")
252
+ elif score > 0.3:
253
+ return NPCAction(action="ignore")
254
+ else:
255
+ return NPCAction(action="report_to_IT")
256
+
257
+
258
+ class NullNPCBehavior:
259
+ """No-op. Level 0 shell scripts handle all NPC traffic."""
260
+
261
+ async def decide(self, persona: NPCPersona, stimulus: Stimulus) -> NPCAction:
262
+ return NPCAction(action="ignore")
263
+ ```
264
+
265
+ ### Validator Checks
266
+
267
+ Each check is already a separate class. The validator pipeline is a **list of checks** — add, remove, or reorder via config.
268
+
269
+ ```python
270
+ # Built-in checks
271
+ class BuildBootCheck:
272
+ async def check(self, snapshot, containers) -> CheckResult: ...
273
+
274
+ class ExploitabilityCheck:
275
+ async def check(self, snapshot, containers) -> CheckResult: ...
276
+
277
+ class PatchabilityCheck:
278
+ async def check(self, snapshot, containers) -> CheckResult: ...
279
+
280
+ class EvidenceSufficiencyCheck:
281
+ async def check(self, snapshot, containers) -> CheckResult: ...
282
+
283
+ class RewardGroundingCheck:
284
+ async def check(self, snapshot, containers) -> CheckResult: ...
285
+
286
+ class IsolationLeakageCheck:
287
+ async def check(self, snapshot, containers) -> CheckResult: ...
288
+
289
+ class NPCConsistencyCheck:
290
+ """Requires NPC behavior implementation to test against."""
291
+ def __init__(self, npc_behavior_class: str | None = None, **kwargs):
292
+ self.npc_behavior = resolve_component(
293
+ npc_behavior_class or "open_range.npc.LLMNPCBehavior",
294
+ kwargs, NPCBehavior
295
+ )
296
+
297
+ async def check(self, snapshot, containers) -> CheckResult: ...
298
+ ```
299
+
300
+ ## Configuration
301
+
302
+ All component selection happens in the manifest YAML (or a separate `openrange.yaml`). This keeps everything in one place and version-controllable.
303
+
304
+ ```yaml
305
+ # openrange.yaml — component configuration
306
+ agents:
307
+ builder:
308
+ class: open_range.builder.LLMSnapshotBuilder
309
+ kwargs:
310
+ model: "anthropic/claude-sonnet-4-20250514"
311
+ prompt_template: "prompts/builder_v2.txt"
312
+ temperature: 0.7
313
+ max_retries: 3
314
+
315
+ npc_behavior:
316
+ class: open_range.npc.LLMNPCBehavior
317
+ kwargs:
318
+ model: "anthropic/claude-haiku-4-5-20251001"
319
+ temperature: 0.3
320
+
321
+ validator_checks:
322
+ - class: open_range.validator.BuildBootCheck
323
+ - class: open_range.validator.ExploitabilityCheck
324
+ - class: open_range.validator.PatchabilityCheck
325
+ - class: open_range.validator.EvidenceSufficiencyCheck
326
+ - class: open_range.validator.RewardGroundingCheck
327
+ - class: open_range.validator.IsolationLeakageCheck
328
+ - class: open_range.validator.NPCConsistencyCheck
329
+ kwargs:
330
+ npc_behavior_class: open_range.npc.LLMNPCBehavior
331
+ model: "anthropic/claude-haiku-4-5-20251001"
332
+ ```
333
+
334
+ ### Override via Environment Variables
335
+
336
+ LiteLLM model strings can always be overridden by env vars (useful for CI, testing, different providers):
337
+
338
+ | Env Var | Overrides | Example |
339
+ |---------|-----------|---------|
340
+ | `OPENRANGE_BUILDER_MODEL` | Builder model | `gpt-4o`, `ollama/llama3`, `anthropic/claude-sonnet-4-20250514` |
341
+ | `OPENRANGE_NPC_MODEL` | NPC model | `anthropic/claude-haiku-4-5-20251001`, `ollama/phi3` |
342
+ | `LITELLM_API_KEY` | Global API key | (or model-specific: `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`) |
343
+
344
+ Env vars take precedence over YAML config. This lets you define the architecture in YAML but swap models at deploy time.
345
+
346
+ ### Testing Profile
347
+
348
+ ```yaml
349
+ # openrange-test.yaml — no LLM calls, deterministic
350
+ agents:
351
+ builder:
352
+ class: open_range.builder.TemplateOnlyBuilder
353
+ kwargs:
354
+ vuln_pool: "vulns/test_pool.json"
355
+ npc_behavior:
356
+ class: open_range.npc.NullNPCBehavior
357
+ validator_checks:
358
+ - class: open_range.validator.BuildBootCheck
359
+ # Skip slow checks in tests
360
+ ```
361
+
362
+ ### Demo Profile
363
+
364
+ ```yaml
365
+ # openrange-demo.yaml — pre-built snapshots, fast resets
366
+ agents:
367
+ builder:
368
+ class: open_range.builder.FileBuilder
369
+ kwargs:
370
+ snapshot_dir: "snapshots/demo/"
371
+ npc_behavior:
372
+ class: open_range.npc.RuleBasedNPCBehavior
373
+ validator_checks: [] # Pre-validated snapshots, skip validation
374
+ ```
375
+
376
+ ## Resolution
377
+
378
+ Dynamic import with Protocol check at startup. Same pattern as open-ctf-env's evaluator.
379
+
380
+ ```python
381
+ import importlib
382
+ from typing import Any, Type
383
+
384
+ def resolve_component(class_path: str, kwargs: dict, protocol: Type) -> Any:
385
+ """Import class by dotted path, instantiate, verify protocol compliance.
386
+
387
+ Args:
388
+ class_path: Dotted Python class path (e.g., "open_range.builder.LLMSnapshotBuilder")
389
+ kwargs: Constructor keyword arguments
390
+ protocol: Protocol class to check against
391
+
392
+ Returns:
393
+ Instantiated component that satisfies the protocol.
394
+
395
+ Raises:
396
+ TypeError: If the instantiated class doesn't satisfy the protocol.
397
+ """
398
+ module_name, _, class_name = class_path.rpartition(".")
399
+ module = importlib.import_module(module_name)
400
+ cls = getattr(module, class_name)
401
+ instance = cls(**kwargs)
402
+ if not isinstance(instance, protocol):
403
+ raise TypeError(
404
+ f"{class_path} does not satisfy {protocol.__name__} protocol. "
405
+ f"Missing methods: {_missing_methods(instance, protocol)}"
406
+ )
407
+ return instance
408
+
409
+
410
+ def load_agent_config(config_path: str) -> dict:
411
+ """Load agent configuration from YAML."""
412
+ with open(config_path) as f:
413
+ config = yaml.safe_load(f)
414
+ return config.get("agents", {})
415
+
416
+
417
+ def build_components(config: dict) -> tuple[SnapshotBuilder, NPCBehavior, list[ValidatorCheck]]:
418
+ """Resolve all infrastructure components from config."""
419
+ builder_cfg = config.get("builder", {})
420
+ builder = resolve_component(
421
+ builder_cfg.get("class", "open_range.builder.LLMSnapshotBuilder"),
422
+ builder_cfg.get("kwargs", {}),
423
+ SnapshotBuilder,
424
+ )
425
+
426
+ npc_cfg = config.get("npc_behavior", {})
427
+ npc = resolve_component(
428
+ npc_cfg.get("class", "open_range.npc.NullNPCBehavior"),
429
+ npc_cfg.get("kwargs", {}),
430
+ NPCBehavior,
431
+ )
432
+
433
+ checks = []
434
+ for check_cfg in config.get("validator_checks", DEFAULT_CHECKS):
435
+ checks.append(resolve_component(
436
+ check_cfg["class"],
437
+ check_cfg.get("kwargs", {}),
438
+ ValidatorCheck,
439
+ ))
440
+
441
+ return builder, npc, checks
442
+ ```
443
+
444
+ ## How Components Wire Together
445
+
446
+ ```mermaid
447
+ flowchart TB
448
+ CONFIG[openrange.yaml<br/>agent class paths + kwargs] --> RESOLVE[resolve_component<br/>importlib + Protocol check]
449
+
450
+ RESOLVE --> BLD[SnapshotBuilder]
451
+ RESOLVE --> NPC[NPCBehavior]
452
+ RESOLVE --> VAL[ValidatorCheck x N]
453
+
454
+ subgraph snapshot_loop [Async Snapshot Creation]
455
+ BLD -->|build| SPEC[SnapshotSpec]
456
+ SPEC --> VAL
457
+ VAL -->|all pass| STORE[Snapshot Store]
458
+ VAL -->|any fail| BLD
459
+ end
460
+
461
+ subgraph episode [Episode Loop]
462
+ STORE -->|reset selects frozen snapshot| ENV[RangeEnvironment]
463
+ ENV -->|step| DOCKER[Docker containers]
464
+ DOCKER --> OBS[Observation + Reward]
465
+ end
466
+
467
+ subgraph npc_loop [Async NPC Loop]
468
+ STIM[Stimulus from Postfix] --> NPC
469
+ NPC -->|action| SIDE[Side effects<br/>click link, reply, report]
470
+ SIDE --> LOGS[Container logs<br/>visible to Blue]
471
+ end
472
+
473
+ style CONFIG fill:#4a9eff22,stroke:#4a9eff
474
+ style RESOLVE fill:#ffd93d22,stroke:#ffd93d
475
+ style snapshot_loop fill:#ff6b6b11,stroke:#ff6b6b
476
+ style episode fill:#6bcb7711,stroke:#6bcb77
477
+ style npc_loop fill:#7c73e611,stroke:#7c73e6
478
+ ```
479
+
480
+ ## Extending: Bring Your Own Builder
481
+
482
+ Write a class with `async def build(self, manifest, context) -> SnapshotSpec`. That's it.
483
+
484
+ ```python
485
+ # my_custom_builder.py
486
+ class FineTunedBuilder:
487
+ """Uses a fine-tuned local model for snapshot generation."""
488
+
489
+ def __init__(self, model_path: str, device: str = "cuda"):
490
+ self.model = load_model(model_path, device)
491
+
492
+ async def build(self, manifest: dict, context: BuildContext) -> SnapshotSpec:
493
+ prompt = render_builder_prompt(manifest, context)
494
+ output = self.model.generate(prompt)
495
+ return SnapshotSpec.model_validate_json(output)
496
+ ```
497
+
498
+ ```yaml
499
+ # openrange.yaml
500
+ agents:
501
+ builder:
502
+ class: my_custom_builder.FineTunedBuilder
503
+ kwargs:
504
+ model_path: "/models/builder-ft-v3"
505
+ device: "cuda:0"
506
+ ```
507
+
508
+ No registration, no base class, no plugin system. Just match the Protocol signature and point the config at it.
509
+
510
+ ## Extending: Bring Your Own NPC
511
+
512
+ ```python
513
+ # my_npc.py
514
+ class VoiceNPC:
515
+ """Level 3 NPC: processes voice stimuli via Whisper + LLM."""
516
+
517
+ def __init__(self, whisper_model: str = "base", llm_model: str = None):
518
+ self.whisper = load_whisper(whisper_model)
519
+ self.llm_model = llm_model or os.environ.get("OPENRANGE_NPC_MODEL")
520
+
521
+ async def decide(self, persona: NPCPersona, stimulus: Stimulus) -> NPCAction:
522
+ if stimulus.type == "voice":
523
+ text = self.whisper.transcribe(stimulus.audio_path)
524
+ stimulus = stimulus.model_copy(update={"content": text, "type": "text"})
525
+ # Fall through to LLM decision
526
+ return await llm_decide(self.llm_model, persona, stimulus)
527
+ ```
528
+
529
+ ## Extending: Bring Your Own Validator Check
530
+
531
+ ```python
532
+ # my_checks.py
533
+ class CustomSecurityAudit:
534
+ """Run a security scanner against the snapshot."""
535
+
536
+ def __init__(self, scanner: str = "trivy"):
537
+ self.scanner = scanner
538
+
539
+ async def check(self, snapshot, containers) -> CheckResult:
540
+ result = await containers.exec("attacker", f"{self.scanner} scan --severity HIGH")
541
+ high_vulns = parse_scanner_output(result)
542
+ return CheckResult(
543
+ passed=len(high_vulns) == 0,
544
+ details={"unintended_vulns": high_vulns},
545
+ )
546
+ ```
547
+
548
+ ```yaml
549
+ agents:
550
+ validator_checks:
551
+ - class: open_range.validator.BuildBootCheck
552
+ - class: open_range.validator.ExploitabilityCheck
553
+ - class: my_checks.CustomSecurityAudit
554
+ kwargs:
555
+ scanner: "nuclei"
556
+ # ... rest of pipeline
557
+ ```
558
+
559
+ ## Key Decisions
560
+
561
+ 1. **Protocol over ABC**: Structural subtyping means zero coupling. Your implementation doesn't import anything from OpenRange.
562
+ 2. **YAML over code registration**: Configuration is data, not code. Version it, diff it, override it per environment.
563
+ 3. **Env vars override YAML**: Deploy-time model swaps without touching config files.
564
+ 4. **LiteLLM is the default, not the requirement**: Default implementations use LiteLLM. Custom implementations can use anything — local models, fine-tuned checkpoints, even non-LLM approaches.
565
+ 5. **Async throughout**: All protocols use `async def`. Infrastructure LLM calls are never in the `step()` hot path.
566
+ 6. **Validator checks are a list**: Add, remove, reorder checks via config. No hardcoded pipeline.
docs/architecture.md CHANGED
@@ -256,25 +256,80 @@ flowchart TB
256
 
257
  **Key design**: NPC LLM calls are **async, not in the step() hot path**. Red sends a phishing email to Postfix in one step. The NPC agent processes it on its own schedule (per `email_check_interval_min`). Red observes the result in later steps via access logs, new sessions, or SIEM alerts. Blue sees the same logs and must distinguish legitimate NPC-to-NPC email from Red's social engineering.
258
 
259
- ## LLM Integration via LiteLLM
260
 
261
- Builder uses LiteLLM for model-agnostic snapshot generation:
 
 
 
 
262
 
263
  ```python
264
- import litellm
265
-
266
- response = litellm.completion(
267
- model=os.environ.get("OPENRANGE_BUILDER_MODEL", "anthropic/claude-sonnet-4-20250514"),
268
- messages=[
269
- {"role": "system", "content": BUILDER_SYSTEM_PROMPT},
270
- {"role": "user", "content": json.dumps(builder_input)}
271
- ],
272
- response_format={"type": "json_object"},
273
- )
 
274
  ```
275
 
276
- Configure via environment variables:
277
- - `OPENRANGE_BUILDER_MODEL` -- which model generates snapshots
278
- - `LITELLM_API_KEY` or model-specific keys (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, etc.)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
279
 
280
- Validator is **purely mechanical** -- no LLM calls. All checks are executable scripts against live containers.
 
256
 
257
  **Key design**: NPC LLM calls are **async, not in the step() hot path**. Red sends a phishing email to Postfix in one step. The NPC agent processes it on its own schedule (per `email_check_interval_min`). Red observes the result in later steps via access logs, new sessions, or SIEM alerts. Blue sees the same logs and must distinguish legitimate NPC-to-NPC email from Red's social engineering.
258
 
259
+ ## Pluggable Infrastructure Components
260
 
261
+ Builder, NPC behavior, and validator checks are all **pluggable via Protocol-based structural subtyping**. No base class inheritance required. Any class with a matching method signature satisfies the protocol.
262
+
263
+ See [`docs/agent-protocols.md`](agent-protocols.md) for the full design.
264
+
265
+ ### Three Protocols
266
 
267
  ```python
268
+ @runtime_checkable
269
+ class SnapshotBuilder(Protocol):
270
+ async def build(self, manifest: dict, context: BuildContext) -> SnapshotSpec: ...
271
+
272
+ @runtime_checkable
273
+ class NPCBehavior(Protocol):
274
+ async def decide(self, persona: NPCPersona, stimulus: Stimulus) -> NPCAction: ...
275
+
276
+ @runtime_checkable
277
+ class ValidatorCheck(Protocol):
278
+ async def check(self, snapshot: SnapshotSpec, containers: ContainerSet) -> CheckResult: ...
279
  ```
280
 
281
+ ### Configuration via YAML
282
+
283
+ ```yaml
284
+ # openrange.yaml
285
+ agents:
286
+ builder:
287
+ class: open_range.builder.LLMSnapshotBuilder
288
+ kwargs:
289
+ model: "anthropic/claude-sonnet-4-20250514"
290
+ temperature: 0.7
291
+ npc_behavior:
292
+ class: open_range.npc.LLMNPCBehavior
293
+ kwargs:
294
+ model: "anthropic/claude-haiku-4-5-20251001"
295
+ validator_checks:
296
+ - class: open_range.validator.BuildBootCheck
297
+ - class: open_range.validator.ExploitabilityCheck
298
+ - class: open_range.validator.PatchabilityCheck
299
+ # ... add, remove, or reorder checks
300
+ ```
301
+
302
+ ### Resolution
303
+
304
+ Dynamic import + Protocol check at startup:
305
+
306
+ ```python
307
+ def resolve_component(class_path: str, kwargs: dict, protocol: type) -> Any:
308
+ module_name, _, class_name = class_path.rpartition(".")
309
+ module = importlib.import_module(module_name)
310
+ cls = getattr(module, class_name)
311
+ instance = cls(**kwargs)
312
+ if not isinstance(instance, protocol):
313
+ raise TypeError(f"{class_path} does not satisfy {protocol.__name__}")
314
+ return instance
315
+ ```
316
+
317
+ ### Default Implementations
318
+
319
+ | Protocol | Default | Alternatives |
320
+ |----------|---------|-------------|
321
+ | `SnapshotBuilder` | `LLMSnapshotBuilder` (LiteLLM) | `TemplateOnlyBuilder` (testing), `FileBuilder` (demo) |
322
+ | `NPCBehavior` | `NullNPCBehavior` (Level 0) | `LLMNPCBehavior` (Level 1+), `RuleBasedNPCBehavior` (heuristic) |
323
+ | `ValidatorCheck` | 7 built-in checks | Add custom checks via config |
324
+
325
+ ### Environment Variables
326
+
327
+ Env vars override YAML config at deploy time:
328
+
329
+ | Env Var | Overrides | Default |
330
+ |---------|-----------|---------|
331
+ | `OPENRANGE_BUILDER_MODEL` | Builder LLM model | `anthropic/claude-sonnet-4-20250514` |
332
+ | `OPENRANGE_NPC_MODEL` | NPC LLM model | `anthropic/claude-haiku-4-5-20251001` |
333
+ | `LITELLM_API_KEY` | Global API key | (or model-specific keys) |
334
 
335
+ Validator checks are **purely mechanical** by default -- no LLM calls. The only LLM-calling check is NPCConsistencyCheck (optional).